mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
1248 Commits
test
...
fix-reserv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd8b01e866 | ||
|
|
4b74a9cf81 | ||
|
|
3be2da6728 | ||
|
|
17d690e77e | ||
|
|
bf05c96dfc | ||
|
|
ed6e0c0189 | ||
|
|
412bfe479c | ||
|
|
861fda21b7 | ||
|
|
919dcf5535 | ||
|
|
cbc424e8e7 | ||
|
|
068fb3572f | ||
|
|
eaf74edbba | ||
|
|
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 | ||
|
|
9b2a4a8ff8 | ||
|
|
320cfe82fd | ||
|
|
8d4691e7f6 | ||
|
|
d7c5d7ce41 | ||
|
|
2da2f96118 | ||
|
|
8cb73f4528 | ||
|
|
e3b146b83f | ||
|
|
b7e4fbf739 | ||
|
|
2c8ba93312 | ||
|
|
cfe03d6dcb | ||
|
|
80b6acbf4b | ||
|
|
bd88a84cd4 | ||
|
|
45f5ba96a8 | ||
|
|
4b935a6f4d | ||
|
|
c9f458b8ba | ||
|
|
d9d2e17e5d | ||
|
|
3d6bd65a64 | ||
|
|
8c08be9e11 | ||
|
|
c49da9bf84 | ||
|
|
c2b97f8e3b | ||
|
|
74480df47d | ||
|
|
f5c9cde852 | ||
|
|
88fbefbd18 | ||
|
|
ded82cd729 | ||
|
|
1e90b223cf | ||
|
|
b8a1d69924 | ||
|
|
33b19ba98b | ||
|
|
64bf8aec3f | ||
|
|
3a69491ac0 | ||
|
|
44286067ae | ||
|
|
1c7cbf64ef | ||
|
|
0c3beba74f | ||
|
|
1b6188b130 | ||
|
|
4859dbd163 | ||
|
|
a3d9022e2b | ||
|
|
dc4dbb4cff | ||
|
|
fc97e75602 | ||
|
|
293f8471b3 | ||
|
|
beedfdc1d2 | ||
|
|
6e7be9695e | ||
|
|
01d40009a3 | ||
|
|
557080d0b2 | ||
|
|
baa2e7038c | ||
|
|
39047361d6 | ||
|
|
57c0defbd7 | ||
|
|
887bd14774 | ||
|
|
b37645cfbe | ||
|
|
4df56571be | ||
|
|
ff4c2c418c | ||
|
|
db0f6e65ef | ||
|
|
aa9019538f | ||
|
|
4537e21ae0 | ||
|
|
079918a0cd | ||
|
|
a88f999dc5 | ||
|
|
968ebaf865 | ||
|
|
32253964dc | ||
|
|
74f4cb3a31 | ||
|
|
0cf0606822 | ||
|
|
b6fbe3b810 | ||
|
|
68d62e08df | ||
|
|
d62ec19ddf | ||
|
|
e754fd506f | ||
|
|
39a0a2939a | ||
|
|
7d95a6187a | ||
|
|
862df136d6 | ||
|
|
a72653a401 | ||
|
|
aa6747d444 | ||
|
|
62305c3416 | ||
|
|
f3c0212ae3 | ||
|
|
d8d91ab57b | ||
|
|
a516c2f04e | ||
|
|
bcadf2961c | ||
|
|
221838a750 | ||
|
|
59cd84acbc | ||
|
|
f7fc5ba5d7 | ||
|
|
8b0ced22e5 | ||
|
|
8bb1e0d321 | ||
|
|
2f2f3ce098 | ||
|
|
17af3aa2eb | ||
|
|
2c507796eb | ||
|
|
c3c4e886c8 | ||
|
|
430bf8a610 | ||
|
|
cf9a6d83ff | ||
|
|
82e01e3e13 | ||
|
|
2ed72d16da | ||
|
|
65a211388e | ||
|
|
014eba550d | ||
|
|
86e4b99297 | ||
|
|
3c81a77ea2 | ||
|
|
8a09c4cf7e | ||
|
|
05f3ce1a43 | ||
|
|
f3e8718660 | ||
|
|
5279eadec3 | ||
|
|
e146b4a08a | ||
|
|
fac8db9cfb | ||
|
|
57cace0e77 | ||
|
|
2121b36e79 | ||
|
|
90f56357d9 | ||
|
|
793f33efb4 | ||
|
|
769ad00e42 | ||
|
|
3716184ea9 | ||
|
|
5f38f1a36b | ||
|
|
b85b81ac22 | ||
|
|
307e81bd61 | ||
|
|
65c19bc375 | ||
|
|
f9eaf48f45 | ||
|
|
b3f3f68dd9 | ||
|
|
3420ac73d7 | ||
|
|
ce77e888ba | ||
|
|
28fda20b6e | ||
|
|
7e173d779f | ||
|
|
855061cc49 | ||
|
|
5b80dfacda | ||
|
|
8950cc5b7f | ||
|
|
dc0f7d7f3b | ||
|
|
45c8bfce1d | ||
|
|
4d28586d13 | ||
|
|
0da08fb945 | ||
|
|
af6ecbdd3e | ||
|
|
4641f40278 | ||
|
|
0c1fa27257 | ||
|
|
24aceb2b07 | ||
|
|
229702a983 | ||
|
|
eda1f8dadf | ||
|
|
71ed601269 | ||
|
|
a8b7554334 | ||
|
|
e0341b06a5 | ||
|
|
be7e1ff4a3 | ||
|
|
8960501f6b | ||
|
|
8d9c9d93eb | ||
|
|
6c66a37dfa | ||
|
|
a33193e4a3 | ||
|
|
da6e0a4c59 | ||
|
|
764bc3d7e8 | ||
|
|
1557a7c58d | ||
|
|
d69a8f76e1 | ||
|
|
78821929a6 | ||
|
|
401728582f | ||
|
|
98aa886477 | ||
|
|
81ac14bbc5 | ||
|
|
036e04467e | ||
|
|
6d6651acbe | ||
|
|
19972bc8fc | ||
|
|
fe2b1846f7 | ||
|
|
859b31e2bc | ||
|
|
80a7f5d2eb | ||
|
|
fa0b2834c5 | ||
|
|
d693b559c0 | ||
|
|
4d09867a9a | ||
|
|
78d99a7462 | ||
|
|
523816db30 | ||
|
|
7e6e0f806b | ||
|
|
9096a6c30d | ||
|
|
61a0559f8e | ||
|
|
4b36fb8d6f | ||
|
|
6538427782 | ||
|
|
123353bf60 | ||
|
|
8beab0e28b | ||
|
|
384a149600 | ||
|
|
c56a06f3df | ||
|
|
325abad736 | ||
|
|
719c091145 | ||
|
|
c90abe9e79 | ||
|
|
36d55216fe | ||
|
|
f657859249 | ||
|
|
f8090f5c35 | ||
|
|
b2e5da4b46 | ||
|
|
729f00caa2 | ||
|
|
54d7a5a247 | ||
|
|
ecbe3158c0 | ||
|
|
bac50f2150 | ||
|
|
150f3084c1 | ||
|
|
d4e1715613 | ||
|
|
c2a26a3c00 | ||
|
|
8e0ae6cbc8 | ||
|
|
18a055ee2c | ||
|
|
c5289364e4 | ||
|
|
5fb583be55 | ||
|
|
4bf02226dc | ||
|
|
c8f4e7d3e5 | ||
|
|
3d4a8e0e14 | ||
|
|
8b9d53be3c | ||
|
|
11a01dae89 | ||
|
|
9ab269dd8f | ||
|
|
c263d38732 | ||
|
|
c4f84cd1e6 | ||
|
|
0478b1c360 | ||
|
|
633ae39622 | ||
|
|
84ff9f5dc3 | ||
|
|
88ec3395c7 | ||
|
|
49485435ad | ||
|
|
d80df28b23 | ||
|
|
4cdbf23ec0 | ||
|
|
73d3613301 | ||
|
|
6993e6b156 | ||
|
|
6d1525748d | ||
|
|
57ac38e6dc | ||
|
|
2e42af61bb | ||
|
|
7dfc2f4056 | ||
|
|
2b045c95c7 | ||
|
|
665b3f9a16 | ||
|
|
dcbaf7413a | ||
|
|
918a083a12 | ||
|
|
7c9265b40d | ||
|
|
44030ddbe5 | ||
|
|
be372c48ea | ||
|
|
939a991d6b | ||
|
|
bb9991e7a5 | ||
|
|
496900baa6 | ||
|
|
8f22826d63 | ||
|
|
15e61d1760 | ||
|
|
a4f28db887 | ||
|
|
db2c6eb98a | ||
|
|
8286f4f0cc | ||
|
|
7761d144f6 | ||
|
|
14718843de | ||
|
|
a28277a0a8 | ||
|
|
d2b8a4e78b | ||
|
|
ac24f10cb0 | ||
|
|
54fe4e27c0 | ||
|
|
cc9e387d3a | ||
|
|
f499be5c15 | ||
|
|
12b88de226 | ||
|
|
a2ea4e60b0 | ||
|
|
619f601cd4 | ||
|
|
f4dbea95f7 | ||
|
|
b28aa20257 | ||
|
|
331a3bebbd | ||
|
|
9b70919d65 | ||
|
|
f09d1991f0 | ||
|
|
e7995dc6b0 | ||
|
|
a62446dbd2 | ||
|
|
1daa5a9a6b | ||
|
|
983ab04e6d | ||
|
|
f94035a74f | ||
|
|
c6b0f4e46d | ||
|
|
3368eb6ef2 | ||
|
|
fa026ee580 | ||
|
|
951eb32c2b | ||
|
|
124fbedf2a | ||
|
|
fa4b78abec | ||
|
|
207cafe285 | ||
|
|
ad068877ac | ||
|
|
c0d115b1ab | ||
|
|
e28ff2cc68 | ||
|
|
f5ef3770f6 | ||
|
|
8e4a08e969 | ||
|
|
4b40e9010d | ||
|
|
0fc4e3cb97 | ||
|
|
b87a3fd88c | ||
|
|
8f9730d87f | ||
|
|
8f6f1d6d22 | ||
|
|
3834720333 | ||
|
|
501b6e6292 | ||
|
|
828e7ced10 | ||
|
|
7e988f94e9 | ||
|
|
8462b64f79 | ||
|
|
65084b1783 | ||
|
|
2311aa981e | ||
|
|
56485bffc3 | ||
|
|
f1d4dd6ff4 | ||
|
|
11c8902dde | ||
|
|
bedca877ce | ||
|
|
ea01122652 | ||
|
|
efa3e18bf9 | ||
|
|
131ccdf5e4 | ||
|
|
9622346cc2 | ||
|
|
6595b30701 | ||
|
|
d4c1389249 | ||
|
|
73e701895d | ||
|
|
394bfe1762 | ||
|
|
f66e83d58a | ||
|
|
14e0f821df | ||
|
|
9ffd44eac6 | ||
|
|
73e6e259bc | ||
|
|
5061d69f57 | ||
|
|
b478f29ee7 | ||
|
|
9a6d976ee5 | ||
|
|
8175188b06 | ||
|
|
6a746eb3df | ||
|
|
441ed82a82 | ||
|
|
93f2bf98b8 | ||
|
|
0ad60853d4 | ||
|
|
d1268c3026 | ||
|
|
763813507a | ||
|
|
05b27315ee | ||
|
|
88d6e22918 | ||
|
|
14b73c2dcc | ||
|
|
cb35168587 | ||
|
|
06050196d4 | ||
|
|
d7e35887de | ||
|
|
6c53c0661c | ||
|
|
00f5ee1dfe | ||
|
|
74d603dc12 | ||
|
|
cb91136192 | ||
|
|
3fd6eee9af | ||
|
|
8c6a6f65a5 | ||
|
|
dbc4f68ea3 | ||
|
|
4eac998bc3 | ||
|
|
2b94185918 | ||
|
|
6d46f86964 | ||
|
|
62dd42f418 | ||
|
|
a42f3b63f3 | ||
|
|
44c2dd1e30 | ||
|
|
cf64210eeb | ||
|
|
8ed75325a1 | ||
|
|
f2b73f5600 | ||
|
|
44d8b8f738 | ||
|
|
be4616e608 | ||
|
|
cc52857a9e | ||
|
|
45a81eabf2 | ||
|
|
558b6339d9 | ||
|
|
b4c69df891 | ||
|
|
de1f40b350 | ||
|
|
5831c3e4d4 | ||
|
|
d036b7ac24 | ||
|
|
a629b903e0 | ||
|
|
ed99996985 | ||
|
|
be9a71b3e9 | ||
|
|
5a0ecad6f1 | ||
|
|
e9302bdfcb | ||
|
|
3af531aacd | ||
|
|
47d65b57f7 | ||
|
|
983b3a1b23 | ||
|
|
35134e5401 | ||
|
|
cb72d73ced | ||
|
|
dbd9b72a23 | ||
|
|
6a835047d3 | ||
|
|
4a338505cc | ||
|
|
0270bf2ca6 | ||
|
|
9ae0e4a238 | ||
|
|
5e442d139c | ||
|
|
f784a2a36d | ||
|
|
78c0ee73b7 | ||
|
|
31f53f7904 | ||
|
|
9f485e4dbb | ||
|
|
cea6ddcd03 | ||
|
|
7fce5318b6 | ||
|
|
56cc14928c | ||
|
|
97cdc0dbcd | ||
|
|
cf24cefc1f | ||
|
|
71dbbe44dd | ||
|
|
8e8c32151d | ||
|
|
84b903a6c2 | ||
|
|
f8665400cd | ||
|
|
9e41f05742 | ||
|
|
150918c5a7 | ||
|
|
9f89e0485e | ||
|
|
ff3d268192 | ||
|
|
d5800335a6 | ||
|
|
1858f7dd28 | ||
|
|
c300082fd7 | ||
|
|
192518c9df | ||
|
|
558d442cd1 | ||
|
|
07257d5682 | ||
|
|
f4d6762baa | ||
|
|
dda016669f | ||
|
|
5d3d80c386 | ||
|
|
9bab7aee7d | ||
|
|
6924f6c18a | ||
|
|
8360d2a6fd | ||
|
|
de5c2bd502 | ||
|
|
eeb458f8fe | ||
|
|
185f060b54 | ||
|
|
9c4b827d74 | ||
|
|
b552aea520 | ||
|
|
5173e0f133 | ||
|
|
67348d7fb2 | ||
|
|
4c15114fc0 | ||
|
|
3d42960d20 | ||
|
|
4c3d11f09c | ||
|
|
432fa25ff8 | ||
|
|
c35464fb11 | ||
|
|
bb32844104 | ||
|
|
ec5eeae49f | ||
|
|
d75939b547 | ||
|
|
8a8aeeab81 | ||
|
|
7eb88346db | ||
|
|
0451ca5bd9 | ||
|
|
a87793b395 | ||
|
|
6b9417aa68 | ||
|
|
277924c777 | ||
|
|
dedb519d9a | ||
|
|
2a934dbda4 | ||
|
|
d1cb123f91 | ||
|
|
6b1f432c58 | ||
|
|
8dc683e6ed | ||
|
|
dff80b9e5d | ||
|
|
f2890d819e | ||
|
|
48b40e196b | ||
|
|
7b405258ad | ||
|
|
491dd48eee | ||
|
|
88a0a41201 | ||
|
|
503e861621 | ||
|
|
1cfa8cbee4 | ||
|
|
194c1d457d | ||
|
|
efb5247cdf | ||
|
|
37c00d1e55 | ||
|
|
34859d94d3 | ||
|
|
2698d0fa81 | ||
|
|
12d2d3714f | ||
|
|
b3208ffe61 | ||
|
|
c31f0f1603 | ||
|
|
65ef300a06 | ||
|
|
13c70722b5 | ||
|
|
c2acaec288 | ||
|
|
1850f7b5b9 | ||
|
|
30756816c4 | ||
|
|
59924661cd | ||
|
|
c85d6423be | ||
|
|
e5e4888dba | ||
|
|
941bb5f052 | ||
|
|
94de9c68c8 | ||
|
|
8bb2191fcc | ||
|
|
a85d7157a3 | ||
|
|
1036a6d85a | ||
|
|
580dd375b6 | ||
|
|
ead00ec25a | ||
|
|
922b0f15a6 | ||
|
|
286c0a7d02 | ||
|
|
f0bb897f75 | ||
|
|
2aebef7722 | ||
|
|
eb5d832719 | ||
|
|
9ad4661111 | ||
|
|
e032723294 | ||
|
|
00b8933758 | ||
|
|
2918548990 | ||
|
|
dd9a133a99 | ||
|
|
2e8140daa0 | ||
|
|
ba987485f2 | ||
|
|
5484e606b8 | ||
|
|
e12c62a133 | ||
|
|
2cfd537d2e | ||
|
|
0558270c05 | ||
|
|
0babd93504 | ||
|
|
6fdbdb2bba | ||
|
|
53c699043c | ||
|
|
6b02a40a06 | ||
|
|
e4ee03f7a0 | ||
|
|
25371d2acc | ||
|
|
6986723388 | ||
|
|
c505db3596 | ||
|
|
77e6896ff3 | ||
|
|
477c67ebeb | ||
|
|
67604242de | ||
|
|
6454b604eb | ||
|
|
632857b52a | ||
|
|
a8c7897fd9 | ||
|
|
1a841432c4 | ||
|
|
037f4222fc | ||
|
|
17414065d1 | ||
|
|
d1aef23626 | ||
|
|
a6b756cff0 | ||
|
|
f428410a24 | ||
|
|
ba74932120 | ||
|
|
63dc47008f | ||
|
|
9612461fe9 | ||
|
|
ca748edaf5 | ||
|
|
95e320f823 | ||
|
|
5d82dc4d26 | ||
|
|
013f17a5aa | ||
|
|
384cc2be21 | ||
|
|
76ea74e8ce | ||
|
|
b7576663b5 | ||
|
|
92318f0b00 | ||
|
|
5dce680d11 | ||
|
|
eef07fabfa | ||
|
|
bd354ac3e8 | ||
|
|
01beaa93c6 | ||
|
|
3845b11951 | ||
|
|
5afa0ba4bc | ||
|
|
490e74e39d | ||
|
|
d1d2417197 | ||
|
|
0bba63ca4d | ||
|
|
91d39f3abe | ||
|
|
1fd4badff4 | ||
|
|
f91bed3532 | ||
|
|
da7c852210 | ||
|
|
346c239b06 | ||
|
|
7323b2f536 | ||
|
|
9bcaa84014 | ||
|
|
22aa55e3d6 | ||
|
|
416730a3e8 | ||
|
|
51001bb31a | ||
|
|
dbbb1b51b8 | ||
|
|
01e14d3f35 | ||
|
|
586a8f19e0 | ||
|
|
1482fef8e1 | ||
|
|
bc3f4d611a | ||
|
|
f8e805088b | ||
|
|
ac648cfc43 | ||
|
|
1cb88603ec | ||
|
|
b4e919ebba | ||
|
|
731bd8d274 | ||
|
|
658fc0c4f5 | ||
|
|
2a56998450 | ||
|
|
d94a27d16e | ||
|
|
6bc963de77 | ||
|
|
a7831952b9 | ||
|
|
bc12d23f5b | ||
|
|
7f9555227f | ||
|
|
c81f024d7d | ||
|
|
bc651f67e8 | ||
|
|
13d6d5bd60 | ||
|
|
b313017492 | ||
|
|
c6d7939d0a | ||
|
|
72b6d5afbd | ||
|
|
4755a8c0e0 | ||
|
|
ca29526761 | ||
|
|
07600b9ff8 | ||
|
|
1b1279f111 | ||
|
|
179e27953c | ||
|
|
e0c93cafd6 | ||
|
|
4266f70256 | ||
|
|
a968b8a086 | ||
|
|
00c1e58504 | ||
|
|
ff5b512bb8 | ||
|
|
067deda386 | ||
|
|
7137a662e6 | ||
|
|
6cabd736f4 | ||
|
|
1bbff6c057 | ||
|
|
e8bed5b20f | ||
|
|
8317af969c | ||
|
|
ed7f86ce01 | ||
|
|
848af6992a | ||
|
|
27c5721cd8 | ||
|
|
90f8fb6a0d | ||
|
|
a88bd0b807 | ||
|
|
588ad0e7e9 | ||
|
|
ee38d0011c | ||
|
|
f37d9c9c73 | ||
|
|
6d12af65b1 | ||
|
|
a2cbc6060d | ||
|
|
45c7adf65c | ||
|
|
d253195d06 | ||
|
|
b1e0d82d61 | ||
|
|
93ebbc77d8 | ||
|
|
5dfc4fcba9 | ||
|
|
c183006317 | ||
|
|
dabf20db4b |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.env
|
||||
.venv
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
compose.yml
|
||||
compose.testing.yml
|
||||
.todo
|
||||
.github
|
||||
.vscode
|
||||
.DS_Store
|
||||
**/node_modules
|
||||
ui/.next
|
||||
76
.env.example
76
.env.example
@@ -1,48 +1,44 @@
|
||||
NAME = "Your Routstr Proxy Name"
|
||||
# Core Configuration
|
||||
UPSTREAM_BASE_URL=https://api.openai.com/v1
|
||||
UPSTREAM_API_KEY=your-upstream-api-key
|
||||
|
||||
DESCRIPTION = "A short Description"
|
||||
# ADMIN_PASSWORD=secure-admin-password
|
||||
|
||||
# Any openai-compatible api endpoint
|
||||
UPSTREAM_BASE_URL="https://api.openai.com/v1"
|
||||
UPSTREAM_API_KEY="sk-21212121212121212121212121212121"
|
||||
# Database
|
||||
# DATABASE_URL=sqlite+aiosqlite:///keys.db
|
||||
|
||||
# Lightning address used to receive funds
|
||||
RECEIVE_LN_ADDRESS="shroominic@walletofsatoshi.com"
|
||||
# Node Information
|
||||
# NAME=My Routstr Node
|
||||
# DESCRIPTION=Fast AI API access with Bitcoin payments
|
||||
# NSEC=nsec1...
|
||||
# HTTP_URL=https://api.mynode.com
|
||||
# ONION_URL=http://mynode.onion (auto fetched from compose)
|
||||
# RELAYS="wss://relay.damus.io,wss://relay.nostr.band,wss://eden.nostr.land,wss://relay.routstr.com"
|
||||
# ENABLE_ANALYTICS_SHARING=true
|
||||
# CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org,https://ecashmint.otrta.me"
|
||||
# RECEIVE_LN_ADDRESS=
|
||||
|
||||
# A prepaid key you can use when you are using it yourself or while testing. Api-key doesn't need to be in any specific format but it should start with "sk-""
|
||||
PREPAID_API_KEY="sk-" # Add any string/hash here. Replace with a new string for a new API
|
||||
PREPAID_BALANCE="10000" # 10k sats
|
||||
# Custom Pricing Configuration
|
||||
# MODEL_BASED_PRICING=true
|
||||
# COST_PER_REQUEST=1
|
||||
# COST_PER_1K_INPUT_TOKENS=0
|
||||
# COST_PER_1K_OUTPUT_TOKENS=0
|
||||
# EXCHANGE_FEE=1.005
|
||||
# UPSTREAM_PROVIDER_FEE=1.05
|
||||
|
||||
# When your cashu balance reaches this number of sats, send the funds to RECEIVE_LN_ADDRESS.
|
||||
MINIMUM_PAYOUT = "100"
|
||||
# Network Configuration
|
||||
# CORS_ORIGINS=*
|
||||
# TOR_PROXY_URL=socks5://127.0.0.1:9050
|
||||
|
||||
# Costs in Sats, if MODEL_BASED_PRICING is set to false
|
||||
COST_PER_REQUEST="10"
|
||||
COST_PER_1K_INPUT_TOKENS = "0"
|
||||
COST_PER_1K_OUTPUT_TOKENS = "0"
|
||||
# Logging
|
||||
# LOG_LEVEL=INFO
|
||||
# ENABLE_CONSOLE_LOGGING=true
|
||||
|
||||
# If set to true, pricing is loaded from the file specified by MODELS_PATH
|
||||
# Defaults to "models.json" and falls back to "models.example.json" if missing
|
||||
MODEL_BASED_PRICING = "false"
|
||||
# MODELS_PATH="models.json"
|
||||
# Custom Model Management
|
||||
# BASE_URL=https://openrouter.ai/api/v1
|
||||
# MODELS_PATH=models.json
|
||||
# SOURCE=
|
||||
|
||||
# Time in seconds between each automatically refunding funds to users whose API keys have expired
|
||||
# Setting this to "0" disables automatic refunds
|
||||
REFUND_PROCESSING_INTERVAL = "3600"
|
||||
|
||||
|
||||
# password used to log into admin interface
|
||||
ADMIN_PASSWORD="XXX"
|
||||
|
||||
# NPUB of Nostr account
|
||||
NSEC=""
|
||||
NPUB="npub..."
|
||||
|
||||
# Not used currently
|
||||
HTTP_URL=""
|
||||
|
||||
# Not used currently
|
||||
ONION_URL="XXX.onion"
|
||||
|
||||
RELAYS="wss://relay.damus.io,wss://relay.nostr.band"
|
||||
CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org"
|
||||
# UI Configuration (for Next.js frontend)
|
||||
# These variables are prefixed with NEXT_PUBLIC_ to be accessible in the browser
|
||||
# NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
|
||||
|
||||
18
.github/workflows/container.yml
vendored
18
.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
|
||||
@@ -25,13 +33,15 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Lowercase and set image tag
|
||||
run: echo "IMAGE_TAG=ghcr.io/$(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]'):latest" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push Docker image
|
||||
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: |
|
||||
${{ env.IMAGE_TAG }}
|
||||
ghcr.io/routstr/proxy:latest
|
||||
ghcr.io/routstr/core:latest
|
||||
|
||||
56
.github/workflows/docs.yml
vendored
Normal file
56
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Deploy MkDocs to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "mkdocs.yml"
|
||||
- ".github/workflows/docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -r docs/requirements.txt
|
||||
|
||||
- name: Build MkDocs site
|
||||
run: mkdocs build --strict --verbose
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./site
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
40
.github/workflows/test.yml
vendored
40
.github/workflows/test.yml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
branches: ["*"] # Run on PRs to all branches
|
||||
|
||||
jobs:
|
||||
test:
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -36,6 +36,9 @@ jobs:
|
||||
uv run mypy .
|
||||
|
||||
- name: Run tests with pytest
|
||||
env:
|
||||
UPSTREAM_BASE_URL: "http://test"
|
||||
UPSTREAM_API_KEY: "test"
|
||||
run: |
|
||||
uv run pytest --verbose --tb=short
|
||||
|
||||
@@ -48,3 +51,38 @@ jobs:
|
||||
pytest.xml
|
||||
.coverage
|
||||
retention-days: 30
|
||||
|
||||
ui-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: ui/pnpm-lock.yaml
|
||||
|
||||
- name: Install UI dependencies
|
||||
working-directory: ./ui
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run UI format check
|
||||
working-directory: ./ui
|
||||
run: pnpm run format-check
|
||||
|
||||
- name: Run UI linting
|
||||
working-directory: ./ui
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Run UI build
|
||||
working-directory: ./ui
|
||||
run: pnpm run build
|
||||
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -3,12 +3,25 @@ __pycache__
|
||||
keys.db
|
||||
wallet.sqlite3
|
||||
|
||||
# Python build artifacts
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
*.egg
|
||||
.mypy_cache/**
|
||||
|
||||
# Development
|
||||
.notes
|
||||
.*keys.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.*wallet.sqlite3
|
||||
*models.json
|
||||
.cashu
|
||||
.relay
|
||||
relay-data
|
||||
.dockerignore
|
||||
relay-data
|
||||
|
||||
compose.override.yml
|
||||
|
||||
@@ -22,3 +35,6 @@ logs/*
|
||||
|
||||
# deployment
|
||||
proof_backups
|
||||
|
||||
*.todo
|
||||
ui_out
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.11
|
||||
5
.todo
5
.todo
@@ -1,5 +0,0 @@
|
||||
- test if currency and payment amount is correct
|
||||
- test payout
|
||||
|
||||
- make tor work
|
||||
-
|
||||
370
CONTRIBUTING.md
Normal file
370
CONTRIBUTING.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# Contributing to Routstr Proxy
|
||||
|
||||
We welcome contributions to Routstr Proxy! This document provides guidelines and instructions for contributing to the project.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Code Standards](#code-standards)
|
||||
- [Testing](#testing)
|
||||
- [Submitting Changes](#submitting-changes)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Documentation](#documentation)
|
||||
- [Release Process](#release-process)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.11 or higher
|
||||
- [uv](https://docs.astral.sh/uv/) package manager
|
||||
- Docker and Docker Compose (optional, for integration tests)
|
||||
- Git
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Fork and clone the repository**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/routstr-proxy.git
|
||||
cd routstr-proxy
|
||||
```
|
||||
|
||||
2. **Set up the development environment**
|
||||
|
||||
```bash
|
||||
make setup
|
||||
```
|
||||
|
||||
This will:
|
||||
- Install `uv` if not already installed
|
||||
- Create a virtual environment
|
||||
- Install all dependencies including dev tools
|
||||
- Install the project in editable mode
|
||||
|
||||
3. **Configure environment variables**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
```
|
||||
|
||||
4. **Verify your setup**
|
||||
|
||||
```bash
|
||||
make check-deps
|
||||
make test-unit
|
||||
```
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Python Style Guide
|
||||
|
||||
We use modern Python 3.11+ features and enforce strict type checking:
|
||||
|
||||
- **Type Hints**: All functions must have complete type annotations
|
||||
|
||||
```python
|
||||
# ✅ Good
|
||||
def calculate_cost(tokens: int, price_per_token: float) -> dict[str, float]:
|
||||
return {"total": tokens * price_per_token}
|
||||
|
||||
# ❌ Bad
|
||||
def calculate_cost(tokens, price_per_token):
|
||||
return {"total": tokens * price_per_token}
|
||||
```
|
||||
|
||||
- **Type Syntax**: Use Python 3.11+ lowercase types
|
||||
|
||||
```python
|
||||
# ✅ Good
|
||||
def process_items(items: list[dict[str, str | None]]) -> dict[str, int]:
|
||||
...
|
||||
|
||||
# ❌ Bad
|
||||
from typing import List, Dict, Optional
|
||||
def process_items(items: List[Dict[str, Optional[str]]]) -> Dict[str, int]:
|
||||
...
|
||||
```
|
||||
|
||||
- **Comments**: Only add comments for non-obvious logic. Code should be self-documenting
|
||||
|
||||
```python
|
||||
# ✅ Good - complex business logic explained
|
||||
# Apply exponential backoff with jitter to prevent thundering herd
|
||||
delay = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay)
|
||||
|
||||
# ❌ Bad - obvious comment
|
||||
# Increment counter by 1
|
||||
counter += 1
|
||||
```
|
||||
|
||||
### Code Quality Tools
|
||||
|
||||
We enforce code quality using:
|
||||
|
||||
- **Ruff**: For linting and formatting
|
||||
|
||||
```bash
|
||||
make lint # Check for issues
|
||||
make format # Auto-fix formatting
|
||||
```
|
||||
|
||||
- **Mypy**: For type checking
|
||||
|
||||
```bash
|
||||
make type-check
|
||||
```
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
|
||||
|
||||
```text
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
Types:
|
||||
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `docs`: Documentation changes
|
||||
- `style`: Code style changes (formatting, etc.)
|
||||
- `refactor`: Code refactoring
|
||||
- `test`: Test additions or fixes
|
||||
- `chore`: Build process or auxiliary tool changes
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
feat(proxy): add support for streaming responses
|
||||
|
||||
fix(wallet): handle expired tokens correctly
|
||||
|
||||
docs: update API documentation for v2 endpoints
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Structure
|
||||
|
||||
Tests are organized into:
|
||||
|
||||
- `tests/unit/` - Fast, isolated unit tests
|
||||
- `tests/integration/` - Integration tests (can use mocks or real services)
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests (unit + integration with mocks)
|
||||
make test
|
||||
|
||||
# Run specific test suites
|
||||
make test-unit # Unit tests only
|
||||
make test-integration # Integration tests with mocks
|
||||
make test-integration-docker # Integration tests with real services
|
||||
make test-performance # Performance benchmarks
|
||||
|
||||
# Advanced testing
|
||||
make test-coverage # Generate coverage report
|
||||
make test-fast # Skip slow tests
|
||||
make test-failed # Re-run only failed tests
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
1. **Use pytest fixtures** for reusable test setup
|
||||
2. **Mark async tests** with `@pytest.mark.asyncio`
|
||||
3. **Use appropriate markers**:
|
||||
|
||||
```python
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.requires_docker
|
||||
async def test_complex_integration():
|
||||
...
|
||||
```
|
||||
|
||||
4. **Follow the AAA pattern**: Arrange, Act, Assert
|
||||
|
||||
```python
|
||||
async def test_token_validation():
|
||||
# Arrange
|
||||
token = create_test_token(amount=1000)
|
||||
|
||||
# Act
|
||||
result = await validate_token(token)
|
||||
|
||||
# Assert
|
||||
assert result.is_valid
|
||||
assert result.amount == 1000
|
||||
```
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
### Pull Request Process
|
||||
|
||||
1. **Create a feature branch**
|
||||
|
||||
```bash
|
||||
git checkout -b feat/your-feature-name
|
||||
```
|
||||
|
||||
2. **Make your changes**
|
||||
- Write code following our standards
|
||||
- Add or update tests
|
||||
- Update documentation if needed
|
||||
|
||||
3. **Run quality checks**
|
||||
|
||||
```bash
|
||||
make lint
|
||||
make type-check
|
||||
make test
|
||||
```
|
||||
|
||||
4. **Commit your changes**
|
||||
- Use conventional commit messages
|
||||
- Keep commits focused and atomic
|
||||
|
||||
5. **Push and create a PR**
|
||||
- Push to your fork
|
||||
- Create a PR against the `main` branch
|
||||
- Fill out the PR template completely
|
||||
- Link any related issues
|
||||
|
||||
### PR Review Checklist
|
||||
|
||||
Before requesting review, ensure:
|
||||
|
||||
- [ ] All tests pass
|
||||
- [ ] Code follows style guidelines
|
||||
- [ ] Type hints are complete and correct
|
||||
- [ ] Documentation is updated
|
||||
- [ ] Commit messages follow conventions
|
||||
- [ ] No unnecessary changes outside scope
|
||||
|
||||
### What to Expect
|
||||
|
||||
- Reviews typically happen within 2-3 business days
|
||||
- Be prepared to make changes based on feedback
|
||||
- Engage constructively in discussions
|
||||
- Once approved, a maintainer will merge your PR
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
routstr-proxy/
|
||||
├── routstr/ # Main application code
|
||||
│ ├── core/ # Core functionality
|
||||
│ │ ├── admin.py # Admin interface
|
||||
│ │ ├── db.py # Database models and operations
|
||||
│ │ ├── logging.py # Logging configuration
|
||||
│ │ └── main.py # FastAPI app initialization
|
||||
│ ├── payment/ # Payment processing
|
||||
│ │ ├── cost_calculation.py
|
||||
│ │ ├── models.py
|
||||
│ │ └── x_cashu.py # Cashu integration
|
||||
│ ├── auth.py # Authentication
|
||||
│ ├── proxy.py # Request proxying logic
|
||||
│ └── wallet.py # Wallet management
|
||||
├── tests/ # Test suite
|
||||
│ ├── unit/ # Unit tests
|
||||
│ └── integration/ # Integration tests
|
||||
├── scripts/ # Utility scripts
|
||||
├── compose.yml # Docker compose for production
|
||||
├── compose.testing.yml # Docker compose for testing
|
||||
├── Makefile # Development commands
|
||||
└── pyproject.toml # Project configuration
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
- **FastAPI Application**: Main API server in `routstr/core/main.py`
|
||||
- **Database Models**: SQLModel definitions in `routstr/core/db.py`
|
||||
- **Payment Logic**: Cashu integration and cost calculation in `routstr/payment/`
|
||||
- **Proxy Handler**: Request forwarding logic in `routstr/proxy.py`
|
||||
|
||||
## Documentation
|
||||
|
||||
### Code Documentation
|
||||
|
||||
- Use descriptive variable and function names
|
||||
- Add docstrings for public APIs:
|
||||
|
||||
```python
|
||||
async def redeem_token(token: str, mint_url: str) -> RedemptionResult:
|
||||
"""Redeem a Cashu token and credit the account.
|
||||
|
||||
Args:
|
||||
token: Base64-encoded Cashu token
|
||||
mint_url: URL of the Cashu mint
|
||||
|
||||
Returns:
|
||||
RedemptionResult with amount and status
|
||||
|
||||
Raises:
|
||||
TokenInvalidError: If token is malformed or expired
|
||||
MintConnectionError: If mint is unreachable
|
||||
"""
|
||||
```
|
||||
|
||||
### API Documentation
|
||||
|
||||
- Update OpenAPI schemas when adding endpoints
|
||||
- Keep `README.md` examples current
|
||||
- Document environment variables in `.env.example`
|
||||
|
||||
### Architecture Decisions
|
||||
|
||||
For significant changes, create an ADR (Architecture Decision Record) in `docs/adr/`:
|
||||
|
||||
```markdown
|
||||
# ADR-001: Use SQLite for Local Storage
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
We need a simple, embedded database for storing API keys and balances.
|
||||
|
||||
## Decision
|
||||
Use SQLite with SQLModel ORM for type safety and async support.
|
||||
|
||||
## Consequences
|
||||
- No external database required
|
||||
- Simple deployment
|
||||
- Limited concurrent write performance
|
||||
```
|
||||
|
||||
## Release Process
|
||||
|
||||
### Version Numbering
|
||||
|
||||
We use [Semantic Versioning](https://semver.org/):
|
||||
|
||||
- MAJOR: Breaking API changes
|
||||
- MINOR: New features, backwards compatible
|
||||
- PATCH: Bug fixes and minor improvements
|
||||
|
||||
### Release Steps
|
||||
|
||||
1. Update version in `pyproject.toml`
|
||||
2. Update `CHANGELOG.md` with release notes
|
||||
3. Create a git tag: `git tag -a v1.2.3 -m "Release v1.2.3"`
|
||||
4. Push tag: `git push origin v1.2.3`
|
||||
5. GitHub Actions will build and publish Docker images
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Issues**: Check existing issues or create a new one
|
||||
- **Discussions**: Use GitHub Discussions for questions
|
||||
- **Security**: Report security issues privately to maintainers
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the GPLv3 license.
|
||||
31
Dockerfile
31
Dockerfile
@@ -1,27 +1,32 @@
|
||||
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 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
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["/.venv/bin/fastapi", "run", "router", "--host", "0.0.0.0"]
|
||||
CMD ["/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]
|
||||
|
||||
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* ./
|
||||
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"]
|
||||
285
Makefile
Normal file
285
Makefile
Normal file
@@ -0,0 +1,285 @@
|
||||
# Makefile for Routstr Proxy
|
||||
|
||||
# Detect if we're in a virtual environment
|
||||
VENV_EXISTS := $(shell test -d .venv && echo 1)
|
||||
ifeq ($(VENV_EXISTS), 1)
|
||||
PYTHON := .venv/bin/python
|
||||
PYTEST := .venv/bin/pytest
|
||||
RUFF := .venv/bin/ruff
|
||||
MYPY := .venv/bin/mypy
|
||||
ALEMBIC := .venv/bin/alembic
|
||||
else
|
||||
PYTHON := python
|
||||
PYTEST := pytest
|
||||
RUFF := ruff
|
||||
MYPY := mypy
|
||||
ALEMBIC := alembic
|
||||
endif
|
||||
|
||||
.PHONY: help setup test test-unit test-integration test-integration-docker test-all test-fast test-performance clean docker-up docker-down lint format type-check dev-setup check-deps db-upgrade db-downgrade db-current db-history db-migrate db-revision db-heads db-clean ui-build ui-build-docker ui-dev
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " make test - Run all tests (unit + integration with mocks)"
|
||||
@echo " make test-unit - Run unit tests only"
|
||||
@echo " make test-integration - Run integration tests with mocks (fast)"
|
||||
@echo " make test-integration-docker - Run integration tests with Docker services"
|
||||
@echo " make test-all - Run all tests including Docker integration"
|
||||
@echo " make test-fast - Run fast tests only (skip slow tests)"
|
||||
@echo " make test-performance - Run performance tests"
|
||||
@echo " make docker-up - Start Docker test services"
|
||||
@echo " make docker-down - Stop Docker test services"
|
||||
@echo " make clean - Clean up test artifacts and caches"
|
||||
@echo " make lint - Run linting checks"
|
||||
@echo " make format - Format code with ruff"
|
||||
@echo " make type-check - Run mypy type checking"
|
||||
@echo " make dev-setup - Set up development environment"
|
||||
@echo " make check-deps - Check system dependencies"
|
||||
@echo " make setup - First-time project setup"
|
||||
@echo ""
|
||||
@echo "UI targets:"
|
||||
@echo " make ui-build - Build UI for production (static export)"
|
||||
@echo " make ui-build-docker - Build UI using Docker (no Node.js needed)"
|
||||
@echo " make ui-dev - Start UI development server"
|
||||
@echo ""
|
||||
@echo "Docker UI build requires only Docker, no local Node.js installation needed."
|
||||
@echo "Database migration shortcuts:"
|
||||
@echo " make create-migration - Auto-generate new migration"
|
||||
@echo " make db-upgrade - Apply all pending migrations"
|
||||
@echo " make db-downgrade - Downgrade one migration"
|
||||
|
||||
# First-time setup
|
||||
setup: check-deps dev-setup
|
||||
@echo ""
|
||||
@echo "🎉 Setup complete! Next steps:"
|
||||
@echo " 1. Run tests: make test"
|
||||
@echo " 2. Run integration: make test-integration-docker"
|
||||
@echo " 3. Start developing!"
|
||||
|
||||
# Test targets
|
||||
test: test-unit test-integration
|
||||
|
||||
test-unit:
|
||||
@echo "🧪 Running unit tests..."
|
||||
$(PYTEST) tests/unit/ -v
|
||||
|
||||
test-integration:
|
||||
@echo "🎭 Running integration tests with mocks..."
|
||||
$(PYTEST) tests/integration/ -v
|
||||
|
||||
test-integration-docker:
|
||||
@echo "🐳 Running integration tests with Docker services..."
|
||||
./tests/run_integration.py
|
||||
|
||||
test-all: test-unit test-integration-docker
|
||||
|
||||
test-fast:
|
||||
@echo "⚡ Running fast tests only..."
|
||||
$(PYTEST) -m "not slow and not requires_docker" -v
|
||||
|
||||
test-performance:
|
||||
@echo "📊 Running performance tests..."
|
||||
$(PYTEST) tests/integration/ -m "performance" -v -s
|
||||
|
||||
# Docker management
|
||||
docker-up:
|
||||
@echo "🚀 Starting Docker test services..."
|
||||
docker-compose -f compose.testing.yml up -d
|
||||
@echo "Waiting for services to be ready..."
|
||||
@sleep 5
|
||||
@echo "Services started. Run 'make test-integration-docker' to test."
|
||||
|
||||
docker-down:
|
||||
@echo "🛑 Stopping Docker test services..."
|
||||
docker-compose -f compose.testing.yml down -v
|
||||
|
||||
# Code quality
|
||||
lint:
|
||||
@echo "🔍 Running linting checks..."
|
||||
$(RUFF) check .
|
||||
$(MYPY) .
|
||||
|
||||
format:
|
||||
@echo "✨ Formatting code..."
|
||||
$(RUFF) format .
|
||||
$(RUFF) check --fix .
|
||||
|
||||
type-check:
|
||||
@echo "🔎 Running type checks..."
|
||||
$(MYPY) .
|
||||
|
||||
# Development setup
|
||||
dev-setup:
|
||||
@echo "🔧 Setting up development environment..."
|
||||
@# Check if uv is installed
|
||||
@if ! command -v uv >/dev/null 2>&1; then \
|
||||
echo "📦 uv not found. Installing uv..."; \
|
||||
if command -v curl >/dev/null 2>&1; then \
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh; \
|
||||
elif command -v pip >/dev/null 2>&1; then \
|
||||
pip install uv; \
|
||||
else \
|
||||
echo "❌ Neither curl nor pip found. Please install uv manually:"; \
|
||||
echo " Visit https://docs.astral.sh/uv/getting-started/installation/"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "✅ uv installed successfully!"; \
|
||||
else \
|
||||
echo "✅ uv is already installed (version: $$(uv --version))"; \
|
||||
fi
|
||||
uv sync --dev
|
||||
uv pip install -e .
|
||||
@echo "✅ Development environment ready!"
|
||||
|
||||
# Check dependencies
|
||||
check-deps:
|
||||
@echo "🔍 Checking system dependencies..."
|
||||
@echo ""
|
||||
@echo "Core tools:"
|
||||
@printf " %-18s" "Python:"; if command -v python >/dev/null 2>&1; then python --version; else echo "❌ Not found"; fi
|
||||
@printf " %-18s" "uv:"; if command -v uv >/dev/null 2>&1; then uv --version; else echo "❌ Not found - run 'make dev-setup' to install"; fi
|
||||
@printf " %-18s" "Docker:"; if command -v docker >/dev/null 2>&1; then docker --version; else echo "⚠️ Not found (optional, needed for integration tests)"; fi
|
||||
@printf " %-18s" "Docker Compose:"; if command -v docker-compose >/dev/null 2>&1; then docker-compose --version; else echo "⚠️ Not found (optional, needed for integration tests)"; fi
|
||||
@echo ""
|
||||
@echo "Development tools:"
|
||||
@printf " %-18s" "pytest:"; if $(PYTEST) --version >/dev/null 2>&1; then $(PYTEST) --version | head -1; else echo "❌ Not found - run 'make dev-setup'"; fi
|
||||
@printf " %-18s" "ruff:"; if $(RUFF) --version >/dev/null 2>&1; then $(RUFF) --version; else echo "❌ Not found - run 'make dev-setup'"; fi
|
||||
@printf " %-18s" "mypy:"; if $(MYPY) --version >/dev/null 2>&1; then $(MYPY) --version; else echo "❌ Not found - run 'make dev-setup'"; fi
|
||||
@printf " %-18s" "alembic:"; if $(ALEMBIC) --version >/dev/null 2>&1; then $(ALEMBIC) --version; else echo "❌ Not found - run 'make dev-setup'"; fi
|
||||
@echo ""
|
||||
@echo "Virtual environment:"
|
||||
@if [ -d ".venv" ]; then \
|
||||
echo " ✅ .venv exists"; \
|
||||
echo " Python: $$(.venv/bin/python --version)"; \
|
||||
else \
|
||||
echo " ❌ .venv not found - run 'make dev-setup'"; \
|
||||
fi
|
||||
@echo ""
|
||||
@echo "To set up missing dependencies, run: make dev-setup"
|
||||
|
||||
# Cleanup
|
||||
clean:
|
||||
@echo "🧹 Cleaning up..."
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name "*.pyc" -delete
|
||||
find . -type f -name ".coverage" -delete
|
||||
rm -rf htmlcov/
|
||||
rm -rf dist/
|
||||
rm -rf build/
|
||||
rm -rf *.egg-info
|
||||
@echo "✨ Cleanup complete!"
|
||||
|
||||
# Database migration management
|
||||
db-upgrade:
|
||||
@echo "⬆️ Applying all pending migrations..."
|
||||
$(ALEMBIC) upgrade head
|
||||
@echo "✅ Database upgraded to latest revision"
|
||||
|
||||
db-downgrade:
|
||||
@echo "⬇️ Downgrading one migration..."
|
||||
$(ALEMBIC) downgrade -1
|
||||
@echo "✅ Database downgraded by one revision"
|
||||
|
||||
db-current:
|
||||
@echo "📍 Current database revision:"
|
||||
$(ALEMBIC) current -v
|
||||
|
||||
db-history:
|
||||
@echo "📜 Migration history:"
|
||||
$(ALEMBIC) history --verbose
|
||||
|
||||
db-migrate:
|
||||
@echo "🔍 Auto-generating migration from model changes..."
|
||||
@read -p "Enter migration message: " msg; \
|
||||
$(ALEMBIC) revision --autogenerate -m "$$msg"
|
||||
@echo "✅ Migration generated. Review and edit if needed."
|
||||
|
||||
db-revision:
|
||||
@echo "📝 Creating empty migration file..."
|
||||
@read -p "Enter migration message: " msg; \
|
||||
$(ALEMBIC) revision -m "$$msg"
|
||||
@echo "✅ Empty migration created"
|
||||
|
||||
db-heads:
|
||||
@echo "🎯 Current migration heads:"
|
||||
$(ALEMBIC) heads
|
||||
|
||||
db-clean:
|
||||
@echo "🧹 Cleaning migration cache files..."
|
||||
find migrations/ -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
@echo "✅ Migration cache cleaned"
|
||||
|
||||
# Advanced testing options
|
||||
test-coverage:
|
||||
@echo "📊 Running tests with coverage..."
|
||||
$(PYTEST) --cov=routstr --cov-report=html --cov-report=term
|
||||
@echo "Coverage report generated in htmlcov/"
|
||||
|
||||
test-watch:
|
||||
@echo "👁️ Running tests in watch mode..."
|
||||
$(PYTEST)-watch
|
||||
|
||||
test-parallel:
|
||||
@echo "🚀 Running tests in parallel..."
|
||||
$(PYTEST) -n auto -v
|
||||
|
||||
# CI/CD specific targets
|
||||
ci-test:
|
||||
@echo "🤖 Running CI test suite..."
|
||||
$(PYTEST) -m "not requires_docker" --tb=short -v
|
||||
|
||||
ci-lint:
|
||||
@echo "🤖 Running CI linting..."
|
||||
$(RUFF) check . --exit-non-zero-on-fix
|
||||
$(MYPY) . --no-error-summary
|
||||
|
||||
# Debug helpers
|
||||
test-debug:
|
||||
@echo "🐛 Running tests with debugging enabled..."
|
||||
$(PYTEST) -vvs --tb=long --pdb-trace
|
||||
|
||||
test-failed:
|
||||
@echo "🔄 Re-running failed tests..."
|
||||
$(PYTEST) --lf -v
|
||||
|
||||
# Performance profiling
|
||||
profile:
|
||||
@echo "🔥 Running with profiling..."
|
||||
$(PYTHON) -m cProfile -o profile.stats -m pytest tests/integration/test_performance_load.py::TestPerformanceBaseline -v
|
||||
@echo "Profile saved to profile.stats. Use '$(PYTHON) -m pstats profile.stats' to analyze."
|
||||
|
||||
# Documentation
|
||||
docs-build:
|
||||
@echo "📚 Building documentation..."
|
||||
mkdocs build
|
||||
|
||||
docs-serve:
|
||||
@echo "📚 Serving documentation at http://localhost:8001..."
|
||||
mkdocs serve -a localhost:8001
|
||||
|
||||
docs-deploy:
|
||||
@echo "📚 Deploying documentation to GitHub Pages..."
|
||||
mkdocs gh-deploy --force
|
||||
|
||||
docs-install:
|
||||
@echo "📚 Installing documentation dependencies..."
|
||||
pip install -r docs/requirements.txt
|
||||
|
||||
# UI build
|
||||
ui-build:
|
||||
@echo "🎨 Building UI for static deployment..."
|
||||
./scripts/build-ui.sh
|
||||
|
||||
ui-build-docker:
|
||||
@echo "🐳 Building UI using Docker (no Node.js installation required)..."
|
||||
@echo "Building UI with environment variables from .env..."
|
||||
docker build -f ui/Dockerfile.build -t routstr-ui-build --build-arg NEXT_PUBLIC_API_URL=$(NEXT_PUBLIC_API_URL) --build-arg NEXT_PUBLIC_ADMIN_API_KEY=$(NEXT_PUBLIC_ADMIN_API_KEY) .
|
||||
docker run --rm -v $(PWD)/ui_out:/output routstr-ui-build cp -r /ui_out /output/
|
||||
@echo "✅ UI build complete! Static files available in ui_out/"
|
||||
|
||||
ui-dev:
|
||||
@echo "🎨 Starting UI development server..."
|
||||
cd ui && (command -v pnpm >/dev/null 2>&1 && pnpm run dev || npm run dev)
|
||||
206
README.md
206
README.md
@@ -1,159 +1,81 @@
|
||||
# Routstr Payment Proxy
|
||||
|
||||
Routstr is a FastAPI-based reverse proxy that sits in front of any OpenAI-compatible API. It handles pay-per-request billing using the [Cashu](https://cashu.space/) eCash protocol on Bitcoin and tracks usage in a local SQL database.
|
||||
[](LICENSE)
|
||||
[](https://github.com/routstr/routstr-core/stargazers)
|
||||
[](https://github.com/routstr/routstr-core/issues)
|
||||
[](https://github.com/routstr/routstr-core/releases)
|
||||
|
||||
The server exposes the same endpoints as the upstream API and deducts sats from user accounts for each call. Pricing can be static or model-specific by loading `models.json` (falls back to `models.example.json`).
|
||||
Routstr is a decentralized protocol for permissionless, private, and censorship-resistant AI inference. It combines Nostr for discovery and Cashu for private Bitcoin micropayments.
|
||||
|
||||
## How It Works
|
||||
This repo contains Routstr Core: a FastAPI-based reverse proxy that sits in front of OpenAI-compatible APIs and handles pay-per-request billing.
|
||||
|
||||
The proxy implements a seamless eCash payment flow that maintains compatibility with existing OpenAI clients while enabling Bitcoin micropayments:
|
||||
## Start Here
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Proxy as Routstr Proxy
|
||||
participant DB as Database
|
||||
participant Upstream as OpenAI API
|
||||
participant Wallet as Cashu Wallet
|
||||
- **Overview**: <https://docs.routstr.com/overview/>
|
||||
- **Provider Guide**: <https://docs.routstr.com/provider/quickstart/>
|
||||
- **User Guide**: <https://docs.routstr.com/user-guide/introduction/>
|
||||
|
||||
Client->>Proxy: API Request + eCash Token
|
||||
Proxy->>Wallet: Validate & Redeem Token
|
||||
Wallet-->>Proxy: Token Value (sats)
|
||||
Proxy->>DB: Store/Update Balance
|
||||
Proxy->>Upstream: Forward API Request
|
||||
Upstream-->>Proxy: API Response + Usage Data
|
||||
Proxy->>DB: Deduct Actual Request Cost
|
||||
Proxy->>DB: Update Final Balance
|
||||
Proxy-->>Client: API Response
|
||||
## Basic Usage
|
||||
|
||||
If you are a user/developer, you just point an OpenAI-compatible SDK at a Routstr node and pay with a Cashu token.
|
||||
|
||||
### OpenAI SDK
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="https://api.routstr.com/v1",
|
||||
api_key="cashuBo2FteCJodHRwczovL21...",
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-5-nano",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Cashu Wallet Integration** – Accept Lightning payments and redeem eCash tokens before forwarding requests
|
||||
- **API Key Management** – Hashed keys stored in SQLite with balance tracking and optional expiry/refund address
|
||||
- **Model-Based Pricing** – Convert USD prices in `models.json` to sats using live BTC/USD rates
|
||||
- **Admin Dashboard** – Simple HTML interface at `/admin` to view balances and API keys
|
||||
- **Discovery** – Fetch available providers from Nostr relays
|
||||
- **Docker Support** – Provided `Dockerfile` and `compose.yml` for running with an optional Tor hidden service
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Requirements
|
||||
|
||||
- Python 3.11+
|
||||
- [uv](https://github.com/astral-sh/uv) package manager (used in development)
|
||||
- A Cashu wallet secret (`NSEC`) and Lightning address for receiving payments
|
||||
|
||||
### Installation
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
uv sync --dev # install dependencies
|
||||
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"}]
|
||||
}'
|
||||
```
|
||||
|
||||
Create a `.env` file based on `.env.example` and fill in the required values:
|
||||
## Quick Start (Docker)
|
||||
|
||||
If you are a node runner, start a Routstr Core instance using Docker Compose:
|
||||
|
||||
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 router --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
The service forwards requests to `UPSTREAM_BASE_URL`. Supply the upstream API key via the `UPSTREAM_API_KEY` environment variable if required.
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
This builds the image and also starts a Tor container exposing the API as a hidden service.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The most common settings are shown below. See `.env.example` for the full list.
|
||||
|
||||
- `UPSTREAM_BASE_URL` – URL of the OpenAI-compatible service
|
||||
- `UPSTREAM_API_KEY` – API key for the upstream service (optional)
|
||||
- `RECEIVE_LN_ADDRESS` – Lightning address that receives payouts
|
||||
- `MINIMUM_PAYOUT` – Minimum sats before forwarding earnings
|
||||
- `MODEL_BASED_PRICING` – Set to `true` to use pricing from `models.json`
|
||||
- `REFUND_PROCESSING_INTERVAL` – Seconds between automatic refunds
|
||||
- `ADMIN_PASSWORD` – Password for the `/admin` dashboard
|
||||
|
||||
## 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:
|
||||
|
||||
- **[cashu-402-client](https://github.com/9qeklajc/ecash-402-client)** – rust client with automatic wallet management
|
||||
|
||||
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.
|
||||
|
||||
35
alembic.ini
Normal file
35
alembic.ini
Normal file
@@ -0,0 +1,35 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
28
build-docs.sh
Executable file
28
build-docs.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build script for MkDocs documentation
|
||||
|
||||
echo "Building Routstr Core documentation..."
|
||||
|
||||
# Check if mkdocs is installed
|
||||
if ! command -v mkdocs &> /dev/null; then
|
||||
echo "MkDocs not found. Installing dependencies..."
|
||||
pip install -r docs/requirements.txt
|
||||
fi
|
||||
|
||||
# Build the documentation
|
||||
echo "Building docs..."
|
||||
mkdocs build
|
||||
|
||||
# Serve locally for preview (optional)
|
||||
if [ "$1" = "serve" ]; then
|
||||
echo "Starting documentation server at http://localhost:8001"
|
||||
mkdocs serve -a localhost:8001
|
||||
elif [ "$1" = "deploy" ]; then
|
||||
echo "Deploying to GitHub Pages..."
|
||||
mkdocs gh-deploy --force
|
||||
else
|
||||
echo "Documentation built successfully in ./site/"
|
||||
echo "Run './build-docs.sh serve' to preview locally"
|
||||
echo "Run './build-docs.sh deploy' to deploy to GitHub Pages"
|
||||
fi
|
||||
62
compose.testing.yml
Normal file
62
compose.testing.yml
Normal file
@@ -0,0 +1,62 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
routstr:
|
||||
build: .
|
||||
command: ["/.venv/bin/fastapi", "dev", "routstr", "--host", "0.0.0.0", "--port", "8000"]
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- "DATABASE_URL=sqlite+aiosqlite:///:memory:"
|
||||
- "NOSTR_RELAY_URL=ws://relay:8080"
|
||||
- "UPSTREAM_BASE_URL=http://mock-openai:3000"
|
||||
- "UPSTREAM_API_KEY=test-upstream-key"
|
||||
- "CASHU_MINTS=http://mint:3338"
|
||||
- "NAME=TestRoutstrNode"
|
||||
- "DESCRIPTION=Test Node for Integration Tests"
|
||||
- "NPUB=npub1test"
|
||||
- "HTTP_URL=http://localhost:8000"
|
||||
- "ONION_URL=http://test.onion"
|
||||
- "CORS_ORIGINS=*"
|
||||
- "RECEIVE_LN_ADDRESS=test@routstr.com"
|
||||
- "FIXED_COST_PER_REQUEST=10"
|
||||
- "FIXED_PER_1K_INPUT_TOKENS=0"
|
||||
- "FIXED_PER_1K_OUTPUT_TOKENS=0"
|
||||
- "FIXED_PRICING=false"
|
||||
- "NSEC=nsec1testkey1234567890abcdef"
|
||||
- "REFUND_PROCESSING_INTERVAL=3600"
|
||||
- "MINIMUM_PAYOUT=1000"
|
||||
- "PAYOUT_INTERVAL=86400"
|
||||
depends_on:
|
||||
- mock-mint
|
||||
- mock-openai
|
||||
- relay
|
||||
|
||||
relay:
|
||||
image: scsibug/nostr-rs-relay:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8088:8080" # host:container
|
||||
environment:
|
||||
- LISTEN_ADDR=0.0.0.0
|
||||
- LISTEN_PORT=8080
|
||||
|
||||
mock-openai:
|
||||
image: zerob13/mock-openai-api
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
mock-mint:
|
||||
image: cashubtc/nutshell:0.17.0
|
||||
container_name: mint
|
||||
ports:
|
||||
- "3338:3338"
|
||||
environment:
|
||||
- MINT_BACKEND_BOLT11_SAT=FakeWallet
|
||||
- MINT_LISTEN_HOST=0.0.0.0
|
||||
- MINT_LISTEN_PORT=3338
|
||||
- MINT_PRIVATE_KEY=TEST_PRIVATE_KEY
|
||||
command: poetry run mint
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- mock-openai
|
||||
35
compose.yml
35
compose.yml
@@ -1,17 +1,36 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
router:
|
||||
build: .
|
||||
ui:
|
||||
env_file:
|
||||
- .env
|
||||
build:
|
||||
context: ./ui
|
||||
dockerfile: Dockerfile.build
|
||||
args:
|
||||
# NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
|
||||
NEXT_PUBLIC_ADMIN_API_KEY: ${NEXT_PUBLIC_ADMIN_API_KEY:-}
|
||||
user: root
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./logs:/app/logs
|
||||
- ./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"]
|
||||
|
||||
routstr:
|
||||
build: .
|
||||
depends_on:
|
||||
- ui
|
||||
volumes:
|
||||
- .:/app:z
|
||||
- ./logs:/app/logs:z
|
||||
- tor-data:/var/lib/tor:ro
|
||||
- ./ui_out:/app/ui_out:ro,z
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TOR_PROXY_URL=socks5://tor:9050
|
||||
ports:
|
||||
- 8000:8000
|
||||
extra_hosts: # Needed to access locally running models
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
tor:
|
||||
image: ghcr.io/hundehausen/tor-hidden-service:latest
|
||||
@@ -19,9 +38,9 @@ services:
|
||||
- tor-data:/var/lib/tor
|
||||
environment:
|
||||
# Format: HS_<NAME>=<TARGET_HOST>:<TARGET_PORT>:<VIRTUAL_PORT>
|
||||
- HS_ROUTER=router:8000:80
|
||||
- HS_ROUTER=routstr:8000:80
|
||||
depends_on:
|
||||
- router
|
||||
- routstr
|
||||
|
||||
volumes:
|
||||
tor-data:
|
||||
|
||||
428
docs/api/authentication.md
Normal file
428
docs/api/authentication.md
Normal file
@@ -0,0 +1,428 @@
|
||||
# Authentication
|
||||
|
||||
Routstr uses API key authentication for all protected endpoints. This guide covers how to create, use, and manage API keys.
|
||||
|
||||
## API Key Creation
|
||||
|
||||
### From eCash Token
|
||||
|
||||
Create an API key by depositing an eCash token:
|
||||
|
||||
**Note: The POST /v1/wallet/create endpoint is coming soon. Currently, you can use Cashu tokens directly as API credentials in the Authorization header. The token is hashed on the server, and the hash acts as an API key with the token's balance.**
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/create
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vbWlu..."
|
||||
}
|
||||
```
|
||||
|
||||
**Request Parameters:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `cashu_token` | string | Yes | Base64-encoded Cashu token |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p",
|
||||
"balance": 10000,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"key_id": "key_123456"
|
||||
}
|
||||
```
|
||||
|
||||
### From Lightning Invoice (Coming Soon)
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/create/lightning
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"amount_sats": 10000,
|
||||
"name": "Lightning Key"
|
||||
}
|
||||
```
|
||||
|
||||
Response includes Lightning invoice for payment.
|
||||
|
||||
## Using API Keys
|
||||
|
||||
### Header Authentication
|
||||
|
||||
Include the API key in the Authorization header:
|
||||
|
||||
```bash
|
||||
curl https://your-node.com/v1/chat/completions \
|
||||
-H "Authorization: Bearer sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello"}]}'
|
||||
```
|
||||
|
||||
### Query Parameter (Not Recommended)
|
||||
|
||||
For tools that don't support headers:
|
||||
|
||||
```bash
|
||||
GET /v1/models?api_key=sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p
|
||||
```
|
||||
|
||||
⚠️ **Warning**: Query parameters may be logged. Use headers when possible.
|
||||
|
||||
## Key Management
|
||||
|
||||
### Check Balance
|
||||
|
||||
Get current balance and usage statistics:
|
||||
|
||||
```bash
|
||||
GET /v1/wallet/balance
|
||||
Authorization: Bearer sk-...
|
||||
|
||||
Response:
|
||||
{
|
||||
"balance": 8546,
|
||||
"total_deposited": 10000,
|
||||
"total_spent": 1454,
|
||||
"last_used": "2024-01-01T12:34:56Z",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"expires_at": null,
|
||||
"key_info": {
|
||||
"name": "Production Key",
|
||||
"key_id": "key_123456"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Top Up Balance
|
||||
|
||||
Add funds to existing key:
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/topup
|
||||
Authorization: Bearer sk-...
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0b2tlbiI6W3..."
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"old_balance": 8546,
|
||||
"added_amount": 5000,
|
||||
"new_balance": 13546,
|
||||
"transaction_id": "txn_789"
|
||||
}
|
||||
```
|
||||
|
||||
### List Transactions
|
||||
|
||||
View transaction history:
|
||||
|
||||
```bash
|
||||
GET /v1/wallet/transactions?limit=10
|
||||
Authorization: Bearer sk-...
|
||||
|
||||
Response:
|
||||
{
|
||||
"transactions": [
|
||||
{
|
||||
"id": "txn_123",
|
||||
"type": "usage",
|
||||
"amount": -154,
|
||||
"balance_after": 8546,
|
||||
"description": "gpt-3.5-turbo: 50 prompt + 150 completion tokens",
|
||||
"timestamp": "2024-01-01T12:34:56Z"
|
||||
},
|
||||
{
|
||||
"id": "txn_122",
|
||||
"type": "deposit",
|
||||
"amount": 10000,
|
||||
"balance_after": 10000,
|
||||
"description": "Initial deposit",
|
||||
"timestamp": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"total": 2
|
||||
}
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### API Key Storage
|
||||
|
||||
**Do:**
|
||||
|
||||
- Store keys in environment variables
|
||||
- Use secret management systems
|
||||
- Encrypt keys at rest
|
||||
- Implement key rotation
|
||||
|
||||
**Don't:**
|
||||
|
||||
- Commit keys to version control
|
||||
- Share keys between environments
|
||||
- Log keys in plain text
|
||||
- Expose keys in client-side code
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# .env file
|
||||
ROUTSTR_API_KEY=sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p
|
||||
ROUTSTR_BASE_URL=https://your-node.com/v1
|
||||
|
||||
# Usage in code
|
||||
import os
|
||||
api_key = os.getenv("ROUTSTR_API_KEY")
|
||||
```
|
||||
|
||||
### Key Rotation
|
||||
|
||||
Regularly rotate API keys:
|
||||
|
||||
```python
|
||||
# 1. Create new key
|
||||
new_key = create_api_key(balance=old_key_balance)
|
||||
|
||||
# 2. Update applications
|
||||
update_environment_variable("ROUTSTR_API_KEY", new_key)
|
||||
|
||||
# 3. Test new key
|
||||
test_api_connection(new_key)
|
||||
|
||||
# 4. Withdraw old key balance
|
||||
withdraw_balance(old_key)
|
||||
```
|
||||
|
||||
## Authentication Errors
|
||||
|
||||
### Invalid API Key
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "authentication_failed",
|
||||
"message": "Invalid API key",
|
||||
"code": "invalid_api_key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Code:** 401
|
||||
|
||||
**Common Causes:**
|
||||
|
||||
- Typo in API key
|
||||
- Key doesn't exist
|
||||
- Key has been deleted
|
||||
|
||||
### Expired API Key
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "authentication_failed",
|
||||
"message": "API key has expired",
|
||||
"code": "key_expired",
|
||||
"details": {
|
||||
"expired_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Code:** 401
|
||||
|
||||
**Resolution:**
|
||||
|
||||
- Create a new API key
|
||||
- Contact admin if refund address was set
|
||||
|
||||
### Insufficient Balance
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "insufficient_balance",
|
||||
"message": "Insufficient balance for request",
|
||||
"code": "payment_required",
|
||||
"details": {
|
||||
"balance": 100,
|
||||
"required": 154,
|
||||
"shortfall": 54
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Code:** 402
|
||||
|
||||
**Resolution:**
|
||||
|
||||
- Top up the API key balance
|
||||
- Use a more economical model
|
||||
- Optimize request parameters
|
||||
|
||||
## Advanced Authentication
|
||||
|
||||
### Per-Request Tokens (Coming Soon)
|
||||
|
||||
Pay per request without maintaining a balance:
|
||||
|
||||
```bash
|
||||
curl https://your-node.com/v1/chat/completions \
|
||||
-H "X-Cashu: cashuAeyJ0b2tlbiI6W3..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gpt-3.5-turbo","messages":[...]}'
|
||||
```
|
||||
|
||||
Response includes change:
|
||||
|
||||
```
|
||||
X-Cashu: cashuAeyJjaGFuZ2UiOlt7...
|
||||
```
|
||||
|
||||
### Multi-Key Authentication
|
||||
|
||||
Use multiple keys for different purposes:
|
||||
|
||||
```python
|
||||
# Production key for main app
|
||||
PROD_KEY = os.getenv("ROUTSTR_PROD_KEY")
|
||||
|
||||
# Development key for testing
|
||||
DEV_KEY = os.getenv("ROUTSTR_DEV_KEY")
|
||||
|
||||
# Analytics key with restricted permissions
|
||||
ANALYTICS_KEY = os.getenv("ROUTSTR_ANALYTICS_KEY")
|
||||
|
||||
# Choose key based on environment
|
||||
api_key = PROD_KEY if is_production() else DEV_KEY
|
||||
```
|
||||
|
||||
### Delegated Authentication
|
||||
|
||||
Create sub-keys with limited permissions:
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/create/subkey
|
||||
Authorization: Bearer sk-parent-key
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Limited Subkey",
|
||||
"balance_limit": 1000,
|
||||
"allowed_models": ["gpt-3.5-turbo"],
|
||||
"expires_in_hours": 24
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Rate limits are applied per API key:
|
||||
|
||||
### Default Limits
|
||||
|
||||
| Metric | Limit | Window |
|
||||
|--------|-------|--------|
|
||||
| Requests | 1000 | 1 minute |
|
||||
| Tokens | 1,000,000 | 1 hour |
|
||||
| Concurrent | 10 | - |
|
||||
|
||||
### Rate Limit Headers
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 1000
|
||||
X-RateLimit-Remaining: 999
|
||||
X-RateLimit-Reset: 1640995200
|
||||
X-RateLimit-Type: requests_per_minute
|
||||
```
|
||||
|
||||
### Handling Rate Limits
|
||||
|
||||
```python
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
def make_request_with_retry(
|
||||
client,
|
||||
max_retries: int = 3
|
||||
) -> Optional[Response]:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = client.chat.completions.create(...)
|
||||
return response
|
||||
except RateLimitError as e:
|
||||
if attempt < max_retries - 1:
|
||||
# Extract retry-after from error
|
||||
retry_after = e.retry_after or 60
|
||||
print(f"Rate limited. Waiting {retry_after}s...")
|
||||
time.sleep(retry_after)
|
||||
else:
|
||||
raise
|
||||
```
|
||||
|
||||
## IP Whitelisting
|
||||
|
||||
Restrict API key usage by IP:
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/update
|
||||
Authorization: Bearer sk-...
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"allowed_ips": [
|
||||
"192.168.1.100",
|
||||
"10.0.0.0/24"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Usage Alerts
|
||||
|
||||
Set up usage notifications:
|
||||
|
||||
```bash
|
||||
POST /v1/wallet/alerts
|
||||
Authorization: Bearer sk-...
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"low_balance_threshold": 1000,
|
||||
"daily_spend_limit": 5000,
|
||||
"webhook_url": "https://your-app.com/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### Audit Logging
|
||||
|
||||
All API key usage is logged:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-01T12:34:56Z",
|
||||
"api_key_id": "key_123456",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"method": "POST",
|
||||
"ip_address": "192.168.1.100",
|
||||
"user_agent": "OpenAI-Python/1.0",
|
||||
"cost_sats": 154,
|
||||
"response_status": 200
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Endpoints](endpoints.md) - Complete endpoint reference
|
||||
- [Errors](errors.md) - Error handling guide
|
||||
- [Using the API](../client/integration.md) - Integration examples
|
||||
604
docs/api/endpoints.md
Normal file
604
docs/api/endpoints.md
Normal file
@@ -0,0 +1,604 @@
|
||||
# API Endpoints
|
||||
|
||||
Complete reference for all Routstr API endpoints.
|
||||
|
||||
## Overview
|
||||
|
||||
Routstr provides OpenAI-compatible endpoints with Bitcoin/eCash payment integration.
|
||||
|
||||
### Base URL
|
||||
|
||||
All endpoints use the base URL:
|
||||
|
||||
```text
|
||||
https://api.routstr.com/v1
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
All endpoints require authentication via:
|
||||
|
||||
- **Bearer Token**: `Authorization: Bearer sk-...` or `Authorization: Bearer cashuAeyJ0...`
|
||||
- **X-Cashu Header**: `X-Cashu: cashuAeyJ0...` (for direct eCash payments)
|
||||
|
||||
See [Authentication](authentication.md) for details.
|
||||
|
||||
## Chat
|
||||
|
||||
### Create Chat Completion
|
||||
|
||||
Send messages to generate model responses.
|
||||
|
||||
```http
|
||||
POST /v1/chat/completions
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-4",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a helpful assistant."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello!"
|
||||
}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"stream": false
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `model` | string | Yes | - | Model ID to use |
|
||||
| `messages` | array | Yes | - | Array of message objects |
|
||||
| `temperature` | number | No | 1.0 | Sampling temperature (0-2) |
|
||||
| `max_tokens` | integer | No | Model default | Maximum tokens to generate |
|
||||
| `stream` | boolean | No | false | Stream partial responses |
|
||||
| `top_p` | number | No | 1.0 | Nucleus sampling |
|
||||
| `n` | integer | No | 1 | Number of completions |
|
||||
| `stop` | string/array | No | null | Stop sequences |
|
||||
| `presence_penalty` | number | No | 0 | Presence penalty (-2 to 2) |
|
||||
| `frequency_penalty` | number | No | 0 | Frequency penalty (-2 to 2) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"created": 1677652288,
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Hello! How can I help you today?"
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 13,
|
||||
"completion_tokens": 9,
|
||||
"total_tokens": 22
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Streaming Response
|
||||
|
||||
When `stream: true`:
|
||||
|
||||
```text
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
## Completions (Coming Soon)
|
||||
|
||||
### Create Completion
|
||||
|
||||
**Note: This endpoint is coming soon and not yet available.**
|
||||
|
||||
Generate text completion (legacy endpoint).
|
||||
|
||||
```http
|
||||
POST /v1/completions
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-3.5-turbo-instruct",
|
||||
"prompt": "Once upon a time",
|
||||
"max_tokens": 50,
|
||||
"temperature": 0.7
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "cmpl-123",
|
||||
"object": "text_completion",
|
||||
"created": 1677652288,
|
||||
"model": "gpt-3.5-turbo-instruct",
|
||||
"choices": [{
|
||||
"text": " in a faraway land, there lived a brave knight...",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"finish_reason": "length"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 4,
|
||||
"completion_tokens": 50,
|
||||
"total_tokens": 54
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Embeddings
|
||||
|
||||
### Create Embeddings (Coming Soon)
|
||||
|
||||
**Note: This endpoint is coming soon and not yet available.**
|
||||
|
||||
Generate vector representations of text.
|
||||
|
||||
```http
|
||||
POST /v1/embeddings
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "text-embedding-3-small",
|
||||
"input": "The quick brown fox jumps over the lazy dog",
|
||||
"encoding_format": "float"
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `model` | string | Yes | - | Embedding model ID |
|
||||
| `input` | string/array | Yes | - | Text(s) to embed |
|
||||
| `encoding_format` | string | No | "float" | Format: "float" or "base64" |
|
||||
| `dimensions` | integer | No | Model default | Output dimensions |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"object": "list",
|
||||
"data": [{
|
||||
"object": "embedding",
|
||||
"index": 0,
|
||||
"embedding": [0.0023064255, -0.009327292, ...]
|
||||
}],
|
||||
"model": "text-embedding-3-small",
|
||||
"usage": {
|
||||
"prompt_tokens": 9,
|
||||
"total_tokens": 9
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Images (Coming Soon)
|
||||
|
||||
### Create Image
|
||||
|
||||
**Note: This endpoint is coming soon and not yet available.**
|
||||
|
||||
Generate images from text prompts.
|
||||
|
||||
```http
|
||||
POST /v1/images/generations
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "dall-e-3",
|
||||
"prompt": "A white siamese cat wearing a space helmet",
|
||||
"n": 1,
|
||||
"size": "1024x1024",
|
||||
"quality": "standard"
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `model` | string | Yes | - | Model: dall-e-2, dall-e-3 |
|
||||
| `prompt` | string | Yes | - | Text description |
|
||||
| `n` | integer | No | 1 | Number of images |
|
||||
| `size` | string | No | "1024x1024" | Image dimensions |
|
||||
| `quality` | string | No | "standard" | Quality: standard, hd |
|
||||
| `style` | string | No | "vivid" | Style: vivid, natural |
|
||||
| `response_format` | string | No | "url" | Format: url, b64_json |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"created": 1677652288,
|
||||
"data": [{
|
||||
"url": "https://generated-image-url.com/image.png",
|
||||
"revised_prompt": "A white Siamese cat wearing a detailed space helmet..."
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Audio (Coming Soon)
|
||||
|
||||
### Create Transcription
|
||||
|
||||
**Note: This endpoint is coming soon and not yet available.**
|
||||
|
||||
Convert audio to text.
|
||||
|
||||
```http
|
||||
POST /v1/audio/transcriptions
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Form Data:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `file` | file | Yes | Audio file (mp3, mp4, mpeg, mpga, m4a, wav, webm) |
|
||||
| `model` | string | Yes | Model ID (whisper-1) |
|
||||
| `language` | string | No | Language code (ISO-639-1) |
|
||||
| `prompt` | string | No | Context prompt |
|
||||
| `response_format` | string | No | Format: json, text, srt, verbose_json, vtt |
|
||||
| `temperature` | number | No | Sampling temperature |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "Hello, this is the transcribed audio content."
|
||||
}
|
||||
```
|
||||
|
||||
### Create Translation
|
||||
|
||||
**Note: This endpoint is coming soon and not yet available.**
|
||||
|
||||
Translate audio to English.
|
||||
|
||||
```http
|
||||
POST /v1/audio/translations
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
Same parameters as transcription, but always translates to English.
|
||||
|
||||
## Models
|
||||
|
||||
### List Models
|
||||
|
||||
Get available models and pricing.
|
||||
|
||||
```http
|
||||
GET /v1/models
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "gpt-3.5-turbo",
|
||||
"object": "model",
|
||||
"created": 1677610602,
|
||||
"owned_by": "openai",
|
||||
"permission": [...],
|
||||
"root": "gpt-3.5-turbo",
|
||||
"parent": null,
|
||||
"pricing": {
|
||||
"prompt": 0.001,
|
||||
"completion": 0.002,
|
||||
"unit": "1k tokens"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Wallet Management
|
||||
|
||||
### Create Wallet (Coming Soon)
|
||||
|
||||
**Note: This endpoint is coming soon. Currently, you can use Cashu tokens directly as API keys.**
|
||||
|
||||
Create a new wallet with eCash deposit.
|
||||
|
||||
```http
|
||||
POST /v1/wallet/create
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0...",
|
||||
"admin_key": "optional-admin-key"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-1234567890abcdef",
|
||||
"admin_key": "radmin_fedcba0987654321",
|
||||
"balance": 10000,
|
||||
"mint": "https://mint.example.com",
|
||||
"unit": "sat"
|
||||
}
|
||||
```
|
||||
|
||||
### Get Key Information
|
||||
|
||||
Get current balance, consumption data, and child keys for an API key.
|
||||
|
||||
```http
|
||||
GET /v1/balance/info
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-abc...",
|
||||
"balance": 8500000,
|
||||
"reserved": 0,
|
||||
"is_child": false,
|
||||
"parent_key": null,
|
||||
"total_requests": 42,
|
||||
"total_spent": 1500000,
|
||||
"balance_limit": null,
|
||||
"balance_limit_reset": null,
|
||||
"validity_date": null,
|
||||
"child_keys": [
|
||||
{
|
||||
"api_key": "sk-child1...",
|
||||
"total_requests": 10,
|
||||
"total_spent": 500000,
|
||||
"balance_limit": 1000000,
|
||||
"balance_limit_reset": "daily",
|
||||
"validity_date": 1738000000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`balance` is the spendable balance used by request admission.
|
||||
|
||||
### Check Balance
|
||||
|
||||
Get current wallet balance.
|
||||
|
||||
```http
|
||||
GET /v1/wallet/balance
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"balance": 8500,
|
||||
"currency": "sat",
|
||||
"reserved": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Top Up Wallet
|
||||
|
||||
Add funds to existing wallet.
|
||||
|
||||
```http
|
||||
POST /v1/wallet/topup
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0..."
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"balance": 18500,
|
||||
"amount_added": 10000,
|
||||
"currency": "sat"
|
||||
}
|
||||
```
|
||||
|
||||
### Withdraw Funds
|
||||
|
||||
Withdraw balance as eCash.
|
||||
|
||||
```http
|
||||
POST /v1/wallet/withdraw
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"amount": 5000,
|
||||
"mint": "https://mint.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"cashu_token": "cashuAeyJ0...",
|
||||
"amount": 5000,
|
||||
"mint": "https://mint.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
### Create Child Key
|
||||
|
||||
Creates one or more child API keys that share the parent's balance. Each child key creation costs a fixed amount (configurable).
|
||||
|
||||
```http
|
||||
POST /v1/balance/child-key
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `count` | integer | Yes | - | Number of child keys to create (1-50) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_keys": ["sk-abc...", "sk-def..."],
|
||||
"count": 2,
|
||||
"cost_msats": 2000,
|
||||
"cost_sats": 2,
|
||||
"parent_balance": 98000,
|
||||
"parent_balance_sats": 98
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Discovery
|
||||
|
||||
## Admin Settings
|
||||
|
||||
These endpoints are protected by the Admin cookie (`admin_password` set to your configured admin password).
|
||||
|
||||
### Get Settings
|
||||
|
||||
```http
|
||||
GET /admin/api/settings
|
||||
```
|
||||
|
||||
Returns the current application settings (sensitive values may be redacted).
|
||||
|
||||
### Update Settings
|
||||
|
||||
```http
|
||||
PATCH /admin/api/settings
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Body is a partial JSON of settings fields to update. Validated and persisted to the database.
|
||||
|
||||
### List Providers
|
||||
|
||||
Get available upstream providers.
|
||||
|
||||
```http
|
||||
GET /v1/providers
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"name": "openai",
|
||||
"models": ["gpt-4", "gpt-3.5-turbo"],
|
||||
"endpoints": ["chat/completions", "completions"],
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Provider Info
|
||||
|
||||
Get specific provider details.
|
||||
|
||||
```http
|
||||
GET /v1/providers/{provider_name}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "openai",
|
||||
"display_name": "OpenAI",
|
||||
"description": "Official OpenAI API",
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-4",
|
||||
"name": "GPT-4",
|
||||
"context_window": 8192,
|
||||
"pricing": {
|
||||
"prompt": 0.03,
|
||||
"completion": 0.06,
|
||||
"unit": "1k tokens"
|
||||
}
|
||||
}
|
||||
],
|
||||
"endpoints": ["chat/completions", "completions", "embeddings"],
|
||||
"features": ["streaming", "function_calling"],
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
All endpoints are subject to rate limiting:
|
||||
|
||||
- **Per minute**: 60 requests
|
||||
- **Per hour**: 1000 requests
|
||||
- **Per day**: 10000 requests
|
||||
|
||||
Rate limit information is included in response headers.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Errors](errors.md) - Error handling reference
|
||||
- [Authentication](authentication.md) - Auth details
|
||||
- [Integration Guide](../client/integration.md) - Code examples
|
||||
592
docs/api/errors.md
Normal file
592
docs/api/errors.md
Normal file
@@ -0,0 +1,592 @@
|
||||
# Error Handling
|
||||
|
||||
This guide covers error responses, codes, and handling strategies for the Routstr API.
|
||||
|
||||
## Error Response Format
|
||||
|
||||
All errors follow a consistent JSON structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "error_type",
|
||||
"message": "Human-readable error message",
|
||||
"code": "error_code",
|
||||
"details": {
|
||||
"additional": "context-specific information"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP Status Codes
|
||||
|
||||
| Status | Meaning | Common Causes |
|
||||
|--------|---------|---------------|
|
||||
| 400 | Bad Request | Invalid parameters, malformed JSON |
|
||||
| 401 | Unauthorized | Invalid or missing API key |
|
||||
| 402 | Payment Required | Insufficient balance |
|
||||
| 403 | Forbidden | Access denied to resource |
|
||||
| 404 | Not Found | Endpoint or resource doesn't exist |
|
||||
| 422 | Unprocessable Entity | Validation errors |
|
||||
| 429 | Too Many Requests | Rate limit exceeded |
|
||||
| 500 | Internal Server Error | Server-side error |
|
||||
| 502 | Bad Gateway | Upstream API error |
|
||||
| 503 | Service Unavailable | Temporary outage |
|
||||
|
||||
## Error Types
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
#### Invalid API Key
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "authentication_failed",
|
||||
"message": "Invalid API key provided",
|
||||
"code": "invalid_api_key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 401
|
||||
**Resolution:** Check API key format and validity
|
||||
|
||||
#### Expired API Key
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "authentication_failed",
|
||||
"message": "API key has expired",
|
||||
"code": "key_expired",
|
||||
"details": {
|
||||
"expired_at": "2024-01-01T00:00:00Z",
|
||||
"refund_available": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 401
|
||||
**Resolution:** Create new API key or contact admin for refund
|
||||
|
||||
#### Missing Authorization
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "authentication_failed",
|
||||
"message": "Authorization header required",
|
||||
"code": "missing_auth"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 401
|
||||
**Resolution:** Include `Authorization: Bearer {api_key}` header
|
||||
|
||||
### Payment Errors
|
||||
|
||||
#### Insufficient Balance
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "insufficient_balance",
|
||||
"message": "Insufficient balance for request",
|
||||
"code": "payment_required",
|
||||
"details": {
|
||||
"balance": 100,
|
||||
"required": 154,
|
||||
"shortfall": 54,
|
||||
"estimated_tokens": {
|
||||
"prompt": 50,
|
||||
"completion": 150
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 402
|
||||
**Resolution:** Top up API key balance
|
||||
|
||||
#### Invalid Token
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "payment_error",
|
||||
"message": "Invalid Cashu token",
|
||||
"code": "invalid_token",
|
||||
"details": {
|
||||
"reason": "Token already spent"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 400
|
||||
**Resolution:** Use a valid, unspent token
|
||||
|
||||
#### Mint Unavailable
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "payment_error",
|
||||
"message": "Cannot connect to Cashu mint",
|
||||
"code": "mint_unavailable",
|
||||
"details": {
|
||||
"mint_url": "https://mint.example.com",
|
||||
"retry_after": 60
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 503
|
||||
**Resolution:** Try again later or use different mint
|
||||
|
||||
### Validation Errors
|
||||
|
||||
#### Invalid Parameters
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "invalid_request",
|
||||
"message": "Invalid request parameters",
|
||||
"code": "validation_error",
|
||||
"details": {
|
||||
"errors": [
|
||||
{
|
||||
"field": "temperature",
|
||||
"message": "Must be between 0 and 2",
|
||||
"value": 3.5
|
||||
},
|
||||
{
|
||||
"field": "model",
|
||||
"message": "Model 'gpt-5' not found",
|
||||
"value": "gpt-5"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 422
|
||||
**Resolution:** Fix parameter values
|
||||
|
||||
#### Missing Required Fields
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "invalid_request",
|
||||
"message": "Missing required fields",
|
||||
"code": "missing_fields",
|
||||
"details": {
|
||||
"missing": ["model", "messages"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 400
|
||||
**Resolution:** Include all required fields
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
#### Rate Limit Exceeded
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "rate_limit_exceeded",
|
||||
"message": "Too many requests",
|
||||
"code": "rate_limit",
|
||||
"details": {
|
||||
"limit": 100,
|
||||
"window": "1 minute",
|
||||
"retry_after": 45
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 429
|
||||
**Headers:**
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 0
|
||||
X-RateLimit-Reset: 1640995200
|
||||
Retry-After: 45
|
||||
```
|
||||
|
||||
**Resolution:** Wait for retry_after seconds
|
||||
|
||||
### Upstream Errors
|
||||
|
||||
#### Model Overloaded
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "upstream_error",
|
||||
"message": "Model is currently overloaded",
|
||||
"code": "model_overloaded",
|
||||
"details": {
|
||||
"model": "gpt-4",
|
||||
"retry_after": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 503
|
||||
**Resolution:** Retry request after delay
|
||||
|
||||
#### Upstream Timeout
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "upstream_error",
|
||||
"message": "Request to upstream API timed out",
|
||||
"code": "upstream_timeout",
|
||||
"details": {
|
||||
"timeout": 30,
|
||||
"endpoint": "chat/completions"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 504
|
||||
**Resolution:** Retry with shorter prompt or max_tokens
|
||||
|
||||
### Content Policy
|
||||
|
||||
#### Content Filtered
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "content_policy_violation",
|
||||
"message": "Content filtered due to policy violation",
|
||||
"code": "content_filtered",
|
||||
"details": {
|
||||
"reason": "harmful_content",
|
||||
"categories": ["violence", "hate"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** 400
|
||||
**Resolution:** Modify prompt to comply with policies
|
||||
|
||||
## Error Handling Best Practices
|
||||
|
||||
### Retry Logic
|
||||
|
||||
Implement exponential backoff with jitter:
|
||||
|
||||
```python
|
||||
import time
|
||||
import random
|
||||
from typing import Optional, Callable
|
||||
|
||||
def retry_with_backoff(
|
||||
func: Callable,
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 60.0
|
||||
) -> Optional[Any]:
|
||||
"""Retry function with exponential backoff."""
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return func()
|
||||
except Exception as e:
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
|
||||
# Check if error is retryable
|
||||
if hasattr(e, 'status_code'):
|
||||
if e.status_code in [429, 502, 503, 504]:
|
||||
# Calculate delay with jitter
|
||||
delay = min(
|
||||
base_delay * (2 ** attempt) + random.uniform(0, 1),
|
||||
max_delay
|
||||
)
|
||||
|
||||
# Use retry_after if provided
|
||||
if hasattr(e, 'retry_after'):
|
||||
delay = e.retry_after
|
||||
|
||||
time.sleep(delay)
|
||||
else:
|
||||
# Non-retryable error
|
||||
raise
|
||||
```
|
||||
|
||||
### Error Categories
|
||||
|
||||
Group errors for handling:
|
||||
|
||||
```python
|
||||
class ErrorHandler:
|
||||
# Errors that should be retried
|
||||
RETRYABLE_ERRORS = {
|
||||
'rate_limit',
|
||||
'upstream_timeout',
|
||||
'model_overloaded',
|
||||
'mint_unavailable'
|
||||
}
|
||||
|
||||
# Errors requiring user action
|
||||
USER_ACTION_ERRORS = {
|
||||
'insufficient_balance',
|
||||
'invalid_api_key',
|
||||
'key_expired'
|
||||
}
|
||||
|
||||
# Errors requiring code changes
|
||||
CLIENT_ERRORS = {
|
||||
'validation_error',
|
||||
'missing_fields',
|
||||
'invalid_request'
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def handle_error(cls, error_response: dict) -> None:
|
||||
error_code = error_response['error']['code']
|
||||
|
||||
if error_code in cls.RETRYABLE_ERRORS:
|
||||
# Implement retry logic
|
||||
pass
|
||||
elif error_code in cls.USER_ACTION_ERRORS:
|
||||
# Alert user
|
||||
pass
|
||||
elif error_code in cls.CLIENT_ERRORS:
|
||||
# Log for debugging
|
||||
pass
|
||||
```
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
Handle errors without breaking application flow:
|
||||
|
||||
```python
|
||||
async def get_ai_response(prompt: str) -> str:
|
||||
"""Get AI response with fallback handling."""
|
||||
try:
|
||||
# Try primary model
|
||||
response = await client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
|
||||
except InsufficientBalanceError:
|
||||
# Fall back to cheaper model
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=100 # Limit tokens
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
except Exception as e:
|
||||
logger.error(f"Fallback failed: {e}")
|
||||
return "Service temporarily unavailable"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
return "An error occurred processing your request"
|
||||
```
|
||||
|
||||
### Logging Errors
|
||||
|
||||
Structure error logs for debugging:
|
||||
|
||||
```python
|
||||
import logging
|
||||
import json
|
||||
|
||||
def log_api_error(error_response: dict, context: dict) -> None:
|
||||
"""Log API errors with context."""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
error_data = {
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'error': error_response['error'],
|
||||
'context': {
|
||||
'endpoint': context.get('endpoint'),
|
||||
'api_key_id': context.get('api_key_id'),
|
||||
'request_id': context.get('request_id'),
|
||||
'model': context.get('model')
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(
|
||||
"API Error",
|
||||
extra={'structured_data': json.dumps(error_data)}
|
||||
)
|
||||
```
|
||||
|
||||
### User-Friendly Messages
|
||||
|
||||
Map technical errors to user messages:
|
||||
|
||||
```python
|
||||
ERROR_MESSAGES = {
|
||||
'insufficient_balance': "Your account balance is too low. Please add funds to continue.",
|
||||
'invalid_api_key': "Invalid API key. Please check your configuration.",
|
||||
'rate_limit': "Too many requests. Please wait a moment and try again.",
|
||||
'model_overloaded': "The AI service is busy. Please try again in a few seconds.",
|
||||
'validation_error': "Invalid request. Please check your input and try again."
|
||||
}
|
||||
|
||||
def get_user_message(error_code: str) -> str:
|
||||
"""Get user-friendly error message."""
|
||||
return ERROR_MESSAGES.get(
|
||||
error_code,
|
||||
"An unexpected error occurred. Please try again later."
|
||||
)
|
||||
```
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Handling Balance Errors
|
||||
|
||||
```python
|
||||
async def make_request_with_balance_check():
|
||||
try:
|
||||
# Check balance first
|
||||
balance_info = await client.get("/v1/wallet/balance")
|
||||
|
||||
# Estimate cost
|
||||
estimated_cost = calculate_cost(model, prompt_length)
|
||||
|
||||
if balance_info['balance'] < estimated_cost * 1.1: # 10% buffer
|
||||
# Proactively top up
|
||||
await top_up_balance()
|
||||
|
||||
# Make request
|
||||
return await client.chat.completions.create(...)
|
||||
|
||||
except InsufficientBalanceError as e:
|
||||
# Handle insufficient balance
|
||||
shortfall = e.details['shortfall']
|
||||
await top_up_balance(amount=shortfall * 2)
|
||||
# Retry request
|
||||
```
|
||||
|
||||
### Handling Rate Limits
|
||||
|
||||
```python
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class RateLimitTracker:
|
||||
def __init__(self):
|
||||
self.reset_times = {}
|
||||
|
||||
def is_limited(self, endpoint: str) -> bool:
|
||||
reset_time = self.reset_times.get(endpoint)
|
||||
if reset_time and datetime.now() < reset_time:
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_limit(self, endpoint: str, reset_timestamp: int):
|
||||
self.reset_times[endpoint] = datetime.fromtimestamp(reset_timestamp)
|
||||
|
||||
def wait_time(self, endpoint: str) -> float:
|
||||
reset_time = self.reset_times.get(endpoint)
|
||||
if reset_time:
|
||||
return max(0, (reset_time - datetime.now()).total_seconds())
|
||||
return 0
|
||||
```
|
||||
|
||||
## Testing Error Handling
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
|
||||
async def test_insufficient_balance_handling():
|
||||
# Mock API client
|
||||
mock_client = Mock()
|
||||
mock_client.chat.completions.create.side_effect = InsufficientBalanceError(
|
||||
required=100,
|
||||
available=50
|
||||
)
|
||||
|
||||
# Test error handling
|
||||
handler = ErrorHandler(mock_client)
|
||||
result = await handler.safe_request(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": "test"}]
|
||||
)
|
||||
|
||||
# Verify fallback behavior
|
||||
assert result.fallback_used is True
|
||||
assert result.model == "gpt-3.5-turbo"
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```python
|
||||
async def test_real_error_scenarios():
|
||||
# Test with invalid API key
|
||||
invalid_client = OpenAI(
|
||||
api_key="sk-invalid",
|
||||
base_url=test_url
|
||||
)
|
||||
|
||||
with pytest.raises(AuthenticationError) as exc_info:
|
||||
await invalid_client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "test"}]
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "invalid_api_key" in str(exc_info.value)
|
||||
```
|
||||
|
||||
## Monitoring Errors
|
||||
|
||||
Track error rates and patterns:
|
||||
|
||||
```python
|
||||
class ErrorMetrics:
|
||||
def __init__(self):
|
||||
self.error_counts = defaultdict(int)
|
||||
self.error_timestamps = defaultdict(list)
|
||||
|
||||
def record_error(self, error_code: str):
|
||||
self.error_counts[error_code] += 1
|
||||
self.error_timestamps[error_code].append(datetime.now())
|
||||
|
||||
def get_error_rate(self, error_code: str, window_minutes: int = 60) -> float:
|
||||
cutoff = datetime.now() - timedelta(minutes=window_minutes)
|
||||
recent_errors = [
|
||||
ts for ts in self.error_timestamps[error_code]
|
||||
if ts > cutoff
|
||||
]
|
||||
return len(recent_errors) / window_minutes
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Authentication](authentication.md) - Auth error details
|
||||
- [Endpoints](endpoints.md) - Endpoint-specific errors
|
||||
- [Integration Guide](../client/integration.md) - Error handling examples
|
||||
312
docs/api/overview.md
Normal file
312
docs/api/overview.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# API Reference Overview
|
||||
|
||||
Routstr Core provides a complete OpenAI-compatible API with additional endpoints for payment management. This reference covers all available endpoints, authentication methods, and response formats.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://api.routstr.com/v1
|
||||
```
|
||||
|
||||
All API endpoints are prefixed with `/v1` for versioning.
|
||||
|
||||
## Authentication
|
||||
|
||||
Routstr uses API keys for authentication. Include your key in the Authorization header:
|
||||
|
||||
```bash
|
||||
Authorization: Bearer sk-...
|
||||
```
|
||||
|
||||
### API Key Format
|
||||
|
||||
- Prefix: `sk-`
|
||||
- Length: 32 characters
|
||||
- Example: `sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p`
|
||||
|
||||
### Cashu Tokens as Authentication
|
||||
|
||||
You can also use a Cashu eCash token directly in the `Authorization` header. The server hashes the token internally; this hash represents your API key identity and carries the token's balance.
|
||||
|
||||
```bash
|
||||
Authorization: Bearer cashuAeyJ0b2tlbiI6W3...
|
||||
```
|
||||
|
||||
## Content Types
|
||||
|
||||
### Request
|
||||
|
||||
- **Required**: `Content-Type: application/json`
|
||||
- **Encoding**: UTF-8
|
||||
- **Maximum Size**: 10MB (configurable)
|
||||
|
||||
### Response
|
||||
|
||||
- **Type**: `application/json` or `text/event-stream` (for streaming)
|
||||
- **Encoding**: UTF-8
|
||||
- **Compression**: gzip (if accepted)
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Rate limits are applied per API key:
|
||||
|
||||
- **Requests**: 1000 per minute
|
||||
- **Tokens**: 1,000,000 per hour
|
||||
- **Concurrent**: 10 simultaneous requests
|
||||
|
||||
Rate limit headers:
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 1000
|
||||
X-RateLimit-Remaining: 999
|
||||
X-RateLimit-Reset: 1640995200
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
All errors follow a consistent format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"type": "insufficient_balance",
|
||||
"message": "Insufficient balance for request",
|
||||
"code": "payment_required",
|
||||
"details": {
|
||||
"required": 154,
|
||||
"available": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Types
|
||||
|
||||
| Type | Status Code | Description |
|
||||
|------|-------------|-------------|
|
||||
| `invalid_request` | 400 | Malformed request |
|
||||
| `authentication_failed` | 401 | Invalid or missing API key |
|
||||
| `insufficient_balance` | 402 | Not enough balance |
|
||||
| `forbidden` | 403 | Access denied |
|
||||
| `not_found` | 404 | Resource not found |
|
||||
| `rate_limit_exceeded` | 429 | Too many requests |
|
||||
| `internal_error` | 500 | Server error |
|
||||
| `upstream_error` | 502 | Upstream API error |
|
||||
|
||||
## Endpoint Categories
|
||||
|
||||
### AI/ML Endpoints
|
||||
|
||||
Standard OpenAI-compatible endpoints:
|
||||
|
||||
- **Models**: `/v1/models`
|
||||
- **Responses**: `/v1/responses`
|
||||
- **Chat Completions**: `/v1/chat/completions`
|
||||
- **Embeddings**: `/v1/embeddings`
|
||||
- **Completions**: `/v1/completions` *(planned)*
|
||||
- **Images**: `/v1/images/generations` *(planned)*
|
||||
- **Audio**: `/v1/audio/transcriptions` *(planned)*
|
||||
|
||||
### Payment Endpoints
|
||||
|
||||
Routstr-specific payment management:
|
||||
|
||||
- **Balance**: `/v1/balance/*`
|
||||
- **Node Info**: `/v1/info`
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
Protected administrative functions:
|
||||
|
||||
- **Dashboard**: `/admin/`
|
||||
- **API Management**: `/admin/api/*`
|
||||
|
||||
## Request Headers
|
||||
|
||||
### Standard Headers
|
||||
|
||||
| Header | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `Authorization` | Yes | Bearer token with API key |
|
||||
| `Content-Type` | Yes | Must be `application/json` |
|
||||
| `Accept` | No | Response format preference |
|
||||
| `Accept-Encoding` | No | Compression support |
|
||||
| `X-Request-ID` | No | Client-provided request ID |
|
||||
|
||||
### Custom Headers
|
||||
|
||||
| Header | Description |
|
||||
|--------|-------------|
|
||||
| `X-Cashu` | eCash token for per-request payment |
|
||||
|
||||
#### X-Cashu: Stateless Per-Request Payment
|
||||
|
||||
Instead of using `Authorization: Bearer sk-...`, you can send a Cashu token directly in the `X-Cashu` header. The response will include an `X-Cashu-Refund` header with your change.
|
||||
|
||||
```bash
|
||||
curl https://api.routstr.com/v1/chat/completions \
|
||||
-H "X-Cashu: cashuA3s8jKx9..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]}'
|
||||
```
|
||||
|
||||
The response includes your change in the same header:
|
||||
```
|
||||
X-Cashu: cashuA7k2mNp4...
|
||||
```
|
||||
|
||||
This is fully stateless—no session, no `/v1/balance/refund` call needed. However, **streaming does not work with `X-Cashu`** because the refund can only be calculated after the full response is generated. 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
|
||||
|
||||
### Standard Headers
|
||||
|
||||
| Header | Description |
|
||||
|--------|-------------|
|
||||
| `Content-Type` | Response format |
|
||||
| `Content-Length` | Response size |
|
||||
| `X-Request-ID` | Unique request identifier |
|
||||
| `X-Cashu` | Change token (when request used `X-Cashu` header) |
|
||||
|
||||
## Streaming Responses
|
||||
|
||||
For endpoints supporting streaming, responses use Server-Sent Events:
|
||||
|
||||
```
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":" there"},"finish_reason":null}]}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
## OpenAPI Specification
|
||||
|
||||
The complete OpenAPI 3.0 specification is available at:
|
||||
|
||||
```
|
||||
GET /openapi.json
|
||||
```
|
||||
|
||||
Interactive documentation:
|
||||
|
||||
```
|
||||
GET /docs # Swagger UI
|
||||
GET /redoc # ReDoc
|
||||
```
|
||||
|
||||
## SDK Support
|
||||
|
||||
Routstr is compatible with official OpenAI SDKs:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key="sk-...",
|
||||
base_url="https://your-node.com/v1"
|
||||
)
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```javascript
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: 'sk-...',
|
||||
baseURL: 'https://your-node.com/v1'
|
||||
});
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl https://your-node.com/v1/chat/completions \
|
||||
-H "Authorization: Bearer sk-..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello"}]}'
|
||||
```
|
||||
|
||||
## Webhook Support
|
||||
|
||||
Configure webhooks for events:
|
||||
|
||||
```json
|
||||
POST /v1/webhooks
|
||||
{
|
||||
"url": "https://your-app.com/webhook",
|
||||
"events": ["balance.low", "key.expired"],
|
||||
"secret": "whsec_your_secret"
|
||||
}
|
||||
```
|
||||
|
||||
Events are sent with signature verification:
|
||||
|
||||
```
|
||||
X-Webhook-Signature: sha256=...
|
||||
```
|
||||
|
||||
## API Versioning
|
||||
|
||||
- Current version: `v1`
|
||||
- Version in URL path: `/v1/endpoint`
|
||||
|
||||
## Status Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 200 | Success |
|
||||
| 201 | Created |
|
||||
| 204 | No content |
|
||||
| 400 | Bad request |
|
||||
| 401 | Unauthorized |
|
||||
| 402 | Payment required |
|
||||
| 403 | Forbidden |
|
||||
| 404 | Not found |
|
||||
| 429 | Rate limited |
|
||||
| 500 | Server error |
|
||||
| 502 | Upstream error |
|
||||
| 503 | Service unavailable |
|
||||
|
||||
## CORS Support
|
||||
|
||||
CORS is enabled with configurable origins:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
|
||||
Access-Control-Allow-Headers: Authorization, Content-Type
|
||||
Access-Control-Max-Age: 86400
|
||||
```
|
||||
|
||||
## Compression
|
||||
|
||||
Responses are compressed with gzip when:
|
||||
|
||||
- Client sends `Accept-Encoding: gzip`
|
||||
- Response is larger than 1KB
|
||||
- Content type is compressible
|
||||
|
||||
## Batch Requests *(planned)*
|
||||
|
||||
Process multiple operations in one request. Coming soon.
|
||||
|
||||
## Node Info
|
||||
|
||||
Get node metadata:
|
||||
|
||||
```
|
||||
GET /v1/info
|
||||
```
|
||||
|
||||
Supported models and pricing are available at `/v1/models`.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Authentication](authentication.md) - Detailed auth guide
|
||||
- [Endpoints](endpoints.md) - Complete endpoint reference
|
||||
- [Errors](errors.md) - Error handling guide
|
||||
- [Integration Guide](../client/integration.md) - Code examples
|
||||
143
docs/client/integration.md
Normal file
143
docs/client/integration.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Using the API
|
||||
|
||||
Routstr is **OpenAI-compatible**. Almost any AI application, SDK, or tool that supports custom endpoints will work out of the box. Just change two things:
|
||||
|
||||
```
|
||||
BASE_URL → https://api.routstr.com/v1
|
||||
API_KEY → sk-... or cashuA...
|
||||
```
|
||||
|
||||
**Both work as API keys:**
|
||||
- `sk-7f8e9d...` — Session key (from Lightning invoice or Cashu import)
|
||||
- `cashuA3s8j...` — Raw Cashu token (use directly from your wallet)
|
||||
|
||||
If the app lets you set a base URL and API key, you're good to go.
|
||||
|
||||
---
|
||||
|
||||
## Quick Setup Examples
|
||||
|
||||
### OpenAI SDK (Python/JS)
|
||||
|
||||
```python
|
||||
client = OpenAI(base_url="https://api.routstr.com/v1", api_key="sk-...") # or any provider's URL
|
||||
```
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL=https://api.routstr.com/v1
|
||||
export ANTHROPIC_AUTH_TOKEN=sk-...
|
||||
```
|
||||
|
||||
### Any OpenAI-compatible app
|
||||
|
||||
Look for "Custom API endpoint", "Base URL", or "OpenAI-compatible" in settings. Paste the URL and key.
|
||||
|
||||
---
|
||||
|
||||
## Detailed Examples
|
||||
|
||||
### Python (Official SDK)
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
# 1. Initialize with Routstr URL and your funded key
|
||||
client = OpenAI(
|
||||
base_url="https://api.routstr.com/v1",
|
||||
api_key="sk-7f8e9d..."
|
||||
)
|
||||
|
||||
# 2. Call the API normally
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
stream=True
|
||||
)
|
||||
|
||||
for chunk in response:
|
||||
if chunk.choices[0].delta.content:
|
||||
print(chunk.choices[0].delta.content, end="")
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
import OpenAI from 'openai';
|
||||
|
||||
// You can use a session key OR a raw Cashu token directly
|
||||
const openai = new OpenAI({
|
||||
baseURL: 'https://api.routstr.com/v1',
|
||||
apiKey: 'cashuA3s8jKx9...', // or 'sk-7f8e9d...'
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const completion = await openai.chat.completions.create({
|
||||
messages: [{ role: 'user', content: 'Say this is a test' }],
|
||||
model: 'gpt-3.5-turbo',
|
||||
});
|
||||
|
||||
console.log(completion.choices[0]);
|
||||
}
|
||||
|
||||
main();
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
# Works with session key or raw Cashu token
|
||||
curl https://api.routstr.com/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer cashuA3s8jKx9..." \
|
||||
-d '{
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [{"role": "user", "content": "Hello!"}]
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Insufficient Balance (402 Payment Required)
|
||||
If your session runs out of funds, the API will return a `402` error.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"message": "Insufficient balance. Current: 1000 msat, Required: 5000 msat",
|
||||
"type": "insufficient_balance",
|
||||
"code": 402
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Action**: Top up your key using the `/lightning/invoice` (topup purpose) or `/v1/balance/topup` endpoints.
|
||||
|
||||
### Rate Limiting
|
||||
Routstr passes through rate limits from the upstream provider. Handle `429 Too Many Requests` with standard exponential backoff.
|
||||
|
||||
---
|
||||
|
||||
## Advanced: Tor Access
|
||||
|
||||
If the node is running as a hidden service, use a SOCKS5 proxy (like `127.0.0.1:9050`).
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
proxy_mounts = {
|
||||
"http://": httpx.HTTPTransport(proxy="socks5://127.0.0.1:9050"),
|
||||
"https://": httpx.HTTPTransport(proxy="socks5://127.0.0.1:9050"),
|
||||
}
|
||||
|
||||
client = OpenAI(
|
||||
base_url="http://verylongonionaddress.onion/v1",
|
||||
api_key="sk-...",
|
||||
http_client=httpx.Client(mounts=proxy_mounts),
|
||||
)
|
||||
```
|
||||
142
docs/client/introduction.md
Normal file
142
docs/client/introduction.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Introduction to Routstr
|
||||
|
||||
Welcome to the Routstr Core User Guide. This guide will help you understand how to use Routstr to access AI APIs with Bitcoin micropayments.
|
||||
|
||||
## What You'll Learn
|
||||
|
||||
- How the payment system works (Cashu eCash)
|
||||
- Creating and managing API keys (Ephemeral Sessions)
|
||||
- Making API calls through Routstr
|
||||
- Using the admin dashboard
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 💰 Wallet
|
||||
|
||||
Cashu ([cashu.me](https://cashu.me)) or Lightning ([Strike](https://strike.me), Cash App, etc.)
|
||||
|
||||
### 🌐 Provider
|
||||
|
||||
A Routstr node, e.g. `https://api.routstr.com`
|
||||
|
||||
### 🤖 Client
|
||||
|
||||
OpenAI SDK, Claude Code, Cursor, or any OpenAI-compatible tool
|
||||
|
||||
---
|
||||
|
||||
## How Routstr Works
|
||||
|
||||
Routstr is a **Payment Proxy**. It sits between your code and the AI provider.
|
||||
|
||||
### Traditional API vs Routstr
|
||||
|
||||
| Traditional | Routstr |
|
||||
|---|---|
|
||||
| Credit Card Required | Bitcoin / Lightning / eCash |
|
||||
| Monthly Billing | Pay-per-request (Real-time) |
|
||||
| KYC / Account | No Account / Private |
|
||||
| Single Provider | Aggregated Providers |
|
||||
|
||||
### Key Concepts
|
||||
|
||||
#### 1. Cashu eCash
|
||||
|
||||
Digital bearer tokens backed by Bitcoin. They are instant, private, and have no fees for internal transfers. Routstr uses these tokens as the "credits" for API requests.
|
||||
|
||||
#### 2. Ephemeral Sessions (API Keys)
|
||||
|
||||
Instead of a permanent account, you create a **Session**.
|
||||
|
||||
- You fund a session with eCash or Lightning.
|
||||
- Routstr gives you an `api_key` (`sk-...`) representing that session.
|
||||
- You use the `api_key` until funds run out or you finish your task.
|
||||
- You can **refund** the remaining balance back to your wallet at any time.
|
||||
|
||||
#### 3. Millisats (msats)
|
||||
|
||||
Everything is priced in **millisatoshis**.
|
||||
|
||||
- 1 Satoshi (sat) = 1,000 msats.
|
||||
- This allows for extremely precise pricing (e.g., 0.05 sats per prompt).
|
||||
|
||||
---
|
||||
|
||||
## Workflow: Zero to Intelligence
|
||||
|
||||
### 1. Fund a Session
|
||||
|
||||
You need an `api_key` with a balance.
|
||||
|
||||
**Easiest: Use the Web UI**
|
||||
Visit the node's root page (e.g., [api.routstr.com](https://api.routstr.com)) or [chat.routstr.com](https://chat.routstr.com) → Settings to create a key visually with Lightning.
|
||||
|
||||
**Option A: Lightning Invoice (CLI)**
|
||||
Generate an invoice and pay it with any Lightning wallet.
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.routstr.com/lightning/invoice \
|
||||
-d '{"amount_sats": 1000, "purpose": "create"}'
|
||||
```
|
||||
|
||||
*Returns an invoice (`bolt11`) and an ID. Once paid, the status endpoint returns your `api_key`.*
|
||||
|
||||
**Option B: Cashu Token (Best for privacy & devs)**
|
||||
If you have a Cashu wallet, you can copy a token string (`cashuA...`) and use it directly.
|
||||
|
||||
- **Direct Usage**: Use the token *as* your API key in the `Authorization` header.
|
||||
- **Import**: Or exchange it for a standard `sk-...` key:
|
||||
|
||||
```bash
|
||||
curl "https://api.routstr.com/v1/balance/create?initial_balance_token=cashuA..."
|
||||
```
|
||||
|
||||
*Returns your `api_key` immediately.*
|
||||
|
||||
### 2. Configure Your Client
|
||||
|
||||
Use the standard OpenAI SDK, just changing the `base_url` and `api_key`.
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="https://api.routstr.com/v1",
|
||||
api_key="sk-7f8e9d..." # The key from Step 1
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Make Requests
|
||||
|
||||
```python
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "Explain quantum computing."}]
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Withdraw Change
|
||||
|
||||
When you are done, get your change back as a Cashu token.
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.routstr.com/v1/balance/refund \
|
||||
-H "Authorization: Bearer sk-7f8e9d..."
|
||||
```
|
||||
|
||||
*Returns a `token` that you can paste back into Nutstash or Minibits to reclaim your funds.*
|
||||
|
||||
---
|
||||
|
||||
## Supported Features
|
||||
|
||||
- **Responses**: `/v1/responses` (OpenAI Responses API)
|
||||
- **Chat Completions**: `/v1/chat/completions` (Streaming supported)
|
||||
- **Embeddings**: `/v1/embeddings`
|
||||
- **Models**: `/v1/models` (List available models and prices)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Payment Flow](payments.md)**: Detailed breakdown of the funding lifecycle.
|
||||
- **[Models & Pricing](../provider/pricing.md)**: How costs are calculated.
|
||||
- **[Admin Dashboard](../provider/dashboard.md)**: Managing your node if you are the operator.
|
||||
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.
|
||||
329
docs/contributing/architecture.md
Normal file
329
docs/contributing/architecture.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# Architecture Overview
|
||||
|
||||
This document describes the high-level architecture of Routstr Core, helping contributors understand how the system works.
|
||||
|
||||
## System Overview
|
||||
|
||||
Routstr Core is a FastAPI-based reverse proxy that adds Bitcoin micropayments to OpenAI-compatible APIs and can optionally announce providers via Nostr.
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "External Services"
|
||||
Client[API Client]
|
||||
Mint[Cashu Mint]
|
||||
Provider[AI Provider]
|
||||
Nostr[Nostr Relays]
|
||||
end
|
||||
|
||||
subgraph "Routstr Core"
|
||||
API[FastAPI Server]
|
||||
Auth[Auth Module]
|
||||
Payment[Payment Module]
|
||||
Proxy[Proxy Module]
|
||||
DB[(SQLModel DB)]
|
||||
|
||||
API --> Auth
|
||||
Auth --> Payment
|
||||
Auth --> DB
|
||||
Payment --> Proxy
|
||||
Proxy --> Provider
|
||||
Payment --> Mint
|
||||
API --> Nostr
|
||||
end
|
||||
|
||||
Client --> API
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### FastAPI Application
|
||||
|
||||
The main application is initialized in `routstr/core/main.py`:
|
||||
|
||||
- **Lifespan Management**: Runs migrations, initializes DB, refreshes pricing/models, starts background tasks
|
||||
- **Middleware**: CORS and request logging
|
||||
- **Routers**: Admin, pricing/models, balance/wallet, providers discovery, proxy
|
||||
- **Background Tasks**: Price refresh, model map refresh, payouts, node announcements, provider discovery refresh
|
||||
|
||||
### Authentication System
|
||||
|
||||
Located in `routstr/auth.py`, handles:
|
||||
|
||||
- **API Key Validation**: SHA-256 hashed key lookup and persistence
|
||||
- **Balance Checking**: Ensures sufficient funds before requests
|
||||
- **Token Redemption**: Converts Cashu tokens to balance
|
||||
|
||||
### Payment Processing
|
||||
|
||||
The `routstr/payment/` module manages:
|
||||
|
||||
- **Cost Calculation**: Token-based or fixed pricing
|
||||
- **Model Pricing**: Derived from upstream providers and DB overrides
|
||||
- **Currency Conversion**: BTC/USD price refresh and conversion
|
||||
- **Fee Application**: Provider fee applied to upstream model pricing
|
||||
|
||||
### Request Proxying
|
||||
|
||||
`routstr/proxy.py` handles:
|
||||
|
||||
- **Request Forwarding**: Forwards requests to selected upstream providers
|
||||
- **Response Streaming**: Streaming and non-streaming paths
|
||||
- **Usage Tracking**: Adjusts costs after upstream responses
|
||||
- **Error Handling**: Maps upstream errors to consistent responses
|
||||
|
||||
### Database Layer
|
||||
|
||||
Using SQLModel in `routstr/core/db.py`:
|
||||
|
||||
```python
|
||||
# Core tables
|
||||
ApiKey:
|
||||
- hashed_key: Primary key (SHA-256 of key or Cashu token)
|
||||
- balance: Current balance (msats)
|
||||
- reserved_balance: Reserved balance (msats)
|
||||
- refund_address: Optional LNURL for refunds
|
||||
- key_expiry_time: Optional refund expiry timestamp
|
||||
- total_spent: Total spent (msats)
|
||||
- total_requests: Request count
|
||||
- refund_mint_url: Mint URL for refunds
|
||||
- refund_currency: Refund currency
|
||||
|
||||
UpstreamProviderRow:
|
||||
- id: Primary key
|
||||
- provider_type: openai/anthropic/azure/openrouter/etc.
|
||||
- base_url: Provider API base URL
|
||||
- api_key: Provider API key
|
||||
- api_version: Optional API version
|
||||
- enabled: Provider enabled flag
|
||||
- provider_fee: Provider fee multiplier
|
||||
|
||||
ModelRow:
|
||||
- id: Model ID
|
||||
- upstream_provider_id: Provider foreign key
|
||||
- name: Model name
|
||||
- architecture: JSON
|
||||
- pricing: JSON
|
||||
- sats_pricing: JSON
|
||||
- per_request_limits: JSON
|
||||
- top_provider: JSON
|
||||
- canonical_slug: Canonical model slug
|
||||
- alias_ids: Model aliases
|
||||
- enabled: Model enabled flag
|
||||
|
||||
LightningInvoice:
|
||||
- id: Primary key
|
||||
- bolt11: Invoice
|
||||
- amount_sats: Amount in sats
|
||||
- payment_hash: Payment hash
|
||||
- status: pending/paid/expired/cancelled
|
||||
- api_key_hash: Optional associated API key
|
||||
- purpose: create/topup
|
||||
- created_at: Unix timestamp
|
||||
- expires_at: Unix timestamp
|
||||
- paid_at: Unix timestamp
|
||||
```
|
||||
|
||||
## Request Flow
|
||||
|
||||
### Standard API Request
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant R as Routstr
|
||||
participant D as Database
|
||||
participant P as AI Provider
|
||||
|
||||
C->>R: API Request + Key
|
||||
R->>D: Validate Key
|
||||
D-->>R: Key Info + Balance
|
||||
R->>R: Reserve Max Cost
|
||||
R->>P: Forward Request
|
||||
P-->>R: AI Response
|
||||
R->>D: Finalize Cost (adjust by usage)
|
||||
R-->>C: Return Response
|
||||
```
|
||||
|
||||
### Payment Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant R as Routstr
|
||||
participant W as Wallet Module
|
||||
participant M as Cashu Mint
|
||||
participant D as Database
|
||||
|
||||
C->>R: Request + Cashu Token
|
||||
R->>W: Redeem Token
|
||||
W->>M: Verify with Mint
|
||||
M-->>W: Token Valid
|
||||
W-->>R: Token Amount
|
||||
R->>D: Create/Update Key + Balance
|
||||
R-->>C: Continue Request
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Async Architecture
|
||||
|
||||
The system is async end-to-end, with background tasks for pricing refresh, provider discovery, model map refresh, and payouts.
|
||||
|
||||
### 2. Modular Design
|
||||
|
||||
Components are loosely coupled:
|
||||
|
||||
- **Routers**: Separate files for different endpoints
|
||||
- **Dependencies**: Injected via FastAPI's DI system
|
||||
- **Models**: Shared data structures
|
||||
- **Services**: Business logic separated from routes
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
Exceptions are handled by FastAPI exception handlers to return consistent JSON responses with a request ID.
|
||||
|
||||
### 4. Database Migrations
|
||||
|
||||
Alembic migrations are run on startup, and tables are created for any models not tracked by migrations.
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### API Key Security
|
||||
|
||||
- **Storage**: SHA-256 hashed keys
|
||||
- **Generation**: Cryptographically secure random (when creating new keys)
|
||||
- **Validation**: Hash lookup in DB
|
||||
- **Expiry**: Optional refund flow via `key_expiry_time` and `refund_address`
|
||||
|
||||
### Payment Security
|
||||
|
||||
- **Token Validation**: Cashu token redemption via mint
|
||||
- **Balance Protection**: Atomic updates and reserved balance tracking
|
||||
- **Audit Trail**: Structured logging of payments and adjustments
|
||||
|
||||
### Network Security
|
||||
|
||||
- **CORS**: Configurable origins
|
||||
- **Input Validation**: Pydantic models
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
Model and provider selections are cached in process memory and refreshed on a schedule.
|
||||
|
||||
### Database Optimization
|
||||
|
||||
- **Async I/O**: Non-blocking queries
|
||||
- **Atomic Updates**: Balance reservation and finalization updates
|
||||
|
||||
### Streaming Responses
|
||||
|
||||
Streaming responses are forwarded from upstream providers with usage tracking hooks.
|
||||
|
||||
## Extension Points
|
||||
|
||||
### Adding New Endpoints
|
||||
|
||||
1. Create router module
|
||||
2. Define Pydantic models
|
||||
3. Implement business logic
|
||||
4. Register with main app
|
||||
5. Add tests
|
||||
|
||||
### Custom Pricing Models
|
||||
|
||||
1. Extend `ModelPrice` class
|
||||
2. Implement calculation logic
|
||||
3. Add to pricing registry
|
||||
4. Update configuration
|
||||
|
||||
### Payment Methods
|
||||
|
||||
1. Create payment handler
|
||||
2. Implement validation
|
||||
3. Add to payment router
|
||||
4. Update balance logic
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- Mock external dependencies
|
||||
- Test business logic in isolation
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Test component interactions
|
||||
- Use test database
|
||||
- Mock external services
|
||||
- Verify end-to-end flows
|
||||
|
||||
### Performance Tests
|
||||
|
||||
- Response time benchmarks (as needed)
|
||||
|
||||
## Monitoring and Observability
|
||||
|
||||
### Structured Logging
|
||||
|
||||
```python
|
||||
logger.info("api_request", extra={
|
||||
"request_id": request_id,
|
||||
"api_key": api_key_id,
|
||||
"endpoint": endpoint,
|
||||
"model": model,
|
||||
"tokens": token_count,
|
||||
"cost_sats": cost,
|
||||
"duration_ms": duration
|
||||
})
|
||||
```
|
||||
|
||||
### Metrics Collection
|
||||
|
||||
Structured logs are emitted for requests, pricing, and payment events.
|
||||
|
||||
### Health Checks
|
||||
|
||||
Use `/v1/info` for basic service metadata and configuration visibility.
|
||||
|
||||
## Deployment Architecture
|
||||
|
||||
### Container Structure
|
||||
|
||||
See `core/Dockerfile` for the current container build configuration.
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
- **Development**: Local SQLite, debug logging
|
||||
- **Testing**: In-memory database, mock services
|
||||
- **Production**: Persistent storage, structured logs
|
||||
|
||||
### Scaling Considerations
|
||||
|
||||
- **Horizontal**: Multiple instances behind a load balancer
|
||||
- **Vertical**: Async handles high concurrency
|
||||
- **Database**: Configure `DATABASE_URL` for external databases
|
||||
|
||||
## Future Architecture
|
||||
|
||||
### Planned Improvements
|
||||
|
||||
1. **WebSocket Support**: Real-time balance updates
|
||||
2. **Plugin System**: Extensible pricing/auth
|
||||
3. **Multi-Region**: Geographic distribution
|
||||
4. **Event Sourcing**: Complete audit trail
|
||||
|
||||
### Technical Debt
|
||||
|
||||
Areas for improvement:
|
||||
|
||||
- Database query optimization
|
||||
- Response caching layer
|
||||
- Metric aggregation
|
||||
- API versioning strategy
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Review [Code Structure](code-structure.md) for detailed organization
|
||||
- See [Testing Guide](testing.md) for test architecture
|
||||
443
docs/contributing/code-structure.md
Normal file
443
docs/contributing/code-structure.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# Code Structure
|
||||
|
||||
This guide provides a detailed overview of Routstr Core's codebase organization and key modules.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
routstr-core/
|
||||
├── routstr/ # Main application package
|
||||
│ ├── __init__.py # Package initialization, exports FastAPI app
|
||||
│ ├── algorithm.py # Model selection/mapping logic
|
||||
│ ├── auth.py # Bearer/Cashu auth and payment handling
|
||||
│ ├── balance.py # Balance management endpoints
|
||||
│ ├── discovery.py # Nostr relay discovery
|
||||
│ ├── lightning.py # Lightning invoice topups
|
||||
│ ├── nip91.py # Node announcement logic
|
||||
│ ├── proxy.py # Request proxying logic
|
||||
│ ├── wallet.py # Cashu wallet operations
|
||||
│ │
|
||||
│ ├── core/ # Core infrastructure
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── admin.py # Admin dashboard and API
|
||||
│ │ ├── db.py # Database models and connection
|
||||
│ │ ├── exceptions.py # Exception handlers
|
||||
│ │ ├── logging.py # Structured logging setup
|
||||
│ │ ├── main.py # FastAPI app initialization
|
||||
│ │ └── middleware.py # HTTP middleware components
|
||||
│ │
|
||||
│ ├── payment/ # Payment processing
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── cost_calculation.py # Usage cost calculation
|
||||
│ │ ├── helpers.py # Payment utilities
|
||||
│ │ ├── lnurl.py # Lightning URL support
|
||||
│ │ ├── models.py # Model pricing management
|
||||
│ │ └── price.py # BTC/USD price handling
|
||||
│ │
|
||||
│ └── upstream/ # Upstream provider integrations
|
||||
│ ├── base.py # Base provider logic
|
||||
│ ├── helpers.py # Provider init and model refresh
|
||||
│ └── ... # Provider implementations
|
||||
│
|
||||
├── tests/ # Test suite
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # Pytest configuration
|
||||
│ ├── unit/ # Unit tests
|
||||
│ └── integration/ # Integration tests
|
||||
│
|
||||
├── migrations/ # Alembic database migrations
|
||||
│ ├── alembic.ini
|
||||
│ ├── env.py
|
||||
│ ├── script.py.mako
|
||||
│ └── versions/ # Migration files
|
||||
│
|
||||
├── scripts/ # Utility scripts
|
||||
│ ├── models_meta.py # Fetch model pricing
|
||||
│ └── ... # Build/update helpers
|
||||
│
|
||||
├── examples/ # Example clients
|
||||
├── testing-clients/ # HTML test clients
|
||||
├── ui/ # Next.js admin UI
|
||||
│
|
||||
├── docs/ # Documentation
|
||||
├── logs/ # Application logs (git ignored)
|
||||
│
|
||||
├── .github/ # GitHub Actions workflows
|
||||
├── .env.example # Environment variable template
|
||||
├── .gitignore # Git ignore rules
|
||||
├── .dockerignore # Docker ignore rules
|
||||
├── Dockerfile # Container definition
|
||||
├── Makefile # Development commands
|
||||
├── README.md # Project overview
|
||||
├── alembic.ini # Migration configuration
|
||||
├── compose.yml # Docker Compose setup
|
||||
├── compose.testing.yml # Testing environment
|
||||
├── pyproject.toml # Project configuration
|
||||
└── uv.lock # Locked dependencies
|
||||
```
|
||||
|
||||
## Key Modules
|
||||
|
||||
### Application Entry Point
|
||||
|
||||
#### `routstr/__init__.py`
|
||||
|
||||
```python
|
||||
from .core.main import app as fastapi_app
|
||||
|
||||
__all__ = ["fastapi_app"]
|
||||
```
|
||||
|
||||
#### `routstr/core/main.py`
|
||||
|
||||
```python
|
||||
# FastAPI application setup
|
||||
app = FastAPI(version=__version__, lifespan=lifespan)
|
||||
|
||||
# Middleware registration
|
||||
app.add_middleware(CORSMiddleware, ...)
|
||||
app.add_middleware(LoggingMiddleware)
|
||||
|
||||
# Router inclusion
|
||||
app.include_router(models_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(balance_router)
|
||||
app.include_router(deprecated_wallet_router)
|
||||
app.include_router(providers_router)
|
||||
app.include_router(proxy_router)
|
||||
```
|
||||
|
||||
### Authentication Module
|
||||
|
||||
#### `routstr/auth.py`
|
||||
|
||||
Handles bearer key validation and payment lifecycle (bearer or Cashu token):
|
||||
|
||||
```python
|
||||
async def validate_bearer_key(
|
||||
bearer_key: str,
|
||||
session: AsyncSession,
|
||||
refund_address: Optional[str] = None,
|
||||
key_expiry_time: Optional[int] = None,
|
||||
) -> ApiKey:
|
||||
"""Validate bearer API key or redeem Cashu token into a balance."""
|
||||
```
|
||||
|
||||
Key functions:
|
||||
|
||||
- `validate_bearer_key()` - Validate API key or Cashu token
|
||||
- `pay_for_request()` - Reserve max cost before upstream call
|
||||
- `adjust_payment_for_tokens()` - Adjust final cost after response
|
||||
- `revert_pay_for_request()` - Refund on upstream failure
|
||||
|
||||
### Payment Processing
|
||||
|
||||
#### `routstr/payment/cost_calculation.py`
|
||||
|
||||
Calculates request costs:
|
||||
|
||||
```python
|
||||
async def calculate_cost(
|
||||
response_data: dict, max_cost: int, session: AsyncSession
|
||||
) -> CostData | MaxCostData | CostDataError:
|
||||
"""Calculate cost in millisatoshis from response usage or model pricing."""
|
||||
```
|
||||
|
||||
#### `routstr/payment/models.py`
|
||||
|
||||
Manages model pricing, database overrides, and pricing refresh:
|
||||
|
||||
```python
|
||||
class Model(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
pricing: Pricing
|
||||
sats_pricing: Pricing | None = None
|
||||
|
||||
async def update_sats_pricing():
|
||||
"""Periodic task to update sats pricing for providers and overrides."""
|
||||
```
|
||||
|
||||
#### `routstr/proxy.py` + `routstr/upstream/*`
|
||||
|
||||
The `x-cashu` header is handled by the proxy route and delegated to upstream providers.
|
||||
|
||||
### Request Proxying
|
||||
|
||||
#### `routstr/proxy.py`
|
||||
|
||||
Core proxy functionality:
|
||||
|
||||
```python
|
||||
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
|
||||
async def proxy(
|
||||
request: Request, path: str, session: AsyncSession = Depends(get_session)
|
||||
) -> Response | StreamingResponse:
|
||||
"""Forward requests to upstream provider and charge usage."""
|
||||
```
|
||||
|
||||
Key features:
|
||||
|
||||
- Streaming support
|
||||
- Header preservation
|
||||
- Error handling
|
||||
- Usage tracking
|
||||
|
||||
### Database Layer
|
||||
|
||||
#### `routstr/core/db.py`
|
||||
|
||||
SQLModel definitions (selected):
|
||||
|
||||
```python
|
||||
class ApiKey(SQLModel, table=True):
|
||||
hashed_key: str = Field(primary_key=True)
|
||||
balance: int
|
||||
reserved_balance: int = 0
|
||||
refund_address: str | None = None
|
||||
key_expiry_time: int | None = None
|
||||
total_spent: int = 0
|
||||
total_requests: int = 0
|
||||
|
||||
class LightningInvoice(SQLModel, table=True):
|
||||
id: str = Field(primary_key=True)
|
||||
bolt11: str
|
||||
amount_sats: int
|
||||
status: str
|
||||
|
||||
class UpstreamProviderRow(SQLModel, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
provider_type: str
|
||||
base_url: str
|
||||
api_key: str
|
||||
```
|
||||
|
||||
### Admin Interface
|
||||
|
||||
#### `routstr/core/admin.py`
|
||||
|
||||
Web dashboard and admin API:
|
||||
|
||||
```python
|
||||
@admin_router.get("/admin")
|
||||
async def admin_dashboard(request: Request):
|
||||
"""Render admin HTML interface"""
|
||||
# Authentication check
|
||||
# Load statistics
|
||||
# Render template
|
||||
|
||||
@admin_router.post("/admin/withdraw")
|
||||
async def withdraw_balance(
|
||||
request: Request, withdraw_request: WithdrawRequest
|
||||
) -> dict[str, str]:
|
||||
"""Generate eCash token for withdrawal"""
|
||||
```
|
||||
|
||||
Features:
|
||||
|
||||
- HTML dashboard
|
||||
- API key management
|
||||
- Balance withdrawals
|
||||
- Usage statistics
|
||||
|
||||
### Wallet Integration
|
||||
|
||||
#### `routstr/wallet.py`
|
||||
|
||||
Cashu wallet operations (function-based):
|
||||
|
||||
```python
|
||||
async def recieve_token(token: str) -> tuple[int, str, str]:
|
||||
"""Redeem eCash token and return amount/unit/mint."""
|
||||
|
||||
async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str:
|
||||
"""Create eCash token for withdrawal."""
|
||||
```
|
||||
|
||||
### Utility Modules
|
||||
|
||||
#### `routstr/core/logging.py`
|
||||
|
||||
Structured logging configuration:
|
||||
|
||||
```python
|
||||
def setup_logging():
|
||||
"""Configure JSON structured logging"""
|
||||
|
||||
class RequestIdFilter(logging.Filter):
|
||||
"""Attach request ID to log records."""
|
||||
```
|
||||
|
||||
#### `routstr/core/middleware.py`
|
||||
|
||||
HTTP middleware components:
|
||||
|
||||
```python
|
||||
class LoggingMiddleware:
|
||||
"""Log all HTTP requests/responses"""
|
||||
```
|
||||
|
||||
#### `routstr/core/exceptions.py`
|
||||
|
||||
Exception handlers:
|
||||
|
||||
```python
|
||||
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""HTTP exception handler with request ID"""
|
||||
|
||||
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Fallback exception handler with request ID"""
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### `pyproject.toml`
|
||||
|
||||
Project metadata and dependencies:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.115",
|
||||
"sqlmodel>=0.0.24",
|
||||
"cashu",
|
||||
# ...
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
```
|
||||
|
||||
### `alembic.ini`
|
||||
|
||||
Database migration configuration:
|
||||
|
||||
```ini
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
```
|
||||
|
||||
### `Makefile`
|
||||
|
||||
Development commands:
|
||||
|
||||
```makefile
|
||||
# Setup commands
|
||||
setup:
|
||||
uv sync
|
||||
uv pip install -e .
|
||||
|
||||
# Development server
|
||||
dev:
|
||||
fastapi dev routstr --host 0.0.0.0
|
||||
|
||||
# Testing
|
||||
test:
|
||||
uv run pytest
|
||||
|
||||
# Code quality
|
||||
lint:
|
||||
uv run ruff check .
|
||||
```
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
Using FastAPI's DI system:
|
||||
|
||||
```python
|
||||
# Define dependency
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
# Use in routes
|
||||
@router.get("/items")
|
||||
async def get_items(db: AsyncSession = Depends(get_session)):
|
||||
result = await db.execute(select(Item))
|
||||
return result.scalars().all()
|
||||
```
|
||||
|
||||
### Async Context Managers
|
||||
|
||||
For resource management:
|
||||
|
||||
```python
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
|
||||
async with database.transaction():
|
||||
# Atomic operations
|
||||
```
|
||||
|
||||
### Type Safety
|
||||
|
||||
Leveraging Python 3.11+ features:
|
||||
|
||||
```python
|
||||
# Union types with |
|
||||
def process(value: str | int) -> dict[str, Any]:
|
||||
pass
|
||||
|
||||
# Type aliases
|
||||
Balance = int # millisatoshis
|
||||
TokenList = list[dict[str, str]]
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Consistent error responses:
|
||||
|
||||
```python
|
||||
try:
|
||||
result = await risky_operation()
|
||||
except SpecificError as e:
|
||||
logger.error("Operation failed", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "specific_error",
|
||||
"message": str(e)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Module Organization
|
||||
|
||||
1. **Single Responsibility**: Each module has one clear purpose
|
||||
2. **Minimal Imports**: Import only what's needed
|
||||
3. **Circular Dependencies**: Avoid by using dependency injection
|
||||
4. **Public API**: Expose through `__init__.py`
|
||||
|
||||
### Function Design
|
||||
|
||||
1. **Type Hints**: Always include complete type annotations
|
||||
2. **Async First**: Use async/await for I/O operations
|
||||
3. **Error Handling**: Raise specific exceptions
|
||||
4. **Documentation**: Docstrings for public functions
|
||||
|
||||
### Testing Structure
|
||||
|
||||
1. **Mirror Source**: Test structure matches source
|
||||
2. **Fixtures**: Reusable test data in conftest.py
|
||||
3. **Mocking**: Mock external dependencies
|
||||
4. **Coverage**: Aim for >80% coverage
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Review [Testing Guide](testing.md) for test structure
|
||||
- Read [Architecture](architecture.md) for system design
|
||||
424
docs/contributing/setup.md
Normal file
424
docs/contributing/setup.md
Normal file
@@ -0,0 +1,424 @@
|
||||
# Development Setup
|
||||
|
||||
This guide will help you set up a development environment for contributing to Routstr Core.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have:
|
||||
|
||||
- **Python 3.11+** - Required for type hints and modern features
|
||||
- **Git** - For version control
|
||||
- **Docker** (optional) - For running integration tests
|
||||
- **Make** - For running development commands
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Fork and Clone
|
||||
|
||||
First, fork the repository on GitHub, then clone your fork:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/routstr-core.git
|
||||
cd routstr-core
|
||||
```
|
||||
|
||||
### 2. Set Up Environment
|
||||
|
||||
Run the setup command:
|
||||
|
||||
```bash
|
||||
make setup
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
- ✅ Install [uv](https://github.com/astral-sh/uv) if not present
|
||||
- ✅ Create a virtual environment
|
||||
- ✅ Install all dependencies
|
||||
- ✅ Install dev tools (mypy, ruff, pytest)
|
||||
- ✅ Install project in editable mode
|
||||
|
||||
### 3. Configure Environment
|
||||
|
||||
Create your environment file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with your configuration:
|
||||
|
||||
```bash
|
||||
# Minimum required for development
|
||||
UPSTREAM_BASE_URL=https://api.openai.com/v1
|
||||
UPSTREAM_API_KEY=your-api-key # Optional for mock testing
|
||||
ADMIN_PASSWORD=development-password
|
||||
DATABASE_URL=sqlite+aiosqlite:///dev.db
|
||||
```
|
||||
|
||||
### 4. Verify Installation
|
||||
|
||||
Run these commands to verify your setup:
|
||||
|
||||
```bash
|
||||
# Check dependencies
|
||||
make check-deps
|
||||
|
||||
# Run unit tests
|
||||
make test-unit
|
||||
|
||||
# Start development server
|
||||
make dev
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Running the Server
|
||||
|
||||
For development with auto-reload:
|
||||
|
||||
```bash
|
||||
make dev
|
||||
# Server starts at http://localhost:8000
|
||||
# Auto-reloads on code changes
|
||||
```
|
||||
|
||||
For production-like environment:
|
||||
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
Before committing, always run:
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
make format
|
||||
|
||||
# Check linting
|
||||
make lint
|
||||
|
||||
# Type checking
|
||||
make type-check
|
||||
|
||||
# All checks at once
|
||||
make check
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Run different test suites:
|
||||
|
||||
```bash
|
||||
# Unit tests only (fast)
|
||||
make test-unit
|
||||
|
||||
# Integration tests with mocks
|
||||
make test-integration
|
||||
|
||||
# All tests
|
||||
make test
|
||||
|
||||
# With coverage report
|
||||
make test-coverage
|
||||
|
||||
# Run specific test
|
||||
uv run pytest tests/unit/test_auth.py::test_token_validation -v
|
||||
```
|
||||
|
||||
### Database Management
|
||||
|
||||
Work with database migrations:
|
||||
|
||||
```bash
|
||||
# Create new migration
|
||||
make db-migrate
|
||||
|
||||
# Apply migrations
|
||||
make db-upgrade
|
||||
|
||||
# Rollback one migration
|
||||
make db-downgrade
|
||||
|
||||
# View current revision
|
||||
make db-current
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
Understanding the codebase:
|
||||
|
||||
```
|
||||
routstr-core/
|
||||
├── routstr/ # Main package
|
||||
│ ├── __init__.py
|
||||
│ ├── algorithm.py # Provider selection algorithms
|
||||
│ ├── auth.py # Authentication logic
|
||||
│ ├── balance.py # Balance management API
|
||||
│ ├── discovery.py # Nostr discovery
|
||||
│ ├── lightning.py # Lightning invoice handling
|
||||
│ ├── nip91.py # Node announcement implementation
|
||||
│ ├── proxy.py # Request proxying
|
||||
│ ├── wallet.py # Cashu wallet integration
|
||||
│ │
|
||||
│ ├── core/ # Core modules
|
||||
│ │ ├── admin.py # Admin dashboard API
|
||||
│ │ ├── db.py # Database models (SQLModel)
|
||||
│ │ ├── exceptions.py # Custom exceptions
|
||||
│ │ ├── log_manager.py # Log management
|
||||
│ │ ├── logging.py # Logging setup
|
||||
│ │ ├── main.py # FastAPI app entry
|
||||
│ │ ├── middleware.py # HTTP middleware
|
||||
│ │ └── settings.py # Configuration
|
||||
│ │
|
||||
│ ├── payment/ # Payment processing
|
||||
│ │ ├── cost_calculation.py
|
||||
│ │ ├── helpers.py
|
||||
│ │ ├── lnurl.py # LNURL support
|
||||
│ │ ├── models.py # Model pricing
|
||||
│ │ └── price.py # BTC/USD rates
|
||||
│ │
|
||||
│ └── upstream/ # Upstream providers
|
||||
│ ├── base.py # Base provider class
|
||||
│ ├── helpers.py # Shared utilities
|
||||
│ ├── openai.py # OpenAI
|
||||
│ ├── anthropic.py # Anthropic
|
||||
│ ├── gemini.py # Google Gemini
|
||||
│ ├── openrouter.py # OpenRouter
|
||||
│ └── ... # More providers
|
||||
│
|
||||
├── ui/ # Admin dashboard (Next.js)
|
||||
│ ├── app/ # Next.js app router
|
||||
│ │ ├── page.tsx # Landing page
|
||||
│ │ ├── balances/ # Balance management
|
||||
│ │ ├── logs/ # Request logs viewer
|
||||
│ │ ├── model/ # Model configuration
|
||||
│ │ ├── providers/ # Upstream providers
|
||||
│ │ ├── settings/ # Node settings
|
||||
│ │ └── transactions/ # Transaction history
|
||||
│ ├── components/ # React components
|
||||
│ │ ├── ui/ # shadcn/ui primitives
|
||||
│ │ ├── landing/ # Landing page components
|
||||
│ │ └── settings/ # Settings components
|
||||
│ └── lib/ # Utilities & API client
|
||||
│ ├── api/ # Backend API client
|
||||
│ ├── auth/ # Auth context
|
||||
│ └── hooks/ # React hooks
|
||||
│
|
||||
├── tests/ # Test suite
|
||||
├── migrations/ # Alembic migrations
|
||||
├── scripts/ # Utility scripts
|
||||
├── docs/ # Documentation
|
||||
├── examples/ # Usage examples
|
||||
│
|
||||
├── Makefile # Dev commands
|
||||
├── pyproject.toml # Project config
|
||||
└── compose.yml # Docker setup
|
||||
```
|
||||
|
||||
### Admin Dashboard (UI)
|
||||
|
||||
The admin dashboard is a Next.js app using:
|
||||
|
||||
- **Next.js 14** with App Router
|
||||
- **shadcn/ui** for components
|
||||
- **Tailwind CSS** for styling
|
||||
- **pnpm** for package management
|
||||
|
||||
```bash
|
||||
# Development
|
||||
cd ui
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:3000
|
||||
|
||||
# Build for production
|
||||
pnpm build
|
||||
```
|
||||
|
||||
The UI is served by the FastAPI backend at `/admin/` when built. Use `make build-ui` to build and copy to the backend.
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Endpoint
|
||||
|
||||
1. Create route in appropriate module
|
||||
2. Add request/response models
|
||||
3. Write unit tests
|
||||
4. Update API documentation
|
||||
5. Add integration tests
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
# In routstr/core/main.py or appropriate router
|
||||
@app.get("/v1/stats")
|
||||
async def get_stats(
|
||||
user: User = Depends(get_current_user)
|
||||
) -> StatsResponse:
|
||||
"""Get usage statistics for the current user."""
|
||||
# Implementation
|
||||
pass
|
||||
```
|
||||
|
||||
### Adding a Database Model
|
||||
|
||||
1. Define model in `routstr/core/db.py`
|
||||
2. Create migration: `make db-migrate`
|
||||
3. Review generated migration
|
||||
4. Apply: `make db-upgrade`
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
class Transaction(SQLModel, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
api_key_id: int = Field(foreign_key="apikey.id")
|
||||
amount: int # millisatoshis
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
description: str
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
Follow the AAA pattern:
|
||||
|
||||
```python
|
||||
async def test_balance_deduction():
|
||||
# Arrange
|
||||
api_key = await create_test_api_key(balance=1000)
|
||||
|
||||
# Act
|
||||
result = await deduct_balance(api_key.key, amount=100)
|
||||
|
||||
# Assert
|
||||
assert result.success
|
||||
assert result.new_balance == 900
|
||||
assert result.deducted == 100
|
||||
```
|
||||
|
||||
## Development Tools
|
||||
|
||||
### Makefile Commands
|
||||
|
||||
Key commands for development:
|
||||
|
||||
```bash
|
||||
make help # Show all commands
|
||||
make setup # Initial setup
|
||||
make dev # Run dev server
|
||||
make test # Run all tests
|
||||
make lint # Check code style
|
||||
make format # Fix code style
|
||||
make type-check # Check types
|
||||
make clean # Clean temp files
|
||||
make docker-build # Build Docker image
|
||||
```
|
||||
|
||||
### IDE Setup
|
||||
|
||||
#### VS Code
|
||||
|
||||
Recommended extensions:
|
||||
|
||||
- Python
|
||||
- Pylance
|
||||
- Ruff
|
||||
- GitLens
|
||||
|
||||
Settings (`.vscode/settings.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"python.linting.enabled": true,
|
||||
"python.linting.ruffEnabled": true,
|
||||
"python.formatting.provider": "ruff",
|
||||
"python.analysis.typeCheckingMode": "strict",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
```
|
||||
|
||||
#### PyCharm
|
||||
|
||||
1. Set Python interpreter to uv venv
|
||||
2. Enable type checking
|
||||
3. Configure Ruff as external tool
|
||||
4. Set up file watchers for formatting
|
||||
|
||||
### Debugging
|
||||
|
||||
#### Debug Server
|
||||
|
||||
```bash
|
||||
# Run with debug logging
|
||||
LOG_LEVEL=DEBUG make dev
|
||||
|
||||
# Or with debugger
|
||||
uv run python -m debugpy --listen 5678 --wait-for-client \
|
||||
-m uvicorn routstr:fastapi_app --reload
|
||||
```
|
||||
|
||||
#### Debug Tests
|
||||
|
||||
```bash
|
||||
# Run specific test with output
|
||||
uv run pytest tests/unit/test_auth.py -v -s
|
||||
|
||||
# With debugger
|
||||
uv run pytest tests/unit/test_auth.py --pdb
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Import Errors**
|
||||
|
||||
```bash
|
||||
# Ensure project is installed in editable mode
|
||||
uv sync
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
**Database Errors**
|
||||
|
||||
```bash
|
||||
# Reset database
|
||||
rm dev.db
|
||||
make db-upgrade
|
||||
```
|
||||
|
||||
**Type Checking Fails**
|
||||
|
||||
```bash
|
||||
# Clear mypy cache
|
||||
make clean
|
||||
make type-check
|
||||
```
|
||||
|
||||
**Tests Fail Locally**
|
||||
|
||||
```bash
|
||||
# Ensure test dependencies are installed
|
||||
uv sync --dev
|
||||
|
||||
# Check for leftover test data
|
||||
rm -rf test_*.db
|
||||
```
|
||||
|
||||
### Getting Help
|
||||
|
||||
- Check existing [GitHub Issues](https://github.com/routstr/routstr-core/issues)
|
||||
- Ask in [GitHub Discussions](https://github.com/routstr/routstr-core/discussions)
|
||||
- Read the [Architecture Guide](architecture.md)
|
||||
|
||||
## Next Steps
|
||||
|
||||
Now that you're set up:
|
||||
|
||||
1. Read the [Architecture Overview](architecture.md)
|
||||
2. Check [open issues](https://github.com/routstr/routstr-core/issues)
|
||||
3. Start with a small contribution
|
||||
|
||||
Happy coding! 🚀
|
||||
233
docs/contributing/testing.md
Normal file
233
docs/contributing/testing.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# Testing Guide
|
||||
|
||||
This guide covers testing practices, patterns, and tools used in Routstr Core development.
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
We follow these principles:
|
||||
|
||||
- Test behavior, not implementation
|
||||
- Fast feedback
|
||||
- Reliable tests
|
||||
- Clear failures
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── integration/
|
||||
│ ├── conftest.py
|
||||
│ ├── utils.py
|
||||
│ ├── test_wallet_topup.py
|
||||
│ ├── test_wallet_refund.py
|
||||
│ ├── test_wallet_information.py
|
||||
│ ├── test_proxy_get_endpoints.py
|
||||
│ ├── test_proxy_post_endpoints.py
|
||||
│ └── ... more integration tests
|
||||
├── unit/
|
||||
│ ├── test_algorithm.py
|
||||
│ ├── test_fee_consistency.py
|
||||
│ ├── test_image_tokens.py
|
||||
│ ├── test_logging_securityfilter.py
|
||||
│ ├── test_payment_helpers.py
|
||||
│ ├── test_settings.py
|
||||
│ ├── test_wallet.py
|
||||
│ └── ... more unit tests
|
||||
└── run_integration.py
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Make Targets
|
||||
|
||||
```bash
|
||||
# Run all tests (unit + integration with mocks)
|
||||
make test
|
||||
|
||||
# Unit tests only
|
||||
make test-unit
|
||||
|
||||
# Integration tests with mocks (fast)
|
||||
make test-integration
|
||||
|
||||
# Integration tests with Docker services
|
||||
make test-integration-docker
|
||||
|
||||
# Fast tests only (skip slow and Docker tests)
|
||||
make test-fast
|
||||
|
||||
# Performance tests
|
||||
make test-performance
|
||||
|
||||
# Coverage
|
||||
make test-coverage
|
||||
```
|
||||
|
||||
### Direct pytest Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run a specific test file
|
||||
pytest tests/unit/test_wallet.py -v
|
||||
|
||||
# Run a specific test
|
||||
pytest tests/unit/test_wallet.py::test_get_balance -v
|
||||
|
||||
# Run tests matching a pattern
|
||||
pytest -k "wallet" -v
|
||||
```
|
||||
|
||||
## Test Modes (Integration)
|
||||
|
||||
Integration tests support two execution modes:
|
||||
|
||||
- Mock mode (default): uses in-memory mocks, no Docker required
|
||||
- Docker mode: uses real Docker services (Cashu mint, mock OpenAI, Nostr relay)
|
||||
|
||||
Use the runner script for Docker mode:
|
||||
|
||||
```bash
|
||||
./tests/run_integration.py
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
docker-compose -f compose.testing.yml up -d
|
||||
USE_LOCAL_SERVICES=1 pytest tests/integration/ -v
|
||||
docker-compose -f compose.testing.yml down -v
|
||||
```
|
||||
|
||||
## Test Markers
|
||||
|
||||
Markers are defined in `pyproject.toml`:
|
||||
|
||||
- `integration`
|
||||
- `unit`
|
||||
- `slow`
|
||||
- `requires_docker`
|
||||
- `requires_real_mint`
|
||||
- `performance`
|
||||
- `asyncio`
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Skip slow tests
|
||||
pytest -m "not slow" -v
|
||||
|
||||
# Run only integration tests
|
||||
pytest -m "integration" -v
|
||||
|
||||
# Run performance tests
|
||||
pytest -m "performance" -v
|
||||
```
|
||||
|
||||
## Fixtures and Utilities
|
||||
|
||||
### Core Integration Fixtures
|
||||
|
||||
Defined in `tests/integration/conftest.py`:
|
||||
|
||||
- `integration_client` - Async HTTP client for the FastAPI app
|
||||
- `authenticated_client` - Client with a pre-created API key
|
||||
- `testmint_wallet` - Test wallet for generating Cashu tokens
|
||||
- `db_snapshot` - Database state snapshot/diff helper
|
||||
- `create_api_key` - Helper to create API keys for tests
|
||||
- `integration_engine`, `integration_session` - Async DB engine/session
|
||||
- `background_tasks_controller` - Control background tasks in tests
|
||||
- `mock_upstream_server` - Mock upstream API responses
|
||||
|
||||
### Integration Utilities
|
||||
|
||||
Defined in `tests/integration/utils.py`:
|
||||
|
||||
- `CashuTokenGenerator`
|
||||
- `ResponseValidator`
|
||||
- `PerformanceValidator`
|
||||
- `ConcurrencyTester`
|
||||
- `DatabaseStateValidator`
|
||||
- `MockServiceBuilder`
|
||||
- `TestDataBuilder`
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Unit Test Example
|
||||
|
||||
```python
|
||||
from routstr.algorithm import calculate_model_cost_score
|
||||
from routstr.payment.models import Architecture, Model, Pricing
|
||||
|
||||
|
||||
def test_calculate_model_cost_score_basic() -> None:
|
||||
model = Model(
|
||||
id="test-model",
|
||||
name="Test test-model",
|
||||
created=1234567890,
|
||||
description="Test model",
|
||||
context_length=8192,
|
||||
architecture=Architecture(
|
||||
modality="text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="gpt",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(
|
||||
prompt=0.001,
|
||||
completion=0.002,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
),
|
||||
)
|
||||
assert calculate_model_cost_score(model) == 0.002
|
||||
```
|
||||
|
||||
### Integration Test Example
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_topup(
|
||||
authenticated_client: AsyncClient,
|
||||
testmint_wallet: object,
|
||||
db_snapshot: object,
|
||||
) -> None:
|
||||
await db_snapshot.capture()
|
||||
token = await testmint_wallet.mint_tokens(1000)
|
||||
response = await authenticated_client.post(
|
||||
"/v1/wallet/topup", params={"cashu_token": token}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
diff = await db_snapshot.diff()
|
||||
assert len(diff["api_keys"]["modified"]) == 1
|
||||
```
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
```bash
|
||||
# Show print output
|
||||
pytest -s tests/unit/test_wallet.py
|
||||
|
||||
# Drop into debugger on failure
|
||||
pytest --pdb
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Docker mode failures: check `docker ps` and `docker-compose -f compose.testing.yml logs`
|
||||
- Connection errors: make sure ports 3338, 3000, 8000, and 8088 are free
|
||||
- Slow tests: use `pytest -m "not slow"` or `make test-fast`
|
||||
|
||||
## Next Steps
|
||||
|
||||
- See [Architecture](architecture.md)
|
||||
- Read [Setup Guide](setup.md)
|
||||
44
docs/index.md
Normal file
44
docs/index.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Routstr Core Documentation
|
||||
|
||||
**Routstr** is a decentralized protocol for permissionless AI inference. It enables an open marketplace where anyone can buy and sell compute using **Bitcoin eCash (Cashu)**.
|
||||
|
||||
---
|
||||
|
||||
## 🐣 For Clients (Users & Builders)
|
||||
|
||||
If you want to use AI models in your application without accounts or KYC.
|
||||
|
||||
- **[Introduction](client/introduction.md)**: How the ecosystem works.
|
||||
- **[Payment Flow](client/payments.md)**: Funding sessions, topping up, and refunds.
|
||||
- **[Integration Guide](client/integration.md)**: Code examples for Python, JS, and cURL.
|
||||
|
||||
## 🦁 For Providers (Node Operators)
|
||||
|
||||
If you want to run a node, resell API access, or monetize hardware.
|
||||
|
||||
- **[Quick Start](provider/quickstart.md)**: Deploy a node in 5 minutes.
|
||||
- **[Deployment](provider/deployment.md)**: Production Docker setup.
|
||||
- **[Configuration](provider/configuration.md)**: Environment variables and settings.
|
||||
- **[Dashboard](provider/dashboard.md)**: Managing your node visually.
|
||||
- **[Pricing Strategy](provider/pricing.md)**: Setting margins and fees.
|
||||
- **[Discovery](provider/discovery.md)**: Announcing your node on Nostr.
|
||||
- **[Tor Support](provider/tor.md)**: Running an anonymous hidden service.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Reference
|
||||
|
||||
- **[Overview](api/overview.md)**: Base URL, headers, and standards.
|
||||
- **[Endpoints](api/endpoints.md)**: Full list of REST endpoints.
|
||||
- **[Authentication](api/authentication.md)**: Handling API keys and tokens.
|
||||
- **[Errors](api/errors.md)**: Status codes and debugging.
|
||||
|
||||
## 🛠️ Contributing
|
||||
|
||||
- **[Architecture](contributing/architecture.md)**: System design.
|
||||
- **[Setup](contributing/setup.md)**: Development environment.
|
||||
- **[Testing](contributing/testing.md)**: Running tests.
|
||||
|
||||
---
|
||||
|
||||
*Powered by [Cashu](https://cashu.space) and [Nostr](https://nostr.com).*
|
||||
76
docs/overview.md
Normal file
76
docs/overview.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Overview
|
||||
|
||||
Routstr is a decentralized protocol for **permissionless, private, and censorship-resistant AI inference**. It creates an open marketplace where anyone can sell llm-tokens and anyone can buy them using privacy-preserving micropayments.
|
||||
|
||||
By combining **Nostr** (for censorship-resistant discovery and communication) and **Cashu** (for private, instant Bitcoin eCash payments), Routstr effectively removes the "middleman" from the AI ecosystem.
|
||||
|
||||
## How it Works
|
||||
|
||||
The network consists of independent **Providers** (Sellers) and **Clients** (Buyers). There is no central server, no login, and no credit card required.
|
||||
|
||||
1. **Discovery (Nostr)**: Providers announce their availability, models (e.g., `gpt-4o`, `deepseek-r1`), and prices on the Nostr network.
|
||||
2. **Payment (Cashu)**: Clients pay providers directly using Bitcoin eCash (Cashu tokens). These payments are untraceable and settle instantly.
|
||||
3. **Inference (Proxy)**: The Provider acts as a gateway (or runs local hardware), executing the AI model and returning the result to the Client.
|
||||
|
||||
## Who is this for?
|
||||
|
||||
The documentation is split into two paths depending on your goal:
|
||||
|
||||
### 🐣 I want to BUILD on Routstr (Client)
|
||||
|
||||
You are a developer building an AI agent, a chat app, or a script, and you want access to AI models without API keys, subscriptions, or KYC.
|
||||
|
||||
* **No Accounts**: Just get a wallet.
|
||||
* **Privacy**: Your requests are mixed with thousands of others; providers can't profile you.
|
||||
* **Choice**: Switch between hundreds of providers instantly for the best price/performance.
|
||||
|
||||
👉 **[Go to Client Guide](client/introduction.md)**
|
||||
|
||||
### 🦁 I want to RUN a Node (Provider)
|
||||
|
||||
You have API credits (OpenAI, Anthropic, etc.) or GPU capacity and want to earn Bitcoin by selling AI access to the network.
|
||||
|
||||
* **Monetize API Keys**: Connect your OpenAI/Anthropic/OpenRouter accounts and earn sats on every request.
|
||||
* **Monetize Hardware**: Run local models (via vLLM, Ollama) and sell access.
|
||||
* **Permissionless**: No approval needed. Start the container, configure via dashboard, start earning.
|
||||
|
||||
!!! note "Coming Soon"
|
||||
Future versions will support node-to-node routing—run a gateway without needing your own AI provider credentials.
|
||||
|
||||
👉 **[Go to Provider Guide](provider/quickstart.md)**
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Routstr is built on a modular stack defined by the [Routstr Improvement Protocols (RIPs)](https://github.com/routstr/rips).
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Client
|
||||
A[App / Agent]
|
||||
end
|
||||
|
||||
subgraph Provider
|
||||
B[Routstr Node<br/>Proxy + Auth + Billing]
|
||||
end
|
||||
|
||||
subgraph Upstream
|
||||
C[OpenAI / Anthropic<br/>vLLM / Ollama / ...]
|
||||
end
|
||||
|
||||
A -- "Request +<br/>Cashu Token" --> B
|
||||
B -- "Forward<br/>Request" --> C
|
||||
C -- "Response +<br/>Usage" --> B
|
||||
B -- "Response +<br/>Refund Token" --> A
|
||||
```
|
||||
|
||||
## Why Routstr?
|
||||
|
||||
| Feature | Closed AI | Routstr |
|
||||
| :--- | :--- | :--- |
|
||||
| **Access** | Account, KYC, Credit Card | Permissionless, Bitcoin-native |
|
||||
| **Privacy** | Full Logging & Tracking | Blinded Payments, Ephemeral Sessions |
|
||||
| **Resilience** | Single Point of Failure | Decentralized Network |
|
||||
| **Pricing** | Fixed, Monopolistic | Dynamic, Market-driven |
|
||||
| **Global** | Geofenced | Borderless (Tor/I2P supported) |
|
||||
114
docs/provider/advanced-pricing.md
Normal file
114
docs/provider/advanced-pricing.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Advanced Pricing
|
||||
|
||||
Advanced pricing strategies for fine-tuned control over your revenue model.
|
||||
|
||||
---
|
||||
|
||||
## Default Behavior
|
||||
|
||||
By default, Routstr:
|
||||
|
||||
1. **Fetches costs** from your upstream provider
|
||||
2. **Applies markup** using your fee settings
|
||||
3. **Converts to sats** using real-time BTC price
|
||||
|
||||
**Formula**: `Price = Upstream Cost × Exchange Fee × Upstream Fee`
|
||||
|
||||
---
|
||||
|
||||
## Strategy 1: Fixed Per-Request
|
||||
|
||||
Charge a flat fee regardless of model or tokens used.
|
||||
|
||||
**Configure in Dashboard** → **Settings** → **Pricing**:
|
||||
|
||||
- Enable **Fixed Pricing**
|
||||
- Set **Fixed Cost Per Request** (in sats)
|
||||
|
||||
**Use cases**:
|
||||
|
||||
- Internal tools with predictable usage
|
||||
- Simple "pay once, get response" APIs
|
||||
- Subscription-like tiers
|
||||
|
||||
---
|
||||
|
||||
## Strategy 2: Fixed Per-Token
|
||||
|
||||
Override dynamic pricing with global per-token rates.
|
||||
|
||||
**Configure in Dashboard** → **Settings** → **Pricing**:
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| **Fixed Per 1K Input** | Sats per 1,000 prompt tokens |
|
||||
| **Fixed Per 1K Output** | Sats per 1,000 completion tokens |
|
||||
|
||||
When set to non-zero values, these override model-specific pricing for all models.
|
||||
|
||||
---
|
||||
|
||||
## Strategy 3: Per-Model Custom Pricing
|
||||
|
||||
Set specific prices for individual models, overriding both upstream cost and global fees.
|
||||
|
||||
**Configure in Dashboard** → **Models**:
|
||||
|
||||
1. Click on a model (e.g., `gpt-4`)
|
||||
2. Enter **Prompt Price** and **Completion Price** (USD per 1M tokens)
|
||||
3. Save
|
||||
|
||||
**Example**: OpenAI charges $30/1M for GPT-4. Set your price to $35/1M to lock in a margin regardless of fee settings.
|
||||
|
||||
---
|
||||
|
||||
## Minimum Charge
|
||||
|
||||
Prevent dust transactions and spam:
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| **Min Request Cost** | Minimum charge in msats | 1000 (1 sat) |
|
||||
|
||||
If a request's calculated cost falls below this (e.g., very short prompts), the client pays the minimum.
|
||||
|
||||
---
|
||||
|
||||
## Combining Strategies
|
||||
|
||||
Strategies apply in order of specificity:
|
||||
|
||||
1. **Per-model override** (highest priority)
|
||||
2. **Fixed per-token rates**
|
||||
3. **Dynamic pricing with fees** (default)
|
||||
4. **Fixed per-request** (overrides all above if enabled)
|
||||
|
||||
**Example setup**:
|
||||
|
||||
- Dynamic pricing as default (10% markup)
|
||||
- GPT-4 locked at $35/1M (premium model)
|
||||
- Claude Haiku at 5 sats/1K tokens (budget option)
|
||||
- Minimum 1 sat per request
|
||||
|
||||
---
|
||||
|
||||
## Pricing for Profit
|
||||
|
||||
### High-Volume Strategy
|
||||
|
||||
Lower margins, more clients:
|
||||
|
||||
- Exchange Fee: 1.002 (0.2%)
|
||||
- Upstream Fee: 1.05 (5%)
|
||||
|
||||
### Premium Strategy
|
||||
|
||||
Higher margins, fewer clients:
|
||||
|
||||
- Exchange Fee: 1.01 (1%)
|
||||
- Upstream Fee: 1.25 (25%)
|
||||
|
||||
### Mixed Strategy
|
||||
|
||||
- Cheap models (GLM-4.7-Flash, Seed-1.6): Low margin to attract volume
|
||||
- Premium models (GPT-5-Pro, Claude-Opus): High margin for profit
|
||||
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")
|
||||
)
|
||||
```
|
||||
4
docs/requirements.txt
Normal file
4
docs/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
mkdocs>=1.5.3
|
||||
mkdocs-material>=9.5.0
|
||||
mkdocs-mermaid2-plugin>=1.1.1
|
||||
pymdown-extensions>=10.5
|
||||
37
example.py
37
example.py
@@ -1,37 +0,0 @@
|
||||
import os
|
||||
|
||||
import openai
|
||||
|
||||
client = openai.OpenAI(
|
||||
api_key=os.environ["CASHU_TOKEN"],
|
||||
base_url=os.environ.get("ROUTSTR_API_URL", "https://api.routstr.com/v1"),
|
||||
# base_url="http://roustrjfsdgfiueghsklchg.onion/v1",
|
||||
# client=httpx.AsyncClient(
|
||||
# proxies={"http": "socks5://localhost:9050"},
|
||||
# ), # to use onion proxy (tor)
|
||||
)
|
||||
history: list = []
|
||||
|
||||
|
||||
def chat() -> None:
|
||||
while True:
|
||||
user_msg = {"role": "user", "content": input("\nYou: ")}
|
||||
history.append(user_msg)
|
||||
ai_msg = {"role": "assistant", "content": ""}
|
||||
|
||||
for chunk in client.chat.completions.create(
|
||||
model=os.environ.get("MODEL", "openai/gpt-4o-mini"),
|
||||
messages=history,
|
||||
stream=True,
|
||||
):
|
||||
if len(chunk.choices) > 0:
|
||||
content = chunk.choices[0].delta.content
|
||||
if content is not None:
|
||||
ai_msg["content"] += content
|
||||
print(content, end="", flush=True)
|
||||
print()
|
||||
history.append(ai_msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
chat()
|
||||
11
examples/balance/check_balance.py
Normal file
11
examples/balance/check_balance.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Use your Cashu token or API key as the Bearer token,
|
||||
# cashu token is hashed on the server and acts as an Temporary API key
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
resp = httpx.get(f"{base_url}/balance/info", headers=headers)
|
||||
print(resp.json())
|
||||
15
examples/balance/create_balance.py
Normal file
15
examples/balance/create_balance.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Send a Cashu token to the /create endpoint to get a persistent API key
|
||||
token = os.environ.get("TOKEN")
|
||||
if not token:
|
||||
print("Please set TOKEN environment variable with a Cashu token")
|
||||
exit(1)
|
||||
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
resp = httpx.get(f"{base_url}/balance/create", params={"initial_balance_token": token})
|
||||
|
||||
print(resp.json())
|
||||
12
examples/balance/refund_balance.py
Normal file
12
examples/balance/refund_balance.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Use your Cashu token or API key as the Bearer token
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
resp = httpx.post(f"{base_url}/balance/refund", headers=headers)
|
||||
|
||||
print("Refund successful!")
|
||||
print(resp.json())
|
||||
16
examples/balance/topup_balance.py
Normal file
16
examples/balance/topup_balance.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Use your Cashu token or API key as the Bearer token
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
# The Cashu token to top up with
|
||||
cashu_token = input("Enter Cashu token to top up: ")
|
||||
|
||||
resp = httpx.post(
|
||||
f"{base_url}/balance/topup", headers=headers, json={"cashu_token": cashu_token}
|
||||
)
|
||||
|
||||
print(resp.json())
|
||||
15
examples/chat_completions.py
Normal file
15
examples/chat_completions.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=os.environ.get("MODEL", "gpt-5-nano"),
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
45
examples/create_child_keys.py
Normal file
45
examples/create_child_keys.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def create_child_keys(base_url: str, api_key: str, count: int = 3) -> list[str]:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
print(f"Requesting {count} child keys from {base_url}...")
|
||||
|
||||
child_keys = []
|
||||
|
||||
for i in range(count):
|
||||
try:
|
||||
response = httpx.post(f"{base_url}/v1/balance/child-key", headers=headers)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
child_keys.append(data["api_key"])
|
||||
print(
|
||||
f" [{i + 1}] Created: {data['api_key']} (Cost: {data['cost_msats']} msats)"
|
||||
)
|
||||
else:
|
||||
print(f" [{i + 1}] Failed: {response.status_code} - {response.text}")
|
||||
except Exception as e:
|
||||
print(f" [{i + 1}] Error: {str(e)}")
|
||||
|
||||
return child_keys
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python create_child_keys.py <api_key_or_cashu_token> [base_url]")
|
||||
sys.exit(1)
|
||||
|
||||
auth_key = sys.argv[1]
|
||||
base_url = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:8000"
|
||||
|
||||
keys = create_child_keys(base_url, auth_key)
|
||||
|
||||
if keys:
|
||||
print("\nSuccessfully created child keys:")
|
||||
print(json.dumps(keys, indent=2))
|
||||
else:
|
||||
print("\nNo child keys were created.")
|
||||
19
examples/list_models.py
Normal file
19
examples/list_models.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN", ""),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
for model in client.models.list():
|
||||
print(model.id)
|
||||
|
||||
# OR
|
||||
|
||||
models = httpx.get(
|
||||
f"{client.base_url}/v1/models",
|
||||
headers={"Authorization": f"Bearer {client.api_key}"},
|
||||
).json()
|
||||
31
examples/responses/conversation.py
Normal file
31
examples/responses/conversation.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
conversation = [] # type: ignore
|
||||
|
||||
# First turn
|
||||
response1 = client.responses.create( # type: ignore
|
||||
model="o4-mini",
|
||||
input="Hi, my name is Alice.",
|
||||
conversation=conversation,
|
||||
)
|
||||
print("Response 1:", response1.output)
|
||||
|
||||
# Note: The 'conversation' parameter might need to be constructed differently
|
||||
# depending on exact SDK/API spec. Typically, you pass back the previous turn's data.
|
||||
# Assuming the SDK manages or returns a conversation object/ID:
|
||||
# conversation.append(response1)
|
||||
|
||||
# Second turn - demonstrating intent, actual implementation depends on strict API spec
|
||||
# response2 = client.responses.create(
|
||||
# model="openai/gpt-4o-mini",
|
||||
# input="What is my name?",
|
||||
# conversation=conversation,
|
||||
# )
|
||||
# print("Response 2:", response2.output)
|
||||
17
examples/responses/create.py
Normal file
17
examples/responses/create.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
# The OpenAI SDK handles the 'responses' endpoint if it's updated to the latest version
|
||||
# and the base_url points to a compatible proxy like Routstr.
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
response = client.responses.create(
|
||||
model="gpt-5-mini",
|
||||
input="Tell me a three sentence bedtime story about a unicorn.",
|
||||
)
|
||||
|
||||
print(response.output)
|
||||
20
examples/responses/streaming_response.py
Normal file
20
examples/responses/streaming_response.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
stream = client.responses.create(
|
||||
model="claude-4.5-sonnet",
|
||||
input="Write a short poem about rust.",
|
||||
stream=True,
|
||||
)
|
||||
|
||||
for event in stream:
|
||||
# Note: Depending on the SDK version and response structure,
|
||||
# you might access event.output_delta or similar fields
|
||||
print(event, end="", flush=True)
|
||||
print()
|
||||
16
examples/responses/web_search.py
Normal file
16
examples/responses/web_search.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
response = client.responses.create(
|
||||
model="gpt-5-mini",
|
||||
input="What is the latest news about AI?",
|
||||
tools=[{"type": "web_search"}], # type: ignore
|
||||
)
|
||||
|
||||
print(response.output)
|
||||
28
examples/streaming.py
Normal file
28
examples/streaming.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
messages = []
|
||||
while True:
|
||||
messages.append({"role": "user", "content": input("\nYou: ")})
|
||||
|
||||
stream = client.chat.completions.create(
|
||||
model=os.environ.get("MODEL", "gpt-5.1-mini"),
|
||||
messages=messages, # type: ignore
|
||||
stream=True,
|
||||
)
|
||||
|
||||
print("AI: ", end="")
|
||||
response_content = ""
|
||||
for chunk in stream:
|
||||
if content := chunk.choices[0].delta.content: # type: ignore
|
||||
print(content, end="", flush=True)
|
||||
response_content += content
|
||||
print()
|
||||
|
||||
messages.append({"role": "assistant", "content": response_content})
|
||||
20
examples/tor.py
Normal file
20
examples/tor.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
# Requires `pip install "httpx[socks]"` and a running Tor proxy on port 9050
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("ONION_URL", "http://roustrjfsdgfiueghsklchg.onion/v1"),
|
||||
http_client=httpx.Client(proxies="socks5://localhost:9050"),
|
||||
)
|
||||
|
||||
print(
|
||||
client.chat.completions.create(
|
||||
model="openai/gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "Hello from Tor!"}],
|
||||
)
|
||||
.choices[0]
|
||||
.message.content
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
73
migrations/env.py
Normal file
73
migrations/env.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import asyncio
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
# from logging.config import fileConfig
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from routstr.core.db import DATABASE_URL
|
||||
|
||||
# Add the parent directory to the Python path so we can import routstr modules
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is None:
|
||||
raise ValueError("config_file_name is None")
|
||||
|
||||
# Skip loading alembic's logging configuration to preserve our custom logging
|
||||
# fileConfig(config.config_file_name)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=DATABASE_URL,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata, compare_type=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_migrations_online() -> None:
|
||||
connectable = create_async_engine(DATABASE_URL, poolclass=pool.NullPool)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
# Check if we're already in an event loop (e.g., being called from FastAPI)
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# If we're in an existing loop, create a new thread to run migrations
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, run_migrations_online())
|
||||
future.result()
|
||||
except RuntimeError:
|
||||
# No event loop running, we can use asyncio.run directly
|
||||
asyncio.run(run_migrations_online())
|
||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
${imports if imports else ""}
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
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,30 @@
|
||||
"""introduce reserved balance
|
||||
|
||||
Revision ID: 042f6b77d69d
|
||||
Revises: 898f00ea481e
|
||||
Create Date: 2025-08-18 19:03:09.507368
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "042f6b77d69d"
|
||||
down_revision = "898f00ea481e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column("reserved_balance", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("api_keys", "reserved_balance")
|
||||
# ### end Alembic commands ###
|
||||
@@ -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")
|
||||
31
migrations/versions/7bc4e8b02b9d_add_mint_field.py
Normal file
31
migrations/versions/7bc4e8b02b9d_add_mint_field.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""add mint field
|
||||
|
||||
Revision ID: 7bc4e8b02b9d
|
||||
Revises: f6ce1348e266
|
||||
Create Date: 2025-08-09 13:48:40.648729
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlmodel.sql import sqltypes
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "7bc4e8b02b9d"
|
||||
down_revision = "f6ce1348e266"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column("mint_url", sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("api_keys", "mint_url")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,38 @@
|
||||
"""add mint+currency refund details
|
||||
|
||||
Revision ID: 898f00ea481e
|
||||
Revises: 7bc4e8b02b9d
|
||||
Create Date: 2025-08-13 16:45:42.148314
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlmodel.sql import sqltypes
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "898f00ea481e"
|
||||
down_revision = "7bc4e8b02b9d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column("refund_mint_url", sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column("refund_currency", sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
op.drop_column("api_keys", "mint_url")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column("api_keys", sa.Column("mint_url", sa.VARCHAR(), nullable=True))
|
||||
op.drop_column("api_keys", "refund_currency")
|
||||
op.drop_column("api_keys", "refund_mint_url")
|
||||
# ### end Alembic commands ###
|
||||
64
migrations/versions/a1a1a1a1a1a1_composite_pk_for_models.py
Normal file
64
migrations/versions/a1a1a1a1a1a1_composite_pk_for_models.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""change models to composite primary key (id, upstream_provider_id)
|
||||
|
||||
Revision ID: a1a1a1a1a1a1
|
||||
Revises: f7a8b9c0d1e2
|
||||
Create Date: 2025-10-20 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "a1a1a1a1a1a1"
|
||||
down_revision = "f7a8b9c0d1e2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
if "models" in inspector.get_table_names():
|
||||
op.drop_table("models")
|
||||
|
||||
op.create_table(
|
||||
"models",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("upstream_provider_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("created", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("context_length", sa.Integer(), nullable=False),
|
||||
sa.Column("architecture", sa.Text(), nullable=False),
|
||||
sa.Column("pricing", sa.Text(), nullable=False),
|
||||
sa.Column("sats_pricing", sa.Text(), nullable=True),
|
||||
sa.Column("per_request_limits", sa.Text(), nullable=True),
|
||||
sa.Column("top_provider", sa.Text(), nullable=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.PrimaryKeyConstraint("id", "upstream_provider_id"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["upstream_provider_id"], ["upstream_providers.id"], ondelete="CASCADE"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("models")
|
||||
op.create_table(
|
||||
"models",
|
||||
sa.Column("id", sa.String(), primary_key=True, nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("created", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("context_length", sa.Integer(), nullable=False),
|
||||
sa.Column("architecture", sa.Text(), nullable=False),
|
||||
sa.Column("pricing", sa.Text(), nullable=False),
|
||||
sa.Column("sats_pricing", sa.Text(), nullable=True),
|
||||
sa.Column("per_request_limits", sa.Text(), nullable=True),
|
||||
sa.Column("top_provider", sa.Text(), nullable=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("upstream_provider_id", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["upstream_provider_id"], ["upstream_providers.id"]),
|
||||
)
|
||||
35
migrations/versions/a1b2c3d4e5f6_add_settings_table.py
Normal file
35
migrations/versions/a1b2c3d4e5f6_add_settings_table.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""add settings table
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 042f6b77d69d
|
||||
Create Date: 2025-09-06 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a1b2c3d4e5f6"
|
||||
down_revision = "042f6b77d69d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"settings",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
|
||||
sa.Column("data", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=True,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("settings")
|
||||
@@ -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 ###
|
||||
37
migrations/versions/c0ffee123456_create_models_table.py
Normal file
37
migrations/versions/c0ffee123456_create_models_table.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""create models table
|
||||
|
||||
Revision ID: c0ffee123456
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2025-09-10 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c0ffee123456"
|
||||
down_revision = "a1b2c3d4e5f6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"models",
|
||||
sa.Column("id", sa.String(), primary_key=True, nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("created", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("context_length", sa.Integer(), nullable=False),
|
||||
sa.Column("architecture", sa.Text(), nullable=False),
|
||||
sa.Column("pricing", sa.Text(), nullable=False),
|
||||
sa.Column("sats_pricing", sa.Text(), nullable=True),
|
||||
sa.Column("per_request_limits", sa.Text(), nullable=True),
|
||||
sa.Column("top_provider", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("models")
|
||||
@@ -0,0 +1,118 @@
|
||||
"""make upstream provider base_url + api_key unique
|
||||
|
||||
Revision ID: c2d3e4f5a6b7
|
||||
Revises: a86e5348850b
|
||||
Create Date: 2026-01-25 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c2d3e4f5a6b7"
|
||||
down_revision = "a86e5348850b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _recreate_table_sqlite(add_base_url_unique: bool) -> None:
|
||||
conn = op.get_bind()
|
||||
existing_tables = {
|
||||
row[0]
|
||||
for row in conn.exec_driver_sql(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
}
|
||||
if "upstream_providers_old" in existing_tables:
|
||||
if "upstream_providers" in existing_tables:
|
||||
op.drop_table("upstream_providers_old")
|
||||
else:
|
||||
op.execute(
|
||||
"ALTER TABLE upstream_providers_old RENAME TO upstream_providers"
|
||||
)
|
||||
existing_tables.add("upstream_providers")
|
||||
if "upstream_providers" not in existing_tables:
|
||||
return
|
||||
|
||||
constraints = [
|
||||
sa.UniqueConstraint(
|
||||
"base_url",
|
||||
"api_key",
|
||||
name="uq_upstream_providers_base_url_api_key",
|
||||
)
|
||||
]
|
||||
if add_base_url_unique:
|
||||
constraints.append(
|
||||
sa.UniqueConstraint("base_url", name="uq_upstream_providers_base_url")
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE upstream_providers RENAME TO upstream_providers_old")
|
||||
op.create_table(
|
||||
"upstream_providers",
|
||||
sa.Column(
|
||||
"id", sa.Integer(), primary_key=True, nullable=False, autoincrement=True
|
||||
),
|
||||
sa.Column("provider_type", sa.String(), nullable=False),
|
||||
sa.Column("base_url", sa.String(), nullable=False),
|
||||
sa.Column("api_key", sa.String(), nullable=False),
|
||||
sa.Column("api_version", sa.String(), nullable=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("provider_fee", sa.Float(), nullable=False, server_default="1.01"),
|
||||
*constraints,
|
||||
)
|
||||
op.execute(
|
||||
"INSERT INTO upstream_providers (id, provider_type, base_url, api_key, api_version, enabled, provider_fee) "
|
||||
"SELECT id, provider_type, base_url, api_key, api_version, enabled, provider_fee "
|
||||
"FROM upstream_providers_old"
|
||||
)
|
||||
op.drop_table("upstream_providers_old")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name == "sqlite":
|
||||
_recreate_table_sqlite(add_base_url_unique=False)
|
||||
return
|
||||
|
||||
inspector = sa.inspect(conn)
|
||||
for constraint in inspector.get_unique_constraints("upstream_providers"):
|
||||
name = constraint.get("name")
|
||||
if constraint.get("column_names") == ["base_url"] and name:
|
||||
op.drop_constraint(
|
||||
name,
|
||||
"upstream_providers",
|
||||
type_="unique",
|
||||
)
|
||||
index_names = {idx["name"] for idx in inspector.get_indexes("upstream_providers")}
|
||||
if "ix_upstream_providers_base_url" in index_names:
|
||||
op.drop_index("ix_upstream_providers_base_url", table_name="upstream_providers")
|
||||
op.create_unique_constraint(
|
||||
"uq_upstream_providers_base_url_api_key",
|
||||
"upstream_providers",
|
||||
["base_url", "api_key"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name == "sqlite":
|
||||
_recreate_table_sqlite(add_base_url_unique=True)
|
||||
return
|
||||
|
||||
op.drop_constraint(
|
||||
"uq_upstream_providers_base_url_api_key",
|
||||
"upstream_providers",
|
||||
type_="unique",
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uq_upstream_providers_base_url",
|
||||
"upstream_providers",
|
||||
["base_url"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_upstream_providers_base_url",
|
||||
"upstream_providers",
|
||||
["base_url"],
|
||||
unique=True,
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add source to cashu_transactions
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: b1c2d3e4f5a6
|
||||
Create Date: 2026-04-10 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c3d4e5f6a7b8"
|
||||
down_revision = "b1c2d3e4f5a6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col["name"] for col in inspector.get_columns("cashu_transactions")]
|
||||
if "source" not in columns:
|
||||
op.add_column(
|
||||
"cashu_transactions",
|
||||
sa.Column(
|
||||
"source",
|
||||
sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=False,
|
||||
server_default="x-cashu",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("cashu_transactions", "source")
|
||||
34
migrations/versions/cli_tokens_table.py
Normal file
34
migrations/versions/cli_tokens_table.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""add cli_tokens table
|
||||
|
||||
Revision ID: cli_tokens_001
|
||||
Revises: e8f9a0b1c2d3
|
||||
Create Date: 2026-04-25 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "cli_tokens_001"
|
||||
down_revision = "e8f9a0b1c2d3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"cli_tokens",
|
||||
sa.Column("id", sa.String(), primary_key=True, nullable=False),
|
||||
sa.Column("token", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||
sa.Column("last_used_at", sa.Integer(), nullable=True),
|
||||
sa.Column("expires_at", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_cli_tokens_token", "cli_tokens", ["token"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_cli_tokens_token", table_name="cli_tokens")
|
||||
op.drop_table("cli_tokens")
|
||||
@@ -0,0 +1,45 @@
|
||||
"""create upstream_providers table
|
||||
|
||||
Revision ID: d1e2f3a4b5c6
|
||||
Revises: c0ffee123456
|
||||
Create Date: 2025-10-09 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "d1e2f3a4b5c6"
|
||||
down_revision = "c0ffee123456"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
if "upstream_providers" not in inspector.get_table_names():
|
||||
op.create_table(
|
||||
"upstream_providers",
|
||||
sa.Column(
|
||||
"id", sa.Integer(), primary_key=True, nullable=False, autoincrement=True
|
||||
),
|
||||
sa.Column("provider_type", sa.String(), nullable=False),
|
||||
sa.Column("base_url", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("api_key", sa.String(), nullable=False),
|
||||
sa.Column("api_version", sa.String(), nullable=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, default=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_upstream_providers_base_url",
|
||||
"upstream_providers",
|
||||
["base_url"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_upstream_providers_base_url", "upstream_providers")
|
||||
op.drop_table("upstream_providers")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""add api key link to cashu_transactions
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: c3d4e5f6a7b8
|
||||
Create Date: 2026-04-20 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "d4e5f6a7b8c9"
|
||||
down_revision = "c3d4e5f6a7b8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col["name"] for col in inspector.get_columns("cashu_transactions")]
|
||||
indexes = {index["name"] for index in inspector.get_indexes("cashu_transactions")}
|
||||
|
||||
if "api_key_hashed_key" not in columns:
|
||||
op.add_column(
|
||||
"cashu_transactions",
|
||||
sa.Column(
|
||||
"api_key_hashed_key",
|
||||
sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
if "ix_cashu_transactions_api_key_hashed_key" not in indexes:
|
||||
op.create_index(
|
||||
"ix_cashu_transactions_api_key_hashed_key",
|
||||
"cashu_transactions",
|
||||
["api_key_hashed_key"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_cashu_transactions_api_key_hashed_key", table_name="cashu_transactions")
|
||||
op.drop_column("cashu_transactions", "api_key_hashed_key")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""add upstream_provider and enabled to models
|
||||
|
||||
Revision ID: e1f2a3b4c5d6
|
||||
Revises: d1e2f3a4b5c6
|
||||
Create Date: 2025-10-13 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "e1f2a3b4c5d6"
|
||||
down_revision = "d1e2f3a4b5c6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_table("models")
|
||||
op.create_table(
|
||||
"models",
|
||||
sa.Column("id", sa.String(), primary_key=True, nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("created", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("context_length", sa.Integer(), nullable=False),
|
||||
sa.Column("architecture", sa.Text(), nullable=False),
|
||||
sa.Column("pricing", sa.Text(), nullable=False),
|
||||
sa.Column("sats_pricing", sa.Text(), nullable=True),
|
||||
sa.Column("per_request_limits", sa.Text(), nullable=True),
|
||||
sa.Column("top_provider", sa.Text(), nullable=True),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("upstream_provider_id", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["upstream_provider_id"], ["upstream_providers.id"]),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("models")
|
||||
op.create_table(
|
||||
"models",
|
||||
sa.Column("id", sa.String(), primary_key=True, nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("created", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("context_length", sa.Integer(), nullable=False),
|
||||
sa.Column("architecture", sa.Text(), nullable=False),
|
||||
sa.Column("pricing", sa.Text(), nullable=False),
|
||||
sa.Column("sats_pricing", sa.Text(), nullable=True),
|
||||
sa.Column("per_request_limits", sa.Text(), nullable=True),
|
||||
sa.Column("top_provider", sa.Text(), nullable=True),
|
||||
)
|
||||
20
migrations/versions/e8f9a0b1c2d3_merge_heads.py
Normal file
20
migrations/versions/e8f9a0b1c2d3_merge_heads.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""merge heads: routstr_fees + api_key_to_cashu_transactions
|
||||
|
||||
Revision ID: e8f9a0b1c2d3
|
||||
Revises: 02650cd6f028, d4e5f6a7b8c9
|
||||
Create Date: 2026-04-24 00:00:00.000000
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "e8f9a0b1c2d3"
|
||||
down_revision = ("02650cd6f028", "d4e5f6a7b8c9")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,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")
|
||||
40
migrations/versions/f6ce1348e266_init.py
Normal file
40
migrations/versions/f6ce1348e266_init.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""init
|
||||
|
||||
Revision ID: f6ce1348e266
|
||||
Revises:
|
||||
Create Date: 2025-08-09 13:28:38.537652
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlmodel.sql import sqltypes
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f6ce1348e266"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if "api_keys" not in sa.inspect(op.get_bind()).get_table_names():
|
||||
op.create_table(
|
||||
"api_keys",
|
||||
sa.Column("hashed_key", sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("balance", sa.Integer(), nullable=False),
|
||||
sa.Column("refund_address", sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("key_expiry_time", sa.Integer(), nullable=True),
|
||||
sa.Column("total_spent", sa.Integer(), nullable=False),
|
||||
sa.Column("total_requests", sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("hashed_key"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Only drop the table if it exists
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if "api_keys" in tables:
|
||||
op.drop_table("api_keys")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""add provider_fee to upstream_providers
|
||||
|
||||
Revision ID: f7a8b9c0d1e2
|
||||
Revises: e1f2a3b4c5d6
|
||||
Create Date: 2025-10-13 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "f7a8b9c0d1e2"
|
||||
down_revision = "e1f2a3b4c5d6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"upstream_providers",
|
||||
sa.Column("provider_fee", sa.Float(), nullable=False, server_default="1.01"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("upstream_providers", "provider_fee")
|
||||
39
migrations/versions/lightning_invoices_table.py
Normal file
39
migrations/versions/lightning_invoices_table.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Add lightning_invoices table
|
||||
|
||||
Revision ID: lightning_invoices
|
||||
Revises: a1a1a1a1a1a1
|
||||
Create Date: 2025-12-10 21:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
revision = "lightning_invoices"
|
||||
down_revision = "a1a1a1a1a1a1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"lightning_invoices",
|
||||
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("bolt11", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("amount_sats", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("payment_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("api_key_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("purpose", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||
sa.Column("expires_at", sa.Integer(), nullable=False),
|
||||
sa.Column("paid_at", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("bolt11"),
|
||||
sa.UniqueConstraint("payment_hash"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("lightning_invoices")
|
||||
103
mkdocs.yml
Normal file
103
mkdocs.yml
Normal file
@@ -0,0 +1,103 @@
|
||||
site_name: Routstr Core Documentation
|
||||
site_url: https://docs.routstr.com
|
||||
site_description: FastAPI-based reverse proxy for OpenAI-compatible APIs with Bitcoin eCash micropayments
|
||||
site_author: Routstr Team
|
||||
|
||||
repo_name: routstr/routstr-core
|
||||
repo_url: https://github.com/routstr/routstr-core
|
||||
edit_uri: tree/main/docs
|
||||
|
||||
theme:
|
||||
name: material
|
||||
language: en
|
||||
palette:
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
primary: orange
|
||||
accent: amber
|
||||
toggle:
|
||||
icon: material/brightness-7
|
||||
name: Switch to dark mode
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
primary: orange
|
||||
accent: amber
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Switch to light mode
|
||||
features:
|
||||
- navigation.tabs
|
||||
- navigation.sections
|
||||
- navigation.expand
|
||||
- navigation.top
|
||||
- navigation.indexes
|
||||
- search.suggest
|
||||
- search.highlight
|
||||
- content.tabs.link
|
||||
- content.code.copy
|
||||
- content.code.annotate
|
||||
icon:
|
||||
logo: material/lightning-bolt
|
||||
repo: fontawesome/brands/github
|
||||
|
||||
plugins:
|
||||
- search
|
||||
- mermaid2
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- codehilite:
|
||||
guess_lang: false
|
||||
- toc:
|
||||
permalink: true
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: mermaid
|
||||
class: mermaid
|
||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||
- pymdownx.details
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets
|
||||
- pymdownx.tasklist:
|
||||
custom_checkbox: true
|
||||
- attr_list
|
||||
- md_in_html
|
||||
|
||||
extra:
|
||||
social:
|
||||
- icon: fontawesome/brands/github
|
||||
link: https://github.com/routstr
|
||||
- icon: fontawesome/brands/bitcoin
|
||||
link: https://cashu.space
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Overview: overview.md
|
||||
- Client Guide:
|
||||
- Introduction: client/introduction.md
|
||||
- Payment Flow: client/payments.md
|
||||
- Integration: client/integration.md
|
||||
- Provider Guide:
|
||||
- Quick Start: provider/quickstart.md
|
||||
- Dashboard: provider/dashboard.md
|
||||
- Deployment: provider/deployment.md
|
||||
- Configuration: provider/configuration.md
|
||||
- Pricing: provider/pricing.md
|
||||
- Advanced Pricing: provider/advanced-pricing.md
|
||||
- Discovery: provider/discovery.md
|
||||
- Tor Support: provider/tor.md
|
||||
- API Reference:
|
||||
- Overview: api/overview.md
|
||||
- Authentication: api/authentication.md
|
||||
- Endpoints: api/endpoints.md
|
||||
- Errors: api/errors.md
|
||||
- Contributing:
|
||||
- Setup Development: contributing/setup.md
|
||||
- Architecture: contributing/architecture.md
|
||||
- Code Structure: contributing/code-structure.md
|
||||
- Testing: contributing/testing.md
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "google/gemini-2.5-pro-preview",
|
||||
"id": "google/gemini-2.5-flash",
|
||||
"canonical_slug": "google/gemini-2.5-flash",
|
||||
"hugging_face_id": "",
|
||||
"name": "Google: Gemini 2.5 Pro Preview 06-05",
|
||||
"created": 1749137257,
|
||||
"description": "Gemini 2.5 Pro is Google\u2019s state-of-the-art AI model designed for advanced reasoning, coding, mathematics, and scientific tasks. It employs \u201cthinking\u201d capabilities, enabling it to reason through responses with enhanced accuracy and nuanced context handling. Gemini 2.5 Pro achieves top-tier performance on multiple benchmarks, including first-place positioning on the LMArena leaderboard, reflecting superior human-preference alignment and complex problem-solving abilities.\n",
|
||||
"name": "Google: Gemini 2.5 Flash",
|
||||
"created": 1750172488,
|
||||
"description": "Gemini 2.5 Flash is Google's state-of-the-art workhorse model, specifically designed for advanced reasoning, coding, mathematics, and scientific tasks. It includes built-in \"thinking\" capabilities, enabling it to provide responses with greater accuracy and nuanced context handling. \n\nAdditionally, Gemini 2.5 Flash is configurable through the \"max tokens for reasoning\" parameter, as described in the documentation (https://openrouter.ai/docs/use-cases/reasoning-tokens#max-tokens-for-reasoning).",
|
||||
"context_length": 1048576,
|
||||
"architecture": {
|
||||
"modality": "text+image->text",
|
||||
@@ -21,18 +22,107 @@
|
||||
"instruct_type": null
|
||||
},
|
||||
"pricing": {
|
||||
"prompt": "0.00000125",
|
||||
"completion": "0.00001",
|
||||
"prompt": "0.0000003",
|
||||
"completion": "0.0000025",
|
||||
"request": "0",
|
||||
"image": "0.00516",
|
||||
"image": "0.001238",
|
||||
"web_search": "0",
|
||||
"internal_reasoning": "0",
|
||||
"input_cache_read": "0.00000031",
|
||||
"input_cache_write": "0.000001625"
|
||||
"input_cache_read": "0.000000075",
|
||||
"input_cache_write": "0.0000003833"
|
||||
},
|
||||
"top_provider": {
|
||||
"context_length": 1048576,
|
||||
"max_completion_tokens": 65536,
|
||||
"max_completion_tokens": 65535,
|
||||
"is_moderated": false
|
||||
},
|
||||
"per_request_limits": null,
|
||||
"supported_parameters": [
|
||||
"max_tokens",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"stop",
|
||||
"response_format",
|
||||
"structured_outputs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "openai/o3-pro",
|
||||
"canonical_slug": "openai/o3-pro-2025-06-10",
|
||||
"hugging_face_id": "",
|
||||
"name": "OpenAI: o3 Pro",
|
||||
"created": 1749598352,
|
||||
"description": "The o-series of models are trained with reinforcement learning to think before they answer and perform complex reasoning. The o3-pro model uses more compute to think harder and provide consistently better answers.\n\nNote that BYOK is required for this model. Set up here: https://openrouter.ai/settings/integrations",
|
||||
"context_length": 200000,
|
||||
"architecture": {
|
||||
"modality": "text+image->text",
|
||||
"input_modalities": [
|
||||
"text",
|
||||
"file",
|
||||
"image"
|
||||
],
|
||||
"output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"tokenizer": "Other",
|
||||
"instruct_type": null
|
||||
},
|
||||
"pricing": {
|
||||
"prompt": "0.00002",
|
||||
"completion": "0.00008",
|
||||
"request": "0",
|
||||
"image": "0.0153",
|
||||
"web_search": "0",
|
||||
"internal_reasoning": "0"
|
||||
},
|
||||
"top_provider": {
|
||||
"context_length": 200000,
|
||||
"max_completion_tokens": 100000,
|
||||
"is_moderated": true
|
||||
},
|
||||
"per_request_limits": null,
|
||||
"supported_parameters": [
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"seed",
|
||||
"max_tokens",
|
||||
"response_format",
|
||||
"structured_outputs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x-ai/grok-3-mini",
|
||||
"canonical_slug": "x-ai/grok-3-mini",
|
||||
"hugging_face_id": "",
|
||||
"name": "xAI: Grok 3 Mini",
|
||||
"created": 1749583245,
|
||||
"description": "A lightweight model that thinks before responding. Fast, smart, and great for logic-based tasks that do not require deep domain knowledge. The raw thinking traces are accessible.",
|
||||
"context_length": 131072,
|
||||
"architecture": {
|
||||
"modality": "text->text",
|
||||
"input_modalities": [
|
||||
"text"
|
||||
],
|
||||
"output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"tokenizer": "Grok",
|
||||
"instruct_type": null
|
||||
},
|
||||
"pricing": {
|
||||
"prompt": "0.0000003",
|
||||
"completion": "0.0000005",
|
||||
"request": "0",
|
||||
"image": "0",
|
||||
"web_search": "0",
|
||||
"internal_reasoning": "0",
|
||||
"input_cache_read": "0.000000075"
|
||||
},
|
||||
"top_provider": {
|
||||
"context_length": 131072,
|
||||
"max_completion_tokens": null,
|
||||
"is_moderated": false
|
||||
},
|
||||
"per_request_limits": null,
|
||||
@@ -45,11 +135,11 @@
|
||||
"reasoning",
|
||||
"include_reasoning",
|
||||
"structured_outputs",
|
||||
"response_format",
|
||||
"stop",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
"seed"
|
||||
"seed",
|
||||
"logprobs",
|
||||
"top_logprobs",
|
||||
"response_format"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
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,17 +1,26 @@
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.0.1"
|
||||
version = "0.4.3"
|
||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.115",
|
||||
"aiosqlite>=0.20",
|
||||
"sixty-nuts>=0.1.4",
|
||||
"sqlmodel>=0.0.24",
|
||||
"httpx[socks]>=0.25.2",
|
||||
"greenlet>=3.2.1",
|
||||
"alembic>=1.13",
|
||||
"python-json-logger>=2.0.0",
|
||||
"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]
|
||||
@@ -23,6 +32,10 @@ dev = [
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"pytest-cov>=6.1.1",
|
||||
"httpx>=0.25.2",
|
||||
"psutil>=5.9.0",
|
||||
"aiohttp>=3.9.0",
|
||||
"pytest-benchmark>=4.0.0",
|
||||
"routstr",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
@@ -42,13 +55,25 @@ addopts = [
|
||||
]
|
||||
markers = [
|
||||
"asyncio: marks tests as async (deselect with '-m \"not asyncio\"')",
|
||||
"integration: marks tests as integration tests",
|
||||
"integration: marks tests as integration tests (deselect with '-m \"not integration\"')",
|
||||
"unit: marks tests as unit tests",
|
||||
"slow: marks tests as slow running (deselect with '-m \"not slow\"')",
|
||||
"requires_real_mint: marks tests that require a running Cashu mint instance",
|
||||
"requires_docker: marks tests that require Docker services running (deselect with '-m \"not requires_docker\"')",
|
||||
"performance: marks tests that measure performance metrics",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["routstr"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
ignore = ["E501"]
|
||||
exclude = ["examples"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
@@ -58,3 +83,6 @@ check_untyped_defs = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.uv.sources]
|
||||
routstr = { workspace = true }
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import dotenv
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
from .main import app as fastapi_app # noqa
|
||||
|
||||
__all__ = ["fastapi_app"]
|
||||
@@ -1,102 +0,0 @@
|
||||
from typing import Annotated, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
|
||||
from .auth import validate_bearer_key
|
||||
from .cashu import (
|
||||
credit_balance,
|
||||
delete_key_if_zero_balance,
|
||||
refund_balance,
|
||||
wallet,
|
||||
)
|
||||
from .db import ApiKey, AsyncSession, get_session
|
||||
|
||||
wallet_router = APIRouter(prefix="/v1/wallet")
|
||||
|
||||
|
||||
async def get_key_from_header(
|
||||
authorization: Annotated[str, Header(...)],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ApiKey:
|
||||
if authorization.startswith("Bearer "):
|
||||
return await validate_bearer_key(authorization[7:], session)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid authorization. Use 'Bearer <cashu-token>' or 'Bearer <api-key>'",
|
||||
)
|
||||
|
||||
|
||||
# TODO: remove this endpoint when frontend is updated
|
||||
@wallet_router.get("/")
|
||||
async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
return {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
}
|
||||
|
||||
|
||||
@wallet_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,
|
||||
}
|
||||
|
||||
|
||||
@wallet_router.post("/topup")
|
||||
async def topup_wallet_endpoint(
|
||||
cashu_token: str,
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, int]:
|
||||
amount_msats = await credit_balance(cashu_token, key, session)
|
||||
return {"msats": amount_msats}
|
||||
|
||||
|
||||
@wallet_router.post("/refund")
|
||||
async def refund_wallet_endpoint(
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
remaining_balance_msats = key.balance
|
||||
|
||||
if remaining_balance_msats == 0:
|
||||
raise HTTPException(status_code=400, detail="No balance to refund")
|
||||
|
||||
# Perform refund operation first, before modifying balance
|
||||
if key.refund_address:
|
||||
await refund_balance(remaining_balance_msats, key, session)
|
||||
result = {"recipient": key.refund_address, "msats": remaining_balance_msats}
|
||||
else:
|
||||
# Convert msats to sats for cashu wallet
|
||||
remaining_balance_sats = remaining_balance_msats // 1000
|
||||
if remaining_balance_sats == 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Balance too small to refund (less than 1 sat)"
|
||||
)
|
||||
|
||||
# TODO: choose currency and mint based on what user has configured
|
||||
token = await wallet().send(remaining_balance_sats)
|
||||
|
||||
result = {"msats": remaining_balance_msats, "recipient": None, "token": token}
|
||||
|
||||
# Only after successful refund, zero out the balance
|
||||
key.balance = 0
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
await delete_key_if_zero_balance(key, session)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@wallet_router.api_route(
|
||||
"/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE"],
|
||||
include_in_schema=False,
|
||||
response_model=None,
|
||||
)
|
||||
async def wallet_catch_all(path: str) -> NoReturn:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Not found check /docs for available endpoints"
|
||||
)
|
||||
162
router/admin.py
162
router/admin.py
@@ -1,162 +0,0 @@
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlmodel import select
|
||||
|
||||
from .cashu import wallet
|
||||
from .db import ApiKey, create_session
|
||||
|
||||
admin_router = APIRouter(prefix="/admin")
|
||||
|
||||
|
||||
def login_form() -> str:
|
||||
return """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
input[type="password"] {
|
||||
padding: 8px;
|
||||
}
|
||||
button {
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
const password = document.getElementById('password').value;
|
||||
document.cookie = `admin_password=${password}; path=/; max-age=86400`;
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<form onsubmit="handleSubmit(event)">
|
||||
<input type="password" id="password" placeholder="Admin Password" required>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def info(content: str) -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="text-align: center;">
|
||||
{content}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def admin_auth() -> str:
|
||||
if os.getenv("ADMIN_PASSWORD", "") == "":
|
||||
return info("Please set a secure ADMIN_PASSWORD= in your ENV variables.")
|
||||
else:
|
||||
return login_form()
|
||||
|
||||
|
||||
async def dashboard(request: Request) -> str:
|
||||
# fetch cashu / api-key data from database
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(ApiKey))
|
||||
api_keys = result.all()
|
||||
|
||||
api_keys_table_rows = []
|
||||
for key in api_keys:
|
||||
expiry_time_utc = (
|
||||
datetime.fromtimestamp(key.key_expiry_time, tz=timezone.utc)
|
||||
if key.key_expiry_time is not None
|
||||
else None
|
||||
)
|
||||
expiry_time_human_readable = (
|
||||
expiry_time_utc.strftime("%Y-%m-%d %H:%M:%S") if expiry_time_utc else ""
|
||||
)
|
||||
|
||||
api_keys_table_rows.append(
|
||||
f"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{'{} ({} UTC)'.format(key.key_expiry_time, expiry_time_human_readable) if key.key_expiry_time else key.key_expiry_time}</td></tr>"
|
||||
)
|
||||
|
||||
# Calculate the total balance of all API keys using integer arithmetic to
|
||||
# avoid rounding issues.
|
||||
total_user_balance = sum(key.balance for key in api_keys) // 1000
|
||||
# Fetch balance from cashu
|
||||
current_balance = await wallet().get_balance()
|
||||
owner_balance = current_balance - total_user_balance
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}}
|
||||
th, td {{
|
||||
border: 1px solid black;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Admin Dashboard</h1>
|
||||
<h2>Current Cashu Balance</h2>
|
||||
<p>Your Balance: {owner_balance} sats</p>
|
||||
<p>The balance is calculated by subtracting the combined user balance from the total Cashu wallet balance.</p>
|
||||
<p>Total Cashu Balance: {current_balance} sats</p>
|
||||
<p>User Balance: {total_user_balance} sats</p>
|
||||
<h2>User's API Keys</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Hashed Key</th>
|
||||
<th>Balance (mSats)</th>
|
||||
<th>Total Spent (mSats)</th>
|
||||
<th>Total Requests</th>
|
||||
<th>Refund Address</th>
|
||||
<th>Refund Time</th>
|
||||
</tr>
|
||||
{"".join(api_keys_table_rows)}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@admin_router.get("/", response_class=HTMLResponse)
|
||||
async def admin(request: Request) -> str:
|
||||
admin_cookie = request.cookies.get("admin_password")
|
||||
if admin_cookie and admin_cookie == os.getenv("ADMIN_PASSWORD"):
|
||||
return await dashboard(request)
|
||||
return admin_auth()
|
||||
552
router/auth.py
552
router/auth.py
@@ -1,552 +0,0 @@
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlmodel import col, update
|
||||
|
||||
from .cashu import credit_balance
|
||||
from .db import ApiKey, AsyncSession
|
||||
from .logging.logging_config import get_logger
|
||||
from .payment.cost_caculation import (
|
||||
CostData,
|
||||
CostDataError,
|
||||
MaxCostData,
|
||||
calculate_cost,
|
||||
)
|
||||
from .payment.helpers import get_max_cost_for_model
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# TODO: implement prepaid api key (not like it was before)
|
||||
# PREPAID_API_KEY = os.environ.get("PREPAID_API_KEY", None)
|
||||
# PREPAID_BALANCE = int(os.environ.get("PREPAID_BALANCE", "0")) * 1000 # Convert to msats
|
||||
|
||||
|
||||
async def validate_bearer_key(
|
||||
bearer_key: str,
|
||||
session: AsyncSession,
|
||||
refund_address: Optional[str] = None,
|
||||
key_expiry_time: Optional[int] = None,
|
||||
) -> ApiKey:
|
||||
"""
|
||||
Validates the provided API key using SQLModel.
|
||||
If it's a cashu key, it redeems it and stores its hash and balance.
|
||||
Otherwise checks if the hash of the key exists.
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting bearer key validation",
|
||||
extra={
|
||||
"key_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
"has_refund_address": bool(refund_address),
|
||||
"has_expiry_time": bool(key_expiry_time),
|
||||
},
|
||||
)
|
||||
|
||||
if not bearer_key:
|
||||
logger.error("Empty bearer key provided")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "API key or Cashu token required",
|
||||
"type": "invalid_request_error",
|
||||
"code": "missing_api_key",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if bearer_key.startswith("sk-"):
|
||||
logger.debug(
|
||||
"Processing sk- prefixed API key",
|
||||
extra={"key_preview": bearer_key[:10] + "..."},
|
||||
)
|
||||
|
||||
if existing_key := await session.get(ApiKey, bearer_key[3:]):
|
||||
logger.info(
|
||||
"Existing sk- API key found",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"balance": existing_key.balance,
|
||||
"total_requests": existing_key.total_requests,
|
||||
},
|
||||
)
|
||||
|
||||
if key_expiry_time is not None:
|
||||
existing_key.key_expiry_time = key_expiry_time
|
||||
logger.debug(
|
||||
"Updated key expiry time",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"expiry_time": key_expiry_time,
|
||||
},
|
||||
)
|
||||
|
||||
if refund_address is not None:
|
||||
existing_key.refund_address = refund_address
|
||||
logger.debug(
|
||||
"Updated refund address",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"refund_address_preview": refund_address[:20] + "..."
|
||||
if len(refund_address) > 20
|
||||
else refund_address,
|
||||
},
|
||||
)
|
||||
|
||||
return existing_key
|
||||
else:
|
||||
logger.warning(
|
||||
"sk- API key not found in database",
|
||||
extra={"key_preview": bearer_key[:10] + "..."},
|
||||
)
|
||||
|
||||
if bearer_key.startswith("cashu"):
|
||||
logger.debug(
|
||||
"Processing Cashu token",
|
||||
extra={
|
||||
"token_preview": bearer_key[:20] + "...",
|
||||
"token_type": bearer_key[:6] if len(bearer_key) >= 6 else bearer_key,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
|
||||
logger.debug(
|
||||
"Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."}
|
||||
)
|
||||
|
||||
if existing_key := await session.get(ApiKey, hashed_key):
|
||||
logger.info(
|
||||
"Existing Cashu token found",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"balance": existing_key.balance,
|
||||
"total_requests": existing_key.total_requests,
|
||||
},
|
||||
)
|
||||
|
||||
if key_expiry_time is not None:
|
||||
existing_key.key_expiry_time = key_expiry_time
|
||||
logger.debug(
|
||||
"Updated key expiry time for existing Cashu key",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"expiry_time": key_expiry_time,
|
||||
},
|
||||
)
|
||||
|
||||
if refund_address is not None:
|
||||
existing_key.refund_address = refund_address
|
||||
logger.debug(
|
||||
"Updated refund address for existing Cashu key",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"refund_address_preview": refund_address[:20] + "..."
|
||||
if len(refund_address) > 20
|
||||
else refund_address,
|
||||
},
|
||||
)
|
||||
|
||||
return existing_key
|
||||
|
||||
logger.info(
|
||||
"Creating new Cashu token entry",
|
||||
extra={
|
||||
"hash_preview": hashed_key[:16] + "...",
|
||||
"has_refund_address": bool(refund_address),
|
||||
"has_expiry_time": bool(key_expiry_time),
|
||||
},
|
||||
)
|
||||
|
||||
new_key = ApiKey(
|
||||
hashed_key=hashed_key,
|
||||
balance=0,
|
||||
refund_address=refund_address,
|
||||
key_expiry_time=key_expiry_time,
|
||||
)
|
||||
session.add(new_key)
|
||||
await session.flush()
|
||||
|
||||
logger.debug(
|
||||
"New key created, starting token redemption",
|
||||
extra={"key_hash": hashed_key[:8] + "..."},
|
||||
)
|
||||
|
||||
msats = await credit_balance(bearer_key, new_key, session)
|
||||
if msats <= 0:
|
||||
logger.error(
|
||||
"Token redemption returned zero or negative amount",
|
||||
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
|
||||
)
|
||||
raise Exception("Token redemption failed")
|
||||
|
||||
await session.refresh(new_key)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"New Cashu token successfully redeemed and stored",
|
||||
extra={
|
||||
"key_hash": hashed_key[:8] + "...",
|
||||
"redeemed_msats": msats,
|
||||
"final_balance": new_key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
return new_key
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Cashu token redemption failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"token_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Invalid or expired Cashu key: {str(e)}",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
logger.error(
|
||||
"Invalid API key format",
|
||||
extra={
|
||||
"key_preview": bearer_key[:10] + "..."
|
||||
if len(bearer_key) > 10
|
||||
else bearer_key,
|
||||
"key_length": len(bearer_key),
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "Invalid API key",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def pay_for_request(key: ApiKey, session: AsyncSession, body: dict) -> int:
|
||||
"""Process payment for a request."""
|
||||
model = body["model"]
|
||||
cost_per_request = get_max_cost_for_model(model=model)
|
||||
|
||||
logger.info(
|
||||
"Processing payment for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"current_balance": key.balance,
|
||||
"required_cost": cost_per_request,
|
||||
"model": model,
|
||||
"sufficient_balance": key.balance >= cost_per_request,
|
||||
},
|
||||
)
|
||||
|
||||
if key.balance < cost_per_request:
|
||||
logger.warning(
|
||||
"Insufficient balance for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"balance": key.balance,
|
||||
"required": cost_per_request,
|
||||
"shortfall": cost_per_request - key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Charging base cost for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"cost": cost_per_request,
|
||||
"balance_before": key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
# Charge the base cost for the request atomically to avoid race conditions
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) >= cost_per_request)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) - cost_per_request,
|
||||
total_spent=col(ApiKey.total_spent) + cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Concurrent request depleted balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"required_cost": cost_per_request,
|
||||
"current_balance": key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
# Another concurrent request spent the balance first
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Payment processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost_per_request,
|
||||
"new_balance": key.balance,
|
||||
"total_spent": key.total_spent,
|
||||
"total_requests": key.total_requests,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
return cost_per_request
|
||||
|
||||
|
||||
async def revert_pay_for_request(
|
||||
key: ApiKey, session: AsyncSession, cost_per_request: int
|
||||
) -> None:
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) + cost_per_request,
|
||||
total_spent=col(ApiKey.total_spent) - cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
)
|
||||
)
|
||||
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"failed to revert request payment: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"type": "payment_error",
|
||||
"code": "payment_error",
|
||||
}
|
||||
},
|
||||
)
|
||||
await session.refresh(key)
|
||||
|
||||
|
||||
async def adjust_payment_for_tokens(
|
||||
key: ApiKey, response_data: dict, session: AsyncSession
|
||||
) -> dict:
|
||||
"""
|
||||
Adjusts the payment based on token usage in the response.
|
||||
This is called after the initial payment and the upstream request is complete.
|
||||
Returns cost data to be included in the response.
|
||||
"""
|
||||
model = response_data.get("model", "unknown")
|
||||
max_cost = get_max_cost_for_model(model=model)
|
||||
|
||||
logger.debug(
|
||||
"Starting payment adjustment for tokens",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"max_cost": max_cost,
|
||||
"current_balance": key.balance,
|
||||
"has_usage": "usage" in response_data,
|
||||
},
|
||||
)
|
||||
|
||||
match calculate_cost(response_data, max_cost):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost data (no token adjustment)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"max_cost": cost.total_msats,
|
||||
},
|
||||
)
|
||||
return cost.dict()
|
||||
|
||||
case CostData() as cost:
|
||||
# If token-based pricing is enabled and base cost is 0, use token-based cost
|
||||
# Otherwise, token cost is additional to the base cost
|
||||
cost_difference = cost.total_msats - max_cost
|
||||
|
||||
logger.info(
|
||||
"Calculated token-based cost",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"token_cost": cost.total_msats,
|
||||
"max_cost": max_cost,
|
||||
"cost_difference": cost_difference,
|
||||
"input_msats": cost.input_msats,
|
||||
"output_msats": cost.output_msats,
|
||||
},
|
||||
)
|
||||
|
||||
if cost_difference == 0:
|
||||
logger.debug(
|
||||
"No cost adjustment needed",
|
||||
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
||||
)
|
||||
await session.commit()
|
||||
return cost.dict()
|
||||
|
||||
if cost_difference > 0:
|
||||
# Need to charge more
|
||||
logger.info(
|
||||
"Additional charge required for token usage",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"additional_charge": cost_difference,
|
||||
"current_balance": key.balance,
|
||||
"sufficient_balance": key.balance >= cost_difference,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
if key.balance < cost_difference:
|
||||
logger.warning(
|
||||
"Insufficient balance for token-based pricing adjustment",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"required": cost_difference,
|
||||
"available": key.balance,
|
||||
"shortfall": cost_difference - key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
else:
|
||||
charge_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) >= cost_difference)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) - cost_difference,
|
||||
total_spent=col(ApiKey.total_spent) + cost_difference,
|
||||
)
|
||||
)
|
||||
result = await session.exec(charge_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount:
|
||||
cost.total_msats = max_cost + cost_difference
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Additional charge applied successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost_difference,
|
||||
"new_balance": key.balance,
|
||||
"total_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to apply additional charge (concurrent operation)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"attempted_charge": cost_difference,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Refund some of the base cost
|
||||
refund = abs(cost_difference)
|
||||
logger.info(
|
||||
"Refunding excess payment",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"refund_amount": refund,
|
||||
"current_balance": key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
refund_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) + refund,
|
||||
total_spent=col(ApiKey.total_spent) - refund,
|
||||
)
|
||||
)
|
||||
await session.exec(refund_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
cost.total_msats = max_cost - refund
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Refund processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"refunded_amount": refund,
|
||||
"new_balance": key.balance,
|
||||
"final_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
return cost.dict()
|
||||
|
||||
case CostDataError() as error:
|
||||
logger.error(
|
||||
"Cost calculation error during payment adjustment",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"error_message": error.message,
|
||||
"error_code": error.code,
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"message": error.message,
|
||||
"type": "invalid_request_error",
|
||||
"code": error.code,
|
||||
}
|
||||
},
|
||||
)
|
||||
536
router/cashu.py
536
router/cashu.py
@@ -1,536 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from typing import cast
|
||||
|
||||
from sixty_nuts import Wallet
|
||||
from sixty_nuts.types import CurrencyUnit
|
||||
from sqlmodel import col, func, select, update
|
||||
|
||||
from .db import ApiKey, AsyncSession, get_session
|
||||
from .logging.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
RECEIVE_LN_ADDRESS = os.environ["RECEIVE_LN_ADDRESS"]
|
||||
MINT = os.environ.get("MINT", "https://mint.minibits.cash/Bitcoin")
|
||||
MINIMUM_PAYOUT = int(os.environ.get("MINIMUM_PAYOUT", 100))
|
||||
REFUND_PROCESSING_INTERVAL = int(os.environ.get("REFUND_PROCESSING_INTERVAL", 3600))
|
||||
PAYOUT_INTERVAL = int(os.environ.get("PAYOUT_INTERVAL", 300)) # Default 5 minutes
|
||||
DEV_LN_ADDRESS = "routstr@minibits.cash"
|
||||
DEVS_DONATION_RATE = float(os.environ.get("DEVS_DONATION_RATE", 0.021)) # 2.1%
|
||||
NSEC = os.environ["NSEC"] # Nostr private key for the wallet
|
||||
|
||||
logger.info(
|
||||
"Cashu module initialized",
|
||||
extra={
|
||||
"mint": MINT,
|
||||
"minimum_payout": MINIMUM_PAYOUT,
|
||||
"refund_processing_interval": REFUND_PROCESSING_INTERVAL,
|
||||
"payout_interval": PAYOUT_INTERVAL,
|
||||
"devs_donation_rate": DEVS_DONATION_RATE,
|
||||
},
|
||||
)
|
||||
|
||||
wallet_instance: Wallet | None = None
|
||||
|
||||
|
||||
async def init_wallet() -> None:
|
||||
"""Initialize the Cashu wallet."""
|
||||
global wallet_instance
|
||||
try:
|
||||
logger.info("Initializing Cashu wallet", extra={"mint": MINT})
|
||||
wallet_instance = await Wallet.create(nsec=NSEC)
|
||||
logger.info("Cashu wallet initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to initialize Cashu wallet",
|
||||
extra={"error": str(e), "error_type": type(e).__name__, "mint": MINT},
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def wallet() -> Wallet:
|
||||
"""Get the wallet instance."""
|
||||
global wallet_instance
|
||||
if wallet_instance is None:
|
||||
logger.error("Wallet not initialized - call init_wallet() first")
|
||||
raise ValueError("Wallet not initialized")
|
||||
return wallet_instance
|
||||
|
||||
|
||||
async def delete_key_if_zero_balance(key: ApiKey, session: AsyncSession) -> None:
|
||||
"""Delete the given API key if its balance is zero."""
|
||||
if key.balance == 0:
|
||||
logger.info(
|
||||
"Deleting API key with zero balance",
|
||||
extra={"key_hash": key.hashed_key[:8] + "...", "balance": key.balance},
|
||||
)
|
||||
await session.delete(key)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def pay_out() -> None:
|
||||
"""
|
||||
Calculates the pay-out amount based on the spent balance, profit, and donation rate.
|
||||
"""
|
||||
try:
|
||||
logger.debug("Starting payout process")
|
||||
from .db import create_session
|
||||
|
||||
async with create_session() as session:
|
||||
result = await session.exec(
|
||||
select(func.sum(col(ApiKey.balance))).where(ApiKey.balance > 0)
|
||||
)
|
||||
balance = result.one_or_none()
|
||||
if not balance:
|
||||
logger.debug("No balance to pay out")
|
||||
return
|
||||
|
||||
user_balance_sats = balance // 1000
|
||||
wallet_balance_sats = await wallet().get_balance()
|
||||
|
||||
logger.debug(
|
||||
"Payout calculation",
|
||||
extra={
|
||||
"user_balance_sats": user_balance_sats,
|
||||
"wallet_balance_sats": wallet_balance_sats,
|
||||
},
|
||||
)
|
||||
|
||||
# Handle edge cases more gracefully
|
||||
if wallet_balance_sats < user_balance_sats:
|
||||
logger.warning(
|
||||
"Insufficient wallet balance for payout",
|
||||
extra={
|
||||
"wallet_balance_sats": wallet_balance_sats,
|
||||
"user_balance_sats": user_balance_sats,
|
||||
"shortfall_sats": user_balance_sats - wallet_balance_sats,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if (revenue := wallet_balance_sats - user_balance_sats) <= MINIMUM_PAYOUT:
|
||||
logger.debug(
|
||||
"Revenue below minimum payout threshold",
|
||||
extra={"revenue_sats": revenue, "minimum_payout": MINIMUM_PAYOUT},
|
||||
)
|
||||
return
|
||||
|
||||
devs_donation = int(revenue * DEVS_DONATION_RATE)
|
||||
owners_draw = revenue - devs_donation
|
||||
|
||||
logger.info(
|
||||
"Processing payout",
|
||||
extra={
|
||||
"revenue_sats": revenue,
|
||||
"devs_donation_sats": devs_donation,
|
||||
"owners_draw_sats": owners_draw,
|
||||
"donation_rate": DEVS_DONATION_RATE,
|
||||
},
|
||||
)
|
||||
|
||||
# Send payouts
|
||||
try:
|
||||
await wallet().send_to_lnurl(RECEIVE_LN_ADDRESS, owners_draw)
|
||||
logger.info(
|
||||
"Owner payout sent successfully",
|
||||
extra={
|
||||
"amount_sats": owners_draw,
|
||||
"address": RECEIVE_LN_ADDRESS[:10] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
await wallet().send_to_lnurl(DEV_LN_ADDRESS, devs_donation)
|
||||
logger.info(
|
||||
"Developer donation sent successfully",
|
||||
extra={"amount_sats": devs_donation, "address": DEV_LN_ADDRESS},
|
||||
)
|
||||
except Exception as payout_error:
|
||||
logger.error(
|
||||
"Failed to send payouts",
|
||||
extra={
|
||||
"error": str(payout_error),
|
||||
"error_type": type(payout_error).__name__,
|
||||
"owners_draw_sats": owners_draw,
|
||||
"devs_donation_sats": devs_donation,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in payout process",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
|
||||
# Periodic payout task
|
||||
async def periodic_payout() -> None:
|
||||
"""Periodically process payouts."""
|
||||
logger.info("Starting periodic payout task", extra={"interval_seconds": 300})
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(300) # Run every 5 minutes
|
||||
await pay_out()
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Periodic payout task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in periodic payout",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
# Continue running even if payout fails
|
||||
|
||||
|
||||
async def credit_balance(cashu_token: str, key: ApiKey, session: AsyncSession) -> int:
|
||||
"""Redeem a Cashu token and credit the amount to the API key balance."""
|
||||
logger.debug(
|
||||
"Starting token redemption",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"token_preview": cashu_token[:20] + "..."
|
||||
if len(cashu_token) > 20
|
||||
else cashu_token,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
amount, unit = await wallet().redeem(cashu_token)
|
||||
logger.info(
|
||||
"Token redeemed successfully",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Token redemption failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"token_preview": cashu_token[:20] + "..."
|
||||
if len(cashu_token) > 20
|
||||
else cashu_token,
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
if amount <= 0:
|
||||
logger.warning(
|
||||
"Zero or negative amount redeemed",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
if unit == "msat":
|
||||
amount_msats = amount
|
||||
else:
|
||||
amount_msats = amount * 1000
|
||||
|
||||
logger.debug(
|
||||
"Crediting balance",
|
||||
extra={
|
||||
"amount_msats": amount_msats,
|
||||
"original_amount": amount,
|
||||
"unit": unit,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
# Apply the balance change atomically to avoid race conditions when topping
|
||||
# up the same key concurrently.
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(balance=col(ApiKey.balance) + amount_msats)
|
||||
)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Balance credited successfully",
|
||||
extra={
|
||||
"credited_msats": amount_msats,
|
||||
"new_balance_msats": key.balance,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
return amount_msats
|
||||
|
||||
|
||||
async def check_for_refunds() -> None:
|
||||
"""
|
||||
Periodically checks for API keys that are eligible for refunds and processes them.
|
||||
|
||||
Raises:
|
||||
Exception: If an error occurs during the refund check process.
|
||||
"""
|
||||
# Setting REFUND_PROCESSING_INTERVAL to 0 disables it
|
||||
if REFUND_PROCESSING_INTERVAL == 0:
|
||||
logger.info("Automatic refund processing is disabled")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Starting refund monitoring task",
|
||||
extra={"interval_seconds": REFUND_PROCESSING_INTERVAL},
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
logger.debug("Checking for expired keys requiring refunds")
|
||||
async for session in get_session():
|
||||
result = await session.exec(select(ApiKey))
|
||||
keys = result.all()
|
||||
current_time = int(time.time())
|
||||
|
||||
expired_keys = []
|
||||
for key in keys:
|
||||
if (
|
||||
key.balance > 0
|
||||
and key.refund_address
|
||||
and key.key_expiry_time
|
||||
and key.key_expiry_time < current_time
|
||||
):
|
||||
expired_keys.append(key)
|
||||
|
||||
if expired_keys:
|
||||
logger.info(
|
||||
"Found expired keys for refund",
|
||||
extra={
|
||||
"expired_count": len(expired_keys),
|
||||
"current_time": current_time,
|
||||
},
|
||||
)
|
||||
|
||||
for key in expired_keys:
|
||||
logger.info(
|
||||
"Processing refund for expired key",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"balance_msats": key.balance,
|
||||
"expiry_time": key.key_expiry_time,
|
||||
"current_time": current_time,
|
||||
"expired_seconds": current_time
|
||||
- (key.key_expiry_time or 0),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
await refund_balance(key.balance, key, session)
|
||||
await delete_key_if_zero_balance(key, session)
|
||||
logger.info(
|
||||
"Refund processed successfully",
|
||||
extra={"key_hash": key.hashed_key[:8] + "..."},
|
||||
)
|
||||
except Exception as refund_error:
|
||||
logger.error(
|
||||
"Failed to process refund",
|
||||
extra={
|
||||
"error": str(refund_error),
|
||||
"error_type": type(refund_error).__name__,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"balance_msats": key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
# Sleep for the specified interval before checking again
|
||||
await asyncio.sleep(REFUND_PROCESSING_INTERVAL)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Refund monitoring task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error during refund check",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
|
||||
async def refund_balance(amount_msats: int, key: ApiKey, session: AsyncSession) -> int:
|
||||
"""Process a refund for an API key."""
|
||||
if amount_msats <= 0:
|
||||
amount_msats = key.balance
|
||||
|
||||
logger.info(
|
||||
"Processing balance refund",
|
||||
extra={
|
||||
"amount_msats": amount_msats,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"refund_address": key.refund_address[:20] + "..."
|
||||
if key.refund_address and len(key.refund_address) > 20
|
||||
else key.refund_address,
|
||||
},
|
||||
)
|
||||
|
||||
# Convert msats to sats for cashu wallet
|
||||
amount_sats = amount_msats // 1000
|
||||
if amount_sats == 0:
|
||||
logger.error(
|
||||
"Amount too small to refund",
|
||||
extra={
|
||||
"amount_msats": amount_msats,
|
||||
"amount_sats": amount_sats,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
raise ValueError("Amount too small to refund (less than 1 sat)")
|
||||
|
||||
# Atomically deduct the balance to avoid race conditions when multiple
|
||||
# refunds are triggered concurrently.
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) >= amount_msats)
|
||||
.values(balance=col(ApiKey.balance) - amount_msats)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Insufficient balance for refund",
|
||||
extra={
|
||||
"requested_msats": amount_msats,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"current_balance": key.balance,
|
||||
},
|
||||
)
|
||||
raise ValueError("Insufficient balance.")
|
||||
|
||||
await session.refresh(key)
|
||||
await delete_key_if_zero_balance(key, session)
|
||||
|
||||
if key.refund_address is None:
|
||||
logger.error(
|
||||
"Refund address not set", extra={"key_hash": key.hashed_key[:8] + "..."}
|
||||
)
|
||||
raise ValueError("Refund address not set.")
|
||||
|
||||
try:
|
||||
result = await wallet().send_to_lnurl(key.refund_address, amount=amount_sats)
|
||||
logger.info(
|
||||
"Refund sent successfully",
|
||||
extra={
|
||||
"amount_sats": amount_sats,
|
||||
"refund_address": key.refund_address[:20] + "..."
|
||||
if len(key.refund_address) > 20
|
||||
else key.refund_address,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"transaction_result": str(result),
|
||||
},
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to send refund",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"amount_sats": amount_sats,
|
||||
"refund_address": key.refund_address,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def x_cashu_refund(key: ApiKey, session: AsyncSession, unit: CurrencyUnit) -> str:
|
||||
"""Process an X-Cashu refund token."""
|
||||
logger.info(
|
||||
"Processing X-Cashu refund",
|
||||
extra={
|
||||
"balance_msats": key.balance,
|
||||
"unit": unit,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
refund_token = await wallet().send(key.balance, unit=unit)
|
||||
logger.info(
|
||||
"X-Cashu refund token created",
|
||||
extra={
|
||||
"amount": key.balance,
|
||||
"unit": unit,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"token_preview": refund_token[:20] + "..."
|
||||
if len(refund_token) > 20
|
||||
else refund_token,
|
||||
},
|
||||
)
|
||||
|
||||
await session.delete(key)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"X-Cashu refund completed", extra={"key_hash": key.hashed_key[:8] + "..."}
|
||||
)
|
||||
|
||||
return refund_token
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to create X-Cashu refund",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"balance": key.balance,
|
||||
"unit": unit,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def redeem(cashu_token: str, lnurl: str) -> int:
|
||||
"""Redeem a Cashu token and send to LNURL."""
|
||||
logger.info(
|
||||
"Starting token redemption for LNURL",
|
||||
extra={
|
||||
"token_preview": cashu_token[:20] + "..."
|
||||
if len(cashu_token) > 20
|
||||
else cashu_token,
|
||||
"lnurl_preview": lnurl[:20] + "..." if len(lnurl) > 20 else lnurl,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
amount, unit = await wallet().redeem(cashu_token)
|
||||
logger.info("Token redeemed for LNURL", extra={"amount": amount, "unit": unit})
|
||||
|
||||
unit = cast(CurrencyUnit, unit)
|
||||
result = await wallet().send_to_lnurl(lnurl, amount=amount, unit=unit)
|
||||
|
||||
logger.info(
|
||||
"Successfully sent to LNURL",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"lnurl_preview": lnurl[:20] + "..." if len(lnurl) > 20 else lnurl,
|
||||
"transaction_result": str(result),
|
||||
},
|
||||
)
|
||||
|
||||
return amount
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to redeem and send to LNURL",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"token_preview": cashu_token[:20] + "..."
|
||||
if len(cashu_token) > 20
|
||||
else cashu_token,
|
||||
"lnurl_preview": lnurl[:20] + "..." if len(lnurl) > 20 else lnurl,
|
||||
},
|
||||
)
|
||||
raise
|
||||
48
router/db.py
48
router/db.py
@@ -1,48 +0,0 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||
from sqlmodel import Field, SQLModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///keys.db")
|
||||
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=False) # echo=True for debugging SQL
|
||||
|
||||
|
||||
class ApiKey(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
hashed_key: str = Field(primary_key=True)
|
||||
balance: int = Field(default=0, description="Balance in millisatoshis (msats)")
|
||||
refund_address: str | None = Field(
|
||||
default=None,
|
||||
description="Lightning address to refund remaining balance after key expires",
|
||||
)
|
||||
key_expiry_time: int | None = Field(
|
||||
default=None,
|
||||
description="Unix-timestamp after which the cashu-token's balance gets refunded to the refund_address",
|
||||
)
|
||||
total_spent: int = Field(
|
||||
default=0, description="Total spent in millisatoshis (msats)"
|
||||
)
|
||||
total_requests: int = Field(default=0)
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Initializes the database and creates tables if they don't exist."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def create_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
@@ -1,207 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from fastapi import APIRouter
|
||||
|
||||
providers_router = APIRouter(prefix="/v1/providers")
|
||||
|
||||
|
||||
def generate_subscription_id() -> str:
|
||||
"""Generate a random subscription ID."""
|
||||
return "".join(random.choices(string.ascii_lowercase + string.digits, k=10))
|
||||
|
||||
|
||||
def extract_onion_urls(content: str) -> list[str]:
|
||||
"""Extract onion URLs from content."""
|
||||
pattern = r"http?://[a-zA-Z0-9\-._~]+\.onion"
|
||||
return re.findall(pattern, content)
|
||||
|
||||
|
||||
async def query_nostr_relay_with_search(
|
||||
search_term: str,
|
||||
relay_url: str,
|
||||
kinds: list[int] | None = None,
|
||||
limit: int = 1000,
|
||||
timeout: int = 30,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Query a Nostr relay and filter for events containing a search term.
|
||||
"""
|
||||
if kinds is None:
|
||||
kinds = [1]
|
||||
|
||||
events = []
|
||||
|
||||
# If searching for an npub mention, try tag-based search first
|
||||
if search_term.startswith("nostr:npub"):
|
||||
# Extract the npub and convert to hex
|
||||
npub = search_term.replace("nostr:", "")
|
||||
try:
|
||||
# Convert npub to hex (you might need to implement or import this)
|
||||
# For now, try tag-based search with the npub
|
||||
filter_obj = {
|
||||
"kinds": kinds,
|
||||
"limit": limit,
|
||||
"#p": [npub], # Posts that tag this pubkey
|
||||
}
|
||||
except Exception:
|
||||
# If conversion fails, try regular search
|
||||
filter_obj = {
|
||||
"kinds": kinds,
|
||||
"limit": limit,
|
||||
}
|
||||
else:
|
||||
# Try relay's search functionality (NIP-50)
|
||||
filter_obj = {
|
||||
"kinds": kinds,
|
||||
"search": search_term,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
sub_id = generate_subscription_id()
|
||||
req_message = json.dumps(["REQ", sub_id, filter_obj])
|
||||
|
||||
try:
|
||||
async with websockets.connect(relay_url, timeout=timeout) as websocket:
|
||||
print(f"Connected to relay, sending request with filter: {filter_obj}")
|
||||
await websocket.send(req_message)
|
||||
|
||||
while True:
|
||||
try:
|
||||
message = await asyncio.wait_for(websocket.recv(), timeout=5)
|
||||
data = json.loads(message)
|
||||
|
||||
if data[0] == "EVENT" and data[1] == sub_id:
|
||||
# For tag-based search, also check content
|
||||
if search_term.startswith("nostr:npub"):
|
||||
if search_term.lower() in data[2]["content"].lower():
|
||||
print(f"Found matching event: {data[2]['id']}")
|
||||
events.append(data[2])
|
||||
else:
|
||||
print(f"Found matching event: {data[2]['id']}")
|
||||
events.append(data[2])
|
||||
elif data[0] == "EOSE" and data[1] == sub_id:
|
||||
print("Received EOSE message")
|
||||
break
|
||||
elif data[0] == "NOTICE":
|
||||
print(f"Relay notice: {data[1]}")
|
||||
# If search not supported, could break and try different approach
|
||||
if "unrecognised filter item" in data[1] and "search" in str(
|
||||
filter_obj
|
||||
):
|
||||
print("Search not supported on this relay")
|
||||
break
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
print("Timeout waiting for message")
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
print("Failed to decode message as JSON")
|
||||
continue
|
||||
|
||||
await websocket.send(json.dumps(["CLOSE", sub_id]))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Query failed: {e}")
|
||||
|
||||
print(f"Query complete. Found {len(events)} matching events")
|
||||
return events
|
||||
|
||||
|
||||
async def get_cache() -> list[dict]:
|
||||
return [] # TODO: Implement cache
|
||||
|
||||
|
||||
async def fetch_onion(provider: str) -> dict:
|
||||
"""Check if an onion service is healthy by making a GET request to its root."""
|
||||
try:
|
||||
# Get Tor proxy URL from environment variable, default to local Tor SOCKS5 proxy
|
||||
tor_proxy = os.getenv("TOR_PROXY_URL", "socks5://127.0.0.1:9050")
|
||||
|
||||
# Configure httpx to use Tor SOCKS5 proxy
|
||||
async with httpx.AsyncClient(
|
||||
proxies={"http://": tor_proxy, "https://": tor_proxy}, # type: ignore
|
||||
timeout=httpx.Timeout(30.0),
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
response = await client.get(provider)
|
||||
# Consider 2xx and 3xx status codes as healthy
|
||||
return {"status_code": response.status_code, "json": response.json()}
|
||||
except Exception:
|
||||
# Any exception means the service is not healthy
|
||||
return {"status_code": 500, "json": {"error": "Failed to fetch onion"}}
|
||||
|
||||
|
||||
@providers_router.get("/")
|
||||
async def get_providers(include_json: bool = False) -> dict[str, list[dict | str]]:
|
||||
npub = "npub130mznv74rxs032peqym6g3wqavh472623mt3z5w73xq9r6qqdufs7ql29s"
|
||||
|
||||
# Relays that support NIP-50 text search
|
||||
search_relays = [
|
||||
"wss://relay.nostr.band", # Known to support search
|
||||
"wss://nostr.wine", # Known to support search
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
]
|
||||
|
||||
# Search for the mention format that appears in posts
|
||||
search_term = f"nostr:{npub}"
|
||||
|
||||
all_events = []
|
||||
event_ids = set() # To avoid duplicates
|
||||
|
||||
# Try multiple relays
|
||||
for relay_url in search_relays:
|
||||
print(f"\nTrying relay: {relay_url}")
|
||||
try:
|
||||
events = await query_nostr_relay_with_search(
|
||||
search_term=search_term,
|
||||
relay_url=relay_url,
|
||||
kinds=[1], # Text notes
|
||||
limit=500,
|
||||
)
|
||||
|
||||
# Add unique events
|
||||
for event in events:
|
||||
if event["id"] not in event_ids:
|
||||
event_ids.add(event["id"])
|
||||
all_events.append(event)
|
||||
|
||||
print(f"Got {len(events)} events from {relay_url}")
|
||||
|
||||
# If we have enough events, we can stop
|
||||
if len(all_events) >= 100:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to query {relay_url}: {e}")
|
||||
continue
|
||||
|
||||
print(f"Found {len(all_events)} total unique events mentioning routstr")
|
||||
|
||||
providers = []
|
||||
for event in all_events:
|
||||
onion_urls = extract_onion_urls(event["content"])
|
||||
providers.extend(onion_urls)
|
||||
|
||||
unique_providers = list(set(providers))
|
||||
|
||||
print(f"Found {len(unique_providers)} unique onion URLs")
|
||||
print(unique_providers)
|
||||
|
||||
healthy_providers: list[dict | str] = []
|
||||
for provider in unique_providers:
|
||||
response = await fetch_onion(provider)
|
||||
|
||||
if include_json:
|
||||
healthy_providers.append({provider: response["json"]})
|
||||
else:
|
||||
healthy_providers.append(provider)
|
||||
|
||||
return {"providers": healthy_providers}
|
||||
@@ -1,253 +0,0 @@
|
||||
import logging.config
|
||||
import logging.handlers
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pythonjsonlogger import jsonlogger
|
||||
|
||||
|
||||
class DailyRotatingFileHandler(logging.handlers.TimedRotatingFileHandler):
|
||||
"""Custom TimedRotatingFileHandler that creates date-based filenames."""
|
||||
|
||||
def __init__(self, filename: str, **kwargs: Any) -> None:
|
||||
"""Initialize with a base filename pattern."""
|
||||
self.base_dir = os.path.dirname(filename)
|
||||
self.base_name = os.path.basename(filename).replace(".log", "")
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
self.current_date = today
|
||||
dated_filename = os.path.join(self.base_dir, f"{self.base_name}_{today}.log")
|
||||
|
||||
super().__init__(dated_filename, **kwargs)
|
||||
|
||||
def doRollover(self) -> None:
|
||||
"""Override rollover to create new date-based filename."""
|
||||
if self.stream:
|
||||
self.stream.close()
|
||||
|
||||
new_date = datetime.now().strftime("%Y-%m-%d")
|
||||
new_filename = os.path.join(self.base_dir, f"{self.base_name}_{new_date}.log")
|
||||
|
||||
self.baseFilename = new_filename
|
||||
self.current_date = new_date
|
||||
|
||||
# FIX ME: not sure if we need this
|
||||
# self._cleanup_old_files()
|
||||
|
||||
if not self.delay:
|
||||
self.stream = self._open()
|
||||
|
||||
def _cleanup_old_files(self) -> None:
|
||||
"""Remove old log files beyond backupCount."""
|
||||
if self.backupCount > 0:
|
||||
log_files = []
|
||||
if os.path.exists(self.base_dir):
|
||||
for file in os.listdir(self.base_dir):
|
||||
if file.startswith(f"{self.base_name}_") and file.endswith(".log"):
|
||||
file_path = os.path.join(self.base_dir, file)
|
||||
log_files.append((file_path, os.path.getmtime(file_path)))
|
||||
|
||||
log_files.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
for file_path, _ in log_files[self.backupCount :]:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def get_package_version() -> str:
|
||||
"""Read the package version from pyproject.toml."""
|
||||
try:
|
||||
# Find project root by looking for pyproject.toml
|
||||
current_path = Path(__file__).parent
|
||||
while current_path != current_path.parent:
|
||||
pyproject_path = current_path / "pyproject.toml"
|
||||
if pyproject_path.exists():
|
||||
with open(pyproject_path, "rb") as f:
|
||||
pyproject_data = tomllib.load(f)
|
||||
version = pyproject_data.get("project", {}).get("version", "unknown")
|
||||
return version
|
||||
current_path = current_path.parent
|
||||
|
||||
# Fallback: try the simple path resolution (3 levels up for router/logging/logging_config.py)
|
||||
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
||||
if pyproject_path.exists():
|
||||
with open(pyproject_path, "rb") as f:
|
||||
pyproject_data = tomllib.load(f)
|
||||
version = pyproject_data.get("project", {}).get("version", "unknown")
|
||||
return version
|
||||
|
||||
return "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
class VersionFilter(logging.Filter):
|
||||
"""Filter to add package version to all log records."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.version = get_package_version()
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""Add version information to the log record."""
|
||||
record.version = self.version
|
||||
return True
|
||||
|
||||
|
||||
class SecurityFilter(logging.Filter):
|
||||
"""Filter to remove sensitive information from logs."""
|
||||
|
||||
SENSITIVE_KEYS = {
|
||||
"authorization",
|
||||
"x-cashu",
|
||||
"bearer",
|
||||
"token",
|
||||
"key",
|
||||
"secret",
|
||||
"password",
|
||||
"cashu_token",
|
||||
"bearer_key",
|
||||
"api_key",
|
||||
"nsec",
|
||||
"upstream_api_key",
|
||||
"refund_address",
|
||||
}
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""Filter out sensitive information from log records."""
|
||||
try:
|
||||
message = record.getMessage()
|
||||
|
||||
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
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
message = re.sub(
|
||||
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
record.msg = message
|
||||
record.args = ()
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_log_level() -> str:
|
||||
"""Get log level from environment variable."""
|
||||
return os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configure centralized logging for the application."""
|
||||
|
||||
log_level = get_log_level()
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"json": {
|
||||
"()": jsonlogger.JsonFormatter,
|
||||
"format": "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d %(version)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
"standard": {
|
||||
"format": "%(asctime)s [%(levelname)s] %(name)s v%(version)s: %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
},
|
||||
"filters": {
|
||||
"version_filter": {"()": VersionFilter},
|
||||
"security_filter": {"()": SecurityFilter},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"level": log_level,
|
||||
"formatter": "json"
|
||||
if os.environ.get("LOG_FORMAT", "json").lower() == "json"
|
||||
else "standard",
|
||||
"stream": "ext://sys.stdout",
|
||||
"filters": ["version_filter", "security_filter"],
|
||||
},
|
||||
"file": {
|
||||
"()": DailyRotatingFileHandler,
|
||||
"level": log_level,
|
||||
"formatter": "json",
|
||||
"filename": "logs/app.log",
|
||||
"when": "midnight", # Rotate at midnight each day
|
||||
"interval": 1, # Every 1 day
|
||||
"backupCount": 30, # Keep 30 days of logs
|
||||
"atTime": None, # Rotate at midnight (00:00)
|
||||
"filters": ["version_filter", "security_filter"],
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"router": {
|
||||
"level": log_level,
|
||||
"handlers": ["console", "file"],
|
||||
"propagate": False,
|
||||
},
|
||||
"router.payment": {
|
||||
"level": log_level,
|
||||
"handlers": ["console", "file"],
|
||||
"propagate": False,
|
||||
},
|
||||
"router.cashu": {
|
||||
"level": log_level,
|
||||
"handlers": ["console", "file"],
|
||||
"propagate": False,
|
||||
},
|
||||
"router.proxy": {
|
||||
"level": log_level,
|
||||
"handlers": ["console", "file"],
|
||||
"propagate": False,
|
||||
},
|
||||
"router.auth": {
|
||||
"level": log_level,
|
||||
"handlers": ["console", "file"],
|
||||
"propagate": False,
|
||||
},
|
||||
# Suppress verbose third-party logging
|
||||
"httpx": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"httpcore": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.access": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
"root": {"level": log_level, "handlers": ["console"]},
|
||||
}
|
||||
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
|
||||
logging.config.dictConfig(LOGGING_CONFIG)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a logger instance for the given module name."""
|
||||
return logging.getLogger(name)
|
||||
119
router/main.py
119
router/main.py
@@ -1,119 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .account import wallet_router
|
||||
from .admin import admin_router
|
||||
from .cashu import check_for_refunds, init_wallet, periodic_payout
|
||||
from .db import init_db
|
||||
from .discovery import providers_router
|
||||
from .logging.logging_config import get_logger, setup_logging
|
||||
from .models import MODELS, models_router, update_sats_pricing
|
||||
from .proxy import proxy_router
|
||||
|
||||
# Initialize logging first
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__version__ = "0.0.1"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logger.info("Application startup initiated", extra={"version": __version__})
|
||||
|
||||
try:
|
||||
await init_db()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
await init_wallet()
|
||||
logger.info("Wallet initialized successfully")
|
||||
|
||||
pricing_task = asyncio.create_task(update_sats_pricing())
|
||||
refund_task = asyncio.create_task(check_for_refunds())
|
||||
payout_task = asyncio.create_task(periodic_payout())
|
||||
|
||||
logger.info(
|
||||
"Background tasks started successfully",
|
||||
extra={"tasks": ["pricing", "refunds", "payouts"]},
|
||||
)
|
||||
|
||||
yield
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Application startup failed",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
logger.info("Application shutdown initiated")
|
||||
|
||||
refund_task.cancel()
|
||||
pricing_task.cancel()
|
||||
payout_task.cancel()
|
||||
|
||||
try:
|
||||
await asyncio.gather(
|
||||
pricing_task, refund_task, payout_task, return_exceptions=True
|
||||
)
|
||||
logger.info("Background tasks stopped successfully")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error stopping background tasks",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
version=__version__,
|
||||
title=os.environ.get("NAME", "ARoutstrNode" + __version__),
|
||||
description=os.environ.get("DESCRIPTION", "A Routstr Node"),
|
||||
contact={"name": os.environ.get("NAME", ""), "npub": os.environ.get("NPUB", "")},
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=os.environ.get("CORS_ORIGINS", "*").split(","),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"CORS middleware configured",
|
||||
extra={"allowed_origins": os.environ.get("CORS_ORIGINS", "*").split(",")},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def info() -> dict:
|
||||
logger.info("Info endpoint accessed")
|
||||
return {
|
||||
"name": app.title,
|
||||
"description": app.description,
|
||||
"version": __version__,
|
||||
"npub": os.environ.get("NPUB", ""),
|
||||
"mint": os.environ.get("MINT", ""),
|
||||
"http_url": os.environ.get("HTTP_URL", ""),
|
||||
"onion_url": os.environ.get("ONION_URL", ""),
|
||||
"models": MODELS,
|
||||
}
|
||||
|
||||
|
||||
app.include_router(models_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(wallet_router)
|
||||
app.include_router(providers_router)
|
||||
app.include_router(proxy_router)
|
||||
|
||||
logger.info(
|
||||
"Application initialized successfully",
|
||||
extra={"version": __version__, "routers_count": 5},
|
||||
)
|
||||
150
router/models.py
150
router/models.py
@@ -1,150 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from .price import sats_usd_ask_price
|
||||
|
||||
models_router = APIRouter()
|
||||
|
||||
|
||||
class Architecture(BaseModel):
|
||||
modality: str
|
||||
input_modalities: list[str]
|
||||
output_modalities: list[str]
|
||||
tokenizer: str
|
||||
instruct_type: str | None
|
||||
|
||||
|
||||
class Pricing(BaseModel):
|
||||
prompt: float
|
||||
completion: float
|
||||
request: float
|
||||
image: float
|
||||
web_search: float
|
||||
internal_reasoning: float
|
||||
max_cost: float = 0.0 # in sats not msats
|
||||
|
||||
|
||||
class TopProvider(BaseModel):
|
||||
context_length: int | None = None
|
||||
max_completion_tokens: int | None = None
|
||||
is_moderated: bool | None = None
|
||||
|
||||
|
||||
class Model(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
created: int
|
||||
description: str
|
||||
context_length: int
|
||||
architecture: Architecture
|
||||
pricing: Pricing
|
||||
sats_pricing: Pricing | None = None
|
||||
per_request_limits: dict | None = None
|
||||
top_provider: TopProvider | None = None
|
||||
|
||||
|
||||
MODELS: list[Model] = []
|
||||
|
||||
|
||||
def load_models() -> list[Model]:
|
||||
"""Load model definitions from a JSON file.
|
||||
|
||||
The file path can be specified via the ``MODELS_PATH`` environment variable.
|
||||
If ``models.json`` is not found, the bundled ``models.example.json`` is used
|
||||
as a fallback. If neither file exists or an error occurs while loading, an
|
||||
empty list is returned.
|
||||
"""
|
||||
|
||||
models_path = Path(os.environ.get("MODELS_PATH", "models.json"))
|
||||
if not models_path.exists():
|
||||
example = Path(__file__).resolve().parent.parent / "models.example.json"
|
||||
if example.exists():
|
||||
models_path = example
|
||||
else:
|
||||
return []
|
||||
|
||||
try:
|
||||
with models_path.open("r") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e: # pragma: no cover - log and continue
|
||||
print(f"Error loading models from {models_path}: {e}")
|
||||
return []
|
||||
|
||||
return [Model(**model) for model in data.get("models", [])]
|
||||
|
||||
|
||||
MODELS = load_models()
|
||||
|
||||
|
||||
async def update_sats_pricing() -> None:
|
||||
while True:
|
||||
try:
|
||||
sats_to_usd = await sats_usd_ask_price()
|
||||
for model in MODELS:
|
||||
model.sats_pricing = Pricing(
|
||||
**{k: v / sats_to_usd for k, v in model.pricing.dict().items()}
|
||||
)
|
||||
if model.top_provider:
|
||||
if (
|
||||
model.top_provider.context_length
|
||||
and model.top_provider.max_completion_tokens
|
||||
):
|
||||
max_context_cost = (
|
||||
model.top_provider.context_length
|
||||
* model.sats_pricing.prompt
|
||||
)
|
||||
max_completion_cost = (
|
||||
model.top_provider.max_completion_tokens
|
||||
* model.sats_pricing.completion
|
||||
)
|
||||
model.sats_pricing.max_cost = (
|
||||
max_context_cost + max_completion_cost
|
||||
)
|
||||
elif model.top_provider.context_length:
|
||||
max_context_cost = (
|
||||
model.top_provider.context_length
|
||||
* model.sats_pricing.prompt
|
||||
)
|
||||
max_completion_cost = 32_000 * model.sats_pricing.completion
|
||||
model.sats_pricing.max_cost = (
|
||||
max_context_cost + max_completion_cost
|
||||
)
|
||||
elif model.top_provider.max_completion_tokens:
|
||||
max_completion_cost = (
|
||||
model.top_provider.max_completion_tokens
|
||||
* model.sats_pricing.completion
|
||||
)
|
||||
max_context_cost = 1_048_576 * model.sats_pricing.prompt
|
||||
model.sats_pricing.max_cost = max_completion_cost
|
||||
else:
|
||||
model.sats_pricing.max_cost = (
|
||||
1_048_576 * model.sats_pricing.prompt
|
||||
+ 32_000 * model.sats_pricing.completion
|
||||
)
|
||||
else:
|
||||
p = model.sats_pricing.prompt * 1_000_000
|
||||
c = model.sats_pricing.completion * 32_000
|
||||
r = model.sats_pricing.request * 100_000
|
||||
i = model.sats_pricing.image * 100
|
||||
w = model.sats_pricing.web_search * 1000
|
||||
ir = model.sats_pricing.internal_reasoning * 100
|
||||
model.sats_pricing.max_cost = p + c + r + i + w + ir
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
print("Error updating sats pricing: ", e)
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
|
||||
@models_router.get("/models")
|
||||
@models_router.get("/v1/models")
|
||||
async def models() -> dict:
|
||||
return {"data": MODELS}
|
||||
@@ -1,169 +0,0 @@
|
||||
import os
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from router.logging.logging_config import get_logger
|
||||
from router.models import MODELS
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
COST_PER_REQUEST = (
|
||||
int(os.environ.get("COST_PER_REQUEST", "1")) * 1000
|
||||
) # Convert to msats
|
||||
COST_PER_1K_INPUT_TOKENS = (
|
||||
int(os.environ.get("COST_PER_1K_INPUT_TOKENS", "0")) * 1000
|
||||
) # Convert to msats
|
||||
COST_PER_1K_OUTPUT_TOKENS = (
|
||||
int(os.environ.get("COST_PER_1K_OUTPUT_TOKENS", "0")) * 1000
|
||||
) # Convert to msats
|
||||
MODEL_BASED_PRICING = os.environ.get("MODEL_BASED_PRICING", "false").lower() == "true"
|
||||
|
||||
logger.info(
|
||||
"Cost calculation initialized",
|
||||
extra={
|
||||
"cost_per_request_msats": COST_PER_REQUEST,
|
||||
"cost_per_1k_input_tokens_msats": COST_PER_1K_INPUT_TOKENS,
|
||||
"cost_per_1k_output_tokens_msats": COST_PER_1K_OUTPUT_TOKENS,
|
||||
"model_based_pricing": MODEL_BASED_PRICING,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class CostData(BaseModel):
|
||||
base_msats: int
|
||||
input_msats: int
|
||||
output_msats: int
|
||||
total_msats: int
|
||||
|
||||
|
||||
class MaxCostData(CostData):
|
||||
pass
|
||||
|
||||
|
||||
class CostDataError(BaseModel):
|
||||
message: str
|
||||
code: str
|
||||
|
||||
|
||||
def calculate_cost(
|
||||
response_data: dict, max_cost: int
|
||||
) -> CostData | MaxCostData | CostDataError:
|
||||
"""
|
||||
Calculate the cost of an API request based on token usage.
|
||||
|
||||
Args:
|
||||
response_data: Response data containing usage information
|
||||
max_cost: Maximum cost in millisats
|
||||
|
||||
Returns:
|
||||
Cost data or error information
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting cost calculation",
|
||||
extra={
|
||||
"max_cost_msats": max_cost,
|
||||
"has_usage_data": "usage" in response_data,
|
||||
"response_model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
cost_data = MaxCostData(
|
||||
base_msats=max_cost,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=max_cost,
|
||||
)
|
||||
|
||||
if "usage" not in response_data or response_data["usage"] is None:
|
||||
logger.warning(
|
||||
"No usage data in response, using base cost only",
|
||||
extra={
|
||||
"max_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS = COST_PER_1K_INPUT_TOKENS
|
||||
MSATS_PER_1K_OUTPUT_TOKENS = COST_PER_1K_OUTPUT_TOKENS
|
||||
|
||||
if MODEL_BASED_PRICING and MODELS:
|
||||
response_model = response_data.get("model", "")
|
||||
logger.debug(
|
||||
"Using model-based pricing",
|
||||
extra={
|
||||
"model": response_model,
|
||||
"available_models": [model.id for model in MODELS],
|
||||
},
|
||||
)
|
||||
|
||||
if response_model not in [model.id for model in MODELS]:
|
||||
logger.error(
|
||||
"Invalid model in response",
|
||||
extra={
|
||||
"response_model": response_model,
|
||||
"available_models": [model.id for model in MODELS],
|
||||
},
|
||||
)
|
||||
return CostDataError(
|
||||
message=f"Invalid model in response: {response_model}",
|
||||
code="model_not_found",
|
||||
)
|
||||
|
||||
model = next(model for model in MODELS if model.id == response_model)
|
||||
if model.sats_pricing is None:
|
||||
logger.error(
|
||||
"Model pricing not defined",
|
||||
extra={"model": response_model, "model_id": model.id},
|
||||
)
|
||||
return CostDataError(
|
||||
message="Model pricing not defined", code="pricing_not_found"
|
||||
)
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS = model.sats_pricing.prompt * 1_000_000 # type: ignore
|
||||
MSATS_PER_1K_OUTPUT_TOKENS = model.sats_pricing.completion * 1_000_000 # type: ignore
|
||||
|
||||
logger.info(
|
||||
"Applied model-specific pricing",
|
||||
extra={
|
||||
"model": response_model,
|
||||
"input_price_msats_per_1k": MSATS_PER_1K_INPUT_TOKENS,
|
||||
"output_price_msats_per_1k": MSATS_PER_1K_OUTPUT_TOKENS,
|
||||
},
|
||||
)
|
||||
|
||||
if not (MSATS_PER_1K_OUTPUT_TOKENS and MSATS_PER_1K_INPUT_TOKENS):
|
||||
logger.warning(
|
||||
"No token pricing configured, using base cost",
|
||||
extra={
|
||||
"base_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
|
||||
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
|
||||
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
|
||||
|
||||
input_msats = int(round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 0))
|
||||
output_msats = int(round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 0))
|
||||
token_based_cost = int(round(input_msats + output_msats, 0))
|
||||
|
||||
logger.info(
|
||||
"Calculated token-based cost",
|
||||
extra={
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"input_cost_msats": input_msats,
|
||||
"output_cost_msats": output_msats,
|
||||
"total_cost_msats": token_based_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return CostData(
|
||||
base_msats=0,
|
||||
input_msats=input_msats,
|
||||
output_msats=output_msats,
|
||||
total_msats=token_based_cost,
|
||||
)
|
||||
@@ -1,385 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
import cbor2
|
||||
from fastapi import HTTPException, Response
|
||||
from sixty_nuts.types import CurrencyUnit
|
||||
|
||||
from router.logging.logging_config import get_logger
|
||||
from router.models import MODELS
|
||||
from router.payment.cost_caculation import COST_PER_REQUEST, MODEL_BASED_PRICING
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
UPSTREAM_BASE_URL = os.environ["UPSTREAM_BASE_URL"]
|
||||
UPSTREAM_API_KEY = os.environ.get("UPSTREAM_API_KEY", "")
|
||||
|
||||
logger.info(
|
||||
"Payment helpers initialized",
|
||||
extra={
|
||||
"upstream_base_url": UPSTREAM_BASE_URL,
|
||||
"has_upstream_api_key": bool(UPSTREAM_API_KEY),
|
||||
"model_based_pricing": MODEL_BASED_PRICING,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_cost_per_request(model: str | None = None) -> int:
|
||||
"""Get the cost per request for a given model."""
|
||||
logger.debug(
|
||||
"Calculating cost per request",
|
||||
extra={
|
||||
"model": model,
|
||||
"model_based_pricing": MODEL_BASED_PRICING,
|
||||
"has_models": bool(MODELS),
|
||||
},
|
||||
)
|
||||
|
||||
if MODEL_BASED_PRICING and MODELS and model:
|
||||
cost = get_max_cost_for_model(model=model)
|
||||
logger.debug(
|
||||
"Using model-based cost", extra={"model": model, "cost_msats": cost}
|
||||
)
|
||||
return cost
|
||||
|
||||
logger.debug(
|
||||
"Using default cost per request", extra={"cost_msats": COST_PER_REQUEST}
|
||||
)
|
||||
return COST_PER_REQUEST
|
||||
|
||||
|
||||
def check_token_balance(headers: dict, body: dict) -> CurrencyUnit:
|
||||
"""Check if the provided token has sufficient balance."""
|
||||
logger.debug(
|
||||
"Checking token balance",
|
||||
extra={
|
||||
"has_x_cashu": "x-cashu" in headers,
|
||||
"has_authorization": "authorization" in headers,
|
||||
"model": body.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
if x_cashu := headers.get("x-cashu", None):
|
||||
cashu_token = x_cashu
|
||||
logger.debug(
|
||||
"Using X-Cashu token",
|
||||
extra={
|
||||
"token_preview": cashu_token[:20] + "..."
|
||||
if len(cashu_token) > 20
|
||||
else cashu_token
|
||||
},
|
||||
)
|
||||
elif auth := headers.get("authorization", None):
|
||||
cashu_token = auth.split(" ")[1] if len(auth.split(" ")) > 1 else ""
|
||||
logger.debug(
|
||||
"Using Authorization header token",
|
||||
extra={
|
||||
"token_preview": cashu_token[:20] + "..."
|
||||
if len(cashu_token) > 20
|
||||
else cashu_token
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.error("No authentication token provided")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# Handle empty token
|
||||
if not cashu_token:
|
||||
logger.error("Empty token provided")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "API key or Cashu token required",
|
||||
"type": "invalid_request_error",
|
||||
"code": "missing_api_key",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Handle regular API keys (sk-*)
|
||||
if cashu_token.startswith("sk-"):
|
||||
logger.debug(
|
||||
"Regular API key detected", extra={"key_preview": cashu_token[:10] + "..."}
|
||||
)
|
||||
return "sat"
|
||||
|
||||
cost = get_cost_per_request(model=body.get("model", None))
|
||||
|
||||
if cashu_token.startswith("cashuA"):
|
||||
logger.debug("Processing CashuA token", extra={"required_cost_msats": cost})
|
||||
|
||||
try:
|
||||
_token = base64_token_json(cashu_token)
|
||||
amount = sum(p["amount"] for t in _token["token"] for p in t["proofs"])
|
||||
unit: CurrencyUnit = _token["unit"]
|
||||
|
||||
if unit == "sat":
|
||||
amount *= 1000
|
||||
|
||||
logger.info(
|
||||
"CashuA token parsed successfully",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"unit": _token["unit"],
|
||||
"amount_msats": amount,
|
||||
"required_cost_msats": cost,
|
||||
"sufficient_balance": amount >= cost,
|
||||
},
|
||||
)
|
||||
|
||||
if amount < cost:
|
||||
logger.warning(
|
||||
"Insufficient token balance",
|
||||
extra={
|
||||
"amount_msats": amount,
|
||||
"required_msats": cost,
|
||||
"shortfall_msats": cost - amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=413, detail="Insufficient balance")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to parse CashuA token",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"token_preview": cashu_token[:20] + "...",
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Invalid token format")
|
||||
|
||||
elif cashu_token.startswith("cashuB"):
|
||||
logger.debug("Processing CashuB token", extra={"required_cost_msats": cost})
|
||||
|
||||
try:
|
||||
_token = base64_token_cbor(cashu_token)
|
||||
amount = sum(p["a"] for t in _token["t"] for p in t["p"])
|
||||
unit = _token["u"]
|
||||
|
||||
if unit == "sat":
|
||||
amount *= 1000
|
||||
|
||||
logger.info(
|
||||
"CashuB token parsed successfully",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"amount_msats": amount,
|
||||
"required_cost_msats": cost,
|
||||
"sufficient_balance": amount >= cost,
|
||||
},
|
||||
)
|
||||
|
||||
if amount < cost:
|
||||
logger.warning(
|
||||
"Insufficient token balance",
|
||||
extra={
|
||||
"amount_msats": amount,
|
||||
"required_msats": cost,
|
||||
"shortfall_msats": cost - amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=413, detail="Insufficient balance")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to parse CashuB token",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"token_preview": cashu_token[:20] + "...",
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Invalid token format")
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
"Unknown token format",
|
||||
extra={"token_prefix": cashu_token[:10] if cashu_token else "empty"},
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
return unit
|
||||
|
||||
|
||||
def base64_token_json(cashu_token: str) -> dict:
|
||||
"""Decode a CashuA (JSON) token."""
|
||||
logger.debug("Decoding CashuA token", extra={"token_length": len(cashu_token)})
|
||||
|
||||
try:
|
||||
# Version 3 - JSON format
|
||||
encoded = cashu_token[6:] # Remove "cashuA"
|
||||
# Add correct padding – (-len) % 4 equals 0,1,2,3
|
||||
encoded += "=" * ((-len(encoded)) % 4)
|
||||
|
||||
decoded = base64.urlsafe_b64decode(encoded).decode()
|
||||
token_data = json.loads(decoded)
|
||||
|
||||
logger.debug(
|
||||
"CashuA token decoded successfully",
|
||||
extra={
|
||||
"token_proofs_count": sum(
|
||||
len(t.get("proofs", [])) for t in token_data.get("token", [])
|
||||
),
|
||||
"unit": token_data.get("unit", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return token_data
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to decode CashuA token",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def base64_token_cbor(cashu_token: str) -> dict:
|
||||
"""Decode a CashuB (CBOR) token."""
|
||||
logger.debug("Decoding CashuB token", extra={"token_length": len(cashu_token)})
|
||||
|
||||
try:
|
||||
encoded = cashu_token[6:] # Remove "cashuB"
|
||||
encoded += "=" * ((-len(encoded)) % 4)
|
||||
decoded_bytes = base64.urlsafe_b64decode(encoded)
|
||||
token_data = cbor2.loads(decoded_bytes)
|
||||
|
||||
logger.debug(
|
||||
"CashuB token decoded successfully",
|
||||
extra={
|
||||
"token_proofs_count": sum(
|
||||
len(t.get("p", [])) for t in token_data.get("t", [])
|
||||
),
|
||||
"unit": token_data.get("u", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return token_data
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to decode CashuB token",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def get_max_cost_for_model(model: str) -> int:
|
||||
"""Get the maximum cost for a specific model."""
|
||||
logger.debug(
|
||||
"Getting max cost for model",
|
||||
extra={
|
||||
"model": model,
|
||||
"model_based_pricing": MODEL_BASED_PRICING,
|
||||
"has_models": bool(MODELS),
|
||||
},
|
||||
)
|
||||
|
||||
if not MODEL_BASED_PRICING or not MODELS:
|
||||
logger.debug(
|
||||
"Using default cost (no model-based pricing)",
|
||||
extra={"cost_msats": COST_PER_REQUEST, "model": model},
|
||||
)
|
||||
return COST_PER_REQUEST
|
||||
|
||||
if model not in [model.id for model in MODELS]:
|
||||
logger.warning(
|
||||
"Model not found in available models",
|
||||
extra={
|
||||
"requested_model": model,
|
||||
"available_models": [m.id for m in MODELS],
|
||||
"using_default_cost": COST_PER_REQUEST,
|
||||
},
|
||||
)
|
||||
return COST_PER_REQUEST
|
||||
|
||||
for m in MODELS:
|
||||
if m.id == model:
|
||||
max_cost = m.sats_pricing.max_cost * 1000 # type: ignore
|
||||
logger.debug(
|
||||
"Found model-specific max cost",
|
||||
extra={"model": model, "max_cost_msats": max_cost},
|
||||
)
|
||||
return int(max_cost)
|
||||
|
||||
logger.warning(
|
||||
"Model pricing not found, using default",
|
||||
extra={"model": model, "default_cost_msats": COST_PER_REQUEST},
|
||||
)
|
||||
return COST_PER_REQUEST
|
||||
|
||||
|
||||
def create_error_response(error_type: str, message: str, status_code: int) -> Response:
|
||||
"""Create a standardized error response."""
|
||||
logger.info(
|
||||
"Creating error response",
|
||||
extra={
|
||||
"error_type": error_type,
|
||||
"error_message": message,
|
||||
"status_code": status_code,
|
||||
},
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": error_type,
|
||||
"code": status_code,
|
||||
}
|
||||
}
|
||||
),
|
||||
status_code=status_code,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
def prepare_upstream_headers(request_headers: dict) -> dict:
|
||||
"""Prepare headers for upstream request, removing sensitive/problematic ones."""
|
||||
logger.debug(
|
||||
"Preparing upstream headers",
|
||||
extra={
|
||||
"original_headers_count": len(request_headers),
|
||||
"has_upstream_api_key": bool(UPSTREAM_API_KEY),
|
||||
},
|
||||
)
|
||||
|
||||
headers = dict(request_headers)
|
||||
|
||||
# Remove headers that shouldn't be forwarded
|
||||
removed_headers = []
|
||||
for header in [
|
||||
"host",
|
||||
"content-length",
|
||||
"refund-lnurl",
|
||||
"key-expiry-time",
|
||||
"x-cashu",
|
||||
]:
|
||||
if headers.pop(header, None) is not None:
|
||||
removed_headers.append(header)
|
||||
|
||||
# Handle authorization
|
||||
if UPSTREAM_API_KEY:
|
||||
headers["Authorization"] = f"Bearer {UPSTREAM_API_KEY}"
|
||||
if headers.pop("authorization", None) is not None:
|
||||
removed_headers.append("authorization (replaced with upstream key)")
|
||||
else:
|
||||
for auth_header in ["Authorization", "authorization"]:
|
||||
if headers.pop(auth_header, None) is not None:
|
||||
removed_headers.append(auth_header)
|
||||
|
||||
logger.debug(
|
||||
"Headers prepared for upstream",
|
||||
extra={
|
||||
"final_headers_count": len(headers),
|
||||
"removed_headers": removed_headers,
|
||||
"added_upstream_auth": bool(UPSTREAM_API_KEY),
|
||||
},
|
||||
)
|
||||
|
||||
return headers
|
||||
@@ -1,617 +0,0 @@
|
||||
import json
|
||||
import traceback
|
||||
from typing import AsyncGenerator, Literal, cast
|
||||
|
||||
import httpx
|
||||
from fastapi import BackgroundTasks, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from sixty_nuts.types import CurrencyUnit
|
||||
|
||||
from router.cashu import wallet
|
||||
from router.logging.logging_config import get_logger
|
||||
from router.payment.cost_caculation import (
|
||||
CostData,
|
||||
CostDataError,
|
||||
MaxCostData,
|
||||
calculate_cost,
|
||||
)
|
||||
from router.payment.helpers import (
|
||||
UPSTREAM_BASE_URL,
|
||||
create_error_response,
|
||||
get_max_cost_for_model,
|
||||
prepare_upstream_headers,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def x_cashu_handler(
|
||||
request: Request, x_cashu_token: str, path: str
|
||||
) -> Response | StreamingResponse:
|
||||
"""Handle X-Cashu token payment requests."""
|
||||
logger.info(
|
||||
"Processing X-Cashu payment request",
|
||||
extra={
|
||||
"path": path,
|
||||
"method": request.method,
|
||||
"token_preview": x_cashu_token[:20] + "..."
|
||||
if len(x_cashu_token) > 20
|
||||
else x_cashu_token,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
headers = dict(request.headers)
|
||||
amount, unit = await redeem_token(x_cashu_token)
|
||||
headers = prepare_upstream_headers(dict(request.headers))
|
||||
|
||||
logger.info(
|
||||
"X-Cashu token redeemed successfully",
|
||||
extra={"amount": amount, "unit": unit, "path": path},
|
||||
)
|
||||
|
||||
return await forward_to_upstream(request, path, headers, amount, unit)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"X-Cashu payment request failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"path": path,
|
||||
"method": request.method,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def forward_to_upstream(
|
||||
request: Request, path: str, headers: dict, amount: int, unit: CurrencyUnit
|
||||
) -> Response | StreamingResponse:
|
||||
"""Forward request to upstream and handle the response."""
|
||||
if path.startswith("v1/"):
|
||||
path = path.replace("v1/", "")
|
||||
|
||||
url = f"{UPSTREAM_BASE_URL}/{path}"
|
||||
|
||||
logger.debug(
|
||||
"Forwarding request to upstream",
|
||||
extra={
|
||||
"url": url,
|
||||
"method": request.method,
|
||||
"path": path,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.AsyncHTTPTransport(retries=1),
|
||||
timeout=None,
|
||||
) as client:
|
||||
try:
|
||||
response = await client.send(
|
||||
client.build_request(
|
||||
request.method,
|
||||
url,
|
||||
headers=headers,
|
||||
content=request.stream(),
|
||||
params=request.query_params,
|
||||
),
|
||||
stream=True,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Received upstream response",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"path": path,
|
||||
"response_headers": dict(response.headers),
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"Upstream request failed, processing refund",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"path": path,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
|
||||
refund_token = await send_refund(amount - 60, unit)
|
||||
|
||||
logger.info(
|
||||
"Refund processed for failed upstream request",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"refund_amount": amount,
|
||||
"unit": unit,
|
||||
"refund_token_preview": refund_token[:20] + "..."
|
||||
if len(refund_token) > 20
|
||||
else refund_token,
|
||||
},
|
||||
)
|
||||
|
||||
error_response = Response(
|
||||
content=json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"message": "Error forwarding request to upstream",
|
||||
"type": "upstream_error",
|
||||
"code": response.status_code,
|
||||
"refund_token": refund_token,
|
||||
}
|
||||
}
|
||||
),
|
||||
status_code=response.status_code,
|
||||
media_type="application/json",
|
||||
)
|
||||
error_response.headers["X-Cashu"] = refund_token
|
||||
return error_response
|
||||
|
||||
if path.endswith("chat/completions"):
|
||||
logger.debug(
|
||||
"Processing chat completion response",
|
||||
extra={"path": path, "amount": amount, "unit": unit},
|
||||
)
|
||||
|
||||
result = await handle_x_cashu_chat_completion(response, amount, unit)
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
result.background = background_tasks
|
||||
return result
|
||||
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
background_tasks.add_task(client.aclose)
|
||||
|
||||
logger.debug(
|
||||
"Streaming non-chat response",
|
||||
extra={"path": path, "status_code": response.status_code},
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
response.aiter_bytes(),
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
background=background_tasks,
|
||||
)
|
||||
except Exception as exc:
|
||||
tb = traceback.format_exc()
|
||||
logger.error(
|
||||
"Unexpected error in upstream forwarding",
|
||||
extra={
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"method": request.method,
|
||||
"url": url,
|
||||
"path": path,
|
||||
"query_params": dict(request.query_params),
|
||||
"traceback": tb,
|
||||
},
|
||||
)
|
||||
return create_error_response(
|
||||
"internal_error", "An unexpected server error occurred", 500
|
||||
)
|
||||
|
||||
|
||||
async def handle_x_cashu_chat_completion(
|
||||
response: httpx.Response, amount: int, unit: CurrencyUnit
|
||||
) -> StreamingResponse | Response:
|
||||
"""Handle both streaming and non-streaming chat completion responses with token-based pricing."""
|
||||
logger.debug(
|
||||
"Handling chat completion response",
|
||||
extra={"amount": amount, "unit": unit, "status_code": response.status_code},
|
||||
)
|
||||
|
||||
try:
|
||||
content = await response.aread()
|
||||
content_str = content.decode("utf-8") if isinstance(content, bytes) else content
|
||||
is_streaming = content_str.startswith("data:") or "data:" in content_str
|
||||
|
||||
logger.debug(
|
||||
"Chat completion response analysis",
|
||||
extra={
|
||||
"is_streaming": is_streaming,
|
||||
"content_length": len(content_str),
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
|
||||
if is_streaming:
|
||||
return await handle_streaming_response(content_str, response, amount, unit)
|
||||
else:
|
||||
return await handle_non_streaming_response(
|
||||
content_str, response, amount, unit
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error processing chat completion response",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
# Return the original response if we can't process it
|
||||
return StreamingResponse(
|
||||
response.aiter_bytes(),
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
)
|
||||
|
||||
|
||||
async def handle_streaming_response(
|
||||
content_str: str, response: httpx.Response, amount: int, unit: CurrencyUnit
|
||||
) -> StreamingResponse:
|
||||
"""Handle Server-Sent Events (SSE) streaming response."""
|
||||
logger.debug(
|
||||
"Processing streaming response",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"content_lines": len(content_str.strip().split("\n")),
|
||||
},
|
||||
)
|
||||
|
||||
# Initialize response headers early so they can be modified during processing
|
||||
response_headers = dict(response.headers)
|
||||
if "transfer-encoding" in response_headers:
|
||||
del response_headers["transfer-encoding"]
|
||||
if "content-encoding" in response_headers:
|
||||
del response_headers["content-encoding"]
|
||||
|
||||
# For streaming responses, we'll extract the final usage data
|
||||
# and calculate cost based on that
|
||||
usage_data = None
|
||||
model = None
|
||||
|
||||
# Parse SSE format to extract usage information
|
||||
lines = content_str.strip().split("\n")
|
||||
for line in lines:
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data_json = json.loads(line[6:]) # Remove 'data: ' prefix
|
||||
# Look for usage information in the final chunks
|
||||
if "usage" in data_json:
|
||||
usage_data = data_json["usage"]
|
||||
model = data_json.get("model")
|
||||
elif "model" in data_json and not model:
|
||||
model = data_json["model"]
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
response_headers = dict(response.headers)
|
||||
# If we found usage data, calculate cost and refund
|
||||
if usage_data and model:
|
||||
logger.debug(
|
||||
"Found usage data in streaming response",
|
||||
extra={
|
||||
"model": model,
|
||||
"usage_data": usage_data,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
|
||||
response_data = {"usage": usage_data, "model": model}
|
||||
try:
|
||||
cost_data = await get_cost(response_data)
|
||||
if cost_data:
|
||||
refund_amount = amount - cost_data.total_msats
|
||||
if refund_amount > 0:
|
||||
logger.info(
|
||||
"Processing refund for streaming response",
|
||||
extra={
|
||||
"original_amount": amount,
|
||||
"cost_msats": cost_data.total_msats,
|
||||
"refund_amount": refund_amount,
|
||||
"unit": unit,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
refund_token = await send_refund(refund_amount - 60, unit)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
|
||||
logger.info(
|
||||
"Refund processed for streaming response",
|
||||
extra={
|
||||
"refund_amount": refund_amount,
|
||||
"unit": unit,
|
||||
"refund_token_preview": refund_token[:20] + "..."
|
||||
if len(refund_token) > 20
|
||||
else refund_token,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"No refund needed for streaming response",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"cost_msats": cost_data.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error calculating cost for streaming response",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"model": model,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
|
||||
async def generate() -> AsyncGenerator[bytes, None]:
|
||||
for line in lines:
|
||||
yield (line + "\n").encode("utf-8")
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
media_type="text/plain",
|
||||
)
|
||||
|
||||
|
||||
async def handle_non_streaming_response(
|
||||
content_str: str, response: httpx.Response, amount: int, unit: CurrencyUnit
|
||||
) -> Response:
|
||||
"""Handle regular JSON response."""
|
||||
logger.debug(
|
||||
"Processing non-streaming response",
|
||||
extra={"amount": amount, "unit": unit, "content_length": len(content_str)},
|
||||
)
|
||||
|
||||
try:
|
||||
response_json = json.loads(content_str)
|
||||
|
||||
cost_data = await get_cost(response_json)
|
||||
|
||||
if not cost_data:
|
||||
logger.error(
|
||||
"Failed to calculate cost for response",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"response_model": response_json.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return Response(
|
||||
content=json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"message": "Error forwarding request to upstream",
|
||||
"type": "upstream_error",
|
||||
"code": response.status_code,
|
||||
}
|
||||
}
|
||||
),
|
||||
status_code=response.status_code,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
response_headers = dict(response.headers)
|
||||
if "transfer-encoding" in response_headers:
|
||||
del response_headers["transfer-encoding"]
|
||||
if "content-encoding" in response_headers:
|
||||
del response_headers["content-encoding"]
|
||||
|
||||
refund_amount = amount - cost_data.total_msats
|
||||
|
||||
logger.info(
|
||||
"Processing non-streaming response cost calculation",
|
||||
extra={
|
||||
"original_amount": amount,
|
||||
"cost_msats": cost_data.total_msats,
|
||||
"refund_amount": refund_amount,
|
||||
"unit": unit,
|
||||
"model": response_json.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
if refund_amount > 0:
|
||||
refund_token = await send_refund(refund_amount - 60, unit)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
|
||||
logger.info(
|
||||
"Refund processed for non-streaming response",
|
||||
extra={
|
||||
"refund_amount": refund_amount,
|
||||
"unit": unit,
|
||||
"refund_token_preview": refund_token[:20] + "..."
|
||||
if len(refund_token) > 20
|
||||
else refund_token,
|
||||
},
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=content_str,
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
media_type="application/json",
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(
|
||||
"Failed to parse JSON from upstream response",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"content_preview": content_str[:200] + "..."
|
||||
if len(content_str) > 200
|
||||
else content_str,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
|
||||
# Emergency refund with small deduction for processing
|
||||
emergency_refund = amount - 60
|
||||
refund_token = await wallet().send(emergency_refund)
|
||||
response.headers["X-Cashu"] = refund_token
|
||||
|
||||
logger.warning(
|
||||
"Emergency refund issued due to JSON parse error",
|
||||
extra={
|
||||
"original_amount": amount,
|
||||
"refund_amount": emergency_refund,
|
||||
"deduction": 60,
|
||||
},
|
||||
)
|
||||
|
||||
# Return original content if JSON parsing fails
|
||||
return Response(
|
||||
content=content_str,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
async def get_cost(response_data: dict) -> MaxCostData | CostData | None:
|
||||
"""
|
||||
Adjusts the payment based on token usage in the response.
|
||||
This is called after the initial payment and the upstream request is complete.
|
||||
Returns cost data to be included in the response.
|
||||
"""
|
||||
model = response_data.get("model", "unknown")
|
||||
logger.debug(
|
||||
"Calculating cost for response",
|
||||
extra={"model": model, "has_usage": "usage" in response_data},
|
||||
)
|
||||
|
||||
max_cost = get_max_cost_for_model(model=model)
|
||||
|
||||
match calculate_cost(response_data, max_cost):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost pricing",
|
||||
extra={"model": model, "max_cost_msats": cost.total_msats},
|
||||
)
|
||||
return cost
|
||||
case CostData() as cost:
|
||||
logger.debug(
|
||||
"Using token-based pricing",
|
||||
extra={
|
||||
"model": model,
|
||||
"total_cost_msats": cost.total_msats,
|
||||
"input_msats": cost.input_msats,
|
||||
"output_msats": cost.output_msats,
|
||||
},
|
||||
)
|
||||
return cost
|
||||
case CostDataError() as error:
|
||||
logger.error(
|
||||
"Cost calculation error",
|
||||
extra={
|
||||
"model": model,
|
||||
"error_message": error.message,
|
||||
"error_code": error.code,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"message": error.message,
|
||||
"type": "invalid_request_error",
|
||||
"code": error.code,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def redeem_token(x_cashu_token: str) -> tuple[int, Literal["sat", "msat"]]:
|
||||
"""Redeem X-Cashu token and return amount and unit."""
|
||||
logger.debug(
|
||||
"Redeeming X-Cashu token",
|
||||
extra={
|
||||
"token_preview": x_cashu_token[:20] + "..."
|
||||
if len(x_cashu_token) > 20
|
||||
else x_cashu_token
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
result = await wallet().redeem(x_cashu_token)
|
||||
amount, unit = cast(tuple[int, Literal["sat", "msat"]], result)
|
||||
|
||||
logger.info(
|
||||
"X-Cashu token redeemed successfully",
|
||||
extra={"amount": amount, "unit": unit},
|
||||
)
|
||||
|
||||
return amount, unit
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"X-Cashu token redemption failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"token_preview": x_cashu_token[:20] + "..."
|
||||
if len(x_cashu_token) > 20
|
||||
else x_cashu_token,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Invalid or expired Cashu key: {str(e)}",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def send_refund(amount: int, unit: CurrencyUnit, mint: str | None = None) -> str:
|
||||
"""Send a refund using Cashu tokens."""
|
||||
logger.debug(
|
||||
"Creating refund token", extra={"amount": amount, "unit": unit, "mint": mint}
|
||||
)
|
||||
|
||||
try:
|
||||
refund_token = await wallet().send(amount, unit=unit, mint_url=mint)
|
||||
|
||||
logger.info(
|
||||
"Refund token created successfully",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"mint": mint,
|
||||
"token_preview": refund_token[:20] + "..."
|
||||
if len(refund_token) > 20
|
||||
else refund_token,
|
||||
},
|
||||
)
|
||||
|
||||
return refund_token
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to create refund token",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"mint": mint,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"failed to create refund: {str(e)}",
|
||||
"type": "invalid_request_error",
|
||||
"code": "send_token_failed",
|
||||
}
|
||||
},
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user