mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06f5d3c8c0 | ||
|
|
082407fa51 | ||
|
|
6688ee26db | ||
|
|
beb003b7ad | ||
|
|
6ecfe0f0a1 | ||
|
|
12057db476 | ||
|
|
ff47d8d48a | ||
|
|
ef5f36cc2b | ||
|
|
84022c4d48 | ||
|
|
118f441029 | ||
|
|
20399b004d | ||
|
|
050eb77508 | ||
|
|
1ab4f079c9 | ||
|
|
6c733f7590 | ||
|
|
d7d8db45ba | ||
|
|
8cf9af79da | ||
|
|
e55793c765 | ||
|
|
d8902ea612 | ||
|
|
a04673a90d | ||
|
|
a97acc07fc | ||
|
|
f8000012f7 | ||
|
|
6080f8cc88 | ||
|
|
37df5b93b1 | ||
|
|
e53abdaec2 | ||
|
|
2db32ea97e | ||
|
|
581897ee74 | ||
|
|
9a88f966d8 | ||
|
|
9d9316e434 | ||
|
|
1b697b1111 | ||
|
|
3043982486 | ||
|
|
0bf92ffffc | ||
|
|
f0f87b56a3 | ||
|
|
4148ab1931 | ||
|
|
550cc36760 | ||
|
|
531cf11025 | ||
|
|
79b70f7b5c | ||
|
|
10d369f59c | ||
|
|
2ef7ac79bc | ||
|
|
778cfb1a5c | ||
|
|
764e9fd131 | ||
|
|
387134ca87 | ||
|
|
a0767d957c | ||
|
|
b94ef91d07 | ||
|
|
e7910751d9 | ||
|
|
1d2655432d | ||
|
|
323273ff30 | ||
|
|
fb2009c65b | ||
|
|
e186cc6848 | ||
|
|
615ac99ad7 | ||
|
|
ec36cfbf75 | ||
|
|
7bf228a33c | ||
|
|
3606290ac8 | ||
|
|
e49024d33b | ||
|
|
fdbc2607ec | ||
|
|
c7cc8fd7db | ||
|
|
07efcb5146 | ||
|
|
856605defa | ||
|
|
713010fa0a | ||
|
|
cd2fbeeead | ||
|
|
a4350a482a | ||
|
|
c825375367 | ||
|
|
fc92c4f431 | ||
|
|
b61c590bdb | ||
|
|
7756c0913f | ||
|
|
c34ec7c1ee | ||
|
|
f8778c4a23 | ||
|
|
e0dbb233f7 | ||
|
|
9725f9abae | ||
|
|
5d575f1590 | ||
|
|
d562c594c3 | ||
|
|
ce226a3010 | ||
|
|
644ae9c1bf | ||
|
|
95053f9502 | ||
|
|
8fbda84acb | ||
|
|
03b7d573e0 | ||
|
|
0c3f51bcec | ||
|
|
e3d97b573b | ||
|
|
ac3796af84 | ||
|
|
f9c343eb07 | ||
|
|
e31df5989a | ||
|
|
98fbf029fc | ||
|
|
4d9a648202 | ||
|
|
405ca3e66a | ||
|
|
0355c28683 | ||
|
|
6c33b8d8fb | ||
|
|
a6c6f14b09 | ||
|
|
e558f55cd9 | ||
|
|
88a057b8d9 | ||
|
|
ed27d404ac | ||
|
|
5dda34c66e | ||
|
|
373ebf26d6 | ||
|
|
f65ed2795c | ||
|
|
664c063a06 | ||
|
|
75795c6fbc | ||
|
|
5b332da7d7 | ||
|
|
d9796d502b | ||
|
|
3b0d87b0fd | ||
|
|
3c348dff3a | ||
|
|
ec1783a35c | ||
|
|
427030c5de | ||
|
|
0be380243b | ||
|
|
312583f055 | ||
|
|
33f49ea9b0 | ||
|
|
470cef17cf | ||
|
|
3f5f65eb9a | ||
|
|
6664c2dbb8 | ||
|
|
0099167a6d | ||
|
|
f009fb73c3 | ||
|
|
715a5ed626 | ||
|
|
5cf38d1b35 | ||
|
|
6b707f29a2 | ||
|
|
9ea84f9748 | ||
|
|
c32d043afb | ||
|
|
8fe4d24408 | ||
|
|
e369e4aab1 | ||
|
|
7dc919e8e3 | ||
|
|
1333efdad5 | ||
|
|
cd8de1aa13 | ||
|
|
d6215d9dec | ||
|
|
9a47267545 | ||
|
|
7851503fbc | ||
|
|
c6d373e6aa | ||
|
|
71fcb9c168 | ||
|
|
3976652942 | ||
|
|
7b56546e21 | ||
|
|
85854e4476 | ||
|
|
b50242ab9f | ||
|
|
20b27a13b2 | ||
|
|
598b2fb374 | ||
|
|
ff7988430d | ||
|
|
25da99fac2 | ||
|
|
8616fe6ee2 | ||
|
|
9b8724453b | ||
|
|
01e104d86a | ||
|
|
0acd1de29c | ||
|
|
a25fab371a | ||
|
|
93e2f95c47 | ||
|
|
f10d631a9c | ||
|
|
cfc4894dab | ||
|
|
3f86fdd6bc | ||
|
|
b09d1f1c33 | ||
|
|
2fc604e047 | ||
|
|
e8afa03e45 | ||
|
|
fc2dfb82d2 | ||
|
|
a728c090a9 | ||
|
|
e58621a735 | ||
|
|
b1be370b2e | ||
|
|
cf0d957ac7 | ||
|
|
f127b67e73 | ||
|
|
7f61bb43c7 | ||
|
|
25c49dd804 | ||
|
|
72222d935c | ||
|
|
063d517306 | ||
|
|
02495ce28e | ||
|
|
63936aa110 | ||
|
|
8d4d42a887 | ||
|
|
2316df5c9a | ||
|
|
59d37ae1dd | ||
|
|
3014fd50c6 | ||
|
|
14c4e3a04e | ||
|
|
8f1070a451 | ||
|
|
0b30cc6b0f | ||
|
|
b2f596b8f0 | ||
|
|
01a96fed74 | ||
|
|
46a903aada | ||
|
|
dfa121dd5b | ||
|
|
bc1da3bf3f | ||
|
|
6e0dc3b59e | ||
|
|
4bf5d4c044 | ||
|
|
736fc76345 | ||
|
|
b6b2ca38f4 | ||
|
|
f07eb25cfc | ||
|
|
d2ea437c1c | ||
|
|
7bc7d0f8d8 | ||
|
|
14cf639aba | ||
|
|
55cdab592c | ||
|
|
ee0ec18283 | ||
|
|
f31c9e03e2 | ||
|
|
e50db10439 | ||
|
|
192dc6c20d | ||
|
|
5e1d14f19b | ||
|
|
b8b89d21b7 | ||
|
|
5eddf4f9ee | ||
|
|
c7186e1720 | ||
|
|
4866509938 | ||
|
|
2122660a5c | ||
|
|
5c68ab896a | ||
|
|
f9c8ec41f4 | ||
|
|
b1ed6b24b0 | ||
|
|
c17c78ad4b | ||
|
|
9ec48ab6b9 | ||
|
|
accd250226 | ||
|
|
80a6579766 | ||
|
|
1ca83ca3fb | ||
|
|
a931da0764 | ||
|
|
a61374c595 | ||
|
|
c3136126e5 | ||
|
|
b23d299533 | ||
|
|
b03aae18c3 | ||
|
|
99b6fe468f | ||
|
|
ef77ec04ca | ||
|
|
242081433e | ||
|
|
b86d4e1f0c | ||
|
|
a151f37d63 | ||
|
|
42f7907740 | ||
|
|
e72e25c59c | ||
|
|
1b0440481b | ||
|
|
26d85681f0 | ||
|
|
1dcee77055 | ||
|
|
1ac16005f9 | ||
|
|
2f1cdb6a0b | ||
|
|
ac93851b2a | ||
|
|
400b3125a4 | ||
|
|
2e5ff32e1a | ||
|
|
a0f7074e59 | ||
|
|
7c32be46ca | ||
|
|
6ed2f9bd0a | ||
|
|
778b106023 | ||
|
|
f179ee72f9 | ||
|
|
974def5fef | ||
|
|
e5351b7d9d | ||
|
|
ed83184d55 | ||
|
|
15b6606c82 | ||
|
|
d7411a3104 | ||
|
|
9f138d09e6 | ||
|
|
bf29129a4b | ||
|
|
f6293b6812 | ||
|
|
7e9424008f | ||
|
|
6c5e70ccb1 | ||
|
|
063834e95b | ||
|
|
c76d6b6396 | ||
|
|
6f00e9fc67 | ||
|
|
d336d1a7fa | ||
|
|
cf0af8fa1e | ||
|
|
fd220b6c42 | ||
|
|
3472bb75e7 | ||
|
|
ba65c96c74 | ||
|
|
c54b214657 | ||
|
|
4fcc17114f | ||
|
|
deb5f55786 | ||
|
|
1836c2b652 | ||
|
|
5b7805181b | ||
|
|
f75894acbb | ||
|
|
541cc197c4 | ||
|
|
363d1aba9a | ||
|
|
eb2cf662b7 | ||
|
|
900f8a7163 | ||
|
|
61bdd304b7 | ||
|
|
279735ae7f | ||
|
|
cc2830f6ec | ||
|
|
8dbd730568 | ||
|
|
bb6aa03485 | ||
|
|
6a22488698 | ||
|
|
f1c30439ff | ||
|
|
1123095bb7 | ||
|
|
938f11981d | ||
|
|
6c4e730e60 | ||
|
|
16584067d7 | ||
|
|
6de0fe75a4 | ||
|
|
34f0913ed0 | ||
|
|
5b305c64e1 | ||
|
|
8ad97761e8 | ||
|
|
0f92ef664d | ||
|
|
16a4fd3687 | ||
|
|
3a3fcbe46a | ||
|
|
18d8ea2052 | ||
|
|
6ab08f4014 | ||
|
|
628a3a0d8d | ||
|
|
f52628e00b | ||
|
|
f9d97ececb | ||
|
|
803e555022 | ||
|
|
b1bd727978 | ||
|
|
c2748dc868 | ||
|
|
302620cb94 | ||
|
|
c255f29e98 | ||
|
|
6d1b818414 | ||
|
|
669636d3e4 | ||
|
|
68038c182b | ||
|
|
308cc88ef7 | ||
|
|
30b545785f | ||
|
|
31fade82f6 | ||
|
|
0246ba93dd | ||
|
|
ff7ec8575c | ||
|
|
aa58cb4a05 | ||
|
|
e9b4efc2d4 | ||
|
|
ea76f7bb0b | ||
|
|
8edcbdcb29 | ||
|
|
5249660e07 | ||
|
|
84b99a641a | ||
|
|
4824e4a487 | ||
|
|
ba723ebe48 | ||
|
|
04ba8cbe9e | ||
|
|
84f41dae77 | ||
|
|
82040bfc21 | ||
|
|
6155ffefcc | ||
|
|
bf4279a590 | ||
|
|
77759fac54 | ||
|
|
63a2fd4dcf | ||
|
|
7a19891c60 | ||
|
|
85573d7980 | ||
|
|
ebd59246a8 | ||
|
|
fd27f55fe5 | ||
|
|
69b8b96fb8 | ||
|
|
19d1d36043 | ||
|
|
9f19ca5754 | ||
|
|
2de2a792f6 | ||
|
|
ada690624b | ||
|
|
465476985b | ||
|
|
da5624c98e | ||
|
|
b2f68bbaf7 | ||
|
|
7507af5829 | ||
|
|
5e39801bba | ||
|
|
5ac153a0bb | ||
|
|
c7a5155ce4 | ||
|
|
869c3d3037 | ||
|
|
ef6a11c146 | ||
|
|
21432911de | ||
|
|
eb98340924 | ||
|
|
746af0d93e | ||
|
|
08ac9c5c58 | ||
|
|
bce3bf2b6e | ||
|
|
4ec9ca61cf | ||
|
|
657e6aa672 | ||
|
|
7835840ebd | ||
|
|
6cabcd85aa | ||
|
|
03e436707d | ||
|
|
781bc5ac58 | ||
|
|
86f72da3d9 | ||
|
|
0a2c674ad8 | ||
|
|
0daa8c196b | ||
|
|
98dc5925a5 | ||
|
|
d5d3f09846 | ||
|
|
0e6fc96eb1 | ||
|
|
b052f40ffb | ||
|
|
2aef9d2478 | ||
|
|
8627a18f2e | ||
|
|
21c478be22 | ||
|
|
9d8f7d158b | ||
|
|
c1649fe837 | ||
|
|
d3c8317939 | ||
|
|
7ffe33f867 | ||
|
|
6c2a57f237 | ||
|
|
0f4141ef3f | ||
|
|
37413c0211 | ||
|
|
d1b64b6748 | ||
|
|
c2bcfab7d4 | ||
|
|
392353ffff | ||
|
|
9734be31cf | ||
|
|
905453d62b | ||
|
|
a3b8a99709 | ||
|
|
2e24e5f358 | ||
|
|
40eb3cf6e1 | ||
|
|
549463088c | ||
|
|
f8b5651883 | ||
|
|
de0a880ca6 | ||
|
|
ba4e194cb5 | ||
|
|
1c05a722c1 | ||
|
|
eda94913cf | ||
|
|
3dfafbc379 | ||
|
|
8d1e54eba6 | ||
|
|
6bfd56b54f | ||
|
|
49f952692b | ||
|
|
fde15c9b60 | ||
|
|
06f26cfacf | ||
|
|
5360665432 | ||
|
|
a20ac1d31f | ||
|
|
1bdd300606 | ||
|
|
c56f0198ff | ||
|
|
02fc6bd4ef | ||
|
|
c3a8352d76 | ||
|
|
463576915f | ||
|
|
1db6b9d307 | ||
|
|
b5a02a118f | ||
|
|
ae96d5d61b | ||
|
|
ce1d532e3c | ||
|
|
f27485ec05 | ||
|
|
9616f458de | ||
|
|
3455faf7da | ||
|
|
7ed4b84654 | ||
|
|
0d76a8e478 | ||
|
|
b9612fef9b | ||
|
|
92ae88f1be | ||
|
|
91a5e58cec | ||
|
|
1658925f52 | ||
|
|
bb5a4454a5 | ||
|
|
9fb600df1b | ||
|
|
fff4fe4e20 | ||
|
|
3e4dfd2bac | ||
|
|
8bd82c8c95 | ||
|
|
b59c724455 | ||
|
|
3ee272fd53 | ||
|
|
ab5d1f266f | ||
|
|
906742e3c4 | ||
|
|
0ee45f41e1 | ||
|
|
6d285410c2 | ||
|
|
eaabfb83ed | ||
|
|
ef2953038e | ||
|
|
cc1a63bf01 | ||
|
|
4b2d8cef3c | ||
|
|
df518ad668 | ||
|
|
37b0c00701 | ||
|
|
88f03aaef2 | ||
|
|
ffd8d273c4 | ||
|
|
ef2a96bcc4 | ||
|
|
734717899b | ||
|
|
d2d28c30d9 | ||
|
|
10532e1a55 | ||
|
|
47886abd2b | ||
|
|
d076f64db3 | ||
|
|
60e3ffc402 | ||
|
|
b21be24faa | ||
|
|
3504875922 | ||
|
|
0f6d4b9146 | ||
|
|
6ebd39ed0b | ||
|
|
5c3a1aecbe | ||
|
|
1a45ec9386 | ||
|
|
97133f657f | ||
|
|
b108dc5ea6 | ||
|
|
35cf44b38e | ||
|
|
6412294262 | ||
|
|
01c8592ca6 | ||
|
|
9b5c3ecd23 | ||
|
|
6de684df59 | ||
|
|
8aca1f8b93 | ||
|
|
bb2fc2ec00 | ||
|
|
18566b5837 | ||
|
|
069e1c1e60 | ||
|
|
2c28d9979c | ||
|
|
0efb3d340d | ||
|
|
535039c29e | ||
|
|
93d3de1644 | ||
|
|
4ce056fe45 | ||
|
|
9ad9858ac2 | ||
|
|
adca142d1e | ||
|
|
739e39e1ca | ||
|
|
14ad6e9b75 | ||
|
|
d46d225a90 | ||
|
|
c05d227df2 | ||
|
|
42e723ff7c | ||
|
|
b02d62642a | ||
|
|
8abedecb16 | ||
|
|
d77a572dc7 | ||
|
|
8606455355 | ||
|
|
21e52722e6 | ||
|
|
6673ab6d4a | ||
|
|
d488b1a680 | ||
|
|
b9ac97ebc3 | ||
|
|
e09d3199c1 | ||
|
|
ccfc4cbddc | ||
|
|
41ad422002 | ||
|
|
674cc85005 | ||
|
|
dd2da69361 | ||
|
|
0ee6e393ce | ||
|
|
433a4d3c7d | ||
|
|
049f26c03b | ||
|
|
cf8372c8cb | ||
|
|
f03550415b | ||
|
|
5a710c4f5e | ||
|
|
56901f91ce | ||
|
|
1109c3547c | ||
|
|
d24ead234d | ||
|
|
d816ae5c88 | ||
|
|
8c6e586063 | ||
|
|
c632ec616d | ||
|
|
bd71a46c25 | ||
|
|
e2b5c3acc8 | ||
|
|
e27ca671fd | ||
|
|
614c999871 | ||
|
|
42693c2c52 | ||
|
|
e21cd72181 | ||
|
|
a9e6a7d644 | ||
|
|
ba72770cab | ||
|
|
7530bec7de | ||
|
|
1173a4d9d5 | ||
|
|
aa409a8a9c | ||
|
|
949e251b2e | ||
|
|
4933ae9014 | ||
|
|
1793443b09 | ||
|
|
23a36e37bb | ||
|
|
c9cf1d458a | ||
|
|
d28a389a93 | ||
|
|
7e76c9763d | ||
|
|
9e029462aa | ||
|
|
4523a2c67b | ||
|
|
84c8bc960e | ||
|
|
c4927162b7 | ||
|
|
1ebe0aeadf | ||
|
|
992c58f2bd | ||
|
|
0bf63cc80e | ||
|
|
5fc6dc8019 | ||
|
|
96184caa48 | ||
|
|
12ff87949d | ||
|
|
b75953bf4c | ||
|
|
c733139091 | ||
|
|
331d37be26 | ||
|
|
57ccd44b89 | ||
|
|
d60b6e7454 | ||
|
|
50e4f27276 | ||
|
|
a0f22ae659 | ||
|
|
235f32e10e | ||
|
|
e7b3acdec3 | ||
|
|
f3a367b02d | ||
|
|
c03aebba3f | ||
|
|
4fb8955bc2 | ||
|
|
5dfccdec3e | ||
|
|
8b386b0aac | ||
|
|
9010f0806a | ||
|
|
495795327c | ||
|
|
686311eabf | ||
|
|
e68b843875 | ||
|
|
b46028cb85 | ||
|
|
fa172ecb95 | ||
|
|
431311979a | ||
|
|
d3249485fa | ||
|
|
4c22a819f9 | ||
|
|
f4d66021e4 | ||
|
|
54c5d5803b | ||
|
|
ae138ddb56 | ||
|
|
b72abec2fc | ||
|
|
db4f3fd210 | ||
|
|
230ce5df5f | ||
|
|
ec681335e8 | ||
|
|
aaad113190 | ||
|
|
63681b4be3 | ||
|
|
b347f1816d | ||
|
|
e9efc5c42a | ||
|
|
28c3a5dbe4 | ||
|
|
505d9fd8bc | ||
|
|
932397d1b3 | ||
|
|
1be445423c | ||
|
|
2df9615fb9 | ||
|
|
fe7fb17ff5 | ||
|
|
72d43878ef | ||
|
|
cc3ce8b5d7 | ||
|
|
d4ae6e0e64 | ||
|
|
480579a0d5 | ||
|
|
ba188aea92 | ||
|
|
40b4e52508 | ||
|
|
e2d5fc9dfb | ||
|
|
c92bdfba16 | ||
|
|
83a2609344 | ||
|
|
18d9004f22 | ||
|
|
74c8bfc59f | ||
|
|
3bf7469d30 | ||
|
|
66837b7d7f | ||
|
|
14b182d09b | ||
|
|
9dba6ec1d9 | ||
|
|
a52b513533 | ||
|
|
218ca8e6eb | ||
|
|
2b2754b779 | ||
|
|
952d1c840d | ||
|
|
2207b60834 | ||
|
|
c09bb28d16 | ||
|
|
13e0759d5a | ||
|
|
80054276eb | ||
|
|
517c9e5108 | ||
|
|
576918daa5 | ||
|
|
bbd4338e8e | ||
|
|
e6423a91aa | ||
|
|
78523f122d | ||
|
|
e1df06f06c | ||
|
|
ecfe04f48a | ||
|
|
ff7f27d4f8 | ||
|
|
dd07425d21 | ||
|
|
ba9aa7c1bd | ||
|
|
92fd253cae | ||
|
|
9f6fac418e | ||
|
|
e53a2757eb | ||
|
|
db32b1d982 | ||
|
|
6b1c1e4f50 | ||
|
|
3eb9614e68 | ||
|
|
8087a98c9d | ||
|
|
5643b2c901 | ||
|
|
18eac2dd7a | ||
|
|
0f2a96554f | ||
|
|
0655e868a2 | ||
|
|
4b66cadf15 | ||
|
|
4ee64339ba | ||
|
|
babe328565 | ||
|
|
6447fda852 | ||
|
|
74f7348529 | ||
|
|
bf456450d7 | ||
|
|
91cb2bbbcc | ||
|
|
c0252387b4 | ||
|
|
bd1e155332 | ||
|
|
ab048b8a03 | ||
|
|
e5ce2ac7a4 | ||
|
|
c7641dad0a | ||
|
|
b5e942ca9d | ||
|
|
74c82d9948 | ||
|
|
d18b13a91a | ||
|
|
0d80db8a8d | ||
|
|
8cc6888c5b | ||
|
|
9af1507238 | ||
|
|
c67818ee86 | ||
|
|
6ef6cbade2 | ||
|
|
8df0e1790d | ||
|
|
8b7643e150 | ||
|
|
ef04f4b0fb | ||
|
|
ce02f1ae8c | ||
|
|
c2d0f60784 | ||
|
|
781830a202 | ||
|
|
9dd545353c | ||
|
|
4c7ebf8b8d | ||
|
|
a4a5f70a10 | ||
|
|
9633bce2e1 | ||
|
|
ca341703bc | ||
|
|
cb2ff61bbc | ||
|
|
08b27806a7 | ||
|
|
b59c3a9e3b | ||
|
|
ecf6019ccb | ||
|
|
2a298de971 | ||
|
|
33633637e5 | ||
|
|
8ede01ad4e | ||
|
|
8966fd6aac | ||
|
|
2b8ff8a743 | ||
|
|
97de4ff8a3 | ||
|
|
f6c3ebf7d3 | ||
|
|
56abfdf39d | ||
|
|
9b95fa4d95 | ||
|
|
f341c573eb | ||
|
|
b227669985 | ||
|
|
ce44d35eb6 | ||
|
|
b05f2a270a | ||
|
|
2e701a90c9 | ||
|
|
507f2f8250 | ||
|
|
9533bd7043 | ||
|
|
2b32b9a445 | ||
|
|
3714c211dc | ||
|
|
504c1ccb37 | ||
|
|
d5e64d6ad9 | ||
|
|
9859aec16c | ||
|
|
0e6d7539ad | ||
|
|
d6eb41aa78 | ||
|
|
97997685b5 | ||
|
|
ab0a90de97 | ||
|
|
eeb7995214 | ||
|
|
68d8f86dc6 | ||
|
|
18fe5a4f11 | ||
|
|
26ee1a9958 | ||
|
|
77c2d91eb0 | ||
|
|
b8a65cbdec | ||
|
|
71f9afc526 | ||
|
|
8ca4a10f24 | ||
|
|
66f21de50e | ||
|
|
3e6ce6cf4a | ||
|
|
cadc45c5b8 | ||
|
|
b7b7b4f718 | ||
|
|
4f49dd5943 | ||
|
|
888414c41b | ||
|
|
8f41bc1558 | ||
|
|
a543ca9e07 | ||
|
|
40b9db3545 | ||
|
|
bd4f6b9206 | ||
|
|
776f95b1ab | ||
|
|
12abde2aeb | ||
|
|
965a8c79af | ||
|
|
d33043288d | ||
|
|
a2ad556f2b | ||
|
|
e53d5f07e8 | ||
|
|
40434005c0 | ||
|
|
3330b2ac4c | ||
|
|
be6e49b9c2 | ||
|
|
e59e6c3797 | ||
|
|
6b04a0a3a6 | ||
|
|
4112a8b2ea | ||
|
|
b84e4a96e2 | ||
|
|
e7f8b259ac | ||
|
|
65c361115a | ||
|
|
129c7c90c0 | ||
|
|
82637ad882 | ||
|
|
931c577345 | ||
|
|
9466d92a7a | ||
|
|
0a0a8b31c7 | ||
|
|
208c77a062 | ||
|
|
02d1343436 | ||
|
|
0226e14251 | ||
|
|
923515ab28 | ||
|
|
4d0c654822 | ||
|
|
779877acd0 | ||
|
|
d49b0a8a45 | ||
|
|
5a9f19cbf2 | ||
|
|
9562295d8b | ||
|
|
3c6924238f | ||
|
|
64ad0f694b | ||
|
|
754f672ee2 | ||
|
|
e50ceeba5a | ||
|
|
57910f906d | ||
|
|
8838e9289b | ||
|
|
d3355a8a09 | ||
|
|
c972bbd397 | ||
|
|
fed9bdf01a | ||
|
|
ab2287202d | ||
|
|
b47282fe4c | ||
|
|
a31e237cc3 | ||
|
|
cfa32a2b4d | ||
|
|
2cacf66a37 | ||
|
|
d0981c2fd5 | ||
|
|
3e12c06627 | ||
|
|
74a3df3f1e | ||
|
|
cb894208a1 | ||
|
|
76752beca6 | ||
|
|
e80e7b0cfa | ||
|
|
77051e6245 | ||
|
|
de7be4f15b | ||
|
|
44fb1af287 | ||
|
|
e7a76b0510 | ||
|
|
2881ff097a | ||
|
|
462c3dde79 | ||
|
|
1be703b56e | ||
|
|
5130da9710 | ||
|
|
8440846bae | ||
|
|
831554f11d | ||
|
|
97fb588a4a | ||
|
|
cd994d57d2 | ||
|
|
70f2882a43 | ||
|
|
fa5d26ce38 | ||
|
|
f76bbaab52 | ||
|
|
cde2062618 | ||
|
|
9673fc4c01 | ||
|
|
19ae7c8902 | ||
|
|
eb63838f6a | ||
|
|
232006f71d | ||
|
|
b6bdc08267 | ||
|
|
7e95e769d5 | ||
|
|
f4c79c80ac | ||
|
|
edded777e7 | ||
|
|
7284165f39 | ||
|
|
1604a6d87d | ||
|
|
7d5ad1e70e | ||
|
|
89860bec97 | ||
|
|
ebc1774300 | ||
|
|
122daf0f87 | ||
|
|
149651f831 | ||
|
|
490306c242 | ||
|
|
316b1e3207 | ||
|
|
84c4c2f9c2 | ||
|
|
4d856f3deb | ||
|
|
61bcbe826a | ||
|
|
bdc848b19e | ||
|
|
65e3b6f3da | ||
|
|
97b05e744f | ||
|
|
fbda210b84 | ||
|
|
ed75ae6d56 | ||
|
|
d1ad1815f7 | ||
|
|
b1a3a26815 | ||
|
|
94760dbc14 | ||
|
|
3a318a86b2 | ||
|
|
f4d0d5904a | ||
|
|
25c7bb935e | ||
|
|
f5deed8709 | ||
|
|
fe3a848eb5 | ||
|
|
8f4f4d2d82 | ||
|
|
66a54cc39e | ||
|
|
7ace958710 | ||
|
|
57655bdb25 | ||
|
|
124077a0a1 | ||
|
|
1b570daf72 | ||
|
|
8bcd5b8189 | ||
|
|
63202a63ef | ||
|
|
7e9ca88e00 | ||
|
|
75aa3dc0cc | ||
|
|
6a1da6a5ff | ||
|
|
d88f092dd1 | ||
|
|
b4d17a392a | ||
|
|
c6a408d4e8 | ||
|
|
d19bf71343 | ||
|
|
02f09c2056 | ||
|
|
6a104d5736 | ||
|
|
95af482ffe | ||
|
|
b05264ff74 | ||
|
|
2aab1ea97b | ||
|
|
f1687017e6 | ||
|
|
052de6b96e | ||
|
|
d2b42e91d2 | ||
|
|
d8a9d7eb5e | ||
|
|
d216a9e219 | ||
|
|
21e82abd54 | ||
|
|
18ed9a57c2 | ||
|
|
702dc3ceb4 | ||
|
|
3180ca2bf9 | ||
|
|
69af74b1e0 | ||
|
|
ef9c0ebbd4 | ||
|
|
ca4d0dc819 | ||
|
|
cd1aa92931 | ||
|
|
9fc9c334c9 | ||
|
|
c563c192b7 | ||
|
|
afedd90c80 | ||
|
|
be2e8e594c | ||
|
|
d392681c58 | ||
|
|
5ed8325592 | ||
|
|
ed1d9fdb57 | ||
|
|
c58ce63fc3 | ||
|
|
5eed329916 | ||
|
|
19c8688eb1 | ||
|
|
4317ff78b1 | ||
|
|
6c16f399d4 | ||
|
|
b480f3aaff | ||
|
|
84f312fa4c | ||
|
|
61d5fdb0ec | ||
|
|
995ab302be | ||
|
|
c6ee558180 | ||
|
|
460cd63d3a | ||
|
|
2a3593d9c5 | ||
|
|
bcca8d295a | ||
|
|
d8f68c1d9a | ||
|
|
ab53326865 | ||
|
|
96b857f642 | ||
|
|
fc12cc8a36 | ||
|
|
276d19b63c | ||
|
|
437024cdb1 | ||
|
|
64b41434ce | ||
|
|
24129d7f12 | ||
|
|
d51b44d642 | ||
|
|
e8c55e8f1f | ||
|
|
9179516b19 | ||
|
|
37abfe66f0 | ||
|
|
ae9d4038b1 | ||
|
|
b6f558d10b | ||
|
|
6d994917a0 | ||
|
|
b99f43783c | ||
|
|
7f76eff827 | ||
|
|
be939f7e63 | ||
|
|
a8a87d2b41 | ||
|
|
0a26accca4 | ||
|
|
40e925680c | ||
|
|
f2cdb74ed8 | ||
|
|
7ac8159728 | ||
|
|
3f0fd15395 | ||
|
|
90dbc279fd | ||
|
|
3723f165bc | ||
|
|
3bffecddf2 | ||
|
|
a818af7833 | ||
|
|
187b3a08fb | ||
|
|
2405ccc0f2 | ||
|
|
0fcfcae9c8 | ||
|
|
2de0cbbafc | ||
|
|
a4012ad353 | ||
|
|
e4315fbbf0 | ||
|
|
a10c02ef63 | ||
|
|
66f154a251 | ||
|
|
92813e6122 | ||
|
|
f50f26e599 | ||
|
|
a3094fda53 | ||
|
|
b004a02e4a | ||
|
|
9586f5158e | ||
|
|
f5ace4fd6d | ||
|
|
a05ae94cea | ||
|
|
dff17b6cb1 | ||
|
|
0b2a8fafce | ||
|
|
691ccaaa04 | ||
|
|
26900c8c9e | ||
|
|
226ce45d0d | ||
|
|
ae472d6744 | ||
|
|
89d08d9953 | ||
|
|
c024c782e4 | ||
|
|
84fc1e35e2 | ||
|
|
995be3781a | ||
|
|
ed570155a1 | ||
|
|
680b617b00 | ||
|
|
0a62e4bc77 | ||
|
|
96d40dd21d | ||
|
|
ff83b54c3b | ||
|
|
81ff375bfd | ||
|
|
b0fc6e68ef | ||
|
|
e1a73be2e1 | ||
|
|
cf2c74e8e8 | ||
|
|
2cb01f7a69 | ||
|
|
48deff15c4 | ||
|
|
d6c8c14de7 | ||
|
|
b2266b588e | ||
|
|
fcaafb3939 | ||
|
|
7d569127ae | ||
|
|
981020a5ab | ||
|
|
d9c8119bda | ||
|
|
485d166912 | ||
|
|
5060532c51 | ||
|
|
f29cca72ba | ||
|
|
77640d51a6 | ||
|
|
f0a6fffa87 | ||
|
|
1b24e1c22a | ||
|
|
ab0d766f47 | ||
|
|
41ffb18604 | ||
|
|
b903f8ff7d | ||
|
|
0483d001b4 | ||
|
|
d290a1fdb9 | ||
|
|
2803e9317d | ||
|
|
c5e26a1ed6 | ||
|
|
a2f91b4108 | ||
|
|
664bd98056 | ||
|
|
5bf236957e | ||
|
|
936e1ae37b | ||
|
|
cbfe1d378f | ||
|
|
e5f1f52759 | ||
|
|
edacc5a7d0 | ||
|
|
ed9267562b | ||
|
|
bddae47454 | ||
|
|
3a5922d4ee | ||
|
|
56994d4c29 | ||
|
|
973eb1a614 | ||
|
|
f9f1fa928a | ||
|
|
328ac721ce | ||
|
|
527feb69db | ||
|
|
ba661f1b3c | ||
|
|
4f584a71df | ||
|
|
97cd92a1a2 | ||
|
|
df7b2824a6 | ||
|
|
03ba1f94d7 | ||
|
|
6dd5d2fe14 | ||
|
|
a2649718ea | ||
|
|
9325c2ad9d | ||
|
|
590151f40b | ||
|
|
4b12ec8913 | ||
|
|
74a1e5ad7d | ||
|
|
75b7319465 | ||
|
|
6a608b8e3f | ||
|
|
2ca4b486ec | ||
|
|
c2cdcefdc8 | ||
|
|
893ac18d60 | ||
|
|
74abb50bdc | ||
|
|
f817f856c8 | ||
|
|
3a23eaa572 | ||
|
|
a7fdce493b | ||
|
|
232976c14a | ||
|
|
dc1009798d | ||
|
|
b6d74249a4 | ||
|
|
f72ab383c9 | ||
|
|
f59cf1090d | ||
|
|
1a50c5e112 | ||
|
|
48da062251 | ||
|
|
bbd3c30b0e | ||
|
|
d6c320bf06 | ||
|
|
d53546d56f | ||
|
|
43d891bee1 | ||
|
|
0e0a24862f | ||
|
|
2345df0e38 | ||
|
|
b09fd48eee | ||
|
|
c916e76bd2 | ||
|
|
8eb4c029b2 | ||
|
|
87e44479cc | ||
|
|
9c7757f801 | ||
|
|
1503986d40 | ||
|
|
0bd3e2fa88 | ||
|
|
0f0b9a6118 | ||
|
|
e4f427f921 | ||
|
|
2006a3e678 | ||
|
|
38240f77ff | ||
|
|
6014925e60 | ||
|
|
1dc9b505e4 | ||
|
|
b0ba1f250d | ||
|
|
2e9feaa60a | ||
|
|
8538364930 | ||
|
|
fb2662b877 | ||
|
|
170218bb26 | ||
|
|
582504d11a | ||
|
|
88d8a8b79f | ||
|
|
8e4fc40be5 | ||
|
|
01a982bdf6 | ||
|
|
daf33a82a6 | ||
|
|
cc5a44e373 | ||
|
|
8e0f8003a9 | ||
|
|
54a8312e46 | ||
|
|
85f48123b6 | ||
|
|
fdea51c7b1 | ||
|
|
870bb19798 | ||
|
|
dbec85344d | ||
|
|
4db01244ec | ||
|
|
6021110fb2 | ||
|
|
1216fa940e | ||
|
|
bcf4adf944 | ||
|
|
07ea745f56 | ||
|
|
3e122c84ef | ||
|
|
98a54b4633 | ||
|
|
2db85b1c53 | ||
|
|
91545cf906 | ||
|
|
5a15822ce0 | ||
|
|
eef21b6c34 | ||
|
|
498b3b1226 | ||
|
|
3b77686cee | ||
|
|
bf511f9f8c | ||
|
|
a0eed2cc51 | ||
|
|
e61aa46dad | ||
|
|
d2831ec6f0 | ||
|
|
a7e71624e3 | ||
|
|
bc0017a1b4 | ||
|
|
4cb7b2d494 | ||
|
|
eb7c8a3ad5 | ||
|
|
6e2f90aba8 | ||
|
|
9c59c0e1a2 | ||
|
|
6a9a54cad6 | ||
|
|
e768145961 | ||
|
|
a4e040a7d7 | ||
|
|
e818443841 | ||
|
|
337d0af136 | ||
|
|
4d2667764f | ||
|
|
feb676b66f | ||
|
|
14871c2255 | ||
|
|
48fe52b207 | ||
|
|
509bd30252 | ||
|
|
91955ad1e0 | ||
|
|
b41a4a000f | ||
|
|
d29d1cf63b | ||
|
|
1f8ff7f6d2 | ||
|
|
e251a63cb3 | ||
|
|
eb654c2fe7 | ||
|
|
697b6e0653 | ||
|
|
acd44328d6 | ||
|
|
8b813f645f | ||
|
|
2d354fa294 | ||
|
|
cfb22d3f06 | ||
|
|
1a196c8cf2 | ||
|
|
f847a71747 | ||
|
|
87c0a915b8 | ||
|
|
2958041dc7 | ||
|
|
5d1460e051 | ||
|
|
6a9017bfce | ||
|
|
a1b0db38f5 | ||
|
|
a99546b390 | ||
|
|
1adbf23be4 | ||
|
|
afbb656510 | ||
|
|
1726df1169 | ||
|
|
d69d862034 | ||
|
|
a03cab4a09 | ||
|
|
c90ed14a24 | ||
|
|
e00df5f7a2 | ||
|
|
3c2497f019 | ||
|
|
3fb007a56d | ||
|
|
10285c5eb9 | ||
|
|
15800d7a80 | ||
|
|
4387a9cdd5 | ||
|
|
8714d93d4b | ||
|
|
68256ece2d | ||
|
|
339808d55b | ||
|
|
e7471f44b0 | ||
|
|
d1a47c068e | ||
|
|
43c476d54a | ||
|
|
9f26383de5 | ||
|
|
8f082674d7 | ||
|
|
fca3f24d91 | ||
|
|
38012c62ff | ||
|
|
63149fe281 | ||
|
|
5509f70ad4 | ||
|
|
110cb147d1 | ||
|
|
92e4066977 | ||
|
|
ba5e802174 | ||
|
|
4b2507b155 | ||
|
|
6d2fcf12cd | ||
|
|
f60fc1cd7a | ||
|
|
9fc270fe69 | ||
|
|
a8ff050518 | ||
|
|
a7bab0ebd2 | ||
|
|
8eda0932b0 | ||
|
|
4e563e3385 | ||
|
|
9c05b5f4e0 | ||
|
|
fa73655134 | ||
|
|
81ee27cdea | ||
|
|
fad28eee2c | ||
|
|
c578689356 | ||
|
|
b9e62d1667 | ||
|
|
09146f8cdd | ||
|
|
0fa97595bf | ||
|
|
7ae38b6c43 | ||
|
|
bfbf7a4663 | ||
|
|
cb0ccb9cdb | ||
|
|
ce70780851 | ||
|
|
30b18d3310 | ||
|
|
1d33e2c51b | ||
|
|
fed676f54f | ||
|
|
9bed5e9f83 | ||
|
|
e16a225eb3 | ||
|
|
67ca47afd4 | ||
|
|
9057537ab8 | ||
|
|
247ea9d1bd | ||
|
|
e91c874863 | ||
|
|
40470d8ca5 | ||
|
|
94c0076778 | ||
|
|
b813498e40 | ||
|
|
cc4512fbbb | ||
|
|
ef4cc55c9a | ||
|
|
e3574e1918 | ||
|
|
7b81c77424 | ||
|
|
c9c968c2e9 | ||
|
|
e3f8fef30c | ||
|
|
ceda0635e4 | ||
|
|
bacb14e5f0 | ||
|
|
9e705ff603 | ||
|
|
1a0f1a7b72 | ||
|
|
3201851667 | ||
|
|
75df21932a | ||
|
|
fb96771b56 | ||
|
|
c4b484fb43 | ||
|
|
37fb79fb87 | ||
|
|
f03039d846 | ||
|
|
69476f69b9 | ||
|
|
bb22978574 | ||
|
|
228253c166 | ||
|
|
d26321006b | ||
|
|
377dd52805 | ||
|
|
d246f6b42c | ||
|
|
59d16ebb4b | ||
|
|
948a173f39 | ||
|
|
56857280d3 | ||
|
|
7e804c408f | ||
|
|
5268f62a64 | ||
|
|
7f101431c5 | ||
|
|
a8ac944959 | ||
|
|
e8259a76f0 | ||
|
|
3983b5a5cf | ||
|
|
79b3a76dc1 | ||
|
|
c2bf17b4dd | ||
|
|
be3afbd279 | ||
|
|
18690ceed2 | ||
|
|
0f42a6ed82 | ||
|
|
545299fc62 | ||
|
|
3c1456706a | ||
|
|
a81053e6ff | ||
|
|
391c2fbe5d | ||
|
|
121bdbd614 | ||
|
|
dcfdba0a97 | ||
|
|
a68c690874 | ||
|
|
3a814f3d1f | ||
|
|
209322b499 | ||
|
|
4a64d078f3 | ||
|
|
5f4fa4ce1f | ||
|
|
7e5a08e09d | ||
|
|
8958bf5e08 | ||
|
|
8b67120964 | ||
|
|
79d07ac79a | ||
|
|
757c264e3e | ||
|
|
a72bf19454 | ||
|
|
eda3738d59 | ||
|
|
3b4f27f767 | ||
|
|
4cf0de681a | ||
|
|
2c865ede35 | ||
|
|
46f44f4ea7 | ||
|
|
da46eb4791 | ||
|
|
e21fb58479 | ||
|
|
a703acd1fe | ||
|
|
9a84a6ff6c | ||
|
|
84a84e3f31 | ||
|
|
3f29335fd6 | ||
|
|
141a81d4a3 | ||
|
|
2543676437 | ||
|
|
fa22384f24 | ||
|
|
2a603fa1e4 | ||
|
|
31d142effc | ||
|
|
66d8e90647 | ||
|
|
080784cd9f | ||
|
|
c52ef1993f | ||
|
|
6247ac3edc | ||
|
|
cc5cb3475e | ||
|
|
71742d5ad2 | ||
|
|
1bc0822ce6 | ||
|
|
fcefa4d198 | ||
|
|
1e2ff26e86 | ||
|
|
dd8c2ebec6 | ||
|
|
6f620d92be | ||
|
|
61473bfb77 | ||
|
|
b7172092e8 | ||
|
|
ea014e0d89 | ||
|
|
8fa90e8ecf | ||
|
|
9eb17eca32 | ||
|
|
18b247de4c | ||
|
|
94852ce60e | ||
|
|
59312ebe73 | ||
|
|
738031696b | ||
|
|
a44667d2a9 | ||
|
|
b8fc36033b | ||
|
|
6ef0bd9488 | ||
|
|
f3d9523502 | ||
|
|
2b439094c5 | ||
|
|
6317587a6b | ||
|
|
7fdbd0c808 | ||
|
|
44c2534f46 | ||
|
|
af8f9e9057 | ||
|
|
cb9d9944e3 | ||
|
|
2cfeb14d88 | ||
|
|
d5d93eda11 | ||
|
|
9bbdf889d4 | ||
|
|
96f9be26da | ||
|
|
0c03c188f1 | ||
|
|
64af7b1d8a | ||
|
|
a345bb8c0d | ||
|
|
624733e874 | ||
|
|
fd44906bb7 | ||
|
|
01c9f9be49 | ||
|
|
3d9f189f0e | ||
|
|
a83aa928a0 | ||
|
|
0386e0426b | ||
|
|
10e679bb2f | ||
|
|
6c0ac2e8da | ||
|
|
2a124318b9 | ||
|
|
47a755a585 | ||
|
|
40314aa7e5 | ||
|
|
df9d30340d | ||
|
|
1f5b294bd0 | ||
|
|
012f8bcdf7 | ||
|
|
79cb9b502a | ||
|
|
4e9f063385 | ||
|
|
41f75a3f19 | ||
|
|
e2bffbbaca | ||
|
|
9525a68719 | ||
|
|
a6b45702e8 | ||
|
|
7b11d43466 | ||
|
|
4bd49d0d7a | ||
|
|
54afe35fcc | ||
|
|
77c0474947 | ||
|
|
33ed7c0737 | ||
|
|
bfda9b3e78 | ||
|
|
621eb55ae1 | ||
|
|
7eaf3e7d03 | ||
|
|
68216bf868 | ||
|
|
6fbb867f5f | ||
|
|
94a6f315ca | ||
|
|
4baee436ba | ||
|
|
beee7a76d2 | ||
|
|
887a58d639 | ||
|
|
110b02a213 | ||
|
|
6219491389 | ||
|
|
d7c2232062 | ||
|
|
2eb4afbec4 | ||
|
|
b03509a5f4 | ||
|
|
b8a6feef4a | ||
|
|
4bf86f85b4 | ||
|
|
be580c35bb | ||
|
|
cf27773582 | ||
|
|
6083461822 | ||
|
|
3264857e4a | ||
|
|
f1358dd845 | ||
|
|
8043cca126 | ||
|
|
6dd7464793 | ||
|
|
79c272f0fd | ||
|
|
f959f02d40 | ||
|
|
98421126f2 | ||
|
|
ddf3fb6f63 | ||
|
|
aacab1a90c | ||
|
|
fce7e959e5 | ||
|
|
6b6d32b4a3 | ||
|
|
f0197b685f | ||
|
|
53fa33b0c5 | ||
|
|
2f915b33c7 | ||
|
|
fd8230121c | ||
|
|
825c1b496d | ||
|
|
ccde3d4045 | ||
|
|
f7071967c0 | ||
|
|
657f9f0be1 | ||
|
|
628329e493 | ||
|
|
82e7a0080e | ||
|
|
ff1ed8b57d | ||
|
|
17b06bd4d6 | ||
|
|
5fcb49b08e | ||
|
|
044ef59d81 | ||
|
|
51b191ad2c | ||
|
|
e890a5c2f1 | ||
|
|
591dcf5cf2 | ||
|
|
58d6b2add6 | ||
|
|
bcd542f7ee | ||
|
|
37da4e245a | ||
|
|
9ef7952da5 | ||
|
|
eb8878695a | ||
|
|
c596d82dec | ||
|
|
96dd3fa4ca | ||
|
|
14eeff8f75 | ||
|
|
2cbbd9ca36 | ||
|
|
627eb4f335 | ||
|
|
8dd4135f7c | ||
|
|
6bdd7792eb | ||
|
|
099653f732 | ||
|
|
1d62722d47 | ||
|
|
fb642f7d62 | ||
|
|
cb5b8ee1ee | ||
|
|
b4a8c5dde2 | ||
|
|
53a5e18b20 | ||
|
|
6f00cabe96 | ||
|
|
a24e4a793d | ||
|
|
8ebee9922c | ||
|
|
c3d97b8c16 | ||
|
|
5abe664d65 | ||
|
|
3e2eca4fd0 | ||
|
|
c4ea042eb4 | ||
|
|
fe27fb17fb | ||
|
|
11c5884d4f | ||
|
|
e3ea2d1451 | ||
|
|
3a770306cc | ||
|
|
47ee8b9c13 | ||
|
|
bfd1ea72b3 | ||
|
|
c130d0e2c9 | ||
|
|
4fc7cecf30 | ||
|
|
9570e5c2c1 | ||
|
|
33aa70c22b | ||
|
|
558abfcfa3 | ||
|
|
7fcb9e6292 | ||
|
|
2b5247b9a8 | ||
|
|
fa47e8a3c0 | ||
|
|
4d59d518d1 | ||
|
|
9e2faa7e5b | ||
|
|
37392d8774 | ||
|
|
a16550249b | ||
|
|
d6de917878 | ||
|
|
e751289dfb | ||
|
|
07a319259b | ||
|
|
02ad67fe33 | ||
|
|
655e369f21 | ||
|
|
a9b809a32c | ||
|
|
230e7d6259 | ||
|
|
3d20c05ef7 | ||
|
|
9194f78e56 | ||
|
|
70f747d406 | ||
|
|
712b484bc8 | ||
|
|
0c9b5ddd77 | ||
|
|
3f1abb6906 | ||
|
|
29fc0be121 | ||
|
|
5311eb0da1 | ||
|
|
321f21ad49 | ||
|
|
639f26ed5d | ||
|
|
032dcf40e7 | ||
|
|
9a67497d8c | ||
|
|
6426b5d80e | ||
|
|
83e1f99ccf | ||
|
|
05b8b8a442 | ||
|
|
ed17147281 | ||
|
|
a7a3c9f023 | ||
|
|
4c16b11cb4 | ||
|
|
17f09fc8c1 | ||
|
|
3e0293ac28 | ||
|
|
07fba70a90 | ||
|
|
da0e968975 | ||
|
|
98e4e91a98 | ||
|
|
218e73e324 | ||
|
|
51c3beb614 | ||
|
|
9da47ceb22 | ||
|
|
e9f03d8d29 | ||
|
|
e5e09b49f4 | ||
|
|
dda6f34a07 | ||
|
|
44930532dc | ||
|
|
f10f5f071d | ||
|
|
517e84e5ae | ||
|
|
2101a4ecc7 | ||
|
|
8d2cbf32df | ||
|
|
a23758808d | ||
|
|
91db4eefd0 | ||
|
|
f52220a8ec | ||
|
|
1d23bf4ecb | ||
|
|
3b542434a2 | ||
|
|
9b866a6d17 | ||
|
|
d8f66b14a8 | ||
|
|
6e1eaf8aec | ||
|
|
488bd08f04 | ||
|
|
3991d47166 | ||
|
|
0c73f245ab | ||
|
|
989b27426b | ||
|
|
c10cd8240e | ||
|
|
5cd2244bc2 | ||
|
|
0ec4b4c8a4 | ||
|
|
4ec591fbf2 | ||
|
|
ea3dc3257e | ||
|
|
5b914aa78c | ||
|
|
e36fb8c07a | ||
|
|
1af9d00abd | ||
|
|
e8763b3bbd | ||
|
|
f3a9fd4c82 | ||
|
|
baa0ddd787 | ||
|
|
1d3ea3232d | ||
|
|
d784c540b6 | ||
|
|
429fdb47e6 | ||
|
|
912a92cd1a | ||
|
|
00744c0ce5 | ||
|
|
bc97e383d3 | ||
|
|
b8205b5a09 | ||
|
|
bb7fe9fe37 | ||
|
|
cef7dcac71 | ||
|
|
61e12e2c17 | ||
|
|
65e915fd1d | ||
|
|
1761921670 | ||
|
|
e5bbc797e0 | ||
|
|
f30b1f3f6b | ||
|
|
2c6209277f | ||
|
|
5b3593038d | ||
|
|
67b092253d | ||
|
|
db1059e73b | ||
|
|
e265475c17 | ||
|
|
343345529d | ||
|
|
657fcd595c | ||
|
|
d0d71aa51a | ||
|
|
4618184516 | ||
|
|
3cc54deb9c | ||
|
|
f695238e8a | ||
|
|
fb46fcd80f | ||
|
|
e0c928fbe8 | ||
|
|
780f09c1a2 | ||
|
|
f3c9835759 | ||
|
|
f29649e3a8 | ||
|
|
35400b0c2d | ||
|
|
5d710d9d10 | ||
|
|
a9d10163af | ||
|
|
31e871fe1b | ||
|
|
5148370253 | ||
|
|
581bc03d4e | ||
|
|
0f94f92c37 | ||
|
|
ccec46eddd | ||
|
|
ad58f4e852 | ||
|
|
bb312ff0cf | ||
|
|
342d4a268c | ||
|
|
40282c3447 | ||
|
|
fa328e18a1 | ||
|
|
f55f22d2e8 | ||
|
|
4374f53315 | ||
|
|
c8d7dbd8d6 | ||
|
|
cf6228f525 | ||
|
|
62153d7d36 | ||
|
|
a5e6bd3b62 | ||
|
|
063ef02306 | ||
|
|
25a2b417be | ||
|
|
c5c56ff92f | ||
|
|
cf7d129595 | ||
|
|
226a6e58d5 | ||
|
|
87afe4898e | ||
|
|
d100c934c0 | ||
|
|
ffe5d16094 | ||
|
|
078347b722 | ||
|
|
9b48a008dc | ||
|
|
b2ff7d2c28 | ||
|
|
13bf222b8d | ||
|
|
c2b7b6c231 | ||
|
|
c1b9d94c84 | ||
|
|
1f74e660de | ||
|
|
77aac74590 | ||
|
|
d719a1329c | ||
|
|
c302dfe42d | ||
|
|
41b51f10a9 | ||
|
|
97cd877ce5 | ||
|
|
7007d7a556 | ||
|
|
e6fd95453d | ||
|
|
0329b7b784 | ||
|
|
2f487fda66 | ||
|
|
e82c9c5104 | ||
|
|
c31261789c | ||
|
|
6666992d01 | ||
|
|
ab54881eb0 | ||
|
|
f8545c5141 | ||
|
|
bafb63a665 | ||
|
|
425227509a | ||
|
|
f2a3836877 | ||
|
|
3363592751 | ||
|
|
569242d72f | ||
|
|
3321bb3ccc | ||
|
|
dd4641d618 | ||
|
|
0ce61bc91c | ||
|
|
cb647d95f9 | ||
|
|
7eae1f90f6 | ||
|
|
b8702ae124 | ||
|
|
e5d3722adf | ||
|
|
ff4e853fd3 | ||
|
|
654a41c3b0 | ||
|
|
c6af4791f8 | ||
|
|
85a630cfa9 | ||
|
|
ac1a126756 | ||
|
|
96a25d058b | ||
|
|
6964729cb7 | ||
|
|
54d77598ae | ||
|
|
0bcfc7d352 | ||
|
|
faaaec28e1 | ||
|
|
dde02e6111 | ||
|
|
aadc6b665c | ||
|
|
6730e821b2 | ||
|
|
05ab09c469 | ||
|
|
1e0bc61526 | ||
|
|
6e5af5ef70 | ||
|
|
d312c397e1 | ||
|
|
0dce667019 | ||
|
|
65cd9dc3e5 | ||
|
|
10605d9fb0 | ||
|
|
af9711c2a2 | ||
|
|
9a41b2c0dc | ||
|
|
d805a28c9a | ||
|
|
ffe34120c1 | ||
|
|
47a11ee0b5 | ||
|
|
28a489acbe | ||
|
|
7597caa3a5 | ||
|
|
586d2cc42b | ||
|
|
1fd1f8216d | ||
|
|
0c3f730852 | ||
|
|
b678132176 | ||
|
|
e4be1d6b56 | ||
|
|
5f0fba1807 | ||
|
|
205f78b39c | ||
|
|
5aa8883865 | ||
|
|
1e2c3fc4fc | ||
|
|
b004551fe5 | ||
|
|
704858390d | ||
|
|
c569081340 | ||
|
|
77d413d777 | ||
|
|
43e7ad112f | ||
|
|
fbb8249c0d | ||
|
|
707d9ac274 | ||
|
|
026a77306c | ||
|
|
4087e096f2 | ||
|
|
8827c46c33 | ||
|
|
adde9ff237 | ||
|
|
cfb4f4582b | ||
|
|
8bb637962f | ||
|
|
67a2ca6e31 | ||
|
|
98ad1172b0 | ||
|
|
05fbbac493 | ||
|
|
a4e7ac1df6 | ||
|
|
fb31928e44 | ||
|
|
47bf1d04a1 | ||
|
|
b70f32c6c2 | ||
|
|
21ac1825f3 | ||
|
|
0081622f90 | ||
|
|
d089ed22c7 | ||
|
|
861ae81ff0 | ||
|
|
593640ac19 | ||
|
|
62e0a0338d | ||
|
|
5fd3240fcf | ||
|
|
563dd44957 | ||
|
|
23233a3243 | ||
|
|
1000b706be | ||
|
|
53acfbabf6 | ||
|
|
37bb120d20 | ||
|
|
4fd2b4a014 | ||
|
|
9a376e4223 | ||
|
|
b2d85d70ca | ||
|
|
6aa16ec792 | ||
|
|
e46629d11a | ||
|
|
5bb08e6aa4 | ||
|
|
3698e5a833 | ||
|
|
f84febbf89 | ||
|
|
e029012f73 | ||
|
|
9703840a36 | ||
|
|
c24a29fa65 | ||
|
|
3fcb2b1514 | ||
|
|
ab82841426 | ||
|
|
def8135118 | ||
|
|
335e440cc5 | ||
|
|
7c5bb7f383 | ||
|
|
a9f610fa69 | ||
|
|
801e16c988 | ||
|
|
a570a77cca | ||
|
|
b0f068cc5d | ||
|
|
fff82de933 | ||
|
|
8a4a41fcef | ||
|
|
aa5761954e | ||
|
|
8cd2c4d5bd | ||
|
|
940a28cff4 | ||
|
|
c95feea286 | ||
|
|
1d896467dc | ||
|
|
3a655440b9 | ||
|
|
80bae8bc2a | ||
|
|
f68c67021c | ||
|
|
e37a32c83d | ||
|
|
ec0bde819a | ||
|
|
848f99d3e5 | ||
|
|
34d295c1e0 | ||
|
|
a54ac76688 | ||
|
|
cf02a10050 | ||
|
|
6144473ebe | ||
|
|
174f11604a | ||
|
|
3f057628b7 | ||
|
|
46f1507d44 | ||
|
|
d5b8583d6b | ||
|
|
b1f6fff0a5 | ||
|
|
010ab127e2 | ||
|
|
82c3c33610 | ||
|
|
677b8f5acb | ||
|
|
b8ce02b4f7 | ||
|
|
5014e2f5fd | ||
|
|
87b433e290 | ||
|
|
5581f7a085 | ||
|
|
0b3f619280 | ||
|
|
b901a6ffc7 | ||
|
|
fe81eafe2c | ||
|
|
b0b40c16ff | ||
|
|
4fc95adfb9 | ||
|
|
4fb9882b54 | ||
|
|
29055c575f | ||
|
|
5d96d6673b | ||
|
|
763ff03a7b | ||
|
|
cbc811f6ce | ||
|
|
1d9c77522a | ||
|
|
8f26e1a31f | ||
|
|
ddf18fed9a | ||
|
|
b5a0070023 | ||
|
|
eaf8475f9e | ||
|
|
455234e797 | ||
|
|
53bb23b510 | ||
|
|
d735b6316f | ||
|
|
46737d32f8 | ||
|
|
25d38ae632 | ||
|
|
aa83b4a7a7 | ||
|
|
913ce2dbcb | ||
|
|
cae5e520ac | ||
|
|
772f2ea601 | ||
|
|
28fa03451c | ||
|
|
6984984c22 | ||
|
|
1209c835c7 | ||
|
|
e4ebd5cca1 | ||
|
|
f573110725 | ||
|
|
a8620e133a | ||
|
|
ddd6adbcf7 | ||
|
|
b570aaac48 | ||
|
|
086efe6efe | ||
|
|
56f3c95763 | ||
|
|
8a6a961900 | ||
|
|
6e55968487 | ||
|
|
8d8cddcef6 | ||
|
|
b90d5095f1 | ||
|
|
a4505b1281 | ||
|
|
1d72a8f9c1 | ||
|
|
3d5b6141a5 | ||
|
|
203cd5a9d5 | ||
|
|
696ec65175 | ||
|
|
cbb66a5667 | ||
|
|
53ef35ec80 | ||
|
|
1af3067303 | ||
|
|
d026398bab | ||
|
|
684689a82b | ||
|
|
eeb5f41bad | ||
|
|
7180eaea88 | ||
|
|
7cb204f18a | ||
|
|
0342f609d0 | ||
|
|
59840fa419 | ||
|
|
d390d46ee8 | ||
|
|
37eada9682 | ||
|
|
73a5325a38 | ||
|
|
460eb5434d | ||
|
|
8a21cb9a55 | ||
|
|
d0df52ce35 | ||
|
|
c1ed42fd3a | ||
|
|
c4bb6b8161 | ||
|
|
d480aa11f3 | ||
|
|
d63d5eff85 | ||
|
|
5dae2a4792 | ||
|
|
dcbd7dc219 | ||
|
|
2dcf8b8414 | ||
|
|
438f16094f | ||
|
|
40e0b82fa0 | ||
|
|
b9b0a75fe4 | ||
|
|
b6cc0bc3a7 | ||
|
|
d2f1431269 | ||
|
|
4ecaefbade | ||
|
|
c97c9332eb | ||
|
|
c070e5a9f6 | ||
|
|
8cd3a69803 | ||
|
|
791c9c98dc | ||
|
|
025e979935 | ||
|
|
131471a13f | ||
|
|
7ff63077c3 | ||
|
|
faba0cbd07 | ||
|
|
75f17935f9 | ||
|
|
60842fbbb5 | ||
|
|
d58c27d22d | ||
|
|
65550159bb | ||
|
|
3c7ad81d62 | ||
|
|
6b23c9b3ce | ||
|
|
900e54d740 | ||
|
|
8cc70934da | ||
|
|
f92b0943b5 | ||
|
|
920a383136 | ||
|
|
693e37d2df | ||
|
|
9c6036a103 | ||
|
|
751a4d9111 | ||
|
|
aafd332198 | ||
|
|
337cd0c505 | ||
|
|
b15ce9977a | ||
|
|
e0286aebe3 | ||
|
|
45985f1c04 | ||
|
|
776dd2f8ea | ||
|
|
bdfe4adc98 | ||
|
|
00a0371997 | ||
|
|
ded6b5b081 | ||
|
|
e9678ea899 | ||
|
|
8d69f72e2a | ||
|
|
127b4e11de | ||
|
|
22093bed4d | ||
|
|
ebd53ad679 | ||
|
|
280c604327 | ||
|
|
ad31cdbf85 | ||
|
|
0112ab752b | ||
|
|
66fec80e79 | ||
|
|
fddfaecf5e | ||
|
|
71ae1a2307 | ||
|
|
4d338ebd3d | ||
|
|
dc440f1507 | ||
|
|
5c732f844a | ||
|
|
c4044ba0b1 | ||
|
|
a8159b7bda | ||
|
|
0ab20be667 | ||
|
|
3f048d373f | ||
|
|
8b49a3d264 | ||
|
|
6e51a3f45d | ||
|
|
0d770d1c4d | ||
|
|
b45f021bba | ||
|
|
1a1bce3e8c | ||
|
|
6aeb5d40ab | ||
|
|
31ef2d134e | ||
|
|
85b50e67e1 | ||
|
|
9353f89af0 | ||
|
|
380d69e096 | ||
|
|
02e2f4f500 | ||
|
|
0dbfefa834 | ||
|
|
04ab4bd9f2 | ||
|
|
4955166b85 | ||
|
|
6235c772ac | ||
|
|
e2ec3f7942 | ||
|
|
a816235efb | ||
|
|
1e39ab3c2e | ||
|
|
85aa66c76d | ||
|
|
9a4817faf8 | ||
|
|
7b0c80a0c4 | ||
|
|
6ec8df97e8 | ||
|
|
f82964217e | ||
|
|
cfa5535f6e | ||
|
|
2d846b2c58 | ||
|
|
f40e8037dd | ||
|
|
2b21a75982 | ||
|
|
9ee27308db | ||
|
|
1518223de6 | ||
|
|
3063938a82 | ||
|
|
86449cae52 | ||
|
|
7c580e843f | ||
|
|
c9f0685b40 | ||
|
|
afd0dcf2ff | ||
|
|
68d4df71d8 | ||
|
|
596227659a | ||
|
|
3b0dbadb1e | ||
|
|
8a8bc999d2 | ||
|
|
57c7cca556 | ||
|
|
9e6578a71b | ||
|
|
bf818e3b61 | ||
|
|
9e7f291aaf | ||
|
|
46bab1b97f | ||
|
|
0258d01ee6 | ||
|
|
4999a1a0a8 | ||
|
|
8cb8666456 | ||
|
|
48f3f481db | ||
|
|
e60462e068 | ||
|
|
c4877e3b6a | ||
|
|
afbb1b9a5d | ||
|
|
9516619b92 | ||
|
|
e106a65c1d | ||
|
|
d84c9d4b71 | ||
|
|
0046123e22 | ||
|
|
40736a8334 | ||
|
|
0a256adc94 | ||
|
|
0bddc7965b | ||
|
|
258be3b640 | ||
|
|
4dbfeb87b8 | ||
|
|
fa69287449 | ||
|
|
654ce89541 | ||
|
|
fd32597015 | ||
|
|
84cf07b7a2 | ||
|
|
e4476d0bc6 | ||
|
|
0379f01ce8 | ||
|
|
95e72594ea | ||
|
|
f9ffb1cae5 | ||
|
|
91b6e0a382 | ||
|
|
f5f7a23bb0 | ||
|
|
8be9601963 | ||
|
|
eaad1579e6 | ||
|
|
1d8bf56efd | ||
|
|
73db997e92 | ||
|
|
2c5654d694 | ||
|
|
f490c3a5cd | ||
|
|
2d9158b321 | ||
|
|
48d13762d9 | ||
|
|
bd3f73c2fc | ||
|
|
25c33846be | ||
|
|
124c4ca403 | ||
|
|
ef0f8dd4d0 | ||
|
|
d0eca509d4 | ||
|
|
90663793a2 | ||
|
|
783f654953 | ||
|
|
9cdcce1b5f | ||
|
|
06b483f79d | ||
|
|
a00e137ffc | ||
|
|
239238fe47 | ||
|
|
4cd6e0d10f | ||
|
|
9b29a65c68 | ||
|
|
df4a49a9fb | ||
|
|
bb268310e2 | ||
|
|
7b0908cd87 | ||
|
|
95bc742057 | ||
|
|
e40c890a8e | ||
|
|
357c4fd61f | ||
|
|
d28fea80df | ||
|
|
fb1aeb789a | ||
|
|
1f3693d3a2 | ||
|
|
90760da499 | ||
|
|
7950ba7dc5 | ||
|
|
a7088ee538 | ||
|
|
050cba9563 | ||
|
|
2269617a9f | ||
|
|
bdccfa6e78 | ||
|
|
d97ec3fde2 | ||
|
|
d17472f09e | ||
|
|
f8b7cd2925 | ||
|
|
e9c3ac94c6 | ||
|
|
fa71cddb60 | ||
|
|
228cbc8f87 | ||
|
|
da915208a8 | ||
|
|
694167f78f | ||
|
|
a7697032a4 | ||
|
|
e86c8edd4b | ||
|
|
b1be413dc0 | ||
|
|
fdb50a065b | ||
|
|
1ac59d4894 | ||
|
|
32ccf61baa | ||
|
|
1d04c41ae7 | ||
|
|
b2dcf82ca8 | ||
|
|
57b86034cf | ||
|
|
095e312ab3 | ||
|
|
82a9fb3c39 | ||
|
|
e181329a81 | ||
|
|
cdce817928 | ||
|
|
97b0146ce9 | ||
|
|
0a60492146 | ||
|
|
4ea187cfac | ||
|
|
dcba7c62a2 | ||
|
|
dba99455a7 | ||
|
|
7ebce161e8 | ||
|
|
022aec5720 | ||
|
|
11997c024e | ||
|
|
f787b1b02a | ||
|
|
a03368a3fe | ||
|
|
e26ed8481f | ||
|
|
8e98eed5c8 | ||
|
|
0bff15f964 | ||
|
|
3384c6d666 | ||
|
|
5f1c74aca0 | ||
|
|
310355bcc1 | ||
|
|
ab07d83aaf | ||
|
|
11cdf52f7b | ||
|
|
8a2c3596ce | ||
|
|
a61d16d120 | ||
|
|
01df063cc1 | ||
|
|
68bae686da | ||
|
|
f978888759 | ||
|
|
f3b9f42202 | ||
|
|
0564893c4f | ||
|
|
c9dbe7936b | ||
|
|
3f7a3d7600 | ||
|
|
5b9d9452c9 | ||
|
|
b7ef900181 | ||
|
|
3eef673885 | ||
|
|
039a18c243 | ||
|
|
97d42703da | ||
|
|
4bf3a453e7 | ||
|
|
a137601728 | ||
|
|
d4840df447 | ||
|
|
2c1e51a490 | ||
|
|
2dcccc9820 | ||
|
|
8fa97ab8da | ||
|
|
a66fa9792d | ||
|
|
dc2bc83c17 | ||
|
|
8b0e92e408 | ||
|
|
b61fc4eb6b | ||
|
|
fea3d183bf | ||
|
|
20ed9cd123 | ||
|
|
d7a8a89aa7 | ||
|
|
005cc3e388 | ||
|
|
fbcb54a8a5 | ||
|
|
85a126f48a | ||
|
|
44cd35c10e | ||
|
|
a2d1cff3b0 | ||
|
|
3ff67fec2f | ||
|
|
6a8b5e6c8e | ||
|
|
92e9caf57e | ||
|
|
10ccbf109c | ||
|
|
f2dc51434d | ||
|
|
9d751e3525 | ||
|
|
27047e5880 | ||
|
|
4fdebfc78e | ||
|
|
11832edf49 | ||
|
|
87d50052cc | ||
|
|
08b89b7ef8 | ||
|
|
4b02078b60 | ||
|
|
1d644de500 | ||
|
|
54530faf03 | ||
|
|
ecb16d345a | ||
|
|
daecdb7676 | ||
|
|
423eb95f7a | ||
|
|
82bbed2720 | ||
|
|
90e804f608 | ||
|
|
e748277902 | ||
|
|
2a0c684e88 | ||
|
|
5bfaee47cc | ||
|
|
8de2f41924 | ||
|
|
ee356c5e6e | ||
|
|
a174cf1b02 | ||
|
|
934723f5f5 | ||
|
|
892c4235ec | ||
|
|
ddeb357c0e | ||
|
|
7cb007b445 | ||
|
|
50262a2f02 | ||
|
|
178fe4be4b | ||
|
|
4855e7cbca | ||
|
|
d2450cbdc1 | ||
|
|
bfee1019ed | ||
|
|
76a6d0ce8e | ||
|
|
579b5e4623 | ||
|
|
f2f2a2dbc4 | ||
|
|
3fff147b9b | ||
|
|
a25f491f45 | ||
|
|
8c2928c39d | ||
|
|
7e73e7fab2 | ||
|
|
61535dfb7e | ||
|
|
54332d31bc | ||
|
|
67b8eb5c2f | ||
|
|
27cef96789 | ||
|
|
560345a889 | ||
|
|
f41bfecf0b | ||
|
|
80438e1d61 | ||
|
|
19f1be03fa | ||
|
|
b6ca16e084 | ||
|
|
c0b80c923a | ||
|
|
61e0959a06 | ||
|
|
0ecf8b703e | ||
|
|
1a1bb0a99c | ||
|
|
5415057a5d | ||
|
|
a2493b4bc0 | ||
|
|
fd9040b9aa | ||
|
|
adaf9e4b93 | ||
|
|
39b036abd5 | ||
|
|
837bed3d47 | ||
|
|
7e447fe54e | ||
|
|
cd70c9148e | ||
|
|
cd05ec770c | ||
|
|
a7675606a1 | ||
|
|
bdd9d0a3dd | ||
|
|
e18a7ee435 | ||
|
|
c43d22b0e4 | ||
|
|
1a45d0aa87 | ||
|
|
41835c8afe | ||
|
|
5da9197eee | ||
|
|
cf2eee222e | ||
|
|
98e60e8f74 | ||
|
|
1e4d0006b9 | ||
|
|
b649a69a5e | ||
|
|
066fc01d2e | ||
|
|
19dd297138 | ||
|
|
f470ab6ec8 | ||
|
|
414f4e40c7 | ||
|
|
bb7d393128 | ||
|
|
05177dffb3 | ||
|
|
0fe5346f4d | ||
|
|
54988916e3 | ||
|
|
a1d972419d | ||
|
|
457fe83a4f | ||
|
|
314e4a497d | ||
|
|
4c5dac603f | ||
|
|
d1724caab4 | ||
|
|
37758c2032 | ||
|
|
d7f0a555c0 | ||
|
|
5629edf487 | ||
|
|
7a81e56553 | ||
|
|
0d2cafaec3 | ||
|
|
11f2ddbba2 | ||
|
|
34f4cca1d2 | ||
|
|
18b1dea8cf | ||
|
|
63870931af | ||
|
|
4dc401677d | ||
|
|
25046d7c98 | ||
|
|
a7bc7f25b4 | ||
|
|
1c16b77a92 | ||
|
|
8a670f5524 | ||
|
|
676e918edc | ||
|
|
6ea33c6bb8 | ||
|
|
26ede849e2 | ||
|
|
f464f32e48 | ||
|
|
c171d65d3f | ||
|
|
d01caaa4b1 | ||
|
|
941e599d36 | ||
|
|
d6b39babf5 | ||
|
|
3f68821663 | ||
|
|
7e68882872 | ||
|
|
e06f6c1935 | ||
|
|
599a62d722 | ||
|
|
d690dcadc7 | ||
|
|
e80bcabccf | ||
|
|
4c83780b5e | ||
|
|
be430ebdde | ||
|
|
483d536e2c | ||
|
|
f80deea110 | ||
|
|
6956536830 | ||
|
|
01c725a2ee | ||
|
|
1c2a8119c0 | ||
|
|
943ca79951 | ||
|
|
62c05093a0 | ||
|
|
d71d4e24be | ||
|
|
37e0785775 | ||
|
|
0ddeccf698 | ||
|
|
875ecca527 | ||
|
|
3a5d687f08 | ||
|
|
257077a59b | ||
|
|
9647d95759 | ||
|
|
2436ce45a2 | ||
|
|
2a2a4b817e | ||
|
|
d51234f202 | ||
|
|
7abe7f1a87 | ||
|
|
2849a9bce5 | ||
|
|
2aee8b8c2c | ||
|
|
e77c28b89b | ||
|
|
ed3f208dda | ||
|
|
68f5e7e502 | ||
|
|
11b0026995 | ||
|
|
5cfae4f8f0 | ||
|
|
bda03d187e | ||
|
|
0fa15604dc | ||
|
|
cd0acf1e6b | ||
|
|
9e183a2033 | ||
|
|
76e8e48400 | ||
|
|
3d6d4a48a5 | ||
|
|
5d0240e675 | ||
|
|
cabaa9f1df | ||
|
|
347bd3345c | ||
|
|
262bc6e1f7 | ||
|
|
e06152b58b | ||
|
|
d8addec077 | ||
|
|
5456596b29 | ||
|
|
8949ad6d9e | ||
|
|
0bb17a1502 | ||
|
|
0c52341dec | ||
|
|
4276e7835c | ||
|
|
fc1ad44346 | ||
|
|
91cf93d133 | ||
|
|
457de0919a | ||
|
|
d4fd3518bf | ||
|
|
f9adf5938e | ||
|
|
5d36fd7f99 | ||
|
|
9f5dd6f658 | ||
|
|
de40bd4705 | ||
|
|
6c5c41e8ea | ||
|
|
bd9c264375 | ||
|
|
1d625916ff | ||
|
|
80d65ad2e4 | ||
|
|
b11e9de6a7 | ||
|
|
7b28680cda | ||
|
|
853c489471 | ||
|
|
c6cd7d5ae6 | ||
|
|
8e120e2665 | ||
|
|
46ff120ab2 | ||
|
|
7f7f569148 | ||
|
|
b14fb31933 | ||
|
|
65658e58d5 | ||
|
|
c5d1d2c21e | ||
|
|
f22d2efb9d | ||
|
|
d0b9dc7a24 | ||
|
|
0da34f729e | ||
|
|
57f3ed71a1 | ||
|
|
34cdc26e6f | ||
|
|
8684548072 | ||
|
|
ea5509f864 | ||
|
|
8702786fa2 | ||
|
|
8673ed1459 | ||
|
|
1b8e73bb5c | ||
|
|
6ffdd38c3a | ||
|
|
a1c5aa4e04 | ||
|
|
d2f2d7f92d | ||
|
|
3e1a046140 | ||
|
|
35b57542d1 | ||
|
|
cfa768eebd | ||
|
|
dc76721d2c | ||
|
|
2f8af7c96b | ||
|
|
3153c60291 | ||
|
|
b34dd12863 | ||
|
|
e324ffdcd6 | ||
|
|
183e7c60e1 | ||
|
|
f07cae540e | ||
|
|
abd85f777f | ||
|
|
519ad67eb1 | ||
|
|
254d30d32d | ||
|
|
4a1029961a | ||
|
|
5384ffd403 | ||
|
|
10bd14c223 | ||
|
|
31bc452374 | ||
|
|
3b8398b2e5 | ||
|
|
02906dad0e | ||
|
|
db96c9a46e | ||
|
|
86dbbe83c2 | ||
|
|
6d95822e19 | ||
|
|
01653efa84 | ||
|
|
1180634269 | ||
|
|
62f852b851 | ||
|
|
ea22f07046 | ||
|
|
18d0af3dbf | ||
|
|
a1ea060cd9 | ||
|
|
8b6a5d3824 | ||
|
|
62dae22a2c | ||
|
|
b88fb6273b | ||
|
|
e01dfee41d | ||
|
|
a9b24c9161 | ||
|
|
93090e227f | ||
|
|
5b9ba06af6 | ||
|
|
8ad53b9490 | ||
|
|
b35ddcba3f | ||
|
|
5be7813ab8 | ||
|
|
440721368f | ||
|
|
ed2ff5c1d7 | ||
|
|
e72e5370c4 | ||
|
|
bb125a1ddc | ||
|
|
fecf48e180 | ||
|
|
9c19a72d67 | ||
|
|
d9d3a1afcf | ||
|
|
34272af711 | ||
|
|
9a8f25d1a9 | ||
|
|
ba4d88e8ce | ||
|
|
73249f09e4 | ||
|
|
4d6e7c094f | ||
|
|
24c9105628 | ||
|
|
c996078f30 | ||
|
|
4c1ec66365 | ||
|
|
26a0f99f8f | ||
|
|
f64631a3a3 | ||
|
|
cb4407ba49 | ||
|
|
bf4e2d7a88 | ||
|
|
065b1caaf9 | ||
|
|
2516460a0d | ||
|
|
d15b294c38 | ||
|
|
8c8a431428 | ||
|
|
44964916db | ||
|
|
a80ddea93c | ||
|
|
f1656c4960 | ||
|
|
3b4ff95854 | ||
|
|
316d7055f5 | ||
|
|
9ffe4b4ce8 | ||
|
|
3ffc6f21c4 | ||
|
|
e4fdc65e52 | ||
|
|
f6dac1c38a | ||
|
|
7f09f191a1 | ||
|
|
f3e2f84b38 | ||
|
|
5d94dcc94d | ||
|
|
381fc6b2cf | ||
|
|
bac0e7fb20 | ||
|
|
6f363a7703 | ||
|
|
d6597121f5 | ||
|
|
ddf3f5c107 | ||
|
|
00442c41fa | ||
|
|
ed68aebfb0 | ||
|
|
ebe1a8d3e3 | ||
|
|
26493ff0ab | ||
|
|
63bae5a31c | ||
|
|
9e31efe26c | ||
|
|
feb7484fda | ||
|
|
4ac8e63c94 | ||
|
|
7b66505634 | ||
|
|
c246ccfc91 | ||
|
|
2101a957ce | ||
|
|
97b15afe7c | ||
|
|
dc4bb25cc2 | ||
|
|
772cb90f64 | ||
|
|
16fb06ff4c | ||
|
|
5603c72f40 | ||
|
|
7066166757 | ||
|
|
041a61f89c | ||
|
|
cd1b103223 | ||
|
|
f8ca3773be | ||
|
|
13487fe4e5 | ||
|
|
a90f065ab0 | ||
|
|
c33cd07ea9 | ||
|
|
897ea2e7a4 | ||
|
|
d0ecdcd8aa | ||
|
|
24d24f6829 | ||
|
|
32b293ef3e | ||
|
|
3e75bc8964 | ||
|
|
0b6763745c | ||
|
|
4487c35486 | ||
|
|
61abb47d22 | ||
|
|
b0b1bdf158 | ||
|
|
c26598c773 | ||
|
|
2140c1443a | ||
|
|
ed2f4c1c34 | ||
|
|
297da59448 | ||
|
|
ec9aea1f0e | ||
|
|
013c807f0d | ||
|
|
407fcfff3e | ||
|
|
557e64c77f | ||
|
|
2e9c9e80d1 | ||
|
|
ac73f6fbac | ||
|
|
06a46383a1 | ||
|
|
b4bbf4eeb1 | ||
|
|
6b34a7e3f5 | ||
|
|
91986baa57 | ||
|
|
bae278a0c1 | ||
|
|
5e81a0b581 | ||
|
|
e7fda0eadd | ||
|
|
95fc848e59 | ||
|
|
4cd196f665 | ||
|
|
40e54097b0 | ||
|
|
bd843bca61 | ||
|
|
5c80be92c9 | ||
|
|
c1d7b13e7a | ||
|
|
865be3a6bf | ||
|
|
b97e029c82 | ||
|
|
91a30adfc4 | ||
|
|
af0daa91ad | ||
|
|
1e0255d0ed | ||
|
|
8e0695e9d9 | ||
|
|
0bd3fe32e2 | ||
|
|
c732a03263 | ||
|
|
3c0bf5fdae | ||
|
|
0c8c774642 | ||
|
|
6f340b7b91 | ||
|
|
9fefa94134 | ||
|
|
e24534668a | ||
|
|
add477fece | ||
|
|
6cf251b19a | ||
|
|
76ed7e9fcb | ||
|
|
c3be83bf94 | ||
|
|
cddccd2b99 | ||
|
|
2256ad1233 | ||
|
|
ac758216ca | ||
|
|
6f00c3cd09 | ||
|
|
f45fd06fb0 | ||
|
|
6f597a5f54 | ||
|
|
39f64fc0f9 | ||
|
|
3963e424f8 | ||
|
|
86b9bc3036 | ||
|
|
3ee3aa4035 | ||
|
|
302c4e7456 | ||
|
|
f8ea1b0ac1 | ||
|
|
bfb4c2fa3c | ||
|
|
08a3148ab3 | ||
|
|
7ef0488419 | ||
|
|
1da70d0518 | ||
|
|
6c7b40764f | ||
|
|
69335ad974 | ||
|
|
ee5ba42b79 | ||
|
|
1d27284575 | ||
|
|
f5373d9b7f | ||
|
|
af1828dd32 | ||
|
|
42aa0cbcee | ||
|
|
cc5db20c58 | ||
|
|
99388bfa33 | ||
|
|
856077a46c | ||
|
|
4eeba69381 | ||
|
|
ffca11bc18 | ||
|
|
8e7582d8b9 | ||
|
|
c8b256ded1 | ||
|
|
dd7cd68b51 | ||
|
|
24ef24558c | ||
|
|
edf8c663f1 | ||
|
|
c24f3d47f7 | ||
|
|
1874214927 | ||
|
|
c29d57622f | ||
|
|
6ae862980d | ||
|
|
c66ecd7517 | ||
|
|
6b8c1fcf47 | ||
|
|
427f173b38 | ||
|
|
b53e3f14d6 | ||
|
|
ffdc9a9585 | ||
|
|
b6a9969aee | ||
|
|
0265849ed3 | ||
|
|
6bc9cdc69d | ||
|
|
3d88dfd98a | ||
|
|
e2e14fd09c | ||
|
|
aa57f78561 | ||
|
|
0f7ba38655 | ||
|
|
1e71d355fc | ||
|
|
0c5c6b3288 | ||
|
|
b98fc8497f | ||
|
|
f002f8de03 | ||
|
|
4fa0201b7e | ||
|
|
8053cbd854 | ||
|
|
c71027c466 | ||
|
|
3cdf471473 | ||
|
|
b807c16395 | ||
|
|
aa59862a70 | ||
|
|
45f071abd9 | ||
|
|
019e148ddb | ||
|
|
b2d08f964d | ||
|
|
d5d74339dd | ||
|
|
9285dc896c | ||
|
|
6c98816f9f | ||
|
|
ed1d828f85 | ||
|
|
50343d5459 | ||
|
|
691a71ac05 | ||
|
|
e6a31a61db | ||
|
|
adc82532eb | ||
|
|
02770d56df | ||
|
|
1366c0b973 | ||
|
|
8bd3a894f1 | ||
|
|
6cc66e404b | ||
|
|
b1cf348abb | ||
|
|
8e11f3864a | ||
|
|
ea45918561 | ||
|
|
80887dd2cc | ||
|
|
b2731eddf3 | ||
|
|
18ac4fe2f3 | ||
|
|
81b7b74fd4 | ||
|
|
38607332b9 | ||
|
|
dd387735d4 | ||
|
|
d9645fa062 | ||
|
|
026f4b9c0e | ||
|
|
a223819dd7 | ||
|
|
bc16c0eec8 | ||
|
|
0d26fd8c5a | ||
|
|
bc2b430ec3 | ||
|
|
6de113b852 | ||
|
|
abe65ad01b | ||
|
|
76f648b4eb | ||
|
|
514bf7e3ed | ||
|
|
24b65d76b5 | ||
|
|
5a81be0756 | ||
|
|
dc2996988b | ||
|
|
c51f94ee87 | ||
|
|
5d34426428 | ||
|
|
4835ca13f5 | ||
|
|
84f46d5ea7 | ||
|
|
19209181dc | ||
|
|
154ec94c58 | ||
|
|
8909e6741a | ||
|
|
45a58a4a03 | ||
|
|
0348b7d915 | ||
|
|
c61c3147ba | ||
|
|
bec9c3a989 | ||
|
|
24ece00e93 | ||
|
|
1becf39269 | ||
|
|
b272109055 | ||
|
|
133f3108f0 | ||
|
|
0ed053b02f | ||
|
|
97d66a8d0c | ||
|
|
ec3c5994d9 | ||
|
|
1f6e384d2b | ||
|
|
5e4099a588 | ||
|
|
68737b9ed6 | ||
|
|
a8813cfb89 | ||
|
|
3b194d3c23 | ||
|
|
5374add5d8 | ||
|
|
8c4c5fa394 | ||
|
|
6fdb944b1e | ||
|
|
d488f809d5 | ||
|
|
5ed3695c48 | ||
|
|
18f39af769 | ||
|
|
1a0a3de2fc | ||
|
|
e536b7bc35 | ||
|
|
85decd7487 | ||
|
|
9fea71a70c | ||
|
|
bde5bf4c76 | ||
|
|
94abab3260 | ||
|
|
76ed136228 | ||
|
|
8d8b20aa47 | ||
|
|
aec0326d40 | ||
|
|
7faca5512a | ||
|
|
ad84272084 | ||
|
|
09e0f594ff | ||
|
|
dd2fbf4424 | ||
|
|
99b12a49c6 | ||
|
|
ea35efe440 | ||
|
|
bf09e740e9 | ||
|
|
60c77cec56 | ||
|
|
0e4a1dddb5 | ||
|
|
1cf18b6e12 | ||
|
|
f9a8be898a | ||
|
|
1521ce5a96 | ||
|
|
f2e62dd197 | ||
|
|
d378630b38 | ||
|
|
d9e6346911 | ||
|
|
238788e0e9 | ||
|
|
68ff828505 | ||
|
|
59447fc12b | ||
|
|
c8033fb6ab | ||
|
|
e33d5b952c | ||
|
|
4345ac2ba2 | ||
|
|
a12b43ce5c | ||
|
|
6885cf1f6d | ||
|
|
00f6fafcfc | ||
|
|
42dc64246c | ||
|
|
fbe303a3cd | ||
|
|
373845450b | ||
|
|
084bbc0bef | ||
|
|
0061fc04b7 | ||
|
|
f6a6410626 | ||
|
|
835be3d329 | ||
|
|
2395093394 | ||
|
|
28209e1c2a | ||
|
|
00562dd1d4 | ||
|
|
0f78d5cbf3 | ||
|
|
431c6de8d2 | ||
|
|
142e15bbcc | ||
|
|
31acc5c607 | ||
|
|
bfa0a26d41 | ||
|
|
93ab9b6a5e | ||
|
|
35e29d46bd | ||
|
|
465da6f818 | ||
|
|
e5f12fddd9 | ||
|
|
4fa9a1303a | ||
|
|
43f349d415 | ||
|
|
02069954de | ||
|
|
2e15875fed | ||
|
|
b34cfb676d | ||
|
|
3064497636 | ||
|
|
dec681fea0 | ||
|
|
523e27ba9a | ||
|
|
e7db76e581 | ||
|
|
689339117a | ||
|
|
b202765be4 | ||
|
|
3bbf3073df | ||
|
|
f46aaa2182 | ||
|
|
a2f33a6c35 | ||
|
|
b6bd6357ed | ||
|
|
c3a5878b1b | ||
|
|
3e4309eba3 | ||
|
|
414f45aa71 | ||
|
|
ebdc76346f | ||
|
|
64bfa955f4 | ||
|
|
612992fa1f | ||
|
|
c02ac56da8 | ||
|
|
9bfb295238 | ||
|
|
cddc22d2b3 | ||
|
|
11ded575d5 | ||
|
|
394cc536a9 | ||
|
|
6bd8cdb9cf | ||
|
|
e20a09f15a | ||
|
|
b89a4af0cf | ||
|
|
a56854af43 | ||
|
|
4a35d78c8d | ||
|
|
26b281271e | ||
|
|
96094cfde2 | ||
|
|
7e26af5476 | ||
|
|
c8dfb784bc | ||
|
|
fd3a5a5afe | ||
|
|
599b3d4c95 | ||
|
|
41719a00e7 | ||
|
|
b5c0f85dca | ||
|
|
7d6d262ed3 | ||
|
|
e21acd73eb | ||
|
|
702f9bc5f1 | ||
|
|
d0ce798881 | ||
|
|
2b1d197047 | ||
|
|
71bc2e6aab | ||
|
|
afb329934a | ||
|
|
1313af45a3 | ||
|
|
dddb327885 | ||
|
|
26b4a37323 | ||
|
|
9dad194130 | ||
|
|
03ad16ea8a | ||
|
|
2fa64b98e3 | ||
|
|
75d7e89cbb | ||
|
|
d73a443484 | ||
|
|
15a9b88fc8 | ||
|
|
03eb7203ec | ||
|
|
e38cd6819b | ||
|
|
d44cfaddf6 | ||
|
|
65225710a8 | ||
|
|
d7f5b16359 | ||
|
|
7185818724 | ||
|
|
868f3349e5 | ||
|
|
d7384e69d9 | ||
|
|
1d5c378343 | ||
|
|
4e1aed9976 | ||
|
|
e2e7996a54 | ||
|
|
df9f9a9f4f | ||
|
|
7553b0da80 | ||
|
|
8f30bf0bef | ||
|
|
8c12174521 |
+6
-15
@@ -1,23 +1,14 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
.venv
|
||||
.uv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
# Build artifacts
|
||||
build/
|
||||
target/
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info/
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
# frontend/dist/ - 注释掉,因为我们需要预构建的dist文件
|
||||
|
||||
# Development
|
||||
.git/
|
||||
@@ -59,4 +50,4 @@ Dockerfile.*
|
||||
|
||||
# Deployment
|
||||
deploy/
|
||||
scripts/
|
||||
scripts/
|
||||
|
||||
+73
-14
@@ -1,11 +1,48 @@
|
||||
# ==================== 必须配置(启动前) ====================
|
||||
# 以下配置项必须在项目启动前设置
|
||||
|
||||
# 数据库密码
|
||||
DB_PASSWORD=your_secure_password_here
|
||||
REDIS_PASSWORD=your_redis_password_here
|
||||
# 应用端口(默认 8084)
|
||||
APP_PORT=8084
|
||||
|
||||
# JWT密钥(使用 python generate_keys.py 生成)
|
||||
# 对外访问地址,用于一键安装、CC Switch 导入、支付回调等需要生成公网 URL 的场景。
|
||||
# 生产环境建议显式配置为不带内部端口的公网域名,例如 https://aether.example.com
|
||||
# AETHER_PUBLIC_BASE_URL=https://aether.example.com
|
||||
|
||||
# Docker Compose 镜像(默认正式版 latest;提前测试可改 rc/beta;也可固定具体版本)
|
||||
# 示例:
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:latest
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:rc
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:beta
|
||||
# APP_IMAGE=ghcr.io/fawney19/aether:0.7.0-rc.1
|
||||
|
||||
# API Key 前缀(默认 sk)
|
||||
API_KEY_PREFIX=sk
|
||||
|
||||
# Rust 日志过滤(默认 aether_gateway=info)
|
||||
# 示例: aether_gateway=debug,sqlx=warn
|
||||
RUST_LOG=aether_gateway=info
|
||||
|
||||
# CORS 配置(跨域带 Cookie 时不要写 *,必须显式列出前端源)
|
||||
# 示例: http://localhost:5173,https://app.example.com
|
||||
# CORS_ORIGINS=http://localhost:5173
|
||||
# CORS_ALLOW_CREDENTIALS=true
|
||||
# 如果前后端跨站并依赖登录刷新 Cookie,还要配合:
|
||||
# AUTH_REFRESH_COOKIE_SAMESITE=None
|
||||
# AUTH_REFRESH_COOKIE_SECURE=true
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_NAME=aether
|
||||
DB_PASSWORD=aether
|
||||
|
||||
# Redis 配置
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=aether
|
||||
|
||||
# JWT密钥(使用 ./generate_keys.sh 生成)
|
||||
# 用于用户登录 token 签名,更换后所有用户需重新登录
|
||||
JWT_SECRET_KEY=change-this-to-a-secure-random-string
|
||||
|
||||
@@ -13,19 +50,41 @@ JWT_SECRET_KEY=change-this-to-a-secure-random-string
|
||||
# 注意:更换此密钥后需要在管理面板重新配置所有 Provider API Key
|
||||
ENCRYPTION_KEY=change-this-to-another-secure-random-string
|
||||
|
||||
# 管理员账号(仅首次初始化时使用, 创建完成后可在系统内修改密码)
|
||||
# 启动自举管理员(仅在当前库里还没有活动管理员时生效)
|
||||
# 手动部署时取消注释并设置;install.sh 首次生成配置时会提示输入。
|
||||
ADMIN_EMAIL=[email protected]
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123456
|
||||
ADMIN_USERNAME=admin123456
|
||||
# ADMIN_PASSWORD=
|
||||
|
||||
# ==================== 可选配置(有默认值) ====================
|
||||
# 以下配置项有合理的默认值,可按需调整
|
||||
|
||||
# 应用端口(默认 8084)
|
||||
# APP_PORT=8084
|
||||
# 可信反向代理 IP/CIDR,只有这些来源发送的 X-Real-IP / X-Forwarded-For 会被采用。
|
||||
# 默认仅信任本机回环代理:127.0.0.0/8,::1/128。
|
||||
# Docker/Nginx 位于独立容器时,请按实际容器网络设置,例如:172.16.0.0/12。
|
||||
# AETHER_TRUSTED_PROXY_CIDRS=127.0.0.0/8,::1/128,172.16.0.0/12
|
||||
|
||||
# API Key 前缀(默认 sk)
|
||||
# API_KEY_PREFIX=sk
|
||||
# docker compose 下 app 启动前自动执行 pending migration/backfill(默认 true)
|
||||
# AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
|
||||
|
||||
# 日志级别(默认 INFO,可选:DEBUG, INFO, WARNING, ERROR)
|
||||
# LOG_LEVEL=INFO
|
||||
# PostgreSQL 连接池配置(默认按 CPU 自动计算;正式高并发环境可显式预算)
|
||||
# AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS=12
|
||||
# AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS=80
|
||||
# AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS=2048
|
||||
# AETHER_GATEWAY_REQUEST_BODY_BUFFER_BUDGET_MB=256
|
||||
# AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS=120000
|
||||
# 可选的 Payload 上限(MiB);默认及 0 均表示不限制。
|
||||
# AETHER_MAX_REQUEST_BODY_MB=0
|
||||
# AETHER_GATEWAY_SECURITY_CACHE_TTL_MS=1000
|
||||
# AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB=0
|
||||
# AETHER_MAX_INTERNAL_BUFFERED_BODY_MB=0
|
||||
# AETHER_TUNNEL_NODE_STATUS_QUEUE_CAPACITY=1024
|
||||
|
||||
# PostgreSQL 容器调优:docker-compose.yml 已内置通用默认值,通常不用配置。
|
||||
# 只有在 Postgres 独占大内存、或压测显示 DB 缓存/排序/维护任务成为瓶颈时再覆盖。
|
||||
# 内置默认:shared_buffers=1GB, effective_cache_size=3GB, shm_size=512mb,
|
||||
# work_mem=16MB, maintenance_work_mem=256MB。
|
||||
# POSTGRES_SHARED_BUFFERS=8GB
|
||||
# POSTGRES_EFFECTIVE_CACHE_SIZE=24GB
|
||||
# POSTGRES_SHM_SIZE=2gb
|
||||
# POSTGRES_WORK_MEM=16MB
|
||||
# POSTGRES_MAINTENANCE_WORK_MEM=1GB
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
name: Build aether-tunnel
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['tunnel-v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: build-tunnel-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Ensure tunnel tag matches Cargo version
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
EXPECTED="${TAG#tunnel-v}"
|
||||
ACTUAL="$(cargo metadata --manifest-path apps/aether-tunnel/Cargo.toml --locked --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "aether-tunnel") | .version')"
|
||||
|
||||
echo "tag version: ${EXPECTED}"
|
||||
echo "cargo version: ${ACTUAL}"
|
||||
|
||||
if [ -z "${ACTUAL}" ]; then
|
||||
echo "Could not resolve aether-tunnel package version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${EXPECTED}" != "${ACTUAL}" ]; then
|
||||
echo "tunnel tag ${TAG} does not match apps/aether-tunnel/Cargo.toml version ${ACTUAL}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: preflight
|
||||
if: always() && (needs.preflight.result == 'success' || needs.preflight.result == 'skipped')
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-amd64
|
||||
target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-arm64
|
||||
target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-musl-amd64
|
||||
target: x86_64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-musl-arm64
|
||||
target: aarch64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: macos-amd64
|
||||
target: x86_64-apple-darwin
|
||||
os: macos-15-intel
|
||||
use_cross: false
|
||||
- name: macos-arm64
|
||||
target: aarch64-apple-darwin
|
||||
os: macos-15
|
||||
use_cross: false
|
||||
- name: windows-amd64
|
||||
target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
use_cross: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Ensure Rust target is installed
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/aether-tunnel -> target
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.use_cross
|
||||
uses: taiki-e/install-action@cross
|
||||
|
||||
- name: Build
|
||||
working-directory: apps/aether-tunnel
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ matrix.use_cross }}" = "true" ]; then
|
||||
cross build --release --locked --target ${{ matrix.target }}
|
||||
else
|
||||
cargo build --release --locked --target ${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: Package (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
cd target/${{ matrix.target }}/release
|
||||
chmod +x aether-tunnel
|
||||
tar czf ../../../aether-tunnel-${{ matrix.name }}.tar.gz aether-tunnel
|
||||
|
||||
- name: Package (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
cd target/${{ matrix.target }}/release
|
||||
7z a ../../../aether-tunnel-${{ matrix.name }}.zip aether-tunnel.exe
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: aether-tunnel-${{ matrix.name }}
|
||||
path: |
|
||||
aether-tunnel-*.tar.gz
|
||||
aether-tunnel-*.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
merge-multiple: true
|
||||
path: artifacts
|
||||
|
||||
- name: Generate checksums
|
||||
working-directory: artifacts
|
||||
run: sha256sum aether-tunnel-* > SHA256SUMS.txt
|
||||
|
||||
- name: Delete stale draft releases for tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
draft_ids="$(gh api "repos/${REPOSITORY}/releases" --paginate --jq '.[] | select(.tag_name == env.RELEASE_TAG and .draft == true) | .id')"
|
||||
|
||||
if [[ -z "${draft_ids}" ]]; then
|
||||
echo "No stale draft releases for ${RELEASE_TAG}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r release_id; do
|
||||
[[ -z "${release_id}" ]] && continue
|
||||
echo "Deleting stale draft release ${release_id} for ${RELEASE_TAG}"
|
||||
gh api -X DELETE "repos/${REPOSITORY}/releases/${release_id}"
|
||||
done <<< "${draft_ids}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: "${{ github.ref_name }}"
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/aether-tunnel-*
|
||||
artifacts/SHA256SUMS.txt
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
update-readme:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Update README download links
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG#tunnel-v}"
|
||||
BASE="https://github.com/fawney19/Aether/releases/download/${TAG}"
|
||||
|
||||
if [ -d apps/aether-tunnel ]; then
|
||||
TUNNEL_DIR="apps/aether-tunnel"
|
||||
else
|
||||
TUNNEL_DIR="aether-tunnel"
|
||||
fi
|
||||
|
||||
cd "$TUNNEL_DIR"
|
||||
|
||||
TABLE="| Platform | Download |\n|----------|----------|\n"
|
||||
TABLE+="| Linux x86_64 (GNU) | [aether-tunnel-linux-amd64.tar.gz](${BASE}/aether-tunnel-linux-amd64.tar.gz) |\n"
|
||||
TABLE+="| Linux ARM64 (GNU) | [aether-tunnel-linux-arm64.tar.gz](${BASE}/aether-tunnel-linux-arm64.tar.gz) |\n"
|
||||
TABLE+="| Linux x86_64 (musl) | [aether-tunnel-linux-musl-amd64.tar.gz](${BASE}/aether-tunnel-linux-musl-amd64.tar.gz) |\n"
|
||||
TABLE+="| Linux ARM64 (musl) | [aether-tunnel-linux-musl-arm64.tar.gz](${BASE}/aether-tunnel-linux-musl-arm64.tar.gz) |\n"
|
||||
TABLE+="| macOS x86_64 | [aether-tunnel-macos-amd64.tar.gz](${BASE}/aether-tunnel-macos-amd64.tar.gz) |\n"
|
||||
TABLE+="| macOS ARM64 | [aether-tunnel-macos-arm64.tar.gz](${BASE}/aether-tunnel-macos-arm64.tar.gz) |\n"
|
||||
TABLE+="| Windows x86_64 | [aether-tunnel-windows-amd64.zip](${BASE}/aether-tunnel-windows-amd64.zip) |"
|
||||
|
||||
# Replace content between markers
|
||||
if grep -q '<!-- DOWNLOAD_TABLE_START -->' README.md; then
|
||||
awk -v table="$TABLE" '
|
||||
/<!-- DOWNLOAD_TABLE_START -->/ { print; printf "%s\n", table; skip=1; next }
|
||||
/<!-- DOWNLOAD_TABLE_END -->/ { skip=0 }
|
||||
!skip { print }
|
||||
' README.md > README.tmp && mv README.tmp README.md
|
||||
fi
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
if [ -d apps/aether-tunnel ]; then
|
||||
TUNNEL_DIR="apps/aether-tunnel"
|
||||
else
|
||||
TUNNEL_DIR="aether-tunnel"
|
||||
fi
|
||||
|
||||
cd "$TUNNEL_DIR"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add README.md
|
||||
git diff --cached --quiet && exit 0
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
git commit -m "chore(tunnel): update download links for ${TAG}"
|
||||
git push
|
||||
@@ -15,15 +15,43 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
deploy_pages: ${{ steps.classify.outputs.deploy_pages }}
|
||||
steps:
|
||||
- name: Ensure stable Pages release tag
|
||||
id: classify
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "deploy_pages=false" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
|
||||
echo "Manual Pages deployment."
|
||||
echo "deploy_pages=true" >> "${GITHUB_OUTPUT}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Skipping Pages deploy for non-stable release tag: ${tag}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "deploy_pages=true" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
build:
|
||||
needs: preflight
|
||||
if: needs.preflight.outputs.deploy_pages == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
@@ -41,7 +69,7 @@ jobs:
|
||||
run: cp frontend/dist/index.html frontend/dist/404.html
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_base:
|
||||
description: 'Rebuild base image'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
BASE_IMAGE_NAME: fawney19/aether-base
|
||||
APP_IMAGE_NAME: fawney19/aether
|
||||
# Files that affect base image - used for hash calculation
|
||||
BASE_FILES: "Dockerfile.base pyproject.toml frontend/package.json frontend/package-lock.json"
|
||||
|
||||
jobs:
|
||||
check-base-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
base_changed: ${{ steps.check.outputs.base_changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if base image needs rebuild
|
||||
id: check
|
||||
run: |
|
||||
if [ "${{ github.event.inputs.build_base }}" == "true" ]; then
|
||||
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Calculate current hash of base-related files
|
||||
CURRENT_HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1)
|
||||
echo "Current base files hash: $CURRENT_HASH"
|
||||
|
||||
# Try to get hash label from remote image config
|
||||
# Pull the image config and extract labels
|
||||
REMOTE_HASH=""
|
||||
if docker pull ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest 2>/dev/null; then
|
||||
REMOTE_HASH=$(docker inspect ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest --format '{{ index .Config.Labels "org.opencontainers.image.base.hash" }}' 2>/dev/null) || true
|
||||
fi
|
||||
|
||||
if [ -z "$REMOTE_HASH" ] || [ "$REMOTE_HASH" == "<no value>" ]; then
|
||||
# No remote image or no hash label, need to rebuild
|
||||
echo "No remote base image or hash label found, need rebuild"
|
||||
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||
elif [ "$CURRENT_HASH" != "$REMOTE_HASH" ]; then
|
||||
echo "Hash mismatch: remote=$REMOTE_HASH, current=$CURRENT_HASH"
|
||||
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Hash matches, no rebuild needed"
|
||||
echo "base_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build-base:
|
||||
needs: check-base-changes
|
||||
if: needs.check-base-changes.outputs.base_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Calculate base files hash
|
||||
id: hash
|
||||
run: |
|
||||
HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1)
|
||||
echo "hash=$HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract metadata for base image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=
|
||||
labels: |
|
||||
org.opencontainers.image.base.hash=${{ steps.hash.outputs.hash }}
|
||||
|
||||
- name: Build and push base image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.base
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
build-app:
|
||||
needs: [check-base-changes, build-base]
|
||||
if: always() && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for app image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=
|
||||
|
||||
- name: Update Dockerfile.app to use registry base image
|
||||
run: |
|
||||
sed -i "s|FROM aether-base:latest AS builder|FROM ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest AS builder|g" Dockerfile.app
|
||||
|
||||
- name: Build and push app image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.app
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -0,0 +1,342 @@
|
||||
name: Release Aether
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-aether-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
GHCR_IMAGE: fawney19/aether
|
||||
DOCKERHUB_IMAGE: fawney19/aether
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
name: Release preflight
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
publish: ${{ steps.classify.outputs.publish }}
|
||||
version_tag: ${{ steps.classify.outputs.version_tag }}
|
||||
prerelease: ${{ steps.classify.outputs.prerelease }}
|
||||
make_latest: ${{ steps.classify.outputs.make_latest }}
|
||||
steps:
|
||||
- name: Classify release tag
|
||||
id: classify
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "publish=false" >> "${GITHUB_OUTPUT}"
|
||||
echo "version_tag=" >> "${GITHUB_OUTPUT}"
|
||||
echo "prerelease=false" >> "${GITHUB_OUTPUT}"
|
||||
echo "make_latest=false" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
|
||||
echo "Manual release build; publish jobs will be skipped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$ ]]; then
|
||||
echo "Unsupported release tag: ${tag}" >&2
|
||||
echo "Expected vX.Y.Z, vX.Y.Z-beta.N, or vX.Y.Z-rc.N." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version_tag=${tag}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [[ "${tag}" == *-* ]]; then
|
||||
echo "prerelease=true" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "make_latest=true" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
|
||||
echo "publish=true" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "Manual release build for ${tag}; publish jobs will be skipped."
|
||||
fi
|
||||
|
||||
frontend:
|
||||
name: Build frontend
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install & build
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
needs: preflight
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-amd64
|
||||
target: x86_64-unknown-linux-musl
|
||||
platform: linux
|
||||
arch: amd64
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: linux-arm64
|
||||
target: aarch64-unknown-linux-musl
|
||||
platform: linux
|
||||
arch: arm64
|
||||
os: ubuntu-latest
|
||||
use_cross: true
|
||||
- name: macos-amd64
|
||||
target: x86_64-apple-darwin
|
||||
platform: macos
|
||||
arch: amd64
|
||||
os: macos-15-intel
|
||||
use_cross: false
|
||||
- name: macos-arm64
|
||||
target: aarch64-apple-darwin
|
||||
platform: macos
|
||||
arch: arm64
|
||||
os: macos-15
|
||||
use_cross: false
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: release-${{ matrix.target }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.use_cross
|
||||
uses: taiki-e/install-action@cross
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
AETHER_VERSION: ${{ needs.preflight.outputs.version_tag }}
|
||||
AETHER_BUILD_TYPE: release
|
||||
CARGO_TERM_COLOR: always
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ matrix.use_cross }}" == "true" ]]; then
|
||||
cross build --release --locked -p aether-gateway --target ${{ matrix.target }}
|
||||
else
|
||||
cargo build --release --locked -p aether-gateway --target ${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: aether-gateway-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: target/${{ matrix.target }}/release/aether-gateway
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
docker:
|
||||
name: Docker multi-arch
|
||||
needs: [preflight, frontend, build]
|
||||
if: needs.preflight.outputs.publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare dist layout
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp artifacts/aether-gateway-linux-amd64/aether-gateway dist/aether-gateway-amd64
|
||||
cp artifacts/aether-gateway-linux-arm64/aether-gateway dist/aether-gateway-arm64
|
||||
chmod +x dist/aether-gateway-amd64 dist/aether-gateway-arm64
|
||||
cp -r artifacts/frontend-dist dist/frontend
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.GHCR_IMAGE }}
|
||||
docker.io/${{ env.DOCKERHUB_IMAGE }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ needs.preflight.outputs.make_latest == 'true' }}
|
||||
type=raw,value=latest,enable=${{ needs.preflight.outputs.make_latest == 'true' }}
|
||||
type=raw,value=beta,enable=${{ contains(github.ref_name, '-beta.') }}
|
||||
type=raw,value=rc,enable=${{ contains(github.ref_name, '-rc.') }}
|
||||
type=sha,prefix=
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.app
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
package:
|
||||
name: Release tarballs
|
||||
needs: [preflight, frontend, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Build release packages
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
SOURCE_REF="${GITHUB_REF_NAME}"
|
||||
else
|
||||
VERSION="snapshot-${GITHUB_SHA::7}"
|
||||
SOURCE_REF="${GITHUB_SHA}"
|
||||
fi
|
||||
|
||||
mkdir -p package release-assets
|
||||
for platform in linux macos; do
|
||||
for arch in amd64 arm64; do
|
||||
bundle="aether-${VERSION}-${platform}-${arch}"
|
||||
root="package/${bundle}"
|
||||
mkdir -p \
|
||||
"${root}/bin" \
|
||||
"${root}/frontend"
|
||||
|
||||
install -m 0755 "artifacts/aether-gateway-${platform}-${arch}/aether-gateway" "${root}/bin/aether-gateway"
|
||||
cp -R artifacts/frontend-dist/. "${root}/frontend/"
|
||||
sed \
|
||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
||||
install.sh > "${root}/install.sh"
|
||||
chmod 0755 "${root}/install.sh"
|
||||
install -m 0755 update.sh "${root}/update.sh"
|
||||
install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
|
||||
install -m 0644 docker-compose.single-node.yml "${root}/docker-compose.single-node.yml"
|
||||
install -m 0644 .env.example "${root}/.env.example"
|
||||
install -m 0755 generate_keys.sh "${root}/generate_keys.sh"
|
||||
install -m 0644 README.md "${root}/README.md"
|
||||
install -m 0644 LICENSE "${root}/LICENSE"
|
||||
|
||||
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
|
||||
done
|
||||
done
|
||||
|
||||
sed \
|
||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
||||
install.sh > release-assets/install.sh
|
||||
chmod +x release-assets/install.sh
|
||||
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS)
|
||||
|
||||
- name: Upload release package artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: release-assets
|
||||
path: release-assets/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
github-release:
|
||||
name: GitHub Release assets
|
||||
needs: [preflight, docker, package]
|
||||
if: needs.preflight.outputs.publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download release package artifact
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: release-assets
|
||||
path: release-assets
|
||||
|
||||
- name: Delete stale draft releases for tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
draft_ids="$(gh api "repos/${REPOSITORY}/releases" --paginate --jq '.[] | select(.tag_name == env.RELEASE_TAG and .draft == true) | .id')"
|
||||
|
||||
if [[ -z "${draft_ids}" ]]; then
|
||||
echo "No stale draft releases for ${RELEASE_TAG}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r release_id; do
|
||||
[[ -z "${release_id}" ]] && continue
|
||||
echo "Deleting stale draft release ${release_id} for ${RELEASE_TAG}"
|
||||
gh api -X DELETE "repos/${REPOSITORY}/releases/${release_id}"
|
||||
done <<< "${draft_ids}"
|
||||
|
||||
- name: Publish GitHub Release assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ needs.preflight.outputs.prerelease }}
|
||||
make_latest: ${{ needs.preflight.outputs.make_latest }}
|
||||
files: |
|
||||
release-assets/*.tar.gz
|
||||
release-assets/SHA256SUMS
|
||||
release-assets/install.sh
|
||||
@@ -0,0 +1,681 @@
|
||||
name: Rust CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
- "apps/**"
|
||||
- ".github/workflows/rust-ci.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
- "apps/**"
|
||||
- ".github/workflows/rust-ci.yml"
|
||||
|
||||
concurrency:
|
||||
group: rust-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
CARGO_PROFILE_DEV_DEBUG: 0
|
||||
CARGO_PROFILE_TEST_DEBUG: 0
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
fmt:
|
||||
name: Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: rustfmt
|
||||
|
||||
- name: Format
|
||||
run: cargo fmt --all --check
|
||||
|
||||
clippy_gateway:
|
||||
name: Clippy (Gateway)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Clippy
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo clippy -p aether-gateway --lib --bins --examples -- -D warnings
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
clippy_data:
|
||||
name: Clippy (Data)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Clippy
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo clippy -p aether-data --all-targets -- -D warnings
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
clippy_rest:
|
||||
name: Clippy (Workspace Rest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: 1.95.0
|
||||
components: clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Clippy
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo clippy --workspace --exclude aether-gateway --exclude aether-data --exclude aether-integration-tests --all-targets -- -D warnings
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
clippy:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- clippy_gateway
|
||||
- clippy_data
|
||||
- clippy_rest
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Verify clippy jobs
|
||||
run: |
|
||||
if [ "${{ needs.clippy_gateway.result }}" != "success" ] || \
|
||||
[ "${{ needs.clippy_data.result }}" != "success" ] || \
|
||||
[ "${{ needs.clippy_rest.result }}" != "success" ]; then
|
||||
echo "Clippy failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test_gateway:
|
||||
name: Test (Gateway)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Setup mold
|
||||
uses: rui314/setup-mold@v1
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Test lib
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUST_MIN_STACK: "16777216"
|
||||
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
||||
run: cargo nextest run -p aether-gateway --lib
|
||||
|
||||
- name: Test bin
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
RUST_MIN_STACK: "16777216"
|
||||
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
||||
run: cargo nextest run -p aether-gateway --bin aether-gateway
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
test_data:
|
||||
name: Test (Data)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo nextest run -p aether-data
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
check_data_features:
|
||||
name: Check (Data Feature - ${{ matrix.feature }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
feature:
|
||||
- postgres
|
||||
- mysql
|
||||
- sqlite
|
||||
- all-drivers
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Check selected data driver
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo check -p aether-data --no-default-features --features ${{ matrix.feature }}
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
test_rest:
|
||||
name: Test (Workspace Rest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo nextest run --workspace --exclude aether-gateway --exclude aether-data --exclude aether-integration-tests
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
test_data_adapters:
|
||||
name: Test (Data Adapter - ${{ matrix.package }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package:
|
||||
- aether-data-postgres
|
||||
- aether-data-mysql
|
||||
- aether-data-sqlite
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Test adapter
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo nextest run -p ${{ matrix.package }}
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
check_integration_scenarios:
|
||||
name: Test (Integration Scenarios)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Test scenario binaries
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo test -p aether-integration-tests --bins
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test_gateway
|
||||
- test_data
|
||||
- check_data_features
|
||||
- test_rest
|
||||
- test_data_adapters
|
||||
- check_integration_scenarios
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Verify test jobs
|
||||
run: |
|
||||
if [ "${{ needs.test_gateway.result }}" != "success" ] || \
|
||||
[ "${{ needs.test_data.result }}" != "success" ] || \
|
||||
[ "${{ needs.check_data_features.result }}" != "success" ] || \
|
||||
[ "${{ needs.test_rest.result }}" != "success" ] || \
|
||||
[ "${{ needs.test_data_adapters.result }}" != "success" ] || \
|
||||
[ "${{ needs.check_integration_scenarios.result }}" != "success" ]; then
|
||||
echo "Tests failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
data_db_smoke_sqlite:
|
||||
name: Data DB Smoke (SQLite)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Run SQLite data smoke tests
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo test -p aether-data --all-features sqlite --lib
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
data_db_smoke_postgres:
|
||||
name: Data DB Smoke (Postgres)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: aether_test
|
||||
POSTGRES_USER: aether
|
||||
POSTGRES_PASSWORD: aether
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd="pg_isready -h 127.0.0.1 -U aether -d aether_test"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Add PostgreSQL server binaries to PATH
|
||||
run: echo "$(pg_config --bindir)" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run Postgres migration smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||
run: cargo test -p aether-data --all-features postgres_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run Postgres provider metadata migration smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||
run: cargo test -p aether-data --all-features postgres_provider_upstream_metadata_migration_preserves_json_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run Postgres API key lifecycle tests
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_REQUIRE_LOCAL_POSTGRES_TESTS: "true"
|
||||
run: |
|
||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_request_candidates_preserve_deleted_api_key_identity --lib -- --exact --nocapture
|
||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_request_candidate_migration_decouples_legacy_api_key_foreign_key --lib -- --exact --nocapture
|
||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_stats_daily_api_key_migration_decouples_legacy_foreign_key --lib -- --exact --nocapture
|
||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_expired_api_key_cleanup_preserves_historical_identity --lib -- --exact --nocapture
|
||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_api_key_leaderboard_user_filter_preserves_aggregate_history --lib -- --exact --nocapture
|
||||
|
||||
- name: Run Postgres core export smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||
run: cargo test -p aether-data --all-features postgres_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run SQLite-to-Postgres import smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||
run: cargo test -p aether-data --all-features sqlite_core_export_reads_migrated_database_rows --lib -- --nocapture
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
data_db_smoke_mysql:
|
||||
name: Data DB Smoke (MySQL)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
env:
|
||||
MYSQL_DATABASE: aether_test
|
||||
MYSQL_USER: aether
|
||||
MYSQL_PASSWORD: aether
|
||||
MYSQL_ROOT_PASSWORD: aether_root
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -uaether -paether --silent"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Show Rust toolchain
|
||||
run: rustup show active-toolchain
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/[email protected]
|
||||
|
||||
- name: Run MySQL migration smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data --all-features mysql_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL usage write smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data-mysql mysql_usage_write_repository_upserts_and_flushes_counters_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL usage read smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data-mysql mysql_usage_read_repository_reads_usage_contract_views_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL provider catalog smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data-mysql mysql_provider_catalog_repository_round_trips_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL core export smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data --all-features mysql_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL wallet read smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data-mysql mysql_wallet_read_repository_reads_wallet_contract_views --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL wallet daily usage aggregation smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data --all-features mysql_wallet_daily_usage_aggregation_uses_settlement_wallets_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Run MySQL stats aggregation smoke test
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||
run: cargo test -p aether-data --all-features mysql_stats_aggregation_runs_after_mysql_migrations_when_url_is_set --lib -- --nocapture
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
data_db_smoke:
|
||||
name: Data DB Smoke
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- data_db_smoke_sqlite
|
||||
- data_db_smoke_postgres
|
||||
- data_db_smoke_mysql
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Verify database smoke jobs
|
||||
run: |
|
||||
if [ "${{ needs.data_db_smoke_sqlite.result }}" != "success" ] || \
|
||||
[ "${{ needs.data_db_smoke_postgres.result }}" != "success" ] || \
|
||||
[ "${{ needs.data_db_smoke_mysql.result }}" != "success" ]; then
|
||||
echo "Data DB smoke failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
check:
|
||||
name: check
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- fmt
|
||||
- clippy
|
||||
- test
|
||||
- data_db_smoke
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Verify required jobs
|
||||
run: |
|
||||
if [ "${{ needs.fmt.result }}" != "success" ] || \
|
||||
[ "${{ needs.clippy.result }}" != "success" ] || \
|
||||
[ "${{ needs.test.result }}" != "success" ] || \
|
||||
[ "${{ needs.data_db_smoke.result }}" != "success" ]; then
|
||||
echo "Rust CI failed"
|
||||
exit 1
|
||||
fi
|
||||
+24
@@ -1,10 +1,17 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||
|
||||
*.rsa
|
||||
*_rsa
|
||||
|
||||
# AI Assistant Configuration
|
||||
.codex/
|
||||
.claude/
|
||||
.deepseek/
|
||||
.serena/
|
||||
.gemini*/
|
||||
.plans
|
||||
.playwright-mcp/
|
||||
|
||||
### Python ###
|
||||
*.db
|
||||
@@ -211,6 +218,10 @@ backups/
|
||||
|
||||
# Runtime lock files
|
||||
.locks/
|
||||
|
||||
# Local Rust/Cargo configuration
|
||||
.cargo/
|
||||
|
||||
# Demo and test files
|
||||
frontend/public/*-demo.html
|
||||
frontend/public/*-measure.html
|
||||
@@ -219,8 +230,21 @@ frontend/public/*-firework.svg
|
||||
# Debug and experimental files
|
||||
debug_*.html
|
||||
extracted_*.ts
|
||||
test.py
|
||||
|
||||
# Deploy script cache
|
||||
.deps-hash
|
||||
.code-hash
|
||||
.migration-hash
|
||||
.hub-hash
|
||||
|
||||
# Hub prebuilt binaries
|
||||
aether-hub/dist/
|
||||
|
||||
# Version file (auto-generated by hatch-vcs)
|
||||
src/_version.py
|
||||
|
||||
# Analysis folder (third-party code for reference)
|
||||
analysis/
|
||||
new-api/
|
||||
apps/aether-tunnel/aether-tunnel.toml
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[tools]
|
||||
rust = "latest"
|
||||
Generated
+6398
File diff suppressed because it is too large
Load Diff
+158
@@ -0,0 +1,158 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"apps/aether-tunnel",
|
||||
"crates/aether-ai/formats",
|
||||
"crates/aether-admin",
|
||||
"crates/aether-admission-core",
|
||||
"crates/aether-ai/serving",
|
||||
"crates/aether-pool-core",
|
||||
"crates/aether-provider/core",
|
||||
"crates/aether-provider/pool",
|
||||
"crates/aether-routing-core",
|
||||
"crates/aether-data/contracts",
|
||||
"crates/aether-data/adapters/postgres",
|
||||
"crates/aether-data/adapters/mysql",
|
||||
"crates/aether-data/adapters/sqlite",
|
||||
"crates/aether-data/query",
|
||||
"crates/aether-data/schema",
|
||||
"crates/aether-dispatch-core",
|
||||
"crates/aether-cache",
|
||||
"crates/aether-billing",
|
||||
"crates/aether-wallet",
|
||||
"crates/aether-crypto",
|
||||
"crates/aether-contracts",
|
||||
"crates/aether-data/runtime",
|
||||
"crates/aether-model-fetch",
|
||||
"crates/aether-oauth",
|
||||
"crates/aether-provider/transport",
|
||||
"crates/aether-scheduler-core",
|
||||
"crates/aether-runtime/state",
|
||||
"crates/aether-task/runtime",
|
||||
"crates/aether-task/core",
|
||||
"crates/aether-gateway/frontdoor",
|
||||
"crates/aether-gateway/control",
|
||||
"crates/aether-gateway/execution",
|
||||
"crates/aether-gateway/workers",
|
||||
"crates/aether-gateway/tunnel",
|
||||
"crates/aether-testing/loadtools",
|
||||
"crates/aether-testing/integration",
|
||||
"crates/aether-usage/core",
|
||||
"crates/aether-testing/support",
|
||||
"crates/aether-usage/runtime",
|
||||
"crates/aether-video-tasks-core",
|
||||
"apps/aether-gateway",
|
||||
"crates/aether-http",
|
||||
"crates/aether-runtime/base",
|
||||
"crates/aether-testing/testkit",
|
||||
]
|
||||
default-members = [
|
||||
"apps/aether-gateway",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
license = "LicenseRef-Aether-NonCommercial"
|
||||
repository = "https://github.com/fawney19/Aether.git"
|
||||
|
||||
[workspace.dependencies]
|
||||
aether-admin = { path = "crates/aether-admin" }
|
||||
aether-admission-core = { path = "crates/aether-admission-core" }
|
||||
aether-ai-formats = { path = "crates/aether-ai/formats" }
|
||||
aether-ai-serving = { path = "crates/aether-ai/serving" }
|
||||
aether-pool-core = { path = "crates/aether-pool-core" }
|
||||
aether-provider-core = { path = "crates/aether-provider/core" }
|
||||
aether-provider-pool = { path = "crates/aether-provider/pool" }
|
||||
aether-routing-core = { path = "crates/aether-routing-core" }
|
||||
aether-data-contracts = { path = "crates/aether-data/contracts" }
|
||||
aether-data-postgres = { path = "crates/aether-data/adapters/postgres" }
|
||||
aether-data-mysql = { path = "crates/aether-data/adapters/mysql" }
|
||||
aether-data-sqlite = { path = "crates/aether-data/adapters/sqlite" }
|
||||
aether-data-query = { path = "crates/aether-data/query" }
|
||||
aether-data-schema = { path = "crates/aether-data/schema" }
|
||||
aether-dispatch-core = { path = "crates/aether-dispatch-core" }
|
||||
aether-cache = { path = "crates/aether-cache" }
|
||||
aether-billing = { path = "crates/aether-billing" }
|
||||
aether-wallet = { path = "crates/aether-wallet" }
|
||||
aether-crypto = { path = "crates/aether-crypto" }
|
||||
aether-contracts = { path = "crates/aether-contracts" }
|
||||
aether-data = { path = "crates/aether-data/runtime" }
|
||||
aether-model-fetch = { path = "crates/aether-model-fetch" }
|
||||
aether-oauth = { path = "crates/aether-oauth" }
|
||||
aether-provider-transport = { path = "crates/aether-provider/transport" }
|
||||
aether-scheduler-core = { path = "crates/aether-scheduler-core" }
|
||||
aether-runtime-state = { path = "crates/aether-runtime/state" }
|
||||
aether-task-runtime = { path = "crates/aether-task/runtime" }
|
||||
aether-task-core = { path = "crates/aether-task/core" }
|
||||
aether-gateway-frontdoor = { path = "crates/aether-gateway/frontdoor" }
|
||||
aether-gateway-control = { path = "crates/aether-gateway/control" }
|
||||
aether-gateway-execution = { path = "crates/aether-gateway/execution" }
|
||||
aether-gateway-workers = { path = "crates/aether-gateway/workers" }
|
||||
aether-gateway-tunnel = { path = "crates/aether-gateway/tunnel" }
|
||||
aether-loadtools = { path = "crates/aether-testing/loadtools" }
|
||||
aether-integration-tests = { path = "crates/aether-testing/integration" }
|
||||
aether-test-support = { path = "crates/aether-testing/support" }
|
||||
aether-usage-core = { path = "crates/aether-usage/core" }
|
||||
aether-usage-runtime = { path = "crates/aether-usage/runtime" }
|
||||
aether-video-tasks-core = { path = "crates/aether-video-tasks-core" }
|
||||
aether-gateway = { path = "apps/aether-gateway" }
|
||||
aether-http = { path = "crates/aether-http" }
|
||||
aether-runtime = { path = "crates/aether-runtime/base" }
|
||||
aether-testkit = { path = "crates/aether-testing/testkit" }
|
||||
aes = "0.8"
|
||||
aes-gcm = "0.10"
|
||||
async-stream = "0.3"
|
||||
async-trait = "0.1"
|
||||
axum = "0.8"
|
||||
base64 = "0.22"
|
||||
bcrypt = "0.16"
|
||||
bytes = "1"
|
||||
cbc = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
crypto_box = { version = "0.9", features = ["seal"] }
|
||||
ed25519-dalek = { version = "2.2", features = ["pkcs8"] }
|
||||
flate2 = "1"
|
||||
futures-util = "0.3"
|
||||
hmac = "0.12"
|
||||
http = "1"
|
||||
object_store = { version = "0.12", default-features = false, features = ["aws"] }
|
||||
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "http2", "socks"] }
|
||||
redis = { version = "0.28", default-features = false, features = ["tokio-comp", "script", "streams", "connection-manager"] }
|
||||
regex = "1"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
semver = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
serde_path_to_error = "0.1"
|
||||
sha2 = "0.10"
|
||||
socket2 = "0.6"
|
||||
tar = "0.4"
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "chrono"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
tokio-util = { version = "0.7", features = ["codec", "io-util"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
uuid = { version = "1", features = ["serde", "v4", "v5"] }
|
||||
webpki-roots = "0.26"
|
||||
wreq = { version = "6.0.0-rc.28", default-features = false, features = ["json", "stream", "socks", "webpki-roots", "ws"] }
|
||||
wreq-util = "3.0.0-rc.10"
|
||||
url = "2"
|
||||
zstd = "0.13"
|
||||
|
||||
[profile.dev]
|
||||
# Keep file/line information for backtraces while avoiding full debug info
|
||||
# generation on very large crates during local development builds.
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.test]
|
||||
# The gateway test target pulls in a very large in-crate test tree, so use the
|
||||
# lighter debug format here as well to reduce rustc peak memory.
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = true
|
||||
codegen-units = 8
|
||||
+30
-121
@@ -1,134 +1,43 @@
|
||||
# 运行镜像:从 base 提取产物到精简运行时
|
||||
# 构建命令: docker build -f Dockerfile.app -t aether-app:latest .
|
||||
# 用于 GitHub Actions CI(官方源)
|
||||
FROM aether-base:latest AS builder
|
||||
# syntax=docker/dockerfile:1
|
||||
# Aether Gateway runtime image (cross-compilation)
|
||||
# Binary and frontend assets are pre-built by CI; this Dockerfile only packages them.
|
||||
# Usage: docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.app .
|
||||
#
|
||||
# Build context must contain:
|
||||
# dist/aether-gateway-amd64 (x86_64-unknown-linux-musl cross-compiled binary)
|
||||
# dist/aether-gateway-arm64 (aarch64-unknown-linux-musl cross-compiled binary)
|
||||
# dist/frontend/ (npm run build output)
|
||||
|
||||
WORKDIR /app
|
||||
# --- layout stage: create /opt/aether directory structure with symlink ---
|
||||
# distroless has no shell, so we use busybox to set up the symlink.
|
||||
FROM busybox:1.37-musl AS layout
|
||||
|
||||
# 复制前端源码并构建
|
||||
COPY frontend/ ./frontend/
|
||||
RUN cd frontend && npm run build
|
||||
ARG TARGETARCH
|
||||
|
||||
# ==================== 运行时镜像 ====================
|
||||
FROM python:3.12-slim
|
||||
RUN mkdir -p /opt/aether/releases/image/bin /opt/aether/releases/image/frontend /opt/aether/logs
|
||||
|
||||
WORKDIR /app
|
||||
COPY dist/aether-gateway-${TARGETARCH} /opt/aether/releases/image/bin/aether-gateway
|
||||
RUN chmod 0755 /opt/aether/releases/image/bin/aether-gateway
|
||||
COPY dist/frontend/ /opt/aether/releases/image/frontend/
|
||||
|
||||
# 运行时依赖(无 gcc/nodejs/npm)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
nginx \
|
||||
supervisor \
|
||||
libpq5 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN ln -s /opt/aether/releases/image /opt/aether/current
|
||||
|
||||
# 从 base 镜像复制 Python 包
|
||||
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
# --- final stage: distroless runtime ---
|
||||
FROM gcr.io/distroless/static-debian12
|
||||
|
||||
# 只复制需要的 Python 可执行文件
|
||||
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
|
||||
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
|
||||
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
|
||||
COPY --from=layout /opt/aether /opt/aether
|
||||
|
||||
# 从 builder 阶段复制前端构建产物
|
||||
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
|
||||
WORKDIR /opt/aether
|
||||
|
||||
# 复制后端代码
|
||||
COPY src/ ./src/
|
||||
COPY alembic.ini ./
|
||||
COPY alembic/ ./alembic/
|
||||
ENV RUST_LOG=aether_gateway=info \
|
||||
APP_PORT=8084 \
|
||||
AETHER_UPDATE_STRATEGY=docker \
|
||||
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
|
||||
|
||||
# Nginx 配置模板
|
||||
RUN printf '%s\n' \
|
||||
'server {' \
|
||||
' listen 80;' \
|
||||
' server_name _;' \
|
||||
' root /usr/share/nginx/html;' \
|
||||
' index index.html;' \
|
||||
' client_max_body_size 100M;' \
|
||||
'' \
|
||||
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
||||
' expires 1y;' \
|
||||
' add_header Cache-Control "public, no-transform";' \
|
||||
' try_files $uri =404;' \
|
||||
' }' \
|
||||
'' \
|
||||
' location ~ ^/(src|node_modules)/ {' \
|
||||
' deny all;' \
|
||||
' return 404;' \
|
||||
' }' \
|
||||
'' \
|
||||
' location ~ ^/(dashboard|admin|login)(/|$) {' \
|
||||
' try_files $uri $uri/ /index.html;' \
|
||||
' }' \
|
||||
'' \
|
||||
' location / {' \
|
||||
' try_files $uri $uri/ @backend;' \
|
||||
' }' \
|
||||
'' \
|
||||
' location @backend {' \
|
||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||
' proxy_http_version 1.1;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $remote_addr;' \
|
||||
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' proxy_set_header Connection "";' \
|
||||
' proxy_set_header Accept $http_accept;' \
|
||||
' proxy_set_header Content-Type $content_type;' \
|
||||
' proxy_set_header Authorization $http_authorization;' \
|
||||
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
||||
' proxy_buffering off;' \
|
||||
' proxy_cache off;' \
|
||||
' proxy_request_buffering off;' \
|
||||
' chunked_transfer_encoding on;' \
|
||||
' gzip off;' \
|
||||
' add_header X-Accel-Buffering no;' \
|
||||
' proxy_connect_timeout 600s;' \
|
||||
' proxy_send_timeout 600s;' \
|
||||
' proxy_read_timeout 600s;' \
|
||||
' }' \
|
||||
'}' > /etc/nginx/sites-available/default.template
|
||||
|
||||
# Supervisor 配置
|
||||
RUN printf '%s\n' \
|
||||
'[supervisord]' \
|
||||
'nodaemon=true' \
|
||||
'logfile=/var/log/supervisor/supervisord.log' \
|
||||
'pidfile=/var/run/supervisord.pid' \
|
||||
'' \
|
||||
'[program:nginx]' \
|
||||
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/var/log/nginx/access.log' \
|
||||
'stderr_logfile=/var/log/nginx/error.log' \
|
||||
'' \
|
||||
'[program:app]' \
|
||||
'command=gunicorn src.main:app -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --access-logfile - --error-logfile - --log-level info' \
|
||||
'directory=/app' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/dev/stdout' \
|
||||
'stdout_logfile_maxbytes=0' \
|
||||
'stderr_logfile=/dev/stderr' \
|
||||
'stderr_logfile_maxbytes=0' \
|
||||
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
# 创建目录
|
||||
RUN mkdir -p /var/log/supervisor /app/logs /app/data
|
||||
|
||||
# 环境变量
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONIOENCODING=utf-8 \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
PORT=8084
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 8084
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost/health || exit 1
|
||||
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
USER root
|
||||
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
|
||||
|
||||
+139
-123
@@ -1,135 +1,151 @@
|
||||
# 运行镜像:从 base 提取产物到精简运行时(国内镜像源版本)
|
||||
# 构建命令: docker build -f Dockerfile.app.local -t aether-app:latest .
|
||||
# 用于本地/国内服务器部署
|
||||
FROM aether-base:latest AS builder
|
||||
# syntax=docker.m.daocloud.io/docker/dockerfile:1
|
||||
# Aether 运行镜像:Rust gateway 直接服务 API + 前端静态文件(国内镜像源版本)
|
||||
# 构建命令: docker build --build-arg AETHER_BUILD_VERSION=v0.7.2 -f Dockerfile.app.local -t aether-app:latest .
|
||||
|
||||
WORKDIR /app
|
||||
ARG RUST_VERSION=1.95.0
|
||||
ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-slim
|
||||
ARG RUST_BASE_IMAGE=docker.m.daocloud.io/library/rust:${RUST_VERSION}-slim
|
||||
|
||||
# 复制前端源码并构建
|
||||
COPY frontend/ ./frontend/
|
||||
RUN cd frontend && npm run build
|
||||
# ==================== 前端构建 ====================
|
||||
FROM ${NODE_BASE_IMAGE} AS frontend-builder
|
||||
ARG AETHER_BUILD_VERSION
|
||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
|
||||
npm config set registry https://registry.npmmirror.com && \
|
||||
npm ci --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ==================== Rust gateway 构建 ====================
|
||||
FROM ${RUST_BASE_IMAGE} AS gateway-base
|
||||
WORKDIR /build
|
||||
|
||||
# 生产级 release 构建:保留 thin LTO,同时用 lld 缩短最终链接阶段。
|
||||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
||||
CARGO_PROFILE_RELEASE_LTO=thin \
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 \
|
||||
RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=lld"
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
clang \
|
||||
cmake \
|
||||
git \
|
||||
libclang-dev \
|
||||
libssl-dev \
|
||||
lld \
|
||||
pkg-config \
|
||||
perl
|
||||
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
cargo install cargo-chef --locked
|
||||
|
||||
FROM gateway-base AS gateway-planner
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY apps/ ./apps/
|
||||
COPY crates/ ./crates/
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM gateway-base AS gateway-builder
|
||||
ARG AETHER_BUILD_VERSION
|
||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
||||
COPY --from=gateway-planner /build/recipe.json ./recipe.json
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
|
||||
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --features jemalloc --recipe-path recipe.json
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY apps/ ./apps/
|
||||
COPY crates/ ./crates/
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
|
||||
set -eux; \
|
||||
cargo build --release --locked -p aether-gateway --bin aether-gateway --features jemalloc; \
|
||||
cp target/release/aether-gateway /tmp/aether-gateway
|
||||
|
||||
# ==================== 最小运行时打包 ====================
|
||||
FROM gateway-builder AS runtime-prep
|
||||
RUN set -eux; \
|
||||
mkdir -p \
|
||||
/runtime-root/app/data \
|
||||
/runtime-root/app/logs \
|
||||
/runtime-root/etc \
|
||||
/runtime-root/etc/ssl \
|
||||
/runtime-root/lib \
|
||||
/runtime-root/lib64 \
|
||||
/runtime-root/usr/local/bin; \
|
||||
cp /tmp/aether-gateway /runtime-root/usr/local/bin/aether-gateway; \
|
||||
: > /tmp/runtime-libs.txt; \
|
||||
: > /tmp/runtime-scan-queue.txt; \
|
||||
printf '%s\n' /tmp/aether-gateway >> /tmp/runtime-scan-queue.txt; \
|
||||
while [ -s /tmp/runtime-scan-queue.txt ]; do \
|
||||
current="$(head -n1 /tmp/runtime-scan-queue.txt)"; \
|
||||
sed -i '1d' /tmp/runtime-scan-queue.txt; \
|
||||
ldd "$current" | awk '/=>/ { print $3 } $1 ~ /^\// { print $1 }' | while read -r lib; do \
|
||||
[ -n "$lib" ]; \
|
||||
if ! grep -Fxq "$lib" /tmp/runtime-libs.txt; then \
|
||||
printf '%s\n' "$lib" >> /tmp/runtime-libs.txt; \
|
||||
printf '%s\n' "$lib" >> /tmp/runtime-scan-queue.txt; \
|
||||
fi; \
|
||||
done; \
|
||||
done; \
|
||||
sort -u /tmp/runtime-libs.txt -o /tmp/runtime-libs.txt; \
|
||||
while read -r lib; do \
|
||||
[ -n "$lib" ]; \
|
||||
dest="/runtime-root$(dirname "$lib")"; \
|
||||
mkdir -p "$dest"; \
|
||||
cp -L "$lib" "$dest/"; \
|
||||
done < /tmp/runtime-libs.txt; \
|
||||
for lib in \
|
||||
/lib/x86_64-linux-gnu/libnss_dns.so.2 \
|
||||
/lib/x86_64-linux-gnu/libnss_files.so.2 \
|
||||
/lib/x86_64-linux-gnu/libresolv.so.2; do \
|
||||
if [ -f "$lib" ]; then \
|
||||
dest="/runtime-root$(dirname "$lib")"; \
|
||||
mkdir -p "$dest"; \
|
||||
cp -L "$lib" "$dest/"; \
|
||||
fi; \
|
||||
done; \
|
||||
cp -a /usr/lib/ssl /runtime-root/usr/lib/; \
|
||||
cp -a /etc/ssl/certs /runtime-root/etc/ssl/; \
|
||||
if [ -f /etc/ssl/openssl.cnf ]; then \
|
||||
cp /etc/ssl/openssl.cnf /runtime-root/etc/ssl/openssl.cnf; \
|
||||
fi; \
|
||||
if [ -f /etc/nsswitch.conf ]; then \
|
||||
cp /etc/nsswitch.conf /runtime-root/etc/nsswitch.conf; \
|
||||
fi
|
||||
|
||||
# ==================== 运行时镜像 ====================
|
||||
FROM python:3.12-slim
|
||||
FROM scratch
|
||||
|
||||
# 复制 gateway 二进制
|
||||
COPY --from=runtime-prep /runtime-root/ /
|
||||
|
||||
# 复制前端构建产物
|
||||
COPY --from=frontend-builder /app/frontend/dist /srv/frontend
|
||||
WORKDIR /app
|
||||
|
||||
# 运行时依赖(使用清华镜像源)
|
||||
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y \
|
||||
nginx \
|
||||
supervisor \
|
||||
libpq5 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 从 base 镜像复制 Python 包
|
||||
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
|
||||
# 只复制需要的 Python 可执行文件
|
||||
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
|
||||
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
|
||||
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
|
||||
|
||||
# 从 builder 阶段复制前端构建产物
|
||||
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
|
||||
|
||||
# 复制后端代码
|
||||
COPY src/ ./src/
|
||||
COPY alembic.ini ./
|
||||
COPY alembic/ ./alembic/
|
||||
|
||||
# Nginx 配置模板
|
||||
RUN printf '%s\n' \
|
||||
'server {' \
|
||||
' listen 80;' \
|
||||
' server_name _;' \
|
||||
' root /usr/share/nginx/html;' \
|
||||
' index index.html;' \
|
||||
' client_max_body_size 100M;' \
|
||||
'' \
|
||||
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
||||
' expires 1y;' \
|
||||
' add_header Cache-Control "public, no-transform";' \
|
||||
' try_files $uri =404;' \
|
||||
' }' \
|
||||
'' \
|
||||
' location ~ ^/(src|node_modules)/ {' \
|
||||
' deny all;' \
|
||||
' return 404;' \
|
||||
' }' \
|
||||
'' \
|
||||
' location ~ ^/(dashboard|admin|login)(/|$) {' \
|
||||
' try_files $uri $uri/ /index.html;' \
|
||||
' }' \
|
||||
'' \
|
||||
' location / {' \
|
||||
' try_files $uri $uri/ @backend;' \
|
||||
' }' \
|
||||
'' \
|
||||
' location @backend {' \
|
||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||
' proxy_http_version 1.1;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $remote_addr;' \
|
||||
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' proxy_set_header Connection "";' \
|
||||
' proxy_set_header Accept $http_accept;' \
|
||||
' proxy_set_header Content-Type $content_type;' \
|
||||
' proxy_set_header Authorization $http_authorization;' \
|
||||
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
||||
' proxy_buffering off;' \
|
||||
' proxy_cache off;' \
|
||||
' proxy_request_buffering off;' \
|
||||
' chunked_transfer_encoding on;' \
|
||||
' gzip off;' \
|
||||
' add_header X-Accel-Buffering no;' \
|
||||
' proxy_connect_timeout 600s;' \
|
||||
' proxy_send_timeout 600s;' \
|
||||
' proxy_read_timeout 600s;' \
|
||||
' }' \
|
||||
'}' > /etc/nginx/sites-available/default.template
|
||||
|
||||
# Supervisor 配置
|
||||
RUN printf '%s\n' \
|
||||
'[supervisord]' \
|
||||
'nodaemon=true' \
|
||||
'logfile=/var/log/supervisor/supervisord.log' \
|
||||
'pidfile=/var/run/supervisord.pid' \
|
||||
'' \
|
||||
'[program:nginx]' \
|
||||
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/var/log/nginx/access.log' \
|
||||
'stderr_logfile=/var/log/nginx/error.log' \
|
||||
'' \
|
||||
'[program:app]' \
|
||||
'command=gunicorn src.main:app -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --access-logfile - --error-logfile - --log-level info' \
|
||||
'directory=/app' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/dev/stdout' \
|
||||
'stdout_logfile_maxbytes=0' \
|
||||
'stderr_logfile=/dev/stderr' \
|
||||
'stderr_logfile_maxbytes=0' \
|
||||
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
# 创建目录
|
||||
RUN mkdir -p /var/log/supervisor /app/logs /app/data
|
||||
|
||||
# 环境变量
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONIOENCODING=utf-8 \
|
||||
LANG=C.UTF-8 \
|
||||
ENV LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
PORT=8084
|
||||
RUST_LOG=aether_gateway=info \
|
||||
APP_PORT=8084 \
|
||||
AETHER_UPDATE_STRATEGY=manual \
|
||||
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 8084
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost/health || exit 1
|
||||
CMD ["/usr/local/bin/aether-gateway", "--healthcheck"]
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
ENTRYPOINT ["/usr/local/bin/aether-gateway"]
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# syntax=docker.m.daocloud.io/docker/dockerfile:1
|
||||
# Aether 本地发布版联调镜像
|
||||
# 作用:用当前源码构建一个 release-layout 容器,专门测试管理后台在线更新流程。
|
||||
|
||||
ARG RUST_VERSION=1.95.0
|
||||
ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-slim
|
||||
ARG RUST_BASE_IMAGE=docker.m.daocloud.io/library/rust:${RUST_VERSION}-slim
|
||||
|
||||
# ==================== 前端构建 ====================
|
||||
FROM ${NODE_BASE_IMAGE} AS frontend-builder
|
||||
ARG AETHER_BUILD_VERSION
|
||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
|
||||
npm config set registry https://registry.npmmirror.com && \
|
||||
npm ci --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ==================== Rust gateway 构建 ====================
|
||||
FROM ${RUST_BASE_IMAGE} AS gateway-base
|
||||
WORKDIR /build
|
||||
|
||||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
||||
CARGO_PROFILE_RELEASE_LTO=thin \
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
cmake \
|
||||
git \
|
||||
libclang-dev \
|
||||
libssl-dev \
|
||||
pkg-config \
|
||||
perl
|
||||
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
cargo install cargo-chef --locked
|
||||
|
||||
FROM gateway-base AS gateway-planner
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY apps/ ./apps/
|
||||
COPY crates/ ./crates/
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM gateway-base AS gateway-builder
|
||||
ARG AETHER_BUILD_VERSION
|
||||
ARG AETHER_BUILD_TYPE=release
|
||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_VERSION=${AETHER_BUILD_VERSION} \
|
||||
AETHER_BUILD_TYPE=${AETHER_BUILD_TYPE}
|
||||
COPY --from=gateway-planner /build/recipe.json ./recipe.json
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
|
||||
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --features jemalloc --recipe-path recipe.json
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY apps/ ./apps/
|
||||
COPY crates/ ./crates/
|
||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
|
||||
cargo build --release --locked -p aether-gateway --features jemalloc && \
|
||||
cp target/release/aether-gateway /tmp/aether-gateway
|
||||
|
||||
# ==================== 最小运行时打包 ====================
|
||||
FROM gateway-builder AS runtime-prep
|
||||
RUN set -eux; \
|
||||
mkdir -p \
|
||||
/runtime-root/app/data \
|
||||
/runtime-root/etc \
|
||||
/runtime-root/etc/ssl \
|
||||
/runtime-root/lib \
|
||||
/runtime-root/lib64 \
|
||||
/runtime-root/usr/lib \
|
||||
/runtime-root/opt/aether/logs \
|
||||
/runtime-root/opt/aether/releases/image/bin \
|
||||
/runtime-root/opt/aether/releases/image/frontend; \
|
||||
cp /tmp/aether-gateway /runtime-root/opt/aether/releases/image/bin/aether-gateway; \
|
||||
ln -s /opt/aether/releases/image /runtime-root/opt/aether/current; \
|
||||
: > /tmp/runtime-libs.txt; \
|
||||
: > /tmp/runtime-scan-queue.txt; \
|
||||
printf '%s\n' /tmp/aether-gateway >> /tmp/runtime-scan-queue.txt; \
|
||||
while [ -s /tmp/runtime-scan-queue.txt ]; do \
|
||||
current="$(head -n1 /tmp/runtime-scan-queue.txt)"; \
|
||||
sed -i '1d' /tmp/runtime-scan-queue.txt; \
|
||||
ldd "$current" | awk '/=>/ { print $3 } $1 ~ /^\// { print $1 }' | while read -r lib; do \
|
||||
[ -n "$lib" ]; \
|
||||
if ! grep -Fxq "$lib" /tmp/runtime-libs.txt; then \
|
||||
printf '%s\n' "$lib" >> /tmp/runtime-libs.txt; \
|
||||
printf '%s\n' "$lib" >> /tmp/runtime-scan-queue.txt; \
|
||||
fi; \
|
||||
done; \
|
||||
done; \
|
||||
sort -u /tmp/runtime-libs.txt -o /tmp/runtime-libs.txt; \
|
||||
while read -r lib; do \
|
||||
[ -n "$lib" ]; \
|
||||
dest="/runtime-root$(dirname "$lib")"; \
|
||||
mkdir -p "$dest"; \
|
||||
cp -L "$lib" "$dest/"; \
|
||||
done < /tmp/runtime-libs.txt; \
|
||||
for lib in \
|
||||
/lib/x86_64-linux-gnu/libnss_dns.so.2 \
|
||||
/lib/x86_64-linux-gnu/libnss_files.so.2 \
|
||||
/lib/x86_64-linux-gnu/libresolv.so.2; do \
|
||||
if [ -f "$lib" ]; then \
|
||||
dest="/runtime-root$(dirname "$lib")"; \
|
||||
mkdir -p "$dest"; \
|
||||
cp -L "$lib" "$dest/"; \
|
||||
fi; \
|
||||
done; \
|
||||
cp -a /usr/lib/ssl /runtime-root/usr/lib/; \
|
||||
cp -a /etc/ssl/certs /runtime-root/etc/ssl/; \
|
||||
if [ -f /etc/ssl/openssl.cnf ]; then \
|
||||
cp /etc/ssl/openssl.cnf /runtime-root/etc/ssl/openssl.cnf; \
|
||||
fi; \
|
||||
if [ -f /etc/nsswitch.conf ]; then \
|
||||
cp /etc/nsswitch.conf /runtime-root/etc/nsswitch.conf; \
|
||||
fi
|
||||
COPY --from=frontend-builder /app/frontend/dist /runtime-root/opt/aether/releases/image/frontend
|
||||
|
||||
# ==================== 运行时镜像 ====================
|
||||
FROM scratch
|
||||
|
||||
COPY --from=runtime-prep /runtime-root/ /
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
RUST_LOG=aether_gateway=info \
|
||||
APP_PORT=8084 \
|
||||
AETHER_BASE_DIR=/opt/aether \
|
||||
AETHER_UPDATE_STRATEGY=self \
|
||||
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
|
||||
|
||||
EXPOSE 8084
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
|
||||
@@ -1,25 +0,0 @@
|
||||
# 构建镜像:编译环境 + 预编译的依赖
|
||||
# 用于 GitHub Actions CI 构建(不使用国内镜像源)
|
||||
# 构建命令: docker build -f Dockerfile.base -t aether-base:latest .
|
||||
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 构建工具
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python 依赖
|
||||
COPY pyproject.toml README.md ./
|
||||
RUN mkdir -p src && touch src/__init__.py && \
|
||||
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir . && \
|
||||
pip cache purge
|
||||
|
||||
# 前端依赖(只安装,不构建)
|
||||
COPY frontend/package*.json ./frontend/
|
||||
RUN cd frontend && npm ci
|
||||
@@ -1,28 +0,0 @@
|
||||
# 构建镜像:编译环境 + 预编译的依赖(国内镜像源版本)
|
||||
# 构建命令: docker build -f Dockerfile.base.local -t aether-base:latest .
|
||||
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 构建工具(使用清华镜像源)
|
||||
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# pip 镜像源
|
||||
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# Python 依赖
|
||||
COPY pyproject.toml README.md ./
|
||||
RUN mkdir -p src && touch src/__init__.py && \
|
||||
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir . && \
|
||||
pip cache purge
|
||||
|
||||
# 前端依赖(只安装,不构建,使用淘宝镜像源)
|
||||
COPY frontend/package*.json ./frontend/
|
||||
RUN cd frontend && npm config set registry https://registry.npmmirror.com && npm ci
|
||||
@@ -5,12 +5,17 @@ Aether 非商业开源许可证
|
||||
特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、
|
||||
复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件:
|
||||
|
||||
1. 仅限非商业用途
|
||||
本软件不得用于商业目的。商业目的包括但不限于:
|
||||
1. 仅限非盈利用途
|
||||
本软件不得用于盈利目的。盈利目的包括但不限于:
|
||||
- 出售本软件或任何衍生作品
|
||||
- 使用本软件提供付费服务
|
||||
- 将本软件用于商业产品或服务
|
||||
- 将本软件用于任何旨在获取商业利益或金钱报酬的活动
|
||||
- 将本软件用于以盈利为目的的商业产品或服务
|
||||
|
||||
以下用途被明确允许:
|
||||
- 个人学习和研究
|
||||
- 教育机构的教学和研究
|
||||
- 非盈利组织的内部使用
|
||||
- 企业内部非盈利性质的使用(如内部工具、测试环境等)
|
||||
|
||||
2. 署名要求
|
||||
上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。
|
||||
@@ -22,7 +27,7 @@ Aether 非商业开源许可证
|
||||
您不得以不同的条款将本软件再许可给他人。
|
||||
|
||||
5. 商业许可
|
||||
如需商业使用,请联系版权持有人以获取单独的商业许可。
|
||||
如需将本软件用于盈利目的,请联系版权持有人以获取单独的商业许可。
|
||||
|
||||
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
|
||||
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
DEV_RUST_LOG := info,executor::candidate_loop=debug,stream::execution=debug
|
||||
ifeq ($(origin RUST_LOG), command line)
|
||||
DEV_RUST_LOG := $(RUST_LOG)
|
||||
endif
|
||||
export DEV_RUST_LOG
|
||||
|
||||
.PHONY: dev dev-backend dev-frontend migration backfill
|
||||
|
||||
define DEV_BACKEND_SCRIPT
|
||||
set -euo pipefail
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "=> 未找到 .env,请先执行: cp .env.example .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
dotenv_has_key() {
|
||||
local key="$$1"
|
||||
grep -Eq "^[[:space:]]*$${key}=" .env
|
||||
}
|
||||
|
||||
lowercase() {
|
||||
printf '%s' "$$1" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
dev_uses_sqlite_database() {
|
||||
local driver
|
||||
local url
|
||||
driver="$$(lowercase "$${AETHER_DATABASE_DRIVER:-}")"
|
||||
url="$${AETHER_DATABASE_URL:-$${DATABASE_URL:-}}"
|
||||
|
||||
[[ "$${driver}" == "sqlite" || "$${url}" == sqlite:* ]]
|
||||
}
|
||||
|
||||
dev_uses_postgres_database() {
|
||||
local driver
|
||||
local url
|
||||
driver="$$(lowercase "$${AETHER_DATABASE_DRIVER:-}")"
|
||||
url="$${AETHER_DATABASE_URL:-$${DATABASE_URL:-}}"
|
||||
|
||||
if [[ -z "$${driver}" && -z "$${url}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$${driver}" == "postgres" || "$${driver}" == "postgresql" || "$${url}" == postgres:* || "$${url}" == postgresql:* ]]
|
||||
}
|
||||
|
||||
dev_uses_redis_runtime() {
|
||||
local backend
|
||||
backend="$$(lowercase "$${AETHER_RUNTIME_BACKEND:-}")"
|
||||
|
||||
if [[ "$${backend}" == "memory" ]]; then
|
||||
return 1
|
||||
fi
|
||||
if [[ "$${backend}" == "redis" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if dev_uses_sqlite_database; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
print_dev_infra_hint() {
|
||||
echo "=> 本地开发依赖未就绪。"
|
||||
echo "=> 可手动启动 Postgres / Redis:"
|
||||
echo "=> docker compose up -d postgres redis"
|
||||
}
|
||||
|
||||
check_postgres_ready() {
|
||||
local host="$$1"
|
||||
local port="$$2"
|
||||
|
||||
if command -v pg_isready >/dev/null 2>&1; then
|
||||
pg_isready -h "$${host}" -p "$${port}" >/dev/null 2>&1
|
||||
return $$?
|
||||
fi
|
||||
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
nc -z "$${host}" "$${port}" >/dev/null 2>&1
|
||||
return $$?
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
check_redis_ready() {
|
||||
local host="$$1"
|
||||
local port="$$2"
|
||||
local password="$$3"
|
||||
|
||||
if command -v redis-cli >/dev/null 2>&1; then
|
||||
REDISCLI_AUTH="$${password}" redis-cli -h "$${host}" -p "$${port}" ping >/dev/null 2>&1
|
||||
return $$?
|
||||
fi
|
||||
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
nc -z "$${host}" "$${port}" >/dev/null 2>&1
|
||||
return $$?
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
is_local_host() {
|
||||
case "$$1" in
|
||||
localhost|127.0.0.1|::1)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_dev_infra() {
|
||||
local postgres_host="$${DB_HOST:-localhost}"
|
||||
local postgres_port="$${DB_PORT:-5432}"
|
||||
local redis_host="$${REDIS_HOST:-localhost}"
|
||||
local redis_port="$${REDIS_PORT:-6379}"
|
||||
local redis_password="$${REDIS_PASSWORD:-}"
|
||||
local need_postgres=false
|
||||
local need_redis=false
|
||||
local services=()
|
||||
|
||||
if dev_uses_postgres_database; then
|
||||
if ! check_postgres_ready "$${postgres_host}" "$${postgres_port}"; then
|
||||
if is_local_host "$${postgres_host}"; then
|
||||
need_postgres=true
|
||||
services+=(postgres)
|
||||
else
|
||||
echo "=> PostgreSQL 不可用: $${postgres_host}:$${postgres_port}"
|
||||
print_dev_infra_hint
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if dev_uses_redis_runtime; then
|
||||
if ! check_redis_ready "$${redis_host}" "$${redis_port}" "$${redis_password}"; then
|
||||
if is_local_host "$${redis_host}"; then
|
||||
need_redis=true
|
||||
services+=(redis)
|
||||
else
|
||||
echo "=> Redis 不可用: $${redis_host}:$${redis_port}"
|
||||
print_dev_infra_hint
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$${#services[@]}" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "=> 未找到 docker,无法自动启动本地开发依赖。"
|
||||
print_dev_infra_hint
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "=> 本地开发依赖未就绪,正在启动: docker compose up -d $${services[*]}"
|
||||
if ! docker compose up -d "$${services[@]}"; then
|
||||
echo "=> docker compose 启动本地开发依赖失败。"
|
||||
print_dev_infra_hint
|
||||
return 1
|
||||
fi
|
||||
|
||||
for _ in {1..100}; do
|
||||
local ready=true
|
||||
if [ "$${need_postgres}" = "true" ] && ! check_postgres_ready "$${postgres_host}" "$${postgres_port}"; then
|
||||
ready=false
|
||||
fi
|
||||
if [ "$${need_redis}" = "true" ] && ! check_redis_ready "$${redis_host}" "$${redis_port}" "$${redis_password}"; then
|
||||
ready=false
|
||||
fi
|
||||
if [ "$${ready}" = "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
if [ "$${need_postgres}" = "true" ] && ! check_postgres_ready "$${postgres_host}" "$${postgres_port}"; then
|
||||
echo "=> PostgreSQL 不可用: $${postgres_host}:$${postgres_port}"
|
||||
fi
|
||||
if [ "$${need_redis}" = "true" ] && ! check_redis_ready "$${redis_host}" "$${redis_port}" "$${redis_password}"; then
|
||||
echo "=> Redis 不可用: $${redis_host}:$${redis_port}"
|
||||
fi
|
||||
print_dev_infra_hint
|
||||
return 1
|
||||
}
|
||||
|
||||
print_startup_failure_hint() {
|
||||
local log_file="$$1"
|
||||
|
||||
if [ -n "$${log_file}" ] && [ -f "$${log_file}" ]; then
|
||||
if grep -Eq "database schema is behind" "$${log_file}"; then
|
||||
echo "=> 检测到数据库 schema 落后,请执行: make migration"
|
||||
return
|
||||
fi
|
||||
|
||||
if grep -Eq "database backfills are behind" "$${log_file}"; then
|
||||
echo "=> 检测到待执行 backfills,请执行: make backfill"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=> 未识别到明确的修复动作,请根据上面的日志继续排查。"
|
||||
}
|
||||
|
||||
wait_for_startup() {
|
||||
local pid="$$1"
|
||||
local timeout_seconds="$$2"
|
||||
local service_name="$$3"
|
||||
shift 3
|
||||
|
||||
STARTUP_WAIT_EARLY_EXIT=false
|
||||
|
||||
local attempts=$$((timeout_seconds * 10))
|
||||
if [ "$${attempts}" -lt 1 ]; then
|
||||
attempts=1
|
||||
fi
|
||||
|
||||
for ((i = 0; i < attempts; i++)); do
|
||||
if "$$@" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$${pid}" >/dev/null 2>&1; then
|
||||
STARTUP_WAIT_EARLY_EXIT=true
|
||||
echo "=> $${service_name} 启动进程已提前退出,请检查上面的日志。"
|
||||
print_startup_failure_hint "$${GATEWAY_LOG_FILE}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
if "$$@" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$${pid}" >/dev/null 2>&1; then
|
||||
STARTUP_WAIT_EARLY_EXIT=true
|
||||
echo "=> $${service_name} 启动进程已提前退出,请检查上面的日志。"
|
||||
print_startup_failure_hint "$${GATEWAY_LOG_FILE}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "=> $${service_name} 在 $${timeout_seconds}s 内未通过启动检查。"
|
||||
echo "=> 如果这是冷编译或存在并发 cargo 构建,可调大启动超时后重试。"
|
||||
return 1
|
||||
}
|
||||
|
||||
create_gateway_log_file() {
|
||||
local tmp_root="$${TMPDIR:-/tmp}"
|
||||
tmp_root="$${tmp_root%/}"
|
||||
|
||||
GATEWAY_LOG_DIR="$$(mktemp -d "$${tmp_root}/aether-dev-startup.XXXXXX")"
|
||||
GATEWAY_LOG_FILE="$${GATEWAY_LOG_DIR}/gateway.log"
|
||||
: > "$${GATEWAY_LOG_FILE}"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local status="$${1:-0}"
|
||||
trap - INT TERM EXIT
|
||||
|
||||
if [ -n "$${GATEWAY_PID:-}" ]; then
|
||||
echo ""
|
||||
echo "=> 停止 aether-gateway..."
|
||||
kill "$${GATEWAY_PID}" >/dev/null 2>&1 || true
|
||||
wait "$${GATEWAY_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [ -n "$${GATEWAY_LOG_FILE:-}" ] && [ -f "$${GATEWAY_LOG_FILE}" ]; then
|
||||
rm -f "$${GATEWAY_LOG_FILE}"
|
||||
fi
|
||||
|
||||
if [ -n "$${GATEWAY_LOG_DIR:-}" ] && [ -d "$${GATEWAY_LOG_DIR}" ]; then
|
||||
rmdir "$${GATEWAY_LOG_DIR}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
exit "$${status}"
|
||||
}
|
||||
|
||||
trap 'cleanup 130' INT
|
||||
trap 'cleanup 143' TERM
|
||||
trap 'cleanup $$?' EXIT
|
||||
|
||||
export APP_PORT="$${APP_PORT:-8084}"
|
||||
export RUST_LOG="$${DEV_RUST_LOG}"
|
||||
RUST_SERVICE_STARTUP_TIMEOUT_SECONDS="$${RUST_SERVICE_STARTUP_TIMEOUT_SECONDS:-180}"
|
||||
GATEWAY_STARTUP_TIMEOUT_SECONDS="$${GATEWAY_STARTUP_TIMEOUT_SECONDS:-$${RUST_SERVICE_STARTUP_TIMEOUT_SECONDS}}"
|
||||
export AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE="$${AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE:-rust-authoritative}"
|
||||
|
||||
if dev_uses_postgres_database; then
|
||||
export DATABASE_URL="postgresql://$${DB_USER:-postgres}:$${DB_PASSWORD:-}@$${DB_HOST:-localhost}:$${DB_PORT:-5432}/$${DB_NAME:-aether}"
|
||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_POSTGRES_URL"; then
|
||||
export AETHER_GATEWAY_DATA_POSTGRES_URL="$${DATABASE_URL}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if dev_uses_redis_runtime; then
|
||||
export REDIS_URL="redis://:$${REDIS_PASSWORD:-}@$${REDIS_HOST:-localhost}:$${REDIS_PORT:-6379}/0"
|
||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_REDIS_URL"; then
|
||||
export AETHER_GATEWAY_DATA_REDIS_URL="$${REDIS_URL}"
|
||||
fi
|
||||
else
|
||||
unset REDIS_URL
|
||||
unset AETHER_GATEWAY_DATA_REDIS_URL
|
||||
fi
|
||||
|
||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_ENCRYPTION_KEY"; then
|
||||
export AETHER_GATEWAY_DATA_ENCRYPTION_KEY="$${ENCRYPTION_KEY:-}"
|
||||
fi
|
||||
|
||||
export DB_POOL_SIZE="$${DB_POOL_SIZE:-5}"
|
||||
export DB_MAX_OVERFLOW="$${DB_MAX_OVERFLOW:-5}"
|
||||
export HTTP_MAX_CONNECTIONS="$${HTTP_MAX_CONNECTIONS:-20}"
|
||||
export HTTP_KEEPALIVE_CONNECTIONS="$${HTTP_KEEPALIVE_CONNECTIONS:-5}"
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "=> 未找到 cargo,无法启动 aether-gateway。请先安装 Rust toolchain。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "=> 未找到 curl,无法检查 aether-gateway 健康状态。请先安装 curl。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$${RUSTC_WRAPPER:-}" ] && command -v sccache >/dev/null 2>&1; then
|
||||
export RUSTC_WRAPPER="$$(command -v sccache)"
|
||||
echo "=> 启用 Rust 编译缓存: $${RUSTC_WRAPPER}"
|
||||
fi
|
||||
|
||||
if ! ensure_dev_infra; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GATEWAY_PID=""
|
||||
GATEWAY_LOG_DIR=""
|
||||
GATEWAY_LOG_FILE=""
|
||||
STARTUP_WAIT_EARLY_EXIT=false
|
||||
create_gateway_log_file
|
||||
|
||||
echo "=> 启动 aether-gateway (Rust frontdoor: 0.0.0.0:$${APP_PORT})..."
|
||||
echo "=> 日志过滤: $${RUST_LOG}"
|
||||
echo "=> 执行命令: cargo run -p aether-gateway -- --app-port $${APP_PORT}"
|
||||
cargo run -p aether-gateway -- --app-port "$${APP_PORT}" > >(
|
||||
tee -a "$${GATEWAY_LOG_FILE}"
|
||||
) 2>&1 &
|
||||
GATEWAY_PID=$$!
|
||||
|
||||
if ! wait_for_startup "$${GATEWAY_PID}" "$${GATEWAY_STARTUP_TIMEOUT_SECONDS}" "aether-gateway" curl -sf "http://127.0.0.1:$${APP_PORT}/_gateway/health"; then
|
||||
if [ "$${STARTUP_WAIT_EARLY_EXIT}" = "true" ]; then
|
||||
GATEWAY_PID=""
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if wait "$${GATEWAY_PID}"; then
|
||||
gateway_exit_code=0
|
||||
else
|
||||
gateway_exit_code=$$?
|
||||
fi
|
||||
|
||||
GATEWAY_PID=""
|
||||
|
||||
if [ "$${gateway_exit_code}" -ne 130 ] && [ "$${gateway_exit_code}" -ne 143 ]; then
|
||||
echo "=> aether-gateway 运行失败并已退出,请检查上面的日志。"
|
||||
print_startup_failure_hint "$${GATEWAY_LOG_FILE}"
|
||||
fi
|
||||
|
||||
exit "$${gateway_exit_code}"
|
||||
endef
|
||||
export DEV_BACKEND_SCRIPT
|
||||
|
||||
define DEV_SCRIPT
|
||||
set -euo pipefail
|
||||
|
||||
backend_pid=""
|
||||
frontend_pid=""
|
||||
|
||||
cleanup() {
|
||||
local status="$${1:-0}"
|
||||
trap - INT TERM EXIT
|
||||
|
||||
if [ -n "$${backend_pid}" ] || [ -n "$${frontend_pid}" ]; then
|
||||
echo ""
|
||||
echo "=> 停止本地开发服务..."
|
||||
if [ -n "$${backend_pid}" ]; then
|
||||
kill "$${backend_pid}" >/dev/null 2>&1 || true
|
||||
wait "$${backend_pid}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -n "$${frontend_pid}" ]; then
|
||||
kill "$${frontend_pid}" >/dev/null 2>&1 || true
|
||||
wait "$${frontend_pid}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
exit "$${status}"
|
||||
}
|
||||
|
||||
wait_for_backend_ready() {
|
||||
while :; do
|
||||
if curl -sf "http://127.0.0.1:$${APP_PORT}/_gateway/health" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$${backend_pid}" >/dev/null 2>&1; then
|
||||
if wait "$${backend_pid}"; then
|
||||
status=0
|
||||
else
|
||||
status=$$?
|
||||
fi
|
||||
if [ "$${status}" -ne 0 ]; then
|
||||
echo "=> 后端进程已退出 (status $${status})"
|
||||
else
|
||||
echo "=> 后端进程已退出"
|
||||
fi
|
||||
backend_pid=""
|
||||
cleanup "$${status}"
|
||||
fi
|
||||
|
||||
sleep 0.2
|
||||
done
|
||||
}
|
||||
|
||||
trap 'cleanup 130' INT
|
||||
trap 'cleanup 143' TERM
|
||||
trap 'cleanup $$?' EXIT
|
||||
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
export APP_PORT="$${APP_PORT:-8084}"
|
||||
|
||||
echo "=> 启动后端: RUST_LOG=$${DEV_RUST_LOG} cargo run -p aether-gateway -- --app-port $${APP_PORT:-8084}"
|
||||
/bin/bash -euo pipefail -c "$$DEV_BACKEND_SCRIPT" &
|
||||
backend_pid=$$!
|
||||
|
||||
echo "=> 等待后端健康检查: http://127.0.0.1:$${APP_PORT}/_gateway/health"
|
||||
wait_for_backend_ready
|
||||
|
||||
echo "=> 启动前端: cd frontend && npm run dev"
|
||||
( cd frontend && exec npm run dev ) &
|
||||
frontend_pid=$$!
|
||||
|
||||
while :; do
|
||||
if ! kill -0 "$${backend_pid}" >/dev/null 2>&1; then
|
||||
if wait "$${backend_pid}"; then
|
||||
status=0
|
||||
else
|
||||
status=$$?
|
||||
fi
|
||||
if [ "$${status}" -ne 0 ]; then
|
||||
echo "=> 后端进程已退出 (status $${status})"
|
||||
else
|
||||
echo "=> 后端进程已退出"
|
||||
fi
|
||||
backend_pid=""
|
||||
cleanup "$${status}"
|
||||
fi
|
||||
|
||||
if ! kill -0 "$${frontend_pid}" >/dev/null 2>&1; then
|
||||
if wait "$${frontend_pid}"; then
|
||||
status=0
|
||||
else
|
||||
status=$$?
|
||||
fi
|
||||
if [ "$${status}" -ne 0 ]; then
|
||||
echo "=> 前端进程已退出 (status $${status})"
|
||||
else
|
||||
echo "=> 前端进程已退出"
|
||||
fi
|
||||
frontend_pid=""
|
||||
cleanup "$${status}"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
endef
|
||||
export DEV_SCRIPT
|
||||
|
||||
define DB_TASK_SCRIPT
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "$${DB_TASK_FLAG:-}" ] || [ -z "$${DB_TASK_LABEL:-}" ]; then
|
||||
echo "=> 内部错误: DB_TASK_FLAG / DB_TASK_LABEL 未设置"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "=> 未找到 .env,请先执行: cp .env.example .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
dotenv_has_key() {
|
||||
local key="$$1"
|
||||
grep -Eq "^[[:space:]]*$${key}=" .env
|
||||
}
|
||||
|
||||
lowercase() {
|
||||
printf '%s' "$$1" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
uses_postgres_database() {
|
||||
local driver
|
||||
local url
|
||||
driver="$$(lowercase "$${AETHER_DATABASE_DRIVER:-}")"
|
||||
url="$${AETHER_DATABASE_URL:-$${DATABASE_URL:-}}"
|
||||
|
||||
if [[ -z "$${driver}" && -z "$${url}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$${driver}" == "postgres" || "$${driver}" == "postgresql" || "$${url}" == postgres:* || "$${url}" == postgresql:* ]]
|
||||
}
|
||||
|
||||
if uses_postgres_database; then
|
||||
export DATABASE_URL="postgresql://$${DB_USER:-postgres}:$${DB_PASSWORD:-}@$${DB_HOST:-localhost}:$${DB_PORT:-5432}/$${DB_NAME:-aether}"
|
||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_POSTGRES_URL"; then
|
||||
export AETHER_GATEWAY_DATA_POSTGRES_URL="$${DATABASE_URL}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_ENCRYPTION_KEY"; then
|
||||
export AETHER_GATEWAY_DATA_ENCRYPTION_KEY="$${ENCRYPTION_KEY:-}"
|
||||
fi
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "=> 未找到 cargo,无法执行 $${DB_TASK_LABEL}。请先安装 Rust toolchain。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=> 执行 $${DB_TASK_LABEL}: cargo run -p aether-gateway -- $${DB_TASK_FLAG}"
|
||||
exec cargo run -p aether-gateway -- "$${DB_TASK_FLAG}"
|
||||
endef
|
||||
export DB_TASK_SCRIPT
|
||||
|
||||
dev:
|
||||
@$(SHELL) -euo pipefail -c "$$DEV_SCRIPT"
|
||||
|
||||
dev-backend:
|
||||
@$(SHELL) -euo pipefail -c "$$DEV_BACKEND_SCRIPT"
|
||||
|
||||
dev-frontend:
|
||||
@cd frontend && npm run dev
|
||||
|
||||
migration:
|
||||
@DB_TASK_FLAG=--migrate DB_TASK_LABEL="数据库迁移" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
||||
|
||||
backfill:
|
||||
@DB_TASK_FLAG=--apply-backfills DB_TASK_LABEL="数据库 backfill" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
||||
@@ -5,12 +5,13 @@
|
||||
<h1 align="center">Aether</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>开源 AI API 网关</strong><br>
|
||||
支持 Claude / OpenAI / Gemini 及其 CLI 客户端的统一接入层
|
||||
<strong>一站式 AI 基础设施平台</strong><br>
|
||||
支持 Claude / OpenAI / Gemini 及其 CLI 客户端的统一接入、格式转换、正/反向代理, 致力于成为用户驱动AI服务的底座
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="#简介">简介</a> •
|
||||
<a href="#部署">部署</a> •
|
||||
<a href="#api-文档">API 文档</a> •
|
||||
<a href="#环境变量">环境变量</a> •
|
||||
<a href="#qa">Q&A</a>
|
||||
</p>
|
||||
@@ -22,27 +23,15 @@
|
||||
|
||||
Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户管理、智能负载均衡、成本配额控制和健康监控能力。通过统一的 API 入口,可以无缝对接 Claude、OpenAI、Gemini 等主流 AI 服务及其 CLI 工具。
|
||||
|
||||
### 页面预览
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/architecture/architecture-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/architecture/architecture-light.svg">
|
||||
<img src="docs/architecture/architecture-light.svg" width="680" alt="Aether Architecture">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
| 首页 | 仪表盘 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| 健康监控 | 用户管理 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| 提供商管理 | 使用记录 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| 模型详情 | 关联提供商 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| 链路追踪 | 系统设置 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
页面预览: https://fawney19.github.io/Aether/
|
||||
|
||||
## 部署
|
||||
|
||||
@@ -51,115 +40,141 @@ Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户
|
||||
```bash
|
||||
# 1. 克隆代码
|
||||
git clone https://github.com/fawney19/Aether.git
|
||||
cd aether
|
||||
cd Aether
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||
# 生成 JWT_SECRET_KEY / ENCRYPTION_KEY, 并填入 .env
|
||||
./generate_keys.sh
|
||||
# 编辑 .env 设置 ADMIN_PASSWORD
|
||||
|
||||
# 3. 部署
|
||||
docker-compose up -d
|
||||
|
||||
# 4. 首次部署时, 初始化数据库
|
||||
./migrate.sh
|
||||
|
||||
# 5. 更新
|
||||
docker-compose pull && docker-compose up -d && ./migrate.sh
|
||||
# 3. 首次部署 / 更新 (从以下部署形态任选其一)
|
||||
# Postgres + Redis (适用于企业或多人使用)
|
||||
docker compose pull && docker compose up -d
|
||||
# Single Node (适用于个人用户或朋友分享)
|
||||
docker compose -f docker-compose.single-node.yml pull && docker compose -f docker-compose.single-node.yml up -d
|
||||
```
|
||||
|
||||
### Docker Compose(本地构建镜像)
|
||||
### 一键更新
|
||||
|
||||
Docker Compose 部署后,可在部署目录直接执行:
|
||||
|
||||
```bash
|
||||
# 1. 克隆代码
|
||||
git clone https://github.com/fawney19/Aether.git
|
||||
cd aether
|
||||
./update.sh
|
||||
```
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||
`update.sh` 会拉取最新 `app` 镜像并重建 `app` 容器,Docker named volumes、`./data` 和 `./logs` 不会被删除。Single Node 部署也可显式指定:
|
||||
|
||||
# 3. 部署 / 更新(自动构建、启动、迁移)
|
||||
```bash
|
||||
./update.sh --mode single-node
|
||||
```
|
||||
|
||||
仓库自带的 Docker Compose 默认把应用日志输出到容器 `stdout/stderr`,直接用 `docker compose logs -f app` 查看,并由 Docker 轮转日志,避免正式发布镜像切换到非 root 用户后再被宿主机挂载日志目录的权限问题拖垮启动。如果你确实需要文件日志,需要在 compose 里把 `AETHER_LOG_DESTINATION` 改成 `file|both`,并额外挂载一个容器用户可写的目录到 `/opt/aether/logs`。
|
||||
|
||||
管理后台右上角“版本信息”会检测新版本。Docker Compose 部署只提示版本,实际更新继续执行 `./update.sh`;systemd / launchd / 二进制部署才使用后台自更新,流程是下载对应平台的 GitHub Release 包、强制校验 `SHA256SUMS`、解压到 `/opt/aether/releases/<version>`,再切换 `/opt/aether/current` 并退出进程,交给 systemd / launchd 拉起新版本。
|
||||
|
||||
源码或本地构建版本不会启用后台在线更新,请继续使用源码更新流程。Docker Compose 用户如果希望“容器重建后也保持镜像层面的新版本”,仍建议定期运行 `./update.sh` 拉取并重建 app 镜像。服务器访问 GitHub 需要代理时,可设置 `AETHER_UPDATE_PROXY_URL`,也兼容 `UPDATE_PROXY_URL`、`HTTPS_PROXY`、`ALL_PROXY`、`HTTP_PROXY` 以及 `NO_PROXY`。共享出口触发 GitHub API 限流时,可设置只读 `AETHER_UPDATE_GITHUB_TOKEN`,也兼容 `GITHUB_TOKEN` / `GH_TOKEN`。下载总超时默认 600 秒,连续无响应/无数据默认 30 秒,可通过 `AETHER_UPDATE_DOWNLOAD_TIMEOUT_SECS` 和 `AETHER_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS` 调整。
|
||||
|
||||
标准 Docker Compose 使用 Docker named volumes 存放 Postgres/Redis/MySQL 数据;Single Node 使用部署目录下的 `./data` 存放 SQLite 数据。
|
||||
|
||||
如果是本地源码构建镜像的部署,继续使用:
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
### 本地开发
|
||||
如果要在本机联调“管理后台在线更新”本身,可启动仓库内置的 release-layout 测试环境:
|
||||
|
||||
```bash
|
||||
# 启动依赖
|
||||
docker-compose -f docker-compose.build.yml up -d postgres redis
|
||||
|
||||
# 后端
|
||||
uv sync
|
||||
./dev.sh
|
||||
|
||||
# 前端
|
||||
cd frontend && npm install && npm run dev
|
||||
docker compose -f docker-compose.release-local.yml up -d --build
|
||||
```
|
||||
|
||||
这套环境会用当前源码构建一个本地测试镜像,但编译为 `release` 类型,并默认伪装成 `v0.7.0`,这样后台会按正式发布版逻辑开放“立即更新”。默认监听 `http://127.0.0.1:18085`,数据目录使用 `./data-release-local`;日志默认走 `docker logs`,不会影响你正在跑的源码构建容器。
|
||||
|
||||
如果这套容器在 `prepare-update` 时访问 GitHub 失败,而你本机是通过代理出网,请在 `.env` 里把 `AETHER_UPDATE_PROXY_URL` 写成宿主机地址,例如 `http://host.docker.internal:7890`;容器内的 `127.0.0.1` 指向容器自身,不是宿主机。
|
||||
|
||||
如果想重置这套联调环境(包括 `/opt/aether/current` 和已下载的历史版本),执行:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.release-local.yml down -v
|
||||
```
|
||||
|
||||
可选变量:
|
||||
|
||||
- `AETHER_RELEASE_LOCAL_VERSION`:本地联调镜像对外声明的当前版本,默认 `v0.7.0`
|
||||
- `AETHER_RELEASE_LOCAL_PORT`:本地联调端口,默认 `18085`
|
||||
- `LOCAL_RELEASE_APP_IMAGE`:本地联调镜像名,默认 `aether-app:release-local`
|
||||
|
||||
### 一键安装(默认 Single Node:Linux systemd / macOS launchd + SQLite)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/fawney19/Aether.git
|
||||
cd Aether
|
||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
依赖 Docker、Rust toolchain、Node.js 和 make。
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
`make dev` 会同时启动后端 `aether-gateway` 和前端 `frontend` 的 Vite dev server。需要单独启动时可使用 `make dev-backend` 或 `make dev-frontend`。
|
||||
Postgres / Redis 本地依赖未就绪时,`make dev` 会自动执行 `docker compose up -d postgres redis`。
|
||||
|
||||
## Aether Tunnel (可选)
|
||||
|
||||
Aether Tunnel 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。
|
||||
|
||||
- Docker Compose 部署或下载预编译二进制直接运行
|
||||
- 提供 macOS/Linux 与 Windows 一键脚本,自动下载最新 `tunnel-v*` 制品并向现有 `aether-tunnel.toml` 追加 `[[servers]]`
|
||||
- 通过 `aether-tunnel setup` 完成交互式配置,自动注册为系统服务
|
||||
- 详细文档见 [apps/aether-tunnel/README.md](apps/aether-tunnel/README.md)
|
||||
|
||||
## API 文档
|
||||
|
||||
- Embeddings: [OpenAI compatible `POST /v1/embeddings`](docs/api/embeddings.md)
|
||||
- Rerank: [OpenAI/Jina compatible `POST /v1/rerank`](docs/api/rerank.md)
|
||||
|
||||
## 环境变量
|
||||
|
||||
### 必需配置
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `DB_PASSWORD` | PostgreSQL 数据库密码 |
|
||||
| `REDIS_PASSWORD` | Redis 密码 |
|
||||
| `JWT_SECRET_KEY` | JWT 签名密钥(使用 `generate_keys.py` 生成) |
|
||||
| `ENCRYPTION_KEY` | API Key 加密密钥(更换后需重新配置 Provider Key) |
|
||||
| `ADMIN_EMAIL` | 初始管理员邮箱 |
|
||||
| `ADMIN_USERNAME` | 初始管理员用户名 |
|
||||
| `ADMIN_PASSWORD` | 初始管理员密码 |
|
||||
|
||||
### 可选配置
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `APP_PORT` | 8084 | 应用端口 |
|
||||
| `API_KEY_PREFIX` | sk | API Key 前缀 |
|
||||
| `LOG_LEVEL` | INFO | 日志级别 (DEBUG/INFO/WARNING/ERROR) |
|
||||
| `GUNICORN_WORKERS` | 4 | Gunicorn 工作进程数 |
|
||||
| `DB_PORT` | 5432 | PostgreSQL 端口 |
|
||||
| `REDIS_PORT` | 6379 | Redis 端口 |
|
||||
|
||||
## Q&A
|
||||
|
||||
### Q: 如何开启/关闭请求体记录?
|
||||
|
||||
管理员在 **系统设置** 中配置日志记录的详细程度:
|
||||
|
||||
| 级别 | 记录内容 |
|
||||
|------|----------|
|
||||
| Base | 基本请求信息 |
|
||||
| Headers | Base + 请求头 |
|
||||
| Full | Headers + 请求体 |
|
||||
|
||||
### Q: 管理员如何给模型配置 1M上下文 / 1H缓存 能力支持?
|
||||
|
||||
1. **模型管理**: 给模型设置 1M上下文 / 1H缓存 的能力支持, 并配置好价格
|
||||
2. **提供商管理**: 给端点添加支持该能力的密钥, 并勾选对应的能力标签
|
||||
|
||||
### Q: 用户如何使用 1H缓存?
|
||||
|
||||
- **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略
|
||||
- **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略
|
||||
|
||||
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能调用支持 1H缓存的模型
|
||||
|
||||
### Q: 如何配置负载均衡?
|
||||
|
||||
在管理后台 **提供商管理** 中切换调度模式:
|
||||
|
||||
| 模式 | 说明 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| **提供商优先** | 按 Provider 优先级排序, 同优先级内按 Key 优先级排序, 相同优先级哈希分散 | 优先使用特定供应商 |
|
||||
| **全局 Key 优先** | 忽略 Provider 层级, 所有 Key 按全局优先级统一排序, 相同优先级哈希分散 | 跨 Provider 统一调度, 最大化利用所有 Key |
|
||||
|
||||
### Q: 提供商免费套餐的计费模式会计入成本吗?
|
||||
|
||||
> **不会**。免费套餐的计费模式倍率为 0, 产生的记录不计入成本费用。
|
||||
- `APP_PORT`:`aether-gateway` 唯一监听端口,固定绑定 `0.0.0.0:${APP_PORT}`
|
||||
- `DATABASE_URL`:数据库连接串;SQLite 例如 `sqlite:///opt/aether/data/aether.db`,Postgres 例如 `postgresql://postgres:aether@postgres:5432/aether`
|
||||
- `AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS` / `AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS`:数据库连接池手动覆盖值;未配置时会自动推导,SQLite 固定 `1/1`,Postgres/MySQL 按 CPU 核心数计算并默认封顶 `100`
|
||||
- `AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS`:单实例请求并发上限;未配置时按 CPU 自动推导(基础范围 `512-65536`),低文件描述符预算时会进一步下调
|
||||
- `AETHER_GATEWAY_REQUEST_BODY_BUFFER_BUDGET_MB`:单实例同时读取和解压请求体的加权内存预算,默认 `256MB`
|
||||
- `AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS`:请求体完整读取超时,默认 `120000ms`
|
||||
- `AETHER_MAX_REQUEST_BODY_MB`:可选的单请求解压后请求体上限;未配置或设为 `0` 时不限制
|
||||
- `AETHER_MAX_INTERNAL_BUFFERED_BODY_MB`:可选的 heartbeat、管理探测等内部整包响应体上限;未配置或设为 `0` 时不限制
|
||||
- `AETHER_TUNNEL_NODE_STATUS_QUEUE_CAPACITY`:隧道节点状态上报队列容量,默认 `1024`;满载时拒绝新事件,避免控制面故障导致无界内存增长
|
||||
- `AETHER_GATEWAY_SECURITY_CACHE_TTL_MS`:IP 黑白名单本地缓存时间,默认 `1000ms`,写操作会主动失效相关缓存
|
||||
- `AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB`:可选的 PII 恢复同步响应缓冲上限;未配置或设为 `0` 时不限制
|
||||
- `REDIS_URL`:Redis 连接串;仅 Postgres + Redis 的 Docker Compose 部署需要配置
|
||||
- `AETHER_RUNTIME_BACKEND=memory|redis`:运行时缓存/协调后端。SQLite 默认用 `memory`,不会连接 Redis;多节点部署和需要跨 gateway 重启恢复 OpenAI Responses continuation history 的部署必须使用共享 Redis
|
||||
- `AETHER_GATEWAY_AUTO_PREPARE_DATABASE`:常规启动前自动执行挂起的 schema migration 和 backfill;仓库自带的 `docker-compose.yml` 默认开启
|
||||
- `JWT_SECRET_KEY` / `ENCRYPTION_KEY`:认证和敏感数据加密所需密钥
|
||||
- `API_KEY_PREFIX`:用户和管理员新建 API Key 时使用的前缀,默认 `sk`
|
||||
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL`:首次启动时自举首个本地管理员;`install.sh` 会提示输入管理员密码
|
||||
- `CORS_ORIGINS` / `CORS_ALLOW_CREDENTIALS`:前端跨域来源控制;如果要跨域带登录 Cookie,`CORS_ORIGINS` 不能写 `*`
|
||||
- `RUST_LOG`:Rust 日志过滤,例如 `aether_gateway=info`、`aether_gateway=debug,sqlx=warn`
|
||||
- Docker Compose 的 `DB_PASSWORD` / `REDIS_PASSWORD` 默认使用 `aether`
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 [Aether 非商业开源许可证](LICENSE)。
|
||||
本项目采用 [Aether 非商业开源许可证](LICENSE)。允许个人学习、教育研究、非盈利组织及企业内部非盈利性质的使用;禁止用于盈利目的。商业使用请联系获取商业许可。
|
||||
|
||||
## 联系作者
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/author/qq_qrcode.jpg" width="200" alt="QQ二维码">
|
||||
|
||||
<img src="docs/author/qrcode_1770574997172.jpg" width="200" alt="QQ群二维码">
|
||||
</p>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/?repos=fawney19%2FAether&type=date&legend=top-left)
|
||||
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
# Alembic 配置文件
|
||||
# 用于数据库版本化迁移
|
||||
|
||||
[alembic]
|
||||
# 迁移脚本存放目录
|
||||
script_location = alembic
|
||||
|
||||
# 模板文件
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# 时区(用于生成迁移文件的时间戳)
|
||||
timezone = UTC
|
||||
|
||||
# 数据库连接 URL(会被 env.py 从环境变量覆盖)
|
||||
# Docker 环境中会从 DATABASE_URL 环境变量读取
|
||||
sqlalchemy.url = postgresql://postgres:${DB_PASSWORD}@localhost:5432/aether
|
||||
|
||||
# 日志配置
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
"""
|
||||
Alembic 环境配置
|
||||
用于数据库迁移的运行时环境设置
|
||||
"""
|
||||
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# 加载 .env 文件(本地开发时需要)
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_file = Path(__file__).parent.parent / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 导入所有数据库模型(确保 Alembic 能检测到所有表)
|
||||
from src.models.database import Base
|
||||
|
||||
# Alembic Config 对象
|
||||
config = context.config
|
||||
|
||||
# 从环境变量获取数据库 URL
|
||||
# 优先使用 DATABASE_URL,否则从 DB_PASSWORD 自动构建(与 docker-compose 保持一致)
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
db_password = os.getenv("DB_PASSWORD", "")
|
||||
db_host = os.getenv("DB_HOST", "localhost")
|
||||
db_port = os.getenv("DB_PORT", "5432")
|
||||
db_name = os.getenv("DB_NAME", "aether")
|
||||
db_user = os.getenv("DB_USER", "postgres")
|
||||
database_url = f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
# 配置日志
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# 目标元数据(包含所有表定义)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""
|
||||
离线模式运行迁移
|
||||
|
||||
在离线模式下,不需要连接数据库,
|
||||
只生成 SQL 脚本
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True, # 比较列类型变更
|
||||
compare_server_default=True, # 比较默认值变更
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""
|
||||
在线模式运行迁移
|
||||
|
||||
在线模式下,直接连接数据库执行迁移
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True, # 比较列类型变更
|
||||
compare_server_default=True, # 比较默认值变更
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
# 根据模式选择运行方式
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -1,26 +0,0 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本"""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -1,771 +0,0 @@
|
||||
"""Baseline migration - all tables consolidated
|
||||
|
||||
Revision ID: 20251210_baseline
|
||||
Revises:
|
||||
Create Date: 2024-12-10
|
||||
|
||||
This is the consolidated baseline migration that creates all tables from scratch.
|
||||
Includes all schema changes up to circuit breaker v2.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers
|
||||
revision = "20251210_baseline"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create ENUM types (with IF NOT EXISTS for idempotency)
|
||||
op.execute("DO $$ BEGIN CREATE TYPE userrole AS ENUM ('admin', 'user'); EXCEPTION WHEN duplicate_object THEN NULL; END $$")
|
||||
op.execute(
|
||||
"DO $$ BEGIN CREATE TYPE providerbillingtype AS ENUM ('monthly_quota', 'pay_as_you_go', 'free_tier'); EXCEPTION WHEN duplicate_object THEN NULL; END $$"
|
||||
)
|
||||
|
||||
# ==================== users ====================
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("email", sa.String(255), unique=True, index=True, nullable=False),
|
||||
sa.Column("username", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||
sa.Column(
|
||||
"role",
|
||||
postgresql.ENUM("admin", "user", name="userrole", create_type=False),
|
||||
nullable=False,
|
||||
server_default="user",
|
||||
),
|
||||
sa.Column("allowed_providers", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_endpoints", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_models", sa.JSON, nullable=True),
|
||||
sa.Column("model_capability_settings", sa.JSON, nullable=True),
|
||||
sa.Column("quota_usd", sa.Float, nullable=True),
|
||||
sa.Column("used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("is_deleted", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
# ==================== providers ====================
|
||||
op.create_table(
|
||||
"providers",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("name", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("display_name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("website", sa.String(500), nullable=True),
|
||||
sa.Column(
|
||||
"billing_type",
|
||||
postgresql.ENUM(
|
||||
"monthly_quota", "pay_as_you_go", "free_tier", name="providerbillingtype", create_type=False
|
||||
),
|
||||
nullable=False,
|
||||
server_default="pay_as_you_go",
|
||||
),
|
||||
sa.Column("monthly_quota_usd", sa.Float, nullable=True),
|
||||
sa.Column("monthly_used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("quota_reset_day", sa.Integer, server_default="30"),
|
||||
sa.Column("quota_last_reset_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("quota_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("rpm_limit", sa.Integer, nullable=True),
|
||||
sa.Column("rpm_used", sa.Integer, server_default="0"),
|
||||
sa.Column("rpm_reset_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("provider_priority", sa.Integer, server_default="100"),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("rate_limit", sa.Integer, nullable=True),
|
||||
sa.Column("concurrent_limit", sa.Integer, nullable=True),
|
||||
sa.Column("config", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== global_models ====================
|
||||
op.create_table(
|
||||
"global_models",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("name", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("display_name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("icon_url", sa.String(500), nullable=True),
|
||||
sa.Column("official_url", sa.String(500), nullable=True),
|
||||
sa.Column("default_price_per_request", sa.Float, nullable=True),
|
||||
sa.Column("default_tiered_pricing", sa.JSON, nullable=False),
|
||||
sa.Column("default_supports_vision", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("default_supports_function_calling", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("default_supports_streaming", sa.Boolean, server_default="true", nullable=True),
|
||||
sa.Column("default_supports_extended_thinking", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("default_supports_image_generation", sa.Boolean, server_default="false", nullable=True),
|
||||
sa.Column("supported_capabilities", sa.JSON, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("usage_count", sa.Integer, server_default="0", nullable=False, index=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== api_keys ====================
|
||||
op.create_table(
|
||||
"api_keys",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("key_hash", sa.String(64), unique=True, index=True, nullable=False),
|
||||
sa.Column("key_encrypted", sa.Text, nullable=True),
|
||||
sa.Column("name", sa.String(100), nullable=True),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("balance_used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("current_balance_usd", sa.Float, nullable=True),
|
||||
sa.Column("is_standalone", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column("allowed_providers", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_endpoints", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_api_formats", sa.JSON, nullable=True),
|
||||
sa.Column("allowed_models", sa.JSON, nullable=True),
|
||||
sa.Column("rate_limit", sa.Integer, server_default="100"),
|
||||
sa.Column("concurrent_limit", sa.Integer, server_default="5", nullable=True),
|
||||
sa.Column("force_capabilities", sa.JSON, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("auto_delete_on_expiry", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== provider_endpoints ====================
|
||||
op.create_table(
|
||||
"provider_endpoints",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("api_format", sa.String(50), nullable=False),
|
||||
sa.Column("base_url", sa.String(500), nullable=False),
|
||||
sa.Column("headers", sa.JSON, nullable=True),
|
||||
sa.Column("timeout", sa.Integer, server_default="300"),
|
||||
sa.Column("max_retries", sa.Integer, server_default="3"),
|
||||
sa.Column("max_concurrent", sa.Integer, nullable=True),
|
||||
sa.Column("rate_limit", sa.Integer, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("custom_path", sa.String(200), nullable=True),
|
||||
sa.Column("config", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("provider_id", "api_format", name="uq_provider_api_format"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_endpoint_format_active", "provider_endpoints", ["api_format", "is_active"]
|
||||
)
|
||||
|
||||
# ==================== models ====================
|
||||
op.create_table(
|
||||
"models",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"global_model_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("global_models.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("provider_model_name", sa.String(200), nullable=False),
|
||||
sa.Column("price_per_request", sa.Float, nullable=True),
|
||||
sa.Column("tiered_pricing", sa.JSON, nullable=True),
|
||||
sa.Column("supports_vision", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_function_calling", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_streaming", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_extended_thinking", sa.Boolean, nullable=True),
|
||||
sa.Column("supports_image_generation", sa.Boolean, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("is_available", sa.Boolean, server_default="true"),
|
||||
sa.Column("config", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("provider_id", "provider_model_name", name="uq_provider_model"),
|
||||
)
|
||||
|
||||
# ==================== model_mappings ====================
|
||||
op.create_table(
|
||||
"model_mappings",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("source_model", sa.String(200), nullable=False, index=True),
|
||||
sa.Column(
|
||||
"target_global_model_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("global_models.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True, index=True
|
||||
),
|
||||
sa.Column("mapping_type", sa.String(20), nullable=False, server_default="alias", index=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("source_model", "provider_id", name="uq_model_mapping_source_provider"),
|
||||
)
|
||||
|
||||
# ==================== provider_api_keys ====================
|
||||
op.create_table(
|
||||
"provider_api_keys",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_endpoints.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("api_key", sa.String(500), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("note", sa.String(500), nullable=True),
|
||||
sa.Column("rate_multiplier", sa.Float, server_default="1.0", nullable=False),
|
||||
sa.Column("internal_priority", sa.Integer, server_default="50"),
|
||||
sa.Column("global_priority", sa.Integer, nullable=True),
|
||||
sa.Column("max_concurrent", sa.Integer, nullable=True),
|
||||
sa.Column("rate_limit", sa.Integer, nullable=True),
|
||||
sa.Column("daily_limit", sa.Integer, nullable=True),
|
||||
sa.Column("monthly_limit", sa.Integer, nullable=True),
|
||||
sa.Column("allowed_models", sa.JSON, nullable=True),
|
||||
sa.Column("capabilities", sa.JSON, nullable=True),
|
||||
sa.Column("learned_max_concurrent", sa.Integer, nullable=True),
|
||||
sa.Column("concurrent_429_count", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("rpm_429_count", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("last_429_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_429_type", sa.String(50), nullable=True),
|
||||
sa.Column("last_concurrent_peak", sa.Integer, nullable=True),
|
||||
sa.Column("adjustment_history", sa.JSON, nullable=True),
|
||||
# Sliding window fields (replaces high_utilization_start)
|
||||
sa.Column("utilization_samples", sa.JSON, nullable=True),
|
||||
sa.Column("last_probe_increase_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("health_score", sa.Float, server_default="1.0"),
|
||||
sa.Column("consecutive_failures", sa.Integer, server_default="0"),
|
||||
sa.Column("last_failure_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("cache_ttl_minutes", sa.Integer, server_default="5", nullable=False),
|
||||
sa.Column("max_probe_interval_minutes", sa.Integer, server_default="32", nullable=False),
|
||||
sa.Column("circuit_breaker_open", sa.Boolean, server_default="false", nullable=False),
|
||||
sa.Column("circuit_breaker_open_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("next_probe_at", sa.DateTime(timezone=True), nullable=True),
|
||||
# Circuit breaker v2 fields
|
||||
sa.Column("request_results_window", sa.JSON, nullable=True),
|
||||
sa.Column("half_open_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("half_open_successes", sa.Integer, server_default="0", nullable=True),
|
||||
sa.Column("half_open_failures", sa.Integer, server_default="0", nullable=True),
|
||||
sa.Column("request_count", sa.Integer, server_default="0"),
|
||||
sa.Column("success_count", sa.Integer, server_default="0"),
|
||||
sa.Column("error_count", sa.Integer, server_default="0"),
|
||||
sa.Column("total_response_time_ms", sa.Integer, server_default="0"),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_error_msg", sa.Text, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== usage ====================
|
||||
op.create_table(
|
||||
"usage",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("api_keys.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("request_id", sa.String(100), unique=True, index=True, nullable=False),
|
||||
sa.Column("provider", sa.String(100), nullable=False),
|
||||
sa.Column("model", sa.String(100), nullable=False),
|
||||
sa.Column("target_model", sa.String(100), nullable=True),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_endpoint_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_endpoints.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_api_keys.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("input_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("output_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("total_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("cache_creation_input_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("cache_read_input_tokens", sa.Integer, server_default="0"),
|
||||
sa.Column("input_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("output_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("cache_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("cache_creation_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("cache_read_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("request_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_input_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_output_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_cache_creation_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_cache_read_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_request_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("actual_total_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("rate_multiplier", sa.Float, server_default="1.0"),
|
||||
sa.Column("input_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("output_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("cache_creation_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("cache_read_price_per_1m", sa.Float, nullable=True),
|
||||
sa.Column("price_per_request", sa.Float, nullable=True),
|
||||
sa.Column("request_type", sa.String(50), nullable=True),
|
||||
sa.Column("api_format", sa.String(50), nullable=True),
|
||||
sa.Column("is_stream", sa.Boolean, server_default="false"),
|
||||
sa.Column("status_code", sa.Integer, nullable=True),
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
sa.Column("response_time_ms", sa.Integer, nullable=True),
|
||||
sa.Column("status", sa.String(20), server_default="completed", nullable=False, index=True),
|
||||
sa.Column("request_headers", sa.JSON, nullable=True),
|
||||
sa.Column("request_body", sa.JSON, nullable=True),
|
||||
sa.Column("provider_request_headers", sa.JSON, nullable=True),
|
||||
sa.Column("response_headers", sa.JSON, nullable=True),
|
||||
sa.Column("response_body", sa.JSON, nullable=True),
|
||||
sa.Column("request_body_compressed", sa.LargeBinary, nullable=True),
|
||||
sa.Column("response_body_compressed", sa.LargeBinary, nullable=True),
|
||||
sa.Column("request_metadata", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== user_quotas ====================
|
||||
op.create_table(
|
||||
"user_quotas",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("quota_type", sa.String(50), nullable=False),
|
||||
sa.Column("quota_usd", sa.Float, nullable=False),
|
||||
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("period_end", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("used_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== system_configs ====================
|
||||
op.create_table(
|
||||
"system_configs",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("key", sa.String(100), unique=True, nullable=False),
|
||||
sa.Column("value", sa.JSON, nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== user_preferences ====================
|
||||
op.create_table(
|
||||
"user_preferences",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("avatar_url", sa.String(500), nullable=True),
|
||||
sa.Column("bio", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"default_provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True
|
||||
),
|
||||
sa.Column("theme", sa.String(20), server_default="light"),
|
||||
sa.Column("language", sa.String(10), server_default="zh-CN"),
|
||||
sa.Column("timezone", sa.String(50), server_default="Asia/Shanghai"),
|
||||
sa.Column("email_notifications", sa.Boolean, server_default="true"),
|
||||
sa.Column("usage_alerts", sa.Boolean, server_default="true"),
|
||||
sa.Column("announcement_notifications", sa.Boolean, server_default="true"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== announcements ====================
|
||||
op.create_table(
|
||||
"announcements",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("title", sa.String(200), nullable=False),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("type", sa.String(20), server_default="info"),
|
||||
sa.Column("priority", sa.Integer, server_default="0"),
|
||||
sa.Column(
|
||||
"author_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("is_active", sa.Boolean, server_default="true", index=True),
|
||||
sa.Column("is_pinned", sa.Boolean, server_default="false"),
|
||||
sa.Column("start_time", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("end_time", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== announcement_reads ====================
|
||||
op.create_table(
|
||||
"announcement_reads",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"announcement_id", sa.String(36), sa.ForeignKey("announcements.id"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"read_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "announcement_id", name="uq_user_announcement"),
|
||||
)
|
||||
|
||||
# ==================== audit_logs ====================
|
||||
op.create_table(
|
||||
"audit_logs",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column("event_type", sa.String(50), nullable=False, index=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("api_key_id", sa.String(36), nullable=True),
|
||||
sa.Column("description", sa.Text, nullable=False),
|
||||
sa.Column("ip_address", sa.String(45), nullable=True),
|
||||
sa.Column("user_agent", sa.String(500), nullable=True),
|
||||
sa.Column("request_id", sa.String(100), nullable=True, index=True),
|
||||
sa.Column("event_metadata", sa.JSON, nullable=True),
|
||||
sa.Column("status_code", sa.Integer, nullable=True),
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== request_candidates ====================
|
||||
op.create_table(
|
||||
"request_candidates",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("request_id", sa.String(100), nullable=False, index=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||
),
|
||||
sa.Column(
|
||||
"api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("candidate_index", sa.Integer, nullable=False),
|
||||
sa.Column("retry_index", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_endpoints.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("provider_api_keys.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("status", sa.String(20), nullable=False),
|
||||
sa.Column("skip_reason", sa.Text, nullable=True),
|
||||
sa.Column("is_cached", sa.Boolean, server_default="false"),
|
||||
sa.Column("status_code", sa.Integer, nullable=True),
|
||||
sa.Column("error_type", sa.String(50), nullable=True),
|
||||
sa.Column("error_message", sa.Text, nullable=True),
|
||||
sa.Column("latency_ms", sa.Integer, nullable=True),
|
||||
sa.Column("concurrent_requests", sa.Integer, nullable=True),
|
||||
sa.Column("extra_data", sa.JSON, nullable=True),
|
||||
sa.Column("required_capabilities", sa.JSON, nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint(
|
||||
"request_id", "candidate_index", "retry_index", name="uq_request_candidate_with_retry"
|
||||
),
|
||||
)
|
||||
op.create_index("idx_request_candidates_request_id", "request_candidates", ["request_id"])
|
||||
op.create_index("idx_request_candidates_status", "request_candidates", ["status"])
|
||||
op.create_index("idx_request_candidates_provider_id", "request_candidates", ["provider_id"])
|
||||
|
||||
# ==================== stats_daily ====================
|
||||
op.create_table(
|
||||
"stats_daily",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("date", sa.DateTime(timezone=True), nullable=False, unique=True, index=True),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("success_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("error_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("total_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("actual_total_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("input_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("output_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("cache_creation_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("cache_read_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("avg_response_time_ms", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("fallback_count", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("unique_models", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("unique_providers", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== stats_summary ====================
|
||||
op.create_table(
|
||||
"stats_summary",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("cutoff_date", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("all_time_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("all_time_success_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("all_time_error_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("all_time_input_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("all_time_output_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column(
|
||||
"all_time_cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False
|
||||
),
|
||||
sa.Column("all_time_cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("all_time_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("all_time_actual_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column("total_users", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("active_users", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("total_api_keys", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("active_api_keys", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
|
||||
# ==================== stats_user_daily ====================
|
||||
op.create_table(
|
||||
"stats_user_daily",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("date", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("success_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("error_requests", sa.Integer, server_default="0", nullable=False),
|
||||
sa.Column("input_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("output_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||
sa.Column("total_cost", sa.Float, server_default="0.0", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "date", name="uq_stats_user_daily"),
|
||||
)
|
||||
op.create_index("idx_stats_user_daily_user_date", "stats_user_daily", ["user_id", "date"])
|
||||
|
||||
# ==================== api_key_provider_mappings ====================
|
||||
op.create_table(
|
||||
"api_key_provider_mappings",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"api_key_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("priority_adjustment", sa.Integer, server_default="0"),
|
||||
sa.Column("weight_multiplier", sa.Float, server_default="1.0"),
|
||||
sa.Column("is_enabled", sa.Boolean, server_default="true", nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.UniqueConstraint("api_key_id", "provider_id", name="uq_apikey_provider"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_apikey_provider_enabled", "api_key_provider_mappings", ["api_key_id", "is_enabled"]
|
||||
)
|
||||
|
||||
# ==================== provider_usage_tracking ====================
|
||||
op.create_table(
|
||||
"provider_usage_tracking",
|
||||
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"provider_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("window_start", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||
sa.Column("window_end", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("successful_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("failed_requests", sa.Integer, server_default="0"),
|
||||
sa.Column("avg_response_time_ms", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_response_time_ms", sa.Float, server_default="0.0"),
|
||||
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_provider_window", "provider_usage_tracking", ["provider_id", "window_start"]
|
||||
)
|
||||
op.create_index("idx_window_time", "provider_usage_tracking", ["window_start", "window_end"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop tables in reverse order (respecting foreign key dependencies)
|
||||
op.drop_table("provider_usage_tracking")
|
||||
op.drop_table("api_key_provider_mappings")
|
||||
op.drop_table("stats_user_daily")
|
||||
op.drop_table("stats_summary")
|
||||
op.drop_table("stats_daily")
|
||||
op.drop_table("request_candidates")
|
||||
op.drop_table("audit_logs")
|
||||
op.drop_table("announcement_reads")
|
||||
op.drop_table("announcements")
|
||||
op.drop_table("user_preferences")
|
||||
op.drop_table("system_configs")
|
||||
op.drop_table("user_quotas")
|
||||
op.drop_table("usage")
|
||||
op.drop_table("provider_api_keys")
|
||||
op.drop_table("model_mappings")
|
||||
op.drop_table("models")
|
||||
op.drop_table("provider_endpoints")
|
||||
op.drop_table("api_keys")
|
||||
op.drop_table("global_models")
|
||||
op.drop_table("providers")
|
||||
op.drop_table("users")
|
||||
|
||||
# Drop ENUM types
|
||||
op.execute("DROP TYPE IF EXISTS providerbillingtype")
|
||||
op.execute("DROP TYPE IF EXISTS userrole")
|
||||
@@ -1,315 +0,0 @@
|
||||
"""remove_model_mappings_add_aliases
|
||||
|
||||
合并迁移:
|
||||
1. 添加 provider_model_aliases 字段到 models 表
|
||||
2. 迁移 model_mappings 数据到 provider_model_aliases
|
||||
3. 删除 model_mappings 表
|
||||
4. 添加索引优化别名解析性能
|
||||
|
||||
Revision ID: e9b3d63f0cbf
|
||||
Revises: 20251210_baseline
|
||||
Create Date: 2025-12-14 13:00:22.828183+00:00
|
||||
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'e9b3d63f0cbf'
|
||||
down_revision = '20251210_baseline'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table_name AND column_name = :column_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def table_exists(bind, table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = :table_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def index_exists(bind, index_name: str) -> bool:
|
||||
"""检查索引是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE indexname = :index_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 provider_model_aliases 字段,迁移数据,删除 model_mappings 表"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 1. 添加 provider_model_aliases 字段(如果不存在)
|
||||
if not column_exists(bind, "models", "provider_model_aliases"):
|
||||
op.add_column(
|
||||
'models',
|
||||
sa.Column('provider_model_aliases', sa.JSON(), nullable=True)
|
||||
)
|
||||
|
||||
# 2. 迁移 model_mappings 数据(如果表存在)
|
||||
session = Session(bind=bind)
|
||||
|
||||
model_mappings_table = sa.table(
|
||||
"model_mappings",
|
||||
sa.column("source_model", sa.String),
|
||||
sa.column("target_global_model_id", sa.String),
|
||||
sa.column("provider_id", sa.String),
|
||||
sa.column("mapping_type", sa.String),
|
||||
sa.column("is_active", sa.Boolean),
|
||||
)
|
||||
|
||||
models_table = sa.table(
|
||||
"models",
|
||||
sa.column("id", sa.String),
|
||||
sa.column("provider_id", sa.String),
|
||||
sa.column("global_model_id", sa.String),
|
||||
sa.column("provider_model_aliases", sa.JSON),
|
||||
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
def normalize_alias_list(value) -> list[dict]:
|
||||
"""将 DB 返回的 JSON 值规范化为 list[{'name': str, 'priority': int}]"""
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value) if value else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
normalized: list[dict] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
raw_name = item.get("name")
|
||||
if not isinstance(raw_name, str):
|
||||
continue
|
||||
name = raw_name.strip()
|
||||
if not name:
|
||||
continue
|
||||
|
||||
raw_priority = item.get("priority", 1)
|
||||
try:
|
||||
priority = int(raw_priority)
|
||||
except Exception:
|
||||
priority = 1
|
||||
if priority < 1:
|
||||
priority = 1
|
||||
|
||||
normalized.append({"name": name, "priority": priority})
|
||||
|
||||
return normalized
|
||||
|
||||
# 查询所有活跃的 provider 级别 alias(只迁移 is_active=True 且 mapping_type='alias' 的)
|
||||
# 全局别名/映射不迁移(新架构不再支持 source_model -> GlobalModel.name 的解析)
|
||||
# 仅当 model_mappings 表存在时执行迁移
|
||||
if table_exists(bind, "model_mappings"):
|
||||
mappings = session.execute(
|
||||
sa.select(
|
||||
model_mappings_table.c.source_model,
|
||||
model_mappings_table.c.target_global_model_id,
|
||||
model_mappings_table.c.provider_id,
|
||||
)
|
||||
.where(
|
||||
model_mappings_table.c.is_active.is_(True),
|
||||
model_mappings_table.c.provider_id.isnot(None),
|
||||
model_mappings_table.c.mapping_type == "alias",
|
||||
)
|
||||
.order_by(model_mappings_table.c.provider_id, model_mappings_table.c.source_model)
|
||||
).all()
|
||||
|
||||
# 按 (provider_id, target_global_model_id) 分组,收集别名
|
||||
alias_groups: dict = {}
|
||||
for source_model, target_global_model_id, provider_id in mappings:
|
||||
if not isinstance(source_model, str):
|
||||
continue
|
||||
source_model = source_model.strip()
|
||||
if not source_model:
|
||||
continue
|
||||
if not isinstance(provider_id, str) or not provider_id:
|
||||
continue
|
||||
if not isinstance(target_global_model_id, str) or not target_global_model_id:
|
||||
continue
|
||||
|
||||
key = (provider_id, target_global_model_id)
|
||||
if key not in alias_groups:
|
||||
alias_groups[key] = []
|
||||
priority = len(alias_groups[key]) + 1
|
||||
alias_groups[key].append({"name": source_model, "priority": priority})
|
||||
|
||||
# 更新对应的 models 记录
|
||||
for (provider_id, global_model_id), aliases in alias_groups.items():
|
||||
model_row = session.execute(
|
||||
sa.select(models_table.c.id, models_table.c.provider_model_aliases)
|
||||
.where(
|
||||
models_table.c.provider_id == provider_id,
|
||||
models_table.c.global_model_id == global_model_id,
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
if model_row:
|
||||
model_id = model_row[0]
|
||||
existing_aliases = normalize_alias_list(model_row[1])
|
||||
|
||||
existing_names = {a["name"] for a in existing_aliases}
|
||||
merged_aliases = list(existing_aliases)
|
||||
for alias in aliases:
|
||||
name = alias.get("name")
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
name = name.strip()
|
||||
if not name or name in existing_names:
|
||||
continue
|
||||
|
||||
merged_aliases.append(
|
||||
{
|
||||
"name": name,
|
||||
"priority": len(merged_aliases) + 1,
|
||||
}
|
||||
)
|
||||
existing_names.add(name)
|
||||
|
||||
session.execute(
|
||||
models_table.update()
|
||||
.where(models_table.c.id == model_id)
|
||||
.values(
|
||||
provider_model_aliases=merged_aliases if merged_aliases else None,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
|
||||
session.commit()
|
||||
|
||||
# 3. 删除 model_mappings 表
|
||||
op.drop_table('model_mappings')
|
||||
|
||||
# 4. 添加索引优化别名解析性能
|
||||
# provider_model_name 索引(支持精确匹配,如果不存在)
|
||||
if not index_exists(bind, "idx_model_provider_model_name"):
|
||||
op.create_index(
|
||||
"idx_model_provider_model_name",
|
||||
"models",
|
||||
["provider_model_name"],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("is_active = true"),
|
||||
)
|
||||
|
||||
# provider_model_aliases GIN 索引(支持 JSONB 查询,仅 PostgreSQL)
|
||||
if bind.dialect.name == "postgresql":
|
||||
# 将 json 列转为 jsonb(jsonb 性能更好且支持 GIN 索引)
|
||||
# 使用 IF NOT EXISTS 风格的检查来避免重复转换
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'models'
|
||||
AND column_name = 'provider_model_aliases'
|
||||
AND data_type = 'json'
|
||||
) THEN
|
||||
ALTER TABLE models
|
||||
ALTER COLUMN provider_model_aliases TYPE jsonb
|
||||
USING provider_model_aliases::jsonb;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
# 创建 GIN 索引
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_model_provider_model_aliases_gin
|
||||
ON models USING gin(provider_model_aliases jsonb_path_ops)
|
||||
WHERE is_active = true
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""恢复 model_mappings 表,移除 provider_model_aliases 字段和索引"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 1. 删除索引
|
||||
op.drop_index("idx_model_provider_model_name", table_name="models")
|
||||
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute("DROP INDEX IF EXISTS idx_model_provider_model_aliases_gin")
|
||||
# 将 jsonb 列还原为 json
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE models
|
||||
ALTER COLUMN provider_model_aliases TYPE json
|
||||
USING provider_model_aliases::json
|
||||
"""
|
||||
)
|
||||
|
||||
# 2. 恢复 model_mappings 表
|
||||
op.create_table(
|
||||
'model_mappings',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('source_model', sa.String(200), nullable=False),
|
||||
sa.Column(
|
||||
'target_global_model_id',
|
||||
sa.String(36),
|
||||
sa.ForeignKey('global_models.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('provider_id', sa.String(36), sa.ForeignKey('providers.id'), nullable=True),
|
||||
sa.Column('mapping_type', sa.String(20), nullable=False, server_default='alias'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint('source_model', 'provider_id', name='uq_model_mapping_source_provider'),
|
||||
)
|
||||
op.create_index('ix_model_mappings_source_model', 'model_mappings', ['source_model'])
|
||||
op.create_index('ix_model_mappings_target_global_model_id', 'model_mappings', ['target_global_model_id'])
|
||||
op.create_index('ix_model_mappings_provider_id', 'model_mappings', ['provider_id'])
|
||||
op.create_index('ix_model_mappings_mapping_type', 'model_mappings', ['mapping_type'])
|
||||
|
||||
# 3. 移除 provider_model_aliases 字段
|
||||
op.drop_column('models', 'provider_model_aliases')
|
||||
@@ -1,47 +0,0 @@
|
||||
"""add first_byte_time_ms to usage table
|
||||
|
||||
Revision ID: 180e63a9c83a
|
||||
Revises: e9b3d63f0cbf
|
||||
Create Date: 2025-12-15 17:07:44.631032+00:00
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '180e63a9c83a'
|
||||
down_revision = 'e9b3d63f0cbf'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table_name AND column_name = :column_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 添加首字时间字段到 usage 表(如果不存在)
|
||||
if not column_exists(bind, "usage", "first_byte_time_ms"):
|
||||
op.add_column('usage', sa.Column('first_byte_time_ms', sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
# 删除首字时间字段
|
||||
op.drop_column('usage', 'first_byte_time_ms')
|
||||
@@ -1,110 +0,0 @@
|
||||
"""refactor global_model to use config json field
|
||||
|
||||
Revision ID: 1cc6942cf06f
|
||||
Revises: 180e63a9c83a
|
||||
Create Date: 2025-12-16 03:11:32.480976+00:00
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '1cc6942cf06f'
|
||||
down_revision = '180e63a9c83a'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
result = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = :table_name AND column_name = :column_name
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移:升级到新版本
|
||||
|
||||
1. 添加 config 列
|
||||
2. 把旧数据迁移到 config
|
||||
3. 删除旧列
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# 检查是否已经迁移过(config 列存在且旧列不存在)
|
||||
has_config = column_exists(bind, "global_models", "config")
|
||||
has_old_columns = column_exists(bind, "global_models", "default_supports_streaming")
|
||||
|
||||
if has_config and not has_old_columns:
|
||||
# 已完成迁移,跳过
|
||||
return
|
||||
|
||||
# 1. 添加 config 列(使用 JSONB 类型,支持索引和更高效的查询)
|
||||
if not has_config:
|
||||
op.add_column('global_models', sa.Column('config', postgresql.JSONB(), nullable=True))
|
||||
|
||||
# 2. 迁移数据:把旧字段合并到 config JSON(仅当旧列存在时)
|
||||
if has_old_columns:
|
||||
op.execute("""
|
||||
UPDATE global_models
|
||||
SET config = jsonb_strip_nulls(jsonb_build_object(
|
||||
'streaming', COALESCE(default_supports_streaming, true),
|
||||
'vision', CASE WHEN COALESCE(default_supports_vision, false) THEN true ELSE NULL END,
|
||||
'function_calling', CASE WHEN COALESCE(default_supports_function_calling, false) THEN true ELSE NULL END,
|
||||
'extended_thinking', CASE WHEN COALESCE(default_supports_extended_thinking, false) THEN true ELSE NULL END,
|
||||
'image_generation', CASE WHEN COALESCE(default_supports_image_generation, false) THEN true ELSE NULL END,
|
||||
'description', description,
|
||||
'icon_url', icon_url,
|
||||
'official_url', official_url
|
||||
))
|
||||
""")
|
||||
|
||||
# 3. 删除旧列
|
||||
op.drop_column('global_models', 'default_supports_streaming')
|
||||
op.drop_column('global_models', 'default_supports_vision')
|
||||
op.drop_column('global_models', 'default_supports_function_calling')
|
||||
op.drop_column('global_models', 'default_supports_extended_thinking')
|
||||
op.drop_column('global_models', 'default_supports_image_generation')
|
||||
op.drop_column('global_models', 'description')
|
||||
op.drop_column('global_models', 'icon_url')
|
||||
op.drop_column('global_models', 'official_url')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移:降级到旧版本"""
|
||||
# 1. 添加旧列
|
||||
op.add_column('global_models', sa.Column('icon_url', sa.VARCHAR(length=500), nullable=True))
|
||||
op.add_column('global_models', sa.Column('official_url', sa.VARCHAR(length=500), nullable=True))
|
||||
op.add_column('global_models', sa.Column('description', sa.TEXT(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_streaming', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_vision', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_function_calling', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_extended_thinking', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('global_models', sa.Column('default_supports_image_generation', sa.BOOLEAN(), nullable=True))
|
||||
|
||||
# 2. 从 config 恢复数据
|
||||
op.execute("""
|
||||
UPDATE global_models
|
||||
SET
|
||||
default_supports_streaming = COALESCE((config->>'streaming')::boolean, true),
|
||||
default_supports_vision = COALESCE((config->>'vision')::boolean, false),
|
||||
default_supports_function_calling = COALESCE((config->>'function_calling')::boolean, false),
|
||||
default_supports_extended_thinking = COALESCE((config->>'extended_thinking')::boolean, false),
|
||||
default_supports_image_generation = COALESCE((config->>'image_generation')::boolean, false),
|
||||
description = config->>'description',
|
||||
icon_url = config->>'icon_url',
|
||||
official_url = config->>'official_url'
|
||||
""")
|
||||
|
||||
# 3. 删除 config 列
|
||||
op.drop_column('global_models', 'config')
|
||||
@@ -1,57 +0,0 @@
|
||||
"""add proxy field to provider_endpoints
|
||||
|
||||
Revision ID: f30f9936f6a2
|
||||
Revises: 1cc6942cf06f
|
||||
Create Date: 2025-12-18 06:31:58.451112+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f30f9936f6a2'
|
||||
down_revision = '1cc6942cf06f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def get_column_type(table_name: str, column_name: str) -> str:
|
||||
"""获取列的类型"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
for col in inspector.get_columns(table_name):
|
||||
if col['name'] == column_name:
|
||||
return str(col['type']).upper()
|
||||
return ''
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 proxy 字段到 provider_endpoints 表"""
|
||||
if not column_exists('provider_endpoints', 'proxy'):
|
||||
# 字段不存在,直接添加 JSONB 类型
|
||||
op.add_column('provider_endpoints', sa.Column('proxy', JSONB(), nullable=True))
|
||||
else:
|
||||
# 字段已存在,检查是否需要转换类型
|
||||
col_type = get_column_type('provider_endpoints', 'proxy')
|
||||
if 'JSONB' not in col_type:
|
||||
# 如果是 JSON 类型,转换为 JSONB
|
||||
op.execute(
|
||||
'ALTER TABLE provider_endpoints '
|
||||
'ALTER COLUMN proxy TYPE JSONB USING proxy::jsonb'
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""移除 proxy 字段"""
|
||||
if column_exists('provider_endpoints', 'proxy'):
|
||||
op.drop_column('provider_endpoints', 'proxy')
|
||||
@@ -1,85 +0,0 @@
|
||||
# Aether - 数据库迁移说明
|
||||
|
||||
## 当前版本
|
||||
|
||||
- **Revision ID**: `aether_baseline`
|
||||
- **创建日期**: 2025-12-06
|
||||
- **状态**: 全新基线
|
||||
|
||||
## 迁移历史
|
||||
|
||||
所有历史增量迁移已清理,当前以完整 schema 作为新起点。
|
||||
|
||||
## 核心数据库结构
|
||||
|
||||
### 用户系统
|
||||
- **users**: 用户账户管理
|
||||
- **api_keys**: API 密钥管理
|
||||
- **user_quotas**: 用户配额管理
|
||||
- **user_preferences**: 用户偏好设置
|
||||
|
||||
### Provider 三层架构
|
||||
- **providers**: LLM 提供商配置
|
||||
- **provider_endpoints**: Provider 的 API 端点配置
|
||||
- **provider_api_keys**: Endpoint 的具体 API 密钥
|
||||
- **api_key_provider_mappings**: 用户 API Key 到 Provider 的映射关系
|
||||
|
||||
### 模型系统
|
||||
- **global_models**: 统一模型定义(GlobalModel)
|
||||
- **models**: Provider 的模型实现和价格配置
|
||||
- **model_mappings**: 统一的别名与降级映射表
|
||||
|
||||
### 监控和追踪
|
||||
- **usage**: API 使用记录
|
||||
- **request_candidates**: 请求候选记录
|
||||
- **provider_usage_tracking**: Provider 使用统计
|
||||
- **audit_logs**: 系统审计日志
|
||||
|
||||
### 系统功能
|
||||
- **announcements**: 系统公告
|
||||
- **announcement_reads**: 公告阅读记录
|
||||
- **system_configs**: 系统配置
|
||||
|
||||
## 从旧数据库迁移
|
||||
|
||||
如需从旧数据库迁移数据,请使用迁移脚本:
|
||||
|
||||
```bash
|
||||
# 设置环境变量
|
||||
export OLD_DATABASE_URL="postgresql://user:pass@old-host:5432/old_db"
|
||||
export NEW_DATABASE_URL="postgresql://user:pass@new-host:5432/aether"
|
||||
|
||||
# 干运行(查看迁移量)
|
||||
python scripts/migrate_data.py --dry-run
|
||||
|
||||
# 执行迁移
|
||||
python scripts/migrate_data.py
|
||||
|
||||
# 只迁移特定表
|
||||
python scripts/migrate_data.py --tables users,providers,api_keys
|
||||
|
||||
# 跳过大表
|
||||
python scripts/migrate_data.py --skip usage,audit_logs
|
||||
```
|
||||
|
||||
## 新数据库初始化
|
||||
|
||||
```bash
|
||||
# 1. 运行迁移创建表结构
|
||||
DATABASE_URL="postgresql://user:pass@host:5432/aether" uv run alembic upgrade head
|
||||
|
||||
# 2. 初始化管理员账户
|
||||
python -m src.database.init_db
|
||||
```
|
||||
|
||||
## 未来迁移
|
||||
|
||||
基于 `aether_baseline` 创建增量迁移:
|
||||
|
||||
```bash
|
||||
# 修改模型后,生成新的迁移
|
||||
DATABASE_URL="..." uv run alembic revision --autogenerate -m "描述变更"
|
||||
|
||||
# 应用迁移
|
||||
DATABASE_URL="..." uv run alembic upgrade head
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
[package]
|
||||
name = "aether-gateway"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Rust ingress gateway for Aether phase 3a transparent proxy"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
jemalloc = [
|
||||
"dep:tikv-jemallocator",
|
||||
"dep:tikv-jemalloc-sys",
|
||||
"tikv-jemallocator/stats",
|
||||
"tikv-jemalloc-sys/stats",
|
||||
]
|
||||
testkit = []
|
||||
|
||||
[dependencies]
|
||||
aether-admin.workspace = true
|
||||
aether-ai-formats.workspace = true
|
||||
aether-ai-serving.workspace = true
|
||||
aether-billing.workspace = true
|
||||
aether-cache.workspace = true
|
||||
aether-contracts.workspace = true
|
||||
aether-crypto.workspace = true
|
||||
aether-data = { workspace = true, features = ["all-drivers"] }
|
||||
aether-data-contracts.workspace = true
|
||||
aether-dispatch-core.workspace = true
|
||||
aether-gateway-frontdoor.workspace = true
|
||||
aether-gateway-control.workspace = true
|
||||
aether-gateway-execution.workspace = true
|
||||
aether-http.workspace = true
|
||||
aether-gateway-workers.workspace = true
|
||||
aether-gateway-tunnel.workspace = true
|
||||
aether-model-fetch.workspace = true
|
||||
aether-oauth.workspace = true
|
||||
aether-pool-core.workspace = true
|
||||
aether-provider-pool.workspace = true
|
||||
aether-provider-transport.workspace = true
|
||||
aether-routing-core.workspace = true
|
||||
aether-scheduler-core.workspace = true
|
||||
aether-runtime.workspace = true
|
||||
aether-runtime-state.workspace = true
|
||||
aether-task-runtime.workspace = true
|
||||
aether-usage-runtime.workspace = true
|
||||
aether-video-tasks-core.workspace = true
|
||||
aether-wallet.workspace = true
|
||||
aes-gcm.workspace = true
|
||||
async-stream.workspace = true
|
||||
async-trait.workspace = true
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
base64.workspace = true
|
||||
bcrypt.workspace = true
|
||||
bytes.workspace = true
|
||||
chrono.workspace = true
|
||||
chrono-tz.workspace = true
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
dashmap = "6"
|
||||
flate2.workspace = true
|
||||
futures-util.workspace = true
|
||||
hmac.workspace = true
|
||||
http.workspace = true
|
||||
http-body-util = "0.1"
|
||||
hyper = { version = "1", features = ["client", "server", "http1", "http2"] }
|
||||
hyper-util = { version = "0.1", features = ["client-legacy", "client-pool", "server-auto", "service", "tokio"] }
|
||||
ldap3 = { version = "0.11", default-features = false, features = ["sync", "tls-rustls"] }
|
||||
libc = "0.2"
|
||||
md-5 = "0.10"
|
||||
object_store.workspace = true
|
||||
parking_lot = "0.12"
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
rsa = "0.9.10"
|
||||
rustls.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha1 = "0.10"
|
||||
sha2 = { workspace = true, features = ["oid"] }
|
||||
socket2.workspace = true
|
||||
tar.workspace = true
|
||||
sqlx = { workspace = true, features = ["postgres", "mysql", "sqlite", "migrate"] }
|
||||
sysinfo = "0.32"
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "compression-gzip", "set-header"] }
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
webpki-roots.workspace = true
|
||||
wreq.workspace = true
|
||||
wreq-util.workspace = true
|
||||
zstd.workspace = true
|
||||
|
||||
[target.'cfg(not(target_env = "msvc"))'.dependencies]
|
||||
tikv-jemallocator = { version = "0.6", optional = true }
|
||||
tikv-jemalloc-sys = { version = "0.6", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
aether-test-support.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
@@ -0,0 +1,59 @@
|
||||
use std::env;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=AETHER_BUILD_VERSION");
|
||||
println!("cargo:rerun-if-env-changed=AETHER_BUILD_TYPE");
|
||||
println!("cargo:rerun-if-env-changed=AETHER_VERSION");
|
||||
println!("cargo:rerun-if-env-changed=GITHUB_REF_NAME");
|
||||
println!("cargo:rerun-if-changed=../../.git/HEAD");
|
||||
|
||||
let package_version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".to_string());
|
||||
let version = env::var("AETHER_BUILD_VERSION")
|
||||
.ok()
|
||||
.and_then(|value| normalize_gateway_version_source(&value))
|
||||
.or_else(|| {
|
||||
env::var("AETHER_VERSION")
|
||||
.ok()
|
||||
.and_then(|value| normalize_gateway_version_source(&value))
|
||||
})
|
||||
.or_else(|| {
|
||||
env::var("GITHUB_REF_NAME")
|
||||
.ok()
|
||||
.and_then(|value| normalize_gateway_version_source(&value))
|
||||
})
|
||||
.or_else(git_describe_version)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(package_version);
|
||||
|
||||
println!("cargo:rustc-env=AETHER_BUILD_VERSION={version}");
|
||||
|
||||
let build_type = env::var("AETHER_BUILD_TYPE")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "source".to_string());
|
||||
println!("cargo:rustc-env=AETHER_BUILD_TYPE={build_type}");
|
||||
}
|
||||
|
||||
fn git_describe_version() -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"describe", "--tags", "--match", "v[0-9]*", "--always", "--dirty",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let version = String::from_utf8(output.stdout).ok()?;
|
||||
let version = version.trim();
|
||||
normalize_gateway_version_source(version)
|
||||
}
|
||||
|
||||
fn normalize_gateway_version_source(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with("tunnel-v") {
|
||||
return None;
|
||||
}
|
||||
Some(trimmed.strip_prefix('v').unwrap_or(trimmed).to_string())
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use tracing::info;
|
||||
|
||||
use aether_gateway::{serve_execution_runtime_tcp, serve_execution_runtime_unix};
|
||||
use aether_runtime::{init_service_runtime, ServiceRuntimeConfig};
|
||||
use aether_runtime_state::{RedisClientConfig, RuntimeSemaphoreConfig, RuntimeState};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "execution-runtime-harness",
|
||||
about = "Internal execution runtime harness for Aether tests"
|
||||
)]
|
||||
struct Args {
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_EXECUTION_RUNTIME_TRANSPORT",
|
||||
default_value = "unix_socket"
|
||||
)]
|
||||
transport: String,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_EXECUTION_RUNTIME_BIND",
|
||||
default_value = "127.0.0.1:5219"
|
||||
)]
|
||||
bind: String,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_EXECUTION_RUNTIME_UNIX_SOCKET",
|
||||
default_value = "/tmp/aether-execution-runtime.sock"
|
||||
)]
|
||||
unix_socket: PathBuf,
|
||||
|
||||
#[arg(long, env = "AETHER_EXECUTION_RUNTIME_MAX_IN_FLIGHT_REQUESTS")]
|
||||
max_in_flight_requests: Option<usize>,
|
||||
|
||||
#[arg(long, env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_LIMIT")]
|
||||
distributed_request_limit: Option<usize>,
|
||||
|
||||
#[arg(long, env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_REDIS_URL")]
|
||||
distributed_request_redis_url: Option<String>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_REDIS_KEY_PREFIX"
|
||||
)]
|
||||
distributed_request_redis_key_prefix: Option<String>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_LEASE_TTL_MS",
|
||||
default_value_t = 30_000
|
||||
)]
|
||||
distributed_request_lease_ttl_ms: u64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_RENEW_INTERVAL_MS",
|
||||
default_value_t = 10_000
|
||||
)]
|
||||
distributed_request_renew_interval_ms: u64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_COMMAND_TIMEOUT_MS",
|
||||
default_value_t = 1_000
|
||||
)]
|
||||
distributed_request_command_timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
init_service_runtime(ServiceRuntimeConfig::new(
|
||||
"aether-execution-runtime-harness",
|
||||
"aether_gateway=info",
|
||||
))?;
|
||||
|
||||
let args = Args::parse();
|
||||
let distributed_request_gate = match args.distributed_request_limit.filter(|limit| *limit > 0) {
|
||||
Some(limit) => {
|
||||
let redis_url = args
|
||||
.distributed_request_redis_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"AETHER_EXECUTION_RUNTIME_DISTRIBUTED_REQUEST_REDIS_URL is required when distributed request limit is enabled",
|
||||
)
|
||||
})?;
|
||||
let runtime = RuntimeState::redis(
|
||||
RedisClientConfig {
|
||||
url: redis_url.to_string(),
|
||||
key_prefix: args
|
||||
.distributed_request_redis_key_prefix
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
},
|
||||
Some(args.distributed_request_command_timeout_ms.max(1)),
|
||||
)
|
||||
.await?;
|
||||
Some(runtime.semaphore(
|
||||
"execution_runtime_requests_distributed",
|
||||
limit,
|
||||
RuntimeSemaphoreConfig {
|
||||
lease_ttl_ms: args.distributed_request_lease_ttl_ms.max(1),
|
||||
renew_interval_ms: args.distributed_request_renew_interval_ms.max(1),
|
||||
command_timeout_ms: Some(args.distributed_request_command_timeout_ms.max(1)),
|
||||
},
|
||||
)?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
match args.transport.trim().to_ascii_lowercase().as_str() {
|
||||
"unix_socket" | "unix" | "uds" => {
|
||||
info!(
|
||||
socket = %args.unix_socket.display(),
|
||||
"aether execution-runtime harness started"
|
||||
);
|
||||
serve_execution_runtime_unix(
|
||||
&args.unix_socket,
|
||||
args.max_in_flight_requests,
|
||||
distributed_request_gate.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
"tcp" => {
|
||||
info!(
|
||||
bind = %args.bind,
|
||||
max_in_flight_requests = args.max_in_flight_requests.unwrap_or_default(),
|
||||
distributed_request_limit = args.distributed_request_limit.unwrap_or_default(),
|
||||
"aether execution-runtime harness started"
|
||||
);
|
||||
serve_execution_runtime_tcp(
|
||||
&args.bind,
|
||||
args.max_in_flight_requests,
|
||||
distributed_request_gate,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
other => {
|
||||
return Err(format!("unsupported execution runtime transport: {other}").into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_gateway::{
|
||||
build_tunnel_runtime_router_with_state, TunnelConnConfig, TunnelControlPlaneClient,
|
||||
TunnelRuntimeState,
|
||||
};
|
||||
use aether_runtime::{init_service_runtime, ServiceRuntimeConfig};
|
||||
use aether_runtime_state::{RedisClientConfig, RuntimeSemaphoreConfig, RuntimeState};
|
||||
use clap::Parser;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "aether-tunnel-runtime-harness",
|
||||
about = "Standalone tunnel relay harness backed by aether-gateway tunnel runtime"
|
||||
)]
|
||||
struct Args {
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "0.0.0.0:8085",
|
||||
env = "AETHER_TUNNEL_STANDALONE_BIND"
|
||||
)]
|
||||
bind: String,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = 15,
|
||||
env = "AETHER_TUNNEL_STANDALONE_PING_INTERVAL"
|
||||
)]
|
||||
ping_interval: u64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = 2048,
|
||||
env = "AETHER_TUNNEL_STANDALONE_MAX_STREAMS"
|
||||
)]
|
||||
max_streams: usize,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = 512,
|
||||
env = "AETHER_TUNNEL_STANDALONE_OUTBOUND_QUEUE_CAPACITY"
|
||||
)]
|
||||
outbound_queue_capacity: usize,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "http://127.0.0.1:8084",
|
||||
env = "AETHER_TUNNEL_STANDALONE_APP_BASE_URL"
|
||||
)]
|
||||
app_base_url: String,
|
||||
|
||||
#[arg(long, env = "AETHER_TUNNEL_STANDALONE_MAX_IN_FLIGHT_REQUESTS")]
|
||||
max_in_flight_requests: Option<usize>,
|
||||
|
||||
#[arg(long, env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_LIMIT")]
|
||||
distributed_request_limit: Option<usize>,
|
||||
|
||||
#[arg(long, env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_REDIS_URL")]
|
||||
distributed_request_redis_url: Option<String>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_REDIS_KEY_PREFIX"
|
||||
)]
|
||||
distributed_request_redis_key_prefix: Option<String>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_LEASE_TTL_MS",
|
||||
default_value_t = 30_000
|
||||
)]
|
||||
distributed_request_lease_ttl_ms: u64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_RENEW_INTERVAL_MS",
|
||||
default_value_t = 10_000
|
||||
)]
|
||||
distributed_request_renew_interval_ms: u64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_COMMAND_TIMEOUT_MS",
|
||||
default_value_t = 1_000
|
||||
)]
|
||||
distributed_request_command_timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
init_service_runtime(ServiceRuntimeConfig::new(
|
||||
"aether-tunnel-standalone",
|
||||
"aether_gateway=info",
|
||||
))?;
|
||||
|
||||
let args = Args::parse();
|
||||
let outbound_queue_capacity = args.outbound_queue_capacity.clamp(8, 4096);
|
||||
let ping_interval = Duration::from_secs(args.ping_interval);
|
||||
let mut state = TunnelRuntimeState::new(
|
||||
TunnelControlPlaneClient::new(args.app_base_url),
|
||||
TunnelConnConfig {
|
||||
ping_interval,
|
||||
idle_timeout: Duration::from_secs(0),
|
||||
outbound_queue_capacity,
|
||||
},
|
||||
args.max_streams,
|
||||
)
|
||||
.with_request_concurrency_limit(args.max_in_flight_requests);
|
||||
|
||||
if let Some(limit) = args.distributed_request_limit.filter(|limit| *limit > 0) {
|
||||
let redis_url = args
|
||||
.distributed_request_redis_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"AETHER_TUNNEL_STANDALONE_DISTRIBUTED_REQUEST_REDIS_URL is required when distributed request limit is enabled",
|
||||
)
|
||||
})?;
|
||||
let runtime = RuntimeState::redis(
|
||||
RedisClientConfig {
|
||||
url: redis_url.to_string(),
|
||||
key_prefix: args
|
||||
.distributed_request_redis_key_prefix
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
},
|
||||
Some(args.distributed_request_command_timeout_ms.max(1)),
|
||||
)
|
||||
.await?;
|
||||
state = state.with_distributed_request_gate(runtime.semaphore(
|
||||
"tunnel_requests_distributed",
|
||||
limit,
|
||||
RuntimeSemaphoreConfig {
|
||||
lease_ttl_ms: args.distributed_request_lease_ttl_ms.max(1),
|
||||
renew_interval_ms: args.distributed_request_renew_interval_ms.max(1),
|
||||
command_timeout_ms: Some(args.distributed_request_command_timeout_ms.max(1)),
|
||||
},
|
||||
)?);
|
||||
}
|
||||
|
||||
let app = build_tunnel_runtime_router_with_state(state);
|
||||
let listener = tokio::net::TcpListener::bind(&args.bind).await?;
|
||||
info!(bind = %args.bind, "tunnel runtime harness started");
|
||||
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
pub(crate) use crate::handlers::admin::{
|
||||
admin_provider_ops_local_action_response, admin_provider_pool_config,
|
||||
build_internal_control_error_response, create_provider_oauth_catalog_key,
|
||||
find_duplicate_provider_oauth_key, maybe_build_local_admin_pool_response,
|
||||
maybe_build_local_admin_response, persist_provider_quota_refresh_state,
|
||||
provider_oauth_maintenance_endpoint_for_provider, provider_oauth_runtime_endpoint_for_provider,
|
||||
provider_quota_refresh_endpoint_for_provider, provider_type_supports_quota_refresh,
|
||||
reconcile_admin_fixed_provider_template_endpoints,
|
||||
refresh_provider_oauth_account_state_after_update, refresh_provider_pool_quota_locally,
|
||||
store_admin_provider_ops_balance_cache, update_existing_provider_oauth_catalog_key,
|
||||
AdminAppState, AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError,
|
||||
AdminRequestContext, AdminRouteRequest, AdminRouteResponse, AdminRouteResult,
|
||||
AdminStatsTimeRange, AdminStatsUsageFilter, OAUTH_ACCOUNT_BLOCK_PREFIX,
|
||||
OAUTH_REQUEST_FAILED_PREFIX,
|
||||
};
|
||||
|
||||
use crate::handlers::admin::{
|
||||
admin_stats_bad_request_response as admin_stats_bad_request_response_impl,
|
||||
parse_bounded_u32 as parse_bounded_u32_impl, round_to as round_to_impl,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
response::Response,
|
||||
};
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_security_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
crate::handlers::admin::maybe_build_local_admin_security_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_endpoint_health_status_payload(
|
||||
state: &AdminAppState<'_>,
|
||||
lookback_hours: u64,
|
||||
) -> Option<serde_json::Value> {
|
||||
crate::handlers::admin::build_admin_endpoint_health_status_payload(state, lookback_hours).await
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_video_tasks_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
crate::handlers::admin::maybe_build_local_admin_video_tasks_response(state, request_context)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_usage_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
crate::handlers::admin::maybe_build_local_admin_usage_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn admin_stats_bad_request_response(detail: String) -> Response<Body> {
|
||||
admin_stats_bad_request_response_impl(detail)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_bounded_u32(
|
||||
field: &str,
|
||||
value: &str,
|
||||
min: u32,
|
||||
max: u32,
|
||||
) -> Result<u32, String> {
|
||||
parse_bounded_u32_impl(field, value, min, max)
|
||||
}
|
||||
|
||||
pub(crate) fn round_to(value: f64, decimals: u32) -> f64 {
|
||||
round_to_impl(value, decimals)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
crate::handlers::admin::maybe_build_local_admin_provider_oauth_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_providers_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
crate::handlers::admin::maybe_build_local_admin_providers_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
pub(crate) mod private_envelope;
|
||||
|
||||
pub(crate) mod kiro {
|
||||
pub(crate) use crate::ai_serving::pure::KiroToClaudeCliStreamState;
|
||||
}
|
||||
|
||||
pub(crate) use crate::ai_serving::{
|
||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
|
||||
provider_adaptation_descriptor_for_envelope, provider_adaptation_descriptor_for_provider_type,
|
||||
provider_adaptation_requires_eventstream_accept,
|
||||
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
|
||||
};
|
||||
pub(crate) use kiro::KiroToClaudeCliStreamState;
|
||||
pub(crate) use private_envelope::{
|
||||
maybe_build_provider_private_stream_normalizer,
|
||||
maybe_normalize_provider_private_sync_report_payload,
|
||||
normalize_provider_private_report_context, normalize_provider_private_response_value,
|
||||
provider_private_response_allows_sync_finalize, transform_provider_private_stream_line,
|
||||
ProviderPrivateStreamNormalizer,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
#[path = "private_envelope/sync.rs"]
|
||||
mod sync;
|
||||
|
||||
pub(crate) use self::sync::maybe_normalize_provider_private_sync_report_payload;
|
||||
pub(crate) use crate::ai_serving::{
|
||||
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
|
||||
normalize_provider_private_response_value, provider_private_response_allows_sync_finalize,
|
||||
stream_body_contains_error_event, transform_provider_private_stream_line,
|
||||
ProviderPrivateStreamNormalizer,
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
use base64::Engine as _;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
||||
|
||||
use super::{
|
||||
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
|
||||
normalize_provider_private_response_value, provider_private_response_allows_sync_finalize,
|
||||
stream_body_contains_error_event, ProviderPrivateStreamNormalizer,
|
||||
};
|
||||
|
||||
pub(crate) fn maybe_normalize_provider_private_sync_report_payload(
|
||||
payload: &GatewaySyncReportRequest,
|
||||
) -> Result<Option<GatewaySyncReportRequest>, GatewayError> {
|
||||
let Some(report_context) = payload.report_context.as_ref() else {
|
||||
return Ok(Some(payload.clone()));
|
||||
};
|
||||
if !report_context
|
||||
.get("has_envelope")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(Some(payload.clone()));
|
||||
}
|
||||
if !provider_private_response_allows_sync_finalize(report_context) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut normalized = payload.clone();
|
||||
normalized.report_context = normalize_provider_private_report_context(Some(report_context));
|
||||
if let (Some(body_json), Some(context)) = (
|
||||
payload.body_json.as_ref(),
|
||||
normalized.report_context.as_mut(),
|
||||
) {
|
||||
maybe_attach_gemini_cli_v1internal_credits_context(report_context, body_json, context);
|
||||
}
|
||||
|
||||
if let Some(body_json) = payload.body_json.clone() {
|
||||
normalized.body_json = normalize_provider_private_response_value(body_json, report_context);
|
||||
if normalized.body_json.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(body_base64) = payload.body_base64.as_deref() {
|
||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(body_base64)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
let Some(normalized_bytes) =
|
||||
normalize_provider_private_stream_bytes(report_context, &body_bytes)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if stream_body_contains_error_event(&normalized_bytes) {
|
||||
return Ok(None);
|
||||
}
|
||||
normalized.body_base64 = (!normalized_bytes.is_empty())
|
||||
.then(|| base64::engine::general_purpose::STANDARD.encode(normalized_bytes));
|
||||
}
|
||||
|
||||
Ok(Some(normalized))
|
||||
}
|
||||
|
||||
fn maybe_attach_gemini_cli_v1internal_credits_context(
|
||||
original_report_context: &Value,
|
||||
body_json: &Value,
|
||||
normalized_report_context: &mut Value,
|
||||
) {
|
||||
if !original_report_context
|
||||
.get("envelope_name")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("gemini_cli:v1internal"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let mut credits = serde_json::Map::new();
|
||||
for (source, target) in [
|
||||
("remainingCredits", "remainingCredits"),
|
||||
("consumedCredits", "consumedCredits"),
|
||||
("traceId", "traceId"),
|
||||
] {
|
||||
if let Some(value) = body_json
|
||||
.get(source)
|
||||
.cloned()
|
||||
.filter(|value| !value.is_null())
|
||||
{
|
||||
credits.insert(target.to_string(), value);
|
||||
}
|
||||
}
|
||||
if credits.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(object) = normalized_report_context.as_object_mut() {
|
||||
object.insert(
|
||||
"gemini_cli_v1internal_credits".to_string(),
|
||||
Value::Object(credits),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_provider_private_stream_bytes(
|
||||
report_context: &Value,
|
||||
body: &[u8],
|
||||
) -> Result<Option<Vec<u8>>, GatewayError> {
|
||||
let Some(mut normalizer): Option<ProviderPrivateStreamNormalizer<'_>> =
|
||||
maybe_build_provider_private_stream_normalizer(Some(report_context))
|
||||
else {
|
||||
return Ok(Some(body.to_vec()));
|
||||
};
|
||||
let mut normalized = normalizer.push_chunk(body).map_err(GatewayError::from)?;
|
||||
normalized.extend(normalizer.finish().map_err(GatewayError::from)?);
|
||||
Ok(Some(normalized))
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
use crate::ai_serving::{is_json_request, GatewayControlDecision};
|
||||
|
||||
pub(crate) use crate::ai_serving::{
|
||||
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
|
||||
build_local_gemini_files_stream_attempt_source_for_kind,
|
||||
build_local_gemini_files_stream_plan_and_reports_for_kind,
|
||||
build_local_gemini_files_sync_attempt_source_for_kind,
|
||||
build_local_gemini_files_sync_plan_and_reports_for_kind,
|
||||
build_local_image_stream_attempt_source_for_kind,
|
||||
build_local_image_stream_plan_and_reports_for_kind,
|
||||
build_local_image_sync_attempt_source_for_kind,
|
||||
build_local_image_sync_plan_and_reports_for_kind,
|
||||
build_local_openai_chat_stream_attempt_source_for_kind,
|
||||
build_local_openai_chat_stream_plan_and_reports_for_kind,
|
||||
build_local_openai_chat_sync_attempt_source_for_kind,
|
||||
build_local_openai_chat_sync_plan_and_reports_for_kind,
|
||||
build_local_openai_responses_stream_attempt_source_for_kind,
|
||||
build_local_openai_responses_stream_plan_and_reports_for_kind,
|
||||
build_local_openai_responses_sync_attempt_source_for_kind,
|
||||
build_local_openai_responses_sync_plan_and_reports_for_kind,
|
||||
build_local_same_format_stream_attempt_source, build_local_same_format_stream_plan_and_reports,
|
||||
build_local_same_format_sync_attempt_source, build_local_same_format_sync_plan_and_reports,
|
||||
build_local_video_sync_attempt_source_for_kind,
|
||||
build_local_video_sync_plan_and_reports_for_kind,
|
||||
build_openai_responses_stream_plan_from_decision,
|
||||
build_openai_responses_sync_plan_from_decision, build_passthrough_sync_plan_from_decision,
|
||||
build_standard_family_stream_attempt_source, build_standard_family_stream_plan_and_reports,
|
||||
build_standard_family_sync_attempt_source, build_standard_family_sync_plan_and_reports,
|
||||
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
|
||||
maybe_build_stream_decision_payload, maybe_build_stream_plan_payload,
|
||||
maybe_build_sync_decision_payload, maybe_build_sync_plan_payload,
|
||||
set_local_openai_chat_execution_exhausted_diagnostic,
|
||||
set_local_openai_image_execution_exhausted_diagnostic,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::{
|
||||
maybe_bridge_standard_sync_json_to_stream, maybe_build_provider_private_stream_normalizer,
|
||||
maybe_build_stream_response_rewriter, maybe_build_sync_finalize_outcome,
|
||||
maybe_compile_sync_finalize_response, LocalCoreSyncFinalizeOutcome,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::{
|
||||
AiExecutionDecision, AiExecutionPlanPayload, AiStreamAttempt, AiSyncAttempt,
|
||||
};
|
||||
pub(crate) use aether_ai_formats::api::{
|
||||
build_core_error_body_for_client_format, convert_standard_chat_response,
|
||||
core_error_background_report_kind, core_error_default_client_api_format,
|
||||
core_success_background_report_kind, encode_kiro_sse_events,
|
||||
extract_provider_private_stream_error_body, implicit_sync_finalize_report_kind,
|
||||
is_core_error_finalize_kind, normalize_provider_private_report_context,
|
||||
normalize_provider_private_response_value, provider_private_response_allows_sync_finalize,
|
||||
resolve_claude_stream_spec, resolve_claude_sync_spec, resolve_gemini_stream_spec,
|
||||
resolve_gemini_sync_spec, resolve_local_image_stream_spec, resolve_local_image_sync_spec,
|
||||
resolve_local_same_format_stream_spec, resolve_local_same_format_sync_spec,
|
||||
resolve_openai_embedding_sync_spec, sanitize_request_path_and_query, AiControlPlanRequest,
|
||||
CanonicalContentPart, CanonicalStreamEvent, CanonicalStreamFrame, ClaudeClientEmitter,
|
||||
ExecutionRuntimeAuthContext, LocalCoreSyncErrorKind, LocalOpenAiImageSpec,
|
||||
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec, LocalStandardSourceFamily,
|
||||
LocalStandardSourceMode, LocalStandardSpec, OpenAIChatClientEmitter,
|
||||
OpenAIResponsesClientEmitter, StreamingStandardTerminalObserver, CLAUDE_CHAT_STREAM_PLAN_KIND,
|
||||
CLAUDE_CLI_STREAM_PLAN_KIND, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
|
||||
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_CHAT_STREAM_PLAN_KIND,
|
||||
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_EMBEDDING_SYNC_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND,
|
||||
GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND,
|
||||
OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND,
|
||||
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
|
||||
OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND,
|
||||
OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||
};
|
||||
pub(crate) use aether_ai_formats::protocol::stream::CanonicalUsage as StreamingCanonicalUsage;
|
||||
pub(crate) use aether_ai_formats::CODEX_RESPONSES_LITE_HEADER;
|
||||
|
||||
pub(crate) fn parse_direct_request_body(
|
||||
parts: &http::request::Parts,
|
||||
body_bytes: &axum::body::Bytes,
|
||||
) -> Option<(serde_json::Value, Option<String>)> {
|
||||
let is_json_request = is_json_request(&parts.headers);
|
||||
let body_bytes = if is_json_request {
|
||||
crate::ai_serving::decoded_request_body_bytes(&parts.headers, body_bytes.as_ref()).ok()?
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(body_bytes.as_ref())
|
||||
};
|
||||
aether_ai_formats::api::parse_direct_request_body(is_json_request, body_bytes.as_ref())
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_execution_runtime_stream_plan_kind(
|
||||
parts: &http::request::Parts,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Option<&'static str> {
|
||||
let plan_kind =
|
||||
aether_ai_formats::api::resolve_execution_runtime_stream_plan_kind_with_client_surface(
|
||||
decision.route_class.as_deref(),
|
||||
decision.route_family.as_deref(),
|
||||
decision.route_kind.as_deref(),
|
||||
decision.client_surface,
|
||||
decision.request_auth_channel.as_deref(),
|
||||
&parts.method,
|
||||
parts.uri.path(),
|
||||
)?;
|
||||
crate::ai_serving::plan_kind_matches_api_operation(plan_kind, true, decision.api_operation)
|
||||
.then_some(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_execution_runtime_sync_plan_kind(
|
||||
parts: &http::request::Parts,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Option<&'static str> {
|
||||
let plan_kind =
|
||||
aether_ai_formats::api::resolve_execution_runtime_sync_plan_kind_with_client_surface(
|
||||
decision.route_class.as_deref(),
|
||||
decision.route_family.as_deref(),
|
||||
decision.route_kind.as_deref(),
|
||||
decision.client_surface,
|
||||
decision.request_auth_channel.as_deref(),
|
||||
&parts.method,
|
||||
parts.uri.path(),
|
||||
)?;
|
||||
crate::ai_serving::plan_kind_matches_api_operation(plan_kind, false, decision.api_operation)
|
||||
.then_some(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn is_matching_stream_request(
|
||||
plan_kind: &str,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
) -> bool {
|
||||
crate::ai_serving::planner_is_matching_stream_request(plan_kind, parts, body_json, body_base64)
|
||||
}
|
||||
|
||||
pub(crate) fn supports_sync_execution_decision_kind(plan_kind: &str) -> bool {
|
||||
aether_ai_formats::api::supports_sync_execution_decision_kind(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn supports_stream_execution_decision_kind(plan_kind: &str) -> bool {
|
||||
aether_ai_formats::api::supports_stream_execution_decision_kind(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option<serde_json::Value> {
|
||||
aether_ai_formats::api::aggregate_openai_chat_stream_sync_response(body)
|
||||
}
|
||||
|
||||
pub(crate) fn aggregate_openai_responses_stream_sync_response(
|
||||
body: &[u8],
|
||||
) -> Option<serde_json::Value> {
|
||||
aether_ai_formats::api::aggregate_openai_responses_stream_sync_response(body)
|
||||
}
|
||||
|
||||
pub(crate) fn aggregate_claude_stream_sync_response(body: &[u8]) -> Option<serde_json::Value> {
|
||||
aether_ai_formats::api::aggregate_claude_stream_sync_response(body)
|
||||
}
|
||||
|
||||
pub(crate) fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<serde_json::Value> {
|
||||
aether_ai_formats::api::aggregate_gemini_stream_sync_response(body)
|
||||
}
|
||||
|
||||
pub(crate) fn gemini_generate_content_response_has_visible_output(
|
||||
body: &serde_json::Value,
|
||||
) -> bool {
|
||||
if aether_ai_formats::formats::gemini::generate_content::response::from_raw(body).is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
openai_chat_response_has_visible_output(body) || openai_responses_body_has_visible_output(body)
|
||||
}
|
||||
|
||||
fn openai_chat_response_has_visible_output(body: &serde_json::Value) -> bool {
|
||||
body.get("choices")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|choices| {
|
||||
choices.iter().any(|choice| {
|
||||
choice
|
||||
.get("message")
|
||||
.or_else(|| choice.get("delta"))
|
||||
.is_some_and(message_like_value_has_visible_output)
|
||||
|| value_has_non_empty_text(choice.get("text"))
|
||||
|| choice
|
||||
.get("finish_reason")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty() && value != "length")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn openai_responses_body_has_visible_output(body: &serde_json::Value) -> bool {
|
||||
body.get("output")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|items| {
|
||||
items.iter().any(|item| {
|
||||
item.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|kind| matches!(kind, "function_call" | "image_generation_call"))
|
||||
|| item
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|content| {
|
||||
content.iter().any(response_content_has_visible_output)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn message_like_value_has_visible_output(value: &serde_json::Value) -> bool {
|
||||
value_has_non_empty_text(value.get("content"))
|
||||
|| value
|
||||
.get("tool_calls")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|items| !items.is_empty())
|
||||
}
|
||||
|
||||
fn response_content_has_visible_output(value: &serde_json::Value) -> bool {
|
||||
value_has_non_empty_text(value.get("text"))
|
||||
|| value
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|kind| matches!(kind, "function_call" | "output_image"))
|
||||
}
|
||||
|
||||
fn value_has_non_empty_text(value: Option<&serde_json::Value>) -> bool {
|
||||
match value {
|
||||
Some(serde_json::Value::String(text)) => !text.trim().is_empty(),
|
||||
Some(serde_json::Value::Array(items)) => items.iter().any(|item| {
|
||||
value_has_non_empty_text(item.get("text"))
|
||||
|| value_has_non_empty_text(item.get("content"))
|
||||
|| item
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|kind| matches!(kind, "image_url" | "input_image"))
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_direct_request_body;
|
||||
use axum::body::Bytes;
|
||||
use axum::http::{header, Request};
|
||||
|
||||
#[test]
|
||||
fn parse_direct_request_body_reads_zstd_encoded_json_body() {
|
||||
let (parts, _) = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/responses")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::CONTENT_ENCODING, "zstd")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts();
|
||||
let encoded =
|
||||
zstd::stream::encode_all(br#"{"model":"gpt-5.4","stream":true}"#.as_slice(), 0)
|
||||
.expect("zstd body should encode");
|
||||
|
||||
let (body_json, body_base64) =
|
||||
parse_direct_request_body(&parts, &Bytes::from(encoded)).expect("body should parse");
|
||||
|
||||
assert_eq!(body_json["model"].as_str(), Some("gpt-5.4"));
|
||||
assert_eq!(body_json["stream"].as_bool(), Some(true));
|
||||
assert!(body_base64.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::Response;
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) use crate::ai_serving::api::{
|
||||
normalize_provider_private_response_value as unwrap_local_finalize_response_value,
|
||||
provider_private_response_allows_sync_finalize as local_finalize_allows_envelope,
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
build_generated_tool_call_id,
|
||||
build_local_success_background_report as build_local_success_background_report_impl,
|
||||
build_local_success_conversion_background_report as build_local_success_conversion_background_report_impl,
|
||||
canonicalize_tool_arguments,
|
||||
prepare_local_success_response_parts as prepare_local_success_response_parts_impl,
|
||||
GatewayControlDecision, LocalSyncReportParts,
|
||||
};
|
||||
use crate::api::response::build_client_response_from_parts;
|
||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
||||
|
||||
pub(crate) struct LocalCoreSyncFinalizeOutcome {
|
||||
pub(crate) response: Response<Body>,
|
||||
pub(crate) background_report: Option<GatewaySyncReportRequest>,
|
||||
}
|
||||
|
||||
fn build_local_success_response(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
status_code: u16,
|
||||
body_bytes: Vec<u8>,
|
||||
headers: BTreeMap<String, String>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
build_client_response_from_parts(
|
||||
status_code,
|
||||
&headers,
|
||||
Body::from(body_bytes),
|
||||
trace_id,
|
||||
Some(decision),
|
||||
)
|
||||
}
|
||||
|
||||
fn surface_report_parts_from_gateway(payload: &GatewaySyncReportRequest) -> LocalSyncReportParts {
|
||||
LocalSyncReportParts {
|
||||
trace_id: payload.trace_id.clone(),
|
||||
report_kind: payload.report_kind.clone(),
|
||||
report_context: payload.report_context.clone(),
|
||||
status_code: payload.status_code,
|
||||
headers: payload.headers.clone(),
|
||||
body_json: payload.body_json.clone(),
|
||||
client_body_json: payload.client_body_json.clone(),
|
||||
body_base64: payload.body_base64.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn gateway_report_from_surface(
|
||||
source: &GatewaySyncReportRequest,
|
||||
report: LocalSyncReportParts,
|
||||
) -> GatewaySyncReportRequest {
|
||||
GatewaySyncReportRequest {
|
||||
trace_id: report.trace_id,
|
||||
report_kind: report.report_kind,
|
||||
report_context: report.report_context,
|
||||
status_code: report.status_code,
|
||||
headers: report.headers,
|
||||
body_json: report.body_json,
|
||||
client_body_json: report.client_body_json,
|
||||
body_base64: report.body_base64,
|
||||
telemetry: source.telemetry.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_success_outcome(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
payload: &GatewaySyncReportRequest,
|
||||
body_json: Value,
|
||||
) -> Result<LocalCoreSyncFinalizeOutcome, GatewayError> {
|
||||
let report_headers = payload.headers.clone();
|
||||
let (body_bytes, response_headers) =
|
||||
prepare_local_success_response_parts_impl(&payload.headers, &body_json)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
let surface_payload = surface_report_parts_from_gateway(payload);
|
||||
let background_report =
|
||||
build_local_success_background_report_impl(&surface_payload, body_json, report_headers)
|
||||
.map(|report| gateway_report_from_surface(payload, report));
|
||||
build_local_success_outcome_with_report(
|
||||
trace_id,
|
||||
decision,
|
||||
payload.status_code,
|
||||
body_bytes,
|
||||
response_headers,
|
||||
background_report,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_success_outcome_with_report(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
status_code: u16,
|
||||
body_bytes: Vec<u8>,
|
||||
headers: BTreeMap<String, String>,
|
||||
background_report: Option<GatewaySyncReportRequest>,
|
||||
) -> Result<LocalCoreSyncFinalizeOutcome, GatewayError> {
|
||||
let response =
|
||||
build_local_success_response(trace_id, decision, status_code, body_bytes, headers)?;
|
||||
Ok(LocalCoreSyncFinalizeOutcome {
|
||||
response,
|
||||
background_report,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_success_outcome_with_conversion_report(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
payload: &GatewaySyncReportRequest,
|
||||
client_body_json: Value,
|
||||
provider_body_json: Value,
|
||||
) -> Result<LocalCoreSyncFinalizeOutcome, GatewayError> {
|
||||
let (body_bytes, response_headers) =
|
||||
prepare_local_success_response_parts_impl(&payload.headers, &client_body_json)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
let surface_payload = surface_report_parts_from_gateway(payload);
|
||||
let report_payload = build_local_success_conversion_background_report_impl(
|
||||
&surface_payload,
|
||||
client_body_json,
|
||||
provider_body_json,
|
||||
)
|
||||
.map(|report| gateway_report_from_surface(payload, report));
|
||||
|
||||
build_local_success_outcome_with_report(
|
||||
trace_id,
|
||||
decision,
|
||||
payload.status_code,
|
||||
body_bytes,
|
||||
response_headers,
|
||||
report_payload,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::Response;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
||||
|
||||
#[path = "stream_rewrite.rs"]
|
||||
pub(crate) mod stream;
|
||||
#[path = "sync_finalize.rs"]
|
||||
pub(crate) mod sync;
|
||||
#[path = "sync_to_stream.rs"]
|
||||
pub(crate) mod sync_to_stream;
|
||||
|
||||
pub(crate) use stream::LocalStreamRewriter;
|
||||
pub(crate) use sync::LocalCoreSyncFinalizeOutcome;
|
||||
pub(crate) use sync_to_stream::{
|
||||
maybe_bridge_standard_sync_json_to_stream, SyncToStreamBridgeOutcome,
|
||||
};
|
||||
|
||||
pub(crate) fn maybe_build_sync_finalize_outcome(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
payload: &GatewaySyncReportRequest,
|
||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
||||
sync::maybe_build_local_core_sync_finalize_response(trace_id, decision, payload)
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_compile_sync_finalize_response(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
payload: &GatewaySyncReportRequest,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
Ok(
|
||||
maybe_build_sync_finalize_outcome(trace_id, decision, payload)?
|
||||
.map(|outcome| outcome.response),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_build_stream_response_rewriter(
|
||||
report_context: Option<&Value>,
|
||||
) -> Option<LocalStreamRewriter<'_>> {
|
||||
stream::maybe_build_local_stream_rewriter(report_context)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::{
|
||||
maybe_build_ai_surface_stream_rewriter, AiSurfaceFinalizeError, AiSurfaceStreamRewriter,
|
||||
ResponseHistoryRecord,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
|
||||
pub(crate) struct LocalStreamRewriter<'a> {
|
||||
inner: AiSurfaceStreamRewriter<'a>,
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_build_local_stream_rewriter<'a>(
|
||||
report_context: Option<&'a Value>,
|
||||
) -> Option<LocalStreamRewriter<'a>> {
|
||||
maybe_build_ai_surface_stream_rewriter(report_context)
|
||||
.map(|inner| LocalStreamRewriter { inner })
|
||||
}
|
||||
|
||||
impl LocalStreamRewriter<'_> {
|
||||
pub(crate) fn push_chunk(&mut self, chunk: &[u8]) -> Result<Vec<u8>, GatewayError> {
|
||||
self.inner.push_chunk(chunk).map_err(map_surface_error)
|
||||
}
|
||||
|
||||
pub(crate) fn finish(&mut self) -> Result<Vec<u8>, GatewayError> {
|
||||
self.inner.finish().map_err(map_surface_error)
|
||||
}
|
||||
|
||||
pub(crate) fn take_response_history_record(&mut self) -> Option<ResponseHistoryRecord> {
|
||||
self.inner.take_response_history_record()
|
||||
}
|
||||
}
|
||||
|
||||
fn map_surface_error(error: AiSurfaceFinalizeError) -> GatewayError {
|
||||
error.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests_stream.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,114 @@
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::ai_serving::{build_generated_tool_call_id, canonicalize_tool_arguments};
|
||||
use crate::{usage::GatewaySyncReportRequest, GatewayError};
|
||||
|
||||
pub(crate) use crate::ai_serving::finalize::common::{
|
||||
build_local_success_outcome, build_local_success_outcome_with_conversion_report,
|
||||
local_finalize_allows_envelope, unwrap_local_finalize_response_value,
|
||||
LocalCoreSyncFinalizeOutcome,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::finalize::standard::{
|
||||
maybe_build_standard_sync_finalize_product_from_normalized_payload,
|
||||
StandardSyncFinalizeNormalizedProduct,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::{
|
||||
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
|
||||
aggregate_openai_chat_stream_sync_response, aggregate_openai_responses_stream_sync_response,
|
||||
maybe_build_openai_image_sync_finalize_product,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::{
|
||||
convert_claude_chat_response_to_openai_chat, convert_claude_response_to_openai_responses,
|
||||
convert_gemini_chat_response_to_openai_chat, convert_gemini_response_to_openai_responses,
|
||||
};
|
||||
|
||||
pub(crate) fn maybe_build_local_core_sync_finalize_response(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
payload: &GatewaySyncReportRequest,
|
||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
||||
if let Some(outcome) =
|
||||
maybe_build_local_openai_image_sync_finalize_response(trace_id, decision, payload)?
|
||||
{
|
||||
return Ok(Some(outcome));
|
||||
}
|
||||
|
||||
let Some(normalized_payload) =
|
||||
crate::ai_serving::adaptation::private_envelope::maybe_normalize_provider_private_sync_report_payload(payload)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let payload = &normalized_payload;
|
||||
let Some(report_context) = payload.report_context.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !local_finalize_allows_envelope(report_context) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(product) = maybe_build_standard_sync_finalize_product_from_normalized_payload(
|
||||
payload.report_kind.as_str(),
|
||||
payload.status_code,
|
||||
Some(report_context),
|
||||
payload.body_json.as_ref(),
|
||||
payload.body_base64.as_deref(),
|
||||
)
|
||||
.map_err(GatewayError::from)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match product {
|
||||
StandardSyncFinalizeNormalizedProduct::SuccessBody(body_json) => {
|
||||
let Some(body_json) = unwrap_local_finalize_response_value(body_json, report_context)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(build_local_success_outcome(
|
||||
trace_id, decision, payload, body_json,
|
||||
)?))
|
||||
}
|
||||
StandardSyncFinalizeNormalizedProduct::CrossFormat(product) => {
|
||||
let Some(provider_body_json) =
|
||||
unwrap_local_finalize_response_value(product.provider_body_json, report_context)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(build_local_success_outcome_with_conversion_report(
|
||||
trace_id,
|
||||
decision,
|
||||
payload,
|
||||
product.client_body_json,
|
||||
provider_body_json,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_build_local_openai_image_sync_finalize_response(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
payload: &GatewaySyncReportRequest,
|
||||
) -> Result<Option<LocalCoreSyncFinalizeOutcome>, GatewayError> {
|
||||
let Some(product) = maybe_build_openai_image_sync_finalize_product(
|
||||
payload.report_kind.as_str(),
|
||||
payload.status_code,
|
||||
payload.report_context.as_ref(),
|
||||
payload.body_json.as_ref(),
|
||||
payload.body_base64.as_deref(),
|
||||
)
|
||||
.map_err(GatewayError::from)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(build_local_success_outcome_with_conversion_report(
|
||||
trace_id,
|
||||
decision,
|
||||
payload,
|
||||
product.client_body_json,
|
||||
product.provider_body_json,
|
||||
)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests_sync.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,20 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::GatewayError;
|
||||
|
||||
pub(crate) use crate::ai_serving::pure::SyncToStreamBridgeOutcome;
|
||||
|
||||
pub(crate) fn maybe_bridge_standard_sync_json_to_stream(
|
||||
provider_body_json: &Value,
|
||||
provider_api_format: &str,
|
||||
client_api_format: &str,
|
||||
report_context: Option<&Value>,
|
||||
) -> Result<Option<SyncToStreamBridgeOutcome>, GatewayError> {
|
||||
crate::ai_serving::pure::maybe_bridge_standard_sync_json_to_stream(
|
||||
provider_body_json,
|
||||
provider_api_format,
|
||||
client_api_format,
|
||||
report_context,
|
||||
)
|
||||
.map_err(GatewayError::from)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub(crate) mod common;
|
||||
pub(crate) mod internal;
|
||||
pub(crate) mod sse;
|
||||
pub(crate) mod standard;
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::{
|
||||
encode_done_sse, encode_json_sse as encode_json_sse_impl, map_claude_stop_reason,
|
||||
AiSurfaceFinalizeError,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
|
||||
fn map_error(err: AiSurfaceFinalizeError) -> GatewayError {
|
||||
err.into()
|
||||
}
|
||||
|
||||
pub(crate) fn encode_json_sse(event: Option<&str>, value: &Value) -> Result<Vec<u8>, GatewayError> {
|
||||
encode_json_sse_impl(event, value).map_err(map_error)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Standard finalize surface for standard contract sync/stream compilation.
|
||||
|
||||
pub(crate) use crate::ai_serving::{
|
||||
aggregate_standard_chat_stream_sync_response, aggregate_standard_cli_stream_sync_response,
|
||||
build_openai_responses_response, convert_claude_chat_response_to_openai_chat,
|
||||
convert_claude_response_to_openai_responses, convert_gemini_chat_response_to_openai_chat,
|
||||
convert_gemini_response_to_openai_responses, convert_openai_chat_response_to_claude_chat,
|
||||
convert_openai_chat_response_to_gemini_chat, convert_openai_chat_response_to_openai_responses,
|
||||
convert_openai_responses_response_to_openai_chat, convert_standard_chat_response,
|
||||
convert_standard_cli_response,
|
||||
maybe_build_openai_chat_cross_format_sync_product_from_normalized_payload,
|
||||
maybe_build_openai_responses_cross_format_sync_product_from_normalized_payload,
|
||||
maybe_build_openai_responses_same_family_sync_body_from_normalized_payload,
|
||||
maybe_build_standard_cross_format_sync_product,
|
||||
maybe_build_standard_cross_format_sync_product_from_normalized_payload,
|
||||
maybe_build_standard_same_format_sync_body_from_normalized_payload,
|
||||
maybe_build_standard_sync_finalize_product_from_normalized_payload,
|
||||
StandardCrossFormatSyncProduct, StandardSyncFinalizeNormalizedProduct,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
mod adaptation;
|
||||
pub(crate) mod api;
|
||||
mod finalize;
|
||||
mod planner;
|
||||
mod pure;
|
||||
mod response_history;
|
||||
pub(crate) mod transport;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Response, Uri};
|
||||
|
||||
use crate::{usage::GatewaySyncReportRequest, AppState, GatewayError};
|
||||
|
||||
pub(crate) use self::adaptation::{
|
||||
maybe_build_provider_private_stream_normalizer, ProviderPrivateStreamNormalizer,
|
||||
};
|
||||
pub(crate) use self::api::{
|
||||
gemini_generate_content_response_has_visible_output, CODEX_RESPONSES_LITE_HEADER,
|
||||
};
|
||||
pub(crate) use self::finalize::common::LocalCoreSyncFinalizeOutcome;
|
||||
pub(crate) use self::finalize::internal::{
|
||||
maybe_bridge_standard_sync_json_to_stream, maybe_build_stream_response_rewriter,
|
||||
maybe_build_sync_finalize_outcome, maybe_compile_sync_finalize_response,
|
||||
SyncToStreamBridgeOutcome,
|
||||
};
|
||||
pub(crate) use self::planner::{
|
||||
apply_local_runtime_candidate_terminal_reason, build_gemini_stream_plan_from_decision,
|
||||
build_gemini_sync_plan_from_decision, build_local_gemini_files_stream_attempt_source_for_kind,
|
||||
build_local_gemini_files_stream_plan_and_reports_for_kind,
|
||||
build_local_gemini_files_sync_attempt_source_for_kind,
|
||||
build_local_gemini_files_sync_plan_and_reports_for_kind,
|
||||
build_local_image_stream_attempt_source_for_kind,
|
||||
build_local_image_stream_plan_and_reports_for_kind,
|
||||
build_local_image_sync_attempt_source_for_kind,
|
||||
build_local_image_sync_plan_and_reports_for_kind,
|
||||
build_local_openai_chat_stream_attempt_source_for_kind,
|
||||
build_local_openai_chat_stream_plan_and_reports_for_kind,
|
||||
build_local_openai_chat_sync_attempt_source_for_kind,
|
||||
build_local_openai_chat_sync_plan_and_reports_for_kind,
|
||||
build_local_openai_responses_stream_attempt_source_for_kind,
|
||||
build_local_openai_responses_stream_plan_and_reports_for_kind,
|
||||
build_local_openai_responses_sync_attempt_source_for_kind,
|
||||
build_local_openai_responses_sync_plan_and_reports_for_kind,
|
||||
build_local_same_format_stream_attempt_source, build_local_same_format_stream_plan_and_reports,
|
||||
build_local_same_format_sync_attempt_source, build_local_same_format_sync_plan_and_reports,
|
||||
build_local_video_sync_attempt_source_for_kind,
|
||||
build_local_video_sync_plan_and_reports_for_kind,
|
||||
build_openai_responses_stream_plan_from_decision,
|
||||
build_openai_responses_sync_plan_from_decision, build_passthrough_sync_plan_from_decision,
|
||||
build_provider_key_pool_score_upsert, build_standard_family_stream_attempt_source,
|
||||
build_standard_family_stream_plan_and_reports, build_standard_family_sync_attempt_source,
|
||||
build_standard_family_sync_plan_and_reports, build_standard_stream_plan_from_decision,
|
||||
build_standard_sync_plan_from_decision, candidate_auth_channel_skip_reason,
|
||||
codex_model_capabilities_for_transport, extract_pool_sticky_session_token,
|
||||
maybe_build_stream_decision_payload, maybe_build_stream_plan_payload,
|
||||
maybe_build_sync_decision_payload, maybe_build_sync_plan_payload,
|
||||
planner_is_matching_stream_request, provider_key_pool_score_id, provider_key_pool_score_scope,
|
||||
read_candidate_transport_snapshot, record_local_runtime_candidate_skip_reason,
|
||||
resolve_tunnel_scheduler_affinity_context, resolve_upstream_is_stream_for_provider,
|
||||
set_local_openai_chat_execution_exhausted_diagnostic,
|
||||
set_local_openai_image_execution_exhausted_diagnostic, validate_final_openai_provider_request,
|
||||
CandidateFailureDiagnostic, CandidateFailureDiagnosticKind, EligibleLocalExecutionCandidate,
|
||||
GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot, LocalExecutionAttemptSource,
|
||||
LocalExecutionCandidateKind, LocalResolvedOAuthRequestAuth, PlannerAppState,
|
||||
SkippedLocalExecutionCandidate,
|
||||
};
|
||||
pub(crate) use self::pure::*;
|
||||
pub(crate) use self::response_history::{
|
||||
hydrate_openai_response_history, persist_converted_response_history,
|
||||
persist_response_history_record,
|
||||
};
|
||||
pub(crate) use self::transport::{
|
||||
append_transport_diagnostics_to_value, build_request_trace_proxy_value,
|
||||
candidate_common_transport_skip_reason, candidate_transport_pair_skip_reason,
|
||||
request_conversion_direct_auth, request_conversion_enabled_for_transport,
|
||||
request_conversion_transport_supported, request_conversion_transport_unsupported_reason,
|
||||
request_pair_allowed_for_transport, request_pair_direct_auth,
|
||||
request_pair_transport_unsupported_reason, CandidateTransportPolicyFacts,
|
||||
};
|
||||
pub(crate) use crate::control::{GatewayControlDecision, GatewayCredentialCarrier};
|
||||
pub(crate) use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
||||
pub(crate) use crate::headers::RequestOrigin;
|
||||
pub(crate) use aether_ai_serving::{
|
||||
ai_local_execution_contract_for_formats, augment_sync_report_context,
|
||||
build_ai_report_context_original_request_echo as build_report_context_original_request_echo,
|
||||
extract_ai_gemini_model_from_path as extract_gemini_model_from_path,
|
||||
generic_decision_missing_exact_provider_request as generic_decision_missing_exact_provider_request_impl,
|
||||
AiExecutionDecision, AiExecutionPlanPayload, AiStreamAttempt, AiSyncAttempt,
|
||||
};
|
||||
|
||||
pub(crate) fn build_provider_transport_request_url(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
provider_api_format: &str,
|
||||
mapped_model: Option<&str>,
|
||||
upstream_is_stream: bool,
|
||||
request_query: Option<&str>,
|
||||
kiro_api_region: Option<&str>,
|
||||
api_operation: Option<ApiOperation>,
|
||||
) -> Option<String> {
|
||||
self::transport::build_transport_request_url(
|
||||
transport,
|
||||
self::transport::TransportRequestUrlParams {
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
request_query,
|
||||
kiro_api_region,
|
||||
api_operation,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_provider_transport_request_url_for_request_body(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
provider_api_format: &str,
|
||||
mapped_model: Option<&str>,
|
||||
upstream_is_stream: bool,
|
||||
request_query: Option<&str>,
|
||||
kiro_api_region: Option<&str>,
|
||||
api_operation: Option<ApiOperation>,
|
||||
provider_request_body: Option<&serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
self::transport::build_transport_request_url_for_request_body(
|
||||
transport,
|
||||
self::transport::TransportRequestUrlParams {
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
request_query,
|
||||
kiro_api_region,
|
||||
api_operation,
|
||||
},
|
||||
provider_request_body,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_execution_runtime_auth_context(
|
||||
state: &AppState,
|
||||
decision: &GatewayControlDecision,
|
||||
headers: &http::HeaderMap,
|
||||
uri: &Uri,
|
||||
trace_id: &str,
|
||||
) -> Result<Option<crate::control::GatewayControlAuthContext>, GatewayError> {
|
||||
crate::control::resolve_execution_runtime_auth_context(state, decision, headers, uri, trace_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn collect_control_headers(
|
||||
headers: &http::HeaderMap,
|
||||
) -> std::collections::BTreeMap<String, String> {
|
||||
crate::headers::collect_control_headers(headers)
|
||||
}
|
||||
|
||||
pub(crate) fn request_origin_from_headers(headers: &http::HeaderMap) -> RequestOrigin {
|
||||
crate::headers::request_origin_from_headers(headers)
|
||||
}
|
||||
|
||||
pub(crate) fn request_origin_from_parts(parts: &http::request::Parts) -> RequestOrigin {
|
||||
crate::headers::request_origin_from_parts(parts)
|
||||
}
|
||||
|
||||
pub(crate) fn is_json_request(headers: &http::HeaderMap) -> bool {
|
||||
crate::headers::is_json_request(headers)
|
||||
}
|
||||
|
||||
pub(crate) fn decoded_request_body_bytes<'a>(
|
||||
headers: &http::HeaderMap,
|
||||
body_bytes: &'a [u8],
|
||||
) -> Result<std::borrow::Cow<'a, [u8]>, crate::headers::RequestBodyNormalizationError> {
|
||||
crate::headers::decoded_request_body_bytes(headers, body_bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn tls_fingerprint_from_headers(headers: &http::HeaderMap) -> Option<serde_json::Value> {
|
||||
crate::headers::tls_fingerprint_from_headers(headers)
|
||||
}
|
||||
|
||||
pub(crate) fn build_execution_runtime_auth_context(
|
||||
auth_context: &crate::control::GatewayControlAuthContext,
|
||||
) -> ExecutionRuntimeAuthContext {
|
||||
ExecutionRuntimeAuthContext {
|
||||
user_id: auth_context.user_id.clone(),
|
||||
api_key_id: auth_context.api_key_id.clone(),
|
||||
username: auth_context.username.clone(),
|
||||
api_key_name: auth_context.api_key_name.clone(),
|
||||
balance_remaining: auth_context.balance_remaining,
|
||||
access_allowed: auth_context.access_allowed,
|
||||
api_key_is_standalone: auth_context.api_key_is_standalone,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_decision_execution_runtime_auth_context(
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Option<ExecutionRuntimeAuthContext> {
|
||||
decision
|
||||
.auth_context
|
||||
.as_ref()
|
||||
.map(build_execution_runtime_auth_context)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_local_decision_execution_runtime_auth_context(
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Option<ExecutionRuntimeAuthContext> {
|
||||
resolve_decision_execution_runtime_auth_context(decision).filter(|auth_context| {
|
||||
auth_context.access_allowed
|
||||
&& !auth_context.user_id.trim().is_empty()
|
||||
&& !auth_context.api_key_id.trim().is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn generic_decision_missing_exact_provider_request(
|
||||
payload: &AiExecutionDecision,
|
||||
) -> bool {
|
||||
if !generic_decision_missing_exact_provider_request_impl(payload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
decision_kind = payload.decision_kind.as_deref().unwrap_or_default(),
|
||||
provider_api_format = payload.provider_api_format.as_deref().unwrap_or_default(),
|
||||
client_api_format = payload.client_api_format.as_deref().unwrap_or_default(),
|
||||
"gateway generic decision missing exact provider request; falling back to plan"
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_build_local_sync_finalize_response(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
payload: &GatewaySyncReportRequest,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
crate::execution_runtime::maybe_build_local_sync_finalize_response(trace_id, decision, payload)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::transport::antigravity::{
|
||||
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
|
||||
classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
|
||||
AntigravityRequestAuth, AntigravityRequestAuthUnsupportedReason,
|
||||
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport,
|
||||
AntigravityRequestSideUnsupportedReason,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
build_standard_provider_request_headers, GatewayProviderTransportSnapshot,
|
||||
StandardProviderRequestHeaders, StandardProviderRequestHeadersInput,
|
||||
};
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) const ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME: &str = "antigravity:v1internal";
|
||||
|
||||
pub(crate) enum AntigravityV1InternalRequestError {
|
||||
TransportUnsupported,
|
||||
EnvelopeUnsupported,
|
||||
UpstreamUrlUnavailable,
|
||||
HeaderRulesApplyFailed,
|
||||
}
|
||||
|
||||
pub(crate) struct AntigravityV1InternalRequestInput<'a> {
|
||||
pub(crate) state: &'a AppState,
|
||||
pub(crate) parts: &'a http::request::Parts,
|
||||
pub(crate) transport: &'a Arc<GatewayProviderTransportSnapshot>,
|
||||
pub(crate) trace_id: &'a str,
|
||||
pub(crate) mapped_model: &'a str,
|
||||
pub(crate) provider_api_format: &'a str,
|
||||
pub(crate) auth_header: &'a str,
|
||||
pub(crate) auth_value: &'a str,
|
||||
pub(crate) request_headers: &'a http::HeaderMap,
|
||||
pub(crate) original_request_body: &'a Value,
|
||||
pub(crate) gemini_request_body: &'a Value,
|
||||
pub(crate) upstream_is_stream: bool,
|
||||
pub(crate) same_format: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct AntigravityV1InternalRequest {
|
||||
pub(crate) transport: Arc<GatewayProviderTransportSnapshot>,
|
||||
pub(crate) body: Value,
|
||||
pub(crate) headers: StandardProviderRequestHeaders,
|
||||
pub(crate) upstream_url: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn build_antigravity_v1internal_provider_request(
|
||||
input: AntigravityV1InternalRequestInput<'_>,
|
||||
) -> Result<AntigravityV1InternalRequest, AntigravityV1InternalRequestError> {
|
||||
let payload = build_antigravity_v1internal_payload(
|
||||
input.state,
|
||||
input.transport,
|
||||
input.trace_id,
|
||||
input.mapped_model,
|
||||
input.gemini_request_body,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let upstream_url = crate::ai_serving::build_provider_transport_request_url_for_request_body(
|
||||
&payload.transport,
|
||||
input.provider_api_format,
|
||||
Some(input.mapped_model),
|
||||
input.upstream_is_stream,
|
||||
input.parts.uri.query(),
|
||||
None,
|
||||
None,
|
||||
Some(&payload.body),
|
||||
)
|
||||
.ok_or(AntigravityV1InternalRequestError::UpstreamUrlUnavailable)?;
|
||||
|
||||
let extra_headers: BTreeMap<String, String> =
|
||||
build_antigravity_static_identity_headers(&payload.auth);
|
||||
let mut headers =
|
||||
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
|
||||
transport: &payload.transport,
|
||||
provider_api_format: input.provider_api_format,
|
||||
same_format: input.same_format,
|
||||
headers: input.request_headers,
|
||||
auth_header: input.auth_header,
|
||||
auth_value: input.auth_value,
|
||||
extra_headers: &extra_headers,
|
||||
header_rules: payload.transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &payload.body,
|
||||
original_request_body: input.original_request_body,
|
||||
upstream_is_stream: input.upstream_is_stream,
|
||||
})
|
||||
.ok_or(AntigravityV1InternalRequestError::HeaderRulesApplyFailed)?;
|
||||
headers
|
||||
.headers
|
||||
.insert("accept".to_string(), "text/event-stream".to_string());
|
||||
|
||||
Ok(AntigravityV1InternalRequest {
|
||||
transport: payload.transport,
|
||||
body: payload.body,
|
||||
headers,
|
||||
upstream_url,
|
||||
})
|
||||
}
|
||||
|
||||
struct AntigravityV1InternalPayload {
|
||||
transport: Arc<GatewayProviderTransportSnapshot>,
|
||||
auth: AntigravityRequestAuth,
|
||||
body: Value,
|
||||
}
|
||||
|
||||
async fn build_antigravity_v1internal_payload(
|
||||
state: &AppState,
|
||||
transport: &Arc<GatewayProviderTransportSnapshot>,
|
||||
trace_id: &str,
|
||||
mapped_model: &str,
|
||||
gemini_request_body: &Value,
|
||||
) -> Result<AntigravityV1InternalPayload, AntigravityV1InternalRequestError> {
|
||||
let mut resolved_transport = Arc::clone(transport);
|
||||
let mut antigravity_support = classify_local_antigravity_request_support(
|
||||
&resolved_transport,
|
||||
gemini_request_body,
|
||||
AntigravityEnvelopeRequestType::Agent,
|
||||
);
|
||||
|
||||
if matches!(
|
||||
antigravity_support,
|
||||
AntigravityRequestSideSupport::Unsupported(
|
||||
AntigravityRequestSideUnsupportedReason::UnsupportedAuth(
|
||||
AntigravityRequestAuthUnsupportedReason::MissingProjectId
|
||||
)
|
||||
)
|
||||
) {
|
||||
if let Some(hydrated) = state
|
||||
.hydrate_antigravity_project_metadata_for_transport(&resolved_transport)
|
||||
.await
|
||||
{
|
||||
resolved_transport = Arc::new(hydrated);
|
||||
antigravity_support = classify_local_antigravity_request_support(
|
||||
&resolved_transport,
|
||||
gemini_request_body,
|
||||
AntigravityEnvelopeRequestType::Agent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let auth = match antigravity_support {
|
||||
AntigravityRequestSideSupport::Supported(spec) => spec.auth,
|
||||
AntigravityRequestSideSupport::Unsupported(_) => {
|
||||
return Err(AntigravityV1InternalRequestError::TransportUnsupported);
|
||||
}
|
||||
};
|
||||
|
||||
let body = match build_antigravity_safe_v1internal_request(
|
||||
&auth,
|
||||
trace_id,
|
||||
mapped_model,
|
||||
gemini_request_body,
|
||||
AntigravityEnvelopeRequestType::Agent,
|
||||
) {
|
||||
AntigravityRequestEnvelopeSupport::Supported(envelope) => envelope,
|
||||
AntigravityRequestEnvelopeSupport::Unsupported(_) => {
|
||||
return Err(AntigravityV1InternalRequestError::EnvelopeUnsupported);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(AntigravityV1InternalPayload {
|
||||
transport: resolved_transport,
|
||||
auth,
|
||||
body,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
use aether_routing_core::ResolvedRoutingPolicy;
|
||||
use aether_scheduler_core::{
|
||||
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session_and_scope,
|
||||
ClientSessionAffinity, SchedulerAffinityScope, SchedulerAffinityTarget,
|
||||
SchedulerMinimalCandidateSelectionCandidate,
|
||||
};
|
||||
|
||||
use crate::ai_serving::{GatewayAuthApiKeySnapshot, PlannerAppState};
|
||||
use crate::scheduler::affinity::SCHEDULER_AFFINITY_TTL;
|
||||
|
||||
const PLANNER_SCHEDULER_AFFINITY_MAX_ENTRIES: usize = 10_000;
|
||||
|
||||
pub(crate) fn has_explicit_session_affinity(
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
) -> bool {
|
||||
client_session_affinity.is_some_and(ClientSessionAffinity::has_session_key)
|
||||
}
|
||||
|
||||
pub(crate) fn read_cached_scheduler_affinity_target(
|
||||
state: PlannerAppState<'_>,
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
client_api_format: &str,
|
||||
requested_model: Option<&str>,
|
||||
routing_policy: Option<&ResolvedRoutingPolicy>,
|
||||
) -> Option<SchedulerAffinityTarget> {
|
||||
if !has_explicit_session_affinity(client_session_affinity) {
|
||||
return None;
|
||||
}
|
||||
let requested_model = requested_model
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let api_key_id = auth_snapshot
|
||||
.map(|snapshot| snapshot.api_key_id.trim())
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let affinity_scope = scheduler_affinity_scope_for_routing_policy(routing_policy);
|
||||
let cache_key =
|
||||
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session_and_scope(
|
||||
api_key_id,
|
||||
client_api_format,
|
||||
requested_model,
|
||||
client_session_affinity,
|
||||
affinity_scope.as_ref(),
|
||||
)?;
|
||||
|
||||
state
|
||||
.app()
|
||||
.read_scheduler_affinity_target(&cache_key, SCHEDULER_AFFINITY_TTL)
|
||||
}
|
||||
|
||||
pub(crate) fn remember_scheduler_affinity_for_candidate(
|
||||
state: PlannerAppState<'_>,
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
client_api_format: &str,
|
||||
requested_model: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
) {
|
||||
if !has_explicit_session_affinity(client_session_affinity) {
|
||||
return;
|
||||
}
|
||||
remember_scheduler_affinity_for_candidate_at_epoch(
|
||||
state,
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
client_api_format,
|
||||
requested_model,
|
||||
candidate,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn remember_scheduler_affinity_for_candidate_at_epoch(
|
||||
state: PlannerAppState<'_>,
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
client_api_format: &str,
|
||||
requested_model: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
expected_epoch: Option<u64>,
|
||||
) {
|
||||
remember_scheduler_affinity_for_candidate_with_scope_at_epoch(
|
||||
state,
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
client_api_format,
|
||||
requested_model,
|
||||
candidate,
|
||||
None,
|
||||
expected_epoch,
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn remember_scheduler_affinity_for_candidate_with_routing_policy_at_epoch(
|
||||
state: PlannerAppState<'_>,
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
client_api_format: &str,
|
||||
requested_model: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
routing_policy: Option<&ResolvedRoutingPolicy>,
|
||||
expected_epoch: Option<u64>,
|
||||
) {
|
||||
let affinity_scope = scheduler_affinity_scope_for_routing_policy(routing_policy);
|
||||
remember_scheduler_affinity_for_candidate_with_scope_at_epoch(
|
||||
state,
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
client_api_format,
|
||||
requested_model,
|
||||
candidate,
|
||||
affinity_scope.as_ref(),
|
||||
expected_epoch,
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn remember_scheduler_affinity_for_candidate_with_scope_at_epoch(
|
||||
state: PlannerAppState<'_>,
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
client_api_format: &str,
|
||||
requested_model: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
affinity_scope: Option<&SchedulerAffinityScope>,
|
||||
expected_epoch: Option<u64>,
|
||||
) {
|
||||
if !has_explicit_session_affinity(client_session_affinity) {
|
||||
return;
|
||||
}
|
||||
let Some(api_key_id) = auth_snapshot
|
||||
.map(|snapshot| snapshot.api_key_id.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(cache_key) =
|
||||
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session_and_scope(
|
||||
api_key_id,
|
||||
client_api_format,
|
||||
requested_model,
|
||||
client_session_affinity,
|
||||
affinity_scope,
|
||||
)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let _ = state.app().remember_scheduler_affinity_target_for_epoch(
|
||||
&cache_key,
|
||||
SchedulerAffinityTarget {
|
||||
provider_id: candidate.provider_id.clone(),
|
||||
endpoint_id: candidate.endpoint_id.clone(),
|
||||
key_id: candidate.key_id.clone(),
|
||||
},
|
||||
SCHEDULER_AFFINITY_TTL,
|
||||
PLANNER_SCHEDULER_AFFINITY_MAX_ENTRIES,
|
||||
expected_epoch,
|
||||
);
|
||||
}
|
||||
|
||||
fn scheduler_affinity_scope_for_routing_policy(
|
||||
routing_policy: Option<&ResolvedRoutingPolicy>,
|
||||
) -> Option<SchedulerAffinityScope> {
|
||||
let policy = routing_policy?;
|
||||
let group_id = policy
|
||||
.group_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|group_id| !group_id.is_empty())?;
|
||||
Some(SchedulerAffinityScope::new(group_id, policy.group_version))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,329 @@
|
||||
use aether_ai_serving::{
|
||||
append_ai_execution_contract_fields_to_value, append_ai_ranking_metadata_to_object,
|
||||
build_ai_candidate_metadata_from_candidate,
|
||||
};
|
||||
use aether_scheduler_core::{SchedulerMinimalCandidateSelectionCandidate, SchedulerRankingOutcome};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::ai_serving::planner::candidate_resolution::EligibleLocalExecutionCandidate;
|
||||
use crate::ai_serving::transport::append_transport_diagnostics_to_value;
|
||||
use crate::ai_serving::GatewayProviderTransportSnapshot;
|
||||
use crate::ai_serving::{ConversionMode, ExecutionStrategy};
|
||||
|
||||
pub(crate) struct LocalExecutionCandidateMetadataParts<'a> {
|
||||
pub(crate) eligible: &'a EligibleLocalExecutionCandidate,
|
||||
pub(crate) provider_api_format: &'a str,
|
||||
pub(crate) client_api_format: &'a str,
|
||||
pub(crate) extra_fields: Map<String, Value>,
|
||||
}
|
||||
|
||||
pub(crate) fn append_ranking_metadata_to_object(
|
||||
object: &mut Map<String, Value>,
|
||||
ranking: &SchedulerRankingOutcome,
|
||||
) {
|
||||
append_ai_ranking_metadata_to_object(object, ranking);
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_execution_candidate_metadata(
|
||||
parts: LocalExecutionCandidateMetadataParts<'_>,
|
||||
) -> Value {
|
||||
build_local_execution_candidate_metadata_for_candidate(
|
||||
&parts.eligible.candidate,
|
||||
Some(parts.eligible.transport.as_ref()),
|
||||
parts.provider_api_format,
|
||||
parts.client_api_format,
|
||||
parts.extra_fields,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_execution_candidate_metadata_for_candidate(
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
transport: Option<&GatewayProviderTransportSnapshot>,
|
||||
provider_api_format: &str,
|
||||
client_api_format: &str,
|
||||
extra_fields: Map<String, Value>,
|
||||
) -> Value {
|
||||
append_transport_diagnostics_to_value(
|
||||
build_ai_candidate_metadata_from_candidate(
|
||||
candidate,
|
||||
provider_api_format,
|
||||
client_api_format,
|
||||
extra_fields,
|
||||
),
|
||||
transport,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_execution_candidate_contract_metadata(
|
||||
parts: LocalExecutionCandidateMetadataParts<'_>,
|
||||
execution_strategy: ExecutionStrategy,
|
||||
conversion_mode: ConversionMode,
|
||||
provider_contract: &str,
|
||||
) -> Value {
|
||||
append_ai_execution_contract_fields_to_value(
|
||||
build_local_execution_candidate_metadata_for_candidate(
|
||||
&parts.eligible.candidate,
|
||||
Some(parts.eligible.transport.as_ref()),
|
||||
parts.provider_api_format,
|
||||
parts.client_api_format,
|
||||
parts.extra_fields,
|
||||
),
|
||||
execution_strategy.as_str(),
|
||||
conversion_mode.as_str(),
|
||||
parts.client_api_format,
|
||||
provider_contract,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_execution_candidate_contract_metadata_for_candidate(
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
transport: Option<&GatewayProviderTransportSnapshot>,
|
||||
provider_api_format: &str,
|
||||
client_api_format: &str,
|
||||
extra_fields: Map<String, Value>,
|
||||
execution_strategy: ExecutionStrategy,
|
||||
conversion_mode: ConversionMode,
|
||||
provider_contract: &str,
|
||||
) -> Value {
|
||||
append_ai_execution_contract_fields_to_value(
|
||||
build_local_execution_candidate_metadata_for_candidate(
|
||||
candidate,
|
||||
transport,
|
||||
provider_api_format,
|
||||
client_api_format,
|
||||
extra_fields,
|
||||
),
|
||||
execution_strategy.as_str(),
|
||||
conversion_mode.as_str(),
|
||||
client_api_format,
|
||||
provider_contract,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_local_execution_candidate_contract_metadata_for_candidate,
|
||||
build_local_execution_candidate_metadata_for_candidate,
|
||||
};
|
||||
use crate::ai_serving::transport::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider,
|
||||
};
|
||||
use crate::ai_serving::{ConversionMode, ExecutionStrategy, GatewayProviderTransportSnapshot};
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn sample_candidate() -> SchedulerMinimalCandidateSelectionCandidate {
|
||||
SchedulerMinimalCandidateSelectionCandidate {
|
||||
provider_id: "provider-1".to_string(),
|
||||
provider_name: "RightCode".to_string(),
|
||||
provider_type: "codex".to_string(),
|
||||
provider_priority: 22,
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
endpoint_api_format: "openai:responses".to_string(),
|
||||
key_id: "key-1".to_string(),
|
||||
key_name: "codex".to_string(),
|
||||
key_auth_type: "oauth".to_string(),
|
||||
key_internal_priority: 10,
|
||||
key_global_priority_for_format: None,
|
||||
key_capabilities: None,
|
||||
model_id: "model-1".to_string(),
|
||||
global_model_id: "global-1".to_string(),
|
||||
global_model_name: "gpt-5.4".to_string(),
|
||||
selected_provider_model_name: "gpt-5.4".to_string(),
|
||||
supports_streaming: true,
|
||||
mapping_matched_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_transport() -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-1".to_string(),
|
||||
name: "RightCode".to_string(),
|
||||
provider_type: "codex".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: true,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: Some(json!({"enabled": true, "mode": "node", "node_id": "proxy-node-1"})),
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: "openai:responses".to_string(),
|
||||
api_family: None,
|
||||
endpoint_kind: None,
|
||||
is_active: true,
|
||||
base_url: "https://example.com".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: Some("/v1/responses".to_string()),
|
||||
config: None,
|
||||
format_acceptance_config: Some(json!({
|
||||
"enabled": true,
|
||||
"accept_formats": ["claude:messages"]
|
||||
})),
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
name: "codex".to_string(),
|
||||
auth_type: "oauth".to_string(),
|
||||
is_active: true,
|
||||
api_formats: None,
|
||||
auth_type_by_format: None,
|
||||
allow_auth_channel_mismatch_formats: None,
|
||||
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: Some(json!({
|
||||
"transport_profile": {
|
||||
"profile_id": "chrome_136",
|
||||
"header_fingerprint": {
|
||||
"user_agent": "Mozilla/5.0"
|
||||
}
|
||||
}
|
||||
})),
|
||||
upstream_metadata: None,
|
||||
decrypted_api_key: "sk-test".to_string(),
|
||||
decrypted_auth_config: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_claude_code_transport_without_auth() -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-cc-1".to_string(),
|
||||
name: "NekoCode".to_string(),
|
||||
provider_type: "claude_code".to_string(),
|
||||
website: Some("https://nekocode.ai".to_string()),
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: true,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-cc-1".to_string(),
|
||||
provider_id: "provider-cc-1".to_string(),
|
||||
api_format: "claude:messages".to_string(),
|
||||
api_family: Some("claude".to_string()),
|
||||
endpoint_kind: Some("cli".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://api.anthropic.com".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
format_acceptance_config: None,
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-cc-1".to_string(),
|
||||
provider_id: "provider-cc-1".to_string(),
|
||||
name: "CC-特价-0.4".to_string(),
|
||||
auth_type: "api_key".to_string(),
|
||||
is_active: true,
|
||||
api_formats: Some(vec!["claude:messages".to_string()]),
|
||||
auth_type_by_format: None,
|
||||
allow_auth_channel_mismatch_formats: None,
|
||||
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
upstream_metadata: None,
|
||||
decrypted_api_key: "__placeholder__".to_string(),
|
||||
decrypted_auth_config: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_contract_metadata_includes_transport_diagnostics() {
|
||||
let metadata = build_local_execution_candidate_contract_metadata_for_candidate(
|
||||
&sample_candidate(),
|
||||
Some(&sample_transport()),
|
||||
"openai:responses",
|
||||
"claude:messages",
|
||||
serde_json::Map::new(),
|
||||
ExecutionStrategy::LocalCrossFormat,
|
||||
ConversionMode::Bidirectional,
|
||||
"openai:responses",
|
||||
);
|
||||
|
||||
assert_eq!(metadata["transport_diagnostics"]["provider_type"], "codex");
|
||||
assert_eq!(
|
||||
metadata["transport_diagnostics"]["fingerprint"]["transport_profile"]["profile_id"],
|
||||
"chrome_136"
|
||||
);
|
||||
assert_eq!(
|
||||
metadata["transport_diagnostics"]["resolved_transport_profile_id"],
|
||||
"chrome_136"
|
||||
);
|
||||
assert_eq!(
|
||||
metadata["transport_diagnostics"]["request_pair"]["conversion_enabled"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
assert!(
|
||||
metadata["transport_diagnostics"]["request_pair"]["transport_unsupported_reason"]
|
||||
.is_null()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_metadata_marks_missing_transport_snapshot() {
|
||||
let metadata = build_local_execution_candidate_metadata_for_candidate(
|
||||
&sample_candidate(),
|
||||
None,
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
serde_json::Map::new(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
metadata["transport_diagnostics"]["transport_snapshot_available"],
|
||||
Value::Bool(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_metadata_uses_same_format_provider_specific_transport_reason() {
|
||||
let metadata = build_local_execution_candidate_metadata_for_candidate(
|
||||
&sample_candidate(),
|
||||
Some(&sample_claude_code_transport_without_auth()),
|
||||
"claude:messages",
|
||||
"claude:messages",
|
||||
serde_json::Map::new(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
metadata["transport_diagnostics"]["request_pair"]["transport_unsupported_reason"],
|
||||
Value::String("transport_auth_unavailable".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use aether_ai_serving::{
|
||||
prepare_ai_header_authenticated_candidate, resolve_ai_candidate_mapped_model,
|
||||
AiPreparedHeaderAuthenticatedCandidate,
|
||||
};
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_serving::{
|
||||
GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth, PlannerAppState,
|
||||
};
|
||||
|
||||
pub(crate) type PreparedHeaderAuthenticatedCandidate = AiPreparedHeaderAuthenticatedCandidate;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct OauthPreparationContext<'a> {
|
||||
pub(crate) trace_id: &'a str,
|
||||
pub(crate) api_format: &'a str,
|
||||
pub(crate) operation: &'a str,
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_header_authenticated_candidate(
|
||||
state: PlannerAppState<'_>,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
direct_auth: Option<(String, String)>,
|
||||
context: OauthPreparationContext<'_>,
|
||||
) -> Result<PreparedHeaderAuthenticatedCandidate, &'static str> {
|
||||
let oauth_auth = if direct_auth.is_none() {
|
||||
match resolve_candidate_oauth_auth(state, transport, context).await {
|
||||
Some(LocalResolvedOAuthRequestAuth::Header { name, value }) => Some((name, value)),
|
||||
Some(LocalResolvedOAuthRequestAuth::Kiro(_)) => None,
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
prepare_ai_header_authenticated_candidate(
|
||||
direct_auth,
|
||||
oauth_auth,
|
||||
candidate.selected_provider_model_name.as_str(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_header_authenticated_candidate_from_auth(
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
auth_header: String,
|
||||
auth_value: String,
|
||||
) -> Result<PreparedHeaderAuthenticatedCandidate, &'static str> {
|
||||
prepare_ai_header_authenticated_candidate(
|
||||
Some((auth_header, auth_value)),
|
||||
None,
|
||||
candidate.selected_provider_model_name.as_str(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_candidate_mapped_model(
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
) -> Result<String, &'static str> {
|
||||
resolve_ai_candidate_mapped_model(candidate.selected_provider_model_name.as_str())
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_candidate_oauth_auth(
|
||||
state: PlannerAppState<'_>,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
context: OauthPreparationContext<'_>,
|
||||
) -> Option<LocalResolvedOAuthRequestAuth> {
|
||||
match state.resolve_local_oauth_request_auth(transport).await {
|
||||
Ok(Some(auth)) => Some(auth),
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event_name = "candidate_preparation_oauth_auth_resolution_failed",
|
||||
log_type = "event",
|
||||
trace_id = %context.trace_id,
|
||||
api_format = %context.api_format,
|
||||
operation = %context.operation,
|
||||
provider_type = %transport.provider.provider_type,
|
||||
error = ?err,
|
||||
"failed to resolve oauth auth while preparing local candidate"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use aether_provider_transport::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
|
||||
use super::{prepare_header_authenticated_candidate, OauthPreparationContext};
|
||||
use crate::ai_serving::PlannerAppState;
|
||||
|
||||
fn sample_transport() -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-1".to_string(),
|
||||
name: "provider".to_string(),
|
||||
provider_type: "custom".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: false,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: "openai:chat".to_string(),
|
||||
api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://example.test".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
format_acceptance_config: None,
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
name: "key".to_string(),
|
||||
auth_type: "api_key".to_string(),
|
||||
is_active: true,
|
||||
api_formats: Some(vec!["openai:chat".to_string()]),
|
||||
auth_type_by_format: None,
|
||||
allow_auth_channel_mismatch_formats: None,
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
upstream_metadata: None,
|
||||
decrypted_api_key: String::new(),
|
||||
decrypted_auth_config: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_candidate() -> SchedulerMinimalCandidateSelectionCandidate {
|
||||
SchedulerMinimalCandidateSelectionCandidate {
|
||||
provider_id: "provider-1".to_string(),
|
||||
provider_name: "provider".to_string(),
|
||||
provider_type: "custom".to_string(),
|
||||
provider_priority: 1,
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
endpoint_api_format: "openai:chat".to_string(),
|
||||
key_id: "key-1".to_string(),
|
||||
key_name: "key".to_string(),
|
||||
key_auth_type: "api_key".to_string(),
|
||||
key_internal_priority: 1,
|
||||
key_global_priority_for_format: None,
|
||||
key_capabilities: None,
|
||||
model_id: "model-1".to_string(),
|
||||
global_model_id: "global-model-1".to_string(),
|
||||
global_model_name: "gpt-test".to_string(),
|
||||
selected_provider_model_name: "gpt-test-upstream".to_string(),
|
||||
supports_streaming: true,
|
||||
mapping_matched_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn header_auth_preparation_allows_empty_auth_value() {
|
||||
let state = crate::AppState::new().expect("state should build");
|
||||
let transport = sample_transport();
|
||||
let candidate = sample_candidate();
|
||||
|
||||
let prepared = prepare_header_authenticated_candidate(
|
||||
PlannerAppState::new(&state),
|
||||
&transport,
|
||||
&candidate,
|
||||
Some(("authorization".to_string(), String::new())),
|
||||
OauthPreparationContext {
|
||||
trace_id: "trace-empty-auth",
|
||||
api_format: "openai:chat",
|
||||
operation: "test",
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("empty auth value should still prepare the candidate");
|
||||
|
||||
assert_eq!(prepared.auth_header, "authorization");
|
||||
assert_eq!(prepared.auth_value, "");
|
||||
assert_eq!(prepared.mapped_model, "gpt-test-upstream");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,716 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use aether_ai_serving::{
|
||||
run_ai_candidate_resolution, AiCandidateResolutionMode, AiCandidateResolutionPort,
|
||||
AiCandidateResolutionRequest,
|
||||
};
|
||||
use aether_routing_core::ResolvedRoutingPolicy;
|
||||
use async_trait::async_trait;
|
||||
use std::convert::Infallible;
|
||||
use std::time::Instant;
|
||||
use tracing::warn;
|
||||
|
||||
use aether_scheduler_core::{
|
||||
ClientSessionAffinity, SchedulerMinimalCandidateSelectionCandidate, SchedulerRankingOutcome,
|
||||
};
|
||||
|
||||
use crate::ai_serving::transport::provider_types::provider_runtime_policy;
|
||||
use crate::ai_serving::{
|
||||
candidate_common_transport_skip_reason, candidate_transport_pair_skip_reason,
|
||||
CandidateTransportPolicyFacts, GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot,
|
||||
PlannerAppState,
|
||||
};
|
||||
use crate::orchestration::LocalExecutionCandidateMetadata;
|
||||
use crate::stage_metrics::observe_gateway_stage_ms;
|
||||
|
||||
use super::candidate_ranking::rank_eligible_local_execution_candidates;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct EligibleLocalExecutionCandidate {
|
||||
pub(crate) kind: LocalExecutionCandidateKind,
|
||||
pub(crate) candidate: SchedulerMinimalCandidateSelectionCandidate,
|
||||
pub(crate) transport: Arc<GatewayProviderTransportSnapshot>,
|
||||
pub(crate) provider_api_format: String,
|
||||
pub(crate) orchestration: LocalExecutionCandidateMetadata,
|
||||
pub(crate) ranking: Option<SchedulerRankingOutcome>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub(crate) enum LocalExecutionCandidateKind {
|
||||
#[default]
|
||||
SingleKey,
|
||||
PoolGroup,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct SkippedLocalExecutionCandidate {
|
||||
pub(crate) candidate: SchedulerMinimalCandidateSelectionCandidate,
|
||||
pub(crate) skip_reason: &'static str,
|
||||
pub(crate) transport: Option<Arc<GatewayProviderTransportSnapshot>>,
|
||||
pub(crate) ranking: Option<SchedulerRankingOutcome>,
|
||||
pub(crate) extra_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl SkippedLocalExecutionCandidate {
|
||||
pub(crate) fn transport_ref(&self) -> Option<&GatewayProviderTransportSnapshot> {
|
||||
self.transport.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayLocalCandidateResolutionPort<'a> {
|
||||
state: PlannerAppState<'a>,
|
||||
requested_model: Option<&'a str>,
|
||||
auth_snapshot: Option<&'a GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&'a ClientSessionAffinity>,
|
||||
required_capabilities: Option<&'a serde_json::Value>,
|
||||
routing_policy: Option<&'a ResolvedRoutingPolicy>,
|
||||
request_auth_channel: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AiCandidateResolutionPort for GatewayLocalCandidateResolutionPort<'_> {
|
||||
type Candidate = SchedulerMinimalCandidateSelectionCandidate;
|
||||
type Transport = Arc<GatewayProviderTransportSnapshot>;
|
||||
type Eligible = EligibleLocalExecutionCandidate;
|
||||
type Skipped = SkippedLocalExecutionCandidate;
|
||||
type Error = Infallible;
|
||||
|
||||
async fn read_candidate_transport(
|
||||
&self,
|
||||
candidate: &Self::Candidate,
|
||||
) -> Result<Option<Self::Transport>, Self::Error> {
|
||||
let started_at = Instant::now();
|
||||
let transport = read_candidate_transport_snapshot_arc(self.state, candidate).await;
|
||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||
observe_gateway_stage_ms("candidate_transport_snapshot", elapsed_ms);
|
||||
observe_gateway_stage_ms("candidate_resolution_transport_read", elapsed_ms);
|
||||
Ok(transport)
|
||||
}
|
||||
|
||||
fn build_missing_transport_skipped_candidate(
|
||||
&self,
|
||||
candidate: Self::Candidate,
|
||||
) -> Self::Skipped {
|
||||
SkippedLocalExecutionCandidate {
|
||||
candidate,
|
||||
skip_reason: "transport_snapshot_missing",
|
||||
transport: None,
|
||||
ranking: None,
|
||||
extra_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_common_skip_reason(
|
||||
&self,
|
||||
candidate: &Self::Candidate,
|
||||
transport: &Self::Transport,
|
||||
requested_model: Option<&str>,
|
||||
) -> Option<&'static str> {
|
||||
if let Some(skip_reason) =
|
||||
routing_policy_candidate_skip_reason(self.routing_policy, candidate, transport)
|
||||
{
|
||||
return Some(skip_reason);
|
||||
}
|
||||
if provider_transport_uses_pool(transport) {
|
||||
return pool_group_common_transport_skip_reason(candidate, transport);
|
||||
}
|
||||
if let Some(skip_reason) =
|
||||
candidate_auth_channel_skip_reason(transport, self.request_auth_channel)
|
||||
{
|
||||
return Some(skip_reason);
|
||||
}
|
||||
candidate_common_transport_skip_reason(
|
||||
transport,
|
||||
candidate_transport_policy_facts(candidate),
|
||||
requested_model,
|
||||
)
|
||||
}
|
||||
|
||||
fn candidate_transport_pair_skip_reason(
|
||||
&self,
|
||||
candidate: &Self::Candidate,
|
||||
transport: &Self::Transport,
|
||||
normalized_client_api_format: &str,
|
||||
requested_model: &str,
|
||||
) -> Option<&'static str> {
|
||||
let _ = (candidate, requested_model);
|
||||
candidate_transport_pair_skip_reason(transport, normalized_client_api_format)
|
||||
}
|
||||
|
||||
fn build_skipped_candidate(
|
||||
&self,
|
||||
candidate: Self::Candidate,
|
||||
transport: Self::Transport,
|
||||
skip_reason: &'static str,
|
||||
) -> Self::Skipped {
|
||||
SkippedLocalExecutionCandidate {
|
||||
candidate,
|
||||
skip_reason,
|
||||
transport: Some(transport),
|
||||
ranking: None,
|
||||
extra_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_eligible_candidate(
|
||||
&self,
|
||||
candidate: Self::Candidate,
|
||||
transport: Self::Transport,
|
||||
) -> Self::Eligible {
|
||||
let provider_api_format = transport.endpoint.api_format.trim().to_ascii_lowercase();
|
||||
let kind = if provider_transport_uses_pool(&transport) {
|
||||
LocalExecutionCandidateKind::PoolGroup
|
||||
} else {
|
||||
LocalExecutionCandidateKind::SingleKey
|
||||
};
|
||||
EligibleLocalExecutionCandidate {
|
||||
kind,
|
||||
candidate,
|
||||
transport,
|
||||
provider_api_format,
|
||||
orchestration: LocalExecutionCandidateMetadata::default(),
|
||||
ranking: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn rank_eligible_candidates(
|
||||
&self,
|
||||
candidates: Vec<Self::Eligible>,
|
||||
normalized_client_api_format: &str,
|
||||
) -> Result<Vec<Self::Eligible>, Self::Error> {
|
||||
let started_at = Instant::now();
|
||||
let ranked = rank_eligible_local_execution_candidates(
|
||||
self.state,
|
||||
candidates,
|
||||
normalized_client_api_format,
|
||||
self.requested_model,
|
||||
self.auth_snapshot,
|
||||
self.client_session_affinity,
|
||||
self.required_capabilities,
|
||||
self.routing_policy,
|
||||
)
|
||||
.await;
|
||||
observe_gateway_stage_ms(
|
||||
"candidate_resolution_rank",
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
);
|
||||
Ok(ranked)
|
||||
}
|
||||
|
||||
async fn apply_pool_scheduler(
|
||||
&self,
|
||||
candidates: Vec<Self::Eligible>,
|
||||
) -> Result<(Vec<Self::Eligible>, Vec<Self::Skipped>), Self::Error> {
|
||||
Ok((candidates, Vec::new()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_and_rank_local_execution_candidates(
|
||||
state: PlannerAppState<'_>,
|
||||
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
client_api_format: &str,
|
||||
requested_model: &str,
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
required_capabilities: Option<&serde_json::Value>,
|
||||
routing_policy: Option<&ResolvedRoutingPolicy>,
|
||||
_sticky_session_token: Option<&str>,
|
||||
request_auth_channel: Option<&str>,
|
||||
) -> (
|
||||
Vec<EligibleLocalExecutionCandidate>,
|
||||
Vec<SkippedLocalExecutionCandidate>,
|
||||
) {
|
||||
let requested_model = requested_model.trim();
|
||||
resolve_and_rank_local_execution_candidates_with_mode(
|
||||
state,
|
||||
candidates,
|
||||
client_api_format,
|
||||
Some(requested_model),
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
required_capabilities,
|
||||
routing_policy,
|
||||
None,
|
||||
request_auth_channel,
|
||||
AiCandidateResolutionMode::Standard,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_and_rank_local_execution_candidates_without_transport_pair_gate(
|
||||
state: PlannerAppState<'_>,
|
||||
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
client_api_format: &str,
|
||||
requested_model: Option<&str>,
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
required_capabilities: Option<&serde_json::Value>,
|
||||
routing_policy: Option<&ResolvedRoutingPolicy>,
|
||||
_sticky_session_token: Option<&str>,
|
||||
request_auth_channel: Option<&str>,
|
||||
) -> (
|
||||
Vec<EligibleLocalExecutionCandidate>,
|
||||
Vec<SkippedLocalExecutionCandidate>,
|
||||
) {
|
||||
let requested_model = requested_model.map(str::trim);
|
||||
resolve_and_rank_local_execution_candidates_with_mode(
|
||||
state,
|
||||
candidates,
|
||||
client_api_format,
|
||||
requested_model,
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
required_capabilities,
|
||||
routing_policy,
|
||||
None,
|
||||
request_auth_channel,
|
||||
AiCandidateResolutionMode::WithoutTransportPairGate,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_and_rank_logical_local_execution_candidates(
|
||||
state: PlannerAppState<'_>,
|
||||
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
client_api_format: &str,
|
||||
requested_model: Option<&str>,
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
required_capabilities: Option<&serde_json::Value>,
|
||||
routing_policy: Option<&ResolvedRoutingPolicy>,
|
||||
_sticky_session_token: Option<&str>,
|
||||
request_auth_channel: Option<&str>,
|
||||
mode: AiCandidateResolutionMode,
|
||||
) -> (
|
||||
Vec<EligibleLocalExecutionCandidate>,
|
||||
Vec<SkippedLocalExecutionCandidate>,
|
||||
) {
|
||||
resolve_and_rank_local_execution_candidates_with_pool_expansion(
|
||||
state,
|
||||
candidates,
|
||||
client_api_format,
|
||||
requested_model,
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
required_capabilities,
|
||||
routing_policy,
|
||||
None,
|
||||
request_auth_channel,
|
||||
mode,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_and_rank_local_execution_candidates_with_mode(
|
||||
state: PlannerAppState<'_>,
|
||||
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
client_api_format: &str,
|
||||
requested_model: Option<&str>,
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
required_capabilities: Option<&serde_json::Value>,
|
||||
routing_policy: Option<&ResolvedRoutingPolicy>,
|
||||
_sticky_session_token: Option<&str>,
|
||||
request_auth_channel: Option<&str>,
|
||||
mode: AiCandidateResolutionMode,
|
||||
) -> (
|
||||
Vec<EligibleLocalExecutionCandidate>,
|
||||
Vec<SkippedLocalExecutionCandidate>,
|
||||
) {
|
||||
resolve_and_rank_local_execution_candidates_with_pool_expansion(
|
||||
state,
|
||||
candidates,
|
||||
client_api_format,
|
||||
requested_model,
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
required_capabilities,
|
||||
routing_policy,
|
||||
None,
|
||||
request_auth_channel,
|
||||
mode,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn resolve_and_rank_local_execution_candidates_with_pool_expansion(
|
||||
state: PlannerAppState<'_>,
|
||||
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
client_api_format: &str,
|
||||
requested_model: Option<&str>,
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
required_capabilities: Option<&serde_json::Value>,
|
||||
routing_policy: Option<&ResolvedRoutingPolicy>,
|
||||
_sticky_session_token: Option<&str>,
|
||||
request_auth_channel: Option<&str>,
|
||||
mode: AiCandidateResolutionMode,
|
||||
expand_pool_groups: bool,
|
||||
) -> (
|
||||
Vec<EligibleLocalExecutionCandidate>,
|
||||
Vec<SkippedLocalExecutionCandidate>,
|
||||
) {
|
||||
let scheduler_affinity_epoch = state.app().scheduler_affinity_epoch();
|
||||
let port = GatewayLocalCandidateResolutionPort {
|
||||
state,
|
||||
requested_model,
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
required_capabilities,
|
||||
routing_policy,
|
||||
request_auth_channel,
|
||||
};
|
||||
|
||||
let request = AiCandidateResolutionRequest {
|
||||
client_api_format,
|
||||
requested_model,
|
||||
mode,
|
||||
expand_pool_groups,
|
||||
};
|
||||
|
||||
let started_at = Instant::now();
|
||||
match run_ai_candidate_resolution(&port, candidates, request).await {
|
||||
Ok(mut outcome) => {
|
||||
observe_gateway_stage_ms(
|
||||
"candidate_resolution_core",
|
||||
started_at.elapsed().as_millis() as u64,
|
||||
);
|
||||
for candidate in &mut outcome.eligible_candidates {
|
||||
candidate.orchestration.scheduler_affinity_epoch = Some(scheduler_affinity_epoch);
|
||||
}
|
||||
(outcome.eligible_candidates, outcome.skipped_candidates)
|
||||
}
|
||||
Err(error) => match error {},
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_transport_policy_facts(
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
) -> CandidateTransportPolicyFacts<'_> {
|
||||
CandidateTransportPolicyFacts {
|
||||
endpoint_api_format: candidate.endpoint_api_format.as_str(),
|
||||
global_model_name: candidate.global_model_name.as_str(),
|
||||
selected_provider_model_name: candidate.selected_provider_model_name.as_str(),
|
||||
mapping_matched_model: candidate.mapping_matched_model.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_transport_uses_pool(transport: &GatewayProviderTransportSnapshot) -> bool {
|
||||
crate::handlers::shared::provider_pool::admin_provider_pool_config_from_config_value(
|
||||
transport.provider.config.as_ref(),
|
||||
)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn routing_policy_candidate_skip_reason(
|
||||
routing_policy: Option<&ResolvedRoutingPolicy>,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<&'static str> {
|
||||
let policy = routing_policy?;
|
||||
if !policy
|
||||
.ranking_overlay
|
||||
.provider_allowed(candidate.provider_id.as_str())
|
||||
{
|
||||
return Some("routing_profile_disallowed_provider");
|
||||
}
|
||||
if !provider_transport_uses_pool(transport)
|
||||
&& !policy
|
||||
.ranking_overlay
|
||||
.key_allowed(candidate.key_id.as_str())
|
||||
{
|
||||
return Some("routing_profile_disallowed_key");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn pool_group_common_transport_skip_reason(
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<&'static str> {
|
||||
if !transport.provider.is_active {
|
||||
return Some("provider_inactive");
|
||||
}
|
||||
if !transport.endpoint.is_active {
|
||||
return Some("endpoint_inactive");
|
||||
}
|
||||
if !crate::ai_serving::api_format_alias_matches(
|
||||
candidate.endpoint_api_format.as_str(),
|
||||
transport.endpoint.api_format.trim(),
|
||||
) {
|
||||
return Some("endpoint_api_format_changed");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn candidate_auth_channel_skip_reason(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
request_auth_channel: Option<&str>,
|
||||
) -> Option<&'static str> {
|
||||
let request_auth_channel = normalize_request_auth_channel(request_auth_channel?)?;
|
||||
let upstream_auth_channel = resolve_transport_request_auth_channel(transport)?;
|
||||
if request_auth_channel == upstream_auth_channel {
|
||||
return None;
|
||||
}
|
||||
auth_channel_mismatch_is_explicitly_disabled_for_format(transport)
|
||||
.then_some("auth_channel_mismatch")
|
||||
}
|
||||
|
||||
fn normalize_request_auth_channel(value: &str) -> Option<&'static str> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"api_key" | "api-key" | "apikey" => Some("api_key"),
|
||||
"bearer_like" | "bearer-like" | "bearer" | "oauth" => Some("bearer_like"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_transport_request_auth_channel(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<&'static str> {
|
||||
let auth_type = resolve_transport_auth_type_for_endpoint_format(transport);
|
||||
let provider_policy = provider_runtime_policy(&transport.provider.provider_type);
|
||||
match auth_type.as_str() {
|
||||
"api_key" => Some("api_key"),
|
||||
"bearer" => Some("bearer_like"),
|
||||
"oauth" if provider_policy.oauth_is_bearer_like => Some("bearer_like"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_transport_auth_type_for_endpoint_format(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> String {
|
||||
let default_auth_type = transport.key.auth_type.trim().to_ascii_lowercase();
|
||||
let api_format = crate::ai_serving::normalize_api_format_alias(&transport.endpoint.api_format);
|
||||
transport
|
||||
.key
|
||||
.auth_type_by_format
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|overrides| {
|
||||
overrides
|
||||
.get(&api_format)
|
||||
.or_else(|| overrides.get(transport.endpoint.api_format.trim()))
|
||||
})
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.filter(|value| matches!(value.as_str(), "api_key" | "bearer"))
|
||||
.unwrap_or(default_auth_type)
|
||||
}
|
||||
|
||||
fn auth_channel_mismatch_is_explicitly_disabled_for_format(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> bool {
|
||||
let api_format = crate::ai_serving::normalize_api_format_alias(&transport.endpoint.api_format);
|
||||
let Some(items) = transport
|
||||
.key
|
||||
.allow_auth_channel_mismatch_formats
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_array)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
!items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.any(|item| crate::ai_serving::normalize_api_format_alias(item) == api_format)
|
||||
}
|
||||
|
||||
pub(crate) async fn read_candidate_transport_snapshot(
|
||||
state: PlannerAppState<'_>,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
) -> Option<GatewayProviderTransportSnapshot> {
|
||||
read_candidate_transport_snapshot_arc(state, candidate)
|
||||
.await
|
||||
.map(|transport| (*transport).clone())
|
||||
}
|
||||
|
||||
pub(crate) async fn read_candidate_transport_snapshot_arc(
|
||||
state: PlannerAppState<'_>,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
) -> Option<Arc<GatewayProviderTransportSnapshot>> {
|
||||
match state
|
||||
.read_provider_transport_snapshot_arc(
|
||||
&candidate.provider_id,
|
||||
&candidate.endpoint_id,
|
||||
&candidate.key_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(transport)) => Some(transport),
|
||||
Ok(None) => None,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
event_name = "candidate_resolution_transport_load_failed",
|
||||
log_type = "event",
|
||||
provider_id = %candidate.provider_id,
|
||||
endpoint_id = %candidate.endpoint_id,
|
||||
key_id = %candidate.key_id,
|
||||
error = ?error,
|
||||
"failed to load provider transport while evaluating local candidate eligibility"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{candidate_auth_channel_skip_reason, pool_group_common_transport_skip_reason};
|
||||
use crate::ai_serving::GatewayProviderTransportSnapshot;
|
||||
use aether_provider_transport::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider,
|
||||
};
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
use serde_json::json;
|
||||
|
||||
fn sample_transport(auth_type: &str) -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-1".to_string(),
|
||||
name: "provider".to_string(),
|
||||
provider_type: "custom".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: false,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: "claude:messages".to_string(),
|
||||
api_family: Some("claude".to_string()),
|
||||
endpoint_kind: Some("messages".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://example.test".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
format_acceptance_config: None,
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
name: "key".to_string(),
|
||||
auth_type: auth_type.to_string(),
|
||||
is_active: true,
|
||||
api_formats: Some(vec!["claude:messages".to_string()]),
|
||||
auth_type_by_format: None,
|
||||
allow_auth_channel_mismatch_formats: None,
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
upstream_metadata: None,
|
||||
decrypted_api_key: "secret".to_string(),
|
||||
decrypted_auth_config: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_candidate() -> SchedulerMinimalCandidateSelectionCandidate {
|
||||
SchedulerMinimalCandidateSelectionCandidate {
|
||||
provider_id: "provider-1".to_string(),
|
||||
provider_name: "provider".to_string(),
|
||||
provider_type: "custom".to_string(),
|
||||
provider_priority: 10,
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
endpoint_api_format: "claude:messages".to_string(),
|
||||
key_id: "key-1".to_string(),
|
||||
key_name: "key".to_string(),
|
||||
key_auth_type: "bearer".to_string(),
|
||||
key_internal_priority: 10,
|
||||
key_global_priority_for_format: None,
|
||||
key_capabilities: None,
|
||||
model_id: "model-1".to_string(),
|
||||
global_model_id: "global-model-1".to_string(),
|
||||
global_model_name: "claude-sonnet".to_string(),
|
||||
selected_provider_model_name: "claude-sonnet".to_string(),
|
||||
supports_streaming: true,
|
||||
mapping_matched_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_channel_gate_allows_mismatched_raw_secret_auth_by_default() {
|
||||
let transport = sample_transport("bearer");
|
||||
assert_eq!(
|
||||
candidate_auth_channel_skip_reason(&transport, Some("api_key")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_channel_gate_allows_explicit_mismatch_format() {
|
||||
let mut transport = sample_transport("bearer");
|
||||
transport.key.allow_auth_channel_mismatch_formats = Some(json!(["claude:messages"]));
|
||||
assert_eq!(
|
||||
candidate_auth_channel_skip_reason(&transport, Some("api_key")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_channel_gate_blocks_explicitly_disabled_mismatch_format() {
|
||||
let mut transport = sample_transport("bearer");
|
||||
transport.key.allow_auth_channel_mismatch_formats = Some(json!(["openai:responses"]));
|
||||
assert_eq!(
|
||||
candidate_auth_channel_skip_reason(&transport, Some("api_key")),
|
||||
Some("auth_channel_mismatch")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_channel_gate_treats_cli_oauth_provider_as_bearer_like() {
|
||||
let mut transport = sample_transport("oauth");
|
||||
transport.provider.provider_type = "claude_code".to_string();
|
||||
assert_eq!(
|
||||
candidate_auth_channel_skip_reason(&transport, Some("bearer_like")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_auth_channel_skip_reason(&transport, Some("api_key")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_channel_gate_allows_kiro_provider_mismatch_by_default() {
|
||||
let mut transport = sample_transport("oauth");
|
||||
transport.provider.provider_type = "kiro".to_string();
|
||||
assert_eq!(
|
||||
candidate_auth_channel_skip_reason(&transport, Some("api_key")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_group_common_gate_ignores_representative_key_model_policy() {
|
||||
let candidate = sample_candidate();
|
||||
let mut transport = sample_transport("bearer");
|
||||
transport.key.allowed_models = Some(vec!["different-model".to_string()]);
|
||||
transport.key.api_formats = Some(vec!["different:format".to_string()]);
|
||||
|
||||
assert_eq!(
|
||||
pool_group_common_transport_skip_reason(&candidate, &transport),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_contracts::ProxySnapshot;
|
||||
use aether_scheduler_core::{
|
||||
SchedulerMinimalCandidateSelectionCandidate, SchedulerTunnelAffinityBucket,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_serving::{GatewayProviderTransportSnapshot, PlannerAppState};
|
||||
use crate::scheduler::config::SchedulerOrderingConfig;
|
||||
|
||||
use super::candidate_resolution::read_candidate_transport_snapshot;
|
||||
|
||||
const TUNNEL_OWNER_INSTANCE_ID_EXTRA_KEY: &str = "tunnel_owner_instance_id";
|
||||
|
||||
pub(super) type CandidateTransportIdentity = (String, String, String);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct CandidateTransportRankingFacts {
|
||||
pub(super) tunnel_bucket: SchedulerTunnelAffinityBucket,
|
||||
pub(super) keep_priority_on_conversion: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct CandidateTransportRankingFactsCache {
|
||||
candidate_facts: BTreeMap<CandidateTransportIdentity, CandidateTransportRankingFacts>,
|
||||
configured_proxy_snapshots: BTreeMap<String, Option<ProxySnapshot>>,
|
||||
system_proxy_snapshot: Option<Option<ProxySnapshot>>,
|
||||
tunnel_buckets_by_node_id: BTreeMap<String, SchedulerTunnelAffinityBucket>,
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_cached_candidate_transport_ranking_facts(
|
||||
state: PlannerAppState<'_>,
|
||||
cache: &mut CandidateTransportRankingFactsCache,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
ordering_config: SchedulerOrderingConfig,
|
||||
) -> CandidateTransportRankingFacts {
|
||||
let identity = candidate_transport_identity(candidate);
|
||||
if let Some(facts) = cache.candidate_facts.get(&identity).copied() {
|
||||
return facts;
|
||||
}
|
||||
|
||||
let facts =
|
||||
resolve_candidate_transport_ranking_facts(state, cache, candidate, ordering_config).await;
|
||||
cache.candidate_facts.insert(identity, facts);
|
||||
facts
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_cached_transport_ranking_facts(
|
||||
state: PlannerAppState<'_>,
|
||||
cache: &mut CandidateTransportRankingFactsCache,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
ordering_config: SchedulerOrderingConfig,
|
||||
) -> CandidateTransportRankingFacts {
|
||||
let identity = candidate_transport_identity(candidate);
|
||||
if let Some(facts) = cache.candidate_facts.get(&identity).copied() {
|
||||
return facts;
|
||||
}
|
||||
|
||||
let facts = resolve_candidate_transport_ranking_facts_from_transport(
|
||||
state,
|
||||
cache,
|
||||
transport,
|
||||
ordering_config,
|
||||
)
|
||||
.await;
|
||||
cache.candidate_facts.insert(identity, facts);
|
||||
facts
|
||||
}
|
||||
|
||||
pub(super) async fn candidate_keeps_priority_on_conversion(
|
||||
state: PlannerAppState<'_>,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
ordering_config: SchedulerOrderingConfig,
|
||||
) -> bool {
|
||||
let mut cache = CandidateTransportRankingFactsCache::default();
|
||||
resolve_candidate_transport_ranking_facts(state, &mut cache, candidate, ordering_config)
|
||||
.await
|
||||
.keep_priority_on_conversion
|
||||
}
|
||||
|
||||
async fn resolve_candidate_transport_ranking_facts(
|
||||
state: PlannerAppState<'_>,
|
||||
cache: &mut CandidateTransportRankingFactsCache,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
ordering_config: SchedulerOrderingConfig,
|
||||
) -> CandidateTransportRankingFacts {
|
||||
let Some(transport) = read_candidate_transport_snapshot(state, candidate).await else {
|
||||
return CandidateTransportRankingFacts {
|
||||
tunnel_bucket: SchedulerTunnelAffinityBucket::Neutral,
|
||||
keep_priority_on_conversion: ordering_config.keep_priority_on_conversion,
|
||||
};
|
||||
};
|
||||
|
||||
resolve_candidate_transport_ranking_facts_from_transport(
|
||||
state,
|
||||
cache,
|
||||
&transport,
|
||||
ordering_config,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_candidate_transport_ranking_facts_from_transport(
|
||||
state: PlannerAppState<'_>,
|
||||
cache: &mut CandidateTransportRankingFactsCache,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
ordering_config: SchedulerOrderingConfig,
|
||||
) -> CandidateTransportRankingFacts {
|
||||
CandidateTransportRankingFacts {
|
||||
tunnel_bucket: resolve_tunnel_owner_affinity_from_transport(state, cache, transport).await,
|
||||
keep_priority_on_conversion: ordering_config.keep_priority_on_conversion
|
||||
|| transport.provider.keep_priority_on_conversion,
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_tunnel_owner_affinity_from_transport(
|
||||
state: PlannerAppState<'_>,
|
||||
cache: &mut CandidateTransportRankingFactsCache,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> SchedulerTunnelAffinityBucket {
|
||||
let Some(proxy) =
|
||||
resolve_transport_proxy_snapshot_with_tunnel_affinity_cached(state, cache, transport).await
|
||||
else {
|
||||
return SchedulerTunnelAffinityBucket::Neutral;
|
||||
};
|
||||
if proxy.enabled == Some(false) {
|
||||
return SchedulerTunnelAffinityBucket::Neutral;
|
||||
}
|
||||
let Some(node_id) = proxy
|
||||
.node_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return SchedulerTunnelAffinityBucket::Neutral;
|
||||
};
|
||||
|
||||
if let Some(bucket) = cache.tunnel_buckets_by_node_id.get(node_id).copied() {
|
||||
return bucket;
|
||||
}
|
||||
|
||||
let bucket = resolve_tunnel_owner_affinity_from_proxy(state, &proxy, node_id).await;
|
||||
cache
|
||||
.tunnel_buckets_by_node_id
|
||||
.insert(node_id.to_string(), bucket);
|
||||
bucket
|
||||
}
|
||||
|
||||
async fn resolve_transport_proxy_snapshot_with_tunnel_affinity_cached(
|
||||
state: PlannerAppState<'_>,
|
||||
cache: &mut CandidateTransportRankingFactsCache,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<ProxySnapshot> {
|
||||
for raw in [
|
||||
transport.key.proxy.as_ref(),
|
||||
transport.endpoint.proxy.as_ref(),
|
||||
transport.provider.proxy.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let cache_key = proxy_config_cache_key(raw);
|
||||
if let Some(snapshot) = cache.configured_proxy_snapshots.get(&cache_key) {
|
||||
if snapshot.is_some() {
|
||||
return snapshot.clone();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let snapshot = state
|
||||
.app()
|
||||
.resolve_configured_proxy_snapshot_with_tunnel_affinity(Some(raw))
|
||||
.await;
|
||||
cache
|
||||
.configured_proxy_snapshots
|
||||
.insert(cache_key, snapshot.clone());
|
||||
if snapshot.is_some() {
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(snapshot) = cache.system_proxy_snapshot.as_ref() {
|
||||
return snapshot.clone();
|
||||
}
|
||||
let snapshot = state.app().resolve_system_proxy_snapshot().await;
|
||||
cache.system_proxy_snapshot = Some(snapshot.clone());
|
||||
snapshot
|
||||
}
|
||||
|
||||
async fn resolve_tunnel_owner_affinity_from_proxy(
|
||||
state: PlannerAppState<'_>,
|
||||
proxy: &ProxySnapshot,
|
||||
node_id: &str,
|
||||
) -> SchedulerTunnelAffinityBucket {
|
||||
if state.app().tunnel.has_local_proxy(node_id) {
|
||||
return SchedulerTunnelAffinityBucket::LocalTunnel;
|
||||
}
|
||||
|
||||
if let Some(owner_instance_id) = proxy_tunnel_owner_instance_id(proxy) {
|
||||
return if owner_instance_id == state.app().tunnel.local_instance_id() {
|
||||
SchedulerTunnelAffinityBucket::LocalTunnel
|
||||
} else {
|
||||
SchedulerTunnelAffinityBucket::RemoteTunnel
|
||||
};
|
||||
}
|
||||
|
||||
match state
|
||||
.app()
|
||||
.tunnel
|
||||
.lookup_attachment_owner(state.app().data.as_ref(), node_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(owner)) if owner.gateway_instance_id == state.app().tunnel.local_instance_id() => {
|
||||
SchedulerTunnelAffinityBucket::LocalTunnel
|
||||
}
|
||||
Ok(Some(_)) => SchedulerTunnelAffinityBucket::RemoteTunnel,
|
||||
Ok(None) => SchedulerTunnelAffinityBucket::Neutral,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
event_name = "candidate_transport_ranking_facts_tunnel_owner_lookup_failed",
|
||||
log_type = "event",
|
||||
node_id = node_id,
|
||||
error = %error,
|
||||
"failed to load tunnel attachment owner while evaluating candidate transport ranking facts"
|
||||
);
|
||||
SchedulerTunnelAffinityBucket::Neutral
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_transport_identity(
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
) -> CandidateTransportIdentity {
|
||||
(
|
||||
candidate.provider_id.clone(),
|
||||
candidate.endpoint_id.clone(),
|
||||
candidate.key_id.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn proxy_config_cache_key(raw: &Value) -> String {
|
||||
serde_json::to_string(raw).unwrap_or_else(|_| raw.to_string())
|
||||
}
|
||||
|
||||
fn proxy_tunnel_owner_instance_id(proxy: &ProxySnapshot) -> Option<&str> {
|
||||
proxy
|
||||
.extra
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|extra| extra.get(TUNNEL_OWNER_INSTANCE_ID_EXTRA_KEY))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
use axum::body::Bytes;
|
||||
|
||||
use crate::ai_serving::is_json_request;
|
||||
use crate::ai_serving::{
|
||||
endpoint_config_forces_upstream_stream_policy as endpoint_config_forces_upstream_stream_policy_impl,
|
||||
enforce_request_body_stream_field as enforce_request_body_stream_field_impl,
|
||||
force_upstream_streaming_for_provider as force_upstream_streaming_for_provider_impl,
|
||||
parse_direct_request_body as parse_direct_request_body_impl,
|
||||
resolve_format_upstream_is_stream_for_provider as resolve_upstream_is_stream_for_provider_impl,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::{
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
CLAUDE_CLI_SYNC_PLAN_KIND, CLAUDE_COUNT_TOKENS_SYNC_PLAN_KIND, EXECUTION_RUNTIME_STREAM_ACTION,
|
||||
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
|
||||
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_CHAT_STREAM_PLAN_KIND,
|
||||
GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
|
||||
GEMINI_EMBEDDING_SYNC_PLAN_KIND, GEMINI_FILES_DELETE_PLAN_KIND,
|
||||
GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND,
|
||||
GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
|
||||
OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
|
||||
OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND,
|
||||
OPENAI_RESPONSES_SYNC_PLAN_KIND, OPENAI_SEARCH_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND,
|
||||
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||
};
|
||||
|
||||
pub(crate) use aether_ai_serving::AiRequestedModelFamily as RequestedModelFamily;
|
||||
|
||||
pub(crate) fn parse_direct_request_body(
|
||||
parts: &http::request::Parts,
|
||||
body_bytes: &Bytes,
|
||||
) -> Option<(serde_json::Value, Option<String>)> {
|
||||
let is_json_request = is_json_request(&parts.headers);
|
||||
let body_bytes = if is_json_request {
|
||||
crate::ai_serving::decoded_request_body_bytes(&parts.headers, body_bytes.as_ref()).ok()?
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(body_bytes.as_ref())
|
||||
};
|
||||
parse_direct_request_body_impl(is_json_request, body_bytes.as_ref())
|
||||
}
|
||||
|
||||
pub(crate) fn force_upstream_streaming_for_provider(
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
force_upstream_streaming_for_provider_impl(provider_type, provider_api_format)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_upstream_is_stream_for_provider(
|
||||
endpoint_config: Option<&serde_json::Value>,
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
client_is_stream: bool,
|
||||
hard_requires_streaming: bool,
|
||||
) -> bool {
|
||||
resolve_upstream_is_stream_for_provider_impl(
|
||||
endpoint_config,
|
||||
provider_type,
|
||||
provider_api_format,
|
||||
client_is_stream,
|
||||
hard_requires_streaming,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn endpoint_config_forces_body_stream_field(
|
||||
endpoint_config: Option<&serde_json::Value>,
|
||||
) -> bool {
|
||||
endpoint_config_forces_upstream_stream_policy_impl(endpoint_config)
|
||||
}
|
||||
|
||||
pub(crate) fn request_requires_body_stream_field(
|
||||
body_json: &serde_json::Value,
|
||||
force_body_stream_field: bool,
|
||||
) -> bool {
|
||||
force_body_stream_field
|
||||
|| body_json
|
||||
.as_object()
|
||||
.is_some_and(|object| object.contains_key("stream"))
|
||||
}
|
||||
|
||||
pub(crate) fn enforce_provider_body_stream_policy(
|
||||
provider_request_body: &mut serde_json::Value,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
require_body_stream_field: bool,
|
||||
) {
|
||||
enforce_request_body_stream_field_impl(
|
||||
provider_request_body,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
require_body_stream_field,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn extract_standard_requested_model(body_json: &serde_json::Value) -> Option<String> {
|
||||
aether_ai_serving::extract_ai_standard_requested_model(body_json)
|
||||
}
|
||||
|
||||
pub(crate) fn extract_requested_model_from_request(
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
family: RequestedModelFamily,
|
||||
) -> Option<String> {
|
||||
aether_ai_serving::extract_ai_requested_model_from_request_path(
|
||||
parts.uri.path(),
|
||||
body_json,
|
||||
family,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
endpoint_config_forces_body_stream_field, enforce_provider_body_stream_policy,
|
||||
extract_requested_model_from_request, extract_standard_requested_model,
|
||||
force_upstream_streaming_for_provider, resolve_upstream_is_stream_for_provider,
|
||||
RequestedModelFamily,
|
||||
};
|
||||
use axum::http::Request;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn forces_streaming_for_codex_openai_responses() {
|
||||
assert!(force_upstream_streaming_for_provider(
|
||||
"codex",
|
||||
"openai:responses"
|
||||
));
|
||||
assert!(!force_upstream_streaming_for_provider(
|
||||
"codex",
|
||||
"openai:responses:compact"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_force_streaming_for_compact_or_other_provider_types() {
|
||||
assert!(!force_upstream_streaming_for_provider(
|
||||
"codex",
|
||||
"openai:responses:compact"
|
||||
));
|
||||
assert!(!force_upstream_streaming_for_provider(
|
||||
"codex",
|
||||
"openai:responses:compact"
|
||||
));
|
||||
assert!(!force_upstream_streaming_for_provider(
|
||||
"openai",
|
||||
"openai:responses"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_endpoint_upstream_stream_policy_with_provider_hard_constraints() {
|
||||
assert!(resolve_upstream_is_stream_for_provider(
|
||||
Some(&json!({"upstream_stream_policy": "force_stream"})),
|
||||
"openai",
|
||||
"openai:chat",
|
||||
false,
|
||||
false,
|
||||
));
|
||||
assert!(!resolve_upstream_is_stream_for_provider(
|
||||
Some(&json!({"upstream_stream_policy": "force_non_stream"})),
|
||||
"openai",
|
||||
"openai:chat",
|
||||
true,
|
||||
false,
|
||||
));
|
||||
assert!(resolve_upstream_is_stream_for_provider(
|
||||
Some(&json!({"upstream_stream_policy": "auto"})),
|
||||
"openai",
|
||||
"openai:chat",
|
||||
true,
|
||||
false,
|
||||
));
|
||||
assert!(resolve_upstream_is_stream_for_provider(
|
||||
Some(&json!({"upstream_stream_policy": "force_non_stream"})),
|
||||
"codex",
|
||||
"openai:responses",
|
||||
true,
|
||||
false,
|
||||
));
|
||||
assert!(!resolve_upstream_is_stream_for_provider(
|
||||
Some(&json!({"upstream_stream_policy": "force_stream"})),
|
||||
"codex",
|
||||
"openai:image",
|
||||
true,
|
||||
true,
|
||||
));
|
||||
assert!(!resolve_upstream_is_stream_for_provider(
|
||||
Some(&json!({"upstream_stream_policy": "force_stream"})),
|
||||
"codex",
|
||||
"openai:responses:compact",
|
||||
true,
|
||||
true,
|
||||
));
|
||||
assert!(!resolve_upstream_is_stream_for_provider(
|
||||
Some(&json!({"upstream_stream_policy": "force_stream"})),
|
||||
"custom",
|
||||
"openai:responses:compact",
|
||||
true,
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforces_provider_body_stream_policy_for_body_and_streamless_formats() {
|
||||
let mut openai_chat = json!({"stream": true});
|
||||
enforce_provider_body_stream_policy(&mut openai_chat, "openai:chat", false, false);
|
||||
assert_eq!(openai_chat.get("stream"), Some(&json!(false)));
|
||||
|
||||
let mut ordinary_sync = json!({"messages": []});
|
||||
enforce_provider_body_stream_policy(&mut ordinary_sync, "openai:chat", false, false);
|
||||
assert!(ordinary_sync.get("stream").is_none());
|
||||
|
||||
let mut compact = json!({"stream": true});
|
||||
enforce_provider_body_stream_policy(&mut compact, "openai:responses:compact", true, true);
|
||||
assert!(compact.get("stream").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_endpoint_configs_that_force_body_stream_field() {
|
||||
assert!(endpoint_config_forces_body_stream_field(Some(
|
||||
&json!({"upstream_stream_policy": "force_stream"})
|
||||
)));
|
||||
assert!(endpoint_config_forces_body_stream_field(Some(
|
||||
&json!({"upstream_stream_policy": "force_non_stream"})
|
||||
)));
|
||||
assert!(!endpoint_config_forces_body_stream_field(Some(
|
||||
&json!({"upstream_stream_policy": "auto"})
|
||||
)));
|
||||
assert!(!endpoint_config_forces_body_stream_field(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_standard_requested_model_from_request_body() {
|
||||
let requested_model =
|
||||
extract_standard_requested_model(&json!({ "model": " claude-sonnet-4 " }));
|
||||
|
||||
assert_eq!(requested_model.as_deref(), Some("claude-sonnet-4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_family_helper_delegates_standard_model_extraction() {
|
||||
let request = Request::builder()
|
||||
.uri("https://example.test/v1/chat/completions")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (parts, _) = request.into_parts();
|
||||
|
||||
let requested_model = extract_requested_model_from_request(
|
||||
&parts,
|
||||
&json!({ "model": " claude-sonnet-4 " }),
|
||||
RequestedModelFamily::Standard,
|
||||
);
|
||||
|
||||
assert_eq!(requested_model.as_deref(), Some("claude-sonnet-4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_gemini_requested_model_from_request_path() {
|
||||
let request = Request::builder()
|
||||
.uri("https://example.test/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (parts, _) = request.into_parts();
|
||||
|
||||
let requested_model =
|
||||
extract_requested_model_from_request(&parts, &json!({}), RequestedModelFamily::Gemini);
|
||||
|
||||
assert_eq!(requested_model.as_deref(), Some("gemini-2.5-pro"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use crate::ai_serving::planner::common::{
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
CLAUDE_CLI_SYNC_PLAN_KIND, CLAUDE_COUNT_TOKENS_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND,
|
||||
GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
|
||||
GEMINI_EMBEDDING_SYNC_PLAN_KIND, GEMINI_FILES_DELETE_PLAN_KIND,
|
||||
GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND,
|
||||
GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND,
|
||||
OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND,
|
||||
OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND,
|
||||
OPENAI_SEARCH_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||
};
|
||||
use crate::ai_serving::planner::plan_builders::{
|
||||
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
|
||||
build_openai_chat_stream_plan_from_decision, build_openai_chat_sync_plan_from_decision,
|
||||
build_openai_responses_stream_plan_from_decision,
|
||||
build_openai_responses_sync_plan_from_decision, build_passthrough_stream_plan_from_decision,
|
||||
build_passthrough_sync_plan_from_decision, build_standard_stream_plan_from_decision,
|
||||
build_standard_sync_plan_from_decision,
|
||||
};
|
||||
use crate::ai_serving::planner::route::{
|
||||
resolve_execution_runtime_stream_plan_kind as resolve_stream_plan_kind,
|
||||
resolve_execution_runtime_sync_plan_kind as resolve_sync_plan_kind,
|
||||
};
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::{AiExecutionDecision, AiExecutionPlanPayload, AppState, GatewayError};
|
||||
use aether_ai_serving::{
|
||||
build_ai_stream_execution_plan_payload, build_ai_sync_execution_plan_payload,
|
||||
};
|
||||
|
||||
pub(crate) async fn maybe_build_sync_plan_payload_impl(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
body_is_empty: bool,
|
||||
) -> Result<Option<AiExecutionPlanPayload>, GatewayError> {
|
||||
let Some(plan_kind) = resolve_sync_plan_kind(parts, decision) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(payload) = super::maybe_build_sync_decision_payload(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
body_base64,
|
||||
body_is_empty,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
build_sync_plan_payload_from_decision(parts, body_json, plan_kind, payload)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_stream_plan_payload_impl(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
) -> Result<Option<AiExecutionPlanPayload>, GatewayError> {
|
||||
let Some(plan_kind) = resolve_stream_plan_kind(parts, decision) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(payload) = super::maybe_build_stream_decision_payload(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
body_base64,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
build_stream_plan_payload_from_decision(parts, body_json, plan_kind, payload)
|
||||
}
|
||||
|
||||
fn build_sync_plan_payload_from_decision(
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
mut payload: AiExecutionDecision,
|
||||
) -> Result<Option<AiExecutionPlanPayload>, GatewayError> {
|
||||
let auth_context = payload.auth_context.take();
|
||||
let plan_and_report = match plan_kind {
|
||||
OPENAI_CHAT_SYNC_PLAN_KIND => {
|
||||
build_openai_chat_sync_plan_from_decision(parts, body_json, payload)?
|
||||
}
|
||||
OPENAI_RESPONSES_SYNC_PLAN_KIND => {
|
||||
build_openai_responses_sync_plan_from_decision(parts, body_json, payload, false)?
|
||||
}
|
||||
OPENAI_IMAGE_SYNC_PLAN_KIND | OPENAI_SEARCH_SYNC_PLAN_KIND => {
|
||||
build_passthrough_sync_plan_from_decision(parts, payload)?
|
||||
}
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND => {
|
||||
build_openai_responses_sync_plan_from_decision(parts, body_json, payload, true)?
|
||||
}
|
||||
CLAUDE_CHAT_SYNC_PLAN_KIND
|
||||
| CLAUDE_CLI_SYNC_PLAN_KIND
|
||||
| CLAUDE_COUNT_TOKENS_SYNC_PLAN_KIND
|
||||
| OPENAI_EMBEDDING_SYNC_PLAN_KIND
|
||||
| OPENAI_RERANK_SYNC_PLAN_KIND => {
|
||||
build_standard_sync_plan_from_decision(parts, body_json, payload)?
|
||||
}
|
||||
GEMINI_CHAT_SYNC_PLAN_KIND
|
||||
| GEMINI_CLI_SYNC_PLAN_KIND
|
||||
| GEMINI_EMBEDDING_SYNC_PLAN_KIND => {
|
||||
build_gemini_sync_plan_from_decision(parts, body_json, payload)?
|
||||
}
|
||||
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND
|
||||
| OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND
|
||||
| OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND
|
||||
| OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND
|
||||
| GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND
|
||||
| GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND
|
||||
| GEMINI_FILES_LIST_PLAN_KIND
|
||||
| GEMINI_FILES_GET_PLAN_KIND
|
||||
| GEMINI_FILES_DELETE_PLAN_KIND => {
|
||||
build_passthrough_sync_plan_from_decision(parts, payload)?
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(plan_and_report
|
||||
.map(|value| build_ai_sync_execution_plan_payload(plan_kind, value, auth_context)))
|
||||
}
|
||||
|
||||
fn build_stream_plan_payload_from_decision(
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
mut payload: AiExecutionDecision,
|
||||
) -> Result<Option<AiExecutionPlanPayload>, GatewayError> {
|
||||
let auth_context = payload.auth_context.take();
|
||||
let plan_and_report = match plan_kind {
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND => {
|
||||
build_openai_chat_stream_plan_from_decision(parts, body_json, payload)?
|
||||
}
|
||||
OPENAI_RESPONSES_STREAM_PLAN_KIND => {
|
||||
build_openai_responses_stream_plan_from_decision(parts, body_json, payload, false)?
|
||||
}
|
||||
OPENAI_IMAGE_STREAM_PLAN_KIND => {
|
||||
build_standard_stream_plan_from_decision(parts, body_json, payload, false)?
|
||||
}
|
||||
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND => {
|
||||
build_openai_responses_stream_plan_from_decision(parts, body_json, payload, true)?
|
||||
}
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND | CLAUDE_CLI_STREAM_PLAN_KIND => {
|
||||
build_standard_stream_plan_from_decision(parts, body_json, payload, true)?
|
||||
}
|
||||
GEMINI_CHAT_STREAM_PLAN_KIND | GEMINI_CLI_STREAM_PLAN_KIND => {
|
||||
build_gemini_stream_plan_from_decision(parts, body_json, payload)?
|
||||
}
|
||||
OPENAI_VIDEO_CONTENT_PLAN_KIND | GEMINI_FILES_DOWNLOAD_PLAN_KIND => {
|
||||
build_passthrough_stream_plan_from_decision(parts, payload)?
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(plan_and_report
|
||||
.map(|value| build_ai_stream_execution_plan_payload(plan_kind, value, auth_context)))
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
mod control_plan;
|
||||
mod stream;
|
||||
mod sync;
|
||||
|
||||
pub(crate) use self::control_plan::{
|
||||
maybe_build_stream_plan_payload_impl, maybe_build_sync_plan_payload_impl,
|
||||
};
|
||||
pub(crate) use self::stream::maybe_build_stream_decision_payload;
|
||||
pub(crate) use self::sync::maybe_build_sync_decision_payload;
|
||||
pub(crate) use super::passthrough::{
|
||||
maybe_build_stream_local_same_format_provider_decision_payload,
|
||||
maybe_build_sync_local_same_format_provider_decision_payload,
|
||||
};
|
||||
pub(crate) use super::specialized::{
|
||||
maybe_build_stream_local_gemini_files_decision_payload,
|
||||
maybe_build_stream_local_image_decision_payload,
|
||||
maybe_build_sync_local_gemini_files_decision_payload,
|
||||
maybe_build_sync_local_image_decision_payload, maybe_build_sync_local_video_decision_payload,
|
||||
};
|
||||
pub(crate) use super::standard::{
|
||||
maybe_build_stream_local_decision_payload,
|
||||
maybe_build_stream_local_openai_responses_decision_payload,
|
||||
maybe_build_stream_local_standard_decision_payload, maybe_build_sync_local_decision_payload,
|
||||
maybe_build_sync_local_openai_responses_decision_payload,
|
||||
maybe_build_sync_local_standard_decision_payload,
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
use aether_ai_serving::{
|
||||
build_ai_execution_decision_from_plan, run_ai_stream_decision_path,
|
||||
AiExecutionDecisionFromPlanParts, AiStreamDecisionPathPort, AiStreamDecisionStep,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::ai_serving::planner::common::{
|
||||
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, OPENAI_VIDEO_CONTENT_PLAN_KIND,
|
||||
};
|
||||
use crate::ai_serving::planner::route::{
|
||||
is_matching_stream_request, resolve_execution_runtime_stream_plan_kind,
|
||||
};
|
||||
use crate::ai_serving::{resolve_decision_execution_runtime_auth_context, GatewayControlDecision};
|
||||
use crate::{AiExecutionDecision, AppState, GatewayError};
|
||||
|
||||
pub(crate) async fn maybe_build_stream_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let Some(plan_kind) = resolve_execution_runtime_stream_plan_kind(parts, decision) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if !is_matching_stream_request(plan_kind, parts, body_json, body_base64) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let port = GatewayStreamDecisionPathPort {
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
body_base64,
|
||||
plan_kind,
|
||||
};
|
||||
|
||||
run_ai_stream_decision_path(&port).await
|
||||
}
|
||||
|
||||
struct GatewayStreamDecisionPathPort<'a> {
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
trace_id: &'a str,
|
||||
decision: &'a GatewayControlDecision,
|
||||
body_json: &'a serde_json::Value,
|
||||
body_base64: Option<&'a str>,
|
||||
plan_kind: &'a str,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AiStreamDecisionPathPort for GatewayStreamDecisionPathPort<'_> {
|
||||
type Decision = AiExecutionDecision;
|
||||
type Error = GatewayError;
|
||||
|
||||
async fn build_stream_decision_step(
|
||||
&self,
|
||||
step: AiStreamDecisionStep,
|
||||
) -> Result<Option<Self::Decision>, Self::Error> {
|
||||
match step {
|
||||
AiStreamDecisionStep::LocalVideoContent => {
|
||||
maybe_build_local_video_task_content_stream_decision_payload(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.trace_id,
|
||||
self.decision,
|
||||
self.plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AiStreamDecisionStep::LocalImage => {
|
||||
super::maybe_build_stream_local_image_decision_payload(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.body_json,
|
||||
self.body_base64,
|
||||
self.trace_id,
|
||||
self.decision,
|
||||
self.plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AiStreamDecisionStep::LocalOpenAiChat => {
|
||||
super::maybe_build_stream_local_decision_payload(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.trace_id,
|
||||
self.decision,
|
||||
self.body_json,
|
||||
self.plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AiStreamDecisionStep::LocalOpenAiResponses => {
|
||||
super::maybe_build_stream_local_openai_responses_decision_payload(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.trace_id,
|
||||
self.decision,
|
||||
self.body_json,
|
||||
self.plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AiStreamDecisionStep::LocalStandardFamily => {
|
||||
super::maybe_build_stream_local_standard_decision_payload(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.trace_id,
|
||||
self.decision,
|
||||
self.body_json,
|
||||
self.plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AiStreamDecisionStep::LocalSameFormatProvider => {
|
||||
super::maybe_build_stream_local_same_format_provider_decision_payload(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.trace_id,
|
||||
self.decision,
|
||||
self.body_json,
|
||||
self.plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AiStreamDecisionStep::LocalGeminiFiles => {
|
||||
super::maybe_build_stream_local_gemini_files_decision_payload(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.trace_id,
|
||||
self.decision,
|
||||
self.plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_build_local_video_task_content_stream_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
if plan_kind != OPENAI_VIDEO_CONTENT_PLAN_KIND
|
||||
|| decision.route_family.as_deref() != Some("openai")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let _ = state
|
||||
.hydrate_video_task_for_route(decision.route_family.as_deref(), parts.uri.path())
|
||||
.await?;
|
||||
|
||||
let Some(action) = state.video_tasks.prepare_openai_content_stream_action(
|
||||
parts.uri.path(),
|
||||
parts.uri.query(),
|
||||
trace_id,
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let crate::video_tasks::LocalVideoTaskContentAction::StreamPlan(plan) = action else {
|
||||
return Ok(None);
|
||||
};
|
||||
let plan = *plan;
|
||||
|
||||
Ok(Some(build_ai_execution_decision_from_plan(
|
||||
AiExecutionDecisionFromPlanParts {
|
||||
action: EXECUTION_RUNTIME_STREAM_DECISION_ACTION.to_string(),
|
||||
decision_kind: Some(plan_kind.to_string()),
|
||||
request_id: None,
|
||||
upstream_base_url: None,
|
||||
include_auth_pair: false,
|
||||
plan,
|
||||
report_kind: None,
|
||||
report_context: None,
|
||||
auth_context: resolve_decision_execution_runtime_auth_context(decision),
|
||||
},
|
||||
)))
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use aether_ai_serving::{
|
||||
build_ai_execution_decision_from_plan, infer_ai_upstream_base_url, run_ai_sync_decision_path,
|
||||
AiExecutionDecisionFromPlanParts, AiSyncDecisionPathPort, AiSyncDecisionStep,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::ai_serving::planner::common::{
|
||||
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_FILES_DELETE_PLAN_KIND,
|
||||
GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||
};
|
||||
use crate::ai_serving::planner::route::resolve_execution_runtime_sync_plan_kind;
|
||||
use crate::ai_serving::{
|
||||
build_execution_runtime_auth_context, resolve_execution_runtime_auth_context,
|
||||
GatewayControlDecision,
|
||||
};
|
||||
use crate::{AiExecutionDecision, AppState, GatewayError};
|
||||
|
||||
pub(crate) async fn maybe_build_sync_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
body_is_empty: bool,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let Some(plan_kind) = resolve_execution_runtime_sync_plan_kind(parts, decision) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let port = GatewaySyncDecisionPathPort {
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
body_base64,
|
||||
body_is_empty,
|
||||
plan_kind,
|
||||
};
|
||||
|
||||
run_ai_sync_decision_path(&port).await
|
||||
}
|
||||
|
||||
struct GatewaySyncDecisionPathPort<'a> {
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
trace_id: &'a str,
|
||||
decision: &'a GatewayControlDecision,
|
||||
body_json: &'a serde_json::Value,
|
||||
body_base64: Option<&'a str>,
|
||||
body_is_empty: bool,
|
||||
plan_kind: &'a str,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AiSyncDecisionPathPort for GatewaySyncDecisionPathPort<'_> {
|
||||
type Decision = AiExecutionDecision;
|
||||
type Error = GatewayError;
|
||||
|
||||
fn sync_decision_step_enabled(&self, step: AiSyncDecisionStep) -> bool {
|
||||
if step == AiSyncDecisionStep::LocalGeminiFiles {
|
||||
return matches!(
|
||||
self.plan_kind,
|
||||
GEMINI_FILES_LIST_PLAN_KIND
|
||||
| GEMINI_FILES_GET_PLAN_KIND
|
||||
| GEMINI_FILES_DELETE_PLAN_KIND
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
async fn build_sync_decision_step(
|
||||
&self,
|
||||
step: AiSyncDecisionStep,
|
||||
) -> Result<Option<Self::Decision>, Self::Error> {
|
||||
match step {
|
||||
AiSyncDecisionStep::VideoTaskFollowUp => {
|
||||
maybe_build_local_video_task_follow_up_sync_decision_payload(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.body_json,
|
||||
self.trace_id,
|
||||
self.decision,
|
||||
self.plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AiSyncDecisionStep::LocalVideo => {
|
||||
super::maybe_build_sync_local_video_decision_payload(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.body_json,
|
||||
self.trace_id,
|
||||
self.decision,
|
||||
self.plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AiSyncDecisionStep::LocalImage => {
|
||||
super::maybe_build_sync_local_image_decision_payload(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.body_json,
|
||||
self.body_base64,
|
||||
self.trace_id,
|
||||
self.decision,
|
||||
self.plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AiSyncDecisionStep::LocalOpenAiChat => {
|
||||
super::maybe_build_sync_local_decision_payload(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.trace_id,
|
||||
self.decision,
|
||||
self.body_json,
|
||||
self.plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AiSyncDecisionStep::LocalOpenAiResponses => {
|
||||
super::maybe_build_sync_local_openai_responses_decision_payload(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.trace_id,
|
||||
self.decision,
|
||||
self.body_json,
|
||||
self.plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AiSyncDecisionStep::LocalStandardFamily => {
|
||||
super::maybe_build_sync_local_standard_decision_payload(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.trace_id,
|
||||
self.decision,
|
||||
self.body_json,
|
||||
self.plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AiSyncDecisionStep::LocalSameFormatProvider => {
|
||||
super::maybe_build_sync_local_same_format_provider_decision_payload(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.trace_id,
|
||||
self.decision,
|
||||
self.body_json,
|
||||
self.plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AiSyncDecisionStep::LocalGeminiFiles => {
|
||||
super::maybe_build_sync_local_gemini_files_decision_payload(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.body_json,
|
||||
self.body_base64,
|
||||
self.body_is_empty,
|
||||
self.trace_id,
|
||||
self.decision,
|
||||
self.plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_build_local_video_task_follow_up_sync_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
if !matches!(
|
||||
plan_kind,
|
||||
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND
|
||||
| OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND
|
||||
| OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND
|
||||
| GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND
|
||||
) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let _ = state
|
||||
.hydrate_video_task_for_route(decision.route_family.as_deref(), parts.uri.path())
|
||||
.await?;
|
||||
|
||||
let auth_context = resolve_execution_runtime_auth_context(
|
||||
state,
|
||||
decision,
|
||||
&parts.headers,
|
||||
&parts.uri,
|
||||
trace_id,
|
||||
)
|
||||
.await?;
|
||||
let Some(auth_context) = auth_context else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(follow_up) = state.video_tasks.prepare_follow_up_sync_plan(
|
||||
plan_kind,
|
||||
parts.uri.path(),
|
||||
Some(body_json),
|
||||
Some(&auth_context),
|
||||
trace_id,
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let aether_video_tasks_core::LocalVideoTaskFollowUpPlan {
|
||||
plan,
|
||||
report_kind,
|
||||
report_context,
|
||||
} = follow_up;
|
||||
let upstream_base_url = infer_ai_upstream_base_url(&plan.url);
|
||||
|
||||
debug!(
|
||||
event_name = "local_video_follow_up_sync_decision_payload_built",
|
||||
log_type = "debug",
|
||||
trace_id = %trace_id,
|
||||
request_id = %trace_id,
|
||||
candidate_id = ?plan.candidate_id,
|
||||
provider_id = %plan.provider_id,
|
||||
endpoint_id = %plan.endpoint_id,
|
||||
key_id = %plan.key_id,
|
||||
plan_kind,
|
||||
downstream_path = %parts.uri.path(),
|
||||
provider_api_format = %plan.provider_api_format,
|
||||
client_api_format = %plan.client_api_format,
|
||||
upstream_base_url = ?upstream_base_url,
|
||||
upstream_url = %plan.url,
|
||||
"gateway built local video follow-up sync decision payload"
|
||||
);
|
||||
|
||||
Ok(Some(build_ai_execution_decision_from_plan(
|
||||
AiExecutionDecisionFromPlanParts {
|
||||
action: EXECUTION_RUNTIME_SYNC_DECISION_ACTION.to_string(),
|
||||
decision_kind: Some(plan_kind.to_string()),
|
||||
request_id: Some(trace_id.to_string()),
|
||||
upstream_base_url,
|
||||
include_auth_pair: true,
|
||||
plan,
|
||||
report_kind,
|
||||
report_context,
|
||||
auth_context: Some(build_execution_runtime_auth_context(&auth_context)),
|
||||
},
|
||||
)))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::transport::{
|
||||
build_gemini_cli_v1internal_request, build_standard_provider_request_headers,
|
||||
GatewayProviderTransportSnapshot, GeminiCliRequestAuth, GeminiCliRequestAuthSupport,
|
||||
GeminiCliRequestEnvelopeSupport, StandardProviderRequestHeaders,
|
||||
StandardProviderRequestHeadersInput, GEMINI_CLI_USER_AGENT,
|
||||
};
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) enum GeminiCliV1InternalRequestError {
|
||||
ProjectUnavailable,
|
||||
EnvelopeUnsupported,
|
||||
UpstreamUrlUnavailable,
|
||||
HeaderRulesApplyFailed,
|
||||
}
|
||||
|
||||
pub(crate) struct GeminiCliV1InternalRequestInput<'a> {
|
||||
pub(crate) state: &'a AppState,
|
||||
pub(crate) parts: &'a http::request::Parts,
|
||||
pub(crate) transport: &'a Arc<GatewayProviderTransportSnapshot>,
|
||||
pub(crate) trace_id: &'a str,
|
||||
pub(crate) mapped_model: &'a str,
|
||||
pub(crate) provider_api_format: &'a str,
|
||||
pub(crate) auth_header: &'a str,
|
||||
pub(crate) auth_value: &'a str,
|
||||
pub(crate) request_headers: &'a http::HeaderMap,
|
||||
pub(crate) original_request_body: &'a Value,
|
||||
pub(crate) gemini_request_body: &'a Value,
|
||||
pub(crate) upstream_is_stream: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct GeminiCliV1InternalRequest {
|
||||
pub(crate) transport: Arc<GatewayProviderTransportSnapshot>,
|
||||
pub(crate) body: Value,
|
||||
pub(crate) headers: StandardProviderRequestHeaders,
|
||||
pub(crate) upstream_url: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn build_gemini_cli_v1internal_provider_request(
|
||||
input: GeminiCliV1InternalRequestInput<'_>,
|
||||
) -> Result<GeminiCliV1InternalRequest, GeminiCliV1InternalRequestError> {
|
||||
let payload = build_gemini_cli_v1internal_payload(
|
||||
input.state,
|
||||
input.transport,
|
||||
input.trace_id,
|
||||
input.mapped_model,
|
||||
input.gemini_request_body,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let upstream_url = crate::ai_serving::build_provider_transport_request_url_for_request_body(
|
||||
&payload.transport,
|
||||
input.provider_api_format,
|
||||
Some(input.mapped_model),
|
||||
input.upstream_is_stream,
|
||||
input.parts.uri.query(),
|
||||
None,
|
||||
None,
|
||||
Some(&payload.body),
|
||||
)
|
||||
.ok_or(GeminiCliV1InternalRequestError::UpstreamUrlUnavailable)?;
|
||||
|
||||
let extra_headers =
|
||||
BTreeMap::from([("user-agent".to_string(), GEMINI_CLI_USER_AGENT.to_string())]);
|
||||
let headers = build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
|
||||
transport: &payload.transport,
|
||||
provider_api_format: input.provider_api_format,
|
||||
same_format: false,
|
||||
headers: input.request_headers,
|
||||
auth_header: input.auth_header,
|
||||
auth_value: input.auth_value,
|
||||
extra_headers: &extra_headers,
|
||||
header_rules: payload.transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &payload.body,
|
||||
original_request_body: input.original_request_body,
|
||||
upstream_is_stream: input.upstream_is_stream,
|
||||
})
|
||||
.ok_or(GeminiCliV1InternalRequestError::HeaderRulesApplyFailed)?;
|
||||
|
||||
Ok(GeminiCliV1InternalRequest {
|
||||
transport: payload.transport,
|
||||
body: payload.body,
|
||||
headers,
|
||||
upstream_url,
|
||||
})
|
||||
}
|
||||
|
||||
struct GeminiCliV1InternalPayload {
|
||||
transport: Arc<GatewayProviderTransportSnapshot>,
|
||||
body: Value,
|
||||
}
|
||||
|
||||
async fn build_gemini_cli_v1internal_payload(
|
||||
state: &AppState,
|
||||
transport: &Arc<GatewayProviderTransportSnapshot>,
|
||||
trace_id: &str,
|
||||
mapped_model: &str,
|
||||
gemini_request_body: &Value,
|
||||
) -> Result<GeminiCliV1InternalPayload, GeminiCliV1InternalRequestError> {
|
||||
let mut resolved_transport = Arc::clone(transport);
|
||||
let mut auth = match crate::ai_serving::transport::resolve_local_gemini_cli_request_auth(
|
||||
&resolved_transport,
|
||||
) {
|
||||
GeminiCliRequestAuthSupport::Supported(auth) => auth,
|
||||
GeminiCliRequestAuthSupport::Unsupported(_) => {
|
||||
return Err(GeminiCliV1InternalRequestError::ProjectUnavailable);
|
||||
}
|
||||
};
|
||||
if auth.project_id.is_none() {
|
||||
auth = match state
|
||||
.hydrate_gemini_cli_project_metadata_for_transport(&resolved_transport)
|
||||
.await
|
||||
{
|
||||
Some(hydrated) => {
|
||||
resolved_transport = Arc::new(hydrated);
|
||||
match crate::ai_serving::transport::resolve_local_gemini_cli_request_auth(
|
||||
&resolved_transport,
|
||||
) {
|
||||
GeminiCliRequestAuthSupport::Supported(auth) => auth,
|
||||
GeminiCliRequestAuthSupport::Unsupported(_) => GeminiCliRequestAuth::default(),
|
||||
}
|
||||
}
|
||||
None => GeminiCliRequestAuth::default(),
|
||||
};
|
||||
}
|
||||
let body = match build_gemini_cli_v1internal_request(
|
||||
&auth,
|
||||
trace_id,
|
||||
mapped_model,
|
||||
gemini_request_body,
|
||||
) {
|
||||
GeminiCliRequestEnvelopeSupport::Supported(envelope) => envelope,
|
||||
GeminiCliRequestEnvelopeSupport::Unsupported(_) => {
|
||||
return Err(GeminiCliV1InternalRequestError::EnvelopeUnsupported);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(GeminiCliV1InternalPayload {
|
||||
transport: resolved_transport,
|
||||
body,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use aether_ai_serving::ai_candidate_persistence_policy_spec;
|
||||
pub(crate) use aether_ai_serving::AiCandidatePersistencePolicyKind as LocalCandidatePersistencePolicyKind;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::planner::candidate_materialization::{
|
||||
LocalAvailableCandidatePersistenceContext, LocalSkippedCandidatePersistenceContext,
|
||||
};
|
||||
use crate::ai_serving::ExecutionRuntimeAuthContext;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct LocalCandidatePersistencePolicy<'a> {
|
||||
pub(crate) available: LocalAvailableCandidatePersistenceContext<'a>,
|
||||
pub(crate) skipped: LocalSkippedCandidatePersistenceContext<'a>,
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_candidate_persistence_policy<'a>(
|
||||
auth_context: &'a ExecutionRuntimeAuthContext,
|
||||
required_capabilities: Option<&'a Value>,
|
||||
kind: LocalCandidatePersistencePolicyKind,
|
||||
) -> LocalCandidatePersistencePolicy<'a> {
|
||||
let spec = ai_candidate_persistence_policy_spec(kind);
|
||||
|
||||
LocalCandidatePersistencePolicy {
|
||||
available: LocalAvailableCandidatePersistenceContext {
|
||||
user_id: &auth_context.user_id,
|
||||
api_key_id: &auth_context.api_key_id,
|
||||
required_capabilities,
|
||||
error_context: spec.available_error_context,
|
||||
},
|
||||
skipped: LocalSkippedCandidatePersistenceContext {
|
||||
user_id: &auth_context.user_id,
|
||||
api_key_id: &auth_context.api_key_id,
|
||||
required_capabilities,
|
||||
error_context: spec.skipped_error_context,
|
||||
record_runtime_miss_diagnostic: spec.record_runtime_miss_diagnostic,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
use crate::ai_serving::{AiExecutionDecision, AiExecutionPlanPayload, GatewayControlDecision};
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
mod antigravity;
|
||||
mod candidate_affinity_cache;
|
||||
mod candidate_materialization;
|
||||
mod candidate_metadata;
|
||||
mod candidate_preparation;
|
||||
mod candidate_ranking;
|
||||
mod candidate_resolution;
|
||||
mod candidate_source;
|
||||
mod candidate_transport_ranking_facts;
|
||||
mod common;
|
||||
mod decision;
|
||||
mod decision_input;
|
||||
mod gemini_cli;
|
||||
mod materialization_policy;
|
||||
mod passthrough;
|
||||
mod plan_builders;
|
||||
mod pool_scheduler;
|
||||
pub(crate) mod pool_scores;
|
||||
mod redaction;
|
||||
mod report_context;
|
||||
mod request_gzip;
|
||||
mod route;
|
||||
mod runtime_miss;
|
||||
mod spec_metadata;
|
||||
mod specialized;
|
||||
mod standard;
|
||||
mod state;
|
||||
|
||||
pub(crate) use self::candidate_materialization::LocalExecutionAttemptSource;
|
||||
pub(crate) use self::candidate_resolution::{
|
||||
candidate_auth_channel_skip_reason, read_candidate_transport_snapshot,
|
||||
EligibleLocalExecutionCandidate, LocalExecutionCandidateKind, SkippedLocalExecutionCandidate,
|
||||
};
|
||||
pub(crate) use self::common::resolve_upstream_is_stream_for_provider;
|
||||
pub(crate) use self::passthrough::{
|
||||
build_local_same_format_stream_attempt_source, build_local_same_format_stream_plan_and_reports,
|
||||
build_local_same_format_sync_attempt_source, build_local_same_format_sync_plan_and_reports,
|
||||
};
|
||||
pub(crate) use self::plan_builders::{
|
||||
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
|
||||
build_openai_responses_stream_plan_from_decision,
|
||||
build_openai_responses_sync_plan_from_decision, build_passthrough_sync_plan_from_decision,
|
||||
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
|
||||
AiStreamAttempt, AiSyncAttempt,
|
||||
};
|
||||
pub(crate) use self::pool_scores::{
|
||||
build_provider_key_pool_score_upsert, provider_key_pool_score_id, provider_key_pool_score_scope,
|
||||
};
|
||||
pub(crate) use self::request_gzip::resolve_transport_request_encoding_policy;
|
||||
pub(crate) use self::route::is_matching_stream_request as planner_is_matching_stream_request;
|
||||
pub(crate) use self::runtime_miss::{
|
||||
apply_local_runtime_candidate_terminal_reason, record_local_runtime_candidate_skip_reason,
|
||||
};
|
||||
pub(crate) use self::specialized::{
|
||||
build_local_gemini_files_stream_attempt_source_for_kind,
|
||||
build_local_gemini_files_stream_plan_and_reports_for_kind,
|
||||
build_local_gemini_files_sync_attempt_source_for_kind,
|
||||
build_local_gemini_files_sync_plan_and_reports_for_kind,
|
||||
build_local_image_stream_attempt_source_for_kind,
|
||||
build_local_image_stream_plan_and_reports_for_kind,
|
||||
build_local_image_sync_attempt_source_for_kind,
|
||||
build_local_image_sync_plan_and_reports_for_kind,
|
||||
build_local_video_sync_attempt_source_for_kind,
|
||||
build_local_video_sync_plan_and_reports_for_kind,
|
||||
set_local_openai_image_execution_exhausted_diagnostic,
|
||||
};
|
||||
pub(crate) use self::standard::{
|
||||
build_local_openai_chat_stream_attempt_source_for_kind,
|
||||
build_local_openai_chat_stream_plan_and_reports_for_kind,
|
||||
build_local_openai_chat_sync_attempt_source_for_kind,
|
||||
build_local_openai_chat_sync_plan_and_reports_for_kind,
|
||||
build_local_openai_responses_stream_attempt_source_for_kind,
|
||||
build_local_openai_responses_stream_plan_and_reports_for_kind,
|
||||
build_local_openai_responses_sync_attempt_source_for_kind,
|
||||
build_local_openai_responses_sync_plan_and_reports_for_kind,
|
||||
build_local_stream_attempt_source as build_standard_family_stream_attempt_source,
|
||||
build_local_stream_plan_and_reports as build_standard_family_stream_plan_and_reports,
|
||||
build_local_sync_attempt_source as build_standard_family_sync_attempt_source,
|
||||
build_local_sync_plan_and_reports as build_standard_family_sync_plan_and_reports,
|
||||
codex_model_capabilities_for_transport, set_local_openai_chat_execution_exhausted_diagnostic,
|
||||
validate_final_openai_provider_request,
|
||||
};
|
||||
pub(crate) use self::state::{
|
||||
GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth,
|
||||
PlannerAppState,
|
||||
};
|
||||
pub(crate) use aether_ai_serving::extract_ai_pool_sticky_session_token as extract_pool_sticky_session_token;
|
||||
pub(crate) use aether_ai_serving::{
|
||||
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
|
||||
CandidateFailureDiagnostic, CandidateFailureDiagnosticKind,
|
||||
};
|
||||
|
||||
pub(crate) struct ResolvedTunnelSchedulerAffinityContext {
|
||||
pub(crate) requested_model: String,
|
||||
pub(crate) client_session_affinity: Option<aether_scheduler_core::ClientSessionAffinity>,
|
||||
pub(crate) policy_context: Option<crate::scheduler::affinity::SchedulerAffinityPolicyContext>,
|
||||
pub(crate) routing_overlay: Option<aether_routing_core::RankingOverlay>,
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_tunnel_scheduler_affinity_context(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
decision: &GatewayControlDecision,
|
||||
requested_model: String,
|
||||
body_json: &serde_json::Value,
|
||||
client_api_format: &str,
|
||||
) -> Result<Option<ResolvedTunnelSchedulerAffinityContext>, GatewayError> {
|
||||
let Some(auth_context) = decision.auth_context.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let execution_auth_context =
|
||||
crate::ai_serving::build_execution_runtime_auth_context(auth_context);
|
||||
let Some(auth_snapshot) = state
|
||||
.read_cached_auth_api_key_snapshot(
|
||||
&execution_auth_context.user_id,
|
||||
&execution_auth_context.api_key_id,
|
||||
crate::clock::current_unix_secs(),
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let resolved_auth_input = decision_input::ResolvedLocalDecisionAuthInput {
|
||||
auth_context: execution_auth_context,
|
||||
auth_snapshot,
|
||||
required_capabilities: None,
|
||||
model_directive_policy: decision.model_directive_policy.clone(),
|
||||
};
|
||||
let mut input = decision_input::build_local_requested_model_decision_input(
|
||||
resolved_auth_input,
|
||||
requested_model,
|
||||
);
|
||||
decision_input::attach_routing_policy_to_local_requested_model_input(
|
||||
state,
|
||||
parts,
|
||||
&mut input,
|
||||
body_json,
|
||||
client_api_format,
|
||||
)
|
||||
.await?;
|
||||
let policy_context = input
|
||||
.routing_policy
|
||||
.as_ref()
|
||||
.map(crate::scheduler::affinity::SchedulerAffinityPolicyContext::from_routing_policy);
|
||||
let routing_overlay = input
|
||||
.routing_policy
|
||||
.as_ref()
|
||||
.map(|policy| policy.ranking_overlay.clone());
|
||||
|
||||
Ok(Some(ResolvedTunnelSchedulerAffinityContext {
|
||||
requested_model: input.requested_model,
|
||||
client_session_affinity: input.client_session_affinity,
|
||||
policy_context,
|
||||
routing_overlay,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_sync_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
body_is_empty: bool,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
decision::maybe_build_sync_decision_payload(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
body_base64,
|
||||
body_is_empty,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_stream_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
decision::maybe_build_stream_decision_payload(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
body_base64,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_sync_plan_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
body_is_empty: bool,
|
||||
) -> Result<Option<AiExecutionPlanPayload>, GatewayError> {
|
||||
decision::maybe_build_sync_plan_payload_impl(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
body_base64,
|
||||
body_is_empty,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_stream_plan_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
) -> Result<Option<AiExecutionPlanPayload>, GatewayError> {
|
||||
decision::maybe_build_stream_plan_payload_impl(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
body_base64,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Requests that can stay in the same public/provider contract family.
|
||||
|
||||
mod provider;
|
||||
|
||||
pub(crate) use self::provider::{
|
||||
build_local_stream_attempt_source as build_local_same_format_stream_attempt_source,
|
||||
build_local_stream_plan_and_reports as build_local_same_format_stream_plan_and_reports,
|
||||
build_local_sync_attempt_source as build_local_same_format_sync_attempt_source,
|
||||
build_local_sync_plan_and_reports as build_local_same_format_sync_plan_and_reports,
|
||||
maybe_build_local_same_format_provider_decision_payload_for_candidate,
|
||||
maybe_build_stream_local_same_format_provider_decision_payload,
|
||||
maybe_build_sync_local_same_format_provider_decision_payload,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::transport::provider_types::provider_type_supports_local_same_format_transport;
|
||||
@@ -0,0 +1,97 @@
|
||||
use aether_contracts::RequestBody;
|
||||
|
||||
use super::{
|
||||
augment_sync_report_context, build_ai_execution_plan_from_decision,
|
||||
resolve_ai_passthrough_sync_request_body, take_ai_decision_plan_core, take_non_empty_string,
|
||||
AiExecutionPlanFromDecisionParts, AiStreamAttempt, AiSyncAttempt,
|
||||
};
|
||||
use crate::{AiExecutionDecision, GatewayError};
|
||||
|
||||
pub(crate) fn build_passthrough_sync_plan_from_decision(
|
||||
parts: &http::request::Parts,
|
||||
payload: AiExecutionDecision,
|
||||
) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||
let mut payload = payload;
|
||||
let Some(core) = take_ai_decision_plan_core(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(upstream_url) = take_non_empty_string(&mut payload.upstream_url) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let provider_request_headers = std::mem::take(&mut payload.provider_request_headers);
|
||||
let ignored_provider_request_body = serde_json::Value::Null;
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context.take(),
|
||||
&provider_request_headers,
|
||||
&ignored_provider_request_body,
|
||||
)?;
|
||||
let request_body = resolve_ai_passthrough_sync_request_body(
|
||||
payload.provider_request_body.take(),
|
||||
payload.provider_request_body_base64.take(),
|
||||
);
|
||||
let provider_request_method = take_non_empty_string(&mut payload.provider_request_method);
|
||||
let content_type = payload
|
||||
.content_type
|
||||
.take()
|
||||
.or_else(|| provider_request_headers.get("content-type").cloned());
|
||||
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: provider_request_method.unwrap_or_else(|| parts.method.to_string()),
|
||||
url: upstream_url,
|
||||
headers: provider_request_headers,
|
||||
content_type,
|
||||
body: request_body,
|
||||
stream: false,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Some(AiSyncAttempt {
|
||||
plan,
|
||||
report_kind: payload.report_kind,
|
||||
report_context,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn build_passthrough_stream_plan_from_decision(
|
||||
parts: &http::request::Parts,
|
||||
payload: AiExecutionDecision,
|
||||
) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||
let mut payload = payload;
|
||||
let Some(core) = take_ai_decision_plan_core(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(upstream_url) = take_non_empty_string(&mut payload.upstream_url) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let provider_request_headers = std::mem::take(&mut payload.provider_request_headers);
|
||||
let content_type = payload
|
||||
.content_type
|
||||
.take()
|
||||
.or_else(|| provider_request_headers.get("content-type").cloned());
|
||||
let stream = payload.upstream_is_stream;
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: parts.method.to_string(),
|
||||
url: upstream_url,
|
||||
headers: provider_request_headers,
|
||||
content_type,
|
||||
body: RequestBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: None,
|
||||
body_ref: None,
|
||||
},
|
||||
stream,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Some(AiStreamAttempt {
|
||||
plan,
|
||||
report_kind: payload.report_kind,
|
||||
report_context: payload.report_context,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::Response;
|
||||
use std::collections::BTreeMap;
|
||||
use url::form_urlencoded;
|
||||
|
||||
use aether_data_contracts::repository::candidates::{
|
||||
RequestCandidateStatus, UpsertRequestCandidateRecord,
|
||||
};
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ai_serving::planner::common::{
|
||||
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
||||
};
|
||||
use crate::ai_serving::planner::plan_builders::{AiStreamAttempt, AiSyncAttempt};
|
||||
use crate::ai_serving::transport::antigravity::{
|
||||
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
|
||||
build_antigravity_v1internal_url, classify_local_antigravity_request_support,
|
||||
AntigravityEnvelopeRequestType, AntigravityRequestEnvelopeSupport,
|
||||
AntigravityRequestSideSupport, AntigravityRequestUrlAction,
|
||||
};
|
||||
use crate::ai_serving::transport::auth::{
|
||||
build_openai_passthrough_headers, resolve_local_gemini_auth, resolve_local_standard_auth,
|
||||
};
|
||||
use crate::ai_serving::transport::claude_code::{
|
||||
build_claude_code_messages_url, build_claude_code_passthrough_headers,
|
||||
sanitize_claude_code_request_body, supports_local_claude_code_transport_with_network,
|
||||
};
|
||||
use crate::ai_serving::transport::kiro::{
|
||||
build_kiro_generate_assistant_response_url, build_kiro_provider_headers,
|
||||
build_kiro_provider_request_body, supports_local_kiro_request_transport_with_network,
|
||||
KIRO_ENVELOPE_NAME,
|
||||
};
|
||||
use crate::ai_serving::transport::policy::{
|
||||
supports_local_gemini_transport_with_network, supports_local_standard_transport_with_network,
|
||||
};
|
||||
use crate::ai_serving::transport::url::{
|
||||
build_claude_messages_url, build_gemini_content_url, build_passthrough_path_url,
|
||||
};
|
||||
use crate::ai_serving::transport::vertex::{
|
||||
build_vertex_api_key_gemini_content_url, resolve_local_vertex_api_key_query_auth,
|
||||
supports_local_vertex_api_key_gemini_transport_with_network,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
apply_local_body_rules, apply_local_header_rules, build_passthrough_headers,
|
||||
ensure_upstream_auth_header, resolve_transport_execution_timeouts,
|
||||
resolve_transport_proxy_snapshot_with_tunnel_affinity, LocalResolvedOAuthRequestAuth,
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
collect_control_headers, ConversionMode, ExecutionStrategy, GatewayControlDecision,
|
||||
};
|
||||
use crate::clock::current_unix_secs;
|
||||
use crate::{
|
||||
append_execution_contract_fields_to_value, AiExecutionDecision, AppState, GatewayError,
|
||||
};
|
||||
|
||||
mod family;
|
||||
mod plans;
|
||||
mod request;
|
||||
|
||||
pub(crate) use self::family::{
|
||||
build_local_same_format_provider_candidate_attempt_source,
|
||||
materialize_local_same_format_provider_candidate_attempts,
|
||||
maybe_build_local_same_format_provider_decision_payload_for_candidate,
|
||||
resolve_local_same_format_provider_decision_input, LocalSameFormatProviderCandidateAttempt,
|
||||
LocalSameFormatProviderCandidateAttemptSource, LocalSameFormatProviderDecisionInput,
|
||||
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec,
|
||||
};
|
||||
pub(crate) use self::family::{
|
||||
maybe_build_stream_local_same_format_provider_decision_payload,
|
||||
maybe_build_sync_local_same_format_provider_decision_payload,
|
||||
};
|
||||
pub(crate) use self::plans::{
|
||||
build_local_stream_attempt_source, build_local_stream_plan_and_reports,
|
||||
build_local_sync_attempt_source, build_local_sync_plan_and_reports,
|
||||
};
|
||||
|
||||
const ANTIGRAVITY_ENVELOPE_NAME: &str = "antigravity:v1internal";
|
||||
@@ -0,0 +1,151 @@
|
||||
use crate::ai_serving::planner::common::extract_requested_model_from_request;
|
||||
use crate::ai_serving::planner::runtime_miss::{
|
||||
apply_local_runtime_candidate_evaluation_progress_preserving_candidate_signal,
|
||||
apply_local_runtime_candidate_terminal_reason, set_local_runtime_miss_diagnostic_reason,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_same_format_provider_spec_metadata;
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::{AiExecutionDecision, AppState, GatewayError};
|
||||
|
||||
use super::super::plans::{resolve_stream_spec, resolve_sync_spec};
|
||||
use super::candidates::{
|
||||
build_local_same_format_provider_candidate_attempt_source,
|
||||
resolve_local_same_format_provider_decision_input,
|
||||
};
|
||||
use super::payload::maybe_build_local_same_format_provider_decision_payload_for_candidate;
|
||||
|
||||
pub(crate) async fn maybe_build_sync_local_same_format_provider_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let spec_metadata = local_same_format_provider_spec_metadata(spec);
|
||||
let requested_model_family = spec_metadata
|
||||
.requested_model_family
|
||||
.expect("same-format provider spec metadata should include requested-model family");
|
||||
|
||||
let Some(input) = resolve_local_same_format_provider_decision_input(
|
||||
state, parts, trace_id, decision, body_json, spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
extract_requested_model_from_request(parts, body_json, requested_model_family)
|
||||
.as_deref(),
|
||||
"decision_input_unavailable",
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"candidate_evaluation_incomplete",
|
||||
);
|
||||
let body_json = input.effective_body_json(body_json);
|
||||
let (mut source, candidate_count) = build_local_same_format_provider_candidate_attempt_source(
|
||||
state, trace_id, &input, body_json, spec,
|
||||
)
|
||||
.await?;
|
||||
apply_local_runtime_candidate_evaluation_progress_preserving_candidate_signal(
|
||||
state,
|
||||
trace_id,
|
||||
candidate_count,
|
||||
);
|
||||
|
||||
while let Some(attempt) = source.next_attempt().await? {
|
||||
if let Some(payload) =
|
||||
maybe_build_local_same_format_provider_decision_payload_for_candidate(
|
||||
state, parts, trace_id, body_json, &input, attempt, spec,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(payload));
|
||||
}
|
||||
}
|
||||
|
||||
apply_local_runtime_candidate_terminal_reason(state, trace_id, "no_local_sync_plans");
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_stream_local_same_format_provider_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let Some(spec) = resolve_stream_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let spec_metadata = local_same_format_provider_spec_metadata(spec);
|
||||
let requested_model_family = spec_metadata
|
||||
.requested_model_family
|
||||
.expect("same-format provider spec metadata should include requested-model family");
|
||||
|
||||
let Some(input) = resolve_local_same_format_provider_decision_input(
|
||||
state, parts, trace_id, decision, body_json, spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
extract_requested_model_from_request(parts, body_json, requested_model_family)
|
||||
.as_deref(),
|
||||
"decision_input_unavailable",
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"candidate_evaluation_incomplete",
|
||||
);
|
||||
let body_json = input.effective_body_json(body_json);
|
||||
let (mut source, candidate_count) = build_local_same_format_provider_candidate_attempt_source(
|
||||
state, trace_id, &input, body_json, spec,
|
||||
)
|
||||
.await?;
|
||||
apply_local_runtime_candidate_evaluation_progress_preserving_candidate_signal(
|
||||
state,
|
||||
trace_id,
|
||||
candidate_count,
|
||||
);
|
||||
|
||||
while let Some(attempt) = source.next_attempt().await? {
|
||||
if let Some(payload) =
|
||||
maybe_build_local_same_format_provider_decision_payload_for_candidate(
|
||||
state, parts, trace_id, body_json, &input, attempt, spec,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(payload));
|
||||
}
|
||||
}
|
||||
|
||||
apply_local_runtime_candidate_terminal_reason(state, trace_id, "no_local_stream_plans");
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_serving::planner::candidate_materialization::{
|
||||
build_local_execution_candidate_attempt_source_with_serving,
|
||||
materialize_local_execution_candidates_with_serving, LocalCandidateResolutionMode,
|
||||
LocalExecutionCandidateAttemptSource,
|
||||
};
|
||||
use crate::ai_serving::planner::candidate_metadata::{
|
||||
build_local_execution_candidate_contract_metadata,
|
||||
build_local_execution_candidate_contract_metadata_for_candidate,
|
||||
LocalExecutionCandidateMetadataParts,
|
||||
};
|
||||
use crate::ai_serving::planner::candidate_resolution::SkippedLocalExecutionCandidate;
|
||||
use crate::ai_serving::planner::common::extract_requested_model_from_request;
|
||||
use crate::ai_serving::planner::decision_input::{
|
||||
attach_routing_policy_to_local_requested_model_input,
|
||||
build_local_requested_model_decision_input, resolve_local_authenticated_decision_input,
|
||||
};
|
||||
use crate::ai_serving::planner::materialization_policy::{
|
||||
build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_same_format_provider_spec_metadata;
|
||||
use crate::ai_serving::{
|
||||
ai_local_execution_contract_for_formats, extract_pool_sticky_session_token,
|
||||
resolve_local_decision_execution_runtime_auth_context, GatewayControlDecision, PlannerAppState,
|
||||
};
|
||||
use crate::client_session_affinity::client_session_affinity_from_api_request;
|
||||
use crate::clock::current_unix_secs;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
use super::{
|
||||
LocalSameFormatProviderCandidateAttempt, LocalSameFormatProviderDecisionInput,
|
||||
LocalSameFormatProviderSpec,
|
||||
};
|
||||
|
||||
pub(crate) async fn resolve_local_same_format_provider_decision_input(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
spec: LocalSameFormatProviderSpec,
|
||||
) -> Result<Option<LocalSameFormatProviderDecisionInput>, GatewayError> {
|
||||
let spec_metadata = local_same_format_provider_spec_metadata(spec);
|
||||
let Some(auth_context) = resolve_local_decision_execution_runtime_auth_context(decision) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(requested_model) = extract_requested_model_from_request(
|
||||
parts,
|
||||
body_json,
|
||||
spec_metadata
|
||||
.requested_model_family
|
||||
.expect("same-format provider specs should declare requested-model family"),
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let resolved_input = match resolve_local_authenticated_decision_input(
|
||||
state,
|
||||
auth_context,
|
||||
Some(requested_model.as_str()),
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
None,
|
||||
&decision.model_directive_policy,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(resolved_input)) => resolved_input,
|
||||
Ok(None) => return Ok(None),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
api_format = spec_metadata.api_format,
|
||||
error = ?err,
|
||||
"gateway local same-format decision auth snapshot read failed"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
|
||||
input.request_auth_channel = decision.request_auth_channel.clone();
|
||||
input.client_surface = decision.client_surface;
|
||||
input.gateway_credential_carrier = decision.gateway_credential_carrier;
|
||||
input.client_session_affinity = client_session_affinity_from_api_request(
|
||||
spec_metadata.api_format,
|
||||
&parts.headers,
|
||||
Some(body_json),
|
||||
);
|
||||
if let Err(err) = attach_routing_policy_to_local_requested_model_input(
|
||||
state,
|
||||
parts,
|
||||
&mut input,
|
||||
body_json,
|
||||
spec_metadata.api_format,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
api_format = spec_metadata.api_format,
|
||||
error = ?err,
|
||||
"gateway local same-format decision routing profile resolution failed"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
Ok(Some(input))
|
||||
}
|
||||
|
||||
pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
input: &LocalSameFormatProviderDecisionInput,
|
||||
body_json: &serde_json::Value,
|
||||
spec: LocalSameFormatProviderSpec,
|
||||
) -> Result<(Vec<LocalSameFormatProviderCandidateAttempt>, usize), GatewayError> {
|
||||
let spec_metadata = local_same_format_provider_spec_metadata(spec);
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let sticky_session_token = extract_pool_sticky_session_token(body_json);
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::SameFormatProviderDecision,
|
||||
);
|
||||
let model_directive_resolution = input
|
||||
.model_directive_policy
|
||||
.resolve_reasoning(spec_metadata.api_format, Some(&input.requested_model));
|
||||
let routing_model = model_directive_resolution
|
||||
.base_model()
|
||||
.unwrap_or(&input.requested_model);
|
||||
let (candidates, preselection_skipped) = planner_state
|
||||
.list_selectable_candidates_with_skip_reasons_for_request_operation(
|
||||
spec_metadata.api_format,
|
||||
routing_model,
|
||||
spec_metadata.require_streaming,
|
||||
input.required_capabilities.as_ref(),
|
||||
Some(&input.auth_snapshot),
|
||||
input.client_session_affinity.as_ref(),
|
||||
current_unix_secs(),
|
||||
false,
|
||||
spec.operation.map(|operation| operation.as_str()),
|
||||
)
|
||||
.await?;
|
||||
let outcome = materialize_local_execution_candidates_with_serving(
|
||||
planner_state,
|
||||
trace_id,
|
||||
spec_metadata.api_format,
|
||||
Some(&input.requested_model),
|
||||
Some(&input.auth_snapshot),
|
||||
input.client_session_affinity.as_ref(),
|
||||
input.required_capabilities.as_ref(),
|
||||
input.routing_policy.as_ref(),
|
||||
sticky_session_token.as_deref(),
|
||||
input.request_auth_channel.as_deref(),
|
||||
persistence_policy,
|
||||
candidates,
|
||||
preselection_skipped
|
||||
.into_iter()
|
||||
.map(|item| SkippedLocalExecutionCandidate {
|
||||
candidate: item.candidate,
|
||||
skip_reason: item.skip_reason,
|
||||
transport: None,
|
||||
ranking: None,
|
||||
extra_data: None,
|
||||
})
|
||||
.collect(),
|
||||
LocalCandidateResolutionMode::Standard,
|
||||
|eligible| {
|
||||
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
|
||||
spec_metadata.api_format,
|
||||
spec_metadata.api_format,
|
||||
);
|
||||
Some(build_local_execution_candidate_contract_metadata(
|
||||
LocalExecutionCandidateMetadataParts {
|
||||
eligible,
|
||||
provider_api_format: spec_metadata.api_format,
|
||||
client_api_format: spec_metadata.api_format,
|
||||
extra_fields: serde_json::Map::new(),
|
||||
},
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
spec_metadata.api_format,
|
||||
))
|
||||
},
|
||||
|mut skipped_candidate| {
|
||||
let provider_api_format = skipped_candidate
|
||||
.transport
|
||||
.as_ref()
|
||||
.map(|transport| transport.endpoint.api_format.trim().to_ascii_lowercase())
|
||||
.unwrap_or_else(|| spec_metadata.api_format.to_string());
|
||||
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
|
||||
spec_metadata.api_format,
|
||||
provider_api_format.as_str(),
|
||||
);
|
||||
skipped_candidate.extra_data = Some(
|
||||
build_local_execution_candidate_contract_metadata_for_candidate(
|
||||
&skipped_candidate.candidate,
|
||||
skipped_candidate.transport_ref(),
|
||||
provider_api_format.as_str(),
|
||||
spec_metadata.api_format,
|
||||
serde_json::Map::new(),
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
provider_api_format.as_str(),
|
||||
),
|
||||
);
|
||||
skipped_candidate
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok((outcome.attempts, outcome.candidate_count))
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_same_format_provider_candidate_attempt_source<'a>(
|
||||
state: &'a AppState,
|
||||
trace_id: &str,
|
||||
input: &LocalSameFormatProviderDecisionInput,
|
||||
body_json: &serde_json::Value,
|
||||
spec: LocalSameFormatProviderSpec,
|
||||
) -> Result<(LocalExecutionCandidateAttemptSource<'a>, usize), GatewayError> {
|
||||
let spec_metadata = local_same_format_provider_spec_metadata(spec);
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let sticky_session_token = extract_pool_sticky_session_token(body_json);
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::SameFormatProviderDecision,
|
||||
);
|
||||
let model_directive_resolution = input
|
||||
.model_directive_policy
|
||||
.resolve_reasoning(spec_metadata.api_format, Some(&input.requested_model));
|
||||
let routing_model = model_directive_resolution
|
||||
.base_model()
|
||||
.unwrap_or(&input.requested_model);
|
||||
let (candidates, preselection_skipped) = planner_state
|
||||
.list_selectable_candidates_with_skip_reasons_for_request_operation(
|
||||
spec_metadata.api_format,
|
||||
routing_model,
|
||||
spec_metadata.require_streaming,
|
||||
input.required_capabilities.as_ref(),
|
||||
Some(&input.auth_snapshot),
|
||||
input.client_session_affinity.as_ref(),
|
||||
current_unix_secs(),
|
||||
false,
|
||||
spec.operation.map(|operation| operation.as_str()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(build_local_execution_candidate_attempt_source_with_serving(
|
||||
planner_state,
|
||||
trace_id,
|
||||
spec_metadata.api_format,
|
||||
Some(&input.requested_model),
|
||||
Some(&input.auth_snapshot),
|
||||
input.client_session_affinity.as_ref(),
|
||||
input.required_capabilities.as_ref(),
|
||||
input.routing_policy.as_ref(),
|
||||
sticky_session_token.as_deref(),
|
||||
input.request_auth_channel.as_deref(),
|
||||
persistence_policy,
|
||||
candidates,
|
||||
preselection_skipped
|
||||
.into_iter()
|
||||
.map(|item| SkippedLocalExecutionCandidate {
|
||||
candidate: item.candidate,
|
||||
skip_reason: item.skip_reason,
|
||||
transport: None,
|
||||
ranking: None,
|
||||
extra_data: None,
|
||||
})
|
||||
.collect(),
|
||||
LocalCandidateResolutionMode::Standard,
|
||||
|eligible| {
|
||||
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
|
||||
spec_metadata.api_format,
|
||||
spec_metadata.api_format,
|
||||
);
|
||||
Some(build_local_execution_candidate_contract_metadata(
|
||||
LocalExecutionCandidateMetadataParts {
|
||||
eligible,
|
||||
provider_api_format: spec_metadata.api_format,
|
||||
client_api_format: spec_metadata.api_format,
|
||||
extra_fields: serde_json::Map::new(),
|
||||
},
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
spec_metadata.api_format,
|
||||
))
|
||||
},
|
||||
|mut skipped_candidate| {
|
||||
let provider_api_format = skipped_candidate
|
||||
.transport
|
||||
.as_ref()
|
||||
.map(|transport| transport.endpoint.api_format.trim().to_ascii_lowercase())
|
||||
.unwrap_or_else(|| spec_metadata.api_format.to_string());
|
||||
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
|
||||
spec_metadata.api_format,
|
||||
provider_api_format.as_str(),
|
||||
);
|
||||
skipped_candidate.extra_data = Some(
|
||||
build_local_execution_candidate_contract_metadata_for_candidate(
|
||||
&skipped_candidate.candidate,
|
||||
skipped_candidate.transport_ref(),
|
||||
provider_api_format.as_str(),
|
||||
spec_metadata.api_format,
|
||||
serde_json::Map::new(),
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
provider_api_format.as_str(),
|
||||
),
|
||||
);
|
||||
skipped_candidate
|
||||
},
|
||||
)
|
||||
.await)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
mod build;
|
||||
mod candidates;
|
||||
mod payload;
|
||||
mod request;
|
||||
|
||||
pub(crate) use self::build::{
|
||||
maybe_build_stream_local_same_format_provider_decision_payload,
|
||||
maybe_build_sync_local_same_format_provider_decision_payload,
|
||||
};
|
||||
pub(crate) use self::candidates::{
|
||||
build_local_same_format_provider_candidate_attempt_source,
|
||||
materialize_local_same_format_provider_candidate_attempts,
|
||||
resolve_local_same_format_provider_decision_input,
|
||||
};
|
||||
pub(crate) use self::payload::maybe_build_local_same_format_provider_decision_payload_for_candidate;
|
||||
pub(crate) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalSameFormatProviderCandidateAttempt;
|
||||
pub(crate) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttemptSource as LocalSameFormatProviderCandidateAttemptSource;
|
||||
pub(crate) use crate::ai_serving::planner::decision_input::LocalRequestedModelDecisionInput as LocalSameFormatProviderDecisionInput;
|
||||
pub(crate) use crate::ai_serving::{LocalSameFormatProviderFamily, LocalSameFormatProviderSpec};
|
||||
@@ -0,0 +1,610 @@
|
||||
use serde_json::json;
|
||||
|
||||
use aether_ai_serving::{AdaptationMode, AiRequestGzipPolicy, OriginalRequestPayload};
|
||||
use aether_contracts::{ExecutionResponseBodyMode, EXECUTION_RESPONSE_BODY_MODE_HEADER};
|
||||
|
||||
use crate::ai_serving::ai_local_execution_contract_for_formats;
|
||||
use crate::ai_serving::build_request_trace_proxy_value;
|
||||
use crate::ai_serving::planner::candidate_materialization::{
|
||||
mark_skipped_local_execution_candidate, mark_skipped_local_execution_candidate_with_extra_data,
|
||||
mark_skipped_local_execution_candidate_with_failure_diagnostic,
|
||||
};
|
||||
use crate::ai_serving::planner::decision_input::apply_provider_request_routing_policy_to_decision;
|
||||
use crate::ai_serving::planner::materialization_policy::{
|
||||
build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind,
|
||||
};
|
||||
use crate::ai_serving::planner::report_context::{
|
||||
build_local_execution_report_context, insert_native_client_envelope_name,
|
||||
LocalExecutionReportContextParts,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_same_format_provider_spec_metadata;
|
||||
use crate::ai_serving::planner::CandidateFailureDiagnostic;
|
||||
use crate::ai_serving::planner::{
|
||||
build_ai_execution_decision_response, resolve_transport_request_encoding_policy,
|
||||
AiExecutionDecisionResponseParts,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
resolve_transport_execution_timeouts, resolve_transport_profile,
|
||||
};
|
||||
use crate::{
|
||||
append_execution_contract_fields_to_value, append_local_failover_policy_to_value,
|
||||
AiExecutionDecision, AppState, GatewayError,
|
||||
};
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
|
||||
use super::request::resolve_local_same_format_provider_candidate_payload_parts;
|
||||
use super::{
|
||||
LocalSameFormatProviderCandidateAttempt, LocalSameFormatProviderDecisionInput,
|
||||
LocalSameFormatProviderSpec,
|
||||
};
|
||||
|
||||
pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_candidate(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
body_json: &serde_json::Value,
|
||||
input: &LocalSameFormatProviderDecisionInput,
|
||||
attempt: LocalSameFormatProviderCandidateAttempt,
|
||||
spec: LocalSameFormatProviderSpec,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let spec_metadata = local_same_format_provider_spec_metadata(spec);
|
||||
let LocalSameFormatProviderCandidateAttempt {
|
||||
eligible,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
..
|
||||
} = &attempt;
|
||||
let candidate = &eligible.candidate;
|
||||
let (execution_strategy, conversion_mode) =
|
||||
ai_local_execution_contract_for_formats(spec_metadata.api_format, spec_metadata.api_format);
|
||||
let Some(resolved) = resolve_local_same_format_provider_candidate_payload_parts(
|
||||
state, parts, trace_id, body_json, input, &attempt, spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let request_redacted = resolved.request_redacted;
|
||||
let compatibility_edits_empty = resolved.compatibility_edits.is_empty();
|
||||
let original_request_body_json = if resolved.request_redacted {
|
||||
Some(&resolved.provider_request_body)
|
||||
} else {
|
||||
Some(body_json)
|
||||
};
|
||||
|
||||
let prompt_cache_key = resolved
|
||||
.provider_request_body
|
||||
.get("prompt_cache_key")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let proxy = state
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&resolved.transport)
|
||||
.await;
|
||||
let transport_profile = resolved
|
||||
.transport_profile
|
||||
.clone()
|
||||
.or_else(|| resolve_transport_profile(&resolved.transport));
|
||||
let mut extra_fields = serde_json::Map::new();
|
||||
extra_fields.insert(
|
||||
"provider_type".to_string(),
|
||||
json!(resolved.transport.provider.provider_type.as_str()),
|
||||
);
|
||||
if let Some(operation) = spec.operation {
|
||||
extra_fields.insert("api_operation".to_string(), json!(operation.as_str()));
|
||||
}
|
||||
if let Some(client_surface) = input.client_surface {
|
||||
extra_fields.insert("client_surface".to_string(), json!(client_surface.as_str()));
|
||||
}
|
||||
if let Some(carrier) = input.gateway_credential_carrier {
|
||||
extra_fields.insert(
|
||||
"gateway_credential_carrier".to_string(),
|
||||
json!(carrier.as_str()),
|
||||
);
|
||||
}
|
||||
extra_fields.insert(
|
||||
"upstream_credential_mode".to_string(),
|
||||
json!(resolved.transport.key.auth_type.trim().to_ascii_lowercase()),
|
||||
);
|
||||
let mut adaptation_mode = if resolved.compatibility_edits.is_empty() {
|
||||
AdaptationMode::NativeTransparent
|
||||
} else {
|
||||
AdaptationMode::SameFormatCompat
|
||||
};
|
||||
if crate::ai_serving::normalize_api_format_alias(&resolved.provider_api_format)
|
||||
== "claude:messages"
|
||||
{
|
||||
let compatibility_profile =
|
||||
crate::ai_serving::transport::resolve_anthropic_compatibility_profile(
|
||||
&resolved.transport,
|
||||
&resolved.provider_api_format,
|
||||
);
|
||||
extra_fields.insert(
|
||||
"anthropic_compatibility_profile".to_string(),
|
||||
json!(compatibility_profile.as_str()),
|
||||
);
|
||||
if compatibility_profile.uses_claude_code_compatibility() {
|
||||
adaptation_mode = AdaptationMode::SameFormatCompat;
|
||||
}
|
||||
}
|
||||
extra_fields.insert(
|
||||
"adaptation_mode".to_string(),
|
||||
json!(adaptation_mode.as_str()),
|
||||
);
|
||||
if let Some(proxy_value) =
|
||||
build_request_trace_proxy_value(Some(&resolved.transport), proxy.as_ref())
|
||||
{
|
||||
extra_fields.insert("proxy".to_string(), proxy_value);
|
||||
}
|
||||
if resolved.is_kiro {
|
||||
extra_fields.insert(
|
||||
"envelope_name".to_string(),
|
||||
json!(crate::ai_serving::transport::kiro::KIRO_ENVELOPE_NAME),
|
||||
);
|
||||
} else if resolved.is_antigravity {
|
||||
extra_fields.insert(
|
||||
"envelope_name".to_string(),
|
||||
json!(super::super::ANTIGRAVITY_ENVELOPE_NAME),
|
||||
);
|
||||
insert_native_client_envelope_name(
|
||||
&mut extra_fields,
|
||||
super::super::ANTIGRAVITY_ENVELOPE_NAME,
|
||||
parts.uri.path(),
|
||||
);
|
||||
} else if resolved.is_gemini_cli {
|
||||
extra_fields.insert(
|
||||
"envelope_name".to_string(),
|
||||
json!(crate::ai_serving::transport::GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME),
|
||||
);
|
||||
}
|
||||
if !resolved.compatibility_edits.is_empty() {
|
||||
if let Ok(value) = serde_json::to_value(&resolved.compatibility_edits) {
|
||||
extra_fields.insert("request_body_compatibility_edits".to_string(), value);
|
||||
}
|
||||
}
|
||||
let provider_api_format = resolved.provider_api_format.clone();
|
||||
let effective_headers = input.effective_headers(&parts.headers);
|
||||
let report_context = append_local_failover_policy_to_value(
|
||||
append_execution_contract_fields_to_value(
|
||||
build_local_execution_report_context(LocalExecutionReportContextParts {
|
||||
auth_context: &input.auth_context,
|
||||
request_id: trace_id,
|
||||
candidate_id,
|
||||
attempt_identity: attempt.attempt_identity(),
|
||||
model: &input.requested_model,
|
||||
provider_name: &resolved.transport.provider.name,
|
||||
provider_id: &candidate.provider_id,
|
||||
endpoint_id: &candidate.endpoint_id,
|
||||
key_id: &candidate.key_id,
|
||||
key_name: Some(&candidate.key_name),
|
||||
model_id: Some(&candidate.model_id),
|
||||
global_model_id: Some(&candidate.global_model_id),
|
||||
global_model_name: Some(&candidate.global_model_name),
|
||||
provider_api_format: provider_api_format.as_str(),
|
||||
client_api_format: spec_metadata.api_format,
|
||||
mapped_model: Some(&resolved.mapped_model),
|
||||
candidate_group_id: eligible.orchestration.candidate_group_id.as_deref(),
|
||||
pool_key_lease: eligible.orchestration.pool_key_lease.as_ref(),
|
||||
ranking: eligible.ranking.as_ref(),
|
||||
upstream_url: Some(&resolved.upstream_url),
|
||||
header_rules: resolved.transport.endpoint.header_rules.as_ref(),
|
||||
body_rules: resolved.transport.endpoint.body_rules.as_ref(),
|
||||
provider_request_method: Some(serde_json::Value::Null),
|
||||
provider_request_headers: Some(&resolved.provider_request_headers),
|
||||
original_headers: effective_headers,
|
||||
request_path: Some(parts.uri.path()),
|
||||
request_query_string: parts.uri.query(),
|
||||
request_origin: Some(crate::ai_serving::request_origin_from_parts(parts)),
|
||||
original_request_body_json,
|
||||
original_request_body_base64: None,
|
||||
client_session_affinity: input.client_session_affinity.as_ref(),
|
||||
routing_policy: input.routing_policy.as_ref(),
|
||||
scheduler_affinity_epoch: eligible.orchestration.scheduler_affinity_epoch,
|
||||
client_requested_stream: body_json
|
||||
.get("stream")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
upstream_is_stream: resolved.upstream_is_stream,
|
||||
has_envelope: resolved.is_kiro || resolved.is_antigravity || resolved.is_gemini_cli,
|
||||
needs_conversion: false,
|
||||
extra_fields,
|
||||
}),
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
spec_metadata.api_format,
|
||||
provider_api_format.as_str(),
|
||||
),
|
||||
&resolved.transport,
|
||||
);
|
||||
let super::request::LocalSameFormatProviderCandidatePayloadParts {
|
||||
transport,
|
||||
is_antigravity: _,
|
||||
is_gemini_cli: _,
|
||||
is_kiro: _,
|
||||
auth_header,
|
||||
auth_value,
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
report_kind,
|
||||
upstream_is_stream,
|
||||
upstream_url,
|
||||
provider_request_headers,
|
||||
provider_request_body,
|
||||
transport_profile: _,
|
||||
compatibility_edits: _,
|
||||
request_redacted: _,
|
||||
} = resolved;
|
||||
let request_encoding = resolve_transport_request_encoding_policy(&transport);
|
||||
|
||||
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
|
||||
decision_is_stream: spec_metadata.require_streaming,
|
||||
decision_kind: spec_metadata.decision_kind.to_string(),
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
request_id: trace_id.to_string(),
|
||||
candidate_id: candidate_id.to_string(),
|
||||
provider_name: transport.provider.name.clone(),
|
||||
provider_type: transport.provider.provider_type.clone(),
|
||||
provider_id: candidate.provider_id.clone(),
|
||||
endpoint_id: candidate.endpoint_id.clone(),
|
||||
key_id: candidate.key_id.clone(),
|
||||
upstream_base_url: transport.endpoint.base_url.clone(),
|
||||
upstream_url,
|
||||
provider_request_method: None,
|
||||
auth_header,
|
||||
auth_value,
|
||||
provider_api_format,
|
||||
client_api_format: spec_metadata.api_format.to_string(),
|
||||
model_name: input.requested_model.clone(),
|
||||
mapped_model,
|
||||
prompt_cache_key,
|
||||
provider_request_headers,
|
||||
provider_request_body: Some(provider_request_body),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: request_encoding.content_encoding,
|
||||
request_gzip: request_encoding.request_gzip,
|
||||
proxy,
|
||||
transport_profile,
|
||||
timeouts: resolve_transport_execution_timeouts(&transport),
|
||||
upstream_is_stream,
|
||||
report_kind: Some(report_kind.to_string()),
|
||||
report_context: Some(report_context),
|
||||
auth_context: input.auth_context.clone(),
|
||||
});
|
||||
apply_provider_request_routing_policy_to_decision(
|
||||
input,
|
||||
&mut decision,
|
||||
Some(transport.as_ref()),
|
||||
)?;
|
||||
enforce_provider_api_operation_invariants(
|
||||
spec.operation,
|
||||
decision.provider_request_body.as_mut(),
|
||||
&mut decision.provider_request_headers,
|
||||
);
|
||||
decision.provider_request_body_base64 = original_request_body_base64(
|
||||
parts,
|
||||
decision.provider_request_body.as_ref(),
|
||||
adaptation_mode,
|
||||
request_redacted,
|
||||
compatibility_edits_empty,
|
||||
decision.content_encoding.as_deref(),
|
||||
decision.request_gzip.as_ref(),
|
||||
);
|
||||
decision
|
||||
.provider_request_headers
|
||||
.retain(|name, _| !name.eq_ignore_ascii_case(EXECUTION_RESPONSE_BODY_MODE_HEADER));
|
||||
if !spec_metadata.require_streaming && decision.provider_request_body_base64.is_some() {
|
||||
decision.provider_request_headers.insert(
|
||||
EXECUTION_RESPONSE_BODY_MODE_HEADER.to_string(),
|
||||
ExecutionResponseBodyMode::PreserveBytes
|
||||
.as_str()
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(Some(decision))
|
||||
}
|
||||
|
||||
fn enforce_provider_api_operation_invariants(
|
||||
operation: Option<crate::ai_serving::ApiOperation>,
|
||||
provider_request_body: Option<&mut serde_json::Value>,
|
||||
provider_request_headers: &mut std::collections::BTreeMap<String, String>,
|
||||
) {
|
||||
if operation != Some(crate::ai_serving::ApiOperation::ClaudeCountTokens) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(provider_request_body) = provider_request_body {
|
||||
crate::ai_serving::transport::enforce_same_format_provider_api_operation_body_policy(
|
||||
provider_request_body,
|
||||
operation,
|
||||
);
|
||||
}
|
||||
for header_name in ["accept", "content-type"] {
|
||||
provider_request_headers.retain(|name, _| !name.eq_ignore_ascii_case(header_name));
|
||||
provider_request_headers.insert(header_name.to_string(), "application/json".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn original_request_body_base64(
|
||||
parts: &http::request::Parts,
|
||||
provider_request_body: Option<&serde_json::Value>,
|
||||
adaptation_mode: AdaptationMode,
|
||||
request_redacted: bool,
|
||||
compatibility_edits_empty: bool,
|
||||
content_encoding: Option<&str>,
|
||||
request_gzip: Option<&AiRequestGzipPolicy>,
|
||||
) -> Option<String> {
|
||||
if adaptation_mode != AdaptationMode::NativeTransparent
|
||||
|| request_redacted
|
||||
|| !compatibility_edits_empty
|
||||
|| content_encoding.is_some_and(|value| !value.trim().is_empty())
|
||||
|| request_gzip.is_some_and(|policy| policy.enabled != Some(false))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
parts
|
||||
.extensions
|
||||
.get::<OriginalRequestPayload>()?
|
||||
.body_bytes_base64_if_unchanged(provider_request_body?)
|
||||
}
|
||||
|
||||
pub(super) async fn mark_skipped_local_same_format_provider_candidate(
|
||||
state: &AppState,
|
||||
input: &LocalSameFormatProviderDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
) {
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::SameFormatProviderDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn mark_skipped_local_same_format_provider_candidate_with_extra_data(
|
||||
state: &AppState,
|
||||
input: &LocalSameFormatProviderDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
extra_data: Option<serde_json::Value>,
|
||||
) {
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::SameFormatProviderDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate_with_extra_data(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
extra_data,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn mark_skipped_local_same_format_provider_candidate_with_failure_diagnostic(
|
||||
state: &AppState,
|
||||
input: &LocalSameFormatProviderDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
diagnostic: CandidateFailureDiagnostic,
|
||||
) {
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::SameFormatProviderDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
diagnostic,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use base64::Engine as _;
|
||||
|
||||
use super::{
|
||||
enforce_provider_api_operation_invariants, original_request_body_base64, AdaptationMode,
|
||||
AiRequestGzipPolicy, OriginalRequestPayload,
|
||||
};
|
||||
use crate::ai_serving::ApiOperation;
|
||||
|
||||
fn request_parts_with_original_payload(
|
||||
body_json: serde_json::Value,
|
||||
body_bytes: &[u8],
|
||||
) -> http::request::Parts {
|
||||
let (mut parts, ()) = http::Request::new(()).into_parts();
|
||||
parts
|
||||
.extensions
|
||||
.insert(OriginalRequestPayload::from_parsed_json(
|
||||
body_json, body_bytes,
|
||||
));
|
||||
parts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_tokens_invariants_win_after_provider_routing_mutations() {
|
||||
let mut body = serde_json::json!({
|
||||
"model": "claude-sonnet-4",
|
||||
"messages": [],
|
||||
"stream": true
|
||||
});
|
||||
let mut headers = BTreeMap::from([
|
||||
("Accept".to_string(), "text/event-stream".to_string()),
|
||||
("Content-Type".to_string(), "text/plain".to_string()),
|
||||
("x-provider-route".to_string(), "kept".to_string()),
|
||||
]);
|
||||
|
||||
enforce_provider_api_operation_invariants(
|
||||
Some(ApiOperation::ClaudeCountTokens),
|
||||
Some(&mut body),
|
||||
&mut headers,
|
||||
);
|
||||
|
||||
assert!(body.get("stream").is_none());
|
||||
assert_eq!(
|
||||
headers.get("accept").map(String::as_str),
|
||||
Some("application/json")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("content-type").map(String::as_str),
|
||||
Some("application/json")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x-provider-route").map(String::as_str),
|
||||
Some("kept")
|
||||
);
|
||||
assert_eq!(
|
||||
headers
|
||||
.keys()
|
||||
.filter(|name| name.eq_ignore_ascii_case("accept"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
headers
|
||||
.keys()
|
||||
.filter(|name| name.eq_ignore_ascii_case("content-type"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_same_format_body_preserves_original_json_bytes() {
|
||||
let raw = br#"{ "unknown": {"enabled":true}, "messages": [], "model": "claude-sonnet-4" }"#;
|
||||
let body_json: serde_json::Value = serde_json::from_slice(raw).expect("body should parse");
|
||||
let parts = request_parts_with_original_payload(body_json.clone(), raw);
|
||||
|
||||
let encoded = original_request_body_base64(
|
||||
&parts,
|
||||
Some(&body_json),
|
||||
AdaptationMode::NativeTransparent,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("unchanged request should retain exact bytes");
|
||||
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.expect("body should decode"),
|
||||
raw
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_edits_or_encoding_disable_original_json_bytes() {
|
||||
let raw = br#"{"model":"claude-sonnet-4","messages":[]}"#;
|
||||
let body_json: serde_json::Value = serde_json::from_slice(raw).expect("body should parse");
|
||||
let parts = request_parts_with_original_payload(body_json.clone(), raw);
|
||||
let changed_body = serde_json::json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"messages": []
|
||||
});
|
||||
|
||||
assert!(original_request_body_base64(
|
||||
&parts,
|
||||
Some(&changed_body),
|
||||
AdaptationMode::NativeTransparent,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.is_none());
|
||||
assert!(original_request_body_base64(
|
||||
&parts,
|
||||
Some(&body_json),
|
||||
AdaptationMode::NativeTransparent,
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.is_none());
|
||||
assert!(original_request_body_base64(
|
||||
&parts,
|
||||
Some(&body_json),
|
||||
AdaptationMode::NativeTransparent,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.is_none());
|
||||
assert!(original_request_body_base64(
|
||||
&parts,
|
||||
Some(&body_json),
|
||||
AdaptationMode::SameFormatCompat,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.is_none());
|
||||
assert!(original_request_body_base64(
|
||||
&parts,
|
||||
Some(&body_json),
|
||||
AdaptationMode::NativeTransparent,
|
||||
false,
|
||||
true,
|
||||
Some("gzip"),
|
||||
None,
|
||||
)
|
||||
.is_none());
|
||||
assert!(original_request_body_base64(
|
||||
&parts,
|
||||
Some(&body_json),
|
||||
AdaptationMode::NativeTransparent,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Some(&AiRequestGzipPolicy {
|
||||
enabled: Some(true),
|
||||
min_bytes: Some(1),
|
||||
}),
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use aether_contracts::ResolvedTransportProfile;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::planner::common::{
|
||||
enforce_provider_body_stream_policy, request_requires_body_stream_field,
|
||||
};
|
||||
use crate::ai_serving::planner::redaction::{
|
||||
request_identity_response_encoding_when_redacted, resolve_provider_chat_pii_redaction,
|
||||
};
|
||||
use crate::ai_serving::transport::antigravity::{
|
||||
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
|
||||
classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
|
||||
AntigravityRequestAuthUnsupportedReason, AntigravityRequestEnvelopeSupport,
|
||||
AntigravityRequestSideSupport, AntigravityRequestSideUnsupportedReason,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
build_gemini_cli_v1internal_request, build_grok_browser_headers, build_grok_upstream_url,
|
||||
build_same_format_provider_headers, resolve_local_gemini_cli_request_auth,
|
||||
GeminiCliRequestAuth, GeminiCliRequestAuthSupport, GeminiCliRequestEnvelopeSupport,
|
||||
GrokHeaderInput, SameFormatProviderCompatibilityEdit,
|
||||
SameFormatProviderCompatibilityEditAction, SameFormatProviderHeadersInput,
|
||||
GEMINI_CLI_USER_AGENT, GROK_CHAT_PATH,
|
||||
};
|
||||
use crate::ai_serving::{CandidateFailureDiagnostic, GatewayProviderTransportSnapshot};
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
mod policy;
|
||||
mod prepare;
|
||||
|
||||
use self::prepare::prepare_local_same_format_provider_candidate;
|
||||
use super::payload::{
|
||||
mark_skipped_local_same_format_provider_candidate,
|
||||
mark_skipped_local_same_format_provider_candidate_with_extra_data,
|
||||
mark_skipped_local_same_format_provider_candidate_with_failure_diagnostic,
|
||||
};
|
||||
use super::{
|
||||
LocalSameFormatProviderCandidateAttempt, LocalSameFormatProviderDecisionInput,
|
||||
LocalSameFormatProviderSpec,
|
||||
};
|
||||
use crate::ai_serving::planner::standard::{
|
||||
codex_model_capabilities_for_transport, openai_provider_request_contract_failure_extra_data,
|
||||
same_format_provider_request_body_failure_extra_data,
|
||||
};
|
||||
|
||||
pub(crate) fn resolve_same_format_provider_transport_unsupported_reason_for_trace(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
provider_api_format: &str,
|
||||
) -> Option<&'static str> {
|
||||
let provider_api_format =
|
||||
match crate::ai_serving::normalize_api_format_alias(provider_api_format).as_str() {
|
||||
"openai:chat" => "openai:chat",
|
||||
"openai:responses" => "openai:responses",
|
||||
"openai:responses:compact" => "openai:responses:compact",
|
||||
"openai:search" => "openai:search",
|
||||
"openai:embedding" => "openai:embedding",
|
||||
"openai:rerank" => "openai:rerank",
|
||||
"claude:messages" => "claude:messages",
|
||||
"gemini:generate_content" => "gemini:generate_content",
|
||||
"gemini:embedding" => "gemini:embedding",
|
||||
"jina:embedding" => "jina:embedding",
|
||||
"jina:rerank" => "jina:rerank",
|
||||
"doubao:embedding" => "doubao:embedding",
|
||||
"aliyun:multimodal_embedding" => "aliyun:multimodal_embedding",
|
||||
_ => return Some("transport_api_format_unsupported"),
|
||||
};
|
||||
let behavior = policy::classify_same_format_provider_request_behavior(
|
||||
transport,
|
||||
provider_api_format,
|
||||
crate::ai_serving::planner::spec_metadata::LocalExecutionSurfaceSpecMetadata {
|
||||
api_format: provider_api_format,
|
||||
require_streaming: false,
|
||||
requested_model_family: None,
|
||||
decision_kind: "trace_candidate_metadata",
|
||||
report_kind: Some("trace_candidate_metadata"),
|
||||
},
|
||||
None,
|
||||
);
|
||||
if !behavior.is_antigravity
|
||||
&& !behavior.is_claude_code_transport
|
||||
&& !behavior.is_gemini_cli
|
||||
&& !behavior.is_vertex
|
||||
&& !behavior.is_kiro
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let family = if provider_api_format.starts_with("gemini:") {
|
||||
crate::ai_serving::LocalSameFormatProviderFamily::Gemini
|
||||
} else {
|
||||
crate::ai_serving::LocalSameFormatProviderFamily::Standard
|
||||
};
|
||||
policy::same_format_provider_transport_unsupported_reason(
|
||||
&behavior,
|
||||
transport,
|
||||
family,
|
||||
provider_api_format,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) struct LocalSameFormatProviderCandidatePayloadParts {
|
||||
pub(super) transport: Arc<GatewayProviderTransportSnapshot>,
|
||||
pub(super) is_antigravity: bool,
|
||||
pub(super) is_gemini_cli: bool,
|
||||
pub(super) is_kiro: bool,
|
||||
pub(super) auth_header: Option<String>,
|
||||
pub(super) auth_value: Option<String>,
|
||||
pub(super) provider_api_format: String,
|
||||
pub(super) mapped_model: String,
|
||||
pub(super) report_kind: &'static str,
|
||||
pub(super) upstream_is_stream: bool,
|
||||
pub(super) upstream_url: String,
|
||||
pub(super) provider_request_headers: BTreeMap<String, String>,
|
||||
pub(super) provider_request_body: Value,
|
||||
pub(super) transport_profile: Option<ResolvedTransportProfile>,
|
||||
pub(super) compatibility_edits: Vec<SameFormatProviderCompatibilityEdit>,
|
||||
pub(super) request_redacted: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
body_json: &serde_json::Value,
|
||||
input: &LocalSameFormatProviderDecisionInput,
|
||||
attempt: &LocalSameFormatProviderCandidateAttempt,
|
||||
spec: LocalSameFormatProviderSpec,
|
||||
) -> Result<Option<LocalSameFormatProviderCandidatePayloadParts>, GatewayError> {
|
||||
let candidate = &attempt.eligible.candidate;
|
||||
if let Some(skip_reason) = same_format_provider_operation_skip_reason(
|
||||
&attempt.eligible.transport,
|
||||
attempt.eligible.provider_api_format.as_str(),
|
||||
spec.operation,
|
||||
) {
|
||||
mark_skipped_local_same_format_provider_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(prepared) = prepare_local_same_format_provider_candidate(
|
||||
state,
|
||||
trace_id,
|
||||
input,
|
||||
&attempt.eligible,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
spec,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let model_directive_resolution = input
|
||||
.model_directive_policy
|
||||
.resolve_reasoning(spec.api_format, Some(&input.requested_model));
|
||||
let model_directive_mapping =
|
||||
match model_directive_resolution.mapping_patch_for_mapped_model(&prepared.mapped_model) {
|
||||
Ok(mapping) => mapping,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_same_format_provider_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let effective_headers = input.effective_headers(&parts.headers);
|
||||
let redaction = resolve_provider_chat_pii_redaction(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
&input.auth_context,
|
||||
spec.api_format,
|
||||
&attempt.candidate_id,
|
||||
)
|
||||
.await?;
|
||||
let body_json = redaction.body_json.as_ref();
|
||||
let mut transport = Arc::clone(&prepared.transport);
|
||||
|
||||
let Some(base_provider_request) =
|
||||
super::super::request::build_same_format_provider_request_body_with_compatibility_report(
|
||||
body_json,
|
||||
prepared.provider_api_format.as_str(),
|
||||
&prepared.mapped_model,
|
||||
spec,
|
||||
prepared.transport.endpoint.body_rules.as_ref(),
|
||||
Some(effective_headers),
|
||||
prepared.upstream_is_stream,
|
||||
prepared.force_body_stream_field,
|
||||
prepared.kiro_auth.as_ref(),
|
||||
prepared.is_claude_code,
|
||||
false,
|
||||
)
|
||||
else {
|
||||
mark_skipped_local_same_format_provider_candidate_with_extra_data(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"provider_request_body_missing",
|
||||
same_format_provider_request_body_failure_extra_data(
|
||||
body_json,
|
||||
attempt.eligible.provider_api_format.as_str(),
|
||||
prepared.transport.endpoint.body_rules.as_ref(),
|
||||
if prepared.kiro_auth.is_some() {
|
||||
"kiro_envelope"
|
||||
} else {
|
||||
"same_format"
|
||||
},
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
};
|
||||
let mut base_provider_request_body = base_provider_request.body;
|
||||
let mut compatibility_edits = base_provider_request.compatibility_edits;
|
||||
if let Some(mapping) = model_directive_mapping.as_ref() {
|
||||
let before_mapping = base_provider_request_body.clone();
|
||||
crate::ai_serving::apply_model_directive_mapping_patch(
|
||||
&mut base_provider_request_body,
|
||||
mapping,
|
||||
);
|
||||
if before_mapping != base_provider_request_body {
|
||||
compatibility_edits.push(SameFormatProviderCompatibilityEdit {
|
||||
field: "model_directive_mapping".to_string(),
|
||||
action: SameFormatProviderCompatibilityEditAction::RuntimeRewrite,
|
||||
detail: "applied configured model directive mapping patch".to_string(),
|
||||
});
|
||||
}
|
||||
// Directive mapping is a deep-merge patch and may overwrite/add `stream`;
|
||||
// re-enforce stream-field policy afterward.
|
||||
// Kiro behavior classification already hard-requires upstream streaming,
|
||||
// and the Kiro envelope does not use a top-level body stream field.
|
||||
if prepared.kiro_auth.is_none() {
|
||||
enforce_provider_body_stream_policy(
|
||||
&mut base_provider_request_body,
|
||||
prepared.provider_api_format.as_str(),
|
||||
prepared.upstream_is_stream,
|
||||
request_requires_body_stream_field(body_json, prepared.force_body_stream_field),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let source_model = body_json
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(input.requested_model.as_str());
|
||||
let codex_model_capabilities = codex_model_capabilities_for_transport(
|
||||
&transport,
|
||||
prepared.provider_api_format.as_str(),
|
||||
prepared.mapped_model.as_str(),
|
||||
source_model,
|
||||
);
|
||||
if let Err(violation) =
|
||||
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
&mut base_provider_request_body,
|
||||
crate::ai_serving::OpenAiProviderRequestFinalization {
|
||||
source_api_format: spec.api_format,
|
||||
provider_api_format: prepared.provider_api_format.as_str(),
|
||||
provider_type: transport.provider.provider_type.as_str(),
|
||||
provider_model: prepared.mapped_model.as_str(),
|
||||
source_model,
|
||||
body_rules: transport.endpoint.body_rules.as_ref(),
|
||||
upstream_is_stream: prepared.upstream_is_stream,
|
||||
require_body_stream_field: request_requires_body_stream_field(
|
||||
body_json,
|
||||
prepared.force_body_stream_field,
|
||||
),
|
||||
},
|
||||
codex_model_capabilities.as_ref(),
|
||||
)
|
||||
{
|
||||
mark_skipped_local_same_format_provider_candidate_with_extra_data(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"provider_request_body_build_failed",
|
||||
Some(openai_provider_request_contract_failure_extra_data(
|
||||
&violation,
|
||||
spec.api_format,
|
||||
prepared.provider_api_format.as_str(),
|
||||
"same_format_provider_request_finalization",
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let antigravity_auth = if prepared.is_antigravity {
|
||||
let mut antigravity_support = classify_local_antigravity_request_support(
|
||||
&transport,
|
||||
&base_provider_request_body,
|
||||
AntigravityEnvelopeRequestType::Agent,
|
||||
);
|
||||
if matches!(
|
||||
antigravity_support,
|
||||
AntigravityRequestSideSupport::Unsupported(
|
||||
AntigravityRequestSideUnsupportedReason::UnsupportedAuth(
|
||||
AntigravityRequestAuthUnsupportedReason::MissingProjectId
|
||||
)
|
||||
)
|
||||
) {
|
||||
if let Some(hydrated) = state
|
||||
.hydrate_antigravity_project_metadata_for_transport(&transport)
|
||||
.await
|
||||
{
|
||||
transport = Arc::new(hydrated);
|
||||
antigravity_support = classify_local_antigravity_request_support(
|
||||
&transport,
|
||||
&base_provider_request_body,
|
||||
AntigravityEnvelopeRequestType::Agent,
|
||||
);
|
||||
}
|
||||
}
|
||||
match antigravity_support {
|
||||
AntigravityRequestSideSupport::Supported(spec) => Some(spec.auth),
|
||||
AntigravityRequestSideSupport::Unsupported(_) => {
|
||||
mark_skipped_local_same_format_provider_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"transport_unsupported",
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let gemini_cli_auth = if prepared.behavior.is_gemini_cli {
|
||||
let mut auth = match resolve_local_gemini_cli_request_auth(&transport) {
|
||||
GeminiCliRequestAuthSupport::Supported(auth) => auth,
|
||||
GeminiCliRequestAuthSupport::Unsupported(_) => {
|
||||
mark_skipped_local_same_format_provider_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"transport_auth_unavailable",
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
if auth.project_id.is_none() {
|
||||
auth = match state
|
||||
.hydrate_gemini_cli_project_metadata_for_transport(&transport)
|
||||
.await
|
||||
{
|
||||
Some(hydrated) => {
|
||||
transport = Arc::new(hydrated);
|
||||
match resolve_local_gemini_cli_request_auth(&transport) {
|
||||
GeminiCliRequestAuthSupport::Supported(auth) => auth,
|
||||
GeminiCliRequestAuthSupport::Unsupported(_) => {
|
||||
GeminiCliRequestAuth::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
None => GeminiCliRequestAuth::default(),
|
||||
};
|
||||
}
|
||||
Some(auth)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut provider_request_body = if let Some(antigravity_auth) = antigravity_auth.as_ref() {
|
||||
match build_antigravity_safe_v1internal_request(
|
||||
antigravity_auth,
|
||||
trace_id,
|
||||
&prepared.mapped_model,
|
||||
&base_provider_request_body,
|
||||
AntigravityEnvelopeRequestType::Agent,
|
||||
) {
|
||||
AntigravityRequestEnvelopeSupport::Supported(envelope) => envelope,
|
||||
AntigravityRequestEnvelopeSupport::Unsupported(_) => {
|
||||
mark_skipped_local_same_format_provider_candidate_with_extra_data(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"provider_request_body_missing",
|
||||
same_format_provider_request_body_failure_extra_data(
|
||||
body_json,
|
||||
attempt.eligible.provider_api_format.as_str(),
|
||||
prepared.transport.endpoint.body_rules.as_ref(),
|
||||
"antigravity_envelope",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
} else if let Some(gemini_cli_auth) = gemini_cli_auth.as_ref() {
|
||||
match build_gemini_cli_v1internal_request(
|
||||
gemini_cli_auth,
|
||||
trace_id,
|
||||
&prepared.mapped_model,
|
||||
&base_provider_request_body,
|
||||
) {
|
||||
GeminiCliRequestEnvelopeSupport::Supported(envelope) => envelope,
|
||||
GeminiCliRequestEnvelopeSupport::Unsupported(_) => {
|
||||
mark_skipped_local_same_format_provider_candidate_with_extra_data(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"provider_request_body_missing",
|
||||
same_format_provider_request_body_failure_extra_data(
|
||||
body_json,
|
||||
attempt.eligible.provider_api_format.as_str(),
|
||||
prepared.transport.endpoint.body_rules.as_ref(),
|
||||
"gemini_cli_v1internal_envelope",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
base_provider_request_body
|
||||
};
|
||||
if crate::ai_serving::transport::enforce_same_format_provider_api_operation_body_policy(
|
||||
&mut provider_request_body,
|
||||
spec.operation,
|
||||
) {
|
||||
compatibility_edits.push(SameFormatProviderCompatibilityEdit {
|
||||
field: "stream".to_string(),
|
||||
action: SameFormatProviderCompatibilityEditAction::RuntimeRewrite,
|
||||
detail: "removed stream field for non-streaming API operation".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let is_grok = prepared
|
||||
.transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("grok");
|
||||
let transport_profile = crate::ai_serving::transport::resolve_transport_profile(&transport);
|
||||
let upstream_url = if is_grok {
|
||||
Some(build_grok_upstream_url(&transport, GROK_CHAT_PATH))
|
||||
} else {
|
||||
super::super::request::build_same_format_upstream_url(
|
||||
parts,
|
||||
&transport,
|
||||
&prepared.mapped_model,
|
||||
prepared.provider_api_format.as_str(),
|
||||
spec,
|
||||
prepared.upstream_is_stream,
|
||||
prepared.kiro_auth.as_ref(),
|
||||
Some(&provider_request_body),
|
||||
)
|
||||
};
|
||||
let Some(upstream_url) = upstream_url else {
|
||||
mark_skipped_local_same_format_provider_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"upstream_url_missing",
|
||||
CandidateFailureDiagnostic::upstream_url_missing(
|
||||
attempt.eligible.provider_api_format.as_str(),
|
||||
attempt.eligible.provider_api_format.as_str(),
|
||||
"same_format_provider_url",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut extra_headers = antigravity_auth
|
||||
.as_ref()
|
||||
.map(build_antigravity_static_identity_headers)
|
||||
.unwrap_or_default();
|
||||
if prepared.behavior.is_gemini_cli {
|
||||
extra_headers.insert("user-agent".to_string(), GEMINI_CLI_USER_AGENT.to_string());
|
||||
}
|
||||
let Some(mut provider_request_headers) = (if is_grok {
|
||||
build_grok_browser_headers(GrokHeaderInput {
|
||||
transport: &transport,
|
||||
transport_profile: transport_profile.as_ref(),
|
||||
request_headers: Some(effective_headers),
|
||||
content_type: "application/json",
|
||||
accept: "text/event-stream",
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: body_json,
|
||||
})
|
||||
} else {
|
||||
build_same_format_provider_headers(SameFormatProviderHeadersInput {
|
||||
headers: effective_headers,
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: body_json,
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
behavior: prepared.behavior,
|
||||
api_operation: spec.operation,
|
||||
auth_header: prepared.auth_header.as_deref(),
|
||||
auth_value: prepared.auth_value.as_deref(),
|
||||
extra_headers: &extra_headers,
|
||||
kiro_auth_config: prepared.kiro_auth.as_ref().map(|auth| &auth.auth_config),
|
||||
kiro_machine_id: prepared
|
||||
.kiro_auth
|
||||
.as_ref()
|
||||
.map(|auth| auth.machine_id.as_str()),
|
||||
})
|
||||
}) else {
|
||||
mark_skipped_local_same_format_provider_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"transport_header_rules_apply_failed",
|
||||
CandidateFailureDiagnostic::header_rules_apply_failed(
|
||||
attempt.eligible.provider_api_format.as_str(),
|
||||
attempt.eligible.provider_api_format.as_str(),
|
||||
"same_format_provider_headers",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
};
|
||||
crate::ai_serving::apply_codex_openai_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
effective_headers,
|
||||
transport.provider.provider_type.as_str(),
|
||||
prepared.provider_api_format.as_str(),
|
||||
Some(trace_id),
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
let provider_model = provider_request_body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(prepared.mapped_model.as_str());
|
||||
crate::ai_serving::apply_codex_openai_responses_lite_header_for_request_body_with_capabilities(
|
||||
&mut provider_request_headers,
|
||||
Some(&provider_request_body),
|
||||
transport.provider.provider_type.as_str(),
|
||||
prepared.provider_api_format.as_str(),
|
||||
provider_model,
|
||||
source_model,
|
||||
codex_model_capabilities.as_ref(),
|
||||
);
|
||||
request_identity_response_encoding_when_redacted(
|
||||
&mut provider_request_headers,
|
||||
redaction.redacted,
|
||||
);
|
||||
|
||||
Ok(Some(LocalSameFormatProviderCandidatePayloadParts {
|
||||
transport,
|
||||
is_antigravity: prepared.is_antigravity,
|
||||
is_gemini_cli: prepared.behavior.is_gemini_cli,
|
||||
is_kiro: prepared.is_kiro,
|
||||
auth_header: prepared.auth_header,
|
||||
auth_value: prepared.auth_value,
|
||||
provider_api_format: prepared.provider_api_format,
|
||||
mapped_model: prepared.mapped_model,
|
||||
report_kind: prepared.report_kind,
|
||||
upstream_is_stream: prepared.upstream_is_stream,
|
||||
upstream_url,
|
||||
provider_request_headers,
|
||||
provider_request_body,
|
||||
transport_profile,
|
||||
compatibility_edits,
|
||||
request_redacted: redaction.redacted,
|
||||
}))
|
||||
}
|
||||
|
||||
fn same_format_provider_operation_skip_reason(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
provider_api_format: &str,
|
||||
operation: Option<crate::ai_serving::ApiOperation>,
|
||||
) -> Option<&'static str> {
|
||||
(!crate::ai_serving::transport::transport_supports_api_operation(
|
||||
transport,
|
||||
provider_api_format,
|
||||
operation,
|
||||
))
|
||||
.then_some("transport_operation_unsupported")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::same_format_provider_operation_skip_reason;
|
||||
use crate::ai_serving::transport::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider,
|
||||
};
|
||||
use crate::ai_serving::{ApiOperation, GatewayProviderTransportSnapshot};
|
||||
|
||||
fn private_adapter_transport(provider_type: &str) -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-1".to_string(),
|
||||
name: provider_type.to_string(),
|
||||
provider_type: provider_type.to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: true,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: "claude:messages".to_string(),
|
||||
api_family: Some("claude".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://private.example".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
format_acceptance_config: None,
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
name: "key".to_string(),
|
||||
auth_type: "oauth".to_string(),
|
||||
is_active: true,
|
||||
api_formats: None,
|
||||
auth_type_by_format: None,
|
||||
allow_auth_channel_mismatch_formats: None,
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
upstream_metadata: None,
|
||||
decrypted_api_key: String::new(),
|
||||
decrypted_auth_config: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_adapter_count_tokens_is_rejected_by_pre_auth_operation_gate() {
|
||||
for provider_type in ["kiro", "grok"] {
|
||||
let transport = private_adapter_transport(provider_type);
|
||||
assert_eq!(
|
||||
same_format_provider_operation_skip_reason(
|
||||
&transport,
|
||||
"claude:messages",
|
||||
Some(ApiOperation::ClaudeCountTokens),
|
||||
),
|
||||
Some("transport_operation_unsupported"),
|
||||
"provider_type={provider_type}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
use crate::ai_serving::planner::spec_metadata::LocalExecutionSurfaceSpecMetadata;
|
||||
use crate::ai_serving::transport::{
|
||||
classify_same_format_provider_request_behavior_for_operation as classify_same_format_provider_request_behavior_impl,
|
||||
resolve_same_format_provider_direct_auth as resolve_same_format_provider_direct_auth_impl,
|
||||
same_format_provider_transport_supported as same_format_provider_transport_supported_impl,
|
||||
same_format_provider_transport_unsupported_reason as same_format_provider_transport_unsupported_reason_impl,
|
||||
should_try_same_format_provider_oauth_auth as should_try_same_format_provider_oauth_auth_impl,
|
||||
GatewayProviderTransportSnapshot, SameFormatProviderFamily, SameFormatProviderRequestBehavior,
|
||||
SameFormatProviderRequestBehaviorParams,
|
||||
};
|
||||
|
||||
use super::super::LocalSameFormatProviderFamily;
|
||||
|
||||
pub(super) fn classify_same_format_provider_request_behavior(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
provider_api_format: &str,
|
||||
spec_metadata: LocalExecutionSurfaceSpecMetadata,
|
||||
api_operation: Option<crate::ai_serving::ApiOperation>,
|
||||
) -> SameFormatProviderRequestBehavior {
|
||||
classify_same_format_provider_request_behavior_impl(
|
||||
transport,
|
||||
SameFormatProviderRequestBehaviorParams {
|
||||
require_streaming: spec_metadata.require_streaming,
|
||||
provider_api_format,
|
||||
report_kind: spec_metadata
|
||||
.report_kind
|
||||
.expect("same-format provider specs should declare report kind"),
|
||||
},
|
||||
api_operation,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn same_format_provider_transport_supported(
|
||||
behavior: &SameFormatProviderRequestBehavior,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
family: LocalSameFormatProviderFamily,
|
||||
api_format: &str,
|
||||
) -> bool {
|
||||
same_format_provider_transport_supported_impl(
|
||||
behavior,
|
||||
transport,
|
||||
same_format_provider_family(family),
|
||||
api_format,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn same_format_provider_transport_unsupported_reason(
|
||||
behavior: &SameFormatProviderRequestBehavior,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
family: LocalSameFormatProviderFamily,
|
||||
api_format: &str,
|
||||
) -> Option<&'static str> {
|
||||
same_format_provider_transport_unsupported_reason_impl(
|
||||
behavior,
|
||||
transport,
|
||||
same_format_provider_family(family),
|
||||
api_format,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn should_try_same_format_provider_oauth_auth(
|
||||
behavior: &SameFormatProviderRequestBehavior,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
family: LocalSameFormatProviderFamily,
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
should_try_same_format_provider_oauth_auth_impl(
|
||||
behavior,
|
||||
transport,
|
||||
same_format_provider_family(family),
|
||||
provider_api_format,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn resolve_same_format_provider_direct_auth(
|
||||
behavior: &SameFormatProviderRequestBehavior,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
family: LocalSameFormatProviderFamily,
|
||||
provider_api_format: &str,
|
||||
) -> Option<(String, String)> {
|
||||
resolve_same_format_provider_direct_auth_impl(
|
||||
behavior,
|
||||
transport,
|
||||
same_format_provider_family(family),
|
||||
provider_api_format,
|
||||
)
|
||||
}
|
||||
|
||||
fn same_format_provider_family(family: LocalSameFormatProviderFamily) -> SameFormatProviderFamily {
|
||||
match family {
|
||||
LocalSameFormatProviderFamily::Standard => SameFormatProviderFamily::Standard,
|
||||
LocalSameFormatProviderFamily::Gemini => SameFormatProviderFamily::Gemini,
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::ai_serving::planner::candidate_preparation::{
|
||||
resolve_candidate_mapped_model, resolve_candidate_oauth_auth, OauthPreparationContext,
|
||||
};
|
||||
use crate::ai_serving::planner::candidate_resolution::EligibleLocalExecutionCandidate;
|
||||
use crate::ai_serving::planner::spec_metadata::local_same_format_provider_spec_metadata;
|
||||
use crate::ai_serving::transport::kiro::KiroRequestAuth;
|
||||
use crate::ai_serving::transport::vertex::{
|
||||
is_vertex_api_key_transport_context, resolve_local_vertex_api_key_query_auth,
|
||||
};
|
||||
use crate::ai_serving::transport::SameFormatProviderRequestBehavior;
|
||||
use crate::ai_serving::{
|
||||
GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth, PlannerAppState,
|
||||
};
|
||||
use crate::AppState;
|
||||
|
||||
use super::super::LocalSameFormatProviderDecisionInput;
|
||||
use super::super::LocalSameFormatProviderSpec;
|
||||
use super::policy::{
|
||||
classify_same_format_provider_request_behavior, resolve_same_format_provider_direct_auth,
|
||||
same_format_provider_transport_supported, same_format_provider_transport_unsupported_reason,
|
||||
should_try_same_format_provider_oauth_auth,
|
||||
};
|
||||
|
||||
pub(super) struct PreparedSameFormatProviderCandidate {
|
||||
pub(super) transport: Arc<GatewayProviderTransportSnapshot>,
|
||||
pub(super) behavior: SameFormatProviderRequestBehavior,
|
||||
pub(super) is_antigravity: bool,
|
||||
pub(super) is_claude_code: bool,
|
||||
pub(super) is_gemini_cli: bool,
|
||||
pub(super) is_vertex: bool,
|
||||
pub(super) is_kiro: bool,
|
||||
pub(super) kiro_auth: Option<KiroRequestAuth>,
|
||||
pub(super) auth_header: Option<String>,
|
||||
pub(super) auth_value: Option<String>,
|
||||
pub(super) provider_api_format: String,
|
||||
pub(super) mapped_model: String,
|
||||
pub(super) report_kind: &'static str,
|
||||
pub(super) upstream_is_stream: bool,
|
||||
pub(super) force_body_stream_field: bool,
|
||||
}
|
||||
|
||||
pub(super) async fn prepare_local_same_format_provider_candidate(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
input: &LocalSameFormatProviderDecisionInput,
|
||||
eligible: &EligibleLocalExecutionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
spec: LocalSameFormatProviderSpec,
|
||||
) -> Option<PreparedSameFormatProviderCandidate> {
|
||||
let spec_metadata = local_same_format_provider_spec_metadata(spec);
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let candidate = &eligible.candidate;
|
||||
let transport = Arc::clone(&eligible.transport);
|
||||
let provider_api_format = eligible.provider_api_format.as_str();
|
||||
let behavior = classify_same_format_provider_request_behavior(
|
||||
&transport,
|
||||
provider_api_format,
|
||||
spec_metadata,
|
||||
spec.operation,
|
||||
);
|
||||
|
||||
if !same_format_provider_transport_supported(
|
||||
&behavior,
|
||||
&transport,
|
||||
spec.family,
|
||||
provider_api_format,
|
||||
) {
|
||||
let skip_reason = same_format_provider_transport_unsupported_reason(
|
||||
&behavior,
|
||||
&transport,
|
||||
spec.family,
|
||||
provider_api_format,
|
||||
)
|
||||
.unwrap_or("transport_unsupported");
|
||||
super::super::payload::mark_skipped_local_same_format_provider_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
|
||||
let vertex_query_auth = if behavior.is_vertex {
|
||||
resolve_local_vertex_api_key_query_auth(&transport)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let should_try_oauth_auth = should_try_same_format_provider_oauth_auth(
|
||||
&behavior,
|
||||
&transport,
|
||||
spec.family,
|
||||
provider_api_format,
|
||||
);
|
||||
let oauth_auth = if should_try_oauth_auth {
|
||||
resolve_candidate_oauth_auth(
|
||||
planner_state,
|
||||
&transport,
|
||||
OauthPreparationContext {
|
||||
trace_id,
|
||||
api_format: provider_api_format,
|
||||
operation: "same_format_provider_prepare",
|
||||
},
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let kiro_auth = match oauth_auth.as_ref() {
|
||||
Some(LocalResolvedOAuthRequestAuth::Kiro(auth)) => Some(auth.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let auth = if let Some(kiro_auth) = kiro_auth.as_ref() {
|
||||
Some((kiro_auth.name.to_string(), kiro_auth.value.clone()))
|
||||
} else if let Some(LocalResolvedOAuthRequestAuth::Header { name, value }) = oauth_auth.as_ref()
|
||||
{
|
||||
Some((name.clone(), value.clone()))
|
||||
} else {
|
||||
resolve_same_format_provider_direct_auth(
|
||||
&behavior,
|
||||
&transport,
|
||||
spec.family,
|
||||
provider_api_format,
|
||||
)
|
||||
};
|
||||
let (auth_header, auth_value) = match auth {
|
||||
Some((name, value)) => (Some(name), Some(value)),
|
||||
None if behavior.is_vertex && vertex_query_auth.is_some() => (None, None),
|
||||
None => {
|
||||
super::super::payload::mark_skipped_local_same_format_provider_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"transport_auth_unavailable",
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if behavior.is_vertex
|
||||
&& is_vertex_api_key_transport_context(&transport)
|
||||
&& vertex_query_auth.is_none()
|
||||
{
|
||||
super::super::payload::mark_skipped_local_same_format_provider_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"transport_auth_unavailable",
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
|
||||
let mapped_model = match resolve_candidate_mapped_model(candidate) {
|
||||
Ok(mapped_model) => mapped_model,
|
||||
Err(skip_reason) => {
|
||||
super::super::payload::mark_skipped_local_same_format_provider_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(PreparedSameFormatProviderCandidate {
|
||||
transport,
|
||||
behavior,
|
||||
is_antigravity: behavior.is_antigravity,
|
||||
is_claude_code: behavior.is_claude_code,
|
||||
is_gemini_cli: behavior.is_gemini_cli,
|
||||
is_vertex: behavior.is_vertex,
|
||||
is_kiro: behavior.is_kiro,
|
||||
kiro_auth,
|
||||
auth_header,
|
||||
auth_value,
|
||||
provider_api_format: provider_api_format.to_string(),
|
||||
mapped_model,
|
||||
report_kind: behavior.report_kind,
|
||||
upstream_is_stream: behavior.upstream_is_stream,
|
||||
force_body_stream_field: behavior.force_body_stream_field,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
use async_trait::async_trait;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_serving::planner::candidate_materialization::LocalExecutionAttemptSource;
|
||||
use crate::ai_serving::planner::common::{
|
||||
extract_requested_model_from_request, RequestedModelFamily,
|
||||
};
|
||||
use crate::ai_serving::planner::runtime_miss::{
|
||||
apply_local_runtime_candidate_evaluation_progress_preserving_candidate_signal,
|
||||
apply_local_runtime_candidate_terminal_reason, set_local_runtime_miss_diagnostic_reason,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::{
|
||||
build_stream_plan_from_requested_model_family, build_sync_plan_from_requested_model_family,
|
||||
local_same_format_provider_spec_metadata,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::{
|
||||
resolve_local_same_format_stream_spec as resolve_stream_spec,
|
||||
resolve_local_same_format_sync_spec as resolve_sync_spec,
|
||||
};
|
||||
|
||||
use super::{
|
||||
build_local_same_format_provider_candidate_attempt_source,
|
||||
maybe_build_local_same_format_provider_decision_payload_for_candidate,
|
||||
resolve_local_same_format_provider_decision_input, AiStreamAttempt, AiSyncAttempt, AppState,
|
||||
GatewayControlDecision, GatewayError, LocalSameFormatProviderCandidateAttempt,
|
||||
LocalSameFormatProviderCandidateAttemptSource, LocalSameFormatProviderDecisionInput,
|
||||
LocalSameFormatProviderSpec,
|
||||
};
|
||||
|
||||
pub(crate) struct LocalSameFormatProviderSyncAttemptSource<'a> {
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
trace_id: &'a str,
|
||||
body_json: serde_json::Value,
|
||||
input: LocalSameFormatProviderDecisionInput,
|
||||
spec: LocalSameFormatProviderSpec,
|
||||
requested_model_family: RequestedModelFamily,
|
||||
candidates: LocalSameFormatProviderCandidateAttemptSource<'a>,
|
||||
}
|
||||
|
||||
pub(crate) struct LocalSameFormatProviderStreamAttemptSource<'a> {
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
trace_id: &'a str,
|
||||
body_json: serde_json::Value,
|
||||
input: LocalSameFormatProviderDecisionInput,
|
||||
spec: LocalSameFormatProviderSpec,
|
||||
requested_model_family: RequestedModelFamily,
|
||||
candidates: LocalSameFormatProviderCandidateAttemptSource<'a>,
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_sync_attempt_source<'a>(
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
trace_id: &'a str,
|
||||
decision: &'a GatewayControlDecision,
|
||||
body_json: &'a serde_json::Value,
|
||||
spec: LocalSameFormatProviderSpec,
|
||||
) -> Result<Option<(LocalSameFormatProviderSyncAttemptSource<'a>, usize)>, GatewayError> {
|
||||
let spec_metadata = local_same_format_provider_spec_metadata(spec);
|
||||
let requested_model_family = spec_metadata
|
||||
.requested_model_family
|
||||
.expect("same-format provider spec metadata should include requested-model family");
|
||||
let Some(input) = resolve_local_same_format_provider_decision_input(
|
||||
state, parts, trace_id, decision, body_json, spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
extract_requested_model_from_request(parts, body_json, requested_model_family)
|
||||
.as_deref(),
|
||||
"decision_input_unavailable",
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"candidate_evaluation_incomplete",
|
||||
);
|
||||
let effective_body_json = input.effective_body_json(body_json).clone();
|
||||
let (candidates, candidate_count) = build_local_same_format_provider_candidate_attempt_source(
|
||||
state,
|
||||
trace_id,
|
||||
&input,
|
||||
&effective_body_json,
|
||||
spec,
|
||||
)
|
||||
.await?;
|
||||
apply_local_runtime_candidate_evaluation_progress_preserving_candidate_signal(
|
||||
state,
|
||||
trace_id,
|
||||
candidate_count,
|
||||
);
|
||||
if candidate_count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some((
|
||||
LocalSameFormatProviderSyncAttemptSource {
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
body_json: effective_body_json,
|
||||
input,
|
||||
spec,
|
||||
requested_model_family,
|
||||
candidates,
|
||||
},
|
||||
candidate_count,
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_stream_attempt_source<'a>(
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
trace_id: &'a str,
|
||||
decision: &'a GatewayControlDecision,
|
||||
body_json: &'a serde_json::Value,
|
||||
spec: LocalSameFormatProviderSpec,
|
||||
) -> Result<Option<(LocalSameFormatProviderStreamAttemptSource<'a>, usize)>, GatewayError> {
|
||||
let spec_metadata = local_same_format_provider_spec_metadata(spec);
|
||||
let requested_model_family = spec_metadata
|
||||
.requested_model_family
|
||||
.expect("same-format provider spec metadata should include requested-model family");
|
||||
let Some(input) = resolve_local_same_format_provider_decision_input(
|
||||
state, parts, trace_id, decision, body_json, spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
extract_requested_model_from_request(parts, body_json, requested_model_family)
|
||||
.as_deref(),
|
||||
"decision_input_unavailable",
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"candidate_evaluation_incomplete",
|
||||
);
|
||||
let effective_body_json = input.effective_body_json(body_json).clone();
|
||||
let (candidates, candidate_count) = build_local_same_format_provider_candidate_attempt_source(
|
||||
state,
|
||||
trace_id,
|
||||
&input,
|
||||
&effective_body_json,
|
||||
spec,
|
||||
)
|
||||
.await?;
|
||||
apply_local_runtime_candidate_evaluation_progress_preserving_candidate_signal(
|
||||
state,
|
||||
trace_id,
|
||||
candidate_count,
|
||||
);
|
||||
if candidate_count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some((
|
||||
LocalSameFormatProviderStreamAttemptSource {
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
body_json: effective_body_json,
|
||||
input,
|
||||
spec,
|
||||
requested_model_family,
|
||||
candidates,
|
||||
},
|
||||
candidate_count,
|
||||
)))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalSameFormatProviderSyncAttemptSource<'_> {
|
||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||
while let Some(attempt) = self.candidates.next_attempt().await? {
|
||||
match self.build_sync_attempt(attempt).await? {
|
||||
Some(attempt) => return Ok(Some(attempt)),
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
apply_local_runtime_candidate_terminal_reason(
|
||||
self.state,
|
||||
self.trace_id,
|
||||
"no_local_sync_plans",
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||
let mut drained = Vec::new();
|
||||
for attempt in self.candidates.drain_static_attempts() {
|
||||
if let Some(attempt) = self.build_sync_attempt(attempt).await? {
|
||||
drained.push(attempt);
|
||||
}
|
||||
}
|
||||
Ok(drained)
|
||||
}
|
||||
|
||||
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_credential(key_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_endpoint(endpoint_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_provider(provider_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LocalExecutionAttemptSource<AiStreamAttempt>
|
||||
for LocalSameFormatProviderStreamAttemptSource<'_>
|
||||
{
|
||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||
while let Some(attempt) = self.candidates.next_attempt().await? {
|
||||
match self.build_stream_attempt(attempt).await? {
|
||||
Some(attempt) => return Ok(Some(attempt)),
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
apply_local_runtime_candidate_terminal_reason(
|
||||
self.state,
|
||||
self.trace_id,
|
||||
"no_local_stream_plans",
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiStreamAttempt>, GatewayError> {
|
||||
let mut drained = Vec::new();
|
||||
for attempt in self.candidates.drain_static_attempts() {
|
||||
if let Some(attempt) = self.build_stream_attempt(attempt).await? {
|
||||
drained.push(attempt);
|
||||
}
|
||||
}
|
||||
Ok(drained)
|
||||
}
|
||||
|
||||
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_credential(key_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_endpoint(endpoint_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_provider(provider_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalSameFormatProviderSyncAttemptSource<'_> {
|
||||
async fn build_sync_attempt(
|
||||
&self,
|
||||
attempt: LocalSameFormatProviderCandidateAttempt,
|
||||
) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||
let Some(payload) = maybe_build_local_same_format_provider_decision_payload_for_candidate(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.trace_id,
|
||||
&self.body_json,
|
||||
&self.input,
|
||||
attempt,
|
||||
self.spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match build_sync_plan_from_requested_model_family(
|
||||
self.requested_model_family,
|
||||
self.parts,
|
||||
&self.body_json,
|
||||
payload,
|
||||
) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %self.trace_id,
|
||||
error = ?err,
|
||||
"gateway local same-format sync decision plan build failed"
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalSameFormatProviderStreamAttemptSource<'_> {
|
||||
async fn build_stream_attempt(
|
||||
&self,
|
||||
attempt: LocalSameFormatProviderCandidateAttempt,
|
||||
) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||
let Some(payload) = maybe_build_local_same_format_provider_decision_payload_for_candidate(
|
||||
self.state,
|
||||
self.parts,
|
||||
self.trace_id,
|
||||
&self.body_json,
|
||||
&self.input,
|
||||
attempt,
|
||||
self.spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match build_stream_plan_from_requested_model_family(
|
||||
self.requested_model_family,
|
||||
self.parts,
|
||||
&self.body_json,
|
||||
payload,
|
||||
) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %self.trace_id,
|
||||
error = ?err,
|
||||
"gateway local same-format stream decision plan build failed"
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_sync_plan_and_reports(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
spec: LocalSameFormatProviderSpec,
|
||||
) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||
let spec_metadata = local_same_format_provider_spec_metadata(spec);
|
||||
let requested_model_family = spec_metadata
|
||||
.requested_model_family
|
||||
.expect("same-format provider spec metadata should include requested-model family");
|
||||
let Some(input) = resolve_local_same_format_provider_decision_input(
|
||||
state, parts, trace_id, decision, body_json, spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
extract_requested_model_from_request(parts, body_json, requested_model_family)
|
||||
.as_deref(),
|
||||
"decision_input_unavailable",
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"candidate_evaluation_incomplete",
|
||||
);
|
||||
let body_json = input.effective_body_json(body_json);
|
||||
let (mut source, candidate_count) = build_local_same_format_provider_candidate_attempt_source(
|
||||
state, trace_id, &input, body_json, spec,
|
||||
)
|
||||
.await?;
|
||||
apply_local_runtime_candidate_evaluation_progress_preserving_candidate_signal(
|
||||
state,
|
||||
trace_id,
|
||||
candidate_count,
|
||||
);
|
||||
if candidate_count == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut plans = Vec::new();
|
||||
while let Some(attempt) = source.next_attempt().await? {
|
||||
let Some(payload) = maybe_build_local_same_format_provider_decision_payload_for_candidate(
|
||||
state, parts, trace_id, body_json, &input, attempt, spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let built = build_sync_plan_from_requested_model_family(
|
||||
requested_model_family,
|
||||
parts,
|
||||
body_json,
|
||||
payload,
|
||||
);
|
||||
|
||||
match built {
|
||||
Ok(Some(value)) => plans.push(value),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
api_format = spec_metadata.api_format,
|
||||
error = ?err,
|
||||
"gateway local same-format sync decision plan build failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply_local_runtime_candidate_terminal_reason(state, trace_id, "no_local_sync_plans");
|
||||
|
||||
Ok(plans)
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_stream_plan_and_reports(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
spec: LocalSameFormatProviderSpec,
|
||||
) -> Result<Vec<AiStreamAttempt>, GatewayError> {
|
||||
let spec_metadata = local_same_format_provider_spec_metadata(spec);
|
||||
let requested_model_family = spec_metadata
|
||||
.requested_model_family
|
||||
.expect("same-format provider spec metadata should include requested-model family");
|
||||
let Some(input) = resolve_local_same_format_provider_decision_input(
|
||||
state, parts, trace_id, decision, body_json, spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
extract_requested_model_from_request(parts, body_json, requested_model_family)
|
||||
.as_deref(),
|
||||
"decision_input_unavailable",
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"candidate_evaluation_incomplete",
|
||||
);
|
||||
let body_json = input.effective_body_json(body_json);
|
||||
let (mut source, candidate_count) = build_local_same_format_provider_candidate_attempt_source(
|
||||
state, trace_id, &input, body_json, spec,
|
||||
)
|
||||
.await?;
|
||||
apply_local_runtime_candidate_evaluation_progress_preserving_candidate_signal(
|
||||
state,
|
||||
trace_id,
|
||||
candidate_count,
|
||||
);
|
||||
if candidate_count == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut plans = Vec::new();
|
||||
while let Some(attempt) = source.next_attempt().await? {
|
||||
let Some(payload) = maybe_build_local_same_format_provider_decision_payload_for_candidate(
|
||||
state, parts, trace_id, body_json, &input, attempt, spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let built = build_stream_plan_from_requested_model_family(
|
||||
requested_model_family,
|
||||
parts,
|
||||
body_json,
|
||||
payload,
|
||||
);
|
||||
|
||||
match built {
|
||||
Ok(Some(value)) => plans.push(value),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
api_format = spec_metadata.api_format,
|
||||
error = ?err,
|
||||
"gateway local same-format stream decision plan build failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply_local_runtime_candidate_terminal_reason(state, trace_id, "no_local_stream_plans");
|
||||
|
||||
Ok(plans)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod body;
|
||||
mod url;
|
||||
|
||||
pub(super) use self::body::build_same_format_provider_request_body;
|
||||
pub(super) use self::body::build_same_format_provider_request_body_with_compatibility_report;
|
||||
pub(super) use self::url::build_same_format_upstream_url;
|
||||
@@ -0,0 +1,80 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use super::super::LocalSameFormatProviderSpec;
|
||||
use crate::ai_serving::transport::{
|
||||
build_same_format_provider_request_body as build_same_format_provider_request_body_impl,
|
||||
build_same_format_provider_request_body_with_compatibility_report as build_same_format_provider_request_body_with_compatibility_report_impl,
|
||||
SameFormatProviderFamily, SameFormatProviderRequestBodyInput,
|
||||
SameFormatProviderRequestBodyOutput,
|
||||
};
|
||||
|
||||
pub(crate) fn build_same_format_provider_request_body(
|
||||
body_json: &Value,
|
||||
provider_api_format: &str,
|
||||
mapped_model: &str,
|
||||
spec: LocalSameFormatProviderSpec,
|
||||
body_rules: Option<&Value>,
|
||||
request_headers: Option<&http::HeaderMap>,
|
||||
upstream_is_stream: bool,
|
||||
force_body_stream_field: bool,
|
||||
kiro_auth: Option<&crate::ai_serving::transport::kiro::KiroRequestAuth>,
|
||||
is_claude_code: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
build_same_format_provider_request_body_impl(SameFormatProviderRequestBodyInput {
|
||||
body_json,
|
||||
mapped_model,
|
||||
client_api_format: spec.api_format,
|
||||
provider_api_format,
|
||||
source_model: body_json.get("model").and_then(Value::as_str),
|
||||
family: same_format_provider_family(spec.family),
|
||||
body_rules,
|
||||
request_headers,
|
||||
upstream_is_stream,
|
||||
force_body_stream_field,
|
||||
kiro_auth_config: kiro_auth.map(|auth| &auth.auth_config),
|
||||
is_claude_code,
|
||||
enable_model_directives,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_same_format_provider_request_body_with_compatibility_report(
|
||||
body_json: &Value,
|
||||
provider_api_format: &str,
|
||||
mapped_model: &str,
|
||||
spec: LocalSameFormatProviderSpec,
|
||||
body_rules: Option<&Value>,
|
||||
request_headers: Option<&http::HeaderMap>,
|
||||
upstream_is_stream: bool,
|
||||
force_body_stream_field: bool,
|
||||
kiro_auth: Option<&crate::ai_serving::transport::kiro::KiroRequestAuth>,
|
||||
is_claude_code: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<SameFormatProviderRequestBodyOutput> {
|
||||
build_same_format_provider_request_body_with_compatibility_report_impl(
|
||||
SameFormatProviderRequestBodyInput {
|
||||
body_json,
|
||||
mapped_model,
|
||||
client_api_format: spec.api_format,
|
||||
provider_api_format,
|
||||
source_model: body_json.get("model").and_then(Value::as_str),
|
||||
family: same_format_provider_family(spec.family),
|
||||
body_rules,
|
||||
request_headers,
|
||||
upstream_is_stream,
|
||||
force_body_stream_field,
|
||||
kiro_auth_config: kiro_auth.map(|auth| &auth.auth_config),
|
||||
is_claude_code,
|
||||
enable_model_directives,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn same_format_provider_family(
|
||||
family: super::super::LocalSameFormatProviderFamily,
|
||||
) -> SameFormatProviderFamily {
|
||||
match family {
|
||||
super::super::LocalSameFormatProviderFamily::Standard => SameFormatProviderFamily::Standard,
|
||||
super::super::LocalSameFormatProviderFamily::Gemini => SameFormatProviderFamily::Gemini,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use crate::ai_serving::transport::{
|
||||
build_same_format_provider_upstream_url as build_same_format_provider_upstream_url_impl,
|
||||
SameFormatProviderUpstreamUrlParams,
|
||||
};
|
||||
use crate::ai_serving::GatewayProviderTransportSnapshot;
|
||||
|
||||
use super::super::LocalSameFormatProviderSpec;
|
||||
|
||||
pub(crate) fn build_same_format_upstream_url(
|
||||
parts: &http::request::Parts,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
spec: LocalSameFormatProviderSpec,
|
||||
upstream_is_stream: bool,
|
||||
kiro_auth: Option<&crate::ai_serving::transport::kiro::KiroRequestAuth>,
|
||||
provider_request_body: Option<&serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
build_same_format_provider_upstream_url_impl(
|
||||
transport,
|
||||
SameFormatProviderUpstreamUrlParams {
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
request_query: parts.uri.query(),
|
||||
kiro_api_region: kiro_auth.map(|auth| auth.auth_config.effective_api_region()),
|
||||
api_operation: spec.operation,
|
||||
provider_request_body,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) use aether_ai_serving::{
|
||||
build_ai_execution_plan_from_decision, resolve_ai_passthrough_sync_request_body,
|
||||
take_ai_decision_plan_core, take_ai_non_empty_string as take_non_empty_string,
|
||||
take_ai_upstream_auth_pair, AiExecutionPlanFromDecisionParts,
|
||||
};
|
||||
|
||||
use crate::ai_serving::augment_sync_report_context as augment_sync_report_context_impl;
|
||||
pub(crate) use crate::ai_serving::{
|
||||
generic_decision_missing_exact_provider_request, AiStreamAttempt, AiSyncAttempt,
|
||||
};
|
||||
use crate::{AiExecutionDecision, GatewayError};
|
||||
|
||||
#[path = "standard/gemini/plan_builders.rs"]
|
||||
mod gemini_builders;
|
||||
#[path = "standard/openai/plan_builders.rs"]
|
||||
mod openai_builders;
|
||||
#[path = "passthrough/plan_builders.rs"]
|
||||
mod passthrough_builders;
|
||||
#[path = "standard/plan_builders.rs"]
|
||||
mod standard_builders;
|
||||
|
||||
pub(crate) use gemini_builders::{
|
||||
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
|
||||
};
|
||||
pub(crate) use openai_builders::{
|
||||
build_openai_chat_stream_plan_from_decision, build_openai_chat_sync_plan_from_decision,
|
||||
build_openai_responses_stream_plan_from_decision,
|
||||
build_openai_responses_sync_plan_from_decision,
|
||||
};
|
||||
pub(crate) use passthrough_builders::{
|
||||
build_passthrough_stream_plan_from_decision, build_passthrough_sync_plan_from_decision,
|
||||
};
|
||||
pub(crate) use standard_builders::{
|
||||
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
|
||||
};
|
||||
|
||||
pub(super) fn augment_sync_report_context(
|
||||
report_context: Option<serde_json::Value>,
|
||||
provider_request_headers: &BTreeMap<String, String>,
|
||||
provider_request_body: &serde_json::Value,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
augment_sync_report_context_impl(
|
||||
report_context,
|
||||
provider_request_headers,
|
||||
provider_request_body,
|
||||
)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub(crate) use crate::dispatch::pool_scheduler::apply_local_execution_pool_scheduler;
|
||||
pub(crate) use crate::dispatch::pool_scheduler::PoolKeyCursor;
|
||||
@@ -0,0 +1,227 @@
|
||||
use aether_data_contracts::repository::pool_scores::{
|
||||
PoolMemberIdentity, PoolMemberProbeStatus, PoolScoreScope, UpsertPoolMemberScore,
|
||||
POOL_SCORE_CAPABILITY_ACCOUNT, POOL_SCORE_SCOPE_KIND_ACCOUNT,
|
||||
};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use aether_pool_core::{
|
||||
score_pool_member_with_rules, PoolMemberScoreInput, PoolMemberScoreRules, POOL_SCORE_VERSION,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::handlers::shared::{provider_key_health_summary, provider_key_status_snapshot_payload};
|
||||
|
||||
pub(crate) fn build_provider_key_pool_score_upsert(
|
||||
key: &StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
existing: Option<&aether_data_contracts::repository::pool_scores::StoredPoolMemberScore>,
|
||||
now_unix_secs: u64,
|
||||
score_rules: PoolMemberScoreRules,
|
||||
) -> UpsertPoolMemberScore {
|
||||
let identity = PoolMemberIdentity::provider_api_key(key.provider_id.clone(), key.id.clone());
|
||||
let scope = provider_key_pool_score_scope();
|
||||
let input = provider_key_score_input(
|
||||
key,
|
||||
provider_type,
|
||||
identity.clone(),
|
||||
scope.clone(),
|
||||
existing,
|
||||
now_unix_secs,
|
||||
);
|
||||
let output = score_pool_member_with_rules(&input, score_rules);
|
||||
UpsertPoolMemberScore {
|
||||
id: provider_key_pool_score_id(&identity, &scope),
|
||||
identity,
|
||||
scope,
|
||||
score: output.score,
|
||||
hard_state: output.hard_state,
|
||||
score_version: POOL_SCORE_VERSION,
|
||||
score_reason: output.score_reason,
|
||||
last_ranked_at: Some(now_unix_secs),
|
||||
last_scheduled_at: existing.and_then(|score| score.last_scheduled_at),
|
||||
last_success_at: existing.and_then(|score| score.last_success_at),
|
||||
last_failure_at: existing.and_then(|score| score.last_failure_at),
|
||||
failure_count: existing.map(|score| score.failure_count).unwrap_or(0),
|
||||
last_probe_attempt_at: existing.and_then(|score| score.last_probe_attempt_at),
|
||||
last_probe_success_at: existing.and_then(|score| score.last_probe_success_at),
|
||||
last_probe_failure_at: existing.and_then(|score| score.last_probe_failure_at),
|
||||
probe_failure_count: existing.map(|score| score.probe_failure_count).unwrap_or(0),
|
||||
probe_status: existing
|
||||
.map(|score| score.probe_status)
|
||||
.unwrap_or(PoolMemberProbeStatus::Never),
|
||||
updated_at: now_unix_secs,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn provider_key_pool_score_scope() -> PoolScoreScope {
|
||||
PoolScoreScope {
|
||||
capability: POOL_SCORE_CAPABILITY_ACCOUNT.to_string(),
|
||||
scope_kind: POOL_SCORE_SCOPE_KIND_ACCOUNT.to_string(),
|
||||
scope_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn provider_key_pool_score_id(
|
||||
identity: &PoolMemberIdentity,
|
||||
scope: &PoolScoreScope,
|
||||
) -> String {
|
||||
let raw = format!(
|
||||
"{}:{}:{}:{}:{}:{}:{}",
|
||||
identity.pool_kind,
|
||||
identity.pool_id,
|
||||
identity.member_kind,
|
||||
identity.member_id,
|
||||
scope.capability,
|
||||
scope.scope_kind,
|
||||
scope.scope_id.as_deref().unwrap_or("*")
|
||||
);
|
||||
format!(
|
||||
"pms-{:016x}-{:016x}",
|
||||
stable_hash(raw.as_bytes()),
|
||||
stable_hash(identity.member_id.as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
fn provider_key_score_input(
|
||||
key: &StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
identity: PoolMemberIdentity,
|
||||
scope: PoolScoreScope,
|
||||
existing: Option<&aether_data_contracts::repository::pool_scores::StoredPoolMemberScore>,
|
||||
now_unix_secs: u64,
|
||||
) -> PoolMemberScoreInput {
|
||||
let status_snapshot = provider_key_status_snapshot_payload(key, provider_type);
|
||||
let quota_snapshot = status_snapshot
|
||||
.as_object()
|
||||
.and_then(|snapshot| snapshot.get("quota"))
|
||||
.and_then(Value::as_object);
|
||||
let account_snapshot = status_snapshot
|
||||
.as_object()
|
||||
.and_then(|snapshot| snapshot.get("account"))
|
||||
.and_then(Value::as_object);
|
||||
let (health_score, _, _, _, _) = provider_key_health_summary(key);
|
||||
let health_score = key
|
||||
.health_by_format
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.filter(|payload| !payload.is_empty())
|
||||
.map(|_| health_score);
|
||||
|
||||
PoolMemberScoreInput {
|
||||
identity,
|
||||
scope: scope.clone(),
|
||||
internal_priority: key.internal_priority,
|
||||
is_active: key.is_active,
|
||||
health_score,
|
||||
quota_usage_ratio: quota_snapshot
|
||||
.and_then(|quota| quota.get("usage_ratio"))
|
||||
.and_then(json_f64)
|
||||
.map(|value| value.clamp(0.0, 1.0)),
|
||||
quota_exhausted: quota_snapshot
|
||||
.and_then(|quota| quota.get("exhausted"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
account_blocked: account_snapshot
|
||||
.and_then(|account| account.get("blocked"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
oauth_invalid_reason: key.oauth_invalid_reason.clone(),
|
||||
success_count: key.success_count.unwrap_or(0).into(),
|
||||
error_count: key.error_count.unwrap_or(0).into(),
|
||||
total_response_time_ms: key.total_response_time_ms.unwrap_or(0),
|
||||
total_tokens: key.total_tokens,
|
||||
total_cost_usd: key.total_cost_usd,
|
||||
last_used_at: key.last_used_at_unix_secs,
|
||||
last_probe_success_at: existing.and_then(|score| score.last_probe_success_at),
|
||||
probe_failure_count: existing.map(|score| score.probe_failure_count).unwrap_or(0),
|
||||
probe_status: existing
|
||||
.map(|score| score.probe_status)
|
||||
.unwrap_or(PoolMemberProbeStatus::Never),
|
||||
now_unix_secs,
|
||||
}
|
||||
}
|
||||
|
||||
fn json_f64(value: &Value) -> Option<f64> {
|
||||
value.as_f64().or_else(|| {
|
||||
value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|value| value.parse::<f64>().ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn stable_hash(bytes: &[u8]) -> u64 {
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
for byte in bytes {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aether_data_contracts::repository::pool_scores::PoolMemberHardState;
|
||||
use serde_json::json;
|
||||
|
||||
fn sample_key_with_circuit_next_probe(
|
||||
next_probe_at_unix_secs: u64,
|
||||
) -> StoredProviderCatalogKey {
|
||||
let mut key = StoredProviderCatalogKey::new(
|
||||
"key-gemini-5".to_string(),
|
||||
"provider-google-api".to_string(),
|
||||
"5".to_string(),
|
||||
"api_key".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("sample key should be valid");
|
||||
key.health_by_format = Some(json!({
|
||||
"gemini:generate_content": {
|
||||
"health_score": 0.2,
|
||||
"consecutive_failures": 8
|
||||
}
|
||||
}));
|
||||
key.circuit_breaker_by_format = Some(json!({
|
||||
"gemini:generate_content": {
|
||||
"open": true,
|
||||
"reason": "consecutive_failures_8",
|
||||
"next_probe_at_unix_secs": next_probe_at_unix_secs
|
||||
}
|
||||
}));
|
||||
key
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_circuit_probe_deadline_does_not_leave_pool_score_in_cooldown() {
|
||||
let now_unix_secs = 1_000;
|
||||
let key = sample_key_with_circuit_next_probe(900);
|
||||
|
||||
let score = build_provider_key_pool_score_upsert(
|
||||
&key,
|
||||
"custom",
|
||||
None,
|
||||
now_unix_secs,
|
||||
PoolMemberScoreRules::default(),
|
||||
);
|
||||
|
||||
assert_eq!(score.hard_state, PoolMemberHardState::Available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_key_circuit_probe_deadline_does_not_drive_pool_score_cooldown() {
|
||||
let now_unix_secs = 1_000;
|
||||
let key = sample_key_with_circuit_next_probe(1_100);
|
||||
|
||||
let score = build_provider_key_pool_score_upsert(
|
||||
&key,
|
||||
"custom",
|
||||
None,
|
||||
now_unix_secs,
|
||||
PoolMemberScoreRules::default(),
|
||||
);
|
||||
|
||||
assert_eq!(score.hard_state, PoolMemberHardState::Available);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
use std::borrow::Cow;
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_serving::ExecutionRuntimeAuthContext;
|
||||
use crate::privacy::{
|
||||
build_redaction_session_config, read_chat_pii_redaction_runtime_config,
|
||||
try_mask_chat_pii_request_value_with_cache_options, CachedRequestRedaction,
|
||||
ChatPiiRedactionRequestFormat, MaskChatRequestOptions, RedactionMaskError,
|
||||
RedactionSessionSlot, RedisRedactionMappingCache,
|
||||
};
|
||||
use crate::stage_metrics::observe_gateway_stage_ms;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
pub(crate) struct ProviderRequestRedaction<'a> {
|
||||
pub(crate) body_json: Cow<'a, Value>,
|
||||
pub(crate) redacted: bool,
|
||||
}
|
||||
|
||||
impl<'a> ProviderRequestRedaction<'a> {
|
||||
fn disabled(body_json: &'a Value) -> Self {
|
||||
Self {
|
||||
body_json: Cow::Borrowed(body_json),
|
||||
redacted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct ChatPiiRedactionFeatureSettings {
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl ChatPiiRedactionFeatureSettings {
|
||||
fn merge_from_value(&mut self, value: Option<&Value>) {
|
||||
let Some(settings) = value
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|features| features.get("chat_pii_redaction"))
|
||||
.and_then(Value::as_object)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(enabled) = settings.get("enabled").and_then(Value::as_bool) {
|
||||
self.enabled = Some(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_enabled(self) -> bool {
|
||||
self.enabled.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn request_identity_response_encoding_when_redacted(
|
||||
headers: &mut std::collections::BTreeMap<String, String>,
|
||||
redacted: bool,
|
||||
) {
|
||||
if redacted {
|
||||
headers.insert("accept-encoding".to_string(), "identity".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_provider_chat_pii_redaction<'a>(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &'a Value,
|
||||
auth_context: &ExecutionRuntimeAuthContext,
|
||||
client_api_format: &str,
|
||||
candidate_id: &str,
|
||||
) -> Result<ProviderRequestRedaction<'a>, GatewayError> {
|
||||
let Some(format) = ChatPiiRedactionRequestFormat::from_api_format(client_api_format) else {
|
||||
return Ok(ProviderRequestRedaction::disabled(body_json));
|
||||
};
|
||||
let Some(slot) = parts.extensions.get::<RedactionSessionSlot>() else {
|
||||
return Ok(ProviderRequestRedaction::disabled(body_json));
|
||||
};
|
||||
let request_cache_key = request_redaction_cache_key(format, body_json);
|
||||
if let Some(cached) = slot.cached_request_redaction(&request_cache_key) {
|
||||
crate::stage_metrics::record_chat_pii_redaction_request_cache_hit();
|
||||
observe_gateway_stage_ms("chat_pii_redaction_request_cache_hit", 0);
|
||||
return Ok(provider_redaction_from_cached(
|
||||
slot,
|
||||
candidate_id,
|
||||
body_json,
|
||||
cached,
|
||||
));
|
||||
}
|
||||
crate::stage_metrics::record_chat_pii_redaction_request_cache_miss();
|
||||
|
||||
let runtime_config_started_at = Instant::now();
|
||||
let runtime_config = read_chat_pii_redaction_runtime_config(state)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(
|
||||
error = ?err,
|
||||
"gateway failed to read chat pii redaction runtime config"
|
||||
);
|
||||
GatewayError::Internal("chat pii redaction setup failed".to_string())
|
||||
})?;
|
||||
observe_gateway_stage_ms(
|
||||
"chat_pii_redaction_runtime_config",
|
||||
runtime_config_started_at.elapsed().as_millis() as u64,
|
||||
);
|
||||
if !runtime_config.enabled {
|
||||
slot.put_cached_request_redaction(request_cache_key, CachedRequestRedaction::unredacted());
|
||||
return Ok(ProviderRequestRedaction::disabled(body_json));
|
||||
}
|
||||
let feature_settings_started_at = Instant::now();
|
||||
let feature_settings = resolve_chat_pii_redaction_feature_settings(state, auth_context).await?;
|
||||
observe_gateway_stage_ms(
|
||||
"chat_pii_redaction_feature_settings",
|
||||
feature_settings_started_at.elapsed().as_millis() as u64,
|
||||
);
|
||||
if !feature_settings.effective_enabled() {
|
||||
slot.put_cached_request_redaction(request_cache_key, CachedRequestRedaction::unredacted());
|
||||
return Ok(ProviderRequestRedaction::disabled(body_json));
|
||||
}
|
||||
let Some(hmac_key) = state.encryption_key().map(str::as_bytes).map(Vec::from) else {
|
||||
warn!("gateway chat pii redaction is enabled but encryption key is unavailable");
|
||||
return Err(GatewayError::Internal(
|
||||
"chat pii redaction setup failed".to_string(),
|
||||
));
|
||||
};
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let cache = RedisRedactionMappingCache::new(state.runtime_state.as_ref());
|
||||
let mask_started_at = Instant::now();
|
||||
let masked = try_mask_chat_pii_request_value_with_cache_options(
|
||||
body_json,
|
||||
format,
|
||||
build_redaction_session_config(hmac_key, &runtime_config, now_unix_secs),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
Some(&cache),
|
||||
)
|
||||
.await
|
||||
.map_err(redaction_mask_error_to_gateway_error)?;
|
||||
observe_gateway_stage_ms(
|
||||
"chat_pii_redaction_mask_body",
|
||||
mask_started_at.elapsed().as_millis() as u64,
|
||||
);
|
||||
if !masked.redacted {
|
||||
slot.put_cached_request_redaction(request_cache_key, CachedRequestRedaction::unredacted());
|
||||
return Ok(ProviderRequestRedaction {
|
||||
body_json: Cow::Borrowed(body_json),
|
||||
redacted: false,
|
||||
});
|
||||
}
|
||||
let Some(masked_body_json) = masked.body_json else {
|
||||
warn!("gateway pii redaction reported redacted without masked body");
|
||||
return Err(GatewayError::Internal(
|
||||
"chat pii redaction setup failed".to_string(),
|
||||
));
|
||||
};
|
||||
slot.put_cached_request_redaction(
|
||||
request_cache_key,
|
||||
CachedRequestRedaction::redacted(masked_body_json.clone(), masked.session.clone()),
|
||||
);
|
||||
slot.put_for_candidate(candidate_id, masked.session);
|
||||
Ok(ProviderRequestRedaction {
|
||||
body_json: Cow::Owned(masked_body_json),
|
||||
redacted: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn request_redaction_cache_key(format: ChatPiiRedactionRequestFormat, body_json: &Value) -> String {
|
||||
format!("{format:?}:{:p}", body_json)
|
||||
}
|
||||
|
||||
fn provider_redaction_from_cached<'a>(
|
||||
slot: &RedactionSessionSlot,
|
||||
candidate_id: &str,
|
||||
body_json: &'a Value,
|
||||
cached: CachedRequestRedaction,
|
||||
) -> ProviderRequestRedaction<'a> {
|
||||
if !cached.redacted {
|
||||
return ProviderRequestRedaction::disabled(body_json);
|
||||
}
|
||||
let Some(masked_body_json) = cached.body_json else {
|
||||
return ProviderRequestRedaction::disabled(body_json);
|
||||
};
|
||||
if let Some(session) = cached.session {
|
||||
slot.put_for_candidate(candidate_id, session);
|
||||
}
|
||||
ProviderRequestRedaction {
|
||||
body_json: Cow::Owned(masked_body_json),
|
||||
redacted: true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_chat_pii_redaction_feature_settings(
|
||||
state: &AppState,
|
||||
auth_context: &ExecutionRuntimeAuthContext,
|
||||
) -> Result<ChatPiiRedactionFeatureSettings, GatewayError> {
|
||||
let user_settings_fut = state.read_user_feature_settings(&auth_context.user_id);
|
||||
let key_settings_fut = state.read_auth_api_key_feature_settings(
|
||||
&auth_context.user_id,
|
||||
&auth_context.api_key_id,
|
||||
auth_context.api_key_is_standalone,
|
||||
);
|
||||
let (user_settings, key_settings) = tokio::try_join!(user_settings_fut, key_settings_fut)
|
||||
.map_err(|err| {
|
||||
warn!(
|
||||
error = ?err,
|
||||
"gateway failed to read chat pii redaction feature settings"
|
||||
);
|
||||
GatewayError::Internal("chat pii redaction setup failed".to_string())
|
||||
})?;
|
||||
|
||||
let mut settings = ChatPiiRedactionFeatureSettings::default();
|
||||
settings.merge_from_value(user_settings.as_ref());
|
||||
settings.merge_from_value(key_settings.as_ref());
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn redaction_mask_error_to_gateway_error(error: RedactionMaskError) -> GatewayError {
|
||||
match error {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::ChatPiiRedactionFeatureSettings;
|
||||
|
||||
#[test]
|
||||
fn chat_pii_redaction_feature_settings_only_control_enablement() {
|
||||
let mut settings = ChatPiiRedactionFeatureSettings::default();
|
||||
settings.merge_from_value(Some(&json!({
|
||||
"chat_pii_redaction": {
|
||||
"enabled": true
|
||||
}
|
||||
})));
|
||||
|
||||
assert!(settings.effective_enabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_ai_serving::{
|
||||
build_ai_execution_report_context,
|
||||
insert_provider_stream_event_api_format as insert_ai_provider_stream_event_api_format,
|
||||
provider_stream_event_api_format_for_provider_type as ai_provider_stream_event_api_format_for_provider_type,
|
||||
AiExecutionReportContextParts, AiRequestOrigin,
|
||||
};
|
||||
use aether_routing_core::ResolvedRoutingPolicy;
|
||||
use aether_runtime_state::RuntimeLockLease;
|
||||
use aether_scheduler_core::{ClientSessionAffinity, SchedulerRankingOutcome};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::ai_serving::{
|
||||
request_origin_from_headers, request_path_implies_stream_request, sanitize_request_path,
|
||||
sanitize_request_path_and_query, sanitize_request_query_string, ExecutionRuntimeAuthContext,
|
||||
RequestOrigin,
|
||||
};
|
||||
use crate::client_session_affinity::{
|
||||
client_session_affinity_report_context_value, CLIENT_SESSION_AFFINITY_REPORT_CONTEXT_FIELD,
|
||||
};
|
||||
use crate::orchestration::{
|
||||
insert_pool_key_lease_report_context_fields, ExecutionAttemptIdentity,
|
||||
ROUTING_POOL_POLICY_OVERRIDE_REPORT_FIELD, SCHEDULER_AFFINITY_EPOCH_REPORT_FIELD,
|
||||
};
|
||||
use crate::scheduler::affinity::insert_scheduler_affinity_policy_report_context_field;
|
||||
|
||||
pub(crate) struct LocalExecutionReportContextParts<'a> {
|
||||
pub(crate) auth_context: &'a ExecutionRuntimeAuthContext,
|
||||
pub(crate) request_id: &'a str,
|
||||
pub(crate) candidate_id: &'a str,
|
||||
pub(crate) attempt_identity: ExecutionAttemptIdentity,
|
||||
pub(crate) model: &'a str,
|
||||
pub(crate) provider_name: &'a str,
|
||||
pub(crate) provider_id: &'a str,
|
||||
pub(crate) endpoint_id: &'a str,
|
||||
pub(crate) key_id: &'a str,
|
||||
pub(crate) key_name: Option<&'a str>,
|
||||
pub(crate) model_id: Option<&'a str>,
|
||||
pub(crate) global_model_id: Option<&'a str>,
|
||||
pub(crate) global_model_name: Option<&'a str>,
|
||||
pub(crate) provider_api_format: &'a str,
|
||||
pub(crate) client_api_format: &'a str,
|
||||
pub(crate) mapped_model: Option<&'a str>,
|
||||
pub(crate) candidate_group_id: Option<&'a str>,
|
||||
pub(crate) pool_key_lease: Option<&'a RuntimeLockLease>,
|
||||
pub(crate) ranking: Option<&'a SchedulerRankingOutcome>,
|
||||
pub(crate) upstream_url: Option<&'a str>,
|
||||
pub(crate) header_rules: Option<&'a Value>,
|
||||
pub(crate) body_rules: Option<&'a Value>,
|
||||
pub(crate) provider_request_method: Option<Value>,
|
||||
pub(crate) provider_request_headers: Option<&'a BTreeMap<String, String>>,
|
||||
pub(crate) original_headers: &'a http::HeaderMap,
|
||||
pub(crate) request_path: Option<&'a str>,
|
||||
pub(crate) request_query_string: Option<&'a str>,
|
||||
pub(crate) request_origin: Option<RequestOrigin>,
|
||||
pub(crate) original_request_body_json: Option<&'a Value>,
|
||||
pub(crate) original_request_body_base64: Option<&'a str>,
|
||||
pub(crate) client_session_affinity: Option<&'a ClientSessionAffinity>,
|
||||
pub(crate) routing_policy: Option<&'a ResolvedRoutingPolicy>,
|
||||
pub(crate) scheduler_affinity_epoch: Option<u64>,
|
||||
pub(crate) client_requested_stream: bool,
|
||||
pub(crate) upstream_is_stream: bool,
|
||||
pub(crate) has_envelope: bool,
|
||||
pub(crate) needs_conversion: bool,
|
||||
pub(crate) extra_fields: Map<String, Value>,
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_execution_report_context(
|
||||
parts: LocalExecutionReportContextParts<'_>,
|
||||
) -> Value {
|
||||
let RequestOrigin {
|
||||
client_ip,
|
||||
user_agent,
|
||||
} = parts
|
||||
.request_origin
|
||||
.unwrap_or_else(|| request_origin_from_headers(parts.original_headers));
|
||||
let original_headers = crate::ai_serving::collect_control_headers(parts.original_headers);
|
||||
let original_request_body = crate::ai_serving::build_report_context_original_request_echo(
|
||||
parts.original_request_body_json,
|
||||
parts.original_request_body_base64,
|
||||
);
|
||||
let mut extra_fields = parts.extra_fields;
|
||||
if let Some(value) = parts
|
||||
.client_session_affinity
|
||||
.and_then(client_session_affinity_report_context_value)
|
||||
{
|
||||
if let Some(client_family) = value
|
||||
.as_object()
|
||||
.and_then(|object| object.get("client_family"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|client_family| !client_family.is_empty())
|
||||
{
|
||||
extra_fields.insert(
|
||||
"client_family".to_string(),
|
||||
Value::String(client_family.to_ascii_lowercase()),
|
||||
);
|
||||
}
|
||||
extra_fields.insert(
|
||||
CLIENT_SESSION_AFFINITY_REPORT_CONTEXT_FIELD.to_string(),
|
||||
value,
|
||||
);
|
||||
}
|
||||
if let Some(incoming_tls) =
|
||||
crate::ai_serving::tls_fingerprint_from_headers(parts.original_headers)
|
||||
{
|
||||
merge_incoming_tls_fingerprint(&mut extra_fields, incoming_tls);
|
||||
}
|
||||
insert_pool_key_lease_report_context_fields(&mut extra_fields, parts.pool_key_lease);
|
||||
insert_scheduler_affinity_policy_report_context_field(&mut extra_fields, parts.routing_policy);
|
||||
if let Some(override_policy) = parts
|
||||
.routing_policy
|
||||
.and_then(|policy| policy.pool_policy_overrides.get(parts.provider_id))
|
||||
.filter(|override_policy| !override_policy.scheduling_presets.is_empty())
|
||||
{
|
||||
if let Ok(value) = serde_json::to_value(override_policy) {
|
||||
extra_fields.insert(ROUTING_POOL_POLICY_OVERRIDE_REPORT_FIELD.to_string(), value);
|
||||
}
|
||||
}
|
||||
if let Some(epoch) = parts.scheduler_affinity_epoch {
|
||||
extra_fields.insert(
|
||||
SCHEDULER_AFFINITY_EPOCH_REPORT_FIELD.to_string(),
|
||||
Value::Number(epoch.into()),
|
||||
);
|
||||
}
|
||||
insert_request_path_fields(
|
||||
&mut extra_fields,
|
||||
parts.request_path,
|
||||
parts.request_query_string,
|
||||
);
|
||||
let client_requested_stream = parts.client_requested_stream
|
||||
|| parts
|
||||
.request_path
|
||||
.is_some_and(request_path_implies_stream_request);
|
||||
|
||||
build_ai_execution_report_context(AiExecutionReportContextParts {
|
||||
auth_context: parts.auth_context,
|
||||
request_id: parts.request_id,
|
||||
candidate_id: parts.candidate_id,
|
||||
candidate_index: parts.attempt_identity.candidate_index,
|
||||
retry_index: parts.attempt_identity.retry_index,
|
||||
pool_key_index: parts.attempt_identity.pool_key_index,
|
||||
model: parts.model,
|
||||
provider_name: parts.provider_name,
|
||||
provider_id: parts.provider_id,
|
||||
endpoint_id: parts.endpoint_id,
|
||||
key_id: parts.key_id,
|
||||
key_name: parts.key_name,
|
||||
model_id: parts.model_id,
|
||||
global_model_id: parts.global_model_id,
|
||||
global_model_name: parts.global_model_name,
|
||||
provider_api_format: parts.provider_api_format,
|
||||
client_api_format: parts.client_api_format,
|
||||
mapped_model: parts.mapped_model,
|
||||
candidate_group_id: parts.candidate_group_id,
|
||||
ranking: parts.ranking,
|
||||
upstream_url: parts.upstream_url,
|
||||
header_rules: parts.header_rules,
|
||||
body_rules: parts.body_rules,
|
||||
provider_request_method: parts.provider_request_method,
|
||||
provider_request_headers: parts.provider_request_headers,
|
||||
original_headers: &original_headers,
|
||||
original_request_body,
|
||||
request_origin: AiRequestOrigin {
|
||||
client_ip,
|
||||
user_agent,
|
||||
},
|
||||
client_requested_stream,
|
||||
upstream_is_stream: parts.upstream_is_stream,
|
||||
has_envelope: parts.has_envelope,
|
||||
needs_conversion: parts.needs_conversion,
|
||||
extra_fields,
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_request_path_fields(
|
||||
extra_fields: &mut Map<String, Value>,
|
||||
request_path: Option<&str>,
|
||||
request_query_string: Option<&str>,
|
||||
) {
|
||||
let Some(path) = request_path.and_then(sanitize_request_path) else {
|
||||
return;
|
||||
};
|
||||
let query = request_query_string.and_then(sanitize_request_query_string);
|
||||
let path_and_query = sanitize_request_path_and_query(path.as_str(), query.as_deref())
|
||||
.unwrap_or_else(|| path.clone());
|
||||
extra_fields
|
||||
.entry("request_path".to_string())
|
||||
.or_insert_with(|| Value::String(path.clone()));
|
||||
if let Some(query) = query.clone() {
|
||||
extra_fields
|
||||
.entry("request_query_string".to_string())
|
||||
.or_insert_with(|| Value::String(query.to_string()));
|
||||
}
|
||||
extra_fields
|
||||
.entry("request_path_and_query".to_string())
|
||||
.or_insert_with(|| Value::String(path_and_query));
|
||||
}
|
||||
|
||||
pub(crate) fn provider_stream_event_api_format_for_provider_type(
|
||||
provider_type: &str,
|
||||
) -> Option<&'static str> {
|
||||
ai_provider_stream_event_api_format_for_provider_type(provider_type)
|
||||
}
|
||||
|
||||
pub(crate) fn insert_provider_stream_event_api_format(
|
||||
extra_fields: &mut Map<String, Value>,
|
||||
provider_type: &str,
|
||||
) {
|
||||
insert_ai_provider_stream_event_api_format(extra_fields, provider_type);
|
||||
}
|
||||
|
||||
pub(crate) fn insert_native_client_envelope_name(
|
||||
extra_fields: &mut Map<String, Value>,
|
||||
envelope_name: &str,
|
||||
request_path: &str,
|
||||
) {
|
||||
if envelope_name.eq_ignore_ascii_case("antigravity:v1internal")
|
||||
&& request_path == "/v1internal:streamGenerateContent"
|
||||
{
|
||||
extra_fields.insert(
|
||||
"client_envelope_name".to_string(),
|
||||
Value::String(envelope_name.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_incoming_tls_fingerprint(extra_fields: &mut Map<String, Value>, incoming_tls: Value) {
|
||||
let entry = extra_fields
|
||||
.entry("tls_fingerprint".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if let Value::Object(object) = entry {
|
||||
object.insert("incoming".to_string(), incoming_tls);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_scheduler_core::ClientSessionAffinity;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::{
|
||||
build_local_execution_report_context, provider_stream_event_api_format_for_provider_type,
|
||||
LocalExecutionReportContextParts,
|
||||
};
|
||||
use crate::ai_serving::ExecutionRuntimeAuthContext;
|
||||
use crate::ai_serving::RequestOrigin;
|
||||
use crate::orchestration::ExecutionAttemptIdentity;
|
||||
|
||||
#[test]
|
||||
fn codex_provider_uses_openai_responses_stream_event_format() {
|
||||
assert_eq!(
|
||||
provider_stream_event_api_format_for_provider_type("codex"),
|
||||
Some("openai:responses")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_stream_event_api_format_for_provider_type("CODEX"),
|
||||
Some("openai:responses")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_providers_do_not_override_stream_event_format() {
|
||||
assert_eq!(
|
||||
provider_stream_event_api_format_for_provider_type("openai"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
provider_stream_event_api_format_for_provider_type("anthropic"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_execution_report_context_records_request_origin_and_session_affinity() {
|
||||
let auth_context = ExecutionRuntimeAuthContext {
|
||||
user_id: "user-1".to_string(),
|
||||
api_key_id: "api-key-1".to_string(),
|
||||
username: None,
|
||||
api_key_name: None,
|
||||
balance_remaining: None,
|
||||
access_allowed: true,
|
||||
api_key_is_standalone: false,
|
||||
};
|
||||
let original_headers = http::HeaderMap::new();
|
||||
let provider_request_headers = BTreeMap::new();
|
||||
let client_session_affinity = ClientSessionAffinity::new(
|
||||
Some("codex".to_string()),
|
||||
Some("account=account-1;session=session-1".to_string()),
|
||||
);
|
||||
|
||||
let report_context =
|
||||
build_local_execution_report_context(LocalExecutionReportContextParts {
|
||||
auth_context: &auth_context,
|
||||
request_id: "trace-1",
|
||||
candidate_id: "candidate-1",
|
||||
attempt_identity: ExecutionAttemptIdentity::new(0, 0),
|
||||
model: "gpt-5",
|
||||
provider_name: "OpenAI",
|
||||
provider_id: "provider-1",
|
||||
endpoint_id: "endpoint-1",
|
||||
key_id: "key-1",
|
||||
key_name: None,
|
||||
model_id: None,
|
||||
global_model_id: None,
|
||||
global_model_name: None,
|
||||
provider_api_format: "openai:chat",
|
||||
client_api_format: "openai:chat",
|
||||
mapped_model: None,
|
||||
candidate_group_id: None,
|
||||
pool_key_lease: None,
|
||||
ranking: None,
|
||||
upstream_url: None,
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
provider_request_method: None,
|
||||
provider_request_headers: Some(&provider_request_headers),
|
||||
original_headers: &original_headers,
|
||||
request_path: Some("/v1/chat/completions"),
|
||||
request_query_string: Some("debug=true&limit=10"),
|
||||
request_origin: Some(RequestOrigin {
|
||||
client_ip: Some("203.0.113.8".to_string()),
|
||||
user_agent: Some("Claude-Code/1.0".to_string()),
|
||||
}),
|
||||
original_request_body_json: Some(&json!({"model": "gpt-5"})),
|
||||
original_request_body_base64: None,
|
||||
client_session_affinity: Some(&client_session_affinity),
|
||||
routing_policy: None,
|
||||
scheduler_affinity_epoch: None,
|
||||
client_requested_stream: false,
|
||||
upstream_is_stream: false,
|
||||
has_envelope: false,
|
||||
needs_conversion: false,
|
||||
extra_fields: Map::new(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
report_context["client_ip"],
|
||||
Value::String("203.0.113.8".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
report_context["user_agent"],
|
||||
Value::String("Claude-Code/1.0".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
report_context["client_session_affinity"],
|
||||
json!({
|
||||
"client_family": "codex",
|
||||
"session_key": "account=account-1;session=session-1"
|
||||
})
|
||||
);
|
||||
assert_eq!(report_context["request_path"], "/v1/chat/completions");
|
||||
assert_eq!(report_context["request_query_string"], "limit=10");
|
||||
assert_eq!(
|
||||
report_context["request_path_and_query"],
|
||||
"/v1/chat/completions?limit=10"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_execution_report_context_treats_stream_generate_content_path_as_client_stream() {
|
||||
let auth_context = ExecutionRuntimeAuthContext {
|
||||
user_id: "user-1".to_string(),
|
||||
api_key_id: "api-key-1".to_string(),
|
||||
username: None,
|
||||
api_key_name: None,
|
||||
balance_remaining: None,
|
||||
access_allowed: true,
|
||||
api_key_is_standalone: false,
|
||||
};
|
||||
let original_headers = http::HeaderMap::new();
|
||||
let provider_request_headers = BTreeMap::new();
|
||||
|
||||
let report_context =
|
||||
build_local_execution_report_context(LocalExecutionReportContextParts {
|
||||
auth_context: &auth_context,
|
||||
request_id: "trace-1",
|
||||
candidate_id: "candidate-1",
|
||||
attempt_identity: ExecutionAttemptIdentity::new(0, 0),
|
||||
model: "gemini-3.1-flash-image-preview",
|
||||
provider_name: "Gemini",
|
||||
provider_id: "provider-1",
|
||||
endpoint_id: "endpoint-1",
|
||||
key_id: "key-1",
|
||||
key_name: None,
|
||||
model_id: None,
|
||||
global_model_id: None,
|
||||
global_model_name: None,
|
||||
provider_api_format: "gemini:generate_content",
|
||||
client_api_format: "gemini:generate_content",
|
||||
mapped_model: None,
|
||||
candidate_group_id: None,
|
||||
pool_key_lease: None,
|
||||
ranking: None,
|
||||
upstream_url: None,
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
provider_request_method: None,
|
||||
provider_request_headers: Some(&provider_request_headers),
|
||||
original_headers: &original_headers,
|
||||
request_path: Some(
|
||||
"/v1beta/models/gemini-3.1-flash-image-preview:streamGenerateContent",
|
||||
),
|
||||
request_query_string: Some("key=secret&alt=sse"),
|
||||
request_origin: None,
|
||||
original_request_body_json: Some(&json!({
|
||||
"contents": [{"role": "user", "parts": [{"text": "hi"}]}]
|
||||
})),
|
||||
original_request_body_base64: None,
|
||||
client_session_affinity: None,
|
||||
routing_policy: None,
|
||||
scheduler_affinity_epoch: None,
|
||||
client_requested_stream: false,
|
||||
upstream_is_stream: true,
|
||||
has_envelope: false,
|
||||
needs_conversion: false,
|
||||
extra_fields: Map::new(),
|
||||
});
|
||||
|
||||
assert_eq!(report_context["client_requested_stream"], true);
|
||||
assert_eq!(report_context["request_query_string"], "alt=sse");
|
||||
assert_eq!(
|
||||
report_context["request_path_and_query"],
|
||||
"/v1beta/models/gemini-3.1-flash-image-preview:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_execution_report_context_records_forwarded_tls_fingerprint() {
|
||||
let auth_context = ExecutionRuntimeAuthContext {
|
||||
user_id: "user-1".to_string(),
|
||||
api_key_id: "api-key-1".to_string(),
|
||||
username: None,
|
||||
api_key_name: None,
|
||||
balance_remaining: None,
|
||||
access_allowed: true,
|
||||
api_key_is_standalone: false,
|
||||
};
|
||||
let mut original_headers = http::HeaderMap::new();
|
||||
original_headers.insert("x-aether-tls-ja3", "ja3-value".parse().unwrap());
|
||||
original_headers.insert("x-aether-tls-ja4", "ja4-value".parse().unwrap());
|
||||
original_headers.insert("x-aether-tls-protocol", "TLSv1.3".parse().unwrap());
|
||||
let provider_request_headers = BTreeMap::new();
|
||||
|
||||
let report_context =
|
||||
build_local_execution_report_context(LocalExecutionReportContextParts {
|
||||
auth_context: &auth_context,
|
||||
request_id: "trace-1",
|
||||
candidate_id: "candidate-1",
|
||||
attempt_identity: ExecutionAttemptIdentity::new(0, 0),
|
||||
model: "gpt-5",
|
||||
provider_name: "OpenAI",
|
||||
provider_id: "provider-1",
|
||||
endpoint_id: "endpoint-1",
|
||||
key_id: "key-1",
|
||||
key_name: None,
|
||||
model_id: None,
|
||||
global_model_id: None,
|
||||
global_model_name: None,
|
||||
provider_api_format: "openai:chat",
|
||||
client_api_format: "openai:chat",
|
||||
mapped_model: None,
|
||||
candidate_group_id: None,
|
||||
pool_key_lease: None,
|
||||
ranking: None,
|
||||
upstream_url: None,
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
provider_request_method: None,
|
||||
provider_request_headers: Some(&provider_request_headers),
|
||||
original_headers: &original_headers,
|
||||
request_path: None,
|
||||
request_query_string: None,
|
||||
request_origin: None,
|
||||
original_request_body_json: Some(&json!({"model": "gpt-5"})),
|
||||
original_request_body_base64: None,
|
||||
client_session_affinity: None,
|
||||
routing_policy: None,
|
||||
scheduler_affinity_epoch: None,
|
||||
client_requested_stream: false,
|
||||
upstream_is_stream: false,
|
||||
has_envelope: false,
|
||||
needs_conversion: false,
|
||||
extra_fields: Map::new(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
report_context["tls_fingerprint"]["incoming"],
|
||||
json!({
|
||||
"source": "forwarded_header",
|
||||
"ja3": "ja3-value",
|
||||
"ja4": "ja4-value",
|
||||
"protocol": "TLSv1.3"
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
use aether_ai_serving::AiRequestGzipPolicy;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::{normalize_api_format_alias, parse_codex_auth_identity};
|
||||
|
||||
use super::state::GatewayProviderTransportSnapshot;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct TransportRequestEncodingPolicy {
|
||||
pub content_encoding: Option<String>,
|
||||
pub request_gzip: Option<AiRequestGzipPolicy>,
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_transport_request_encoding_policy(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> TransportRequestEncodingPolicy {
|
||||
if transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("codex")
|
||||
&& normalize_api_format_alias(transport.endpoint.api_format.as_str())
|
||||
== "openai:responses:compact"
|
||||
{
|
||||
return TransportRequestEncodingPolicy::default();
|
||||
}
|
||||
|
||||
let request_gzip = transport_request_gzip_policy_from_config(
|
||||
transport.endpoint.config.as_ref(),
|
||||
)
|
||||
.or_else(|| transport_request_gzip_policy_from_config(transport.provider.config.as_ref()));
|
||||
if request_gzip.is_some() {
|
||||
return TransportRequestEncodingPolicy {
|
||||
content_encoding: None,
|
||||
request_gzip,
|
||||
};
|
||||
}
|
||||
|
||||
TransportRequestEncodingPolicy {
|
||||
content_encoding: default_transport_request_content_encoding(transport),
|
||||
request_gzip: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_transport_request_content_encoding(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<String> {
|
||||
if !transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("codex")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if !is_codex_request_compression_api_format(transport.endpoint.api_format.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let auth_type =
|
||||
crate::ai_serving::transport::auth::resolve_local_auth_type_for_transport_format(transport);
|
||||
let uses_codex_backend = auth_type == "oauth"
|
||||
|| (auth_type == "bearer"
|
||||
&& parse_codex_auth_identity(transport.key.decrypted_auth_config.as_deref())
|
||||
.uses_codex_backend);
|
||||
if !uses_codex_backend {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some("zstd".to_string())
|
||||
}
|
||||
|
||||
fn is_codex_request_compression_api_format(api_format: &str) -> bool {
|
||||
normalize_api_format_alias(api_format) == "openai:responses"
|
||||
}
|
||||
|
||||
fn transport_request_gzip_policy_from_config(
|
||||
config: Option<&Value>,
|
||||
) -> Option<AiRequestGzipPolicy> {
|
||||
let object = config?.as_object()?;
|
||||
|
||||
for key in ["request_gzip", "request_body_gzip"] {
|
||||
if let Some(policy) = object
|
||||
.get(key)
|
||||
.and_then(transport_request_gzip_policy_from_value)
|
||||
{
|
||||
return Some(policy);
|
||||
}
|
||||
}
|
||||
|
||||
let enabled = first_config_bool(
|
||||
object,
|
||||
&["request_gzip_enabled", "request_body_gzip_enabled"],
|
||||
);
|
||||
let min_bytes = first_config_usize(
|
||||
object,
|
||||
&["request_gzip_min_bytes", "request_body_gzip_min_bytes"],
|
||||
);
|
||||
|
||||
match (enabled, min_bytes) {
|
||||
(Some(false), _) => Some(AiRequestGzipPolicy {
|
||||
enabled: Some(false),
|
||||
min_bytes: None,
|
||||
}),
|
||||
(Some(true), min_bytes) => Some(AiRequestGzipPolicy {
|
||||
enabled: Some(true),
|
||||
min_bytes,
|
||||
}),
|
||||
(None, Some(min_bytes)) => Some(AiRequestGzipPolicy {
|
||||
enabled: Some(true),
|
||||
min_bytes: Some(min_bytes),
|
||||
}),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn transport_request_gzip_policy_from_value(value: &Value) -> Option<AiRequestGzipPolicy> {
|
||||
if let Some(enabled) = value.as_bool() {
|
||||
return Some(AiRequestGzipPolicy {
|
||||
enabled: Some(enabled),
|
||||
min_bytes: None,
|
||||
});
|
||||
}
|
||||
|
||||
let object = value.as_object()?;
|
||||
let enabled = first_config_bool(object, &["enabled"]);
|
||||
let min_bytes = first_config_usize(object, &["min_bytes"]);
|
||||
|
||||
match (enabled, min_bytes) {
|
||||
(Some(false), _) => Some(AiRequestGzipPolicy {
|
||||
enabled: Some(false),
|
||||
min_bytes: None,
|
||||
}),
|
||||
(Some(true), min_bytes) => Some(AiRequestGzipPolicy {
|
||||
enabled: Some(true),
|
||||
min_bytes,
|
||||
}),
|
||||
(None, Some(min_bytes)) => Some(AiRequestGzipPolicy {
|
||||
enabled: Some(true),
|
||||
min_bytes: Some(min_bytes),
|
||||
}),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn first_config_bool(object: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<bool> {
|
||||
keys.iter()
|
||||
.find_map(|key| object.get(*key).and_then(config_bool))
|
||||
}
|
||||
|
||||
fn config_bool(value: &Value) -> Option<bool> {
|
||||
value.as_bool().or_else(|| {
|
||||
value.as_str().and_then(|text| {
|
||||
let normalized = text.trim();
|
||||
if normalized.eq_ignore_ascii_case("true") {
|
||||
Some(true)
|
||||
} else if normalized.eq_ignore_ascii_case("false") {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn first_config_usize(object: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<usize> {
|
||||
keys.iter()
|
||||
.find_map(|key| object.get(*key).and_then(config_usize))
|
||||
}
|
||||
|
||||
fn config_usize(value: &Value) -> Option<usize> {
|
||||
value
|
||||
.as_u64()
|
||||
.and_then(|number| usize::try_from(number).ok())
|
||||
.or_else(|| {
|
||||
value
|
||||
.as_str()
|
||||
.and_then(|text| text.trim().parse::<usize>().ok())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aether_provider_transport::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn sample_transport(
|
||||
provider_type: &str,
|
||||
endpoint_api_format: &str,
|
||||
provider_config: Option<Value>,
|
||||
endpoint_config: Option<Value>,
|
||||
) -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-1".to_string(),
|
||||
name: "Provider".to_string(),
|
||||
provider_type: provider_type.to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: true,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: provider_config,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: endpoint_api_format.to_string(),
|
||||
api_family: None,
|
||||
endpoint_kind: None,
|
||||
is_active: true,
|
||||
base_url: "https://api.example.test".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: None,
|
||||
config: endpoint_config,
|
||||
format_acceptance_config: None,
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
name: "key".to_string(),
|
||||
auth_type: "api_key".to_string(),
|
||||
is_active: true,
|
||||
api_formats: None,
|
||||
auth_type_by_format: None,
|
||||
allow_auth_channel_mismatch_formats: None,
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
upstream_metadata: None,
|
||||
decrypted_api_key: "secret".to_string(),
|
||||
decrypted_auth_config: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved_gzip_policy(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<AiRequestGzipPolicy> {
|
||||
resolve_transport_request_encoding_policy(transport).request_gzip
|
||||
}
|
||||
|
||||
fn resolved_content_encoding(transport: &GatewayProviderTransportSnapshot) -> Option<String> {
|
||||
resolve_transport_request_encoding_policy(transport).content_encoding
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_request_gzip_policy_overrides_provider_policy() {
|
||||
let transport = sample_transport(
|
||||
"openai",
|
||||
"openai:responses",
|
||||
Some(json!({"request_gzip": false})),
|
||||
Some(json!({"request_gzip": {"enabled": true, "min_bytes": 1024}})),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolved_gzip_policy(&transport),
|
||||
Some(AiRequestGzipPolicy {
|
||||
enabled: Some(true),
|
||||
min_bytes: Some(1024),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_request_gzip_false_disables_provider_and_codex_defaults() {
|
||||
let transport = sample_transport(
|
||||
"codex",
|
||||
"openai:responses",
|
||||
Some(json!({"request_gzip": {"enabled": true, "min_bytes": 1024}})),
|
||||
Some(json!({"request_gzip": false})),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolved_gzip_policy(&transport),
|
||||
Some(AiRequestGzipPolicy {
|
||||
enabled: Some(false),
|
||||
min_bytes: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_gzip_policy_supports_top_level_aliases() {
|
||||
let transport = sample_transport(
|
||||
"openai",
|
||||
"openai:responses",
|
||||
None,
|
||||
Some(json!({
|
||||
"request_body_gzip_enabled": true,
|
||||
"request_body_gzip_min_bytes": "4096"
|
||||
})),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolved_gzip_policy(&transport),
|
||||
Some(AiRequestGzipPolicy {
|
||||
enabled: Some(true),
|
||||
min_bytes: Some(4096),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_gzip_policy_treats_min_bytes_only_as_enabled() {
|
||||
let transport = sample_transport(
|
||||
"openai",
|
||||
"openai:responses",
|
||||
None,
|
||||
Some(json!({"request_gzip_min_bytes": 1})),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolved_gzip_policy(&transport),
|
||||
Some(AiRequestGzipPolicy {
|
||||
enabled: Some(true),
|
||||
min_bytes: Some(1),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_responses_endpoint_uses_zstd_without_a_size_threshold() {
|
||||
let mut transport = sample_transport("codex", "openai:responses", None, None);
|
||||
transport.key.auth_type = "oauth".to_string();
|
||||
|
||||
assert_eq!(
|
||||
resolved_content_encoding(&transport).as_deref(),
|
||||
Some("zstd")
|
||||
);
|
||||
assert_eq!(resolved_gzip_policy(&transport), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_responses_api_key_auth_does_not_enable_default_compression() {
|
||||
let transport = sample_transport("codex", "openai:responses", None, None);
|
||||
|
||||
assert_eq!(resolved_content_encoding(&transport), None);
|
||||
assert_eq!(resolved_gzip_policy(&transport), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_responses_bearer_auth_uses_identity_metadata_for_backend_compression() {
|
||||
let mut transport = sample_transport("codex", "openai:responses", None, None);
|
||||
transport.key.auth_type = "bearer".to_string();
|
||||
transport.key.decrypted_auth_config =
|
||||
Some(r#"{"provider_type":"codex","account_id":"account-1"}"#.to_string());
|
||||
|
||||
assert_eq!(
|
||||
resolved_content_encoding(&transport).as_deref(),
|
||||
Some("zstd")
|
||||
);
|
||||
assert_eq!(resolved_gzip_policy(&transport), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_image_endpoint_does_not_get_responses_request_gzip_policy() {
|
||||
let transport = sample_transport("codex", "openai:image", None, None);
|
||||
|
||||
assert_eq!(resolved_content_encoding(&transport), None);
|
||||
assert_eq!(resolved_gzip_policy(&transport), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_compact_endpoint_does_not_get_default_request_gzip_policy() {
|
||||
let transport = sample_transport("codex", "openai:responses:compact", None, None);
|
||||
|
||||
assert_eq!(resolved_content_encoding(&transport), None);
|
||||
assert_eq!(resolved_gzip_policy(&transport), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_compact_endpoint_rejects_an_explicit_request_gzip_policy() {
|
||||
let transport = sample_transport(
|
||||
"codex",
|
||||
"openai:responses:compact",
|
||||
None,
|
||||
Some(json!({"request_gzip": {"enabled": true, "min_bytes": 2048}})),
|
||||
);
|
||||
|
||||
assert_eq!(resolved_gzip_policy(&transport), None);
|
||||
assert_eq!(resolved_content_encoding(&transport), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_codex_endpoint_does_not_get_default_request_gzip_policy() {
|
||||
let transport = sample_transport("openai", "openai:responses", None, None);
|
||||
|
||||
assert_eq!(resolved_content_encoding(&transport), None);
|
||||
assert_eq!(resolved_gzip_policy(&transport), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::ai_serving::{
|
||||
is_matching_stream_http_request as is_matching_stream_http_request_impl,
|
||||
resolve_execution_runtime_stream_plan_kind_with_client_surface as resolve_execution_runtime_stream_plan_kind_impl,
|
||||
resolve_execution_runtime_sync_plan_kind_with_client_surface as resolve_execution_runtime_sync_plan_kind_impl,
|
||||
supports_stream_execution_decision_kind as supports_stream_execution_decision_kind_impl,
|
||||
supports_sync_execution_decision_kind as supports_sync_execution_decision_kind_impl,
|
||||
};
|
||||
|
||||
pub(crate) fn resolve_execution_runtime_stream_plan_kind(
|
||||
parts: &http::request::Parts,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Option<&'static str> {
|
||||
let plan_kind = resolve_execution_runtime_stream_plan_kind_impl(
|
||||
decision.route_class.as_deref(),
|
||||
decision.route_family.as_deref(),
|
||||
decision.route_kind.as_deref(),
|
||||
decision.client_surface,
|
||||
decision.request_auth_channel.as_deref(),
|
||||
&parts.method,
|
||||
parts.uri.path(),
|
||||
)?;
|
||||
crate::ai_serving::plan_kind_matches_api_operation(plan_kind, true, decision.api_operation)
|
||||
.then_some(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_execution_runtime_sync_plan_kind(
|
||||
parts: &http::request::Parts,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Option<&'static str> {
|
||||
let plan_kind = resolve_execution_runtime_sync_plan_kind_impl(
|
||||
decision.route_class.as_deref(),
|
||||
decision.route_family.as_deref(),
|
||||
decision.route_kind.as_deref(),
|
||||
decision.client_surface,
|
||||
decision.request_auth_channel.as_deref(),
|
||||
&parts.method,
|
||||
parts.uri.path(),
|
||||
)?;
|
||||
crate::ai_serving::plan_kind_matches_api_operation(plan_kind, false, decision.api_operation)
|
||||
.then_some(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn is_matching_stream_request(
|
||||
plan_kind: &str,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
) -> bool {
|
||||
is_matching_stream_http_request_impl(plan_kind, parts, body_json, body_base64)
|
||||
}
|
||||
|
||||
pub(crate) fn supports_sync_execution_decision_kind(plan_kind: &str) -> bool {
|
||||
supports_sync_execution_decision_kind_impl(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn supports_stream_execution_decision_kind(plan_kind: &str) -> bool {
|
||||
supports_stream_execution_decision_kind_impl(plan_kind)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::http::{Method, Request};
|
||||
use base64::Engine as _;
|
||||
|
||||
use super::{
|
||||
is_matching_stream_request, resolve_execution_runtime_stream_plan_kind,
|
||||
resolve_execution_runtime_sync_plan_kind, supports_stream_execution_decision_kind,
|
||||
supports_sync_execution_decision_kind,
|
||||
};
|
||||
use crate::ai_serving::{ApiOperation, ClientSurface, GatewayControlDecision};
|
||||
|
||||
fn sample_decision(route_family: &str, route_kind: &str) -> GatewayControlDecision {
|
||||
GatewayControlDecision {
|
||||
public_path: "/".to_string(),
|
||||
public_query_string: None,
|
||||
route_class: Some("ai_public".to_string()),
|
||||
route_family: Some(route_family.to_string()),
|
||||
route_kind: Some(route_kind.to_string()),
|
||||
client_surface: None,
|
||||
api_operation: None,
|
||||
gateway_credential_carrier: None,
|
||||
request_auth_channel: None,
|
||||
auth_context: None,
|
||||
admin_principal: None,
|
||||
auth_endpoint_signature: None,
|
||||
execution_runtime_candidate: true,
|
||||
local_auth_rejection: None,
|
||||
model_directive_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_decision_with_auth_channel(
|
||||
route_family: &str,
|
||||
route_kind: &str,
|
||||
request_auth_channel: &str,
|
||||
) -> GatewayControlDecision {
|
||||
let mut decision = sample_decision(route_family, route_kind);
|
||||
decision.request_auth_channel = Some(request_auth_channel.to_string());
|
||||
decision
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_chat_plan_kinds_via_format_crate() {
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/v1/chat/completions")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (parts, _) = request.into_parts();
|
||||
let decision = sample_decision("openai", "chat");
|
||||
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(&parts, &decision),
|
||||
Some("openai_chat_sync")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_stream_plan_kind(&parts, &decision),
|
||||
Some("openai_chat_stream")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_endpoint_route_kinds_by_request_auth_channel_via_format_crate() {
|
||||
let claude_request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/v1/messages")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (claude_parts, _) = claude_request.into_parts();
|
||||
|
||||
let claude_api_key = sample_decision_with_auth_channel("claude", "messages", "api_key");
|
||||
let mut claude_bearer =
|
||||
sample_decision_with_auth_channel("claude", "messages", "bearer_like");
|
||||
claude_bearer.client_surface = Some(ClientSurface::ClaudeCode);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(&claude_parts, &claude_api_key),
|
||||
Some("claude_chat_sync")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_stream_plan_kind(&claude_parts, &claude_bearer),
|
||||
Some("claude_cli_stream")
|
||||
);
|
||||
|
||||
let claude_sdk_bearer =
|
||||
sample_decision_with_auth_channel("claude", "messages", "bearer_like");
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(&claude_parts, &claude_sdk_bearer),
|
||||
Some("claude_chat_sync")
|
||||
);
|
||||
|
||||
let gemini_request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/v1beta/models/gemini-2.5-pro:generateContent")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (gemini_parts, _) = gemini_request.into_parts();
|
||||
|
||||
let gemini_api_key =
|
||||
sample_decision_with_auth_channel("gemini", "generate_content", "api_key");
|
||||
let gemini_bearer =
|
||||
sample_decision_with_auth_channel("gemini", "generate_content", "bearer_like");
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(&gemini_parts, &gemini_api_key),
|
||||
Some("gemini_chat_sync")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(&gemini_parts, &gemini_bearer),
|
||||
Some("gemini_cli_sync")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_claude_count_tokens_as_native_sync_operation() {
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/v1/messages/count_tokens")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (parts, _) = request.into_parts();
|
||||
let mut decision = sample_decision("claude", "count_tokens");
|
||||
decision.api_operation = Some(ApiOperation::ClaudeCountTokens);
|
||||
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(&parts, &decision),
|
||||
Some("claude_count_tokens_sync")
|
||||
);
|
||||
assert!(supports_sync_execution_decision_kind(
|
||||
"claude_count_tokens_sync"
|
||||
));
|
||||
|
||||
decision.api_operation = Some(ApiOperation::ClaudeMessagesCreate);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(&parts, &decision),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_stream_plan_kind(&parts, &decision),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_matching_uses_surface_route_logic() {
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/v1/chat/completions")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (parts, _) = request.into_parts();
|
||||
|
||||
assert!(!is_matching_stream_request(
|
||||
"openai_chat_stream",
|
||||
&parts,
|
||||
&serde_json::json!({"stream": false}),
|
||||
None,
|
||||
));
|
||||
assert!(is_matching_stream_request(
|
||||
"openai_chat_stream",
|
||||
&parts,
|
||||
&serde_json::json!({"stream": true}),
|
||||
None,
|
||||
));
|
||||
assert!(supports_sync_execution_decision_kind("openai_chat_sync"));
|
||||
assert!(supports_stream_execution_decision_kind(
|
||||
"openai_chat_stream"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_stream_matching_parses_multipart_stream_flag() {
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/v1/images/edits")
|
||||
.header(
|
||||
http::header::CONTENT_TYPE,
|
||||
"multipart/form-data; boundary=image-stream-boundary",
|
||||
)
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (parts, _) = request.into_parts();
|
||||
let body = concat!(
|
||||
"--image-stream-boundary\r\n",
|
||||
"Content-Disposition: form-data; name=\"stream\"\r\n\r\n",
|
||||
"true\r\n",
|
||||
"--image-stream-boundary--\r\n"
|
||||
);
|
||||
let body_base64 = base64::engine::general_purpose::STANDARD.encode(body.as_bytes());
|
||||
|
||||
assert!(is_matching_stream_request(
|
||||
"openai_image_stream",
|
||||
&parts,
|
||||
&serde_json::json!({}),
|
||||
Some(body_base64.as_str()),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
use aether_ai_serving::{
|
||||
apply_ai_runtime_candidate_evaluation_progress,
|
||||
apply_ai_runtime_candidate_evaluation_progress_preserving_candidate_signal,
|
||||
apply_ai_runtime_candidate_evaluation_progress_to_diagnostic,
|
||||
apply_ai_runtime_candidate_terminal_plan_reason_to_diagnostic,
|
||||
apply_ai_runtime_candidate_terminal_reason, build_ai_runtime_candidate_evaluation_diagnostic,
|
||||
build_ai_runtime_execution_exhausted_diagnostic, record_ai_runtime_candidate_skip_reason,
|
||||
record_ai_runtime_candidate_skip_reason_on_diagnostic,
|
||||
set_ai_runtime_candidate_evaluation_diagnostic, set_ai_runtime_execution_exhausted_diagnostic,
|
||||
set_ai_runtime_miss_diagnostic_reason, AiRuntimeMissDiagnosticFields,
|
||||
AiRuntimeMissDiagnosticPort,
|
||||
};
|
||||
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::{AppState, LocalExecutionRuntimeMissDiagnostic};
|
||||
|
||||
struct GatewayRuntimeMissDiagnosticPort<'a> {
|
||||
state: Option<&'a AppState>,
|
||||
}
|
||||
|
||||
impl AiRuntimeMissDiagnosticFields for LocalExecutionRuntimeMissDiagnostic {
|
||||
fn set_reason(&mut self, reason: String) {
|
||||
self.reason = reason;
|
||||
}
|
||||
|
||||
fn set_candidate_count(&mut self, candidate_count: usize) {
|
||||
self.candidate_count = Some(candidate_count);
|
||||
}
|
||||
|
||||
fn candidate_count(&self) -> Option<usize> {
|
||||
self.candidate_count
|
||||
}
|
||||
|
||||
fn skipped_candidate_count(&self) -> Option<usize> {
|
||||
self.skipped_candidate_count
|
||||
}
|
||||
|
||||
fn skip_reason_count(&self, skip_reason: &str) -> usize {
|
||||
self.skip_reasons.get(skip_reason).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn skip_reason_len(&self) -> usize {
|
||||
self.skip_reasons.len()
|
||||
}
|
||||
|
||||
fn record_skip_reason(&mut self, skip_reason: &'static str) {
|
||||
*self
|
||||
.skip_reasons
|
||||
.entry(skip_reason.to_string())
|
||||
.or_insert(0) += 1;
|
||||
*self.skipped_candidate_count.get_or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
impl AiRuntimeMissDiagnosticPort for GatewayRuntimeMissDiagnosticPort<'_> {
|
||||
type Decision = GatewayControlDecision;
|
||||
type Diagnostic = LocalExecutionRuntimeMissDiagnostic;
|
||||
|
||||
fn build_runtime_miss_diagnostic(
|
||||
&self,
|
||||
decision: &Self::Decision,
|
||||
plan_kind: &str,
|
||||
requested_model: Option<&str>,
|
||||
reason: &str,
|
||||
) -> Self::Diagnostic {
|
||||
LocalExecutionRuntimeMissDiagnostic {
|
||||
reason: reason.to_string(),
|
||||
route_family: decision.route_family.clone(),
|
||||
route_kind: decision.route_kind.clone(),
|
||||
public_path: Some(decision.public_path.clone()),
|
||||
plan_kind: Some(plan_kind.to_string()),
|
||||
requested_model: requested_model.map(ToOwned::to_owned),
|
||||
candidate_count: None,
|
||||
skipped_candidate_count: None,
|
||||
skip_reasons: std::collections::BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_candidate_count(&self, diagnostic: &mut Self::Diagnostic, candidate_count: usize) {
|
||||
AiRuntimeMissDiagnosticFields::set_candidate_count(diagnostic, candidate_count);
|
||||
}
|
||||
|
||||
fn apply_candidate_evaluation_progress(
|
||||
&self,
|
||||
diagnostic: &mut Self::Diagnostic,
|
||||
candidate_count: usize,
|
||||
) {
|
||||
apply_ai_runtime_candidate_evaluation_progress_to_diagnostic(diagnostic, candidate_count);
|
||||
}
|
||||
|
||||
fn apply_candidate_terminal_plan_reason(
|
||||
&self,
|
||||
diagnostic: &mut Self::Diagnostic,
|
||||
no_plan_reason: &'static str,
|
||||
) {
|
||||
apply_ai_runtime_candidate_terminal_plan_reason_to_diagnostic(diagnostic, no_plan_reason);
|
||||
}
|
||||
|
||||
fn record_candidate_skip_reason(
|
||||
&self,
|
||||
diagnostic: &mut Self::Diagnostic,
|
||||
skip_reason: &'static str,
|
||||
) {
|
||||
record_ai_runtime_candidate_skip_reason_on_diagnostic(diagnostic, skip_reason);
|
||||
}
|
||||
|
||||
fn set_runtime_miss_diagnostic(&self, trace_id: &str, diagnostic: Self::Diagnostic) {
|
||||
self.state
|
||||
.expect("runtime miss diagnostic setter requires gateway state")
|
||||
.set_local_execution_runtime_miss_diagnostic(trace_id, diagnostic);
|
||||
}
|
||||
|
||||
fn mutate_runtime_miss_diagnostic<F>(&self, trace_id: &str, apply: F)
|
||||
where
|
||||
F: FnOnce(&mut Self::Diagnostic) + Send,
|
||||
{
|
||||
self.state
|
||||
.expect("runtime miss diagnostic mutator requires gateway state")
|
||||
.mutate_local_execution_runtime_miss_diagnostic(trace_id, apply);
|
||||
}
|
||||
|
||||
fn runtime_miss_diagnostic_has_candidate_signal(&self, trace_id: &str) -> bool {
|
||||
self.state
|
||||
.expect("runtime miss diagnostic signal check requires gateway state")
|
||||
.local_execution_runtime_miss_diagnostic_has_candidate_signal(trace_id)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_local_runtime_miss_diagnostic_reason(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
requested_model: Option<&str>,
|
||||
reason: &str,
|
||||
) {
|
||||
let port = GatewayRuntimeMissDiagnosticPort { state: Some(state) };
|
||||
set_ai_runtime_miss_diagnostic_reason(
|
||||
&port,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
requested_model,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_runtime_execution_exhausted_diagnostic(
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
requested_model: Option<&str>,
|
||||
candidate_count: usize,
|
||||
) -> LocalExecutionRuntimeMissDiagnostic {
|
||||
let port = GatewayRuntimeMissDiagnosticPort { state: None };
|
||||
build_ai_runtime_execution_exhausted_diagnostic(
|
||||
&port,
|
||||
decision,
|
||||
plan_kind,
|
||||
requested_model,
|
||||
candidate_count,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn set_local_runtime_execution_exhausted_diagnostic(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
requested_model: Option<&str>,
|
||||
candidate_count: usize,
|
||||
) {
|
||||
let port = GatewayRuntimeMissDiagnosticPort { state: Some(state) };
|
||||
set_ai_runtime_execution_exhausted_diagnostic(
|
||||
&port,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
requested_model,
|
||||
candidate_count,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_runtime_candidate_evaluation_diagnostic(
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
requested_model: Option<&str>,
|
||||
candidate_count: usize,
|
||||
) -> LocalExecutionRuntimeMissDiagnostic {
|
||||
let port = GatewayRuntimeMissDiagnosticPort { state: None };
|
||||
build_ai_runtime_candidate_evaluation_diagnostic(
|
||||
&port,
|
||||
decision,
|
||||
plan_kind,
|
||||
requested_model,
|
||||
candidate_count,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn set_local_runtime_candidate_evaluation_diagnostic(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
requested_model: Option<&str>,
|
||||
candidate_count: usize,
|
||||
) {
|
||||
let port = GatewayRuntimeMissDiagnosticPort { state: Some(state) };
|
||||
set_ai_runtime_candidate_evaluation_diagnostic(
|
||||
&port,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
requested_model,
|
||||
candidate_count,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn apply_local_runtime_candidate_evaluation_progress(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
candidate_count: usize,
|
||||
) {
|
||||
let port = GatewayRuntimeMissDiagnosticPort { state: Some(state) };
|
||||
apply_ai_runtime_candidate_evaluation_progress(&port, trace_id, candidate_count);
|
||||
}
|
||||
|
||||
pub(crate) fn apply_local_runtime_candidate_evaluation_progress_preserving_candidate_signal(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
candidate_count: usize,
|
||||
) {
|
||||
let port = GatewayRuntimeMissDiagnosticPort { state: Some(state) };
|
||||
apply_ai_runtime_candidate_evaluation_progress_preserving_candidate_signal(
|
||||
&port,
|
||||
trace_id,
|
||||
candidate_count,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn apply_local_runtime_candidate_terminal_reason(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
no_plan_reason: &'static str,
|
||||
) {
|
||||
let port = GatewayRuntimeMissDiagnosticPort { state: Some(state) };
|
||||
apply_ai_runtime_candidate_terminal_reason(&port, trace_id, no_plan_reason);
|
||||
}
|
||||
|
||||
pub(crate) fn record_local_runtime_candidate_skip_reason(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
skip_reason: &'static str,
|
||||
) {
|
||||
let port = GatewayRuntimeMissDiagnosticPort { state: Some(state) };
|
||||
record_ai_runtime_candidate_skip_reason(&port, trace_id, skip_reason);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::ai_serving::planner::plan_builders::{
|
||||
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
|
||||
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
|
||||
AiStreamAttempt, AiSyncAttempt,
|
||||
};
|
||||
use crate::ai_serving::AiExecutionDecision;
|
||||
use crate::GatewayError;
|
||||
|
||||
pub(crate) use aether_ai_serving::{
|
||||
ai_gemini_files_spec_metadata as local_gemini_files_spec_metadata,
|
||||
ai_openai_image_spec_metadata as local_openai_image_spec_metadata,
|
||||
ai_openai_responses_spec_metadata as local_openai_responses_spec_metadata,
|
||||
ai_requested_model_family_for_same_format_provider as requested_model_family_for_same_format_provider,
|
||||
ai_requested_model_family_for_standard_source as requested_model_family_for_standard_source,
|
||||
ai_requested_model_family_for_video_create as requested_model_family_for_video_create,
|
||||
ai_same_format_provider_spec_metadata as local_same_format_provider_spec_metadata,
|
||||
ai_standard_spec_metadata as local_standard_spec_metadata,
|
||||
ai_video_create_spec_metadata as local_video_create_spec_metadata,
|
||||
AiExecutionSurfaceSpecMetadata as LocalExecutionSurfaceSpecMetadata,
|
||||
AiRequestedModelFamily as RequestedModelFamily,
|
||||
};
|
||||
|
||||
pub(crate) fn build_sync_plan_from_requested_model_family(
|
||||
family: RequestedModelFamily,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
payload: AiExecutionDecision,
|
||||
) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||
match family {
|
||||
RequestedModelFamily::Standard => {
|
||||
build_standard_sync_plan_from_decision(parts, body_json, payload)
|
||||
}
|
||||
RequestedModelFamily::Gemini => {
|
||||
build_gemini_sync_plan_from_decision(parts, body_json, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_stream_plan_from_requested_model_family(
|
||||
family: RequestedModelFamily,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
payload: AiExecutionDecision,
|
||||
) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||
match family {
|
||||
RequestedModelFamily::Standard => {
|
||||
build_standard_stream_plan_from_decision(parts, body_json, payload, false)
|
||||
}
|
||||
RequestedModelFamily::Gemini => {
|
||||
build_gemini_stream_plan_from_decision(parts, body_json, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
mod decision;
|
||||
mod request;
|
||||
mod support;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_serving::planner::candidate_materialization::LocalExecutionAttemptSource;
|
||||
use crate::ai_serving::planner::plan_builders::{
|
||||
build_passthrough_stream_plan_from_decision, build_passthrough_sync_plan_from_decision,
|
||||
AiStreamAttempt, AiSyncAttempt,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_gemini_files_spec_metadata;
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::ai_serving::{
|
||||
resolve_gemini_files_stream_spec as resolve_stream_spec,
|
||||
resolve_gemini_files_sync_spec as resolve_sync_spec, LocalGeminiFilesSpec,
|
||||
};
|
||||
use crate::{AiExecutionDecision, AppState, GatewayError};
|
||||
|
||||
use self::decision::maybe_build_local_gemini_files_decision_payload_for_candidate;
|
||||
use self::support::{
|
||||
build_local_gemini_files_candidate_attempt_source,
|
||||
materialize_local_gemini_files_candidate_attempts, resolve_local_gemini_files_decision_input,
|
||||
LocalGeminiFilesCandidateAttempt, LocalGeminiFilesCandidateAttemptSource,
|
||||
LocalGeminiFilesDecisionInput,
|
||||
};
|
||||
|
||||
pub(crate) struct LocalGeminiFilesSyncAttemptSource<'a> {
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
body_json: serde_json::Value,
|
||||
body_base64: Option<&'a str>,
|
||||
body_is_empty: bool,
|
||||
trace_id: &'a str,
|
||||
input: LocalGeminiFilesDecisionInput,
|
||||
spec: LocalGeminiFilesSpec,
|
||||
candidates: LocalGeminiFilesCandidateAttemptSource<'a>,
|
||||
}
|
||||
|
||||
pub(crate) struct LocalGeminiFilesStreamAttemptSource<'a> {
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
trace_id: &'a str,
|
||||
input: LocalGeminiFilesDecisionInput,
|
||||
spec: LocalGeminiFilesSpec,
|
||||
candidates: LocalGeminiFilesCandidateAttemptSource<'a>,
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_gemini_files_sync_plan_and_reports_for_kind(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
body_is_empty: bool,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
build_local_sync_plan_and_reports(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
body_is_empty,
|
||||
trace_id,
|
||||
decision,
|
||||
spec,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_gemini_files_stream_plan_and_reports_for_kind(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Vec<AiStreamAttempt>, GatewayError> {
|
||||
let Some(spec) = resolve_stream_spec(plan_kind) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
build_local_stream_plan_and_reports(state, parts, trace_id, decision, spec).await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn build_local_gemini_files_sync_attempt_source_for_kind<'a>(
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
body_json: &'a serde_json::Value,
|
||||
body_base64: Option<&'a str>,
|
||||
body_is_empty: bool,
|
||||
trace_id: &'a str,
|
||||
decision: &'a GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<(LocalGeminiFilesSyncAttemptSource<'a>, usize)>, GatewayError> {
|
||||
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(input) = resolve_local_gemini_files_decision_input(
|
||||
state,
|
||||
parts,
|
||||
Some(body_json),
|
||||
trace_id,
|
||||
decision,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let effective_body_json = input.effective_body_json(body_json).clone();
|
||||
let (candidates, candidate_count) =
|
||||
build_local_gemini_files_candidate_attempt_source(state, trace_id, &input).await?;
|
||||
if candidate_count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some((
|
||||
LocalGeminiFilesSyncAttemptSource {
|
||||
state,
|
||||
parts,
|
||||
body_json: effective_body_json,
|
||||
body_base64,
|
||||
body_is_empty,
|
||||
trace_id,
|
||||
input,
|
||||
spec,
|
||||
candidates,
|
||||
},
|
||||
candidate_count,
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_gemini_files_stream_attempt_source_for_kind<'a>(
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
trace_id: &'a str,
|
||||
decision: &'a GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<(LocalGeminiFilesStreamAttemptSource<'a>, usize)>, GatewayError> {
|
||||
let Some(spec) = resolve_stream_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(input) =
|
||||
resolve_local_gemini_files_decision_input(state, parts, None, trace_id, decision).await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (candidates, candidate_count) =
|
||||
build_local_gemini_files_candidate_attempt_source(state, trace_id, &input).await?;
|
||||
if candidate_count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some((
|
||||
LocalGeminiFilesStreamAttemptSource {
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
input,
|
||||
spec,
|
||||
candidates,
|
||||
},
|
||||
candidate_count,
|
||||
)))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalGeminiFilesSyncAttemptSource<'_> {
|
||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||
while let Some(attempt) = self.candidates.next_attempt().await? {
|
||||
match self.build_sync_attempt(attempt).await? {
|
||||
Some(attempt) => return Ok(Some(attempt)),
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||
let mut drained = Vec::new();
|
||||
for attempt in self.candidates.drain_static_attempts() {
|
||||
if let Some(attempt) = self.build_sync_attempt(attempt).await? {
|
||||
drained.push(attempt);
|
||||
}
|
||||
}
|
||||
Ok(drained)
|
||||
}
|
||||
|
||||
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_credential(key_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_endpoint(endpoint_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_provider(provider_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalGeminiFilesStreamAttemptSource<'_> {
|
||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||
while let Some(attempt) = self.candidates.next_attempt().await? {
|
||||
match self.build_stream_attempt(attempt).await? {
|
||||
Some(attempt) => return Ok(Some(attempt)),
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiStreamAttempt>, GatewayError> {
|
||||
let mut drained = Vec::new();
|
||||
for attempt in self.candidates.drain_static_attempts() {
|
||||
if let Some(attempt) = self.build_stream_attempt(attempt).await? {
|
||||
drained.push(attempt);
|
||||
}
|
||||
}
|
||||
Ok(drained)
|
||||
}
|
||||
|
||||
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_credential(key_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_endpoint(endpoint_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_provider(provider_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalGeminiFilesSyncAttemptSource<'_> {
|
||||
async fn build_sync_attempt(
|
||||
&self,
|
||||
attempt: LocalGeminiFilesCandidateAttempt,
|
||||
) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||
let spec_metadata = local_gemini_files_spec_metadata(self.spec);
|
||||
let Some(payload) = maybe_build_local_gemini_files_decision_payload_for_candidate(
|
||||
self.state,
|
||||
self.parts,
|
||||
&self.body_json,
|
||||
self.body_base64,
|
||||
self.body_is_empty,
|
||||
self.trace_id,
|
||||
&self.input,
|
||||
attempt,
|
||||
self.spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match build_passthrough_sync_plan_from_decision(self.parts, payload) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %self.trace_id,
|
||||
decision_kind = spec_metadata.decision_kind,
|
||||
error = ?err,
|
||||
"gateway local gemini files sync decision plan build failed"
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalGeminiFilesStreamAttemptSource<'_> {
|
||||
async fn build_stream_attempt(
|
||||
&self,
|
||||
attempt: LocalGeminiFilesCandidateAttempt,
|
||||
) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||
let spec_metadata = local_gemini_files_spec_metadata(self.spec);
|
||||
let empty_body_json = serde_json::Value::Null;
|
||||
let Some(payload) = maybe_build_local_gemini_files_decision_payload_for_candidate(
|
||||
self.state,
|
||||
self.parts,
|
||||
&empty_body_json,
|
||||
None,
|
||||
true,
|
||||
self.trace_id,
|
||||
&self.input,
|
||||
attempt,
|
||||
self.spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match build_passthrough_stream_plan_from_decision(self.parts, payload) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %self.trace_id,
|
||||
decision_kind = spec_metadata.decision_kind,
|
||||
error = ?err,
|
||||
"gateway local gemini files stream decision plan build failed"
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_sync_local_gemini_files_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
body_is_empty: bool,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(input) = resolve_local_gemini_files_decision_input(
|
||||
state,
|
||||
parts,
|
||||
Some(body_json),
|
||||
trace_id,
|
||||
decision,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let body_json = input.effective_body_json(body_json);
|
||||
|
||||
let (mut source, _) =
|
||||
build_local_gemini_files_candidate_attempt_source(state, trace_id, &input).await?;
|
||||
|
||||
while let Some(attempt) = source.next_attempt().await? {
|
||||
if let Some(payload) = maybe_build_local_gemini_files_decision_payload_for_candidate(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
body_is_empty,
|
||||
trace_id,
|
||||
&input,
|
||||
attempt,
|
||||
spec,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(payload));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_stream_local_gemini_files_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let Some(spec) = resolve_stream_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(input) =
|
||||
resolve_local_gemini_files_decision_input(state, parts, None, trace_id, decision).await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let (mut source, _) =
|
||||
build_local_gemini_files_candidate_attempt_source(state, trace_id, &input).await?;
|
||||
|
||||
let empty_body_json = serde_json::Value::Null;
|
||||
while let Some(attempt) = source.next_attempt().await? {
|
||||
if let Some(payload) = maybe_build_local_gemini_files_decision_payload_for_candidate(
|
||||
state,
|
||||
parts,
|
||||
&empty_body_json,
|
||||
None,
|
||||
true,
|
||||
trace_id,
|
||||
&input,
|
||||
attempt,
|
||||
spec,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(payload));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn build_local_sync_plan_and_reports(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
body_is_empty: bool,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
spec: LocalGeminiFilesSpec,
|
||||
) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||
let spec_metadata = local_gemini_files_spec_metadata(spec);
|
||||
let Some(input) = resolve_local_gemini_files_decision_input(
|
||||
state,
|
||||
parts,
|
||||
Some(body_json),
|
||||
trace_id,
|
||||
decision,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let body_json = input.effective_body_json(body_json);
|
||||
|
||||
let (mut source, _) =
|
||||
build_local_gemini_files_candidate_attempt_source(state, trace_id, &input).await?;
|
||||
|
||||
let mut plans = Vec::new();
|
||||
while let Some(attempt) = source.next_attempt().await? {
|
||||
let Some(payload) = maybe_build_local_gemini_files_decision_payload_for_candidate(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
body_is_empty,
|
||||
trace_id,
|
||||
&input,
|
||||
attempt,
|
||||
spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match build_passthrough_sync_plan_from_decision(parts, payload) {
|
||||
Ok(Some(value)) => plans.push(value),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
decision_kind = spec_metadata.decision_kind,
|
||||
error = ?err,
|
||||
"gateway local gemini files sync decision plan build failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plans)
|
||||
}
|
||||
|
||||
async fn build_local_stream_plan_and_reports(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
spec: LocalGeminiFilesSpec,
|
||||
) -> Result<Vec<AiStreamAttempt>, GatewayError> {
|
||||
let spec_metadata = local_gemini_files_spec_metadata(spec);
|
||||
let Some(input) =
|
||||
resolve_local_gemini_files_decision_input(state, parts, None, trace_id, decision).await?
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let (mut source, _) =
|
||||
build_local_gemini_files_candidate_attempt_source(state, trace_id, &input).await?;
|
||||
|
||||
let mut plans = Vec::new();
|
||||
let empty_body_json = serde_json::Value::Null;
|
||||
while let Some(attempt) = source.next_attempt().await? {
|
||||
let Some(payload) = maybe_build_local_gemini_files_decision_payload_for_candidate(
|
||||
state,
|
||||
parts,
|
||||
&empty_body_json,
|
||||
None,
|
||||
true,
|
||||
trace_id,
|
||||
&input,
|
||||
attempt,
|
||||
spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match build_passthrough_stream_plan_from_decision(parts, payload) {
|
||||
Ok(Some(value)) => plans.push(value),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
decision_kind = spec_metadata.decision_kind,
|
||||
error = ?err,
|
||||
"gateway local gemini files stream decision plan build failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plans)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::ai_serving::build_request_trace_proxy_value;
|
||||
use crate::ai_serving::planner::decision_input::apply_provider_request_routing_policy_to_decision;
|
||||
use crate::ai_serving::planner::report_context::{
|
||||
build_local_execution_report_context, LocalExecutionReportContextParts,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_gemini_files_spec_metadata;
|
||||
use crate::ai_serving::planner::{
|
||||
build_ai_execution_decision_response, resolve_transport_request_encoding_policy,
|
||||
AiExecutionDecisionResponseParts,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
resolve_transport_execution_timeouts, resolve_transport_profile,
|
||||
};
|
||||
use crate::ai_serving::{ai_local_execution_contract_for_formats, PlannerAppState};
|
||||
use crate::{append_local_failover_policy_to_value, AiExecutionDecision, AppState, GatewayError};
|
||||
|
||||
use super::request::resolve_local_gemini_files_candidate_payload_parts;
|
||||
use super::support::{
|
||||
LocalGeminiFilesCandidateAttempt, LocalGeminiFilesDecisionInput, GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
};
|
||||
use super::LocalGeminiFilesSpec;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn maybe_build_local_gemini_files_decision_payload_for_candidate(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
body_is_empty: bool,
|
||||
trace_id: &str,
|
||||
input: &LocalGeminiFilesDecisionInput,
|
||||
attempt: LocalGeminiFilesCandidateAttempt,
|
||||
spec: LocalGeminiFilesSpec,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let spec_metadata = local_gemini_files_spec_metadata(spec);
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let attempt_identity = attempt.attempt_identity();
|
||||
let resolved = resolve_local_gemini_files_candidate_payload_parts(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
body_is_empty,
|
||||
trace_id,
|
||||
input,
|
||||
&attempt,
|
||||
spec,
|
||||
)
|
||||
.await;
|
||||
let Some(resolved) = resolved else {
|
||||
return Ok(None);
|
||||
};
|
||||
let LocalGeminiFilesCandidateAttempt {
|
||||
eligible,
|
||||
candidate_id,
|
||||
..
|
||||
} = attempt;
|
||||
let candidate = eligible.candidate;
|
||||
let transport = resolved.transport;
|
||||
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
|
||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
);
|
||||
let proxy = planner_state
|
||||
.app()
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&transport)
|
||||
.await;
|
||||
let transport_profile = resolve_transport_profile(&transport);
|
||||
let mut extra_fields = serde_json::Map::new();
|
||||
if let Some(proxy_value) = build_request_trace_proxy_value(Some(&transport), proxy.as_ref()) {
|
||||
extra_fields.insert("proxy".to_string(), proxy_value);
|
||||
}
|
||||
extra_fields.insert("file_key_id".to_string(), json!(candidate.key_id));
|
||||
extra_fields.insert("file_name".to_string(), json!(resolved.file_name));
|
||||
let effective_headers = input.effective_headers(&parts.headers);
|
||||
let report_context = build_local_execution_report_context(LocalExecutionReportContextParts {
|
||||
auth_context: &input.auth_context,
|
||||
request_id: trace_id,
|
||||
candidate_id: &candidate_id,
|
||||
attempt_identity,
|
||||
model: "gemini-files",
|
||||
provider_name: &transport.provider.name,
|
||||
provider_id: &candidate.provider_id,
|
||||
endpoint_id: &candidate.endpoint_id,
|
||||
key_id: &candidate.key_id,
|
||||
key_name: None,
|
||||
model_id: Some(&candidate.model_id),
|
||||
global_model_id: Some(&candidate.global_model_id),
|
||||
global_model_name: Some(&candidate.global_model_name),
|
||||
provider_api_format: GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
client_api_format: GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
mapped_model: None,
|
||||
candidate_group_id: eligible.orchestration.candidate_group_id.as_deref(),
|
||||
pool_key_lease: eligible.orchestration.pool_key_lease.as_ref(),
|
||||
ranking: eligible.ranking.as_ref(),
|
||||
upstream_url: None,
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
body_rules: transport.endpoint.body_rules.as_ref(),
|
||||
provider_request_method: None,
|
||||
provider_request_headers: None,
|
||||
original_headers: effective_headers,
|
||||
request_path: Some(parts.uri.path()),
|
||||
request_query_string: parts.uri.query(),
|
||||
request_origin: Some(crate::ai_serving::request_origin_from_parts(parts)),
|
||||
original_request_body_json: Some(body_json),
|
||||
original_request_body_base64: resolved.provider_request_body_base64.as_deref(),
|
||||
client_session_affinity: input.client_session_affinity.as_ref(),
|
||||
routing_policy: input.routing_policy.as_ref(),
|
||||
scheduler_affinity_epoch: eligible.orchestration.scheduler_affinity_epoch,
|
||||
client_requested_stream: spec_metadata.require_streaming,
|
||||
upstream_is_stream: spec_metadata.require_streaming,
|
||||
has_envelope: false,
|
||||
needs_conversion: false,
|
||||
extra_fields,
|
||||
});
|
||||
let report_context = append_local_failover_policy_to_value(report_context, &transport);
|
||||
let super::request::LocalGeminiFilesCandidatePayloadParts {
|
||||
transport: _,
|
||||
auth_header,
|
||||
auth_value,
|
||||
provider_request_headers,
|
||||
provider_request_body,
|
||||
provider_request_body_base64,
|
||||
upstream_url,
|
||||
file_name: _,
|
||||
} = resolved;
|
||||
let request_encoding = resolve_transport_request_encoding_policy(&transport);
|
||||
|
||||
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
|
||||
decision_is_stream: spec_metadata.require_streaming,
|
||||
decision_kind: spec_metadata.decision_kind.to_string(),
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
request_id: trace_id.to_string(),
|
||||
candidate_id: candidate_id.clone(),
|
||||
provider_name: transport.provider.name.clone(),
|
||||
provider_type: transport.provider.provider_type.clone(),
|
||||
provider_id: candidate.provider_id.clone(),
|
||||
endpoint_id: candidate.endpoint_id.clone(),
|
||||
key_id: candidate.key_id.clone(),
|
||||
upstream_base_url: transport.endpoint.base_url.clone(),
|
||||
upstream_url,
|
||||
provider_request_method: Some(parts.method.to_string()),
|
||||
auth_header: Some(auth_header),
|
||||
auth_value: Some(auth_value),
|
||||
provider_api_format: GEMINI_FILES_CLIENT_API_FORMAT.to_string(),
|
||||
client_api_format: GEMINI_FILES_CLIENT_API_FORMAT.to_string(),
|
||||
model_name: "gemini-files".to_string(),
|
||||
mapped_model: candidate.selected_provider_model_name.clone(),
|
||||
prompt_cache_key: None,
|
||||
provider_request_headers,
|
||||
provider_request_body,
|
||||
provider_request_body_base64,
|
||||
content_type: effective_headers
|
||||
.get(http::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
content_encoding: request_encoding.content_encoding,
|
||||
request_gzip: request_encoding.request_gzip,
|
||||
proxy,
|
||||
transport_profile,
|
||||
timeouts: resolve_transport_execution_timeouts(&transport),
|
||||
upstream_is_stream: spec_metadata.require_streaming,
|
||||
report_kind: spec_metadata.report_kind.map(ToOwned::to_owned),
|
||||
report_context: Some(report_context),
|
||||
auth_context: input.auth_context.clone(),
|
||||
});
|
||||
apply_provider_request_routing_policy_to_decision(
|
||||
input,
|
||||
&mut decision,
|
||||
Some(transport.as_ref()),
|
||||
)?;
|
||||
Ok(Some(decision))
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::ai_serving::planner::spec_metadata::local_gemini_files_spec_metadata;
|
||||
use crate::ai_serving::transport::{
|
||||
build_gemini_files_headers, build_gemini_files_request_body, build_gemini_files_upstream_url,
|
||||
gemini_files_transport_unsupported_reason, resolve_gemini_files_auth, GeminiFilesHeadersInput,
|
||||
GeminiFilesRequestBodyError,
|
||||
};
|
||||
use crate::ai_serving::GEMINI_FILES_UPLOAD_PLAN_KIND;
|
||||
use crate::ai_serving::{CandidateFailureDiagnostic, GatewayProviderTransportSnapshot};
|
||||
use crate::AppState;
|
||||
|
||||
use super::support::{
|
||||
mark_skipped_local_gemini_files_candidate,
|
||||
mark_skipped_local_gemini_files_candidate_with_failure_diagnostic,
|
||||
LocalGeminiFilesCandidateAttempt, LocalGeminiFilesDecisionInput,
|
||||
GEMINI_FILES_CANDIDATE_API_FORMAT, GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
};
|
||||
use super::LocalGeminiFilesSpec;
|
||||
|
||||
pub(super) struct LocalGeminiFilesCandidatePayloadParts {
|
||||
pub(super) transport: Arc<GatewayProviderTransportSnapshot>,
|
||||
pub(super) auth_header: String,
|
||||
pub(super) auth_value: String,
|
||||
pub(super) provider_request_headers: BTreeMap<String, String>,
|
||||
pub(super) provider_request_body: Option<serde_json::Value>,
|
||||
pub(super) provider_request_body_base64: Option<String>,
|
||||
pub(super) upstream_url: String,
|
||||
pub(super) file_name: String,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn resolve_local_gemini_files_candidate_payload_parts(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
body_is_empty: bool,
|
||||
trace_id: &str,
|
||||
input: &LocalGeminiFilesDecisionInput,
|
||||
attempt: &LocalGeminiFilesCandidateAttempt,
|
||||
spec: LocalGeminiFilesSpec,
|
||||
) -> Option<LocalGeminiFilesCandidatePayloadParts> {
|
||||
let spec_metadata = local_gemini_files_spec_metadata(spec);
|
||||
let candidate = &attempt.eligible.candidate;
|
||||
let transport = &attempt.eligible.transport;
|
||||
let effective_headers = input.effective_headers(&parts.headers);
|
||||
|
||||
if let Some(skip_reason) =
|
||||
gemini_files_transport_unsupported_reason(transport, GEMINI_FILES_CANDIDATE_API_FORMAT)
|
||||
{
|
||||
mark_skipped_local_gemini_files_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
|
||||
let Some((auth_header, auth_value)) = resolve_gemini_files_auth(transport) else {
|
||||
mark_skipped_local_gemini_files_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"transport_auth_unavailable",
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
let Some(upstream_url) =
|
||||
build_gemini_files_upstream_url(transport, parts.uri.path(), parts.uri.query())
|
||||
else {
|
||||
mark_skipped_local_gemini_files_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"upstream_url_missing",
|
||||
CandidateFailureDiagnostic::upstream_url_missing(
|
||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
GEMINI_FILES_CANDIDATE_API_FORMAT,
|
||||
"gemini_files_passthrough_url",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
let body_parts = match build_gemini_files_request_body(
|
||||
body_json,
|
||||
body_base64,
|
||||
body_is_empty,
|
||||
spec_metadata.decision_kind == GEMINI_FILES_UPLOAD_PLAN_KIND,
|
||||
transport.endpoint.body_rules.as_ref(),
|
||||
Some(effective_headers),
|
||||
) {
|
||||
Ok(parts) => parts,
|
||||
Err(GeminiFilesRequestBodyError::BodyRulesUnsupportedForBinaryUpload) => {
|
||||
mark_skipped_local_gemini_files_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"transport_body_rules_unsupported_for_binary_upload",
|
||||
CandidateFailureDiagnostic::body_rules_unsupported_for_binary_upload(
|
||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
GEMINI_FILES_CANDIDATE_API_FORMAT,
|
||||
"gemini_files_binary_upload",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
Err(GeminiFilesRequestBodyError::BodyRulesApplyFailed) => {
|
||||
mark_skipped_local_gemini_files_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"transport_body_rules_apply_failed",
|
||||
CandidateFailureDiagnostic::body_rules_apply_failed(
|
||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
GEMINI_FILES_CANDIDATE_API_FORMAT,
|
||||
"gemini_files_body_rules",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(provider_request_headers) = build_gemini_files_headers(GeminiFilesHeadersInput {
|
||||
headers: effective_headers,
|
||||
auth_header: &auth_header,
|
||||
auth_value: &auth_value,
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: body_parts.provider_request_body.as_ref(),
|
||||
provider_request_body_base64: body_parts.provider_request_body_base64.as_deref(),
|
||||
original_request_body_json: body_json,
|
||||
original_body_is_empty: body_is_empty,
|
||||
}) else {
|
||||
mark_skipped_local_gemini_files_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"transport_header_rules_apply_failed",
|
||||
CandidateFailureDiagnostic::header_rules_apply_failed(
|
||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
GEMINI_FILES_CANDIDATE_API_FORMAT,
|
||||
"gemini_files_header_rules",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
let file_name = parts
|
||||
.uri
|
||||
.path()
|
||||
.trim_start_matches("/v1beta/")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
Some(LocalGeminiFilesCandidatePayloadParts {
|
||||
transport: Arc::clone(transport),
|
||||
auth_header,
|
||||
auth_value,
|
||||
provider_request_headers,
|
||||
provider_request_body: body_parts.provider_request_body,
|
||||
provider_request_body_base64: body_parts.provider_request_body_base64,
|
||||
upstream_url,
|
||||
file_name,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
use serde_json::json;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_serving::planner::candidate_materialization::{
|
||||
build_local_execution_candidate_attempt_source_with_serving,
|
||||
mark_skipped_local_execution_candidate,
|
||||
mark_skipped_local_execution_candidate_with_failure_diagnostic,
|
||||
materialize_local_execution_candidates_with_serving, LocalCandidateResolutionMode,
|
||||
LocalExecutionCandidateAttemptSource,
|
||||
};
|
||||
use crate::ai_serving::planner::candidate_metadata::{
|
||||
build_local_execution_candidate_metadata,
|
||||
build_local_execution_candidate_metadata_for_candidate, LocalExecutionCandidateMetadataParts,
|
||||
};
|
||||
use crate::ai_serving::planner::decision_input::{
|
||||
attach_routing_policy_to_local_requested_model_input,
|
||||
build_local_requested_model_decision_input, resolve_local_authenticated_decision_input,
|
||||
};
|
||||
use crate::ai_serving::planner::materialization_policy::{
|
||||
build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind,
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
resolve_local_decision_execution_runtime_auth_context, CandidateFailureDiagnostic,
|
||||
ExecutionRuntimeAuthContext, GatewayControlDecision, PlannerAppState,
|
||||
};
|
||||
use crate::client_session_affinity::client_session_affinity_from_parts;
|
||||
use crate::clock::current_unix_secs;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalGeminiFilesCandidateAttempt;
|
||||
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttemptSource as LocalGeminiFilesCandidateAttemptSource;
|
||||
pub(super) use crate::ai_serving::planner::decision_input::LocalRequestedModelDecisionInput as LocalGeminiFilesDecisionInput;
|
||||
|
||||
pub(super) const GEMINI_FILES_CANDIDATE_API_FORMAT: &str = "gemini:files";
|
||||
pub(super) const GEMINI_FILES_CLIENT_API_FORMAT: &str = "gemini:files";
|
||||
pub(super) const GEMINI_FILES_REQUIRED_CAPABILITY: &str = "gemini_files";
|
||||
pub(super) const GEMINI_FILES_ROUTING_MODEL: &str = "gemini-files";
|
||||
|
||||
pub(super) async fn resolve_local_gemini_files_decision_input(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: Option<&serde_json::Value>,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Result<Option<LocalGeminiFilesDecisionInput>, GatewayError> {
|
||||
let Some(auth_context) = resolve_local_decision_execution_runtime_auth_context(decision) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let explicit_required_capabilities = json!({ "gemini_files": true });
|
||||
let resolved_input = match resolve_local_authenticated_decision_input(
|
||||
state,
|
||||
auth_context,
|
||||
None,
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
Some(&explicit_required_capabilities),
|
||||
&decision.model_directive_policy,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(resolved_input)) => resolved_input,
|
||||
Ok(None) => return Ok(None),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
error = ?err,
|
||||
"gateway local gemini files decision auth snapshot read failed"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
let routing_body_json = body_json.cloned().unwrap_or(serde_json::Value::Null);
|
||||
let mut input = build_local_requested_model_decision_input(
|
||||
resolved_input,
|
||||
GEMINI_FILES_ROUTING_MODEL.to_string(),
|
||||
);
|
||||
input.request_auth_channel = decision.request_auth_channel.clone();
|
||||
input.client_session_affinity = client_session_affinity_from_parts(parts, body_json);
|
||||
attach_routing_policy_to_local_requested_model_input(
|
||||
state,
|
||||
parts,
|
||||
&mut input,
|
||||
&routing_body_json,
|
||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
)
|
||||
.await?;
|
||||
Ok(Some(input))
|
||||
}
|
||||
|
||||
pub(super) async fn materialize_local_gemini_files_candidate_attempts(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
input: &LocalGeminiFilesDecisionInput,
|
||||
) -> Result<Vec<LocalGeminiFilesCandidateAttempt>, GatewayError> {
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::GeminiFilesDecision,
|
||||
);
|
||||
let candidates = planner_state
|
||||
.list_selectable_candidates_for_required_capability_without_requested_model(
|
||||
GEMINI_FILES_CANDIDATE_API_FORMAT,
|
||||
GEMINI_FILES_REQUIRED_CAPABILITY,
|
||||
false,
|
||||
Some(&input.auth_snapshot),
|
||||
input.client_session_affinity.as_ref(),
|
||||
current_unix_secs(),
|
||||
)
|
||||
.await?;
|
||||
let outcome = materialize_local_execution_candidates_with_serving(
|
||||
planner_state,
|
||||
trace_id,
|
||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
None,
|
||||
Some(&input.auth_snapshot),
|
||||
input.client_session_affinity.as_ref(),
|
||||
input.required_capabilities.as_ref(),
|
||||
input.routing_policy.as_ref(),
|
||||
None,
|
||||
input.request_auth_channel.as_deref(),
|
||||
persistence_policy,
|
||||
candidates,
|
||||
Vec::new(),
|
||||
LocalCandidateResolutionMode::WithoutTransportPairGate,
|
||||
|eligible| {
|
||||
let mut extra_fields = serde_json::Map::new();
|
||||
extra_fields.insert(
|
||||
"candidate_api_format".to_string(),
|
||||
json!(GEMINI_FILES_CANDIDATE_API_FORMAT),
|
||||
);
|
||||
Some(build_local_execution_candidate_metadata(
|
||||
LocalExecutionCandidateMetadataParts {
|
||||
eligible,
|
||||
provider_api_format: GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
client_api_format: GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
extra_fields,
|
||||
},
|
||||
))
|
||||
},
|
||||
|mut skipped_candidate| {
|
||||
let mut extra_fields = serde_json::Map::new();
|
||||
extra_fields.insert(
|
||||
"candidate_api_format".to_string(),
|
||||
json!(GEMINI_FILES_CANDIDATE_API_FORMAT),
|
||||
);
|
||||
skipped_candidate.extra_data =
|
||||
Some(build_local_execution_candidate_metadata_for_candidate(
|
||||
&skipped_candidate.candidate,
|
||||
skipped_candidate.transport_ref(),
|
||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
extra_fields,
|
||||
));
|
||||
skipped_candidate
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(outcome.attempts)
|
||||
}
|
||||
|
||||
pub(super) async fn build_local_gemini_files_candidate_attempt_source<'a>(
|
||||
state: &'a AppState,
|
||||
trace_id: &str,
|
||||
input: &LocalGeminiFilesDecisionInput,
|
||||
) -> Result<(LocalGeminiFilesCandidateAttemptSource<'a>, usize), GatewayError> {
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::GeminiFilesDecision,
|
||||
);
|
||||
let candidates = planner_state
|
||||
.list_selectable_candidates_for_required_capability_without_requested_model(
|
||||
GEMINI_FILES_CANDIDATE_API_FORMAT,
|
||||
GEMINI_FILES_REQUIRED_CAPABILITY,
|
||||
false,
|
||||
Some(&input.auth_snapshot),
|
||||
input.client_session_affinity.as_ref(),
|
||||
current_unix_secs(),
|
||||
)
|
||||
.await?;
|
||||
Ok(build_local_execution_candidate_attempt_source_with_serving(
|
||||
planner_state,
|
||||
trace_id,
|
||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
None,
|
||||
Some(&input.auth_snapshot),
|
||||
input.client_session_affinity.as_ref(),
|
||||
input.required_capabilities.as_ref(),
|
||||
input.routing_policy.as_ref(),
|
||||
None,
|
||||
input.request_auth_channel.as_deref(),
|
||||
persistence_policy,
|
||||
candidates,
|
||||
Vec::new(),
|
||||
LocalCandidateResolutionMode::WithoutTransportPairGate,
|
||||
|eligible| {
|
||||
let mut extra_fields = serde_json::Map::new();
|
||||
extra_fields.insert(
|
||||
"candidate_api_format".to_string(),
|
||||
json!(GEMINI_FILES_CANDIDATE_API_FORMAT),
|
||||
);
|
||||
Some(build_local_execution_candidate_metadata(
|
||||
LocalExecutionCandidateMetadataParts {
|
||||
eligible,
|
||||
provider_api_format: GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
client_api_format: GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
extra_fields,
|
||||
},
|
||||
))
|
||||
},
|
||||
|mut skipped_candidate| {
|
||||
let mut extra_fields = serde_json::Map::new();
|
||||
extra_fields.insert(
|
||||
"candidate_api_format".to_string(),
|
||||
json!(GEMINI_FILES_CANDIDATE_API_FORMAT),
|
||||
);
|
||||
skipped_candidate.extra_data =
|
||||
Some(build_local_execution_candidate_metadata_for_candidate(
|
||||
&skipped_candidate.candidate,
|
||||
skipped_candidate.transport_ref(),
|
||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
extra_fields,
|
||||
));
|
||||
skipped_candidate
|
||||
},
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
pub(super) async fn mark_skipped_local_gemini_files_candidate(
|
||||
state: &AppState,
|
||||
input: &LocalGeminiFilesDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
) {
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::GeminiFilesDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) async fn mark_skipped_local_gemini_files_candidate_with_failure_diagnostic(
|
||||
state: &AppState,
|
||||
input: &LocalGeminiFilesDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
diagnostic: CandidateFailureDiagnostic,
|
||||
) {
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::GeminiFilesDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
diagnostic,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
mod decision;
|
||||
mod request;
|
||||
mod support;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_serving::planner::candidate_materialization::LocalExecutionAttemptSource;
|
||||
use crate::ai_serving::planner::plan_builders::{
|
||||
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
|
||||
build_passthrough_sync_plan_from_decision, build_standard_stream_plan_from_decision,
|
||||
AiStreamAttempt, AiSyncAttempt,
|
||||
};
|
||||
use crate::ai_serving::planner::runtime_miss::set_local_runtime_execution_exhausted_diagnostic;
|
||||
use crate::ai_serving::planner::spec_metadata::local_openai_image_spec_metadata;
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::ai_serving::{
|
||||
resolve_local_image_stream_spec as resolve_stream_spec,
|
||||
resolve_local_image_sync_spec as resolve_sync_spec,
|
||||
};
|
||||
use crate::{AiExecutionDecision, AppState, GatewayError};
|
||||
|
||||
use self::decision::maybe_build_local_openai_image_decision_payload_for_candidate;
|
||||
use self::support::{
|
||||
build_local_openai_image_candidate_attempt_source, list_local_openai_image_candidate_attempts,
|
||||
resolve_local_openai_image_decision_input, LocalOpenAiImageCandidateAttempt,
|
||||
LocalOpenAiImageCandidateAttemptSource, LocalOpenAiImageDecisionInput,
|
||||
};
|
||||
|
||||
pub(super) use crate::ai_serving::LocalOpenAiImageSpec;
|
||||
|
||||
pub(crate) struct LocalOpenAiImageSyncAttemptSource<'a> {
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
body_json: serde_json::Value,
|
||||
body_base64: Option<&'a str>,
|
||||
trace_id: &'a str,
|
||||
input: LocalOpenAiImageDecisionInput,
|
||||
spec: LocalOpenAiImageSpec,
|
||||
candidates: LocalOpenAiImageCandidateAttemptSource<'a>,
|
||||
}
|
||||
|
||||
pub(crate) struct LocalOpenAiImageStreamAttemptSource<'a> {
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
body_json: serde_json::Value,
|
||||
body_base64: Option<&'a str>,
|
||||
trace_id: &'a str,
|
||||
input: LocalOpenAiImageDecisionInput,
|
||||
spec: LocalOpenAiImageSpec,
|
||||
candidates: LocalOpenAiImageCandidateAttemptSource<'a>,
|
||||
}
|
||||
|
||||
pub(crate) fn set_local_openai_image_execution_exhausted_diagnostic(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
body_json: &serde_json::Value,
|
||||
candidate_count: usize,
|
||||
) {
|
||||
warn!(
|
||||
event_name = "local_openai_image_candidates_exhausted",
|
||||
log_type = "event",
|
||||
trace_id = %trace_id,
|
||||
plan_kind,
|
||||
route_class = decision.route_class.as_deref().unwrap_or("passthrough"),
|
||||
route_family = decision.route_family.as_deref().unwrap_or("unknown"),
|
||||
candidate_count,
|
||||
model = body_json.get("model").and_then(|value| value.as_str()).unwrap_or(""),
|
||||
"gateway local openai image execution exhausted all candidates"
|
||||
);
|
||||
set_local_runtime_execution_exhausted_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
body_json.get("model").and_then(|value| value.as_str()),
|
||||
candidate_count,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_image_sync_plan_and_reports_for_kind(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
build_local_sync_plan_and_reports(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
decision,
|
||||
spec,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_image_stream_plan_and_reports_for_kind(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Vec<AiStreamAttempt>, GatewayError> {
|
||||
let Some(spec) = resolve_stream_spec(plan_kind) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
build_local_stream_plan_and_reports(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
decision,
|
||||
spec,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_image_sync_attempt_source_for_kind<'a>(
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
body_json: &'a serde_json::Value,
|
||||
body_base64: Option<&'a str>,
|
||||
trace_id: &'a str,
|
||||
decision: &'a GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<(LocalOpenAiImageSyncAttemptSource<'a>, usize)>, GatewayError> {
|
||||
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let spec_metadata = local_openai_image_spec_metadata(spec);
|
||||
|
||||
let Some(input) = resolve_local_openai_image_decision_input(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
decision,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let effective_body_json = input.effective_body_json(body_json).clone();
|
||||
let Some((candidates, candidate_count)) = build_local_openai_image_candidate_attempt_source(
|
||||
state,
|
||||
trace_id,
|
||||
&input,
|
||||
&effective_body_json,
|
||||
spec_metadata.api_format,
|
||||
spec_metadata.decision_kind,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if candidate_count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some((
|
||||
LocalOpenAiImageSyncAttemptSource {
|
||||
state,
|
||||
parts,
|
||||
body_json: effective_body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
input,
|
||||
spec,
|
||||
candidates,
|
||||
},
|
||||
candidate_count,
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_image_stream_attempt_source_for_kind<'a>(
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
body_json: &'a serde_json::Value,
|
||||
body_base64: Option<&'a str>,
|
||||
trace_id: &'a str,
|
||||
decision: &'a GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<(LocalOpenAiImageStreamAttemptSource<'a>, usize)>, GatewayError> {
|
||||
let Some(spec) = resolve_stream_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let spec_metadata = local_openai_image_spec_metadata(spec);
|
||||
|
||||
let Some(input) = resolve_local_openai_image_decision_input(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
decision,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let effective_body_json = input.effective_body_json(body_json).clone();
|
||||
let Some((candidates, candidate_count)) = build_local_openai_image_candidate_attempt_source(
|
||||
state,
|
||||
trace_id,
|
||||
&input,
|
||||
&effective_body_json,
|
||||
spec_metadata.api_format,
|
||||
spec_metadata.decision_kind,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if candidate_count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some((
|
||||
LocalOpenAiImageStreamAttemptSource {
|
||||
state,
|
||||
parts,
|
||||
body_json: effective_body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
input,
|
||||
spec,
|
||||
candidates,
|
||||
},
|
||||
candidate_count,
|
||||
)))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiImageSyncAttemptSource<'_> {
|
||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||
while let Some(attempt) = self.candidates.next_attempt().await? {
|
||||
match self.build_sync_attempt(attempt).await? {
|
||||
Some(attempt) => return Ok(Some(attempt)),
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||
let mut drained = Vec::new();
|
||||
for attempt in self.candidates.drain_static_attempts() {
|
||||
if let Some(attempt) = self.build_sync_attempt(attempt).await? {
|
||||
drained.push(attempt);
|
||||
}
|
||||
}
|
||||
Ok(drained)
|
||||
}
|
||||
|
||||
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_credential(key_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_endpoint(endpoint_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_provider(provider_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiImageStreamAttemptSource<'_> {
|
||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||
while let Some(attempt) = self.candidates.next_attempt().await? {
|
||||
match self.build_stream_attempt(attempt).await? {
|
||||
Some(attempt) => return Ok(Some(attempt)),
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiStreamAttempt>, GatewayError> {
|
||||
let mut drained = Vec::new();
|
||||
for attempt in self.candidates.drain_static_attempts() {
|
||||
if let Some(attempt) = self.build_stream_attempt(attempt).await? {
|
||||
drained.push(attempt);
|
||||
}
|
||||
}
|
||||
Ok(drained)
|
||||
}
|
||||
|
||||
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_credential(key_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_endpoint(endpoint_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_provider(provider_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalOpenAiImageSyncAttemptSource<'_> {
|
||||
async fn build_sync_attempt(
|
||||
&self,
|
||||
attempt: LocalOpenAiImageCandidateAttempt,
|
||||
) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||
let spec_metadata = local_openai_image_spec_metadata(self.spec);
|
||||
let Some(payload) = maybe_build_local_openai_image_decision_payload_for_candidate(
|
||||
self.state,
|
||||
self.parts,
|
||||
&self.body_json,
|
||||
self.body_base64,
|
||||
self.trace_id,
|
||||
&self.input,
|
||||
attempt,
|
||||
self.spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let provider_api_format = payload.provider_api_format.as_deref().unwrap_or_default();
|
||||
let built = if provider_api_format == "gemini:generate_content" {
|
||||
build_gemini_sync_plan_from_decision(self.parts, &self.body_json, payload)
|
||||
} else {
|
||||
build_passthrough_sync_plan_from_decision(self.parts, payload)
|
||||
};
|
||||
match built {
|
||||
Ok(value) => Ok(value),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %self.trace_id,
|
||||
decision_kind = spec_metadata.decision_kind,
|
||||
error = ?err,
|
||||
"gateway local openai image sync decision plan build failed"
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalOpenAiImageStreamAttemptSource<'_> {
|
||||
async fn build_stream_attempt(
|
||||
&self,
|
||||
attempt: LocalOpenAiImageCandidateAttempt,
|
||||
) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||
let spec_metadata = local_openai_image_spec_metadata(self.spec);
|
||||
let Some(payload) = maybe_build_local_openai_image_decision_payload_for_candidate(
|
||||
self.state,
|
||||
self.parts,
|
||||
&self.body_json,
|
||||
self.body_base64,
|
||||
self.trace_id,
|
||||
&self.input,
|
||||
attempt,
|
||||
self.spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let provider_api_format = payload.provider_api_format.as_deref().unwrap_or_default();
|
||||
let built = if provider_api_format == "gemini:generate_content" {
|
||||
build_gemini_stream_plan_from_decision(self.parts, &self.body_json, payload)
|
||||
} else {
|
||||
build_standard_stream_plan_from_decision(self.parts, &self.body_json, payload, false)
|
||||
};
|
||||
match built {
|
||||
Ok(value) => Ok(value),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %self.trace_id,
|
||||
decision_kind = spec_metadata.decision_kind,
|
||||
error = ?err,
|
||||
"gateway local openai image stream decision plan build failed"
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_sync_local_image_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let spec_metadata = local_openai_image_spec_metadata(spec);
|
||||
|
||||
let Some(input) = resolve_local_openai_image_decision_input(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
decision,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let body_json = input.effective_body_json(body_json);
|
||||
|
||||
let Some((mut source, _)) = build_local_openai_image_candidate_attempt_source(
|
||||
state,
|
||||
trace_id,
|
||||
&input,
|
||||
body_json,
|
||||
spec_metadata.api_format,
|
||||
spec_metadata.decision_kind,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
while let Some(attempt) = source.next_attempt().await? {
|
||||
if let Some(payload) = maybe_build_local_openai_image_decision_payload_for_candidate(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
&input,
|
||||
attempt,
|
||||
spec,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(payload));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_stream_local_image_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let Some(spec) = resolve_stream_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let spec_metadata = local_openai_image_spec_metadata(spec);
|
||||
|
||||
let Some(input) = resolve_local_openai_image_decision_input(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
decision,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let body_json = input.effective_body_json(body_json);
|
||||
|
||||
let Some((mut source, _)) = build_local_openai_image_candidate_attempt_source(
|
||||
state,
|
||||
trace_id,
|
||||
&input,
|
||||
body_json,
|
||||
spec_metadata.api_format,
|
||||
spec_metadata.decision_kind,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
while let Some(attempt) = source.next_attempt().await? {
|
||||
if let Some(payload) = maybe_build_local_openai_image_decision_payload_for_candidate(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
&input,
|
||||
attempt,
|
||||
spec,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(payload));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn build_local_sync_plan_and_reports(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
spec: LocalOpenAiImageSpec,
|
||||
) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||
let spec_metadata = local_openai_image_spec_metadata(spec);
|
||||
let Some(input) = resolve_local_openai_image_decision_input(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
decision,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let body_json = input.effective_body_json(body_json);
|
||||
|
||||
let Some((mut source, _)) = build_local_openai_image_candidate_attempt_source(
|
||||
state,
|
||||
trace_id,
|
||||
&input,
|
||||
body_json,
|
||||
spec_metadata.api_format,
|
||||
spec_metadata.decision_kind,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let mut plans = Vec::new();
|
||||
while let Some(attempt) = source.next_attempt().await? {
|
||||
let Some(payload) = maybe_build_local_openai_image_decision_payload_for_candidate(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
&input,
|
||||
attempt,
|
||||
spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let provider_api_format = payload.provider_api_format.as_deref().unwrap_or_default();
|
||||
let built = if provider_api_format == "gemini:generate_content" {
|
||||
build_gemini_sync_plan_from_decision(parts, body_json, payload)
|
||||
} else {
|
||||
build_passthrough_sync_plan_from_decision(parts, payload)
|
||||
};
|
||||
match built {
|
||||
Ok(Some(value)) => plans.push(value),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
decision_kind = spec_metadata.decision_kind,
|
||||
error = ?err,
|
||||
"gateway local openai image sync decision plan build failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plans)
|
||||
}
|
||||
|
||||
async fn build_local_stream_plan_and_reports(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
spec: LocalOpenAiImageSpec,
|
||||
) -> Result<Vec<AiStreamAttempt>, GatewayError> {
|
||||
let spec_metadata = local_openai_image_spec_metadata(spec);
|
||||
let Some(input) = resolve_local_openai_image_decision_input(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
decision,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let body_json = input.effective_body_json(body_json);
|
||||
|
||||
let Some((mut source, _)) = build_local_openai_image_candidate_attempt_source(
|
||||
state,
|
||||
trace_id,
|
||||
&input,
|
||||
body_json,
|
||||
spec_metadata.api_format,
|
||||
spec_metadata.decision_kind,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let mut plans = Vec::new();
|
||||
while let Some(attempt) = source.next_attempt().await? {
|
||||
let Some(payload) = maybe_build_local_openai_image_decision_payload_for_candidate(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
&input,
|
||||
attempt,
|
||||
spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let provider_api_format = payload.provider_api_format.as_deref().unwrap_or_default();
|
||||
let built = if provider_api_format == "gemini:generate_content" {
|
||||
build_gemini_stream_plan_from_decision(parts, body_json, payload)
|
||||
} else {
|
||||
build_standard_stream_plan_from_decision(parts, body_json, payload, false)
|
||||
};
|
||||
match built {
|
||||
Ok(Some(value)) => plans.push(value),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
decision_kind = spec_metadata.decision_kind,
|
||||
error = ?err,
|
||||
"gateway local openai image stream decision plan build failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plans)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use crate::ai_serving::build_request_trace_proxy_value;
|
||||
use crate::ai_serving::planner::decision_input::apply_provider_request_routing_policy_to_decision;
|
||||
use crate::ai_serving::planner::report_context::{
|
||||
build_local_execution_report_context, LocalExecutionReportContextParts,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_openai_image_spec_metadata;
|
||||
use crate::ai_serving::planner::{
|
||||
build_ai_execution_decision_response, resolve_transport_request_encoding_policy,
|
||||
AiExecutionDecisionResponseParts,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
resolve_transport_execution_timeouts, resolve_transport_profile,
|
||||
};
|
||||
use crate::ai_serving::{ai_local_execution_contract_for_formats, PlannerAppState};
|
||||
use crate::{
|
||||
append_execution_contract_fields_to_value, append_local_failover_policy_to_value,
|
||||
AiExecutionDecision, AppState, GatewayError,
|
||||
};
|
||||
|
||||
use super::request::resolve_local_openai_image_candidate_payload_parts;
|
||||
use super::support::{LocalOpenAiImageCandidateAttempt, LocalOpenAiImageDecisionInput};
|
||||
use super::LocalOpenAiImageSpec;
|
||||
|
||||
pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidate(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
trace_id: &str,
|
||||
input: &LocalOpenAiImageDecisionInput,
|
||||
attempt: LocalOpenAiImageCandidateAttempt,
|
||||
spec: LocalOpenAiImageSpec,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let spec_metadata = local_openai_image_spec_metadata(spec);
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let attempt_identity = attempt.attempt_identity();
|
||||
let Some(resolved) = resolve_local_openai_image_candidate_payload_parts(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
input,
|
||||
&attempt,
|
||||
spec,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let LocalOpenAiImageCandidateAttempt {
|
||||
eligible,
|
||||
candidate_id,
|
||||
..
|
||||
} = attempt;
|
||||
let candidate = eligible.candidate;
|
||||
let transport = resolved.transport;
|
||||
let provider_api_format = resolved.provider_api_format.clone();
|
||||
let needs_conversion = provider_api_format != spec_metadata.api_format;
|
||||
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
|
||||
spec_metadata.api_format,
|
||||
provider_api_format.as_str(),
|
||||
);
|
||||
let proxy = planner_state
|
||||
.app()
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&transport)
|
||||
.await;
|
||||
let transport_profile = resolved
|
||||
.transport_profile
|
||||
.clone()
|
||||
.or_else(|| resolve_transport_profile(&transport));
|
||||
let mut extra_fields = serde_json::Map::new();
|
||||
if let Some(proxy_value) = build_request_trace_proxy_value(Some(&transport), proxy.as_ref()) {
|
||||
extra_fields.insert("proxy".to_string(), proxy_value);
|
||||
}
|
||||
extra_fields.insert("image_request".to_string(), resolved.input_summary.clone());
|
||||
if transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("chatgpt_web")
|
||||
{
|
||||
extra_fields.insert(
|
||||
"chatgpt_web_image".to_string(),
|
||||
serde_json::Value::Bool(true),
|
||||
);
|
||||
}
|
||||
let upstream_is_stream = resolved.upstream_is_stream;
|
||||
let effective_headers = input.effective_headers(&parts.headers);
|
||||
let report_context = append_execution_contract_fields_to_value(
|
||||
build_local_execution_report_context(LocalExecutionReportContextParts {
|
||||
auth_context: &input.auth_context,
|
||||
request_id: trace_id,
|
||||
candidate_id: &candidate_id,
|
||||
attempt_identity,
|
||||
model: &resolved.requested_model,
|
||||
provider_name: &transport.provider.name,
|
||||
provider_id: &candidate.provider_id,
|
||||
endpoint_id: &candidate.endpoint_id,
|
||||
key_id: &candidate.key_id,
|
||||
key_name: None,
|
||||
model_id: Some(&candidate.model_id),
|
||||
global_model_id: Some(&candidate.global_model_id),
|
||||
global_model_name: Some(&candidate.global_model_name),
|
||||
provider_api_format: provider_api_format.as_str(),
|
||||
client_api_format: spec_metadata.api_format,
|
||||
mapped_model: Some(&resolved.mapped_model),
|
||||
candidate_group_id: eligible.orchestration.candidate_group_id.as_deref(),
|
||||
pool_key_lease: eligible.orchestration.pool_key_lease.as_ref(),
|
||||
ranking: eligible.ranking.as_ref(),
|
||||
upstream_url: Some(&resolved.upstream_url),
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
body_rules: transport.endpoint.body_rules.as_ref(),
|
||||
provider_request_method: Some(serde_json::Value::String(parts.method.to_string())),
|
||||
provider_request_headers: Some(&resolved.provider_request_headers),
|
||||
original_headers: effective_headers,
|
||||
request_path: Some(parts.uri.path()),
|
||||
request_query_string: parts.uri.query(),
|
||||
request_origin: Some(crate::ai_serving::request_origin_from_parts(parts)),
|
||||
original_request_body_json: Some(body_json),
|
||||
original_request_body_base64: body_base64,
|
||||
client_session_affinity: input.client_session_affinity.as_ref(),
|
||||
routing_policy: input.routing_policy.as_ref(),
|
||||
scheduler_affinity_epoch: eligible.orchestration.scheduler_affinity_epoch,
|
||||
client_requested_stream: spec_metadata.require_streaming,
|
||||
upstream_is_stream,
|
||||
has_envelope: false,
|
||||
needs_conversion,
|
||||
extra_fields,
|
||||
}),
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
spec_metadata.api_format,
|
||||
provider_api_format.as_str(),
|
||||
);
|
||||
let report_context = append_local_failover_policy_to_value(report_context, &transport);
|
||||
let request_encoding = resolve_transport_request_encoding_policy(&transport);
|
||||
|
||||
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
|
||||
decision_is_stream: spec_metadata.require_streaming,
|
||||
decision_kind: spec_metadata.decision_kind.to_string(),
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
request_id: trace_id.to_string(),
|
||||
candidate_id: candidate_id.clone(),
|
||||
provider_name: transport.provider.name.clone(),
|
||||
provider_type: transport.provider.provider_type.clone(),
|
||||
provider_id: candidate.provider_id.clone(),
|
||||
endpoint_id: candidate.endpoint_id.clone(),
|
||||
key_id: candidate.key_id.clone(),
|
||||
upstream_base_url: transport.endpoint.base_url.clone(),
|
||||
upstream_url: resolved.upstream_url,
|
||||
provider_request_method: Some(parts.method.to_string()),
|
||||
auth_header: Some(resolved.auth_header),
|
||||
auth_value: Some(resolved.auth_value),
|
||||
provider_api_format,
|
||||
client_api_format: spec_metadata.api_format.to_string(),
|
||||
model_name: resolved.requested_model,
|
||||
mapped_model: resolved.mapped_model,
|
||||
prompt_cache_key: None,
|
||||
provider_request_headers: resolved.provider_request_headers,
|
||||
provider_request_body: Some(resolved.provider_request_body),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: request_encoding.content_encoding,
|
||||
request_gzip: request_encoding.request_gzip,
|
||||
proxy,
|
||||
transport_profile,
|
||||
timeouts: resolve_transport_execution_timeouts(&transport),
|
||||
upstream_is_stream,
|
||||
report_kind: spec_metadata.report_kind.map(ToOwned::to_owned),
|
||||
report_context: Some(report_context),
|
||||
auth_context: input.auth_context.clone(),
|
||||
});
|
||||
apply_provider_request_routing_policy_to_decision(
|
||||
input,
|
||||
&mut decision,
|
||||
Some(transport.as_ref()),
|
||||
)?;
|
||||
Ok(Some(decision))
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use aether_contracts::ResolvedTransportProfile;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::planner::candidate_preparation::{
|
||||
prepare_header_authenticated_candidate, OauthPreparationContext,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_openai_image_spec_metadata;
|
||||
use crate::ai_serving::pure::normalize_openai_image_request_with_options;
|
||||
use crate::ai_serving::transport::{
|
||||
build_grok_browser_headers, build_grok_upstream_url, build_openai_image_headers,
|
||||
build_openai_image_upstream_url, build_standard_provider_request_headers,
|
||||
openai_image_transport_unsupported_reason, resolve_openai_image_auth, GrokHeaderInput,
|
||||
ProviderOpenAiImageHeadersInput, StandardProviderRequestHeadersInput, GROK_CHAT_PATH,
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
apply_codex_openai_special_headers, build_chatgpt_web_image_request_body,
|
||||
build_codex_openai_image_api_provider_request_body,
|
||||
build_gemini_image_request_body_from_openai_image_request,
|
||||
build_openai_image_api_provider_request_body, build_openai_image_provider_request_body,
|
||||
default_model_for_openai_image_operation, normalize_openai_image_request,
|
||||
request_conversion_direct_auth, CandidateFailureDiagnostic, GatewayProviderTransportSnapshot,
|
||||
PlannerAppState, RequestConversionKind,
|
||||
};
|
||||
use crate::image_capabilities::openai_image_normalize_options_for_provider;
|
||||
use crate::AppState;
|
||||
|
||||
use super::support::{
|
||||
mark_skipped_local_openai_image_candidate,
|
||||
mark_skipped_local_openai_image_candidate_with_failure_diagnostic,
|
||||
LocalOpenAiImageCandidateAttempt, LocalOpenAiImageDecisionInput,
|
||||
};
|
||||
use super::LocalOpenAiImageSpec;
|
||||
|
||||
pub(super) use crate::ai_serving::resolve_requested_openai_image_model_for_request as resolve_requested_image_model_for_request;
|
||||
|
||||
pub(super) struct LocalOpenAiImageCandidatePayloadParts {
|
||||
pub(super) transport: Arc<GatewayProviderTransportSnapshot>,
|
||||
pub(super) auth_header: String,
|
||||
pub(super) auth_value: String,
|
||||
pub(super) requested_model: String,
|
||||
pub(super) mapped_model: String,
|
||||
pub(super) provider_api_format: String,
|
||||
pub(super) provider_request_headers: BTreeMap<String, String>,
|
||||
pub(super) provider_request_body: Value,
|
||||
pub(super) upstream_url: String,
|
||||
pub(super) input_summary: Value,
|
||||
pub(super) transport_profile: Option<ResolvedTransportProfile>,
|
||||
pub(super) upstream_is_stream: bool,
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &Value,
|
||||
body_base64: Option<&str>,
|
||||
trace_id: &str,
|
||||
input: &LocalOpenAiImageDecisionInput,
|
||||
attempt: &LocalOpenAiImageCandidateAttempt,
|
||||
spec: LocalOpenAiImageSpec,
|
||||
) -> Option<LocalOpenAiImageCandidatePayloadParts> {
|
||||
let spec_metadata = local_openai_image_spec_metadata(spec);
|
||||
let candidate = &attempt.eligible.candidate;
|
||||
let transport = &attempt.eligible.transport;
|
||||
let provider_api_format = attempt.eligible.provider_api_format.as_str();
|
||||
let effective_headers = input.effective_headers(&parts.headers);
|
||||
|
||||
if provider_api_format == "gemini:generate_content" {
|
||||
return resolve_local_openai_image_to_gemini_candidate_payload_parts(
|
||||
state,
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
trace_id,
|
||||
input,
|
||||
attempt,
|
||||
spec,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Some(skip_reason) =
|
||||
openai_image_transport_unsupported_reason(transport, spec_metadata.api_format)
|
||||
{
|
||||
mark_skipped_local_openai_image_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
let prepared_candidate = match prepare_header_authenticated_candidate(
|
||||
PlannerAppState::new(state),
|
||||
transport,
|
||||
candidate,
|
||||
resolve_openai_image_auth(transport),
|
||||
OauthPreparationContext {
|
||||
trace_id,
|
||||
api_format: spec_metadata.api_format,
|
||||
operation: "openai_image_candidate_request",
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(prepared) => prepared,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_openai_image_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let auth_header = prepared_candidate.auth_header;
|
||||
let auth_value = prepared_candidate.auth_value;
|
||||
|
||||
let normalized_request = normalize_openai_image_request_with_options(
|
||||
parts,
|
||||
body_json,
|
||||
body_base64,
|
||||
openai_image_normalize_options_for_provider(
|
||||
&transport.provider.provider_type,
|
||||
Some(prepared_candidate.mapped_model.as_str()),
|
||||
),
|
||||
);
|
||||
let Some(normalized_request) = normalized_request else {
|
||||
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"provider_request_body_missing",
|
||||
CandidateFailureDiagnostic::provider_request_body_missing(
|
||||
spec_metadata.api_format,
|
||||
spec_metadata.api_format,
|
||||
"openai_image_request_normalize",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
let is_chatgpt_web = transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("chatgpt_web");
|
||||
let is_grok = transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("grok");
|
||||
let is_codex = transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("codex");
|
||||
let transport_profile = crate::ai_serving::transport::resolve_transport_profile(transport);
|
||||
let upstream_url = if is_chatgpt_web {
|
||||
chatgpt_web_image_internal_url(&transport.endpoint.base_url)
|
||||
} else if is_grok {
|
||||
build_grok_upstream_url(transport, GROK_CHAT_PATH)
|
||||
} else {
|
||||
build_openai_image_upstream_url(transport, Some(parts.uri.path()), parts.uri.query())
|
||||
};
|
||||
let upstream_is_stream =
|
||||
crate::ai_serving::planner::common::resolve_upstream_is_stream_for_provider(
|
||||
transport.endpoint.config.as_ref(),
|
||||
transport.provider.provider_type.as_str(),
|
||||
spec_metadata.api_format,
|
||||
spec_metadata.require_streaming && candidate.supports_streaming,
|
||||
false,
|
||||
);
|
||||
let provider_request_body = if is_chatgpt_web {
|
||||
Some(
|
||||
match build_chatgpt_web_image_request_body(parts, body_json, body_base64) {
|
||||
Ok(body) => body,
|
||||
Err(err) => err.to_error_json(),
|
||||
},
|
||||
)
|
||||
} else if is_codex {
|
||||
build_codex_openai_image_api_provider_request_body(
|
||||
&normalized_request,
|
||||
Some(prepared_candidate.mapped_model.as_str()),
|
||||
upstream_is_stream,
|
||||
)
|
||||
} else if is_grok {
|
||||
Some(build_openai_image_provider_request_body(
|
||||
&normalized_request,
|
||||
))
|
||||
} else {
|
||||
build_openai_image_api_provider_request_body(
|
||||
&normalized_request,
|
||||
Some(prepared_candidate.mapped_model.as_str()),
|
||||
upstream_is_stream,
|
||||
)
|
||||
};
|
||||
let Some(provider_request_body) = provider_request_body else {
|
||||
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"provider_request_body_missing",
|
||||
CandidateFailureDiagnostic::provider_request_body_missing(
|
||||
spec_metadata.api_format,
|
||||
spec_metadata.api_format,
|
||||
"codex_openai_images_request_contract",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
let Some(mut provider_request_headers) = (if is_grok {
|
||||
build_grok_browser_headers(GrokHeaderInput {
|
||||
transport,
|
||||
transport_profile: transport_profile.as_ref(),
|
||||
request_headers: Some(effective_headers),
|
||||
content_type: "application/json",
|
||||
accept: "*/*",
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: body_json,
|
||||
})
|
||||
} else {
|
||||
build_openai_image_headers(ProviderOpenAiImageHeadersInput {
|
||||
transport,
|
||||
headers: effective_headers,
|
||||
auth_header: &auth_header,
|
||||
auth_value: &auth_value,
|
||||
accept: if is_codex {
|
||||
None
|
||||
} else if upstream_is_stream {
|
||||
Some("text/event-stream")
|
||||
} else {
|
||||
Some("application/json")
|
||||
},
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: body_json,
|
||||
})
|
||||
}) else {
|
||||
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"transport_header_rules_apply_failed",
|
||||
CandidateFailureDiagnostic::header_rules_apply_failed(
|
||||
spec_metadata.api_format,
|
||||
spec_metadata.api_format,
|
||||
"openai_image_header_rules",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
if is_chatgpt_web {
|
||||
provider_request_headers.insert("x-aether-chatgpt-web-image".to_string(), "1".to_string());
|
||||
} else if is_grok {
|
||||
} else {
|
||||
apply_codex_openai_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
effective_headers,
|
||||
transport.provider.provider_type.as_str(),
|
||||
spec_metadata.api_format,
|
||||
Some(trace_id),
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
}
|
||||
let requested_model = normalized_request
|
||||
.requested_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| {
|
||||
default_model_for_openai_image_operation(normalized_request.operation).to_string()
|
||||
});
|
||||
let mapped_model = provider_request_body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let input_summary = if is_chatgpt_web || is_grok {
|
||||
provider_request_body.clone()
|
||||
} else {
|
||||
normalized_request.summary_json
|
||||
};
|
||||
|
||||
Some(LocalOpenAiImageCandidatePayloadParts {
|
||||
transport: Arc::clone(transport),
|
||||
auth_header,
|
||||
auth_value,
|
||||
requested_model,
|
||||
mapped_model,
|
||||
provider_api_format: spec_metadata.api_format.to_string(),
|
||||
provider_request_headers,
|
||||
provider_request_body,
|
||||
upstream_url,
|
||||
input_summary,
|
||||
transport_profile,
|
||||
upstream_is_stream,
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_local_openai_image_to_gemini_candidate_payload_parts(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &Value,
|
||||
body_base64: Option<&str>,
|
||||
trace_id: &str,
|
||||
input: &LocalOpenAiImageDecisionInput,
|
||||
attempt: &LocalOpenAiImageCandidateAttempt,
|
||||
spec: LocalOpenAiImageSpec,
|
||||
) -> Option<LocalOpenAiImageCandidatePayloadParts> {
|
||||
let spec_metadata = local_openai_image_spec_metadata(spec);
|
||||
let candidate = &attempt.eligible.candidate;
|
||||
let transport = &attempt.eligible.transport;
|
||||
let provider_api_format = "gemini:generate_content";
|
||||
let effective_headers = input.effective_headers(&parts.headers);
|
||||
|
||||
let prepared_candidate = match prepare_header_authenticated_candidate(
|
||||
PlannerAppState::new(state),
|
||||
transport,
|
||||
candidate,
|
||||
request_conversion_direct_auth(transport, RequestConversionKind::ToGeminiStandard),
|
||||
OauthPreparationContext {
|
||||
trace_id,
|
||||
api_format: provider_api_format,
|
||||
operation: "openai_image_to_gemini_candidate_request",
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(prepared) => prepared,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_openai_image_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(normalized_request) = normalize_openai_image_request(parts, body_json, body_base64)
|
||||
else {
|
||||
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"provider_request_body_missing",
|
||||
CandidateFailureDiagnostic::provider_request_body_missing(
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
"openai_image_request_normalize",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
let Some(mut converted) = build_gemini_image_request_body_from_openai_image_request(
|
||||
&normalized_request,
|
||||
&prepared_candidate.mapped_model,
|
||||
) else {
|
||||
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"provider_request_body_missing",
|
||||
CandidateFailureDiagnostic::provider_request_body_missing(
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
"openai_image_to_gemini_request_body",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
converted.body_json =
|
||||
match crate::ai_serving::transport::apply_standard_provider_request_body_rules_with_request_headers(
|
||||
converted.body_json,
|
||||
transport.endpoint.body_rules.as_ref(),
|
||||
body_json,
|
||||
effective_headers,
|
||||
) {
|
||||
Some(body) => body,
|
||||
None => {
|
||||
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"provider_request_body_missing",
|
||||
CandidateFailureDiagnostic::provider_request_body_missing(
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
"openai_image_to_gemini_body_rules",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let upstream_is_stream =
|
||||
crate::ai_serving::planner::common::resolve_upstream_is_stream_for_provider(
|
||||
transport.endpoint.config.as_ref(),
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
spec_metadata.require_streaming && candidate.supports_streaming,
|
||||
false,
|
||||
);
|
||||
let Some(upstream_url) = crate::ai_serving::planner::standard::build_standard_upstream_url(
|
||||
parts,
|
||||
transport,
|
||||
&converted.mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
Some(&converted.body_json),
|
||||
) else {
|
||||
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"upstream_url_missing",
|
||||
CandidateFailureDiagnostic::upstream_url_missing(
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
"openai_image_to_gemini_url",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
let Some(resolved_headers) =
|
||||
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
|
||||
transport,
|
||||
provider_api_format,
|
||||
same_format: false,
|
||||
headers: effective_headers,
|
||||
auth_header: &prepared_candidate.auth_header,
|
||||
auth_value: &prepared_candidate.auth_value,
|
||||
extra_headers: &BTreeMap::new(),
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &converted.body_json,
|
||||
original_request_body: body_json,
|
||||
upstream_is_stream,
|
||||
})
|
||||
else {
|
||||
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"transport_header_rules_apply_failed",
|
||||
CandidateFailureDiagnostic::header_rules_apply_failed(
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
"openai_image_to_gemini_headers",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(LocalOpenAiImageCandidatePayloadParts {
|
||||
transport: Arc::clone(transport),
|
||||
auth_header: resolved_headers.auth_header,
|
||||
auth_value: resolved_headers.auth_value,
|
||||
requested_model: converted.requested_model,
|
||||
mapped_model: converted.mapped_model,
|
||||
provider_api_format: provider_api_format.to_string(),
|
||||
provider_request_headers: resolved_headers.headers,
|
||||
provider_request_body: converted.body_json,
|
||||
upstream_url,
|
||||
input_summary: converted.summary_json,
|
||||
transport_profile: None,
|
||||
upstream_is_stream,
|
||||
})
|
||||
}
|
||||
|
||||
fn chatgpt_web_image_internal_url(base_url: &str) -> String {
|
||||
let base_url = base_url.trim().trim_end_matches('/');
|
||||
let base_url = if base_url.is_empty() {
|
||||
"https://chatgpt.com"
|
||||
} else {
|
||||
base_url
|
||||
};
|
||||
format!("{base_url}/__aether/chatgpt-web-image")
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_serving::planner::candidate_materialization::{
|
||||
build_local_execution_candidate_attempt_source_with_serving,
|
||||
mark_skipped_local_execution_candidate,
|
||||
mark_skipped_local_execution_candidate_with_failure_diagnostic,
|
||||
materialize_local_execution_candidates_with_serving, LocalCandidateResolutionMode,
|
||||
};
|
||||
use crate::ai_serving::planner::candidate_metadata::{
|
||||
build_local_execution_candidate_metadata,
|
||||
build_local_execution_candidate_metadata_for_candidate, LocalExecutionCandidateMetadataParts,
|
||||
};
|
||||
use crate::ai_serving::planner::candidate_resolution::SkippedLocalExecutionCandidate;
|
||||
use crate::ai_serving::planner::candidate_source::auth_snapshot_allows_cross_format_candidate;
|
||||
use crate::ai_serving::planner::decision_input::{
|
||||
attach_routing_policy_to_local_requested_model_input,
|
||||
build_local_requested_model_decision_input, resolve_local_authenticated_decision_input,
|
||||
};
|
||||
use crate::ai_serving::planner::materialization_policy::{
|
||||
build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_openai_image_spec_metadata;
|
||||
use crate::ai_serving::{
|
||||
extract_pool_sticky_session_token, request_candidate_api_formats,
|
||||
resolve_local_decision_execution_runtime_auth_context, CandidateFailureDiagnostic,
|
||||
ExecutionRuntimeAuthContext, GatewayControlDecision, PlannerAppState,
|
||||
};
|
||||
use crate::client_session_affinity::client_session_affinity_from_parts;
|
||||
use crate::clock::current_unix_secs;
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
|
||||
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalOpenAiImageCandidateAttempt;
|
||||
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttemptSource as LocalOpenAiImageCandidateAttemptSource;
|
||||
pub(super) use crate::ai_serving::planner::decision_input::LocalRequestedModelDecisionInput as LocalOpenAiImageDecisionInput;
|
||||
|
||||
use super::request::resolve_requested_image_model_for_request;
|
||||
|
||||
pub(super) async fn resolve_local_openai_image_decision_input(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
body_base64: Option<&str>,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Result<Option<LocalOpenAiImageDecisionInput>, GatewayError> {
|
||||
let Some(auth_context) = resolve_local_openai_image_auth_context(decision) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(requested_model) =
|
||||
resolve_requested_image_model_for_request(parts, body_json, body_base64)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let resolved_input = match resolve_local_authenticated_decision_input(
|
||||
state,
|
||||
auth_context,
|
||||
Some(requested_model.as_str()),
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
None,
|
||||
&decision.model_directive_policy,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(resolved_input)) => resolved_input,
|
||||
Ok(None) => return Ok(None),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
error = ?err,
|
||||
"gateway local openai image decision auth snapshot read failed"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
|
||||
input.request_auth_channel = decision.request_auth_channel.clone();
|
||||
input.client_session_affinity = client_session_affinity_from_parts(parts, Some(body_json));
|
||||
if let Err(err) = attach_routing_policy_to_local_requested_model_input(
|
||||
state,
|
||||
parts,
|
||||
&mut input,
|
||||
body_json,
|
||||
"openai:image",
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
error = ?err,
|
||||
"gateway local openai image decision routing profile resolution failed"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
Ok(Some(input))
|
||||
}
|
||||
|
||||
fn resolve_local_openai_image_auth_context(
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Option<ExecutionRuntimeAuthContext> {
|
||||
resolve_local_decision_execution_runtime_auth_context(decision)
|
||||
}
|
||||
|
||||
pub(super) async fn list_local_openai_image_candidate_attempts(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
input: &LocalOpenAiImageDecisionInput,
|
||||
body_json: &serde_json::Value,
|
||||
api_format: &str,
|
||||
decision_kind: &str,
|
||||
) -> Option<Vec<LocalOpenAiImageCandidateAttempt>> {
|
||||
let candidate_api_formats = image_candidate_api_formats(api_format);
|
||||
let mut attempts = Vec::new();
|
||||
for candidate_api_format in candidate_api_formats {
|
||||
let matches_client_format = candidate_api_format == api_format;
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let (mut candidates, preselection_skipped) = match planner_state
|
||||
.list_selectable_candidates_with_skip_reasons(
|
||||
candidate_api_format,
|
||||
&input.requested_model,
|
||||
false,
|
||||
input.required_capabilities.as_ref(),
|
||||
matches_client_format.then_some(&input.auth_snapshot),
|
||||
input.client_session_affinity.as_ref(),
|
||||
current_unix_secs(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(candidates) => candidates,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
decision_kind,
|
||||
api_format = candidate_api_format,
|
||||
error = ?err,
|
||||
"gateway local openai image decision scheduler selection failed"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !matches_client_format {
|
||||
candidates.retain(|candidate| {
|
||||
auth_snapshot_allows_cross_format_candidate(
|
||||
&input.auth_snapshot,
|
||||
&input.requested_model,
|
||||
None,
|
||||
candidate,
|
||||
)
|
||||
});
|
||||
}
|
||||
attempts.extend(
|
||||
materialize_local_openai_image_candidate_attempts(
|
||||
planner_state,
|
||||
trace_id,
|
||||
input,
|
||||
body_json,
|
||||
candidates,
|
||||
preselection_skipped
|
||||
.into_iter()
|
||||
.map(|item| SkippedLocalExecutionCandidate {
|
||||
candidate: item.candidate,
|
||||
skip_reason: item.skip_reason,
|
||||
transport: None,
|
||||
ranking: None,
|
||||
extra_data: None,
|
||||
})
|
||||
.collect(),
|
||||
api_format,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
Some(attempts)
|
||||
}
|
||||
|
||||
pub(super) async fn build_local_openai_image_candidate_attempt_source<'a>(
|
||||
state: &'a AppState,
|
||||
trace_id: &str,
|
||||
input: &LocalOpenAiImageDecisionInput,
|
||||
body_json: &serde_json::Value,
|
||||
api_format: &str,
|
||||
decision_kind: &str,
|
||||
) -> Result<Option<(LocalOpenAiImageCandidateAttemptSource<'a>, usize)>, GatewayError> {
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let mut candidates = Vec::new();
|
||||
let mut preselection_skipped = Vec::new();
|
||||
for candidate_api_format in image_candidate_api_formats(api_format) {
|
||||
let matches_client_format = candidate_api_format == api_format;
|
||||
match planner_state
|
||||
.list_selectable_candidates_with_skip_reasons(
|
||||
candidate_api_format,
|
||||
&input.requested_model,
|
||||
false,
|
||||
input.required_capabilities.as_ref(),
|
||||
matches_client_format.then_some(&input.auth_snapshot),
|
||||
input.client_session_affinity.as_ref(),
|
||||
current_unix_secs(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((mut format_candidates, mut format_skipped)) => {
|
||||
if !matches_client_format {
|
||||
format_candidates.retain(|candidate| {
|
||||
auth_snapshot_allows_cross_format_candidate(
|
||||
&input.auth_snapshot,
|
||||
&input.requested_model,
|
||||
None,
|
||||
candidate,
|
||||
)
|
||||
});
|
||||
format_skipped.retain(|candidate| {
|
||||
auth_snapshot_allows_cross_format_candidate(
|
||||
&input.auth_snapshot,
|
||||
&input.requested_model,
|
||||
None,
|
||||
&candidate.candidate,
|
||||
)
|
||||
});
|
||||
}
|
||||
candidates.append(&mut format_candidates);
|
||||
preselection_skipped.append(&mut format_skipped);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
decision_kind,
|
||||
api_format = candidate_api_format,
|
||||
error = ?err,
|
||||
"gateway local openai image decision scheduler selection failed"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sticky_session_token = extract_pool_sticky_session_token(body_json);
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::ImageDecision,
|
||||
);
|
||||
|
||||
let (source, candidate_count) = build_local_execution_candidate_attempt_source_with_serving(
|
||||
planner_state,
|
||||
trace_id,
|
||||
api_format,
|
||||
Some(&input.requested_model),
|
||||
Some(&input.auth_snapshot),
|
||||
input.client_session_affinity.as_ref(),
|
||||
input.required_capabilities.as_ref(),
|
||||
input.routing_policy.as_ref(),
|
||||
sticky_session_token.as_deref(),
|
||||
input.request_auth_channel.as_deref(),
|
||||
persistence_policy,
|
||||
candidates,
|
||||
preselection_skipped
|
||||
.into_iter()
|
||||
.map(|item| SkippedLocalExecutionCandidate {
|
||||
candidate: item.candidate,
|
||||
skip_reason: item.skip_reason,
|
||||
transport: None,
|
||||
ranking: None,
|
||||
extra_data: None,
|
||||
})
|
||||
.collect(),
|
||||
LocalCandidateResolutionMode::WithoutTransportPairGate,
|
||||
|eligible| {
|
||||
Some(build_local_execution_candidate_metadata(
|
||||
LocalExecutionCandidateMetadataParts {
|
||||
eligible,
|
||||
provider_api_format: eligible.provider_api_format.as_str(),
|
||||
client_api_format: api_format,
|
||||
extra_fields: serde_json::Map::new(),
|
||||
},
|
||||
))
|
||||
},
|
||||
|mut skipped_candidate| {
|
||||
let provider_api_format = skipped_candidate
|
||||
.transport
|
||||
.as_ref()
|
||||
.map(|transport| transport.endpoint.api_format.trim().to_ascii_lowercase())
|
||||
.unwrap_or_else(|| {
|
||||
skipped_candidate
|
||||
.candidate
|
||||
.endpoint_api_format
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
});
|
||||
skipped_candidate.extra_data =
|
||||
Some(build_local_execution_candidate_metadata_for_candidate(
|
||||
&skipped_candidate.candidate,
|
||||
skipped_candidate.transport_ref(),
|
||||
provider_api_format.as_str(),
|
||||
api_format,
|
||||
serde_json::Map::new(),
|
||||
));
|
||||
skipped_candidate
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Some((source, candidate_count)))
|
||||
}
|
||||
|
||||
async fn materialize_local_openai_image_candidate_attempts(
|
||||
state: PlannerAppState<'_>,
|
||||
trace_id: &str,
|
||||
input: &LocalOpenAiImageDecisionInput,
|
||||
body_json: &serde_json::Value,
|
||||
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
preselection_skipped: Vec<SkippedLocalExecutionCandidate>,
|
||||
api_format: &str,
|
||||
) -> Vec<LocalOpenAiImageCandidateAttempt> {
|
||||
let sticky_session_token = extract_pool_sticky_session_token(body_json);
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::ImageDecision,
|
||||
);
|
||||
let outcome = materialize_local_execution_candidates_with_serving(
|
||||
state,
|
||||
trace_id,
|
||||
api_format,
|
||||
Some(&input.requested_model),
|
||||
Some(&input.auth_snapshot),
|
||||
input.client_session_affinity.as_ref(),
|
||||
input.required_capabilities.as_ref(),
|
||||
input.routing_policy.as_ref(),
|
||||
sticky_session_token.as_deref(),
|
||||
input.request_auth_channel.as_deref(),
|
||||
persistence_policy,
|
||||
candidates,
|
||||
preselection_skipped,
|
||||
LocalCandidateResolutionMode::WithoutTransportPairGate,
|
||||
|eligible| {
|
||||
Some(build_local_execution_candidate_metadata(
|
||||
LocalExecutionCandidateMetadataParts {
|
||||
eligible,
|
||||
provider_api_format: eligible.provider_api_format.as_str(),
|
||||
client_api_format: api_format,
|
||||
extra_fields: serde_json::Map::new(),
|
||||
},
|
||||
))
|
||||
},
|
||||
|mut skipped_candidate| {
|
||||
let provider_api_format = skipped_candidate
|
||||
.transport
|
||||
.as_ref()
|
||||
.map(|transport| transport.endpoint.api_format.trim().to_ascii_lowercase())
|
||||
.unwrap_or_else(|| {
|
||||
skipped_candidate
|
||||
.candidate
|
||||
.endpoint_api_format
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
});
|
||||
skipped_candidate.extra_data =
|
||||
Some(build_local_execution_candidate_metadata_for_candidate(
|
||||
&skipped_candidate.candidate,
|
||||
skipped_candidate.transport_ref(),
|
||||
provider_api_format.as_str(),
|
||||
api_format,
|
||||
serde_json::Map::new(),
|
||||
));
|
||||
skipped_candidate
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
outcome.attempts
|
||||
}
|
||||
|
||||
fn image_candidate_api_formats(api_format: &str) -> Vec<&'static str> {
|
||||
if api_format.trim().eq_ignore_ascii_case("openai:image") {
|
||||
vec!["openai:image", "gemini:generate_content"]
|
||||
} else {
|
||||
request_candidate_api_formats(api_format, false)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn mark_skipped_local_openai_image_candidate(
|
||||
state: &AppState,
|
||||
input: &LocalOpenAiImageDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
) {
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::ImageDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) async fn mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
||||
state: &AppState,
|
||||
input: &LocalOpenAiImageDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
diagnostic: CandidateFailureDiagnostic,
|
||||
) {
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::ImageDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
diagnostic,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Non-matrix AI surfaces such as files and video.
|
||||
|
||||
mod files;
|
||||
mod image;
|
||||
mod video;
|
||||
|
||||
pub(crate) use self::files::{
|
||||
build_local_gemini_files_stream_attempt_source_for_kind,
|
||||
build_local_gemini_files_stream_plan_and_reports_for_kind,
|
||||
build_local_gemini_files_sync_attempt_source_for_kind,
|
||||
build_local_gemini_files_sync_plan_and_reports_for_kind,
|
||||
maybe_build_stream_local_gemini_files_decision_payload,
|
||||
maybe_build_sync_local_gemini_files_decision_payload,
|
||||
};
|
||||
pub(crate) use self::image::{
|
||||
build_local_image_stream_attempt_source_for_kind,
|
||||
build_local_image_stream_plan_and_reports_for_kind,
|
||||
build_local_image_sync_attempt_source_for_kind,
|
||||
build_local_image_sync_plan_and_reports_for_kind,
|
||||
maybe_build_stream_local_image_decision_payload, maybe_build_sync_local_image_decision_payload,
|
||||
set_local_openai_image_execution_exhausted_diagnostic,
|
||||
};
|
||||
pub(crate) use self::video::{
|
||||
build_local_video_sync_attempt_source_for_kind,
|
||||
build_local_video_sync_plan_and_reports_for_kind,
|
||||
maybe_build_sync_local_video_decision_payload,
|
||||
};
|
||||
@@ -0,0 +1,282 @@
|
||||
mod decision;
|
||||
mod request;
|
||||
mod support;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_serving::planner::candidate_materialization::LocalExecutionAttemptSource;
|
||||
use crate::ai_serving::planner::plan_builders::{
|
||||
build_passthrough_sync_plan_from_decision, AiSyncAttempt,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_video_create_spec_metadata;
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::ai_serving::{
|
||||
resolve_local_video_sync_spec as resolve_sync_spec, LocalVideoCreateFamily,
|
||||
LocalVideoCreateSpec,
|
||||
};
|
||||
use crate::{AiExecutionDecision, AppState, GatewayError};
|
||||
|
||||
use self::decision::maybe_build_local_video_create_decision_payload_for_candidate;
|
||||
use self::support::{
|
||||
build_local_video_create_candidate_attempt_source, list_local_video_create_candidate_attempts,
|
||||
resolve_local_video_create_decision_input, LocalVideoCreateCandidateAttempt,
|
||||
LocalVideoCreateCandidateAttemptSource, LocalVideoCreateDecisionInput,
|
||||
};
|
||||
|
||||
pub(crate) struct LocalVideoCreateSyncAttemptSource<'a> {
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
body_json: serde_json::Value,
|
||||
trace_id: &'a str,
|
||||
input: LocalVideoCreateDecisionInput,
|
||||
spec: LocalVideoCreateSpec,
|
||||
candidates: LocalVideoCreateCandidateAttemptSource<'a>,
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_video_sync_plan_and_reports_for_kind(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
build_local_sync_plan_and_reports(state, parts, body_json, trace_id, decision, spec).await
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_video_sync_attempt_source_for_kind<'a>(
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
body_json: &'a serde_json::Value,
|
||||
trace_id: &'a str,
|
||||
decision: &'a GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<(LocalVideoCreateSyncAttemptSource<'a>, usize)>, GatewayError> {
|
||||
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let spec_metadata = local_video_create_spec_metadata(spec);
|
||||
|
||||
let Some(input) = resolve_local_video_create_decision_input(
|
||||
state, parts, trace_id, decision, body_json, spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let effective_body_json = input.effective_body_json(body_json).clone();
|
||||
let Some((candidates, candidate_count)) = build_local_video_create_candidate_attempt_source(
|
||||
state,
|
||||
trace_id,
|
||||
&input,
|
||||
&effective_body_json,
|
||||
spec_metadata.api_format,
|
||||
spec_metadata.decision_kind,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if candidate_count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some((
|
||||
LocalVideoCreateSyncAttemptSource {
|
||||
state,
|
||||
parts,
|
||||
body_json: effective_body_json,
|
||||
trace_id,
|
||||
input,
|
||||
spec,
|
||||
candidates,
|
||||
},
|
||||
candidate_count,
|
||||
)))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalVideoCreateSyncAttemptSource<'_> {
|
||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||
while let Some(attempt) = self.candidates.next_attempt().await? {
|
||||
match self.build_sync_attempt(attempt).await? {
|
||||
Some(attempt) => return Ok(Some(attempt)),
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||
let mut drained = Vec::new();
|
||||
for attempt in self.candidates.drain_static_attempts() {
|
||||
if let Some(attempt) = self.build_sync_attempt(attempt).await? {
|
||||
drained.push(attempt);
|
||||
}
|
||||
}
|
||||
Ok(drained)
|
||||
}
|
||||
|
||||
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_credential(key_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_endpoint(endpoint_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||
self.candidates.skip_provider(provider_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalVideoCreateSyncAttemptSource<'_> {
|
||||
async fn build_sync_attempt(
|
||||
&self,
|
||||
attempt: LocalVideoCreateCandidateAttempt,
|
||||
) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||
let spec_metadata = local_video_create_spec_metadata(self.spec);
|
||||
let Some(payload) = maybe_build_local_video_create_decision_payload_for_candidate(
|
||||
self.state,
|
||||
self.parts,
|
||||
&self.body_json,
|
||||
self.trace_id,
|
||||
&self.input,
|
||||
attempt,
|
||||
self.spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match build_passthrough_sync_plan_from_decision(self.parts, payload) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %self.trace_id,
|
||||
decision_kind = spec_metadata.decision_kind,
|
||||
error = ?err,
|
||||
"gateway local video sync decision plan build failed"
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_sync_local_video_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let spec_metadata = local_video_create_spec_metadata(spec);
|
||||
|
||||
let Some(input) = resolve_local_video_create_decision_input(
|
||||
state, parts, trace_id, decision, body_json, spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let body_json = input.effective_body_json(body_json);
|
||||
|
||||
let Some((mut source, _)) = build_local_video_create_candidate_attempt_source(
|
||||
state,
|
||||
trace_id,
|
||||
&input,
|
||||
body_json,
|
||||
spec_metadata.api_format,
|
||||
spec_metadata.decision_kind,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
while let Some(attempt) = source.next_attempt().await? {
|
||||
if let Some(payload) = maybe_build_local_video_create_decision_payload_for_candidate(
|
||||
state, parts, body_json, trace_id, &input, attempt, spec,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(payload));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn build_local_sync_plan_and_reports(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
spec: LocalVideoCreateSpec,
|
||||
) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||
let spec_metadata = local_video_create_spec_metadata(spec);
|
||||
let Some(input) = resolve_local_video_create_decision_input(
|
||||
state, parts, trace_id, decision, body_json, spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let body_json = input.effective_body_json(body_json);
|
||||
|
||||
let Some((mut source, _)) = build_local_video_create_candidate_attempt_source(
|
||||
state,
|
||||
trace_id,
|
||||
&input,
|
||||
body_json,
|
||||
spec_metadata.api_format,
|
||||
spec_metadata.decision_kind,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let mut plans = Vec::new();
|
||||
while let Some(attempt) = source.next_attempt().await? {
|
||||
let Some(payload) = maybe_build_local_video_create_decision_payload_for_candidate(
|
||||
state, parts, body_json, trace_id, &input, attempt, spec,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match build_passthrough_sync_plan_from_decision(parts, payload) {
|
||||
Ok(Some(value)) => plans.push(value),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
decision_kind = spec_metadata.decision_kind,
|
||||
error = ?err,
|
||||
"gateway local video sync decision plan build failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plans)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use crate::ai_serving::build_request_trace_proxy_value;
|
||||
use crate::ai_serving::planner::decision_input::apply_provider_request_routing_policy_to_decision;
|
||||
use crate::ai_serving::planner::report_context::{
|
||||
build_local_execution_report_context, LocalExecutionReportContextParts,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_video_create_spec_metadata;
|
||||
use crate::ai_serving::planner::{
|
||||
build_ai_execution_decision_response, resolve_transport_request_encoding_policy,
|
||||
AiExecutionDecisionResponseParts,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
resolve_transport_execution_timeouts, resolve_transport_profile,
|
||||
};
|
||||
use crate::ai_serving::{ai_local_execution_contract_for_formats, PlannerAppState};
|
||||
use crate::{append_local_failover_policy_to_value, AiExecutionDecision, AppState, GatewayError};
|
||||
|
||||
use super::request::resolve_local_video_create_candidate_payload_parts;
|
||||
use super::support::{LocalVideoCreateCandidateAttempt, LocalVideoCreateDecisionInput};
|
||||
use super::LocalVideoCreateSpec;
|
||||
|
||||
pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidate(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
trace_id: &str,
|
||||
input: &LocalVideoCreateDecisionInput,
|
||||
attempt: LocalVideoCreateCandidateAttempt,
|
||||
spec: LocalVideoCreateSpec,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let spec_metadata = local_video_create_spec_metadata(spec);
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let attempt_identity = attempt.attempt_identity();
|
||||
let Some(resolved) = resolve_local_video_create_candidate_payload_parts(
|
||||
state, parts, body_json, trace_id, input, &attempt, spec,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let LocalVideoCreateCandidateAttempt {
|
||||
eligible,
|
||||
candidate_id,
|
||||
..
|
||||
} = attempt;
|
||||
let candidate = eligible.candidate;
|
||||
let transport = resolved.transport;
|
||||
let (execution_strategy, conversion_mode) =
|
||||
ai_local_execution_contract_for_formats(spec_metadata.api_format, spec_metadata.api_format);
|
||||
let proxy = planner_state
|
||||
.app()
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&transport)
|
||||
.await;
|
||||
let transport_profile = resolve_transport_profile(&transport);
|
||||
let mut extra_fields = serde_json::Map::new();
|
||||
if let Some(proxy_value) = build_request_trace_proxy_value(Some(&transport), proxy.as_ref()) {
|
||||
extra_fields.insert("proxy".to_string(), proxy_value);
|
||||
}
|
||||
let effective_headers = input.effective_headers(&parts.headers);
|
||||
let report_context = build_local_execution_report_context(LocalExecutionReportContextParts {
|
||||
auth_context: &input.auth_context,
|
||||
request_id: trace_id,
|
||||
candidate_id: &candidate_id,
|
||||
attempt_identity,
|
||||
model: &input.requested_model,
|
||||
provider_name: &transport.provider.name,
|
||||
provider_id: &candidate.provider_id,
|
||||
endpoint_id: &candidate.endpoint_id,
|
||||
key_id: &candidate.key_id,
|
||||
key_name: None,
|
||||
model_id: Some(&candidate.model_id),
|
||||
global_model_id: Some(&candidate.global_model_id),
|
||||
global_model_name: Some(&candidate.global_model_name),
|
||||
provider_api_format: spec_metadata.api_format,
|
||||
client_api_format: spec_metadata.api_format,
|
||||
mapped_model: Some(&resolved.mapped_model),
|
||||
candidate_group_id: eligible.orchestration.candidate_group_id.as_deref(),
|
||||
pool_key_lease: eligible.orchestration.pool_key_lease.as_ref(),
|
||||
ranking: eligible.ranking.as_ref(),
|
||||
upstream_url: None,
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
body_rules: transport.endpoint.body_rules.as_ref(),
|
||||
provider_request_method: None,
|
||||
provider_request_headers: None,
|
||||
original_headers: effective_headers,
|
||||
request_path: Some(parts.uri.path()),
|
||||
request_query_string: parts.uri.query(),
|
||||
request_origin: Some(crate::ai_serving::request_origin_from_parts(parts)),
|
||||
original_request_body_json: Some(body_json),
|
||||
original_request_body_base64: None,
|
||||
client_session_affinity: input.client_session_affinity.as_ref(),
|
||||
routing_policy: input.routing_policy.as_ref(),
|
||||
scheduler_affinity_epoch: eligible.orchestration.scheduler_affinity_epoch,
|
||||
client_requested_stream: false,
|
||||
upstream_is_stream: false,
|
||||
has_envelope: false,
|
||||
needs_conversion: false,
|
||||
extra_fields,
|
||||
});
|
||||
let report_context = append_local_failover_policy_to_value(report_context, &transport);
|
||||
let super::request::LocalVideoCreateCandidatePayloadParts {
|
||||
transport: _,
|
||||
auth_header,
|
||||
auth_value,
|
||||
mapped_model,
|
||||
provider_request_headers,
|
||||
provider_request_body,
|
||||
upstream_url,
|
||||
} = resolved;
|
||||
let request_encoding = resolve_transport_request_encoding_policy(&transport);
|
||||
|
||||
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
|
||||
decision_is_stream: false,
|
||||
decision_kind: spec_metadata.decision_kind.to_string(),
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
request_id: trace_id.to_string(),
|
||||
candidate_id: candidate_id.clone(),
|
||||
provider_name: transport.provider.name.clone(),
|
||||
provider_type: transport.provider.provider_type.clone(),
|
||||
provider_id: candidate.provider_id.clone(),
|
||||
endpoint_id: candidate.endpoint_id.clone(),
|
||||
key_id: candidate.key_id.clone(),
|
||||
upstream_base_url: transport.endpoint.base_url.clone(),
|
||||
upstream_url,
|
||||
provider_request_method: Some(parts.method.to_string()),
|
||||
auth_header: Some(auth_header),
|
||||
auth_value: Some(auth_value),
|
||||
provider_api_format: spec_metadata.api_format.to_string(),
|
||||
client_api_format: spec_metadata.api_format.to_string(),
|
||||
model_name: input.requested_model.clone(),
|
||||
mapped_model,
|
||||
prompt_cache_key: None,
|
||||
provider_request_headers,
|
||||
provider_request_body: Some(provider_request_body),
|
||||
provider_request_body_base64: None,
|
||||
content_type: parts
|
||||
.headers
|
||||
.get(http::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
content_encoding: request_encoding.content_encoding,
|
||||
request_gzip: request_encoding.request_gzip,
|
||||
proxy,
|
||||
transport_profile,
|
||||
timeouts: resolve_transport_execution_timeouts(&transport),
|
||||
upstream_is_stream: false,
|
||||
report_kind: spec_metadata.report_kind.map(ToOwned::to_owned),
|
||||
report_context: Some(report_context),
|
||||
auth_context: input.auth_context.clone(),
|
||||
});
|
||||
apply_provider_request_routing_policy_to_decision(
|
||||
input,
|
||||
&mut decision,
|
||||
Some(transport.as_ref()),
|
||||
)?;
|
||||
Ok(Some(decision))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user