mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 15:26:14 +00:00
Compare commits
605
Commits
v0.1.1
...
fix-migration
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f57beb6411 | ||
|
|
3e41e59a1d | ||
|
|
002d750830 | ||
|
|
8c2eb55760 | ||
|
|
6fbd479bdc | ||
|
|
f67c26935a | ||
|
|
9ce91f58a9 | ||
|
|
bb82361434 | ||
|
|
5f1d67e87e | ||
|
|
b2106ad1e6 | ||
|
|
709a4ba0dc | ||
|
|
21f421b212 | ||
|
|
b3c5e4cbf6 | ||
|
|
1d95379328 | ||
|
|
48529f672a | ||
|
|
030c2f65e4 | ||
|
|
3510402af2 | ||
|
|
5f376d716d | ||
|
|
d889274f84 | ||
|
|
0c0f19d854 | ||
|
|
b61bffc666 | ||
|
|
af658136d4 | ||
|
|
8973627b5f | ||
|
|
25f427033a | ||
|
|
d3dd8318e4 | ||
|
|
52c7f17215 | ||
|
|
2c03302055 | ||
|
|
c3221f2a31 | ||
|
|
58fa063c6b | ||
|
|
7c94f60797 | ||
|
|
4cb4c6dfec | ||
|
|
512b686e5f | ||
|
|
f495a10eeb | ||
|
|
d1692edb63 | ||
|
|
8f81bcd2fc | ||
|
|
6a5ed9d063 | ||
|
|
b9890e6ad5 | ||
|
|
e4b8293d41 | ||
|
|
ba5f9fc181 | ||
|
|
795fff61e0 | ||
|
|
f9bfd4f0d2 | ||
|
|
5683382ada | ||
|
|
c92372dafd | ||
|
|
b58dd78fde | ||
|
|
4c31bf9767 | ||
|
|
42efa3c1ba | ||
|
|
74b1d39d5c | ||
|
|
4df4976f44 | ||
|
|
4aa57959bf | ||
|
|
1af39f043f | ||
|
|
1751cd3b47 | ||
|
|
b1facd58d5 | ||
|
|
6288d6fef7 | ||
|
|
a1223ad610 | ||
|
|
c75f170ed0 | ||
|
|
c6e401c3f6 | ||
|
|
1b3b206a20 | ||
|
|
248937e05f | ||
|
|
b9b477e5eb | ||
|
|
ace8cf960c | ||
|
|
3396e0cd47 | ||
|
|
31898192b2 | ||
|
|
e50facc835 | ||
|
|
55dc485705 | ||
|
|
855d60b4a5 | ||
|
|
27ace348b5 | ||
|
|
80559a57d5 | ||
|
|
8e9f6647e7 | ||
|
|
73e3d34623 | ||
|
|
c8f8857f03 | ||
|
|
c9650441bb | ||
|
|
5ce9c2217f | ||
|
|
89b8488ab8 | ||
|
|
2ee917fa31 | ||
|
|
5e12a7e92d | ||
|
|
1d043cd98d | ||
|
|
39e0959fcd | ||
|
|
29be9d5b9c | ||
|
|
1ebb7d71e1 | ||
|
|
bbf1e65a5d | ||
|
|
51c3e5dcd7 | ||
|
|
788075f656 | ||
|
|
0bbcacd186 | ||
|
|
6d5b811c20 | ||
|
|
42258ae39c | ||
|
|
04d6903369 | ||
|
|
b0b2ceb1a0 | ||
|
|
bf91f401af | ||
|
|
60e0eebd05 | ||
|
|
8fff716bb2 | ||
|
|
b2ef15a406 | ||
|
|
f8fdcf3bf3 | ||
|
|
58c4d3bf5f | ||
|
|
2210e9be6c | ||
|
|
9c66789665 | ||
|
|
9dfa58d69f | ||
|
|
22ec9c3132 | ||
|
|
b72c578954 | ||
|
|
7e299180fe | ||
|
|
f290534df4 | ||
|
|
8d3c064b29 | ||
|
|
4ac96ade5f | ||
|
|
85aa8fbbc5 | ||
|
|
180a469399 | ||
|
|
701b870d63 | ||
|
|
87fbb48ca8 | ||
|
|
3465a44d0e | ||
|
|
a4259af38f | ||
|
|
0d07dd0cdb | ||
|
|
24015ebec1 | ||
|
|
db021866d8 | ||
|
|
d4339287be | ||
|
|
88bcc0edcb | ||
|
|
917a4d32b1 | ||
|
|
daf17f51ab | ||
|
|
fc042c768c | ||
|
|
3bc38937e8 | ||
|
|
9229b87b70 | ||
|
|
367265b9fe | ||
|
|
0b3ccb5fb0 | ||
|
|
dbd43f52fb | ||
|
|
39657ed64f | ||
|
|
493b4f0f1f | ||
|
|
cb36189db3 | ||
|
|
d2487f42b0 | ||
|
|
5255fce7b2 | ||
|
|
ca7e8bec71 | ||
|
|
c4cc09d61e | ||
|
|
ee668ee93b | ||
|
|
cf8b990fc7 | ||
|
|
78dd74845b | ||
|
|
e7f4c98475 | ||
|
|
fad792068e | ||
|
|
21d363f6aa | ||
|
|
5e21f6ccbc | ||
|
|
1e2d130022 | ||
|
|
1e21dce735 | ||
|
|
b7603dcf69 | ||
|
|
7dccfa745f | ||
|
|
54d5118980 | ||
|
|
7723ab4a95 | ||
|
|
86c022d8db | ||
|
|
21ae22abec | ||
|
|
3a939d0dd1 | ||
|
|
50eabafa57 | ||
|
|
d192a6a6b4 | ||
|
|
7d829af681 | ||
|
|
57bf1b68d9 | ||
|
|
00d0415518 | ||
|
|
e8585b276f | ||
|
|
4b5e911435 | ||
|
|
761aabfec3 | ||
|
|
f0c45a7ce4 | ||
|
|
fc8ccf63ba | ||
|
|
eeb70e4ee5 | ||
|
|
a3b410b467 | ||
|
|
9e9bc5bff8 | ||
|
|
bdf0e2c192 | ||
|
|
6d780ef96d | ||
|
|
b70b94b9b4 | ||
|
|
334453f934 | ||
|
|
5a4ba60072 | ||
|
|
f1fa7d094f | ||
|
|
eed5bc5b04 | ||
|
|
f4b014cb05 | ||
|
|
4418d87664 | ||
|
|
634a473f50 | ||
|
|
ea655b748b | ||
|
|
2d247ddc8b | ||
|
|
c064452aea | ||
|
|
1c6a603042 | ||
|
|
84b0007b05 | ||
|
|
525476ccfa | ||
|
|
b54812cb04 | ||
|
|
ee508cbb3a | ||
|
|
0c61fdee07 | ||
|
|
b9418db31f | ||
|
|
71e7c2171b | ||
|
|
a3e8d5fd38 | ||
|
|
41fd2e2dfc | ||
|
|
2c404c66d6 | ||
|
|
0fa3e77f9a | ||
|
|
5b8e56f590 | ||
|
|
a6d0bd1a19 | ||
|
|
9594e9fb52 | ||
|
|
b01c7b2e56 | ||
|
|
dd4ed7541f | ||
|
|
cc42534a97 | ||
|
|
d203370f01 | ||
|
|
e39742c429 | ||
|
|
301dd81215 | ||
|
|
5416cefd87 | ||
|
|
d41c214d9e | ||
|
|
ec0fcfb48b | ||
|
|
8edc3512c1 | ||
|
|
82d2627c60 | ||
|
|
43e97326e0 | ||
|
|
06770a0702 | ||
|
|
590fb4bc2c | ||
|
|
5db9abc3ce | ||
|
|
c11cc107c8 | ||
|
|
547365894d | ||
|
|
19b5f2889a | ||
|
|
52601f89bd | ||
|
|
72b281b815 | ||
|
|
c0176a5274 | ||
|
|
329d22363f | ||
|
|
87b1443c23 | ||
|
|
29129f8953 | ||
|
|
c97c74a2ee | ||
|
|
1e37c42ea0 | ||
|
|
4c7887fa4e | ||
|
|
2cc5063dee | ||
|
|
e4eda59e6a | ||
|
|
7f918eab6a | ||
|
|
b8c34afef8 | ||
|
|
5738b1bd99 | ||
|
|
2aee75e7d5 | ||
|
|
4d67af51ab | ||
|
|
2f841dfcbc | ||
|
|
7ca69063bd | ||
|
|
085ff75d1d | ||
|
|
21340b2de1 | ||
|
|
a150d38df7 | ||
|
|
02ca36141e | ||
|
|
20bf5e35a4 | ||
|
|
5a67e6b4d6 | ||
|
|
34d1e4b041 | ||
|
|
b88ef858ee | ||
|
|
a363c7a0b1 | ||
|
|
4ac257db0d | ||
|
|
fd5ec01a99 | ||
|
|
bc8c08c468 | ||
|
|
d6648d3337 | ||
|
|
9438bc957f | ||
|
|
5d2219880d | ||
|
|
195da0c9da | ||
|
|
8df0c17bc3 | ||
|
|
7bc9ee0653 | ||
|
|
355f8601c1 | ||
|
|
e7f677b315 | ||
|
|
d7611e74c3 | ||
|
|
d0790dcb22 | ||
|
|
6b4b3924a1 | ||
|
|
2b4f71cc4f | ||
|
|
80359d0854 | ||
|
|
a226a78222 | ||
|
|
42c2ddb355 | ||
|
|
65e7702f90 | ||
|
|
253f419ada | ||
|
|
83818e1097 | ||
|
|
f116a5ed38 | ||
|
|
ee185f56e5 | ||
|
|
b76598ea41 | ||
|
|
432fe48f36 | ||
|
|
7b6e00edd8 | ||
|
|
e59335db4d | ||
|
|
6f00478580 | ||
|
|
4ed4f610f4 | ||
|
|
30fd369bfa | ||
|
|
8208a870a2 | ||
|
|
5eb4a40395 | ||
|
|
95ffc612ca | ||
|
|
afa4a59bb6 | ||
|
|
a67cd2d604 | ||
|
|
d6fdf0c581 | ||
|
|
ed15f61392 | ||
|
|
684ab639ac | ||
|
|
0d698e9e75 | ||
|
|
f5be9878d8 | ||
|
|
30fc8d91f6 | ||
|
|
f7d6a0e349 | ||
|
|
2cbf7b41f3 | ||
|
|
d142ca52c7 | ||
|
|
c5b448b1ff | ||
|
|
6f4e57eeff | ||
|
|
3371b03068 | ||
|
|
64b45a75c7 | ||
|
|
dcfd398008 | ||
|
|
85962aa45a | ||
|
|
d0aa91aa51 | ||
|
|
f13638387f | ||
|
|
e154f65e16 | ||
|
|
86b1ba0228 | ||
|
|
eb83b3c51a | ||
|
|
2438f3231f | ||
|
|
13640d13d1 | ||
|
|
ec60dbe568 | ||
|
|
12515cb0e3 | ||
|
|
0f23335de9 | ||
|
|
edd7bd40c5 | ||
|
|
dacabaa5d4 | ||
|
|
6d6f66d6d0 | ||
|
|
8a70e19430 | ||
|
|
19575eb1b9 | ||
|
|
e71baeb6d6 | ||
|
|
83a49a3c4a | ||
|
|
071444f9d0 | ||
|
|
177ea25723 | ||
|
|
028e73951e | ||
|
|
1a2b52ab90 | ||
|
|
f45ff16674 | ||
|
|
bd0764ee0d | ||
|
|
c5c032bd2c | ||
|
|
b717c9739a | ||
|
|
0bcf7bb948 | ||
|
|
dbffef62e6 | ||
|
|
38356d7bb3 | ||
|
|
99d98ffb2c | ||
|
|
26110a68dd | ||
|
|
23ff99d41f | ||
|
|
3e7d4c6e86 | ||
|
|
34cdbfe446 | ||
|
|
14ae4ecce3 | ||
|
|
a8b6d4866f | ||
|
|
924f93c18d | ||
|
|
7c2ac805c8 | ||
|
|
bb9b632ceb | ||
|
|
50c43e9b07 | ||
|
|
a4c092d8dc | ||
|
|
94e7b2b4d2 | ||
|
|
637f3459c5 | ||
|
|
a5ac510cd0 | ||
|
|
fe66f249f0 | ||
|
|
9a52e30470 | ||
|
|
30d62bf65c | ||
|
|
29b088c035 | ||
|
|
d16b0d5190 | ||
|
|
9b2a4a8ff8 | ||
|
|
320cfe82fd | ||
|
|
8d4691e7f6 | ||
|
|
d7c5d7ce41 | ||
|
|
2da2f96118 | ||
|
|
8cb73f4528 | ||
|
|
e3b146b83f | ||
|
|
b7e4fbf739 | ||
|
|
2c8ba93312 | ||
|
|
cfe03d6dcb | ||
|
|
80b6acbf4b | ||
|
|
bd88a84cd4 | ||
|
|
45f5ba96a8 | ||
|
|
4b935a6f4d | ||
|
|
c9f458b8ba | ||
|
|
d9d2e17e5d | ||
|
|
3d6bd65a64 | ||
|
|
8c08be9e11 | ||
|
|
c49da9bf84 | ||
|
|
c2b97f8e3b | ||
|
|
74480df47d | ||
|
|
f5c9cde852 | ||
|
|
88fbefbd18 | ||
|
|
ded82cd729 | ||
|
|
1e90b223cf | ||
|
|
b8a1d69924 | ||
|
|
33b19ba98b | ||
|
|
64bf8aec3f | ||
|
|
3a69491ac0 | ||
|
|
44286067ae | ||
|
|
1c7cbf64ef | ||
|
|
0c3beba74f | ||
|
|
1b6188b130 | ||
|
|
4859dbd163 | ||
|
|
a3d9022e2b | ||
|
|
dc4dbb4cff | ||
|
|
fc97e75602 | ||
|
|
293f8471b3 | ||
|
|
beedfdc1d2 | ||
|
|
6e7be9695e | ||
|
|
01d40009a3 | ||
|
|
557080d0b2 | ||
|
|
baa2e7038c | ||
|
|
39047361d6 | ||
|
|
57c0defbd7 | ||
|
|
887bd14774 | ||
|
|
b37645cfbe | ||
|
|
4df56571be | ||
|
|
ff4c2c418c | ||
|
|
db0f6e65ef | ||
|
|
aa9019538f | ||
|
|
4537e21ae0 | ||
|
|
079918a0cd | ||
|
|
a88f999dc5 | ||
|
|
968ebaf865 | ||
|
|
32253964dc | ||
|
|
74f4cb3a31 | ||
|
|
0cf0606822 | ||
|
|
b6fbe3b810 | ||
|
|
68d62e08df | ||
|
|
d62ec19ddf | ||
|
|
e754fd506f | ||
|
|
39a0a2939a | ||
|
|
7d95a6187a | ||
|
|
862df136d6 | ||
|
|
a72653a401 | ||
|
|
aa6747d444 | ||
|
|
62305c3416 | ||
|
|
f3c0212ae3 | ||
|
|
d8d91ab57b | ||
|
|
a516c2f04e | ||
|
|
bcadf2961c | ||
|
|
221838a750 | ||
|
|
59cd84acbc | ||
|
|
f7fc5ba5d7 | ||
|
|
8b0ced22e5 | ||
|
|
8bb1e0d321 | ||
|
|
2f2f3ce098 | ||
|
|
17af3aa2eb | ||
|
|
2c507796eb | ||
|
|
c3c4e886c8 | ||
|
|
430bf8a610 | ||
|
|
cf9a6d83ff | ||
|
|
82e01e3e13 | ||
|
|
2ed72d16da | ||
|
|
65a211388e | ||
|
|
014eba550d | ||
|
|
86e4b99297 | ||
|
|
3c81a77ea2 | ||
|
|
8a09c4cf7e | ||
|
|
05f3ce1a43 | ||
|
|
f3e8718660 | ||
|
|
5279eadec3 | ||
|
|
e146b4a08a | ||
|
|
fac8db9cfb | ||
|
|
57cace0e77 | ||
|
|
2121b36e79 | ||
|
|
90f56357d9 | ||
|
|
793f33efb4 | ||
|
|
769ad00e42 | ||
|
|
3716184ea9 | ||
|
|
5f38f1a36b | ||
|
|
b85b81ac22 | ||
|
|
307e81bd61 | ||
|
|
65c19bc375 | ||
|
|
f9eaf48f45 | ||
|
|
b3f3f68dd9 | ||
|
|
3420ac73d7 | ||
|
|
ce77e888ba | ||
|
|
28fda20b6e | ||
|
|
7e173d779f | ||
|
|
855061cc49 | ||
|
|
5b80dfacda | ||
|
|
8950cc5b7f | ||
|
|
dc0f7d7f3b | ||
|
|
45c8bfce1d | ||
|
|
4d28586d13 | ||
|
|
0da08fb945 | ||
|
|
af6ecbdd3e | ||
|
|
4641f40278 | ||
|
|
0c1fa27257 | ||
|
|
24aceb2b07 | ||
|
|
229702a983 | ||
|
|
eda1f8dadf | ||
|
|
71ed601269 | ||
|
|
a8b7554334 | ||
|
|
e0341b06a5 | ||
|
|
be7e1ff4a3 | ||
|
|
8960501f6b | ||
|
|
8d9c9d93eb | ||
|
|
6c66a37dfa | ||
|
|
a33193e4a3 | ||
|
|
da6e0a4c59 | ||
|
|
764bc3d7e8 | ||
|
|
1557a7c58d | ||
|
|
d69a8f76e1 | ||
|
|
78821929a6 | ||
|
|
401728582f | ||
|
|
98aa886477 | ||
|
|
81ac14bbc5 | ||
|
|
036e04467e | ||
|
|
6d6651acbe | ||
|
|
19972bc8fc | ||
|
|
fe2b1846f7 | ||
|
|
859b31e2bc | ||
|
|
80a7f5d2eb | ||
|
|
fa0b2834c5 | ||
|
|
d693b559c0 | ||
|
|
4d09867a9a | ||
|
|
78d99a7462 | ||
|
|
523816db30 | ||
|
|
7e6e0f806b | ||
|
|
9096a6c30d | ||
|
|
61a0559f8e | ||
|
|
4b36fb8d6f | ||
|
|
6538427782 | ||
|
|
123353bf60 | ||
|
|
8beab0e28b | ||
|
|
384a149600 | ||
|
|
c56a06f3df | ||
|
|
325abad736 | ||
|
|
719c091145 | ||
|
|
c90abe9e79 | ||
|
|
36d55216fe | ||
|
|
f657859249 | ||
|
|
f8090f5c35 | ||
|
|
b2e5da4b46 | ||
|
|
729f00caa2 | ||
|
|
54d7a5a247 | ||
|
|
ecbe3158c0 | ||
|
|
bac50f2150 | ||
|
|
150f3084c1 | ||
|
|
d4e1715613 | ||
|
|
c2a26a3c00 | ||
|
|
8e0ae6cbc8 | ||
|
|
18a055ee2c | ||
|
|
c5289364e4 | ||
|
|
5fb583be55 | ||
|
|
4bf02226dc | ||
|
|
c8f4e7d3e5 | ||
|
|
3d4a8e0e14 | ||
|
|
8b9d53be3c | ||
|
|
11a01dae89 | ||
|
|
9ab269dd8f | ||
|
|
c263d38732 | ||
|
|
c4f84cd1e6 | ||
|
|
0478b1c360 | ||
|
|
633ae39622 | ||
|
|
84ff9f5dc3 | ||
|
|
88ec3395c7 | ||
|
|
49485435ad | ||
|
|
d80df28b23 | ||
|
|
4cdbf23ec0 | ||
|
|
73d3613301 | ||
|
|
6993e6b156 | ||
|
|
6d1525748d | ||
|
|
57ac38e6dc | ||
|
|
2e42af61bb | ||
|
|
7dfc2f4056 | ||
|
|
2b045c95c7 | ||
|
|
665b3f9a16 | ||
|
|
dcbaf7413a | ||
|
|
918a083a12 | ||
|
|
7c9265b40d | ||
|
|
44030ddbe5 | ||
|
|
be372c48ea | ||
|
|
939a991d6b | ||
|
|
bb9991e7a5 | ||
|
|
496900baa6 | ||
|
|
8f22826d63 | ||
|
|
15e61d1760 | ||
|
|
a4f28db887 | ||
|
|
db2c6eb98a | ||
|
|
8286f4f0cc | ||
|
|
7761d144f6 | ||
|
|
14718843de | ||
|
|
a28277a0a8 | ||
|
|
d2b8a4e78b | ||
|
|
ac24f10cb0 | ||
|
|
54fe4e27c0 | ||
|
|
cc9e387d3a | ||
|
|
f499be5c15 | ||
|
|
12b88de226 | ||
|
|
a2ea4e60b0 | ||
|
|
619f601cd4 | ||
|
|
f4dbea95f7 | ||
|
|
b28aa20257 | ||
|
|
331a3bebbd | ||
|
|
9b70919d65 | ||
|
|
f09d1991f0 | ||
|
|
e7995dc6b0 | ||
|
|
a62446dbd2 | ||
|
|
1daa5a9a6b | ||
|
|
983ab04e6d | ||
|
|
f94035a74f | ||
|
|
c6b0f4e46d | ||
|
|
3368eb6ef2 | ||
|
|
fa026ee580 | ||
|
|
951eb32c2b | ||
|
|
124fbedf2a | ||
|
|
fa4b78abec | ||
|
|
207cafe285 | ||
|
|
ad068877ac | ||
|
|
c0d115b1ab | ||
|
|
e28ff2cc68 | ||
|
|
f5ef3770f6 | ||
|
|
8e4a08e969 | ||
|
|
4b40e9010d | ||
|
|
0fc4e3cb97 | ||
|
|
b87a3fd88c | ||
|
|
8f9730d87f | ||
|
|
8f6f1d6d22 | ||
|
|
3834720333 | ||
|
|
501b6e6292 | ||
|
|
828e7ced10 | ||
|
|
7e988f94e9 | ||
|
|
8462b64f79 | ||
|
|
65084b1783 | ||
|
|
2311aa981e | ||
|
|
56485bffc3 | ||
|
|
f1d4dd6ff4 | ||
|
|
11c8902dde | ||
|
|
bedca877ce | ||
|
|
ea01122652 | ||
|
|
efa3e18bf9 | ||
|
|
131ccdf5e4 | ||
|
|
9622346cc2 | ||
|
|
6595b30701 | ||
|
|
d4c1389249 | ||
|
|
9ae0e4a238 | ||
|
|
5e442d139c | ||
|
|
78c0ee73b7 | ||
|
|
dedb519d9a | ||
|
|
2a934dbda4 | ||
|
|
6b1f432c58 | ||
|
|
8dc683e6ed | ||
|
|
dff80b9e5d |
+3
-1
@@ -8,4 +8,6 @@ compose.testing.yml
|
||||
.todo
|
||||
.github
|
||||
.vscode
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
**/node_modules
|
||||
ui/.next
|
||||
|
||||
+35
-32
@@ -1,40 +1,43 @@
|
||||
# NAME = "Your Routstr Proxy Name"
|
||||
# DESCRIPTION = "A short Description"
|
||||
# Core Configuration
|
||||
UPSTREAM_BASE_URL=https://api.openai.com/v1
|
||||
UPSTREAM_API_KEY=your-upstream-api-key
|
||||
|
||||
# Any openai-compatible api endpoint
|
||||
UPSTREAM_BASE_URL="https://api.openai.com/v1"
|
||||
UPSTREAM_API_KEY="sk-21212121212121212121212121212121"
|
||||
# UPSTREAM_PROVIDER_FEE=1 # 1 = no fees, 1.05 = 5% fees
|
||||
# ADMIN_PASSWORD=secure-admin-password
|
||||
|
||||
# Lightning address used to receive funds
|
||||
# Database
|
||||
# DATABASE_URL=sqlite+aiosqlite:///keys.db
|
||||
|
||||
# When your cashu balance reaches this number of sats, send the funds to RECEIVE_LN_ADDRESS.
|
||||
# RECEIVE_LN_ADDRESS="user@minibits.cash"
|
||||
#MINIMUM_PAYOUT = "100"
|
||||
# Node Information
|
||||
# NAME=My Routstr Node
|
||||
# DESCRIPTION=Fast AI API access with Bitcoin payments
|
||||
# NSEC=nsec1...
|
||||
# HTTP_URL=https://api.mynode.com
|
||||
# ONION_URL=http://mynode.onion (auto fetched from compose)
|
||||
# RELAYS="wss://relay.damus.io,wss://relay.nostr.band,wss://eden.nostr.land,wss://relay.routstr.com"
|
||||
# CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org,https://ecashmint.otrta.me"
|
||||
# RECEIVE_LN_ADDRESS=
|
||||
|
||||
# If set to true, pricing is loaded from the file specified by MODELS_PATH
|
||||
# Defaults to "models.json" and falls back to "models.example.json" if missing
|
||||
# MODEL_BASED_PRICING = "true"
|
||||
# MODELS_PATH="models.json"
|
||||
# Custom Pricing Configuration
|
||||
# MODEL_BASED_PRICING=true
|
||||
# COST_PER_REQUEST=1
|
||||
# COST_PER_1K_INPUT_TOKENS=0
|
||||
# COST_PER_1K_OUTPUT_TOKENS=0
|
||||
# EXCHANGE_FEE=1.005
|
||||
# UPSTREAM_PROVIDER_FEE=1.05
|
||||
|
||||
# Costs in Sats, if MODEL_BASED_PRICING is set to false
|
||||
# COST_PER_REQUEST="10"
|
||||
# COST_PER_1K_INPUT_TOKENS = "0"
|
||||
# COST_PER_1K_OUTPUT_TOKENS = "0"
|
||||
# EXCHANGE_FEE = "1.005" # 0.5 % currency exchange fee
|
||||
# Network Configuration
|
||||
# CORS_ORIGINS=*
|
||||
# TOR_PROXY_URL=socks5://127.0.0.1:9050
|
||||
|
||||
# password used to log into admin interface
|
||||
# ADMIN_PASSWORD=""
|
||||
# Logging
|
||||
# LOG_LEVEL=INFO
|
||||
# ENABLE_CONSOLE_LOGGING=true
|
||||
|
||||
# Public Endpoint
|
||||
# HTTP_URL="https://your.domain.com"
|
||||
# Custom Model Management
|
||||
# BASE_URL=https://openrouter.ai/api/v1
|
||||
# MODELS_PATH=models.json
|
||||
# SOURCE=
|
||||
|
||||
# Tor Endpoint (copy from docker logs)
|
||||
# ONION_URL=".onion"
|
||||
|
||||
# RELAYS="wss://relay.routstr.com,wss://relay.nostr.band"
|
||||
# CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org"
|
||||
|
||||
# Development
|
||||
# DEBUG=TRUE
|
||||
# LOG_LEVEL=TRACE
|
||||
# UI Configuration (for Next.js frontend)
|
||||
# These variables are prefixed with NEXT_PUBLIC_ to be accessible in the browser
|
||||
# NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Deploy MkDocs to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "mkdocs.yml"
|
||||
- ".github/workflows/docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -r docs/requirements.txt
|
||||
|
||||
- name: Build MkDocs site
|
||||
run: mkdocs build --strict --verbose
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./site
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
branches: ["*"] # Run on PRs to all branches
|
||||
|
||||
jobs:
|
||||
test:
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -51,3 +51,38 @@ jobs:
|
||||
pytest.xml
|
||||
.coverage
|
||||
retention-days: 30
|
||||
|
||||
ui-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: ui/pnpm-lock.yaml
|
||||
|
||||
- name: Install UI dependencies
|
||||
working-directory: ./ui
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run UI format check
|
||||
working-directory: ./ui
|
||||
run: pnpm run format-check
|
||||
|
||||
- name: Run UI linting
|
||||
working-directory: ./ui
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Run UI build
|
||||
working-directory: ./ui
|
||||
run: pnpm run build
|
||||
|
||||
@@ -8,10 +8,13 @@ wallet.sqlite3
|
||||
build/
|
||||
dist/
|
||||
*.egg
|
||||
.mypy_cache/**
|
||||
|
||||
# Development
|
||||
.notes
|
||||
.*keys.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.*wallet.sqlite3
|
||||
*models.json
|
||||
.cashu
|
||||
@@ -33,3 +36,5 @@ logs/*
|
||||
# deployment
|
||||
proof_backups
|
||||
|
||||
*.todo
|
||||
ui_out
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.11
|
||||
@@ -0,0 +1,50 @@
|
||||
# Multi-stage Dockerfile for Routstr (includes UI build)
|
||||
# Stage 1: Build the UI
|
||||
FROM node:23-alpine AS ui-builder
|
||||
WORKDIR /app/ui
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy UI source
|
||||
COPY ui/package.json ui/pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY ui/ ./
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Next.js build produces a static export in 'out' directory
|
||||
RUN pnpm run build
|
||||
|
||||
# Stage 2: Build the Routstr Node
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-alpine AS runner
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
pkgconf \
|
||||
build-base \
|
||||
automake \
|
||||
autoconf \
|
||||
libtool \
|
||||
m4 \
|
||||
perl \
|
||||
git
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the rest of the application (required for uv sync to find the package)
|
||||
COPY . .
|
||||
|
||||
# Install dependencies including the specific secp256k1 branch
|
||||
RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060
|
||||
RUN uv sync --no-dev
|
||||
|
||||
# Copy the built UI from the ui-builder stage
|
||||
COPY --from=ui-builder /app/ui/out ./ui_out
|
||||
|
||||
ENV PORT=8000
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["/app/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]
|
||||
@@ -16,7 +16,7 @@ else
|
||||
ALEMBIC := alembic
|
||||
endif
|
||||
|
||||
.PHONY: help setup test test-unit test-integration test-integration-docker test-all test-fast test-performance clean docker-up docker-down lint format type-check dev-setup check-deps db-upgrade db-downgrade db-current db-history db-migrate db-revision db-heads db-clean
|
||||
.PHONY: help setup test test-unit test-integration test-integration-docker test-all test-fast test-performance clean docker-up docker-down lint format type-check dev-setup check-deps db-upgrade db-downgrade db-current db-history db-migrate db-revision db-heads db-clean ui-build ui-build-docker ui-dev
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@@ -38,6 +38,12 @@ help:
|
||||
@echo " make check-deps - Check system dependencies"
|
||||
@echo " make setup - First-time project setup"
|
||||
@echo ""
|
||||
@echo "UI targets:"
|
||||
@echo " make ui-build - Build UI for production (static export)"
|
||||
@echo " make ui-build-docker - Build UI using Docker (no Node.js needed)"
|
||||
@echo " make ui-dev - Start UI development server"
|
||||
@echo ""
|
||||
@echo "Docker UI build requires only Docker, no local Node.js installation needed."
|
||||
@echo "Database migration shortcuts:"
|
||||
@echo " make create-migration - Auto-generate new migration"
|
||||
@echo " make db-upgrade - Apply all pending migrations"
|
||||
@@ -244,3 +250,36 @@ profile:
|
||||
@echo "🔥 Running with profiling..."
|
||||
$(PYTHON) -m cProfile -o profile.stats -m pytest tests/integration/test_performance_load.py::TestPerformanceBaseline -v
|
||||
@echo "Profile saved to profile.stats. Use '$(PYTHON) -m pstats profile.stats' to analyze."
|
||||
|
||||
# Documentation
|
||||
docs-build:
|
||||
@echo "📚 Building documentation..."
|
||||
mkdocs build
|
||||
|
||||
docs-serve:
|
||||
@echo "📚 Serving documentation at http://localhost:8001..."
|
||||
mkdocs serve -a localhost:8001
|
||||
|
||||
docs-deploy:
|
||||
@echo "📚 Deploying documentation to GitHub Pages..."
|
||||
mkdocs gh-deploy --force
|
||||
|
||||
docs-install:
|
||||
@echo "📚 Installing documentation dependencies..."
|
||||
pip install -r docs/requirements.txt
|
||||
|
||||
# UI build
|
||||
ui-build:
|
||||
@echo "🎨 Building UI for static deployment..."
|
||||
./scripts/build-ui.sh
|
||||
|
||||
ui-build-docker:
|
||||
@echo "🐳 Building UI using Docker (no Node.js installation required)..."
|
||||
@echo "Building UI with environment variables from .env..."
|
||||
docker build -f ui/Dockerfile.build -t routstr-ui-build --build-arg NEXT_PUBLIC_API_URL=$(NEXT_PUBLIC_API_URL) --build-arg NEXT_PUBLIC_ADMIN_API_KEY=$(NEXT_PUBLIC_ADMIN_API_KEY) .
|
||||
docker run --rm -v $(PWD)/ui_out:/output routstr-ui-build cp -r /ui_out /output/
|
||||
@echo "✅ UI build complete! Static files available in ui_out/"
|
||||
|
||||
ui-dev:
|
||||
@echo "🎨 Starting UI development server..."
|
||||
cd ui && (command -v pnpm >/dev/null 2>&1 && pnpm run dev || npm run dev)
|
||||
|
||||
@@ -1,220 +1,69 @@
|
||||
# Routstr Payment Proxy
|
||||
|
||||
Routstr is a FastAPI-based reverse proxy that sits in front of any OpenAI-compatible API. It handles pay-per-request billing using the [Cashu](https://cashu.space/) eCash protocol on Bitcoin and tracks usage in a local SQL database.
|
||||
[](LICENSE)
|
||||
[](https://github.com/routstr/routstr-core/stargazers)
|
||||
[](https://github.com/routstr/routstr-core/issues)
|
||||
[](https://github.com/routstr/routstr-core/releases)
|
||||
|
||||
The server exposes the same endpoints as the upstream API and deducts sats from user accounts for each call. Pricing can be static or model-specific by loading `models.json` (falls back to `models.example.json`).
|
||||
Routstr is a decentralized protocol for permissionless, private, and censorship-resistant AI inference. It combines Nostr for discovery and Cashu for private Bitcoin micropayments.
|
||||
|
||||
## How It Works
|
||||
This repo contains Routstr Core: a FastAPI-based reverse proxy that sits in front of OpenAI-compatible APIs and handles pay-per-request billing.
|
||||
|
||||
The proxy implements a seamless eCash payment flow that maintains compatibility with existing OpenAI clients while enabling Bitcoin micropayments:
|
||||
## Start Here
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Proxy as Routstr Proxy
|
||||
participant DB as Database
|
||||
participant Upstream as OpenAI API
|
||||
participant Wallet as Cashu Wallet
|
||||
- **Overview**: <https://docs.routstr.com/overview/>
|
||||
- **Provider Guide**: <https://docs.routstr.com/provider/quickstart/>
|
||||
- **User Guide**: <https://docs.routstr.com/user-guide/introduction/>
|
||||
|
||||
Client->>Proxy: API Request + eCash Token
|
||||
Proxy->>Wallet: Validate & Redeem Token
|
||||
Wallet-->>Proxy: Token Value (sats)
|
||||
Proxy->>DB: Store/Update Balance
|
||||
Proxy->>Upstream: Forward API Request
|
||||
Upstream-->>Proxy: API Response + Usage Data
|
||||
Proxy->>DB: Deduct Actual Request Cost
|
||||
Proxy->>DB: Update Final Balance
|
||||
Proxy-->>Client: API Response
|
||||
## Basic Usage
|
||||
|
||||
If you are a user/developer, you just point an OpenAI-compatible SDK at a Routstr node and pay with a Cashu token.
|
||||
|
||||
### OpenAI SDK
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="https://api.routstr.com/v1",
|
||||
api_key="cashuBo2FteCJodHRwczovL21...",
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-5-nano",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
## Features
|
||||
### cURL
|
||||
|
||||
- **Cashu Wallet Integration** – Accept Lightning payments and redeem eCash tokens before forwarding requests
|
||||
- **API Key Management** – Hashed keys stored in SQLite with balance tracking and optional expiry/refund address
|
||||
- **Model-Based Pricing** – Convert USD prices in `models.json` to sats using live BTC/USD rates
|
||||
- **Admin Dashboard** – Simple HTML interface at `/admin/` to view balances and API keys
|
||||
- **Discovery** – Fetch available providers from Nostr relays
|
||||
- **Docker Support** – Provided `Dockerfile` and `compose.yml` for running with an optional Tor hidden service
|
||||
```bash
|
||||
curl https://api.routstr.com/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-cashu: cashuBo2FteCJodHRwczovL21..." \
|
||||
-d '{
|
||||
"model": "gpt-5-nano",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
}'
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
## Quick Start (Docker)
|
||||
|
||||
### Running the proxy using Docker
|
||||
If you are a node runner, start a Routstr Core instance and configure upstream access in the dashboard.
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name routstr-proxy \
|
||||
-p 8000:8000 \
|
||||
-e UPSTREAM_BASE_URL=https://api.openai.com/v1 \
|
||||
-e UPSTREAM_API_KEY=your-openai-api-key \
|
||||
ghcr.io/routstr/proxy:latest
|
||||
--name routstr-proxy \
|
||||
-p 8000:8000 \
|
||||
ghcr.io/routstr/proxy:latest
|
||||
```
|
||||
|
||||
### Development Requirements
|
||||
|
||||
- Python 3.11+
|
||||
- [uv](https://github.com/astral-sh/uv) package manager (used in development)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
uv sync # install dependencies
|
||||
```
|
||||
|
||||
Create a `.env` file based on `.env.example` and fill in the required values:
|
||||
## Development
|
||||
|
||||
```bash
|
||||
make setup
|
||||
cp .env.example .env
|
||||
fastapi run routstr
|
||||
```
|
||||
|
||||
### Running Locally
|
||||
|
||||
```bash
|
||||
fastapi run routstr --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
The service forwards requests to `UPSTREAM_BASE_URL`. Supply the upstream API key via the `UPSTREAM_API_KEY` environment variable if required.
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
This builds the image and also starts a Tor container exposing the API as a hidden service.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The most common settings are shown below. See `.env.example` for the full list.
|
||||
|
||||
- `UPSTREAM_BASE_URL` – URL of the OpenAI-compatible service
|
||||
- `UPSTREAM_API_KEY` – API key for the upstream service (optional)
|
||||
- `MODEL_BASED_PRICING` – Set to `true` to use pricing from `models.json`
|
||||
- `ADMIN_PASSWORD` – Password for the `/admin/` dashboard
|
||||
- `CASHU_MINTS` – Comma-separated list of Cashu mint URLs
|
||||
- `NAME` – Name of the proxy
|
||||
- `DESCRIPTION` – Description of the proxy
|
||||
- `NPUB` – Nostr public key of the proxy
|
||||
- `HTTP_URL` – Public-facing URL of the proxy
|
||||
- `ONION_URL` – Tor hidden service URL of the proxy
|
||||
|
||||
## Database Migrations
|
||||
|
||||
The application uses Alembic for database schema management and **automatically runs migrations on startup**. This ensures your database is always up-to-date when deploying new versions.
|
||||
|
||||
### Automatic Migrations in Production
|
||||
|
||||
When the FastAPI application starts, it automatically:
|
||||
|
||||
1. Runs all pending database migrations
|
||||
2. Updates the schema to the latest version
|
||||
3. Logs the migration status
|
||||
|
||||
This means you don't need to manually run migrations when deploying - just restart the application and migrations will be applied automatically.
|
||||
|
||||
### Manual Migration Commands
|
||||
|
||||
For development or troubleshooting, you can use these Makefile commands:
|
||||
|
||||
```bash
|
||||
make db-upgrade # Apply all pending migrations
|
||||
make db-downgrade # Downgrade one migration
|
||||
make db-current # Show current migration revision
|
||||
make db-history # Show migration history
|
||||
make db-migrate # Auto-generate new migration from model changes
|
||||
make db-revision # Create empty migration file
|
||||
make db-heads # Show current migration heads
|
||||
make db-clean # Clean migration cache files
|
||||
```
|
||||
|
||||
### Creating New Migrations
|
||||
|
||||
When you modify SQLModel models:
|
||||
|
||||
```bash
|
||||
# Auto-generate a migration from model changes
|
||||
make db-migrate
|
||||
# Enter a descriptive message when prompted
|
||||
|
||||
# Review the generated migration file in migrations/versions/
|
||||
# Edit if needed, then test with:
|
||||
make db-upgrade
|
||||
```
|
||||
|
||||
## Withdrawing Balance
|
||||
|
||||
Go to `https://<your.routstr.proxy>/admin/` (NOTE: be sure to add the '/' at the end), enter the `ADMIN_PASSWORD` you set above and withdraw your balance as a Cashu token.
|
||||
|
||||
## Example Client
|
||||
|
||||
`example.py` shows how to use the proxy with the official OpenAI client:
|
||||
|
||||
```bash
|
||||
CASHU_TOKEN=<redeemable token> python example.py
|
||||
```
|
||||
|
||||
The script sends streaming chat completions and pays for each request using the provided token.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
The tests create a temporary SQLite database and mock the Cashu wallet. See `tests/README.md` for more details.
|
||||
|
||||
## Future Features
|
||||
|
||||
### Nut-24 Header Support (Coming Soon)
|
||||
|
||||
We're implementing support for the Cashu Nut-24 specification, which will enable per-request token exchange with automatic change handling:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["Client Request<br/>x-cashu: token"] --> B[Proxy Validates Token]
|
||||
B --> C{Token ≥ Minimum Amount?}
|
||||
C -->|No| F[Return 402 Payment Required]
|
||||
C -->|Yes| D[Calculate Request Cost]
|
||||
D --> E[Process Request]
|
||||
E --> G[Forward to Upstream API]
|
||||
G --> H[Receive API Response]
|
||||
H --> I[Calculate Change]
|
||||
I --> J["Return Response<br/>x-cashu: change_token"]
|
||||
F --> K[End]
|
||||
J --> K
|
||||
```
|
||||
|
||||
**Key Benefits:**
|
||||
|
||||
- **Per-Request Payments** – Send exact tokens for each API call
|
||||
- **Automatic Change** – Receive change tokens in response headers
|
||||
- **No Pre-funding** – No need to maintain account balances
|
||||
- **Precise Billing** – Pay only for actual usage with msat-level precision
|
||||
- **Minimum Amount Protection** – Proxy enforces minimum token value to prevent dust attacks
|
||||
|
||||
**Header Format:**
|
||||
|
||||
- **Request**: `x-cashu: <ecash_token>` – Token to spend for this request (must meet minimum amount)
|
||||
- **Response**: `x-cashu: <change_token>` – Change token if payment exceeds cost
|
||||
|
||||
**Implementation Note:**
|
||||
The proxy should implement either a dedicated endpoint to communicate minimum eCash requirements per request, or extend the existing `models.json` to include minimum token amounts per model. This allows clients to autonomously determine the appropriate token amount to send with each request.
|
||||
|
||||
**Compatible Clients:**
|
||||
|
||||
To use this feature, you'll need a client that handles both OpenAI API calls and eCash header management. The following clients provide seamless integration:
|
||||
|
||||
- **[routstr-chat](https://github.com/routstr/routstr-chat)** – chat app for the routstr network
|
||||
- **[otrta-client](https://github.com/routstr/otrta-client)** – rust web app for the routstr network
|
||||
|
||||
clients automatically:
|
||||
|
||||
- **Handle eCash Headers** – Add `x-cashu` tokens to requests and process change tokens
|
||||
- **Manage Wallets** – Maintain your Cashu wallet
|
||||
- **Configure Proxy** – Set Routstr proxy endpoints
|
||||
- **Top-up Balances** – Automatically request ecash when tokens run low and redeem ecash tokens
|
||||
|
||||
This approach eliminates the need for account management while maintaining the security and privacy benefits of eCash payments.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the terms of the GPLv3. See the `LICENSE` file for the full license text.
|
||||
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build script for MkDocs documentation
|
||||
|
||||
echo "Building Routstr Core documentation..."
|
||||
|
||||
# Check if mkdocs is installed
|
||||
if ! command -v mkdocs &> /dev/null; then
|
||||
echo "MkDocs not found. Installing dependencies..."
|
||||
pip install -r docs/requirements.txt
|
||||
fi
|
||||
|
||||
# Build the documentation
|
||||
echo "Building docs..."
|
||||
mkdocs build
|
||||
|
||||
# Serve locally for preview (optional)
|
||||
if [ "$1" = "serve" ]; then
|
||||
echo "Starting documentation server at http://localhost:8001"
|
||||
mkdocs serve -a localhost:8001
|
||||
elif [ "$1" = "deploy" ]; then
|
||||
echo "Deploying to GitHub Pages..."
|
||||
mkdocs gh-deploy --force
|
||||
else
|
||||
echo "Documentation built successfully in ./site/"
|
||||
echo "Run './build-docs.sh serve' to preview locally"
|
||||
echo "Run './build-docs.sh deploy' to deploy to GitHub Pages"
|
||||
fi
|
||||
+4
-4
@@ -19,10 +19,10 @@ services:
|
||||
- "ONION_URL=http://test.onion"
|
||||
- "CORS_ORIGINS=*"
|
||||
- "RECEIVE_LN_ADDRESS=test@routstr.com"
|
||||
- "COST_PER_REQUEST=10"
|
||||
- "COST_PER_1K_INPUT_TOKENS=0"
|
||||
- "COST_PER_1K_OUTPUT_TOKENS=0"
|
||||
- "MODEL_BASED_PRICING=true"
|
||||
- "FIXED_COST_PER_REQUEST=10"
|
||||
- "FIXED_PER_1K_INPUT_TOKENS=0"
|
||||
- "FIXED_PER_1K_OUTPUT_TOKENS=0"
|
||||
- "FIXED_PRICING=false"
|
||||
- "NSEC=nsec1testkey1234567890abcdef"
|
||||
- "REFUND_PROCESSING_INTERVAL=3600"
|
||||
- "MINIMUM_PAYOUT=1000"
|
||||
|
||||
+18
-9
@@ -1,11 +1,27 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
ui:
|
||||
env_file:
|
||||
- .env
|
||||
build:
|
||||
context: ./ui
|
||||
dockerfile: Dockerfile.build
|
||||
args:
|
||||
# NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
|
||||
NEXT_PUBLIC_ADMIN_API_KEY: ${NEXT_PUBLIC_ADMIN_API_KEY:-}
|
||||
volumes:
|
||||
- ./ui_out:/output
|
||||
command:
|
||||
["sh", "-c", "mkdir -p /output && cp -r /app/built/. /output/ && echo 'UI build copied to mounted volume' && ls -la /output/ && echo 'UI built and ready' && tail -f /dev/null"]
|
||||
|
||||
routstr:
|
||||
build: .
|
||||
depends_on:
|
||||
- ui
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./logs:/app/logs
|
||||
- tor-data:/var/lib/tor:ro
|
||||
- ./ui_out:/app/ui_out:ro
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -25,12 +41,5 @@ services:
|
||||
depends_on:
|
||||
- routstr
|
||||
|
||||
# Legacy service definition to ensure cleanup of old container
|
||||
router:
|
||||
image: alpine:latest
|
||||
command: /bin/true
|
||||
profiles:
|
||||
- cleanup
|
||||
|
||||
volumes:
|
||||
tor-data:
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
# Authentication
|
||||
|
||||
Routstr uses API key authentication for all protected endpoints. This guide covers how to create, use, and manage API keys.
|
||||
|
||||
## API Key Creation
|
||||
|
||||
### From eCash Token
|
||||
|
||||
Create an API key by depositing an eCash token:
|
||||
|
||||
**Note: The POST /v1/wallet/create endpoint is coming soon. Currently, you can use Cashu tokens directly as API credentials in the Authorization header. The token is hashed on the server, and the hash acts as an API key with the token's balance.**
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/create
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vbWlu..."
|
||||
}
|
||||
```
|
||||
|
||||
**Request Parameters:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `cashu_token` | string | Yes | Base64-encoded Cashu token |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p",
|
||||
"balance": 10000,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"key_id": "key_123456"
|
||||
}
|
||||
```
|
||||
|
||||
### From Lightning Invoice (Coming Soon)
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/create/lightning
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"amount_sats": 10000,
|
||||
"name": "Lightning Key"
|
||||
}
|
||||
```
|
||||
|
||||
Response includes Lightning invoice for payment.
|
||||
|
||||
## Using API Keys
|
||||
|
||||
### Header Authentication
|
||||
|
||||
Include the API key in the Authorization header:
|
||||
|
||||
```bash
|
||||
curl https://your-node.com/v1/chat/completions \
|
||||
-H "Authorization: Bearer sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello"}]}'
|
||||
```
|
||||
|
||||
### Query Parameter (Not Recommended)
|
||||
|
||||
For tools that don't support headers:
|
||||
|
||||
```bash
|
||||
GET /v1/models?api_key=sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p
|
||||
```
|
||||
|
||||
⚠️ **Warning**: Query parameters may be logged. Use headers when possible.
|
||||
|
||||
## Key Management
|
||||
|
||||
### Check Balance
|
||||
|
||||
Get current balance and usage statistics:
|
||||
|
||||
```bash
|
||||
GET /v1/wallet/balance
|
||||
Authorization: Bearer sk-...
|
||||
|
||||
Response:
|
||||
{
|
||||
"balance": 8546,
|
||||
"total_deposited": 10000,
|
||||
"total_spent": 1454,
|
||||
"last_used": "2024-01-01T12:34:56Z",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"expires_at": null,
|
||||
"key_info": {
|
||||
"name": "Production Key",
|
||||
"key_id": "key_123456"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Top Up Balance
|
||||
|
||||
Add funds to existing key:
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/topup
|
||||
Authorization: Bearer sk-...
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0b2tlbiI6W3..."
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"old_balance": 8546,
|
||||
"added_amount": 5000,
|
||||
"new_balance": 13546,
|
||||
"transaction_id": "txn_789"
|
||||
}
|
||||
```
|
||||
|
||||
### List Transactions
|
||||
|
||||
View transaction history:
|
||||
|
||||
```bash
|
||||
GET /v1/wallet/transactions?limit=10
|
||||
Authorization: Bearer sk-...
|
||||
|
||||
Response:
|
||||
{
|
||||
"transactions": [
|
||||
{
|
||||
"id": "txn_123",
|
||||
"type": "usage",
|
||||
"amount": -154,
|
||||
"balance_after": 8546,
|
||||
"description": "gpt-3.5-turbo: 50 prompt + 150 completion tokens",
|
||||
"timestamp": "2024-01-01T12:34:56Z"
|
||||
},
|
||||
{
|
||||
"id": "txn_122",
|
||||
"type": "deposit",
|
||||
"amount": 10000,
|
||||
"balance_after": 10000,
|
||||
"description": "Initial deposit",
|
||||
"timestamp": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"total": 2
|
||||
}
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### API Key Storage
|
||||
|
||||
**Do:**
|
||||
|
||||
- Store keys in environment variables
|
||||
- Use secret management systems
|
||||
- Encrypt keys at rest
|
||||
- Implement key rotation
|
||||
|
||||
**Don't:**
|
||||
|
||||
- Commit keys to version control
|
||||
- Share keys between environments
|
||||
- Log keys in plain text
|
||||
- Expose keys in client-side code
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# .env file
|
||||
ROUTSTR_API_KEY=sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p
|
||||
ROUTSTR_BASE_URL=https://your-node.com/v1
|
||||
|
||||
# Usage in code
|
||||
import os
|
||||
api_key = os.getenv("ROUTSTR_API_KEY")
|
||||
```
|
||||
|
||||
### Key Rotation
|
||||
|
||||
Regularly rotate API keys:
|
||||
|
||||
```python
|
||||
# 1. Create new key
|
||||
new_key = create_api_key(balance=old_key_balance)
|
||||
|
||||
# 2. Update applications
|
||||
update_environment_variable("ROUTSTR_API_KEY", new_key)
|
||||
|
||||
# 3. Test new key
|
||||
test_api_connection(new_key)
|
||||
|
||||
# 4. Withdraw old key balance
|
||||
withdraw_balance(old_key)
|
||||
```
|
||||
|
||||
## Authentication Errors
|
||||
|
||||
### Invalid API Key
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "authentication_failed",
|
||||
"message": "Invalid API key",
|
||||
"code": "invalid_api_key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Code:** 401
|
||||
|
||||
**Common Causes:**
|
||||
|
||||
- Typo in API key
|
||||
- Key doesn't exist
|
||||
- Key has been deleted
|
||||
|
||||
### Expired API Key
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "authentication_failed",
|
||||
"message": "API key has expired",
|
||||
"code": "key_expired",
|
||||
"details": {
|
||||
"expired_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Code:** 401
|
||||
|
||||
**Resolution:**
|
||||
|
||||
- Create a new API key
|
||||
- Contact admin if refund address was set
|
||||
|
||||
### Insufficient Balance
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "insufficient_balance",
|
||||
"message": "Insufficient balance for request",
|
||||
"code": "payment_required",
|
||||
"details": {
|
||||
"balance": 100,
|
||||
"required": 154,
|
||||
"shortfall": 54
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Code:** 402
|
||||
|
||||
**Resolution:**
|
||||
|
||||
- Top up the API key balance
|
||||
- Use a more economical model
|
||||
- Optimize request parameters
|
||||
|
||||
## Advanced Authentication
|
||||
|
||||
### Per-Request Tokens (Coming Soon)
|
||||
|
||||
Pay per request without maintaining a balance:
|
||||
|
||||
```bash
|
||||
curl https://your-node.com/v1/chat/completions \
|
||||
-H "X-Cashu: cashuAeyJ0b2tlbiI6W3..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gpt-3.5-turbo","messages":[...]}'
|
||||
```
|
||||
|
||||
Response includes change:
|
||||
|
||||
```
|
||||
X-Cashu: cashuAeyJjaGFuZ2UiOlt7...
|
||||
```
|
||||
|
||||
### Multi-Key Authentication
|
||||
|
||||
Use multiple keys for different purposes:
|
||||
|
||||
```python
|
||||
# Production key for main app
|
||||
PROD_KEY = os.getenv("ROUTSTR_PROD_KEY")
|
||||
|
||||
# Development key for testing
|
||||
DEV_KEY = os.getenv("ROUTSTR_DEV_KEY")
|
||||
|
||||
# Analytics key with restricted permissions
|
||||
ANALYTICS_KEY = os.getenv("ROUTSTR_ANALYTICS_KEY")
|
||||
|
||||
# Choose key based on environment
|
||||
api_key = PROD_KEY if is_production() else DEV_KEY
|
||||
```
|
||||
|
||||
### Delegated Authentication
|
||||
|
||||
Create sub-keys with limited permissions:
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/create/subkey
|
||||
Authorization: Bearer sk-parent-key
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Limited Subkey",
|
||||
"balance_limit": 1000,
|
||||
"allowed_models": ["gpt-3.5-turbo"],
|
||||
"expires_in_hours": 24
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Rate limits are applied per API key:
|
||||
|
||||
### Default Limits
|
||||
|
||||
| Metric | Limit | Window |
|
||||
|--------|-------|--------|
|
||||
| Requests | 1000 | 1 minute |
|
||||
| Tokens | 1,000,000 | 1 hour |
|
||||
| Concurrent | 10 | - |
|
||||
|
||||
### Rate Limit Headers
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 1000
|
||||
X-RateLimit-Remaining: 999
|
||||
X-RateLimit-Reset: 1640995200
|
||||
X-RateLimit-Type: requests_per_minute
|
||||
```
|
||||
|
||||
### Handling Rate Limits
|
||||
|
||||
```python
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
def make_request_with_retry(
|
||||
client,
|
||||
max_retries: int = 3
|
||||
) -> Optional[Response]:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = client.chat.completions.create(...)
|
||||
return response
|
||||
except RateLimitError as e:
|
||||
if attempt < max_retries - 1:
|
||||
# Extract retry-after from error
|
||||
retry_after = e.retry_after or 60
|
||||
print(f"Rate limited. Waiting {retry_after}s...")
|
||||
time.sleep(retry_after)
|
||||
else:
|
||||
raise
|
||||
```
|
||||
|
||||
## IP Whitelisting
|
||||
|
||||
Restrict API key usage by IP:
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/update
|
||||
Authorization: Bearer sk-...
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"allowed_ips": [
|
||||
"192.168.1.100",
|
||||
"10.0.0.0/24"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Usage Alerts
|
||||
|
||||
Set up usage notifications:
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/alerts
|
||||
Authorization: Bearer sk-...
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"low_balance_threshold": 1000,
|
||||
"daily_spend_limit": 5000,
|
||||
"webhook_url": "https://your-app.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### Audit Logging
|
||||
|
||||
All API key usage is logged:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-01T12:34:56Z",
|
||||
"api_key_id": "key_123456",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"method": "POST",
|
||||
"ip_address": "192.168.1.100",
|
||||
"user_agent": "OpenAI-Python/1.0",
|
||||
"cost_sats": 154,
|
||||
"response_status": 200
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Endpoints](endpoints.md) - Complete endpoint reference
|
||||
- [Errors](errors.md) - Error handling guide
|
||||
- [Using the API](../client/integration.md) - Integration examples
|
||||
@@ -0,0 +1,602 @@
|
||||
# API Endpoints
|
||||
|
||||
Complete reference for all Routstr API endpoints.
|
||||
|
||||
## Overview
|
||||
|
||||
Routstr provides OpenAI-compatible endpoints with Bitcoin/eCash payment integration.
|
||||
|
||||
### Base URL
|
||||
|
||||
All endpoints use the base URL:
|
||||
|
||||
```text
|
||||
https://api.routstr.com/v1
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
All endpoints require authentication via:
|
||||
|
||||
- **Bearer Token**: `Authorization: Bearer sk-...` or `Authorization: Bearer cashuAeyJ0...`
|
||||
- **X-Cashu Header**: `X-Cashu: cashuAeyJ0...` (for direct eCash payments)
|
||||
|
||||
See [Authentication](authentication.md) for details.
|
||||
|
||||
## Chat
|
||||
|
||||
### Create Chat Completion
|
||||
|
||||
Send messages to generate model responses.
|
||||
|
||||
```http
|
||||
POST /v1/chat/completions
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-4",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a helpful assistant."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello!"
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"stream": false
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `model` | string | Yes | - | Model ID to use |
|
||||
| `messages` | array | Yes | - | Array of message objects |
|
||||
| `temperature` | number | No | 1.0 | Sampling temperature (0-2) |
|
||||
| `max_tokens` | integer | No | Model default | Maximum tokens to generate |
|
||||
| `stream` | boolean | No | false | Stream partial responses |
|
||||
| `top_p` | number | No | 1.0 | Nucleus sampling |
|
||||
| `n` | integer | No | 1 | Number of completions |
|
||||
| `stop` | string/array | No | null | Stop sequences |
|
||||
| `presence_penalty` | number | No | 0 | Presence penalty (-2 to 2) |
|
||||
| `frequency_penalty` | number | No | 0 | Frequency penalty (-2 to 2) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"created": 1677652288,
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Hello! How can I help you today?"
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 13,
|
||||
"completion_tokens": 9,
|
||||
"total_tokens": 22
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Streaming Response
|
||||
|
||||
When `stream: true`:
|
||||
|
||||
```text
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
## Completions (Coming Soon)
|
||||
|
||||
### Create Completion
|
||||
|
||||
**Note: This endpoint is coming soon and not yet available.**
|
||||
|
||||
Generate text completion (legacy endpoint).
|
||||
|
||||
```http
|
||||
POST /v1/completions
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-3.5-turbo-instruct",
|
||||
"prompt": "Once upon a time",
|
||||
"max_tokens": 50,
|
||||
"temperature": 0.7
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "cmpl-123",
|
||||
"object": "text_completion",
|
||||
"created": 1677652288,
|
||||
"model": "gpt-3.5-turbo-instruct",
|
||||
"choices": [{
|
||||
"text": " in a faraway land, there lived a brave knight...",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"finish_reason": "length"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 4,
|
||||
"completion_tokens": 50,
|
||||
"total_tokens": 54
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Embeddings
|
||||
|
||||
### Create Embeddings (Coming Soon)
|
||||
|
||||
**Note: This endpoint is coming soon and not yet available.**
|
||||
|
||||
Generate vector representations of text.
|
||||
|
||||
```http
|
||||
POST /v1/embeddings
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "text-embedding-3-small",
|
||||
"input": "The quick brown fox jumps over the lazy dog",
|
||||
"encoding_format": "float"
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `model` | string | Yes | - | Embedding model ID |
|
||||
| `input` | string/array | Yes | - | Text(s) to embed |
|
||||
| `encoding_format` | string | No | "float" | Format: "float" or "base64" |
|
||||
| `dimensions` | integer | No | Model default | Output dimensions |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"object": "list",
|
||||
"data": [{
|
||||
"object": "embedding",
|
||||
"index": 0,
|
||||
"embedding": [0.0023064255, -0.009327292, ...]
|
||||
}],
|
||||
"model": "text-embedding-3-small",
|
||||
"usage": {
|
||||
"prompt_tokens": 9,
|
||||
"total_tokens": 9
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Images (Coming Soon)
|
||||
|
||||
### Create Image
|
||||
|
||||
**Note: This endpoint is coming soon and not yet available.**
|
||||
|
||||
Generate images from text prompts.
|
||||
|
||||
```http
|
||||
POST /v1/images/generations
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "dall-e-3",
|
||||
"prompt": "A white siamese cat wearing a space helmet",
|
||||
"n": 1,
|
||||
"size": "1024x1024",
|
||||
"quality": "standard"
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `model` | string | Yes | - | Model: dall-e-2, dall-e-3 |
|
||||
| `prompt` | string | Yes | - | Text description |
|
||||
| `n` | integer | No | 1 | Number of images |
|
||||
| `size` | string | No | "1024x1024" | Image dimensions |
|
||||
| `quality` | string | No | "standard" | Quality: standard, hd |
|
||||
| `style` | string | No | "vivid" | Style: vivid, natural |
|
||||
| `response_format` | string | No | "url" | Format: url, b64_json |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"created": 1677652288,
|
||||
"data": [{
|
||||
"url": "https://generated-image-url.com/image.png",
|
||||
"revised_prompt": "A white Siamese cat wearing a detailed space helmet..."
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Audio (Coming Soon)
|
||||
|
||||
### Create Transcription
|
||||
|
||||
**Note: This endpoint is coming soon and not yet available.**
|
||||
|
||||
Convert audio to text.
|
||||
|
||||
```http
|
||||
POST /v1/audio/transcriptions
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Form Data:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `file` | file | Yes | Audio file (mp3, mp4, mpeg, mpga, m4a, wav, webm) |
|
||||
| `model` | string | Yes | Model ID (whisper-1) |
|
||||
| `language` | string | No | Language code (ISO-639-1) |
|
||||
| `prompt` | string | No | Context prompt |
|
||||
| `response_format` | string | No | Format: json, text, srt, verbose_json, vtt |
|
||||
| `temperature` | number | No | Sampling temperature |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "Hello, this is the transcribed audio content."
|
||||
}
|
||||
```
|
||||
|
||||
### Create Translation
|
||||
|
||||
**Note: This endpoint is coming soon and not yet available.**
|
||||
|
||||
Translate audio to English.
|
||||
|
||||
```http
|
||||
POST /v1/audio/translations
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
Same parameters as transcription, but always translates to English.
|
||||
|
||||
## Models
|
||||
|
||||
### List Models
|
||||
|
||||
Get available models and pricing.
|
||||
|
||||
```http
|
||||
GET /v1/models
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "gpt-3.5-turbo",
|
||||
"object": "model",
|
||||
"created": 1677610602,
|
||||
"owned_by": "openai",
|
||||
"permission": [...],
|
||||
"root": "gpt-3.5-turbo",
|
||||
"parent": null,
|
||||
"pricing": {
|
||||
"prompt": 0.001,
|
||||
"completion": 0.002,
|
||||
"unit": "1k tokens"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Wallet Management
|
||||
|
||||
### Create Wallet (Coming Soon)
|
||||
|
||||
**Note: This endpoint is coming soon. Currently, you can use Cashu tokens directly as API keys.**
|
||||
|
||||
Create a new wallet with eCash deposit.
|
||||
|
||||
```http
|
||||
POST /v1/wallet/create
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0...",
|
||||
"admin_key": "optional-admin-key"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-1234567890abcdef",
|
||||
"admin_key": "radmin_fedcba0987654321",
|
||||
"balance": 10000,
|
||||
"mint": "https://mint.example.com",
|
||||
"unit": "sat"
|
||||
}
|
||||
```
|
||||
|
||||
### Get Key Information
|
||||
|
||||
Get current balance, consumption data, and child keys for an API key.
|
||||
|
||||
```http
|
||||
GET /v1/balance/info
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-abc...",
|
||||
"balance": 8500000,
|
||||
"reserved": 0,
|
||||
"is_child": false,
|
||||
"parent_key": null,
|
||||
"total_requests": 42,
|
||||
"total_spent": 1500000,
|
||||
"balance_limit": null,
|
||||
"balance_limit_reset": null,
|
||||
"validity_date": null,
|
||||
"child_keys": [
|
||||
{
|
||||
"api_key": "sk-child1...",
|
||||
"total_requests": 10,
|
||||
"total_spent": 500000,
|
||||
"balance_limit": 1000000,
|
||||
"balance_limit_reset": "daily",
|
||||
"validity_date": 1738000000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Check Balance
|
||||
|
||||
Get current wallet balance.
|
||||
|
||||
```http
|
||||
GET /v1/wallet/balance
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"balance": 8500,
|
||||
"currency": "sat",
|
||||
"reserved": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Top Up Wallet
|
||||
|
||||
Add funds to existing wallet.
|
||||
|
||||
```http
|
||||
POST /v1/wallet/topup
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0..."
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"balance": 18500,
|
||||
"amount_added": 10000,
|
||||
"currency": "sat"
|
||||
}
|
||||
```
|
||||
|
||||
### Withdraw Funds
|
||||
|
||||
Withdraw balance as eCash.
|
||||
|
||||
```http
|
||||
POST /v1/wallet/withdraw
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"amount": 5000,
|
||||
"mint": "https://mint.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0...",
|
||||
"amount": 5000,
|
||||
"mint": "https://mint.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
### Create Child Key
|
||||
|
||||
Creates one or more child API keys that share the parent's balance. Each child key creation costs a fixed amount (configurable).
|
||||
|
||||
```http
|
||||
POST /v1/balance/child-key
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `count` | integer | Yes | - | Number of child keys to create (1-50) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_keys": ["sk-abc...", "sk-def..."],
|
||||
"count": 2,
|
||||
"cost_msats": 2000,
|
||||
"cost_sats": 2,
|
||||
"parent_balance": 98000,
|
||||
"parent_balance_sats": 98
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Discovery
|
||||
|
||||
## Admin Settings
|
||||
|
||||
These endpoints are protected by the Admin cookie (`admin_password` set to your configured admin password).
|
||||
|
||||
### Get Settings
|
||||
|
||||
```http
|
||||
GET /admin/api/settings
|
||||
```
|
||||
|
||||
Returns the current application settings (sensitive values may be redacted).
|
||||
|
||||
### Update Settings
|
||||
|
||||
```http
|
||||
PATCH /admin/api/settings
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Body is a partial JSON of settings fields to update. Validated and persisted to the database.
|
||||
|
||||
### List Providers
|
||||
|
||||
Get available upstream providers.
|
||||
|
||||
```http
|
||||
GET /v1/providers
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"name": "openai",
|
||||
"models": ["gpt-4", "gpt-3.5-turbo"],
|
||||
"endpoints": ["chat/completions", "completions"],
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Provider Info
|
||||
|
||||
Get specific provider details.
|
||||
|
||||
```http
|
||||
GET /v1/providers/{provider_name}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "openai",
|
||||
"display_name": "OpenAI",
|
||||
"description": "Official OpenAI API",
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-4",
|
||||
"name": "GPT-4",
|
||||
"context_window": 8192,
|
||||
"pricing": {
|
||||
"prompt": 0.03,
|
||||
"completion": 0.06,
|
||||
"unit": "1k tokens"
|
||||
}
|
||||
}
|
||||
],
|
||||
"endpoints": ["chat/completions", "completions", "embeddings"],
|
||||
"features": ["streaming", "function_calling"],
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
All endpoints are subject to rate limiting:
|
||||
|
||||
- **Per minute**: 60 requests
|
||||
- **Per hour**: 1000 requests
|
||||
- **Per day**: 10000 requests
|
||||
|
||||
Rate limit information is included in response headers.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Errors](errors.md) - Error handling reference
|
||||
- [Authentication](authentication.md) - Auth details
|
||||
- [Integration Guide](../client/integration.md) - Code examples
|
||||
@@ -0,0 +1,592 @@
|
||||
# Error Handling
|
||||
|
||||
This guide covers error responses, codes, and handling strategies for the Routstr API.
|
||||
|
||||
## Error Response Format
|
||||
|
||||
All errors follow a consistent JSON structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "error_type",
|
||||
"message": "Human-readable error message",
|
||||
"code": "error_code",
|
||||
"details": {
|
||||
"additional": "context-specific information"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP Status Codes
|
||||
|
||||
| Status | Meaning | Common Causes |
|
||||
|--------|---------|---------------|
|
||||
| 400 | Bad Request | Invalid parameters, malformed JSON |
|
||||
| 401 | Unauthorized | Invalid or missing API key |
|
||||
| 402 | Payment Required | Insufficient balance |
|
||||
| 403 | Forbidden | Access denied to resource |
|
||||
| 404 | Not Found | Endpoint or resource doesn't exist |
|
||||
| 422 | Unprocessable Entity | Validation errors |
|
||||
| 429 | Too Many Requests | Rate limit exceeded |
|
||||
| 500 | Internal Server Error | Server-side error |
|
||||
| 502 | Bad Gateway | Upstream API error |
|
||||
| 503 | Service Unavailable | Temporary outage |
|
||||
|
||||
## Error Types
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
#### Invalid API Key
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "authentication_failed",
|
||||
"message": "Invalid API key provided",
|
||||
"code": "invalid_api_key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 401
|
||||
**Resolution:** Check API key format and validity
|
||||
|
||||
#### Expired API Key
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "authentication_failed",
|
||||
"message": "API key has expired",
|
||||
"code": "key_expired",
|
||||
"details": {
|
||||
"expired_at": "2024-01-01T00:00:00Z",
|
||||
"refund_available": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 401
|
||||
**Resolution:** Create new API key or contact admin for refund
|
||||
|
||||
#### Missing Authorization
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "authentication_failed",
|
||||
"message": "Authorization header required",
|
||||
"code": "missing_auth"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 401
|
||||
**Resolution:** Include `Authorization: Bearer {api_key}` header
|
||||
|
||||
### Payment Errors
|
||||
|
||||
#### Insufficient Balance
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "insufficient_balance",
|
||||
"message": "Insufficient balance for request",
|
||||
"code": "payment_required",
|
||||
"details": {
|
||||
"balance": 100,
|
||||
"required": 154,
|
||||
"shortfall": 54,
|
||||
"estimated_tokens": {
|
||||
"prompt": 50,
|
||||
"completion": 150
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 402
|
||||
**Resolution:** Top up API key balance
|
||||
|
||||
#### Invalid Token
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "payment_error",
|
||||
"message": "Invalid Cashu token",
|
||||
"code": "invalid_token",
|
||||
"details": {
|
||||
"reason": "Token already spent"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 400
|
||||
**Resolution:** Use a valid, unspent token
|
||||
|
||||
#### Mint Unavailable
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "payment_error",
|
||||
"message": "Cannot connect to Cashu mint",
|
||||
"code": "mint_unavailable",
|
||||
"details": {
|
||||
"mint_url": "https://mint.example.com",
|
||||
"retry_after": 60
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 503
|
||||
**Resolution:** Try again later or use different mint
|
||||
|
||||
### Validation Errors
|
||||
|
||||
#### Invalid Parameters
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "invalid_request",
|
||||
"message": "Invalid request parameters",
|
||||
"code": "validation_error",
|
||||
"details": {
|
||||
"errors": [
|
||||
{
|
||||
"field": "temperature",
|
||||
"message": "Must be between 0 and 2",
|
||||
"value": 3.5
|
||||
},
|
||||
{
|
||||
"field": "model",
|
||||
"message": "Model 'gpt-5' not found",
|
||||
"value": "gpt-5"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 422
|
||||
**Resolution:** Fix parameter values
|
||||
|
||||
#### Missing Required Fields
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "invalid_request",
|
||||
"message": "Missing required fields",
|
||||
"code": "missing_fields",
|
||||
"details": {
|
||||
"missing": ["model", "messages"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 400
|
||||
**Resolution:** Include all required fields
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
#### Rate Limit Exceeded
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "rate_limit_exceeded",
|
||||
"message": "Too many requests",
|
||||
"code": "rate_limit",
|
||||
"details": {
|
||||
"limit": 100,
|
||||
"window": "1 minute",
|
||||
"retry_after": 45
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 429
|
||||
**Headers:**
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 0
|
||||
X-RateLimit-Reset: 1640995200
|
||||
Retry-After: 45
|
||||
```
|
||||
|
||||
**Resolution:** Wait for retry_after seconds
|
||||
|
||||
### Upstream Errors
|
||||
|
||||
#### Model Overloaded
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "upstream_error",
|
||||
"message": "Model is currently overloaded",
|
||||
"code": "model_overloaded",
|
||||
"details": {
|
||||
"model": "gpt-4",
|
||||
"retry_after": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 503
|
||||
**Resolution:** Retry request after delay
|
||||
|
||||
#### Upstream Timeout
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "upstream_error",
|
||||
"message": "Request to upstream API timed out",
|
||||
"code": "upstream_timeout",
|
||||
"details": {
|
||||
"timeout": 30,
|
||||
"endpoint": "chat/completions"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 504
|
||||
**Resolution:** Retry with shorter prompt or max_tokens
|
||||
|
||||
### Content Policy
|
||||
|
||||
#### Content Filtered
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "content_policy_violation",
|
||||
"message": "Content filtered due to policy violation",
|
||||
"code": "content_filtered",
|
||||
"details": {
|
||||
"reason": "harmful_content",
|
||||
"categories": ["violence", "hate"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 400
|
||||
**Resolution:** Modify prompt to comply with policies
|
||||
|
||||
## Error Handling Best Practices
|
||||
|
||||
### Retry Logic
|
||||
|
||||
Implement exponential backoff with jitter:
|
||||
|
||||
```python
|
||||
import time
|
||||
import random
|
||||
from typing import Optional, Callable
|
||||
|
||||
def retry_with_backoff(
|
||||
func: Callable,
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 60.0
|
||||
) -> Optional[Any]:
|
||||
"""Retry function with exponential backoff."""
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return func()
|
||||
except Exception as e:
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
|
||||
# Check if error is retryable
|
||||
if hasattr(e, 'status_code'):
|
||||
if e.status_code in [429, 502, 503, 504]:
|
||||
# Calculate delay with jitter
|
||||
delay = min(
|
||||
base_delay * (2 ** attempt) + random.uniform(0, 1),
|
||||
max_delay
|
||||
)
|
||||
|
||||
# Use retry_after if provided
|
||||
if hasattr(e, 'retry_after'):
|
||||
delay = e.retry_after
|
||||
|
||||
time.sleep(delay)
|
||||
else:
|
||||
# Non-retryable error
|
||||
raise
|
||||
```
|
||||
|
||||
### Error Categories
|
||||
|
||||
Group errors for handling:
|
||||
|
||||
```python
|
||||
class ErrorHandler:
|
||||
# Errors that should be retried
|
||||
RETRYABLE_ERRORS = {
|
||||
'rate_limit',
|
||||
'upstream_timeout',
|
||||
'model_overloaded',
|
||||
'mint_unavailable'
|
||||
}
|
||||
|
||||
# Errors requiring user action
|
||||
USER_ACTION_ERRORS = {
|
||||
'insufficient_balance',
|
||||
'invalid_api_key',
|
||||
'key_expired'
|
||||
}
|
||||
|
||||
# Errors requiring code changes
|
||||
CLIENT_ERRORS = {
|
||||
'validation_error',
|
||||
'missing_fields',
|
||||
'invalid_request'
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def handle_error(cls, error_response: dict) -> None:
|
||||
error_code = error_response['error']['code']
|
||||
|
||||
if error_code in cls.RETRYABLE_ERRORS:
|
||||
# Implement retry logic
|
||||
pass
|
||||
elif error_code in cls.USER_ACTION_ERRORS:
|
||||
# Alert user
|
||||
pass
|
||||
elif error_code in cls.CLIENT_ERRORS:
|
||||
# Log for debugging
|
||||
pass
|
||||
```
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
Handle errors without breaking application flow:
|
||||
|
||||
```python
|
||||
async def get_ai_response(prompt: str) -> str:
|
||||
"""Get AI response with fallback handling."""
|
||||
try:
|
||||
# Try primary model
|
||||
response = await client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
|
||||
except InsufficientBalanceError:
|
||||
# Fall back to cheaper model
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=100 # Limit tokens
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
except Exception as e:
|
||||
logger.error(f"Fallback failed: {e}")
|
||||
return "Service temporarily unavailable"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
return "An error occurred processing your request"
|
||||
```
|
||||
|
||||
### Logging Errors
|
||||
|
||||
Structure error logs for debugging:
|
||||
|
||||
```python
|
||||
import logging
|
||||
import json
|
||||
|
||||
def log_api_error(error_response: dict, context: dict) -> None:
|
||||
"""Log API errors with context."""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
error_data = {
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'error': error_response['error'],
|
||||
'context': {
|
||||
'endpoint': context.get('endpoint'),
|
||||
'api_key_id': context.get('api_key_id'),
|
||||
'request_id': context.get('request_id'),
|
||||
'model': context.get('model')
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(
|
||||
"API Error",
|
||||
extra={'structured_data': json.dumps(error_data)}
|
||||
)
|
||||
```
|
||||
|
||||
### User-Friendly Messages
|
||||
|
||||
Map technical errors to user messages:
|
||||
|
||||
```python
|
||||
ERROR_MESSAGES = {
|
||||
'insufficient_balance': "Your account balance is too low. Please add funds to continue.",
|
||||
'invalid_api_key': "Invalid API key. Please check your configuration.",
|
||||
'rate_limit': "Too many requests. Please wait a moment and try again.",
|
||||
'model_overloaded': "The AI service is busy. Please try again in a few seconds.",
|
||||
'validation_error': "Invalid request. Please check your input and try again."
|
||||
}
|
||||
|
||||
def get_user_message(error_code: str) -> str:
|
||||
"""Get user-friendly error message."""
|
||||
return ERROR_MESSAGES.get(
|
||||
error_code,
|
||||
"An unexpected error occurred. Please try again later."
|
||||
)
|
||||
```
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Handling Balance Errors
|
||||
|
||||
```python
|
||||
async def make_request_with_balance_check():
|
||||
try:
|
||||
# Check balance first
|
||||
balance_info = await client.get("/v1/wallet/balance")
|
||||
|
||||
# Estimate cost
|
||||
estimated_cost = calculate_cost(model, prompt_length)
|
||||
|
||||
if balance_info['balance'] < estimated_cost * 1.1: # 10% buffer
|
||||
# Proactively top up
|
||||
await top_up_balance()
|
||||
|
||||
# Make request
|
||||
return await client.chat.completions.create(...)
|
||||
|
||||
except InsufficientBalanceError as e:
|
||||
# Handle insufficient balance
|
||||
shortfall = e.details['shortfall']
|
||||
await top_up_balance(amount=shortfall * 2)
|
||||
# Retry request
|
||||
```
|
||||
|
||||
### Handling Rate Limits
|
||||
|
||||
```python
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class RateLimitTracker:
|
||||
def __init__(self):
|
||||
self.reset_times = {}
|
||||
|
||||
def is_limited(self, endpoint: str) -> bool:
|
||||
reset_time = self.reset_times.get(endpoint)
|
||||
if reset_time and datetime.now() < reset_time:
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_limit(self, endpoint: str, reset_timestamp: int):
|
||||
self.reset_times[endpoint] = datetime.fromtimestamp(reset_timestamp)
|
||||
|
||||
def wait_time(self, endpoint: str) -> float:
|
||||
reset_time = self.reset_times.get(endpoint)
|
||||
if reset_time:
|
||||
return max(0, (reset_time - datetime.now()).total_seconds())
|
||||
return 0
|
||||
```
|
||||
|
||||
## Testing Error Handling
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
|
||||
async def test_insufficient_balance_handling():
|
||||
# Mock API client
|
||||
mock_client = Mock()
|
||||
mock_client.chat.completions.create.side_effect = InsufficientBalanceError(
|
||||
required=100,
|
||||
available=50
|
||||
)
|
||||
|
||||
# Test error handling
|
||||
handler = ErrorHandler(mock_client)
|
||||
result = await handler.safe_request(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": "test"}]
|
||||
)
|
||||
|
||||
# Verify fallback behavior
|
||||
assert result.fallback_used is True
|
||||
assert result.model == "gpt-3.5-turbo"
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```python
|
||||
async def test_real_error_scenarios():
|
||||
# Test with invalid API key
|
||||
invalid_client = OpenAI(
|
||||
api_key="sk-invalid",
|
||||
base_url=test_url
|
||||
)
|
||||
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
await invalid_client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "test"}]
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "invalid_api_key" in str(exc_info.value)
|
||||
```
|
||||
|
||||
## Monitoring Errors
|
||||
|
||||
Track error rates and patterns:
|
||||
|
||||
```python
|
||||
class ErrorMetrics:
|
||||
def __init__(self):
|
||||
self.error_counts = defaultdict(int)
|
||||
self.error_timestamps = defaultdict(list)
|
||||
|
||||
def record_error(self, error_code: str):
|
||||
self.error_counts[error_code] += 1
|
||||
self.error_timestamps[error_code].append(datetime.now())
|
||||
|
||||
def get_error_rate(self, error_code: str, window_minutes: int = 60) -> float:
|
||||
cutoff = datetime.now() - timedelta(minutes=window_minutes)
|
||||
recent_errors = [
|
||||
ts for ts in self.error_timestamps[error_code]
|
||||
if ts > cutoff
|
||||
]
|
||||
return len(recent_errors) / window_minutes
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Authentication](authentication.md) - Auth error details
|
||||
- [Endpoints](endpoints.md) - Endpoint-specific errors
|
||||
- [Integration Guide](../client/integration.md) - Error handling examples
|
||||
@@ -0,0 +1,312 @@
|
||||
# API Reference Overview
|
||||
|
||||
Routstr Core provides a complete OpenAI-compatible API with additional endpoints for payment management. This reference covers all available endpoints, authentication methods, and response formats.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://api.routstr.com/v1
|
||||
```
|
||||
|
||||
All API endpoints are prefixed with `/v1` for versioning.
|
||||
|
||||
## Authentication
|
||||
|
||||
Routstr uses API keys for authentication. Include your key in the Authorization header:
|
||||
|
||||
```bash
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
### API Key Format
|
||||
|
||||
- Prefix: `sk-`
|
||||
- Length: 32 characters
|
||||
- Example: `sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p`
|
||||
|
||||
### Cashu Tokens as Authentication
|
||||
|
||||
You can also use a Cashu eCash token directly in the `Authorization` header. The server hashes the token internally; this hash represents your API key identity and carries the token's balance.
|
||||
|
||||
```bash
|
||||
Authorization: Bearer cashuAeyJ0b2tlbiI6W3...
|
||||
```
|
||||
|
||||
## Content Types
|
||||
|
||||
### Request
|
||||
|
||||
- **Required**: `Content-Type: application/json`
|
||||
- **Encoding**: UTF-8
|
||||
- **Maximum Size**: 10MB (configurable)
|
||||
|
||||
### Response
|
||||
|
||||
- **Type**: `application/json` or `text/event-stream` (for streaming)
|
||||
- **Encoding**: UTF-8
|
||||
- **Compression**: gzip (if accepted)
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Rate limits are applied per API key:
|
||||
|
||||
- **Requests**: 1000 per minute
|
||||
- **Tokens**: 1,000,000 per hour
|
||||
- **Concurrent**: 10 simultaneous requests
|
||||
|
||||
Rate limit headers:
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 1000
|
||||
X-RateLimit-Remaining: 999
|
||||
X-RateLimit-Reset: 1640995200
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
All errors follow a consistent format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "insufficient_balance",
|
||||
"message": "Insufficient balance for request",
|
||||
"code": "payment_required",
|
||||
"details": {
|
||||
"required": 154,
|
||||
"available": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Types
|
||||
|
||||
| Type | Status Code | Description |
|
||||
|------|-------------|-------------|
|
||||
| `invalid_request` | 400 | Malformed request |
|
||||
| `authentication_failed` | 401 | Invalid or missing API key |
|
||||
| `insufficient_balance` | 402 | Not enough balance |
|
||||
| `forbidden` | 403 | Access denied |
|
||||
| `not_found` | 404 | Resource not found |
|
||||
| `rate_limit_exceeded` | 429 | Too many requests |
|
||||
| `internal_error` | 500 | Server error |
|
||||
| `upstream_error` | 502 | Upstream API error |
|
||||
|
||||
## Endpoint Categories
|
||||
|
||||
### AI/ML Endpoints
|
||||
|
||||
Standard OpenAI-compatible endpoints:
|
||||
|
||||
- **Models**: `/v1/models`
|
||||
- **Responses**: `/v1/responses`
|
||||
- **Chat Completions**: `/v1/chat/completions`
|
||||
- **Embeddings**: `/v1/embeddings`
|
||||
- **Completions**: `/v1/completions` *(planned)*
|
||||
- **Images**: `/v1/images/generations` *(planned)*
|
||||
- **Audio**: `/v1/audio/transcriptions` *(planned)*
|
||||
|
||||
### Payment Endpoints
|
||||
|
||||
Routstr-specific payment management:
|
||||
|
||||
- **Balance**: `/v1/balance/*`
|
||||
- **Node Info**: `/v1/info`
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
Protected administrative functions:
|
||||
|
||||
- **Dashboard**: `/admin/`
|
||||
- **API Management**: `/admin/api/*`
|
||||
|
||||
## Request Headers
|
||||
|
||||
### Standard Headers
|
||||
|
||||
| Header | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `Authorization` | Yes | Bearer token with API key |
|
||||
| `Content-Type` | Yes | Must be `application/json` |
|
||||
| `Accept` | No | Response format preference |
|
||||
| `Accept-Encoding` | No | Compression support |
|
||||
| `X-Request-ID` | No | Client-provided request ID |
|
||||
|
||||
### Custom Headers
|
||||
|
||||
| Header | Description |
|
||||
|--------|-------------|
|
||||
| `X-Cashu` | eCash token for per-request payment |
|
||||
|
||||
#### X-Cashu: Stateless Per-Request Payment
|
||||
|
||||
Instead of using `Authorization: Bearer sk-...`, you can send a Cashu token directly in the `X-Cashu` header. The response will include an `X-Cashu-Refund` header with your change.
|
||||
|
||||
```bash
|
||||
curl https://api.routstr.com/v1/chat/completions \
|
||||
-H "X-Cashu: cashuA3s8jKx9..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]}'
|
||||
```
|
||||
|
||||
The response includes your change in the same header:
|
||||
```
|
||||
X-Cashu: cashuA7k2mNp4...
|
||||
```
|
||||
|
||||
This is fully stateless—no session, no `/v1/balance/refund` call needed. However, **streaming does not work with `X-Cashu`** because the refund can only be calculated after the full response is generated.
|
||||
|
||||
## Response Headers
|
||||
|
||||
### Standard Headers
|
||||
|
||||
| Header | Description |
|
||||
|--------|-------------|
|
||||
| `Content-Type` | Response format |
|
||||
| `Content-Length` | Response size |
|
||||
| `X-Request-ID` | Unique request identifier |
|
||||
| `X-Cashu` | Change token (when request used `X-Cashu` header) |
|
||||
|
||||
## Streaming Responses
|
||||
|
||||
For endpoints supporting streaming, responses use Server-Sent Events:
|
||||
|
||||
```
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":" there"},"finish_reason":null}]}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
## OpenAPI Specification
|
||||
|
||||
The complete OpenAPI 3.0 specification is available at:
|
||||
|
||||
```
|
||||
GET /openapi.json
|
||||
```
|
||||
|
||||
Interactive documentation:
|
||||
|
||||
```
|
||||
GET /docs # Swagger UI
|
||||
GET /redoc # ReDoc
|
||||
```
|
||||
|
||||
## SDK Support
|
||||
|
||||
Routstr is compatible with official OpenAI SDKs:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://your-node.com/v1"
|
||||
)
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```javascript
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: 'sk-...',
|
||||
baseURL: 'https://your-node.com/v1'
|
||||
});
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl https://your-node.com/v1/chat/completions \
|
||||
-H "Authorization: Bearer sk-..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello"}]}'
|
||||
```
|
||||
|
||||
## Webhook Support
|
||||
|
||||
Configure webhooks for events:
|
||||
|
||||
```json
|
||||
POST /v1/webhooks
|
||||
{
|
||||
"url": "https://your-app.com/webhook",
|
||||
"events": ["balance.low", "key.expired"],
|
||||
"secret": "whsec_your_secret"
|
||||
}
|
||||
```
|
||||
|
||||
Events are sent with signature verification:
|
||||
|
||||
```
|
||||
X-Webhook-Signature: sha256=...
|
||||
```
|
||||
|
||||
## API Versioning
|
||||
|
||||
- Current version: `v1`
|
||||
- Version in URL path: `/v1/endpoint`
|
||||
|
||||
## Status Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 200 | Success |
|
||||
| 201 | Created |
|
||||
| 204 | No content |
|
||||
| 400 | Bad request |
|
||||
| 401 | Unauthorized |
|
||||
| 402 | Payment required |
|
||||
| 403 | Forbidden |
|
||||
| 404 | Not found |
|
||||
| 429 | Rate limited |
|
||||
| 500 | Server error |
|
||||
| 502 | Upstream error |
|
||||
| 503 | Service unavailable |
|
||||
|
||||
## CORS Support
|
||||
|
||||
CORS is enabled with configurable origins:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
|
||||
Access-Control-Allow-Headers: Authorization, Content-Type
|
||||
Access-Control-Max-Age: 86400
|
||||
```
|
||||
|
||||
## Compression
|
||||
|
||||
Responses are compressed with gzip when:
|
||||
|
||||
- Client sends `Accept-Encoding: gzip`
|
||||
- Response is larger than 1KB
|
||||
- Content type is compressible
|
||||
|
||||
## Batch Requests *(planned)*
|
||||
|
||||
Process multiple operations in one request. Coming soon.
|
||||
|
||||
## Node Info
|
||||
|
||||
Get node metadata:
|
||||
|
||||
```
|
||||
GET /v1/info
|
||||
```
|
||||
|
||||
Supported models and pricing are available at `/v1/models`.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Authentication](authentication.md) - Detailed auth guide
|
||||
- [Endpoints](endpoints.md) - Complete endpoint reference
|
||||
- [Errors](errors.md) - Error handling guide
|
||||
- [Integration Guide](../client/integration.md) - Code examples
|
||||
@@ -0,0 +1,143 @@
|
||||
# Using the API
|
||||
|
||||
Routstr is **OpenAI-compatible**. Almost any AI application, SDK, or tool that supports custom endpoints will work out of the box. Just change two things:
|
||||
|
||||
```
|
||||
BASE_URL → https://api.routstr.com/v1
|
||||
API_KEY → sk-... or cashuA...
|
||||
```
|
||||
|
||||
**Both work as API keys:**
|
||||
- `sk-7f8e9d...` — Session key (from Lightning invoice or Cashu import)
|
||||
- `cashuA3s8j...` — Raw Cashu token (use directly from your wallet)
|
||||
|
||||
If the app lets you set a base URL and API key, you're good to go.
|
||||
|
||||
---
|
||||
|
||||
## Quick Setup Examples
|
||||
|
||||
### OpenAI SDK (Python/JS)
|
||||
|
||||
```python
|
||||
client = OpenAI(base_url="https://api.routstr.com/v1", api_key="sk-...") # or any provider's URL
|
||||
```
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL=https://api.routstr.com/v1
|
||||
export ANTHROPIC_AUTH_TOKEN=sk-...
|
||||
```
|
||||
|
||||
### Any OpenAI-compatible app
|
||||
|
||||
Look for "Custom API endpoint", "Base URL", or "OpenAI-compatible" in settings. Paste the URL and key.
|
||||
|
||||
---
|
||||
|
||||
## Detailed Examples
|
||||
|
||||
### Python (Official SDK)
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
# 1. Initialize with Routstr URL and your funded key
|
||||
client = OpenAI(
|
||||
base_url="https://api.routstr.com/v1",
|
||||
api_key="sk-7f8e9d..."
|
||||
)
|
||||
|
||||
# 2. Call the API normally
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
stream=True
|
||||
)
|
||||
|
||||
for chunk in response:
|
||||
if chunk.choices[0].delta.content:
|
||||
print(chunk.choices[0].delta.content, end="")
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
import OpenAI from 'openai';
|
||||
|
||||
// You can use a session key OR a raw Cashu token directly
|
||||
const openai = new OpenAI({
|
||||
baseURL: 'https://api.routstr.com/v1',
|
||||
apiKey: 'cashuA3s8jKx9...', // or 'sk-7f8e9d...'
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const completion = await openai.chat.completions.create({
|
||||
messages: [{ role: 'user', content: 'Say this is a test' }],
|
||||
model: 'gpt-3.5-turbo',
|
||||
});
|
||||
|
||||
console.log(completion.choices[0]);
|
||||
}
|
||||
|
||||
main();
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
# Works with session key or raw Cashu token
|
||||
curl https://api.routstr.com/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer cashuA3s8jKx9..." \
|
||||
-d '{
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [{"role": "user", "content": "Hello!"}]
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Insufficient Balance (402 Payment Required)
|
||||
If your session runs out of funds, the API will return a `402` error.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"message": "Insufficient balance. Current: 1000 msat, Required: 5000 msat",
|
||||
"type": "insufficient_balance",
|
||||
"code": 402
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Action**: Top up your key using the `/lightning/invoice` (topup purpose) or `/v1/balance/topup` endpoints.
|
||||
|
||||
### Rate Limiting
|
||||
Routstr passes through rate limits from the upstream provider. Handle `429 Too Many Requests` with standard exponential backoff.
|
||||
|
||||
---
|
||||
|
||||
## Advanced: Tor Access
|
||||
|
||||
If the node is running as a hidden service, use a SOCKS5 proxy (like `127.0.0.1:9050`).
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
proxy_mounts = {
|
||||
"http://": httpx.HTTPTransport(proxy="socks5://127.0.0.1:9050"),
|
||||
"https://": httpx.HTTPTransport(proxy="socks5://127.0.0.1:9050"),
|
||||
}
|
||||
|
||||
client = OpenAI(
|
||||
base_url="http://verylongonionaddress.onion/v1",
|
||||
api_key="sk-...",
|
||||
http_client=httpx.Client(mounts=proxy_mounts),
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,142 @@
|
||||
# Introduction to Routstr
|
||||
|
||||
Welcome to the Routstr Core User Guide. This guide will help you understand how to use Routstr to access AI APIs with Bitcoin micropayments.
|
||||
|
||||
## What You'll Learn
|
||||
|
||||
- How the payment system works (Cashu eCash)
|
||||
- Creating and managing API keys (Ephemeral Sessions)
|
||||
- Making API calls through Routstr
|
||||
- Using the admin dashboard
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 💰 Wallet
|
||||
|
||||
Cashu ([cashu.me](https://cashu.me)) or Lightning ([Strike](https://strike.me), Cash App, etc.)
|
||||
|
||||
### 🌐 Provider
|
||||
|
||||
A Routstr node, e.g. `https://api.routstr.com`
|
||||
|
||||
### 🤖 Client
|
||||
|
||||
OpenAI SDK, Claude Code, Cursor, or any OpenAI-compatible tool
|
||||
|
||||
---
|
||||
|
||||
## How Routstr Works
|
||||
|
||||
Routstr is a **Payment Proxy**. It sits between your code and the AI provider.
|
||||
|
||||
### Traditional API vs Routstr
|
||||
|
||||
| Traditional | Routstr |
|
||||
|---|---|
|
||||
| Credit Card Required | Bitcoin / Lightning / eCash |
|
||||
| Monthly Billing | Pay-per-request (Real-time) |
|
||||
| KYC / Account | No Account / Private |
|
||||
| Single Provider | Aggregated Providers |
|
||||
|
||||
### Key Concepts
|
||||
|
||||
#### 1. Cashu eCash
|
||||
|
||||
Digital bearer tokens backed by Bitcoin. They are instant, private, and have no fees for internal transfers. Routstr uses these tokens as the "credits" for API requests.
|
||||
|
||||
#### 2. Ephemeral Sessions (API Keys)
|
||||
|
||||
Instead of a permanent account, you create a **Session**.
|
||||
|
||||
- You fund a session with eCash or Lightning.
|
||||
- Routstr gives you an `api_key` (`sk-...`) representing that session.
|
||||
- You use the `api_key` until funds run out or you finish your task.
|
||||
- You can **refund** the remaining balance back to your wallet at any time.
|
||||
|
||||
#### 3. Millisats (msats)
|
||||
|
||||
Everything is priced in **millisatoshis**.
|
||||
|
||||
- 1 Satoshi (sat) = 1,000 msats.
|
||||
- This allows for extremely precise pricing (e.g., 0.05 sats per prompt).
|
||||
|
||||
---
|
||||
|
||||
## Workflow: Zero to Intelligence
|
||||
|
||||
### 1. Fund a Session
|
||||
|
||||
You need an `api_key` with a balance.
|
||||
|
||||
**Easiest: Use the Web UI**
|
||||
Visit the node's root page (e.g., [api.routstr.com](https://api.routstr.com)) or [chat.routstr.com](https://chat.routstr.com) → Settings to create a key visually with Lightning.
|
||||
|
||||
**Option A: Lightning Invoice (CLI)**
|
||||
Generate an invoice and pay it with any Lightning wallet.
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.routstr.com/lightning/invoice \
|
||||
-d '{"amount_sats": 1000, "purpose": "create"}'
|
||||
```
|
||||
|
||||
*Returns an invoice (`bolt11`) and an ID. Once paid, the status endpoint returns your `api_key`.*
|
||||
|
||||
**Option B: Cashu Token (Best for privacy & devs)**
|
||||
If you have a Cashu wallet, you can copy a token string (`cashuA...`) and use it directly.
|
||||
|
||||
- **Direct Usage**: Use the token *as* your API key in the `Authorization` header.
|
||||
- **Import**: Or exchange it for a standard `sk-...` key:
|
||||
|
||||
```bash
|
||||
curl "https://api.routstr.com/v1/balance/create?initial_balance_token=cashuA..."
|
||||
```
|
||||
|
||||
*Returns your `api_key` immediately.*
|
||||
|
||||
### 2. Configure Your Client
|
||||
|
||||
Use the standard OpenAI SDK, just changing the `base_url` and `api_key`.
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="https://api.routstr.com/v1",
|
||||
api_key="sk-7f8e9d..." # The key from Step 1
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Make Requests
|
||||
|
||||
```python
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "Explain quantum computing."}]
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Withdraw Change
|
||||
|
||||
When you are done, get your change back as a Cashu token.
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.routstr.com/v1/balance/refund \
|
||||
-H "Authorization: Bearer sk-7f8e9d..."
|
||||
```
|
||||
|
||||
*Returns a `token` that you can paste back into Nutstash or Minibits to reclaim your funds.*
|
||||
|
||||
---
|
||||
|
||||
## Supported Features
|
||||
|
||||
- **Responses**: `/v1/responses` (OpenAI Responses API)
|
||||
- **Chat Completions**: `/v1/chat/completions` (Streaming supported)
|
||||
- **Embeddings**: `/v1/embeddings`
|
||||
- **Models**: `/v1/models` (List available models and prices)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Payment Flow](payments.md)**: Detailed breakdown of the funding lifecycle.
|
||||
- **[Models & Pricing](../provider/pricing.md)**: How costs are calculated.
|
||||
- **[Admin Dashboard](../provider/dashboard.md)**: Managing your node if you are the operator.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Payment Flow
|
||||
|
||||
Routstr uses a **Pre-paid, Ephemeral** payment model. Pay first, use the funds, withdraw the rest. No accounts, no credit cards, no trails.
|
||||
|
||||
```
|
||||
💰 Deposit → 🤖 Use AI → 💸 Withdraw Change
|
||||
```
|
||||
|
||||
## 1. Creating a Balance (Deposit)
|
||||
|
||||
To start making requests, you must create a "Balance" (represented by an API Key).
|
||||
|
||||
### Method A: Lightning Network (Bolt11)
|
||||
|
||||
**Ideal for**: Users connecting from a standard Lightning wallet (Strike, Cash App, WoS).
|
||||
|
||||
1. **Request Invoice**:
|
||||
`POST /lightning/invoice` with `{"amount_sats": 5000, "purpose": "create"}`.
|
||||
2. **Pay Invoice**: User scans and pays the QR code/bolt11 string.
|
||||
3. **Receive Key**: Routstr detects the payment and issues a new API Key (`sk-...`) pre-loaded with 5,000 sats (5,000,000 msats).
|
||||
|
||||
### Method B: Cashu Token Import
|
||||
|
||||
**Ideal for**: Private, instant access or automated agents.
|
||||
|
||||
1. **Generate Token**: User creates a token in their local wallet (e.g., 1000 sats).
|
||||
2. **Import**: `GET /v1/balance/create?initial_balance_token=cashuA...`
|
||||
3. **Receive Key**: Routstr claims the token and issues an API Key (`sk-...`) with that balance.
|
||||
|
||||
---
|
||||
|
||||
## 2. Consuming Funds (Inference)
|
||||
|
||||
Every time you make a request to `/v1/chat/completions` (or others), the cost is deducted from your balance **in real-time**.
|
||||
|
||||
### Cost Calculation
|
||||
|
||||
`Cost = (Input_Tokens * Price_Input) + (Output_Tokens * Price_Output) + Request_Fee`
|
||||
|
||||
- Prices are defined per model (see `/v1/models`).
|
||||
- If you stream the response, the balance is deducted incrementally or finalized at the end of the stream.
|
||||
- If your balance hits 0 mid-stream, the connection is closed.
|
||||
|
||||
### Headers
|
||||
|
||||
Routstr checks the `Authorization: Bearer sk-...` header to identify which balance to charge.
|
||||
|
||||
---
|
||||
|
||||
## 3. Topping Up
|
||||
|
||||
If your balance runs low, you don't need a new key. You can top up the existing one.
|
||||
|
||||
### Via Lightning
|
||||
|
||||
`POST /lightning/invoice` with `{"amount_sats": 1000, "purpose": "topup", "api_key": "sk-..."}`.
|
||||
*Once paid, the funds are added to your existing key.*
|
||||
|
||||
### Via Cashu
|
||||
|
||||
`POST /v1/balance/topup` with `{"cashu_token": "..."}` and `Authorization: Bearer sk-...`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Refund (Withdrawal)
|
||||
|
||||
Don't leave large balances sitting on a node—it's a hot wallet. When you're done, get your sats back.
|
||||
|
||||
### Endpoint
|
||||
|
||||
`POST /v1/balance/refund`
|
||||
|
||||
**Headers**:
|
||||
`Authorization: Bearer sk-...`
|
||||
|
||||
**Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "cashuAeyJ0b2tlbiI6W3sibWludCI6...",
|
||||
"msats": "450000"
|
||||
}
|
||||
```
|
||||
|
||||
You can verify the refund was successful by checking that the API Key is now invalid or has 0 balance. Copy the `token` string and paste it into your Cashu wallet to claim the Bitcoin.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Step | Action | Result |
|
||||
|------|--------|--------|
|
||||
| 💰 **Deposit** | Pay Lightning invoice or import Cashu | Get `sk-...` key |
|
||||
| 🤖 **Use** | Make API requests | Balance decreases |
|
||||
| 💸 **Refund** | Call `/v1/balance/refund` | Get Cashu token back |
|
||||
|
||||
That's it. No monthly bills, no surprise charges, no data harvesting.
|
||||
@@ -0,0 +1,329 @@
|
||||
# Architecture Overview
|
||||
|
||||
This document describes the high-level architecture of Routstr Core, helping contributors understand how the system works.
|
||||
|
||||
## System Overview
|
||||
|
||||
Routstr Core is a FastAPI-based reverse proxy that adds Bitcoin micropayments to OpenAI-compatible APIs and can optionally announce providers via Nostr.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "External Services"
|
||||
Client[API Client]
|
||||
Mint[Cashu Mint]
|
||||
Provider[AI Provider]
|
||||
Nostr[Nostr Relays]
|
||||
end
|
||||
|
||||
subgraph "Routstr Core"
|
||||
API[FastAPI Server]
|
||||
Auth[Auth Module]
|
||||
Payment[Payment Module]
|
||||
Proxy[Proxy Module]
|
||||
DB[(SQLModel DB)]
|
||||
|
||||
API --> Auth
|
||||
Auth --> Payment
|
||||
Auth --> DB
|
||||
Payment --> Proxy
|
||||
Proxy --> Provider
|
||||
Payment --> Mint
|
||||
API --> Nostr
|
||||
end
|
||||
|
||||
Client --> API
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### FastAPI Application
|
||||
|
||||
The main application is initialized in `routstr/core/main.py`:
|
||||
|
||||
- **Lifespan Management**: Runs migrations, initializes DB, refreshes pricing/models, starts background tasks
|
||||
- **Middleware**: CORS and request logging
|
||||
- **Routers**: Admin, pricing/models, balance/wallet, providers discovery, proxy
|
||||
- **Background Tasks**: Price refresh, model map refresh, payouts, node announcements, provider discovery refresh
|
||||
|
||||
### Authentication System
|
||||
|
||||
Located in `routstr/auth.py`, handles:
|
||||
|
||||
- **API Key Validation**: SHA-256 hashed key lookup and persistence
|
||||
- **Balance Checking**: Ensures sufficient funds before requests
|
||||
- **Token Redemption**: Converts Cashu tokens to balance
|
||||
|
||||
### Payment Processing
|
||||
|
||||
The `routstr/payment/` module manages:
|
||||
|
||||
- **Cost Calculation**: Token-based or fixed pricing
|
||||
- **Model Pricing**: Derived from upstream providers and DB overrides
|
||||
- **Currency Conversion**: BTC/USD price refresh and conversion
|
||||
- **Fee Application**: Provider fee applied to upstream model pricing
|
||||
|
||||
### Request Proxying
|
||||
|
||||
`routstr/proxy.py` handles:
|
||||
|
||||
- **Request Forwarding**: Forwards requests to selected upstream providers
|
||||
- **Response Streaming**: Streaming and non-streaming paths
|
||||
- **Usage Tracking**: Adjusts costs after upstream responses
|
||||
- **Error Handling**: Maps upstream errors to consistent responses
|
||||
|
||||
### Database Layer
|
||||
|
||||
Using SQLModel in `routstr/core/db.py`:
|
||||
|
||||
```python
|
||||
# Core tables
|
||||
ApiKey:
|
||||
- hashed_key: Primary key (SHA-256 of key or Cashu token)
|
||||
- balance: Current balance (msats)
|
||||
- reserved_balance: Reserved balance (msats)
|
||||
- refund_address: Optional LNURL for refunds
|
||||
- key_expiry_time: Optional refund expiry timestamp
|
||||
- total_spent: Total spent (msats)
|
||||
- total_requests: Request count
|
||||
- refund_mint_url: Mint URL for refunds
|
||||
- refund_currency: Refund currency
|
||||
|
||||
UpstreamProviderRow:
|
||||
- id: Primary key
|
||||
- provider_type: openai/anthropic/azure/openrouter/etc.
|
||||
- base_url: Provider API base URL
|
||||
- api_key: Provider API key
|
||||
- api_version: Optional API version
|
||||
- enabled: Provider enabled flag
|
||||
- provider_fee: Provider fee multiplier
|
||||
|
||||
ModelRow:
|
||||
- id: Model ID
|
||||
- upstream_provider_id: Provider foreign key
|
||||
- name: Model name
|
||||
- architecture: JSON
|
||||
- pricing: JSON
|
||||
- sats_pricing: JSON
|
||||
- per_request_limits: JSON
|
||||
- top_provider: JSON
|
||||
- canonical_slug: Canonical model slug
|
||||
- alias_ids: Model aliases
|
||||
- enabled: Model enabled flag
|
||||
|
||||
LightningInvoice:
|
||||
- id: Primary key
|
||||
- bolt11: Invoice
|
||||
- amount_sats: Amount in sats
|
||||
- payment_hash: Payment hash
|
||||
- status: pending/paid/expired/cancelled
|
||||
- api_key_hash: Optional associated API key
|
||||
- purpose: create/topup
|
||||
- created_at: Unix timestamp
|
||||
- expires_at: Unix timestamp
|
||||
- paid_at: Unix timestamp
|
||||
```
|
||||
|
||||
## Request Flow
|
||||
|
||||
### Standard API Request
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant R as Routstr
|
||||
participant D as Database
|
||||
participant P as AI Provider
|
||||
|
||||
C->>R: API Request + Key
|
||||
R->>D: Validate Key
|
||||
D-->>R: Key Info + Balance
|
||||
R->>R: Reserve Max Cost
|
||||
R->>P: Forward Request
|
||||
P-->>R: AI Response
|
||||
R->>D: Finalize Cost (adjust by usage)
|
||||
R-->>C: Return Response
|
||||
```
|
||||
|
||||
### Payment Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant R as Routstr
|
||||
participant W as Wallet Module
|
||||
participant M as Cashu Mint
|
||||
participant D as Database
|
||||
|
||||
C->>R: Request + Cashu Token
|
||||
R->>W: Redeem Token
|
||||
W->>M: Verify with Mint
|
||||
M-->>W: Token Valid
|
||||
W-->>R: Token Amount
|
||||
R->>D: Create/Update Key + Balance
|
||||
R-->>C: Continue Request
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Async Architecture
|
||||
|
||||
The system is async end-to-end, with background tasks for pricing refresh, provider discovery, model map refresh, and payouts.
|
||||
|
||||
### 2. Modular Design
|
||||
|
||||
Components are loosely coupled:
|
||||
|
||||
- **Routers**: Separate files for different endpoints
|
||||
- **Dependencies**: Injected via FastAPI's DI system
|
||||
- **Models**: Shared data structures
|
||||
- **Services**: Business logic separated from routes
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
Exceptions are handled by FastAPI exception handlers to return consistent JSON responses with a request ID.
|
||||
|
||||
### 4. Database Migrations
|
||||
|
||||
Alembic migrations are run on startup, and tables are created for any models not tracked by migrations.
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### API Key Security
|
||||
|
||||
- **Storage**: SHA-256 hashed keys
|
||||
- **Generation**: Cryptographically secure random (when creating new keys)
|
||||
- **Validation**: Hash lookup in DB
|
||||
- **Expiry**: Optional refund flow via `key_expiry_time` and `refund_address`
|
||||
|
||||
### Payment Security
|
||||
|
||||
- **Token Validation**: Cashu token redemption via mint
|
||||
- **Balance Protection**: Atomic updates and reserved balance tracking
|
||||
- **Audit Trail**: Structured logging of payments and adjustments
|
||||
|
||||
### Network Security
|
||||
|
||||
- **CORS**: Configurable origins
|
||||
- **Input Validation**: Pydantic models
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
Model and provider selections are cached in process memory and refreshed on a schedule.
|
||||
|
||||
### Database Optimization
|
||||
|
||||
- **Async I/O**: Non-blocking queries
|
||||
- **Atomic Updates**: Balance reservation and finalization updates
|
||||
|
||||
### Streaming Responses
|
||||
|
||||
Streaming responses are forwarded from upstream providers with usage tracking hooks.
|
||||
|
||||
## Extension Points
|
||||
|
||||
### Adding New Endpoints
|
||||
|
||||
1. Create router module
|
||||
2. Define Pydantic models
|
||||
3. Implement business logic
|
||||
4. Register with main app
|
||||
5. Add tests
|
||||
|
||||
### Custom Pricing Models
|
||||
|
||||
1. Extend `ModelPrice` class
|
||||
2. Implement calculation logic
|
||||
3. Add to pricing registry
|
||||
4. Update configuration
|
||||
|
||||
### Payment Methods
|
||||
|
||||
1. Create payment handler
|
||||
2. Implement validation
|
||||
3. Add to payment router
|
||||
4. Update balance logic
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- Mock external dependencies
|
||||
- Test business logic in isolation
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Test component interactions
|
||||
- Use test database
|
||||
- Mock external services
|
||||
- Verify end-to-end flows
|
||||
|
||||
### Performance Tests
|
||||
|
||||
- Response time benchmarks (as needed)
|
||||
|
||||
## Monitoring and Observability
|
||||
|
||||
### Structured Logging
|
||||
|
||||
```python
|
||||
logger.info("api_request", extra={
|
||||
"request_id": request_id,
|
||||
"api_key": api_key_id,
|
||||
"endpoint": endpoint,
|
||||
"model": model,
|
||||
"tokens": token_count,
|
||||
"cost_sats": cost,
|
||||
"duration_ms": duration
|
||||
})
|
||||
```
|
||||
|
||||
### Metrics Collection
|
||||
|
||||
Structured logs are emitted for requests, pricing, and payment events.
|
||||
|
||||
### Health Checks
|
||||
|
||||
Use `/v1/info` for basic service metadata and configuration visibility.
|
||||
|
||||
## Deployment Architecture
|
||||
|
||||
### Container Structure
|
||||
|
||||
See `core/Dockerfile` for the current container build configuration.
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
- **Development**: Local SQLite, debug logging
|
||||
- **Testing**: In-memory database, mock services
|
||||
- **Production**: Persistent storage, structured logs
|
||||
|
||||
### Scaling Considerations
|
||||
|
||||
- **Horizontal**: Multiple instances behind a load balancer
|
||||
- **Vertical**: Async handles high concurrency
|
||||
- **Database**: Configure `DATABASE_URL` for external databases
|
||||
|
||||
## Future Architecture
|
||||
|
||||
### Planned Improvements
|
||||
|
||||
1. **WebSocket Support**: Real-time balance updates
|
||||
2. **Plugin System**: Extensible pricing/auth
|
||||
3. **Multi-Region**: Geographic distribution
|
||||
4. **Event Sourcing**: Complete audit trail
|
||||
|
||||
### Technical Debt
|
||||
|
||||
Areas for improvement:
|
||||
|
||||
- Database query optimization
|
||||
- Response caching layer
|
||||
- Metric aggregation
|
||||
- API versioning strategy
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Review [Code Structure](code-structure.md) for detailed organization
|
||||
- See [Testing Guide](testing.md) for test architecture
|
||||
@@ -0,0 +1,443 @@
|
||||
# Code Structure
|
||||
|
||||
This guide provides a detailed overview of Routstr Core's codebase organization and key modules.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
routstr-core/
|
||||
├── routstr/ # Main application package
|
||||
│ ├── __init__.py # Package initialization, exports FastAPI app
|
||||
│ ├── algorithm.py # Model selection/mapping logic
|
||||
│ ├── auth.py # Bearer/Cashu auth and payment handling
|
||||
│ ├── balance.py # Balance management endpoints
|
||||
│ ├── discovery.py # Nostr relay discovery
|
||||
│ ├── lightning.py # Lightning invoice topups
|
||||
│ ├── nip91.py # Node announcement logic
|
||||
│ ├── proxy.py # Request proxying logic
|
||||
│ ├── wallet.py # Cashu wallet operations
|
||||
│ │
|
||||
│ ├── core/ # Core infrastructure
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── admin.py # Admin dashboard and API
|
||||
│ │ ├── db.py # Database models and connection
|
||||
│ │ ├── exceptions.py # Exception handlers
|
||||
│ │ ├── logging.py # Structured logging setup
|
||||
│ │ ├── main.py # FastAPI app initialization
|
||||
│ │ └── middleware.py # HTTP middleware components
|
||||
│ │
|
||||
│ ├── payment/ # Payment processing
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── cost_calculation.py # Usage cost calculation
|
||||
│ │ ├── helpers.py # Payment utilities
|
||||
│ │ ├── lnurl.py # Lightning URL support
|
||||
│ │ ├── models.py # Model pricing management
|
||||
│ │ └── price.py # BTC/USD price handling
|
||||
│ │
|
||||
│ └── upstream/ # Upstream provider integrations
|
||||
│ ├── base.py # Base provider logic
|
||||
│ ├── helpers.py # Provider init and model refresh
|
||||
│ └── ... # Provider implementations
|
||||
│
|
||||
├── tests/ # Test suite
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # Pytest configuration
|
||||
│ ├── unit/ # Unit tests
|
||||
│ └── integration/ # Integration tests
|
||||
│
|
||||
├── migrations/ # Alembic database migrations
|
||||
│ ├── alembic.ini
|
||||
│ ├── env.py
|
||||
│ ├── script.py.mako
|
||||
│ └── versions/ # Migration files
|
||||
│
|
||||
├── scripts/ # Utility scripts
|
||||
│ ├── models_meta.py # Fetch model pricing
|
||||
│ └── ... # Build/update helpers
|
||||
│
|
||||
├── examples/ # Example clients
|
||||
├── testing-clients/ # HTML test clients
|
||||
├── ui/ # Next.js admin UI
|
||||
│
|
||||
├── docs/ # Documentation
|
||||
├── logs/ # Application logs (git ignored)
|
||||
│
|
||||
├── .github/ # GitHub Actions workflows
|
||||
├── .env.example # Environment variable template
|
||||
├── .gitignore # Git ignore rules
|
||||
├── .dockerignore # Docker ignore rules
|
||||
├── Dockerfile # Container definition
|
||||
├── Makefile # Development commands
|
||||
├── README.md # Project overview
|
||||
├── alembic.ini # Migration configuration
|
||||
├── compose.yml # Docker Compose setup
|
||||
├── compose.testing.yml # Testing environment
|
||||
├── pyproject.toml # Project configuration
|
||||
└── uv.lock # Locked dependencies
|
||||
```
|
||||
|
||||
## Key Modules
|
||||
|
||||
### Application Entry Point
|
||||
|
||||
#### `routstr/__init__.py`
|
||||
|
||||
```python
|
||||
from .core.main import app as fastapi_app
|
||||
|
||||
__all__ = ["fastapi_app"]
|
||||
```
|
||||
|
||||
#### `routstr/core/main.py`
|
||||
|
||||
```python
|
||||
# FastAPI application setup
|
||||
app = FastAPI(version=__version__, lifespan=lifespan)
|
||||
|
||||
# Middleware registration
|
||||
app.add_middleware(CORSMiddleware, ...)
|
||||
app.add_middleware(LoggingMiddleware)
|
||||
|
||||
# Router inclusion
|
||||
app.include_router(models_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(balance_router)
|
||||
app.include_router(deprecated_wallet_router)
|
||||
app.include_router(providers_router)
|
||||
app.include_router(proxy_router)
|
||||
```
|
||||
|
||||
### Authentication Module
|
||||
|
||||
#### `routstr/auth.py`
|
||||
|
||||
Handles bearer key validation and payment lifecycle (bearer or Cashu token):
|
||||
|
||||
```python
|
||||
async def validate_bearer_key(
|
||||
bearer_key: str,
|
||||
session: AsyncSession,
|
||||
refund_address: Optional[str] = None,
|
||||
key_expiry_time: Optional[int] = None,
|
||||
) -> ApiKey:
|
||||
"""Validate bearer API key or redeem Cashu token into a balance."""
|
||||
```
|
||||
|
||||
Key functions:
|
||||
|
||||
- `validate_bearer_key()` - Validate API key or Cashu token
|
||||
- `pay_for_request()` - Reserve max cost before upstream call
|
||||
- `adjust_payment_for_tokens()` - Adjust final cost after response
|
||||
- `revert_pay_for_request()` - Refund on upstream failure
|
||||
|
||||
### Payment Processing
|
||||
|
||||
#### `routstr/payment/cost_calculation.py`
|
||||
|
||||
Calculates request costs:
|
||||
|
||||
```python
|
||||
async def calculate_cost(
|
||||
response_data: dict, max_cost: int, session: AsyncSession
|
||||
) -> CostData | MaxCostData | CostDataError:
|
||||
"""Calculate cost in millisatoshis from response usage or model pricing."""
|
||||
```
|
||||
|
||||
#### `routstr/payment/models.py`
|
||||
|
||||
Manages model pricing, database overrides, and pricing refresh:
|
||||
|
||||
```python
|
||||
class Model(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
pricing: Pricing
|
||||
sats_pricing: Pricing | None = None
|
||||
|
||||
async def update_sats_pricing():
|
||||
"""Periodic task to update sats pricing for providers and overrides."""
|
||||
```
|
||||
|
||||
#### `routstr/proxy.py` + `routstr/upstream/*`
|
||||
|
||||
The `x-cashu` header is handled by the proxy route and delegated to upstream providers.
|
||||
|
||||
### Request Proxying
|
||||
|
||||
#### `routstr/proxy.py`
|
||||
|
||||
Core proxy functionality:
|
||||
|
||||
```python
|
||||
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
|
||||
async def proxy(
|
||||
request: Request, path: str, session: AsyncSession = Depends(get_session)
|
||||
) -> Response | StreamingResponse:
|
||||
"""Forward requests to upstream provider and charge usage."""
|
||||
```
|
||||
|
||||
Key features:
|
||||
|
||||
- Streaming support
|
||||
- Header preservation
|
||||
- Error handling
|
||||
- Usage tracking
|
||||
|
||||
### Database Layer
|
||||
|
||||
#### `routstr/core/db.py`
|
||||
|
||||
SQLModel definitions (selected):
|
||||
|
||||
```python
|
||||
class ApiKey(SQLModel, table=True):
|
||||
hashed_key: str = Field(primary_key=True)
|
||||
balance: int
|
||||
reserved_balance: int = 0
|
||||
refund_address: str | None = None
|
||||
key_expiry_time: int | None = None
|
||||
total_spent: int = 0
|
||||
total_requests: int = 0
|
||||
|
||||
class LightningInvoice(SQLModel, table=True):
|
||||
id: str = Field(primary_key=True)
|
||||
bolt11: str
|
||||
amount_sats: int
|
||||
status: str
|
||||
|
||||
class UpstreamProviderRow(SQLModel, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
provider_type: str
|
||||
base_url: str
|
||||
api_key: str
|
||||
```
|
||||
|
||||
### Admin Interface
|
||||
|
||||
#### `routstr/core/admin.py`
|
||||
|
||||
Web dashboard and admin API:
|
||||
|
||||
```python
|
||||
@admin_router.get("/admin")
|
||||
async def admin_dashboard(request: Request):
|
||||
"""Render admin HTML interface"""
|
||||
# Authentication check
|
||||
# Load statistics
|
||||
# Render template
|
||||
|
||||
@admin_router.post("/admin/withdraw")
|
||||
async def withdraw_balance(
|
||||
request: Request, withdraw_request: WithdrawRequest
|
||||
) -> dict[str, str]:
|
||||
"""Generate eCash token for withdrawal"""
|
||||
```
|
||||
|
||||
Features:
|
||||
|
||||
- HTML dashboard
|
||||
- API key management
|
||||
- Balance withdrawals
|
||||
- Usage statistics
|
||||
|
||||
### Wallet Integration
|
||||
|
||||
#### `routstr/wallet.py`
|
||||
|
||||
Cashu wallet operations (function-based):
|
||||
|
||||
```python
|
||||
async def recieve_token(token: str) -> tuple[int, str, str]:
|
||||
"""Redeem eCash token and return amount/unit/mint."""
|
||||
|
||||
async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str:
|
||||
"""Create eCash token for withdrawal."""
|
||||
```
|
||||
|
||||
### Utility Modules
|
||||
|
||||
#### `routstr/core/logging.py`
|
||||
|
||||
Structured logging configuration:
|
||||
|
||||
```python
|
||||
def setup_logging():
|
||||
"""Configure JSON structured logging"""
|
||||
|
||||
class RequestIdFilter(logging.Filter):
|
||||
"""Attach request ID to log records."""
|
||||
```
|
||||
|
||||
#### `routstr/core/middleware.py`
|
||||
|
||||
HTTP middleware components:
|
||||
|
||||
```python
|
||||
class LoggingMiddleware:
|
||||
"""Log all HTTP requests/responses"""
|
||||
```
|
||||
|
||||
#### `routstr/core/exceptions.py`
|
||||
|
||||
Exception handlers:
|
||||
|
||||
```python
|
||||
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""HTTP exception handler with request ID"""
|
||||
|
||||
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Fallback exception handler with request ID"""
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### `pyproject.toml`
|
||||
|
||||
Project metadata and dependencies:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.115",
|
||||
"sqlmodel>=0.0.24",
|
||||
"cashu",
|
||||
# ...
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
```
|
||||
|
||||
### `alembic.ini`
|
||||
|
||||
Database migration configuration:
|
||||
|
||||
```ini
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
```
|
||||
|
||||
### `Makefile`
|
||||
|
||||
Development commands:
|
||||
|
||||
```makefile
|
||||
# Setup commands
|
||||
setup:
|
||||
uv sync
|
||||
uv pip install -e .
|
||||
|
||||
# Development server
|
||||
dev:
|
||||
fastapi dev routstr --host 0.0.0.0
|
||||
|
||||
# Testing
|
||||
test:
|
||||
uv run pytest
|
||||
|
||||
# Code quality
|
||||
lint:
|
||||
uv run ruff check .
|
||||
```
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
Using FastAPI's DI system:
|
||||
|
||||
```python
|
||||
# Define dependency
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
# Use in routes
|
||||
@router.get("/items")
|
||||
async def get_items(db: AsyncSession = Depends(get_session)):
|
||||
result = await db.execute(select(Item))
|
||||
return result.scalars().all()
|
||||
```
|
||||
|
||||
### Async Context Managers
|
||||
|
||||
For resource management:
|
||||
|
||||
```python
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
|
||||
async with database.transaction():
|
||||
# Atomic operations
|
||||
```
|
||||
|
||||
### Type Safety
|
||||
|
||||
Leveraging Python 3.11+ features:
|
||||
|
||||
```python
|
||||
# Union types with |
|
||||
def process(value: str | int) -> dict[str, Any]:
|
||||
pass
|
||||
|
||||
# Type aliases
|
||||
Balance = int # millisatoshis
|
||||
TokenList = list[dict[str, str]]
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Consistent error responses:
|
||||
|
||||
```python
|
||||
try:
|
||||
result = await risky_operation()
|
||||
except SpecificError as e:
|
||||
logger.error("Operation failed", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "specific_error",
|
||||
"message": str(e)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Module Organization
|
||||
|
||||
1. **Single Responsibility**: Each module has one clear purpose
|
||||
2. **Minimal Imports**: Import only what's needed
|
||||
3. **Circular Dependencies**: Avoid by using dependency injection
|
||||
4. **Public API**: Expose through `__init__.py`
|
||||
|
||||
### Function Design
|
||||
|
||||
1. **Type Hints**: Always include complete type annotations
|
||||
2. **Async First**: Use async/await for I/O operations
|
||||
3. **Error Handling**: Raise specific exceptions
|
||||
4. **Documentation**: Docstrings for public functions
|
||||
|
||||
### Testing Structure
|
||||
|
||||
1. **Mirror Source**: Test structure matches source
|
||||
2. **Fixtures**: Reusable test data in conftest.py
|
||||
3. **Mocking**: Mock external dependencies
|
||||
4. **Coverage**: Aim for >80% coverage
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Review [Testing Guide](testing.md) for test structure
|
||||
- Read [Architecture](architecture.md) for system design
|
||||
@@ -0,0 +1,424 @@
|
||||
# Development Setup
|
||||
|
||||
This guide will help you set up a development environment for contributing to Routstr Core.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have:
|
||||
|
||||
- **Python 3.11+** - Required for type hints and modern features
|
||||
- **Git** - For version control
|
||||
- **Docker** (optional) - For running integration tests
|
||||
- **Make** - For running development commands
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Fork and Clone
|
||||
|
||||
First, fork the repository on GitHub, then clone your fork:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/routstr-core.git
|
||||
cd routstr-core
|
||||
```
|
||||
|
||||
### 2. Set Up Environment
|
||||
|
||||
Run the setup command:
|
||||
|
||||
```bash
|
||||
make setup
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
- ✅ Install [uv](https://github.com/astral-sh/uv) if not present
|
||||
- ✅ Create a virtual environment
|
||||
- ✅ Install all dependencies
|
||||
- ✅ Install dev tools (mypy, ruff, pytest)
|
||||
- ✅ Install project in editable mode
|
||||
|
||||
### 3. Configure Environment
|
||||
|
||||
Create your environment file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with your configuration:
|
||||
|
||||
```bash
|
||||
# Minimum required for development
|
||||
UPSTREAM_BASE_URL=https://api.openai.com/v1
|
||||
UPSTREAM_API_KEY=your-api-key # Optional for mock testing
|
||||
ADMIN_PASSWORD=development-password
|
||||
DATABASE_URL=sqlite+aiosqlite:///dev.db
|
||||
```
|
||||
|
||||
### 4. Verify Installation
|
||||
|
||||
Run these commands to verify your setup:
|
||||
|
||||
```bash
|
||||
# Check dependencies
|
||||
make check-deps
|
||||
|
||||
# Run unit tests
|
||||
make test-unit
|
||||
|
||||
# Start development server
|
||||
make dev
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Running the Server
|
||||
|
||||
For development with auto-reload:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
# Server starts at http://localhost:8000
|
||||
# Auto-reloads on code changes
|
||||
```
|
||||
|
||||
For production-like environment:
|
||||
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
Before committing, always run:
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
make format
|
||||
|
||||
# Check linting
|
||||
make lint
|
||||
|
||||
# Type checking
|
||||
make type-check
|
||||
|
||||
# All checks at once
|
||||
make check
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Run different test suites:
|
||||
|
||||
```bash
|
||||
# Unit tests only (fast)
|
||||
make test-unit
|
||||
|
||||
# Integration tests with mocks
|
||||
make test-integration
|
||||
|
||||
# All tests
|
||||
make test
|
||||
|
||||
# With coverage report
|
||||
make test-coverage
|
||||
|
||||
# Run specific test
|
||||
uv run pytest tests/unit/test_auth.py::test_token_validation -v
|
||||
```
|
||||
|
||||
### Database Management
|
||||
|
||||
Work with database migrations:
|
||||
|
||||
```bash
|
||||
# Create new migration
|
||||
make db-migrate
|
||||
|
||||
# Apply migrations
|
||||
make db-upgrade
|
||||
|
||||
# Rollback one migration
|
||||
make db-downgrade
|
||||
|
||||
# View current revision
|
||||
make db-current
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
Understanding the codebase:
|
||||
|
||||
```
|
||||
routstr-core/
|
||||
├── routstr/ # Main package
|
||||
│ ├── __init__.py
|
||||
│ ├── algorithm.py # Provider selection algorithms
|
||||
│ ├── auth.py # Authentication logic
|
||||
│ ├── balance.py # Balance management API
|
||||
│ ├── discovery.py # Nostr discovery
|
||||
│ ├── lightning.py # Lightning invoice handling
|
||||
│ ├── nip91.py # Node announcement implementation
|
||||
│ ├── proxy.py # Request proxying
|
||||
│ ├── wallet.py # Cashu wallet integration
|
||||
│ │
|
||||
│ ├── core/ # Core modules
|
||||
│ │ ├── admin.py # Admin dashboard API
|
||||
│ │ ├── db.py # Database models (SQLModel)
|
||||
│ │ ├── exceptions.py # Custom exceptions
|
||||
│ │ ├── log_manager.py # Log management
|
||||
│ │ ├── logging.py # Logging setup
|
||||
│ │ ├── main.py # FastAPI app entry
|
||||
│ │ ├── middleware.py # HTTP middleware
|
||||
│ │ └── settings.py # Configuration
|
||||
│ │
|
||||
│ ├── payment/ # Payment processing
|
||||
│ │ ├── cost_calculation.py
|
||||
│ │ ├── helpers.py
|
||||
│ │ ├── lnurl.py # LNURL support
|
||||
│ │ ├── models.py # Model pricing
|
||||
│ │ └── price.py # BTC/USD rates
|
||||
│ │
|
||||
│ └── upstream/ # Upstream providers
|
||||
│ ├── base.py # Base provider class
|
||||
│ ├── helpers.py # Shared utilities
|
||||
│ ├── openai.py # OpenAI
|
||||
│ ├── anthropic.py # Anthropic
|
||||
│ ├── gemini.py # Google Gemini
|
||||
│ ├── openrouter.py # OpenRouter
|
||||
│ └── ... # More providers
|
||||
│
|
||||
├── ui/ # Admin dashboard (Next.js)
|
||||
│ ├── app/ # Next.js app router
|
||||
│ │ ├── page.tsx # Landing page
|
||||
│ │ ├── balances/ # Balance management
|
||||
│ │ ├── logs/ # Request logs viewer
|
||||
│ │ ├── model/ # Model configuration
|
||||
│ │ ├── providers/ # Upstream providers
|
||||
│ │ ├── settings/ # Node settings
|
||||
│ │ └── transactions/ # Transaction history
|
||||
│ ├── components/ # React components
|
||||
│ │ ├── ui/ # shadcn/ui primitives
|
||||
│ │ ├── landing/ # Landing page components
|
||||
│ │ └── settings/ # Settings components
|
||||
│ └── lib/ # Utilities & API client
|
||||
│ ├── api/ # Backend API client
|
||||
│ ├── auth/ # Auth context
|
||||
│ └── hooks/ # React hooks
|
||||
│
|
||||
├── tests/ # Test suite
|
||||
├── migrations/ # Alembic migrations
|
||||
├── scripts/ # Utility scripts
|
||||
├── docs/ # Documentation
|
||||
├── examples/ # Usage examples
|
||||
│
|
||||
├── Makefile # Dev commands
|
||||
├── pyproject.toml # Project config
|
||||
└── compose.yml # Docker setup
|
||||
```
|
||||
|
||||
### Admin Dashboard (UI)
|
||||
|
||||
The admin dashboard is a Next.js app using:
|
||||
|
||||
- **Next.js 14** with App Router
|
||||
- **shadcn/ui** for components
|
||||
- **Tailwind CSS** for styling
|
||||
- **pnpm** for package management
|
||||
|
||||
```bash
|
||||
# Development
|
||||
cd ui
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:3000
|
||||
|
||||
# Build for production
|
||||
pnpm build
|
||||
```
|
||||
|
||||
The UI is served by the FastAPI backend at `/admin/` when built. Use `make build-ui` to build and copy to the backend.
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Endpoint
|
||||
|
||||
1. Create route in appropriate module
|
||||
2. Add request/response models
|
||||
3. Write unit tests
|
||||
4. Update API documentation
|
||||
5. Add integration tests
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
# In routstr/core/main.py or appropriate router
|
||||
@app.get("/v1/stats")
|
||||
async def get_stats(
|
||||
user: User = Depends(get_current_user)
|
||||
) -> StatsResponse:
|
||||
"""Get usage statistics for the current user."""
|
||||
# Implementation
|
||||
pass
|
||||
```
|
||||
|
||||
### Adding a Database Model
|
||||
|
||||
1. Define model in `routstr/core/db.py`
|
||||
2. Create migration: `make db-migrate`
|
||||
3. Review generated migration
|
||||
4. Apply: `make db-upgrade`
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
class Transaction(SQLModel, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
api_key_id: int = Field(foreign_key="apikey.id")
|
||||
amount: int # millisatoshis
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
description: str
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
Follow the AAA pattern:
|
||||
|
||||
```python
|
||||
async def test_balance_deduction():
|
||||
# Arrange
|
||||
api_key = await create_test_api_key(balance=1000)
|
||||
|
||||
# Act
|
||||
result = await deduct_balance(api_key.key, amount=100)
|
||||
|
||||
# Assert
|
||||
assert result.success
|
||||
assert result.new_balance == 900
|
||||
assert result.deducted == 100
|
||||
```
|
||||
|
||||
## Development Tools
|
||||
|
||||
### Makefile Commands
|
||||
|
||||
Key commands for development:
|
||||
|
||||
```bash
|
||||
make help # Show all commands
|
||||
make setup # Initial setup
|
||||
make dev # Run dev server
|
||||
make test # Run all tests
|
||||
make lint # Check code style
|
||||
make format # Fix code style
|
||||
make type-check # Check types
|
||||
make clean # Clean temp files
|
||||
make docker-build # Build Docker image
|
||||
```
|
||||
|
||||
### IDE Setup
|
||||
|
||||
#### VS Code
|
||||
|
||||
Recommended extensions:
|
||||
|
||||
- Python
|
||||
- Pylance
|
||||
- Ruff
|
||||
- GitLens
|
||||
|
||||
Settings (`.vscode/settings.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"python.linting.enabled": true,
|
||||
"python.linting.ruffEnabled": true,
|
||||
"python.formatting.provider": "ruff",
|
||||
"python.analysis.typeCheckingMode": "strict",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
```
|
||||
|
||||
#### PyCharm
|
||||
|
||||
1. Set Python interpreter to uv venv
|
||||
2. Enable type checking
|
||||
3. Configure Ruff as external tool
|
||||
4. Set up file watchers for formatting
|
||||
|
||||
### Debugging
|
||||
|
||||
#### Debug Server
|
||||
|
||||
```bash
|
||||
# Run with debug logging
|
||||
LOG_LEVEL=DEBUG make dev
|
||||
|
||||
# Or with debugger
|
||||
uv run python -m debugpy --listen 5678 --wait-for-client \
|
||||
-m uvicorn routstr:fastapi_app --reload
|
||||
```
|
||||
|
||||
#### Debug Tests
|
||||
|
||||
```bash
|
||||
# Run specific test with output
|
||||
uv run pytest tests/unit/test_auth.py -v -s
|
||||
|
||||
# With debugger
|
||||
uv run pytest tests/unit/test_auth.py --pdb
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Import Errors**
|
||||
|
||||
```bash
|
||||
# Ensure project is installed in editable mode
|
||||
uv sync
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
**Database Errors**
|
||||
|
||||
```bash
|
||||
# Reset database
|
||||
rm dev.db
|
||||
make db-upgrade
|
||||
```
|
||||
|
||||
**Type Checking Fails**
|
||||
|
||||
```bash
|
||||
# Clear mypy cache
|
||||
make clean
|
||||
make type-check
|
||||
```
|
||||
|
||||
**Tests Fail Locally**
|
||||
|
||||
```bash
|
||||
# Ensure test dependencies are installed
|
||||
uv sync --dev
|
||||
|
||||
# Check for leftover test data
|
||||
rm -rf test_*.db
|
||||
```
|
||||
|
||||
### Getting Help
|
||||
|
||||
- Check existing [GitHub Issues](https://github.com/routstr/routstr-core/issues)
|
||||
- Ask in [GitHub Discussions](https://github.com/routstr/routstr-core/discussions)
|
||||
- Read the [Architecture Guide](architecture.md)
|
||||
|
||||
## Next Steps
|
||||
|
||||
Now that you're set up:
|
||||
|
||||
1. Read the [Architecture Overview](architecture.md)
|
||||
2. Check [open issues](https://github.com/routstr/routstr-core/issues)
|
||||
3. Start with a small contribution
|
||||
|
||||
Happy coding! 🚀
|
||||
@@ -0,0 +1,233 @@
|
||||
# Testing Guide
|
||||
|
||||
This guide covers testing practices, patterns, and tools used in Routstr Core development.
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
We follow these principles:
|
||||
|
||||
- Test behavior, not implementation
|
||||
- Fast feedback
|
||||
- Reliable tests
|
||||
- Clear failures
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── integration/
|
||||
│ ├── conftest.py
|
||||
│ ├── utils.py
|
||||
│ ├── test_wallet_topup.py
|
||||
│ ├── test_wallet_refund.py
|
||||
│ ├── test_wallet_information.py
|
||||
│ ├── test_proxy_get_endpoints.py
|
||||
│ ├── test_proxy_post_endpoints.py
|
||||
│ └── ... more integration tests
|
||||
├── unit/
|
||||
│ ├── test_algorithm.py
|
||||
│ ├── test_fee_consistency.py
|
||||
│ ├── test_image_tokens.py
|
||||
│ ├── test_logging_securityfilter.py
|
||||
│ ├── test_payment_helpers.py
|
||||
│ ├── test_settings.py
|
||||
│ ├── test_wallet.py
|
||||
│ └── ... more unit tests
|
||||
└── run_integration.py
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Make Targets
|
||||
|
||||
```bash
|
||||
# Run all tests (unit + integration with mocks)
|
||||
make test
|
||||
|
||||
# Unit tests only
|
||||
make test-unit
|
||||
|
||||
# Integration tests with mocks (fast)
|
||||
make test-integration
|
||||
|
||||
# Integration tests with Docker services
|
||||
make test-integration-docker
|
||||
|
||||
# Fast tests only (skip slow and Docker tests)
|
||||
make test-fast
|
||||
|
||||
# Performance tests
|
||||
make test-performance
|
||||
|
||||
# Coverage
|
||||
make test-coverage
|
||||
```
|
||||
|
||||
### Direct pytest Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run a specific test file
|
||||
pytest tests/unit/test_wallet.py -v
|
||||
|
||||
# Run a specific test
|
||||
pytest tests/unit/test_wallet.py::test_get_balance -v
|
||||
|
||||
# Run tests matching a pattern
|
||||
pytest -k "wallet" -v
|
||||
```
|
||||
|
||||
## Test Modes (Integration)
|
||||
|
||||
Integration tests support two execution modes:
|
||||
|
||||
- Mock mode (default): uses in-memory mocks, no Docker required
|
||||
- Docker mode: uses real Docker services (Cashu mint, mock OpenAI, Nostr relay)
|
||||
|
||||
Use the runner script for Docker mode:
|
||||
|
||||
```bash
|
||||
./tests/run_integration.py
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
docker-compose -f compose.testing.yml up -d
|
||||
USE_LOCAL_SERVICES=1 pytest tests/integration/ -v
|
||||
docker-compose -f compose.testing.yml down -v
|
||||
```
|
||||
|
||||
## Test Markers
|
||||
|
||||
Markers are defined in `pyproject.toml`:
|
||||
|
||||
- `integration`
|
||||
- `unit`
|
||||
- `slow`
|
||||
- `requires_docker`
|
||||
- `requires_real_mint`
|
||||
- `performance`
|
||||
- `asyncio`
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Skip slow tests
|
||||
pytest -m "not slow" -v
|
||||
|
||||
# Run only integration tests
|
||||
pytest -m "integration" -v
|
||||
|
||||
# Run performance tests
|
||||
pytest -m "performance" -v
|
||||
```
|
||||
|
||||
## Fixtures and Utilities
|
||||
|
||||
### Core Integration Fixtures
|
||||
|
||||
Defined in `tests/integration/conftest.py`:
|
||||
|
||||
- `integration_client` - Async HTTP client for the FastAPI app
|
||||
- `authenticated_client` - Client with a pre-created API key
|
||||
- `testmint_wallet` - Test wallet for generating Cashu tokens
|
||||
- `db_snapshot` - Database state snapshot/diff helper
|
||||
- `create_api_key` - Helper to create API keys for tests
|
||||
- `integration_engine`, `integration_session` - Async DB engine/session
|
||||
- `background_tasks_controller` - Control background tasks in tests
|
||||
- `mock_upstream_server` - Mock upstream API responses
|
||||
|
||||
### Integration Utilities
|
||||
|
||||
Defined in `tests/integration/utils.py`:
|
||||
|
||||
- `CashuTokenGenerator`
|
||||
- `ResponseValidator`
|
||||
- `PerformanceValidator`
|
||||
- `ConcurrencyTester`
|
||||
- `DatabaseStateValidator`
|
||||
- `MockServiceBuilder`
|
||||
- `TestDataBuilder`
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Unit Test Example
|
||||
|
||||
```python
|
||||
from routstr.algorithm import calculate_model_cost_score
|
||||
from routstr.payment.models import Architecture, Model, Pricing
|
||||
|
||||
|
||||
def test_calculate_model_cost_score_basic() -> None:
|
||||
model = Model(
|
||||
id="test-model",
|
||||
name="Test test-model",
|
||||
created=1234567890,
|
||||
description="Test model",
|
||||
context_length=8192,
|
||||
architecture=Architecture(
|
||||
modality="text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="gpt",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(
|
||||
prompt=0.001,
|
||||
completion=0.002,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
),
|
||||
)
|
||||
assert calculate_model_cost_score(model) == 0.002
|
||||
```
|
||||
|
||||
### Integration Test Example
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_topup(
|
||||
authenticated_client: AsyncClient,
|
||||
testmint_wallet: object,
|
||||
db_snapshot: object,
|
||||
) -> None:
|
||||
await db_snapshot.capture()
|
||||
token = await testmint_wallet.mint_tokens(1000)
|
||||
response = await authenticated_client.post(
|
||||
"/v1/wallet/topup", params={"cashu_token": token}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
diff = await db_snapshot.diff()
|
||||
assert len(diff["api_keys"]["modified"]) == 1
|
||||
```
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
```bash
|
||||
# Show print output
|
||||
pytest -s tests/unit/test_wallet.py
|
||||
|
||||
# Drop into debugger on failure
|
||||
pytest --pdb
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Docker mode failures: check `docker ps` and `docker-compose -f compose.testing.yml logs`
|
||||
- Connection errors: make sure ports 3338, 3000, 8000, and 8088 are free
|
||||
- Slow tests: use `pytest -m "not slow"` or `make test-fast`
|
||||
|
||||
## Next Steps
|
||||
|
||||
- See [Architecture](architecture.md)
|
||||
- Read [Setup Guide](setup.md)
|
||||
@@ -0,0 +1,44 @@
|
||||
# Routstr Core Documentation
|
||||
|
||||
**Routstr** is a decentralized protocol for permissionless AI inference. It enables an open marketplace where anyone can buy and sell compute using **Bitcoin eCash (Cashu)**.
|
||||
|
||||
---
|
||||
|
||||
## 🐣 For Clients (Users & Builders)
|
||||
|
||||
If you want to use AI models in your application without accounts or KYC.
|
||||
|
||||
- **[Introduction](client/introduction.md)**: How the ecosystem works.
|
||||
- **[Payment Flow](client/payments.md)**: Funding sessions, topping up, and refunds.
|
||||
- **[Integration Guide](client/integration.md)**: Code examples for Python, JS, and cURL.
|
||||
|
||||
## 🦁 For Providers (Node Operators)
|
||||
|
||||
If you want to run a node, resell API access, or monetize hardware.
|
||||
|
||||
- **[Quick Start](provider/quickstart.md)**: Deploy a node in 5 minutes.
|
||||
- **[Deployment](provider/deployment.md)**: Production Docker setup.
|
||||
- **[Configuration](provider/configuration.md)**: Environment variables and settings.
|
||||
- **[Dashboard](provider/dashboard.md)**: Managing your node visually.
|
||||
- **[Pricing Strategy](provider/pricing.md)**: Setting margins and fees.
|
||||
- **[Discovery](provider/discovery.md)**: Announcing your node on Nostr.
|
||||
- **[Tor Support](provider/tor.md)**: Running an anonymous hidden service.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Reference
|
||||
|
||||
- **[Overview](api/overview.md)**: Base URL, headers, and standards.
|
||||
- **[Endpoints](api/endpoints.md)**: Full list of REST endpoints.
|
||||
- **[Authentication](api/authentication.md)**: Handling API keys and tokens.
|
||||
- **[Errors](api/errors.md)**: Status codes and debugging.
|
||||
|
||||
## 🛠️ Contributing
|
||||
|
||||
- **[Architecture](contributing/architecture.md)**: System design.
|
||||
- **[Setup](contributing/setup.md)**: Development environment.
|
||||
- **[Testing](contributing/testing.md)**: Running tests.
|
||||
|
||||
---
|
||||
|
||||
*Powered by [Cashu](https://cashu.space) and [Nostr](https://nostr.com).*
|
||||
@@ -0,0 +1,76 @@
|
||||
# Overview
|
||||
|
||||
Routstr is a decentralized protocol for **permissionless, private, and censorship-resistant AI inference**. It creates an open marketplace where anyone can sell llm-tokens and anyone can buy them using privacy-preserving micropayments.
|
||||
|
||||
By combining **Nostr** (for censorship-resistant discovery and communication) and **Cashu** (for private, instant Bitcoin eCash payments), Routstr effectively removes the "middleman" from the AI ecosystem.
|
||||
|
||||
## How it Works
|
||||
|
||||
The network consists of independent **Providers** (Sellers) and **Clients** (Buyers). There is no central server, no login, and no credit card required.
|
||||
|
||||
1. **Discovery (Nostr)**: Providers announce their availability, models (e.g., `gpt-4o`, `deepseek-r1`), and prices on the Nostr network.
|
||||
2. **Payment (Cashu)**: Clients pay providers directly using Bitcoin eCash (Cashu tokens). These payments are untraceable and settle instantly.
|
||||
3. **Inference (Proxy)**: The Provider acts as a gateway (or runs local hardware), executing the AI model and returning the result to the Client.
|
||||
|
||||
## Who is this for?
|
||||
|
||||
The documentation is split into two paths depending on your goal:
|
||||
|
||||
### 🐣 I want to BUILD on Routstr (Client)
|
||||
|
||||
You are a developer building an AI agent, a chat app, or a script, and you want access to AI models without API keys, subscriptions, or KYC.
|
||||
|
||||
* **No Accounts**: Just get a wallet.
|
||||
* **Privacy**: Your requests are mixed with thousands of others; providers can't profile you.
|
||||
* **Choice**: Switch between hundreds of providers instantly for the best price/performance.
|
||||
|
||||
👉 **[Go to Client Guide](client/introduction.md)**
|
||||
|
||||
### 🦁 I want to RUN a Node (Provider)
|
||||
|
||||
You have API credits (OpenAI, Anthropic, etc.) or GPU capacity and want to earn Bitcoin by selling AI access to the network.
|
||||
|
||||
* **Monetize API Keys**: Connect your OpenAI/Anthropic/OpenRouter accounts and earn sats on every request.
|
||||
* **Monetize Hardware**: Run local models (via vLLM, Ollama) and sell access.
|
||||
* **Permissionless**: No approval needed. Start the container, configure via dashboard, start earning.
|
||||
|
||||
!!! note "Coming Soon"
|
||||
Future versions will support node-to-node routing—run a gateway without needing your own AI provider credentials.
|
||||
|
||||
👉 **[Go to Provider Guide](provider/quickstart.md)**
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Routstr is built on a modular stack defined by the [Routstr Improvement Protocols (RIPs)](https://github.com/routstr/rips).
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Client
|
||||
A[App / Agent]
|
||||
end
|
||||
|
||||
subgraph Provider
|
||||
B[Routstr Node<br/>Proxy + Auth + Billing]
|
||||
end
|
||||
|
||||
subgraph Upstream
|
||||
C[OpenAI / Anthropic<br/>vLLM / Ollama / ...]
|
||||
end
|
||||
|
||||
A -- "Request +<br/>Cashu Token" --> B
|
||||
B -- "Forward<br/>Request" --> C
|
||||
C -- "Response +<br/>Usage" --> B
|
||||
B -- "Response +<br/>Refund Token" --> A
|
||||
```
|
||||
|
||||
## Why Routstr?
|
||||
|
||||
| Feature | Closed AI | Routstr |
|
||||
| :--- | :--- | :--- |
|
||||
| **Access** | Account, KYC, Credit Card | Permissionless, Bitcoin-native |
|
||||
| **Privacy** | Full Logging & Tracking | Blinded Payments, Ephemeral Sessions |
|
||||
| **Resilience** | Single Point of Failure | Decentralized Network |
|
||||
| **Pricing** | Fixed, Monopolistic | Dynamic, Market-driven |
|
||||
| **Global** | Geofenced | Borderless (Tor/I2P supported) |
|
||||
@@ -0,0 +1,114 @@
|
||||
# Advanced Pricing
|
||||
|
||||
Advanced pricing strategies for fine-tuned control over your revenue model.
|
||||
|
||||
---
|
||||
|
||||
## Default Behavior
|
||||
|
||||
By default, Routstr:
|
||||
|
||||
1. **Fetches costs** from your upstream provider
|
||||
2. **Applies markup** using your fee settings
|
||||
3. **Converts to sats** using real-time BTC price
|
||||
|
||||
**Formula**: `Price = Upstream Cost × Exchange Fee × Upstream Fee`
|
||||
|
||||
---
|
||||
|
||||
## Strategy 1: Fixed Per-Request
|
||||
|
||||
Charge a flat fee regardless of model or tokens used.
|
||||
|
||||
**Configure in Dashboard** → **Settings** → **Pricing**:
|
||||
|
||||
- Enable **Fixed Pricing**
|
||||
- Set **Fixed Cost Per Request** (in sats)
|
||||
|
||||
**Use cases**:
|
||||
|
||||
- Internal tools with predictable usage
|
||||
- Simple "pay once, get response" APIs
|
||||
- Subscription-like tiers
|
||||
|
||||
---
|
||||
|
||||
## Strategy 2: Fixed Per-Token
|
||||
|
||||
Override dynamic pricing with global per-token rates.
|
||||
|
||||
**Configure in Dashboard** → **Settings** → **Pricing**:
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| **Fixed Per 1K Input** | Sats per 1,000 prompt tokens |
|
||||
| **Fixed Per 1K Output** | Sats per 1,000 completion tokens |
|
||||
|
||||
When set to non-zero values, these override model-specific pricing for all models.
|
||||
|
||||
---
|
||||
|
||||
## Strategy 3: Per-Model Custom Pricing
|
||||
|
||||
Set specific prices for individual models, overriding both upstream cost and global fees.
|
||||
|
||||
**Configure in Dashboard** → **Models**:
|
||||
|
||||
1. Click on a model (e.g., `gpt-4`)
|
||||
2. Enter **Prompt Price** and **Completion Price** (USD per 1M tokens)
|
||||
3. Save
|
||||
|
||||
**Example**: OpenAI charges $30/1M for GPT-4. Set your price to $35/1M to lock in a margin regardless of fee settings.
|
||||
|
||||
---
|
||||
|
||||
## Minimum Charge
|
||||
|
||||
Prevent dust transactions and spam:
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| **Min Request Cost** | Minimum charge in msats | 1000 (1 sat) |
|
||||
|
||||
If a request's calculated cost falls below this (e.g., very short prompts), the client pays the minimum.
|
||||
|
||||
---
|
||||
|
||||
## Combining Strategies
|
||||
|
||||
Strategies apply in order of specificity:
|
||||
|
||||
1. **Per-model override** (highest priority)
|
||||
2. **Fixed per-token rates**
|
||||
3. **Dynamic pricing with fees** (default)
|
||||
4. **Fixed per-request** (overrides all above if enabled)
|
||||
|
||||
**Example setup**:
|
||||
|
||||
- Dynamic pricing as default (10% markup)
|
||||
- GPT-4 locked at $35/1M (premium model)
|
||||
- Claude Haiku at 5 sats/1K tokens (budget option)
|
||||
- Minimum 1 sat per request
|
||||
|
||||
---
|
||||
|
||||
## Pricing for Profit
|
||||
|
||||
### High-Volume Strategy
|
||||
|
||||
Lower margins, more clients:
|
||||
|
||||
- Exchange Fee: 1.002 (0.2%)
|
||||
- Upstream Fee: 1.05 (5%)
|
||||
|
||||
### Premium Strategy
|
||||
|
||||
Higher margins, fewer clients:
|
||||
|
||||
- Exchange Fee: 1.01 (1%)
|
||||
- Upstream Fee: 1.25 (25%)
|
||||
|
||||
### Mixed Strategy
|
||||
|
||||
- Cheap models (GLM-4.7-Flash, Seed-1.6): Low margin to attract volume
|
||||
- Premium models (GPT-5-Pro, Claude-Opus): High margin for profit
|
||||
@@ -0,0 +1,148 @@
|
||||
# Configuration
|
||||
|
||||
Routstr is configured primarily through the **Admin Dashboard**. All settings persist in the database and take effect immediately—no restarts required.
|
||||
|
||||
For automated deployments, you can optionally pre-configure settings via environment variables.
|
||||
|
||||
---
|
||||
|
||||
## Initial Setup (.env file)
|
||||
|
||||
Before running your node, you should create a `.env` file in the project root. This file is used to bootstrap the initial configuration and store sensitive secrets.
|
||||
|
||||
### Example .env
|
||||
|
||||
```bash
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
|
||||
# Node Identity
|
||||
NAME="My AI Node"
|
||||
DESCRIPTION="Fast access to models"
|
||||
|
||||
# Lightning Payouts
|
||||
RECEIVE_LN_ADDRESS=yourname@wallet.com
|
||||
```
|
||||
|
||||
### Setting the UI Password
|
||||
|
||||
There are two ways to set or change your Admin Dashboard password:
|
||||
|
||||
1. **Via Environment Variable**: Set `ADMIN_PASSWORD` in your `.env` file before starting the container. This will be the password used for the first login.
|
||||
2. **Via Dashboard**: Once logged in, go to **Settings** → **Security** to update your password. Dashboard settings override the `.env` file once saved.
|
||||
|
||||
---
|
||||
|
||||
## Admin Dashboard (Primary)
|
||||
|
||||
Access the dashboard at `/admin/` on your node.
|
||||
|
||||
### Upstream Providers
|
||||
|
||||
Connect to your AI provider(s):
|
||||
|
||||
| Setting | Description |
|
||||
| ---------------- | ------------------------------------------------ |
|
||||
| **Upstream URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
|
||||
| **API Key** | Your provider's API key |
|
||||
|
||||
### Node Identity
|
||||
|
||||
How your node appears to clients:
|
||||
|
||||
| Setting | Description |
|
||||
| --------------- | -------------------------------------- |
|
||||
| **Name** | Display name (e.g., "Fast GPT-4 Node") |
|
||||
| **Description** | Brief description of your service |
|
||||
|
||||
### Pricing
|
||||
|
||||
Control your profit margins:
|
||||
|
||||
| Setting | Description | Default |
|
||||
| ----------------- | ------------------------------------------ | ------------ |
|
||||
| **Fixed Pricing** | Charge flat rate per request vs. per-token | Off |
|
||||
| **Exchange Fee** | Buffer for BTC volatility | 1.005 (0.5%) |
|
||||
| **Upstream Fee** | Your profit markup | 1.10 (10%) |
|
||||
|
||||
See [Pricing](pricing.md) for detailed strategies.
|
||||
|
||||
### Cashu Mints
|
||||
|
||||
Which mints to accept payments from:
|
||||
|
||||
| Setting | Description |
|
||||
| --------- | ------------------------------- |
|
||||
| **Mints** | List of trusted Cashu mint URLs |
|
||||
|
||||
### Lightning Withdrawals
|
||||
|
||||
Automatic profit withdrawal:
|
||||
|
||||
| Setting | Description |
|
||||
| --------------------- | ------------------------------- |
|
||||
| **Lightning Address** | Your LN address for withdrawals |
|
||||
|
||||
### Security
|
||||
|
||||
| Setting | Description |
|
||||
| ------------------ | ----------------------------- |
|
||||
| **Admin Password** | Password for dashboard access |
|
||||
|
||||
### Nostr Discovery
|
||||
|
||||
Announce your node on the network:
|
||||
|
||||
| Setting | Description |
|
||||
| ---------- | ------------------------------------ |
|
||||
| **Npub** | Your Nostr public key |
|
||||
| **Nsec** | Your Nostr private key (for signing) |
|
||||
| **Relays** | Relays to publish announcements |
|
||||
|
||||
See [Discovery](discovery.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables (Optional)
|
||||
|
||||
Use environment variables for:
|
||||
|
||||
- **Automated deployments** (CI/CD, infrastructure-as-code)
|
||||
- **Secrets management** (external secret stores)
|
||||
- **Initial bootstrap** (set once, manage via dashboard later)
|
||||
|
||||
### All Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------------------- | --------------------------------- | ------------------------------------ |
|
||||
| `UPSTREAM_BASE_URL` | Upstream API endpoint | — |
|
||||
| `UPSTREAM_API_KEY` | Upstream API key | — |
|
||||
| `ADMIN_PASSWORD` | Dashboard password | (none) |
|
||||
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///keys.db` |
|
||||
| `NAME` | Node display name | `ARoutstrNode` |
|
||||
| `DESCRIPTION` | Node description | `A Routstr Node` |
|
||||
| `NPUB` | Nostr public key (bech32) | — |
|
||||
| `NSEC` | Nostr private key | — |
|
||||
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
|
||||
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
|
||||
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
|
||||
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
|
||||
| `RELAYS` | Nostr relays (comma-separated) | (default set) |
|
||||
|
||||
### Priority
|
||||
|
||||
Environment variables are read on startup. Dashboard settings override them and persist in the database. Once you change a setting in the dashboard, the env var is ignored for that setting.
|
||||
|
||||
---
|
||||
|
||||
## Models
|
||||
|
||||
Manage which AI models you offer:
|
||||
|
||||
1. Go to **Models** in the dashboard
|
||||
2. Models are auto-discovered from your upstream
|
||||
3. For each model, you can:
|
||||
- **Enable/Disable** — hide expensive models you don't want to serve
|
||||
- **Override pricing** — set custom per-token rates
|
||||
- **Create aliases** — friendly names for models
|
||||
|
||||
See [Pricing](pricing.md) for per-model pricing strategies.
|
||||
@@ -0,0 +1,208 @@
|
||||
# Admin Dashboard
|
||||
|
||||
The Admin Dashboard is your command center for managing your Routstr provider node. Configure providers, monitor earnings, manage models, and withdraw profits—all from a web interface.
|
||||
|
||||
**URL**: `http://your-node:8000/admin/`
|
||||
|
||||
---
|
||||
|
||||
## Overview Tab
|
||||
|
||||
The main dashboard view shows your node's financial status at a glance.
|
||||
|
||||
### Wallet Summary
|
||||
|
||||
| Metric | Description |
|
||||
|--------|-------------|
|
||||
| **Total Wallet** | All Bitcoin currently held by your node |
|
||||
| **User Balances** | Funds belonging to active client sessions |
|
||||
| **Your Balance** | Your profit: `Total - User Balances` |
|
||||
|
||||
### Mint Status
|
||||
|
||||
Shows connected Cashu mints and their balances. Each mint displays:
|
||||
|
||||
- Connection status
|
||||
- Balance in sats/msats
|
||||
- Unit type
|
||||
|
||||
<!-- TODO: Screenshot of Overview tab -->
|
||||
|
||||
---
|
||||
|
||||
## Sessions Tab
|
||||
|
||||
View and manage active client sessions (API keys).
|
||||
|
||||
### Session List
|
||||
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| **Hashed Key** | Privacy-preserving identifier (not the actual key) |
|
||||
| **Balance** | Remaining funds in the session |
|
||||
| **Spent** | Total amount spent by this session |
|
||||
| **Requests** | Number of API calls made |
|
||||
| **Created** | When the session was created |
|
||||
| **Expires** | Auto-expiry time (if set) |
|
||||
|
||||
### Actions
|
||||
|
||||
- **View Details** — See full session history
|
||||
- **Revoke** — Terminate a session (remaining balance returns to your wallet)
|
||||
|
||||
<!-- TODO: Screenshot of Sessions tab -->
|
||||
|
||||
---
|
||||
|
||||
## Models Tab
|
||||
|
||||
Manage which AI models you offer to clients.
|
||||
|
||||
### Model List
|
||||
|
||||
Shows all models available from your upstream provider(s):
|
||||
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| **Model ID** | The model identifier (e.g., `gpt-4o`) |
|
||||
| **Enabled** | Whether clients can use this model |
|
||||
| **Input Price** | Cost per 1M input tokens (USD) |
|
||||
| **Output Price** | Cost per 1M output tokens (USD) |
|
||||
| **Custom** | Whether pricing is overridden |
|
||||
|
||||
### Actions
|
||||
|
||||
- **Import Models** — Fetch latest model list from upstream
|
||||
- **Enable/Disable** — Toggle model availability
|
||||
- **Edit Pricing** — Override default pricing for a model
|
||||
- **Create Alias** — Map a friendly name to a model
|
||||
|
||||
### Editing a Model
|
||||
|
||||
Click on any model to configure:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Enabled** | Show this model to clients |
|
||||
| **Prompt Price** | Custom price per 1M input tokens (USD) |
|
||||
| **Completion Price** | Custom price per 1M output tokens (USD) |
|
||||
| **Alias** | Alternative name for this model |
|
||||
|
||||
<!-- TODO: Screenshot of Models tab -->
|
||||
<!-- TODO: Screenshot of Model edit modal -->
|
||||
|
||||
---
|
||||
|
||||
## Settings Tab
|
||||
|
||||
Configure all node settings. Changes take effect immediately.
|
||||
|
||||
### Upstream
|
||||
|
||||
Connect to your AI provider:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Base URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
|
||||
| **API Key** | Your provider's secret key |
|
||||
|
||||
<!-- TODO: Screenshot of Upstream settings -->
|
||||
|
||||
### Node Identity
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Name** | Public display name |
|
||||
| **Description** | Brief description of your service |
|
||||
| **Npub** | Nostr public key for discovery |
|
||||
|
||||
### Pricing
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Fixed Pricing** | Toggle flat-rate vs. per-token pricing |
|
||||
| **Fixed Cost** | Sats per request (when fixed pricing enabled) |
|
||||
| **Exchange Fee** | Multiplier for BTC volatility buffer |
|
||||
| **Upstream Fee** | Your profit margin multiplier |
|
||||
|
||||
**Example**: With Exchange Fee `1.005` and Upstream Fee `1.10`:
|
||||
|
||||
- Upstream cost: $30/1M tokens
|
||||
- Your price: $30 × 1.005 × 1.10 = $33.17/1M tokens
|
||||
|
||||
### Cashu Mints
|
||||
|
||||
Manage which mints you accept payments from:
|
||||
|
||||
- **Add Mint** — Enter a mint URL
|
||||
- **Remove Mint** — Stop accepting from a mint
|
||||
- **Test Connection** — Verify mint is reachable
|
||||
|
||||
<!-- TODO: Screenshot of Mints settings -->
|
||||
|
||||
### Lightning
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Lightning Address** | Your LN address for automatic withdrawals |
|
||||
|
||||
### Nostr Discovery
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Nsec** | Private key for signing announcements |
|
||||
| **Relays** | Where to publish your node advertisement |
|
||||
|
||||
### Security
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Admin Password** | Password for dashboard access |
|
||||
|
||||
!!! warning "Set a Password"
|
||||
The dashboard has no password by default. Always set one for production nodes.
|
||||
|
||||
<!-- TODO: Screenshot of Security settings -->
|
||||
|
||||
---
|
||||
|
||||
## Withdraw Tab
|
||||
|
||||
Withdraw your profits to a Lightning wallet.
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Select Mint** — Choose which mint to withdraw from
|
||||
2. **Enter Amount** — How many sats to withdraw
|
||||
3. **Generate Token** — Creates a Cashu token
|
||||
4. **Redeem** — Paste the token into your Cashu wallet and melt to Lightning
|
||||
|
||||
<!-- TODO: Screenshot of Withdraw tab -->
|
||||
|
||||
### Alternative: Lightning Address
|
||||
|
||||
If you've configured a Lightning Address in Settings, profits can be automatically swept to your wallet (coming soon).
|
||||
|
||||
---
|
||||
|
||||
## Logs Tab
|
||||
|
||||
View node logs for debugging without SSH access.
|
||||
|
||||
### Features
|
||||
|
||||
- **Filter by Level** — Error, Warning, Info, Debug
|
||||
- **Search** — Find specific entries
|
||||
- **Time Range** — View logs from specific periods
|
||||
- **Auto-refresh** — Watch logs in real-time
|
||||
|
||||
### Common Log Entries
|
||||
|
||||
| Entry | Meaning |
|
||||
|-------|---------|
|
||||
| `Upstream request failed` | Problem connecting to your AI provider |
|
||||
| `Invalid token` | Client sent an invalid Cashu token |
|
||||
| `Session expired` | API key reached its time limit |
|
||||
| `Insufficient balance` | Client ran out of funds mid-request |
|
||||
|
||||
<!-- TODO: Screenshot of Logs tab -->
|
||||
@@ -0,0 +1,203 @@
|
||||
# Deployment
|
||||
|
||||
Production deployment guide for Routstr Provider nodes.
|
||||
|
||||
## Docker Compose (Recommended)
|
||||
|
||||
For production, use Docker Compose with persistent storage and optional Tor support.
|
||||
|
||||
### Unified Setup (All-in-one)
|
||||
To build and run the node with the UI integrated in a single container using the multi-stage build:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.full -t routstr-full .
|
||||
docker run -d -p 8000:8000 --env-file .env routstr-full
|
||||
```
|
||||
|
||||
### Advanced Setup (Separated UI & Node)
|
||||
Use the included `compose.yml` for a more flexible setup that separates the UI build process from the node execution. This is useful for development or when you want to manage Tor as a separate service.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This will:
|
||||
1. **Build the UI**: Compiles the frontend and copies it to a shared volume.
|
||||
2. **Start Routstr**: Runs the Python node, mounting the built UI.
|
||||
3. **Start Tor**: Provides anonymous access via a `.onion` address.
|
||||
|
||||
---
|
||||
|
||||
## With Tor (Anonymous Access)
|
||||
|
||||
Add Tor to serve your node as a hidden service—no port forwarding needed.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
routstr:
|
||||
image: ghcr.io/routstr/proxy:latest
|
||||
container_name: routstr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- TOR_PROXY_URL=socks5://tor:9050
|
||||
depends_on:
|
||||
- tor
|
||||
|
||||
tor:
|
||||
image: ghcr.io/hundehausen/tor-hidden-service:latest
|
||||
container_name: tor
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./tor-data:/var/lib/tor
|
||||
environment:
|
||||
- HS_ROUTER=routstr:8000:80
|
||||
```
|
||||
|
||||
After starting, find your `.onion` address:
|
||||
|
||||
```bash
|
||||
docker exec tor cat /var/lib/tor/hidden_service/hostname
|
||||
```
|
||||
|
||||
See [Tor Support](tor.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Configuration (Optional)
|
||||
|
||||
While everything can be configured via the dashboard, you can pre-configure settings with environment variables for automated deployments.
|
||||
|
||||
### Using Environment Variables
|
||||
|
||||
```yaml
|
||||
services:
|
||||
routstr:
|
||||
image: ghcr.io/routstr/proxy:latest
|
||||
environment:
|
||||
# Pre-configure upstream (optional)
|
||||
- UPSTREAM_BASE_URL=https://api.openai.com/v1
|
||||
- UPSTREAM_API_KEY=sk-proj-...
|
||||
|
||||
# Secure the dashboard (recommended)
|
||||
- ADMIN_PASSWORD=your-secure-password
|
||||
|
||||
# Node identity
|
||||
- NAME=My Provider Node
|
||||
- DESCRIPTION=Fast GPT-4 access via Lightning
|
||||
|
||||
# Lightning withdrawals
|
||||
- RECEIVE_LN_ADDRESS=me@walletofsatoshi.com
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
```
|
||||
|
||||
### Using an .env File
|
||||
|
||||
```yaml
|
||||
services:
|
||||
routstr:
|
||||
image: ghcr.io/routstr/proxy:latest
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
```
|
||||
|
||||
Example `.env`:
|
||||
|
||||
```bash
|
||||
UPSTREAM_BASE_URL=https://api.openai.com/v1
|
||||
UPSTREAM_API_KEY=sk-proj-...
|
||||
ADMIN_PASSWORD=change-me
|
||||
NAME=My Provider Node
|
||||
RECEIVE_LN_ADDRESS=me@walletofsatoshi.com
|
||||
```
|
||||
|
||||
See [Configuration](configuration.md) for all available options.
|
||||
|
||||
---
|
||||
|
||||
## Persistence
|
||||
|
||||
Routstr stores all data in `/app/data`:
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `keys.db` | SQLite database (settings, API keys, sessions) |
|
||||
| `.wallet/` | Cashu wallet data (your Bitcoin!) |
|
||||
|
||||
!!! warning "Back Up Your Data"
|
||||
The `./data` volume contains your wallet. Losing it means losing funds. Back up regularly.
|
||||
|
||||
---
|
||||
|
||||
## Reverse Proxy (Optional)
|
||||
|
||||
For custom domains and SSL, use a reverse proxy like Caddy or nginx.
|
||||
|
||||
### Caddy Example
|
||||
|
||||
```
|
||||
api.yournode.com {
|
||||
reverse_proxy localhost:8000
|
||||
}
|
||||
```
|
||||
|
||||
### nginx Example
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name api.yournode.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Updates
|
||||
|
||||
Pull the latest image and restart:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Building from Source
|
||||
|
||||
### Unified Image (UI + Node)
|
||||
The easiest way to build everything from source into a single production-ready image:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.full -t routstr-full .
|
||||
```
|
||||
|
||||
### Individual Components
|
||||
If you prefer building them separately or using Docker Compose:
|
||||
|
||||
```bash
|
||||
# Build using compose
|
||||
docker compose build
|
||||
|
||||
# Or build the node only (requires manual UI build first)
|
||||
docker build -t routstr-node .
|
||||
```
|
||||
@@ -0,0 +1,95 @@
|
||||
# Discovery
|
||||
|
||||
Routstr uses **Nostr** as a decentralized directory for service discovery. Your node announces its presence, models, and pricing on Nostr relays, allowing clients to find you without a central server.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Provider Advertisement (Kind 38421)**: Your node periodically publishes an event with its URL, models, and pricing
|
||||
2. **Client Discovery**: Clients query relays for these events to find suitable providers
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure discovery in **Dashboard** → **Settings** → **Nostr**.
|
||||
|
||||
### Required Settings
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Npub** | Your node's public identity (clients use this to verify your node) |
|
||||
| **Nsec** | Your node's private key (used to sign advertisements) |
|
||||
| **Relays** | Where to publish your announcements |
|
||||
|
||||
### Default Relays
|
||||
|
||||
If not configured, Routstr publishes to:
|
||||
|
||||
- `wss://relay.damus.io`
|
||||
- `wss://relay.nostr.band`
|
||||
- `wss://nos.lol`
|
||||
|
||||
---
|
||||
|
||||
## Advertisement Format
|
||||
|
||||
Your node publishes events like:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 38421,
|
||||
"content": {
|
||||
"name": "My Routstr Node",
|
||||
"description": "Fast GPT-4 access via Lightning",
|
||||
"endpoints": {
|
||||
"http": "https://api.mynode.com",
|
||||
"onion": "http://xyz...onion"
|
||||
},
|
||||
"models": ["gpt-4", "claude-3-opus"],
|
||||
"pricing": { ... }
|
||||
},
|
||||
"tags": [
|
||||
["d", "routstr-provider"],
|
||||
["g", "US"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tor Integration
|
||||
|
||||
If you're running with Tor (see [Tor Support](tor.md)), your `.onion` address is automatically included in announcements. This allows clients to connect anonymously.
|
||||
|
||||
---
|
||||
|
||||
## Verify Your Announcements
|
||||
|
||||
Check if your node is broadcasting:
|
||||
|
||||
1. Copy your `Npub`
|
||||
2. Search on [Nostr.band](https://nostr.band) or [Primal](https://primal.net)
|
||||
3. Look for Kind 38421 events
|
||||
|
||||
---
|
||||
|
||||
## Generating Keys
|
||||
|
||||
If you don't have a Nostr identity:
|
||||
|
||||
1. Use any Nostr client (e.g., [Primal](https://primal.net), [Damus](https://damus.io))
|
||||
2. Create an account
|
||||
3. Export your keys (npub and nsec)
|
||||
4. Enter them in the dashboard
|
||||
|
||||
Or generate keys programmatically:
|
||||
|
||||
```python
|
||||
from nostr_sdk import Keys
|
||||
|
||||
keys = Keys.generate()
|
||||
print(f"npub: {keys.public_key().to_bech32()}")
|
||||
print(f"nsec: {keys.secret_key().to_bech32()}")
|
||||
```
|
||||
@@ -0,0 +1,91 @@
|
||||
# Pricing
|
||||
|
||||
Routstr's pricing engine lets you act as a retailer of AI compute. You pay upstream providers (OpenAI, Anthropic, etc.) at their rates and sell to clients with your markup.
|
||||
|
||||
---
|
||||
|
||||
## Pricing Strategies
|
||||
|
||||
Configure these in **Dashboard** → **Settings** → **Pricing**.
|
||||
|
||||
### Dynamic Pricing (Default)
|
||||
|
||||
Passes through upstream costs plus your percentage markup.
|
||||
|
||||
**Formula**: `Client Price = Upstream Cost × Exchange Fee × Upstream Fee`
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| **Exchange Fee** | Buffer for BTC price volatility | 1.005 (0.5%) |
|
||||
| **Upstream Fee** | Your profit margin | 1.10 (10%) |
|
||||
|
||||
**Example**: GPT-4 costs $30/1M tokens from OpenAI. With default settings:
|
||||
|
||||
- Price: $30 × 1.005 × 1.10 = $33.17/1M tokens
|
||||
- At $60k BTC: ~55,000 sats/1M tokens
|
||||
|
||||
### Fixed Pricing
|
||||
|
||||
Charge a flat rate per request, regardless of model or token count.
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| **Fixed Pricing** | Enable flat-rate mode |
|
||||
| **Fixed Cost** | Sats per request |
|
||||
|
||||
**Best for**: Simple proxies, internal tools, or subscription-like access.
|
||||
|
||||
---
|
||||
|
||||
## Per-Model Pricing
|
||||
|
||||
Override pricing for specific models in **Dashboard** → **Models**.
|
||||
|
||||
1. Click on a model
|
||||
2. Enter custom **Prompt Price** and **Completion Price** (USD per 1M tokens)
|
||||
3. Save
|
||||
|
||||
This overrides both the upstream cost and your global markup for that model.
|
||||
|
||||
**Example**: Lock GPT-4 at $35/1M tokens regardless of OpenAI's actual rate or your fee settings.
|
||||
|
||||
---
|
||||
|
||||
## Token-Based Overrides
|
||||
|
||||
Set global fixed rates per token (overrides dynamic pricing for all models):
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| **Fixed Per 1K Input** | Sats per 1,000 prompt tokens |
|
||||
| **Fixed Per 1K Output** | Sats per 1,000 completion tokens |
|
||||
|
||||
---
|
||||
|
||||
## Minimum Charge
|
||||
|
||||
Prevent spam with a minimum cost per request:
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| **Min Request Cost** | Minimum charge in msats | 1000 (1 sat) |
|
||||
|
||||
If a request's calculated cost is lower than this, the client pays the minimum instead.
|
||||
|
||||
---
|
||||
|
||||
## Cost Tracking
|
||||
|
||||
Routstr tracks balances in **millisats (msats)** for precision with cheap models.
|
||||
|
||||
- 1 sat = 1,000 msats
|
||||
- API responses include cost in msats
|
||||
- Lightning withdrawals round down to whole sats
|
||||
|
||||
### Client Verification (RIP-05)
|
||||
|
||||
Clients can verify charges:
|
||||
|
||||
1. Fetch `/v1/models` for your advertised rates
|
||||
2. Calculate expected cost from token counts
|
||||
3. Compare to `x-routstr-cost` response header
|
||||
@@ -0,0 +1,136 @@
|
||||
# Quick Start
|
||||
|
||||
Start earning Bitcoin by selling AI access in under 5 minutes.
|
||||
|
||||
## What You'll Build
|
||||
|
||||
A **Routstr Provider Node** acts as a gateway that:
|
||||
|
||||
1. **Connects** to upstream AI providers (OpenAI, Anthropic, OpenRouter, etc.)
|
||||
2. **Accepts** Bitcoin payments via Cashu eCash
|
||||
3. **Serves** AI requests to clients on the network
|
||||
|
||||
You bring the API keys, Routstr handles the billing, payments, and client management.
|
||||
|
||||
!!! tip "Future: Node-to-Node Routing"
|
||||
In future versions, you'll be able to run a node that connects to other Routstr nodes—eliminating the need to configure upstream providers yourself. For now, you'll need your own API credentials.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/) installed
|
||||
- API credentials from at least one AI provider (OpenAI, Anthropic, OpenRouter, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 1. Prepare Configuration
|
||||
|
||||
Create a `.env` file in the root of the project to store your secrets:
|
||||
|
||||
```bash
|
||||
# Initial Admin Password
|
||||
ADMIN_PASSWORD=mysecretpassword
|
||||
|
||||
# Node Identity
|
||||
NAME="My AI Node"
|
||||
DESCRIPTION="Fast access to models"
|
||||
|
||||
# Lightning Payouts
|
||||
RECEIVE_LN_ADDRESS=yourname@wallet.com
|
||||
|
||||
```
|
||||
|
||||
## 2. Start the Node
|
||||
|
||||
You can run the pre-built image directly:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name routstr \
|
||||
-p 8000:8000 \
|
||||
--env-file .env \
|
||||
-v routstr-data:/app/data \
|
||||
ghcr.io/routstr/proxy:latest
|
||||
```
|
||||
|
||||
*Note: The pre-built image does not contain the UI. For the all-in-one experience with the Admin Dashboard, use the Build from Source instructions below.*
|
||||
|
||||
### Build from Source (Recommended)
|
||||
|
||||
If you want to build the node and UI yourself from source, use the unified Dockerfile:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/routstr/routstr-core.git
|
||||
cd routstr-core
|
||||
# Edit your .env with ADMIN_PASSWORD and API keys
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
|
||||
docker build -f Dockerfile.full -t routstr-local .
|
||||
docker run -d -p 8000:8000 --env-file .env --name routstr routstr-local
|
||||
```
|
||||
|
||||
Verify it's running:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/info
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configure via Dashboard
|
||||
|
||||
Open the **Admin Dashboard** at [http://localhost:8000/admin/](http://localhost:8000/admin/).
|
||||
|
||||
!!! note "Login"
|
||||
Use the `ADMIN_PASSWORD` you defined in your `.env` file to log in. If you didn't set one, the dashboard will prompt you to set one on first visit.
|
||||
|
||||
### Connect Your AI Providers
|
||||
|
||||
1. Navigate to **Settings** → **Upstream**
|
||||
2. Enter your upstream URL (e.g., `https://api.openai.com/v1`)
|
||||
3. Enter your API key
|
||||
4. Save
|
||||
|
||||
### Set Your Profit Margin
|
||||
|
||||
1. Go to **Settings** → **Pricing**
|
||||
2. Configure your markup (default is 10%)
|
||||
3. Optionally set a fixed price per request instead
|
||||
|
||||
### Secure the Dashboard
|
||||
|
||||
1. Go to **Settings** → **Admin**
|
||||
2. Set a strong password
|
||||
3. Save and re-login
|
||||
|
||||
---
|
||||
|
||||
## 3. Start Earning
|
||||
|
||||
Once configured, your node is live. Clients pay you in Bitcoin (via Cashu tokens) for every AI request.
|
||||
|
||||
### Monitor Your Earnings
|
||||
|
||||
The dashboard shows:
|
||||
|
||||
- **Total Wallet**: All Bitcoin held by your node
|
||||
- **User Balances**: Funds belonging to active client sessions
|
||||
- **Your Balance**: Your profit (`Total - User Balances`)
|
||||
|
||||
### Withdraw Profits
|
||||
|
||||
1. Go to **Withdraw** in the dashboard
|
||||
2. Select amount and mint
|
||||
3. Generate a Cashu token
|
||||
4. Redeem to your Lightning wallet
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Deployment](deployment.md)**: Production setup with Docker Compose and Tor
|
||||
- **[Dashboard Guide](dashboard.md)**: Full reference for all dashboard features
|
||||
- **[Pricing](pricing.md)**: Configure pricing strategies and per-model overrides
|
||||
- **[Discovery](discovery.md)**: Announce your node on Nostr for clients to find you
|
||||
@@ -0,0 +1,50 @@
|
||||
# Tor Support
|
||||
|
||||
Running Routstr as a **Tor Hidden Service** allows you to offer API access anonymously and bypass NAT/firewalls without port forwarding.
|
||||
|
||||
## Automatic Setup (Docker)
|
||||
|
||||
The standard `compose.yml` includes a Tor container pre-configured to serve your node.
|
||||
|
||||
1. **Start the stack**: `docker compose up -d`
|
||||
2. **Wait**: Tor takes about 30 seconds to generate keys and bootstrap.
|
||||
3. **Find your address**:
|
||||
```bash
|
||||
docker exec tor cat /var/lib/tor/hidden_service/hostname
|
||||
```
|
||||
Output: `v2xyz...longaddress.onion`
|
||||
|
||||
Routstr will automatically detect this address (via the `discover_onion_url_from_tor` logic) and include it in:
|
||||
- The `/v1/info` endpoint.
|
||||
- Nostr announcements (RIP-02).
|
||||
|
||||
## Manual Setup
|
||||
|
||||
If you are running outside Docker or managing Tor yourself:
|
||||
|
||||
1. **Install Tor**: `sudo apt install tor`
|
||||
2. **Edit `torrc`**:
|
||||
```
|
||||
HiddenServiceDir /var/lib/tor/routstr/
|
||||
HiddenServicePort 80 127.0.0.1:8000
|
||||
```
|
||||
3. **Restart Tor**: `sudo systemctl restart tor`
|
||||
4. **Get Address**: `sudo cat /var/lib/tor/routstr/hostname`
|
||||
5. **Configure Routstr**:
|
||||
Set `ONION_URL=http://youraddress.onion` in your `.env` file so the node knows its own address.
|
||||
|
||||
## Client Usage
|
||||
|
||||
Clients connecting to your `.onion` address must route traffic through SOCKS5.
|
||||
|
||||
**Python Example:**
|
||||
```python
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="http://youraddress.onion/v1",
|
||||
api_key="sk-...",
|
||||
http_client=httpx.Client(proxy="socks5://127.0.0.1:9050")
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
mkdocs>=1.5.3
|
||||
mkdocs-material>=9.5.0
|
||||
mkdocs-mermaid2-plugin>=1.1.1
|
||||
pymdown-extensions>=10.5
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
import os
|
||||
|
||||
import openai
|
||||
|
||||
client = openai.OpenAI(
|
||||
api_key=os.environ["CASHU_TOKEN"],
|
||||
base_url=os.environ.get("ROUTSTR_API_URL", "https://api.routstr.com/v1"),
|
||||
# base_url="http://roustrjfsdgfiueghsklchg.onion/v1",
|
||||
# client=httpx.AsyncClient(
|
||||
# proxies={"http": "socks5://localhost:9050"},
|
||||
# ), # to use onion proxy (tor)
|
||||
)
|
||||
history: list = []
|
||||
|
||||
|
||||
def chat() -> None:
|
||||
while True:
|
||||
user_msg = {"role": "user", "content": input("\nYou: ")}
|
||||
history.append(user_msg)
|
||||
ai_msg = {"role": "assistant", "content": ""}
|
||||
|
||||
for chunk in client.chat.completions.create(
|
||||
model=os.environ.get("MODEL", "openai/gpt-4o-mini"),
|
||||
messages=history,
|
||||
stream=True,
|
||||
):
|
||||
if len(chunk.choices) > 0:
|
||||
content = chunk.choices[0].delta.content
|
||||
if content is not None:
|
||||
ai_msg["content"] += content
|
||||
print(content, end="", flush=True)
|
||||
print()
|
||||
history.append(ai_msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
chat()
|
||||
@@ -0,0 +1,11 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Use your Cashu token or API key as the Bearer token,
|
||||
# cashu token is hashed on the server and acts as an Temporary API key
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
resp = httpx.get(f"{base_url}/balance/info", headers=headers)
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Send a Cashu token to the /create endpoint to get a persistent API key
|
||||
token = os.environ.get("TOKEN")
|
||||
if not token:
|
||||
print("Please set TOKEN environment variable with a Cashu token")
|
||||
exit(1)
|
||||
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
resp = httpx.get(f"{base_url}/balance/create", params={"initial_balance_token": token})
|
||||
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Use your Cashu token or API key as the Bearer token
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
resp = httpx.post(f"{base_url}/balance/refund", headers=headers)
|
||||
|
||||
print("Refund successful!")
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Use your Cashu token or API key as the Bearer token
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
# The Cashu token to top up with
|
||||
cashu_token = input("Enter Cashu token to top up: ")
|
||||
|
||||
resp = httpx.post(
|
||||
f"{base_url}/balance/topup", headers=headers, json={"cashu_token": cashu_token}
|
||||
)
|
||||
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=os.environ.get("MODEL", "gpt-5-nano"),
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
@@ -0,0 +1,45 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def create_child_keys(base_url: str, api_key: str, count: int = 3) -> list[str]:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
print(f"Requesting {count} child keys from {base_url}...")
|
||||
|
||||
child_keys = []
|
||||
|
||||
for i in range(count):
|
||||
try:
|
||||
response = httpx.post(f"{base_url}/v1/balance/child-key", headers=headers)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
child_keys.append(data["api_key"])
|
||||
print(
|
||||
f" [{i + 1}] Created: {data['api_key']} (Cost: {data['cost_msats']} msats)"
|
||||
)
|
||||
else:
|
||||
print(f" [{i + 1}] Failed: {response.status_code} - {response.text}")
|
||||
except Exception as e:
|
||||
print(f" [{i + 1}] Error: {str(e)}")
|
||||
|
||||
return child_keys
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python create_child_keys.py <api_key_or_cashu_token> [base_url]")
|
||||
sys.exit(1)
|
||||
|
||||
auth_key = sys.argv[1]
|
||||
base_url = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:8000"
|
||||
|
||||
keys = create_child_keys(base_url, auth_key)
|
||||
|
||||
if keys:
|
||||
print("\nSuccessfully created child keys:")
|
||||
print(json.dumps(keys, indent=2))
|
||||
else:
|
||||
print("\nNo child keys were created.")
|
||||
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN", ""),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
for model in client.models.list():
|
||||
print(model.id)
|
||||
|
||||
# OR
|
||||
|
||||
models = httpx.get(
|
||||
f"{client.base_url}/v1/models",
|
||||
headers={"Authorization": f"Bearer {client.api_key}"},
|
||||
).json()
|
||||
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
conversation = [] # type: ignore
|
||||
|
||||
# First turn
|
||||
response1 = client.responses.create( # type: ignore
|
||||
model="o4-mini",
|
||||
input="Hi, my name is Alice.",
|
||||
conversation=conversation,
|
||||
)
|
||||
print("Response 1:", response1.output)
|
||||
|
||||
# Note: The 'conversation' parameter might need to be constructed differently
|
||||
# depending on exact SDK/API spec. Typically, you pass back the previous turn's data.
|
||||
# Assuming the SDK manages or returns a conversation object/ID:
|
||||
# conversation.append(response1)
|
||||
|
||||
# Second turn - demonstrating intent, actual implementation depends on strict API spec
|
||||
# response2 = client.responses.create(
|
||||
# model="openai/gpt-4o-mini",
|
||||
# input="What is my name?",
|
||||
# conversation=conversation,
|
||||
# )
|
||||
# print("Response 2:", response2.output)
|
||||
@@ -0,0 +1,17 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
# The OpenAI SDK handles the 'responses' endpoint if it's updated to the latest version
|
||||
# and the base_url points to a compatible proxy like Routstr.
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
response = client.responses.create(
|
||||
model="gpt-5-mini",
|
||||
input="Tell me a three sentence bedtime story about a unicorn.",
|
||||
)
|
||||
|
||||
print(response.output)
|
||||
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
stream = client.responses.create(
|
||||
model="claude-4.5-sonnet",
|
||||
input="Write a short poem about rust.",
|
||||
stream=True,
|
||||
)
|
||||
|
||||
for event in stream:
|
||||
# Note: Depending on the SDK version and response structure,
|
||||
# you might access event.output_delta or similar fields
|
||||
print(event, end="", flush=True)
|
||||
print()
|
||||
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
response = client.responses.create(
|
||||
model="gpt-5-mini",
|
||||
input="What is the latest news about AI?",
|
||||
tools=[{"type": "web_search"}], # type: ignore
|
||||
)
|
||||
|
||||
print(response.output)
|
||||
@@ -0,0 +1,28 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
messages = []
|
||||
while True:
|
||||
messages.append({"role": "user", "content": input("\nYou: ")})
|
||||
|
||||
stream = client.chat.completions.create(
|
||||
model=os.environ.get("MODEL", "gpt-5.1-mini"),
|
||||
messages=messages, # type: ignore
|
||||
stream=True,
|
||||
)
|
||||
|
||||
print("AI: ", end="")
|
||||
response_content = ""
|
||||
for chunk in stream:
|
||||
if content := chunk.choices[0].delta.content: # type: ignore
|
||||
print(content, end="", flush=True)
|
||||
response_content += content
|
||||
print()
|
||||
|
||||
messages.append({"role": "assistant", "content": response_content})
|
||||
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
# Requires `pip install "httpx[socks]"` and a running Tor proxy on port 9050
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("ONION_URL", "http://roustrjfsdgfiueghsklchg.onion/v1"),
|
||||
http_client=httpx.Client(proxies="socks5://localhost:9050"),
|
||||
)
|
||||
|
||||
print(
|
||||
client.chat.completions.create(
|
||||
model="openai/gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "Hello from Tor!"}],
|
||||
)
|
||||
.choices[0]
|
||||
.message.content
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add key management and reset fields to api_keys
|
||||
|
||||
Revision ID: 06f81c0fc88d
|
||||
Revises: c2d3e4f5a6b7
|
||||
Create Date: 2026-02-04 22:44:03.311983
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "06f81c0fc88d"
|
||||
down_revision = "c2d3e4f5a6b7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("api_keys", sa.Column("balance_limit", sa.Integer(), nullable=True))
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column(
|
||||
"balance_limit_reset", sqlmodel.sql.sqltypes.AutoString(), nullable=True
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"api_keys", sa.Column("balance_limit_reset_date", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.add_column("api_keys", sa.Column("validity_date", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("api_keys", "validity_date")
|
||||
op.drop_column("api_keys", "balance_limit_reset_date")
|
||||
op.drop_column("api_keys", "balance_limit_reset")
|
||||
op.drop_column("api_keys", "balance_limit")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""change models to composite primary key (id, upstream_provider_id)
|
||||
|
||||
Revision ID: a1a1a1a1a1a1
|
||||
Revises: f7a8b9c0d1e2
|
||||
Create Date: 2025-10-20 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "a1a1a1a1a1a1"
|
||||
down_revision = "f7a8b9c0d1e2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
if "models" in inspector.get_table_names():
|
||||
op.drop_table("models")
|
||||
|
||||
op.create_table(
|
||||
"models",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("upstream_provider_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("created", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("context_length", sa.Integer(), nullable=False),
|
||||
sa.Column("architecture", sa.Text(), nullable=False),
|
||||
sa.Column("pricing", sa.Text(), nullable=False),
|
||||
sa.Column("sats_pricing", sa.Text(), nullable=True),
|
||||
sa.Column("per_request_limits", sa.Text(), nullable=True),
|
||||
sa.Column("top_provider", sa.Text(), nullable=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.PrimaryKeyConstraint("id", "upstream_provider_id"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["upstream_provider_id"], ["upstream_providers.id"], ondelete="CASCADE"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("models")
|
||||
op.create_table(
|
||||
"models",
|
||||
sa.Column("id", sa.String(), primary_key=True, nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("created", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("context_length", sa.Integer(), nullable=False),
|
||||
sa.Column("architecture", sa.Text(), nullable=False),
|
||||
sa.Column("pricing", sa.Text(), nullable=False),
|
||||
sa.Column("sats_pricing", sa.Text(), nullable=True),
|
||||
sa.Column("per_request_limits", sa.Text(), nullable=True),
|
||||
sa.Column("top_provider", sa.Text(), nullable=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("upstream_provider_id", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["upstream_provider_id"], ["upstream_providers.id"]),
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""add settings table
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 042f6b77d69d
|
||||
Create Date: 2025-09-06 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a1b2c3d4e5f6"
|
||||
down_revision = "042f6b77d69d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"settings",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
|
||||
sa.Column("data", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=True,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("settings")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
|
||||
Revision ID: a86e5348850b
|
||||
Revises: b9667ffc5701
|
||||
Create Date: 2026-01-10 18:57:48.475781
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a86e5348850b"
|
||||
down_revision = "b9667ffc5701"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Use batch_alter_table for SQLite compatibility
|
||||
with op.batch_alter_table("api_keys", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"parent_key_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True
|
||||
)
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_api_keys_parent_key_hash"), ["parent_key_hash"], unique=False
|
||||
)
|
||||
batch_op.create_foreign_key(
|
||||
"fk_api_keys_parent_key_hash",
|
||||
"api_keys",
|
||||
["parent_key_hash"],
|
||||
["hashed_key"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("api_keys", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("fk_api_keys_parent_key_hash", type_="foreignkey")
|
||||
batch_op.drop_index(batch_op.f("ix_api_keys_parent_key_hash"))
|
||||
batch_op.drop_column("parent_key_hash")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""alias-ids
|
||||
|
||||
Revision ID: b9667ffc5701
|
||||
Revises: lightning_invoices
|
||||
Create Date: 2025-12-25 19:30:44.673350
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b9667ffc5701"
|
||||
down_revision = "lightning_invoices"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic ###
|
||||
op.add_column(
|
||||
"models",
|
||||
sa.Column("canonical_slug", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"models",
|
||||
sa.Column("alias_ids", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("models", "alias_ids")
|
||||
op.drop_column("models", "canonical_slug")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,37 @@
|
||||
"""create models table
|
||||
|
||||
Revision ID: c0ffee123456
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2025-09-10 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c0ffee123456"
|
||||
down_revision = "a1b2c3d4e5f6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"models",
|
||||
sa.Column("id", sa.String(), primary_key=True, nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("created", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("context_length", sa.Integer(), nullable=False),
|
||||
sa.Column("architecture", sa.Text(), nullable=False),
|
||||
sa.Column("pricing", sa.Text(), nullable=False),
|
||||
sa.Column("sats_pricing", sa.Text(), nullable=True),
|
||||
sa.Column("per_request_limits", sa.Text(), nullable=True),
|
||||
sa.Column("top_provider", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("models")
|
||||
@@ -0,0 +1,118 @@
|
||||
"""make upstream provider base_url + api_key unique
|
||||
|
||||
Revision ID: c2d3e4f5a6b7
|
||||
Revises: a86e5348850b
|
||||
Create Date: 2026-01-25 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c2d3e4f5a6b7"
|
||||
down_revision = "a86e5348850b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _recreate_table_sqlite(add_base_url_unique: bool) -> None:
|
||||
conn = op.get_bind()
|
||||
existing_tables = {
|
||||
row[0]
|
||||
for row in conn.exec_driver_sql(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
}
|
||||
if "upstream_providers_old" in existing_tables:
|
||||
if "upstream_providers" in existing_tables:
|
||||
op.drop_table("upstream_providers_old")
|
||||
else:
|
||||
op.execute(
|
||||
"ALTER TABLE upstream_providers_old RENAME TO upstream_providers"
|
||||
)
|
||||
existing_tables.add("upstream_providers")
|
||||
if "upstream_providers" not in existing_tables:
|
||||
return
|
||||
|
||||
constraints = [
|
||||
sa.UniqueConstraint(
|
||||
"base_url",
|
||||
"api_key",
|
||||
name="uq_upstream_providers_base_url_api_key",
|
||||
)
|
||||
]
|
||||
if add_base_url_unique:
|
||||
constraints.append(
|
||||
sa.UniqueConstraint("base_url", name="uq_upstream_providers_base_url")
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE upstream_providers RENAME TO upstream_providers_old")
|
||||
op.create_table(
|
||||
"upstream_providers",
|
||||
sa.Column(
|
||||
"id", sa.Integer(), primary_key=True, nullable=False, autoincrement=True
|
||||
),
|
||||
sa.Column("provider_type", sa.String(), nullable=False),
|
||||
sa.Column("base_url", sa.String(), nullable=False),
|
||||
sa.Column("api_key", sa.String(), nullable=False),
|
||||
sa.Column("api_version", sa.String(), nullable=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("provider_fee", sa.Float(), nullable=False, server_default="1.01"),
|
||||
*constraints,
|
||||
)
|
||||
op.execute(
|
||||
"INSERT INTO upstream_providers (id, provider_type, base_url, api_key, api_version, enabled, provider_fee) "
|
||||
"SELECT id, provider_type, base_url, api_key, api_version, enabled, provider_fee "
|
||||
"FROM upstream_providers_old"
|
||||
)
|
||||
op.drop_table("upstream_providers_old")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name == "sqlite":
|
||||
_recreate_table_sqlite(add_base_url_unique=False)
|
||||
return
|
||||
|
||||
inspector = sa.inspect(conn)
|
||||
for constraint in inspector.get_unique_constraints("upstream_providers"):
|
||||
name = constraint.get("name")
|
||||
if constraint.get("column_names") == ["base_url"] and name:
|
||||
op.drop_constraint(
|
||||
name,
|
||||
"upstream_providers",
|
||||
type_="unique",
|
||||
)
|
||||
index_names = {idx["name"] for idx in inspector.get_indexes("upstream_providers")}
|
||||
if "ix_upstream_providers_base_url" in index_names:
|
||||
op.drop_index("ix_upstream_providers_base_url", table_name="upstream_providers")
|
||||
op.create_unique_constraint(
|
||||
"uq_upstream_providers_base_url_api_key",
|
||||
"upstream_providers",
|
||||
["base_url", "api_key"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name == "sqlite":
|
||||
_recreate_table_sqlite(add_base_url_unique=True)
|
||||
return
|
||||
|
||||
op.drop_constraint(
|
||||
"uq_upstream_providers_base_url_api_key",
|
||||
"upstream_providers",
|
||||
type_="unique",
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uq_upstream_providers_base_url",
|
||||
"upstream_providers",
|
||||
["base_url"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_upstream_providers_base_url",
|
||||
"upstream_providers",
|
||||
["base_url"],
|
||||
unique=True,
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""create upstream_providers table
|
||||
|
||||
Revision ID: d1e2f3a4b5c6
|
||||
Revises: c0ffee123456
|
||||
Create Date: 2025-10-09 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "d1e2f3a4b5c6"
|
||||
down_revision = "c0ffee123456"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
if "upstream_providers" not in inspector.get_table_names():
|
||||
op.create_table(
|
||||
"upstream_providers",
|
||||
sa.Column(
|
||||
"id", sa.Integer(), primary_key=True, nullable=False, autoincrement=True
|
||||
),
|
||||
sa.Column("provider_type", sa.String(), nullable=False),
|
||||
sa.Column("base_url", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("api_key", sa.String(), nullable=False),
|
||||
sa.Column("api_version", sa.String(), nullable=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, default=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_upstream_providers_base_url",
|
||||
"upstream_providers",
|
||||
["base_url"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_upstream_providers_base_url", "upstream_providers")
|
||||
op.drop_table("upstream_providers")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""add upstream_provider and enabled to models
|
||||
|
||||
Revision ID: e1f2a3b4c5d6
|
||||
Revises: d1e2f3a4b5c6
|
||||
Create Date: 2025-10-13 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "e1f2a3b4c5d6"
|
||||
down_revision = "d1e2f3a4b5c6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_table("models")
|
||||
op.create_table(
|
||||
"models",
|
||||
sa.Column("id", sa.String(), primary_key=True, nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("created", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("context_length", sa.Integer(), nullable=False),
|
||||
sa.Column("architecture", sa.Text(), nullable=False),
|
||||
sa.Column("pricing", sa.Text(), nullable=False),
|
||||
sa.Column("sats_pricing", sa.Text(), nullable=True),
|
||||
sa.Column("per_request_limits", sa.Text(), nullable=True),
|
||||
sa.Column("top_provider", sa.Text(), nullable=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("upstream_provider_id", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["upstream_provider_id"], ["upstream_providers.id"]),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("models")
|
||||
op.create_table(
|
||||
"models",
|
||||
sa.Column("id", sa.String(), primary_key=True, nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("created", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("context_length", sa.Integer(), nullable=False),
|
||||
sa.Column("architecture", sa.Text(), nullable=False),
|
||||
sa.Column("pricing", sa.Text(), nullable=False),
|
||||
sa.Column("sats_pricing", sa.Text(), nullable=True),
|
||||
sa.Column("per_request_limits", sa.Text(), nullable=True),
|
||||
sa.Column("top_provider", sa.Text(), nullable=True),
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""add provider_fee to upstream_providers
|
||||
|
||||
Revision ID: f7a8b9c0d1e2
|
||||
Revises: e1f2a3b4c5d6
|
||||
Create Date: 2025-10-13 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "f7a8b9c0d1e2"
|
||||
down_revision = "e1f2a3b4c5d6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"upstream_providers",
|
||||
sa.Column("provider_fee", sa.Float(), nullable=False, server_default="1.01"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("upstream_providers", "provider_fee")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Add lightning_invoices table
|
||||
|
||||
Revision ID: lightning_invoices
|
||||
Revises: a1a1a1a1a1a1
|
||||
Create Date: 2025-12-10 21:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
revision = "lightning_invoices"
|
||||
down_revision = "a1a1a1a1a1a1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"lightning_invoices",
|
||||
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("bolt11", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("amount_sats", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("payment_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("api_key_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("purpose", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||
sa.Column("expires_at", sa.Integer(), nullable=False),
|
||||
sa.Column("paid_at", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("bolt11"),
|
||||
sa.UniqueConstraint("payment_hash"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("lightning_invoices")
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
site_name: Routstr Core Documentation
|
||||
site_url: https://docs.routstr.com
|
||||
site_description: FastAPI-based reverse proxy for OpenAI-compatible APIs with Bitcoin eCash micropayments
|
||||
site_author: Routstr Team
|
||||
|
||||
repo_name: routstr/routstr-core
|
||||
repo_url: https://github.com/routstr/routstr-core
|
||||
edit_uri: tree/main/docs
|
||||
|
||||
theme:
|
||||
name: material
|
||||
language: en
|
||||
palette:
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
primary: orange
|
||||
accent: amber
|
||||
toggle:
|
||||
icon: material/brightness-7
|
||||
name: Switch to dark mode
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
primary: orange
|
||||
accent: amber
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Switch to light mode
|
||||
features:
|
||||
- navigation.tabs
|
||||
- navigation.sections
|
||||
- navigation.expand
|
||||
- navigation.top
|
||||
- navigation.indexes
|
||||
- search.suggest
|
||||
- search.highlight
|
||||
- content.tabs.link
|
||||
- content.code.copy
|
||||
- content.code.annotate
|
||||
icon:
|
||||
logo: material/lightning-bolt
|
||||
repo: fontawesome/brands/github
|
||||
|
||||
plugins:
|
||||
- search
|
||||
- mermaid2
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- codehilite:
|
||||
guess_lang: false
|
||||
- toc:
|
||||
permalink: true
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: mermaid
|
||||
class: mermaid
|
||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||
- pymdownx.details
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets
|
||||
- pymdownx.tasklist:
|
||||
custom_checkbox: true
|
||||
- attr_list
|
||||
- md_in_html
|
||||
|
||||
extra:
|
||||
social:
|
||||
- icon: fontawesome/brands/github
|
||||
link: https://github.com/routstr
|
||||
- icon: fontawesome/brands/bitcoin
|
||||
link: https://cashu.space
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Overview: overview.md
|
||||
- Client Guide:
|
||||
- Introduction: client/introduction.md
|
||||
- Payment Flow: client/payments.md
|
||||
- Integration: client/integration.md
|
||||
- Provider Guide:
|
||||
- Quick Start: provider/quickstart.md
|
||||
- Dashboard: provider/dashboard.md
|
||||
- Deployment: provider/deployment.md
|
||||
- Configuration: provider/configuration.md
|
||||
- Pricing: provider/pricing.md
|
||||
- Advanced Pricing: provider/advanced-pricing.md
|
||||
- Discovery: provider/discovery.md
|
||||
- Tor Support: provider/tor.md
|
||||
- API Reference:
|
||||
- Overview: api/overview.md
|
||||
- Authentication: api/authentication.md
|
||||
- Endpoints: api/endpoints.md
|
||||
- Errors: api/errors.md
|
||||
- Contributing:
|
||||
- Setup Development: contributing/setup.md
|
||||
- Architecture: contributing/architecture.md
|
||||
- Code Structure: contributing/code-structure.md
|
||||
- Testing: contributing/testing.md
|
||||
+7
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.1.1b"
|
||||
version = "0.3.0"
|
||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -16,6 +16,11 @@ dependencies = [
|
||||
"cashu",
|
||||
"secp256k1",
|
||||
"marshmallow>=3.13,<4.0",
|
||||
"websockets>=12.0",
|
||||
"nostr>=0.0.2",
|
||||
"mdurl==0.1.2",
|
||||
"pillow>=10",
|
||||
"openai>=1.98.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -68,6 +73,7 @@ packages = ["routstr"]
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
ignore = ["E501"]
|
||||
exclude = ["examples"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import dotenv
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
from .core.main import app as fastapi_app # noqa
|
||||
|
||||
__all__ = ["fastapi_app"]
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Model prioritization algorithm for selecting cheapest upstream providers."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .core.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .payment.models import Model
|
||||
from .upstream import BaseUpstreamProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def calculate_model_cost_score(model: "Model") -> float:
|
||||
"""Calculate a representative cost score for a model.
|
||||
|
||||
This score is used to compare models when multiple providers offer the same model.
|
||||
Lower scores indicate cheaper models.
|
||||
|
||||
The score is calculated as a weighted average of:
|
||||
- Input token cost (weighted by typical input usage)
|
||||
- Output token cost (weighted by typical output usage)
|
||||
- Fixed request cost
|
||||
|
||||
Args:
|
||||
model: Model instance with pricing information
|
||||
|
||||
Returns:
|
||||
Float representing the cost score. Lower is better.
|
||||
"""
|
||||
pricing = model.pricing
|
||||
|
||||
# Weight costs by typical usage patterns
|
||||
# Assume average request: 1000 input tokens, 500 output tokens
|
||||
TYPICAL_INPUT_TOKENS = 1000.0
|
||||
TYPICAL_OUTPUT_TOKENS = 500.0
|
||||
|
||||
# Calculate weighted cost in USD
|
||||
input_cost = pricing.prompt * (TYPICAL_INPUT_TOKENS / 1000.0)
|
||||
output_cost = pricing.completion * (TYPICAL_OUTPUT_TOKENS / 1000.0)
|
||||
request_cost = pricing.request
|
||||
|
||||
# Include additional costs if present
|
||||
image_cost = (
|
||||
getattr(pricing, "image", 0.0) * 0.1
|
||||
) # Weight lower as not every request uses images
|
||||
web_search_cost = getattr(pricing, "web_search", 0.0) * 0.1
|
||||
reasoning_cost = getattr(pricing, "internal_reasoning", 0.0) * 0.2
|
||||
|
||||
total_cost = (
|
||||
input_cost
|
||||
+ output_cost
|
||||
+ request_cost
|
||||
+ image_cost
|
||||
+ web_search_cost
|
||||
+ reasoning_cost
|
||||
)
|
||||
|
||||
return total_cost
|
||||
|
||||
|
||||
def get_provider_penalty(provider: "BaseUpstreamProvider") -> float:
|
||||
"""Calculate a penalty multiplier for certain providers.
|
||||
|
||||
This allows applying policy-based adjustments beyond pure cost.
|
||||
For example, preferring certain providers for reliability or features.
|
||||
|
||||
Args:
|
||||
provider: UpstreamProvider instance
|
||||
|
||||
Returns:
|
||||
Float multiplier to apply to cost (1.0 = no penalty, >1.0 = penalize)
|
||||
"""
|
||||
# Default: no penalty
|
||||
penalty = 1.0
|
||||
|
||||
# Check if this is OpenRouter (can be identified by base URL)
|
||||
base_url = getattr(provider, "base_url", "")
|
||||
if "openrouter.ai" in base_url.lower():
|
||||
# Small penalty for OpenRouter to prefer other providers when costs are very close
|
||||
# This maintains the original behavior of preferring non-OpenRouter providers
|
||||
penalty = 1.001 # 0.1% penalty
|
||||
|
||||
return penalty
|
||||
|
||||
|
||||
def create_model_mappings(
|
||||
upstreams: list["BaseUpstreamProvider"],
|
||||
overrides_by_id: dict[str, tuple],
|
||||
disabled_model_ids: set[str],
|
||||
) -> tuple[
|
||||
dict[str, "Model"], dict[str, list["BaseUpstreamProvider"]], dict[str, "Model"]
|
||||
]:
|
||||
"""Create optimal model mappings based on cost and provider preferences.
|
||||
|
||||
This is the main entry point for the algorithm. It processes all upstream providers
|
||||
and creates three mappings based on cost optimization:
|
||||
|
||||
1. model_instances: alias -> Model (all model aliases mapped to their Model objects)
|
||||
2. provider_map: alias -> List[UpstreamProvider] (sorted list of providers for each alias)
|
||||
3. unique_models: base_id -> Model (unique models without provider prefixes)
|
||||
|
||||
The algorithm:
|
||||
- Processes non-OpenRouter providers first (they're typically cheaper)
|
||||
- Then processes OpenRouter models (they can still win if cheaper)
|
||||
- For each model alias, collects all candidates and sorts them by priority and cost.
|
||||
|
||||
Args:
|
||||
upstreams: List of all upstream provider instances
|
||||
overrides_by_id: Dict of model overrides from database {model_id: (ModelRow, fee)}
|
||||
disabled_model_ids: Set of model IDs that should be excluded
|
||||
|
||||
Returns:
|
||||
Tuple of (model_instances, provider_map, unique_models)
|
||||
"""
|
||||
from .payment.models import _row_to_model
|
||||
from .upstream.helpers import resolve_model_alias
|
||||
|
||||
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
|
||||
unique_models: dict[str, "Model"] = {}
|
||||
|
||||
# Separate OpenRouter from other providers
|
||||
openrouter: "BaseUpstreamProvider" | None = None
|
||||
other_upstreams: list["BaseUpstreamProvider"] = []
|
||||
|
||||
for upstream in upstreams:
|
||||
base_url = getattr(upstream, "base_url", "")
|
||||
if base_url == "https://openrouter.ai/api/v1":
|
||||
openrouter = upstream
|
||||
else:
|
||||
other_upstreams.append(upstream)
|
||||
|
||||
def get_base_model_id(model_id: str) -> str:
|
||||
"""Get base model ID by removing provider prefix."""
|
||||
return model_id.split("/", 1)[1] if "/" in model_id else model_id
|
||||
|
||||
def _add_candidate(
|
||||
alias: str, model: "Model", provider: "BaseUpstreamProvider"
|
||||
) -> None:
|
||||
"""Add candidate model/provider for an alias."""
|
||||
alias_lower = alias.lower()
|
||||
if alias_lower not in candidates:
|
||||
candidates[alias_lower] = []
|
||||
candidates[alias_lower].append((model, provider))
|
||||
|
||||
def process_provider_models(
|
||||
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
|
||||
) -> None:
|
||||
"""Process all models from a given provider."""
|
||||
upstream_prefix = getattr(upstream, "upstream_name", None)
|
||||
|
||||
for model in upstream.get_cached_models():
|
||||
if not model.enabled or model.id in disabled_model_ids:
|
||||
continue
|
||||
|
||||
# Apply overrides if present
|
||||
if model.id in overrides_by_id:
|
||||
override_row, provider_fee = overrides_by_id[model.id]
|
||||
model_to_use = _row_to_model(
|
||||
override_row, apply_provider_fee=True, provider_fee=provider_fee
|
||||
)
|
||||
else:
|
||||
model_to_use = model
|
||||
|
||||
# Add to unique models
|
||||
base_id = get_base_model_id(model_to_use.id)
|
||||
if not is_openrouter or base_id not in unique_models:
|
||||
unique_model = model_to_use.copy(
|
||||
update={
|
||||
"id": base_id,
|
||||
"upstream_provider_id": upstream.provider_type,
|
||||
}
|
||||
)
|
||||
unique_models[base_id] = unique_model
|
||||
|
||||
# Get all aliases for this model
|
||||
aliases = resolve_model_alias(
|
||||
model_to_use.id,
|
||||
model_to_use.canonical_slug,
|
||||
alias_ids=model_to_use.alias_ids,
|
||||
)
|
||||
|
||||
# Add prefixed alias if applicable
|
||||
if upstream_prefix and "/" not in model_to_use.id:
|
||||
prefixed_id = f"{upstream_prefix}/{model_to_use.id}"
|
||||
if prefixed_id not in aliases:
|
||||
aliases.append(prefixed_id)
|
||||
|
||||
# Try to set each alias
|
||||
for alias in aliases:
|
||||
_add_candidate(alias, model_to_use, upstream)
|
||||
|
||||
# Process non-OpenRouter providers first
|
||||
for upstream in other_upstreams:
|
||||
process_provider_models(upstream, is_openrouter=False)
|
||||
|
||||
# Process OpenRouter last
|
||||
if openrouter:
|
||||
process_provider_models(openrouter, is_openrouter=True)
|
||||
|
||||
# Sort candidates and build final maps
|
||||
model_instances: dict[str, "Model"] = {}
|
||||
provider_map: dict[str, list["BaseUpstreamProvider"]] = {}
|
||||
|
||||
def alias_priority(model: "Model", alias: str) -> int:
|
||||
"""Rank how strong the mapping of alias->model is."""
|
||||
model_base = get_base_model_id(model.id)
|
||||
if model_base == alias:
|
||||
return 3
|
||||
if model.canonical_slug:
|
||||
canonical_base = get_base_model_id(model.canonical_slug)
|
||||
if canonical_base == alias:
|
||||
return 2
|
||||
return 1
|
||||
|
||||
for alias, items in candidates.items():
|
||||
# Sort key: (priority DESC, cost ASC)
|
||||
# Using negative cost for DESC sort overall to keep high priority first
|
||||
def sort_key(item: tuple["Model", "BaseUpstreamProvider"]) -> tuple[int, float]:
|
||||
model, provider = item
|
||||
priority = alias_priority(model, alias)
|
||||
cost = calculate_model_cost_score(model)
|
||||
penalty = get_provider_penalty(provider)
|
||||
adjusted_cost = cost * penalty
|
||||
return (priority, -adjusted_cost)
|
||||
|
||||
items.sort(key=sort_key, reverse=True)
|
||||
|
||||
best_model, best_provider = items[0]
|
||||
model_instances[alias] = best_model
|
||||
provider_map[alias] = [p for _, p in items]
|
||||
|
||||
# Log provider distribution (using top provider for stats)
|
||||
provider_counts: dict[str, int] = {}
|
||||
for providers in provider_map.values():
|
||||
if providers:
|
||||
provider = providers[0]
|
||||
provider_name = getattr(provider, "upstream_name", "unknown")
|
||||
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
|
||||
|
||||
logger.debug(
|
||||
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",
|
||||
extra={"provider_distribution": provider_counts},
|
||||
)
|
||||
|
||||
return model_instances, provider_map, unique_models
|
||||
+566
-101
@@ -1,24 +1,25 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlmodel import col, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import col, select, update
|
||||
|
||||
from .core import get_logger
|
||||
from .core.db import ApiKey, AsyncSession
|
||||
from .payment.cost_caculation import (
|
||||
from .core.settings import settings
|
||||
from .payment.cost_calculation import (
|
||||
CostData,
|
||||
CostDataError,
|
||||
MaxCostData,
|
||||
calculate_cost,
|
||||
)
|
||||
from .wallet import (
|
||||
PRIMARY_MINT_URL,
|
||||
TRUSTED_MINTS,
|
||||
credit_balance,
|
||||
deserialize_token_from_string,
|
||||
)
|
||||
from .wallet import credit_balance, deserialize_token_from_string
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -27,16 +28,60 @@ logger = get_logger(__name__)
|
||||
# PREPAID_BALANCE = int(os.environ.get("PREPAID_BALANCE", "0")) * 1000 # Convert to msats
|
||||
|
||||
|
||||
async def check_and_reset_limit(key: ApiKey, session: AsyncSession) -> bool:
|
||||
"""Checks if a key's balance limit should be reset based on its policy."""
|
||||
if key.balance_limit is not None and key.balance_limit_reset:
|
||||
now = int(time.time())
|
||||
reset_date = key.balance_limit_reset_date or 0
|
||||
should_reset = False
|
||||
|
||||
if key.balance_limit_reset == "daily":
|
||||
if (
|
||||
datetime.fromtimestamp(now).date()
|
||||
> datetime.fromtimestamp(reset_date).date()
|
||||
):
|
||||
should_reset = True
|
||||
elif key.balance_limit_reset == "weekly":
|
||||
if (
|
||||
datetime.fromtimestamp(now).isocalendar()[:2]
|
||||
> datetime.fromtimestamp(reset_date).isocalendar()[:2]
|
||||
):
|
||||
should_reset = True
|
||||
elif key.balance_limit_reset == "monthly":
|
||||
dt_now = datetime.fromtimestamp(now)
|
||||
dt_reset = datetime.fromtimestamp(reset_date)
|
||||
if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month:
|
||||
should_reset = True
|
||||
|
||||
if should_reset:
|
||||
logger.info(
|
||||
"Resetting balance limit for key",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"policy": key.balance_limit_reset,
|
||||
"old_spent": key.total_spent,
|
||||
},
|
||||
)
|
||||
key.total_spent = 0
|
||||
key.balance_limit_reset_date = now
|
||||
session.add(key)
|
||||
await session.flush()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def validate_bearer_key(
|
||||
bearer_key: str,
|
||||
session: AsyncSession,
|
||||
refund_address: Optional[str] = None,
|
||||
key_expiry_time: Optional[int] = None,
|
||||
min_cost: int = 0,
|
||||
) -> ApiKey:
|
||||
"""
|
||||
Validates the provided API key using SQLModel.
|
||||
If it's a cashu key, it redeems it and stores its hash and balance.
|
||||
Otherwise checks if the hash of the key exists.
|
||||
Includes a balance check against min_cost for limited keys.
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting bearer key validation",
|
||||
@@ -46,6 +91,7 @@ async def validate_bearer_key(
|
||||
else bearer_key,
|
||||
"has_refund_address": bool(refund_address),
|
||||
"has_expiry_time": bool(key_expiry_time),
|
||||
"min_cost": min_cost,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -100,6 +146,50 @@ async def validate_bearer_key(
|
||||
},
|
||||
)
|
||||
|
||||
# Check and reset limit if needed
|
||||
await check_and_reset_limit(existing_key, session)
|
||||
|
||||
# Early check: Billing balance check (Parent balance)
|
||||
billing_key = await get_billing_key(existing_key, session)
|
||||
if min_cost > 0 and billing_key.total_balance < min_cost:
|
||||
logger.warning(
|
||||
"Insufficient billing balance during validation",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"balance": billing_key.total_balance,
|
||||
"required": min_cost,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {min_cost} mSats required for this model. {billing_key.total_balance} available.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Early check: Spending limit check (Child key limit)
|
||||
if (
|
||||
min_cost > 0
|
||||
and existing_key.balance_limit is not None
|
||||
and existing_key.total_spent + existing_key.reserved_balance + min_cost
|
||||
> existing_key.balance_limit
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Balance limit exceeded: {existing_key.balance_limit} mSats limit. {existing_key.total_spent} already spent ({existing_key.reserved_balance} reserved), {min_cost} minimum required for this model.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "balance_limit_exceeded",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return existing_key
|
||||
else:
|
||||
logger.warning(
|
||||
@@ -155,6 +245,19 @@ async def validate_bearer_key(
|
||||
},
|
||||
)
|
||||
|
||||
# Early check: Billing balance check
|
||||
if min_cost > 0 and existing_key.total_balance < min_cost:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {min_cost} mSats required for this model. {existing_key.total_balance} available.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return existing_key
|
||||
|
||||
logger.info(
|
||||
@@ -165,12 +268,12 @@ async def validate_bearer_key(
|
||||
"has_expiry_time": bool(key_expiry_time),
|
||||
},
|
||||
)
|
||||
if token_obj.mint in TRUSTED_MINTS:
|
||||
if token_obj.mint in settings.cashu_mints:
|
||||
refund_currency = token_obj.unit
|
||||
refund_mint_url = token_obj.mint
|
||||
else:
|
||||
refund_currency = "sat"
|
||||
refund_mint_url = PRIMARY_MINT_URL
|
||||
refund_mint_url = settings.primary_mint
|
||||
|
||||
new_key = ApiKey(
|
||||
hashed_key=hashed_key,
|
||||
@@ -181,7 +284,25 @@ async def validate_bearer_key(
|
||||
refund_mint_url=refund_mint_url,
|
||||
)
|
||||
session.add(new_key)
|
||||
await session.flush()
|
||||
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
logger.info(
|
||||
"Concurrent key creation detected, fetching existing key",
|
||||
extra={"key_hash": hashed_key[:8] + "..."},
|
||||
)
|
||||
existing_key = await session.get(ApiKey, hashed_key)
|
||||
if not existing_key:
|
||||
raise Exception("Failed to fetch existing key after IntegrityError")
|
||||
|
||||
if key_expiry_time is not None:
|
||||
existing_key.key_expiry_time = key_expiry_time
|
||||
if refund_address is not None:
|
||||
existing_key.refund_address = refund_address
|
||||
|
||||
return existing_key
|
||||
|
||||
logger.debug(
|
||||
"New key created, starting token redemption",
|
||||
@@ -271,30 +392,57 @@ async def validate_bearer_key(
|
||||
)
|
||||
|
||||
|
||||
async def get_billing_key(key: ApiKey, session: AsyncSession) -> ApiKey:
|
||||
"""Returns the key that should be charged for the request."""
|
||||
if key.parent_key_hash:
|
||||
parent = await session.get(ApiKey, key.parent_key_hash)
|
||||
if parent:
|
||||
# We want to keep the total_requests and total_spent on the child key
|
||||
# but use the balance and reserved_balance of the parent.
|
||||
# However, pay_for_request updates reserved_balance and total_requests.
|
||||
# To stay simple, we charge the parent's balance and update parent's total_requests.
|
||||
return parent
|
||||
else:
|
||||
logger.error(
|
||||
"Parent key not found for child key",
|
||||
extra={
|
||||
"child_key_hash": key.hashed_key[:8] + "...",
|
||||
"parent_key_hash": key.parent_key_hash[:8] + "...",
|
||||
},
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
async def pay_for_request(
|
||||
key: ApiKey, cost_per_request: int, session: AsyncSession
|
||||
) -> int:
|
||||
"""Process payment for a request."""
|
||||
# Ensure cost_per_request is at least the minimum allowed request cost
|
||||
cost_per_request = max(cost_per_request, settings.min_request_msat)
|
||||
|
||||
billing_key = await get_billing_key(key, session)
|
||||
|
||||
logger.info(
|
||||
"Processing payment for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"current_balance": key.balance,
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"current_balance": billing_key.balance,
|
||||
"required_cost": cost_per_request,
|
||||
"sufficient_balance": key.balance >= cost_per_request,
|
||||
"sufficient_balance": billing_key.balance >= cost_per_request,
|
||||
},
|
||||
)
|
||||
|
||||
if key.total_balance < cost_per_request:
|
||||
if billing_key.total_balance < cost_per_request:
|
||||
logger.warning(
|
||||
"Insufficient balance for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"balance": key.balance,
|
||||
"reserved_balance": key.reserved_balance,
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"balance": billing_key.balance,
|
||||
"reserved_balance": billing_key.reserved_balance,
|
||||
"required": cost_per_request,
|
||||
"shortfall": cost_per_request - key.total_balance,
|
||||
"shortfall": cost_per_request - billing_key.total_balance,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -302,33 +450,98 @@ async def pay_for_request(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.total_balance} available. (reserved: {key.reserved_balance})",
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.total_balance} available. (reserved: {billing_key.reserved_balance})",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Check validity date
|
||||
if key.validity_date is not None:
|
||||
if time.time() > key.validity_date:
|
||||
logger.warning(
|
||||
"Key validity date expired",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"validity_date": key.validity_date,
|
||||
"current_time": time.time(),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "API key has expired (validity date reached).",
|
||||
"type": "invalid_request_error",
|
||||
"code": "key_expired",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Check balance limit for child keys (or any key with a limit)
|
||||
if key.balance_limit is not None:
|
||||
await check_and_reset_limit(key, session)
|
||||
|
||||
if (
|
||||
key.total_spent + key.reserved_balance + cost_per_request
|
||||
> key.balance_limit
|
||||
):
|
||||
logger.warning(
|
||||
"Balance limit exceeded",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"total_spent": key.total_spent,
|
||||
"reserved": key.reserved_balance,
|
||||
"balance_limit": key.balance_limit,
|
||||
"required": cost_per_request,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent ({key.reserved_balance} reserved), {cost_per_request} required for this request.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "balance_limit_exceeded",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Charging base cost for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"cost": cost_per_request,
|
||||
"balance_before": key.balance,
|
||||
"balance_before": billing_key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
# Charge the base cost for the request atomically to avoid race conditions
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) >= cost_per_request)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also increment total_requests and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
@@ -336,8 +549,9 @@ async def pay_for_request(
|
||||
"Concurrent request depleted balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"required_cost": cost_per_request,
|
||||
"current_balance": key.balance,
|
||||
"current_balance": billing_key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -346,23 +560,26 @@ async def pay_for_request(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.balance} available.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await session.refresh(key)
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Payment processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost_per_request,
|
||||
"new_balance": key.balance,
|
||||
"total_spent": key.total_spent,
|
||||
"total_requests": key.total_requests,
|
||||
"new_balance": billing_key.balance,
|
||||
"total_spent": billing_key.total_spent,
|
||||
"total_requests": billing_key.total_requests,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -372,9 +589,11 @@ async def pay_for_request(
|
||||
async def revert_pay_for_request(
|
||||
key: ApiKey, session: AsyncSession, cost_per_request: int
|
||||
) -> None:
|
||||
billing_key = await get_billing_key(key, session)
|
||||
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
@@ -382,27 +601,43 @@ async def revert_pay_for_request(
|
||||
)
|
||||
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also decrement total_requests and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to revert payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"cost_to_revert": cost_per_request,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"current_reserved_balance": billing_key.reserved_balance,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"failed to revert request payment: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"message": f"failed to revert request payment: {cost_per_request} mSats required. {billing_key.balance} available.",
|
||||
"type": "payment_error",
|
||||
"code": "payment_error",
|
||||
}
|
||||
},
|
||||
)
|
||||
await session.refresh(key)
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
|
||||
|
||||
async def adjust_payment_for_tokens(
|
||||
@@ -413,29 +648,128 @@ async def adjust_payment_for_tokens(
|
||||
This is called after the initial payment and the upstream request is complete.
|
||||
Returns cost data to be included in the response.
|
||||
"""
|
||||
billing_key = await get_billing_key(key, session)
|
||||
model = response_data.get("model", "unknown")
|
||||
|
||||
logger.debug(
|
||||
"Starting payment adjustment for tokens",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_balance": key.balance,
|
||||
"current_balance": billing_key.balance,
|
||||
"has_usage": "usage" in response_data,
|
||||
},
|
||||
)
|
||||
|
||||
match calculate_cost(response_data, deducted_max_cost):
|
||||
async def release_reservation_only() -> None:
|
||||
"""Fallback to release reservation without charging when main update fails."""
|
||||
try:
|
||||
release_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost
|
||||
)
|
||||
)
|
||||
await session.exec(release_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also release on child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_release_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost
|
||||
)
|
||||
)
|
||||
await session.exec(child_release_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
logger.warning(
|
||||
"Released reservation without charging (fallback)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to release reservation in fallback",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
match await calculate_cost(response_data, deducted_max_cost, session):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost data (no token adjustment)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"max_cost": cost.total_msats,
|
||||
},
|
||||
)
|
||||
# Finalize by releasing reservation and charging max cost
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - cost.total_msats,
|
||||
total_spent=col(ApiKey.total_spent) + cost.total_msats,
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
total_spent=col(ApiKey.total_spent) + cost.total_msats,
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize max-cost payment - retrying reservation release",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": billing_key.reserved_balance,
|
||||
"total_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await release_reservation_only()
|
||||
else:
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
logger.info(
|
||||
"Max cost payment finalized",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost.total_msats,
|
||||
"new_balance": billing_key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
return cost.dict()
|
||||
|
||||
case CostData() as cost:
|
||||
@@ -448,6 +782,7 @@ async def adjust_payment_for_tokens(
|
||||
"Calculated token-based cost",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"token_cost": cost.total_msats,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
@@ -459,76 +794,113 @@ async def adjust_payment_for_tokens(
|
||||
|
||||
if cost_difference == 0:
|
||||
logger.debug(
|
||||
"No cost adjustment needed",
|
||||
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
||||
"Finalizing with exact reserved cost",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - total_cost_msats,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
)
|
||||
)
|
||||
await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
return cost.dict()
|
||||
|
||||
# this should never happen why do we handle this???
|
||||
if cost_difference > 0:
|
||||
# Need to charge more
|
||||
# Need to charge more than reserved, finalize by releasing reservation and charging total
|
||||
logger.info(
|
||||
"Additional charge required for token usage",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"additional_charge": cost_difference,
|
||||
"current_balance": key.balance,
|
||||
"sufficient_balance": key.balance >= cost_difference,
|
||||
"current_balance": billing_key.balance,
|
||||
"sufficient_balance": billing_key.balance >= cost_difference,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
# this should never happen why do we handle this???
|
||||
if key.balance < cost_difference:
|
||||
logger.warning(
|
||||
"Insufficient balance for token-based pricing adjustment",
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - total_cost_msats,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount:
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Finalized payment with additional charge",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"required": cost_difference,
|
||||
"available": key.balance,
|
||||
"shortfall": cost_difference - key.balance,
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"charged_amount": total_cost_msats,
|
||||
"new_balance": billing_key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
else:
|
||||
# this should never happen why do we handle this???
|
||||
charge_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) >= cost_difference)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) - cost_difference,
|
||||
total_spent=col(ApiKey.total_spent) + cost_difference,
|
||||
)
|
||||
logger.warning(
|
||||
"Failed to finalize additional charge - releasing reservation",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"attempted_charge": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
result = await session.exec(charge_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount:
|
||||
cost.total_msats = deducted_max_cost + cost_difference
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Additional charge applied successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost_difference,
|
||||
"new_balance": key.balance,
|
||||
"total_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to apply additional charge (concurrent operation)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"attempted_charge": cost_difference,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await release_reservation_only()
|
||||
else:
|
||||
# Refund some of the base cost
|
||||
refund = abs(cost_difference)
|
||||
@@ -536,15 +908,16 @@ async def adjust_payment_for_tokens(
|
||||
"Refunding excess payment",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"refund_amount": refund,
|
||||
"current_balance": key.balance,
|
||||
"current_balance": billing_key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
refund_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
@@ -553,41 +926,58 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
)
|
||||
result = await session.exec(refund_stmt) # type: ignore[call-overload]
|
||||
|
||||
# Also update total_spent and reserved_balance on the child key if it's different
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
child_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
)
|
||||
)
|
||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize payment - insufficient reserved balance",
|
||||
"Failed to finalize payment - releasing reservation",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"current_reserved_balance": billing_key.reserved_balance,
|
||||
"total_cost": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
# Still return the cost data even if we couldn't properly finalize
|
||||
# The reservation was already made, so the user has paid
|
||||
await release_reservation_only()
|
||||
else:
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(billing_key)
|
||||
if billing_key.hashed_key != key.hashed_key:
|
||||
await session.refresh(key)
|
||||
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Refund processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"refunded_amount": refund,
|
||||
"new_balance": key.balance,
|
||||
"final_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"Refund processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||
"refunded_amount": refund,
|
||||
"new_balance": billing_key.balance,
|
||||
"final_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
return cost.dict()
|
||||
|
||||
case CostDataError() as error:
|
||||
logger.error(
|
||||
"Cost calculation error during payment adjustment",
|
||||
"Cost calculation error during payment adjustment - releasing reservation",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
@@ -595,6 +985,7 @@ async def adjust_payment_for_tokens(
|
||||
"error_code": error.code,
|
||||
},
|
||||
)
|
||||
await release_reservation_only()
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -606,3 +997,77 @@ async def adjust_payment_for_tokens(
|
||||
}
|
||||
},
|
||||
)
|
||||
# Fallback: should not reach here, but release reservation just in case
|
||||
logger.error(
|
||||
"Unexpected fallback in adjust_payment_for_tokens - releasing reservation",
|
||||
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
||||
)
|
||||
await release_reservation_only()
|
||||
return {
|
||||
"base_msats": deducted_max_cost,
|
||||
"input_msats": 0,
|
||||
"output_msats": 0,
|
||||
"total_msats": deducted_max_cost,
|
||||
}
|
||||
|
||||
|
||||
async def periodic_key_reset() -> None:
|
||||
"""Background task to reset key limits based on their policy."""
|
||||
from .core.db import create_session
|
||||
|
||||
while True:
|
||||
try:
|
||||
interval = 3600 # Run every hour
|
||||
jitter = 300
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
try:
|
||||
async with create_session() as session:
|
||||
# Find all keys that have a reset policy
|
||||
stmt = select(ApiKey).where(ApiKey.balance_limit_reset.is_not(None)) # type: ignore
|
||||
keys = (await session.exec(stmt)).all()
|
||||
|
||||
now = int(time.time())
|
||||
updated_count = 0
|
||||
|
||||
for key in keys:
|
||||
reset_date = key.balance_limit_reset_date or 0
|
||||
should_reset = False
|
||||
|
||||
if key.balance_limit_reset == "daily":
|
||||
if (
|
||||
datetime.fromtimestamp(now).date()
|
||||
> datetime.fromtimestamp(reset_date).date()
|
||||
):
|
||||
should_reset = True
|
||||
elif key.balance_limit_reset == "weekly":
|
||||
if (
|
||||
datetime.fromtimestamp(now).isocalendar()[:2]
|
||||
> datetime.fromtimestamp(reset_date).isocalendar()[:2]
|
||||
):
|
||||
should_reset = True
|
||||
elif key.balance_limit_reset == "monthly":
|
||||
dt_now = datetime.fromtimestamp(now)
|
||||
dt_reset = datetime.fromtimestamp(reset_date)
|
||||
if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month:
|
||||
should_reset = True
|
||||
|
||||
if should_reset:
|
||||
key.total_spent = 0
|
||||
key.balance_limit_reset_date = now
|
||||
session.add(key)
|
||||
updated_count += 1
|
||||
|
||||
if updated_count > 0:
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Periodic key reset complete",
|
||||
extra={"keys_reset": updated_count},
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in periodic_key_reset: {e}")
|
||||
|
||||
+283
-28
@@ -1,15 +1,25 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import time
|
||||
from time import monotonic
|
||||
from typing import Annotated, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
|
||||
from .auth import validate_bearer_key
|
||||
from .auth import get_billing_key, validate_bearer_key
|
||||
from .core.db import ApiKey, AsyncSession, get_session
|
||||
from .wallet import PRIMARY_MINT_URL, credit_balance, send_to_lnurl, send_token
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from .lightning import lightning_router
|
||||
from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
|
||||
|
||||
router = APIRouter()
|
||||
balance_router = APIRouter(prefix="/v1/balance")
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def get_key_from_header(
|
||||
authorization: Annotated[str, Header(...)],
|
||||
@@ -24,20 +34,83 @@ async def get_key_from_header(
|
||||
)
|
||||
|
||||
|
||||
async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
|
||||
billing_key = await get_billing_key(key, session)
|
||||
info = {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": billing_key.balance,
|
||||
"reserved": billing_key.reserved_balance,
|
||||
"is_child": key.parent_key_hash is not None,
|
||||
"parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None,
|
||||
"total_requests": key.total_requests,
|
||||
"total_spent": key.total_spent,
|
||||
"balance_limit": key.balance_limit,
|
||||
"balance_limit_reset": key.balance_limit_reset,
|
||||
"validity_date": key.validity_date,
|
||||
}
|
||||
|
||||
if not key.parent_key_hash:
|
||||
# Fetch child keys if this is a parent key
|
||||
statement = select(ApiKey).where(ApiKey.parent_key_hash == key.hashed_key)
|
||||
results = await session.exec(statement)
|
||||
child_keys = results.all()
|
||||
if child_keys:
|
||||
info["child_keys"] = [
|
||||
{
|
||||
"api_key": "sk-" + ck.hashed_key,
|
||||
"total_requests": ck.total_requests,
|
||||
"total_spent": ck.total_spent,
|
||||
"balance_limit": ck.balance_limit,
|
||||
"balance_limit_reset": ck.balance_limit_reset,
|
||||
"validity_date": ck.validity_date,
|
||||
}
|
||||
for ck in child_keys
|
||||
]
|
||||
|
||||
return info
|
||||
|
||||
|
||||
# TODO: remove this endpoint when frontend is updated
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
return {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
}
|
||||
async def account_info(
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
return await get_balance_info(key, session)
|
||||
|
||||
|
||||
# TODO: Implement POST /v1/wallet/create endpoint
|
||||
# This endpoint should accept:
|
||||
# - cashu_token (required): The eCash token to deposit
|
||||
# - refund_lnurl (optional): LNURL for refunds (instead of refund_address in validate_bearer_key)
|
||||
# - refund_expiry (optional): Expiry timestamp for the key (maps to key_expiry_time in validate_bearer_key)
|
||||
# The endpoint should:
|
||||
# 1. Create a new wallet/API key from the cashu_token
|
||||
# 2. Store refund_lnurl and refund_expiry in the database
|
||||
# 3. Return the API key (rstr_...) and balance
|
||||
# Note: validate_bearer_key already supports refund_address and key_expiry_time params
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_balance(
|
||||
initial_balance_token: str, session: AsyncSession = Depends(get_session)
|
||||
initial_balance_token: str,
|
||||
balance_limit: int | None = None,
|
||||
balance_limit_reset: str | None = None,
|
||||
validity_date: int | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
key = await validate_bearer_key(initial_balance_token, session)
|
||||
|
||||
if balance_limit is not None or balance_limit_reset or validity_date:
|
||||
key.balance_limit = balance_limit
|
||||
key.balance_limit_reset = balance_limit_reset
|
||||
key.validity_date = validity_date
|
||||
if balance_limit_reset:
|
||||
key.balance_limit_reset_date = int(time.time())
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
|
||||
return {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
@@ -45,11 +118,11 @@ async def create_balance(
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
return {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
}
|
||||
async def wallet_info(
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
return await get_balance_info(key, session)
|
||||
|
||||
|
||||
class TopupRequest(BaseModel):
|
||||
@@ -63,6 +136,8 @@ async def topup_wallet_endpoint(
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, int]:
|
||||
billing_key = await get_billing_key(key, session)
|
||||
|
||||
if topup_request is not None:
|
||||
cashu_token = topup_request.cashu_token
|
||||
if cashu_token is None:
|
||||
@@ -72,7 +147,7 @@ async def topup_wallet_endpoint(
|
||||
if len(cashu_token) < 10 or "cashu" not in cashu_token:
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
try:
|
||||
amount_msats = await credit_balance(cashu_token, key, session)
|
||||
amount_msats = await credit_balance(cashu_token, billing_key, session)
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
if "already spent" in error_msg.lower():
|
||||
@@ -86,37 +161,87 @@ async def topup_wallet_endpoint(
|
||||
return {"msats": amount_msats}
|
||||
|
||||
|
||||
_REFUND_CACHE_TTL_SECONDS: int = settings.refund_cache_ttl_seconds
|
||||
_refund_cache_lock: asyncio.Lock = asyncio.Lock()
|
||||
_refund_cache: dict[str, tuple[float, dict[str, str]]] = {}
|
||||
|
||||
|
||||
def _cache_key_for_authorization(authorization: str) -> str:
|
||||
return hashlib.sha256(authorization.strip().encode()).hexdigest()
|
||||
|
||||
|
||||
async def _refund_cache_get(authorization: str) -> dict[str, str] | None:
|
||||
key = _cache_key_for_authorization(authorization)
|
||||
async with _refund_cache_lock:
|
||||
item = _refund_cache.get(key)
|
||||
if item is None:
|
||||
return None
|
||||
expires_at, value = item
|
||||
if expires_at <= monotonic():
|
||||
del _refund_cache[key]
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None:
|
||||
key = _cache_key_for_authorization(authorization)
|
||||
expiry = monotonic() + _REFUND_CACHE_TTL_SECONDS
|
||||
async with _refund_cache_lock:
|
||||
_refund_cache[key] = (expiry, value)
|
||||
|
||||
|
||||
@router.post("/refund")
|
||||
async def refund_wallet_endpoint(
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
authorization: Annotated[str, Header(...)],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
remaining_balance_msats: int = key.balance
|
||||
) -> dict[str, str]:
|
||||
if not authorization.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid authorization. Use 'Bearer <cashu-token>' or 'Bearer <api-key>'",
|
||||
)
|
||||
|
||||
if remaining_balance_msats <= 0:
|
||||
bearer_value: str = authorization[7:]
|
||||
|
||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||
|
||||
if cached := await _refund_cache_get(bearer_value):
|
||||
return cached
|
||||
|
||||
if key.parent_key_hash:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot refund child key. Please refund the parent key instead.",
|
||||
)
|
||||
|
||||
remaining_balance_msats: int = key.total_balance
|
||||
|
||||
if key.refund_currency == "sat":
|
||||
remaining_balance = remaining_balance_msats // 1000
|
||||
else:
|
||||
remaining_balance = remaining_balance_msats
|
||||
|
||||
if remaining_balance_msats > 0 and remaining_balance <= 0:
|
||||
raise HTTPException(status_code=400, detail="Balance too small to refund")
|
||||
elif remaining_balance <= 0:
|
||||
raise HTTPException(status_code=400, detail="No balance to refund")
|
||||
|
||||
# Perform refund operation first, before modifying balance
|
||||
try:
|
||||
if key.refund_address:
|
||||
if key.refund_currency == "sat":
|
||||
remaining_balance = remaining_balance_msats * 1000
|
||||
from .core.settings import settings as global_settings
|
||||
|
||||
await send_to_lnurl(
|
||||
remaining_balance,
|
||||
key.refund_currency or "sat",
|
||||
key.refund_mint_url or PRIMARY_MINT_URL,
|
||||
key.refund_mint_url or global_settings.primary_mint,
|
||||
key.refund_address,
|
||||
)
|
||||
result = {"recipient": key.refund_address}
|
||||
else:
|
||||
refund_amount = (
|
||||
remaining_balance_msats // 1000
|
||||
if key.refund_currency == "sat"
|
||||
else remaining_balance_msats
|
||||
)
|
||||
refund_currency = key.refund_currency or "sat"
|
||||
token = await send_token(
|
||||
refund_amount, refund_currency, key.refund_mint_url
|
||||
remaining_balance, refund_currency, key.refund_mint_url
|
||||
)
|
||||
result = {"token": token}
|
||||
|
||||
@@ -141,12 +266,140 @@ async def refund_wallet_endpoint(
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Refund failed")
|
||||
|
||||
await session.delete(key)
|
||||
await _refund_cache_set(bearer_value, result)
|
||||
|
||||
key.balance = 0
|
||||
key.reserved_balance = 0
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/donate")
|
||||
async def donate(token: str, ref: str | None = None) -> str:
|
||||
try:
|
||||
amount, unit, _ = await recieve_token(token)
|
||||
if ref:
|
||||
logger.info(
|
||||
"donation received", extra={"ref": ref, "amount": amount, "unit": unit}
|
||||
)
|
||||
return "Thanks!"
|
||||
except Exception:
|
||||
return "Invalid token."
|
||||
|
||||
|
||||
class ChildKeyRequest(BaseModel):
|
||||
count: int
|
||||
balance_limit: int | None = None
|
||||
balance_limit_reset: str | None = None
|
||||
validity_date: int | None = None
|
||||
|
||||
|
||||
@router.post("/child-key")
|
||||
async def create_child_key(
|
||||
payload: ChildKeyRequest,
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Creates one or more child API keys that use the parent's balance."""
|
||||
# Log incoming request for debugging
|
||||
logger.debug(f"Child key creation request: count={payload.count}")
|
||||
|
||||
count = payload.count
|
||||
if count < 1 or count > 50:
|
||||
raise HTTPException(status_code=400, detail="Count must be between 1 and 50.")
|
||||
|
||||
# Check if this is already a child key
|
||||
if key.parent_key_hash:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot create a child key for another child key.",
|
||||
)
|
||||
|
||||
cost_per_key = settings.child_key_cost
|
||||
total_cost = cost_per_key * count
|
||||
|
||||
if key.total_balance < total_cost:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
|
||||
)
|
||||
|
||||
# Deduct cost from parent
|
||||
key.balance -= total_cost
|
||||
key.total_spent += total_cost
|
||||
session.add(key)
|
||||
|
||||
# Generate new keys
|
||||
import secrets
|
||||
|
||||
new_keys = []
|
||||
for _ in range(count):
|
||||
new_key_raw = secrets.token_hex(32)
|
||||
new_key_hash = new_key_raw # We use the raw key as the hash for sk- keys
|
||||
|
||||
child_key = ApiKey(
|
||||
hashed_key=new_key_hash,
|
||||
balance=0,
|
||||
parent_key_hash=key.hashed_key,
|
||||
balance_limit=payload.balance_limit,
|
||||
balance_limit_reset=payload.balance_limit_reset,
|
||||
balance_limit_reset_date=int(time.time())
|
||||
if payload.balance_limit_reset
|
||||
else None,
|
||||
validity_date=payload.validity_date,
|
||||
)
|
||||
session.add(child_key)
|
||||
new_keys.append("sk-" + new_key_hash)
|
||||
|
||||
await session.commit()
|
||||
|
||||
response_data = {
|
||||
"api_keys": new_keys,
|
||||
"count": count,
|
||||
"cost_msats": total_cost,
|
||||
"cost_sats": total_cost // 1000,
|
||||
"parent_balance": key.balance,
|
||||
"parent_balance_sats": key.balance // 1000,
|
||||
}
|
||||
logger.debug(f"Child key creation response: {response_data}")
|
||||
return response_data
|
||||
|
||||
|
||||
class ChildKeyResetRequest(BaseModel):
|
||||
child_key: str
|
||||
|
||||
|
||||
@router.post("/child-key/reset")
|
||||
async def reset_child_key_spent(
|
||||
payload: ChildKeyResetRequest,
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Resets the total_spent of a child key. Must be called by the parent."""
|
||||
child_key_raw = payload.child_key
|
||||
if child_key_raw.startswith("sk-"):
|
||||
child_key_raw = child_key_raw[3:]
|
||||
|
||||
child_key = await session.get(ApiKey, child_key_raw)
|
||||
if not child_key:
|
||||
raise HTTPException(status_code=404, detail="Child key not found.")
|
||||
|
||||
if child_key.parent_key_hash != key.hashed_key:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Unauthorized. You are not the parent of this key."
|
||||
)
|
||||
|
||||
child_key.total_spent = 0
|
||||
if child_key.balance_limit_reset:
|
||||
child_key.balance_limit_reset_date = int(time.time())
|
||||
session.add(child_key)
|
||||
await session.commit()
|
||||
|
||||
return {"success": True, "message": "Child key balance reset successfully."}
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE"],
|
||||
@@ -159,6 +412,8 @@ async def wallet_catch_all(path: str) -> NoReturn:
|
||||
)
|
||||
|
||||
|
||||
balance_router.include_router(lightning_router)
|
||||
balance_router.include_router(router)
|
||||
|
||||
deprecated_wallet_router = APIRouter(prefix="/v1/wallet", include_in_schema=False)
|
||||
deprecated_wallet_router.include_router(router)
|
||||
|
||||
+1024
-637
File diff suppressed because it is too large
Load Diff
+148
-5
@@ -1,11 +1,15 @@
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||
from sqlmodel import Field, SQLModel, func, select
|
||||
from sqlmodel import Field, Relationship, SQLModel, func, select, update
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .logging import get_logger
|
||||
@@ -46,12 +50,110 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
default=None,
|
||||
description="Currency of the cashu-token",
|
||||
)
|
||||
parent_key_hash: str | None = Field(
|
||||
default=None, foreign_key="api_keys.hashed_key", index=True
|
||||
)
|
||||
balance_limit: int | None = Field(
|
||||
default=None,
|
||||
description="Max spendable balance in msats for this key (mostly for child keys)",
|
||||
)
|
||||
balance_limit_reset: str | None = Field(
|
||||
default=None,
|
||||
description="Reset policy for balance limit (manual, daily, monthly, etc.)",
|
||||
)
|
||||
balance_limit_reset_date: int | None = Field(
|
||||
default=None,
|
||||
description="Unix timestamp of the last time the balance limit was reset",
|
||||
)
|
||||
validity_date: int | None = Field(
|
||||
default=None,
|
||||
description="Unix timestamp after which the key is no longer valid",
|
||||
)
|
||||
|
||||
@property
|
||||
def total_balance(self) -> int:
|
||||
return self.balance - self.reserved_balance
|
||||
|
||||
|
||||
async def reset_all_reserved_balances(session: AsyncSession) -> None:
|
||||
logger.info("Resetting all reserved balances to 0")
|
||||
stmt = update(ApiKey).values(reserved_balance=0)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
logger.info("Reserved balances reset successfully")
|
||||
|
||||
|
||||
class ModelRow(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "models"
|
||||
id: str = Field(primary_key=True)
|
||||
upstream_provider_id: int = Field(
|
||||
primary_key=True, foreign_key="upstream_providers.id", ondelete="CASCADE"
|
||||
)
|
||||
name: str = Field()
|
||||
created: int = Field()
|
||||
description: str = Field()
|
||||
context_length: int = Field()
|
||||
architecture: str = Field()
|
||||
pricing: str = Field()
|
||||
sats_pricing: str | None = Field(default=None)
|
||||
per_request_limits: str | None = Field(default=None)
|
||||
top_provider: str | None = Field(default=None)
|
||||
canonical_slug: str | None = Field(default=None, description="Canonical model slug")
|
||||
alias_ids: str | None = Field(
|
||||
default=None, description="JSON array of model alias IDs"
|
||||
)
|
||||
enabled: bool = Field(default=True, description="Whether this model is enabled")
|
||||
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
|
||||
|
||||
|
||||
class LightningInvoice(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "lightning_invoices"
|
||||
|
||||
id: str = Field(primary_key=True, description="Unique invoice identifier")
|
||||
bolt11: str = Field(description="BOLT11 invoice string", unique=True)
|
||||
amount_sats: int = Field(description="Amount in satoshis")
|
||||
description: str = Field(description="Invoice description")
|
||||
payment_hash: str = Field(description="Payment hash for tracking", unique=True)
|
||||
status: str = Field(
|
||||
default="pending", description="pending, paid, expired, cancelled"
|
||||
)
|
||||
api_key_hash: str | None = Field(
|
||||
default=None, description="Associated API key hash for topup operations"
|
||||
)
|
||||
purpose: str = Field(description="create or topup")
|
||||
created_at: int = Field(
|
||||
default_factory=lambda: int(time.time()), description="Unix timestamp"
|
||||
)
|
||||
expires_at: int = Field(description="Unix timestamp when invoice expires")
|
||||
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
|
||||
|
||||
|
||||
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "upstream_providers"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"base_url", "api_key", name="uq_upstream_providers_base_url_api_key"
|
||||
),
|
||||
)
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
provider_type: str = Field(
|
||||
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
|
||||
)
|
||||
base_url: str = Field(description="Base URL of the upstream API")
|
||||
api_key: str = Field(description="API key for the upstream provider")
|
||||
api_version: str | None = Field(
|
||||
default=None, description="API version for Azure OpenAI"
|
||||
)
|
||||
enabled: bool = Field(default=True, description="Whether this provider is enabled")
|
||||
provider_fee: float = Field(
|
||||
default=1.01, description="Provider fee multiplier (default 1%)"
|
||||
)
|
||||
models: list["ModelRow"] = Relationship(
|
||||
back_populates="upstream_provider",
|
||||
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
|
||||
)
|
||||
|
||||
|
||||
async def balances_for_mint_and_unit(
|
||||
db_session: AsyncSession, mint_url: str, unit: str
|
||||
) -> int:
|
||||
@@ -65,6 +167,8 @@ async def balances_for_mint_and_unit(
|
||||
async def init_db() -> None:
|
||||
"""Initializes the database and creates tables if they don't exist."""
|
||||
async with engine.begin() as conn:
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
await conn.exec_driver_sql("PRAGMA journal_mode=WAL")
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
|
||||
@@ -79,12 +183,52 @@ async def create_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
yield session
|
||||
|
||||
|
||||
def fix_cashu_migrations() -> None:
|
||||
"""
|
||||
Fixes Cashu wallet migrations that are not idempotent.
|
||||
This specifically addresses the 'duplicate column name: public_keys' error
|
||||
in the keysets table of Cashu's internal SQLite databases.
|
||||
"""
|
||||
project_root = pathlib.Path(__file__).resolve().parents[2]
|
||||
wallet_dir = project_root / ".wallet"
|
||||
|
||||
if not wallet_dir.exists() or not wallet_dir.is_dir():
|
||||
return
|
||||
|
||||
logger.info("Checking Cashu wallet databases for migration idempotency")
|
||||
|
||||
for db_file in wallet_dir.glob("*.sqlite3"):
|
||||
try:
|
||||
conn = sqlite3.connect(db_file)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if keysets table exists
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='keysets'"
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
conn.close()
|
||||
continue
|
||||
|
||||
# Check if public_keys column exists
|
||||
cursor.execute("PRAGMA table_info(keysets)")
|
||||
columns = [info[1] for info in cursor.fetchall()]
|
||||
|
||||
if "public_keys" not in columns:
|
||||
logger.info(f"Adding missing public_keys column to {db_file.name}")
|
||||
cursor.execute("ALTER TABLE keysets ADD COLUMN public_keys TEXT")
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not check/fix Cashu database {db_file}: {e}")
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
"""Run Alembic migrations programmatically."""
|
||||
import pathlib
|
||||
|
||||
try:
|
||||
logger.info("Starting database migrations")
|
||||
# Run Cashu migration fix first
|
||||
fix_cashu_migrations()
|
||||
|
||||
# Get the path to the alembic.ini file
|
||||
project_root = pathlib.Path(__file__).resolve().parents[2]
|
||||
@@ -102,7 +246,6 @@ def run_migrations() -> None:
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
# Run migrations to the latest revision
|
||||
logger.info("Running migrations to latest revision")
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
logger.info("Database migrations completed successfully")
|
||||
|
||||
@@ -6,6 +6,15 @@ from .logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UpstreamError(Exception):
|
||||
"""Exception raised when an upstream provider fails."""
|
||||
|
||||
def __init__(self, message: str, status_code: int = 502):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Handle HTTP exceptions and include request ID in response."""
|
||||
request_id = getattr(request.state, "request_id", "unknown")
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
from .logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LogManager:
|
||||
def __init__(self, logs_dir: Path = Path("logs")):
|
||||
self.logs_dir = logs_dir
|
||||
|
||||
def _yield_log_entries(
|
||||
self,
|
||||
hours_back: int | None = None,
|
||||
specific_date: str | None = None,
|
||||
reverse_files: bool = False,
|
||||
max_files: int | None = None,
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""
|
||||
Yields log entries from files.
|
||||
|
||||
Args:
|
||||
hours_back: specific number of hours to look back.
|
||||
specific_date: specific date string (YYYY-MM-DD) to look at.
|
||||
reverse_files: if True, process files in reverse order (newest first).
|
||||
max_files: maximum number of log files to process (most recent if reverse_files is True).
|
||||
"""
|
||||
if not self.logs_dir.exists():
|
||||
return
|
||||
|
||||
log_files = []
|
||||
cutoff_date = None
|
||||
|
||||
if specific_date:
|
||||
log_file = self.logs_dir / f"app_{specific_date}.log"
|
||||
if log_file.exists():
|
||||
log_files.append(log_file)
|
||||
else:
|
||||
log_files = sorted(self.logs_dir.glob("app_*.log"))
|
||||
if reverse_files:
|
||||
log_files.reverse()
|
||||
|
||||
# If we only care about hours back, we can optimize file selection
|
||||
if hours_back is not None:
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(hours=hours_back)
|
||||
filtered_files = []
|
||||
for log_path in log_files:
|
||||
try:
|
||||
file_date_str = log_path.stem.split("_")[1]
|
||||
file_date = datetime.strptime(
|
||||
file_date_str, "%Y-%m-%d"
|
||||
).replace(tzinfo=timezone.utc)
|
||||
# Include file if it's from the same day or after the cutoff day
|
||||
if file_date >= cutoff_date.replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
):
|
||||
filtered_files.append(log_path)
|
||||
except Exception:
|
||||
continue
|
||||
log_files = filtered_files
|
||||
|
||||
if max_files is not None and len(log_files) > max_files:
|
||||
log_files = log_files[:max_files]
|
||||
|
||||
for log_file in log_files:
|
||||
try:
|
||||
with open(log_file, "r") as f:
|
||||
# For reverse search, we might want to read lines in reverse?
|
||||
# But usually logs are append-only.
|
||||
# If reverse_files is True, we iterate files newest to oldest.
|
||||
# But lines within file are still oldest to newest unless we reverse them.
|
||||
lines = f.readlines()
|
||||
if reverse_files:
|
||||
lines.reverse()
|
||||
|
||||
for line in lines:
|
||||
try:
|
||||
entry = json.loads(line.strip())
|
||||
|
||||
if cutoff_date:
|
||||
timestamp_str = entry.get("asctime", "")
|
||||
if not timestamp_str:
|
||||
continue
|
||||
log_time = datetime.strptime(
|
||||
timestamp_str, "%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
log_time = log_time.replace(tzinfo=timezone.utc)
|
||||
if log_time < cutoff_date:
|
||||
continue
|
||||
|
||||
yield entry
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing log file {log_file}: {e}")
|
||||
continue
|
||||
|
||||
def search_logs(
|
||||
self,
|
||||
date: str | None = None,
|
||||
level: str | None = None,
|
||||
request_id: str | None = None,
|
||||
search_text: str | None = None,
|
||||
status_codes: list[int] | None = None,
|
||||
methods: list[str] | None = None,
|
||||
endpoints: list[str] | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Search through log files and return matching entries.
|
||||
"""
|
||||
log_entries: list[dict[str, Any]] = []
|
||||
|
||||
# Use reverse=True to get newest logs first by default
|
||||
# If date is specified, we only look at that file
|
||||
|
||||
search_text_lower = search_text.lower() if search_text else None
|
||||
|
||||
# We iterate efficiently
|
||||
iterator = self._yield_log_entries(
|
||||
specific_date=date,
|
||||
reverse_files=True if not date else False,
|
||||
max_files=7 if not date else None,
|
||||
)
|
||||
|
||||
# If we are searching globally (no date), we might want to limit how far back we go?
|
||||
# PR 228 did: "glob("app_*.log") sorted by mtime reverse [:7]" (last 7 files)
|
||||
# My _yield_log_entries with reverse_files=True does all files.
|
||||
# Let's rely on limit to stop us.
|
||||
|
||||
# Optimization: if we are not searching by date, maybe limit to last 7 files inside _yield?
|
||||
# For now, let's just iterate.
|
||||
|
||||
for log_data in iterator:
|
||||
if not self._matches_filters(
|
||||
log_data,
|
||||
level,
|
||||
request_id,
|
||||
search_text_lower,
|
||||
status_codes,
|
||||
methods,
|
||||
endpoints,
|
||||
):
|
||||
continue
|
||||
|
||||
log_entries.append(log_data)
|
||||
|
||||
if len(log_entries) >= limit:
|
||||
break
|
||||
|
||||
# Sort by time descending (newest first)
|
||||
log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=True)
|
||||
return log_entries
|
||||
|
||||
def _matches_filters(
|
||||
self,
|
||||
log_data: dict[str, Any],
|
||||
level: str | None,
|
||||
request_id: str | None,
|
||||
search_text_lower: str | None,
|
||||
status_codes: list[int] | None = None,
|
||||
methods: list[str] | None = None,
|
||||
endpoints: list[str] | None = None,
|
||||
) -> bool:
|
||||
if level and log_data.get("levelname", "").upper() != level.upper():
|
||||
return False
|
||||
|
||||
if request_id and log_data.get("request_id") != request_id:
|
||||
return False
|
||||
|
||||
if status_codes:
|
||||
entry_status = log_data.get("status_code")
|
||||
if entry_status is not None:
|
||||
try:
|
||||
if int(entry_status) not in status_codes:
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
if methods:
|
||||
entry_method = log_data.get("method", "").upper()
|
||||
if entry_method not in [m.upper() for m in methods]:
|
||||
return False
|
||||
|
||||
if endpoints:
|
||||
entry_path = log_data.get("path", "")
|
||||
matched = False
|
||||
for endpoint in endpoints:
|
||||
clean_endpoint = endpoint.lstrip("/")
|
||||
if entry_path.startswith(clean_endpoint):
|
||||
matched = True
|
||||
break
|
||||
if clean_endpoint in entry_path:
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
return False
|
||||
|
||||
if search_text_lower:
|
||||
message = str(log_data.get("message", "")).lower()
|
||||
name = str(log_data.get("name", "")).lower()
|
||||
pathname = str(log_data.get("pathname", "")).lower()
|
||||
|
||||
if (
|
||||
search_text_lower not in message
|
||||
and search_text_lower not in name
|
||||
and search_text_lower not in pathname
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_usage_summary(self, hours: int = 24) -> dict:
|
||||
entries = list(self._yield_log_entries(hours_back=hours))
|
||||
return self._calculate_summary_stats(entries)
|
||||
|
||||
def get_usage_metrics(self, interval: int = 15, hours: int = 24) -> dict:
|
||||
entries = list(self._yield_log_entries(hours_back=hours))
|
||||
return self._aggregate_metrics_by_time(entries, interval, hours)
|
||||
|
||||
def get_error_details(self, hours: int = 24, limit: int = 100) -> dict:
|
||||
errors: list[dict] = []
|
||||
# Iterate newest to oldest for errors?
|
||||
# yield_log_entries sorts files by name (date) ascending by default.
|
||||
# usage stats logic usually expects ascending time for aggregation (though dictionaries don't care).
|
||||
# For error details "last N errors", we probably want newest first.
|
||||
|
||||
# Using list() loads everything into memory, which is what PR 229 did.
|
||||
# For optimization, we could use reverse iterator.
|
||||
|
||||
# Let's just stick to PR 229 logic which filters 'ERROR' level.
|
||||
|
||||
entries = self._yield_log_entries(hours_back=hours) # oldest to newest
|
||||
|
||||
for entry in entries:
|
||||
if entry.get("levelname", "").upper() == "ERROR":
|
||||
timestamp_str = entry.get("asctime", "")
|
||||
errors.append(
|
||||
{
|
||||
"timestamp": timestamp_str,
|
||||
"message": entry.get("message", ""),
|
||||
"error_type": entry.get("error_type", "unknown"),
|
||||
"pathname": entry.get("pathname", ""),
|
||||
"lineno": entry.get("lineno", 0),
|
||||
"request_id": entry.get("request_id", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort reverse time
|
||||
errors.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||
return {"errors": errors[:limit], "total_count": len(errors)}
|
||||
|
||||
def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict:
|
||||
entries = list(self._yield_log_entries(hours_back=hours))
|
||||
|
||||
model_stats: dict[str, dict[str, int | float]] = defaultdict(
|
||||
lambda: {
|
||||
"revenue_msats": 0,
|
||||
"refunds_msats": 0,
|
||||
"requests": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
)
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
model = entry.get("model", "unknown")
|
||||
if not isinstance(model, str):
|
||||
model = "unknown"
|
||||
|
||||
message = entry.get("message", "").lower()
|
||||
|
||||
if "received proxy request" in message:
|
||||
model_stats[model]["requests"] += 1
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
model_stats[model]["successful"] += 1
|
||||
cost_data = entry.get("cost_data")
|
||||
if isinstance(cost_data, dict):
|
||||
actual_cost = cost_data.get("total_msats", 0)
|
||||
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
|
||||
model_stats[model]["revenue_msats"] += actual_cost
|
||||
|
||||
if "revert payment" in message or "upstream request failed" in message:
|
||||
model_stats[model]["failed"] += 1
|
||||
if "revert payment" in message:
|
||||
max_cost = entry.get("max_cost_for_model", 0)
|
||||
if isinstance(max_cost, (int, float)) and max_cost > 0:
|
||||
model_stats[model]["refunds_msats"] += max_cost
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
models: list[dict[str, Any]] = []
|
||||
total_revenue = 0.0
|
||||
|
||||
for model, stats in model_stats.items():
|
||||
revenue_msats = float(stats["revenue_msats"])
|
||||
refunds_msats = float(stats["refunds_msats"])
|
||||
|
||||
revenue_sats = revenue_msats / 1000
|
||||
refunds_sats = refunds_msats / 1000
|
||||
net_revenue_sats = revenue_sats - refunds_sats
|
||||
|
||||
total_revenue += net_revenue_sats
|
||||
|
||||
requests = int(stats["requests"])
|
||||
successful = int(stats["successful"])
|
||||
|
||||
models.append(
|
||||
{
|
||||
"model": model,
|
||||
"revenue_sats": revenue_sats,
|
||||
"refunds_sats": refunds_sats,
|
||||
"net_revenue_sats": net_revenue_sats,
|
||||
"requests": requests,
|
||||
"successful": successful,
|
||||
"failed": int(stats["failed"]),
|
||||
"avg_revenue_per_request": (
|
||||
revenue_sats / successful if successful > 0 else 0
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
models.sort(key=lambda x: float(x["net_revenue_sats"]), reverse=True)
|
||||
|
||||
return {
|
||||
"models": models[:limit],
|
||||
"total_revenue_sats": total_revenue,
|
||||
"total_models": len(models),
|
||||
}
|
||||
|
||||
def _calculate_summary_stats(self, entries: list[dict]) -> dict:
|
||||
stats: dict[str, Any] = {
|
||||
"total_entries": 0,
|
||||
"total_requests": 0,
|
||||
"successful_chat_completions": 0,
|
||||
"failed_requests": 0,
|
||||
"total_errors": 0,
|
||||
"total_warnings": 0,
|
||||
"payment_processed": 0,
|
||||
"upstream_errors": 0,
|
||||
"unique_models": set(),
|
||||
"error_types": defaultdict(int),
|
||||
"revenue_msats": 0.0,
|
||||
"refunds_msats": 0.0,
|
||||
}
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
stats["total_entries"] += 1
|
||||
|
||||
message = entry.get("message", "").lower()
|
||||
level = entry.get("levelname", "").upper()
|
||||
|
||||
if level == "ERROR":
|
||||
stats["total_errors"] += 1
|
||||
if "error_type" in entry:
|
||||
stats["error_types"][str(entry["error_type"])] += 1
|
||||
elif level == "WARNING":
|
||||
stats["total_warnings"] += 1
|
||||
|
||||
if "received proxy request" in message:
|
||||
stats["total_requests"] += 1
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
stats["successful_chat_completions"] += 1
|
||||
|
||||
if "upstream request failed" in message or "revert payment" in message:
|
||||
stats["failed_requests"] += 1
|
||||
|
||||
if "payment processed successfully" in message:
|
||||
stats["payment_processed"] += 1
|
||||
|
||||
if "upstream" in message and level == "ERROR":
|
||||
stats["upstream_errors"] += 1
|
||||
|
||||
if "model" in entry:
|
||||
model = entry["model"]
|
||||
if isinstance(model, str) and model != "unknown":
|
||||
stats["unique_models"].add(model)
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
cost_data = entry.get("cost_data")
|
||||
if isinstance(cost_data, dict):
|
||||
actual_cost = cost_data.get("total_msats", 0)
|
||||
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
|
||||
stats["revenue_msats"] += float(actual_cost)
|
||||
|
||||
if "revert payment" in message:
|
||||
max_cost = entry.get("max_cost_for_model", 0)
|
||||
if isinstance(max_cost, (int, float)) and max_cost > 0:
|
||||
stats["refunds_msats"] += float(max_cost)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
revenue_sats = stats["revenue_msats"] / 1000
|
||||
refunds_sats = stats["refunds_msats"] / 1000
|
||||
net_revenue_sats = revenue_sats - refunds_sats
|
||||
|
||||
total_requests = stats["total_requests"]
|
||||
successful = stats["successful_chat_completions"]
|
||||
|
||||
return {
|
||||
"total_entries": stats["total_entries"],
|
||||
"total_requests": total_requests,
|
||||
"successful_chat_completions": successful,
|
||||
"failed_requests": stats["failed_requests"],
|
||||
"total_errors": stats["total_errors"],
|
||||
"total_warnings": stats["total_warnings"],
|
||||
"payment_processed": stats["payment_processed"],
|
||||
"upstream_errors": stats["upstream_errors"],
|
||||
"unique_models_count": len(stats["unique_models"]),
|
||||
"unique_models": sorted(list(stats["unique_models"])),
|
||||
"error_types": dict(stats["error_types"]),
|
||||
"success_rate": (successful / total_requests * 100)
|
||||
if total_requests > 0
|
||||
else 0,
|
||||
"revenue_msats": stats["revenue_msats"],
|
||||
"refunds_msats": stats["refunds_msats"],
|
||||
"revenue_sats": revenue_sats,
|
||||
"refunds_sats": refunds_sats,
|
||||
"net_revenue_msats": stats["revenue_msats"] - stats["refunds_msats"],
|
||||
"net_revenue_sats": net_revenue_sats,
|
||||
"avg_revenue_per_request_msats": (
|
||||
stats["revenue_msats"] / successful if successful > 0 else 0
|
||||
),
|
||||
"refund_rate": (
|
||||
(stats["failed_requests"] / total_requests * 100)
|
||||
if total_requests > 0
|
||||
else 0
|
||||
),
|
||||
}
|
||||
|
||||
def _aggregate_metrics_by_time(
|
||||
self, entries: list[dict], interval_minutes: int, hours_back: int
|
||||
) -> dict:
|
||||
time_buckets: dict[str, dict[str, Any]] = defaultdict(
|
||||
lambda: {"requests": 0, "errors": 0, "revenue_msats": 0.0}
|
||||
)
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
timestamp_str = entry.get("asctime", "")
|
||||
if not timestamp_str:
|
||||
continue
|
||||
|
||||
log_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
|
||||
log_time = log_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Round down to nearest interval
|
||||
minutes = log_time.minute
|
||||
rounded_minutes = (minutes // interval_minutes) * interval_minutes
|
||||
bucket_time = log_time.replace(
|
||||
minute=rounded_minutes, second=0, microsecond=0
|
||||
)
|
||||
bucket_key = bucket_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
bucket = time_buckets[bucket_key]
|
||||
|
||||
message = entry.get("message", "").lower()
|
||||
level = entry.get("levelname", "").upper()
|
||||
|
||||
if "received proxy request" in message:
|
||||
bucket["requests"] += 1
|
||||
|
||||
if level == "ERROR":
|
||||
bucket["errors"] += 1
|
||||
|
||||
if (
|
||||
"completed for streaming" in message
|
||||
or "completed for non-streaming" in message
|
||||
):
|
||||
cost_data = entry.get("cost_data")
|
||||
if isinstance(cost_data, dict):
|
||||
actual_cost = cost_data.get("total_msats", 0)
|
||||
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
|
||||
bucket["revenue_msats"] += float(actual_cost)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
result = []
|
||||
for bucket_key in sorted(time_buckets.keys()):
|
||||
result.append({"timestamp": bucket_key, **time_buckets[bucket_key]})
|
||||
|
||||
return {
|
||||
"metrics": result,
|
||||
"interval_minutes": interval_minutes,
|
||||
"hours_back": hours_back,
|
||||
"total_buckets": len(result),
|
||||
}
|
||||
|
||||
|
||||
log_manager = LogManager()
|
||||
+83
-18
@@ -1,3 +1,40 @@
|
||||
"""
|
||||
Logging configuration for Routstr.
|
||||
|
||||
CRITICAL LOG MESSAGES FOR USAGE STATISTICS:
|
||||
===========================================
|
||||
The following log messages are parsed by the usage tracking system (routstr/core/admin.py).
|
||||
DO NOT modify or remove these messages without updating the usage tracking logic:
|
||||
|
||||
1. "Received proxy request" (INFO) - routstr/proxy.py
|
||||
- Used to count total incoming requests
|
||||
- Includes model information in context
|
||||
|
||||
2. "Payment adjustment completed for streaming" (INFO) - routstr/upstream/base.py
|
||||
"Payment adjustment completed for non-streaming" (INFO) - routstr/upstream/base.py
|
||||
- Used to track successful completions and revenue
|
||||
- The 'cost_data.total_msats' field is extracted for revenue calculation
|
||||
- Must include 'cost_data' in extra dict
|
||||
|
||||
3. "Payment processed successfully" (INFO) - routstr/auth.py
|
||||
- Used to count successful payment processing events
|
||||
- Tracks payment-related metrics
|
||||
|
||||
4. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py
|
||||
- Used to track failed requests and refunds
|
||||
- The 'max_cost_for_model' field is extracted for refund calculation
|
||||
- Must include 'max_cost_for_model' in extra dict
|
||||
|
||||
5. Any ERROR level logs with "upstream" in the message
|
||||
- Used to count upstream provider errors
|
||||
- Helps identify service reliability issues
|
||||
|
||||
If you need to modify these messages, ensure you also update the parsing logic in:
|
||||
- routstr/core/admin.py:_aggregate_metrics_by_time()
|
||||
- routstr/core/admin.py:_get_summary_stats()
|
||||
- routstr/core/admin.py:get_revenue_by_model()
|
||||
"""
|
||||
|
||||
import logging.config
|
||||
import logging.handlers
|
||||
import os
|
||||
@@ -155,21 +192,24 @@ class SecurityFilter(logging.Filter):
|
||||
"""Filter out sensitive information from log records."""
|
||||
try:
|
||||
message = record.getMessage()
|
||||
standalone_patterns = [
|
||||
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
|
||||
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
|
||||
r"nsec[a-z0-9]+", # Nostr Public / Private Key
|
||||
]
|
||||
for pattern in standalone_patterns:
|
||||
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
|
||||
|
||||
for key in self.SENSITIVE_KEYS:
|
||||
if key in message.lower():
|
||||
patterns = [
|
||||
rf"{key}[:\s=]+([a-zA-Z0-9_\-\.]+)", # key: value or key=value
|
||||
rf'{key}[:\s=]+["\']([^"\']+)["\']', # key: "value" or key='value'
|
||||
r"Bearer\s+([a-zA-Z0-9_\-\.]+)", # Bearer token
|
||||
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
|
||||
key_patterns = [
|
||||
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
|
||||
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
for pattern in key_patterns:
|
||||
message = re.sub(
|
||||
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
record.msg = message
|
||||
record.args = ()
|
||||
|
||||
@@ -181,7 +221,12 @@ class SecurityFilter(logging.Filter):
|
||||
|
||||
def get_log_level() -> str:
|
||||
"""Get log level from environment variable."""
|
||||
level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||
try:
|
||||
from .settings import settings
|
||||
|
||||
level = settings.log_level.upper()
|
||||
except Exception:
|
||||
level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||
# Validate log level - if invalid, default to INFO
|
||||
valid_levels = {"TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||
if level not in valid_levels:
|
||||
@@ -191,11 +236,16 @@ def get_log_level() -> str:
|
||||
|
||||
def should_enable_console_logging() -> bool:
|
||||
"""Check if console logging should be enabled."""
|
||||
return os.environ.get("ENABLE_CONSOLE_LOGGING", "true").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
try:
|
||||
from .settings import settings
|
||||
|
||||
return bool(settings.enable_console_logging)
|
||||
except Exception:
|
||||
return os.environ.get("ENABLE_CONSOLE_LOGGING", "true").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
@@ -288,23 +338,38 @@ def setup_logging() -> None:
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
"openai": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
"httpcore": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
"websockets": {
|
||||
"level": "WARNING",
|
||||
"handlers": [],
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.access": {
|
||||
"level": log_level, # Use the configured log level instead of WARNING
|
||||
"handlers": handlers, # Use both console and file handlers
|
||||
"level": "WARNING",
|
||||
"handlers": ["file"],
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.error": {
|
||||
"level": log_level, # Use the configured log level
|
||||
"handlers": handlers, # Use both console and file handlers
|
||||
"level": log_level,
|
||||
"handlers": handlers,
|
||||
"propagate": False,
|
||||
},
|
||||
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
|
||||
"aiosqlite": {"level": "ERROR", "handlers": [], "propagate": False},
|
||||
"alembic": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"level": log_level,
|
||||
|
||||
+246
-29
@@ -1,54 +1,115 @@
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from ..auth import periodic_key_reset
|
||||
from ..balance import balance_router, deprecated_wallet_router
|
||||
from ..discovery import providers_router
|
||||
from ..payment.models import MODELS, models_router, update_sats_pricing
|
||||
from ..proxy import proxy_router
|
||||
from ..nostr import announce_provider, providers_cache_refresher
|
||||
from ..nostr.discovery import providers_router
|
||||
from ..payment.models import models_router, update_sats_pricing
|
||||
from ..payment.price import update_prices_periodically
|
||||
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
||||
from ..wallet import periodic_payout
|
||||
from .admin import admin_router
|
||||
from .db import init_db, run_migrations
|
||||
from .db import create_session, init_db, run_migrations
|
||||
from .exceptions import general_exception_handler, http_exception_handler
|
||||
from .logging import get_logger, setup_logging
|
||||
from .middleware import LoggingMiddleware
|
||||
from .settings import SettingsService
|
||||
from .settings import settings as global_settings
|
||||
|
||||
# Initialize logging first
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__version__ = "0.1.1b"
|
||||
if os.getenv("VERSION_SUFFIX") is not None:
|
||||
__version__ = f"0.3.0-{os.getenv('VERSION_SUFFIX')}"
|
||||
else:
|
||||
__version__ = "0.3.0"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logger.info("Application startup initiated", extra={"version": __version__})
|
||||
|
||||
btc_price_task = None
|
||||
pricing_task = None
|
||||
payout_task = None
|
||||
nip91_task = None
|
||||
providers_task = None
|
||||
models_refresh_task = None
|
||||
model_maps_refresh_task = None
|
||||
key_reset_task = None
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
# This ensures the database schema is always up-to-date in production
|
||||
# Migrations are idempotent - running them multiple times is safe
|
||||
logger.info("Running database migrations")
|
||||
run_migrations()
|
||||
|
||||
# Initialize database connection pools
|
||||
# This creates any tables that might not be tracked by migrations yet
|
||||
await init_db()
|
||||
|
||||
# Initialize application settings (env -> computed -> DB precedence)
|
||||
async with create_session() as session:
|
||||
s = await SettingsService.initialize(session)
|
||||
if s.reset_reserved_balance_on_startup:
|
||||
from .db import reset_all_reserved_balances
|
||||
|
||||
await reset_all_reserved_balances(session)
|
||||
|
||||
if not s.admin_password:
|
||||
logger.warning(
|
||||
f"Admin password is not set. Visit {s.http_url or 'http://localhost:8000'}/admin to set the password."
|
||||
)
|
||||
|
||||
# Apply app metadata from settings
|
||||
try:
|
||||
app.title = s.name
|
||||
app.description = s.description
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# await ensure_models_bootstrapped()
|
||||
|
||||
from ..payment.price import _update_prices
|
||||
from ..proxy import get_upstreams
|
||||
from ..upstream.helpers import refresh_upstreams_models_periodically
|
||||
|
||||
_update_prices_task = asyncio.create_task(_update_prices())
|
||||
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
|
||||
|
||||
# ensure both setup tasks complete
|
||||
await asyncio.gather(
|
||||
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
|
||||
)
|
||||
|
||||
btc_price_task = asyncio.create_task(update_prices_periodically())
|
||||
pricing_task = asyncio.create_task(update_sats_pricing())
|
||||
if global_settings.models_refresh_interval_seconds > 0:
|
||||
models_refresh_task = asyncio.create_task(
|
||||
refresh_upstreams_models_periodically(get_upstreams())
|
||||
)
|
||||
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
|
||||
payout_task = asyncio.create_task(periodic_payout())
|
||||
if global_settings.nsec:
|
||||
nip91_task = asyncio.create_task(announce_provider())
|
||||
if global_settings.providers_refresh_interval_seconds > 0:
|
||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||
key_reset_task = asyncio.create_task(periodic_key_reset())
|
||||
|
||||
yield
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Expected during shutdown
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Application startup failed",
|
||||
@@ -58,17 +119,41 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
finally:
|
||||
logger.info("Application shutdown initiated")
|
||||
|
||||
if btc_price_task is not None:
|
||||
btc_price_task.cancel()
|
||||
if pricing_task is not None:
|
||||
pricing_task.cancel()
|
||||
if payout_task is not None:
|
||||
payout_task.cancel()
|
||||
if nip91_task is not None:
|
||||
nip91_task.cancel()
|
||||
if providers_task is not None:
|
||||
providers_task.cancel()
|
||||
if models_refresh_task is not None:
|
||||
models_refresh_task.cancel()
|
||||
if model_maps_refresh_task is not None:
|
||||
model_maps_refresh_task.cancel()
|
||||
if key_reset_task is not None:
|
||||
key_reset_task.cancel()
|
||||
|
||||
try:
|
||||
tasks_to_wait = []
|
||||
if btc_price_task is not None:
|
||||
tasks_to_wait.append(btc_price_task)
|
||||
if pricing_task is not None:
|
||||
tasks_to_wait.append(pricing_task)
|
||||
if payout_task is not None:
|
||||
tasks_to_wait.append(payout_task)
|
||||
if nip91_task is not None:
|
||||
tasks_to_wait.append(nip91_task)
|
||||
if providers_task is not None:
|
||||
tasks_to_wait.append(providers_task)
|
||||
if models_refresh_task is not None:
|
||||
tasks_to_wait.append(models_refresh_task)
|
||||
if model_maps_refresh_task is not None:
|
||||
tasks_to_wait.append(model_maps_refresh_task)
|
||||
if key_reset_task is not None:
|
||||
tasks_to_wait.append(key_reset_task)
|
||||
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
@@ -80,18 +165,12 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
)
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
version=__version__,
|
||||
title=os.environ.get("NAME", "ARoutstrNode" + __version__),
|
||||
description=os.environ.get("DESCRIPTION", "A Routstr Node"),
|
||||
contact={"name": os.environ.get("NAME", ""), "npub": os.environ.get("NPUB", "")},
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app = FastAPI(version=__version__, lifespan=lifespan)
|
||||
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=os.environ.get("CORS_ORIGINS", "*").split(","),
|
||||
allow_origins=global_settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@@ -106,24 +185,162 @@ app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
@app.get("/v1/info")
|
||||
async def info() -> dict:
|
||||
return {
|
||||
"name": app.title,
|
||||
"description": app.description,
|
||||
"name": global_settings.name,
|
||||
"description": global_settings.description,
|
||||
"version": __version__,
|
||||
"npub": os.environ.get("NPUB", ""),
|
||||
"mints": os.environ.get("CASHU_MINTS", "").split(","),
|
||||
"http_url": os.environ.get("HTTP_URL", ""),
|
||||
"onion_url": os.environ.get("ONION_URL", ""),
|
||||
"models": MODELS,
|
||||
"npub": global_settings.npub,
|
||||
"mints": global_settings.cashu_mints,
|
||||
"http_url": global_settings.http_url,
|
||||
"onion_url": global_settings.onion_url,
|
||||
"child_key_cost_msats": global_settings.child_key_cost,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/admin")
|
||||
async def admin_redirect() -> RedirectResponse:
|
||||
return RedirectResponse("/admin/")
|
||||
@app.get("/v1/providers")
|
||||
async def providers() -> RedirectResponse:
|
||||
return RedirectResponse("/v1/providers/")
|
||||
|
||||
|
||||
UI_DIST_PATH = Path(__file__).parent.parent.parent / "ui_out"
|
||||
|
||||
if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
|
||||
logger.info(f"Serving static UI from {UI_DIST_PATH}")
|
||||
|
||||
app.mount(
|
||||
"/_next",
|
||||
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
|
||||
name="next-static",
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def serve_root_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
# Add explicit route for /index.txt to redirect to /
|
||||
@app.get("/index.txt", include_in_schema=False)
|
||||
async def redirect_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/")
|
||||
|
||||
@app.get("/admin")
|
||||
async def admin_redirect() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@app.get("/dashboard", include_in_schema=False)
|
||||
async def serve_dashboard_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@app.get("/login", include_in_schema=False)
|
||||
async def serve_login_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "login" / "index.html")
|
||||
|
||||
# Add explicit route for /login/index.txt to redirect to /login
|
||||
@app.get("/login/index.txt", include_in_schema=False)
|
||||
async def redirect_login_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/login")
|
||||
|
||||
@app.get("/model", include_in_schema=False)
|
||||
async def serve_models_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "model" / "index.html")
|
||||
|
||||
# Add explicit route for /model/index.txt to redirect to /model
|
||||
@app.get("/model/index.txt", include_in_schema=False)
|
||||
async def redirect_model_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/model")
|
||||
|
||||
@app.get("/providers", include_in_schema=False)
|
||||
async def serve_providers_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
|
||||
|
||||
# Add explicit route for /providers/index.txt to redirect to /providers
|
||||
@app.get("/providers/index.txt", include_in_schema=False)
|
||||
async def redirect_providers_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/providers")
|
||||
|
||||
@app.get("/settings", include_in_schema=False)
|
||||
async def serve_settings_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
|
||||
|
||||
# Add explicit route for /settings/index.txt to redirect to /settings
|
||||
@app.get("/settings/index.txt", include_in_schema=False)
|
||||
async def redirect_settings_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/settings")
|
||||
|
||||
@app.get("/transactions", include_in_schema=False)
|
||||
async def serve_transactions_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
|
||||
|
||||
# Add explicit route for /transactions/index.txt to redirect to /transactions
|
||||
@app.get("/transactions/index.txt", include_in_schema=False)
|
||||
async def redirect_transactions_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/transactions")
|
||||
|
||||
@app.get("/balances", include_in_schema=False)
|
||||
async def serve_balances_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "balances" / "index.html")
|
||||
|
||||
# Add explicit route for /balances/index.txt to redirect to /balances
|
||||
@app.get("/balances/index.txt", include_in_schema=False)
|
||||
async def redirect_balances_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/balances")
|
||||
|
||||
@app.get("/logs", include_in_schema=False)
|
||||
async def serve_logs_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "logs" / "index.html")
|
||||
|
||||
# Add explicit route for /logs/index.txt to redirect to /logs
|
||||
@app.get("/logs/index.txt", include_in_schema=False)
|
||||
async def redirect_logs_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/logs")
|
||||
|
||||
@app.get("/usage", include_in_schema=False)
|
||||
async def serve_usage_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
|
||||
|
||||
# Add explicit route for /usage/index.txt to redirect to /usage
|
||||
@app.get("/usage/index.txt", include_in_schema=False)
|
||||
async def redirect_usage_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/usage")
|
||||
|
||||
@app.get("/unauthorized", include_in_schema=False)
|
||||
async def serve_unauthorized_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
|
||||
|
||||
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
|
||||
@app.get("/unauthorized/index.txt", include_in_schema=False)
|
||||
async def redirect_unauthorized_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/unauthorized")
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def serve_favicon() -> FileResponse:
|
||||
icon_path = UI_DIST_PATH / "icon.ico"
|
||||
if icon_path.exists():
|
||||
return FileResponse(icon_path)
|
||||
return FileResponse(UI_DIST_PATH / "favicon.ico")
|
||||
|
||||
@app.get("/icon.ico", include_in_schema=False)
|
||||
async def serve_icon() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "icon.ico")
|
||||
|
||||
app.mount(
|
||||
"/static", StaticFiles(directory=UI_DIST_PATH, check_dir=True), name="ui-static"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root_fallback() -> dict:
|
||||
return {
|
||||
"name": global_settings.name,
|
||||
"description": global_settings.description,
|
||||
"version": __version__,
|
||||
"status": "running",
|
||||
"ui": "not available",
|
||||
}
|
||||
|
||||
|
||||
app.include_router(models_router)
|
||||
|
||||
@@ -55,7 +55,16 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
"headers": {
|
||||
k: v
|
||||
for k, v in request.headers.items()
|
||||
if k.lower() not in ["authorization", "x-cashu", "cookie"]
|
||||
if k.lower()
|
||||
not in [
|
||||
"authorization",
|
||||
"x-cashu",
|
||||
"cookie",
|
||||
"cf-connecting-ip",
|
||||
"cf-ipcountry",
|
||||
"x-forwarded-for",
|
||||
"x-real-ip",
|
||||
]
|
||||
},
|
||||
"body_size": len(request_body) if request_body else 0,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from pydantic.v1 import BaseModel, BaseSettings, Field
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
class Config:
|
||||
case_sensitive = True
|
||||
|
||||
@classmethod
|
||||
def parse_env_var(cls, field_name: str, raw_value: str) -> Any: # type: ignore[override]
|
||||
if field_name in {"cashu_mints", "cors_origins", "relays"}:
|
||||
v = str(raw_value).strip()
|
||||
if v == "":
|
||||
return []
|
||||
return [p.strip() for p in v.split(",") if p.strip()]
|
||||
return raw_value
|
||||
|
||||
# Core
|
||||
upstream_base_url: str = Field(default="", env="UPSTREAM_BASE_URL")
|
||||
upstream_api_key: str = Field(default="", env="UPSTREAM_API_KEY")
|
||||
admin_password: str = Field(default="", env="ADMIN_PASSWORD")
|
||||
|
||||
# Node info
|
||||
name: str = Field(default="ARoutstrNode", env="NAME")
|
||||
description: str = Field(default="A Routstr Node", env="DESCRIPTION")
|
||||
npub: str = Field(default="", env="NPUB")
|
||||
http_url: str = Field(default="", env="HTTP_URL")
|
||||
onion_url: str = Field(default="", env="ONION_URL")
|
||||
|
||||
# Cashu
|
||||
cashu_mints: list[str] = Field(default_factory=list, env="CASHU_MINTS")
|
||||
receive_ln_address: str = Field(default="", env="RECEIVE_LN_ADDRESS")
|
||||
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
|
||||
primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT")
|
||||
|
||||
# Pricing
|
||||
# Default behavior: derive pricing from MODELS
|
||||
# If fixed_pricing is True -> use fixed_cost_per_request and ignore tokens
|
||||
# If fixed_per_1k_* are set (non-zero) -> override model token pricing when model-based
|
||||
fixed_pricing: bool = Field(default=False, env="FIXED_PRICING")
|
||||
fixed_cost_per_request: int = Field(default=1, env="FIXED_COST_PER_REQUEST")
|
||||
fixed_per_1k_input_tokens: int = Field(default=0, env="FIXED_PER_1K_INPUT_TOKENS")
|
||||
fixed_per_1k_output_tokens: int = Field(default=0, env="FIXED_PER_1K_OUTPUT_TOKENS")
|
||||
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
|
||||
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
|
||||
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
|
||||
child_key_cost: int = Field(default=1000, env="CHILD_KEY_COST")
|
||||
# Minimum per-request charge in millisatoshis when model pricing is free/zero
|
||||
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
|
||||
reset_reserved_balance_on_startup: bool = Field(
|
||||
default=True, env="RESET_RESERVED_BALANCE_ON_STARTUP"
|
||||
) # deactivate in horizontal scaling setups
|
||||
|
||||
# Network
|
||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
|
||||
tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL")
|
||||
providers_refresh_interval_seconds: int = Field(
|
||||
default=0, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
|
||||
)
|
||||
pricing_refresh_interval_seconds: int = Field(
|
||||
default=120, env="PRICING_REFRESH_INTERVAL_SECONDS"
|
||||
)
|
||||
models_refresh_interval_seconds: int = Field(
|
||||
default=360, env="MODELS_REFRESH_INTERVAL_SECONDS"
|
||||
)
|
||||
enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH")
|
||||
enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH")
|
||||
refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS")
|
||||
|
||||
# Logging
|
||||
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
||||
enable_console_logging: bool = Field(default=True, env="ENABLE_CONSOLE_LOGGING")
|
||||
|
||||
# Other
|
||||
chat_completions_api_version: str = Field(
|
||||
default="", env="CHAT_COMPLETIONS_API_VERSION"
|
||||
)
|
||||
models_path: str = Field(default="models.json", env="MODELS_PATH")
|
||||
source: str = Field(default="", env="SOURCE")
|
||||
|
||||
# Secrets / optional runtime controls
|
||||
provider_id: str = Field(default="", env="PROVIDER_ID")
|
||||
nsec: str = Field(default="", env="NSEC")
|
||||
|
||||
# Discovery
|
||||
relays: list[str] = Field(default_factory=list, env="RELAYS")
|
||||
|
||||
|
||||
def _compute_primary_mint(cashu_mints: list[str]) -> str:
|
||||
return cashu_mints[0] if cashu_mints else "https://mint.minibits.cash/Bitcoin"
|
||||
|
||||
|
||||
def resolve_bootstrap() -> Settings:
|
||||
base = Settings() # Reads env with custom parse_env_var
|
||||
# Back-compat env mapping
|
||||
try:
|
||||
# Map MODEL_BASED_PRICING -> fixed_pricing (inverted)
|
||||
if "MODEL_BASED_PRICING" in os.environ and "FIXED_PRICING" not in os.environ:
|
||||
mbp_raw = os.environ.get("MODEL_BASED_PRICING", "").strip().lower()
|
||||
mbp = mbp_raw in {"1", "true", "yes", "on"}
|
||||
base.fixed_pricing = not mbp
|
||||
# Map COST_PER_REQUEST -> fixed_cost_per_request if new not provided
|
||||
if (
|
||||
"COST_PER_REQUEST" in os.environ
|
||||
and "FIXED_COST_PER_REQUEST" not in os.environ
|
||||
):
|
||||
try:
|
||||
base.fixed_cost_per_request = int(
|
||||
os.environ["COST_PER_REQUEST"].strip()
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Map COST_PER_1K_* -> FIXED_PER_1K_*
|
||||
if (
|
||||
"COST_PER_1K_INPUT_TOKENS" in os.environ
|
||||
and "FIXED_PER_1K_INPUT_TOKENS" not in os.environ
|
||||
):
|
||||
try:
|
||||
base.fixed_per_1k_input_tokens = int(
|
||||
os.environ["COST_PER_1K_INPUT_TOKENS"].strip()
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if (
|
||||
"COST_PER_1K_OUTPUT_TOKENS" in os.environ
|
||||
and "FIXED_PER_1K_OUTPUT_TOKENS" not in os.environ
|
||||
):
|
||||
try:
|
||||
base.fixed_per_1k_output_tokens = int(
|
||||
os.environ["COST_PER_1K_OUTPUT_TOKENS"].strip()
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
if not base.onion_url:
|
||||
try:
|
||||
from ..nostr.listing import discover_onion_url_from_tor # type: ignore
|
||||
|
||||
discovered = discover_onion_url_from_tor()
|
||||
if discovered:
|
||||
base.onion_url = discovered
|
||||
except Exception:
|
||||
pass
|
||||
# Derive NPUB from NSEC if not provided
|
||||
if not base.npub and base.nsec:
|
||||
try:
|
||||
from nostr.key import PrivateKey # type: ignore
|
||||
|
||||
if base.nsec.startswith("nsec"):
|
||||
pk = PrivateKey.from_nsec(base.nsec)
|
||||
elif len(base.nsec) == 64:
|
||||
pk = PrivateKey(bytes.fromhex(base.nsec))
|
||||
else:
|
||||
pk = None
|
||||
if pk is not None:
|
||||
try:
|
||||
base.npub = pk.public_key.bech32()
|
||||
except Exception:
|
||||
# Fallback to hex if bech32 not available
|
||||
base.npub = pk.public_key.hex()
|
||||
except Exception:
|
||||
pass
|
||||
if not base.cors_origins:
|
||||
base.cors_origins = ["*"]
|
||||
if not base.primary_mint:
|
||||
base.primary_mint = _compute_primary_mint(base.cashu_mints)
|
||||
return base
|
||||
|
||||
|
||||
class SettingsRow(BaseModel):
|
||||
id: int
|
||||
data: dict[str, Any]
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
# Single, concrete settings instance that callers import directly
|
||||
settings: Settings = resolve_bootstrap()
|
||||
|
||||
|
||||
class SettingsService:
|
||||
_current: Settings | None = None
|
||||
_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
def get(cls) -> Settings:
|
||||
if cls._current is None:
|
||||
raise RuntimeError("SettingsService not initialized")
|
||||
return cls._current
|
||||
|
||||
@classmethod
|
||||
async def initialize(cls, db_session: AsyncSession) -> Settings:
|
||||
async with cls._lock:
|
||||
from sqlmodel import text
|
||||
|
||||
await db_session.exec( # type: ignore
|
||||
text(
|
||||
"CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY, data TEXT NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
|
||||
)
|
||||
)
|
||||
|
||||
row = await db_session.exec( # type: ignore
|
||||
text("SELECT id, data, updated_at FROM settings WHERE id = 1")
|
||||
)
|
||||
row = row.first()
|
||||
env_resolved = resolve_bootstrap()
|
||||
|
||||
if row is None:
|
||||
await db_session.exec( # type: ignore
|
||||
text(
|
||||
"INSERT INTO settings (id, data, updated_at) VALUES (1, :data, :updated_at)"
|
||||
).bindparams(
|
||||
data=json.dumps(env_resolved.dict()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
cls._current = settings
|
||||
# Update the existing instance in-place for all live importers
|
||||
for k, v in env_resolved.dict().items():
|
||||
setattr(settings, k, v)
|
||||
return cls._current
|
||||
|
||||
db_id, db_data, _updated_at = row
|
||||
try:
|
||||
db_json = (
|
||||
json.loads(db_data) if isinstance(db_data, str) else dict(db_data)
|
||||
)
|
||||
except Exception:
|
||||
db_json = {}
|
||||
|
||||
merged_dict: dict[str, Any] = dict(env_resolved.dict())
|
||||
merged_dict.update(
|
||||
{k: v for k, v in db_json.items() if v not in (None, "", [], {})}
|
||||
)
|
||||
|
||||
# Ensure primary_mint is consistent with cashu_mints if not explicitly set
|
||||
if not merged_dict.get("primary_mint"):
|
||||
merged_dict["primary_mint"] = _compute_primary_mint(
|
||||
merged_dict.get("cashu_mints", [])
|
||||
)
|
||||
|
||||
if any(k not in db_json for k in merged_dict.keys()):
|
||||
await db_session.exec( # type: ignore
|
||||
text(
|
||||
"UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1"
|
||||
).bindparams(
|
||||
data=json.dumps(merged_dict),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
# Update the existing instance in-place for all live importers
|
||||
for k, v in merged_dict.items():
|
||||
setattr(settings, k, v)
|
||||
cls._current = settings
|
||||
return cls._current
|
||||
|
||||
@classmethod
|
||||
async def update(
|
||||
cls, partial: dict[str, Any], db_session: AsyncSession
|
||||
) -> Settings:
|
||||
async with cls._lock:
|
||||
current = cls.get()
|
||||
candidate_dict = {**current.dict(), **partial}
|
||||
candidate = Settings(**candidate_dict)
|
||||
from sqlmodel import text
|
||||
|
||||
# Ensure primary_mint reflects candidate mints if missing
|
||||
if not candidate.primary_mint:
|
||||
candidate.primary_mint = _compute_primary_mint(candidate.cashu_mints)
|
||||
|
||||
await db_session.exec( # type: ignore
|
||||
text(
|
||||
"UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1"
|
||||
).bindparams(
|
||||
data=json.dumps(candidate.dict()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
# Update in-place
|
||||
for k, v in candidate.dict().items():
|
||||
setattr(settings, k, v)
|
||||
cls._current = settings
|
||||
return settings
|
||||
|
||||
@classmethod
|
||||
async def reload_from_db(cls, db_session: AsyncSession) -> Settings:
|
||||
async with cls._lock:
|
||||
from sqlmodel import text
|
||||
|
||||
row = await db_session.exec(text("SELECT data FROM settings WHERE id = 1")) # type: ignore
|
||||
row = row.first()
|
||||
if row is None:
|
||||
raise RuntimeError("Settings row missing")
|
||||
(data_str,) = row
|
||||
data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str)
|
||||
# Update in-place
|
||||
for k, v in data.items():
|
||||
setattr(settings, k, v)
|
||||
cls._current = settings
|
||||
return settings
|
||||
@@ -1,272 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
providers_router = APIRouter(prefix="/v1/providers")
|
||||
|
||||
|
||||
def generate_subscription_id() -> str:
|
||||
"""Generate a random subscription ID."""
|
||||
return "".join(random.choices(string.ascii_lowercase + string.digits, k=10))
|
||||
|
||||
|
||||
async def query_nostr_relay_for_providers(
|
||||
relay_url: str,
|
||||
pubkey: str | None = None,
|
||||
limit: int = 1000,
|
||||
timeout: int = 30,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Query a Nostr relay for provider announcements using RIP-02 spec.
|
||||
Searches for kind 31338 events (Routstr Provider Announcements).
|
||||
"""
|
||||
events = []
|
||||
|
||||
# Build filter according to RIP-02 spec
|
||||
filter_obj: dict[str, Any] = {
|
||||
"kinds": [31338], # RIP-02 Provider Announcement events
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
# If specific pubkey provided, filter by author
|
||||
if pubkey:
|
||||
filter_obj["authors"] = [pubkey]
|
||||
|
||||
sub_id = generate_subscription_id()
|
||||
req_message = json.dumps(["REQ", sub_id, filter_obj])
|
||||
|
||||
try:
|
||||
async with websockets.connect(relay_url, timeout=timeout) as websocket:
|
||||
logger.debug("Connected to relay, searching for kind 31338 events")
|
||||
await websocket.send(req_message)
|
||||
|
||||
while True:
|
||||
try:
|
||||
message = await asyncio.wait_for(websocket.recv(), timeout=5)
|
||||
data = json.loads(message)
|
||||
|
||||
if data[0] == "EVENT" and data[1] == sub_id:
|
||||
event = data[2]
|
||||
logger.debug(f"Found provider announcement: {event['id']}")
|
||||
events.append(event)
|
||||
elif data[0] == "EOSE" and data[1] == sub_id:
|
||||
logger.debug("Received EOSE message")
|
||||
break
|
||||
elif data[0] == "NOTICE":
|
||||
logger.warning(f"Relay notice: {data[1]}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Timeout waiting for message")
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Failed to decode message as JSON")
|
||||
continue
|
||||
|
||||
await websocket.send(json.dumps(["CLOSE", sub_id]))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Query failed: {e}")
|
||||
|
||||
logger.info(f"Query complete. Found {len(events)} provider announcements")
|
||||
return events
|
||||
|
||||
|
||||
def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""
|
||||
Parse a kind 31338 provider announcement event according to RIP-02 spec.
|
||||
Returns structured provider data or None if invalid.
|
||||
"""
|
||||
try:
|
||||
# Extract required tags according to RIP-02
|
||||
tags = event.get("tags", [])
|
||||
|
||||
# Find required tags
|
||||
endpoint_url = None
|
||||
provider_name = None
|
||||
d_tag = None
|
||||
|
||||
for tag in tags:
|
||||
if len(tag) >= 2:
|
||||
if tag[0] == "endpoint":
|
||||
endpoint_url = tag[1]
|
||||
elif tag[0] == "name":
|
||||
provider_name = tag[1]
|
||||
elif tag[0] == "d":
|
||||
d_tag = tag[1]
|
||||
|
||||
# Validate required fields
|
||||
if not endpoint_url or not provider_name or not d_tag:
|
||||
logger.warning(
|
||||
f"Invalid provider announcement - missing required tags: {event['id']}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Extract optional tags
|
||||
description = None
|
||||
contact = None
|
||||
pricing_url = None
|
||||
supported_models = []
|
||||
|
||||
for tag in tags:
|
||||
if len(tag) >= 2:
|
||||
if tag[0] == "description":
|
||||
description = tag[1]
|
||||
elif tag[0] == "contact":
|
||||
contact = tag[1]
|
||||
elif tag[0] == "pricing":
|
||||
pricing_url = tag[1]
|
||||
elif tag[0] == "model":
|
||||
supported_models.append(tag[1])
|
||||
|
||||
return {
|
||||
"id": event["id"],
|
||||
"pubkey": event["pubkey"],
|
||||
"created_at": event["created_at"],
|
||||
"d_tag": d_tag,
|
||||
"endpoint_url": endpoint_url,
|
||||
"name": provider_name,
|
||||
"description": description,
|
||||
"contact": contact,
|
||||
"pricing_url": pricing_url,
|
||||
"supported_models": supported_models,
|
||||
"content": event.get("content", ""),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_cache() -> list[dict[str, Any]]:
|
||||
return [] # TODO: Implement cache
|
||||
|
||||
|
||||
async def fetch_provider_health(endpoint_url: str) -> dict[str, Any]:
|
||||
"""Check if a provider endpoint is healthy by making a GET request."""
|
||||
try:
|
||||
# Determine if we need Tor proxy based on .onion domain
|
||||
is_onion = ".onion" in endpoint_url
|
||||
|
||||
# Set up client arguments conditionally
|
||||
proxies = None
|
||||
if is_onion:
|
||||
# Get Tor proxy URL from environment variable
|
||||
tor_proxy = os.getenv("TOR_PROXY_URL", "socks5://127.0.0.1:9050")
|
||||
proxies = {"http://": tor_proxy, "https://": tor_proxy} # type: ignore[assignment]
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(30.0),
|
||||
follow_redirects=True,
|
||||
proxies=proxies, # type: ignore[arg-type]
|
||||
) as client:
|
||||
# Try to fetch models endpoint first (common for AI providers)
|
||||
models_url = f"{endpoint_url.rstrip('/')}/v1/models"
|
||||
try:
|
||||
response = await client.get(models_url)
|
||||
if response.status_code == 200:
|
||||
return {
|
||||
"status_code": response.status_code,
|
||||
"endpoint": "models",
|
||||
"json": response.json(),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to root endpoint
|
||||
response = await client.get(endpoint_url)
|
||||
return {
|
||||
"status_code": response.status_code,
|
||||
"endpoint": "root",
|
||||
"json": response.json()
|
||||
if response.headers.get("content-type", "").startswith(
|
||||
"application/json"
|
||||
)
|
||||
else {"message": "OK"},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"status_code": 500,
|
||||
"endpoint": "error",
|
||||
"json": {"error": f"Failed to fetch provider: {str(e)}"},
|
||||
}
|
||||
|
||||
|
||||
@providers_router.get("/")
|
||||
async def get_providers(
|
||||
include_json: bool = False, pubkey: str | None = None
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""
|
||||
Discover Routstr providers using RIP-02 specification.
|
||||
Searches for kind 31338 provider announcement events on Nostr relays.
|
||||
|
||||
Reference: https://github.com/Routstr/protocol/blob/main/RIP-02.md
|
||||
"""
|
||||
# Default relays for provider discovery
|
||||
discovery_relays = [
|
||||
"wss://relay.nostr.band",
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.routstr.com",
|
||||
]
|
||||
|
||||
all_events = []
|
||||
event_ids = set() # To avoid duplicates
|
||||
|
||||
# Query multiple relays for provider announcements
|
||||
for relay_url in discovery_relays:
|
||||
logger.info(f"Querying relay for providers: {relay_url}")
|
||||
try:
|
||||
events = await query_nostr_relay_for_providers(
|
||||
relay_url=relay_url,
|
||||
pubkey=pubkey,
|
||||
limit=100,
|
||||
)
|
||||
|
||||
# Add unique events
|
||||
for event in events:
|
||||
if event["id"] not in event_ids:
|
||||
event_ids.add(event["id"])
|
||||
all_events.append(event)
|
||||
|
||||
logger.info(f"Got {len(events)} provider announcements from {relay_url}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to query {relay_url}: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Found {len(all_events)} total unique provider announcements")
|
||||
|
||||
# Parse provider announcements according to RIP-02
|
||||
providers = []
|
||||
for event in all_events:
|
||||
parsed_provider = parse_provider_announcement(event)
|
||||
if parsed_provider:
|
||||
providers.append(parsed_provider)
|
||||
|
||||
logger.info(f"Parsed {len(providers)} valid provider announcements")
|
||||
|
||||
# Check provider health if requested
|
||||
healthy_providers: list[dict[str, Any]] = []
|
||||
for provider in providers:
|
||||
endpoint_url = provider["endpoint_url"]
|
||||
|
||||
if include_json:
|
||||
health_check = await fetch_provider_health(endpoint_url)
|
||||
provider_data = {"provider": provider, "health": health_check}
|
||||
healthy_providers.append(provider_data)
|
||||
else:
|
||||
# Just return the provider info without health check
|
||||
healthy_providers.append(provider)
|
||||
|
||||
return {"providers": healthy_providers}
|
||||
@@ -0,0 +1,276 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .core.db import ApiKey, LightningInvoice, get_session
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from .wallet import get_wallet
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
lightning_router = APIRouter(prefix="/lightning")
|
||||
|
||||
|
||||
class InvoiceCreateRequest(BaseModel):
|
||||
amount_sats: int = Field(gt=0, le=1_000_000, description="Amount in satoshis")
|
||||
purpose: str = Field(description="create or topup", pattern="^(create|topup)$")
|
||||
api_key: str | None = Field(
|
||||
default=None, description="Required for topup operations"
|
||||
)
|
||||
balance_limit: int | None = Field(default=None)
|
||||
balance_limit_reset: str | None = Field(default=None)
|
||||
validity_date: int | None = Field(default=None)
|
||||
|
||||
|
||||
class InvoiceCreateResponse(BaseModel):
|
||||
invoice_id: str
|
||||
bolt11: str
|
||||
amount_sats: int
|
||||
expires_at: int
|
||||
payment_hash: str
|
||||
|
||||
|
||||
class InvoiceStatusResponse(BaseModel):
|
||||
status: str
|
||||
api_key: str | None = None
|
||||
amount_sats: int
|
||||
paid_at: int | None = None
|
||||
created_at: int
|
||||
expires_at: int
|
||||
|
||||
|
||||
class InvoiceRecoverRequest(BaseModel):
|
||||
bolt11: str = Field(description="BOLT11 invoice string")
|
||||
|
||||
|
||||
async def generate_lightning_invoice(
|
||||
amount_sats: int, description: str
|
||||
) -> tuple[str, str]:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
quote = await wallet.request_mint(amount_sats)
|
||||
return quote.request, quote.quote
|
||||
|
||||
|
||||
def generate_invoice_id() -> str:
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
@lightning_router.post("/invoice", response_model=InvoiceCreateResponse)
|
||||
async def create_invoice(
|
||||
request: InvoiceCreateRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvoiceCreateResponse:
|
||||
if request.purpose == "topup" and not request.api_key:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="api_key is required for topup operations"
|
||||
)
|
||||
|
||||
if request.purpose == "topup" and request.api_key:
|
||||
if not request.api_key.startswith("sk-"):
|
||||
raise HTTPException(status_code=400, detail="Invalid API key format")
|
||||
|
||||
api_key = await session.get(ApiKey, request.api_key[3:])
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
|
||||
try:
|
||||
description = f"Routstr {request.purpose} {request.amount_sats} sats"
|
||||
bolt11, payment_hash = await generate_lightning_invoice(
|
||||
request.amount_sats, description
|
||||
)
|
||||
|
||||
invoice_id = generate_invoice_id()
|
||||
expires_at = int(time.time()) + 3600 # 1 hour expiry
|
||||
|
||||
invoice = LightningInvoice(
|
||||
id=invoice_id,
|
||||
bolt11=bolt11,
|
||||
amount_sats=request.amount_sats,
|
||||
description=description,
|
||||
payment_hash=payment_hash,
|
||||
status="pending",
|
||||
api_key_hash=request.api_key[3:] if request.api_key else None,
|
||||
purpose=request.purpose,
|
||||
balance_limit=request.balance_limit,
|
||||
balance_limit_reset=request.balance_limit_reset,
|
||||
validity_date=request.validity_date,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
session.add(invoice)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Lightning invoice created",
|
||||
extra={
|
||||
"invoice_id": invoice_id,
|
||||
"amount_sats": request.amount_sats,
|
||||
"purpose": request.purpose,
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
)
|
||||
|
||||
return InvoiceCreateResponse(
|
||||
invoice_id=invoice_id,
|
||||
bolt11=bolt11,
|
||||
amount_sats=request.amount_sats,
|
||||
expires_at=expires_at,
|
||||
payment_hash=payment_hash,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create Lightning invoice: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to create Lightning invoice"
|
||||
)
|
||||
|
||||
|
||||
@lightning_router.get(
|
||||
"/invoice/{invoice_id}/status", response_model=InvoiceStatusResponse
|
||||
)
|
||||
async def get_invoice_status(
|
||||
invoice_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvoiceStatusResponse:
|
||||
invoice = await session.get(LightningInvoice, invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
if invoice.status == "pending" and int(time.time()) > invoice.expires_at:
|
||||
invoice.status = "expired"
|
||||
await session.commit()
|
||||
|
||||
if invoice.status == "pending":
|
||||
await check_invoice_payment(invoice, session)
|
||||
|
||||
api_key = None
|
||||
if invoice.status == "paid" and invoice.purpose == "create":
|
||||
if invoice.api_key_hash:
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
elif (
|
||||
invoice.status == "paid" and invoice.purpose == "topup" and invoice.api_key_hash
|
||||
):
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
|
||||
return InvoiceStatusResponse(
|
||||
status=invoice.status,
|
||||
api_key=api_key,
|
||||
amount_sats=invoice.amount_sats,
|
||||
paid_at=invoice.paid_at,
|
||||
created_at=invoice.created_at,
|
||||
expires_at=invoice.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@lightning_router.post("/recover", response_model=InvoiceStatusResponse)
|
||||
async def recover_invoice(
|
||||
request: InvoiceRecoverRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvoiceStatusResponse:
|
||||
result = await session.exec(
|
||||
select(LightningInvoice).where(LightningInvoice.bolt11 == request.bolt11)
|
||||
)
|
||||
invoice = result.first()
|
||||
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
if invoice.status == "pending":
|
||||
await check_invoice_payment(invoice, session)
|
||||
|
||||
api_key = None
|
||||
if invoice.status == "paid":
|
||||
if invoice.purpose == "create" and invoice.api_key_hash:
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
elif invoice.purpose == "topup" and invoice.api_key_hash:
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
|
||||
return InvoiceStatusResponse(
|
||||
status=invoice.status,
|
||||
api_key=api_key,
|
||||
amount_sats=invoice.amount_sats,
|
||||
paid_at=invoice.paid_at,
|
||||
created_at=invoice.created_at,
|
||||
expires_at=invoice.expires_at,
|
||||
)
|
||||
|
||||
|
||||
async def check_invoice_payment(
|
||||
invoice: LightningInvoice, session: AsyncSession
|
||||
) -> None:
|
||||
try:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
|
||||
mint_status = await wallet.get_mint_quote(invoice.payment_hash)
|
||||
|
||||
if mint_status.paid:
|
||||
invoice.status = "paid"
|
||||
invoice.paid_at = int(time.time())
|
||||
|
||||
if invoice.purpose == "create":
|
||||
api_key = await create_api_key_from_invoice(invoice, session)
|
||||
invoice.api_key_hash = api_key.hashed_key
|
||||
elif invoice.purpose == "topup" and invoice.api_key_hash:
|
||||
await topup_api_key_from_invoice(invoice, session)
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Lightning invoice paid",
|
||||
extra={
|
||||
"invoice_id": invoice.id,
|
||||
"amount_sats": invoice.amount_sats,
|
||||
"purpose": invoice.purpose,
|
||||
"api_key_hash": invoice.api_key_hash[:8] + "..."
|
||||
if invoice.api_key_hash
|
||||
else None,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check invoice payment: {e}")
|
||||
|
||||
|
||||
async def create_api_key_from_invoice(
|
||||
invoice: LightningInvoice, session: AsyncSession
|
||||
) -> ApiKey:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash)
|
||||
|
||||
dummy_token = f"invoice-{invoice.id}-{invoice.payment_hash}"
|
||||
hashed_key = hashlib.sha256(dummy_token.encode()).hexdigest()
|
||||
|
||||
api_key = ApiKey(
|
||||
hashed_key=hashed_key,
|
||||
balance=invoice.amount_sats * 1000, # Convert to msats
|
||||
refund_currency="sat",
|
||||
refund_mint_url=settings.primary_mint,
|
||||
)
|
||||
|
||||
session.add(api_key)
|
||||
await session.flush()
|
||||
|
||||
return api_key
|
||||
|
||||
|
||||
async def topup_api_key_from_invoice(
|
||||
invoice: LightningInvoice, session: AsyncSession
|
||||
) -> None:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash)
|
||||
|
||||
if not invoice.api_key_hash:
|
||||
raise ValueError("No API key associated with topup invoice")
|
||||
|
||||
api_key = await session.get(ApiKey, invoice.api_key_hash)
|
||||
if not api_key:
|
||||
raise ValueError("Associated API key not found")
|
||||
|
||||
api_key.balance += invoice.amount_sats * 1000 # Convert to msats
|
||||
await session.flush()
|
||||
@@ -0,0 +1,4 @@
|
||||
from .discovery import providers_cache_refresher
|
||||
from .listing import announce_provider
|
||||
|
||||
__all__ = ["providers_cache_refresher", "announce_provider"]
|
||||
@@ -0,0 +1,402 @@
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from ..core.logging import get_logger
|
||||
from ..core.settings import settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
providers_router = APIRouter(prefix="/v1/providers")
|
||||
|
||||
# In-memory providers cache and lock
|
||||
_PROVIDERS_CACHE: list[dict[str, Any]] = []
|
||||
_PROVIDERS_CACHE_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
def generate_subscription_id() -> str:
|
||||
"""Generate a random subscription ID."""
|
||||
return "".join(random.choices(string.ascii_lowercase + string.digits, k=10))
|
||||
|
||||
|
||||
async def query_nostr_relay_for_providers(
|
||||
relay_url: str,
|
||||
pubkey: str | None = None,
|
||||
limit: int = 1000,
|
||||
timeout: int = 30,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Query a Nostr relay for provider announcements.
|
||||
Searches for NIP-91 (kind:38421) events.
|
||||
"""
|
||||
events = []
|
||||
|
||||
# Build filter for NIP-91 events
|
||||
filter_obj: dict[str, Any] = {
|
||||
"kinds": [38421], # NIP-91 Provider Announcements
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
# If specific pubkey provided, filter by author
|
||||
if pubkey:
|
||||
filter_obj["authors"] = [pubkey]
|
||||
|
||||
sub_id = generate_subscription_id()
|
||||
req_message = json.dumps(["REQ", sub_id, filter_obj])
|
||||
|
||||
try:
|
||||
async with websockets.connect(relay_url, open_timeout=timeout) as websocket:
|
||||
logger.debug("Connected to relay, searching for NIP-91 events (kind 38421)")
|
||||
await websocket.send(req_message)
|
||||
|
||||
while True:
|
||||
try:
|
||||
message = await asyncio.wait_for(websocket.recv(), timeout=5)
|
||||
data = json.loads(message)
|
||||
|
||||
if data[0] == "EVENT" and data[1] == sub_id:
|
||||
event = data[2]
|
||||
logger.debug(
|
||||
f"Found provider announcement: {event['id'][:6]}...{event['id'][-6:]}"
|
||||
)
|
||||
events.append(event)
|
||||
elif data[0] == "EOSE" and data[1] == sub_id:
|
||||
logger.debug("Received EOSE message")
|
||||
break
|
||||
elif data[0] == "NOTICE":
|
||||
try:
|
||||
msg = str(data[1])
|
||||
logger.debug(f"Relay notice: {msg}")
|
||||
except Exception:
|
||||
logger.debug("Relay notice received")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Timeout waiting for message")
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Failed to decode message as JSON")
|
||||
continue
|
||||
|
||||
await websocket.send(json.dumps(["CLOSE", sub_id]))
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Query failed: {type(e).__name__}")
|
||||
|
||||
logger.info(f"Query complete. Found {len(events)} provider announcements")
|
||||
return events
|
||||
|
||||
|
||||
def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""
|
||||
Parse provider announcement events.
|
||||
Handles NIP-91 (kind:38421) format.
|
||||
Returns structured provider data or None if invalid.
|
||||
"""
|
||||
try:
|
||||
tags = event.get("tags", [])
|
||||
kind = event.get("kind")
|
||||
|
||||
# Common fields
|
||||
d_tag = None
|
||||
endpoint_urls = []
|
||||
provider_name = None
|
||||
endpoint_url = None
|
||||
|
||||
for tag in tags:
|
||||
if len(tag) >= 2:
|
||||
if tag[0] == "endpoint":
|
||||
endpoint_url = tag[1]
|
||||
elif tag[0] == "name":
|
||||
provider_name = tag[1]
|
||||
elif tag[0] == "d":
|
||||
d_tag = tag[1]
|
||||
|
||||
# Early validation only applies to legacy/other kinds, not NIP-91
|
||||
if kind != 38421 and (not endpoint_url or not provider_name or not d_tag):
|
||||
logger.warning(
|
||||
f"Invalid provider announcement - missing required tags: {event['id']}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Extract optional tags
|
||||
description = None
|
||||
mint_urls = []
|
||||
version = None
|
||||
|
||||
# Parse NIP-91 format
|
||||
if kind == 38421: # NIP-91 format
|
||||
for tag in tags:
|
||||
if len(tag) >= 2:
|
||||
if tag[0] == "d":
|
||||
d_tag = tag[1]
|
||||
elif tag[0] == "u":
|
||||
endpoint_urls.append(tag[1])
|
||||
elif tag[0] == "mint":
|
||||
mint_urls.append(tag[1])
|
||||
elif tag[0] == "version":
|
||||
version = tag[1]
|
||||
|
||||
# Parse metadata from content for NIP-91
|
||||
content = event.get("content", "")
|
||||
if content:
|
||||
try:
|
||||
metadata = json.loads(content)
|
||||
provider_name = metadata.get("name", "Unknown Provider")
|
||||
description = metadata.get("about")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
provider_name = "Unknown Provider"
|
||||
else:
|
||||
provider_name = "Unknown Provider"
|
||||
|
||||
# Use first URL as primary endpoint
|
||||
endpoint_url = endpoint_urls[0] if endpoint_urls else None
|
||||
|
||||
# Validate NIP-91 required fields
|
||||
if not endpoint_url or not d_tag:
|
||||
logger.warning(
|
||||
f"Invalid NIP-91 announcement - missing required fields: {event['id']}"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unknown event kind when parsing provider announcement: {kind}"
|
||||
)
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": d_tag,
|
||||
"pubkey": event["pubkey"],
|
||||
"created_at": event["created_at"],
|
||||
"kind": kind,
|
||||
"endpoint_url": endpoint_url,
|
||||
"endpoint_urls": endpoint_urls, # All URLs for NIP-91
|
||||
"name": provider_name,
|
||||
"description": description,
|
||||
"mint_urls": mint_urls,
|
||||
"version": version,
|
||||
"content": event.get("content", ""),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def get_cache() -> list[dict[str, Any]]:
|
||||
async with _PROVIDERS_CACHE_LOCK:
|
||||
return list(_PROVIDERS_CACHE)
|
||||
|
||||
|
||||
def _get_discovery_relays() -> list[str]:
|
||||
try:
|
||||
relays = settings.relays
|
||||
except Exception:
|
||||
relays = []
|
||||
if not relays:
|
||||
relays = [
|
||||
"wss://relay.nostr.band",
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.routstr.com",
|
||||
"wss://nos.lol",
|
||||
]
|
||||
return relays
|
||||
|
||||
|
||||
async def _discover_providers(pubkey: str | None = None) -> list[dict[str, Any]]:
|
||||
discovery_relays = _get_discovery_relays()
|
||||
|
||||
tasks = [
|
||||
query_nostr_relay_for_providers(relay_url=r, pubkey=pubkey, limit=100)
|
||||
for r in discovery_relays
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
all_events: list[dict[str, Any]] = []
|
||||
event_ids: set[str] = set()
|
||||
for res in results:
|
||||
if isinstance(res, BaseException):
|
||||
logger.error(f"Relay query failed: {res}")
|
||||
continue
|
||||
if isinstance(res, list):
|
||||
for event in res:
|
||||
# Filter out localhost announcements
|
||||
try:
|
||||
tags = event.get("tags", [])
|
||||
is_localhost = any(
|
||||
isinstance(tag, list)
|
||||
and len(tag) >= 2
|
||||
and tag[0] == "u"
|
||||
and tag[1] == "http://localhost:8000"
|
||||
for tag in tags
|
||||
)
|
||||
if is_localhost:
|
||||
logger.debug(
|
||||
f"Skipping localhost provider event: {event.get('id', 'unknown')}"
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
# If tags are malformed, fall through to normal handling
|
||||
pass
|
||||
|
||||
if (eid := event.get("id")) and eid not in event_ids:
|
||||
event_ids.add(eid)
|
||||
all_events.append(event)
|
||||
else:
|
||||
logger.error(f"Unexpected relay result type: {type(res)}")
|
||||
|
||||
providers: list[dict[str, Any]] = []
|
||||
seen_endpoints: set[str] = set()
|
||||
for event in all_events:
|
||||
parsed = parse_provider_announcement(event)
|
||||
if parsed and (eu := parsed.get("endpoint_url")) and eu not in seen_endpoints:
|
||||
seen_endpoints.add(eu)
|
||||
providers.append(parsed)
|
||||
|
||||
random.shuffle(providers)
|
||||
return providers[:42]
|
||||
|
||||
|
||||
async def refresh_providers_cache(pubkey: str | None = None) -> None:
|
||||
try:
|
||||
providers = await _discover_providers(pubkey=pubkey)
|
||||
|
||||
health_tasks = [
|
||||
fetch_provider_health(provider["endpoint_url"]) for provider in providers
|
||||
]
|
||||
health_results = await asyncio.gather(*health_tasks, return_exceptions=True)
|
||||
|
||||
new_cache: list[dict[str, Any]] = []
|
||||
for provider, hr in zip(providers, health_results):
|
||||
if isinstance(hr, Exception):
|
||||
health: dict[str, Any] = {
|
||||
"status_code": 500,
|
||||
"endpoint": "error",
|
||||
"json": {"error": str(hr)},
|
||||
}
|
||||
else:
|
||||
health = hr # type: ignore[assignment]
|
||||
new_cache.append({"provider": provider, "health": health})
|
||||
|
||||
async with _PROVIDERS_CACHE_LOCK:
|
||||
_PROVIDERS_CACHE.clear()
|
||||
_PROVIDERS_CACHE.extend(new_cache)
|
||||
logger.info(
|
||||
f"Providers cache refreshed with {len(new_cache)} entries (limit 42)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh providers cache: {e}")
|
||||
|
||||
|
||||
async def providers_cache_refresher(
|
||||
interval_seconds: int | None = None, pubkey: str | None = None
|
||||
) -> None:
|
||||
if interval_seconds is None:
|
||||
try:
|
||||
interval_seconds = settings.providers_refresh_interval_seconds
|
||||
except Exception:
|
||||
interval_seconds = 300
|
||||
|
||||
await refresh_providers_cache(pubkey=pubkey)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(interval_seconds)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
await refresh_providers_cache(pubkey=pubkey)
|
||||
|
||||
|
||||
async def fetch_provider_health(endpoint_url: str) -> dict[str, Any]:
|
||||
"""Fetch provider health and info, preferring /v1/info for models and pricing."""
|
||||
try:
|
||||
# Determine if we need Tor proxy based on .onion domain
|
||||
is_onion = ".onion" in endpoint_url
|
||||
|
||||
# Set up client arguments conditionally
|
||||
proxies = None
|
||||
if is_onion:
|
||||
try:
|
||||
tor_proxy = settings.tor_proxy_url
|
||||
except Exception:
|
||||
tor_proxy = "socks5://127.0.0.1:9050"
|
||||
proxies = {"http://": tor_proxy, "https://": tor_proxy} # type: ignore[assignment]
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(30.0),
|
||||
follow_redirects=True,
|
||||
proxies=proxies, # type: ignore[arg-type]
|
||||
) as client:
|
||||
# Prefer provider's /v1/info for full details
|
||||
info_url = f"{endpoint_url.rstrip('/')}/v1/info"
|
||||
try:
|
||||
response = await client.get(info_url)
|
||||
if response.status_code == 200:
|
||||
return {
|
||||
"status_code": response.status_code,
|
||||
"endpoint": "info",
|
||||
"json": response.json(),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to /v1/models
|
||||
models_url = f"{endpoint_url.rstrip('/')}/v1/models"
|
||||
try:
|
||||
response = await client.get(models_url)
|
||||
if response.status_code == 200:
|
||||
return {
|
||||
"status_code": response.status_code,
|
||||
"endpoint": "models",
|
||||
"json": response.json(),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to root endpoint
|
||||
response = await client.get(endpoint_url)
|
||||
return {
|
||||
"status_code": response.status_code,
|
||||
"endpoint": "root",
|
||||
"json": response.json()
|
||||
if response.headers.get("content-type", "").startswith(
|
||||
"application/json"
|
||||
)
|
||||
else {"message": "OK"},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"status_code": 500,
|
||||
"endpoint": "error",
|
||||
"json": {"error": f"Failed to fetch provider: {str(e)}"},
|
||||
}
|
||||
|
||||
|
||||
@providers_router.get("/")
|
||||
async def get_providers(
|
||||
include_json: bool = False, pubkey: str | None = None
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""
|
||||
Return cached providers. If include_json, return provider+health; otherwise provider only.
|
||||
Optional filter by pubkey.
|
||||
"""
|
||||
if settings.providers_refresh_interval_seconds == 0:
|
||||
raise HTTPException(status_code=404, detail="Provider discovery is disabled")
|
||||
|
||||
cache = await get_cache()
|
||||
if not cache:
|
||||
await refresh_providers_cache(pubkey=pubkey)
|
||||
cache = await get_cache()
|
||||
if pubkey:
|
||||
cache = [c for c in cache if c.get("provider", {}).get("pubkey") == pubkey]
|
||||
if include_json:
|
||||
return {"providers": cache}
|
||||
providers_only = [c["provider"] for c in cache]
|
||||
return {"providers": providers_only}
|
||||
@@ -0,0 +1,574 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Listing: Routstr Provider Discoverability Implementation
|
||||
Automatically announces this Routstr proxy instance to Nostr relays.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import ssl
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
from nostr.event import Event
|
||||
from nostr.filter import Filter, Filters
|
||||
from nostr.key import PrivateKey
|
||||
from nostr.message_type import ClientMessageType
|
||||
from nostr.relay_manager import RelayManager
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.settings import settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_app_version() -> str | None:
|
||||
try:
|
||||
from ..core.main import __version__ as imported_version
|
||||
|
||||
return imported_version
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _event_to_dict(ev: Event) -> dict[str, Any]:
|
||||
return {
|
||||
"id": ev.id,
|
||||
"pubkey": ev.public_key,
|
||||
"created_at": ev.created_at,
|
||||
"kind": int(ev.kind) if not isinstance(ev.kind, int) else ev.kind,
|
||||
"tags": ev.tags,
|
||||
"content": ev.content,
|
||||
"sig": ev.signature,
|
||||
}
|
||||
|
||||
|
||||
def nsec_to_keypair(nsec: str) -> tuple[str, str] | None:
|
||||
"""
|
||||
Convert a Nostr private key (nsec) to a keypair (privkey_hex, pubkey_hex).
|
||||
|
||||
Args:
|
||||
nsec: Nostr private key in nsec format or hex format
|
||||
|
||||
Returns:
|
||||
Tuple of (private_key_hex, public_key_hex) or None if invalid
|
||||
"""
|
||||
try:
|
||||
if nsec.startswith("nsec"):
|
||||
pk = PrivateKey.from_nsec(nsec)
|
||||
return (pk.hex(), pk.public_key.hex())
|
||||
|
||||
if len(nsec) == 64:
|
||||
pk = PrivateKey(bytes.fromhex(nsec))
|
||||
return (pk.hex(), pk.public_key.hex())
|
||||
|
||||
logger.error(f"Invalid private key format/length: {len(nsec)}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to convert nsec to keypair: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def create_listing_event(
|
||||
private_key_hex: str,
|
||||
provider_id: str,
|
||||
endpoint_urls: list[str],
|
||||
mint_urls: list[str] | None = None,
|
||||
version: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a listing provider announcement event (kind:38421).
|
||||
|
||||
Args:
|
||||
private_key_hex: 32-byte hex private key for signing
|
||||
provider_id: Unique identifier for this provider (d tag)
|
||||
endpoint_urls: List of URLs to connect to the provider
|
||||
mint_urls: Optional list of ecash mint URLs for payments
|
||||
version: Provider software version
|
||||
metadata: Optional metadata dictionary (name, picture, about, etc.)
|
||||
|
||||
Returns:
|
||||
Complete signed nostr event as a dict ready for publishing
|
||||
"""
|
||||
pk = PrivateKey(bytes.fromhex(private_key_hex))
|
||||
|
||||
tags = [["d", provider_id]]
|
||||
for url in endpoint_urls:
|
||||
tags.append(["u", url])
|
||||
if mint_urls:
|
||||
for m in mint_urls:
|
||||
if m:
|
||||
tags.append(["mint", m])
|
||||
if version:
|
||||
tags.append(["version", version])
|
||||
|
||||
content = json.dumps(metadata, separators=(",", ":")) if metadata else ""
|
||||
|
||||
ev = Event(pk.public_key.hex(), content, kind=38421, tags=tags)
|
||||
pk.sign_event(ev)
|
||||
return _event_to_dict(ev)
|
||||
|
||||
|
||||
def _get_tag_values(event: dict[str, Any], key: str) -> list[str]:
|
||||
tags = event.get("tags", [])
|
||||
values: list[str] = []
|
||||
for tag in tags:
|
||||
if isinstance(tag, list) and tag and tag[0] == key and len(tag) >= 2:
|
||||
values.append(tag[1])
|
||||
return values
|
||||
|
||||
|
||||
def _get_single_tag_value(event: dict[str, Any], key: str) -> str | None:
|
||||
values = _get_tag_values(event, key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _parse_content_json(content: str) -> dict[str, Any]:
|
||||
if not content:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def events_semantically_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
|
||||
if a.get("kind") != b.get("kind"):
|
||||
return False
|
||||
|
||||
if _get_single_tag_value(a, "d") != _get_single_tag_value(b, "d"):
|
||||
return False
|
||||
|
||||
urls_a = set(_get_tag_values(a, "u"))
|
||||
urls_b = set(_get_tag_values(b, "u"))
|
||||
if urls_a != urls_b:
|
||||
return False
|
||||
|
||||
mints_a = set(_get_tag_values(a, "mint"))
|
||||
mints_b = set(_get_tag_values(b, "mint"))
|
||||
if mints_a != mints_b:
|
||||
return False
|
||||
|
||||
if _get_single_tag_value(a, "version") != _get_single_tag_value(b, "version"):
|
||||
return False
|
||||
|
||||
content_a = _parse_content_json(cast(str, a.get("content", "")))
|
||||
content_b = _parse_content_json(cast(str, b.get("content", "")))
|
||||
if content_a != content_b:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def query_listing_events(
|
||||
relay_url: str,
|
||||
pubkey: str,
|
||||
provider_id: str | None = None,
|
||||
timeout: int = 30,
|
||||
) -> tuple[list[dict[str, Any]], bool]:
|
||||
"""
|
||||
Query a Nostr relay for listing provider announcements (kind:38421) via nostr library.
|
||||
|
||||
Returns a tuple of (events, ok) where ok indicates whether the relay interaction
|
||||
succeeded without transport-level errors.
|
||||
"""
|
||||
|
||||
def _sync_query() -> tuple[list[dict[str, Any]], bool]:
|
||||
rm = RelayManager()
|
||||
rm.add_relay(relay_url)
|
||||
events_out: list[dict[str, Any]] = []
|
||||
ok = True
|
||||
try:
|
||||
rm.open_connections({"cert_reqs": ssl.CERT_NONE})
|
||||
time.sleep(1.0)
|
||||
|
||||
flt = Filter(kinds=[38421], authors=[pubkey], limit=10)
|
||||
filters = Filters([flt])
|
||||
sub_id = f"routstr_listing_{int(time.time())}"
|
||||
rm.add_subscription(sub_id, filters)
|
||||
req: list[Any] = [ClientMessageType.REQUEST, sub_id]
|
||||
req.extend(filters.to_json_array())
|
||||
rm.publish_message(json.dumps(req))
|
||||
|
||||
start = time.time()
|
||||
last_event_ts = start
|
||||
while time.time() - start < timeout:
|
||||
drained = False
|
||||
while rm.message_pool.has_events():
|
||||
drained = True
|
||||
ev_msg = rm.message_pool.get_event()
|
||||
ev = ev_msg.event
|
||||
ev_dict = _event_to_dict(ev)
|
||||
if provider_id is not None:
|
||||
tags = ev_dict.get("tags", [])
|
||||
if not any(
|
||||
isinstance(t, list)
|
||||
and len(t) >= 2
|
||||
and t[0] == "d"
|
||||
and t[1] == provider_id
|
||||
for t in tags
|
||||
):
|
||||
continue
|
||||
events_out.append(ev_dict)
|
||||
logger.debug(
|
||||
f"Found listing event: {ev_dict.get('id', '')[:6]}...{ev_dict.get('id', '')[-6:]}"
|
||||
)
|
||||
if drained:
|
||||
last_event_ts = time.time()
|
||||
|
||||
while rm.message_pool.has_notices():
|
||||
notice = rm.message_pool.get_notice()
|
||||
try:
|
||||
content = getattr(notice, "content", notice)
|
||||
s = str(content)
|
||||
if len(s) > 200:
|
||||
s = s[:200] + "..."
|
||||
logger.debug(f"Relay notice: {s}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if time.time() - last_event_ts > 2.5:
|
||||
break
|
||||
|
||||
time.sleep(0.1)
|
||||
except Exception as e:
|
||||
ok = False
|
||||
logger.debug(f"Failed to query relay {relay_url}: {type(e).__name__}")
|
||||
finally:
|
||||
try:
|
||||
rm.close_connections()
|
||||
except Exception:
|
||||
pass
|
||||
return events_out, ok
|
||||
|
||||
return await asyncio.to_thread(_sync_query)
|
||||
|
||||
|
||||
def discover_onion_url_from_tor(base_dir: str = "/var/lib/tor") -> str | None:
|
||||
"""Discover onion URL by reading Tor hidden service hostname files.
|
||||
|
||||
Tries common paths first, then scans recursively for any 'hostname' file.
|
||||
Returns an http URL like 'http://<host>.onion' if found.
|
||||
"""
|
||||
common_candidates = [
|
||||
os.path.join(base_dir, "hs", "router", "hostname"),
|
||||
os.path.join(base_dir, "hs", "ROUTER", "hostname"),
|
||||
os.path.join(base_dir, "hidden_service", "hostname"),
|
||||
]
|
||||
|
||||
for candidate in common_candidates:
|
||||
try:
|
||||
with open(candidate, "r", encoding="utf-8") as f:
|
||||
host = f.readline().strip()
|
||||
if host and host.endswith(".onion"):
|
||||
return f"http://{host}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
for root, _dirs, files in os.walk(base_dir):
|
||||
if "hostname" in files:
|
||||
path = os.path.join(root, "hostname")
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
host = f.readline().strip()
|
||||
if host and host.endswith(".onion"):
|
||||
return f"http://{host}"
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) -> str:
|
||||
explicit = settings.provider_id
|
||||
if explicit:
|
||||
logger.info(f"Using configured provider_id from env: {explicit}")
|
||||
return explicit
|
||||
|
||||
async def query_single_relay(relay_url: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
events, _ok = await query_listing_events(relay_url, public_key_hex, None)
|
||||
return events
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# Query all relays concurrently
|
||||
all_events_lists = await asyncio.gather(
|
||||
*[query_single_relay(relay_url) for relay_url in relay_urls]
|
||||
)
|
||||
|
||||
latest_event: dict[str, Any] | None = None
|
||||
latest_ts = -1
|
||||
|
||||
for events_list in all_events_lists:
|
||||
for ev in events_list:
|
||||
ts = int(ev.get("created_at", 0))
|
||||
if ts > latest_ts:
|
||||
latest_event = ev
|
||||
latest_ts = ts
|
||||
|
||||
existing_d = _get_single_tag_value(latest_event, "d") if latest_event else None
|
||||
if existing_d:
|
||||
logger.info(f"Reusing existing provider_id from relay: {existing_d}")
|
||||
return existing_d
|
||||
|
||||
fallback = public_key_hex[:12]
|
||||
logger.info(f"No existing provider_id found; using fallback: {fallback}")
|
||||
return fallback
|
||||
|
||||
|
||||
async def publish_to_relay(
|
||||
relay_url: str,
|
||||
event: dict[str, Any],
|
||||
timeout: int = 30,
|
||||
) -> bool:
|
||||
"""
|
||||
Publish a listing event to a nostr relay via nostr library.
|
||||
"""
|
||||
|
||||
def _sync_publish() -> bool:
|
||||
rm = RelayManager()
|
||||
rm.add_relay(relay_url)
|
||||
try:
|
||||
rm.open_connections({"cert_reqs": ssl.CERT_NONE})
|
||||
time.sleep(1.0)
|
||||
# Publish the event as-is via publish_message to preserve signature
|
||||
rm.publish_message(json.dumps(["EVENT", event]))
|
||||
logger.debug(f"Sent listing event {event.get('id', '')} to {relay_url}")
|
||||
time.sleep(1.0)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to publish to {relay_url}: {type(e).__name__}")
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
rm.close_connections()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return await asyncio.to_thread(_sync_publish)
|
||||
|
||||
|
||||
async def announce_provider() -> None:
|
||||
"""
|
||||
Background task to announce this Routstr provider to Nostr relays.
|
||||
Checks for existing announcements and creates new ones if needed.
|
||||
"""
|
||||
# Check for NSEC in environment (use NSEC only)
|
||||
nsec = settings.nsec
|
||||
if not nsec:
|
||||
logger.info("Nostr private key not found (NSEC), skipping listing announcement")
|
||||
return
|
||||
|
||||
# Convert NSEC to keypair
|
||||
keypair = nsec_to_keypair(nsec)
|
||||
if not keypair:
|
||||
logger.error("Failed to parse NSEC, skipping listing announcement")
|
||||
return
|
||||
|
||||
private_key_hex, public_key_hex = keypair
|
||||
logger.info(f"Using Nostr pubkey: {public_key_hex}")
|
||||
|
||||
# Resolve settings and determine if we can publish BEFORE touching relays
|
||||
try:
|
||||
base_url: str | None = settings.http_url
|
||||
onion_url: str | None = settings.onion_url
|
||||
provider_name = settings.name or "Routstr Proxy"
|
||||
provider_about = settings.description or "Privacy-preserving AI proxy via Nostr"
|
||||
cashu_mints = [m.strip() for m in settings.cashu_mints if m.strip()]
|
||||
except Exception:
|
||||
base_url = settings.http_url or None
|
||||
onion_url = settings.onion_url or None
|
||||
provider_name = settings.name or "Routstr Proxy"
|
||||
provider_about = settings.description or "Privacy-preserving AI proxy via Nostr"
|
||||
cashu_mints = [m.strip() for m in settings.cashu_mints if m.strip()]
|
||||
if not onion_url:
|
||||
discovered = discover_onion_url_from_tor()
|
||||
if discovered:
|
||||
onion_url = discovered
|
||||
logger.info(f"Discovered onion URL via Tor volume: {onion_url}")
|
||||
mint_urls = cashu_mints if cashu_mints else None
|
||||
|
||||
endpoint_urls: list[str] = []
|
||||
if base_url and base_url.strip() and base_url.strip() != "http://localhost:8000":
|
||||
endpoint_urls.append(base_url.strip())
|
||||
if onion_url and onion_url.strip():
|
||||
ou = onion_url.strip()
|
||||
if ou.endswith(".onion") and not (
|
||||
ou.startswith("http://") or ou.startswith("https://")
|
||||
):
|
||||
ou = f"http://{ou}"
|
||||
endpoint_urls.append(ou)
|
||||
|
||||
if not endpoint_urls:
|
||||
logger.warning(
|
||||
"No valid endpoints configured (HTTP_URL/ONION_URL). Skipping listing publish."
|
||||
)
|
||||
return
|
||||
|
||||
# Only now configure relays and determine provider_id (may query relays)
|
||||
relay_urls = [u.strip() for u in getattr(settings, "relays", []) if u.strip()]
|
||||
if not relay_urls:
|
||||
relay_urls = [
|
||||
"wss://relay.nostr.band",
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.routstr.com",
|
||||
"wss://nos.lol",
|
||||
]
|
||||
|
||||
provider_id = await _determine_provider_id(public_key_hex, relay_urls)
|
||||
logger.info(f"Using provider_id: {provider_id}")
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"name": provider_name,
|
||||
"about": provider_about,
|
||||
}
|
||||
|
||||
# Create the candidate event that we would publish
|
||||
version_str = get_app_version()
|
||||
candidate_event = create_listing_event(
|
||||
private_key_hex=private_key_hex,
|
||||
provider_id=provider_id,
|
||||
endpoint_urls=endpoint_urls,
|
||||
mint_urls=mint_urls,
|
||||
version=version_str,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
# Backoff configuration and state (sensible defaults)
|
||||
backoff_base = 5.0
|
||||
backoff_max = 900.0
|
||||
backoff_jitter_ratio = 0.2
|
||||
relay_next_allowed: dict[str, float] = {}
|
||||
relay_current_delay: dict[str, float] = {}
|
||||
|
||||
def _should_skip(relay: str) -> bool:
|
||||
return time.time() < relay_next_allowed.get(relay, 0.0)
|
||||
|
||||
def _register_success(relay: str) -> None:
|
||||
relay_current_delay[relay] = 0.0
|
||||
relay_next_allowed[relay] = time.time()
|
||||
|
||||
def _register_failure(relay: str) -> None:
|
||||
previous = relay_current_delay.get(relay, 0.0)
|
||||
delay = backoff_base if previous <= 0.0 else min(backoff_max, previous * 2.0)
|
||||
jitter = delay * backoff_jitter_ratio * (2.0 * random.random() - 1.0)
|
||||
scheduled = time.time() + max(0.0, delay + jitter)
|
||||
relay_current_delay[relay] = delay
|
||||
relay_next_allowed[relay] = scheduled
|
||||
logger.debug(
|
||||
f"Backoff: {relay} delay={delay:.1f}s jitter={jitter:.1f}s next={int(scheduled)}"
|
||||
)
|
||||
|
||||
# Fetch existing events for this provider_id
|
||||
existing_events: list[dict[str, Any]] = []
|
||||
for relay_url in relay_urls:
|
||||
if _should_skip(relay_url):
|
||||
logger.debug(f"Skipping {relay_url} due to backoff")
|
||||
continue
|
||||
events, ok = await query_listing_events(relay_url, public_key_hex, provider_id)
|
||||
if ok:
|
||||
_register_success(relay_url)
|
||||
existing_events.extend(events)
|
||||
else:
|
||||
_register_failure(relay_url)
|
||||
|
||||
# Decide whether to publish: publish if none exist or any differ from candidate
|
||||
found_any = len(existing_events) > 0
|
||||
all_match = found_any and all(
|
||||
events_semantically_equal(ev, candidate_event) for ev in existing_events
|
||||
)
|
||||
|
||||
if not all_match:
|
||||
logger.debug(
|
||||
"No matching listing announcement found or differences detected; publishing update"
|
||||
)
|
||||
success_count = 0
|
||||
for relay_url in relay_urls:
|
||||
if _should_skip(relay_url):
|
||||
logger.debug(f"Skipping publish to {relay_url} due to backoff")
|
||||
continue
|
||||
if await publish_to_relay(relay_url, candidate_event):
|
||||
_register_success(relay_url)
|
||||
success_count += 1
|
||||
else:
|
||||
_register_failure(relay_url)
|
||||
logger.info(
|
||||
f"Published listing announcement to {success_count}/{len(relay_urls)} relays"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Matching listing announcement already present; skipping publish on startup"
|
||||
)
|
||||
|
||||
# Re-announce periodically (every 24 hours)
|
||||
announcement_interval = 24 * 60 * 60
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(announcement_interval)
|
||||
|
||||
# Build fresh candidate event for comparison
|
||||
version_str = get_app_version()
|
||||
candidate_event = create_listing_event(
|
||||
private_key_hex=private_key_hex,
|
||||
provider_id=provider_id,
|
||||
endpoint_urls=endpoint_urls,
|
||||
mint_urls=mint_urls,
|
||||
version=version_str,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
# Fetch existing events for this provider_id
|
||||
existing_events = []
|
||||
for relay_url in relay_urls:
|
||||
if _should_skip(relay_url):
|
||||
logger.debug(f"Skipping {relay_url} due to backoff")
|
||||
continue
|
||||
events, ok = await query_listing_events(
|
||||
relay_url, public_key_hex, provider_id
|
||||
)
|
||||
if ok:
|
||||
_register_success(relay_url)
|
||||
existing_events.extend(events)
|
||||
else:
|
||||
_register_failure(relay_url)
|
||||
|
||||
found_any = len(existing_events) > 0
|
||||
all_match = found_any and all(
|
||||
events_semantically_equal(ev, candidate_event) for ev in existing_events
|
||||
)
|
||||
|
||||
if all_match:
|
||||
logger.debug(
|
||||
"Matching listing announcement already present; skipping periodic re-announce"
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
f"Re-announcing provider due to differences or absence: {candidate_event['id']}"
|
||||
)
|
||||
for relay_url in relay_urls:
|
||||
if _should_skip(relay_url):
|
||||
logger.debug(f"Skipping publish to {relay_url} due to backoff")
|
||||
continue
|
||||
ok = await publish_to_relay(relay_url, candidate_event)
|
||||
if ok:
|
||||
_register_success(relay_url)
|
||||
else:
|
||||
_register_failure(relay_url)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Listing announcement task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"Error in listing announcement loop: {type(e).__name__}")
|
||||
# Continue running despite errors
|
||||
@@ -1,4 +1,4 @@
|
||||
from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
from .cost_calculation import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
|
||||
__all__ = [
|
||||
"CostData",
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import math
|
||||
import os
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from ..core import get_logger
|
||||
from .models import MODELS
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
COST_PER_REQUEST = (
|
||||
int(os.environ.get("COST_PER_REQUEST", "1")) * 1000
|
||||
) # Convert to msats
|
||||
COST_PER_1K_INPUT_TOKENS = (
|
||||
int(os.environ.get("COST_PER_1K_INPUT_TOKENS", "0")) * 1000
|
||||
) # Convert to msats
|
||||
COST_PER_1K_OUTPUT_TOKENS = (
|
||||
int(os.environ.get("COST_PER_1K_OUTPUT_TOKENS", "0")) * 1000
|
||||
) # Convert to msats
|
||||
MODEL_BASED_PRICING = os.environ.get("MODEL_BASED_PRICING", "false").lower() == "true"
|
||||
|
||||
logger.info(
|
||||
"Cost calculation initialized",
|
||||
extra={
|
||||
"cost_per_request_msats": COST_PER_REQUEST,
|
||||
"cost_per_1k_input_tokens_msats": COST_PER_1K_INPUT_TOKENS,
|
||||
"cost_per_1k_output_tokens_msats": COST_PER_1K_OUTPUT_TOKENS,
|
||||
"model_based_pricing": MODEL_BASED_PRICING,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class CostData(BaseModel):
|
||||
base_msats: int
|
||||
input_msats: int
|
||||
output_msats: int
|
||||
total_msats: int
|
||||
|
||||
|
||||
class MaxCostData(CostData):
|
||||
pass
|
||||
|
||||
|
||||
class CostDataError(BaseModel):
|
||||
message: str
|
||||
code: str
|
||||
|
||||
|
||||
def calculate_cost(
|
||||
response_data: dict, max_cost: int
|
||||
) -> CostData | MaxCostData | CostDataError:
|
||||
"""
|
||||
Calculate the cost of an API request based on token usage.
|
||||
|
||||
Args:
|
||||
response_data: Response data containing usage information
|
||||
max_cost: Maximum cost in millisats
|
||||
|
||||
Returns:
|
||||
Cost data or error information
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting cost calculation",
|
||||
extra={
|
||||
"max_cost_msats": max_cost,
|
||||
"has_usage_data": "usage" in response_data,
|
||||
"response_model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
cost_data = MaxCostData(
|
||||
base_msats=max_cost,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=max_cost,
|
||||
)
|
||||
|
||||
if "usage" not in response_data or response_data["usage"] is None:
|
||||
logger.warning(
|
||||
"No usage data in response, using base cost only",
|
||||
extra={
|
||||
"max_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS = COST_PER_1K_INPUT_TOKENS
|
||||
MSATS_PER_1K_OUTPUT_TOKENS = COST_PER_1K_OUTPUT_TOKENS
|
||||
|
||||
if MODEL_BASED_PRICING and MODELS:
|
||||
response_model = response_data.get("model", "")
|
||||
logger.debug(
|
||||
"Using model-based pricing",
|
||||
extra={
|
||||
"model": response_model,
|
||||
"available_models": [model.id for model in MODELS],
|
||||
},
|
||||
)
|
||||
|
||||
if response_model not in [model.id for model in MODELS]:
|
||||
logger.error(
|
||||
"Invalid model in response",
|
||||
extra={
|
||||
"response_model": response_model,
|
||||
"available_models": [model.id for model in MODELS],
|
||||
},
|
||||
)
|
||||
return CostDataError(
|
||||
message=f"Invalid model in response: {response_model}",
|
||||
code="model_not_found",
|
||||
)
|
||||
|
||||
model = next(model for model in MODELS if model.id == response_model)
|
||||
if model.sats_pricing is None:
|
||||
logger.error(
|
||||
"Model pricing not defined",
|
||||
extra={"model": response_model, "model_id": model.id},
|
||||
)
|
||||
return CostDataError(
|
||||
message="Model pricing not defined", code="pricing_not_found"
|
||||
)
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS = model.sats_pricing.prompt * 1_000_000 # type: ignore
|
||||
MSATS_PER_1K_OUTPUT_TOKENS = model.sats_pricing.completion * 1_000_000 # type: ignore
|
||||
|
||||
logger.info(
|
||||
"Applied model-specific pricing",
|
||||
extra={
|
||||
"model": response_model,
|
||||
"input_price_msats_per_1k": MSATS_PER_1K_INPUT_TOKENS,
|
||||
"output_price_msats_per_1k": MSATS_PER_1K_OUTPUT_TOKENS,
|
||||
},
|
||||
)
|
||||
|
||||
if not (MSATS_PER_1K_OUTPUT_TOKENS and MSATS_PER_1K_INPUT_TOKENS):
|
||||
logger.warning(
|
||||
"No token pricing configured, using base cost",
|
||||
extra={
|
||||
"base_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
|
||||
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
|
||||
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
|
||||
|
||||
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||
token_based_cost = math.ceil(input_msats + output_msats)
|
||||
|
||||
logger.info(
|
||||
"Calculated token-based cost",
|
||||
extra={
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"input_cost_msats": input_msats,
|
||||
"output_cost_msats": output_msats,
|
||||
"total_cost_msats": token_based_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return CostData(
|
||||
base_msats=0,
|
||||
input_msats=int(input_msats),
|
||||
output_msats=int(output_msats),
|
||||
total_msats=token_based_cost,
|
||||
)
|
||||
@@ -0,0 +1,237 @@
|
||||
import math
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession
|
||||
from ..core.settings import settings
|
||||
from .price import sats_usd_price
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CostData(BaseModel):
|
||||
base_msats: int
|
||||
input_msats: int
|
||||
output_msats: int
|
||||
total_msats: int
|
||||
total_usd: float = 0.0
|
||||
|
||||
|
||||
class MaxCostData(CostData):
|
||||
pass
|
||||
|
||||
|
||||
class CostDataError(BaseModel):
|
||||
message: str
|
||||
code: str
|
||||
|
||||
|
||||
async def calculate_cost( # todo: can be sync
|
||||
response_data: dict, max_cost: int, session: AsyncSession
|
||||
) -> CostData | MaxCostData | CostDataError:
|
||||
"""
|
||||
Calculate the cost of an API request based on token usage.
|
||||
|
||||
Args:
|
||||
response_data: Response data containing usage information
|
||||
max_cost: Maximum cost in millisats
|
||||
|
||||
Returns:
|
||||
Cost data or error information
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting cost calculation",
|
||||
extra={
|
||||
"max_cost_msats": max_cost,
|
||||
"has_usage_data": "usage" in response_data,
|
||||
"response_model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
if "usage" not in response_data or response_data["usage"] is None:
|
||||
logger.warning(
|
||||
"No usage data in response, using base cost only",
|
||||
extra={
|
||||
"max_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return MaxCostData(
|
||||
base_msats=0,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=0,
|
||||
total_usd=0.0,
|
||||
)
|
||||
|
||||
usage_data = response_data["usage"]
|
||||
|
||||
usd_cost = 0.0
|
||||
|
||||
# Prioritize cost_details.upstream_inference_cost
|
||||
if "cost_details" in usage_data:
|
||||
usd_cost = float(
|
||||
usage_data["cost_details"].get("upstream_inference_cost", 0) or 0
|
||||
)
|
||||
|
||||
# Fallback to cost field if upstream_inference_cost is 0
|
||||
if usd_cost == 0 and "cost" in usage_data:
|
||||
try:
|
||||
usd_cost = float(usage_data.get("cost", 0) or 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if usd_cost > 0:
|
||||
try:
|
||||
sats_per_usd = 1.0 / sats_usd_price()
|
||||
cost_in_sats = usd_cost * sats_per_usd
|
||||
cost_in_msats = math.ceil(cost_in_sats * 1000)
|
||||
|
||||
logger.info(
|
||||
"Using cost from usage data/details",
|
||||
extra={
|
||||
"usd_cost": usd_cost,
|
||||
"cost_in_sats": cost_in_sats,
|
||||
"cost_in_msats": cost_in_msats,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return CostData(
|
||||
base_msats=-1,
|
||||
input_msats=-1, # Cost field doesn't break down by token type
|
||||
output_msats=-1,
|
||||
total_msats=cost_in_msats,
|
||||
total_usd=usd_cost,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Error calculating cost from usage data",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"usd_cost": usd_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
# Fall through to token-based calculation
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS: float = (
|
||||
float(settings.fixed_per_1k_input_tokens) * 1000.0
|
||||
)
|
||||
MSATS_PER_1K_OUTPUT_TOKENS: float = (
|
||||
float(settings.fixed_per_1k_output_tokens) * 1000.0
|
||||
)
|
||||
|
||||
if not settings.fixed_pricing:
|
||||
response_model = response_data.get("model", "")
|
||||
logger.debug(
|
||||
"Using model-based pricing",
|
||||
extra={"model": response_model},
|
||||
)
|
||||
|
||||
from ..proxy import get_model_instance
|
||||
|
||||
model_obj = get_model_instance(response_model)
|
||||
|
||||
if not model_obj:
|
||||
logger.error(
|
||||
"Invalid model in response",
|
||||
extra={"response_model": response_model},
|
||||
)
|
||||
return CostDataError(
|
||||
message=f"Invalid model in response: {response_model}",
|
||||
code="model_not_found",
|
||||
)
|
||||
|
||||
if not model_obj.sats_pricing:
|
||||
logger.error(
|
||||
"Model pricing not defined",
|
||||
extra={"model": response_model, "model_id": response_model},
|
||||
)
|
||||
return CostDataError(
|
||||
message="Model pricing not defined", code="pricing_not_found"
|
||||
)
|
||||
|
||||
try:
|
||||
mspp = float(model_obj.sats_pricing.prompt)
|
||||
mspc = float(model_obj.sats_pricing.completion)
|
||||
except Exception:
|
||||
return CostDataError(message="Invalid pricing data", code="pricing_invalid")
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS = mspp * 1_000_000.0
|
||||
MSATS_PER_1K_OUTPUT_TOKENS = mspc * 1_000_000.0
|
||||
|
||||
logger.info(
|
||||
"Applied model-specific pricing",
|
||||
extra={
|
||||
"model": response_model,
|
||||
"input_price_msats_per_1k": MSATS_PER_1K_INPUT_TOKENS,
|
||||
"output_price_msats_per_1k": MSATS_PER_1K_OUTPUT_TOKENS,
|
||||
},
|
||||
)
|
||||
|
||||
if not (MSATS_PER_1K_OUTPUT_TOKENS and MSATS_PER_1K_INPUT_TOKENS):
|
||||
logger.warning(
|
||||
"No token pricing configured, using base cost",
|
||||
extra={
|
||||
"base_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return MaxCostData(
|
||||
base_msats=max_cost,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=max_cost,
|
||||
)
|
||||
|
||||
input_tokens = usage_data.get("prompt_tokens", 0)
|
||||
output_tokens = usage_data.get("completion_tokens", 0)
|
||||
|
||||
# added for response api
|
||||
input_tokens = (
|
||||
input_tokens if input_tokens != 0 else usage_data.get("input_tokens", 0)
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens if output_tokens != 0 else usage_data.get("output_tokens", 0)
|
||||
)
|
||||
|
||||
# added for response api
|
||||
input_tokens = (
|
||||
input_tokens
|
||||
if input_tokens != 0
|
||||
else response_data.get("usage", {}).get("input_tokens", 0)
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens
|
||||
if output_tokens != 0
|
||||
else response_data.get("usage", {}).get("output_tokens", 0)
|
||||
)
|
||||
|
||||
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||
|
||||
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||
token_based_cost = math.ceil(input_msats + output_msats)
|
||||
total_usd = (token_based_cost / 1000.0) * sats_usd_price()
|
||||
|
||||
logger.info(
|
||||
"Calculated token-based cost",
|
||||
extra={
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"input_cost_msats": input_msats,
|
||||
"output_cost_msats": output_msats,
|
||||
"total_cost_msats": token_based_cost,
|
||||
"total_usd": total_usd,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return CostData(
|
||||
base_msats=0,
|
||||
input_msats=int(input_msats),
|
||||
output_msats=int(output_msats),
|
||||
total_msats=token_based_cost,
|
||||
total_usd=total_usd,
|
||||
)
|
||||
+312
-76
@@ -1,24 +1,22 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import math
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, Response
|
||||
from fastapi.requests import Request
|
||||
from PIL import Image
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.settings import settings
|
||||
from ..wallet import deserialize_token_from_string
|
||||
from .cost_caculation import COST_PER_REQUEST, MODEL_BASED_PRICING
|
||||
from .models import MODELS
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
UPSTREAM_BASE_URL = os.environ.get("UPSTREAM_BASE_URL", "")
|
||||
UPSTREAM_API_KEY = os.environ.get("UPSTREAM_API_KEY", "")
|
||||
|
||||
if not UPSTREAM_BASE_URL:
|
||||
raise ValueError("Please set the UPSTREAM_BASE_URL environment variable")
|
||||
|
||||
|
||||
def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> None:
|
||||
if x_cashu := headers.get("x-cashu", None):
|
||||
cashu_token = x_cashu
|
||||
@@ -87,49 +85,333 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
|
||||
)
|
||||
|
||||
|
||||
def get_max_cost_for_model(model: str, tolerance_percentage: int = 1) -> int:
|
||||
"""Get the maximum cost for a specific model."""
|
||||
async def get_max_cost_for_model(
|
||||
model: str,
|
||||
session: AsyncSession,
|
||||
model_obj: Any | None = None,
|
||||
) -> int:
|
||||
"""Get the maximum cost for a specific model from providers with overrides."""
|
||||
logger.debug(
|
||||
"Getting max cost for model",
|
||||
extra={
|
||||
"model": model,
|
||||
"model_based_pricing": MODEL_BASED_PRICING,
|
||||
"has_models": bool(MODELS),
|
||||
"fixed_pricing": settings.fixed_pricing,
|
||||
},
|
||||
)
|
||||
|
||||
if not MODEL_BASED_PRICING or not MODELS:
|
||||
if settings.fixed_pricing:
|
||||
default_cost_msats = settings.fixed_cost_per_request * 1000
|
||||
logger.debug(
|
||||
"Using default cost (no model-based pricing)",
|
||||
extra={"cost_msats": COST_PER_REQUEST, "model": model},
|
||||
"Using fixed cost pricing",
|
||||
extra={"cost_msats": default_cost_msats, "model": model},
|
||||
)
|
||||
return COST_PER_REQUEST
|
||||
return max(settings.min_request_msat, default_cost_msats)
|
||||
|
||||
if model not in [model.id for model in MODELS]:
|
||||
if not model_obj:
|
||||
from ..proxy import get_model_instance
|
||||
|
||||
model_obj = get_model_instance(model)
|
||||
|
||||
if not model_obj:
|
||||
fallback_msats = settings.fixed_cost_per_request * 1000
|
||||
logger.warning(
|
||||
"Model not found in available models",
|
||||
"Model not found in providers or overrides",
|
||||
extra={
|
||||
"requested_model": model,
|
||||
"available_models": [m.id for m in MODELS],
|
||||
"using_default_cost": COST_PER_REQUEST,
|
||||
"using_default_cost": fallback_msats,
|
||||
},
|
||||
)
|
||||
return COST_PER_REQUEST
|
||||
return max(settings.min_request_msat, fallback_msats)
|
||||
|
||||
for m in MODELS:
|
||||
if m.id == model:
|
||||
max_cost = m.sats_pricing.max_cost * 1000 * (1 - tolerance_percentage / 100) # type: ignore
|
||||
if model_obj.sats_pricing:
|
||||
try:
|
||||
max_cost = (
|
||||
model_obj.sats_pricing.max_cost
|
||||
* 1000
|
||||
* (1 - settings.tolerance_percentage / 100)
|
||||
)
|
||||
logger.debug(
|
||||
"Found model-specific max cost",
|
||||
extra={"model": model, "max_cost_msats": max_cost},
|
||||
)
|
||||
return int(max_cost)
|
||||
calculated_msats = int(max_cost)
|
||||
return max(settings.min_request_msat, calculated_msats)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error calculating max cost from model pricing",
|
||||
extra={"model": model, "error": str(e)},
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"Model pricing not found, using default",
|
||||
extra={"model": model, "default_cost_msats": COST_PER_REQUEST},
|
||||
"Model pricing not found, using fixed cost",
|
||||
extra={
|
||||
"model": model,
|
||||
"default_cost_msats": settings.fixed_cost_per_request * 1000,
|
||||
},
|
||||
)
|
||||
return COST_PER_REQUEST
|
||||
return max(settings.min_request_msat, settings.fixed_cost_per_request * 1000)
|
||||
|
||||
|
||||
async def calculate_discounted_max_cost(
|
||||
max_cost_for_model: int,
|
||||
body: dict,
|
||||
model_obj: Any | None = None,
|
||||
) -> int:
|
||||
"""Calculate the discounted max cost for a request using model pricing when available."""
|
||||
if settings.fixed_pricing:
|
||||
return max_cost_for_model
|
||||
|
||||
model = body.get("model", "unknown")
|
||||
|
||||
model_pricing = model_obj.sats_pricing if model_obj else None
|
||||
if not model_pricing:
|
||||
return max_cost_for_model
|
||||
|
||||
tol = settings.tolerance_percentage
|
||||
tol_factor = max(0.0, 1 - float(tol) / 100.0)
|
||||
|
||||
max_prompt_allowed_sats = model_pricing.max_prompt_cost * tol_factor
|
||||
max_completion_allowed_sats = model_pricing.max_completion_cost * tol_factor
|
||||
|
||||
if model_obj:
|
||||
prompt_token_limit: int | None = None
|
||||
if model_obj.top_provider and (
|
||||
model_obj.top_provider.context_length
|
||||
or model_obj.top_provider.max_completion_tokens
|
||||
):
|
||||
cl = model_obj.top_provider.context_length
|
||||
mct = model_obj.top_provider.max_completion_tokens
|
||||
if cl and mct:
|
||||
prompt_token_limit = max(0, cl - mct)
|
||||
elif cl:
|
||||
prompt_token_limit = cl
|
||||
elif mct:
|
||||
prompt_token_limit = 0
|
||||
elif model_obj.context_length:
|
||||
prompt_token_limit = model_obj.context_length
|
||||
|
||||
if prompt_token_limit is not None:
|
||||
max_prompt_allowed_sats = (
|
||||
prompt_token_limit * model_pricing.prompt * tol_factor
|
||||
)
|
||||
|
||||
adjusted = max_cost_for_model
|
||||
|
||||
if messages := body.get("messages"):
|
||||
prompt_tokens = estimate_tokens(messages)
|
||||
|
||||
image_tokens = await estimate_image_tokens_in_messages(messages)
|
||||
if image_tokens > 0:
|
||||
logger.debug(
|
||||
"Found images in request",
|
||||
extra={
|
||||
"model": model,
|
||||
"image_tokens": image_tokens,
|
||||
},
|
||||
)
|
||||
prompt_tokens += image_tokens
|
||||
|
||||
estimated_prompt_delta_sats = (
|
||||
max_prompt_allowed_sats - prompt_tokens * model_pricing.prompt
|
||||
)
|
||||
if estimated_prompt_delta_sats > 0:
|
||||
adjusted = adjusted - math.floor(estimated_prompt_delta_sats * 1000)
|
||||
|
||||
max_tokens_raw = body.get("max_tokens", None)
|
||||
if max_tokens_raw is not None:
|
||||
try:
|
||||
max_tokens_int = int(max_tokens_raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Invalid max_tokens; ignoring in cost adjustment",
|
||||
extra={"max_tokens": str(max_tokens_raw)[:64], "model": model},
|
||||
)
|
||||
else:
|
||||
estimated_completion_delta_sats = (
|
||||
max_completion_allowed_sats - max_tokens_int * model_pricing.completion
|
||||
)
|
||||
if estimated_completion_delta_sats > 0:
|
||||
adjusted = adjusted - math.floor(estimated_completion_delta_sats * 1000)
|
||||
|
||||
logger.debug(
|
||||
"Discounted max cost computed",
|
||||
extra={
|
||||
"model": model,
|
||||
"original_msats": max_cost_for_model,
|
||||
"adjusted_msats": adjusted,
|
||||
"tolerance_pct": tol,
|
||||
},
|
||||
)
|
||||
|
||||
return max(0, adjusted)
|
||||
|
||||
|
||||
def estimate_tokens(messages: list) -> int:
|
||||
"""Estimate tokens for text content, excluding image_url fields."""
|
||||
total = 0
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict):
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
total += len(content)
|
||||
elif isinstance(content, list):
|
||||
total += sum(
|
||||
len(item.get("text", ""))
|
||||
for item in content
|
||||
if isinstance(item, dict) and item.get("type") == "text"
|
||||
)
|
||||
return total // 3
|
||||
|
||||
|
||||
def _get_image_dimensions(image_data: bytes) -> tuple[int, int]:
|
||||
"""Extract image dimensions from image bytes."""
|
||||
try:
|
||||
img = Image.open(BytesIO(image_data))
|
||||
return img.size
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to get image dimensions, using default",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
return (512, 512)
|
||||
|
||||
|
||||
async def _fetch_image_from_url(url: str) -> bytes | None:
|
||||
"""Fetch image from URL."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to fetch image from URL",
|
||||
extra={"error": str(e), "url": url[:100]},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _calculate_image_tokens(width: int, height: int, detail: str = "auto") -> int:
|
||||
"""Calculate image tokens based on OpenAI's vision pricing.
|
||||
|
||||
For low detail: 85 tokens
|
||||
For high detail/auto: 85 base tokens + 170 tokens per 512px tile
|
||||
"""
|
||||
if detail == "low":
|
||||
return 85
|
||||
|
||||
if width > 2048 or height > 2048:
|
||||
aspect_ratio = width / height
|
||||
if width > height:
|
||||
width = 2048
|
||||
height = int(width / aspect_ratio)
|
||||
else:
|
||||
height = 2048
|
||||
width = int(height * aspect_ratio)
|
||||
|
||||
if width > 768 or height > 768:
|
||||
aspect_ratio = width / height
|
||||
if width > height:
|
||||
width = 768
|
||||
height = int(width / aspect_ratio)
|
||||
else:
|
||||
height = 768
|
||||
width = int(height * aspect_ratio)
|
||||
|
||||
tiles_width = (width + 511) // 512
|
||||
tiles_height = (height + 511) // 512
|
||||
num_tiles = tiles_width * tiles_height
|
||||
|
||||
return 85 + (170 * num_tiles)
|
||||
|
||||
|
||||
async def estimate_image_tokens_in_messages(messages: list) -> int:
|
||||
"""Estimate total tokens for all images in messages.
|
||||
|
||||
Supports both base64 encoded images and image URLs.
|
||||
"""
|
||||
total_image_tokens = 0
|
||||
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
|
||||
content = message.get("content")
|
||||
if not content:
|
||||
continue
|
||||
|
||||
if isinstance(content, str):
|
||||
continue
|
||||
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
|
||||
for content_item in content:
|
||||
if not isinstance(content_item, dict):
|
||||
continue
|
||||
|
||||
content_type = content_item.get("type")
|
||||
if content_type not in ("image_url", "input_image"):
|
||||
continue
|
||||
|
||||
image_url_data = content_item.get("image_url")
|
||||
if not image_url_data:
|
||||
continue
|
||||
|
||||
if isinstance(image_url_data, str):
|
||||
url = image_url_data
|
||||
detail = "auto"
|
||||
elif isinstance(image_url_data, dict):
|
||||
url = image_url_data.get("url", "")
|
||||
detail = image_url_data.get("detail", "auto")
|
||||
else:
|
||||
continue
|
||||
|
||||
if not url:
|
||||
continue
|
||||
|
||||
if url.startswith("data:image/"):
|
||||
try:
|
||||
header, base64_data = url.split(",", 1)
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
width, height = _get_image_dimensions(image_bytes)
|
||||
tokens = _calculate_image_tokens(width, height, detail)
|
||||
total_image_tokens += tokens
|
||||
logger.debug(
|
||||
"Calculated tokens for base64 image",
|
||||
extra={
|
||||
"width": width,
|
||||
"height": height,
|
||||
"detail": detail,
|
||||
"tokens": tokens,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to process base64 image",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
total_image_tokens += 85
|
||||
else:
|
||||
image_bytes_or_none = await _fetch_image_from_url(url)
|
||||
if image_bytes_or_none:
|
||||
width, height = _get_image_dimensions(image_bytes_or_none)
|
||||
tokens = _calculate_image_tokens(width, height, detail)
|
||||
total_image_tokens += tokens
|
||||
logger.debug(
|
||||
"Calculated tokens for URL image",
|
||||
extra={
|
||||
"url": url[:100],
|
||||
"width": width,
|
||||
"height": height,
|
||||
"detail": detail,
|
||||
"tokens": tokens,
|
||||
},
|
||||
)
|
||||
else:
|
||||
total_image_tokens += 85
|
||||
|
||||
return total_image_tokens
|
||||
|
||||
|
||||
def create_error_response(
|
||||
@@ -155,49 +437,3 @@ def create_error_response(
|
||||
media_type="application/json",
|
||||
headers={"X-Cashu": token} if token else {},
|
||||
)
|
||||
|
||||
|
||||
def prepare_upstream_headers(request_headers: dict) -> dict:
|
||||
"""Prepare headers for upstream request, removing sensitive/problematic ones."""
|
||||
logger.debug(
|
||||
"Preparing upstream headers",
|
||||
extra={
|
||||
"original_headers_count": len(request_headers),
|
||||
"has_upstream_api_key": bool(UPSTREAM_API_KEY),
|
||||
},
|
||||
)
|
||||
|
||||
headers = dict(request_headers)
|
||||
|
||||
# Remove headers that shouldn't be forwarded
|
||||
removed_headers = []
|
||||
for header in [
|
||||
"host",
|
||||
"content-length",
|
||||
"refund-lnurl",
|
||||
"key-expiry-time",
|
||||
"x-cashu",
|
||||
]:
|
||||
if headers.pop(header, None) is not None:
|
||||
removed_headers.append(header)
|
||||
|
||||
# Handle authorization
|
||||
if UPSTREAM_API_KEY:
|
||||
headers["Authorization"] = f"Bearer {UPSTREAM_API_KEY}"
|
||||
if headers.pop("authorization", None) is not None:
|
||||
removed_headers.append("authorization (replaced with upstream key)")
|
||||
else:
|
||||
for auth_header in ["Authorization", "authorization"]:
|
||||
if headers.pop(auth_header, None) is not None:
|
||||
removed_headers.append(auth_header)
|
||||
|
||||
logger.debug(
|
||||
"Headers prepared for upstream",
|
||||
extra={
|
||||
"final_headers_count": len(headers),
|
||||
"removed_headers": removed_headers,
|
||||
"added_upstream_auth": bool(UPSTREAM_API_KEY),
|
||||
},
|
||||
)
|
||||
|
||||
return headers
|
||||
|
||||
+16
-81
@@ -25,82 +25,6 @@ class LNURLError(Exception):
|
||||
"""LNURL related errors."""
|
||||
|
||||
|
||||
def parse_lightning_invoice_amount(invoice: str, currency: str = "sat") -> int:
|
||||
"""Parse Lightning invoice (BOLT-11) to extract amount in specified currency units.
|
||||
|
||||
Args:
|
||||
invoice: BOLT-11 Lightning invoice string
|
||||
currency: Target currency unit ("sat" or "msat")
|
||||
|
||||
Returns:
|
||||
Amount in the specified currency unit
|
||||
|
||||
Raises:
|
||||
LNURLError: If invoice format is invalid or amount cannot be parsed
|
||||
"""
|
||||
invoice = invoice.lower().strip()
|
||||
|
||||
if not invoice.startswith("ln"):
|
||||
raise LNURLError("Invalid Lightning invoice format")
|
||||
|
||||
# Find the network part (bc, tb, etc.)
|
||||
network_start = 2
|
||||
while network_start < len(invoice) and invoice[network_start] not in "0123456789":
|
||||
network_start += 1
|
||||
|
||||
if network_start >= len(invoice):
|
||||
raise LNURLError("Invalid Lightning invoice format")
|
||||
|
||||
# Parse amount and multiplier
|
||||
amount_str = ""
|
||||
multiplier = ""
|
||||
i = network_start
|
||||
|
||||
# Extract numeric part
|
||||
while i < len(invoice) and invoice[i].isdigit():
|
||||
amount_str += invoice[i]
|
||||
i += 1
|
||||
|
||||
# Extract multiplier if present
|
||||
if i < len(invoice) and invoice[i] in "munp":
|
||||
multiplier = invoice[i]
|
||||
i += 1
|
||||
|
||||
# Check if we have the required "1" separator
|
||||
if i >= len(invoice) or invoice[i] != "1":
|
||||
raise LNURLError("Invalid Lightning invoice format")
|
||||
|
||||
if not amount_str:
|
||||
raise LNURLError("Lightning invoice amount not specified")
|
||||
|
||||
# Convert to base units
|
||||
try:
|
||||
amount = int(amount_str)
|
||||
except ValueError:
|
||||
raise LNURLError("Invalid Lightning invoice amount")
|
||||
|
||||
# Apply multiplier to get millisatoshis
|
||||
if multiplier == "m": # milli = 10^-3
|
||||
amount_msat = amount * 100_000_000 # amount is in BTC * 10^-3
|
||||
elif multiplier == "u": # micro = 10^-6
|
||||
amount_msat = amount * 100_000 # amount is in BTC * 10^-6
|
||||
elif multiplier == "n": # nano = 10^-9
|
||||
amount_msat = amount * 100 # amount is in BTC * 10^-9
|
||||
elif multiplier == "p": # pico = 10^-12
|
||||
amount_msat = amount // 10 # amount is in BTC * 10^-12
|
||||
else:
|
||||
# No multiplier means the amount is in BTC
|
||||
amount_msat = amount * 100_000_000_000 # Convert BTC to msat
|
||||
|
||||
# Convert to target currency unit
|
||||
if currency == "msat":
|
||||
return amount_msat
|
||||
elif currency == "sat":
|
||||
return amount_msat // 1000
|
||||
else:
|
||||
raise LNURLError(f"Unsupported currency for Lightning: {currency}")
|
||||
|
||||
|
||||
async def decode_lnurl(lnurl: str) -> str:
|
||||
"""Decode LNURL to get the actual URL.
|
||||
|
||||
@@ -230,7 +154,11 @@ async def get_lnurl_invoice(
|
||||
|
||||
|
||||
async def raw_send_to_lnurl(
|
||||
wallet: Wallet, proofs: list[Proof], lnurl: str, unit: str
|
||||
wallet: Wallet,
|
||||
proofs: list[Proof],
|
||||
lnurl: str,
|
||||
unit: str,
|
||||
amount: int | None = None,
|
||||
) -> int:
|
||||
"""Send funds to an LNURL address.
|
||||
|
||||
@@ -255,6 +183,11 @@ async def raw_send_to_lnurl(
|
||||
paid = await wallet.send_to_lnurl("user@getalby.com", 50, unit="usd")
|
||||
"""
|
||||
total_balance = sum(proof.amount for proof in proofs)
|
||||
if amount and total_balance < amount:
|
||||
raise ValueError("Amount to send is higher than available proofs.")
|
||||
else:
|
||||
assert isinstance(amount, int)
|
||||
total_balance = amount
|
||||
lnurl_data = await get_lnurl_data(lnurl)
|
||||
|
||||
if unit == "sat":
|
||||
@@ -274,7 +207,7 @@ async def raw_send_to_lnurl(
|
||||
f"({min_sendable_sat} - {max_sendable_sat} {unit})"
|
||||
)
|
||||
|
||||
estimated_fees_sat = int(max(math.ceil((amount_msat / 1000) * 0.01), 2))
|
||||
estimated_fees_sat = int(max(math.ceil((amount_msat / 1000) * 0.01), 2)) + 1
|
||||
estimated_fees_msat = estimated_fees_sat * 1000
|
||||
final_amount = amount_msat - estimated_fees_msat
|
||||
|
||||
@@ -282,9 +215,11 @@ async def raw_send_to_lnurl(
|
||||
lnurl_data["callback_url"], final_amount
|
||||
)
|
||||
|
||||
melt_quote_resp = await wallet.melt_quote(
|
||||
invoice=bolt11_invoice, amount_msat=final_amount
|
||||
)
|
||||
melt_quote_resp = await wallet.melt_quote(invoice=bolt11_invoice)
|
||||
|
||||
if amount:
|
||||
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
|
||||
|
||||
_ = await wallet.melt(
|
||||
proofs=proofs,
|
||||
invoice=bolt11_invoice,
|
||||
|
||||
+325
-94
@@ -1,14 +1,16 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen
|
||||
import random
|
||||
|
||||
from fastapi import APIRouter
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic.v1 import BaseModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..core.db import ModelRow, get_session
|
||||
from ..core.logging import get_logger
|
||||
from .price import sats_usd_ask_price
|
||||
from ..core.settings import settings
|
||||
from .price import sats_usd_price
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -26,10 +28,14 @@ class Architecture(BaseModel):
|
||||
class Pricing(BaseModel):
|
||||
prompt: float
|
||||
completion: float
|
||||
request: float
|
||||
image: float
|
||||
web_search: float
|
||||
internal_reasoning: float
|
||||
request: float = 0.0
|
||||
image: float = 0.0
|
||||
web_search: float = 0.0
|
||||
internal_reasoning: float = 0.0
|
||||
input_cache_read: float = 0.0
|
||||
input_cache_write: float = 0.0
|
||||
max_prompt_cost: float = 0.0 # in sats not msats
|
||||
max_completion_cost: float = 0.0 # in sats not msats
|
||||
max_cost: float = 0.0 # in sats not msats
|
||||
|
||||
|
||||
@@ -50,21 +56,68 @@ class Model(BaseModel):
|
||||
sats_pricing: Pricing | None = None
|
||||
per_request_limits: dict | None = None
|
||||
top_provider: TopProvider | None = None
|
||||
enabled: bool = True
|
||||
upstream_provider_id: int | str | None = None
|
||||
canonical_slug: str | None = None
|
||||
alias_ids: list[str] | None = None
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
MODELS: list[Model] = []
|
||||
|
||||
|
||||
def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
"""Fetches model information from OpenRouter API."""
|
||||
base_url = os.getenv("BASE_URL", "https://openrouter.ai/api/v1")
|
||||
def _has_valid_pricing(model: dict) -> bool:
|
||||
"""Check if model has valid pricing (not free, no negative values)."""
|
||||
pricing = model.get("pricing", {})
|
||||
if not pricing:
|
||||
return False
|
||||
|
||||
try:
|
||||
with urlopen(f"{base_url}/models") as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
prompt = float(pricing.get("prompt", 0))
|
||||
completion = float(pricing.get("completion", 0))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
if prompt < 0 or completion < 0:
|
||||
return False
|
||||
|
||||
if prompt == 0 and completion == 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
"""Asynchronously fetch model information from OpenRouter API."""
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
models_response, embeddings_response = await asyncio.gather(
|
||||
client.get(f"{base_url}/models", timeout=30),
|
||||
client.get(f"{base_url}/embeddings/models", timeout=30),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
def process_models_response(
|
||||
response: httpx.Response | BaseException,
|
||||
) -> list[dict]:
|
||||
if not isinstance(response, BaseException):
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return [
|
||||
model
|
||||
for model in data.get("data", [])
|
||||
if ":free" not in model.get("id", "").lower()
|
||||
]
|
||||
return []
|
||||
|
||||
models_data: list[dict] = []
|
||||
for model in data.get("data", []):
|
||||
models_data.extend(process_models_response(models_response))
|
||||
models_data.extend(process_models_response(embeddings_response))
|
||||
|
||||
# Apply source filter and exclusions
|
||||
filtered_models = []
|
||||
for model in models_data:
|
||||
model_id = model.get("id", "")
|
||||
|
||||
if source_filter:
|
||||
@@ -76,106 +129,284 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
model["id"] = model_id[len(source_prefix) :]
|
||||
model_id = model["id"]
|
||||
|
||||
if (
|
||||
"(free)" in model.get("name", "")
|
||||
or model_id == "openrouter/auto"
|
||||
or model_id == "google/gemini-2.5-pro-exp-03-25"
|
||||
):
|
||||
if "(free)" in model.get("name", ""):
|
||||
continue
|
||||
|
||||
models_data.append(model)
|
||||
if not _has_valid_pricing(model):
|
||||
continue
|
||||
|
||||
return models_data
|
||||
filtered_models.append(model)
|
||||
|
||||
return filtered_models
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching models from OpenRouter API: {e}")
|
||||
logger.error(f"Error (async) fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def load_models() -> list[Model]:
|
||||
"""Load model definitions from a JSON file or auto-generate from OpenRouter API.
|
||||
def _row_to_model(
|
||||
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
|
||||
) -> Model:
|
||||
architecture = json.loads(row.architecture)
|
||||
pricing = json.loads(row.pricing)
|
||||
per_request_limits = (
|
||||
json.loads(row.per_request_limits) if row.per_request_limits else None
|
||||
)
|
||||
top_provider_dict = json.loads(row.top_provider) if row.top_provider else None
|
||||
|
||||
The file path can be specified via the ``MODELS_PATH`` environment variable.
|
||||
If a user-provided models.json exists, it will be used. Otherwise, models are
|
||||
automatically fetched from OpenRouter API in memory. If the example file exists
|
||||
and no user file is provided, it will be used as a fallback.
|
||||
if apply_provider_fee and isinstance(pricing, dict):
|
||||
pricing = {k: float(v) * provider_fee for k, v in pricing.items()}
|
||||
|
||||
if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0:
|
||||
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
|
||||
|
||||
parsed_pricing = Pricing.parse_obj(pricing)
|
||||
model = Model(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
created=row.created,
|
||||
description=row.description,
|
||||
context_length=row.context_length,
|
||||
architecture=Architecture.parse_obj(architecture),
|
||||
pricing=parsed_pricing,
|
||||
sats_pricing=None,
|
||||
per_request_limits=per_request_limits,
|
||||
top_provider=TopProvider.parse_obj(top_provider_dict)
|
||||
if top_provider_dict
|
||||
else None,
|
||||
enabled=row.enabled,
|
||||
upstream_provider_id=row.upstream_provider_id,
|
||||
canonical_slug=getattr(row, "canonical_slug", None),
|
||||
alias_ids=json.loads(row.alias_ids) if row.alias_ids else None,
|
||||
)
|
||||
|
||||
if apply_provider_fee:
|
||||
(
|
||||
parsed_pricing.max_prompt_cost,
|
||||
parsed_pricing.max_completion_cost,
|
||||
parsed_pricing.max_cost,
|
||||
) = _calculate_usd_max_costs(model)
|
||||
|
||||
try:
|
||||
sats_to_usd = sats_usd_price()
|
||||
model = _update_model_sats_pricing(model, sats_to_usd)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not calculate sats pricing: {e}")
|
||||
|
||||
return model
|
||||
|
||||
|
||||
async def list_models(
|
||||
session: AsyncSession,
|
||||
upstream_id: int,
|
||||
include_disabled: bool = False,
|
||||
apply_fees: bool = True,
|
||||
) -> list[Model]:
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
query = select(ModelRow)
|
||||
if upstream_id is not None:
|
||||
query = query.where(ModelRow.upstream_provider_id == upstream_id)
|
||||
if not include_disabled:
|
||||
query = query.where(ModelRow.enabled)
|
||||
|
||||
rows = (await session.exec(query)).all() # type: ignore
|
||||
provider_result = await session.exec(select(UpstreamProviderRow))
|
||||
providers_by_id = {p.id: p for p in provider_result.all()}
|
||||
return [
|
||||
_row_to_model(
|
||||
r,
|
||||
apply_provider_fee=apply_fees,
|
||||
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
|
||||
if r.upstream_provider_id in providers_by_id
|
||||
else 1.01,
|
||||
)
|
||||
for r in rows
|
||||
if include_disabled
|
||||
or (
|
||||
r.upstream_provider_id in providers_by_id
|
||||
and providers_by_id[r.upstream_provider_id].enabled
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
|
||||
"""Calculate max costs in USD based on model context/token limits.
|
||||
|
||||
Args:
|
||||
model: Model object
|
||||
|
||||
Returns:
|
||||
Tuple of (max_prompt_cost, max_completion_cost, max_cost) in USD
|
||||
"""
|
||||
min_req_msat = max(1, int(getattr(settings, "min_request_msat", 1)))
|
||||
min_req_usd = float(min_req_msat) / 1_000_000.0
|
||||
|
||||
models_path = Path(os.environ.get("MODELS_PATH", "models.json"))
|
||||
prompt_price = model.pricing.prompt
|
||||
completion_price = model.pricing.completion
|
||||
|
||||
# Check if user has actively provided a models.json file
|
||||
if models_path.exists():
|
||||
logger.info(f"Loading models from user-provided file: {models_path}")
|
||||
try:
|
||||
with models_path.open("r") as f:
|
||||
data = json.load(f)
|
||||
return [Model(**model) for model in data.get("models", [])]
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading models from {models_path}: {e}")
|
||||
# Fall through to auto-generation
|
||||
if model.top_provider and (
|
||||
model.top_provider.context_length or model.top_provider.max_completion_tokens
|
||||
):
|
||||
if (cl := model.top_provider.context_length) and (
|
||||
mct := model.top_provider.max_completion_tokens
|
||||
):
|
||||
if cl <= mct:
|
||||
return (
|
||||
cl * prompt_price,
|
||||
cl * completion_price,
|
||||
cl * max(completion_price, prompt_price),
|
||||
)
|
||||
return (
|
||||
cl * prompt_price,
|
||||
mct * completion_price,
|
||||
(cl - mct) * prompt_price + mct * completion_price,
|
||||
)
|
||||
elif cl := model.top_provider.context_length:
|
||||
return (
|
||||
cl * prompt_price,
|
||||
cl * completion_price,
|
||||
cl * max(completion_price, prompt_price),
|
||||
)
|
||||
elif mct := model.top_provider.max_completion_tokens:
|
||||
return (
|
||||
mct * prompt_price,
|
||||
mct * completion_price,
|
||||
mct * completion_price,
|
||||
)
|
||||
elif model.context_length:
|
||||
return (
|
||||
model.context_length * prompt_price,
|
||||
model.context_length * completion_price,
|
||||
model.context_length * max(completion_price, prompt_price),
|
||||
)
|
||||
|
||||
# Auto-generate models from OpenRouter API
|
||||
logger.info("Auto-generating models from OpenRouter API")
|
||||
source_filter = os.getenv("SOURCE")
|
||||
source_filter = source_filter if source_filter and source_filter.strip() else None
|
||||
|
||||
models_data = fetch_openrouter_models(source_filter=source_filter)
|
||||
if not models_data:
|
||||
logger.error("Failed to fetch models from OpenRouter API")
|
||||
return []
|
||||
|
||||
logger.info(f"Successfully fetched {len(models_data)} models from OpenRouter API")
|
||||
return [Model(**model) for model in models_data]
|
||||
p = prompt_price * 1_000_000
|
||||
c = completion_price * 32_000
|
||||
r = model.pricing.request * 100_000
|
||||
i = model.pricing.image * 100
|
||||
w = model.pricing.web_search * 1000
|
||||
ir = model.pricing.internal_reasoning * 100
|
||||
return (p, c, max(p + c + r + i + w + ir, min_req_usd))
|
||||
|
||||
|
||||
MODELS = load_models()
|
||||
def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
|
||||
"""Update a model's sats_pricing based on USD pricing and exchange rate.
|
||||
|
||||
Args:
|
||||
model: Model object to update
|
||||
sats_to_usd: Current sats to USD exchange rate
|
||||
|
||||
Returns:
|
||||
Updated Model object with new sats_pricing
|
||||
"""
|
||||
try:
|
||||
min_req_msat = max(1, int(getattr(settings, "min_request_msat", 1)))
|
||||
min_req_sats = float(min_req_msat) / 1000.0
|
||||
|
||||
sats = Pricing.parse_obj(
|
||||
{k: v / sats_to_usd for k, v in model.pricing.dict().items()}
|
||||
)
|
||||
|
||||
if sats.request <= 0.0:
|
||||
sats.request = min_req_sats
|
||||
if (sats.max_cost or 0.0) < min_req_sats:
|
||||
sats.max_cost = min_req_sats
|
||||
|
||||
return Model(
|
||||
id=model.id,
|
||||
name=model.name,
|
||||
created=model.created,
|
||||
description=model.description,
|
||||
context_length=model.context_length,
|
||||
architecture=model.architecture,
|
||||
pricing=model.pricing,
|
||||
sats_pricing=sats,
|
||||
per_request_limits=model.per_request_limits,
|
||||
top_provider=model.top_provider,
|
||||
enabled=model.enabled,
|
||||
upstream_provider_id=model.upstream_provider_id,
|
||||
canonical_slug=model.canonical_slug,
|
||||
alias_ids=model.alias_ids,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to update sats pricing for model",
|
||||
extra={
|
||||
"model_id": model.id,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
async def _update_sats_pricing_once() -> None:
|
||||
"""Update sats pricing once for all provider models (in-memory only)."""
|
||||
from ..proxy import get_upstreams, refresh_model_maps
|
||||
|
||||
upstreams = get_upstreams()
|
||||
sats_to_usd = sats_usd_price()
|
||||
|
||||
updated_count = 0
|
||||
for upstream in upstreams:
|
||||
updated_models = [
|
||||
_update_model_sats_pricing(m, sats_to_usd)
|
||||
for m in upstream.get_cached_models()
|
||||
]
|
||||
upstream._models_cache = updated_models
|
||||
upstream._models_by_id = {m.id: m for m in updated_models}
|
||||
updated_count += len(updated_models)
|
||||
|
||||
if updated_count > 0:
|
||||
logger.info("Updated sats pricing", extra={"models_updated": updated_count})
|
||||
await refresh_model_maps()
|
||||
|
||||
|
||||
async def update_sats_pricing() -> None:
|
||||
"""Periodically update sats pricing for all provider models and database overrides."""
|
||||
try:
|
||||
if not settings.enable_pricing_refresh:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
await _update_sats_pricing_once()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Initial sats pricing update failed (will retry in loop)",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
sats_to_usd = await sats_usd_ask_price()
|
||||
for model in MODELS:
|
||||
model.sats_pricing = Pricing(
|
||||
**{k: v / sats_to_usd for k, v in model.pricing.dict().items()}
|
||||
)
|
||||
mspp = model.sats_pricing.prompt
|
||||
mspc = model.sats_pricing.completion
|
||||
if (tp := model.top_provider) and (
|
||||
tp.context_length or tp.max_completion_tokens
|
||||
):
|
||||
if (cl := model.top_provider.context_length) and (
|
||||
mct := model.top_provider.max_completion_tokens
|
||||
):
|
||||
model.sats_pricing.max_cost = (cl - mct) * mspp + mct * mspc
|
||||
elif cl := model.top_provider.context_length:
|
||||
model.sats_pricing.max_cost = cl * 0.8 * mspp + cl * 0.2 * mspc
|
||||
elif mct := model.top_provider.max_completion_tokens:
|
||||
model.sats_pricing.max_cost = mct * 4 * mspp + mct * mspc
|
||||
else:
|
||||
model.sats_pricing.max_cost = 1_000_000 * mspp + 32_000 * mspc
|
||||
elif model.context_length:
|
||||
model.sats_pricing.max_cost = (
|
||||
model.sats_pricing.prompt * model.context_length * 0.8
|
||||
) + (model.sats_pricing.completion * model.context_length * 0.2)
|
||||
else:
|
||||
p = model.sats_pricing.prompt * 1_000_000
|
||||
c = model.sats_pricing.completion * 32_000
|
||||
r = model.sats_pricing.request * 100_000
|
||||
i = model.sats_pricing.image * 100
|
||||
w = model.sats_pricing.web_search * 1000
|
||||
ir = model.sats_pricing.internal_reasoning * 100
|
||||
model.sats_pricing.max_cost = p + c + r + i + w + ir
|
||||
interval = getattr(settings, "pricing_refresh_interval_seconds", 120)
|
||||
jitter = max(0.0, float(interval) * 0.1)
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
try:
|
||||
try:
|
||||
if not settings.enable_pricing_refresh:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await _update_sats_pricing_once()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating sats pricing: {e}")
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
|
||||
@models_router.get("/v1/models")
|
||||
@models_router.get("/models", include_in_schema=False)
|
||||
async def models() -> dict:
|
||||
return {"data": MODELS}
|
||||
async def models(session: AsyncSession = Depends(get_session)) -> dict:
|
||||
"""Get all available models from all providers with database overrides applied."""
|
||||
from ..proxy import get_unique_models
|
||||
|
||||
items = get_unique_models()
|
||||
return {"data": items}
|
||||
|
||||
+82
-33
@@ -1,20 +1,18 @@
|
||||
import asyncio
|
||||
import os
|
||||
import random
|
||||
|
||||
import httpx
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.settings import settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# artifical spread to cover conversion fees
|
||||
EXCHANGE_FEE = float(os.environ.get("EXCHANGE_FEE", "1.005")) # 0.5% default
|
||||
UPSTREAM_PROVIDER_FEE = float(
|
||||
os.environ.get("UPSTREAM_PROVIDER_FEE", "1.05")
|
||||
) # 5% default (e.g. openrouter charges 5% margin)
|
||||
BTC_USD_PRICE: float | None = None
|
||||
SATS_USD_PRICE: float | None = None
|
||||
|
||||
|
||||
async def kraken_btc_usd(client: httpx.AsyncClient) -> float | None:
|
||||
async def _kraken_btc_usd(client: httpx.AsyncClient) -> float | None:
|
||||
"""Fetch BTC/USD price from Kraken API."""
|
||||
api = "https://api.kraken.com/0/public/Ticker?pair=XBTUSD"
|
||||
try:
|
||||
@@ -35,7 +33,7 @@ async def kraken_btc_usd(client: httpx.AsyncClient) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
async def coinbase_btc_usd(client: httpx.AsyncClient) -> float | None:
|
||||
async def _coinbase_btc_usd(client: httpx.AsyncClient) -> float | None:
|
||||
"""Fetch BTC/USD price from Coinbase API."""
|
||||
api = "https://api.coinbase.com/v2/prices/BTC-USD/spot"
|
||||
try:
|
||||
@@ -56,7 +54,7 @@ async def coinbase_btc_usd(client: httpx.AsyncClient) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
async def binance_btc_usdt(client: httpx.AsyncClient) -> float | None:
|
||||
async def _binance_btc_usdt(client: httpx.AsyncClient) -> float | None:
|
||||
"""Fetch BTC/USDT price from Binance API."""
|
||||
api = "https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT"
|
||||
try:
|
||||
@@ -77,27 +75,34 @@ async def binance_btc_usdt(client: httpx.AsyncClient) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
async def btc_usd_ask_price() -> float:
|
||||
"""Get the lowest BTC/USD price from multiple exchanges with fee adjustment."""
|
||||
|
||||
async def _fetch_btc_usd_price() -> float:
|
||||
"""Fetch the lowest BTC/USD price from multiple exchanges."""
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
prices = await asyncio.gather(
|
||||
kraken_btc_usd(client),
|
||||
coinbase_btc_usd(client),
|
||||
binance_btc_usdt(client),
|
||||
)
|
||||
tasks = [
|
||||
asyncio.create_task(_kraken_btc_usd(client)),
|
||||
asyncio.create_task(_coinbase_btc_usd(client)),
|
||||
asyncio.create_task(_binance_btc_usdt(client)),
|
||||
]
|
||||
valid_prices: list[float] = []
|
||||
|
||||
valid_prices = [price for price in prices if price is not None]
|
||||
for future in asyncio.as_completed(tasks):
|
||||
price = await future
|
||||
if price is not None:
|
||||
valid_prices.append(price)
|
||||
|
||||
if len(valid_prices) >= 2:
|
||||
break
|
||||
|
||||
for task in tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
if not valid_prices:
|
||||
logger.error("No valid BTC prices obtained from any exchange")
|
||||
raise ValueError("Unable to fetch BTC price from any exchange")
|
||||
|
||||
min_price = min(valid_prices)
|
||||
final_price = min_price / (EXCHANGE_FEE * UPSTREAM_PROVIDER_FEE)
|
||||
return final_price
|
||||
|
||||
return min(valid_prices)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in BTC price aggregation",
|
||||
@@ -106,18 +111,62 @@ async def btc_usd_ask_price() -> float:
|
||||
raise
|
||||
|
||||
|
||||
async def sats_usd_ask_price() -> float:
|
||||
"""Get the USD price per satoshi."""
|
||||
|
||||
async def _update_prices() -> None:
|
||||
"""Update global BTC and SATS price variables."""
|
||||
global BTC_USD_PRICE, SATS_USD_PRICE
|
||||
try:
|
||||
btc_price = await btc_usd_ask_price()
|
||||
sats_price = btc_price / 100_000_000
|
||||
|
||||
return sats_price
|
||||
|
||||
btc_price = await _fetch_btc_usd_price()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error calculating satoshi price",
|
||||
logger.warning(
|
||||
"Skipping price update; unable to fetch BTC price",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
return
|
||||
BTC_USD_PRICE = btc_price
|
||||
SATS_USD_PRICE = btc_price / 100_000_000
|
||||
|
||||
|
||||
def btc_usd_price() -> float:
|
||||
"""Get the current BTC/USD price."""
|
||||
if BTC_USD_PRICE is None:
|
||||
raise ValueError("BTC price not initialized")
|
||||
return BTC_USD_PRICE
|
||||
|
||||
|
||||
def sats_usd_price() -> float:
|
||||
"""Get the current USD price per satoshi."""
|
||||
if SATS_USD_PRICE is None:
|
||||
raise ValueError("SATS price not initialized")
|
||||
return SATS_USD_PRICE
|
||||
|
||||
|
||||
async def update_prices_periodically() -> None:
|
||||
"""Background task to periodically update BTC and SATS prices."""
|
||||
try:
|
||||
if not settings.enable_pricing_refresh:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await _update_prices()
|
||||
|
||||
while True:
|
||||
try:
|
||||
interval = getattr(settings, "pricing_refresh_interval_seconds", 120)
|
||||
jitter = max(0.0, float(interval) * 0.1)
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
try:
|
||||
if not settings.enable_pricing_refresh:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
await _update_prices()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating BTC/SATS prices: {e}")
|
||||
|
||||
@@ -1,656 +0,0 @@
|
||||
import json
|
||||
import traceback
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
from fastapi import BackgroundTasks, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from ..core import get_logger
|
||||
from ..wallet import recieve_token, send_token
|
||||
from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
from .helpers import UPSTREAM_BASE_URL, create_error_response, prepare_upstream_headers
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def x_cashu_handler(
|
||||
request: Request, x_cashu_token: str, path: str, max_cost_for_model: int
|
||||
) -> Response | StreamingResponse:
|
||||
"""Handle X-Cashu token payment requests."""
|
||||
logger.info(
|
||||
"Processing X-Cashu payment request",
|
||||
extra={
|
||||
"path": path,
|
||||
"method": request.method,
|
||||
"token_preview": x_cashu_token[:20] + "..."
|
||||
if len(x_cashu_token) > 20
|
||||
else x_cashu_token,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
headers = dict(request.headers)
|
||||
amount, unit, mint = await recieve_token(x_cashu_token)
|
||||
headers = prepare_upstream_headers(dict(request.headers))
|
||||
|
||||
logger.info(
|
||||
"X-Cashu token redeemed successfully",
|
||||
extra={"amount": amount, "unit": unit, "path": path, "mint": mint},
|
||||
)
|
||||
|
||||
return await forward_to_upstream(
|
||||
request, path, headers, amount, unit, max_cost_for_model
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
logger.error(
|
||||
"X-Cashu payment request failed",
|
||||
extra={
|
||||
"error": error_message,
|
||||
"error_type": type(e).__name__,
|
||||
"path": path,
|
||||
"method": request.method,
|
||||
},
|
||||
)
|
||||
|
||||
# Handle specific CASHU errors with appropriate HTTP status codes
|
||||
if "already spent" in error_message.lower():
|
||||
return create_error_response(
|
||||
"token_already_spent",
|
||||
"The provided CASHU token has already been spent",
|
||||
400,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
)
|
||||
|
||||
if "invalid token" in error_message.lower():
|
||||
return create_error_response(
|
||||
"invalid_token",
|
||||
"The provided CASHU token is invalid",
|
||||
400,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
)
|
||||
|
||||
if "mint error" in error_message.lower():
|
||||
return create_error_response(
|
||||
"mint_error",
|
||||
f"CASHU mint error: {error_message}",
|
||||
422,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
)
|
||||
|
||||
# Generic error for other cases
|
||||
return create_error_response(
|
||||
"cashu_error",
|
||||
f"CASHU token processing failed: {error_message}",
|
||||
400,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
)
|
||||
|
||||
|
||||
async def forward_to_upstream(
|
||||
request: Request,
|
||||
path: str,
|
||||
headers: dict,
|
||||
amount: int,
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Forward request to upstream and handle the response."""
|
||||
if path.startswith("v1/"):
|
||||
path = path.replace("v1/", "")
|
||||
|
||||
url = f"{UPSTREAM_BASE_URL}/{path}"
|
||||
|
||||
logger.debug(
|
||||
"Forwarding request to upstream",
|
||||
extra={
|
||||
"url": url,
|
||||
"method": request.method,
|
||||
"path": path,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.AsyncHTTPTransport(retries=1),
|
||||
timeout=None,
|
||||
) as client:
|
||||
try:
|
||||
response = await client.send(
|
||||
client.build_request(
|
||||
request.method,
|
||||
url,
|
||||
headers=headers,
|
||||
content=request.stream(),
|
||||
params=request.query_params,
|
||||
),
|
||||
stream=True,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Received upstream response",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"path": path,
|
||||
"response_headers": dict(response.headers),
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"Upstream request failed, processing refund",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"path": path,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
|
||||
refund_token = await send_refund(amount - 60, unit)
|
||||
|
||||
logger.info(
|
||||
"Refund processed for failed upstream request",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"refund_amount": amount,
|
||||
"unit": unit,
|
||||
"refund_token_preview": refund_token[:20] + "..."
|
||||
if len(refund_token) > 20
|
||||
else refund_token,
|
||||
},
|
||||
)
|
||||
|
||||
error_response = Response(
|
||||
content=json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"message": "Error forwarding request to upstream",
|
||||
"type": "upstream_error",
|
||||
"code": response.status_code,
|
||||
"refund_token": refund_token,
|
||||
}
|
||||
}
|
||||
),
|
||||
status_code=response.status_code,
|
||||
media_type="application/json",
|
||||
)
|
||||
error_response.headers["X-Cashu"] = refund_token
|
||||
return error_response
|
||||
|
||||
if path.endswith("chat/completions"):
|
||||
logger.debug(
|
||||
"Processing chat completion response",
|
||||
extra={"path": path, "amount": amount, "unit": unit},
|
||||
)
|
||||
|
||||
result = await handle_x_cashu_chat_completion(
|
||||
response, amount, unit, max_cost_for_model
|
||||
)
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
result.background = background_tasks
|
||||
return result
|
||||
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
background_tasks.add_task(client.aclose)
|
||||
|
||||
logger.debug(
|
||||
"Streaming non-chat response",
|
||||
extra={"path": path, "status_code": response.status_code},
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
response.aiter_bytes(),
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
background=background_tasks,
|
||||
)
|
||||
except Exception as exc:
|
||||
tb = traceback.format_exc()
|
||||
logger.error(
|
||||
"Unexpected error in upstream forwarding",
|
||||
extra={
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"method": request.method,
|
||||
"url": url,
|
||||
"path": path,
|
||||
"query_params": dict(request.query_params),
|
||||
"traceback": tb,
|
||||
},
|
||||
)
|
||||
return create_error_response(
|
||||
"internal_error",
|
||||
"An unexpected server error occurred",
|
||||
500,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
async def handle_x_cashu_chat_completion(
|
||||
response: httpx.Response, amount: int, unit: str, max_cost_for_model: int
|
||||
) -> StreamingResponse | Response:
|
||||
"""Handle both streaming and non-streaming chat completion responses with token-based pricing."""
|
||||
logger.debug(
|
||||
"Handling chat completion response",
|
||||
extra={"amount": amount, "unit": unit, "status_code": response.status_code},
|
||||
)
|
||||
|
||||
try:
|
||||
content = await response.aread()
|
||||
content_str = content.decode("utf-8") if isinstance(content, bytes) else content
|
||||
is_streaming = content_str.startswith("data:") or "data:" in content_str
|
||||
|
||||
logger.debug(
|
||||
"Chat completion response analysis",
|
||||
extra={
|
||||
"is_streaming": is_streaming,
|
||||
"content_length": len(content_str),
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
|
||||
if is_streaming:
|
||||
return await handle_streaming_response(
|
||||
content_str, response, amount, unit, max_cost_for_model
|
||||
)
|
||||
else:
|
||||
return await handle_non_streaming_response(
|
||||
content_str, response, amount, unit, max_cost_for_model
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error processing chat completion response",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
# Return the original response if we can't process it
|
||||
return StreamingResponse(
|
||||
response.aiter_bytes(),
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
)
|
||||
|
||||
|
||||
async def handle_streaming_response(
|
||||
content_str: str,
|
||||
response: httpx.Response,
|
||||
amount: int,
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
) -> StreamingResponse:
|
||||
"""Handle Server-Sent Events (SSE) streaming response."""
|
||||
logger.debug(
|
||||
"Processing streaming response",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"content_lines": len(content_str.strip().split("\n")),
|
||||
},
|
||||
)
|
||||
|
||||
# Initialize response headers early so they can be modified during processing
|
||||
response_headers = dict(response.headers)
|
||||
if "transfer-encoding" in response_headers:
|
||||
del response_headers["transfer-encoding"]
|
||||
if "content-encoding" in response_headers:
|
||||
del response_headers["content-encoding"]
|
||||
|
||||
# For streaming responses, we'll extract the final usage data
|
||||
# and calculate cost based on that
|
||||
usage_data = None
|
||||
model = None
|
||||
|
||||
# Parse SSE format to extract usage information
|
||||
lines = content_str.strip().split("\n")
|
||||
for line in lines:
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data_json = json.loads(line[6:]) # Remove 'data: ' prefix
|
||||
# Look for usage information in the final chunks
|
||||
if "usage" in data_json:
|
||||
usage_data = data_json["usage"]
|
||||
model = data_json.get("model")
|
||||
elif "model" in data_json and not model:
|
||||
model = data_json["model"]
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
response_headers = dict(response.headers)
|
||||
# If we found usage data, calculate cost and refund
|
||||
if usage_data and model:
|
||||
logger.debug(
|
||||
"Found usage data in streaming response",
|
||||
extra={
|
||||
"model": model,
|
||||
"usage_data": usage_data,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
|
||||
response_data = {"usage": usage_data, "model": model}
|
||||
try:
|
||||
cost_data = await get_cost(response_data, max_cost_for_model)
|
||||
if cost_data:
|
||||
if unit == "msat":
|
||||
refund_amount = amount - cost_data.total_msats
|
||||
elif unit == "sat":
|
||||
refund_amount = amount - (cost_data.total_msats + 999) // 1000
|
||||
else:
|
||||
raise ValueError(f"Invalid unit: {unit}")
|
||||
|
||||
if refund_amount > 0:
|
||||
logger.info(
|
||||
"Processing refund for streaming response",
|
||||
extra={
|
||||
"original_amount": amount,
|
||||
"cost_msats": cost_data.total_msats,
|
||||
"refund_amount": refund_amount,
|
||||
"unit": unit,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
refund_token = await send_refund(refund_amount, unit)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
|
||||
logger.info(
|
||||
"Refund processed for streaming response",
|
||||
extra={
|
||||
"refund_amount": refund_amount,
|
||||
"unit": unit,
|
||||
"refund_token_preview": refund_token[:20] + "..."
|
||||
if len(refund_token) > 20
|
||||
else refund_token,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"No refund needed for streaming response",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"cost_msats": cost_data.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error calculating cost for streaming response",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"model": model,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
|
||||
async def generate() -> AsyncGenerator[bytes, None]:
|
||||
for line in lines:
|
||||
yield (line + "\n").encode("utf-8")
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
media_type="text/plain",
|
||||
)
|
||||
|
||||
|
||||
async def handle_non_streaming_response(
|
||||
content_str: str,
|
||||
response: httpx.Response,
|
||||
amount: int,
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
) -> Response:
|
||||
"""Handle regular JSON response."""
|
||||
logger.debug(
|
||||
"Processing non-streaming response",
|
||||
extra={"amount": amount, "unit": unit, "content_length": len(content_str)},
|
||||
)
|
||||
|
||||
try:
|
||||
response_json = json.loads(content_str)
|
||||
|
||||
cost_data = await get_cost(response_json, max_cost_for_model)
|
||||
|
||||
if not cost_data:
|
||||
logger.error(
|
||||
"Failed to calculate cost for response",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"response_model": response_json.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return Response(
|
||||
content=json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"message": "Error forwarding request to upstream",
|
||||
"type": "upstream_error",
|
||||
"code": response.status_code,
|
||||
}
|
||||
}
|
||||
),
|
||||
status_code=response.status_code,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
response_headers = dict(response.headers)
|
||||
if "transfer-encoding" in response_headers:
|
||||
del response_headers["transfer-encoding"]
|
||||
if "content-encoding" in response_headers:
|
||||
del response_headers["content-encoding"]
|
||||
|
||||
if unit == "msat":
|
||||
refund_amount = amount - cost_data.total_msats
|
||||
elif unit == "sat":
|
||||
refund_amount = amount - (cost_data.total_msats + 999) // 1000
|
||||
else:
|
||||
raise ValueError(f"Invalid unit: {unit}")
|
||||
|
||||
logger.info(
|
||||
"Processing non-streaming response cost calculation",
|
||||
extra={
|
||||
"original_amount": amount,
|
||||
"cost_msats": cost_data.total_msats,
|
||||
"refund_amount": refund_amount,
|
||||
"unit": unit,
|
||||
"model": response_json.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
if refund_amount > 0:
|
||||
refund_token = await send_refund(refund_amount, unit)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
|
||||
logger.info(
|
||||
"Refund processed for non-streaming response",
|
||||
extra={
|
||||
"refund_amount": refund_amount,
|
||||
"unit": unit,
|
||||
"refund_token_preview": refund_token[:20] + "..."
|
||||
if len(refund_token) > 20
|
||||
else refund_token,
|
||||
},
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=content_str,
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
media_type="application/json",
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(
|
||||
"Failed to parse JSON from upstream response",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"content_preview": content_str[:200] + "..."
|
||||
if len(content_str) > 200
|
||||
else content_str,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
|
||||
# Emergency refund with small deduction for processing
|
||||
emergency_refund = amount
|
||||
refund_token = await send_token(emergency_refund, unit=unit)
|
||||
response.headers["X-Cashu"] = refund_token
|
||||
|
||||
logger.warning(
|
||||
"Emergency refund issued due to JSON parse error",
|
||||
extra={
|
||||
"original_amount": amount,
|
||||
"refund_amount": emergency_refund,
|
||||
"deduction": 60,
|
||||
},
|
||||
)
|
||||
|
||||
# Return original content if JSON parsing fails
|
||||
return Response(
|
||||
content=content_str,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
async def get_cost(
|
||||
response_data: dict, max_cost_for_model: int
|
||||
) -> MaxCostData | CostData | None:
|
||||
"""
|
||||
Adjusts the payment based on token usage in the response.
|
||||
This is called after the initial payment and the upstream request is complete.
|
||||
Returns cost data to be included in the response.
|
||||
"""
|
||||
model = response_data.get("model", None)
|
||||
logger.debug(
|
||||
"Calculating cost for response",
|
||||
extra={"model": model, "has_usage": "usage" in response_data},
|
||||
)
|
||||
|
||||
match calculate_cost(response_data, max_cost_for_model):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost pricing",
|
||||
extra={"model": model, "max_cost_msats": cost.total_msats},
|
||||
)
|
||||
return cost
|
||||
case CostData() as cost:
|
||||
logger.debug(
|
||||
"Using token-based pricing",
|
||||
extra={
|
||||
"model": model,
|
||||
"total_cost_msats": cost.total_msats,
|
||||
"input_msats": cost.input_msats,
|
||||
"output_msats": cost.output_msats,
|
||||
},
|
||||
)
|
||||
return cost
|
||||
case CostDataError() as error:
|
||||
logger.error(
|
||||
"Cost calculation error",
|
||||
extra={
|
||||
"model": model,
|
||||
"error_message": error.message,
|
||||
"error_code": error.code,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"message": error.message,
|
||||
"type": "invalid_request_error",
|
||||
"code": error.code,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def send_refund(amount: int, unit: str, mint: str | None = None) -> str:
|
||||
"""Send a refund using Cashu tokens."""
|
||||
logger.debug(
|
||||
"Creating refund token", extra={"amount": amount, "unit": unit, "mint": mint}
|
||||
)
|
||||
|
||||
max_retries = 3
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
refund_token = await send_token(amount, unit=unit, mint_url=mint)
|
||||
|
||||
logger.info(
|
||||
"Refund token created successfully",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"mint": mint,
|
||||
"attempt": attempt + 1,
|
||||
"token_preview": refund_token[:20] + "..."
|
||||
if len(refund_token) > 20
|
||||
else refund_token,
|
||||
},
|
||||
)
|
||||
|
||||
return refund_token
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(
|
||||
"Refund token creation failed, retrying",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"attempt": attempt + 1,
|
||||
"max_retries": max_retries,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"mint": mint,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to create refund token after all retries",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"attempt": attempt + 1,
|
||||
"max_retries": max_retries,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"mint": mint,
|
||||
},
|
||||
)
|
||||
|
||||
# If we get here, all retries failed
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"failed to create refund after {max_retries} attempts: {str(last_exception)}",
|
||||
"type": "invalid_request_error",
|
||||
"code": "send_token_failed",
|
||||
}
|
||||
},
|
||||
)
|
||||
+373
-599
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
from .anthropic import AnthropicUpstreamProvider
|
||||
from .azure import AzureUpstreamProvider
|
||||
from .base import BaseUpstreamProvider
|
||||
from .fireworks import FireworksUpstreamProvider
|
||||
from .gemini import GeminiUpstreamProvider
|
||||
from .generic import GenericUpstreamProvider
|
||||
from .groq import GroqUpstreamProvider
|
||||
from .ollama import OllamaUpstreamProvider
|
||||
from .openai import OpenAIUpstreamProvider
|
||||
from .openrouter import OpenRouterUpstreamProvider
|
||||
from .perplexity import PerplexityUpstreamProvider
|
||||
from .ppqai import PPQAIUpstreamProvider
|
||||
from .xai import XAIUpstreamProvider
|
||||
|
||||
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
AnthropicUpstreamProvider,
|
||||
AzureUpstreamProvider,
|
||||
FireworksUpstreamProvider,
|
||||
GeminiUpstreamProvider,
|
||||
GenericUpstreamProvider,
|
||||
GroqUpstreamProvider,
|
||||
OllamaUpstreamProvider,
|
||||
OpenAIUpstreamProvider,
|
||||
OpenRouterUpstreamProvider,
|
||||
PerplexityUpstreamProvider,
|
||||
PPQAIUpstreamProvider,
|
||||
XAIUpstreamProvider,
|
||||
]
|
||||
"""List of all upstream classes"""
|
||||
|
||||
__all__ = [
|
||||
"BaseUpstreamProvider",
|
||||
*[cls.__name__ for cls in upstream_provider_classes],
|
||||
"upstream_provider_classes",
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class AnthropicUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Anthropic API."""
|
||||
|
||||
provider_type = "anthropic"
|
||||
default_base_url = "https://api.anthropic.com/v1"
|
||||
platform_url = "https://console.anthropic.com/settings/keys"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url,
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "AnthropicUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Anthropic",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'anthropic/' prefix for Anthropic API compatibility and transform model names."""
|
||||
if model_id.startswith("anthropic/"):
|
||||
model_id = model_id[len("anthropic/") :]
|
||||
fixed_transforms = {
|
||||
"claude-haiku-4.5": "claude-haiku-4-5-20251001",
|
||||
"claude-sonnet-4.5": "claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4.1": "claude-opus-4-1-20250805",
|
||||
"claude-opus-4": "claude-opus-4-20250514",
|
||||
"claude-sonnet-4": "claude-sonnet-4-20250514",
|
||||
"claude-3.5-haiku": "claude-3-5-haiku-20241022",
|
||||
"claude-3-haiku": "claude-3-haiku-20240307",
|
||||
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4-1": "claude-opus-4-1-20250805",
|
||||
"claude-3-5-haiku": "claude-3-5-haiku-20241022",
|
||||
}
|
||||
if model_id in fixed_transforms:
|
||||
model_id = fixed_transforms[model_id]
|
||||
return model_id
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch Anthropic models from OpenRouter API filtered by anthropic source."""
|
||||
models_data = await async_fetch_openrouter_models(source_filter="anthropic")
|
||||
models = [Model(**model) for model in models_data] # type: ignore
|
||||
for model in models:
|
||||
model.alias_ids = [self.transform_model_name(model.id)]
|
||||
return models
|
||||
@@ -0,0 +1,76 @@
|
||||
from typing import TYPE_CHECKING, Mapping
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class AzureUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Azure OpenAI Service."""
|
||||
|
||||
provider_type = "azure"
|
||||
default_base_url = None
|
||||
platform_url = "https://portal.azure.com/"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
api_version: str,
|
||||
provider_fee: float = 1.01,
|
||||
):
|
||||
"""Initialize Azure provider with API key and version.
|
||||
|
||||
Args:
|
||||
base_url: Azure OpenAI endpoint base URL
|
||||
api_key: Azure OpenAI API key for authentication
|
||||
api_version: Azure OpenAI API version (e.g., "2024-02-15-preview")
|
||||
provider_fee: Provider fee multiplier (default 1.01 for 1% fee)
|
||||
"""
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
self.api_version = api_version
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "AzureUpstreamProvider | None":
|
||||
if not provider_row.api_version:
|
||||
return None
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
api_version=provider_row.api_version,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Azure OpenAI",
|
||||
"default_base_url": "",
|
||||
"fixed_base_url": False,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def prepare_params(
|
||||
self, path: str, query_params: Mapping[str, str] | None
|
||||
) -> Mapping[str, str]:
|
||||
"""Prepare query parameters for Azure OpenAI, adding API version.
|
||||
|
||||
Args:
|
||||
path: Request path
|
||||
query_params: Original query parameters from the client
|
||||
|
||||
Returns:
|
||||
Query parameters dict with Azure API version added for chat completions
|
||||
"""
|
||||
params = dict(query_params or {})
|
||||
if path.endswith("chat/completions"):
|
||||
params["api-version"] = self.api_version
|
||||
return params
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
from .gemini import GeminiClient
|
||||
|
||||
__all__ = ["GeminiClient"]
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
|
||||
class BaseAPIClient(ABC):
|
||||
"""Base class for AI provider API clients."""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str | None = None):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
||||
@abstractmethod
|
||||
async def generate_content(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate content non-streaming."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def generate_content_stream(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_models(self) -> list[dict[str, Any]]:
|
||||
"""List available models."""
|
||||
pass
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from .base import BaseAPIClient
|
||||
|
||||
|
||||
class GeminiClient(BaseAPIClient):
|
||||
"""Gemini API client using OpenAI compatibility layer."""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str | None = None):
|
||||
super().__init__(api_key, base_url)
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url
|
||||
or "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
)
|
||||
|
||||
async def generate_content(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
from openai import NOT_GIVEN
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages, # type: ignore
|
||||
temperature=temperature if temperature is not None else NOT_GIVEN,
|
||||
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
|
||||
top_p=kwargs.get("top_p", NOT_GIVEN),
|
||||
)
|
||||
return response.model_dump()
|
||||
|
||||
async def generate_content_stream(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
from openai import NOT_GIVEN
|
||||
|
||||
usage_callback = kwargs.get("usage_callback")
|
||||
completion_callback = kwargs.get("completion_callback")
|
||||
|
||||
stream = await self.client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages, # type: ignore
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
temperature=temperature if temperature is not None else NOT_GIVEN,
|
||||
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
|
||||
top_p=kwargs.get("top_p", NOT_GIVEN),
|
||||
)
|
||||
|
||||
final_usage = None
|
||||
|
||||
async for chunk in stream:
|
||||
chunk_data = chunk.model_dump()
|
||||
|
||||
if chunk.usage:
|
||||
final_usage = chunk.usage.model_dump()
|
||||
if usage_callback:
|
||||
usage_callback(final_usage)
|
||||
|
||||
yield chunk_data
|
||||
|
||||
if completion_callback:
|
||||
await completion_callback(model, final_usage)
|
||||
|
||||
async def list_models(self) -> list[dict[str, Any]]:
|
||||
"""List available Gemini models."""
|
||||
try:
|
||||
response = await self.client.models.list()
|
||||
return [model.model_dump() for model in response.data]
|
||||
except Exception as e:
|
||||
from ...core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"Failed to list Gemini models: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,42 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class FireworksUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Fireworks.ai API."""
|
||||
|
||||
provider_type = "fireworks"
|
||||
default_base_url = "https://api.fireworks.ai/inference/v1"
|
||||
platform_url = "https://app.fireworks.ai/settings/users/api-keys"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "FireworksUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Fireworks",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'fireworks/' prefix for Fireworks API compatibility."""
|
||||
return model_id.split("/")[-1]
|
||||
@@ -0,0 +1,319 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
from .clients.gemini import GeminiClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GeminiUpstreamProvider(BaseUpstreamProvider):
|
||||
provider_type = "gemini"
|
||||
default_base_url = "https://generativelanguage.googleapis.com/v1beta"
|
||||
platform_url = "https://aistudio.google.com/app/apikey"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://generativelanguage.googleapis.com/v1beta",
|
||||
api_key: str = "",
|
||||
provider_fee: float = 1.01,
|
||||
):
|
||||
super().__init__(
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
base_url=base_url,
|
||||
)
|
||||
self._client: GeminiClient | None = None
|
||||
|
||||
@property
|
||||
def client(self) -> GeminiClient:
|
||||
"""Get or create the Gemini API client."""
|
||||
if self._client is None:
|
||||
self._client = GeminiClient(api_key=self.api_key)
|
||||
return self._client
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "GeminiUpstreamProvider":
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Google Gemini",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
return model_id.removeprefix("gemini/")
|
||||
|
||||
async def forward_request(
|
||||
self,
|
||||
request: Request,
|
||||
path: str,
|
||||
headers: dict,
|
||||
request_body: bytes | None,
|
||||
key: ApiKey,
|
||||
max_cost_for_model: int,
|
||||
session: AsyncSession,
|
||||
model_obj: Model,
|
||||
) -> Response | StreamingResponse:
|
||||
# Remove provider prefix from model ID for Gemini API
|
||||
if "/" in model_obj.id:
|
||||
model_obj.id = model_obj.id.split("/", 1)[1]
|
||||
|
||||
if not path.startswith("chat/completions"):
|
||||
return await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
if not request_body:
|
||||
return await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
try:
|
||||
openai_data = json.loads(request_body)
|
||||
messages = openai_data.get("messages", [])
|
||||
temperature = openai_data.get("temperature")
|
||||
max_tokens = openai_data.get("max_tokens")
|
||||
top_p = openai_data.get("top_p")
|
||||
is_streaming = openai_data.get("stream", False)
|
||||
|
||||
logger.info(
|
||||
"Processing Gemini request with client abstraction",
|
||||
extra={
|
||||
"model": model_obj.id,
|
||||
"is_streaming": is_streaming,
|
||||
"message_count": len(messages),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
if is_streaming:
|
||||
final_usage_data: dict | None = None
|
||||
|
||||
def usage_callback(usage_data: dict[str, Any]) -> None:
|
||||
"""Callback to capture usage data during streaming"""
|
||||
nonlocal final_usage_data
|
||||
final_usage_data = usage_data
|
||||
|
||||
async def completion_callback(
|
||||
model: str, usage_data: dict[str, Any] | None
|
||||
) -> None:
|
||||
"""Callback to handle payment when streaming completes"""
|
||||
nonlocal final_usage_data
|
||||
if usage_data:
|
||||
final_usage_data = usage_data
|
||||
|
||||
payment_data = {
|
||||
"model": model,
|
||||
"usage": final_usage_data,
|
||||
}
|
||||
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
from ..core.db import create_session
|
||||
|
||||
async with create_session() as new_session:
|
||||
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
||||
if fresh_key:
|
||||
try:
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
fresh_key,
|
||||
payment_data,
|
||||
new_session,
|
||||
max_cost_for_model,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Gemini streaming payment finalized",
|
||||
extra={
|
||||
"cost_data": cost_data,
|
||||
"usage_data": final_usage_data,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
except Exception as cost_error:
|
||||
logger.error(
|
||||
"Error finalizing Gemini streaming payment",
|
||||
extra={
|
||||
"error": str(cost_error),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
response_generator = self.client.generate_content_stream(
|
||||
model=model_obj.id,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
usage_callback=usage_callback,
|
||||
completion_callback=completion_callback,
|
||||
)
|
||||
|
||||
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
|
||||
payment_finalized = False
|
||||
|
||||
async def finalize_payment() -> None:
|
||||
nonlocal payment_finalized
|
||||
if payment_finalized:
|
||||
return
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
from ..core.db import create_session
|
||||
|
||||
async with create_session() as new_session:
|
||||
fresh_key = await new_session.get(
|
||||
key.__class__, key.hashed_key
|
||||
)
|
||||
if fresh_key:
|
||||
try:
|
||||
await adjust_payment_for_tokens(
|
||||
fresh_key,
|
||||
{
|
||||
"model": model_obj.id,
|
||||
"usage": final_usage_data,
|
||||
},
|
||||
new_session,
|
||||
max_cost_for_model,
|
||||
)
|
||||
payment_finalized = True
|
||||
except Exception as cost_error:
|
||||
logger.error(
|
||||
"Error finalizing Gemini streaming payment in fallback",
|
||||
extra={
|
||||
"error": str(cost_error),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
async for chunk in response_generator:
|
||||
sse_data = f"data: {json.dumps(chunk)}\n\n"
|
||||
yield sse_data.encode()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in Gemini streaming response",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if not payment_finalized:
|
||||
await finalize_payment()
|
||||
|
||||
return StreamingResponse(
|
||||
stream_with_cost(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||
)
|
||||
|
||||
else:
|
||||
openai_format_response = await self.client.generate_content(
|
||||
model=model_obj.id,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
)
|
||||
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
key, openai_format_response, session, max_cost_for_model
|
||||
)
|
||||
openai_format_response["cost"] = cost_data
|
||||
|
||||
logger.info(
|
||||
"Gemini non-streaming payment completed",
|
||||
extra={
|
||||
"cost_data": cost_data,
|
||||
"model": model_obj.id,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=json.dumps(openai_format_response),
|
||||
media_type="application/json",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in Gemini forward_request",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"path": path,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
return await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
async def _fetch_provider_models(self) -> dict:
|
||||
"""Fetch models from Gemini API."""
|
||||
try:
|
||||
models_data = await self.client.list_models()
|
||||
|
||||
for model in models_data:
|
||||
if "id" in model and model["id"].startswith("models/"):
|
||||
model["id"] = model["id"].removeprefix("models/")
|
||||
|
||||
return {"data": models_data}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to fetch models from Gemini API: {e}",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"base_url": self.base_url,
|
||||
},
|
||||
)
|
||||
return {"data": []}
|
||||
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GenericUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Generic upstream provider that can fetch models from any OpenAI-compatible API."""
|
||||
|
||||
provider_type = "generic"
|
||||
default_base_url = "http://localhost:8888"
|
||||
platform_url = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str = "",
|
||||
provider_fee: float = 1.01,
|
||||
upstream_name: str | None = None,
|
||||
):
|
||||
"""Initialize generic provider.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the upstream API endpoint
|
||||
api_key: Optional API key for authentication
|
||||
provider_fee: Provider fee multiplier (default 1.01 for 1% fee)
|
||||
upstream_name: Optional name for the upstream provider
|
||||
"""
|
||||
self.upstream_name = upstream_name or "generic"
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "GenericUpstreamProvider":
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Generic",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": False,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from upstream API using /models endpoint."""
|
||||
from ..payment.models import Architecture, Model, Pricing, TopProvider
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
headers = {}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
response = await client.get(f"{self.base_url}/models", headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
models_list = []
|
||||
for model_data in data.get("data", []):
|
||||
model_id = model_data.get("id", "")
|
||||
if not model_id:
|
||||
continue
|
||||
|
||||
model_name = model_data.get("name", model_id)
|
||||
created = model_data.get("created", 0)
|
||||
owned_by = model_data.get("owned_by", "unknown")
|
||||
model_spec = model_data.get("model_spec", {})
|
||||
|
||||
context_length = 4096
|
||||
if model_spec.get("availableContextTokens"):
|
||||
context_length = model_spec["availableContextTokens"]
|
||||
elif any(
|
||||
pattern in model_id.lower() for pattern in ["32k", "32000"]
|
||||
):
|
||||
context_length = 32768
|
||||
elif any(
|
||||
pattern in model_id.lower() for pattern in ["16k", "16000"]
|
||||
):
|
||||
context_length = 16384
|
||||
elif any(pattern in model_id.lower() for pattern in ["8k", "8000"]):
|
||||
context_length = 8192
|
||||
elif "gpt-4" in model_id.lower():
|
||||
context_length = 8192
|
||||
elif "claude" in model_id.lower():
|
||||
context_length = 200000
|
||||
|
||||
pricing_info = model_spec.get("pricing", {})
|
||||
input_pricing = pricing_info.get("input", {})
|
||||
output_pricing = pricing_info.get("output", {})
|
||||
|
||||
prompt_price = input_pricing.get("usd", 0.001) / 1000000
|
||||
completion_price = output_pricing.get("usd", 0.001) / 1000000
|
||||
|
||||
capabilities = model_spec.get("capabilities", {})
|
||||
input_modalities = ["text"]
|
||||
output_modalities = ["text"]
|
||||
|
||||
if capabilities.get("supportsVision", False):
|
||||
input_modalities.append("image")
|
||||
|
||||
modality = "text"
|
||||
if capabilities.get("supportsVision", False):
|
||||
modality = "text->text"
|
||||
|
||||
spec_name = model_spec.get("name", model_name)
|
||||
description = f"{spec_name}"
|
||||
if owned_by != "unknown":
|
||||
description += f" via {owned_by}"
|
||||
|
||||
models_list.append(
|
||||
Model(
|
||||
id=model_id,
|
||||
name=spec_name,
|
||||
created=created,
|
||||
description=description,
|
||||
context_length=context_length,
|
||||
architecture=Architecture(
|
||||
modality=modality,
|
||||
input_modalities=input_modalities,
|
||||
output_modalities=output_modalities,
|
||||
tokenizer="unknown",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(
|
||||
prompt=prompt_price,
|
||||
completion=completion_price,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
max_prompt_cost=0.001,
|
||||
max_completion_cost=0.001,
|
||||
max_cost=0.001,
|
||||
),
|
||||
sats_pricing=None,
|
||||
per_request_limits=None,
|
||||
top_provider=TopProvider(
|
||||
context_length=context_length,
|
||||
max_completion_tokens=context_length // 2,
|
||||
is_moderated=False,
|
||||
),
|
||||
enabled=True,
|
||||
upstream_provider_id=None,
|
||||
canonical_slug=None,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Fetched {len(models_list)} models from {self.upstream_name}",
|
||||
extra={"model_count": len(models_list), "base_url": self.base_url},
|
||||
)
|
||||
return models_list
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to fetch models from {self.upstream_name} API: {e}",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"base_url": self.base_url,
|
||||
},
|
||||
)
|
||||
return []
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
|
||||
class GroqUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Groq API."""
|
||||
|
||||
provider_type = "groq"
|
||||
default_base_url = "https://api.groq.com/openai/v1"
|
||||
platform_url = "https://console.groq.com/keys"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.01):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "GroqUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Groq",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'groq/' prefix for Groq API compatibility."""
|
||||
return model_id.removeprefix("groq/")
|
||||
@@ -0,0 +1,399 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.settings import Settings
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
|
||||
from ..payment.models import Model
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def resolve_model_alias(
|
||||
model_id: str, canonical_slug: str | None = None, alias_ids: list[str] | None = None
|
||||
) -> list[str]:
|
||||
"""Resolve model ID to all possible aliases.
|
||||
|
||||
Returns list of aliases including canonical slug and variations without provider prefix.
|
||||
|
||||
Args:
|
||||
model_id: Model identifier (e.g., "gpt-5-mini" or "openai/gpt-5-mini")
|
||||
canonical_slug: Optional canonical slug from provider (e.g., "openai/gpt-5-pro-2025-10-06")
|
||||
|
||||
Returns:
|
||||
List of possible model ID aliases
|
||||
"""
|
||||
aliases = [model_id]
|
||||
|
||||
base_model = model_id
|
||||
if "/" in model_id:
|
||||
without_prefix = model_id.split("/", 1)[1]
|
||||
aliases.append(without_prefix)
|
||||
base_model = without_prefix
|
||||
|
||||
date_pattern = re.compile(r"-\d{4}-\d{2}-\d{2}$")
|
||||
if date_pattern.search(base_model):
|
||||
base_without_date = date_pattern.sub("", base_model)
|
||||
if base_without_date not in aliases:
|
||||
aliases.append(base_without_date)
|
||||
if "/" in model_id:
|
||||
prefix = model_id.split("/", 1)[0]
|
||||
prefixed_without_date = f"{prefix}/{base_without_date}"
|
||||
if prefixed_without_date not in aliases:
|
||||
aliases.append(prefixed_without_date)
|
||||
|
||||
if canonical_slug and canonical_slug not in aliases:
|
||||
aliases.append(canonical_slug)
|
||||
if "/" in canonical_slug:
|
||||
canonical_without_prefix = canonical_slug.split("/", 1)[1]
|
||||
if canonical_without_prefix not in aliases:
|
||||
aliases.append(canonical_without_prefix)
|
||||
if date_pattern.search(canonical_without_prefix):
|
||||
canonical_base = date_pattern.sub("", canonical_without_prefix)
|
||||
if canonical_base not in aliases:
|
||||
aliases.append(canonical_base)
|
||||
|
||||
if alias_ids:
|
||||
aliases.extend(alias_ids)
|
||||
|
||||
return aliases
|
||||
|
||||
|
||||
async def get_all_models_with_overrides(
|
||||
upstreams: list[BaseUpstreamProvider],
|
||||
) -> list[Model]:
|
||||
"""Get all models from all providers with database overrides applied.
|
||||
|
||||
Models in the database with upstream_provider_id set are treated as overrides
|
||||
that replace the provider's model with the same ID.
|
||||
|
||||
Args:
|
||||
upstreams: List of upstream provider instances
|
||||
|
||||
Returns:
|
||||
List of Model objects with overrides applied
|
||||
"""
|
||||
from sqlmodel import select
|
||||
|
||||
from ..payment.models import _row_to_model
|
||||
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(ModelRow).where(ModelRow.enabled))
|
||||
override_rows = result.all()
|
||||
|
||||
provider_result = await session.exec(select(UpstreamProviderRow))
|
||||
providers_by_id = {p.id: p for p in provider_result.all()}
|
||||
|
||||
overrides_by_id: dict[str, tuple[ModelRow, float]] = {
|
||||
row.id: (
|
||||
row,
|
||||
providers_by_id[row.upstream_provider_id].provider_fee
|
||||
if row.upstream_provider_id in providers_by_id
|
||||
else 1.01,
|
||||
)
|
||||
for row in override_rows
|
||||
if row.upstream_provider_id is not None
|
||||
and row.upstream_provider_id in providers_by_id
|
||||
and providers_by_id[row.upstream_provider_id].enabled
|
||||
}
|
||||
|
||||
all_models: dict[str, Model] = {}
|
||||
|
||||
for upstream in upstreams:
|
||||
for model in upstream.get_cached_models():
|
||||
if model.id in overrides_by_id:
|
||||
override_row, provider_fee = overrides_by_id[model.id]
|
||||
all_models[model.id] = _row_to_model(
|
||||
override_row, apply_provider_fee=True, provider_fee=provider_fee
|
||||
)
|
||||
elif model.enabled:
|
||||
all_models[model.id] = model
|
||||
|
||||
return list(all_models.values())
|
||||
|
||||
|
||||
async def refresh_upstreams_models_periodically(
|
||||
upstreams: list[BaseUpstreamProvider],
|
||||
) -> None:
|
||||
"""Background task to periodically refresh models cache for all providers.
|
||||
|
||||
Args:
|
||||
upstreams: List of upstream provider instances
|
||||
"""
|
||||
import asyncio
|
||||
import random
|
||||
|
||||
from ..core.settings import settings
|
||||
|
||||
interval = getattr(settings, "models_refresh_interval_seconds", 0)
|
||||
if not interval or interval <= 0:
|
||||
logger.info("Provider models refresh disabled (interval <= 0)")
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
for upstream in upstreams:
|
||||
try:
|
||||
await upstream.refresh_models_cache()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error refreshing models for {upstream.base_url}",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
try:
|
||||
from ..payment.models import _update_sats_pricing_once
|
||||
|
||||
await _update_sats_pricing_once()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update pricing after model refresh: {e}")
|
||||
from ..proxy import refresh_model_maps
|
||||
|
||||
await refresh_model_maps()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in provider models refresh loop",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
try:
|
||||
jitter = max(0.0, float(interval) * 0.1)
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
|
||||
async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
"""Initialize upstream providers from database.
|
||||
|
||||
Seeds database with providers from settings if empty, then loads and instantiates
|
||||
provider instances from database records, and refreshes their models cache.
|
||||
"""
|
||||
from ..core.settings import settings
|
||||
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(UpstreamProviderRow))
|
||||
existing_providers = result.all()
|
||||
|
||||
if not existing_providers:
|
||||
logger.info(
|
||||
"No upstream providers found in database, seeding from settings"
|
||||
)
|
||||
await _seed_providers_from_settings(session, settings)
|
||||
await session.commit()
|
||||
result = await session.exec(select(UpstreamProviderRow))
|
||||
existing_providers = result.all()
|
||||
|
||||
async def _init_single_provider(
|
||||
provider_row: UpstreamProviderRow,
|
||||
) -> BaseUpstreamProvider | None:
|
||||
if not provider_row.enabled:
|
||||
logger.debug(f"Skipping disabled provider: {provider_row.base_url}")
|
||||
return None
|
||||
|
||||
provider = _instantiate_provider(provider_row)
|
||||
if provider:
|
||||
await provider.refresh_models_cache()
|
||||
logger.debug(
|
||||
f"Initialized {provider_row.provider_type} provider",
|
||||
extra={
|
||||
"base_url": provider_row.base_url,
|
||||
"models_cached": len(provider.get_cached_models()),
|
||||
},
|
||||
)
|
||||
return provider
|
||||
return None
|
||||
|
||||
tasks = [_init_single_provider(row) for row in existing_providers]
|
||||
results = await asyncio.gather(*tasks)
|
||||
upstreams = [p for p in results if p is not None]
|
||||
|
||||
return upstreams
|
||||
|
||||
|
||||
async def _seed_providers_from_settings(
|
||||
session: AsyncSession, settings: "Settings"
|
||||
) -> None:
|
||||
"""Seed database with upstream providers from environment variables.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
"""
|
||||
from sqlmodel import select
|
||||
|
||||
from . import upstream_provider_classes
|
||||
|
||||
providers_to_add: list[UpstreamProviderRow] = []
|
||||
seeded_provider_keys: set[tuple[str, str]] = set()
|
||||
|
||||
provider_classes_by_type = {
|
||||
cls.provider_type: cls
|
||||
for cls in upstream_provider_classes # type: ignore[attr-defined]
|
||||
}
|
||||
|
||||
env_mappings: list[tuple[str, str, str | None, str | None]] = [
|
||||
("OPENAI_API_KEY", "openai", None, None),
|
||||
("ANTHROPIC_API_KEY", "anthropic", None, None),
|
||||
("OPENROUTER_API_KEY", "openrouter", None, None),
|
||||
("GROQ_API_KEY", "groq", None, None),
|
||||
("PERPLEXITY_API_KEY", "perplexity", None, None),
|
||||
("FIREWORKS_API_KEY", "fireworks", None, None),
|
||||
("XAI_API_KEY", "xai", None, None),
|
||||
]
|
||||
|
||||
for env_key, provider_type, _, _ in env_mappings:
|
||||
api_key = os.environ.get(env_key)
|
||||
if api_key and provider_type in provider_classes_by_type:
|
||||
provider_class = provider_classes_by_type[provider_type]
|
||||
if provider_class.default_base_url: # type: ignore[attr-defined]
|
||||
base_url = provider_class.default_base_url # type: ignore[attr-defined]
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == base_url,
|
||||
UpstreamProviderRow.api_key == api_key,
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type=provider_type,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_provider_keys.add((base_url, api_key))
|
||||
|
||||
ollama_base_url = os.environ.get("OLLAMA_BASE_URL")
|
||||
if ollama_base_url:
|
||||
ollama_api_key = os.environ.get("OLLAMA_API_KEY", "")
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == ollama_base_url,
|
||||
UpstreamProviderRow.api_key == ollama_api_key,
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="ollama",
|
||||
base_url=ollama_base_url,
|
||||
api_key=ollama_api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_provider_keys.add((ollama_base_url, ollama_api_key))
|
||||
|
||||
if settings.chat_completions_api_version and settings.upstream_base_url:
|
||||
base_url = settings.upstream_base_url
|
||||
api_key = settings.upstream_api_key
|
||||
if (base_url, api_key) not in seeded_provider_keys:
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == base_url,
|
||||
UpstreamProviderRow.api_key == api_key,
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="azure",
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
api_version=settings.chat_completions_api_version,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_provider_keys.add((base_url, api_key))
|
||||
|
||||
if settings.upstream_base_url and settings.upstream_api_key:
|
||||
base_url = settings.upstream_base_url
|
||||
api_key = settings.upstream_api_key
|
||||
if (base_url, api_key) not in seeded_provider_keys:
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(
|
||||
UpstreamProviderRow.base_url == base_url,
|
||||
UpstreamProviderRow.api_key == api_key,
|
||||
)
|
||||
)
|
||||
if not result.first():
|
||||
providers_to_add.append(
|
||||
UpstreamProviderRow(
|
||||
provider_type="custom",
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
seeded_provider_keys.add((base_url, api_key))
|
||||
|
||||
for provider in providers_to_add:
|
||||
session.add(provider)
|
||||
logger.info(
|
||||
f"Seeding {provider.provider_type} provider", # type: ignore[str-format]
|
||||
extra={"base_url": provider.base_url},
|
||||
)
|
||||
|
||||
|
||||
def _instantiate_provider(
|
||||
provider_row: UpstreamProviderRow,
|
||||
) -> BaseUpstreamProvider | None:
|
||||
"""Instantiate an UpstreamProvider from a database row.
|
||||
|
||||
Args:
|
||||
provider_row: Database row containing provider configuration
|
||||
|
||||
Returns:
|
||||
Instantiated provider or None if provider type is unknown
|
||||
"""
|
||||
from . import upstream_provider_classes
|
||||
|
||||
try:
|
||||
provider_classes_by_type = {
|
||||
cls.provider_type: cls
|
||||
for cls in upstream_provider_classes # type: ignore[attr-defined]
|
||||
}
|
||||
|
||||
provider_class = provider_classes_by_type.get(provider_row.provider_type)
|
||||
|
||||
if provider_class:
|
||||
provider = provider_class.from_db_row(provider_row) # type: ignore[attr-defined]
|
||||
if provider is None:
|
||||
logger.error(
|
||||
f"Failed to instantiate {provider_row.provider_type} provider",
|
||||
extra={"base_url": provider_row.base_url},
|
||||
)
|
||||
return provider
|
||||
|
||||
if provider_row.provider_type == "custom":
|
||||
return BaseUpstreamProvider(
|
||||
provider_row.base_url, provider_row.api_key, provider_row.provider_fee
|
||||
)
|
||||
|
||||
logger.error(
|
||||
f"Unknown provider type: {provider_row.provider_type}",
|
||||
extra={"base_url": provider_row.base_url},
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to instantiate provider: {e}",
|
||||
extra={
|
||||
"provider_type": provider_row.provider_type,
|
||||
"base_url": provider_row.base_url,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,297 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider specifically configured for Ollama API."""
|
||||
|
||||
provider_type = "ollama"
|
||||
default_base_url = "http://localhost:11434"
|
||||
platform_url = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "http://localhost:11434",
|
||||
api_key: str = "",
|
||||
provider_fee: float = 1.01,
|
||||
):
|
||||
"""Initialize Ollama provider.
|
||||
|
||||
Args:
|
||||
base_url: Ollama API base URL (default http://localhost:11434)
|
||||
api_key: Optional API key (Ollama typically doesn't require one)
|
||||
provider_fee: Provider fee multiplier (default 1.01 for 1% fee)
|
||||
"""
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "OllamaUpstreamProvider":
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Ollama",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": False,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
"""Strip 'ollama/' prefix for Ollama API compatibility."""
|
||||
return model_id.removeprefix("ollama/")
|
||||
|
||||
async def forward_request(
|
||||
self,
|
||||
request: Request,
|
||||
path: str,
|
||||
headers: dict,
|
||||
request_body: bytes | None,
|
||||
key: ApiKey,
|
||||
max_cost_for_model: int,
|
||||
session: AsyncSession,
|
||||
model_obj: Model,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Override to use OpenAI-compatible endpoint for proxy requests."""
|
||||
if path.startswith("v1/"):
|
||||
path = path.replace("v1/", "")
|
||||
|
||||
original_base_url = self.base_url
|
||||
self.base_url = f"{self.base_url}/v1"
|
||||
|
||||
try:
|
||||
result = await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
return result
|
||||
finally:
|
||||
self.base_url = original_base_url
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from Ollama API using /api/tags endpoint."""
|
||||
from ..payment.models import Architecture, Model, Pricing, TopProvider
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(f"{self.base_url}/api/tags")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
models_list = []
|
||||
for model_data in data.get("models", []):
|
||||
model_name = model_data.get("name", "")
|
||||
if not model_name:
|
||||
continue
|
||||
|
||||
details = model_data.get("details", {})
|
||||
parameter_size = details.get("parameter_size", "")
|
||||
|
||||
context_length = 4096
|
||||
if (
|
||||
"70b" in parameter_size.lower()
|
||||
or "72b" in parameter_size.lower()
|
||||
):
|
||||
context_length = 8192
|
||||
elif "13b" in parameter_size.lower():
|
||||
context_length = 4096
|
||||
elif "7b" in parameter_size.lower():
|
||||
context_length = 4096
|
||||
elif "3b" in parameter_size.lower():
|
||||
context_length = 2048
|
||||
elif "1b" in parameter_size.lower():
|
||||
context_length = 2048
|
||||
|
||||
model_family = details.get("family", "unknown")
|
||||
model_format = details.get("format", "unknown")
|
||||
|
||||
description = f"Ollama {model_family} model"
|
||||
if parameter_size:
|
||||
description += f" ({parameter_size})"
|
||||
|
||||
models_list.append(
|
||||
Model(
|
||||
id=model_name,
|
||||
name=model_name.replace(":", " "),
|
||||
created=0,
|
||||
description=description,
|
||||
context_length=context_length,
|
||||
architecture=Architecture(
|
||||
modality="text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer=model_format,
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(
|
||||
prompt=0.000003,
|
||||
completion=0.000003,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
max_prompt_cost=0.001,
|
||||
max_completion_cost=0.001,
|
||||
max_cost=0.001,
|
||||
),
|
||||
sats_pricing=None,
|
||||
per_request_limits=None,
|
||||
top_provider=TopProvider(
|
||||
context_length=context_length,
|
||||
max_completion_tokens=context_length // 2,
|
||||
is_moderated=False,
|
||||
),
|
||||
enabled=True,
|
||||
upstream_provider_id=None,
|
||||
canonical_slug=None,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Fetched {len(models_list)} models from Ollama",
|
||||
extra={"model_count": len(models_list), "base_url": self.base_url},
|
||||
)
|
||||
return models_list
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to fetch models from Ollama API: {e}",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"base_url": self.base_url,
|
||||
},
|
||||
)
|
||||
return []
|
||||
|
||||
async def refresh_models_cache(self) -> None:
|
||||
"""Refresh the in-memory models cache from upstream API."""
|
||||
try:
|
||||
from ..payment.models import _update_model_sats_pricing
|
||||
from ..payment.price import sats_usd_price
|
||||
|
||||
models = await self.fetch_models()
|
||||
models_with_fees = [self._apply_provider_fee_to_model(m) for m in models]
|
||||
|
||||
try:
|
||||
sats_to_usd = sats_usd_price()
|
||||
self._models_cache = [
|
||||
_update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees
|
||||
]
|
||||
except Exception:
|
||||
self._models_cache = models_with_fees
|
||||
|
||||
self._models_by_id = {m.id: m for m in self._models_cache}
|
||||
logger.info(
|
||||
f"Refreshed models cache for {self.base_url}",
|
||||
extra={"model_count": len(models)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to refresh models cache for {self.base_url}",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
def get_cached_models(self) -> list[Model]:
|
||||
"""Get cached models for this provider.
|
||||
|
||||
Returns:
|
||||
List of cached Model objects
|
||||
"""
|
||||
return self._models_cache
|
||||
|
||||
def get_cached_model_by_id(self, model_id: str) -> Model | None:
|
||||
"""Get a specific cached model by ID.
|
||||
|
||||
Args:
|
||||
model_id: Model identifier
|
||||
|
||||
Returns:
|
||||
Model object or None if not found
|
||||
"""
|
||||
return self._models_by_id.get(model_id)
|
||||
|
||||
def _apply_provider_fee_to_model(self, model: Model) -> Model:
|
||||
"""Apply provider fee to model's USD pricing and calculate max costs.
|
||||
|
||||
Args:
|
||||
model: Model object to update
|
||||
|
||||
Returns:
|
||||
Model with provider fee applied to pricing and max costs calculated
|
||||
"""
|
||||
from ..payment.models import Model, Pricing, _calculate_usd_max_costs
|
||||
|
||||
adjusted_pricing = Pricing.parse_obj(
|
||||
{k: v * self.provider_fee for k, v in model.pricing.dict().items()}
|
||||
)
|
||||
|
||||
temp_model = Model(
|
||||
id=model.id,
|
||||
name=model.name,
|
||||
created=model.created,
|
||||
description=model.description,
|
||||
context_length=model.context_length,
|
||||
architecture=model.architecture,
|
||||
pricing=adjusted_pricing,
|
||||
sats_pricing=None,
|
||||
per_request_limits=model.per_request_limits,
|
||||
top_provider=model.top_provider,
|
||||
enabled=model.enabled,
|
||||
upstream_provider_id=model.upstream_provider_id,
|
||||
canonical_slug=model.canonical_slug,
|
||||
)
|
||||
|
||||
(
|
||||
adjusted_pricing.max_prompt_cost,
|
||||
adjusted_pricing.max_completion_cost,
|
||||
adjusted_pricing.max_cost,
|
||||
) = _calculate_usd_max_costs(temp_model)
|
||||
|
||||
return Model(
|
||||
id=model.id,
|
||||
name=model.name,
|
||||
created=model.created,
|
||||
description=model.description,
|
||||
context_length=model.context_length,
|
||||
architecture=model.architecture,
|
||||
pricing=adjusted_pricing,
|
||||
sats_pricing=model.sats_pricing,
|
||||
per_request_limits=model.per_request_limits,
|
||||
top_provider=model.top_provider,
|
||||
enabled=model.enabled,
|
||||
upstream_provider_id=model.upstream_provider_id,
|
||||
canonical_slug=model.canonical_slug,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user