mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
841 Commits
v0.1.1
...
display-ru
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceca0e8efc | ||
|
|
89109dc209 | ||
|
|
234ea19cad | ||
|
|
cd7de3958c | ||
|
|
8c0ac499ef | ||
|
|
28d91227af | ||
|
|
a2db3e2d57 | ||
|
|
e43ceb2e43 | ||
|
|
689a07f562 | ||
|
|
da487a850e | ||
|
|
fa6d3c76d0 | ||
|
|
ede1804d4b | ||
|
|
3392e8d4cb | ||
|
|
b5174d9753 | ||
|
|
1f2ff8a99c | ||
|
|
0c60644ba2 | ||
|
|
aca8d43a61 | ||
|
|
7fe4c1963b | ||
|
|
9a0919f149 | ||
|
|
d69ab913d4 | ||
|
|
42b8c332df | ||
|
|
aa682bf8ec | ||
|
|
c53e72e80a | ||
|
|
afd81aeca2 | ||
|
|
4e03145323 | ||
|
|
169686681f | ||
|
|
fa7d2804bb | ||
|
|
ccabc5d06d | ||
|
|
cb68227c88 | ||
|
|
c72fc7dd56 | ||
|
|
8c6d1f89dc | ||
|
|
1ab7e54bd7 | ||
|
|
42dceb0cd6 | ||
|
|
05115c3387 | ||
|
|
c0c8cafd00 | ||
|
|
16d6d66d17 | ||
|
|
3681fc8aab | ||
|
|
d95c09e00c | ||
|
|
3cdef5ec17 | ||
|
|
58e1620347 | ||
|
|
42b5a5e6c8 | ||
|
|
d84d249f2c | ||
|
|
8b6393f794 | ||
|
|
81146710ba | ||
|
|
25f39897d7 | ||
|
|
45bdfaee58 | ||
|
|
a1ae6e94e9 | ||
|
|
eebcc67c85 | ||
|
|
3f9e7f7728 | ||
|
|
a5b5549edd | ||
|
|
22eec0162c | ||
|
|
9f55da9bb8 | ||
|
|
16dce9ea81 | ||
|
|
963ee04619 | ||
|
|
b874b1f01c | ||
|
|
b6cca3d3a0 | ||
|
|
86ebc84f4c | ||
|
|
8a74c0543f | ||
|
|
3d16a9b988 | ||
|
|
40f98b99aa | ||
|
|
7fcae5b08d | ||
|
|
f50cb31749 | ||
|
|
85a1ea4b4c | ||
|
|
0f0f8c40bf | ||
|
|
a40d224ee7 | ||
|
|
a637acd8f4 | ||
|
|
58be0c7976 | ||
|
|
d934f3eead | ||
|
|
74b58e5fa3 | ||
|
|
c1497e0cfe | ||
|
|
30db582321 | ||
|
|
a1018776e9 | ||
|
|
5ef499e2ae | ||
|
|
c7c802c610 | ||
|
|
685368bb0a | ||
|
|
55e240d92a | ||
|
|
7da4ad3818 | ||
|
|
453337cb2c | ||
|
|
9e1934bfda | ||
|
|
31a9730cd8 | ||
|
|
d686e0e851 | ||
|
|
7708ed1c8b | ||
|
|
22ede7636a | ||
|
|
c9a5af0ffa | ||
|
|
072079b33d | ||
|
|
2abc28d295 | ||
|
|
7879b68cad | ||
|
|
9eff340c57 | ||
|
|
933ba105d8 | ||
|
|
205dc6c3b3 | ||
|
|
c1ada39278 | ||
|
|
200b32f1ff | ||
|
|
2b623a86c3 | ||
|
|
a68998e8ff | ||
|
|
905ae67015 | ||
|
|
6fde846be2 | ||
|
|
a833cf429e | ||
|
|
c5a205cf98 | ||
|
|
62185fbd38 | ||
|
|
236854bfe4 | ||
|
|
a7886c528f | ||
|
|
bb97e8dedb | ||
|
|
a7455a48d2 | ||
|
|
a090632804 | ||
|
|
d6b755ff81 | ||
|
|
a1c2a785a1 | ||
|
|
dc8ebe66e0 | ||
|
|
750193e99d | ||
|
|
4ee654331f | ||
|
|
b8fcf5bcc8 | ||
|
|
2ce8981f0c | ||
|
|
b76ff62f1c | ||
|
|
73ebfe8975 | ||
|
|
3dae03d731 | ||
|
|
a7b815b29f | ||
|
|
8a1f909083 | ||
|
|
a6c6d3e034 | ||
|
|
f2a0473fb3 | ||
|
|
33d2ef9ef1 | ||
|
|
f1c3b515fe | ||
|
|
74ddc48ff5 | ||
|
|
6db11ed88f | ||
|
|
4d302baeb5 | ||
|
|
063df51ced | ||
|
|
e97fa60b27 | ||
|
|
42b3840df6 | ||
|
|
a0b2b9466c | ||
|
|
9a553de011 | ||
|
|
901a6a9ba2 | ||
|
|
691927a996 | ||
|
|
8fcecf2c1f | ||
|
|
aed967dc44 | ||
|
|
ed8102d533 | ||
|
|
8fa8475cca | ||
|
|
4723b9db4d | ||
|
|
358ff25899 | ||
|
|
7267bb87b9 | ||
|
|
173f5fbcbd | ||
|
|
9006709f8d | ||
|
|
7d48b36be8 | ||
|
|
8b3fdaa545 | ||
|
|
b43d57df23 | ||
|
|
b9a5a9276e | ||
|
|
a79fdf7212 | ||
|
|
9560050946 | ||
|
|
dd88e9b172 | ||
|
|
e31b45fa9e | ||
|
|
1ed8b29d64 | ||
|
|
59f8d31719 | ||
|
|
93a368b1a2 | ||
|
|
d3dd346853 | ||
|
|
0198569a9a | ||
|
|
7e648cb5c2 | ||
|
|
deb75624f3 | ||
|
|
c5cb562165 | ||
|
|
8a89a38864 | ||
|
|
09e7f1f0bf | ||
|
|
d9d082ad5c | ||
|
|
9cd4ff5c21 | ||
|
|
11eb20a2d1 | ||
|
|
a63e81db06 | ||
|
|
8fc1b6484c | ||
|
|
9bc3feff62 | ||
|
|
cb22968ff3 | ||
|
|
e3bca39815 | ||
|
|
958f28fd82 | ||
|
|
c8c30d7cfd | ||
|
|
2c2124952f | ||
|
|
f56ba92ae8 | ||
|
|
1fa71ee806 | ||
|
|
97d164e181 | ||
|
|
9f3c915dec | ||
|
|
4ff6218140 | ||
|
|
67ea6aec43 | ||
|
|
41f346417a | ||
|
|
8a373276ce | ||
|
|
4857d741de | ||
|
|
eabe17cc26 | ||
|
|
8f298ae9c1 | ||
|
|
e5f3b7d755 | ||
|
|
3796e29cb4 | ||
|
|
812f50ea28 | ||
|
|
ce5fa136c2 | ||
|
|
4643aefb22 | ||
|
|
5aa902fd4a | ||
|
|
db144e0903 | ||
|
|
e20b20dbca | ||
|
|
aa700443d3 | ||
|
|
7e89b5bc6d | ||
|
|
8dbf036558 | ||
|
|
12f2cfecc9 | ||
|
|
a0f3378cf5 | ||
|
|
20b4c3a641 | ||
|
|
88f7ee734f | ||
|
|
326a0086b7 | ||
|
|
eb46651373 | ||
|
|
226188e22d | ||
|
|
89fc48eea2 | ||
|
|
f66d98e6b5 | ||
|
|
cfea4f9cd1 | ||
|
|
c74a877ab5 | ||
|
|
83a76e86fe | ||
|
|
66975ee271 | ||
|
|
5788d63892 | ||
|
|
d0c7cc6bd9 | ||
|
|
40096867d9 | ||
|
|
e002c0b66f | ||
|
|
608549d051 | ||
|
|
72f389c8d2 | ||
|
|
97daddb95a | ||
|
|
847f4b07a5 | ||
|
|
4bce04ada4 | ||
|
|
f1e2448620 | ||
|
|
28340a152c | ||
|
|
e28f6118e7 | ||
|
|
9fb6f54d12 | ||
|
|
3cb8d7b5dd | ||
|
|
ede076b881 | ||
|
|
449a0951f9 | ||
|
|
8c9ede2272 | ||
|
|
e794b09614 | ||
|
|
24f6519267 | ||
|
|
f57beb6411 | ||
|
|
3e41e59a1d | ||
|
|
002d750830 | ||
|
|
8c2eb55760 | ||
|
|
350714f23a | ||
|
|
9cc2e84f7c | ||
|
|
6fbd479bdc | ||
|
|
f67c26935a | ||
|
|
9ce91f58a9 | ||
|
|
bb82361434 | ||
|
|
5f1d67e87e | ||
|
|
b2106ad1e6 | ||
|
|
709a4ba0dc | ||
|
|
21f421b212 | ||
|
|
b3c5e4cbf6 | ||
|
|
1d95379328 | ||
|
|
342a7f7f16 | ||
|
|
7694f20006 | ||
|
|
3ea0a267dd | ||
|
|
68e537f6fb | ||
|
|
814c39898d | ||
|
|
fdd4f12f5c | ||
|
|
6957d8c0d9 | ||
|
|
2c358276bf | ||
|
|
48529f672a | ||
|
|
030c2f65e4 | ||
|
|
3510402af2 | ||
|
|
5f376d716d | ||
|
|
d889274f84 | ||
|
|
0c0f19d854 | ||
|
|
b61bffc666 | ||
|
|
af658136d4 | ||
|
|
8973627b5f | ||
|
|
25f427033a | ||
|
|
d3dd8318e4 | ||
|
|
c5fd386c1e | ||
|
|
52c7f17215 | ||
|
|
2c03302055 | ||
|
|
c3221f2a31 | ||
|
|
ce9834d7ec | ||
|
|
6ebe73f2f7 | ||
|
|
98aecb08f9 | ||
|
|
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 |
@@ -8,4 +8,6 @@ compose.testing.yml
|
||||
.todo
|
||||
.github
|
||||
.vscode
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
**/node_modules
|
||||
ui/.next
|
||||
|
||||
68
.env.example
68
.env.example
@@ -1,40 +1,44 @@
|
||||
# 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"
|
||||
# ENABLE_ANALYTICS_SHARING=true
|
||||
# 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
|
||||
|
||||
11
.github/workflows/container.yml
vendored
11
.github/workflows/container.yml
vendored
@@ -14,6 +14,14 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve git metadata
|
||||
id: gitmeta
|
||||
run: |
|
||||
echo "sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$(git describe --tags --exact-match HEAD 2>/dev/null || true)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
@@ -30,6 +38,9 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ steps.gitmeta.outputs.sha }}
|
||||
GIT_TAG=${{ steps.gitmeta.outputs.tag }}
|
||||
tags: |
|
||||
ghcr.io/routstr/proxy:latest
|
||||
ghcr.io/routstr/core:latest
|
||||
|
||||
56
.github/workflows/docs.yml
vendored
Normal file
56
.github/workflows/docs.yml
vendored
Normal file
@@ -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
|
||||
37
.github/workflows/test.yml
vendored
37
.github/workflows/test.yml
vendored
@@ -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: "20"
|
||||
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
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.11
|
||||
@@ -21,6 +21,10 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG GIT_COMMIT=""
|
||||
ARG GIT_TAG=""
|
||||
ENV GIT_COMMIT=${GIT_COMMIT}
|
||||
ENV GIT_TAG=${GIT_TAG}
|
||||
ENV PORT=8000
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
|
||||
54
Dockerfile.full
Normal file
54
Dockerfile.full
Normal file
@@ -0,0 +1,54 @@
|
||||
# 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
|
||||
|
||||
ARG GIT_COMMIT=""
|
||||
ARG GIT_TAG=""
|
||||
ENV GIT_COMMIT=${GIT_COMMIT}
|
||||
ENV GIT_TAG=${GIT_TAG}
|
||||
ENV PORT=8000
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["/app/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]
|
||||
41
Makefile
41
Makefile
@@ -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)
|
||||
|
||||
263
README.md
263
README.md
@@ -1,220 +1,81 @@
|
||||
# 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
|
||||
|
||||
- **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
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Running the proxy using Docker
|
||||
### cURL
|
||||
|
||||
```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
|
||||
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"}]
|
||||
}'
|
||||
```
|
||||
|
||||
### Development Requirements
|
||||
## Quick Start (Docker)
|
||||
|
||||
- Python 3.11+
|
||||
- [uv](https://github.com/astral-sh/uv) package manager (used in development)
|
||||
If you are a node runner, start a Routstr Core instance using Docker Compose:
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
uv sync # install dependencies
|
||||
```
|
||||
|
||||
Create a `.env` file based on `.env.example` and fill in the required values:
|
||||
1. **Prepare your `.env`**:
|
||||
```bash
|
||||
ADMIN_PASSWORD=mysecretpassword
|
||||
NAME="My AI Node"
|
||||
DESCRIPTION="Fast access to models"
|
||||
NSEC=yournsec
|
||||
RECEIVE_LN_ADDRESS=yourname@wallet.com
|
||||
```
|
||||
|
||||
2. **Start the services**:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. **Configure**:
|
||||
Open [http://localhost:8000/admin/](http://localhost:8000/admin/) to connect your AI providers and set pricing.
|
||||
|
||||
For full instructions, see the **[Provider Quick Start Guide](https://docs.routstr.com/provider/quickstart/)**.
|
||||
|
||||
## 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.
|
||||
|
||||
28
build-docs.sh
Executable file
28
build-docs.sh
Executable file
@@ -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
|
||||
@@ -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"
|
||||
|
||||
27
compose.yml
27
compose.yml
@@ -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:
|
||||
|
||||
428
docs/api/authentication.md
Normal file
428
docs/api/authentication.md
Normal file
@@ -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
|
||||
602
docs/api/endpoints.md
Normal file
602
docs/api/endpoints.md
Normal file
@@ -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
|
||||
592
docs/api/errors.md
Normal file
592
docs/api/errors.md
Normal file
@@ -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
|
||||
312
docs/api/overview.md
Normal file
312
docs/api/overview.md
Normal file
@@ -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
|
||||
143
docs/client/integration.md
Normal file
143
docs/client/integration.md
Normal file
@@ -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),
|
||||
)
|
||||
```
|
||||
142
docs/client/introduction.md
Normal file
142
docs/client/introduction.md
Normal file
@@ -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.
|
||||
97
docs/client/payments.md
Normal file
97
docs/client/payments.md
Normal file
@@ -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.
|
||||
329
docs/contributing/architecture.md
Normal file
329
docs/contributing/architecture.md
Normal file
@@ -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
|
||||
443
docs/contributing/code-structure.md
Normal file
443
docs/contributing/code-structure.md
Normal file
@@ -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
|
||||
424
docs/contributing/setup.md
Normal file
424
docs/contributing/setup.md
Normal file
@@ -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! 🚀
|
||||
233
docs/contributing/testing.md
Normal file
233
docs/contributing/testing.md
Normal file
@@ -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)
|
||||
44
docs/index.md
Normal file
44
docs/index.md
Normal file
@@ -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).*
|
||||
76
docs/overview.md
Normal file
76
docs/overview.md
Normal file
@@ -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) |
|
||||
114
docs/provider/advanced-pricing.md
Normal file
114
docs/provider/advanced-pricing.md
Normal file
@@ -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
|
||||
150
docs/provider/configuration.md
Normal file
150
docs/provider/configuration.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# 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 |
|
||||
| **Share Analytics** | Publish aggregate usage stats to Nostr |
|
||||
|
||||
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 | — |
|
||||
| `ENABLE_ANALYTICS_SHARING` | Enable usage analytics sharing to Nostr | `true` |
|
||||
| `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.
|
||||
209
docs/provider/dashboard.md
Normal file
209
docs/provider/dashboard.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# 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 |
|
||||
| **Share Analytics** | Toggle publishing aggregate usage stats to Nostr |
|
||||
|
||||
### 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 -->
|
||||
190
docs/provider/deployment.md
Normal file
190
docs/provider/deployment.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Deployment
|
||||
|
||||
Production deployment guide for Routstr Provider nodes.
|
||||
|
||||
## Docker Compose (Recommended)
|
||||
|
||||
For production, use Docker Compose with persistent storage and optional Tor support.
|
||||
|
||||
Use the included `compose.yml` for a flexible setup that handles both the UI and 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
|
||||
|
||||
### Using Docker Compose
|
||||
The easiest way to build everything from source:
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
```
|
||||
|
||||
### Individual Components
|
||||
If you prefer building the node only (requires manual UI build first):
|
||||
|
||||
```bash
|
||||
docker build -t routstr-node .
|
||||
```
|
||||
95
docs/provider/discovery.md
Normal file
95
docs/provider/discovery.md
Normal file
@@ -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()}")
|
||||
```
|
||||
91
docs/provider/pricing.md
Normal file
91
docs/provider/pricing.md
Normal file
@@ -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
|
||||
124
docs/provider/quickstart.md
Normal file
124
docs/provider/quickstart.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# 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"
|
||||
NSEC=yournsec
|
||||
|
||||
# Lightning Payouts
|
||||
RECEIVE_LN_ADDRESS=yourname@wallet.com
|
||||
|
||||
```
|
||||
|
||||
## 2. Start the Node
|
||||
|
||||
The recommended way to run Routstr is using Docker Compose, which handles the node, the UI, and optional services like Tor.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Verify it's running:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/info
|
||||
```
|
||||
|
||||
### Build from Source (Optional)
|
||||
|
||||
If you've cloned the repository and want to build the images yourself:
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
50
docs/provider/tor.md
Normal file
50
docs/provider/tor.md
Normal file
@@ -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")
|
||||
)
|
||||
```
|
||||
4
docs/requirements.txt
Normal file
4
docs/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
mkdocs>=1.5.3
|
||||
mkdocs-material>=9.5.0
|
||||
mkdocs-mermaid2-plugin>=1.1.1
|
||||
pymdown-extensions>=10.5
|
||||
37
example.py
37
example.py
@@ -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()
|
||||
11
examples/balance/check_balance.py
Normal file
11
examples/balance/check_balance.py
Normal file
@@ -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())
|
||||
15
examples/balance/create_balance.py
Normal file
15
examples/balance/create_balance.py
Normal file
@@ -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())
|
||||
12
examples/balance/refund_balance.py
Normal file
12
examples/balance/refund_balance.py
Normal file
@@ -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())
|
||||
16
examples/balance/topup_balance.py
Normal file
16
examples/balance/topup_balance.py
Normal file
@@ -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())
|
||||
15
examples/chat_completions.py
Normal file
15
examples/chat_completions.py
Normal file
@@ -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)
|
||||
45
examples/create_child_keys.py
Normal file
45
examples/create_child_keys.py
Normal file
@@ -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.")
|
||||
19
examples/list_models.py
Normal file
19
examples/list_models.py
Normal file
@@ -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()
|
||||
31
examples/responses/conversation.py
Normal file
31
examples/responses/conversation.py
Normal file
@@ -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)
|
||||
17
examples/responses/create.py
Normal file
17
examples/responses/create.py
Normal file
@@ -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)
|
||||
20
examples/responses/streaming_response.py
Normal file
20
examples/responses/streaming_response.py
Normal file
@@ -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()
|
||||
16
examples/responses/web_search.py
Normal file
16
examples/responses/web_search.py
Normal file
@@ -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)
|
||||
28
examples/streaming.py
Normal file
28
examples/streaming.py
Normal file
@@ -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})
|
||||
20
examples/tor.py
Normal file
20
examples/tor.py
Normal file
@@ -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 @@
|
||||
|
||||
|
||||
32
migrations/versions/02650cd6f028_add_routstr_fees_table.py
Normal file
32
migrations/versions/02650cd6f028_add_routstr_fees_table.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""add routstr_fees table
|
||||
|
||||
Revision ID: 02650cd6f028
|
||||
Revises: c3d4e5f6a7b8
|
||||
Create Date: 2026-04-24 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "02650cd6f028"
|
||||
down_revision = "c3d4e5f6a7b8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"routstr_fees",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("accumulated_msats", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("total_paid_msats", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("last_paid_at", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
# Seed with a single row
|
||||
op.execute("INSERT INTO routstr_fees (id, accumulated_msats, total_paid_msats) VALUES (1, 0, 0)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("routstr_fees")
|
||||
@@ -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,32 @@
|
||||
"""add provider_settings to upstream_providers
|
||||
|
||||
Revision ID: 614c0a740e68
|
||||
Revises: 06f81c0fc88d
|
||||
Create Date: 2026-02-13 22:36:53.608737
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "614c0a740e68"
|
||||
down_revision = "06f81c0fc88d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Check if column exists before adding it
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [c["name"] for c in inspector.get_columns("upstream_providers")]
|
||||
|
||||
if "provider_settings" not in columns:
|
||||
op.add_column(
|
||||
"upstream_providers",
|
||||
sa.Column("provider_settings", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("upstream_providers", "provider_settings")
|
||||
64
migrations/versions/a1a1a1a1a1a1_composite_pk_for_models.py
Normal file
64
migrations/versions/a1a1a1a1a1a1_composite_pk_for_models.py
Normal file
@@ -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"]),
|
||||
)
|
||||
35
migrations/versions/a1b2c3d4e5f6_add_settings_table.py
Normal file
35
migrations/versions/a1b2c3d4e5f6_add_settings_table.py
Normal file
@@ -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")
|
||||
42
migrations/versions/a776ca70e5fe_add_cashu_refunds_table.py
Normal file
42
migrations/versions/a776ca70e5fe_add_cashu_refunds_table.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""add cashu_transactions table
|
||||
|
||||
Revision ID: a776ca70e5fe
|
||||
Revises: 614c0a740e68
|
||||
Create Date: 2026-03-11 22:00:01.554762
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a776ca70e5fe"
|
||||
down_revision = "614c0a740e68"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"cashu_transactions",
|
||||
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("amount", sa.Integer(), nullable=False),
|
||||
sa.Column("unit", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column(
|
||||
"type",
|
||||
sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=False,
|
||||
server_default="out",
|
||||
),
|
||||
sa.Column("request_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||
sa.Column("collected", sa.Boolean(), nullable=False),
|
||||
sa.Column("swept", sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("cashu_transactions")
|
||||
42
migrations/versions/a86e5348850b_.py
Normal file
42
migrations/versions/a86e5348850b_.py
Normal file
@@ -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,33 @@
|
||||
"""add forwarded_model_id to models
|
||||
|
||||
Revision ID: b1c2d3e4f5a6
|
||||
Revises: a776ca70e5fe
|
||||
Create Date: 2026-04-05 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b1c2d3e4f5a6"
|
||||
down_revision = "a776ca70e5fe"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"models",
|
||||
sa.Column(
|
||||
"forwarded_model_id",
|
||||
sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
# Backfill: set forwarded_model_id = id for all existing rows
|
||||
op.execute("UPDATE models SET forwarded_model_id = id WHERE forwarded_model_id IS NULL")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("models", "forwarded_model_id")
|
||||
37
migrations/versions/b9667ffc5701_alias_ids.py
Normal file
37
migrations/versions/b9667ffc5701_alias_ids.py
Normal file
@@ -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 ###
|
||||
37
migrations/versions/c0ffee123456_create_models_table.py
Normal file
37
migrations/versions/c0ffee123456_create_models_table.py
Normal file
@@ -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,36 @@
|
||||
"""add source to cashu_transactions
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: b1c2d3e4f5a6
|
||||
Create Date: 2026-04-10 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c3d4e5f6a7b8"
|
||||
down_revision = "b1c2d3e4f5a6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col["name"] for col in inspector.get_columns("cashu_transactions")]
|
||||
if "source" not in columns:
|
||||
op.add_column(
|
||||
"cashu_transactions",
|
||||
sa.Column(
|
||||
"source",
|
||||
sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=False,
|
||||
server_default="x-cashu",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("cashu_transactions", "source")
|
||||
34
migrations/versions/cli_tokens_table.py
Normal file
34
migrations/versions/cli_tokens_table.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""add cli_tokens table
|
||||
|
||||
Revision ID: cli_tokens_001
|
||||
Revises: e8f9a0b1c2d3
|
||||
Create Date: 2026-04-25 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "cli_tokens_001"
|
||||
down_revision = "e8f9a0b1c2d3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"cli_tokens",
|
||||
sa.Column("id", sa.String(), primary_key=True, nullable=False),
|
||||
sa.Column("token", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||
sa.Column("last_used_at", sa.Integer(), nullable=True),
|
||||
sa.Column("expires_at", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_cli_tokens_token", "cli_tokens", ["token"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_cli_tokens_token", table_name="cli_tokens")
|
||||
op.drop_table("cli_tokens")
|
||||
@@ -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,46 @@
|
||||
"""add api key link to cashu_transactions
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: c3d4e5f6a7b8
|
||||
Create Date: 2026-04-20 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "d4e5f6a7b8c9"
|
||||
down_revision = "c3d4e5f6a7b8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col["name"] for col in inspector.get_columns("cashu_transactions")]
|
||||
indexes = {index["name"] for index in inspector.get_indexes("cashu_transactions")}
|
||||
|
||||
if "api_key_hashed_key" not in columns:
|
||||
op.add_column(
|
||||
"cashu_transactions",
|
||||
sa.Column(
|
||||
"api_key_hashed_key",
|
||||
sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
if "ix_cashu_transactions_api_key_hashed_key" not in indexes:
|
||||
op.create_index(
|
||||
"ix_cashu_transactions_api_key_hashed_key",
|
||||
"cashu_transactions",
|
||||
["api_key_hashed_key"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_cashu_transactions_api_key_hashed_key", table_name="cashu_transactions")
|
||||
op.drop_column("cashu_transactions", "api_key_hashed_key")
|
||||
@@ -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),
|
||||
)
|
||||
20
migrations/versions/e8f9a0b1c2d3_merge_heads.py
Normal file
20
migrations/versions/e8f9a0b1c2d3_merge_heads.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""merge heads: routstr_fees + api_key_to_cashu_transactions
|
||||
|
||||
Revision ID: e8f9a0b1c2d3
|
||||
Revises: 02650cd6f028, d4e5f6a7b8c9
|
||||
Create Date: 2026-04-24 00:00:00.000000
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "e8f9a0b1c2d3"
|
||||
down_revision = ("02650cd6f028", "d4e5f6a7b8c9")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -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")
|
||||
39
migrations/versions/lightning_invoices_table.py
Normal file
39
migrations/versions/lightning_invoices_table.py
Normal file
@@ -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
mkdocs.yml
Normal file
103
mkdocs.yml
Normal file
@@ -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
|
||||
355
plans/other-mints-balance-tracking-plan.md
Normal file
355
plans/other-mints-balance-tracking-plan.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Plan: Track unsupported incoming mints in `other_mints` and include them in balances
|
||||
|
||||
## Goal
|
||||
|
||||
When a Cashu token arrives from a mint that is **not** in `settings.cashu_mints`, we currently create/use a wallet for that mint and swap value into the primary mint. Any change/surplus left behind after `melt()` remains in the foreign `token_wallet`, but that balance is not surfaced by `fetch_all_balances()` because it only iterates over configured mints.
|
||||
|
||||
This plan adds persistent tracking for those foreign mints in a new database table called `other_mints`, and updates balance reporting to include them.
|
||||
|
||||
---
|
||||
|
||||
## Current behavior
|
||||
|
||||
### Incoming unsupported mint flow
|
||||
|
||||
In `routstr/wallet.py`:
|
||||
|
||||
- `recieve_token()` deserializes the token
|
||||
- if `token_obj.mint not in settings.cashu_mints`, it calls `swap_to_primary_mint(token_obj, wallet)`
|
||||
- `swap_to_primary_mint()` calls `token_wallet.melt(...)` to pay the primary mint invoice
|
||||
|
||||
### Important detail: change is retained, not discarded
|
||||
|
||||
The underlying Cashu wallet library keeps any melt change:
|
||||
|
||||
- `Wallet.melt()` constructs blank outputs for change
|
||||
- when the melt succeeds, returned change is reconstructed into proofs
|
||||
- those proofs are appended to `self.proofs` and stored in the wallet DB
|
||||
|
||||
So surplus from unsupported mints is **not discarded**, but it may become invisible operationally.
|
||||
|
||||
### Visibility problem
|
||||
|
||||
`fetch_all_balances()` currently only loops over:
|
||||
|
||||
- `settings.cashu_mints`
|
||||
- units `sat` and `msat`
|
||||
|
||||
This means balances left on unsupported mints are not shown in admin balance reporting.
|
||||
|
||||
---
|
||||
|
||||
## Proposed design
|
||||
|
||||
## 1. Add a new DB table: `other_mints`
|
||||
|
||||
Add a small table in `routstr/core/db.py` to persist unsupported mints we have seen in incoming tokens.
|
||||
|
||||
Suggested schema:
|
||||
|
||||
- `mint_url: str` primary key
|
||||
- `created_at: int`
|
||||
- `last_seen_at: int`
|
||||
|
||||
Minimal model:
|
||||
|
||||
```python
|
||||
class OtherMint(SQLModel, table=True):
|
||||
__tablename__ = "other_mints"
|
||||
|
||||
mint_url: str = Field(primary_key=True)
|
||||
created_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
last_seen_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
```
|
||||
|
||||
Why minimal:
|
||||
|
||||
- the only required function is mint discovery/tracking
|
||||
- unit handling can remain dynamic via existing balance queries over `sat` and `msat`
|
||||
|
||||
---
|
||||
|
||||
## 2. Add DB helpers for `other_mints`
|
||||
|
||||
In `routstr/core/db.py`, add helper functions:
|
||||
|
||||
### `register_other_mint(mint_url: str) -> None`
|
||||
|
||||
Behavior:
|
||||
|
||||
- if the mint is not present, insert it
|
||||
- if it already exists, update `last_seen_at`
|
||||
|
||||
### `list_other_mints(session) -> list[str]`
|
||||
|
||||
Behavior:
|
||||
|
||||
- return all tracked unsupported mint URLs
|
||||
|
||||
Optional later:
|
||||
|
||||
- `delete_other_mint(...)`
|
||||
- admin cleanup helpers
|
||||
|
||||
---
|
||||
|
||||
## 3. Register unsupported mints during token receipt
|
||||
|
||||
Update `recieve_token()` in `routstr/wallet.py`.
|
||||
|
||||
Current logic:
|
||||
|
||||
```python
|
||||
if token_obj.mint not in settings.cashu_mints:
|
||||
return await swap_to_primary_mint(token_obj, wallet)
|
||||
```
|
||||
|
||||
Planned logic:
|
||||
|
||||
```python
|
||||
if token_obj.mint not in settings.cashu_mints:
|
||||
await db.register_other_mint(token_obj.mint)
|
||||
return await swap_to_primary_mint(token_obj, wallet)
|
||||
```
|
||||
|
||||
Why here:
|
||||
|
||||
- this is the earliest reliable point where we know the mint came in via an actual token
|
||||
- this is exactly the path that can leave foreign-mint change behind
|
||||
- it avoids needing to infer unsupported mints later from wallet internals
|
||||
|
||||
---
|
||||
|
||||
## 4. Update `fetch_all_balances()` to include `other_mints`
|
||||
|
||||
Current behavior only includes configured mints.
|
||||
|
||||
Planned behavior:
|
||||
|
||||
- load tracked unsupported mints from DB
|
||||
- combine them with `settings.cashu_mints`
|
||||
- dedupe while preserving order
|
||||
- fetch balances for all tracked mints across requested units
|
||||
|
||||
Conceptual flow:
|
||||
|
||||
```python
|
||||
tracked_mints = dedupe(settings.cashu_mints + other_mints_from_db)
|
||||
```
|
||||
|
||||
Then existing per-mint/per-unit balance logic can remain mostly unchanged.
|
||||
|
||||
This ensures that retained change on unsupported mints becomes visible in admin balance reporting.
|
||||
|
||||
---
|
||||
|
||||
## 5. Add a balance source marker
|
||||
|
||||
Extend `BalanceDetail` in `routstr/wallet.py` to identify whether a balance row comes from a configured mint or an `other_mints` entry.
|
||||
|
||||
Suggested field:
|
||||
|
||||
- `source: str` with values:
|
||||
- `"configured"`
|
||||
- `"other"`
|
||||
|
||||
Updated shape:
|
||||
|
||||
```python
|
||||
class BalanceDetail(TypedDict, total=False):
|
||||
mint_url: str
|
||||
unit: str
|
||||
source: str
|
||||
wallet_balance: int
|
||||
user_balance: int
|
||||
owner_balance: int
|
||||
error: str
|
||||
```
|
||||
|
||||
Why this helps:
|
||||
|
||||
- admin can distinguish normal configured wallet balances from foreign/unsupported balances
|
||||
- avoids confusion if unexpected mint URLs show up in the balances API/UI
|
||||
|
||||
---
|
||||
|
||||
## 6. Admin/API impact
|
||||
|
||||
Backend impact is minimal because `/admin/api/balances` already returns `fetch_all_balances()` output.
|
||||
|
||||
Effects:
|
||||
|
||||
- supported mints continue to show as before
|
||||
- tracked unsupported mints will also appear
|
||||
- UI can optionally display the new `source` field
|
||||
|
||||
No API contract break is expected if the frontend ignores unknown fields.
|
||||
|
||||
---
|
||||
|
||||
## 7. Payout behavior: do not change in phase 1
|
||||
|
||||
`periodic_payout()` currently only iterates over `settings.cashu_mints`.
|
||||
|
||||
Recommendation for this change:
|
||||
|
||||
- **do not** expand `periodic_payout()` to include `other_mints` yet
|
||||
- only improve visibility through balance reporting
|
||||
|
||||
Reason:
|
||||
|
||||
- automatic payout from unsupported/foreign mints may be operationally undesirable
|
||||
- visibility should come first, automation second
|
||||
|
||||
Possible future phase:
|
||||
|
||||
- add optional sweeping/payout support for `other_mints`
|
||||
- or provide an admin-triggered withdrawal/sweep flow
|
||||
|
||||
---
|
||||
|
||||
## 8. Logging improvements (optional)
|
||||
|
||||
Optional follow-up improvement in `swap_to_primary_mint()`:
|
||||
|
||||
- capture the return value from `token_wallet.melt(...)`
|
||||
- if feasible, log any reported change amount
|
||||
- otherwise, rely on wallet balance reporting to surface residual amounts
|
||||
|
||||
This is useful but not required for the first implementation.
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
### `routstr/core/db.py`
|
||||
|
||||
Add:
|
||||
|
||||
- `OtherMint` SQLModel
|
||||
- `register_other_mint()`
|
||||
- `list_other_mints()`
|
||||
|
||||
### `migrations/versions/<new_revision>_add_other_mints_table.py`
|
||||
|
||||
Create migration to add the `other_mints` table.
|
||||
|
||||
### `routstr/wallet.py`
|
||||
|
||||
Update:
|
||||
|
||||
- `recieve_token()` to register unsupported mints
|
||||
- `BalanceDetail` to include `source`
|
||||
- `fetch_all_balances()` to include both configured and tracked unsupported mints
|
||||
|
||||
### `routstr/core/admin.py`
|
||||
|
||||
Likely no backend changes required unless a dedicated `other_mints` API is desired.
|
||||
|
||||
---
|
||||
|
||||
## Behavior rules
|
||||
|
||||
### Register a mint when
|
||||
|
||||
- an incoming token is processed
|
||||
- the token mint is not in `settings.cashu_mints`
|
||||
|
||||
### Do not remove automatically when
|
||||
|
||||
- balance reaches zero
|
||||
|
||||
Reason:
|
||||
|
||||
- historical visibility is useful
|
||||
- avoids flapping entries in the admin balance list
|
||||
- mint may receive additional unsupported tokens later
|
||||
|
||||
Potential future enhancement:
|
||||
|
||||
- admin endpoint to prune zero-balance `other_mints`
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
### A mint later becomes configured
|
||||
|
||||
If a mint in `other_mints` is later added to `settings.cashu_mints`:
|
||||
|
||||
- deduplication prevents duplicate balance rows
|
||||
- `source` should resolve to `configured`
|
||||
|
||||
### Unsupported mint with zero balance
|
||||
|
||||
A tracked unsupported mint may show zero balances.
|
||||
|
||||
Initial recommendation:
|
||||
|
||||
- allow it to appear
|
||||
- consider later filtering zero-balance `other` rows if the UI becomes noisy
|
||||
|
||||
### Units
|
||||
|
||||
Balance fetching can continue to query both `sat` and `msat` for each tracked mint.
|
||||
|
||||
If a mint has no proofs in one unit, current error/zero handling can continue to apply.
|
||||
|
||||
---
|
||||
|
||||
## Test plan
|
||||
|
||||
### DB tests
|
||||
|
||||
- registering a new unsupported mint inserts a row
|
||||
- registering the same mint again updates `last_seen_at` without duplication
|
||||
- listing other mints returns expected mint URLs
|
||||
|
||||
### Wallet tests
|
||||
|
||||
#### `recieve_token()`
|
||||
|
||||
- when mint is unsupported, `db.register_other_mint()` is called before swap
|
||||
- when mint is configured, `db.register_other_mint()` is not called
|
||||
|
||||
#### `fetch_all_balances()`
|
||||
|
||||
- includes configured mints
|
||||
- includes `other_mints` from DB
|
||||
- dedupes if a mint exists in both configured and other lists
|
||||
- sets `source` correctly
|
||||
|
||||
### Regression tests
|
||||
|
||||
- existing trusted mint balance reporting remains unchanged
|
||||
- `/admin/api/balances` continues to work
|
||||
|
||||
---
|
||||
|
||||
## Recommended implementation order
|
||||
|
||||
1. Add `OtherMint` model to `routstr/core/db.py`
|
||||
2. Add Alembic migration for `other_mints`
|
||||
3. Add `register_other_mint()` and `list_other_mints()` helpers
|
||||
4. Update `recieve_token()` to register unsupported mints
|
||||
5. Update `fetch_all_balances()` to union configured + tracked other mints
|
||||
6. Add `source` to `BalanceDetail`
|
||||
7. Add/adjust tests
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
This change solves an operational visibility problem:
|
||||
|
||||
- unsupported incoming mints can leave retained change in foreign wallets
|
||||
- those funds are currently preserved but not surfaced in balance reporting
|
||||
- introducing `other_mints` makes those mints discoverable and auditable
|
||||
- expanding `fetch_all_balances()` ensures their balances are visible in admin tooling
|
||||
|
||||
Recommended scope for the first pass:
|
||||
|
||||
- track unsupported mints in DB
|
||||
- include them in balance reporting
|
||||
- mark them as `source="other"`
|
||||
- do not yet change payout/sweeping behavior
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.1.1b"
|
||||
version = "0.4.3"
|
||||
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"]
|
||||
|
||||
365
routstr/algorithm.py
Normal file
365
routstr/algorithm.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""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"] = {}
|
||||
seen_model_provider: set[tuple[str, str]] = set()
|
||||
|
||||
providers_by_db_id: dict[int, "BaseUpstreamProvider"] = {}
|
||||
for upstream in upstreams:
|
||||
db_id = getattr(upstream, "db_id", None)
|
||||
if isinstance(db_id, int):
|
||||
providers_by_db_id[db_id] = upstream
|
||||
|
||||
# Group upstreams by URL and keep only the one with the lowest fee for each URL
|
||||
upstreams_by_url: dict[str, list["BaseUpstreamProvider"]] = {}
|
||||
for upstream in upstreams:
|
||||
url = getattr(upstream, "base_url", "")
|
||||
if url not in upstreams_by_url:
|
||||
upstreams_by_url[url] = []
|
||||
upstreams_by_url[url].append(upstream)
|
||||
|
||||
filtered_upstreams: list["BaseUpstreamProvider"] = []
|
||||
for providers in upstreams_by_url.values():
|
||||
best_provider = min(providers, key=lambda p: p.provider_fee)
|
||||
filtered_upstreams.append(best_provider)
|
||||
|
||||
# Separate OpenRouter from other providers
|
||||
openrouter: "BaseUpstreamProvider" | None = None
|
||||
other_upstreams: list["BaseUpstreamProvider"] = []
|
||||
|
||||
for upstream in filtered_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 get_provider_identity(upstream: "BaseUpstreamProvider") -> str:
|
||||
"""Get a stable provider identity used for deduplication."""
|
||||
db_id = getattr(upstream, "db_id", None)
|
||||
if isinstance(db_id, int):
|
||||
return f"db:{db_id}"
|
||||
|
||||
provider_type = str(getattr(upstream, "provider_type", "") or "").lower()
|
||||
base_url = str(getattr(upstream, "base_url", "") or "").lower()
|
||||
return f"{provider_type}|{base_url}"
|
||||
|
||||
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)
|
||||
provider_key = get_provider_identity(upstream)
|
||||
|
||||
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)
|
||||
|
||||
# Register forwarded_model_id as a routable alias
|
||||
if model_to_use.forwarded_model_id and model_to_use.forwarded_model_id not in aliases:
|
||||
aliases.append(model_to_use.forwarded_model_id)
|
||||
|
||||
# Try to set each alias
|
||||
for alias in aliases:
|
||||
_add_candidate(alias, model_to_use, upstream)
|
||||
seen_model_provider.add((model_to_use.id.lower(), provider_key))
|
||||
|
||||
# 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)
|
||||
|
||||
# Include enabled DB overrides even when provider discovery misses models.
|
||||
# This is important for deployment-based providers like Azure.
|
||||
for model_id, override_data in overrides_by_id.items():
|
||||
if model_id in disabled_model_ids:
|
||||
continue
|
||||
override_row, provider_fee = override_data
|
||||
upstream_provider_id = getattr(override_row, "upstream_provider_id", None)
|
||||
if not isinstance(upstream_provider_id, int):
|
||||
continue
|
||||
|
||||
upstream_for_override = providers_by_db_id.get(upstream_provider_id)
|
||||
if upstream_for_override is None:
|
||||
continue
|
||||
|
||||
provider_key = get_provider_identity(upstream_for_override)
|
||||
dedupe_key = (model_id.lower(), provider_key)
|
||||
if dedupe_key in seen_model_provider:
|
||||
continue
|
||||
|
||||
try:
|
||||
model_to_use = _row_to_model(
|
||||
override_row, apply_provider_fee=True, provider_fee=provider_fee
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Skipping invalid model override while building model mappings",
|
||||
extra={
|
||||
"model_id": model_id,
|
||||
"upstream_provider_id": upstream_provider_id,
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
continue
|
||||
if not model_to_use.enabled:
|
||||
continue
|
||||
|
||||
base_id = get_base_model_id(model_to_use.id)
|
||||
is_openrouter = (
|
||||
getattr(upstream_for_override, "base_url", "")
|
||||
== "https://openrouter.ai/api/v1"
|
||||
)
|
||||
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_for_override.provider_type,
|
||||
}
|
||||
)
|
||||
unique_models[base_id] = unique_model
|
||||
|
||||
try:
|
||||
aliases = resolve_model_alias(
|
||||
model_to_use.id,
|
||||
model_to_use.canonical_slug,
|
||||
alias_ids=model_to_use.alias_ids,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Skipping model aliases for invalid override model",
|
||||
extra={
|
||||
"model_id": model_id,
|
||||
"upstream_provider_id": upstream_provider_id,
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
upstream_prefix = getattr(upstream_for_override, "upstream_name", None)
|
||||
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)
|
||||
|
||||
# Register forwarded_model_id as a routable alias
|
||||
if model_to_use.forwarded_model_id and model_to_use.forwarded_model_id not in aliases:
|
||||
aliases.append(model_to_use.forwarded_model_id)
|
||||
|
||||
for alias in aliases:
|
||||
_add_candidate(alias, model_to_use, upstream_for_override)
|
||||
seen_model_provider.add(dedupe_key)
|
||||
|
||||
# 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
|
||||
904
routstr/auth.py
904
routstr/auth.py
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,32 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import time
|
||||
from time import monotonic
|
||||
from typing import Annotated, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import col, select, update
|
||||
|
||||
from .auth import 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 .auth import get_billing_key, validate_bearer_key
|
||||
from .core.db import (
|
||||
ApiKey,
|
||||
AsyncSession,
|
||||
CashuTransaction,
|
||||
get_session,
|
||||
store_cashu_transaction,
|
||||
)
|
||||
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 +41,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 +125,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 +143,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,51 +154,204 @@ 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():
|
||||
raise HTTPException(status_code=400, detail="Token already spent")
|
||||
elif "invalid" in error_msg.lower() or "decode" in error_msg.lower():
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
elif "insufficient" in error_msg.lower() or "melt fee" in error_msg.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Token value is too small to cover swap fees. {error_msg}",
|
||||
)
|
||||
elif "failed to melt" in error_msg.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to swap foreign mint token. {error_msg}",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Failed to redeem token")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to redeem token: {error_msg}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"topup_wallet_endpoint: unhandled error",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
return {"msats": amount_msats}
|
||||
|
||||
|
||||
@router.post("/refund")
|
||||
async def refund_wallet_endpoint(
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
remaining_balance_msats: int = key.balance
|
||||
_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]]] = {}
|
||||
|
||||
if remaining_balance_msats <= 0:
|
||||
|
||||
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)
|
||||
|
||||
|
||||
async def _restore_balance(
|
||||
session: AsyncSession, hashed_key: str, balance: int, reserved_balance: int
|
||||
) -> None:
|
||||
"""Restore balance after a failed refund mint attempt."""
|
||||
restore_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == hashed_key)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) + balance,
|
||||
reserved_balance=col(ApiKey.reserved_balance) + reserved_balance,
|
||||
)
|
||||
)
|
||||
await session.exec(restore_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"refund_wallet_endpoint: balance restored after mint failure",
|
||||
extra={"hashed_key": hashed_key, "restored_balance": balance},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refund", response_model=None)
|
||||
async def refund_wallet_endpoint(
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
x_cashu: Annotated[str | None, Header()] = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> JSONResponse | dict[str, str]:
|
||||
if x_cashu:
|
||||
# Find the "in" transaction by the original payment token
|
||||
in_tx_result = await session.exec(
|
||||
select(CashuTransaction).where(
|
||||
CashuTransaction.token == x_cashu,
|
||||
CashuTransaction.type == "in",
|
||||
)
|
||||
)
|
||||
in_tx = in_tx_result.first()
|
||||
if in_tx is None:
|
||||
raise HTTPException(status_code=404, detail="Refund not found")
|
||||
|
||||
# Use the request_id to find the associated "out" (refund) transaction
|
||||
if in_tx.request_id is None:
|
||||
raise HTTPException(status_code=404, detail="Refund not found")
|
||||
|
||||
out_tx_result = await session.exec(
|
||||
select(CashuTransaction).where(
|
||||
CashuTransaction.request_id == in_tx.request_id,
|
||||
CashuTransaction.type == "out",
|
||||
)
|
||||
)
|
||||
out_tx = out_tx_result.first()
|
||||
if out_tx is None:
|
||||
raise HTTPException(status_code=404, detail="Refund not found")
|
||||
if out_tx.swept:
|
||||
raise HTTPException(status_code=410, detail="Refund has been swept")
|
||||
|
||||
out_tx.collected = True
|
||||
session.add(out_tx)
|
||||
await session.commit()
|
||||
body: dict[str, str] = {"token": out_tx.token}
|
||||
if out_tx.unit == "sat":
|
||||
body["sats"] = str(out_tx.amount)
|
||||
else:
|
||||
body["msats"] = str(out_tx.amount)
|
||||
return JSONResponse(content=body, headers={"X-Cashu": out_tx.token})
|
||||
|
||||
if authorization is None or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid authorization. Use 'Bearer <cashu-token>' or 'Bearer <api-key>'",
|
||||
)
|
||||
|
||||
bearer_value: str = authorization[7:]
|
||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||
|
||||
if key.total_balance <= 0:
|
||||
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.",
|
||||
)
|
||||
|
||||
if key.reserved_balance > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot refund key. There are ongoing requests for this api key.",
|
||||
)
|
||||
|
||||
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
|
||||
# Capture values before debit — the session may refresh key after commit
|
||||
pre_debit_balance = key.balance
|
||||
pre_debit_reserved = key.reserved_balance
|
||||
|
||||
# --- DEBIT FIRST: atomically zero the balance before minting tokens ---
|
||||
# This prevents the race where a concurrent topup/spend happens between
|
||||
# reading the balance and minting the refund token (double-spend).
|
||||
debit_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) == pre_debit_balance)
|
||||
.where(col(ApiKey.reserved_balance) == pre_debit_reserved)
|
||||
.values(balance=0, reserved_balance=0)
|
||||
)
|
||||
debit_result = await session.exec(debit_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if debit_result.rowcount == 0:
|
||||
# Balance changed between read and debit — another request is active
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Balance changed concurrently. Please retry the refund.",
|
||||
)
|
||||
|
||||
# --- MINT: balance is locked at zero, safe to create the refund token ---
|
||||
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}
|
||||
|
||||
@@ -125,11 +360,24 @@ async def refund_wallet_endpoint(
|
||||
else:
|
||||
result["msats"] = str(remaining_balance_msats)
|
||||
|
||||
if "token" in result:
|
||||
logger.info(
|
||||
"refund_wallet_endpoint: cashu token issued",
|
||||
extra={
|
||||
"path": "/v1/wallet/refund",
|
||||
"token": result["token"],
|
||||
"amount": remaining_balance,
|
||||
"currency": key.refund_currency or "sat",
|
||||
},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions (like 400 for balance too small)
|
||||
# Minting failed — restore the debited balance
|
||||
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved)
|
||||
raise
|
||||
except Exception as e:
|
||||
# If refund fails, don't modify the database
|
||||
# Minting failed — restore the debited balance
|
||||
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved)
|
||||
error_msg = str(e)
|
||||
if (
|
||||
"mint" in error_msg.lower()
|
||||
@@ -141,12 +389,214 @@ async def refund_wallet_endpoint(
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Refund failed")
|
||||
|
||||
await session.delete(key)
|
||||
await session.commit()
|
||||
await _refund_cache_set(bearer_value, result)
|
||||
|
||||
if "token" in result:
|
||||
try:
|
||||
await store_cashu_transaction(
|
||||
token=result["token"],
|
||||
amount=remaining_balance,
|
||||
unit=key.refund_currency or "sat",
|
||||
mint_url=key.refund_mint_url,
|
||||
typ="out",
|
||||
collected=False,
|
||||
source="apikey",
|
||||
api_key_hashed_key=key.hashed_key,
|
||||
)
|
||||
except Exception:
|
||||
pass # store_cashu_transaction already logs
|
||||
|
||||
logger.info(
|
||||
"refund_wallet_endpoint: refund successful",
|
||||
extra={
|
||||
"refunded_msats": remaining_balance_msats,
|
||||
"previous_reserved_balance": key.reserved_balance,
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def wallet_history(
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, list[dict[str, str | int | bool | None]]]:
|
||||
if key.parent_key_hash:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot view child key history. Please use the parent key instead.",
|
||||
)
|
||||
|
||||
result = await session.exec(
|
||||
select(CashuTransaction)
|
||||
.where(CashuTransaction.api_key_hashed_key == key.hashed_key)
|
||||
.order_by(col(CashuTransaction.created_at).desc())
|
||||
)
|
||||
transactions = result.all()
|
||||
return {
|
||||
"transactions": [
|
||||
{
|
||||
"id": tx.id,
|
||||
"type": tx.type,
|
||||
"source": tx.source,
|
||||
"amount": tx.amount,
|
||||
"unit": tx.unit,
|
||||
"mint_url": tx.mint_url,
|
||||
"created_at": tx.created_at,
|
||||
"collected": tx.collected,
|
||||
"swept": tx.swept,
|
||||
}
|
||||
for tx in transactions
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@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.get("/cashu-refund/{payment_token_hash}")
|
||||
async def get_cashu_refund(
|
||||
payment_token_hash: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Retrieve a stored Cashu refund token by the hash of the original payment token."""
|
||||
result = await session.get(CashuTransaction, payment_token_hash)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Refund not found")
|
||||
if result.swept:
|
||||
raise HTTPException(status_code=410, detail="Refund has been swept")
|
||||
result.collected = True
|
||||
session.add(result)
|
||||
await session.commit()
|
||||
return {
|
||||
"refund_token": result.token,
|
||||
"amount": result.amount,
|
||||
"unit": result.unit,
|
||||
}
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE"],
|
||||
@@ -159,6 +609,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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,18 @@
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.util.exc import CommandError
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||
from sqlmodel import Field, SQLModel, func, select
|
||||
from sqlmodel import Field, Relationship, SQLModel, col, func, select, update
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .logging import get_logger
|
||||
@@ -46,12 +53,244 @@ 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")
|
||||
forwarded_model_id: str | None = Field(
|
||||
default=None,
|
||||
description="Model ID to use when forwarding requests to upstream provider. Defaults to id if not set.",
|
||||
)
|
||||
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 CashuTransaction(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "cashu_transactions"
|
||||
|
||||
id: str = Field(
|
||||
primary_key=True,
|
||||
default_factory=lambda: uuid.uuid4().hex,
|
||||
description="Unique transaction identifier",
|
||||
)
|
||||
token: str = Field(description="Serialized Cashu token")
|
||||
amount: int = Field(description="Amount in the token's unit")
|
||||
unit: str = Field(description="Token unit (sat or msat)")
|
||||
mint_url: str | None = Field(default=None, description="Mint URL for the token")
|
||||
type: str = Field(default="out", description="Transaction type: in or out")
|
||||
request_id: str | None = Field(default=None, description="Associated request ID")
|
||||
created_at: int = Field(
|
||||
default_factory=lambda: int(time.time()),
|
||||
description="Unix timestamp",
|
||||
)
|
||||
collected: bool = Field(default=False)
|
||||
swept: bool = Field(default=False)
|
||||
source: str = Field(
|
||||
default="x-cashu",
|
||||
description="Payment source: x-cashu or apikey",
|
||||
)
|
||||
api_key_hashed_key: str | None = Field(
|
||||
default=None,
|
||||
foreign_key="api_keys.hashed_key",
|
||||
index=True,
|
||||
description="Associated API key hash for wallet history",
|
||||
)
|
||||
|
||||
|
||||
async def store_cashu_transaction(
|
||||
token: str,
|
||||
amount: int,
|
||||
unit: str,
|
||||
mint_url: str | None = None,
|
||||
typ: str = "out",
|
||||
request_id: str | None = None,
|
||||
collected: bool = False,
|
||||
created_at: int | None = None,
|
||||
source: str = "x-cashu",
|
||||
api_key_hashed_key: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
async with create_session() as session:
|
||||
tx = CashuTransaction(
|
||||
token=token,
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
mint_url=mint_url,
|
||||
type=typ,
|
||||
request_id=request_id,
|
||||
collected=collected,
|
||||
created_at=created_at or int(time.time()),
|
||||
source=source,
|
||||
api_key_hashed_key=api_key_hashed_key,
|
||||
)
|
||||
session.add(tx)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to store cashu transaction: {e} (type={typ})",
|
||||
extra={"error": str(e), "type": typ},
|
||||
)
|
||||
|
||||
|
||||
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%)"
|
||||
)
|
||||
provider_settings: str | None = Field(
|
||||
default=None, description="JSON string for provider-specific settings"
|
||||
)
|
||||
models: list["ModelRow"] = Relationship(
|
||||
back_populates="upstream_provider",
|
||||
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
|
||||
)
|
||||
|
||||
|
||||
class RoutstrFee(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "routstr_fees"
|
||||
id: int = Field(default=1, primary_key=True)
|
||||
accumulated_msats: int = Field(default=0)
|
||||
total_paid_msats: int = Field(default=0)
|
||||
last_paid_at: int | None = Field(default=None)
|
||||
|
||||
|
||||
class CliToken(SQLModel, table=True): # type: ignore
|
||||
"""Long-lived authorization token for CLI/agent use against admin endpoints."""
|
||||
|
||||
__tablename__ = "cli_tokens"
|
||||
id: str = Field(
|
||||
primary_key=True, default_factory=lambda: uuid.uuid4().hex
|
||||
)
|
||||
token: str = Field(unique=True, index=True, description="Bearer token value")
|
||||
name: str = Field(description="Human-readable label for this token")
|
||||
created_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
last_used_at: int | None = Field(default=None)
|
||||
expires_at: int | None = Field(
|
||||
default=None, description="Optional expiry unix timestamp; null = never expires"
|
||||
)
|
||||
|
||||
|
||||
async def accumulate_routstr_fee(session: AsyncSession, amount_msats: int) -> None:
|
||||
stmt = (
|
||||
update(RoutstrFee)
|
||||
.where(col(RoutstrFee.id) == 1)
|
||||
.values(accumulated_msats=RoutstrFee.accumulated_msats + amount_msats)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
if result.rowcount == 0:
|
||||
session.add(RoutstrFee(id=1, accumulated_msats=amount_msats))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_routstr_fee(session: AsyncSession) -> RoutstrFee:
|
||||
fee = await session.get(RoutstrFee, 1)
|
||||
if fee is None:
|
||||
fee = RoutstrFee(id=1, accumulated_msats=0, total_paid_msats=0)
|
||||
session.add(fee)
|
||||
await session.commit()
|
||||
await session.refresh(fee)
|
||||
return fee
|
||||
|
||||
|
||||
async def reset_routstr_fee(session: AsyncSession, paid_msats: int) -> None:
|
||||
stmt = (
|
||||
update(RoutstrFee)
|
||||
.where(col(RoutstrFee.id) == 1)
|
||||
.values(
|
||||
accumulated_msats=RoutstrFee.accumulated_msats - paid_msats,
|
||||
total_paid_msats=RoutstrFee.total_paid_msats + paid_msats,
|
||||
last_paid_at=int(time.time()),
|
||||
)
|
||||
)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def balances_for_mint_and_unit(
|
||||
db_session: AsyncSession, mint_url: str, unit: str
|
||||
) -> int:
|
||||
@@ -65,6 +304,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 +320,63 @@ 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 _clear_alembic_version() -> None:
|
||||
"""Clear the alembic_version table so stamp/upgrade can proceed."""
|
||||
sync_url = DATABASE_URL.replace("+aiosqlite", "")
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
eng = create_engine(sync_url)
|
||||
with eng.begin() as conn:
|
||||
conn.execute(text("DELETE FROM alembic_version"))
|
||||
eng.dispose()
|
||||
|
||||
|
||||
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]
|
||||
@@ -101,9 +393,30 @@ def run_migrations() -> None:
|
||||
# Set the database URL in the config
|
||||
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")
|
||||
try:
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
except CommandError as e:
|
||||
if "Can't locate revision" in str(e):
|
||||
logger.warning(
|
||||
"Database stamped with unknown revision (likely from another branch). "
|
||||
"Re-stamping to current head.",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
_clear_alembic_version()
|
||||
command.stamp(alembic_cfg, "head")
|
||||
else:
|
||||
raise
|
||||
except OperationalError as e:
|
||||
if "duplicate column name" in str(e).lower():
|
||||
logger.warning(
|
||||
"Migration hit a column that already exists (likely added via "
|
||||
"create_all on another branch). Stamping to current head.",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
_clear_alembic_version()
|
||||
command.stamp(alembic_cfg, "head")
|
||||
else:
|
||||
raise
|
||||
|
||||
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")
|
||||
|
||||
1120
routstr/core/log_manager.py
Normal file
1120
routstr/core/log_manager.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,42 @@
|
||||
"""
|
||||
Logging configuration for Routstr.
|
||||
|
||||
CRITICAL LOG MESSAGES FOR USAGE STATISTICS:
|
||||
===========================================
|
||||
The following log messages are parsed by the usage tracking system
|
||||
(routstr/core/usage_analytics_store.py and routstr/core/log_manager.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. "Calculated token-based cost" (INFO) - routstr/auth.py
|
||||
- Used to track successful completions and revenue
|
||||
- The 'token_cost', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
|
||||
|
||||
3. "Max cost payment finalized" (INFO) - routstr/auth.py
|
||||
- Used as the successful completion fallback when token usage is unavailable
|
||||
- The 'charged_amount', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
|
||||
|
||||
4. "Payment processed successfully" (INFO) - routstr/auth.py
|
||||
- Used to count successful payment processing events
|
||||
- Tracks payment-related metrics
|
||||
|
||||
5. "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
|
||||
|
||||
6. 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/usage_analytics_store.py
|
||||
- routstr/core/log_manager.py
|
||||
"""
|
||||
|
||||
import logging.config
|
||||
import logging.handlers
|
||||
import os
|
||||
@@ -155,21 +194,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 +223,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 +238,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 +340,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,
|
||||
|
||||
@@ -1,54 +1,128 @@
|
||||
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 starlette.responses import Response as StarletteResponse
|
||||
from starlette.types import Scope
|
||||
|
||||
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 ..wallet import periodic_payout
|
||||
from ..nostr import (
|
||||
announce_provider,
|
||||
providers_cache_refresher,
|
||||
publish_usage_analytics,
|
||||
)
|
||||
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 ..upstream.auto_topup import periodic_auto_topup
|
||||
from ..wallet import periodic_payout, periodic_refund_sweep, periodic_routstr_fee_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
|
||||
from .version import __version__
|
||||
|
||||
# Initialize logging first
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__version__ = "0.1.1b"
|
||||
|
||||
|
||||
@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
|
||||
analytics_task = None
|
||||
providers_task = None
|
||||
models_refresh_task = None
|
||||
model_maps_refresh_task = None
|
||||
key_reset_task = None
|
||||
auto_topup_task = None
|
||||
refund_sweep_task = None
|
||||
routstr_fee_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:
|
||||
# Pass the accessor (not its current value) so the loop sees providers
|
||||
# added/changed via reinitialize_upstreams() instead of staying pinned
|
||||
# to the startup snapshot.
|
||||
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())
|
||||
analytics_task = asyncio.create_task(publish_usage_analytics())
|
||||
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())
|
||||
auto_topup_task = asyncio.create_task(periodic_auto_topup())
|
||||
refund_sweep_task = asyncio.create_task(periodic_refund_sweep())
|
||||
routstr_fee_task = asyncio.create_task(periodic_routstr_fee_payout())
|
||||
|
||||
yield
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Expected during shutdown
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Application startup failed",
|
||||
@@ -58,17 +132,57 @@ 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 analytics_task is not None:
|
||||
analytics_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()
|
||||
if auto_topup_task is not None:
|
||||
auto_topup_task.cancel()
|
||||
if refund_sweep_task is not None:
|
||||
refund_sweep_task.cancel()
|
||||
if routstr_fee_task is not None:
|
||||
routstr_fee_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 analytics_task is not None:
|
||||
tasks_to_wait.append(analytics_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 auto_topup_task is not None:
|
||||
tasks_to_wait.append(auto_topup_task)
|
||||
if refund_sweep_task is not None:
|
||||
tasks_to_wait.append(refund_sweep_task)
|
||||
if routstr_fee_task is not None:
|
||||
tasks_to_wait.append(routstr_fee_task)
|
||||
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
@@ -80,22 +194,33 @@ 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,
|
||||
)
|
||||
class _ImmutableStaticFiles(StaticFiles):
|
||||
"""Static files with long Cache-Control for content-hashed Next.js assets.
|
||||
|
||||
Files under `/_next/static/` are emitted with content hashes in their
|
||||
filenames and never mutate, so we serve them with a one-year immutable
|
||||
cache header so browsers and CDNs stop revalidating on every reload.
|
||||
"""
|
||||
|
||||
async def get_response(self, path: str, scope: Scope) -> StarletteResponse:
|
||||
response = await super().get_response(path, scope)
|
||||
if response.status_code == 200:
|
||||
response.headers["Cache-Control"] = (
|
||||
"public, max-age=31536000, immutable"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
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=["*"],
|
||||
expose_headers=["x-routstr-request-id"],
|
||||
expose_headers=["x-routstr-request-id", "x-cashu"],
|
||||
)
|
||||
|
||||
# Add logging middleware
|
||||
@@ -106,24 +231,175 @@ 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",
|
||||
_ImmutableStaticFiles(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")
|
||||
|
||||
# Serve the App Router RSC payload for the home page.
|
||||
@app.get("/index.txt", include_in_schema=False)
|
||||
async def serve_root_rsc() -> FileResponse:
|
||||
return FileResponse(
|
||||
UI_DIST_PATH / "index.txt", media_type="text/x-component"
|
||||
)
|
||||
|
||||
@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")
|
||||
|
||||
@app.get("/login/index.txt", include_in_schema=False)
|
||||
async def serve_login_rsc() -> FileResponse:
|
||||
return FileResponse(
|
||||
UI_DIST_PATH / "login" / "index.txt", media_type="text/x-component"
|
||||
)
|
||||
|
||||
@app.get("/model", include_in_schema=False)
|
||||
async def serve_models_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "model" / "index.html")
|
||||
|
||||
@app.get("/model/index.txt", include_in_schema=False)
|
||||
async def serve_model_rsc() -> FileResponse:
|
||||
return FileResponse(
|
||||
UI_DIST_PATH / "model" / "index.txt", media_type="text/x-component"
|
||||
)
|
||||
|
||||
@app.get("/providers", include_in_schema=False)
|
||||
async def serve_providers_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
|
||||
|
||||
@app.get("/providers/index.txt", include_in_schema=False)
|
||||
async def serve_providers_rsc() -> FileResponse:
|
||||
return FileResponse(
|
||||
UI_DIST_PATH / "providers" / "index.txt",
|
||||
media_type="text/x-component",
|
||||
)
|
||||
|
||||
@app.get("/settings", include_in_schema=False)
|
||||
async def serve_settings_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
|
||||
|
||||
@app.get("/settings/index.txt", include_in_schema=False)
|
||||
async def serve_settings_rsc() -> FileResponse:
|
||||
return FileResponse(
|
||||
UI_DIST_PATH / "settings" / "index.txt",
|
||||
media_type="text/x-component",
|
||||
)
|
||||
|
||||
@app.get("/transactions", include_in_schema=False)
|
||||
async def serve_transactions_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
|
||||
|
||||
@app.get("/transactions/index.txt", include_in_schema=False)
|
||||
async def serve_transactions_rsc() -> FileResponse:
|
||||
return FileResponse(
|
||||
UI_DIST_PATH / "transactions" / "index.txt",
|
||||
media_type="text/x-component",
|
||||
)
|
||||
|
||||
@app.get("/balances", include_in_schema=False)
|
||||
async def serve_balances_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "balances" / "index.html")
|
||||
|
||||
@app.get("/balances/index.txt", include_in_schema=False)
|
||||
async def serve_balances_rsc() -> FileResponse:
|
||||
return FileResponse(
|
||||
UI_DIST_PATH / "balances" / "index.txt",
|
||||
media_type="text/x-component",
|
||||
)
|
||||
|
||||
@app.get("/logs", include_in_schema=False)
|
||||
async def serve_logs_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "logs" / "index.html")
|
||||
|
||||
@app.get("/logs/index.txt", include_in_schema=False)
|
||||
async def serve_logs_rsc() -> FileResponse:
|
||||
return FileResponse(
|
||||
UI_DIST_PATH / "logs" / "index.txt", media_type="text/x-component"
|
||||
)
|
||||
|
||||
@app.get("/usage", include_in_schema=False)
|
||||
async def serve_usage_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
|
||||
|
||||
@app.get("/usage/index.txt", include_in_schema=False)
|
||||
async def serve_usage_rsc() -> FileResponse:
|
||||
return FileResponse(
|
||||
UI_DIST_PATH / "usage" / "index.txt", media_type="text/x-component"
|
||||
)
|
||||
|
||||
@app.get("/unauthorized", include_in_schema=False)
|
||||
async def serve_unauthorized_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
|
||||
|
||||
@app.get("/unauthorized/index.txt", include_in_schema=False)
|
||||
async def serve_unauthorized_rsc() -> FileResponse:
|
||||
return FileResponse(
|
||||
UI_DIST_PATH / "unauthorized" / "index.txt",
|
||||
media_type="text/x-component",
|
||||
)
|
||||
|
||||
@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")
|
||||
|
||||
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)
|
||||
|
||||
@@ -14,8 +14,54 @@ logger = get_logger(__name__)
|
||||
request_id_context: ContextVar[str | None] = ContextVar("request_id")
|
||||
|
||||
|
||||
# Methods that are never logged: HEAD requests are health probes from
|
||||
# monitoring/load balancers, OPTIONS are CORS preflights — both are framework
|
||||
# chatter, not user-meaningful events.
|
||||
_SKIP_LOG_METHODS: frozenset[str] = frozenset({"HEAD", "OPTIONS"})
|
||||
|
||||
# Path prefixes to skip. Includes Next.js static chunks and the admin
|
||||
# dashboard's internal polling API (/admin/api/*) which the UI hits on a timer
|
||||
# to refresh balances, logs, providers, etc. — high volume, low diagnostic
|
||||
# value. Mutating admin actions are recorded separately in the audit log.
|
||||
_SKIP_LOG_PREFIXES: tuple[str, ...] = (
|
||||
"/_next/",
|
||||
"/admin/api/",
|
||||
)
|
||||
|
||||
# Exact paths to skip. RSC payload prefetches (`*/index.txt`) fire automatically
|
||||
# as the user hovers near `<Link>`s, and `/v1/wallet/info` is polled by the UI.
|
||||
_SKIP_LOG_EXACT: frozenset[str] = frozenset(
|
||||
{
|
||||
"/favicon.ico",
|
||||
"/icon.ico",
|
||||
"/v1/wallet/info",
|
||||
"/index.txt",
|
||||
"/login/index.txt",
|
||||
"/model/index.txt",
|
||||
"/providers/index.txt",
|
||||
"/settings/index.txt",
|
||||
"/transactions/index.txt",
|
||||
"/balances/index.txt",
|
||||
"/logs/index.txt",
|
||||
"/usage/index.txt",
|
||||
"/unauthorized/index.txt",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _should_log(method: str, path: str) -> bool:
|
||||
if method in _SKIP_LOG_METHODS:
|
||||
return False
|
||||
if path in _SKIP_LOG_EXACT:
|
||||
return False
|
||||
return not any(path.startswith(prefix) for prefix in _SKIP_LOG_PREFIXES)
|
||||
|
||||
|
||||
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to log detailed request and response information."""
|
||||
"""Middleware to log proxy interactions and page navigation.
|
||||
|
||||
Skips logging for static assets and Next.js chunks to avoid noise.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
# Generate request ID
|
||||
@@ -25,53 +71,20 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
# Set request ID in context for logging
|
||||
token = request_id_context.set(request_id)
|
||||
|
||||
path = request.url.path
|
||||
should_log = _should_log(request.method, path)
|
||||
|
||||
# Start timing
|
||||
start_time = time.time()
|
||||
|
||||
# Log request details
|
||||
request_body = None
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
try:
|
||||
# Only read body for non-streaming requests
|
||||
if hasattr(request, "_body"):
|
||||
request_body = await request.body()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extract request info
|
||||
client_host = None
|
||||
if request.client:
|
||||
client_host = request.client.host
|
||||
|
||||
# Log incoming request
|
||||
logger.info(
|
||||
"Incoming request",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"query_params": dict(request.query_params),
|
||||
"client_host": client_host,
|
||||
"headers": {
|
||||
k: v
|
||||
for k, v in request.headers.items()
|
||||
if k.lower() not in ["authorization", "x-cashu", "cookie"]
|
||||
},
|
||||
"body_size": len(request_body) if request_body else 0,
|
||||
},
|
||||
)
|
||||
|
||||
# Log at TRACE level for full body (security filter will redact sensitive data)
|
||||
if request_body and hasattr(logger, "exception"):
|
||||
logger.exception(
|
||||
"Request body",
|
||||
if should_log:
|
||||
logger.info(
|
||||
"Incoming request",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"body": request_body.decode("utf-8", errors="ignore")[
|
||||
:1000
|
||||
], # Limit size
|
||||
"path": path,
|
||||
"query_params": dict(request.query_params),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -79,39 +92,33 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
try:
|
||||
response = await call_next(request)
|
||||
|
||||
# Calculate duration
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Log response
|
||||
logger.info(
|
||||
"Request completed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": round(duration * 1000, 2),
|
||||
"client_host": client_host,
|
||||
},
|
||||
)
|
||||
if should_log:
|
||||
duration = time.time() - start_time
|
||||
logger.info(
|
||||
"Request completed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": path,
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": round(duration * 1000, 2),
|
||||
},
|
||||
)
|
||||
if hasattr(response, "headers"):
|
||||
response.headers["x-routstr-request-id"] = request_id
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
# Calculate duration
|
||||
# Always log failures, even for skipped paths, so we don't lose errors.
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Log error
|
||||
logger.error(
|
||||
"Request failed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"path": path,
|
||||
"duration_ms": round(duration * 1000, 2),
|
||||
"client_host": client_host,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
|
||||
334
routstr/core/settings.py
Normal file
334
routstr/core/settings.py
Normal file
@@ -0,0 +1,334 @@
|
||||
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=0, 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")
|
||||
refund_sweep_ttl_seconds: int = Field(default=604800, env="REFUND_SWEEP_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")
|
||||
enable_analytics_sharing: bool = Field(
|
||||
default=True, env="ENABLE_ANALYTICS_SHARING"
|
||||
)
|
||||
|
||||
def _normalize_settings_data(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Discard unknown keys from persisted settings."""
|
||||
normalized: dict[str, Any] = {}
|
||||
known_fields = Settings.__fields__
|
||||
|
||||
for key, value in data.items():
|
||||
if key in known_fields:
|
||||
normalized[key] = value
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
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_raw = (
|
||||
json.loads(db_data) if isinstance(db_data, str) else dict(db_data)
|
||||
)
|
||||
if not isinstance(db_json_raw, dict):
|
||||
db_json_raw = {}
|
||||
except Exception:
|
||||
db_json_raw = {}
|
||||
db_json = _normalize_settings_data(db_json_raw)
|
||||
|
||||
valid_fields = set(env_resolved.dict().keys())
|
||||
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, "", [], {}) and k in valid_fields}
|
||||
)
|
||||
merged_dict = Settings(**merged_dict).dict()
|
||||
|
||||
# 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 db_json_raw != merged_dict:
|
||||
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(), **_normalize_settings_data(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)
|
||||
valid_fields = set(settings.dict().keys())
|
||||
# Update in-place
|
||||
for k, v in data.items():
|
||||
if k in valid_fields:
|
||||
setattr(settings, k, v)
|
||||
cls._current = settings
|
||||
return settings
|
||||
1380
routstr/core/usage_analytics_store.py
Normal file
1380
routstr/core/usage_analytics_store.py
Normal file
File diff suppressed because it is too large
Load Diff
81
routstr/core/version.py
Normal file
81
routstr/core/version.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Application version resolution.
|
||||
|
||||
Priority order:
|
||||
|
||||
1. ``VERSION_SUFFIX`` env var (manual override; preserves prior behaviour).
|
||||
2. Bare base version when HEAD is on the matching release tag (detected via
|
||||
``GIT_TAG`` env or ``git describe --tags --exact-match HEAD``).
|
||||
3. ``GIT_COMMIT`` env var (build-time injection) -> ``<base>+g<sha>``.
|
||||
4. Local ``.git`` lookup (source checkouts) -> ``<base>+g<sha>``.
|
||||
5. Fallback: bare base version.
|
||||
|
||||
The ``+g<sha>`` form is PEP 440 local-version syntax so the result remains a
|
||||
valid package version.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
BASE_VERSION = "0.4.3"
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_GIT_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
|
||||
def _run_git(*args: str) -> str | None:
|
||||
try:
|
||||
result = subprocess.run( # noqa: S603 - fixed argv, no shell
|
||||
["git", *args],
|
||||
cwd=_REPO_ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_GIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.SubprocessError, OSError):
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return result.stdout.strip() or None
|
||||
|
||||
|
||||
def _git_short_sha() -> str | None:
|
||||
sha = os.getenv("GIT_COMMIT", "").strip()
|
||||
if sha:
|
||||
return sha[:7]
|
||||
return _run_git("rev-parse", "--short=7", "HEAD")
|
||||
|
||||
|
||||
def _on_tagged_release() -> bool:
|
||||
tag = os.getenv("GIT_TAG", "").strip()
|
||||
if tag:
|
||||
return tag.lstrip("v") == BASE_VERSION
|
||||
described = _run_git("describe", "--tags", "--exact-match", "HEAD")
|
||||
if not described:
|
||||
return False
|
||||
return described.lstrip("v") == BASE_VERSION
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_version() -> str:
|
||||
suffix = os.getenv("VERSION_SUFFIX")
|
||||
if suffix is not None:
|
||||
return f"{BASE_VERSION}-{suffix}"
|
||||
|
||||
if _on_tagged_release():
|
||||
return BASE_VERSION
|
||||
|
||||
sha = _git_short_sha()
|
||||
if not sha:
|
||||
return BASE_VERSION
|
||||
|
||||
return f"{BASE_VERSION}+g{sha}"
|
||||
|
||||
|
||||
__version__ = get_version()
|
||||
|
||||
__all__ = ["BASE_VERSION", "__version__", "get_version"]
|
||||
@@ -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}
|
||||
276
routstr/lightning.py
Normal file
276
routstr/lightning.py
Normal file
@@ -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()
|
||||
5
routstr/nostr/__init__.py
Normal file
5
routstr/nostr/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .analytics import publish_usage_analytics
|
||||
from .discovery import providers_cache_refresher
|
||||
from .listing import announce_provider
|
||||
|
||||
__all__ = ["providers_cache_refresher", "announce_provider", "publish_usage_analytics"]
|
||||
419
routstr/nostr/analytics.py
Normal file
419
routstr/nostr/analytics.py
Normal file
@@ -0,0 +1,419 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Nostr usage analytics publisher.
|
||||
Publishes a single replaceable analytics snapshot for each provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from nostr.event import Event
|
||||
from nostr.key import PrivateKey
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.log_manager import log_manager
|
||||
from ..core.settings import settings
|
||||
from .listing import nsec_to_keypair, publish_to_relay
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
ANALYTICS_KIND = 38422
|
||||
ANALYTICS_SCHEMA = "routstr.analytics.snapshot.v1"
|
||||
DEFAULT_RELAYS = [
|
||||
"wss://relay.nostr.band",
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.routstr.com",
|
||||
"wss://nos.lol",
|
||||
]
|
||||
PUBLISH_INTERVAL_SECONDS = 15 * 60
|
||||
DISABLED_POLL_SECONDS = 60
|
||||
DASHBOARD_WINDOW_HOURS = 24
|
||||
DASHBOARD_INTERVAL_MINUTES = 60
|
||||
MODEL_LIMIT = 20
|
||||
WINDOW_DEFINITIONS: tuple[tuple[str, int, int], ...] = (
|
||||
("24h", 24, 60),
|
||||
("7d", 7 * 24, 6 * 60),
|
||||
("30d", 30 * 24, 24 * 60),
|
||||
("3m", 90 * 24, 24 * 60),
|
||||
("1y", 365 * 24, 7 * 24 * 60),
|
||||
)
|
||||
|
||||
|
||||
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 _resolve_provider_id(public_key_hex: str) -> str:
|
||||
explicit_provider_id = (settings.provider_id or "").strip()
|
||||
if explicit_provider_id:
|
||||
return explicit_provider_id
|
||||
return public_key_hex[:12]
|
||||
|
||||
|
||||
def _resolve_endpoint_urls() -> list[str]:
|
||||
urls: list[str] = []
|
||||
http_url = (settings.http_url or "").strip()
|
||||
onion_url = (settings.onion_url or "").strip()
|
||||
|
||||
if http_url and http_url != "http://localhost:8000":
|
||||
urls.append(http_url)
|
||||
|
||||
if onion_url:
|
||||
if onion_url.endswith(".onion") and not (
|
||||
onion_url.startswith("http://") or onion_url.startswith("https://")
|
||||
):
|
||||
onion_url = f"http://{onion_url}"
|
||||
urls.append(onion_url)
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
def _resolve_relays() -> list[str]:
|
||||
configured = [url.strip() for url in settings.relays if url.strip()]
|
||||
return configured if configured else list(DEFAULT_RELAYS)
|
||||
|
||||
|
||||
def _to_int(value: Any) -> int:
|
||||
if isinstance(value, bool):
|
||||
return int(value)
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(float(value))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def _to_float(value: Any) -> float:
|
||||
if isinstance(value, bool):
|
||||
return float(int(value))
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
|
||||
def _aggregate_top_model_usage(
|
||||
model_usage_mix: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
top_models_raw = model_usage_mix.get("top_models", [])
|
||||
mix_metrics_raw = model_usage_mix.get("metrics", [])
|
||||
|
||||
top_models = [model for model in top_models_raw if isinstance(model, str)]
|
||||
metrics = [row for row in mix_metrics_raw if isinstance(row, dict)]
|
||||
|
||||
model_totals: dict[str, dict[str, float | int]] = {
|
||||
model: {
|
||||
"successful_requests": 0,
|
||||
"revenue_msats": 0.0,
|
||||
"total_tokens": 0,
|
||||
}
|
||||
for model in top_models
|
||||
}
|
||||
others = {
|
||||
"successful_requests": 0,
|
||||
"revenue_msats": 0.0,
|
||||
"total_tokens": 0,
|
||||
}
|
||||
|
||||
for metric in metrics:
|
||||
model_counts = metric.get("model_counts", {})
|
||||
model_revenue = metric.get("model_revenue_msats", {})
|
||||
model_tokens = metric.get("model_tokens", {})
|
||||
|
||||
if isinstance(model_counts, dict):
|
||||
for model, count in model_counts.items():
|
||||
if model in model_totals:
|
||||
model_totals[model]["successful_requests"] += _to_int(count)
|
||||
|
||||
if isinstance(model_revenue, dict):
|
||||
for model, amount in model_revenue.items():
|
||||
if model in model_totals:
|
||||
model_totals[model]["revenue_msats"] += _to_float(amount)
|
||||
|
||||
if isinstance(model_tokens, dict):
|
||||
for model, token_count in model_tokens.items():
|
||||
if model in model_totals:
|
||||
model_totals[model]["total_tokens"] += _to_int(token_count)
|
||||
|
||||
others["successful_requests"] += _to_int(metric.get("others", 0))
|
||||
others["revenue_msats"] += _to_float(metric.get("others_revenue_msats", 0.0))
|
||||
others["total_tokens"] += _to_int(metric.get("others_tokens", 0))
|
||||
|
||||
model_rows = [
|
||||
{
|
||||
"model": model,
|
||||
"successful_requests": int(values["successful_requests"]),
|
||||
"revenue_msats": float(values["revenue_msats"]),
|
||||
"total_tokens": int(values["total_tokens"]),
|
||||
}
|
||||
for model, values in model_totals.items()
|
||||
]
|
||||
model_rows.sort(
|
||||
key=lambda row: _to_int(row.get("successful_requests", 0)),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
return model_rows, others
|
||||
|
||||
|
||||
def _build_summary_payload(summary: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"total_requests": _to_int(summary.get("total_requests", 0)),
|
||||
"successful_chat_completions": _to_int(
|
||||
summary.get("successful_chat_completions", 0)
|
||||
),
|
||||
"failed_requests": _to_int(summary.get("failed_requests", 0)),
|
||||
"success_rate": _to_float(summary.get("success_rate", 0.0)),
|
||||
"unique_models_count": _to_int(summary.get("unique_models_count", 0)),
|
||||
"input_tokens": _to_int(summary.get("input_tokens", 0)),
|
||||
"output_tokens": _to_int(summary.get("output_tokens", 0)),
|
||||
"total_tokens": _to_int(summary.get("total_tokens", 0)),
|
||||
"revenue_msats": _to_float(summary.get("revenue_msats", 0.0)),
|
||||
"refunds_msats": _to_float(summary.get("refunds_msats", 0.0)),
|
||||
"net_revenue_msats": _to_float(summary.get("net_revenue_msats", 0.0)),
|
||||
"revenue_sats": _to_float(summary.get("revenue_sats", 0.0)),
|
||||
"refunds_sats": _to_float(summary.get("refunds_sats", 0.0)),
|
||||
"net_revenue_sats": _to_float(summary.get("net_revenue_sats", 0.0)),
|
||||
}
|
||||
|
||||
|
||||
def _build_window_payload(
|
||||
*,
|
||||
hours: int,
|
||||
interval_minutes: int,
|
||||
model_limit: int,
|
||||
) -> dict[str, Any]:
|
||||
dashboard = log_manager.get_usage_dashboard(
|
||||
interval=interval_minutes,
|
||||
hours=hours,
|
||||
error_limit=1,
|
||||
model_limit=model_limit,
|
||||
)
|
||||
|
||||
summary = dashboard.get("summary", {})
|
||||
model_usage_mix = dashboard.get("model_usage_mix", {})
|
||||
|
||||
summary_payload = _build_summary_payload(summary if isinstance(summary, dict) else {})
|
||||
usage_mix_payload = model_usage_mix if isinstance(model_usage_mix, dict) else {}
|
||||
top_model_usage, others_usage = _aggregate_top_model_usage(usage_mix_payload)
|
||||
|
||||
return {
|
||||
"window_hours": hours,
|
||||
"interval_minutes": interval_minutes,
|
||||
"summary": summary_payload,
|
||||
"model_usage_mix": usage_mix_payload,
|
||||
"top_model_usage": top_model_usage,
|
||||
"others_usage": others_usage,
|
||||
}
|
||||
|
||||
|
||||
def build_stats_snapshot_payload(
|
||||
provider_id: str,
|
||||
*,
|
||||
public_key_hex: str,
|
||||
generated_at: int,
|
||||
window_hours: int = DASHBOARD_WINDOW_HOURS,
|
||||
interval_minutes: int = DASHBOARD_INTERVAL_MINUTES,
|
||||
model_limit: int = MODEL_LIMIT,
|
||||
) -> dict[str, Any]:
|
||||
_ = (window_hours, interval_minutes)
|
||||
windows: dict[str, dict[str, Any]] = {}
|
||||
for key, hours, window_interval_minutes in WINDOW_DEFINITIONS:
|
||||
windows[key] = _build_window_payload(
|
||||
hours=hours,
|
||||
interval_minutes=window_interval_minutes,
|
||||
model_limit=model_limit,
|
||||
)
|
||||
|
||||
primary_window = windows.get("24h", {})
|
||||
summary_payload = (
|
||||
primary_window.get("summary", {})
|
||||
if isinstance(primary_window.get("summary", {}), dict)
|
||||
else {}
|
||||
)
|
||||
usage_mix_payload = (
|
||||
primary_window.get("model_usage_mix", {})
|
||||
if isinstance(primary_window.get("model_usage_mix", {}), dict)
|
||||
else {}
|
||||
)
|
||||
top_model_usage = (
|
||||
primary_window.get("top_model_usage", [])
|
||||
if isinstance(primary_window.get("top_model_usage", []), list)
|
||||
else []
|
||||
)
|
||||
others_usage = (
|
||||
primary_window.get("others_usage", {})
|
||||
if isinstance(primary_window.get("others_usage", {}), dict)
|
||||
else {}
|
||||
)
|
||||
|
||||
return {
|
||||
"schema": ANALYTICS_SCHEMA,
|
||||
"generated_at": generated_at,
|
||||
"provider_id": provider_id,
|
||||
"pubkey": public_key_hex,
|
||||
"npub": settings.npub or "",
|
||||
"endpoint_urls": _resolve_endpoint_urls(),
|
||||
"window_hours": DASHBOARD_WINDOW_HOURS,
|
||||
"interval_minutes": DASHBOARD_INTERVAL_MINUTES,
|
||||
"summary": summary_payload,
|
||||
"model_usage_mix": usage_mix_payload,
|
||||
"top_model_usage": top_model_usage,
|
||||
"others_usage": others_usage,
|
||||
"windows": windows,
|
||||
}
|
||||
|
||||
|
||||
def create_stats_snapshot_event(
|
||||
private_key_hex: str,
|
||||
provider_id: str,
|
||||
payload_json: str,
|
||||
*,
|
||||
d_tag: str,
|
||||
) -> dict[str, Any]:
|
||||
private_key = PrivateKey(bytes.fromhex(private_key_hex))
|
||||
tags = [
|
||||
["d", d_tag],
|
||||
["provider", provider_id],
|
||||
["schema", ANALYTICS_SCHEMA],
|
||||
]
|
||||
|
||||
event = Event(
|
||||
public_key=private_key.public_key.hex(),
|
||||
content=payload_json,
|
||||
kind=ANALYTICS_KIND,
|
||||
tags=tags,
|
||||
)
|
||||
private_key.sign_event(event)
|
||||
return _event_to_dict(event)
|
||||
|
||||
|
||||
def _fingerprint_payload(payload: dict[str, Any]) -> str:
|
||||
normalized = dict(payload)
|
||||
# Ignore generated timestamp for semantic dedupe.
|
||||
normalized.pop("generated_at", None)
|
||||
payload_json = json.dumps(normalized, separators=(",", ":"), sort_keys=True)
|
||||
return hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def publish_usage_analytics() -> None:
|
||||
last_payload_hash: str | None = None
|
||||
|
||||
parsed_nsec: str | None = None
|
||||
private_key_hex: str | None = None
|
||||
public_key_hex: str | None = None
|
||||
provider_id: str | None = None
|
||||
warned_missing_nsec = False
|
||||
|
||||
logger.info("Usage analytics sharing task started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
if not settings.enable_analytics_sharing:
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
nsec = (settings.nsec or "").strip()
|
||||
if not nsec:
|
||||
if not warned_missing_nsec:
|
||||
logger.info("NSEC is not configured; skipping analytics sharing to Nostr")
|
||||
warned_missing_nsec = True
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
warned_missing_nsec = False
|
||||
if nsec != parsed_nsec or private_key_hex is None or public_key_hex is None:
|
||||
keypair = nsec_to_keypair(nsec)
|
||||
if not keypair:
|
||||
logger.error("Invalid NSEC; analytics sharing is paused")
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
continue
|
||||
private_key_hex, public_key_hex = keypair
|
||||
parsed_nsec = nsec
|
||||
provider_id = _resolve_provider_id(public_key_hex)
|
||||
last_payload_hash = None
|
||||
|
||||
if private_key_hex is None or public_key_hex is None:
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
relay_urls = _resolve_relays()
|
||||
if not relay_urls:
|
||||
logger.warning("No Nostr relays configured; analytics sharing skipped")
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
resolved_provider_id = provider_id or _resolve_provider_id(public_key_hex)
|
||||
now_ts = int(time.time())
|
||||
payload = build_stats_snapshot_payload(
|
||||
resolved_provider_id,
|
||||
public_key_hex=public_key_hex,
|
||||
generated_at=now_ts,
|
||||
)
|
||||
|
||||
payload_hash = _fingerprint_payload(payload)
|
||||
if last_payload_hash == payload_hash:
|
||||
await asyncio.sleep(PUBLISH_INTERVAL_SECONDS)
|
||||
continue
|
||||
|
||||
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
||||
d_tag = f"{resolved_provider_id}:stats"
|
||||
event = create_stats_snapshot_event(
|
||||
private_key_hex,
|
||||
resolved_provider_id,
|
||||
payload_json,
|
||||
d_tag=d_tag,
|
||||
)
|
||||
|
||||
success_count = 0
|
||||
for relay_url in relay_urls:
|
||||
if await publish_to_relay(relay_url, event):
|
||||
success_count += 1
|
||||
|
||||
if success_count > 0:
|
||||
last_payload_hash = payload_hash
|
||||
|
||||
logger.info(
|
||||
"Published analytics snapshot (success=%s/%s provider=%s)",
|
||||
success_count,
|
||||
len(relay_urls),
|
||||
resolved_provider_id,
|
||||
extra={
|
||||
"relay_success_count": success_count,
|
||||
"relay_total": len(relay_urls),
|
||||
"provider_id": resolved_provider_id,
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(PUBLISH_INTERVAL_SECONDS)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Usage analytics sharing task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Usage analytics sharing error",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
await asyncio.sleep(DISABLED_POLL_SECONDS)
|
||||
402
routstr/nostr/discovery.py
Normal file
402
routstr/nostr/discovery.py
Normal file
@@ -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}
|
||||
574
routstr/nostr/listing.py
Normal file
574
routstr/nostr/listing.py
Normal file
@@ -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.version 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,
|
||||
)
|
||||
284
routstr/payment/cost_calculation.py
Normal file
284
routstr/payment/cost_calculation.py
Normal file
@@ -0,0 +1,284 @@
|
||||
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
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 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,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
)
|
||||
|
||||
usage_data = response_data["usage"]
|
||||
|
||||
def parse_token_count(value: object) -> int:
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return max(0, value)
|
||||
if isinstance(value, float):
|
||||
return max(0, int(value))
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return max(0, int(float(value)))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
input_tokens = parse_token_count(usage_data.get("prompt_tokens", 0))
|
||||
output_tokens = parse_token_count(usage_data.get("completion_tokens", 0))
|
||||
input_tokens = (
|
||||
input_tokens
|
||||
if input_tokens != 0
|
||||
else parse_token_count(usage_data.get("input_tokens", 0))
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens
|
||||
if output_tokens != 0
|
||||
else parse_token_count(usage_data.get("output_tokens", 0))
|
||||
)
|
||||
input_tokens = (
|
||||
input_tokens
|
||||
if input_tokens != 0
|
||||
else parse_token_count(response_data.get("usage", {}).get("input_tokens", 0))
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens
|
||||
if output_tokens != 0
|
||||
else parse_token_count(response_data.get("usage", {}).get("output_tokens", 0))
|
||||
)
|
||||
|
||||
usd_cost = 0.0
|
||||
input_usd = 0.0
|
||||
output_usd = 0.0
|
||||
|
||||
if "cost_details" in usage_data:
|
||||
usd_cost = float(
|
||||
usage_data["cost_details"].get("upstream_inference_cost", 0) or 0
|
||||
)
|
||||
input_usd = float(
|
||||
usage_data["cost_details"].get("upstream_inference_prompt_cost", 0) or 0
|
||||
)
|
||||
output_usd = float(
|
||||
usage_data["cost_details"].get("upstream_inference_completions_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
|
||||
|
||||
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 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)
|
||||
|
||||
input_msats = 0
|
||||
output_msats = 0
|
||||
|
||||
if input_usd > 0 or output_usd > 0:
|
||||
input_msats = int((input_usd * sats_per_usd) * 1000)
|
||||
output_msats = int((output_usd * sats_per_usd) * 1000)
|
||||
else:
|
||||
total_tokens = input_tokens + output_tokens
|
||||
if total_tokens > 0:
|
||||
input_ratio = input_tokens / total_tokens
|
||||
input_msats = int(cost_in_msats * input_ratio)
|
||||
output_msats = cost_in_msats - input_msats
|
||||
else:
|
||||
output_msats = cost_in_msats
|
||||
|
||||
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=0,
|
||||
input_msats=input_msats,
|
||||
output_msats=output_msats,
|
||||
total_msats=cost_in_msats,
|
||||
total_usd=usd_cost,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
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
|
||||
|
||||
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=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
|
||||
calc_input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||
|
||||
calc_output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||
token_based_cost = math.ceil(calc_input_msats + calc_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": calc_input_msats,
|
||||
"output_cost_msats": calc_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(calc_input_msats),
|
||||
output_msats=int(calc_output_msats),
|
||||
total_msats=token_based_cost,
|
||||
total_usd=total_usd,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
@@ -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
|
||||
@@ -31,15 +29,13 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
|
||||
},
|
||||
)
|
||||
elif auth := headers.get("authorization", None):
|
||||
cashu_token = auth.split(" ")[1] if len(auth.split(" ")) > 1 else ""
|
||||
logger.debug(
|
||||
"Using Authorization header token",
|
||||
"Skipping preflight token balance check for Authorization header",
|
||||
extra={
|
||||
"token_preview": cashu_token[:20] + "..."
|
||||
if len(cashu_token) > 20
|
||||
else cashu_token
|
||||
"auth_preview": auth[:20] + "..." if len(auth) > 20 else auth,
|
||||
},
|
||||
)
|
||||
return
|
||||
else:
|
||||
logger.error("No authentication token provided")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
@@ -77,7 +73,7 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
|
||||
|
||||
if max_cost_for_model > amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
status_code=402,
|
||||
detail={
|
||||
"reason": "Insufficient balance",
|
||||
"amount_required_msat": max_cost_for_model,
|
||||
@@ -87,49 +83,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 +435,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, UpstreamProviderRow, 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,69 @@ 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
|
||||
forwarded_model_id: 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 +130,364 @@ 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,
|
||||
forwarded_model_id=getattr(row, "forwarded_model_id", None) or row.id,
|
||||
)
|
||||
|
||||
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,
|
||||
forwarded_model_id=model.forwarded_model_id,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
class ModelTestRequest(BaseModel):
|
||||
model_id: str
|
||||
endpoint_type: str
|
||||
request_data: dict
|
||||
|
||||
|
||||
@models_router.post("/api/models/test")
|
||||
async def test_model(
|
||||
payload: ModelTestRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Test a model by sending a request through its configured upstream provider."""
|
||||
from sqlmodel import select
|
||||
|
||||
result = await session.execute(
|
||||
select(ModelRow).where(ModelRow.id == payload.model_id)
|
||||
)
|
||||
model_row = result.scalars().first()
|
||||
|
||||
if not model_row:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Model '{payload.model_id}' not found in database",
|
||||
"status_code": 404,
|
||||
}
|
||||
|
||||
provider = await session.get(UpstreamProviderRow, model_row.upstream_provider_id)
|
||||
if not provider:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Upstream provider not found",
|
||||
"status_code": 404,
|
||||
}
|
||||
|
||||
base_url = provider.base_url.rstrip("/")
|
||||
if payload.endpoint_type == "chat-completions":
|
||||
url = f"{base_url}/chat/completions"
|
||||
else:
|
||||
url = f"{base_url}/{payload.endpoint_type}"
|
||||
|
||||
actual_model_id = model_row.forwarded_model_id or model_row.id
|
||||
request_data = dict(payload.request_data)
|
||||
request_data["model"] = actual_model_id
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {provider.api_key}",
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, json=request_data, headers=headers)
|
||||
try:
|
||||
response_data = response.json()
|
||||
except Exception:
|
||||
response_data = {"raw": response.text}
|
||||
|
||||
return {
|
||||
"success": response.status_code < 400,
|
||||
"data": response_data,
|
||||
"status_code": response.status_code,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"status_code": 500,
|
||||
}
|
||||
|
||||
|
||||
@models_router.get("/v1/models")
|
||||
@models_router.get("/models", include_in_schema=False)
|
||||
async def models() -> dict:
|
||||
return {"data": MODELS}
|
||||
@models_router.get("/v1/models/", include_in_schema=False)
|
||||
@models_router.get("/models")
|
||||
@models_router.get("/models/", include_in_schema=False)
|
||||
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()
|
||||
data = []
|
||||
for model in items:
|
||||
m = model.dict()
|
||||
if model.forwarded_model_id:
|
||||
m["id"] = model.forwarded_model_id
|
||||
data.append(m)
|
||||
return {"data": data}
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user