mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
824 Commits
update-dep
...
feat/vulne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59bcc3cbbf | ||
|
|
25f75643c2 | ||
|
|
c68c1936d3 | ||
|
|
49d285571e | ||
|
|
eb108a4a5a | ||
|
|
1a9041766b | ||
|
|
01c01fe8ad | ||
|
|
c671d277d3 | ||
|
|
9460e24f21 | ||
|
|
f31929b538 | ||
|
|
129ea7bb76 | ||
|
|
b88c6d84fa | ||
|
|
238beb3e52 | ||
|
|
dc833d9f5f | ||
|
|
31e7ff8fbd | ||
|
|
a1850dfa55 | ||
|
|
9eacabde0e | ||
|
|
73f52ef1fd | ||
|
|
110ec5da5d | ||
|
|
a5cee796c9 | ||
|
|
aabf08a930 | ||
|
|
07c15de106 | ||
|
|
aea24d23da | ||
|
|
e100c77288 | ||
|
|
057a752b1e | ||
|
|
5daa2602f5 | ||
|
|
d80912b10e | ||
|
|
8f087bf07a | ||
|
|
744321153f | ||
|
|
a1118a510d | ||
|
|
27dedfdf77 | ||
|
|
ebb2267844 | ||
|
|
7c2e2ce512 | ||
|
|
89b93fde98 | ||
|
|
c48693973b | ||
|
|
407f4745a0 | ||
|
|
806771df13 | ||
|
|
3c1b6d5f17 | ||
|
|
a9d5a52b32 | ||
|
|
b36bb56a30 | ||
|
|
9b4b4d734f | ||
|
|
97172641f8 | ||
|
|
46bbba7027 | ||
|
|
edfb0f127c | ||
|
|
65ccf83dbd | ||
|
|
2baea4149e | ||
|
|
64d9460711 | ||
|
|
f55c3e9c8c | ||
|
|
e3ac06342f | ||
|
|
6097193442 | ||
|
|
e2aa02307b | ||
|
|
66bc1260a4 | ||
|
|
9dc4c979b8 | ||
|
|
ec0fd1143b | ||
|
|
ae9748db02 | ||
|
|
ccee76b31e | ||
|
|
9f04525c82 | ||
|
|
c81de0de0a | ||
|
|
12c4c1f030 | ||
|
|
81658d0ba6 | ||
|
|
ee5ced10c7 | ||
|
|
2a27fb239e | ||
|
|
ffde661d93 | ||
|
|
7f49ba1771 | ||
|
|
949dc433f1 | ||
|
|
b7fcf000af | ||
|
|
a1af1383e1 | ||
|
|
5bedbd129f | ||
|
|
f588147b41 | ||
|
|
17bc949597 | ||
|
|
7705656016 | ||
|
|
434283c58d | ||
|
|
9fcf870e3f | ||
|
|
782b233d40 | ||
|
|
0c7373675f | ||
|
|
a33ea5da07 | ||
|
|
b509581950 | ||
|
|
38d7075652 | ||
|
|
ed27ad0dea | ||
|
|
6b1b4285fa | ||
|
|
7b56dcb902 | ||
|
|
2131cfc7f9 | ||
|
|
d6d9af91e4 | ||
|
|
cbd97e8e3a | ||
|
|
e4b26dd27b | ||
|
|
1e50afbd82 | ||
|
|
4d6e2cdf59 | ||
|
|
d079f45ba3 | ||
|
|
d08f07d6fb | ||
|
|
af0796689f | ||
|
|
f408785409 | ||
|
|
90f1ba89e5 | ||
|
|
e29c9241ce | ||
|
|
5a3774a414 | ||
|
|
e972b62758 | ||
|
|
b57f2d408e | ||
|
|
70d9e1a1ce | ||
|
|
c0a34a391f | ||
|
|
a97ea2995a | ||
|
|
5b50a78d95 | ||
|
|
ccab5e4216 | ||
|
|
ff9c645bb1 | ||
|
|
d4318dcc07 | ||
|
|
75ed865a5f | ||
|
|
7969a8da55 | ||
|
|
830c299130 | ||
|
|
e9dced8148 | ||
|
|
ecd46975b4 | ||
|
|
8f5f3d9738 | ||
|
|
355e3f19ef | ||
|
|
439ac48216 | ||
|
|
bf05c96dfc | ||
|
|
ed6e0c0189 | ||
|
|
412bfe479c | ||
|
|
861fda21b7 | ||
|
|
919dcf5535 | ||
|
|
cbc424e8e7 | ||
|
|
068fb3572f | ||
|
|
eaf74edbba | ||
|
|
eb6dc5189c | ||
|
|
cbdbe4b899 | ||
|
|
7870db9af1 | ||
|
|
28c4008892 | ||
|
|
a16bc1220c | ||
|
|
5d3e687876 | ||
|
|
68a7cd1dfb | ||
|
|
d96dc20e86 | ||
|
|
934afaa091 | ||
|
|
9ef01acac9 | ||
|
|
cbd38c15fe | ||
|
|
06c8a071a5 | ||
|
|
9e9c2bde57 | ||
|
|
222fd6ed45 | ||
|
|
4abd751f5f | ||
|
|
8f89171db1 | ||
|
|
5dc9d60bec | ||
|
|
feb76bc89d | ||
|
|
3b7f96560b | ||
|
|
23e206c93a | ||
|
|
3e0ca0daf9 | ||
|
|
d7bf1d6582 | ||
|
|
955090bd91 | ||
|
|
379c319e0d | ||
|
|
aaa80d47bc | ||
|
|
9633483fa4 | ||
|
|
9aa3408905 | ||
|
|
31ff1fd90c | ||
|
|
b9622689ea | ||
|
|
f9e8a3250d | ||
|
|
ade2d19be6 | ||
|
|
c4d0a1afba | ||
|
|
81817355cc | ||
|
|
d345d3b53f | ||
|
|
9fe13e9733 | ||
|
|
ee79a305ba | ||
|
|
ed2c8c9fe2 | ||
|
|
9161256d31 | ||
|
|
1a8f407142 | ||
|
|
eddc070628 | ||
|
|
f7bd250c97 | ||
|
|
f2ef63da62 | ||
|
|
29cfeaed9a | ||
|
|
e2b46dab9f | ||
|
|
632244e54f | ||
|
|
3251664513 | ||
|
|
ee16cd0495 | ||
|
|
f80d59182f | ||
|
|
70ef3c357c | ||
|
|
e4165f1dab | ||
|
|
41edae52b0 | ||
|
|
cb16da5543 | ||
|
|
d52b727bce | ||
|
|
6a2abd3439 | ||
|
|
1e4ed75179 | ||
|
|
efb5719679 | ||
|
|
3a1902b2b5 | ||
|
|
ba1978f830 | ||
|
|
2f51d62a46 | ||
|
|
1ca344ba7b | ||
|
|
dda9b848ba | ||
|
|
033d8c5a38 | ||
|
|
e73cdffdc5 | ||
|
|
e32b332eb5 | ||
|
|
89b84392a4 | ||
|
|
f37ff30598 | ||
|
|
547f45c185 | ||
|
|
a6e81a83cb | ||
|
|
e2143aa173 | ||
|
|
2eb257c2a6 | ||
|
|
490687bb71 | ||
|
|
966613847e | ||
|
|
d030d86f9a | ||
|
|
d9ab46b3bb | ||
|
|
6e596e3860 | ||
|
|
3c13be20cb | ||
|
|
9d5e14903b | ||
|
|
9e33d3b100 | ||
|
|
c88665f6d1 | ||
|
|
a8157b3e2d | ||
|
|
a07e6723d9 | ||
|
|
5bc7b741bc | ||
|
|
1db37cf084 | ||
|
|
6c5b103149 | ||
|
|
27ec052c17 | ||
|
|
00b813f6a8 | ||
|
|
91e5198a94 | ||
|
|
27f1cc3c42 | ||
|
|
a4d048f2b5 | ||
|
|
752d4f3803 | ||
|
|
164ed775c8 | ||
|
|
9d905758ec | ||
|
|
0d1cb66855 | ||
|
|
6e9932e0ac | ||
|
|
59773e972b | ||
|
|
7fd0cdf987 | ||
|
|
b1947d660a | ||
|
|
d45a9edcba | ||
|
|
4dbbb45240 | ||
|
|
a286b5efa0 | ||
|
|
26a0b04aef | ||
|
|
f7ccc25a7f | ||
|
|
8dd0ece501 | ||
|
|
10969e0719 | ||
|
|
b633455071 | ||
|
|
e280d1f8b1 | ||
|
|
81ad9f604b | ||
|
|
2345691176 | ||
|
|
a7615bc827 | ||
|
|
befdc5307e | ||
|
|
145777ffd0 | ||
|
|
37c2bea93d | ||
|
|
985e765285 | ||
|
|
a4b1330627 | ||
|
|
7b69284812 | ||
|
|
7fb1da7b58 | ||
|
|
38f9923469 | ||
|
|
5b986a3e15 | ||
|
|
56d9ff6b3f | ||
|
|
15b31e7c53 | ||
|
|
d0dcc3219d | ||
|
|
52db6fd308 | ||
|
|
ceca0e8efc | ||
|
|
309bc873e6 | ||
|
|
89109dc209 | ||
|
|
234ea19cad | ||
|
|
489b6eba14 | ||
|
|
66421cd17f | ||
|
|
cd7de3958c | ||
|
|
8c0ac499ef | ||
|
|
1d22155e05 | ||
|
|
ab2fb2afc5 | ||
|
|
884bae9fc4 | ||
|
|
92d581573d | ||
|
|
85a3d3adc0 | ||
|
|
2d7f03b2ed | ||
|
|
37bce70f76 | ||
|
|
038bc14757 | ||
|
|
287cbac5f9 | ||
|
|
28d91227af | ||
|
|
846d894a13 | ||
|
|
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 | ||
|
|
320cfe82fd | ||
|
|
fe2b1846f7 | ||
|
|
859b31e2bc | ||
|
|
80a7f5d2eb | ||
|
|
fa0b2834c5 | ||
|
|
d693b559c0 |
@@ -8,4 +8,6 @@ compose.testing.yml
|
||||
.todo
|
||||
.github
|
||||
.vscode
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
**/node_modules
|
||||
ui/.next
|
||||
|
||||
@@ -14,6 +14,7 @@ UPSTREAM_API_KEY=your-upstream-api-key
|
||||
# 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=
|
||||
|
||||
|
||||
12
.github/workflows/container.yml
vendored
12
.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
|
||||
@@ -29,7 +37,11 @@ jobs:
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.full
|
||||
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
|
||||
|
||||
21
.github/workflows/test.yml
vendored
21
.github/workflows/test.yml
vendored
@@ -59,21 +59,30 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: ui/pnpm-lock.yaml
|
||||
|
||||
- name: Install UI dependencies
|
||||
working-directory: ./ui
|
||||
run: npm ci
|
||||
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: npm run lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Run UI build
|
||||
working-directory: ./ui
|
||||
run: npm run build
|
||||
run: pnpm run build
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,3 +38,4 @@ proof_backups
|
||||
|
||||
*.todo
|
||||
ui_out
|
||||
.wallet/
|
||||
|
||||
29
Dockerfile
29
Dockerfile
@@ -1,26 +1,29 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-alpine
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
|
||||
|
||||
# Install system dependencies required for secp256k1
|
||||
RUN apk add --no-cache \
|
||||
pkgconf \
|
||||
build-base \
|
||||
automake \
|
||||
autoconf \
|
||||
libtool \
|
||||
m4 \
|
||||
perl
|
||||
RUN apk add git
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libsecp256k1-dev \
|
||||
autoconf \
|
||||
automake \
|
||||
libtool \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY uv.lock pyproject.toml ./
|
||||
RUN mkdir -p /routstr
|
||||
|
||||
RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060
|
||||
# RUN uv sync
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
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
|
||||
|
||||
|
||||
53
Dockerfile.full
Normal file
53
Dockerfile.full
Normal file
@@ -0,0 +1,53 @@
|
||||
# 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@10.15.0 --activate
|
||||
|
||||
# Copy UI source
|
||||
COPY ui/package.json ui/pnpm-lock.yaml* ui/pnpm-workspace.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-bookworm-slim AS runner
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libsecp256k1-dev \
|
||||
autoconf \
|
||||
automake \
|
||||
libtool \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY uv.lock pyproject.toml ./
|
||||
|
||||
RUN uv sync --no-dev --no-install-project
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
# 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 ["/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]
|
||||
6
Makefile
6
Makefile
@@ -98,7 +98,7 @@ docker-down:
|
||||
lint:
|
||||
@echo "🔍 Running linting checks..."
|
||||
$(RUFF) check .
|
||||
$(MYPY) routstr/ --ignore-missing-imports
|
||||
$(MYPY) .
|
||||
|
||||
format:
|
||||
@echo "✨ Formatting code..."
|
||||
@@ -107,7 +107,7 @@ format:
|
||||
|
||||
type-check:
|
||||
@echo "🔎 Running type checks..."
|
||||
$(MYPY) routstr/ --ignore-missing-imports
|
||||
$(MYPY) .
|
||||
|
||||
# Development setup
|
||||
dev-setup:
|
||||
@@ -234,7 +234,7 @@ ci-test:
|
||||
ci-lint:
|
||||
@echo "🤖 Running CI linting..."
|
||||
$(RUFF) check . --exit-non-zero-on-fix
|
||||
$(MYPY) routstr/ --ignore-missing-imports --no-error-summary
|
||||
$(MYPY) . --no-error-summary
|
||||
|
||||
# Debug helpers
|
||||
test-debug:
|
||||
|
||||
299
README.md
299
README.md
@@ -1,256 +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 using NIP-91 protocol
|
||||
- **NIP-91 Auto-Announcement** – Automatically announce this provider to Nostr relays when NSEC is provided
|
||||
- **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.
|
||||
|
||||
### Core Settings
|
||||
|
||||
- `UPSTREAM_BASE_URL` – URL of the OpenAI-compatible service
|
||||
- `UPSTREAM_API_KEY` – API key for the upstream service (optional)
|
||||
- `FIXED_PRICING` – Set to `true` to use a fixed per-request price; `false` (default) uses model 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
|
||||
- `NEXT_PUBLIC_API_URL` - UI Configuration for Next.js frontend (proxy URL, default: 'http://127.0.0.1:8000' )
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Admin UI
|
||||
|
||||
Routstr includes a modern Next.js admin dashboard that's served directly from the Python backend as static files - no separate Node.js server required.
|
||||
|
||||
### Building the UI
|
||||
|
||||
```bash
|
||||
make ui-build
|
||||
```
|
||||
|
||||
This compiles the Next.js application into static HTML, CSS, and JavaScript files in `ui/out/`.
|
||||
|
||||
### Accessing the Dashboard
|
||||
|
||||
Once built, the UI is automatically served by the FastAPI backend:
|
||||
|
||||
- **Dashboard**: `http://localhost:8000/`
|
||||
- **Login**: `http://localhost:8000/login`
|
||||
- **Models Management**: `http://localhost:8000/model
|
||||
- **Providers Management**: `http://localhost:8000/providers`
|
||||
- **Settings**: `http://localhost:8000/settings`
|
||||
|
||||
The dashboard provides:
|
||||
|
||||
- Real-time wallet balance monitoring
|
||||
- Model pricing configuration
|
||||
- Upstream provider management
|
||||
- Transaction history
|
||||
- System settings
|
||||
|
||||
**Authentication**: Use the `ADMIN_PASSWORD` environment variable to access the dashboard.
|
||||
|
||||
## Withdrawing Balance
|
||||
|
||||
Go to the admin dashboard at `http://localhost:8000/` and login with your `ADMIN_PASSWORD` to 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.
|
||||
|
||||
11
compose.yml
11
compose.yml
@@ -6,10 +6,11 @@ services:
|
||||
context: ./ui
|
||||
dockerfile: Dockerfile.build
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
|
||||
# NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
|
||||
NEXT_PUBLIC_ADMIN_API_KEY: ${NEXT_PUBLIC_ADMIN_API_KEY:-}
|
||||
user: root
|
||||
volumes:
|
||||
- ./ui_out:/output
|
||||
- ./ui_out:/output:z
|
||||
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"]
|
||||
|
||||
@@ -18,10 +19,10 @@ services:
|
||||
depends_on:
|
||||
- ui
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./logs:/app/logs
|
||||
- .:/app:z
|
||||
- ./logs:/app/logs:z
|
||||
- tor-data:/var/lib/tor:ro
|
||||
- ./ui_out:/app/ui_out:ro
|
||||
- ./ui_out:/app/ui_out:ro,z
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
@@ -1,567 +0,0 @@
|
||||
# Custom Pricing
|
||||
|
||||
This guide covers advanced pricing strategies and customization options for Routstr Core.
|
||||
|
||||
## Pricing Models Overview
|
||||
|
||||
Routstr supports three pricing models:
|
||||
|
||||
1. **Fixed Pricing** - Simple per-request fee
|
||||
2. **Token-Based Pricing** - Charge per input/output token
|
||||
3. **Model-Based Pricing** - Dynamic pricing from models.json
|
||||
|
||||
## Model-Based Pricing
|
||||
|
||||
### Configuration
|
||||
|
||||
Enable model-based pricing (default behavior):
|
||||
|
||||
```bash
|
||||
# .env
|
||||
FIXED_PRICING=false
|
||||
MODELS_PATH=/app/config/models.json
|
||||
EXCHANGE_FEE=1.005 # 0.5% exchange fee
|
||||
UPSTREAM_PROVIDER_FEE=1.05 # 5% provider margin
|
||||
```
|
||||
|
||||
### Custom Models File
|
||||
|
||||
Create a `models.json` with your pricing:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-4",
|
||||
"name": "GPT-4",
|
||||
"description": "Advanced reasoning model",
|
||||
"context_length": 8192,
|
||||
"pricing": {
|
||||
"prompt": "0.03", // USD per 1K tokens
|
||||
"completion": "0.06", // USD per 1K tokens
|
||||
"request": "0.0001", // Fixed per-request fee
|
||||
"image": "0", // For multimodal models
|
||||
"web_search": "0.005", // Additional features
|
||||
"internal_reasoning": "0.01"
|
||||
},
|
||||
"supported_features": [
|
||||
"function_calling",
|
||||
"vision",
|
||||
"json_mode"
|
||||
],
|
||||
"deprecation_date": null,
|
||||
"replacement_model": null
|
||||
},
|
||||
{
|
||||
"id": "custom-model",
|
||||
"name": "Custom Fine-tuned Model",
|
||||
"pricing": {
|
||||
"prompt": "0.001",
|
||||
"completion": "0.002",
|
||||
"request": "0.00005"
|
||||
},
|
||||
"minimum_charge": "0.0001" // Minimum charge per request
|
||||
}
|
||||
],
|
||||
"default_pricing": {
|
||||
"prompt": "0.002",
|
||||
"completion": "0.002",
|
||||
"request": "0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Price Updates
|
||||
|
||||
Automatically fetch prices from providers:
|
||||
|
||||
```python
|
||||
# scripts/update_prices.py
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
|
||||
async def fetch_openrouter_models():
|
||||
"""Fetch current model pricing from OpenRouter."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"https://openrouter.ai/api/v1/models"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def update_models_json():
|
||||
"""Update local models.json with latest prices."""
|
||||
data = await fetch_openrouter_models()
|
||||
|
||||
models = []
|
||||
for model in data['data']:
|
||||
models.append({
|
||||
"id": model['id'],
|
||||
"name": model['name'],
|
||||
"pricing": {
|
||||
"prompt": model['pricing']['prompt'],
|
||||
"completion": model['pricing']['completion'],
|
||||
"request": model['pricing'].get('request', '0')
|
||||
},
|
||||
"context_length": model.get('context_length', 4096)
|
||||
})
|
||||
|
||||
with open('models.json', 'w') as f:
|
||||
json.dump({"models": models}, f, indent=2)
|
||||
|
||||
# Run periodically
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(update_models_json())
|
||||
```
|
||||
|
||||
## Token-Based Pricing
|
||||
|
||||
### Configuration
|
||||
|
||||
Set up token-based pricing overrides:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
FIXED_PRICING=false # use model pricing
|
||||
FIXED_COST_PER_REQUEST=1 # optional base fee
|
||||
FIXED_PER_1K_INPUT_TOKENS=5 # optional override
|
||||
FIXED_PER_1K_OUTPUT_TOKENS=15 # optional override
|
||||
```
|
||||
|
||||
### Custom Token Counting
|
||||
|
||||
Override default token counting:
|
||||
|
||||
```python
|
||||
from tiktoken import encoding_for_model
|
||||
|
||||
class CustomTokenCounter:
|
||||
def __init__(self):
|
||||
self.encodings = {}
|
||||
|
||||
def count_tokens(
|
||||
self,
|
||||
text: str,
|
||||
model: str
|
||||
) -> int:
|
||||
"""Custom token counting logic."""
|
||||
# Cache encodings
|
||||
if model not in self.encodings:
|
||||
try:
|
||||
self.encodings[model] = encoding_for_model(model)
|
||||
except:
|
||||
# Fallback encoding
|
||||
self.encodings[model] = encoding_for_model("gpt-3.5-turbo")
|
||||
|
||||
encoding = self.encodings[model]
|
||||
|
||||
# Special handling for certain content
|
||||
if text.startswith("```"):
|
||||
# Code blocks might need special handling
|
||||
tokens = encoding.encode(text)
|
||||
return len(tokens) * 1.1 # 10% markup for code
|
||||
|
||||
return len(encoding.encode(text))
|
||||
```
|
||||
|
||||
## Advanced Pricing Strategies
|
||||
|
||||
### Time-Based Pricing
|
||||
|
||||
Implement peak/off-peak pricing:
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
class TimeBased PricingStrategy:
|
||||
def __init__(self):
|
||||
self.timezone = pytz.timezone('US/Eastern')
|
||||
self.peak_hours = [(9, 17)] # 9 AM - 5 PM
|
||||
self.peak_multiplier = 1.5
|
||||
self.weekend_discount = 0.8
|
||||
|
||||
def get_price_multiplier(self) -> float:
|
||||
"""Calculate price multiplier based on time."""
|
||||
now = datetime.now(self.timezone)
|
||||
|
||||
# Weekend discount
|
||||
if now.weekday() >= 5: # Saturday or Sunday
|
||||
return self.weekend_discount
|
||||
|
||||
# Peak hours surcharge
|
||||
hour = now.hour
|
||||
for start, end in self.peak_hours:
|
||||
if start <= hour < end:
|
||||
return self.peak_multiplier
|
||||
|
||||
# Off-peak standard pricing
|
||||
return 1.0
|
||||
|
||||
def apply_to_cost(self, base_cost: int) -> int:
|
||||
"""Apply time-based pricing to cost."""
|
||||
multiplier = self.get_price_multiplier()
|
||||
return int(base_cost * multiplier)
|
||||
```
|
||||
|
||||
### Model-Specific Surcharges
|
||||
|
||||
Add custom fees for specific models:
|
||||
|
||||
```python
|
||||
class ModelSurchargeStrategy:
|
||||
def __init__(self):
|
||||
self.surcharges = {
|
||||
"gpt-4-turbo": 1.1, # 10% premium
|
||||
"claude-3-opus": 1.15, # 15% premium
|
||||
"dall-e-3-hd": 1.25, # 25% premium for HD
|
||||
}
|
||||
|
||||
self.discounts = {
|
||||
"gpt-3.5-turbo": 0.95, # 5% discount
|
||||
"deprecated-model": 0.8, # 20% discount
|
||||
}
|
||||
|
||||
def get_model_multiplier(self, model: str) -> float:
|
||||
"""Get price multiplier for model."""
|
||||
if model in self.surcharges:
|
||||
return self.surcharges[model]
|
||||
elif model in self.discounts:
|
||||
return self.discounts[model]
|
||||
return 1.0
|
||||
```
|
||||
|
||||
### Geographic Pricing
|
||||
|
||||
Adjust pricing based on client location:
|
||||
|
||||
```python
|
||||
import geoip2.database
|
||||
|
||||
class GeographicPricingStrategy:
|
||||
def __init__(self):
|
||||
self.reader = geoip2.database.Reader('GeoLite2-Country.mmdb')
|
||||
self.country_multipliers = {
|
||||
'US': 1.0,
|
||||
'GB': 1.0,
|
||||
'DE': 1.0,
|
||||
'IN': 0.7, # 30% discount
|
||||
'BR': 0.8, # 20% discount
|
||||
'NG': 0.6, # 40% discount
|
||||
}
|
||||
self.default_multiplier = 0.9
|
||||
|
||||
def get_country_multiplier(self, ip_address: str) -> float:
|
||||
"""Get price multiplier based on country."""
|
||||
try:
|
||||
response = self.reader.country(ip_address)
|
||||
country_code = response.country.iso_code
|
||||
return self.country_multipliers.get(
|
||||
country_code,
|
||||
self.default_multiplier
|
||||
)
|
||||
except:
|
||||
return 1.0 # Default pricing if lookup fails
|
||||
```
|
||||
|
||||
## Cost Calculation Pipeline
|
||||
|
||||
### Implementing Custom Calculator
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
class CostCalculator(ABC):
|
||||
@abstractmethod
|
||||
async def calculate(
|
||||
self,
|
||||
request_data: dict,
|
||||
usage_data: dict,
|
||||
context: dict
|
||||
) -> CostResult:
|
||||
pass
|
||||
|
||||
class CompositeCostCalculator(CostCalculator):
|
||||
"""Combine multiple pricing strategies."""
|
||||
|
||||
def __init__(self):
|
||||
self.strategies = [
|
||||
BaseCostCalculator(),
|
||||
TimeBasedPricingStrategy(),
|
||||
ModelSurchargeStrategy(),
|
||||
GeographicPricingStrategy()
|
||||
]
|
||||
|
||||
async def calculate(
|
||||
self,
|
||||
request_data: dict,
|
||||
usage_data: dict,
|
||||
context: dict
|
||||
) -> CostResult:
|
||||
# Start with base cost
|
||||
base_cost = await self.strategies[0].calculate(
|
||||
request_data, usage_data, context
|
||||
)
|
||||
|
||||
# Apply each strategy
|
||||
final_cost = base_cost.total_msats
|
||||
breakdown = {"base": base_cost.total_msats}
|
||||
|
||||
for strategy in self.strategies[1:]:
|
||||
multiplier = await strategy.get_multiplier(context)
|
||||
adjustment = final_cost * (multiplier - 1)
|
||||
final_cost += adjustment
|
||||
breakdown[strategy.__class__.__name__] = adjustment
|
||||
|
||||
return CostResult(
|
||||
total_msats=int(final_cost),
|
||||
breakdown=breakdown
|
||||
)
|
||||
```
|
||||
|
||||
### Integration with Routstr
|
||||
|
||||
```python
|
||||
# In routstr/payment/cost_calculation.py
|
||||
async def calculate_request_cost(
|
||||
model: str,
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
request_type: str,
|
||||
context: dict
|
||||
) -> CostData:
|
||||
"""Enhanced cost calculation with custom strategies."""
|
||||
|
||||
# Use custom calculator if configured
|
||||
if os.getenv("USE_CUSTOM_PRICING", "false").lower() == "true":
|
||||
calculator = CompositeCostCalculator()
|
||||
result = await calculator.calculate(
|
||||
request_data={
|
||||
"model": model,
|
||||
"type": request_type
|
||||
},
|
||||
usage_data={
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens
|
||||
},
|
||||
context=context
|
||||
)
|
||||
return result
|
||||
|
||||
# Fall back to standard calculation
|
||||
return standard_calculate_cost(...)
|
||||
```
|
||||
|
||||
## Monitoring Pricing
|
||||
|
||||
### Price Analytics
|
||||
|
||||
Track pricing effectiveness:
|
||||
|
||||
```python
|
||||
class PricingAnalytics:
|
||||
async def analyze_pricing(
|
||||
self,
|
||||
start_date: datetime,
|
||||
end_date: datetime
|
||||
):
|
||||
"""Analyze pricing performance."""
|
||||
# Average cost per request by model
|
||||
model_costs = await self.get_average_costs_by_model(
|
||||
start_date, end_date
|
||||
)
|
||||
|
||||
# Revenue by pricing strategy
|
||||
strategy_revenue = await self.get_revenue_by_strategy(
|
||||
start_date, end_date
|
||||
)
|
||||
|
||||
# Price elasticity
|
||||
elasticity = await self.calculate_price_elasticity()
|
||||
|
||||
return {
|
||||
"model_costs": model_costs,
|
||||
"strategy_revenue": strategy_revenue,
|
||||
"price_elasticity": elasticity,
|
||||
"recommendations": self.generate_recommendations(
|
||||
model_costs, elasticity
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### A/B Testing Prices
|
||||
|
||||
Test different pricing strategies:
|
||||
|
||||
```python
|
||||
class PricingExperiment:
|
||||
def __init__(self):
|
||||
self.experiments = {
|
||||
"exp_001": {
|
||||
"name": "10% discount test",
|
||||
"group_a": {"multiplier": 1.0},
|
||||
"group_b": {"multiplier": 0.9},
|
||||
"allocation": 0.5 # 50/50 split
|
||||
}
|
||||
}
|
||||
|
||||
def assign_group(self, api_key_id: int) -> str:
|
||||
"""Assign API key to experiment group."""
|
||||
# Consistent assignment based on key ID
|
||||
import hashlib
|
||||
hash_value = int(hashlib.md5(
|
||||
str(api_key_id).encode()
|
||||
).hexdigest()[:8], 16)
|
||||
|
||||
return "group_b" if (hash_value % 100) < 50 else "group_a"
|
||||
|
||||
def get_experiment_multiplier(
|
||||
self,
|
||||
api_key_id: int,
|
||||
experiment_id: str
|
||||
) -> float:
|
||||
"""Get price multiplier for experiment."""
|
||||
experiment = self.experiments.get(experiment_id)
|
||||
if not experiment:
|
||||
return 1.0
|
||||
|
||||
group = self.assign_group(api_key_id)
|
||||
return experiment[group]["multiplier"]
|
||||
```
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Enterprise Pricing
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-4-enterprise",
|
||||
"name": "GPT-4 Enterprise",
|
||||
"pricing": {
|
||||
"prompt": "0.02",
|
||||
"completion": "0.04"
|
||||
},
|
||||
"minimum_commitment": "1000", // $1000/month minimum
|
||||
"sla": {
|
||||
"uptime": "99.9%",
|
||||
"support_response": "1 hour",
|
||||
"dedicated_capacity": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"enterprise_features": {
|
||||
"priority_queue": true,
|
||||
"custom_models": true,
|
||||
"audit_logs": true,
|
||||
"sso": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Budget-Friendly Options
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-3.5-turbo-budget",
|
||||
"name": "GPT-3.5 Turbo Budget",
|
||||
"pricing": {
|
||||
"prompt": "0.0005",
|
||||
"completion": "0.001"
|
||||
},
|
||||
"restrictions": {
|
||||
"max_tokens_per_request": 1000,
|
||||
"requests_per_minute": 10,
|
||||
"peak_hours_blocked": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"prepaid_packages": [
|
||||
{
|
||||
"name": "Starter Pack",
|
||||
"price_usd": 10,
|
||||
"tokens_included": 10000000,
|
||||
"expires_days": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Price Calculation Issues
|
||||
|
||||
```python
|
||||
# Debug pricing
|
||||
async def debug_price_calculation(
|
||||
model: str,
|
||||
tokens: dict,
|
||||
api_key_id: int
|
||||
):
|
||||
"""Debug price calculation step by step."""
|
||||
print(f"Model: {model}")
|
||||
print(f"Tokens: {tokens}")
|
||||
|
||||
# Base price
|
||||
base_price = get_model_price(model)
|
||||
print(f"Base price: {base_price}")
|
||||
|
||||
# Token cost
|
||||
token_cost = calculate_token_cost(base_price, tokens)
|
||||
print(f"Token cost: {token_cost}")
|
||||
|
||||
# Strategies
|
||||
strategies = get_active_strategies()
|
||||
for strategy in strategies:
|
||||
multiplier = await strategy.get_multiplier(api_key_id)
|
||||
print(f"{strategy.name}: {multiplier}x")
|
||||
|
||||
# Final cost
|
||||
final_cost = apply_all_strategies(token_cost, api_key_id)
|
||||
print(f"Final cost: {final_cost} msats")
|
||||
|
||||
return final_cost
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Prices Not Updating**
|
||||
- Check `MODELS_PATH` is correct
|
||||
- Verify file permissions
|
||||
- Check background task logs
|
||||
|
||||
2. **Wrong Currency Conversion**
|
||||
- Verify BTC/USD rate source
|
||||
- Check `EXCHANGE_FEE` setting
|
||||
- Monitor rate update frequency
|
||||
|
||||
3. **Discounts Not Applied**
|
||||
- Verify strategy configuration
|
||||
- Check API key metadata
|
||||
- Review transaction history
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Transparent Pricing**
|
||||
- Publish pricing clearly
|
||||
- Show cost breakdowns
|
||||
- Notify of price changes
|
||||
|
||||
2. **Fair Pricing**
|
||||
- Regular competitive analysis
|
||||
- Consider user feedback
|
||||
- Offer budget options
|
||||
|
||||
3. **Performance**
|
||||
- Cache price calculations
|
||||
- Optimize database queries
|
||||
- Monitor calculation time
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Migrations](migrations.md) - Database migration guide
|
||||
- [API Endpoints](../api/endpoints.md) - Pricing endpoints
|
||||
- [Monitoring](../user-guide/admin-dashboard.md) - Track pricing metrics
|
||||
@@ -1,646 +0,0 @@
|
||||
# Database Migrations
|
||||
|
||||
This guide covers database schema management using Alembic migrations in Routstr Core.
|
||||
|
||||
## Overview
|
||||
|
||||
Routstr uses Alembic for database migrations with these features:
|
||||
|
||||
- **Automatic migrations** on startup
|
||||
- **Version control** for schema changes
|
||||
- **Rollback capability** for safety
|
||||
- **Support for multiple databases** (SQLite, PostgreSQL)
|
||||
|
||||
## Automatic Migrations
|
||||
|
||||
### Startup Behavior
|
||||
|
||||
Migrations run automatically when Routstr starts:
|
||||
|
||||
```python
|
||||
# In routstr/core/main.py
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logger.info("Running database migrations")
|
||||
run_migrations() # Automatic migration
|
||||
await init_db() # Initialize connection pool
|
||||
# ... rest of startup
|
||||
```
|
||||
|
||||
This ensures:
|
||||
|
||||
- ✅ Database is always up-to-date
|
||||
- ✅ No manual migration steps in production
|
||||
- ✅ Zero-downtime deployments
|
||||
- ✅ Backwards compatibility
|
||||
|
||||
### Migration Safety
|
||||
|
||||
Migrations are designed to be safe:
|
||||
|
||||
- Idempotent (can run multiple times)
|
||||
- Non-destructive by default
|
||||
- Tested before release
|
||||
- Reversible when possible
|
||||
|
||||
## Creating Migrations
|
||||
|
||||
### Auto-generating from Models
|
||||
|
||||
After modifying SQLModel classes:
|
||||
|
||||
```bash
|
||||
# Generate migration from model changes
|
||||
make db-migrate
|
||||
|
||||
# You'll be prompted for a description
|
||||
Enter migration message: Add user preferences table
|
||||
|
||||
# Review generated file
|
||||
cat migrations/versions/xxxx_add_user_preferences_table.py
|
||||
```
|
||||
|
||||
### Manual Migrations
|
||||
|
||||
For complex changes, create manually:
|
||||
|
||||
```bash
|
||||
# Create empty migration
|
||||
alembic revision -m "Complex data transformation"
|
||||
|
||||
# Edit the generated file
|
||||
vim migrations/versions/xxxx_complex_data_transformation.py
|
||||
```
|
||||
|
||||
### Migration Template
|
||||
|
||||
```python
|
||||
"""Add user preferences table
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: f6e5d4c3b2a1
|
||||
Create Date: 2024-01-15 10:30:00.123456
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
# revision identifiers
|
||||
revision = 'a1b2c3d4e5f6'
|
||||
down_revision = 'f6e5d4c3b2a1'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Apply migration."""
|
||||
# Create new table
|
||||
op.create_table(
|
||||
'userpreferences',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('api_key_id', sa.Integer(), nullable=False),
|
||||
sa.Column('theme', sa.String(), nullable=True),
|
||||
sa.Column('notifications_enabled', sa.Boolean(), default=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['api_key_id'], ['apikey.id'], )
|
||||
)
|
||||
|
||||
# Create index
|
||||
op.create_index(
|
||||
'ix_userpreferences_api_key_id',
|
||||
'userpreferences',
|
||||
['api_key_id']
|
||||
)
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Revert migration."""
|
||||
op.drop_index('ix_userpreferences_api_key_id', table_name='userpreferences')
|
||||
op.drop_table('userpreferences')
|
||||
```
|
||||
|
||||
## Common Migration Patterns
|
||||
|
||||
### Adding Columns
|
||||
|
||||
Add column with default value:
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# Add nullable column first
|
||||
op.add_column(
|
||||
'apikey',
|
||||
sa.Column('last_rotation', sa.DateTime(), nullable=True)
|
||||
)
|
||||
|
||||
# Populate existing rows
|
||||
connection = op.get_bind()
|
||||
connection.execute(
|
||||
"UPDATE apikey SET last_rotation = created_at WHERE last_rotation IS NULL"
|
||||
)
|
||||
|
||||
# Make non-nullable if needed
|
||||
op.alter_column('apikey', 'last_rotation', nullable=False)
|
||||
```
|
||||
|
||||
### Renaming Columns
|
||||
|
||||
Safe column rename:
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# SQLite doesn't support ALTER COLUMN, so we need a workaround
|
||||
with op.batch_alter_table('apikey') as batch_op:
|
||||
batch_op.alter_column('old_name', new_column_name='new_name')
|
||||
```
|
||||
|
||||
### Adding Indexes
|
||||
|
||||
Performance-improving indexes:
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# Single column index
|
||||
op.create_index(
|
||||
'ix_transaction_timestamp',
|
||||
'transaction',
|
||||
['timestamp']
|
||||
)
|
||||
|
||||
# Composite index
|
||||
op.create_index(
|
||||
'ix_transaction_key_time',
|
||||
'transaction',
|
||||
['api_key_id', 'timestamp']
|
||||
)
|
||||
|
||||
# Partial index (PostgreSQL only)
|
||||
op.create_index(
|
||||
'ix_apikey_active',
|
||||
'apikey',
|
||||
['balance'],
|
||||
postgresql_where='balance > 0'
|
||||
)
|
||||
```
|
||||
|
||||
### Data Migrations
|
||||
|
||||
Transform existing data:
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# Add new column
|
||||
op.add_column(
|
||||
'apikey',
|
||||
sa.Column('key_type', sa.String(), nullable=True)
|
||||
)
|
||||
|
||||
# Migrate data
|
||||
connection = op.get_bind()
|
||||
result = connection.execute('SELECT id, metadata FROM apikey')
|
||||
|
||||
for row in result:
|
||||
key_type = 'premium' if row.metadata.get('premium') else 'standard'
|
||||
connection.execute(
|
||||
f"UPDATE apikey SET key_type = '{key_type}' WHERE id = {row.id}"
|
||||
)
|
||||
|
||||
# Make column non-nullable
|
||||
op.alter_column('apikey', 'key_type', nullable=False)
|
||||
```
|
||||
|
||||
### Enum Types
|
||||
|
||||
Add enum column:
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
class KeyStatus(str, Enum):
|
||||
ACTIVE = "active"
|
||||
SUSPENDED = "suspended"
|
||||
EXPIRED = "expired"
|
||||
|
||||
def upgrade():
|
||||
# Create enum type (PostgreSQL)
|
||||
key_status_enum = sa.Enum(KeyStatus, name='keystatus')
|
||||
key_status_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# Add column
|
||||
op.add_column(
|
||||
'apikey',
|
||||
sa.Column(
|
||||
'status',
|
||||
key_status_enum,
|
||||
nullable=False,
|
||||
server_default='active'
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Database-Specific Considerations
|
||||
|
||||
### SQLite Limitations
|
||||
|
||||
SQLite has limitations requiring workarounds:
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# SQLite doesn't support ALTER COLUMN directly
|
||||
# Use batch_alter_table for compatibility
|
||||
with op.batch_alter_table('apikey') as batch_op:
|
||||
batch_op.alter_column(
|
||||
'balance',
|
||||
type_=sa.BigInteger(), # Change from Integer
|
||||
existing_type=sa.Integer()
|
||||
)
|
||||
```
|
||||
|
||||
### PostgreSQL Features
|
||||
|
||||
Leverage PostgreSQL-specific features:
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# Use JSONB for better performance
|
||||
op.add_column(
|
||||
'apikey',
|
||||
sa.Column('metadata', sa.JSON().with_variant(
|
||||
sa.dialects.postgresql.JSONB(), 'postgresql'
|
||||
))
|
||||
)
|
||||
|
||||
# Add GIN index for JSONB queries
|
||||
op.create_index(
|
||||
'ix_apikey_metadata',
|
||||
'apikey',
|
||||
['metadata'],
|
||||
postgresql_using='gin'
|
||||
)
|
||||
|
||||
# Add check constraint
|
||||
op.create_check_constraint(
|
||||
'ck_apikey_balance_positive',
|
||||
'apikey',
|
||||
'balance >= 0'
|
||||
)
|
||||
```
|
||||
|
||||
## Migration Commands
|
||||
|
||||
### Running Migrations
|
||||
|
||||
```bash
|
||||
# Apply all pending migrations
|
||||
make db-upgrade
|
||||
|
||||
# Upgrade to specific revision
|
||||
alembic upgrade a1b2c3d4e5f6
|
||||
|
||||
# Upgrade one revision
|
||||
alembic upgrade +1
|
||||
```
|
||||
|
||||
### Checking Status
|
||||
|
||||
```bash
|
||||
# Show current revision
|
||||
make db-current
|
||||
# Output: a1b2c3d4e5f6 (head)
|
||||
|
||||
# Show migration history
|
||||
make db-history
|
||||
# Output:
|
||||
# a1b2c3d4e5f6 -> b2c3d4e5f6a7 (head), Add user preferences
|
||||
# f6e5d4c3b2a1 -> a1b2c3d4e5f6, Add indexes
|
||||
# e5d4c3b2a1f6 -> f6e5d4c3b2a1, Initial schema
|
||||
```
|
||||
|
||||
### Rolling Back
|
||||
|
||||
```bash
|
||||
# Rollback one migration
|
||||
make db-downgrade
|
||||
|
||||
# Rollback to specific revision
|
||||
alembic downgrade f6e5d4c3b2a1
|
||||
|
||||
# Rollback all (dangerous!)
|
||||
alembic downgrade base
|
||||
```
|
||||
|
||||
## Testing Migrations
|
||||
|
||||
### Unit Testing
|
||||
|
||||
Test migrations in isolation:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
def test_migration_add_user_preferences():
|
||||
"""Test user preferences migration."""
|
||||
# Create test database
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
|
||||
# Run migrations up to previous version
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", str(engine.url))
|
||||
command.upgrade(alembic_cfg, "f6e5d4c3b2a1")
|
||||
|
||||
# Verify state before migration
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
assert "userpreferences" not in tables
|
||||
|
||||
# Run target migration
|
||||
command.upgrade(alembic_cfg, "a1b2c3d4e5f6")
|
||||
|
||||
# Verify state after migration
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
assert "userpreferences" in tables
|
||||
|
||||
# Check columns
|
||||
columns = {col['name'] for col in inspector.get_columns('userpreferences')}
|
||||
assert columns == {'id', 'api_key_id', 'theme', 'notifications_enabled', 'created_at'}
|
||||
|
||||
# Test downgrade
|
||||
command.downgrade(alembic_cfg, "f6e5d4c3b2a1")
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
assert "userpreferences" not in tables
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
Test with real data:
|
||||
|
||||
```python
|
||||
async def test_migration_with_data():
|
||||
"""Test migration preserves existing data."""
|
||||
# Setup test database with data
|
||||
async with test_engine.begin() as conn:
|
||||
# Insert test data
|
||||
await conn.execute(
|
||||
"INSERT INTO apikey (key_hash, balance) VALUES ('test', 1000)"
|
||||
)
|
||||
|
||||
# Run migration
|
||||
run_migrations()
|
||||
|
||||
# Verify data integrity
|
||||
async with test_engine.connect() as conn:
|
||||
result = await conn.execute("SELECT * FROM apikey WHERE key_hash = 'test'")
|
||||
row = result.first()
|
||||
assert row.balance == 1000
|
||||
assert row.key_type == 'standard' # New column with default
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Zero-Downtime Migrations
|
||||
|
||||
Strategy for seamless updates:
|
||||
|
||||
1. **Make migrations backwards compatible**
|
||||
|
||||
```python
|
||||
# Good: Add nullable column
|
||||
op.add_column('apikey', sa.Column('new_field', sa.String(), nullable=True))
|
||||
|
||||
# Bad: Drop column immediately
|
||||
# op.drop_column('apikey', 'old_field')
|
||||
```
|
||||
|
||||
2. **Deploy in phases**
|
||||
|
||||
```bash
|
||||
# Phase 1: Deploy code that works with both schemas
|
||||
# Phase 2: Run migration
|
||||
# Phase 3: Deploy code that requires new schema
|
||||
# Phase 4: Clean up deprecated columns
|
||||
```
|
||||
|
||||
3. **Use feature flags**
|
||||
|
||||
```python
|
||||
if feature_enabled('use_new_schema'):
|
||||
# Use new column
|
||||
query = select(APIKey.new_field)
|
||||
else:
|
||||
# Use old column
|
||||
query = select(APIKey.old_field)
|
||||
```
|
||||
|
||||
### Migration Monitoring
|
||||
|
||||
Track migration execution:
|
||||
|
||||
```python
|
||||
# Add to migration
|
||||
def upgrade():
|
||||
start_time = time.time()
|
||||
logger.info(f"Starting migration {revision}")
|
||||
|
||||
try:
|
||||
# Migration logic here
|
||||
op.create_table(...)
|
||||
|
||||
duration = time.time() - start_time
|
||||
logger.info(f"Migration {revision} completed in {duration:.2f}s")
|
||||
except Exception as e:
|
||||
logger.error(f"Migration {revision} failed: {e}")
|
||||
raise
|
||||
```
|
||||
|
||||
### Backup Before Migration
|
||||
|
||||
Always backup before major changes:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# backup_before_migration.sh
|
||||
|
||||
# Backup database
|
||||
if [[ "$DATABASE_URL" == *"sqlite"* ]]; then
|
||||
cp database.db "backup_$(date +%Y%m%d_%H%M%S).db"
|
||||
else
|
||||
pg_dump $DATABASE_URL > "backup_$(date +%Y%m%d_%H%M%S).sql"
|
||||
fi
|
||||
|
||||
# Run migration
|
||||
alembic upgrade head
|
||||
|
||||
# Verify
|
||||
alembic current
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Migration Conflicts**
|
||||
|
||||
```bash
|
||||
# Multiple heads detected
|
||||
alembic heads
|
||||
# a1b2c3d4e5f6 (head)
|
||||
# b2c3d4e5f6a7 (head)
|
||||
|
||||
# Merge heads
|
||||
alembic merge -m "Merge migrations" a1b2c3 b2c3d4
|
||||
```
|
||||
|
||||
**Failed Migration**
|
||||
|
||||
```python
|
||||
# Add rollback logic
|
||||
def upgrade():
|
||||
try:
|
||||
op.create_table(...)
|
||||
except Exception as e:
|
||||
# Clean up partial changes
|
||||
op.drop_table('partial_table', checkfirst=True)
|
||||
raise
|
||||
|
||||
def downgrade():
|
||||
# Ensure clean rollback
|
||||
op.drop_table('new_table', checkfirst=True)
|
||||
```
|
||||
|
||||
**Lock Timeout**
|
||||
|
||||
```python
|
||||
# Add timeout handling
|
||||
def upgrade():
|
||||
connection = op.get_bind()
|
||||
|
||||
# Set timeout (PostgreSQL)
|
||||
connection.execute("SET lock_timeout = '10s'")
|
||||
|
||||
try:
|
||||
op.add_column(...)
|
||||
except OperationalError as e:
|
||||
if 'lock timeout' in str(e):
|
||||
logger.error("Migration failed due to lock timeout")
|
||||
raise
|
||||
```
|
||||
|
||||
### Recovery Procedures
|
||||
|
||||
If migration fails in production:
|
||||
|
||||
1. **Check current state**
|
||||
|
||||
```bash
|
||||
alembic current
|
||||
alembic history
|
||||
```
|
||||
|
||||
2. **Manual rollback if needed**
|
||||
|
||||
```sql
|
||||
-- Check migration table
|
||||
SELECT * FROM alembic_version;
|
||||
|
||||
-- Force version if necessary
|
||||
UPDATE alembic_version SET version_num = 'previous_version';
|
||||
```
|
||||
|
||||
3. **Fix and retry**
|
||||
|
||||
```bash
|
||||
# Fix migration file
|
||||
vim migrations/versions/problematic_migration.py
|
||||
|
||||
# Retry
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Migration Guidelines
|
||||
|
||||
1. **Keep migrations small and focused**
|
||||
- One logical change per migration
|
||||
- Easier to review and rollback
|
||||
|
||||
2. **Test migrations thoroughly**
|
||||
- Test upgrade and downgrade
|
||||
- Test with production-like data
|
||||
- Test database-specific features
|
||||
|
||||
3. **Document breaking changes**
|
||||
|
||||
```python
|
||||
"""BREAKING: Change balance column type
|
||||
|
||||
This migration requires application update.
|
||||
Deploy order:
|
||||
1. Update application to handle both int and bigint
|
||||
2. Run this migration
|
||||
3. Update application to use only bigint
|
||||
"""
|
||||
```
|
||||
|
||||
4. **Make migrations idempotent**
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# Check if column exists
|
||||
inspector = inspect(op.get_bind())
|
||||
columns = [col['name'] for col in inspector.get_columns('apikey')]
|
||||
|
||||
if 'new_column' not in columns:
|
||||
op.add_column(
|
||||
'apikey',
|
||||
sa.Column('new_column', sa.String())
|
||||
)
|
||||
```
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
1. **Add indexes concurrently (PostgreSQL)**
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# Create index without locking table
|
||||
op.create_index(
|
||||
'ix_large_table_column',
|
||||
'large_table',
|
||||
['column'],
|
||||
postgresql_concurrently=True
|
||||
)
|
||||
```
|
||||
|
||||
2. **Batch large updates**
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
connection = op.get_bind()
|
||||
|
||||
# Process in batches
|
||||
batch_size = 1000
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
result = connection.execute(
|
||||
f"UPDATE apikey SET processed = true "
|
||||
f"WHERE id IN (SELECT id FROM apikey WHERE processed = false LIMIT {batch_size})"
|
||||
)
|
||||
|
||||
if result.rowcount == 0:
|
||||
break
|
||||
|
||||
offset += batch_size
|
||||
time.sleep(0.1) # Prevent overload
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Testing Guide](../contributing/testing.md) - Testing migrations
|
||||
- [Deployment](../getting-started/docker.md) - Production deployment
|
||||
@@ -1,598 +0,0 @@
|
||||
# Nostr Discovery
|
||||
|
||||
Routstr Core integrates with Nostr (Notes and Other Stuff Transmitted by Relays) for decentralized provider discovery. This enables users to find Routstr nodes without relying on centralized directories.
|
||||
|
||||
## Overview
|
||||
|
||||
Nostr integration provides:
|
||||
|
||||
- **Decentralized Discovery**: Find providers through relay network
|
||||
- **Cryptographic Identity**: Providers identified by public keys
|
||||
- **Real-time Updates**: Live provider status and pricing
|
||||
- **Censorship Resistance**: No central point of control
|
||||
|
||||
## How It Works
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Routstr Node] --> B[Nostr Relay]
|
||||
B --> C[Nostr Relay]
|
||||
B --> D[Nostr Relay]
|
||||
|
||||
E[User Client] --> B
|
||||
E --> C
|
||||
E --> D
|
||||
|
||||
B --> F[Provider List]
|
||||
C --> F
|
||||
D --> F
|
||||
```
|
||||
|
||||
Providers announce themselves by publishing signed events to Nostr relays. Clients can query these relays to discover available providers.
|
||||
|
||||
## Provider Configuration
|
||||
|
||||
### Setting Up Nostr Identity
|
||||
|
||||
1. **Generate Nostr Keys**
|
||||
|
||||
```bash
|
||||
# Using nostril or similar tool
|
||||
nostril --generate-keypair
|
||||
|
||||
# Output:
|
||||
# Private key (nsec): nsec1abc...
|
||||
# Public key (npub): npub1xyz...
|
||||
```
|
||||
|
||||
2. **Configure Environment**
|
||||
|
||||
```bash
|
||||
# .env
|
||||
NPUB=npub1xyz... # Your public key
|
||||
NSEC=nsec1abc... # Your private key (keep secret!)
|
||||
NAME=Lightning AI Gateway
|
||||
DESCRIPTION=Fast and reliable AI API with Bitcoin payments
|
||||
HTTP_URL=https://api.lightning-ai.com
|
||||
ONION_URL=http://lightningai.onion
|
||||
```
|
||||
|
||||
### Publishing to Nostr
|
||||
|
||||
Routstr automatically publishes provider information to configured relays:
|
||||
|
||||
```python
|
||||
# Published event structure (NIP-89)
|
||||
{
|
||||
"kind": 31990, # Application handler event
|
||||
"pubkey": "your_public_key",
|
||||
"content": {
|
||||
"name": "Lightning AI Gateway",
|
||||
"description": "Fast and reliable AI API",
|
||||
"endpoints": {
|
||||
"http": "https://api.lightning-ai.com",
|
||||
"onion": "http://lightningai.onion"
|
||||
},
|
||||
"models": ["gpt-3.5-turbo", "gpt-4", "claude-3"],
|
||||
"pricing": {
|
||||
"gpt-3.5-turbo": {
|
||||
"prompt_sats_per_1k": 3,
|
||||
"completion_sats_per_1k": 4
|
||||
}
|
||||
},
|
||||
"cashu_mints": [
|
||||
"https://mint.minibits.cash/Bitcoin"
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
["d", "routstr"],
|
||||
["t", "ai-api"],
|
||||
["t", "bitcoin"],
|
||||
["p", "payment-proxy"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Relay Configuration
|
||||
|
||||
Configure which relays to publish to:
|
||||
|
||||
```python
|
||||
# Default relays
|
||||
DEFAULT_RELAYS = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.nostr.band",
|
||||
"wss://relay.routstr.com",
|
||||
"wss://nos.lol"
|
||||
]
|
||||
|
||||
# Custom relay configuration
|
||||
RELAYS=wss://relay1.com,wss://relay2.com
|
||||
```
|
||||
|
||||
## Client Discovery
|
||||
|
||||
### Using the Discovery Endpoint
|
||||
|
||||
Find providers through the API:
|
||||
|
||||
```bash
|
||||
GET /v1/providers
|
||||
|
||||
Response:
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"name": "Lightning AI Gateway",
|
||||
"npub": "npub1xyz...",
|
||||
"description": "Fast and reliable AI API",
|
||||
"endpoints": {
|
||||
"http": "https://api.lightning-ai.com",
|
||||
"onion": "http://lightningai.onion"
|
||||
},
|
||||
"models": ["gpt-3.5-turbo", "gpt-4"],
|
||||
"pricing": {
|
||||
"gpt-3.5-turbo": {
|
||||
"prompt_sats_per_1k": 3,
|
||||
"completion_sats_per_1k": 4
|
||||
}
|
||||
},
|
||||
"last_seen": "2024-01-01T12:00:00Z",
|
||||
"reliability_score": 0.99
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Direct Nostr Queries
|
||||
|
||||
Query Nostr relays directly:
|
||||
|
||||
```python
|
||||
import json
|
||||
import websocket
|
||||
|
||||
def discover_providers(relay_url: str):
|
||||
"""Discover Routstr providers from Nostr relay."""
|
||||
ws = websocket.create_connection(relay_url)
|
||||
|
||||
# Subscribe to provider events
|
||||
subscription = {
|
||||
"kinds": [31990],
|
||||
"tags": {
|
||||
"d": ["routstr"]
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(json.dumps(["REQ", "sub1", subscription]))
|
||||
|
||||
providers = []
|
||||
while True:
|
||||
response = json.loads(ws.recv())
|
||||
if response[0] == "EVENT":
|
||||
event = response[2]
|
||||
providers.append(parse_provider_event(event))
|
||||
elif response[0] == "EOSE": # End of stored events
|
||||
break
|
||||
|
||||
ws.close()
|
||||
return providers
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```typescript
|
||||
import { SimplePool } from 'nostr-tools';
|
||||
|
||||
async function discoverProviders(): Promise<Provider[]> {
|
||||
const pool = new SimplePool();
|
||||
const relays = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.nostr.band'
|
||||
];
|
||||
|
||||
const filter = {
|
||||
kinds: [31990],
|
||||
'#d': ['routstr']
|
||||
};
|
||||
|
||||
const events = await pool.list(relays, [filter]);
|
||||
|
||||
return events.map(event => ({
|
||||
name: event.content.name,
|
||||
npub: nip19.npubEncode(event.pubkey),
|
||||
url: event.content.endpoints.http,
|
||||
models: event.content.models,
|
||||
pricing: event.content.pricing
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Ranking
|
||||
|
||||
### Reliability Scoring
|
||||
|
||||
Providers are ranked based on:
|
||||
|
||||
```python
|
||||
class ProviderScore:
|
||||
def calculate(self, provider: Provider) -> float:
|
||||
score = 1.0
|
||||
|
||||
# Uptime (based on recent checks)
|
||||
uptime_ratio = provider.successful_pings / provider.total_pings
|
||||
score *= uptime_ratio
|
||||
|
||||
# Response time
|
||||
if provider.avg_response_time < 500: # ms
|
||||
score *= 1.0
|
||||
elif provider.avg_response_time < 1000:
|
||||
score *= 0.9
|
||||
else:
|
||||
score *= 0.7
|
||||
|
||||
# Model availability
|
||||
model_score = len(provider.models) / 10 # Max 10 models
|
||||
score *= min(1.0, 0.5 + model_score * 0.5)
|
||||
|
||||
# Price competitiveness
|
||||
if provider.is_cheapest_for_any_model():
|
||||
score *= 1.1
|
||||
|
||||
return min(1.0, score)
|
||||
```
|
||||
|
||||
### Provider Selection
|
||||
|
||||
Choose optimal provider:
|
||||
|
||||
```python
|
||||
def select_provider(
|
||||
providers: list[Provider],
|
||||
model: str,
|
||||
requirements: dict
|
||||
) -> Provider:
|
||||
"""Select best provider for requirements."""
|
||||
|
||||
# Filter by model availability
|
||||
candidates = [p for p in providers if model in p.models]
|
||||
|
||||
# Filter by requirements
|
||||
if requirements.get('tor_required'):
|
||||
candidates = [p for p in candidates if p.onion_url]
|
||||
|
||||
if requirements.get('max_price_per_1k'):
|
||||
max_price = requirements['max_price_per_1k']
|
||||
candidates = [
|
||||
p for p in candidates
|
||||
if p.pricing[model]['prompt_sats_per_1k'] <= max_price
|
||||
]
|
||||
|
||||
# Sort by score
|
||||
candidates.sort(key=lambda p: p.reliability_score, reverse=True)
|
||||
|
||||
return candidates[0] if candidates else None
|
||||
```
|
||||
|
||||
## Publishing Updates
|
||||
|
||||
### Automatic Updates
|
||||
|
||||
Routstr publishes updates when:
|
||||
|
||||
- Node starts up
|
||||
- Configuration changes
|
||||
- Models are added/removed
|
||||
- Pricing updates
|
||||
|
||||
### Manual Publishing
|
||||
|
||||
Force publish current state:
|
||||
|
||||
```python
|
||||
async def publish_provider_info():
|
||||
"""Manually publish provider information."""
|
||||
event = create_provider_event(
|
||||
name=os.getenv("NAME"),
|
||||
description=os.getenv("DESCRIPTION"),
|
||||
models=get_available_models(),
|
||||
pricing=get_current_pricing()
|
||||
)
|
||||
|
||||
await publish_to_relays(event, RELAYS)
|
||||
```
|
||||
|
||||
### Event Lifecycle
|
||||
|
||||
```python
|
||||
# Publish every 6 hours
|
||||
@periodic_task(hours=6)
|
||||
async def update_nostr_presence():
|
||||
"""Keep provider information fresh."""
|
||||
try:
|
||||
await publish_provider_info()
|
||||
logger.info("Updated Nostr presence")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update Nostr: {e}")
|
||||
|
||||
# Delete on shutdown
|
||||
async def remove_nostr_presence():
|
||||
"""Remove provider from discovery."""
|
||||
deletion_event = create_deletion_event()
|
||||
await publish_to_relays(deletion_event, RELAYS)
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Key Management
|
||||
|
||||
1. **Secure Storage**
|
||||
|
||||
```python
|
||||
# Never log private keys
|
||||
SENSITIVE_VARS = ['NSEC', 'ADMIN_PASSWORD']
|
||||
|
||||
def sanitize_env(env_dict: dict) -> dict:
|
||||
return {
|
||||
k: '***' if k in SENSITIVE_VARS else v
|
||||
for k, v in env_dict.items()
|
||||
}
|
||||
```
|
||||
|
||||
2. **Key Rotation**
|
||||
|
||||
```bash
|
||||
# Generate new keys
|
||||
nostril --generate-keypair
|
||||
|
||||
# Update configuration
|
||||
# Publish transition event
|
||||
# Update all references
|
||||
```
|
||||
|
||||
### Event Validation
|
||||
|
||||
Verify provider events:
|
||||
|
||||
```python
|
||||
def validate_provider_event(event: dict) -> bool:
|
||||
"""Validate provider announcement."""
|
||||
# Check signature
|
||||
if not verify_signature(event):
|
||||
return False
|
||||
|
||||
# Check required fields
|
||||
required = ['name', 'endpoints', 'models', 'pricing']
|
||||
content = json.loads(event['content'])
|
||||
if not all(field in content for field in required):
|
||||
return False
|
||||
|
||||
# Verify endpoints are reachable
|
||||
if not await check_endpoints(content['endpoints']):
|
||||
return False
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
### Relay Security
|
||||
|
||||
Choose relays carefully:
|
||||
|
||||
```python
|
||||
TRUSTED_RELAYS = {
|
||||
'wss://relay.damus.io': {
|
||||
'operator': 'Damus',
|
||||
'reputation': 'high',
|
||||
'filters_spam': True
|
||||
},
|
||||
'wss://relay.nostr.band': {
|
||||
'operator': 'Nostr.Band',
|
||||
'reputation': 'high',
|
||||
'paid_tier': True
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Multi-Relay Broadcasting
|
||||
|
||||
Ensure wide distribution:
|
||||
|
||||
```python
|
||||
async def broadcast_to_relays(event: dict, relays: list[str]):
|
||||
"""Broadcast event to multiple relays."""
|
||||
tasks = []
|
||||
for relay in relays:
|
||||
task = asyncio.create_task(
|
||||
publish_to_relay(event, relay)
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
successful = sum(1 for r in results if not isinstance(r, Exception))
|
||||
logger.info(f"Published to {successful}/{len(relays)} relays")
|
||||
```
|
||||
|
||||
### Provider Metadata
|
||||
|
||||
Extended metadata in events:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31990,
|
||||
"content": {
|
||||
"name": "Lightning AI",
|
||||
"description": "Enterprise AI API",
|
||||
"metadata": {
|
||||
"established": "2024-01-01",
|
||||
"total_requests": 1000000,
|
||||
"average_response_ms": 250,
|
||||
"supported_features": [
|
||||
"streaming",
|
||||
"function_calling",
|
||||
"vision",
|
||||
"embeddings"
|
||||
],
|
||||
"certifications": ["SOC2", "GDPR"],
|
||||
"contact": {
|
||||
"nostr": "npub1contact...",
|
||||
"email": "support@lightning-ai.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Discovery Filters
|
||||
|
||||
Advanced filtering options:
|
||||
|
||||
```python
|
||||
# Find providers with specific features
|
||||
GET /v1/providers?features=streaming,vision&max_price=5&min_reliability=0.95
|
||||
|
||||
# Response includes filtered results
|
||||
{
|
||||
"providers": [...],
|
||||
"filters_applied": {
|
||||
"features": ["streaming", "vision"],
|
||||
"max_price_sats_per_1k": 5,
|
||||
"min_reliability": 0.95
|
||||
},
|
||||
"total_providers": 50,
|
||||
"matching_providers": 12
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Discovery Metrics
|
||||
|
||||
Track discovery performance:
|
||||
|
||||
```python
|
||||
class DiscoveryMetrics:
|
||||
def __init__(self):
|
||||
self.relay_health = {}
|
||||
self.provider_count = 0
|
||||
self.query_latency = []
|
||||
|
||||
async def check_relay_health(self, relay_url: str):
|
||||
"""Monitor relay connectivity."""
|
||||
start = time.time()
|
||||
try:
|
||||
await connect_to_relay(relay_url)
|
||||
latency = time.time() - start
|
||||
self.relay_health[relay_url] = {
|
||||
'status': 'healthy',
|
||||
'latency_ms': latency * 1000
|
||||
}
|
||||
except Exception as e:
|
||||
self.relay_health[relay_url] = {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}
|
||||
```
|
||||
|
||||
### Provider Monitoring
|
||||
|
||||
```python
|
||||
@periodic_task(minutes=5)
|
||||
async def monitor_providers():
|
||||
"""Check provider health."""
|
||||
providers = await discover_providers()
|
||||
|
||||
for provider in providers:
|
||||
try:
|
||||
# Test endpoint
|
||||
response = await test_provider_endpoint(provider.http_url)
|
||||
|
||||
# Update metrics
|
||||
await update_provider_metrics(
|
||||
provider.npub,
|
||||
success=response.status_code == 200,
|
||||
response_time=response.elapsed
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Provider {provider.name} check failed: {e}")
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No Providers Found
|
||||
|
||||
```python
|
||||
# Debug discovery issues
|
||||
async def debug_discovery():
|
||||
"""Diagnose discovery problems."""
|
||||
issues = []
|
||||
|
||||
# Check relay connectivity
|
||||
for relay in RELAYS:
|
||||
if not await can_connect_to_relay(relay):
|
||||
issues.append(f"Cannot connect to {relay}")
|
||||
|
||||
# Check event publishing
|
||||
if not await verify_own_events_visible():
|
||||
issues.append("Own events not visible on relays")
|
||||
|
||||
# Check filters
|
||||
if len(await get_all_provider_events()) == 0:
|
||||
issues.append("No provider events on any relay")
|
||||
|
||||
return issues
|
||||
```
|
||||
|
||||
### Relay Connection Issues
|
||||
|
||||
```bash
|
||||
# Test relay connection
|
||||
wscat -c wss://relay.damus.io
|
||||
|
||||
# Send subscription
|
||||
["REQ","test",{"kinds":[31990],"#d":["routstr"]}]
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Providers
|
||||
|
||||
1. **Consistent Identity**
|
||||
- Use same npub across services
|
||||
- Maintain profile metadata
|
||||
- Verify identity on multiple platforms
|
||||
|
||||
2. **Regular Updates**
|
||||
- Publish status every few hours
|
||||
- Update pricing promptly
|
||||
- Remove stale information
|
||||
|
||||
3. **Relay Diversity**
|
||||
- Publish to 5+ relays
|
||||
- Include regional relays
|
||||
- Monitor relay health
|
||||
|
||||
### For Clients
|
||||
|
||||
1. **Verify Providers**
|
||||
- Check multiple relays
|
||||
- Verify endpoints work
|
||||
- Monitor reliability over time
|
||||
|
||||
2. **Cache Discovery**
|
||||
- Cache provider list
|
||||
- Refresh periodically
|
||||
- Handle stale data gracefully
|
||||
|
||||
3. **Fallback Options**
|
||||
- Keep backup providers
|
||||
- Handle discovery failures
|
||||
- Support manual configuration
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Tor Support](tor.md) - Anonymous provider access
|
||||
- [Custom Pricing](custom-pricing.md) - Dynamic pricing strategies
|
||||
- [API Reference](../api/endpoints.md) - Discovery API details
|
||||
@@ -1,530 +0,0 @@
|
||||
# Tor Support
|
||||
|
||||
Routstr Core includes built-in support for Tor hidden services, enabling anonymous access to your API and enhanced privacy for users.
|
||||
|
||||
## Overview
|
||||
|
||||
Tor support provides:
|
||||
|
||||
- **Anonymous Access**: Hidden service (.onion) address
|
||||
- **Enhanced Privacy**: No IP address logging
|
||||
- **Censorship Resistance**: Accessible from restricted networks
|
||||
- **Optional Usage**: Regular HTTP/HTTPS access remains available
|
||||
|
||||
## Docker Setup
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
The included `compose.yml` automatically sets up Tor:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
routstr:
|
||||
build: .
|
||||
environment:
|
||||
- TOR_PROXY_URL=socks5://tor:9050
|
||||
ports:
|
||||
- 8000:8000
|
||||
|
||||
tor:
|
||||
image: ghcr.io/hundehausen/tor-hidden-service:latest
|
||||
volumes:
|
||||
- tor-data:/var/lib/tor
|
||||
environment:
|
||||
- HS_ROUTER=routstr:8000:80
|
||||
depends_on:
|
||||
- routstr
|
||||
|
||||
volumes:
|
||||
tor-data:
|
||||
```
|
||||
|
||||
Start with:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Getting Your Onion Address
|
||||
|
||||
After starting, retrieve your hidden service address:
|
||||
|
||||
```bash
|
||||
# View Tor logs
|
||||
docker compose logs tor
|
||||
|
||||
# Or directly from the hostname file
|
||||
docker exec tor cat /var/lib/tor/hidden_service/hostname
|
||||
```
|
||||
|
||||
Your onion address will look like:
|
||||
|
||||
```
|
||||
roustrjfsdgfiueghsklchg.onion
|
||||
```
|
||||
|
||||
## Manual Tor Setup
|
||||
|
||||
### Install Tor
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install tor
|
||||
|
||||
# macOS
|
||||
brew install tor
|
||||
|
||||
# Start Tor
|
||||
sudo systemctl start tor
|
||||
```
|
||||
|
||||
### Configure Hidden Service
|
||||
|
||||
Edit `/etc/tor/torrc`:
|
||||
|
||||
```bash
|
||||
# Hidden service configuration
|
||||
HiddenServiceDir /var/lib/tor/routstr/
|
||||
HiddenServicePort 80 127.0.0.1:8000
|
||||
|
||||
# Optional: Restrict to v3 addresses
|
||||
HiddenServiceVersion 3
|
||||
```
|
||||
|
||||
Restart Tor:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart tor
|
||||
```
|
||||
|
||||
Get onion address:
|
||||
|
||||
```bash
|
||||
sudo cat /var/lib/tor/routstr/hostname
|
||||
```
|
||||
|
||||
## Client Configuration
|
||||
|
||||
### Using Tor with Python
|
||||
|
||||
```python
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
# Configure SOCKS proxy
|
||||
proxies = {
|
||||
"http://": "socks5://127.0.0.1:9050",
|
||||
"https://": "socks5://127.0.0.1:9050"
|
||||
}
|
||||
|
||||
# Create client with Tor
|
||||
http_client = httpx.Client(proxies=proxies)
|
||||
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="http://roustrjfsdgfiueghsklchg.onion/v1",
|
||||
http_client=http_client
|
||||
)
|
||||
|
||||
# Use normally
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello via Tor!"}]
|
||||
)
|
||||
```
|
||||
|
||||
### Using Tor with cURL
|
||||
|
||||
```bash
|
||||
# Install torify
|
||||
sudo apt-get install torsocks
|
||||
|
||||
# Make request through Tor
|
||||
torify curl http://roustrjfsdgfiueghsklchg.onion/v1/models
|
||||
|
||||
# Or with explicit proxy
|
||||
curl --socks5 127.0.0.1:9050 http://roustrjfsdgfiueghsklchg.onion/v1/models
|
||||
```
|
||||
|
||||
### JavaScript/Node.js
|
||||
|
||||
```javascript
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
// Create SOCKS agent
|
||||
const agent = new SocksProxyAgent('socks5://127.0.0.1:9050');
|
||||
|
||||
// Configure OpenAI client
|
||||
const openai = new OpenAI({
|
||||
apiKey: 'sk-...',
|
||||
baseURL: 'http://roustrjfsdgfiueghsklchg.onion/v1',
|
||||
httpAgent: agent
|
||||
});
|
||||
|
||||
// Use normally
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-3.5-turbo',
|
||||
messages: [{ role: 'user', content: 'Hello via Tor!' }]
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Configure Tor proxy for outgoing connections:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
TOR_PROXY_URL=socks5://tor:9050 # Docker
|
||||
# or
|
||||
TOR_PROXY_URL=socks5://127.0.0.1:9050 # Local
|
||||
```
|
||||
|
||||
### Publishing Onion Address
|
||||
|
||||
Make your onion address discoverable:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
ONION_URL=http://roustrjfsdgfiueghsklchg.onion
|
||||
```
|
||||
|
||||
This will be included in:
|
||||
|
||||
- `/v1/info` endpoint
|
||||
- Nostr announcements
|
||||
- Admin dashboard
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Hidden Service Security
|
||||
|
||||
1. **Keep Private Key Secure**
|
||||
|
||||
```bash
|
||||
# Backup hidden service keys
|
||||
sudo tar -czf tor-keys-backup.tar.gz /var/lib/tor/routstr/
|
||||
|
||||
# Restore to maintain same address
|
||||
sudo tar -xzf tor-keys-backup.tar.gz -C /
|
||||
```
|
||||
|
||||
2. **Access Control**
|
||||
|
||||
```bash
|
||||
# Restrict to authenticated clients
|
||||
HiddenServiceAuthorizeClient stealth client1,client2
|
||||
```
|
||||
|
||||
3. **Rate Limiting**
|
||||
|
||||
```bash
|
||||
# In torrc
|
||||
HiddenServiceMaxStreams 100
|
||||
HiddenServiceMaxStreamsCloseCircuit 1
|
||||
```
|
||||
|
||||
### Operational Security
|
||||
|
||||
1. **Separate Tor Instance**
|
||||
|
||||
```yaml
|
||||
# Use dedicated Tor container
|
||||
tor:
|
||||
image: ghcr.io/hundehausen/tor-hidden-service:latest
|
||||
restart: always
|
||||
networks:
|
||||
- tor_network
|
||||
```
|
||||
|
||||
2. **Monitor Tor Health**
|
||||
|
||||
```python
|
||||
async def check_tor_connection():
|
||||
"""Verify Tor connectivity."""
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
proxies={"all://": TOR_PROXY_URL}
|
||||
) as client:
|
||||
response = await client.get(
|
||||
"https://check.torproject.org/api/ip"
|
||||
)
|
||||
data = response.json()
|
||||
return data.get("IsTor", False)
|
||||
except Exception:
|
||||
return False
|
||||
```
|
||||
|
||||
3. **Logging Considerations**
|
||||
|
||||
```python
|
||||
# Don't log .onion addresses with IPs
|
||||
def sanitize_logs(message: str) -> str:
|
||||
# Remove IP addresses when .onion is present
|
||||
if ".onion" in message:
|
||||
message = re.sub(r'\d+\.\d+\.\d+\.\d+', '[IP]', message)
|
||||
return message
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Connection Pooling
|
||||
|
||||
```python
|
||||
# Reuse Tor circuits
|
||||
class TorConnectionPool:
|
||||
def __init__(self, proxy_url: str):
|
||||
self.proxy_url = proxy_url
|
||||
self._clients = []
|
||||
|
||||
async def get_client(self) -> httpx.AsyncClient:
|
||||
if not self._clients:
|
||||
client = httpx.AsyncClient(
|
||||
proxies={"all://": self.proxy_url},
|
||||
timeout=httpx.Timeout(30.0),
|
||||
limits=httpx.Limits(
|
||||
max_keepalive_connections=5,
|
||||
max_connections=10
|
||||
)
|
||||
)
|
||||
self._clients.append(client)
|
||||
return self._clients[0]
|
||||
```
|
||||
|
||||
### Circuit Management
|
||||
|
||||
```python
|
||||
# Rotate Tor circuits periodically
|
||||
async def rotate_tor_circuit():
|
||||
"""Signal Tor to create new circuit."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Tor control port (requires configuration)
|
||||
response = await client.post(
|
||||
"http://localhost:9051",
|
||||
data="AUTHENTICATE\r\nSIGNAL NEWNYM\r\n"
|
||||
)
|
||||
```
|
||||
|
||||
### Caching Strategies
|
||||
|
||||
```python
|
||||
# Cache responses for Tor users
|
||||
@lru_cache(maxsize=1000)
|
||||
def get_cached_response(
|
||||
endpoint: str,
|
||||
params_hash: str
|
||||
) -> Optional[dict]:
|
||||
"""Cache frequently accessed data."""
|
||||
# Longer cache for Tor users due to latency
|
||||
return cache.get(f"tor:{endpoint}:{params_hash}")
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Tor Metrics
|
||||
|
||||
Track Tor-specific metrics:
|
||||
|
||||
```python
|
||||
class TorMetrics:
|
||||
def __init__(self):
|
||||
self.tor_requests = 0
|
||||
self.tor_errors = 0
|
||||
self.circuit_builds = 0
|
||||
self.average_latency = 0
|
||||
|
||||
async def record_request(
|
||||
self,
|
||||
duration: float,
|
||||
success: bool
|
||||
):
|
||||
self.tor_requests += 1
|
||||
if not success:
|
||||
self.tor_errors += 1
|
||||
|
||||
# Update average latency
|
||||
self.average_latency = (
|
||||
(self.average_latency * (self.tor_requests - 1) + duration)
|
||||
/ self.tor_requests
|
||||
)
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
```python
|
||||
@router.get("/health/tor")
|
||||
async def tor_health():
|
||||
"""Check Tor service health."""
|
||||
checks = {
|
||||
"tor_proxy": await check_tor_proxy(),
|
||||
"hidden_service": await check_hidden_service(),
|
||||
"circuit_established": await check_circuit()
|
||||
}
|
||||
|
||||
status = "healthy" if all(checks.values()) else "unhealthy"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"checks": checks,
|
||||
"metrics": {
|
||||
"tor_requests_total": metrics.tor_requests,
|
||||
"tor_error_rate": metrics.tor_errors / max(metrics.tor_requests, 1),
|
||||
"average_latency_ms": metrics.average_latency * 1000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Hidden Service Not Accessible**
|
||||
|
||||
```bash
|
||||
# Check Tor logs
|
||||
docker compose logs tor
|
||||
# or
|
||||
sudo journalctl -u tor
|
||||
|
||||
# Verify service is running
|
||||
sudo systemctl status tor
|
||||
|
||||
# Test locally
|
||||
curl --socks5 127.0.0.1:9050 http://your-onion.onion/v1/info
|
||||
```
|
||||
|
||||
**Slow Connection**
|
||||
|
||||
- Tor adds 3+ hops of latency
|
||||
- Use connection pooling
|
||||
- Implement aggressive caching
|
||||
- Consider increasing timeouts
|
||||
|
||||
**Connection Errors**
|
||||
|
||||
```python
|
||||
# Implement Tor-specific retry logic
|
||||
async def tor_retry(func, max_retries=5):
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return await func()
|
||||
except httpx.ProxyError:
|
||||
if attempt < max_retries - 1:
|
||||
# Exponential backoff for circuit building
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
else:
|
||||
raise
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
Enable Tor debug logging:
|
||||
|
||||
```bash
|
||||
# In torrc
|
||||
Log debug file /var/log/tor/debug.log
|
||||
|
||||
# Monitor in real-time
|
||||
tail -f /var/log/tor/debug.log
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Operators
|
||||
|
||||
1. **Backup Hidden Service Keys**
|
||||
- Store securely offline
|
||||
- Enables service recovery
|
||||
- Maintains same .onion address
|
||||
|
||||
2. **Monitor Tor Health**
|
||||
- Check circuit establishment
|
||||
- Track request latency
|
||||
- Alert on failures
|
||||
|
||||
3. **Separate Concerns**
|
||||
- Run Tor in separate container
|
||||
- Isolate from main application
|
||||
- Use internal networks
|
||||
|
||||
### For Users
|
||||
|
||||
1. **Verify Onion Addresses**
|
||||
- Check against multiple sources
|
||||
- Bookmark verified addresses
|
||||
- Watch for phishing
|
||||
|
||||
2. **Handle Higher Latency**
|
||||
- Increase client timeouts
|
||||
- Implement retries
|
||||
- Use connection pooling
|
||||
|
||||
3. **Enhance Privacy**
|
||||
- Use Tor Browser for web access
|
||||
- Avoid mixing Tor/clearnet
|
||||
- Don't include identifying info
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Multi-Hop Onion Services
|
||||
|
||||
For extra security, chain multiple Tor instances:
|
||||
|
||||
```yaml
|
||||
# compose.yml
|
||||
services:
|
||||
tor-entry:
|
||||
image: tor:latest
|
||||
command: tor -f /etc/tor/torrc.entry
|
||||
|
||||
tor-middle:
|
||||
image: tor:latest
|
||||
command: tor -f /etc/tor/torrc.middle
|
||||
|
||||
tor-exit:
|
||||
image: tor:latest
|
||||
command: tor -f /etc/tor/torrc.exit
|
||||
```
|
||||
|
||||
### Onion Service Authentication
|
||||
|
||||
Require client authorization:
|
||||
|
||||
```bash
|
||||
# Generate client auth
|
||||
openssl rand -base64 32 > client_auth_key
|
||||
|
||||
# In torrc
|
||||
HiddenServiceDir /var/lib/tor/routstr/
|
||||
HiddenServicePort 80 127.0.0.1:8000
|
||||
HiddenServiceAuthorizeClient stealth payments
|
||||
```
|
||||
|
||||
### Load Balancing
|
||||
|
||||
Distribute load across multiple instances:
|
||||
|
||||
```nginx
|
||||
# Onion service nginx config
|
||||
upstream routstr_backends {
|
||||
server routstr1:8000;
|
||||
server routstr2:8000;
|
||||
server routstr3:8000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
location / {
|
||||
proxy_pass http://routstr_backends;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Nostr Discovery](nostr.md) - Announce your onion service
|
||||
- [Docker Setup](../getting-started/docker.md) - Container configuration
|
||||
@@ -425,4 +425,4 @@ All API key usage is logged:
|
||||
|
||||
- [Endpoints](endpoints.md) - Complete endpoint reference
|
||||
- [Errors](errors.md) - Error handling guide
|
||||
- [Using the API](../user-guide/using-api.md) - Integration examples
|
||||
- [Using the API](../client/integration.md) - Integration examples
|
||||
|
||||
@@ -360,6 +360,44 @@ POST /v1/wallet/create
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`balance` is the spendable balance used by request admission.
|
||||
|
||||
### Check Balance
|
||||
|
||||
Get current wallet balance.
|
||||
@@ -434,6 +472,42 @@ Authorization: Bearer sk-...
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -527,4 +601,4 @@ Rate limit information is included in response headers.
|
||||
|
||||
- [Errors](errors.md) - Error handling reference
|
||||
- [Authentication](authentication.md) - Auth details
|
||||
- [Examples](../user-guide/using-api.md) - Code examples
|
||||
- [Integration Guide](../client/integration.md) - Code examples
|
||||
|
||||
@@ -113,42 +113,109 @@ All errors follow a consistent JSON structure:
|
||||
**Status:** 402
|
||||
**Resolution:** Top up API key balance
|
||||
|
||||
#### Invalid Token
|
||||
### Cashu Token Redemption Errors
|
||||
|
||||
These errors are returned when a Cashu token you pay with cannot be redeemed.
|
||||
They apply to every endpoint that accepts a token:
|
||||
|
||||
- **Per-request payment** via the `X-Cashu` header (chat completions + Responses API).
|
||||
- **API key top-up** via `POST /v1/wallet/topup`.
|
||||
- **Minting an API key** from a token sent in `Authorization: Bearer <cashu-token>`.
|
||||
|
||||
All three share one classifier, so the same failure yields the same HTTP status
|
||||
and sanitized message everywhere. Structured error envelopes (`X-Cashu` and
|
||||
`Authorization: Bearer <cashu-token>`) also expose the same `type` and `code` —
|
||||
branch on `type` (or `code` for finer granularity). `POST /v1/wallet/topup`
|
||||
keeps its existing plain-string `detail` envelope, so branch on status there.
|
||||
|
||||
| `type` | Status | `code` | Retryable | Meaning |
|
||||
|--------|--------|--------|-----------|---------|
|
||||
| `token_already_spent` | 400 | `cashu_token_already_spent` | No | The token was already redeemed. |
|
||||
| `invalid_token` | 400 | `invalid_cashu_token` | No | The token is malformed or cannot be decoded. |
|
||||
| `mint_error` | 422 | `cashu_token_swap_fees_exceed_amount` | No | Token value is too small to cover the mint's swap/melt fees. |
|
||||
| `mint_error` | 422 | `cashu_foreign_mint_swap_failed` | No | Swapping the token from a foreign mint to the primary mint failed. |
|
||||
| `mint_unreachable` | 503 | `cashu_mint_unreachable` | **Yes** | The mint could not be reached (DNS failure, refused/reset connection, timeout). The token is fine — retry once the mint recovers. |
|
||||
| `cashu_error` | 400 | `cashu_token_redemption_failed` | No | The token could not be redeemed for another expected reason. |
|
||||
| `cashu_error` | 400 | `cashu_token_zero_value` | No | The token redeemed to zero (empty/dust token, or value fully consumed by fees). |
|
||||
| `token_consumed` | 500 | `cashu_token_consumed` | No | The token was **spent** (melted/redeemed) but crediting it then failed. Do not retry — the token is gone; contact support to reconcile. |
|
||||
| `api_error` | 500 | `internal_error` | Maybe | Unexpected server-side fault during redemption. |
|
||||
|
||||
!!! important "Retry only `mint_unreachable`"
|
||||
Only `mint_unreachable` (503) means the same token will work again later —
|
||||
everything else is a permanent property of the token and must not be
|
||||
blindly retried. Use exponential backoff for the 503. In particular, a
|
||||
`token_consumed` 500 means the mint already spent the token, so a retry
|
||||
would fail as `token_already_spent`.
|
||||
|
||||
#### Mint Unreachable (retryable)
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "payment_error",
|
||||
"message": "Invalid Cashu token",
|
||||
"code": "invalid_token",
|
||||
"details": {
|
||||
"reason": "Token already spent"
|
||||
}
|
||||
"type": "mint_unreachable",
|
||||
"message": "Cashu mint is unreachable",
|
||||
"code": "cashu_mint_unreachable"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 400
|
||||
**Resolution:** Use a valid, unspent token
|
||||
**Status:** 503
|
||||
|
||||
#### Mint Unavailable
|
||||
**Resolution:** The token is valid — the mint is temporarily down. Retry with
|
||||
backoff, or pay with a token from a different mint.
|
||||
|
||||
#### Token Already Spent
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "payment_error",
|
||||
"message": "Cannot connect to Cashu mint",
|
||||
"code": "mint_unavailable",
|
||||
"details": {
|
||||
"mint_url": "https://mint.example.com",
|
||||
"retry_after": 60
|
||||
}
|
||||
"type": "token_already_spent",
|
||||
"message": "Cashu token already spent",
|
||||
"code": "cashu_token_already_spent"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 503
|
||||
**Resolution:** Try again later or use different mint
|
||||
**Status:** 400
|
||||
|
||||
**Resolution:** Use a fresh, unspent token. Do not retry with the same token.
|
||||
|
||||
#### Response envelope differs by endpoint
|
||||
|
||||
The `error` object above is identical everywhere, but the surrounding envelope
|
||||
depends on how you paid:
|
||||
|
||||
- **`X-Cashu` header payments** (chat + Responses API) return the object at the
|
||||
top level, alongside a `request_id`:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": { "type": "mint_unreachable", "message": "Cashu mint is unreachable", "code": "cashu_mint_unreachable" },
|
||||
"request_id": "req-abc123"
|
||||
}
|
||||
```
|
||||
|
||||
The original token is echoed back in the `X-Cashu` **response header only when
|
||||
it is still spendable** (e.g. `mint_unreachable`, `invalid_cashu_token`, fee
|
||||
errors) so you can recover/retry it. It is **not** echoed for spent/consumed
|
||||
tokens (`cashu_token_already_spent`, `cashu_token_consumed`,
|
||||
`cashu_token_zero_value`, `internal_error`) — retrying those can never succeed.
|
||||
|
||||
- **`Authorization: Bearer <cashu-token>`** (API key minting) wraps it in
|
||||
FastAPI's `detail` field:
|
||||
|
||||
```json
|
||||
{ "detail": { "error": { "type": "mint_unreachable", "message": "Cashu mint is unreachable", "code": "cashu_mint_unreachable" } } }
|
||||
```
|
||||
|
||||
- **`POST /v1/wallet/topup`** returns a plain string message under `detail` —
|
||||
it carries the shared HTTP **status** and **message** (e.g. `503` for an
|
||||
unreachable mint) but not the structured `type`/`code`, so branch on the
|
||||
status code here:
|
||||
|
||||
```json
|
||||
{ "detail": "Cashu mint is unreachable" }
|
||||
```
|
||||
|
||||
### Validation Errors
|
||||
|
||||
@@ -347,7 +414,7 @@ class ErrorHandler:
|
||||
'rate_limit',
|
||||
'upstream_timeout',
|
||||
'model_overloaded',
|
||||
'mint_unavailable'
|
||||
'cashu_mint_unreachable'
|
||||
}
|
||||
|
||||
# Errors requiring user action
|
||||
@@ -589,4 +656,4 @@ class ErrorMetrics:
|
||||
|
||||
- [Authentication](authentication.md) - Auth error details
|
||||
- [Endpoints](endpoints.md) - Endpoint-specific errors
|
||||
- [Examples](../user-guide/using-api.md) - Error handling examples
|
||||
- [Integration Guide](../client/integration.md) - Error handling examples
|
||||
|
||||
@@ -99,19 +99,19 @@ All errors follow a consistent format:
|
||||
|
||||
Standard OpenAI-compatible endpoints:
|
||||
|
||||
- **Chat Completions**: `/v1/chat/completions`
|
||||
- **Completions**: `/v1/completions` *(Coming soon)*
|
||||
- **Embeddings**: `/v1/embeddings` *(Coming soon)*
|
||||
- **Images**: `/v1/images/generations` *(Coming soon)*
|
||||
- **Audio**: `/v1/audio/transcriptions` *(Coming soon)*
|
||||
- **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:
|
||||
|
||||
- **Wallet**: `/v1/wallet/*`
|
||||
- **Balance**: `/v1/balance`
|
||||
- **Balance**: `/v1/balance/*`
|
||||
- **Node Info**: `/v1/info`
|
||||
|
||||
### Admin Endpoints
|
||||
@@ -137,9 +137,25 @@ Protected administrative functions:
|
||||
|
||||
| Header | Description |
|
||||
|--------|-------------|
|
||||
| `X-Routstr-Version` | API version override |
|
||||
| `X-Cashu` | eCash token for per-request payment |
|
||||
| `X-Max-Cost` | Maximum acceptable cost in sats |
|
||||
|
||||
#### 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. If you lose the `X-Cashu` response header before claiming your change, you can reclaim the refund via `POST /v1/wallet/refund` by supplying the original payment token in the `x-cashu` header.
|
||||
|
||||
## Response Headers
|
||||
|
||||
@@ -149,16 +165,8 @@ Protected administrative functions:
|
||||
|--------|-------------|
|
||||
| `Content-Type` | Response format |
|
||||
| `Content-Length` | Response size |
|
||||
| `X-Routstr-Request-ID` | Unique request identifier |
|
||||
| `X-Routstr-Version` | API version used |
|
||||
|
||||
### Cost Headers
|
||||
|
||||
| Header | Description |
|
||||
|--------|-------------|
|
||||
| `X-Routstr-Cost` | Request cost in sats |
|
||||
| `X-Routstr-Balance` | Remaining balance |
|
||||
| `X-Cashu` | Change token (if applicable) |
|
||||
| `X-Request-ID` | Unique request identifier |
|
||||
| `X-Cashu` | Change token (when request used `X-Cashu` header) |
|
||||
|
||||
## Streaming Responses
|
||||
|
||||
@@ -245,8 +253,6 @@ X-Webhook-Signature: sha256=...
|
||||
|
||||
- Current version: `v1`
|
||||
- Version in URL path: `/v1/endpoint`
|
||||
- Override with header: `X-Routstr-Version: v2`
|
||||
- Deprecation notices: 6 months
|
||||
|
||||
## Status Codes
|
||||
|
||||
@@ -284,82 +290,23 @@ Responses are compressed with gzip when:
|
||||
- Response is larger than 1KB
|
||||
- Content type is compressible
|
||||
|
||||
## Pagination
|
||||
## Batch Requests *(planned)*
|
||||
|
||||
List endpoints support pagination:
|
||||
Process multiple operations in one request. Coming soon.
|
||||
|
||||
## Node Info
|
||||
|
||||
Get node metadata:
|
||||
|
||||
```
|
||||
GET /v1/transactions?limit=50&offset=100
|
||||
GET /v1/info
|
||||
```
|
||||
|
||||
Response includes pagination metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [...],
|
||||
"has_more": true,
|
||||
"total": 500,
|
||||
"limit": 50,
|
||||
"offset": 100
|
||||
}
|
||||
```
|
||||
|
||||
## Field Filtering
|
||||
|
||||
Select specific fields in responses:
|
||||
|
||||
```
|
||||
GET /v1/models?fields=id,name,pricing
|
||||
```
|
||||
|
||||
## Batch Requests
|
||||
|
||||
Process multiple operations in one request:
|
||||
|
||||
```json
|
||||
POST /v1/batch
|
||||
{
|
||||
"requests": [
|
||||
{"method": "POST", "endpoint": "/chat/completions", "body": {...}},
|
||||
{"method": "GET", "endpoint": "/models"},
|
||||
{"method": "GET", "endpoint": "/balance"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Idempotency
|
||||
|
||||
Prevent duplicate operations:
|
||||
|
||||
```
|
||||
Idempotency-Key: unique-request-id
|
||||
```
|
||||
|
||||
Keys are stored for 24 hours.
|
||||
|
||||
## Health Check
|
||||
|
||||
Monitor service status:
|
||||
|
||||
```
|
||||
GET /health
|
||||
|
||||
Response:
|
||||
{
|
||||
"status": "healthy",
|
||||
"version": "0.2.0",
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"checks": {
|
||||
"database": "ok",
|
||||
"upstream": "ok",
|
||||
"mint": "ok"
|
||||
}
|
||||
}
|
||||
```
|
||||
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
|
||||
- [Examples](../user-guide/using-api.md) - Code examples
|
||||
- [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.
|
||||
99
docs/client/payments.md
Normal file
99
docs/client/payments.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# 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 `Authorization: Bearer sk-...` header and body `{"amount_sats": 1000, "purpose": "topup"}`.
|
||||
*Once paid, the funds are added to your existing key.*
|
||||
|
||||
> Legacy: the endpoint is also exposed at `/v1/balance/lightning/invoice`, and accepts an `api_key` field in the body as a fallback for older clients. New integrations should use the RIP-08 path with the `Authorization` header.
|
||||
|
||||
### 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.
|
||||
@@ -4,7 +4,7 @@ This document describes the high-level architecture of Routstr Core, helping con
|
||||
|
||||
## System Overview
|
||||
|
||||
Routstr Core is a FastAPI-based reverse proxy that adds Bitcoin micropayments to OpenAI-compatible APIs.
|
||||
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
|
||||
@@ -20,7 +20,7 @@ graph TB
|
||||
Auth[Auth Module]
|
||||
Payment[Payment Module]
|
||||
Proxy[Proxy Module]
|
||||
DB[(SQLite DB)]
|
||||
DB[(SQLModel DB)]
|
||||
|
||||
API --> Auth
|
||||
Auth --> Payment
|
||||
@@ -40,57 +40,87 @@ graph TB
|
||||
|
||||
The main application is initialized in `routstr/core/main.py`:
|
||||
|
||||
- **Lifespan Management**: Handles startup/shutdown tasks
|
||||
- **Middleware**: CORS, logging, error handling
|
||||
- **Routers**: Modular endpoint organization
|
||||
- **Background Tasks**: Price updates, automatic payouts
|
||||
- **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**: Hashed key storage and lookup
|
||||
- **Balance Checking**: Ensures sufficient funds
|
||||
- **Rate Limiting**: Optional request throttling
|
||||
- **Token Redemption**: Converts eCash to balance
|
||||
- **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**: Dynamic pricing from models.json
|
||||
- **Currency Conversion**: BTC/USD rate management
|
||||
- **Fee Application**: Exchange and provider fees
|
||||
- **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**: Preserves headers and body
|
||||
- **Response Streaming**: Efficient memory usage
|
||||
- **Usage Tracking**: Counts tokens and costs
|
||||
- **Error Handling**: Graceful upstream failures
|
||||
- **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 models
|
||||
APIKey:
|
||||
- id: Primary key
|
||||
- key_hash: Hashed API key
|
||||
# Core tables
|
||||
ApiKey:
|
||||
- hashed_key: Primary key (SHA-256 of key or Cashu token)
|
||||
- balance: Current balance (msats)
|
||||
- created_at: Timestamp
|
||||
- metadata: JSON field
|
||||
- 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
|
||||
|
||||
Transaction:
|
||||
UpstreamProviderRow:
|
||||
- id: Primary key
|
||||
- api_key_id: Foreign key
|
||||
- amount: Transaction amount
|
||||
- type: deposit/usage/withdrawal
|
||||
- timestamp: When occurred
|
||||
- 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
|
||||
@@ -107,10 +137,10 @@ sequenceDiagram
|
||||
C->>R: API Request + Key
|
||||
R->>D: Validate Key
|
||||
D-->>R: Key Info + Balance
|
||||
R->>R: Check Balance
|
||||
R->>R: Reserve Max Cost
|
||||
R->>P: Forward Request
|
||||
P-->>R: AI Response
|
||||
R->>D: Deduct Cost
|
||||
R->>D: Finalize Cost (adjust by usage)
|
||||
R-->>C: Return Response
|
||||
```
|
||||
|
||||
@@ -124,36 +154,20 @@ sequenceDiagram
|
||||
participant M as Cashu Mint
|
||||
participant D as Database
|
||||
|
||||
C->>R: Create Key Request + Token
|
||||
R->>W: Validate Token
|
||||
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 Key + Balance
|
||||
R-->>C: Return API Key
|
||||
R->>D: Create/Update Key + Balance
|
||||
R-->>C: Continue Request
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Async Architecture
|
||||
|
||||
Everything is async for maximum performance:
|
||||
|
||||
```python
|
||||
async def handle_request(request: Request) -> Response:
|
||||
# Non-blocking database queries
|
||||
api_key = await get_api_key(request.headers["Authorization"])
|
||||
|
||||
# Concurrent operations
|
||||
balance_check, rate_limit = await asyncio.gather(
|
||||
check_balance(api_key),
|
||||
check_rate_limit(api_key)
|
||||
)
|
||||
|
||||
# Stream response without blocking
|
||||
async for chunk in proxy_request(request):
|
||||
yield chunk
|
||||
```
|
||||
The system is async end-to-end, with background tasks for pricing refresh, provider discovery, model map refresh, and payouts.
|
||||
|
||||
### 2. Modular Design
|
||||
|
||||
@@ -166,82 +180,46 @@ Components are loosely coupled:
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
Graceful degradation and clear error messages:
|
||||
|
||||
```python
|
||||
class RoustrError(Exception):
|
||||
"""Base exception with structured error response"""
|
||||
status_code: int = 500
|
||||
error_type: str = "internal_error"
|
||||
|
||||
class InsufficientBalanceError(RoustrError):
|
||||
status_code = 402
|
||||
error_type = "insufficient_balance"
|
||||
```
|
||||
Exceptions are handled by FastAPI exception handlers to return consistent JSON responses with a request ID.
|
||||
|
||||
### 4. Database Migrations
|
||||
|
||||
Using Alembic for schema management:
|
||||
|
||||
- Auto-migrations on startup
|
||||
- Version control for schema changes
|
||||
- Rollback capability
|
||||
- Zero-downtime updates
|
||||
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
|
||||
- **Validation**: Constant-time comparison
|
||||
- **Rotation**: Support for key expiry
|
||||
- **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**: Cryptographic verification
|
||||
- **Double-Spend Prevention**: Mint verification
|
||||
- **Balance Protection**: Atomic transactions
|
||||
- **Audit Trail**: All transactions logged
|
||||
- **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
|
||||
|
||||
- **HTTPS**: Enforced in production
|
||||
- **CORS**: Configurable origins
|
||||
- **Rate Limiting**: Per-key limits
|
||||
- **Input Validation**: Pydantic models
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
```python
|
||||
# Model pricing cache
|
||||
@lru_cache(maxsize=100)
|
||||
def get_model_price(model_id: str) -> ModelPrice:
|
||||
return MODELS.get(model_id)
|
||||
|
||||
# Balance cache with TTL
|
||||
balance_cache = TTLCache(maxsize=1000, ttl=60)
|
||||
```
|
||||
Model and provider selections are cached in process memory and refreshed on a schedule.
|
||||
|
||||
### Database Optimization
|
||||
|
||||
- **Connection Pooling**: Reuse connections
|
||||
- **Indexed Queries**: Key lookups are O(1)
|
||||
- **Batch Operations**: Group updates
|
||||
- **Async I/O**: Non-blocking queries
|
||||
- **Atomic Updates**: Balance reservation and finalization updates
|
||||
|
||||
### Streaming Responses
|
||||
|
||||
Efficient memory usage for large responses:
|
||||
|
||||
```python
|
||||
async def stream_response(upstream_response):
|
||||
async for chunk in upstream_response.aiter_bytes():
|
||||
# Process chunk without loading full response
|
||||
yield process_chunk(chunk)
|
||||
```
|
||||
Streaming responses are forwarded from upstream providers with usage tracking hooks.
|
||||
|
||||
## Extension Points
|
||||
|
||||
@@ -273,8 +251,6 @@ async def stream_response(upstream_response):
|
||||
|
||||
- Mock external dependencies
|
||||
- Test business logic in isolation
|
||||
- Fast execution (< 1 second per test)
|
||||
- High coverage target (> 80%)
|
||||
|
||||
### Integration Tests
|
||||
|
||||
@@ -285,10 +261,7 @@ async def stream_response(upstream_response):
|
||||
|
||||
### Performance Tests
|
||||
|
||||
- Load testing with locust
|
||||
- Memory profiling
|
||||
- Database query optimization
|
||||
- Response time benchmarks
|
||||
- Response time benchmarks (as needed)
|
||||
|
||||
## Monitoring and Observability
|
||||
|
||||
@@ -308,41 +281,17 @@ logger.info("api_request", extra={
|
||||
|
||||
### Metrics Collection
|
||||
|
||||
Key metrics tracked:
|
||||
|
||||
- Request rate by endpoint
|
||||
- Token usage by model
|
||||
- Balance changes
|
||||
- Error rates
|
||||
- Response times
|
||||
Structured logs are emitted for requests, pricing, and payment events.
|
||||
|
||||
### Health Checks
|
||||
|
||||
```python
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": __version__,
|
||||
"database": await check_db(),
|
||||
"upstream": await check_upstream(),
|
||||
"mints": await check_mints()
|
||||
}
|
||||
```
|
||||
Use `/v1/info` for basic service metadata and configuration visibility.
|
||||
|
||||
## Deployment Architecture
|
||||
|
||||
### Container Structure
|
||||
|
||||
```dockerfile
|
||||
# Multi-stage build
|
||||
FROM python:3.11-slim AS builder
|
||||
# Install dependencies
|
||||
|
||||
FROM python:3.11-slim
|
||||
# Copy only runtime needs
|
||||
# Run as non-root user
|
||||
```
|
||||
See `core/Dockerfile` for the current container build configuration.
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
@@ -352,10 +301,9 @@ FROM python:3.11-slim
|
||||
|
||||
### Scaling Considerations
|
||||
|
||||
- **Horizontal**: Multiple instances behind load balancer
|
||||
- **Horizontal**: Multiple instances behind a load balancer
|
||||
- **Vertical**: Async handles high concurrency
|
||||
- **Database**: Consider PostgreSQL for scale
|
||||
- **Caching**: Redis for distributed cache
|
||||
- **Database**: Configure `DATABASE_URL` for external databases
|
||||
|
||||
## Future Architecture
|
||||
|
||||
|
||||
@@ -7,10 +7,13 @@ This guide provides a detailed overview of Routstr Core's codebase organization
|
||||
```
|
||||
routstr-core/
|
||||
├── routstr/ # Main application package
|
||||
│ ├── __init__.py # Package initialization, loads .env
|
||||
│ ├── auth.py # Authentication and authorization
|
||||
│ ├── __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
|
||||
│ │
|
||||
@@ -18,19 +21,23 @@ routstr-core/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── admin.py # Admin dashboard and API
|
||||
│ │ ├── db.py # Database models and connection
|
||||
│ │ ├── exceptions.py # Custom exception classes
|
||||
│ │ ├── 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
|
||||
│ └── x_cashu.py # Cashu header protocol
|
||||
│ ├── 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
|
||||
@@ -45,7 +52,12 @@ routstr-core/
|
||||
│ └── versions/ # Migration files
|
||||
│
|
||||
├── scripts/ # Utility scripts
|
||||
│ └── models_meta.py # Fetch model pricing
|
||||
│ ├── 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)
|
||||
@@ -71,30 +83,27 @@ routstr-core/
|
||||
#### `routstr/__init__.py`
|
||||
|
||||
```python
|
||||
# Loads environment variables
|
||||
import dotenv
|
||||
dotenv.load_dotenv()
|
||||
|
||||
# Exports FastAPI app
|
||||
from .core.main import app as fastapi_app
|
||||
|
||||
__all__ = ["fastapi_app"]
|
||||
```
|
||||
|
||||
#### `routstr/core/main.py`
|
||||
|
||||
```python
|
||||
# FastAPI application setup
|
||||
app = FastAPI(
|
||||
title="Routstr Node",
|
||||
lifespan=lifespan, # Manages startup/shutdown
|
||||
)
|
||||
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)
|
||||
```
|
||||
|
||||
@@ -102,29 +111,24 @@ app.include_router(proxy_router)
|
||||
|
||||
#### `routstr/auth.py`
|
||||
|
||||
Handles API key validation and authorization:
|
||||
Handles bearer key validation and payment lifecycle (bearer or Cashu token):
|
||||
|
||||
```python
|
||||
class APIKeyAuth:
|
||||
"""FastAPI dependency for API key authentication"""
|
||||
|
||||
async def __call__(self, request: Request) -> APIKey:
|
||||
# Extract and validate API key
|
||||
# Check balance
|
||||
# Return authenticated key object
|
||||
|
||||
# Usage in routes:
|
||||
@router.get("/protected")
|
||||
async def protected_route(api_key: APIKey = Depends(APIKeyAuth())):
|
||||
pass
|
||||
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:
|
||||
|
||||
- `create_api_key()` - Generate new API keys
|
||||
- `validate_api_key()` - Verify and retrieve key
|
||||
- `check_balance()` - Ensure sufficient funds
|
||||
- `update_last_used()` - Track usage
|
||||
- `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
|
||||
|
||||
@@ -133,56 +137,30 @@ Key functions:
|
||||
Calculates request costs:
|
||||
|
||||
```python
|
||||
def calculate_request_cost(
|
||||
model: str,
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
**kwargs
|
||||
) -> CostData:
|
||||
"""Calculate cost in millisatoshis"""
|
||||
# Model-based or fixed pricing
|
||||
# Token counting
|
||||
# Fee application
|
||||
# Currency conversion
|
||||
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 data:
|
||||
Manages model pricing, database overrides, and pricing refresh:
|
||||
|
||||
```python
|
||||
class ModelPrice:
|
||||
class Model(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
pricing: dict[str, float] # USD prices
|
||||
context_length: int
|
||||
|
||||
# Global model registry
|
||||
MODELS: dict[str, ModelPrice] = load_models()
|
||||
pricing: Pricing
|
||||
sats_pricing: Pricing | None = None
|
||||
|
||||
# Dynamic price updates
|
||||
async def update_sats_pricing():
|
||||
"""Background task to update BTC prices"""
|
||||
"""Periodic task to update sats pricing for providers and overrides."""
|
||||
```
|
||||
|
||||
#### `routstr/payment/x_cashu.py`
|
||||
#### `routstr/proxy.py` + `routstr/upstream/*`
|
||||
|
||||
Implements Cashu payment protocol:
|
||||
|
||||
```python
|
||||
class XCashuHandler:
|
||||
"""Handle x-cashu header payments"""
|
||||
|
||||
async def process_request_payment(
|
||||
self,
|
||||
token: str,
|
||||
estimated_cost: int
|
||||
) -> PaymentResult:
|
||||
# Validate token
|
||||
# Check minimum amount
|
||||
# Process payment
|
||||
# Generate change
|
||||
```
|
||||
The `x-cashu` header is handled by the proxy route and delegated to upstream providers.
|
||||
|
||||
### Request Proxying
|
||||
|
||||
@@ -191,17 +169,11 @@ class XCashuHandler:
|
||||
Core proxy functionality:
|
||||
|
||||
```python
|
||||
@router.api_route("/{path:path}", methods=ALL_METHODS)
|
||||
async def proxy_request(
|
||||
request: Request,
|
||||
path: str,
|
||||
api_key: APIKey = Depends(APIKeyAuth())
|
||||
) -> Response:
|
||||
"""Forward requests to upstream provider"""
|
||||
# Build upstream request
|
||||
# Stream response
|
||||
# Track usage
|
||||
# Deduct costs
|
||||
@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:
|
||||
@@ -215,27 +187,29 @@ Key features:
|
||||
|
||||
#### `routstr/core/db.py`
|
||||
|
||||
SQLModel definitions:
|
||||
SQLModel definitions (selected):
|
||||
|
||||
```python
|
||||
class APIKey(SQLModel, table=True):
|
||||
id: int | None = Field(primary_key=True)
|
||||
key_hash: str = Field(index=True, unique=True)
|
||||
balance: int # millisatoshis
|
||||
total_deposited: int = 0
|
||||
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
|
||||
created_at: datetime
|
||||
expires_at: datetime | None = None
|
||||
metadata: dict = Field(default_factory=dict, sa_column=Column(JSON))
|
||||
total_requests: int = 0
|
||||
|
||||
class Transaction(SQLModel, table=True):
|
||||
id: int | None = Field(primary_key=True)
|
||||
api_key_id: int = Field(foreign_key="apikey.id")
|
||||
amount: int # can be negative
|
||||
balance_after: int
|
||||
type: TransactionType
|
||||
description: str
|
||||
timestamp: datetime
|
||||
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
|
||||
@@ -245,18 +219,17 @@ class Transaction(SQLModel, table=True):
|
||||
Web dashboard and admin API:
|
||||
|
||||
```python
|
||||
@admin_router.get("/admin/")
|
||||
@admin_router.get("/admin")
|
||||
async def admin_dashboard(request: Request):
|
||||
"""Render admin HTML interface"""
|
||||
# Authentication check
|
||||
# Load statistics
|
||||
# Render template
|
||||
|
||||
@admin_router.post("/admin/api/withdraw")
|
||||
@admin_router.post("/admin/withdraw")
|
||||
async def withdraw_balance(
|
||||
api_key: str,
|
||||
amount: int | None = None
|
||||
) -> WithdrawalResponse:
|
||||
request: Request, withdraw_request: WithdrawRequest
|
||||
) -> dict[str, str]:
|
||||
"""Generate eCash token for withdrawal"""
|
||||
```
|
||||
|
||||
@@ -271,25 +244,14 @@ Features:
|
||||
|
||||
#### `routstr/wallet.py`
|
||||
|
||||
Cashu wallet operations:
|
||||
Cashu wallet operations (function-based):
|
||||
|
||||
```python
|
||||
class WalletManager:
|
||||
"""Manage Cashu wallet instances"""
|
||||
|
||||
async def redeem_token(
|
||||
self,
|
||||
token: str,
|
||||
mint_url: str | None = None
|
||||
) -> int:
|
||||
"""Redeem eCash token and return value"""
|
||||
|
||||
async def create_token(
|
||||
self,
|
||||
amount: int,
|
||||
mint_url: str
|
||||
) -> str:
|
||||
"""Create eCash token for withdrawal"""
|
||||
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
|
||||
@@ -301,12 +263,9 @@ Structured logging configuration:
|
||||
```python
|
||||
def setup_logging():
|
||||
"""Configure JSON structured logging"""
|
||||
# Set log level
|
||||
# Configure formatters
|
||||
# Add handlers
|
||||
|
||||
class RequestIdMiddleware:
|
||||
"""Add request ID to all logs"""
|
||||
|
||||
class RequestIdFilter(logging.Filter):
|
||||
"""Attach request ID to log records."""
|
||||
```
|
||||
|
||||
#### `routstr/core/middleware.py`
|
||||
@@ -316,27 +275,18 @@ HTTP middleware components:
|
||||
```python
|
||||
class LoggingMiddleware:
|
||||
"""Log all HTTP requests/responses"""
|
||||
|
||||
class ErrorHandlingMiddleware:
|
||||
"""Consistent error responses"""
|
||||
```
|
||||
|
||||
#### `routstr/core/exceptions.py`
|
||||
|
||||
Custom exception hierarchy:
|
||||
Exception handlers:
|
||||
|
||||
```python
|
||||
class RoustrError(Exception):
|
||||
"""Base exception with error details"""
|
||||
status_code: int
|
||||
error_type: str
|
||||
detail: str
|
||||
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""HTTP exception handler with request ID"""
|
||||
|
||||
class PaymentError(RoustrError):
|
||||
"""Payment-related errors"""
|
||||
|
||||
class UpstreamError(RoustrError):
|
||||
"""Upstream API errors"""
|
||||
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Fallback exception handler with request ID"""
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
@@ -348,7 +298,7 @@ Project metadata and dependencies:
|
||||
```toml
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.2.0"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.115",
|
||||
"sqlmodel>=0.0.24",
|
||||
@@ -409,13 +359,13 @@ Using FastAPI's DI system:
|
||||
|
||||
```python
|
||||
# Define dependency
|
||||
async def get_db() -> AsyncSession:
|
||||
async with async_session() as session:
|
||||
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_db)):
|
||||
async def get_items(db: AsyncSession = Depends(get_session)):
|
||||
result = await db.execute(select(Item))
|
||||
return result.scalars().all()
|
||||
```
|
||||
|
||||
@@ -22,22 +22,7 @@ git clone https://github.com/YOUR_USERNAME/routstr-core.git
|
||||
cd routstr-core
|
||||
```
|
||||
|
||||
### 2. Install uv
|
||||
|
||||
We use [uv](https://github.com/astral-sh/uv) for fast, reliable Python package management:
|
||||
|
||||
```bash
|
||||
# Using the installer script
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Or with pip
|
||||
pip install uv
|
||||
|
||||
# Or with Homebrew (macOS)
|
||||
brew install uv
|
||||
```
|
||||
|
||||
### 3. Set Up Environment
|
||||
### 2. Set Up Environment
|
||||
|
||||
Run the setup command:
|
||||
|
||||
@@ -47,13 +32,13 @@ make setup
|
||||
|
||||
This will:
|
||||
|
||||
- ✅ Install uv if not present
|
||||
- ✅ 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
|
||||
|
||||
### 4. Configure Environment
|
||||
### 3. Configure Environment
|
||||
|
||||
Create your environment file:
|
||||
|
||||
@@ -71,7 +56,7 @@ ADMIN_PASSWORD=development-password
|
||||
DATABASE_URL=sqlite+aiosqlite:///dev.db
|
||||
```
|
||||
|
||||
### 5. Verify Installation
|
||||
### 4. Verify Installation
|
||||
|
||||
Run these commands to verify your setup:
|
||||
|
||||
@@ -168,42 +153,92 @@ Understanding the codebase:
|
||||
```
|
||||
routstr-core/
|
||||
├── routstr/ # Main package
|
||||
│ ├── __init__.py # Package initialization
|
||||
│ ├── __init__.py
|
||||
│ ├── algorithm.py # Provider selection algorithms
|
||||
│ ├── auth.py # Authentication logic
|
||||
│ ├── balance.py # Balance management
|
||||
│ ├── 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
|
||||
│ │ ├── db.py # Database models
|
||||
│ │ ├── 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
|
||||
│ │ └── middleware.py # HTTP middleware
|
||||
│ │ ├── main.py # FastAPI app entry
|
||||
│ │ ├── middleware.py # HTTP middleware
|
||||
│ │ └── settings.py # Configuration
|
||||
│ │
|
||||
│ └── payment/ # Payment processing
|
||||
│ ├── cost_calculation.py # Cost logic
|
||||
│ ├── helpers.py # Utilities
|
||||
│ ├── lnurl.py # Lightning URLs
|
||||
│ ├── models.py # Model pricing
|
||||
│ ├── price.py # BTC pricing
|
||||
│ └── x_cashu.py # Cashu headers
|
||||
│ ├── 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
|
||||
│ ├── unit/ # Unit tests
|
||||
│ └── integration/ # Integration tests
|
||||
├── migrations/ # Alembic migrations
|
||||
├── scripts/ # Utility scripts
|
||||
├── docs/ # Documentation
|
||||
├── examples/ # Usage examples
|
||||
│
|
||||
├── migrations/ # Database migrations
|
||||
├── scripts/ # Utility scripts
|
||||
├── docs/ # Documentation
|
||||
│
|
||||
├── Makefile # Dev commands
|
||||
├── pyproject.toml # Project config
|
||||
└── compose.yml # Docker setup
|
||||
├── 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
|
||||
@@ -383,7 +418,7 @@ rm -rf test_*.db
|
||||
Now that you're set up:
|
||||
|
||||
1. Read the [Architecture Overview](architecture.md)
|
||||
3. Check [open issues](https://github.com/routstr/routstr-core/issues)
|
||||
4. Start with a small contribution
|
||||
2. Check [open issues](https://github.com/routstr/routstr-core/issues)
|
||||
3. Start with a small contribution
|
||||
|
||||
Happy coding! 🚀
|
||||
|
||||
@@ -6,576 +6,228 @@ This guide covers testing practices, patterns, and tools used in Routstr Core de
|
||||
|
||||
We follow these principles:
|
||||
|
||||
- **Test Behavior, Not Implementation** - Tests should survive refactoring
|
||||
- **Fast Feedback** - Unit tests run in milliseconds
|
||||
- **Reliable Tests** - No flaky tests allowed
|
||||
- **Clear Failures** - Tests should clearly indicate what broke
|
||||
- Test behavior, not implementation
|
||||
- Fast feedback
|
||||
- Reliable tests
|
||||
- Clear failures
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── __init__.py
|
||||
├── conftest.py # Shared fixtures and configuration
|
||||
├── unit/ # Fast, isolated unit tests
|
||||
│ ├── test_auth.py
|
||||
│ ├── test_balance.py
|
||||
│ ├── test_cost_calculation.py
|
||||
│ ├── test_models.py
|
||||
│ └── test_wallet.py
|
||||
└── integration/ # Component integration tests
|
||||
├── test_api_endpoints.py
|
||||
├── test_payment_flow.py
|
||||
├── test_proxy_streaming.py
|
||||
└── test_real_mint.py
|
||||
├── 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
|
||||
|
||||
### Quick Test Commands
|
||||
### 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
|
||||
make test
|
||||
pytest
|
||||
|
||||
# Run unit tests only (fast)
|
||||
make test-unit
|
||||
# Run a specific test file
|
||||
pytest tests/unit/test_wallet.py -v
|
||||
|
||||
# Run integration tests
|
||||
make test-integration
|
||||
# Run a specific test
|
||||
pytest tests/unit/test_wallet.py::test_get_balance -v
|
||||
|
||||
# Run with coverage
|
||||
make test-coverage
|
||||
|
||||
# Run specific test file
|
||||
uv run pytest tests/unit/test_auth.py -v
|
||||
|
||||
# Run specific test
|
||||
uv run pytest tests/unit/test_auth.py::test_create_api_key -v
|
||||
|
||||
# Run tests matching pattern
|
||||
uv run pytest -k "balance" -v
|
||||
# Run tests matching a pattern
|
||||
pytest -k "wallet" -v
|
||||
```
|
||||
|
||||
### Test Markers
|
||||
## Test Modes (Integration)
|
||||
|
||||
Use markers to categorize tests:
|
||||
Integration tests support two execution modes:
|
||||
|
||||
```python
|
||||
@pytest.mark.slow
|
||||
async def test_heavy_computation():
|
||||
pass
|
||||
- Mock mode (default): uses in-memory mocks, no Docker required
|
||||
- Docker mode: uses real Docker services (Cashu mint, mock OpenAI, Nostr relay)
|
||||
|
||||
@pytest.mark.requires_docker
|
||||
async def test_real_services():
|
||||
pass
|
||||
Use the runner script for Docker mode:
|
||||
|
||||
# Run without slow tests
|
||||
pytest -m "not slow"
|
||||
```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"
|
||||
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
|
||||
# tests/unit/test_auth.py
|
||||
import pytest
|
||||
from routstr.auth import create_api_key, validate_api_key
|
||||
from routstr.algorithm import calculate_model_cost_score
|
||||
from routstr.payment.models import Architecture, Model, Pricing
|
||||
|
||||
class TestAPIKeyAuth:
|
||||
"""Test API key authentication functionality"""
|
||||
|
||||
async def test_create_api_key(self, test_db):
|
||||
"""Test creating a new API key"""
|
||||
# Arrange
|
||||
initial_balance = 10000
|
||||
|
||||
# Act
|
||||
api_key = await create_api_key(
|
||||
balance=initial_balance,
|
||||
name="Test Key"
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert api_key.key.startswith("sk-")
|
||||
assert len(api_key.key) == 32
|
||||
assert api_key.balance == initial_balance
|
||||
|
||||
async def test_validate_invalid_key(self, test_db):
|
||||
"""Test validation fails for invalid key"""
|
||||
# Act & Assert
|
||||
with pytest.raises(InvalidAPIKeyError):
|
||||
await validate_api_key("invalid_key")
|
||||
|
||||
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
|
||||
# tests/integration/test_payment_flow.py
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
class TestPaymentFlow:
|
||||
"""Test end-to-end payment flows"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_redemption_flow(
|
||||
self,
|
||||
app_client: AsyncClient,
|
||||
mock_cashu_wallet
|
||||
):
|
||||
"""Test complete token redemption and API usage"""
|
||||
# Arrange
|
||||
token = create_test_token(amount=5000)
|
||||
mock_cashu_wallet.redeem.return_value = 5000
|
||||
|
||||
# Act - Create API key
|
||||
response = await app_client.post(
|
||||
"/v1/wallet/create",
|
||||
json={"cashu_token": token}
|
||||
)
|
||||
|
||||
# Assert - Key created
|
||||
assert response.status_code == 200
|
||||
api_key = response.json()["api_key"]
|
||||
assert response.json()["balance"] == 5000
|
||||
|
||||
# Act - Use API key
|
||||
response = await app_client.post(
|
||||
"/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
json={
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "Hi"}]
|
||||
}
|
||||
)
|
||||
|
||||
# Assert - Request successful
|
||||
assert response.status_code == 200
|
||||
assert "choices" in response.json()
|
||||
```
|
||||
|
||||
## Test Fixtures
|
||||
|
||||
### Common Fixtures
|
||||
|
||||
Located in `tests/conftest.py`:
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
async def test_db():
|
||||
"""Provide a clean test database"""
|
||||
# Create in-memory SQLite database
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
async_session = sessionmaker(engine, class_=AsyncSession)
|
||||
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.fixture
|
||||
async def app_client(test_db):
|
||||
"""Provide test client with test database"""
|
||||
app.dependency_overrides[get_db] = lambda: test_db
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cashu_wallet(mocker):
|
||||
"""Mock Cashu wallet for testing"""
|
||||
mock = mocker.patch("routstr.wallet.Wallet")
|
||||
mock.return_value.redeem.return_value = 1000
|
||||
return mock
|
||||
```
|
||||
|
||||
### Using Fixtures
|
||||
|
||||
```python
|
||||
async def test_with_fixtures(
|
||||
test_db, # Get test database
|
||||
app_client, # Get test HTTP client
|
||||
mock_cashu_wallet # Get mocked wallet
|
||||
):
|
||||
# Use fixtures in test
|
||||
pass
|
||||
```
|
||||
|
||||
## Mocking Strategies
|
||||
|
||||
### Mocking External Services
|
||||
|
||||
```python
|
||||
# Mock upstream API
|
||||
@pytest.fixture
|
||||
def mock_openai(mocker):
|
||||
mock_response = mocker.Mock()
|
||||
mock_response.json.return_value = {
|
||||
"choices": [{
|
||||
"message": {"content": "Hello!"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 30
|
||||
}
|
||||
}
|
||||
|
||||
mocker.patch(
|
||||
"httpx.AsyncClient.post",
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
# Mock Cashu mint
|
||||
@pytest.fixture
|
||||
def mock_mint(mocker):
|
||||
mint = mocker.patch("routstr.wallet.Mint")
|
||||
mint.return_value.check_proof_state.return_value = True
|
||||
return mint
|
||||
```
|
||||
|
||||
### Mocking Time
|
||||
|
||||
```python
|
||||
from freezegun import freeze_time
|
||||
|
||||
@freeze_time("2024-01-01 12:00:00")
|
||||
async def test_time_dependent():
|
||||
# Time is frozen during test
|
||||
key = await create_api_key(expires_in_days=30)
|
||||
assert key.expires_at == datetime(2024, 1, 31, 12, 0, 0)
|
||||
```
|
||||
|
||||
## Test Patterns
|
||||
|
||||
### Testing Async Code
|
||||
|
||||
```python
|
||||
# Always mark async tests
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_function():
|
||||
result = await async_operation()
|
||||
assert result == expected
|
||||
|
||||
# Test async context managers
|
||||
async def test_async_context():
|
||||
async with create_resource() as resource:
|
||||
assert resource.is_active
|
||||
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
|
||||
```
|
||||
|
||||
### Testing Exceptions
|
||||
|
||||
```python
|
||||
# Test specific exception
|
||||
async def test_raises_specific_error():
|
||||
with pytest.raises(InsufficientBalanceError) as exc_info:
|
||||
await deduct_balance(api_key, amount=999999)
|
||||
|
||||
assert "Insufficient balance" in str(exc_info.value)
|
||||
assert exc_info.value.status_code == 402
|
||||
|
||||
# Test exception details
|
||||
async def test_exception_details():
|
||||
with pytest.raises(RoustrError) as exc_info:
|
||||
await risky_operation()
|
||||
|
||||
error = exc_info.value
|
||||
assert error.error_type == "validation_error"
|
||||
assert error.detail == "Invalid input"
|
||||
```
|
||||
|
||||
### Testing Streaming Responses
|
||||
|
||||
```python
|
||||
async def test_streaming_response():
|
||||
"""Test streaming chat completion"""
|
||||
chunks = []
|
||||
|
||||
async with app_client.stream(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
json={"model": "gpt-3.5-turbo", "stream": True}
|
||||
) as response:
|
||||
async for line in response.aiter_lines():
|
||||
if line.startswith("data: "):
|
||||
chunks.append(json.loads(line[6:]))
|
||||
|
||||
# Verify chunks
|
||||
assert len(chunks) > 0
|
||||
assert chunks[-1] == "[DONE]"
|
||||
```
|
||||
|
||||
### Database Testing
|
||||
|
||||
```python
|
||||
async def test_database_transaction(test_db):
|
||||
"""Test atomic transactions"""
|
||||
async with test_db.begin():
|
||||
# Create test data
|
||||
api_key = APIKey(key_hash="test", balance=1000)
|
||||
test_db.add(api_key)
|
||||
await test_db.flush()
|
||||
|
||||
# Test rollback
|
||||
try:
|
||||
async with test_db.begin_nested():
|
||||
api_key.balance = -100 # Invalid
|
||||
await test_db.flush()
|
||||
raise ValueError("Rollback")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Verify rollback worked
|
||||
await test_db.refresh(api_key)
|
||||
assert api_key.balance == 1000
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
### Benchmark Tests
|
||||
|
||||
```python
|
||||
@pytest.mark.benchmark
|
||||
def test_performance(benchmark):
|
||||
"""Benchmark critical functions"""
|
||||
result = benchmark(expensive_function, arg1, arg2)
|
||||
assert result == expected
|
||||
|
||||
# Run benchmarks
|
||||
pytest --benchmark-only
|
||||
```
|
||||
|
||||
### Load Testing
|
||||
|
||||
```python
|
||||
# tests/integration/test_load.py
|
||||
async def test_concurrent_requests(app_client):
|
||||
"""Test handling multiple concurrent requests"""
|
||||
async def make_request(i):
|
||||
response = await app_client.get(f"/test/{i}")
|
||||
return response.status_code
|
||||
|
||||
# Make 100 concurrent requests
|
||||
tasks = [make_request(i) for i in range(100)]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# All should succeed
|
||||
assert all(status == 200 for status in results)
|
||||
```
|
||||
|
||||
## Test Data
|
||||
|
||||
### Factories
|
||||
|
||||
```python
|
||||
# tests/factories.py
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
def create_test_api_key(**kwargs):
|
||||
"""Factory for test API keys"""
|
||||
defaults = {
|
||||
"key": f"sk-test-{uuid4().hex[:8]}",
|
||||
"balance": 10000,
|
||||
"created_at": datetime.utcnow(),
|
||||
"expires_at": datetime.utcnow() + timedelta(days=30)
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return APIKey(**defaults)
|
||||
|
||||
def create_test_token(amount: int = 1000, mint: str = None):
|
||||
"""Factory for test Cashu tokens"""
|
||||
# Create valid test token structure
|
||||
return base64.encode(...)
|
||||
```
|
||||
|
||||
### Test Constants
|
||||
|
||||
```python
|
||||
# tests/constants.py
|
||||
TEST_MODELS = {
|
||||
"gpt-3.5-turbo": {
|
||||
"prompt": 0.0015,
|
||||
"completion": 0.002
|
||||
},
|
||||
"gpt-4": {
|
||||
"prompt": 0.03,
|
||||
"completion": 0.06
|
||||
}
|
||||
}
|
||||
|
||||
TEST_API_KEY = "sk-test-1234567890"
|
||||
TEST_MINT_URL = "https://testmint.example.com"
|
||||
```
|
||||
|
||||
## Coverage
|
||||
|
||||
### Running Coverage
|
||||
## Debugging Tips
|
||||
|
||||
```bash
|
||||
# Generate coverage report
|
||||
make test-coverage
|
||||
# Show print output
|
||||
pytest -s tests/unit/test_wallet.py
|
||||
|
||||
# View HTML report
|
||||
open htmlcov/index.html
|
||||
|
||||
# Coverage with specific tests
|
||||
pytest --cov=routstr --cov-report=html tests/unit/
|
||||
```
|
||||
|
||||
### Coverage Configuration
|
||||
|
||||
In `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[tool.coverage.run]
|
||||
source = ["routstr"]
|
||||
omit = ["tests/*", "*/migrations/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if TYPE_CHECKING:"
|
||||
]
|
||||
```
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### Print Debugging
|
||||
|
||||
```python
|
||||
# Use -s flag to see print output
|
||||
pytest -s tests/unit/test_auth.py
|
||||
|
||||
# In test
|
||||
async def test_debug():
|
||||
print(f"Value: {value}") # Will show with -s
|
||||
assert value == expected
|
||||
```
|
||||
|
||||
### Interactive Debugging
|
||||
|
||||
```python
|
||||
# Drop into debugger on failure
|
||||
pytest --pdb
|
||||
|
||||
# Set breakpoint in test
|
||||
async def test_debug():
|
||||
import pdb; pdb.set_trace()
|
||||
# Execution stops here
|
||||
```
|
||||
|
||||
### Logging in Tests
|
||||
|
||||
```python
|
||||
# Enable debug logging in tests
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
# Or use caplog fixture
|
||||
async def test_logging(caplog):
|
||||
with caplog.at_level(logging.INFO):
|
||||
await function_that_logs()
|
||||
|
||||
assert "Expected message" in caplog.text
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
Tests run automatically on:
|
||||
|
||||
- Pull requests
|
||||
- Pushes to main
|
||||
- Nightly schedules
|
||||
|
||||
See `.github/workflows/test.yml` for configuration.
|
||||
|
||||
### Pre-commit Hooks
|
||||
|
||||
Install pre-commit hooks:
|
||||
|
||||
```bash
|
||||
pre-commit install
|
||||
|
||||
# Run manually
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do's
|
||||
|
||||
1. ✅ Write tests first (TDD)
|
||||
2. ✅ Keep tests simple and focused
|
||||
3. ✅ Use descriptive test names
|
||||
4. ✅ Test edge cases
|
||||
5. ✅ Mock external dependencies
|
||||
6. ✅ Use fixtures for setup
|
||||
7. ✅ Assert specific values
|
||||
|
||||
### Don'ts
|
||||
|
||||
1. ❌ Don't test implementation details
|
||||
2. ❌ Don't use production services
|
||||
3. ❌ Don't rely on test order
|
||||
4. ❌ Don't ignore flaky tests
|
||||
5. ❌ Don't skip error cases
|
||||
6. ❌ Don't use hard-coded waits
|
||||
7. ❌ Don't commit commented tests
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Async Test Errors**
|
||||
|
||||
```python
|
||||
# Wrong
|
||||
def test_async(): # Missing async
|
||||
await function()
|
||||
|
||||
# Right
|
||||
async def test_async():
|
||||
await function()
|
||||
```
|
||||
|
||||
**Database State**
|
||||
|
||||
```python
|
||||
# Ensure clean state
|
||||
@pytest.fixture(autouse=True)
|
||||
async def cleanup(test_db):
|
||||
yield
|
||||
# Cleanup after each test
|
||||
await test_db.execute("DELETE FROM apikey")
|
||||
await test_db.commit()
|
||||
```
|
||||
|
||||
**Mock Not Working**
|
||||
|
||||
```python
|
||||
# Check import path
|
||||
mocker.patch("routstr.wallet.Wallet") # Full path
|
||||
# Not just "Wallet"
|
||||
```
|
||||
- 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) for system design
|
||||
- Read [Setup Guide](setup.md) for environment setup
|
||||
- See [Architecture](architecture.md)
|
||||
- Read [Setup Guide](setup.md)
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
# Configuration
|
||||
|
||||
Routstr Core is configured via a single settings row in the database. Environment variables are only used on first run to seed that row (with a few computed defaults like `ONION_URL`). After that, the database is the source of truth. You can update settings at runtime via the admin API. `DATABASE_URL` is always env-only.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Core Settings
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `UPSTREAM_BASE_URL` | Base URL of the OpenAI-compatible API to proxy | - | ✅ |
|
||||
| `UPSTREAM_API_KEY` | API key for the upstream service | - | ❌ |
|
||||
| `ADMIN_PASSWORD` | Password for admin dashboard access | - | ⚠️ |
|
||||
|
||||
### Node Information
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `NAME` | Public name of your Routstr node | `ARoutstrNode` | ❌ |
|
||||
| `DESCRIPTION` | Description of your node | `A Routstr Node` | ❌ |
|
||||
| `NPUB` | Nostr public key for node identity | - | ❌ |
|
||||
| `HTTP_URL` | Public HTTP URL of your node | - | ❌ |
|
||||
| `ONION_URL` | Tor hidden service URL | - | ❌ |
|
||||
|
||||
### Cashu Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `CASHU_MINTS` | Comma-separated list of trusted Cashu mint URLs | `https://mint.minibits.cash/Bitcoin` | ❌ |
|
||||
| `RECEIVE_LN_ADDRESS` | Lightning address for automatic payouts | - | ❌ |
|
||||
|
||||
### Pricing Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `FIXED_PRICING` | Force fixed per-request pricing (ignore model token pricing) | `false` | ❌ |
|
||||
| `FIXED_COST_PER_REQUEST` | Fixed cost per API request in sats | `1` | ❌ |
|
||||
| `FIXED_PER_1K_INPUT_TOKENS` | Optional override: sats per 1000 input tokens | `0` | ❌ |
|
||||
| `FIXED_PER_1K_OUTPUT_TOKENS` | Optional override: sats per 1000 output tokens | `0` | ❌ |
|
||||
| `EXCHANGE_FEE` | Exchange rate markup (1.005 = 0.5% fee) | `1.005` | ❌ |
|
||||
| `UPSTREAM_PROVIDER_FEE` | Provider fee markup (1.05 = 5% fee) | `1.05` | ❌ |
|
||||
|
||||
### Network & Discovery
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `CORS_ORIGINS` | Comma-separated list of allowed CORS origins | `*` | ❌ |
|
||||
| `TOR_PROXY_URL` | SOCKS5 proxy URL for Tor connections | `socks5://127.0.0.1:9050` | ❌ |
|
||||
| `RELAYS` | Comma-separated nostr relays used for provider discovery | sane defaults | ❌ |
|
||||
| `PROVIDERS_REFRESH_INTERVAL_SECONDS` | Provider cache refresh interval | `300` | ❌ |
|
||||
|
||||
### Logging Configuration
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `LOG_LEVEL` | Logging level (DEBUG, INFO, WARNING, ERROR) | `INFO` | ❌ |
|
||||
| `ENABLE_CONSOLE_LOGGING` | Enable console log output | `true` | ❌ |
|
||||
|
||||
### Other
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `CHAT_COMPLETIONS_API_VERSION` | Append `api-version` to `/chat/completions` (Azure OpenAI) | - | ❌ |
|
||||
| `DATABASE_URL` | SQLite database connection string | `sqlite+aiosqlite:///keys.db` | ❌ |
|
||||
| `REFUND_CACHE_TTL_SECONDS` | Cache TTL for refund responses (seconds) | `3600` | ❌ |
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Basic OpenAI Proxy
|
||||
|
||||
```bash
|
||||
# .env
|
||||
UPSTREAM_BASE_URL=https://api.openai.com/v1
|
||||
UPSTREAM_API_KEY=sk-...
|
||||
ADMIN_PASSWORD=my-secure-password
|
||||
```
|
||||
|
||||
### Custom AI Provider
|
||||
|
||||
```bash
|
||||
# .env
|
||||
UPSTREAM_BASE_URL=https://api.anthropic.com/v1
|
||||
UPSTREAM_API_KEY=your-anthropic-key
|
||||
MODELS_PATH=/app/config/anthropic-models.json
|
||||
```
|
||||
|
||||
### Azure OpenAI (optional)
|
||||
|
||||
```bash
|
||||
# .env
|
||||
UPSTREAM_BASE_URL=https://<resource>.openai.azure.com/openai/deployments/<deployment>
|
||||
UPSTREAM_API_KEY=<azure_api_key>
|
||||
CHAT_COMPLETIONS_API_VERSION=2024-05-01-preview
|
||||
```
|
||||
|
||||
### High-Security Setup
|
||||
|
||||
```bash
|
||||
# .env
|
||||
UPSTREAM_BASE_URL=https://api.openai.com/v1
|
||||
UPSTREAM_API_KEY=sk-...
|
||||
ADMIN_PASSWORD=very-long-secure-password-here
|
||||
CORS_ORIGINS=https://myapp.com,https://app.myapp.com
|
||||
TOR_PROXY_URL=socks5://tor:9050
|
||||
LOG_LEVEL=WARNING
|
||||
```
|
||||
|
||||
### Public Node Configuration
|
||||
|
||||
```bash
|
||||
# .env
|
||||
NAME=Lightning AI Gateway
|
||||
DESCRIPTION=Fast and reliable AI API access with Bitcoin payments
|
||||
NPUB=npub1abcd...
|
||||
HTTP_URL=https://api.lightning-ai.com
|
||||
ONION_URL=http://lightningai.onion
|
||||
CASHU_MINTS=https://mint1.com,https://mint2.com
|
||||
```
|
||||
|
||||
## Pricing
|
||||
|
||||
- Default: pricing comes from your `models.json`.
|
||||
- Force fixed per-request pricing: set `FIXED_PRICING=true` and `FIXED_COST_PER_REQUEST`.
|
||||
- Optional token overrides when using model pricing: set
|
||||
`FIXED_PER_1K_INPUT_TOKENS` and/or `FIXED_PER_1K_OUTPUT_TOKENS`.
|
||||
- Legacy envs are still accepted and mapped automatically:
|
||||
`MODEL_BASED_PRICING` → `!FIXED_PRICING`, `COST_PER_REQUEST` → `FIXED_COST_PER_REQUEST`,
|
||||
`COST_PER_1K_*` → `FIXED_PER_1K_*`.
|
||||
|
||||
Example fixed pricing:
|
||||
|
||||
```bash
|
||||
FIXED_PRICING=true
|
||||
FIXED_COST_PER_REQUEST=10
|
||||
```
|
||||
|
||||
## Custom Models Configuration
|
||||
|
||||
Create a `models.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-4",
|
||||
"name": "GPT-4",
|
||||
"pricing": {
|
||||
"prompt": "0.00003",
|
||||
"completion": "0.00006",
|
||||
"request": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-3.5-turbo",
|
||||
"name": "GPT-3.5 Turbo",
|
||||
"pricing": {
|
||||
"prompt": "0.0000015",
|
||||
"completion": "0.000002",
|
||||
"request": "0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Admin Password
|
||||
|
||||
Generate a strong password:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
### API Keys
|
||||
|
||||
- Rotate upstream API keys regularly
|
||||
- Use read-only keys when possible
|
||||
- Monitor key usage
|
||||
|
||||
### Network Security
|
||||
|
||||
- Restrict CORS origins in production
|
||||
- Use HTTPS for public endpoints
|
||||
- Enable Tor for anonymity
|
||||
|
||||
### Database Security
|
||||
|
||||
- Regular backups
|
||||
- Encrypted storage volumes
|
||||
- Restricted file permissions
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Check Current Configuration
|
||||
|
||||
```bash
|
||||
# View all environment variables
|
||||
docker exec routstr env | sort
|
||||
|
||||
# Test configuration
|
||||
curl http://localhost:8000/v1/info
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Missing Upstream URL**
|
||||
|
||||
```
|
||||
ERROR: UPSTREAM_BASE_URL not set
|
||||
Solution: Set UPSTREAM_BASE_URL in .env
|
||||
```
|
||||
|
||||
**Invalid Cashu Mint**
|
||||
|
||||
```
|
||||
ERROR: Failed to connect to mint
|
||||
Solution: Verify CASHU_MINTS URLs are accessible
|
||||
```
|
||||
|
||||
**Database Errors**
|
||||
|
||||
```
|
||||
ERROR: Database connection failed
|
||||
Solution: Check DATABASE_URL and file permissions
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Multiple Mints
|
||||
|
||||
Configure fallback mints:
|
||||
|
||||
```bash
|
||||
CASHU_MINTS=https://primary.mint,https://backup1.mint,https://backup2.mint
|
||||
```
|
||||
|
||||
### Custom Database
|
||||
|
||||
Use PostgreSQL instead of SQLite:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/routstr
|
||||
```
|
||||
|
||||
### Proxy Settings
|
||||
|
||||
For corporate environments:
|
||||
|
||||
```bash
|
||||
HTTP_PROXY=http://proxy.company.com:8080
|
||||
HTTPS_PROXY=http://proxy.company.com:8080
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [User Guide](../user-guide/introduction.md) - Start using Routstr
|
||||
- [Admin Dashboard](../user-guide/admin-dashboard.md) - Manage your node
|
||||
- [Custom Pricing](../advanced/custom-pricing.md) - Advanced pricing strategies
|
||||
@@ -1,337 +0,0 @@
|
||||
# Docker Setup
|
||||
|
||||
This guide covers deploying Routstr Core using Docker for production environments.
|
||||
|
||||
## Docker Images
|
||||
|
||||
Official images are available on GitHub Container Registry:
|
||||
|
||||
```bash
|
||||
ghcr.io/routstr/proxy:latest
|
||||
```
|
||||
|
||||
## Basic Docker Run
|
||||
|
||||
### Minimal Setup
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name routstr \
|
||||
-p 8000:8000 \
|
||||
-e UPSTREAM_BASE_URL=https://api.openai.com/v1 \
|
||||
-e UPSTREAM_API_KEY=sk-... \
|
||||
-e ADMIN_PASSWORD=secure-password \
|
||||
ghcr.io/routstr/proxy:latest
|
||||
```
|
||||
|
||||
### With Persistent Storage
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name routstr \
|
||||
-p 8000:8000 \
|
||||
-v routstr-data:/app/data \
|
||||
-v routstr-logs:/app/logs \
|
||||
-e UPSTREAM_BASE_URL=https://api.openai.com/v1 \
|
||||
-e UPSTREAM_API_KEY=sk-... \
|
||||
-e DATABASE_URL=sqlite+aiosqlite:///data/keys.db \
|
||||
ghcr.io/routstr/proxy:latest
|
||||
```
|
||||
|
||||
## Docker Compose Setup
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
Create `compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
routstr:
|
||||
image: ghcr.io/routstr/proxy:latest
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### With Tor Hidden Service
|
||||
|
||||
The included `compose.yml` provides Tor support:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
routstr:
|
||||
build: . # Or use image: ghcr.io/routstr/proxy:latest
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./logs:/app/logs
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TOR_PROXY_URL=socks5://tor:9050
|
||||
ports:
|
||||
- 8000:8000
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
tor:
|
||||
image: ghcr.io/hundehausen/tor-hidden-service:latest
|
||||
volumes:
|
||||
- tor-data:/var/lib/tor
|
||||
environment:
|
||||
- HS_ROUTER=routstr:8000:80
|
||||
depends_on:
|
||||
- routstr
|
||||
|
||||
volumes:
|
||||
tor-data:
|
||||
```
|
||||
|
||||
### Environment File
|
||||
|
||||
Create `.env` file:
|
||||
|
||||
```bash
|
||||
# Required
|
||||
UPSTREAM_BASE_URL=https://api.openai.com/v1
|
||||
UPSTREAM_API_KEY=your-api-key
|
||||
ADMIN_PASSWORD=secure-admin-password
|
||||
|
||||
# Cashu Configuration
|
||||
CASHU_MINTS=https://mint.minibits.cash/Bitcoin
|
||||
|
||||
# Optional
|
||||
NAME=My Routstr Node
|
||||
DESCRIPTION=Pay-per-use AI API proxy
|
||||
NPUB=npub1...
|
||||
HTTP_URL=https://api.mynode.com
|
||||
ONION_URL=http://mynode.onion
|
||||
|
||||
# Pricing (optional)
|
||||
FIXED_PRICING=false
|
||||
EXCHANGE_FEE=1.005
|
||||
UPSTREAM_PROVIDER_FEE=1.05
|
||||
```
|
||||
|
||||
## Building Custom Image
|
||||
|
||||
### Dockerfile Overview
|
||||
|
||||
The provided Dockerfile:
|
||||
|
||||
- Uses Alpine Linux for small size
|
||||
- Installs required dependencies for secp256k1
|
||||
- Runs as non-root user
|
||||
- Exposes port 8000
|
||||
|
||||
### Build Locally
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/routstr/routstr-core.git
|
||||
cd routstr-core
|
||||
|
||||
# Build image
|
||||
docker build -t my-routstr:latest .
|
||||
|
||||
# Run custom image
|
||||
docker run -d \
|
||||
--name routstr \
|
||||
-p 8000:8000 \
|
||||
--env-file .env \
|
||||
my-routstr:latest
|
||||
```
|
||||
|
||||
## Deployment Considerations
|
||||
|
||||
### Resource Requirements
|
||||
|
||||
- **CPU**: 1-2 cores recommended
|
||||
- **Memory**: 512MB-1GB
|
||||
- **Storage**: 1GB + database growth
|
||||
- **Network**: Low latency to upstream provider
|
||||
|
||||
### Health Checks
|
||||
|
||||
Add health check to compose.yml:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
routstr:
|
||||
# ... other config ...
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/v1/info"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
```
|
||||
|
||||
### Reverse Proxy Setup
|
||||
|
||||
#### Nginx Example
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
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_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# For streaming responses
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_set_header Connection '';
|
||||
proxy_http_version 1.1;
|
||||
chunked_transfer_encoding off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Caddy Example
|
||||
|
||||
```caddy
|
||||
api.yournode.com {
|
||||
reverse_proxy localhost:8000 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Log Management
|
||||
|
||||
View logs:
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker logs -f routstr
|
||||
|
||||
# Docker Compose
|
||||
docker compose logs -f routstr
|
||||
|
||||
# Log files
|
||||
tail -f ./logs/routstr.log
|
||||
```
|
||||
|
||||
### Metrics
|
||||
|
||||
Monitor key metrics:
|
||||
|
||||
- Request count and latency
|
||||
- Token validation success rate
|
||||
- Upstream API errors
|
||||
- Database size growth
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
### Database Backup
|
||||
|
||||
```bash
|
||||
# Backup SQLite database
|
||||
docker exec routstr sqlite3 /app/data/keys.db ".backup /app/data/backup.db"
|
||||
|
||||
# Copy backup locally
|
||||
docker cp routstr:/app/data/backup.db ./backup-$(date +%Y%m%d).db
|
||||
```
|
||||
|
||||
### Restore from Backup
|
||||
|
||||
```bash
|
||||
# Stop service
|
||||
docker compose down
|
||||
|
||||
# Restore database
|
||||
docker cp ./backup.db routstr:/app/data/keys.db
|
||||
|
||||
# Restart service
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- Never commit `.env` files
|
||||
- Use Docker secrets for sensitive data
|
||||
- Rotate API keys regularly
|
||||
- Use strong admin passwords
|
||||
|
||||
### Network Security
|
||||
|
||||
- Use HTTPS/TLS termination
|
||||
- Restrict admin interface access
|
||||
- Enable firewall rules
|
||||
- Monitor for suspicious activity
|
||||
|
||||
### Container Security
|
||||
|
||||
- Run as non-root user
|
||||
- Use read-only filesystem where possible
|
||||
- Limit container capabilities
|
||||
- Keep base image updated
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker logs routstr
|
||||
|
||||
# Verify environment
|
||||
docker exec routstr env | grep -E "(UPSTREAM|CASHU|ADMIN)"
|
||||
|
||||
# Test database connection
|
||||
docker exec routstr sqlite3 /app/data/keys.db ".tables"
|
||||
```
|
||||
|
||||
### Permission Issues
|
||||
|
||||
```bash
|
||||
# Fix volume permissions
|
||||
sudo chown -R 1000:1000 ./data ./logs
|
||||
```
|
||||
|
||||
### Network Issues
|
||||
|
||||
```bash
|
||||
# Test upstream connectivity
|
||||
docker exec routstr curl -I https://api.openai.com
|
||||
|
||||
# Check DNS resolution
|
||||
docker exec routstr nslookup api.openai.com
|
||||
```
|
||||
|
||||
## Production Checklist
|
||||
|
||||
- [ ] Set strong `ADMIN_PASSWORD`
|
||||
- [ ] Configure proper `UPSTREAM_BASE_URL` and `UPSTREAM_API_KEY`
|
||||
- [ ] Set up persistent volumes for data and logs
|
||||
- [ ] Configure reverse proxy with TLS
|
||||
- [ ] Set up monitoring and alerting
|
||||
- [ ] Implement backup strategy
|
||||
- [ ] Test disaster recovery
|
||||
- [ ] Document deployment process
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuration Guide](configuration.md) - All environment variables
|
||||
- [Admin Dashboard](../user-guide/admin-dashboard.md) - Manage your node
|
||||
@@ -1,156 +0,0 @@
|
||||
# Overview
|
||||
|
||||
Routstr Core is a powerful payment proxy that brings Bitcoin micropayments to AI APIs. This overview will help you understand the core concepts and architecture.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Payment Proxy
|
||||
|
||||
Routstr acts as a transparent proxy between your application and OpenAI-compatible APIs. It:
|
||||
|
||||
- Intercepts API requests
|
||||
- Validates payment tokens
|
||||
- Forwards requests to the upstream provider
|
||||
- Tracks usage and deducts costs
|
||||
- Returns responses to the client
|
||||
|
||||
### Cashu eCash Protocol
|
||||
|
||||
[Cashu](https://cashu.space) is a Bitcoin eCash protocol that enables:
|
||||
|
||||
- **Privacy**: Payments are unlinkable and untraceable
|
||||
- **Instant Settlement**: No waiting for blockchain confirmations
|
||||
- **Micropayments**: Send fractions of a satoshi
|
||||
- **Offline Capability**: Tokens can be transferred without internet
|
||||
|
||||
### Lightning Network Integration
|
||||
|
||||
Routstr connects to the Lightning Network through Cashu mints, enabling:
|
||||
|
||||
- Fast Bitcoin deposits and withdrawals
|
||||
- Global payment reach
|
||||
- Low transaction fees
|
||||
- No minimum payment amounts
|
||||
|
||||
## Architecture
|
||||
|
||||
### System Components
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Client Side"
|
||||
A[AI Application]
|
||||
B[OpenAI SDK]
|
||||
C[eCash Wallet]
|
||||
end
|
||||
|
||||
subgraph "Routstr Core"
|
||||
D[FastAPI Server]
|
||||
E[Auth Module]
|
||||
F[Payment Module]
|
||||
G[Proxy Module]
|
||||
H[SQLite Database]
|
||||
end
|
||||
|
||||
subgraph "External Services"
|
||||
I[Upstream AI Provider]
|
||||
J[Cashu Mint]
|
||||
K[Bitcoin/Lightning]
|
||||
end
|
||||
|
||||
A --> B
|
||||
B --> D
|
||||
C --> D
|
||||
D --> E
|
||||
E --> F
|
||||
F --> J
|
||||
D --> G
|
||||
G --> I
|
||||
D --> H
|
||||
J --> K
|
||||
```
|
||||
|
||||
### Key Modules
|
||||
|
||||
1. **Authentication** (`auth.py`)
|
||||
- API key validation
|
||||
- Balance checking
|
||||
- Request authorization
|
||||
|
||||
2. **Payment Processing** (`payment/`)
|
||||
- Token validation
|
||||
- Cost calculation
|
||||
- Balance updates
|
||||
- Pricing models
|
||||
|
||||
3. **Proxy Handler** (`proxy.py`)
|
||||
- Request forwarding
|
||||
- Response streaming
|
||||
- Usage tracking
|
||||
- Error handling
|
||||
|
||||
4. **Wallet Management** (`wallet.py`)
|
||||
- Cashu wallet integration
|
||||
- Token redemption
|
||||
- Balance management
|
||||
- Automatic payouts
|
||||
|
||||
5. **Admin Interface** (`core/admin.py`)
|
||||
- Web dashboard
|
||||
- Balance viewing
|
||||
- Key management
|
||||
- Withdrawal interface
|
||||
|
||||
## Payment Flow
|
||||
|
||||
### Standard Flow (API Key)
|
||||
|
||||
1. User deposits eCash tokens to create an API key
|
||||
2. Client sends requests with the API key
|
||||
3. Routstr checks balance and forwards request
|
||||
4. Cost is deducted based on actual usage
|
||||
5. Response is returned to client
|
||||
|
||||
### Per-Request Flow (Coming Soon)
|
||||
|
||||
1. Client includes eCash token in request header
|
||||
2. Routstr validates token meets minimum amount
|
||||
3. Request is processed
|
||||
4. Change is returned in response header
|
||||
5. No account or balance needed
|
||||
|
||||
## Supported Features
|
||||
|
||||
### API Compatibility
|
||||
|
||||
- ✅ Chat completions (streaming and non-streaming)
|
||||
- ✅ Text completions
|
||||
- ✅ Embeddings
|
||||
- ✅ Image generation
|
||||
- ✅ Audio transcription/translation
|
||||
- ✅ Model listing
|
||||
- ✅ Custom endpoints
|
||||
|
||||
### Payment Features
|
||||
|
||||
- ✅ Multiple Cashu mint support
|
||||
- ✅ Automatic balance tracking
|
||||
- ✅ Model-based pricing
|
||||
- ✅ USD to BTC conversion
|
||||
- ✅ Configurable fees
|
||||
- ✅ Balance withdrawals
|
||||
|
||||
### Operational Features
|
||||
|
||||
- ✅ Docker deployment
|
||||
- ✅ Tor hidden service support
|
||||
- ✅ Nostr relay discovery
|
||||
- ✅ Database migrations
|
||||
- ✅ Comprehensive logging
|
||||
- ✅ Admin dashboard
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Quick Start](quickstart.md) - Get running in minutes
|
||||
- [Docker Setup](docker.md) - Deploy with containers
|
||||
- [Configuration](configuration.md) - Customize your instance
|
||||
@@ -1,226 +0,0 @@
|
||||
# Quick Start
|
||||
|
||||
Get Routstr Core up and running in minutes with Docker or local development setup.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose (for production)
|
||||
- Python 3.11+ (for development)
|
||||
- A Cashu-compatible wallet (optional for testing)
|
||||
|
||||
## Option 1: Docker (Recommended)
|
||||
|
||||
### Quick Run
|
||||
|
||||
The fastest way to start Routstr Core:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
For a full setup with Tor support:
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/routstr/routstr-core.git
|
||||
cd routstr-core
|
||||
```
|
||||
|
||||
2. Create environment file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
3. Start the services:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This will start:
|
||||
|
||||
- Routstr proxy on port 8000
|
||||
- Tor hidden service (optional)
|
||||
- Automatic database migrations
|
||||
|
||||
### Verify Installation
|
||||
|
||||
Check that Routstr is running:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/info
|
||||
```
|
||||
|
||||
You should see:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "ARoutstrNode",
|
||||
"description": "A Routstr Node",
|
||||
"version": "0.2.0",
|
||||
"npub": "",
|
||||
"mints": ["https://mint.minibits.cash/Bitcoin"],
|
||||
"models": {...}
|
||||
}
|
||||
```
|
||||
|
||||
## Option 2: Local Development
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
1. Install [uv](https://github.com/astral-sh/uv) package manager:
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
2. Clone and setup:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/routstr/routstr-core.git
|
||||
cd routstr-core
|
||||
uv sync
|
||||
```
|
||||
|
||||
3. Configure environment:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
### Run the Server
|
||||
|
||||
```bash
|
||||
fastapi run routstr --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## First API Call
|
||||
|
||||
### 1. Get an eCash Token
|
||||
|
||||
You'll need a Cashu token to pay for API calls. Options:
|
||||
|
||||
- Use a [Cashu wallet](https://cashu.space) to create tokens
|
||||
- Get test tokens from a testnet mint
|
||||
- Use the example token (for testing only)
|
||||
|
||||
### 2. Create an API Key
|
||||
|
||||
Send your eCash token to create an API key:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/v1/wallet/create \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"cashu_token": "cashuAeyJ0b2..."
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "rUvK7...",
|
||||
"balance": 10000
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Make an API Call
|
||||
|
||||
Use your API key like a normal OpenAI key:
|
||||
|
||||
```python
|
||||
import openai
|
||||
|
||||
client = openai.OpenAI(
|
||||
api_key="rUvK7...", # Your Routstr API key
|
||||
base_url="http://localhost:8000/v1"
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "Hello!"}]
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
## Example Client
|
||||
|
||||
Run the included example:
|
||||
|
||||
```bash
|
||||
CASHU_TOKEN="your-token" python example.py
|
||||
```
|
||||
|
||||
This demonstrates:
|
||||
|
||||
- Creating an API key from a token
|
||||
- Making streaming chat requests
|
||||
- Automatic balance deduction
|
||||
|
||||
## Testing the Setup
|
||||
|
||||
### Check Available Models
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/models
|
||||
```
|
||||
|
||||
### View Admin Dashboard
|
||||
|
||||
Open <http://localhost:8000/admin/> in your browser.
|
||||
|
||||
Default password is set in `ADMIN_PASSWORD` environment variable.
|
||||
|
||||
### Monitor Logs
|
||||
|
||||
Docker:
|
||||
|
||||
```bash
|
||||
docker compose logs -f routstr
|
||||
```
|
||||
|
||||
Local:
|
||||
|
||||
```bash
|
||||
# Logs are in ./logs/ directory
|
||||
tail -f logs/routstr.log
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Connection Refused
|
||||
|
||||
- Ensure the service is running: `docker ps`
|
||||
- Check firewall settings
|
||||
- Verify port 8000 is not in use
|
||||
|
||||
### Invalid API Key
|
||||
|
||||
- Ensure you've created an API key with sufficient balance
|
||||
- Check the token was valid and had value
|
||||
- Verify the mint URL is accessible
|
||||
|
||||
### Upstream Errors
|
||||
|
||||
- Check `UPSTREAM_BASE_URL` is correct
|
||||
- Verify `UPSTREAM_API_KEY` if required
|
||||
- Test upstream service directly
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuration Guide](configuration.md) - Customize settings
|
||||
- [Docker Setup](docker.md) - Production deployment
|
||||
- [User Guide](../user-guide/introduction.md) - Detailed usage
|
||||
@@ -1,53 +0,0 @@
|
||||
# UI Configuration
|
||||
|
||||
This guide explains how to configure the Routstr UI for different environments.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The UI uses Next.js environment variables to configure API endpoints and authentication.
|
||||
|
||||
### Centralized Configuration
|
||||
|
||||
This project uses a centralized configuration approach with a single `.env` file in the project root. This file contains both backend and frontend configuration variables.
|
||||
|
||||
Create or update your `.env` file in the project root:
|
||||
|
||||
```bash
|
||||
# .env (in project root)
|
||||
|
||||
# UI Configuration (NEXT_PUBLIC_ variables are exposed to the browser)
|
||||
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
### Development vs Production
|
||||
|
||||
The same `.env` file is used for both development and production. Simply change the values:
|
||||
|
||||
**Development:**
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
**Production:**
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_API_URL=https://api.yourroutstr.com
|
||||
```
|
||||
|
||||
## Building the UI
|
||||
|
||||
The build process automatically reads configuration from the root `.env` file:
|
||||
|
||||
```bash
|
||||
# From the project root
|
||||
make ui-build
|
||||
# or
|
||||
./scripts/build-ui.sh
|
||||
```
|
||||
|
||||
The build script will automatically:
|
||||
|
||||
- Load `NEXT_PUBLIC_*` variables from the root `.env` file
|
||||
- Use them during the Next.js build process
|
||||
- Display warnings if the `.env` file is missing
|
||||
@@ -1,83 +1,44 @@
|
||||
# Routstr Core Documentation
|
||||
|
||||
Welcome to the official documentation for **Routstr Core** - a FastAPI-based reverse proxy that enables Bitcoin micropayments for OpenAI-compatible APIs using the Cashu eCash protocol.
|
||||
**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)**.
|
||||
|
||||
## What is Routstr Core?
|
||||
---
|
||||
|
||||
Routstr Core is a payment proxy that sits between API clients and OpenAI-compatible services. It enables:
|
||||
## 🐣 For Clients (Users & Builders)
|
||||
|
||||
- **Pay-per-request billing** using Bitcoin eCash tokens
|
||||
- **Seamless integration** with existing OpenAI clients
|
||||
- **Privacy-preserving payments** through the Cashu protocol
|
||||
- **Flexible pricing models** with per-token or per-request billing
|
||||
- **Multi-provider support** for various AI model providers
|
||||
If you want to use AI models in your application without accounts or KYC.
|
||||
|
||||
### Key Features
|
||||
- **[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.
|
||||
|
||||
- 🪙 **Cashu Wallet Integration** - Accept Lightning payments and redeem eCash tokens
|
||||
- 🔑 **API Key Management** - Secure key storage with balance tracking
|
||||
- 💰 **Dynamic Pricing** - Model-based pricing with live BTC/USD conversion
|
||||
- 🎛️ **Admin Dashboard** - Web interface for balance and key management
|
||||
- 🌐 **Nostr Discovery** - Find providers through decentralized relay network
|
||||
- 🐋 **Docker Support** - Easy deployment with optional Tor hidden service
|
||||
- ⚡ **Lightning Fast** - Minimal latency overhead for API requests
|
||||
## 🦁 For Providers (Node Operators)
|
||||
|
||||
## How It Works
|
||||
If you want to run a node, resell API access, or monetize hardware.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Routstr as Routstr Proxy
|
||||
participant DB as Database
|
||||
participant Upstream as AI Provider
|
||||
participant Wallet as Cashu Wallet
|
||||
- **[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.
|
||||
|
||||
Client->>Routstr: API Request + eCash Token
|
||||
Routstr->>Wallet: Validate & Redeem Token
|
||||
Wallet-->>Routstr: Token Value (sats)
|
||||
Routstr->>DB: Store/Update Balance
|
||||
Routstr->>Upstream: Forward API Request
|
||||
Upstream-->>Routstr: API Response + Usage Data
|
||||
Routstr->>DB: Deduct Actual Cost
|
||||
Routstr-->>Client: API Response
|
||||
```
|
||||
---
|
||||
|
||||
## Quick Links
|
||||
## 🔌 API Reference
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
- **[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.
|
||||
|
||||
- :rocket: **[Quick Start](getting-started/quickstart.md)**
|
||||
## 🛠️ Contributing
|
||||
|
||||
Get up and running with Docker in minutes
|
||||
- **[Architecture](contributing/architecture.md)**: System design.
|
||||
- **[Setup](contributing/setup.md)**: Development environment.
|
||||
- **[Testing](contributing/testing.md)**: Running tests.
|
||||
|
||||
- :gear: **[Configuration](getting-started/configuration.md)**
|
||||
---
|
||||
|
||||
Learn about environment variables and settings
|
||||
|
||||
- :book: **[User Guide](user-guide/introduction.md)**
|
||||
|
||||
Comprehensive guide for using Routstr
|
||||
|
||||
- :hammer: **[Contributing](contributing/setup.md)**
|
||||
|
||||
Help improve Routstr Core
|
||||
|
||||
</div>
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **AI Application Developers** - Add Bitcoin payments to your AI apps without managing infrastructure
|
||||
- **API Resellers** - Resell API access with custom pricing and profit margins
|
||||
- **Privacy-Focused Users** - Access AI models without revealing personal information
|
||||
- **Micropayment Experiments** - Test new business models with instant, small payments
|
||||
|
||||
## Getting Help
|
||||
|
||||
- 📖 Browse the [User Guide](user-guide/introduction.md) for detailed usage instructions
|
||||
- 🐛 Report issues on [GitHub](https://github.com/routstr/routstr-core/issues)
|
||||
- 💬 Join the community discussions
|
||||
- 🔧 Check the [API Reference](api/overview.md) for technical details
|
||||
|
||||
## License
|
||||
|
||||
Routstr Core is open source software licensed under the GPLv3. See the [LICENSE](https://github.com/routstr/routstr-core/blob/main/LICENSE) file for details.
|
||||
*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
|
||||
158
docs/provider/configuration.md
Normal file
158
docs/provider/configuration.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# 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 | Default |
|
||||
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| **Lightning Address** | Your LN address for withdrawals | — |
|
||||
| **Minimum Payout (sat)** | Min available balance (in sats) before profit is paid out. Applies to both `sat` and `msat` mints (auto-converted). | `210` |
|
||||
| **Payout Interval (seconds)** | How often the payout loop wakes up and checks balances | `900` |
|
||||
|
||||
All payout amounts must be positive. Set the minimums above your wallet's
|
||||
minimum-invoice constraint (typically 1 sat) and high enough to amortise
|
||||
routing fees.
|
||||
|
||||
### 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 | — |
|
||||
| `MIN_PAYOUT_SAT` | Min payout balance in sats (applies to all mints) | `210` |
|
||||
| `PAYOUT_INTERVAL_SECONDS` | Payout loop interval (seconds) | `900` |
|
||||
| `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 -->
|
||||
241
docs/provider/deployment.md
Normal file
241
docs/provider/deployment.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# Deployment
|
||||
|
||||
Production deployment guide for Routstr Provider nodes.
|
||||
|
||||
## All-in-One Docker Image (Preferred)
|
||||
|
||||
The easiest way to deploy Routstr is using the all-in-one Docker image from Docker Hub, which includes both the FastAPI backend and the Next.js admin dashboard in a single container.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name routstr \
|
||||
-p 8000:8000 \
|
||||
-v routstr-data:/app/data \
|
||||
-e DATABASE_URL="sqlite:////app/data/routstr.db" \
|
||||
9qeklajc/routstr:latest
|
||||
```
|
||||
|
||||
Access your node:
|
||||
- **API & Admin Dashboard**: http://localhost:8000
|
||||
|
||||
### Docker Compose Setup
|
||||
|
||||
Create `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
routstr:
|
||||
image: 9qeklajc/routstr:latest
|
||||
container_name: routstr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- routstr-data:/app/data
|
||||
environment:
|
||||
DATABASE_URL: "sqlite:////app/data/routstr.db"
|
||||
ADMIN_KEY: "your-secure-admin-key"
|
||||
LOG_LEVEL: "info"
|
||||
|
||||
volumes:
|
||||
routstr-data:
|
||||
```
|
||||
|
||||
Start it:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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")
|
||||
)
|
||||
```
|
||||
@@ -1,216 +0,0 @@
|
||||
# Admin Dashboard
|
||||
|
||||
The Routstr admin dashboard is a modern web interface for managing your node, monitoring wallet balances, configuring AI models and providers, and handling Bitcoin Lightning payments through Cashu eCash.
|
||||
|
||||
## Accessing the Dashboard
|
||||
|
||||
### Authentication
|
||||
|
||||
The dashboard is protected by password authentication:
|
||||
|
||||
1. Navigate to `/admin/` in your browser
|
||||
2. Enter the admin password
|
||||
3. Optional: Configure custom base URL if not pre-configured
|
||||
4. Click "Login"
|
||||
|
||||
The interface supports both environment-configured URLs and manual URL entry for deployment flexibility.
|
||||
|
||||
## Dashboard Overview
|
||||
|
||||
The main dashboard consists of four primary sections accessible through a collapsible sidebar:
|
||||
|
||||
- **Dashboard** - Wallet balance monitoring and fund management
|
||||
- **Models** - AI model management and testing
|
||||
- **Providers** - Upstream provider configuration
|
||||
- **Settings** - Node configuration and admin preferences
|
||||
|
||||
### Navigation
|
||||
|
||||
## Dashboard Page
|
||||
|
||||
### Wallet Balance Management
|
||||
|
||||
#### Balance Display Options
|
||||
|
||||
Switch between display units using the toggle buttons:
|
||||
|
||||
- **msat** - Millisatoshis (highest precision)
|
||||
- **sat** - Satoshis (standard Bitcoin unit)
|
||||
- **usd** - US Dollar equivalent (when exchange rate available)
|
||||
|
||||
#### Balance Overview
|
||||
|
||||
The dashboard displays three key metrics:
|
||||
|
||||
- **Your Balance (Total)** - Available funds for node operator
|
||||
- **Total Wallet** - Combined balance across all Cashu mints
|
||||
- **User Balance** - Funds held for API key holders
|
||||
|
||||
#### Detailed Balance Breakdown
|
||||
|
||||
View balances by mint with the following information:
|
||||
|
||||
| Column | Description |
|
||||
| ----------- | ------------------------------------- |
|
||||
| Mint / Unit | Cashu mint URL and currency unit |
|
||||
| Wallet | Total funds in this mint |
|
||||
| Users | Funds belonging to API key holders |
|
||||
| Owner | Your available funds (Wallet - Users) |
|
||||
|
||||
### Temporary Balances
|
||||
|
||||
Monitor API key activity with:
|
||||
|
||||
- **Summary Cards** - Total balance, total spent, total requests
|
||||
- **Search Functionality** - Filter by key hash or refund address
|
||||
- **Detailed Table** - Individual key balances with expiry times
|
||||
- **Auto-refresh** - Updates every 60 seconds
|
||||
|
||||
### Fund Management
|
||||
|
||||
#### Withdrawing Funds
|
||||
|
||||
To withdraw your available balance:
|
||||
|
||||
1. Click the **Withdraw** button
|
||||
2. Select which mint to withdraw from
|
||||
3. Specify the amount (or withdraw full balance)
|
||||
4. Click **Generate Token**
|
||||
5. Copy the generated eCash token
|
||||
6. Import the token into your Cashu wallet
|
||||
|
||||
#### Real-time Updates
|
||||
|
||||
- Balances refresh automatically every 30 seconds
|
||||
- Manual refresh option available
|
||||
- Live Bitcoin/USD exchange rate integration
|
||||
- Error handling for mint connectivity issues
|
||||
|
||||
## Models Management Page
|
||||
|
||||
### Model Organization
|
||||
|
||||
Models are organized by provider groups with tabs:
|
||||
|
||||
- **All Models** - Combined view of all available models
|
||||
- **Provider-specific tabs** - Individual providers (OpenRouter, Azure, etc.)
|
||||
- Badge indicators showing active/total model counts
|
||||
|
||||
### Model Management Features
|
||||
|
||||
#### Individual Model Operations
|
||||
|
||||
For each model you can:
|
||||
|
||||
- **Toggle Enable/Disable** - Control model availability
|
||||
- **View Details** - Context length, pricing, description
|
||||
- **Edit Configuration** - Model-specific settings
|
||||
- **Status Indicators** - Green badges for enabled, gray for disabled
|
||||
|
||||
#### Bulk Operations
|
||||
|
||||
- **Select All/Deselect All** - Quick selection controls
|
||||
- **Bulk Enable/Disable** - Mass model management
|
||||
- **Bulk Delete** - Remove model overrides
|
||||
- **Provider-level Actions** - Apply settings to all models in a provider
|
||||
|
||||
#### Model Information Display
|
||||
|
||||
- **Model Types** - Text, embedding, image, audio, multimodal indicators
|
||||
- **Pricing Information** - Per-million-token costs for input/output
|
||||
- **Context Length** - Maximum tokens supported
|
||||
- **API Key Status** - Whether credentials are configured
|
||||
- **Free Model Indicators** - No-cost models clearly marked
|
||||
|
||||
## Providers Management Page
|
||||
|
||||
### Upstream Provider Configuration
|
||||
|
||||
Manage AI provider connections and credentials:
|
||||
|
||||
#### Provider Types Supported
|
||||
|
||||
- **OpenRouter** - Multi-model aggregator
|
||||
- **Azure OpenAI** - Microsoft's OpenAI service
|
||||
- **OpenAI** - Direct OpenAI integration
|
||||
- **Custom Providers** - Any OpenAI-compatible API
|
||||
|
||||
#### Adding New Providers
|
||||
|
||||
1. Click **Add Provider**
|
||||
2. Select **Provider Type** from dropdown
|
||||
3. Enter **Base URL** (auto-populated for known providers)
|
||||
4. Add **API Key** for authentication
|
||||
5. Set **API Version** (required for Azure)
|
||||
6. Toggle **Enabled** status
|
||||
7. Click **Create**
|
||||
|
||||
#### Provider Management
|
||||
|
||||
**Provider Cards Display:**
|
||||
|
||||
- Provider type and status (Enabled/Disabled)
|
||||
- Base URL configuration
|
||||
- Action buttons (Models, Edit, Delete)
|
||||
|
||||
**Available Actions:**
|
||||
|
||||
- **Edit** - Modify provider configuration
|
||||
- **Delete** - Remove provider (with confirmation)
|
||||
- **View Models** - Expand model discovery interface
|
||||
- **Enable/Disable** - Toggle provider availability
|
||||
|
||||
#### Model Discovery
|
||||
|
||||
Each provider shows two types of models:
|
||||
|
||||
**Provided Models Tab:**
|
||||
|
||||
- Auto-discovered from provider's catalog
|
||||
- Read-only model information
|
||||
- Real-time availability updates
|
||||
|
||||
**Custom Models Tab:**
|
||||
|
||||
- Manually configured model overrides
|
||||
- Extend or override provider catalog
|
||||
- Individual enable/disable controls
|
||||
|
||||
## Settings Page
|
||||
|
||||
### Node Configuration
|
||||
|
||||
Configure core node settings and preferences:
|
||||
|
||||
#### Basic Information
|
||||
|
||||
- **Node Name** - Identifier for your node
|
||||
- **Node Description** - Descriptive text for your service
|
||||
- **HTTP URL** - Public HTTP endpoint
|
||||
- **Onion URL** - Tor hidden service address
|
||||
|
||||
#### Nostr Integration
|
||||
|
||||
- **Public Key (npub)** - Your Nostr public identity
|
||||
- **Private Key (nsec)** - Nostr private key with show/hide toggle
|
||||
- **Nostr Relays** - Configure relays for provider announcements
|
||||
|
||||
#### Cashu Mint Management
|
||||
|
||||
- **Add Mint URLs** - Configure multiple Cashu mint endpoints
|
||||
- **Remove Mints** - Delete unused mint configurations
|
||||
- **Mint Validation** - Verify mint endpoint connectivity
|
||||
|
||||
#### Settings Features
|
||||
|
||||
- **Real-time Save** - Changes apply immediately
|
||||
- **Validation** - Form validation with error feedback
|
||||
- **Secure Fields** - Password masking with reveal toggles
|
||||
- **Reload Functionality** - Refresh configuration from server
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Payment Flow](payment-flow.md) - Understanding Bitcoin payment processing
|
||||
- [Using the API](using-api.md) - Making API requests to your node
|
||||
- [Models & Pricing](models-pricing.md) - Configuring model pricing and fees
|
||||
- [API Reference](../api/overview.md) - Complete API documentation
|
||||
@@ -1,245 +0,0 @@
|
||||
# User Guide Introduction
|
||||
|
||||
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
|
||||
- Creating and managing API keys
|
||||
- Making API calls through Routstr
|
||||
- Using the admin dashboard
|
||||
- Managing your balance
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting, you'll need:
|
||||
|
||||
1. **A Running Routstr Instance**
|
||||
- Either your own deployment or access to a public node
|
||||
- The base URL (e.g., `https://api.routstr.com/v1`)
|
||||
|
||||
2. **A Cashu Wallet** (optional but recommended)
|
||||
- [Nutstash](https://nutstash.app) - Web wallet
|
||||
- [Minibits](https://www.minibits.cash) - Mobile wallet
|
||||
- [Cashu.me](https://cashu.me) - Simple web wallet
|
||||
|
||||
3. **An API Client**
|
||||
- OpenAI Python/JavaScript SDK
|
||||
- Any HTTP client (curl, Postman, etc.)
|
||||
- Your application code
|
||||
|
||||
## How Routstr Works
|
||||
|
||||
### Traditional API Access
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Your App] --> B[OpenAI API]
|
||||
B --> A
|
||||
```
|
||||
|
||||
- Direct connection to provider
|
||||
- Monthly billing
|
||||
- Credit card required
|
||||
- Usage limits
|
||||
|
||||
### With Routstr
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Your App] --> B[Routstr Proxy]
|
||||
B --> C[OpenAI API]
|
||||
C --> B
|
||||
B --> A
|
||||
D[Bitcoin/eCash] --> B
|
||||
```
|
||||
|
||||
- Pay per request with Bitcoin
|
||||
- No credit card needed
|
||||
- Anonymous payments
|
||||
- Instant settlement
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### eCash Tokens
|
||||
|
||||
- Digital bearer tokens backed by Bitcoin
|
||||
- Can be sent like cash - whoever has the token owns it
|
||||
- Redeemable at Cashu mints for Bitcoin
|
||||
- Perfect for micropayments
|
||||
|
||||
### API Keys
|
||||
|
||||
- Created by depositing eCash tokens
|
||||
- Track your balance and usage
|
||||
- Can be topped up anytime
|
||||
- Optional expiry and refund address
|
||||
|
||||
### Balance Management
|
||||
|
||||
- Measured in millisatoshis (msats)
|
||||
- 1 Bitcoin = 100,000,000 sats = 100,000,000,000 msats
|
||||
- Deducted based on actual usage
|
||||
- Withdrawable as eCash tokens
|
||||
|
||||
## Typical Workflow
|
||||
|
||||
### 1. Get Bitcoin/eCash
|
||||
|
||||
Options:
|
||||
|
||||
- Buy Bitcoin and deposit to a Cashu mint
|
||||
- Receive eCash tokens from someone else
|
||||
- Use a testnet mint for testing
|
||||
|
||||
### 2. Use Your eCash
|
||||
|
||||
You have two options for using your eCash tokens with Routstr:
|
||||
|
||||
#### Option A: Create a Persistent Wallet
|
||||
|
||||
Create a wallet with an API key for multiple requests:
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/create
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0..."
|
||||
}
|
||||
```
|
||||
|
||||
This returns an API key (`sk-...`) and your balance. The wallet persists between requests.
|
||||
|
||||
#### Option B: Direct Token Usage
|
||||
|
||||
Use your Cashu token directly as the API key:
|
||||
|
||||
```python
|
||||
client = OpenAI(
|
||||
api_key="cashuAeyJ0...", # Your Cashu token directly
|
||||
base_url="https://api.routstr.com/v1"
|
||||
)
|
||||
```
|
||||
|
||||
Routstr automatically converts the token to access the associated wallet. Each request consumes from the token's balance.
|
||||
|
||||
### 3. Make API Calls
|
||||
|
||||
With either method:
|
||||
|
||||
```python
|
||||
# Using persistent wallet API key
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://api.routstr.com/v1"
|
||||
)
|
||||
|
||||
# Or using Cashu token directly
|
||||
client = OpenAI(
|
||||
api_key="cashuAeyJ0...",
|
||||
base_url="https://api.routstr.com/v1"
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Monitor Usage
|
||||
|
||||
- Check balance: `GET /v1/wallet/balance`
|
||||
- View admin dashboard
|
||||
- Track costs per request
|
||||
|
||||
### 5. Withdraw Funds
|
||||
|
||||
When done, withdraw remaining balance as eCash through the admin interface.
|
||||
|
||||
## Supported Endpoints
|
||||
|
||||
Routstr supports all standard OpenAI endpoints:
|
||||
|
||||
- ✅ `/v1/chat/completions` - Chat models
|
||||
- 🚧 `/v1/completions` - Text completion (Coming soon)
|
||||
- 🚧 `/v1/embeddings` - Text embeddings (Coming soon)
|
||||
- 🚧 `/v1/images/generations` - Image generation (Coming soon)
|
||||
- 🚧 `/v1/audio/transcriptions` - Audio to text (Coming soon)
|
||||
- 🚧 `/v1/audio/translations` - Audio translation (Coming soon)
|
||||
- ✅ `/v1/models` - List available models
|
||||
- ✅ Custom provider endpoints
|
||||
|
||||
## Cost Structure
|
||||
|
||||
### Pricing Models
|
||||
|
||||
1. **Fixed Cost Per Request**
|
||||
- Simple flat fee per API call
|
||||
- Good for uniform usage
|
||||
|
||||
2. **Token-Based Pricing**
|
||||
- Pay per input/output token
|
||||
- More accurate for varied usage
|
||||
|
||||
3. **Model-Based Pricing**
|
||||
- Different rates per model
|
||||
- Reflects actual provider costs
|
||||
|
||||
### Cost Calculation
|
||||
|
||||
```
|
||||
Total Cost = Base Fee + (Input Tokens * Input Rate) + (Output Tokens * Output Rate)
|
||||
```
|
||||
|
||||
Fees may include:
|
||||
|
||||
- Exchange rate markup (BTC/USD conversion)
|
||||
- Provider margin
|
||||
- Node operator fee
|
||||
|
||||
## Getting Support
|
||||
|
||||
### Documentation
|
||||
|
||||
- This user guide for general usage
|
||||
- [API Reference](../api/overview.md) for technical details
|
||||
- [Contributing Guide](../contributing/setup.md) for developers
|
||||
|
||||
### Community
|
||||
|
||||
- GitHub Issues for bugs and features
|
||||
- Nostr for decentralized discussion
|
||||
- Node operator contact info
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Common issues and solutions:
|
||||
|
||||
- [Payment Flow](payment-flow.md) - Understanding the payment process
|
||||
- [Using the API](using-api.md) - API integration guide
|
||||
- [Admin Dashboard](admin-dashboard.md) - Managing your node
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### API Key Security
|
||||
|
||||
- Treat API keys like passwords
|
||||
- Never share or commit them
|
||||
- Rotate keys regularly
|
||||
- Use environment variables
|
||||
|
||||
### Payment Security
|
||||
|
||||
- eCash tokens are bearer instruments
|
||||
- Verify mint trustworthiness
|
||||
- Keep backups of tokens
|
||||
- Use small amounts for testing
|
||||
|
||||
### Network Security
|
||||
|
||||
- Always use HTTPS connections
|
||||
- Verify SSL certificates
|
||||
- Consider using Tor for privacy
|
||||
- Monitor for unusual activity
|
||||
|
||||
## Next Steps
|
||||
|
||||
Ready to start? Continue with:
|
||||
|
||||
1. [Payment Flow](payment-flow.md) - Detailed payment process
|
||||
2. [Using the API](using-api.md) - Making your first calls
|
||||
3. [Admin Dashboard](admin-dashboard.md) - Managing your account
|
||||
@@ -1,466 +0,0 @@
|
||||
# Models & Pricing
|
||||
|
||||
Understanding how Routstr calculates costs is essential for managing your API usage efficiently. This guide explains the pricing models and how to configure them.
|
||||
|
||||
## Pricing Models
|
||||
|
||||
Routstr supports three pricing models:
|
||||
|
||||
### 1. Fixed Pricing
|
||||
|
||||
Simple per-request charging:
|
||||
|
||||
```bash
|
||||
FIXED_PRICING=true
|
||||
FIXED_COST_PER_REQUEST=10 # 10 sats per request
|
||||
```
|
||||
|
||||
**Best for:**
|
||||
|
||||
- Uniform API usage
|
||||
- Simple applications
|
||||
- Predictable costs
|
||||
|
||||
### 2. Token-Based Pricing
|
||||
|
||||
Charge based on actual token usage:
|
||||
|
||||
```bash
|
||||
FIXED_PRICING=false # use model pricing
|
||||
FIXED_COST_PER_REQUEST=1 # optional base fee
|
||||
FIXED_PER_1K_INPUT_TOKENS=5 # optional override
|
||||
FIXED_PER_1K_OUTPUT_TOKENS=15 # optional override
|
||||
```
|
||||
|
||||
**Best for:**
|
||||
|
||||
- Varied request sizes
|
||||
- Fair usage billing
|
||||
- Cost optimization
|
||||
|
||||
### 3. Model-Based Pricing
|
||||
|
||||
Dynamic pricing based on model costs:
|
||||
|
||||
```bash
|
||||
FIXED_PRICING=false
|
||||
EXCHANGE_FEE=1.005 # 0.5% exchange fee
|
||||
UPSTREAM_PROVIDER_FEE=1.05 # 5% provider fee
|
||||
```
|
||||
|
||||
**Best for:**
|
||||
|
||||
- Multiple models
|
||||
- Market-based pricing
|
||||
- Automatic updates
|
||||
|
||||
## Model Configuration
|
||||
|
||||
### Default Models
|
||||
|
||||
Routstr includes pricing for popular models:
|
||||
|
||||
| Model | Input ($/1K) | Output ($/1K) | Context | Notes |
|
||||
|-------|--------------|---------------|---------|-------|
|
||||
| gpt-3.5-turbo | $0.0015 | $0.002 | 16K | Fast, economical |
|
||||
| gpt-4 | $0.03 | $0.06 | 8K | Advanced reasoning |
|
||||
| gpt-4-turbo | $0.01 | $0.03 | 128K | Large context |
|
||||
| claude-3-opus | $0.015 | $0.075 | 200K | Best quality |
|
||||
| claude-3-sonnet | $0.003 | $0.015 | 200K | Balanced |
|
||||
| llama-2-70b | $0.0007 | $0.0009 | 4K | Open source |
|
||||
|
||||
### Custom Models File
|
||||
|
||||
Create `models.json` to override defaults:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-4-vision",
|
||||
"name": "GPT-4 Vision",
|
||||
"pricing": {
|
||||
"prompt": "0.00003",
|
||||
"completion": "0.00006",
|
||||
"request": "0",
|
||||
"image": "0.00255"
|
||||
},
|
||||
"context_length": 128000,
|
||||
"supports_vision": true
|
||||
},
|
||||
{
|
||||
"id": "custom-model",
|
||||
"name": "My Custom Model",
|
||||
"pricing": {
|
||||
"prompt": "0.001",
|
||||
"completion": "0.002",
|
||||
"request": "0.0001"
|
||||
},
|
||||
"context_length": 8192
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-updating Models
|
||||
|
||||
Fetch latest models from OpenRouter:
|
||||
|
||||
```bash
|
||||
# Update models from API
|
||||
python scripts/models_meta.py
|
||||
|
||||
# Or manually
|
||||
curl https://openrouter.ai/api/v1/models > models.json
|
||||
```
|
||||
|
||||
## Cost Calculation
|
||||
|
||||
### Understanding the Formula
|
||||
|
||||
```
|
||||
Base Cost = (Input Tokens × Input Rate) + (Output Tokens × Output Rate) + Request Fee
|
||||
|
||||
Bitcoin Price = Current BTC/USD rate (e.g., $50,000)
|
||||
Sats Cost = (Base Cost / Bitcoin Price) × 100,000,000
|
||||
|
||||
Final Cost = Sats Cost × Exchange Fee × Provider Fee
|
||||
```
|
||||
|
||||
### Example Calculations
|
||||
|
||||
**Example 1: Simple Chat (gpt-3.5-turbo)**
|
||||
|
||||
```
|
||||
Input: 50 tokens
|
||||
Output: 150 tokens
|
||||
Model rates: $0.0015/1K input, $0.002/1K output
|
||||
|
||||
USD Cost = (50/1000 × 0.0015) + (150/1000 × 0.002)
|
||||
= $0.000075 + $0.0003
|
||||
= $0.000375
|
||||
|
||||
At $50,000/BTC: 0.75 sats
|
||||
With 5.5% total fees: 0.79 sats
|
||||
```
|
||||
|
||||
**Example 2: Large Context (gpt-4)**
|
||||
|
||||
```
|
||||
Input: 2,000 tokens
|
||||
Output: 500 tokens
|
||||
Model rates: $0.03/1K input, $0.06/1K output
|
||||
|
||||
USD Cost = (2000/1000 × 0.03) + (500/1000 × 0.06)
|
||||
= $0.06 + $0.03
|
||||
= $0.09
|
||||
|
||||
At $50,000/BTC: 180 sats
|
||||
With 5.5% total fees: 190 sats
|
||||
```
|
||||
|
||||
**Example 3: Image Generation (dall-e-3)**
|
||||
|
||||
```
|
||||
Model: dall-e-3
|
||||
Size: 1024x1024
|
||||
Quality: standard
|
||||
Cost: $0.04 per image
|
||||
|
||||
At $50,000/BTC: 80 sats
|
||||
With 5.5% fees: 84 sats
|
||||
```
|
||||
|
||||
## Fee Structure
|
||||
|
||||
### Exchange Fee
|
||||
|
||||
Covers Bitcoin/USD conversion costs:
|
||||
|
||||
```bash
|
||||
EXCHANGE_FEE=1.005 # 0.5% default
|
||||
```
|
||||
|
||||
Factors:
|
||||
|
||||
- Exchange rate volatility
|
||||
- Conversion costs
|
||||
- Price update frequency
|
||||
|
||||
### Provider Fee
|
||||
|
||||
Node operator's margin:
|
||||
|
||||
```bash
|
||||
UPSTREAM_PROVIDER_FEE=1.05 # 5% default
|
||||
```
|
||||
|
||||
Covers:
|
||||
|
||||
- Infrastructure costs
|
||||
- Maintenance
|
||||
- Support
|
||||
- Profit margin
|
||||
|
||||
### Calculating Total Fees
|
||||
|
||||
```
|
||||
Total Multiplier = EXCHANGE_FEE × UPSTREAM_PROVIDER_FEE
|
||||
Example: 1.005 × 1.05 = 1.05525 (5.525% total)
|
||||
```
|
||||
|
||||
## Special Pricing
|
||||
|
||||
### Image Models
|
||||
|
||||
Image generation uses per-image pricing:
|
||||
|
||||
| Model | Size | Quality | Price |
|
||||
|-------|------|---------|-------|
|
||||
| dall-e-2 | 256x256 | - | $0.016 |
|
||||
| dall-e-2 | 512x512 | - | $0.018 |
|
||||
| dall-e-2 | 1024x1024 | - | $0.02 |
|
||||
| dall-e-3 | 1024x1024 | standard | $0.04 |
|
||||
| dall-e-3 | 1024x1024 | hd | $0.08 |
|
||||
| dall-e-3 | 1024x1792 | standard | $0.08 |
|
||||
| dall-e-3 | 1024x1792 | hd | $0.12 |
|
||||
|
||||
### Audio Models
|
||||
|
||||
Audio pricing by duration:
|
||||
|
||||
| Model | Type | Price |
|
||||
|-------|------|-------|
|
||||
| whisper-1 | Transcription | $0.006/minute |
|
||||
| whisper-1 | Translation | $0.006/minute |
|
||||
| tts-1 | Text-to-speech | $0.015/1K chars |
|
||||
| tts-1-hd | HD speech | $0.03/1K chars |
|
||||
|
||||
### Embedding Models
|
||||
|
||||
Lower costs for embeddings:
|
||||
|
||||
| Model | Price/1K tokens |
|
||||
|-------|-----------------|
|
||||
| text-embedding-3-small | $0.00002 |
|
||||
| text-embedding-3-large | $0.00013 |
|
||||
| text-embedding-ada-002 | $0.0001 |
|
||||
|
||||
## Monitoring Costs
|
||||
|
||||
### Per-Request Tracking
|
||||
|
||||
Each API response includes usage data:
|
||||
|
||||
```json
|
||||
{
|
||||
"usage": {
|
||||
"prompt_tokens": 50,
|
||||
"completion_tokens": 150,
|
||||
"total_tokens": 200
|
||||
},
|
||||
"x-routstr-cost": {
|
||||
"sats": 79,
|
||||
"usd": 0.000375,
|
||||
"breakdown": {
|
||||
"prompt_cost": 15,
|
||||
"completion_cost": 60,
|
||||
"fees": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Daily Summaries
|
||||
|
||||
View in admin dashboard:
|
||||
|
||||
- Total requests
|
||||
- Token usage by model
|
||||
- Cost distribution
|
||||
- Trending patterns
|
||||
|
||||
### Cost Alerts
|
||||
|
||||
Set up notifications:
|
||||
|
||||
```python
|
||||
# Example monitoring script
|
||||
def check_daily_spend(api_key):
|
||||
balance_start = get_balance(api_key, "00:00")
|
||||
balance_now = get_balance(api_key)
|
||||
spent = balance_start - balance_now
|
||||
|
||||
if spent > DAILY_LIMIT:
|
||||
send_alert(f"Daily spend exceeded: {spent} sats")
|
||||
```
|
||||
|
||||
## Optimization Strategies
|
||||
|
||||
### Model Selection
|
||||
|
||||
Choose the right model for each task:
|
||||
|
||||
| Task | Recommended Model | Why |
|
||||
|------|-------------------|-----|
|
||||
| Simple Q&A | gpt-3.5-turbo | Fast, cheap, sufficient |
|
||||
| Code generation | gpt-4 | Better reasoning |
|
||||
| Summarization | claude-3-haiku | Good balance |
|
||||
| Creative writing | claude-3-opus | Best quality |
|
||||
| Embeddings | text-embedding-3-small | Optimized for vectors |
|
||||
|
||||
### Prompt Engineering
|
||||
|
||||
Reduce costs with efficient prompts:
|
||||
|
||||
```python
|
||||
# Expensive
|
||||
prompt = """
|
||||
You are an AI assistant. Your task is to help users.
|
||||
Please provide detailed, comprehensive answers.
|
||||
Now, answer this question: What is 2+2?
|
||||
"""
|
||||
|
||||
# Economical
|
||||
prompt = "Calculate: 2+2"
|
||||
```
|
||||
|
||||
### Caching Strategies
|
||||
|
||||
Implement smart caching:
|
||||
|
||||
```python
|
||||
# Cache embedding results
|
||||
@lru_cache(maxsize=1000)
|
||||
def get_embedding(text):
|
||||
return client.embeddings.create(
|
||||
model="text-embedding-3-small",
|
||||
input=text
|
||||
)
|
||||
|
||||
# Cache common responses
|
||||
COMMON_RESPONSES = {
|
||||
"greeting": "Hello! How can I help you?",
|
||||
"goodbye": "Goodbye! Have a great day!"
|
||||
}
|
||||
```
|
||||
|
||||
### Batch Processing
|
||||
|
||||
Process multiple items efficiently:
|
||||
|
||||
```python
|
||||
# Instead of multiple calls
|
||||
for item in items:
|
||||
response = client.chat.completions.create(...)
|
||||
|
||||
# Use single call with formatted prompt
|
||||
prompt = "\n".join([f"{i+1}. {item}" for i, item in enumerate(items)])
|
||||
response = client.chat.completions.create(
|
||||
messages=[{"role": "user", "content": f"Process these items:\n{prompt}"}]
|
||||
)
|
||||
```
|
||||
|
||||
## Custom Pricing Rules
|
||||
|
||||
### Time-Based Pricing
|
||||
|
||||
Implement off-peak discounts:
|
||||
|
||||
```python
|
||||
def calculate_multiplier():
|
||||
hour = datetime.now().hour
|
||||
if 2 <= hour <= 6: # 2 AM - 6 AM
|
||||
return 0.8 # 20% discount
|
||||
elif 18 <= hour <= 22: # 6 PM - 10 PM
|
||||
return 1.2 # 20% premium
|
||||
return 1.0
|
||||
```
|
||||
|
||||
### Model-Specific Rules
|
||||
|
||||
Custom pricing logic:
|
||||
|
||||
```python
|
||||
def adjust_model_price(model, base_price):
|
||||
# Premium for latest models
|
||||
if "turbo" in model or "latest" in model:
|
||||
return base_price * 1.1
|
||||
|
||||
# Discount for older models
|
||||
if "legacy" in model:
|
||||
return base_price * 0.8
|
||||
|
||||
return base_price
|
||||
```
|
||||
|
||||
## Pricing Transparency
|
||||
|
||||
### Public Pricing Page
|
||||
|
||||
Display current rates:
|
||||
|
||||
```html
|
||||
<!-- Available at /pricing -->
|
||||
<table>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th>Input (sats/1K)</th>
|
||||
<th>Output (sats/1K)</th>
|
||||
</tr>
|
||||
<!-- Dynamically generated from models.json -->
|
||||
</table>
|
||||
```
|
||||
|
||||
### Cost Estimation API
|
||||
|
||||
Provide cost estimates:
|
||||
|
||||
```bash
|
||||
POST /v1/estimate
|
||||
{
|
||||
"model": "gpt-4",
|
||||
"prompt_tokens": 500,
|
||||
"max_tokens": 200
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"estimated_cost_sats": 45,
|
||||
"breakdown": {
|
||||
"prompt": 30,
|
||||
"completion": 12,
|
||||
"fees": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pricing Mismatches
|
||||
|
||||
**Issue**: Costs don't match expectations
|
||||
|
||||
- Check current BTC/USD rate
|
||||
- Verify fee settings
|
||||
- Review model configuration
|
||||
|
||||
**Issue**: Models not found
|
||||
|
||||
- Update models.json
|
||||
- Check model ID spelling
|
||||
- Verify upstream support
|
||||
|
||||
### Fee Calculations
|
||||
|
||||
**Issue**: Fees seem too high
|
||||
|
||||
- Review EXCHANGE_FEE setting
|
||||
- Check UPSTREAM_PROVIDER_FEE
|
||||
- Calculate total multiplier
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [API Reference](../api/overview.md) - Technical details
|
||||
- [Custom Pricing](../advanced/custom-pricing.md) - Advanced configuration
|
||||
- [Contributing](../contributing/setup.md) - Help improve Routstr
|
||||
@@ -1,373 +0,0 @@
|
||||
# Payment Flow
|
||||
|
||||
Understanding how payments work in Routstr is key to using the system effectively. This guide explains the payment process in detail.
|
||||
|
||||
## Overview
|
||||
|
||||
Routstr uses a pre-funded account model where:
|
||||
|
||||
1. Users deposit eCash tokens to create an API key
|
||||
2. Each API request deducts from the balance
|
||||
3. Users can withdraw remaining balance as eCash
|
||||
|
||||
## Creating an API Key
|
||||
|
||||
### Step 1: Obtain eCash Token
|
||||
|
||||
Get a Cashu token from any compatible source:
|
||||
|
||||
**Option A: From a Cashu Wallet**
|
||||
|
||||
```bash
|
||||
# Example: Creating a 10,000 sat token
|
||||
cashu send 10000
|
||||
```
|
||||
|
||||
**Option B: Lightning Invoice**
|
||||
|
||||
```bash
|
||||
# Some mints support direct Lightning deposits
|
||||
curl -X POST https://mint.example.com/v1/mint/quote/bolt11 \
|
||||
-d '{"amount": 10000, "unit": "sat"}'
|
||||
```
|
||||
|
||||
**Option C: Test Tokens**
|
||||
|
||||
```bash
|
||||
# Get test tokens from testnet mints
|
||||
# Check mint documentation for faucets
|
||||
```
|
||||
|
||||
### Step 2: Create API Key
|
||||
|
||||
**Note: The POST /v1/wallet/create endpoint is coming soon. Currently, you can use Cashu tokens directly as API keys in the Authorization header.**
|
||||
|
||||
Send your token to Routstr:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.routstr.com/v1/wallet/create \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"cashu_token": "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vbWlu..."
|
||||
}'
|
||||
```
|
||||
|
||||
**Request Parameters:**
|
||||
|
||||
- `cashu_token` (required): The eCash token to deposit
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-1234567890abcdef",
|
||||
"balance": 10000000,
|
||||
"created_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Verify Balance
|
||||
|
||||
Check your key's balance:
|
||||
|
||||
```bash
|
||||
curl -X GET https://api.routstr.com/v1/wallet/balance \
|
||||
-H "Authorization: Bearer sk-1234567890abcdef"
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"balance": 10000000,
|
||||
"total_deposited": 10000000,
|
||||
"total_spent": 0,
|
||||
"last_used": null
|
||||
}
|
||||
```
|
||||
|
||||
## Making API Requests
|
||||
|
||||
### Cost Calculation
|
||||
|
||||
Costs are calculated based on:
|
||||
|
||||
1. **Request Type**
|
||||
- Chat completions
|
||||
- Embeddings
|
||||
- Image generation
|
||||
- Audio processing
|
||||
|
||||
2. **Token Usage**
|
||||
- Input tokens (prompt)
|
||||
- Output tokens (response)
|
||||
- Model-specific rates
|
||||
|
||||
3. **Additional Costs**
|
||||
- Base request fee
|
||||
- Image generation fees
|
||||
- Audio processing time
|
||||
|
||||
### Example: Chat Completion
|
||||
|
||||
```python
|
||||
import openai
|
||||
|
||||
client = openai.OpenAI(
|
||||
api_key="sk-1234567890abcdef",
|
||||
base_url="https://api.routstr.com/v1"
|
||||
)
|
||||
|
||||
# Make request
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[
|
||||
{"role": "user", "content": "Hello, how are you?"}
|
||||
]
|
||||
)
|
||||
|
||||
# Check usage
|
||||
print(f"Input tokens: {response.usage.prompt_tokens}")
|
||||
print(f"Output tokens: {response.usage.completion_tokens}")
|
||||
print(f"Total tokens: {response.usage.total_tokens}")
|
||||
```
|
||||
|
||||
### Cost Breakdown
|
||||
|
||||
For the above request:
|
||||
|
||||
```
|
||||
Model: gpt-3.5-turbo
|
||||
Input tokens: 13
|
||||
Output tokens: 27
|
||||
Model rates: $0.0015/1K input, $0.002/1K output
|
||||
|
||||
USD Cost = (13/1000 * 0.0015) + (27/1000 * 0.002) = $0.0000735
|
||||
BTC/USD Rate: $50,000
|
||||
BTC Cost = 0.0000735 / 50000 = 0.00000000147 BTC = 147 sats
|
||||
With fees (5%): 154 sats
|
||||
|
||||
Final cost: 154 sats
|
||||
```
|
||||
|
||||
## Balance Management
|
||||
|
||||
### Monitoring Usage
|
||||
|
||||
Track your usage in real-time:
|
||||
|
||||
```bash
|
||||
# Get current balance
|
||||
curl -X GET https://api.routstr.com/v1/wallet/balance \
|
||||
-H "Authorization: Bearer your-api-key"
|
||||
|
||||
# View recent transactions (through admin dashboard)
|
||||
# Access at https://api.routstr.com/admin/
|
||||
```
|
||||
|
||||
### Low Balance Handling
|
||||
|
||||
When balance is insufficient:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "insufficient_balance",
|
||||
"message": "Insufficient balance. Current: 100 sats, Required: 154 sats",
|
||||
"code": "payment_required"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Topping Up
|
||||
|
||||
Add funds to existing key:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.routstr.com/v1/wallet/topup \
|
||||
-H "Authorization: Bearer your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"cashu_token": "cashuAeyJ0b2..."
|
||||
}'
|
||||
```
|
||||
|
||||
## Withdrawing Balance
|
||||
|
||||
### Via Admin Dashboard
|
||||
|
||||
1. Navigate to `/admin/`
|
||||
2. Enter admin password
|
||||
3. Find your API key
|
||||
4. Click "Withdraw"
|
||||
5. Receive eCash token
|
||||
|
||||
### Via API (if enabled)
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.routstr.com/v1/wallet/withdraw \
|
||||
-H "Authorization: Bearer your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"amount": 5000,
|
||||
"mint": "https://mint.minibits.cash/Bitcoin"
|
||||
}'
|
||||
```
|
||||
|
||||
## Payment Security
|
||||
|
||||
### Token Validation
|
||||
|
||||
Routstr validates tokens by:
|
||||
|
||||
1. Checking signature validity
|
||||
2. Verifying with the issuing mint
|
||||
3. Ensuring no double-spending
|
||||
4. Confirming sufficient value
|
||||
|
||||
### Failed Payments
|
||||
|
||||
Common failure reasons:
|
||||
|
||||
- Invalid token signature
|
||||
- Already spent token
|
||||
- Untrusted mint
|
||||
- Network issues with mint
|
||||
|
||||
### Refund Policy
|
||||
|
||||
- Unused balance can be withdrawn anytime
|
||||
- Expired keys with balance can be refunded
|
||||
- Node operators may have additional policies
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Multi-Mint Support
|
||||
|
||||
Routstr accepts tokens from multiple mints:
|
||||
|
||||
```bash
|
||||
CASHU_MINTS=https://mint1.com,https://mint2.com,https://mint3.com
|
||||
```
|
||||
|
||||
Benefits:
|
||||
|
||||
- Redundancy if one mint is down
|
||||
- User choice of mints
|
||||
- Geographic distribution
|
||||
|
||||
### Automatic Payouts
|
||||
|
||||
Configure automatic Lightning payouts:
|
||||
|
||||
```bash
|
||||
RECEIVE_LN_ADDRESS=satoshi@getalby.com
|
||||
```
|
||||
|
||||
When enabled:
|
||||
|
||||
- Balances above threshold are swept
|
||||
- Converted to Lightning payments
|
||||
- Sent to configured address
|
||||
|
||||
### Per-Request Payments (Coming Soon)
|
||||
|
||||
Future support for Nut-24 headers:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.routstr.com/v1/chat/completions \
|
||||
-H "x-cashu: cashuAeyJ0..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{...}'
|
||||
```
|
||||
|
||||
Response includes change:
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
x-cashu: cashuAeyJjaGFuZ2Ui...
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### API Key Management
|
||||
|
||||
1. **Separate Keys per Application**
|
||||
- Easier tracking
|
||||
- Better security
|
||||
- Independent budgets
|
||||
|
||||
2. **Set Expiration Dates**
|
||||
- Automatic cleanup
|
||||
- Security improvement
|
||||
- Budget control
|
||||
|
||||
3. **Monitor Balances**
|
||||
- Set up alerts
|
||||
- Regular checks
|
||||
- Usage analytics
|
||||
|
||||
### Cost Optimization
|
||||
|
||||
1. **Choose Appropriate Models**
|
||||
- Smaller models for simple tasks
|
||||
- Larger models only when needed
|
||||
|
||||
2. **Optimize Prompts**
|
||||
- Concise, clear instructions
|
||||
- Avoid unnecessary tokens
|
||||
|
||||
3. **Use Streaming**
|
||||
- Early termination possible
|
||||
- Better user experience
|
||||
|
||||
### Security
|
||||
|
||||
1. **Secure Storage**
|
||||
- Environment variables
|
||||
- Secrets management
|
||||
- Never in code
|
||||
|
||||
2. **Network Security**
|
||||
- Always use HTTPS
|
||||
- Verify certificates
|
||||
- Consider Tor for privacy
|
||||
|
||||
3. **Regular Rotation**
|
||||
- Change keys periodically
|
||||
- Withdraw unused funds
|
||||
- Audit usage logs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Payment Rejected
|
||||
|
||||
**Error:** "Invalid token"
|
||||
|
||||
- Check token format
|
||||
- Verify mint is trusted
|
||||
- Ensure not already spent
|
||||
|
||||
**Error:** "Insufficient value"
|
||||
|
||||
- Token value too low
|
||||
- Check current pricing
|
||||
- Add larger token
|
||||
|
||||
### Balance Discrepancies
|
||||
|
||||
- Allow for price fluctuations
|
||||
- Check model pricing updates
|
||||
- Review transaction history
|
||||
|
||||
### Mint Issues
|
||||
|
||||
- Try different mint from list
|
||||
- Check mint status
|
||||
- Contact mint operator
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Using the API](using-api.md) - Integration guide
|
||||
- [Admin Dashboard](admin-dashboard.md) - Account management
|
||||
- [Models & Pricing](models-pricing.md) - Cost details
|
||||
@@ -1,545 +0,0 @@
|
||||
# Using the API
|
||||
|
||||
This guide shows how to integrate Routstr with your applications using various programming languages and tools.
|
||||
|
||||
## API Compatibility
|
||||
|
||||
Routstr maintains full compatibility with the OpenAI API, meaning:
|
||||
|
||||
- Existing OpenAI client libraries work without modification
|
||||
- Only the base URL and API key need to change
|
||||
- All parameters and responses match OpenAI's format
|
||||
|
||||
## Basic Setup
|
||||
|
||||
### Python
|
||||
|
||||
Using the official OpenAI Python library:
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
# Initialize client with Routstr endpoint
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://api.routstr.com/v1"
|
||||
)
|
||||
|
||||
# Use exactly like OpenAI
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello!"}
|
||||
]
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
Using the official OpenAI Node.js library:
|
||||
|
||||
```javascript
|
||||
import OpenAI from 'openai';
|
||||
|
||||
// Initialize client
|
||||
const openai = new OpenAI({
|
||||
apiKey: 'sk-...',
|
||||
baseURL: 'https://api.routstr.com/v1'
|
||||
});
|
||||
|
||||
// Make a request
|
||||
async function main() {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-3.5-turbo',
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are a helpful assistant.' },
|
||||
{ role: 'user', content: 'Hello!' }
|
||||
]
|
||||
});
|
||||
|
||||
console.log(completion.choices[0].message.content);
|
||||
}
|
||||
|
||||
main();
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
Direct HTTP requests:
|
||||
|
||||
```bash
|
||||
curl https://api.routstr.com/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer sk-..." \
|
||||
-d '{
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello!"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Chat Completions
|
||||
|
||||
Standard chat with conversation history:
|
||||
|
||||
```python
|
||||
messages = []
|
||||
|
||||
def chat(user_input):
|
||||
# Add user message
|
||||
messages.append({"role": "user", "content": user_input})
|
||||
|
||||
# Get AI response
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=messages,
|
||||
temperature=0.7,
|
||||
max_tokens=150
|
||||
)
|
||||
|
||||
# Add AI response to history
|
||||
ai_message = response.choices[0].message
|
||||
messages.append({"role": "assistant", "content": ai_message.content})
|
||||
|
||||
return ai_message.content
|
||||
|
||||
# Usage
|
||||
print(chat("What's the weather like?"))
|
||||
print(chat("How should I dress?")) # Maintains context
|
||||
```
|
||||
|
||||
### Streaming Responses
|
||||
|
||||
For real-time output:
|
||||
|
||||
```python
|
||||
stream = client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Write a short story"}],
|
||||
stream=True
|
||||
)
|
||||
|
||||
for chunk in stream:
|
||||
if chunk.choices[0].delta.content is not None:
|
||||
print(chunk.choices[0].delta.content, end="", flush=True)
|
||||
```
|
||||
|
||||
### Function Calling
|
||||
|
||||
Using OpenAI's function calling feature:
|
||||
|
||||
```python
|
||||
tools = [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get current weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string"},
|
||||
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
|
||||
},
|
||||
"required": ["location"]
|
||||
}
|
||||
}
|
||||
}]
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": "What's the weather in Tokyo?"}],
|
||||
tools=tools,
|
||||
tool_choice="auto"
|
||||
)
|
||||
|
||||
# Check if function was called
|
||||
if response.choices[0].message.tool_calls:
|
||||
tool_call = response.choices[0].message.tool_calls[0]
|
||||
print(f"Function: {tool_call.function.name}")
|
||||
print(f"Arguments: {tool_call.function.arguments}")
|
||||
```
|
||||
|
||||
### Embeddings
|
||||
|
||||
Generate text embeddings:
|
||||
|
||||
```python
|
||||
response = client.embeddings.create(
|
||||
model="text-embedding-3-small",
|
||||
input="The quick brown fox jumps over the lazy dog"
|
||||
)
|
||||
|
||||
embedding = response.data[0].embedding
|
||||
print(f"Embedding dimension: {len(embedding)}")
|
||||
```
|
||||
|
||||
### Image Generation
|
||||
|
||||
Create images with DALL-E:
|
||||
|
||||
```python
|
||||
response = client.images.generate(
|
||||
model="dall-e-3",
|
||||
prompt="A futuristic city with flying cars",
|
||||
size="1024x1024",
|
||||
quality="standard",
|
||||
n=1
|
||||
)
|
||||
|
||||
image_url = response.data[0].url
|
||||
print(f"Image URL: {image_url}")
|
||||
```
|
||||
|
||||
### Audio Transcription
|
||||
|
||||
Convert speech to text:
|
||||
|
||||
```python
|
||||
with open("audio.mp3", "rb") as audio_file:
|
||||
response = client.audio.transcriptions.create(
|
||||
model="whisper-1",
|
||||
file=audio_file,
|
||||
response_format="text"
|
||||
)
|
||||
|
||||
print(response.text)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Balance Errors
|
||||
|
||||
Handle insufficient balance gracefully:
|
||||
|
||||
```python
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": "Hello"}]
|
||||
)
|
||||
except Exception as e:
|
||||
if "insufficient_balance" in str(e):
|
||||
print("Low balance! Please top up your API key.")
|
||||
# Implement top-up logic
|
||||
else:
|
||||
raise
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Implement exponential backoff:
|
||||
|
||||
```python
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
def make_request_with_retry(
|
||||
func,
|
||||
max_retries: int = 3,
|
||||
initial_delay: float = 1.0
|
||||
) -> Optional[any]:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return func()
|
||||
except Exception as e:
|
||||
if "rate_limit" in str(e) and attempt < max_retries - 1:
|
||||
delay = initial_delay * (2 ** attempt)
|
||||
print(f"Rate limited. Waiting {delay}s...")
|
||||
time.sleep(delay)
|
||||
else:
|
||||
raise
|
||||
return None
|
||||
```
|
||||
|
||||
### Connection Errors
|
||||
|
||||
Handle network issues:
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
# Configure timeout and retries
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://your-node.com/v1",
|
||||
timeout=httpx.Timeout(60.0, connect=5.0),
|
||||
max_retries=2
|
||||
)
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Using Tor
|
||||
|
||||
Route requests through Tor for privacy:
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
# Configure Tor proxy
|
||||
proxies = {
|
||||
"http://": "socks5://127.0.0.1:9050",
|
||||
"https://": "socks5://127.0.0.1:9050"
|
||||
}
|
||||
|
||||
http_client = httpx.Client(proxies=proxies)
|
||||
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="http://your-onion-address.onion/v1",
|
||||
http_client=http_client
|
||||
)
|
||||
```
|
||||
|
||||
### Custom Headers
|
||||
|
||||
Add custom headers if needed:
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
class CustomClient(httpx.Client):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.headers["X-Custom-Header"] = "value"
|
||||
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://your-node.com/v1",
|
||||
http_client=CustomClient()
|
||||
)
|
||||
```
|
||||
|
||||
### Azure OpenAI compatibility
|
||||
|
||||
To use Azure OpenAI through Routstr with minimal changes:
|
||||
|
||||
- Set `UPSTREAM_BASE_URL` to your Azure deployments URL, for example: `https://<resource>.openai.azure.com/openai/deployments/<deployment>`
|
||||
- Set `CHAT_COMPLETIONS_API_VERSION=2024-05-01-preview`
|
||||
|
||||
When this env var is set, Routstr automatically appends `api-version=2024-05-01-preview` to all upstream `/chat/completions` requests.
|
||||
|
||||
### Async Operations
|
||||
|
||||
For high-performance applications:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
async_client = AsyncOpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://your-node.com/v1"
|
||||
)
|
||||
|
||||
async def process_messages(messages):
|
||||
tasks = []
|
||||
for msg in messages:
|
||||
task = async_client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": msg}]
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
responses = await asyncio.gather(*tasks)
|
||||
return [r.choices[0].message.content for r in responses]
|
||||
|
||||
# Run async
|
||||
messages = ["Hello", "How are you?", "What's 2+2?"]
|
||||
results = asyncio.run(process_messages(messages))
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Environment Variables
|
||||
|
||||
Never hardcode API keys:
|
||||
|
||||
```python
|
||||
import os
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.getenv("ROUTSTR_API_KEY"),
|
||||
base_url=os.getenv("ROUTSTR_BASE_URL", "https://api.routstr.com/v1")
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Error Logging
|
||||
|
||||
Implement comprehensive logging:
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(...)
|
||||
logger.info(f"Request successful. Tokens used: {response.usage.total_tokens}")
|
||||
except Exception as e:
|
||||
logger.error(f"API request failed: {e}")
|
||||
raise
|
||||
```
|
||||
|
||||
### 3. Cost Tracking
|
||||
|
||||
Monitor your usage:
|
||||
|
||||
```python
|
||||
class UsageTracker:
|
||||
def __init__(self):
|
||||
self.total_tokens = 0
|
||||
self.total_requests = 0
|
||||
|
||||
def track(self, response):
|
||||
self.total_tokens += response.usage.total_tokens
|
||||
self.total_requests += 1
|
||||
|
||||
# Estimate cost (example rates)
|
||||
cost_per_1k = 0.002 # $0.002 per 1K tokens
|
||||
estimated_cost = (self.total_tokens / 1000) * cost_per_1k
|
||||
|
||||
logger.info(f"Total usage: {self.total_tokens} tokens, "
|
||||
f"${estimated_cost:.4f} (~{estimated_cost * 50000:.0f} sats)")
|
||||
|
||||
tracker = UsageTracker()
|
||||
response = client.chat.completions.create(...)
|
||||
tracker.track(response)
|
||||
```
|
||||
|
||||
### 4. Caching Responses
|
||||
|
||||
Reduce costs with intelligent caching:
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
import json
|
||||
from functools import lru_cache
|
||||
|
||||
@lru_cache(maxsize=100)
|
||||
def cached_completion(prompt: str, model: str = "gpt-3.5-turbo"):
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0 # Deterministic for caching
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
|
||||
# Repeated calls with same prompt use cache
|
||||
result1 = cached_completion("What is 2+2?")
|
||||
result2 = cached_completion("What is 2+2?") # From cache, no API call
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Mock Responses
|
||||
|
||||
For development without spending sats:
|
||||
|
||||
```python
|
||||
class MockOpenAI:
|
||||
class Completions:
|
||||
def create(self, **kwargs):
|
||||
return type('Response', (), {
|
||||
'choices': [type('Choice', (), {
|
||||
'message': type('Message', (), {
|
||||
'content': 'Mock response'
|
||||
})()
|
||||
})],
|
||||
'usage': type('Usage', (), {
|
||||
'total_tokens': 10
|
||||
})()
|
||||
})()
|
||||
|
||||
def __init__(self):
|
||||
self.chat = type('Chat', (), {
|
||||
'completions': self.Completions()
|
||||
})()
|
||||
|
||||
# Use mock in tests
|
||||
if os.getenv('TESTING'):
|
||||
client = MockOpenAI()
|
||||
else:
|
||||
client = OpenAI(...)
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Test your Routstr integration:
|
||||
|
||||
```python
|
||||
def test_routstr_connection():
|
||||
try:
|
||||
# Test models endpoint
|
||||
models = client.models.list()
|
||||
assert len(models.data) > 0
|
||||
|
||||
# Test simple completion
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
max_tokens=5
|
||||
)
|
||||
assert response.choices[0].message.content
|
||||
|
||||
print("✅ Routstr integration working!")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Integration test failed: {e}")
|
||||
return False
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**SSL Certificate Errors**
|
||||
|
||||
```python
|
||||
# For development only - not for production!
|
||||
import ssl
|
||||
import httpx
|
||||
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://localhost:8000/v1",
|
||||
http_client=httpx.Client(verify=False)
|
||||
)
|
||||
```
|
||||
|
||||
**Timeout Issues**
|
||||
|
||||
```python
|
||||
# Increase timeout for slow connections
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://your-node.com/v1",
|
||||
timeout=httpx.Timeout(120.0) # 2 minutes
|
||||
)
|
||||
```
|
||||
|
||||
**Debugging Requests**
|
||||
|
||||
```python
|
||||
import logging
|
||||
import httpx
|
||||
|
||||
# Enable debug logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
httpx_logger = logging.getLogger("httpx")
|
||||
httpx_logger.setLevel(logging.DEBUG)
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Admin Dashboard](admin-dashboard.md) - Manage your account
|
||||
- [Models & Pricing](models-pricing.md) - Understanding costs
|
||||
- [API Reference](../api/overview.md) - Technical details
|
||||
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")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Add balance_limit, balance_limit_reset, validity_date to lightning_invoices
|
||||
|
||||
Revision ID: a2b3c4d5e6f7
|
||||
Revises: f1a2b3c4d5e6
|
||||
Create Date: 2026-06-03 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "a2b3c4d5e6f7"
|
||||
down_revision = "f1a2b3c4d5e6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("lightning_invoices", sa.Column("balance_limit", sa.Integer(), nullable=True))
|
||||
op.add_column("lightning_invoices", sa.Column("balance_limit_reset", sa.String(), nullable=True))
|
||||
op.add_column("lightning_invoices", sa.Column("validity_date", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("lightning_invoices", "validity_date")
|
||||
op.drop_column("lightning_invoices", "balance_limit_reset")
|
||||
op.drop_column("lightning_invoices", "balance_limit")
|
||||
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")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""add reserved_at to api_keys
|
||||
|
||||
Revision ID: b5e7c9d1f3a2
|
||||
Revises: a2b3c4d5e6f7
|
||||
Create Date: 2026-06-12 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b5e7c9d1f3a2"
|
||||
down_revision = "a2b3c4d5e6f7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# existing keys keep NULL
|
||||
# New reservations populate it via pay_for_request.
|
||||
op.add_column("api_keys", sa.Column("reserved_at", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("api_keys", "reserved_at")
|
||||
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 ###
|
||||
@@ -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")
|
||||
@@ -0,0 +1,88 @@
|
||||
"""add slug to upstream_providers
|
||||
|
||||
Revision ID: c6d7e8f9a0b1
|
||||
Revises: b5e7c9d1f3a2
|
||||
Create Date: 2026-06-29 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from routstr.core.provider_slugs import provider_slug_base, provider_slug_candidate
|
||||
|
||||
revision = "c6d7e8f9a0b1"
|
||||
down_revision = "b5e7c9d1f3a2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _allocate_backfill_slug(provider_type: str, reserved_slugs: set[str]) -> str:
|
||||
base = provider_slug_base(provider_type)
|
||||
suffix_number = 1
|
||||
while True:
|
||||
candidate = provider_slug_candidate(base, suffix_number)
|
||||
if candidate not in reserved_slugs:
|
||||
reserved_slugs.add(candidate)
|
||||
return candidate
|
||||
suffix_number += 1
|
||||
|
||||
|
||||
def _backfill_provider_slugs(conn: sa.Connection) -> None:
|
||||
existing_rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT slug FROM upstream_providers "
|
||||
"WHERE slug IS NOT NULL AND slug != ''"
|
||||
)
|
||||
)
|
||||
reserved_slugs = {str(row.slug).lower() for row in existing_rows}
|
||||
|
||||
rows_to_backfill = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, provider_type FROM upstream_providers "
|
||||
"WHERE slug IS NULL OR slug = '' "
|
||||
"ORDER BY id"
|
||||
)
|
||||
)
|
||||
for row in rows_to_backfill:
|
||||
slug = _allocate_backfill_slug(str(row.provider_type), reserved_slugs)
|
||||
conn.execute(
|
||||
sa.text("UPDATE upstream_providers SET slug = :slug WHERE id = :id"),
|
||||
{"slug": slug, "id": row.id},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {c["name"] for c in inspector.get_columns("upstream_providers")}
|
||||
|
||||
if "slug" not in columns:
|
||||
op.add_column(
|
||||
"upstream_providers",
|
||||
sa.Column("slug", sa.String(), nullable=True),
|
||||
)
|
||||
|
||||
_backfill_provider_slugs(conn)
|
||||
|
||||
existing_indexes = {idx["name"] for idx in inspector.get_indexes("upstream_providers")}
|
||||
if "ix_upstream_providers_slug" not in existing_indexes:
|
||||
op.create_index(
|
||||
"ix_upstream_providers_slug",
|
||||
"upstream_providers",
|
||||
["slug"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_indexes = {idx["name"] for idx in inspector.get_indexes("upstream_providers")}
|
||||
if "ix_upstream_providers_slug" in existing_indexes:
|
||||
op.drop_index("ix_upstream_providers_slug", table_name="upstream_providers")
|
||||
|
||||
columns = {c["name"] for c in inspector.get_columns("upstream_providers")}
|
||||
if "slug" in columns:
|
||||
op.drop_column("upstream_providers", "slug")
|
||||
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,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")
|
||||
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,25 @@
|
||||
"""add created_at to api_keys
|
||||
|
||||
Revision ID: f1a2b3c4d5e6
|
||||
Revises: cli_tokens_001
|
||||
Create Date: 2026-06-01 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f1a2b3c4d5e6"
|
||||
down_revision = "cli_tokens_001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Nullable on purpose: existing keys keep NULL (unknown creation time) and
|
||||
# sort last; new keys get populated by the model's default_factory.
|
||||
op.add_column("api_keys", sa.Column("created_at", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("api_keys", "created_at")
|
||||
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")
|
||||
40
mkdocs.yml
40
mkdocs.yml
@@ -77,29 +77,27 @@ extra:
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Getting Started:
|
||||
- Overview: getting-started/overview.md
|
||||
- Quick Start: getting-started/quickstart.md
|
||||
- Docker Setup: getting-started/docker.md
|
||||
- Configuration: getting-started/configuration.md
|
||||
- User Guide:
|
||||
- Introduction: user-guide/introduction.md
|
||||
- Payment Flow: user-guide/payment-flow.md
|
||||
- Using the API: user-guide/using-api.md
|
||||
- Admin Dashboard: user-guide/admin-dashboard.md
|
||||
- Models & Pricing: user-guide/models-pricing.md
|
||||
- Contributing:
|
||||
- Setup Development: contributing/setup.md
|
||||
- Architecture: contributing/architecture.md
|
||||
- Code Structure: contributing/code-structure.md
|
||||
- Testing: contributing/testing.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
|
||||
- Advanced:
|
||||
- Tor Support: advanced/tor.md
|
||||
- Nostr Discovery: advanced/nostr.md
|
||||
- Custom Pricing: advanced/custom-pricing.md
|
||||
- Migrations: advanced/migrations.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.2.0"
|
||||
version = "0.4.4"
|
||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -13,12 +13,14 @@ dependencies = [
|
||||
"greenlet>=3.2.1",
|
||||
"alembic>=1.13",
|
||||
"python-json-logger>=2.0.0",
|
||||
"cashu",
|
||||
"secp256k1",
|
||||
"cashu>=0.20",
|
||||
"marshmallow>=3.13,<4.0",
|
||||
"websockets>=12.0",
|
||||
"nostr>=0.0.2",
|
||||
"mdurl==0.1.2",
|
||||
"pillow>=10",
|
||||
"openai>=1.98.0",
|
||||
"litellm>=1.55.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -71,6 +73,7 @@ packages = ["routstr"]
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
ignore = ["E501"]
|
||||
exclude = ["examples"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
@@ -83,4 +86,3 @@ disallow_untyped_decorators = true
|
||||
|
||||
[tool.uv.sources]
|
||||
routstr = { workspace = true }
|
||||
secp256k1 = { git = "https://github.com/saschanaz/secp256k1-py", branch = "upgrade060" }
|
||||
|
||||
@@ -84,126 +84,67 @@ def get_provider_penalty(provider: "BaseUpstreamProvider") -> float:
|
||||
return penalty
|
||||
|
||||
|
||||
def should_prefer_model(
|
||||
candidate_model: "Model",
|
||||
candidate_provider: "BaseUpstreamProvider",
|
||||
current_model: "Model",
|
||||
current_provider: "BaseUpstreamProvider",
|
||||
alias: str,
|
||||
) -> bool:
|
||||
"""Determine if candidate model should replace current model for an alias.
|
||||
|
||||
This is the core decision function for model prioritization. It considers:
|
||||
1. Alias matching quality (exact match vs. canonical slug match)
|
||||
2. Model cost (lower is better)
|
||||
3. Provider penalties (e.g., slight preference against OpenRouter)
|
||||
|
||||
Args:
|
||||
candidate_model: The new model being considered
|
||||
candidate_provider: Provider offering the candidate model
|
||||
current_model: The currently selected model for this alias
|
||||
current_provider: Provider offering the current model
|
||||
alias: The model alias being mapped
|
||||
|
||||
Returns:
|
||||
True if candidate should replace current, False otherwise
|
||||
"""
|
||||
|
||||
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 alias_priority(model: "Model") -> int:
|
||||
"""Rank how strong the mapping of alias->model is.
|
||||
|
||||
Highest priority when alias exactly equals the model ID without provider prefix.
|
||||
Next when alias equals canonical slug without prefix. Otherwise lowest.
|
||||
"""
|
||||
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
|
||||
|
||||
candidate_alias_priority = alias_priority(candidate_model)
|
||||
current_alias_priority = alias_priority(current_model)
|
||||
|
||||
# If candidate has better alias match, prefer it regardless of cost
|
||||
if candidate_alias_priority > current_alias_priority:
|
||||
return True
|
||||
|
||||
# If current has better alias match, keep it regardless of cost
|
||||
if current_alias_priority > candidate_alias_priority:
|
||||
return False
|
||||
|
||||
# Same alias priority - compare costs
|
||||
candidate_cost = calculate_model_cost_score(candidate_model)
|
||||
current_cost = calculate_model_cost_score(current_model)
|
||||
|
||||
# Apply provider penalties
|
||||
candidate_adjusted = candidate_cost * get_provider_penalty(candidate_provider)
|
||||
current_adjusted = current_cost * get_provider_penalty(current_provider)
|
||||
|
||||
# Prefer lower adjusted cost
|
||||
should_replace = candidate_adjusted < current_adjusted
|
||||
|
||||
# Log provider changes when candidate wins
|
||||
if should_replace:
|
||||
candidate_provider_name = getattr(
|
||||
candidate_provider, "upstream_name", "unknown"
|
||||
)
|
||||
current_provider_name = getattr(current_provider, "upstream_name", "unknown")
|
||||
logger.debug(
|
||||
f"Model selection for alias '{alias}': choosing {candidate_provider_name} "
|
||||
f"(cost: ${candidate_adjusted:.6f}) over {current_provider_name} "
|
||||
f"(cost: ${current_adjusted:.6f})"
|
||||
)
|
||||
|
||||
return should_replace
|
||||
|
||||
|
||||
def create_model_mappings(
|
||||
upstreams: list["BaseUpstreamProvider"],
|
||||
overrides_by_id: dict[str, tuple],
|
||||
disabled_model_ids: set[str],
|
||||
) -> tuple[dict[str, "Model"], dict[str, "BaseUpstreamProvider"], dict[str, "Model"]]:
|
||||
overrides_by_key: dict[tuple[str, int], tuple],
|
||||
disabled_model_keys: set[tuple[str, int]],
|
||||
) -> 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 -> UpstreamProvider (which provider to use for each alias)
|
||||
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, uses should_prefer_model() to select the best provider
|
||||
- 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
|
||||
overrides_by_key: Dict of model overrides from database
|
||||
{(model_id_lower, upstream_provider_id): (ModelRow, fee)}
|
||||
disabled_model_keys: Set of provider-scoped model keys that should be excluded
|
||||
|
||||
Returns:
|
||||
Tuple of (model_instances, provider_map, unique_models)
|
||||
"""
|
||||
from .payment.models import _row_to_model
|
||||
from .upstream import resolve_model_alias
|
||||
from .upstream.helpers import resolve_model_alias
|
||||
|
||||
model_instances: dict[str, "Model"] = {}
|
||||
provider_map: dict[str, "BaseUpstreamProvider"] = {}
|
||||
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 upstreams:
|
||||
for upstream in filtered_upstreams:
|
||||
base_url = getattr(upstream, "base_url", "")
|
||||
if base_url == "https://openrouter.ai/api/v1":
|
||||
openrouter = upstream
|
||||
@@ -214,37 +155,47 @@ def create_model_mappings(
|
||||
"""Get base model ID by removing provider prefix."""
|
||||
return model_id.split("/", 1)[1] if "/" in model_id else model_id
|
||||
|
||||
def _maybe_set_alias(
|
||||
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:
|
||||
"""Set alias to model/provider if not set or if new model is preferred."""
|
||||
existing_model = model_instances.get(alias)
|
||||
if not existing_model:
|
||||
# No existing mapping, set it
|
||||
model_instances[alias] = model
|
||||
provider_map[alias] = provider
|
||||
else:
|
||||
# Check if candidate should replace existing
|
||||
existing_provider = provider_map[alias]
|
||||
if should_prefer_model(
|
||||
model, provider, existing_model, existing_provider, alias
|
||||
):
|
||||
model_instances[alias] = model
|
||||
provider_map[alias] = provider
|
||||
"""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)
|
||||
upstream_db_id = getattr(upstream, "db_id", None)
|
||||
|
||||
for model in upstream.get_cached_models():
|
||||
if not model.enabled or model.id in disabled_model_ids:
|
||||
model_key = (
|
||||
(model.id.lower(), upstream_db_id)
|
||||
if isinstance(upstream_db_id, int)
|
||||
else None
|
||||
)
|
||||
if not model.enabled or (
|
||||
model_key is not None and model_key in disabled_model_keys
|
||||
):
|
||||
continue
|
||||
|
||||
# Apply overrides if present
|
||||
if model.id in overrides_by_id:
|
||||
override_row, provider_fee = overrides_by_id[model.id]
|
||||
# Apply overrides only for this provider's model row.
|
||||
if model_key is not None and model_key in overrides_by_key:
|
||||
override_row, provider_fee = overrides_by_key[model_key]
|
||||
model_to_use = _row_to_model(
|
||||
override_row, apply_provider_fee=True, provider_fee=provider_fee
|
||||
)
|
||||
@@ -253,9 +204,15 @@ def create_model_mappings(
|
||||
|
||||
# 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})
|
||||
unique_models[base_id] = unique_model
|
||||
unique_key = model_to_use.forwarded_model_id or base_id
|
||||
if not is_openrouter or unique_key not in unique_models:
|
||||
unique_model = model_to_use.copy(
|
||||
update={
|
||||
"id": base_id,
|
||||
"upstream_provider_id": upstream.provider_type,
|
||||
}
|
||||
)
|
||||
unique_models[unique_key] = unique_model
|
||||
|
||||
# Get all aliases for this model
|
||||
aliases = resolve_model_alias(
|
||||
@@ -270,31 +227,166 @@ def create_model_mappings(
|
||||
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:
|
||||
_maybe_set_alias(alias, model_to_use, upstream)
|
||||
_add_candidate(alias, model_to_use, upstream)
|
||||
seen_model_provider.add((model_to_use.id.lower(), provider_key))
|
||||
|
||||
# Process non-OpenRouter providers first (they're typically cheaper)
|
||||
# Process non-OpenRouter providers first
|
||||
for upstream in other_upstreams:
|
||||
process_provider_models(upstream, is_openrouter=False)
|
||||
|
||||
# Process OpenRouter last - models only win if they're cheaper or better matched
|
||||
# Process OpenRouter last
|
||||
if openrouter:
|
||||
process_provider_models(openrouter, is_openrouter=True)
|
||||
|
||||
# Log provider distribution
|
||||
# Include enabled DB overrides even when provider discovery misses models.
|
||||
# This is important for deployment-based providers like Azure.
|
||||
for (model_id, upstream_provider_id), override_data in overrides_by_key.items():
|
||||
if (model_id, upstream_provider_id) in disabled_model_keys:
|
||||
continue
|
||||
override_row, provider_fee = override_data
|
||||
|
||||
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)
|
||||
unique_key = model_to_use.forwarded_model_id or base_id
|
||||
is_openrouter = (
|
||||
getattr(upstream_for_override, "base_url", "")
|
||||
== "https://openrouter.ai/api/v1"
|
||||
)
|
||||
if not is_openrouter or unique_key not in unique_models:
|
||||
unique_model = model_to_use.copy(
|
||||
update={
|
||||
"id": base_id,
|
||||
"upstream_provider_id": upstream_for_override.provider_type,
|
||||
}
|
||||
)
|
||||
unique_models[unique_key] = 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.
|
||||
|
||||
forwarded_model_id is the most specific identifier (set per-provider
|
||||
instance), so a match there should beat a model_id match. This way,
|
||||
when multiple providers have the same model_id but different
|
||||
forwarded_model_ids, the one whose forwarded_model_id equals the
|
||||
requested alias wins.
|
||||
"""
|
||||
if (
|
||||
model.forwarded_model_id
|
||||
and model.forwarded_model_id.lower() == alias
|
||||
):
|
||||
return 5
|
||||
|
||||
if (
|
||||
model.id
|
||||
and model.id.lower() == alias
|
||||
):
|
||||
return 4
|
||||
|
||||
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 provider in provider_map.values():
|
||||
provider_name = getattr(provider, "upstream_name", "unknown")
|
||||
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
|
||||
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(
|
||||
"Created model mappings",
|
||||
extra={
|
||||
"unique_model_count": len(unique_models),
|
||||
"total_alias_count": len(model_instances),
|
||||
"provider_distribution": provider_counts,
|
||||
},
|
||||
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
|
||||
|
||||
1004
routstr/auth.py
1004
routstr/auth.py
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,33 @@
|
||||
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, or_, select, update
|
||||
|
||||
from .auth import validate_bearer_key
|
||||
from .core.db import ApiKey, AsyncSession, get_session
|
||||
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 .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
|
||||
from .lightning import lightning_router
|
||||
from .wallet import (
|
||||
classify_redemption_error,
|
||||
credit_balance,
|
||||
is_mint_connection_error,
|
||||
recieve_token,
|
||||
send_to_lnurl,
|
||||
send_token,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
balance_router = APIRouter(prefix="/v1/balance")
|
||||
@@ -31,14 +48,50 @@ 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.total_balance,
|
||||
"reserved": billing_key.reserved_balance,
|
||||
"is_child": key.parent_key_hash is not 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 key.parent_key_hash:
|
||||
info["parent_key_preview"] = key.parent_key_hash[:8] + "..."
|
||||
else:
|
||||
# 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,
|
||||
"reserved": key.reserved_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
|
||||
@@ -55,9 +108,24 @@ async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
|
||||
@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,
|
||||
@@ -65,12 +133,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,
|
||||
"reserved": key.reserved_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):
|
||||
@@ -84,6 +151,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:
|
||||
@@ -93,17 +162,19 @@ 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)
|
||||
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")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Failed to redeem token")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
amount_msats = await credit_balance(cashu_token, billing_key, session)
|
||||
except Exception as e:
|
||||
# Shared taxonomy so top-up matches the bearer/X-Cashu paths (503 for an
|
||||
# unreachable mint, 422 for fee/swap failures, 400 for token faults).
|
||||
classified = classify_redemption_error(e)
|
||||
if classified is None:
|
||||
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")
|
||||
_type, status_code, message, _code = classified
|
||||
raise HTTPException(status_code=status_code, detail=message)
|
||||
return {"msats": amount_msats}
|
||||
|
||||
|
||||
@@ -136,24 +207,153 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None:
|
||||
_refund_cache[key] = (expiry, value)
|
||||
|
||||
|
||||
@router.post("/refund")
|
||||
async def _lookup_key_no_create(
|
||||
bearer_value: str, session: AsyncSession
|
||||
) -> ApiKey | None:
|
||||
"""Look up an existing API key without creating one Used by the refund endpoint"""
|
||||
if bearer_value.startswith("sk-"):
|
||||
return await session.get(ApiKey, bearer_value[3:])
|
||||
if bearer_value.startswith("cashu"):
|
||||
hashed = hashlib.sha256(bearer_value.encode()).hexdigest()
|
||||
return await session.get(ApiKey, hashed)
|
||||
return None
|
||||
|
||||
|
||||
async def _restore_balance(
|
||||
session: AsyncSession, hashed_key: str, balance: int, reserved_balance: int, mint_url: str
|
||||
) -> 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, "mint_url": mint_url},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refund", response_model=None)
|
||||
async def refund_wallet_endpoint(
|
||||
authorization: Annotated[str, Header(...)],
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
x_cashu: Annotated[str | None, Header()] = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
if not authorization.startswith("Bearer "):
|
||||
) -> 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:
|
||||
# The "in" row exists with a request_id, but the "out" (refund)
|
||||
# row hasn't been written yet — the upstream request is still in
|
||||
# flight and the refund will be minted once it completes. Tell the
|
||||
# client to retry instead of 404ing permanently (race condition
|
||||
# where /v1/wallet/refund is polled before the refund exists).
|
||||
logger.debug(
|
||||
"refund_wallet_endpoint: refund pending (in row exists, out row not yet created)",
|
||||
extra={"request_id": in_tx.request_id},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=425,
|
||||
detail="Refund is pending; retry shortly.",
|
||||
headers={"Retry-After": "2"},
|
||||
)
|
||||
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 | None = await _lookup_key_no_create(bearer_value, session)
|
||||
if key is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Key not found. Deposit first via /v1/wallet/create before requesting a refund.",
|
||||
)
|
||||
|
||||
if cached := await _refund_cache_get(bearer_value):
|
||||
return cached
|
||||
if key.total_balance <= 0:
|
||||
if cached := await _refund_cache_get(bearer_value):
|
||||
return cached
|
||||
|
||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||
remaining_balance_msats: int = key.balance
|
||||
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:
|
||||
# Release the reservation if it is stale
|
||||
cutoff = int(time.time()) - settings.stale_reservation_timeout_seconds
|
||||
stale_release_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.reserved_balance) > 0)
|
||||
.where(
|
||||
or_(
|
||||
col(ApiKey.reserved_at).is_(None),
|
||||
col(ApiKey.reserved_at) < cutoff,
|
||||
)
|
||||
)
|
||||
.values(reserved_balance=0, reserved_at=None)
|
||||
)
|
||||
stale_result = await session.exec(stale_release_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if stale_result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot refund key. There are ongoing requests for this api key.",
|
||||
)
|
||||
|
||||
await session.refresh(key)
|
||||
logger.warning(
|
||||
"refund_wallet_endpoint: released stale reservation before refund",
|
||||
extra={
|
||||
"hashed_key": key.hashed_key,
|
||||
"stale_timeout_seconds": settings.stale_reservation_timeout_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
remaining_balance_msats: int = key.total_balance
|
||||
|
||||
if key.refund_currency == "sat":
|
||||
remaining_balance = remaining_balance_msats // 1000
|
||||
@@ -165,22 +365,51 @@ async def refund_wallet_endpoint(
|
||||
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, reserved_at=None)
|
||||
)
|
||||
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 ---
|
||||
# Proofs from untrusted mints are swapped to primary_mint on receive.
|
||||
# Use primary_mint unless key.refund_mint_url is an explicitly trusted mint.
|
||||
effective_refund_mint = (
|
||||
key.refund_mint_url
|
||||
if key.refund_mint_url and key.refund_mint_url in settings.cashu_mints
|
||||
else settings.primary_mint
|
||||
)
|
||||
try:
|
||||
if key.refund_address:
|
||||
from .core.settings import settings as global_settings
|
||||
|
||||
await send_to_lnurl(
|
||||
remaining_balance,
|
||||
key.refund_currency or "sat",
|
||||
key.refund_mint_url or global_settings.primary_mint,
|
||||
effective_refund_mint,
|
||||
key.refund_address,
|
||||
)
|
||||
result = {"recipient": key.refund_address}
|
||||
else:
|
||||
refund_currency = key.refund_currency or "sat"
|
||||
token = await send_token(
|
||||
remaining_balance, refund_currency, key.refund_mint_url
|
||||
remaining_balance, refund_currency, effective_refund_mint
|
||||
)
|
||||
result = {"token": token}
|
||||
|
||||
@@ -189,30 +418,105 @@ 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, key.refund_mint_url or "")
|
||||
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, key.refund_mint_url or "")
|
||||
error_msg = str(e)
|
||||
if (
|
||||
"mint" in error_msg.lower()
|
||||
or "connection" in error_msg.lower()
|
||||
or isinstance(e, Exception)
|
||||
and "ConnectError" in str(type(e))
|
||||
):
|
||||
logger.error(
|
||||
"refund_wallet_endpoint: mint/send failed",
|
||||
extra={
|
||||
"error": error_msg,
|
||||
"error_type": type(e).__name__,
|
||||
"hashed_key": key.hashed_key,
|
||||
"remaining_balance": remaining_balance,
|
||||
"refund_currency": key.refund_currency,
|
||||
"refund_mint_url": key.refund_mint_url,
|
||||
"has_refund_address": bool(key.refund_address),
|
||||
},
|
||||
)
|
||||
if is_mint_connection_error(e):
|
||||
raise HTTPException(status_code=503, detail="Mint service unavailable")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Refund failed")
|
||||
|
||||
await _refund_cache_set(bearer_value, result)
|
||||
|
||||
await session.delete(key)
|
||||
await session.commit()
|
||||
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:
|
||||
@@ -226,6 +530,133 @@ async def donate(token: str, ref: str | None = None) -> str:
|
||||
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 atomically — guards against concurrent requests
|
||||
# that both pass the balance check above on stale in-memory state.
|
||||
deduct_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= total_cost)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) - total_cost,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost,
|
||||
)
|
||||
)
|
||||
result = await session.exec(deduct_stmt) # type: ignore[call-overload]
|
||||
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
|
||||
)
|
||||
|
||||
# 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()
|
||||
await session.refresh(key)
|
||||
|
||||
response_data = {
|
||||
"api_keys": new_keys,
|
||||
"count": count,
|
||||
"cost_msats": total_cost,
|
||||
"cost_sats": total_cost // 1000,
|
||||
"parent_balance": key.balance,
|
||||
"parent_balance_sats": key.balance // 1000,
|
||||
}
|
||||
logger.debug(f"Child key creation response: {response_data}")
|
||||
return response_data
|
||||
|
||||
|
||||
class ChildKeyResetRequest(BaseModel):
|
||||
child_key: str
|
||||
|
||||
|
||||
@router.post("/child-key/reset")
|
||||
async def reset_child_key_spent(
|
||||
payload: ChildKeyResetRequest,
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Resets the total_spent of a child key. Must be called by the parent."""
|
||||
child_key_raw = payload.child_key
|
||||
if child_key_raw.startswith("sk-"):
|
||||
child_key_raw = child_key_raw[3:]
|
||||
|
||||
child_key = await session.get(ApiKey, child_key_raw)
|
||||
if not child_key:
|
||||
raise HTTPException(status_code=404, detail="Child key not found.")
|
||||
|
||||
if child_key.parent_key_hash != key.hashed_key:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Unauthorized. You are not the parent of this key."
|
||||
)
|
||||
|
||||
child_key.total_spent = 0
|
||||
if child_key.balance_limit_reset:
|
||||
child_key.balance_limit_reset_date = int(time.time())
|
||||
session.add(child_key)
|
||||
await session.commit()
|
||||
|
||||
return {"success": True, "message": "Child key balance reset successfully."}
|
||||
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE"],
|
||||
@@ -238,6 +669,8 @@ async def wallet_catch_all(path: str) -> NoReturn:
|
||||
)
|
||||
|
||||
|
||||
balance_router.include_router(lightning_router, include_in_schema=False)
|
||||
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,19 @@
|
||||
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, delete
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||
from sqlmodel import Field, Relationship, SQLModel, func, select
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlmodel import Field, Relationship, SQLModel, col, func, select, update
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .logging import get_logger
|
||||
@@ -26,6 +34,14 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
reserved_balance: int = Field(
|
||||
default=0, description="Reserved balance in millisatoshis (msats)"
|
||||
)
|
||||
reserved_at: int | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Unix timestamp of the most recent balance reservation. Used to "
|
||||
"detect and release stale reservations (e.g. after client "
|
||||
"disconnects). NULL when no reservation has been made yet."
|
||||
),
|
||||
)
|
||||
refund_address: str | None = Field(
|
||||
default=None,
|
||||
description="Lightning address to refund remaining balance after key expires",
|
||||
@@ -38,6 +54,14 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
default=0, description="Total spent in millisatoshis (msats)"
|
||||
)
|
||||
total_requests: int = Field(default=0)
|
||||
created_at: int | None = Field(
|
||||
default_factory=lambda: int(time.time()),
|
||||
nullable=True,
|
||||
description=(
|
||||
"Unix timestamp when the key was created. Nullable: keys created "
|
||||
"before this column existed have no value and sort last."
|
||||
),
|
||||
)
|
||||
refund_mint_url: str | None = Field(
|
||||
default=None,
|
||||
description="URL of the mint used to create the cashu-token",
|
||||
@@ -46,12 +70,119 @@ 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:
|
||||
stmt = update(ApiKey).values(reserved_balance=0, reserved_at=None)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
logger.info("Reset reserved balances on startup")
|
||||
|
||||
|
||||
async def release_stale_reservations(
|
||||
session: AsyncSession, max_age_seconds: int
|
||||
) -> int:
|
||||
"""Release reservations whose last reserve is older than max_age_seconds.
|
||||
"""
|
||||
cutoff = int(time.time()) - max_age_seconds
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.reserved_balance) > 0)
|
||||
.where(col(ApiKey.reserved_at).is_not(None))
|
||||
.where(col(ApiKey.reserved_at) < cutoff)
|
||||
.values(reserved_balance=0, reserved_at=None)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
released = int(result.rowcount or 0)
|
||||
if released:
|
||||
logger.warning(
|
||||
"Released stale balance reservations",
|
||||
extra={"released_keys": released, "max_age_seconds": max_age_seconds},
|
||||
)
|
||||
return released
|
||||
|
||||
|
||||
async def prune_dead_api_keys(session: AsyncSession, min_age_seconds: int) -> int:
|
||||
"""Delete dead parentless API keys; return the count removed.
|
||||
|
||||
Dead = 0 balance/reservation/spend/requests, older than the grace period,
|
||||
no parent, no children, no pending invoice. Cashu rows are unlinked (not
|
||||
deleted) first to keep the audit trail.
|
||||
"""
|
||||
cutoff = int(time.time()) - min_age_seconds
|
||||
|
||||
child = aliased(ApiKey)
|
||||
has_children = (
|
||||
select(child.hashed_key).where(
|
||||
col(child.parent_key_hash) == col(ApiKey.hashed_key)
|
||||
)
|
||||
).exists()
|
||||
pending_invoice = (
|
||||
select(LightningInvoice.id)
|
||||
.where(col(LightningInvoice.api_key_hash) == col(ApiKey.hashed_key))
|
||||
.where(col(LightningInvoice.status) == "pending")
|
||||
).exists()
|
||||
|
||||
eligible_hashes = (
|
||||
select(ApiKey.hashed_key)
|
||||
.where(col(ApiKey.balance) == 0)
|
||||
.where(col(ApiKey.reserved_balance) == 0)
|
||||
.where(col(ApiKey.total_spent) == 0)
|
||||
.where(col(ApiKey.total_requests) == 0)
|
||||
.where(col(ApiKey.parent_key_hash).is_(None))
|
||||
.where(
|
||||
(col(ApiKey.created_at).is_(None)) | (col(ApiKey.created_at) < cutoff)
|
||||
)
|
||||
.where(~pending_invoice)
|
||||
.where(~has_children)
|
||||
)
|
||||
|
||||
# Unlink transactions rather than cascade-deleting them, so the financial
|
||||
# audit trail survives. The eligibility predicate is re-evaluated inside both
|
||||
# statements so a key that gained balance mid-run is left untouched.
|
||||
await session.exec( # type: ignore[call-overload]
|
||||
update(CashuTransaction)
|
||||
.where(col(CashuTransaction.api_key_hashed_key).in_(eligible_hashes))
|
||||
.values(api_key_hashed_key=None)
|
||||
)
|
||||
|
||||
result = await session.exec( # type: ignore[call-overload]
|
||||
delete(ApiKey).where(col(ApiKey.hashed_key).in_(eligible_hashes))
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
pruned = int(result.rowcount or 0)
|
||||
logger.info(
|
||||
"Pruned dead API keys",
|
||||
extra={"pruned_keys": pruned, "min_age_seconds": min_age_seconds},
|
||||
)
|
||||
return pruned
|
||||
|
||||
|
||||
class ModelRow(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "models"
|
||||
id: str = Field(primary_key=True)
|
||||
@@ -67,17 +198,139 @@ class ModelRow(SQLModel, table=True): # type: ignore
|
||||
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")
|
||||
balance_limit: int | None = Field(
|
||||
default=None,
|
||||
description="Max spendable msats for the created key",
|
||||
)
|
||||
balance_limit_reset: str | None = Field(
|
||||
default=None,
|
||||
description="Reset policy for balance limit (daily, weekly, monthly)",
|
||||
)
|
||||
validity_date: int | None = Field(
|
||||
default=None,
|
||||
description="Unix timestamp after which the created key expires",
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
) -> bool:
|
||||
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()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to store cashu transaction: {e} (type={typ})",
|
||||
extra={"error": str(e), "type": typ},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
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)
|
||||
slug: str | None = Field(
|
||||
default=None,
|
||||
unique=True,
|
||||
index=True,
|
||||
description="Stable external slug used for updates via API key.",
|
||||
)
|
||||
provider_type: str = Field(
|
||||
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
|
||||
)
|
||||
base_url: str = Field(unique=True, description="Base URL of the upstream API")
|
||||
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"
|
||||
@@ -86,12 +339,75 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
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:
|
||||
@@ -121,12 +437,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]
|
||||
@@ -143,9 +510,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,30 @@ from .logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UpstreamError(Exception):
|
||||
"""Exception raised when an upstream provider fails.
|
||||
|
||||
``code`` carries a stable, machine-readable classification (e.g.
|
||||
``UPSTREAM_RATE_LIMIT``) so callers can distinguish failure kinds without
|
||||
string-matching the message. ``details`` holds optional structured,
|
||||
redaction-safe context. Both default to ``None`` for backwards
|
||||
compatibility.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = 502,
|
||||
code: str | None = None,
|
||||
details: dict[str, object] | None = None,
|
||||
):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
self.details = details
|
||||
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")
|
||||
@@ -13,16 +37,20 @@ async def http_exception_handler(request: Request, exc: Exception) -> JSONRespon
|
||||
# Get status code and detail - works for both FastAPI and Starlette HTTPException
|
||||
status_code = getattr(exc, "status_code", 500)
|
||||
detail = getattr(exc, "detail", str(exc))
|
||||
path = request.url.path
|
||||
|
||||
logger.warning(
|
||||
"HTTP exception",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"status_code": status_code,
|
||||
"detail": detail,
|
||||
"path": request.url.path,
|
||||
},
|
||||
)
|
||||
# 4xx is client behaviour; the uvicorn access log already records it.
|
||||
# Only 5xx warrants a server-side warning/error log here.
|
||||
if status_code >= 500:
|
||||
logger.error(
|
||||
f"HTTP {status_code} on {path}: {detail}",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"status_code": status_code,
|
||||
"detail": detail,
|
||||
"path": path,
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
|
||||
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,15 +1,65 @@
|
||||
"""
|
||||
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
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pythonjsonlogger import jsonlogger
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
|
||||
from .redaction import redact_obj, redact_org_ids
|
||||
|
||||
# Only use RichHandler when stdout is a real TTY. In non-TTY contexts
|
||||
# (docker logs, pipes, CI) Rich pads every line to width and wraps long
|
||||
# records, producing visually-empty trailing whitespace and split records.
|
||||
# A plain StreamHandler avoids both problems.
|
||||
_stdout_is_tty = sys.stdout.isatty()
|
||||
_console = Console(soft_wrap=True) if _stdout_is_tty else None
|
||||
|
||||
# Define custom TRACE level
|
||||
TRACE_LEVEL = 5
|
||||
logging.addLevelName(TRACE_LEVEL, "TRACE")
|
||||
@@ -132,6 +182,37 @@ class RequestIdFilter(logging.Filter):
|
||||
return True
|
||||
|
||||
|
||||
# Standard ``LogRecord`` attributes that are never user-supplied ``extra``
|
||||
# fields; skipped when redacting structured extras (``msg``/``message`` are
|
||||
# handled separately above).
|
||||
_NON_EXTRA_RECORD_ATTRS = frozenset(
|
||||
{
|
||||
"name",
|
||||
"msg",
|
||||
"args",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"pathname",
|
||||
"filename",
|
||||
"module",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"stack_info",
|
||||
"lineno",
|
||||
"funcName",
|
||||
"created",
|
||||
"msecs",
|
||||
"relativeCreated",
|
||||
"thread",
|
||||
"threadName",
|
||||
"processName",
|
||||
"process",
|
||||
"taskName",
|
||||
"message",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SecurityFilter(logging.Filter):
|
||||
"""Filter to remove sensitive information from logs."""
|
||||
|
||||
@@ -155,24 +236,38 @@ class SecurityFilter(logging.Filter):
|
||||
"""Filter out sensitive information from log records."""
|
||||
try:
|
||||
message = record.getMessage()
|
||||
message = redact_org_ids(message)
|
||||
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 = ()
|
||||
|
||||
# Structured `extra={...}` fields are emitted by the JSON formatter
|
||||
# straight from the record dict and never pass through the message
|
||||
# formatting above. Redact organization IDs from any string-valued
|
||||
# extra so they cannot leak via structured logs.
|
||||
for attr, value in list(record.__dict__.items()):
|
||||
if attr in _NON_EXTRA_RECORD_ATTRS:
|
||||
continue
|
||||
if isinstance(value, (str, dict, list, tuple)):
|
||||
record.__dict__[attr] = redact_obj(value)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -219,6 +314,26 @@ def setup_logging() -> None:
|
||||
if console_enabled:
|
||||
handlers.append("console")
|
||||
|
||||
if _stdout_is_tty:
|
||||
console_handler: dict[str, Any] = {
|
||||
"()": RichHandler,
|
||||
"level": log_level,
|
||||
"show_time": False,
|
||||
"show_path": False,
|
||||
"rich_tracebacks": True,
|
||||
"markup": True,
|
||||
"console": _console,
|
||||
"filters": ["request_id_filter", "security_filter"],
|
||||
}
|
||||
else:
|
||||
console_handler = {
|
||||
"class": "logging.StreamHandler",
|
||||
"level": log_level,
|
||||
"formatter": "plain",
|
||||
"stream": "ext://sys.stdout",
|
||||
"filters": ["request_id_filter", "security_filter"],
|
||||
}
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
@@ -228,6 +343,10 @@ def setup_logging() -> None:
|
||||
"format": "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d %(version)s %(request_id)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
"plain": {
|
||||
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
},
|
||||
"filters": {
|
||||
"version_filter": {"()": VersionFilter},
|
||||
@@ -235,15 +354,7 @@ def setup_logging() -> None:
|
||||
"security_filter": {"()": SecurityFilter},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"()": RichHandler,
|
||||
"level": log_level,
|
||||
"show_time": False,
|
||||
"show_path": False,
|
||||
"rich_tracebacks": True,
|
||||
"markup": True,
|
||||
"filters": ["request_id_filter", "security_filter"],
|
||||
},
|
||||
"console": console_handler,
|
||||
"file": {
|
||||
"()": DailyRotatingFileHandler,
|
||||
"level": log_level,
|
||||
@@ -298,6 +409,11 @@ 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 [],
|
||||
@@ -320,6 +436,11 @@ def setup_logging() -> None:
|
||||
},
|
||||
"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,5 +1,4 @@
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
@@ -9,35 +8,43 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
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 ..balance import balance_router, deprecated_wallet_router
|
||||
from ..discovery import providers_cache_refresher, providers_router
|
||||
from ..nip91 import announce_provider
|
||||
from ..payment.models import (
|
||||
cleanup_enabled_models_periodically,
|
||||
models_router,
|
||||
update_sats_pricing,
|
||||
from ..auth import (
|
||||
periodic_dead_key_prune,
|
||||
periodic_key_reset,
|
||||
periodic_stale_reservation_sweep,
|
||||
)
|
||||
from ..balance import balance_router, deprecated_wallet_router
|
||||
from ..lightning import lightning_router, periodic_invoice_watcher
|
||||
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 ..wallet import periodic_payout
|
||||
from ..upstream.auto_topup import periodic_auto_topup
|
||||
from ..upstream.deepseek_v4_pricing_shim import register_deepseek_v4_pricing
|
||||
from ..upstream.litellm_routing import configure_litellm
|
||||
from ..wallet import periodic_payout, periodic_refund_sweep, periodic_routstr_fee_payout
|
||||
from .admin import admin_router
|
||||
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 .not_found import _NOT_FOUND_HTML, not_found_catch_all # noqa: F401
|
||||
from .settings import SettingsService
|
||||
from .settings import settings as global_settings
|
||||
from .version import __version__
|
||||
|
||||
# Initialize logging first
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
if os.getenv("VERSION_SUFFIX") is not None:
|
||||
__version__ = f"0.2.0-{os.getenv('VERSION_SUFFIX')}"
|
||||
else:
|
||||
__version__ = "0.2.0"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
@@ -47,16 +54,29 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
pricing_task = None
|
||||
payout_task = None
|
||||
nip91_task = None
|
||||
analytics_task = None
|
||||
providers_task = None
|
||||
models_refresh_task = None
|
||||
models_cleanup_task = None
|
||||
model_maps_refresh_task = None
|
||||
key_reset_task = None
|
||||
stale_reservation_task = None
|
||||
dead_key_prune_task = None
|
||||
auto_topup_task = None
|
||||
refund_sweep_task = None
|
||||
routstr_fee_task = None
|
||||
invoice_watcher_task = None
|
||||
|
||||
try:
|
||||
# Apply litellm-wide settings (drop_params, chat-completions URL,
|
||||
# debug logging) before any upstream provider dispatches a request.
|
||||
configure_litellm()
|
||||
|
||||
# TEMPORARY: backfill DeepSeek V4 pricing missing from litellm's cost
|
||||
# map (BerriAI/litellm#30430). Remove this call and
|
||||
# deepseek_v4_pricing_shim.py once litellm ships these models.
|
||||
register_deepseek_v4_pricing()
|
||||
|
||||
# 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
|
||||
@@ -66,6 +86,15 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# 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:
|
||||
@@ -78,25 +107,47 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
from ..payment.price import _update_prices
|
||||
from ..proxy import get_upstreams
|
||||
from ..upstream import refresh_upstreams_models_periodically
|
||||
from ..upstream.helpers import refresh_upstreams_models_periodically
|
||||
|
||||
await _update_prices()
|
||||
await initialize_upstreams()
|
||||
_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())
|
||||
refresh_upstreams_models_periodically(get_upstreams)
|
||||
)
|
||||
models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically())
|
||||
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
|
||||
payout_task = asyncio.create_task(periodic_payout())
|
||||
nip91_task = asyncio.create_task(announce_provider())
|
||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||
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())
|
||||
stale_reservation_task = asyncio.create_task(
|
||||
periodic_stale_reservation_sweep()
|
||||
)
|
||||
dead_key_prune_task = asyncio.create_task(periodic_dead_key_prune())
|
||||
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())
|
||||
invoice_watcher_task = asyncio.create_task(periodic_invoice_watcher())
|
||||
|
||||
yield
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Expected during shutdown
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Application startup failed",
|
||||
@@ -114,14 +165,28 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, 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 models_cleanup_task is not None:
|
||||
models_cleanup_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 stale_reservation_task is not None:
|
||||
stale_reservation_task.cancel()
|
||||
if dead_key_prune_task is not None:
|
||||
dead_key_prune_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()
|
||||
if invoice_watcher_task is not None:
|
||||
invoice_watcher_task.cancel()
|
||||
|
||||
try:
|
||||
tasks_to_wait = []
|
||||
@@ -133,14 +198,28 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, 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 models_cleanup_task is not None:
|
||||
tasks_to_wait.append(models_cleanup_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 stale_reservation_task is not None:
|
||||
tasks_to_wait.append(stale_reservation_task)
|
||||
if dead_key_prune_task is not None:
|
||||
tasks_to_wait.append(dead_key_prune_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 invoice_watcher_task is not None:
|
||||
tasks_to_wait.append(invoice_watcher_task)
|
||||
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
@@ -152,6 +231,23 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -161,7 +257,7 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["x-routstr-request-id"],
|
||||
expose_headers=["x-routstr-request-id", "x-cashu"],
|
||||
)
|
||||
|
||||
# Add logging middleware
|
||||
@@ -182,7 +278,7 @@ async def info() -> dict:
|
||||
"mints": global_settings.cashu_mints,
|
||||
"http_url": global_settings.http_url,
|
||||
"onion_url": global_settings.onion_url,
|
||||
"models": [], # kept for back-compat; prefer /v1/models
|
||||
"child_key_cost_msats": global_settings.child_key_cost,
|
||||
}
|
||||
|
||||
|
||||
@@ -198,7 +294,7 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
|
||||
|
||||
app.mount(
|
||||
"/_next",
|
||||
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
|
||||
_ImmutableStaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
|
||||
name="next-static",
|
||||
)
|
||||
|
||||
@@ -206,73 +302,70 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
|
||||
async def serve_root_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
# Add explicit route for /index.txt to redirect to /
|
||||
# Serve the App Router RSC payload for the home page.
|
||||
@app.get("/index.txt", include_in_schema=False)
|
||||
async def redirect_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/")
|
||||
async def serve_root_rsc() -> FileResponse:
|
||||
return FileResponse(
|
||||
UI_DIST_PATH / "index.txt", media_type="text/x-component"
|
||||
)
|
||||
|
||||
# Next.js is built with `trailingSlash: true`, so all UI page URLs end
|
||||
# with a slash (e.g. `/login/`). The proxy router catches `/{path:path}`
|
||||
# before FastAPI's `redirect_slashes` logic can normalize the URL, so we
|
||||
# must register both the with-slash and without-slash variants here.
|
||||
UI_PAGES = (
|
||||
"dashboard",
|
||||
"login",
|
||||
"model",
|
||||
"providers",
|
||||
"settings",
|
||||
"transactions",
|
||||
"balances",
|
||||
"logs",
|
||||
"usage",
|
||||
"unauthorized",
|
||||
)
|
||||
|
||||
def _register_ui_page(name: str) -> None:
|
||||
page_dir = UI_DIST_PATH / name
|
||||
index_html = page_dir / "index.html"
|
||||
index_txt = page_dir / "index.txt"
|
||||
|
||||
async def serve_page() -> FileResponse:
|
||||
return FileResponse(index_html)
|
||||
|
||||
async def serve_page_rsc() -> FileResponse:
|
||||
return FileResponse(index_txt, media_type="text/x-component")
|
||||
|
||||
app.add_api_route(
|
||||
f"/{name}",
|
||||
serve_page,
|
||||
methods=["GET"],
|
||||
include_in_schema=False,
|
||||
name=f"serve_{name}_ui",
|
||||
)
|
||||
app.add_api_route(
|
||||
f"/{name}/",
|
||||
serve_page,
|
||||
methods=["GET"],
|
||||
include_in_schema=False,
|
||||
name=f"serve_{name}_ui_slash",
|
||||
)
|
||||
app.add_api_route(
|
||||
f"/{name}/index.txt",
|
||||
serve_page_rsc,
|
||||
methods=["GET"],
|
||||
include_in_schema=False,
|
||||
name=f"serve_{name}_rsc",
|
||||
)
|
||||
|
||||
for _page in UI_PAGES:
|
||||
_register_ui_page(_page)
|
||||
|
||||
@app.get("/admin")
|
||||
async def admin_redirect() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@app.get("/dashboard", include_in_schema=False)
|
||||
async def serve_dashboard_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@app.get("/login", include_in_schema=False)
|
||||
async def serve_login_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "login" / "index.html")
|
||||
|
||||
# Add explicit route for /login/index.txt to redirect to /login
|
||||
@app.get("/login/index.txt", include_in_schema=False)
|
||||
async def redirect_login_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/login")
|
||||
|
||||
@app.get("/model", include_in_schema=False)
|
||||
async def serve_models_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "model" / "index.html")
|
||||
|
||||
# Add explicit route for /model/index.txt to redirect to /model
|
||||
@app.get("/model/index.txt", include_in_schema=False)
|
||||
async def redirect_model_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/model")
|
||||
|
||||
@app.get("/providers", include_in_schema=False)
|
||||
async def serve_providers_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
|
||||
|
||||
# Add explicit route for /providers/index.txt to redirect to /providers
|
||||
@app.get("/providers/index.txt", include_in_schema=False)
|
||||
async def redirect_providers_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/providers")
|
||||
|
||||
@app.get("/settings", include_in_schema=False)
|
||||
async def serve_settings_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
|
||||
|
||||
# Add explicit route for /settings/index.txt to redirect to /settings
|
||||
@app.get("/settings/index.txt", include_in_schema=False)
|
||||
async def redirect_settings_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/settings")
|
||||
|
||||
@app.get("/transactions", include_in_schema=False)
|
||||
async def serve_transactions_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
|
||||
|
||||
# Add explicit route for /transactions/index.txt to redirect to /transactions
|
||||
@app.get("/transactions/index.txt", include_in_schema=False)
|
||||
async def redirect_transactions_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/transactions")
|
||||
|
||||
@app.get("/unauthorized", include_in_schema=False)
|
||||
async def serve_unauthorized_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
|
||||
|
||||
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
|
||||
@app.get("/unauthorized/index.txt", include_in_schema=False)
|
||||
async def redirect_unauthorized_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/unauthorized")
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def serve_favicon() -> FileResponse:
|
||||
icon_path = UI_DIST_PATH / "icon.ico"
|
||||
@@ -284,12 +377,12 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
|
||||
async def serve_icon() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "icon.ico")
|
||||
|
||||
app.mount(
|
||||
"/static", StaticFiles(directory=UI_DIST_PATH, check_dir=True), name="ui-static"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
|
||||
"UI dist directory not found at %s; serving API only. Run `make ui-build` "
|
||||
"to build the static UI served from here, or `make ui-dev` for the Next.js "
|
||||
"dev server with hot reload on :3000 (it targets this backend on :8000).",
|
||||
UI_DIST_PATH,
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
@@ -306,6 +399,7 @@ else:
|
||||
app.include_router(models_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(balance_router)
|
||||
app.include_router(lightning_router)
|
||||
app.include_router(deprecated_wallet_router)
|
||||
app.include_router(providers_router)
|
||||
app.include_router(proxy_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__,
|
||||
},
|
||||
|
||||
55
routstr/core/not_found.py
Normal file
55
routstr/core/not_found.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Shared 404 handler used by the proxy catch-all and tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||
|
||||
_NOT_FOUND_HTML_FILE = Path(__file__).parent.parent.parent / "ui_out" / "404.html"
|
||||
|
||||
|
||||
def _read_not_found_html() -> str | None:
|
||||
try:
|
||||
return _NOT_FOUND_HTML_FILE.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
_NOT_FOUND_HTML: str | None = _read_not_found_html()
|
||||
|
||||
|
||||
def build_not_found_response(request: Request, path: str) -> Response:
|
||||
"""Return a 404 response.
|
||||
|
||||
HTML 404 page only for GET requests from browsers (Accept: text/html).
|
||||
All POST requests and API clients receive a JSON 404.
|
||||
"""
|
||||
accept = request.headers.get("accept", "").lower()
|
||||
prefers_html = (
|
||||
request.method == "GET"
|
||||
and "text/html" in accept
|
||||
and "application/json" not in accept
|
||||
)
|
||||
request_id = getattr(request.state, "request_id", "unknown")
|
||||
|
||||
if prefers_html and _NOT_FOUND_HTML is not None:
|
||||
return HTMLResponse(content=_NOT_FOUND_HTML, status_code=404)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": {
|
||||
"message": f"Path '/{path}' not found",
|
||||
"type": "not_found",
|
||||
"code": 404,
|
||||
},
|
||||
"request_id": request_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def not_found_catch_all(request: Request, path: str) -> Response:
|
||||
"""ASGI handler form of :func:`build_not_found_response`."""
|
||||
return build_not_found_response(request, path)
|
||||
65
routstr/core/provider_slugs.py
Normal file
65
routstr/core/provider_slugs.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from itertools import count
|
||||
from typing import Collection
|
||||
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .db import UpstreamProviderRow
|
||||
|
||||
_SLUG_BASE_PATTERN = re.compile(r"[^a-z0-9]+")
|
||||
_MAX_SLUG_LENGTH = 64
|
||||
|
||||
|
||||
def provider_slug_base(provider_type: str) -> str:
|
||||
"""Return a deterministic slug base for a provider type."""
|
||||
base = _SLUG_BASE_PATTERN.sub("-", provider_type.lower()).strip("-")
|
||||
if not base:
|
||||
base = "provider"
|
||||
elif base.isdigit():
|
||||
base = f"provider-{base}"
|
||||
elif len(base) < 3:
|
||||
base = f"{base}-provider"
|
||||
|
||||
if len(base) > _MAX_SLUG_LENGTH:
|
||||
base = base[:_MAX_SLUG_LENGTH].rstrip("-") or "provider"
|
||||
return base
|
||||
|
||||
|
||||
def provider_slug_candidate(base: str, suffix_number: int) -> str:
|
||||
if suffix_number == 1:
|
||||
return base
|
||||
|
||||
suffix = f"-{suffix_number}"
|
||||
max_base_length = _MAX_SLUG_LENGTH - len(suffix)
|
||||
return f"{base[:max_base_length].rstrip('-')}{suffix}"
|
||||
|
||||
|
||||
async def allocate_unique_provider_slug(
|
||||
session: AsyncSession,
|
||||
provider_type: str,
|
||||
reserved_slugs: Collection[str] = (),
|
||||
) -> str:
|
||||
"""Allocate a stable, deterministic provider slug.
|
||||
|
||||
The first provider of a type gets ``openai``; later collisions get
|
||||
``openai-2``, ``openai-3``, etc. ``reserved_slugs`` covers rows staged in
|
||||
memory but not flushed yet, such as settings/env seeding.
|
||||
"""
|
||||
base = provider_slug_base(provider_type)
|
||||
reserved = {slug.lower() for slug in reserved_slugs}
|
||||
|
||||
for suffix_number in count(1):
|
||||
candidate = provider_slug_candidate(base, suffix_number)
|
||||
if candidate in reserved:
|
||||
continue
|
||||
|
||||
result = await session.exec(
|
||||
select(UpstreamProviderRow).where(UpstreamProviderRow.slug == candidate)
|
||||
)
|
||||
if result.first() is None:
|
||||
return candidate
|
||||
|
||||
raise RuntimeError("unreachable")
|
||||
51
routstr/core/redaction.py
Normal file
51
routstr/core/redaction.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Redaction helpers for sensitive provider identifiers.
|
||||
|
||||
Single source of truth for stripping account-scoped identifiers (e.g. OpenAI
|
||||
organization IDs) from any text before it is logged, returned to a caller, or
|
||||
written to an audit entry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# OpenAI-style organization identifiers look like ``org-<base62>``. Require at
|
||||
# least 6 trailing chars so the already-redacted literal ``org-[REDACTED]`` is
|
||||
# never re-matched (``[`` is not in the character class).
|
||||
_ORG_ID_PATTERN = re.compile(r"\borg-[A-Za-z0-9]{6,}\b")
|
||||
|
||||
ORG_ID_PLACEHOLDER = "org-[REDACTED]"
|
||||
|
||||
|
||||
def redact_org_ids(text: str) -> str:
|
||||
"""Replace OpenAI-style organization IDs with ``org-[REDACTED]``.
|
||||
|
||||
Args:
|
||||
text: Arbitrary text that may embed an ``org-*`` identifier.
|
||||
|
||||
Returns:
|
||||
The text with every organization ID replaced. Non-string input is
|
||||
returned unchanged after coercion to ``str``.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
return _ORG_ID_PATTERN.sub(ORG_ID_PLACEHOLDER, text)
|
||||
|
||||
|
||||
def redact_obj(obj: Any) -> Any:
|
||||
"""Recursively redact organization IDs in arbitrary nested structures.
|
||||
|
||||
Strings are redacted in place; dicts and lists/tuples are walked so that
|
||||
identifiers nested inside structured payloads (e.g. log ``extra`` fields or
|
||||
error ``details``) are also stripped. Other types are returned unchanged.
|
||||
"""
|
||||
if isinstance(obj, str):
|
||||
return redact_org_ids(obj)
|
||||
if isinstance(obj, dict):
|
||||
return {key: redact_obj(value) for key, value in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [redact_obj(value) for value in obj]
|
||||
if isinstance(obj, tuple):
|
||||
return tuple(redact_obj(value) for value in obj)
|
||||
return obj
|
||||
@@ -41,6 +41,15 @@ class Settings(BaseSettings):
|
||||
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
|
||||
primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT")
|
||||
|
||||
# Lightning payout configuration
|
||||
# Minimum available balance (in satoshis) before profit is paid out over
|
||||
# Lightning
|
||||
min_payout_sat: int = Field(default=210, gt=0, env="MIN_PAYOUT_SAT")
|
||||
# Interval (seconds) between periodic payout attempts. Must be positive.
|
||||
payout_interval_seconds: int = Field(
|
||||
default=900, gt=0, env="PAYOUT_INTERVAL_SECONDS"
|
||||
)
|
||||
|
||||
# Pricing
|
||||
# Default behavior: derive pricing from MODELS
|
||||
# If fixed_pricing is True -> use fixed_cost_per_request and ignore tokens
|
||||
@@ -52,14 +61,32 @@ class Settings(BaseSettings):
|
||||
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
|
||||
# Reservations older than this are considered leaked (client disconnect,
|
||||
# crash, abandoned stream) and released by the background sweeper and the
|
||||
# refund endpoint.
|
||||
stale_reservation_timeout_seconds: int = Field(
|
||||
default=300, env="STALE_RESERVATION_TIMEOUT_SECONDS"
|
||||
)
|
||||
# Background prune of dead (zero balance, never used) API keys.
|
||||
# Interval 0 disables it; min-age is a grace period (default 1 week).
|
||||
dead_key_prune_interval_seconds: int = Field(
|
||||
default=3600, env="DEAD_KEY_PRUNE_INTERVAL_SECONDS"
|
||||
)
|
||||
dead_key_min_age_seconds: int = Field(
|
||||
default=604_800, env="DEAD_KEY_MIN_AGE_SECONDS"
|
||||
)
|
||||
|
||||
# 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=300, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
|
||||
default=0, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
|
||||
)
|
||||
pricing_refresh_interval_seconds: int = Field(
|
||||
default=120, env="PRICING_REFRESH_INTERVAL_SECONDS"
|
||||
@@ -70,6 +97,7 @@ class Settings(BaseSettings):
|
||||
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")
|
||||
@@ -88,6 +116,20 @@ class Settings(BaseSettings):
|
||||
|
||||
# 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:
|
||||
@@ -114,7 +156,7 @@ def resolve_bootstrap() -> Settings:
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Map COST_PER_1K_* -> CUSTOM_PER_1K_*
|
||||
# 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
|
||||
@@ -139,7 +181,7 @@ def resolve_bootstrap() -> Settings:
|
||||
pass
|
||||
if not base.onion_url:
|
||||
try:
|
||||
from ..nip91 import discover_onion_url_from_tor # type: ignore
|
||||
from ..nostr.listing import discover_onion_url_from_tor # type: ignore
|
||||
|
||||
discovered = discover_onion_url_from_tor()
|
||||
if discovered:
|
||||
@@ -227,16 +269,21 @@ class SettingsService:
|
||||
|
||||
db_id, db_data, _updated_at = row
|
||||
try:
|
||||
db_json = (
|
||||
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 = {}
|
||||
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 v}
|
||||
{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"):
|
||||
@@ -244,7 +291,7 @@ class SettingsService:
|
||||
merged_dict.get("cashu_mints", [])
|
||||
)
|
||||
|
||||
if any(k not in db_json for k in merged_dict.keys()):
|
||||
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"
|
||||
@@ -267,7 +314,7 @@ class SettingsService:
|
||||
) -> Settings:
|
||||
async with cls._lock:
|
||||
current = cls.get()
|
||||
candidate_dict = {**current.dict(), **partial}
|
||||
candidate_dict = {**current.dict(), **_normalize_settings_data(partial)}
|
||||
candidate = Settings(**candidate_dict)
|
||||
from sqlmodel import text
|
||||
|
||||
@@ -301,8 +348,10 @@ class SettingsService:
|
||||
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():
|
||||
setattr(settings, k, v)
|
||||
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.4"
|
||||
|
||||
_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 described and described.lstrip("v") == BASE_VERSION:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@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"]
|
||||
335
routstr/lightning.py
Normal file
335
routstr/lightning.py
Normal file
@@ -0,0 +1,335 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .core.db import ApiKey, LightningInvoice, create_session, 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(
|
||||
default="create",
|
||||
description="create or topup",
|
||||
pattern="^(create|topup)$",
|
||||
)
|
||||
api_key: str | None = Field(
|
||||
default=None,
|
||||
description="Deprecated: legacy field for topup. Prefer Authorization header.",
|
||||
)
|
||||
balance_limit: int | None = Field(default=None)
|
||||
balance_limit_reset: str | None = Field(default=None)
|
||||
validity_date: int | None = Field(default=None)
|
||||
|
||||
|
||||
def _extract_bearer_api_key(authorization: str | None) -> str | None:
|
||||
if not authorization:
|
||||
return None
|
||||
token = authorization.strip()
|
||||
if token.lower().startswith("bearer "):
|
||||
token = token[7:].strip()
|
||||
return token or 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,
|
||||
authorization: str | None = Header(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvoiceCreateResponse:
|
||||
api_key_token = _extract_bearer_api_key(authorization) or request.api_key
|
||||
|
||||
if request.purpose == "topup":
|
||||
if not api_key_token:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authorization bearer api key is required for topup",
|
||||
)
|
||||
if not api_key_token.startswith("sk-"):
|
||||
raise HTTPException(status_code=400, detail="Invalid API key format")
|
||||
|
||||
api_key = await session.get(ApiKey, api_key_token[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=api_key_token[3:] if api_key_token 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":
|
||||
await check_invoice_payment(invoice, session)
|
||||
|
||||
if invoice.status == "pending" and int(time.time()) > invoice.expires_at:
|
||||
invoice.status = "expired"
|
||||
await session.commit()
|
||||
|
||||
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,
|
||||
balance_limit=invoice.balance_limit,
|
||||
balance_limit_reset=invoice.balance_limit_reset,
|
||||
validity_date=invoice.validity_date,
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
INVOICE_WATCH_INTERVAL_SECONDS = 5
|
||||
INVOICE_WATCH_BATCH_LIMIT = 100
|
||||
|
||||
|
||||
async def periodic_invoice_watcher() -> None:
|
||||
"""Background task: detect paid Lightning invoices and credit balances.
|
||||
|
||||
Removes the need for clients to poll the status endpoint after paying.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
async with create_session() as session:
|
||||
now = int(time.time())
|
||||
result = await session.exec(
|
||||
select(LightningInvoice)
|
||||
.where(
|
||||
LightningInvoice.status == "pending",
|
||||
col(LightningInvoice.expires_at) > now,
|
||||
)
|
||||
.limit(INVOICE_WATCH_BATCH_LIMIT)
|
||||
)
|
||||
pending = result.all()
|
||||
for invoice in pending:
|
||||
try:
|
||||
await check_invoice_payment(invoice, session)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Invoice watcher failed for invoice",
|
||||
extra={"invoice_id": invoice.id, "error": str(e)},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Invoice watcher loop error: {e}")
|
||||
|
||||
await asyncio.sleep(INVOICE_WATCH_INTERVAL_SECONDS)
|
||||
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)
|
||||
@@ -6,10 +6,10 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from ..core.logging import get_logger
|
||||
from ..core.settings import settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -62,7 +62,9 @@ async def query_nostr_relay_for_providers(
|
||||
|
||||
if data[0] == "EVENT" and data[1] == sub_id:
|
||||
event = data[2]
|
||||
logger.debug(f"Found provider announcement: {event['id']}")
|
||||
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")
|
||||
@@ -70,8 +72,6 @@ async def query_nostr_relay_for_providers(
|
||||
elif data[0] == "NOTICE":
|
||||
try:
|
||||
msg = str(data[1])
|
||||
if len(msg) > 200:
|
||||
msg = msg[:200] + "..."
|
||||
logger.debug(f"Relay notice: {msg}")
|
||||
except Exception:
|
||||
logger.debug("Relay notice received")
|
||||
@@ -387,6 +387,9 @@ async def get_providers(
|
||||
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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user