Compare commits

...

809 Commits

Author SHA1 Message Date
Shroominic
1b7e0b7d22 temp undo other refactor (until merged) 2026-01-31 07:41:01 +08:00
Shroominic
1c5e709b0a refactor: move tasks and ui from main.py to seperate files 2026-01-31 07:06:48 +08:00
Shroominic
c6e401c3f6 Merge branch 'main' into v0.3.0
# Conflicts:
#	routstr/proxy.py
2026-01-30 10:54:35 +08:00
shroominic
1b3b206a20 Merge pull request #336 from Routstr/prevent-payout-race-contition
prevent payout race condition
2026-01-30 10:49:44 +08:00
shroominic
b9b477e5eb Merge pull request #335 from Routstr/fix-recurring-payout-error
Fix recurring payout error
2026-01-30 10:06:37 +08:00
Shroominic
ace8cf960c prevent payout race condition 2026-01-30 10:05:37 +08:00
Shroominic
3396e0cd47 fix recurring payout error 2026-01-30 09:59:59 +08:00
shroominic
855d60b4a5 Merge pull request #330 from Routstr/fix-provider-model-pricing
fix provider model pricing
2026-01-29 10:58:27 +08:00
shroominic
27ace348b5 Merge pull request #329 from Routstr/fix-multiple-custom-models
fix: unable to add multiple custom models
2026-01-29 10:58:14 +08:00
Shroominic
73e3d34623 fix provider model pricing 2026-01-29 08:50:17 +08:00
Shroominic
c8f8857f03 fckng prettier again 2026-01-28 18:12:54 +08:00
Shroominic
c9650441bb fix build error 2026-01-28 18:11:07 +08:00
Shroominic
5ce9c2217f fix fmt 2026-01-28 18:04:56 +08:00
Shroominic
89b8488ab8 prettier 2026-01-28 17:58:24 +08:00
Shroominic
2ee917fa31 fix not being able to add multiple custom models when provider does not have Provided Models 2026-01-28 17:55:06 +08:00
9qeklajc
5e12a7e92d Merge pull request #327 from Routstr/fix/multi-provider-base-url
Allow multiple providers per base URL
2026-01-27 09:24:20 +01:00
9qeklajc
1d043cd98d lint 2026-01-27 09:22:07 +01:00
9qeklajc
39e0959fcd Merge pull request #326 from Routstr/feat/preset-selector-override-modal
Add preset selector to model override modal
2026-01-27 09:19:56 +01:00
9qeklajc
29be9d5b9c Merge pull request #323 from Routstr/create-child-key-ui
create child keys within the dashboard
2026-01-27 09:07:05 +01:00
9qeklajc
1ebb7d71e1 fix test 2026-01-27 08:58:46 +01:00
9qeklajc
bbf1e65a5d clean up & add docs 2026-01-26 20:44:23 +01:00
Shroominic
51c3e5dcd7 fix migrations 2026-01-25 22:58:36 +08:00
Shroominic
788075f656 fix db migration 2026-01-25 22:44:13 +08:00
Shroominic
0bbcacd186 fix: allow multiple provider keys per base url
Use a composite unique constraint and query filters so providers
can share base URLs with distinct API keys.
2026-01-25 22:18:39 +08:00
Shroominic
6d5b811c20 feat(ui): add preset selector to model override modal
Add the same preset selector that exists in the custom model creation
modal to the model override modal. This allows users to apply pricing
and settings from OpenRouter presets when creating overrides.

- Show preset selector for override mode (not just create mode)
- Preserve original model ID when applying preset in override mode
- Add contextual help text for override vs create mode

Closes #324
2026-01-25 22:04:51 +08:00
9qeklajc
42258ae39c fmt 2026-01-25 14:38:52 +01:00
9qeklajc
04d6903369 lint & fmt 2026-01-25 14:31:55 +01:00
9qeklajc
b0b2ceb1a0 create child keys within the dashboard 2026-01-25 12:24:02 +01:00
shroominic
bf91f401af Merge pull request #322 from Routstr/tiny-docs-fixes
tiny docs fixes
2026-01-24 21:17:57 +08:00
Shroominic
60e0eebd05 tiny docs fixes 2026-01-24 21:16:58 +08:00
shroominic
8fff716bb2 Merge pull request #321 from Routstr/fix-readme
fix readme
2026-01-24 18:14:45 +08:00
Shroominic
b2ef15a406 fix readme 2026-01-24 18:12:32 +08:00
shroominic
f8fdcf3bf3 Merge pull request #320 from Routstr/improved-docs
Improved docs
2026-01-24 18:10:41 +08:00
Shroominic
58c4d3bf5f improved readme 2026-01-24 18:08:45 +08:00
Shroominic
2210e9be6c rm old docs 2026-01-24 17:55:50 +08:00
Shroominic
9c66789665 improved docs 2026-01-24 17:55:45 +08:00
shroominic
9dfa58d69f Merge pull request #319 from Routstr/opencode-integ
Opencode integ
2026-01-24 14:40:56 +08:00
9qeklajc
22ec9c3132 fmt 2026-01-23 23:30:42 +01:00
9qeklajc
b72c578954 Merge branch 'v0.3.0' into opencode-integ 2026-01-23 21:44:16 +01:00
shroominic
7e299180fe Merge pull request #296 from Routstr/provider-fallback
Multi-Provider Fallback on UpstreamError
2026-01-23 09:50:04 +08:00
Shroominic
f290534df4 bump v0.3.0 2026-01-23 09:49:49 +08:00
Shroominic
8d3c064b29 ruff fix 2026-01-23 09:47:48 +08:00
shroominic
4ac96ade5f Merge branch 'v0.3.0' into provider-fallback 2026-01-23 09:45:14 +08:00
shroominic
85aa8fbbc5 Merge pull request #315 from Routstr/314-fix-timeout-error
make sure to refund
2026-01-23 09:42:59 +08:00
shroominic
180a469399 Merge pull request #304 from Routstr/introduce-child-key
Introduce child key
2026-01-23 09:35:53 +08:00
9qeklajc
701b870d63 make sure to refund 2026-01-22 13:44:04 +01:00
shroominic
87fbb48ca8 routstr v0.2.2 - fixfix
v0.2.2 - release summary
--------------------------
#292 - Fix not enough inputs to melt
#295 - Update UI dependencies
#291 - Fix reserved balance
#289 - Better filtering options (#282)
#284 - Do not charge for empty content (#274)
#298 - Fix Provider Balance display in dashboard
#299 - fix refunds not accounting reserved balance
#300 - reset reserved balance on startup (optional)
#303 - ignore disabled provider
#301 - optimize price fetching
2026-01-22 13:03:36 +01:00
shroominic
3465a44d0e routstr v0.2.2 - fixfix
v0.2.2 - release summary
--------------------------
#292 - Fix not enough inputs to melt
#295 - Update UI dependencies
#291 - Fix reserved balance
#289 - Better filtering options (#282)
#284 - Do not charge for empty content (#274)
#298 - Fix Provider Balance display in dashboard
#299 - fix refunds not accounting reserved balance
#300 - reset reserved balance on startup (optional)
#303 - ignore disabled provider
#301 - optimize price fetching
2026-01-15 11:21:22 +08:00
shroominic
a4259af38f Merge pull request #301 from Routstr/optimize-price-fetching
optimize price fetching
2026-01-15 11:13:14 +08:00
shroominic
0d07dd0cdb Merge pull request #303 from Routstr/ignore-disabled-provider
ignore disabled provider
2026-01-15 11:12:16 +08:00
Shroominic
24015ebec1 Revert "Merge remote-tracking branch 'origin/mock-upstream-with-testnut-mint' into v0.2.2"
This reverts commit 5a4ba60072, reversing
changes made to eed5bc5b04.
2026-01-13 06:31:24 +08:00
9qeklajc
db021866d8 fmt u 2026-01-10 21:42:21 +01:00
9qeklajc
d4339287be chore: add type annotations to example and test files 2026-01-10 21:35:16 +01:00
9qeklajc
88bcc0edcb fmt 2026-01-10 21:34:48 +01:00
9qeklajc
917a4d32b1 ui: visualize parent-child relationship in balances page 2026-01-10 20:10:59 +01:00
9qeklajc
daf17f51ab fix: move child-key route before catch-all and fix indentation 2026-01-10 20:10:31 +01:00
9qeklajc
fc042c768c support child keys mapped to parent balance 2026-01-10 19:53:27 +01:00
9qeklajc
3bc38937e8 ignore disabled provider 2026-01-10 18:39:24 +01:00
Shroominic
9229b87b70 optimize price fetching 2026-01-10 17:37:05 +08:00
shroominic
367265b9fe Merge pull request #300 from Routstr/reset-reserved-balance-on-startup
Reset reserved balance on startup
2026-01-10 08:50:32 +08:00
shroominic
0b3ccb5fb0 Merge pull request #299 from Routstr/urgent-reserved-balance-fix
Reserved balance fix
2026-01-10 08:50:20 +08:00
shroominic
dbd43f52fb Merge pull request #298 from Routstr/fix-provider-balance
Fix provider balance
2026-01-10 08:49:23 +08:00
Shroominic
39657ed64f reset reserved balance on startup 2026-01-09 17:36:04 +08:00
Shroominic
493b4f0f1f urgent reserved balance fix 2026-01-09 17:32:35 +08:00
Shroominic
cb36189db3 fmt 2026-01-09 17:04:09 +08:00
Shroominic
d2487f42b0 fix tests 2026-01-09 17:02:35 +08:00
Shroominic
5255fce7b2 todo comment 2026-01-09 16:55:24 +08:00
Shroominic
ca7e8bec71 fmt 2026-01-09 16:53:25 +08:00
Shroominic
c4cc09d61e fix provider balance not displaying when 0 2026-01-09 16:50:10 +08:00
Shroominic
ee668ee93b handle specific errors eg deactivate ppq on insufficient balance 2026-01-09 16:49:03 +08:00
Shroominic
cf8b990fc7 fix specify error codes retry 2026-01-09 16:10:14 +08:00
Shroominic
78dd74845b fix merge 2026-01-07 17:44:28 +01:00
Shroominic
e7f4c98475 ranked provider fallback on upstream errors 2026-01-07 17:39:05 +01:00
9qeklajc
fad792068e Merge pull request #287 from Routstr/provider-discovery-default
Disable provider discovery by default
2026-01-06 19:39:09 +01:00
Shroominic
21d363f6aa bump v0.2.2 2026-01-06 19:26:10 +01:00
shroominic
5e21f6ccbc Merge pull request #292 from Routstr/fix-not-enough-inputs-to-melt
Fix not enough inputs to melt
2026-01-06 19:24:33 +01:00
shroominic
1e2d130022 Merge pull request #285 from Routstr/admin-info
Add admin password warning
2026-01-06 19:14:53 +01:00
Shroominic
1e21dce735 change to warning log 2026-01-06 18:23:01 +01:00
shroominic
b7603dcf69 Merge pull request #295 from Routstr/update-ui-deps
update vuln deps
2026-01-06 17:38:13 +01:00
9qeklajc
7dccfa745f fix test 2026-01-06 12:02:27 +01:00
9qeklajc
54d5118980 fmt 2026-01-06 11:50:12 +01:00
9qeklajc
7723ab4a95 update build script 2026-01-06 11:48:53 +01:00
9qeklajc
86c022d8db update vuln deps 2026-01-06 11:46:12 +01:00
9qeklajc
21ae22abec Merge pull request #293 from Routstr/rm-performance-tests
test: Remove flaky performance requirement tests
2026-01-05 22:00:46 +01:00
9qeklajc
3a939d0dd1 Merge pull request #291 from Routstr/fix-reserved-balance
Fix reserved balance
2026-01-05 21:59:46 +01:00
Shroominic
50eabafa57 remove performance tests due to unpredictable behaviour 2026-01-05 12:12:04 +01:00
Shroominic
d192a6a6b4 fix not enough inputs to melt bug 2026-01-05 00:34:58 +01:00
Shroominic
7d829af681 fix types 2026-01-05 00:06:53 +01:00
Shroominic
57bf1b68d9 bump delay to make sure its not taking more time to reset 2026-01-05 00:04:58 +01:00
Shroominic
00d0415518 lint pls 2026-01-04 23:40:17 +01:00
Shroominic
e8585b276f fix typing 2026-01-04 23:39:55 +01:00
Shroominic
4b5e911435 rm not needed generic 2026-01-04 23:39:14 +01:00
Shroominic
761aabfec3 fix linting 2026-01-04 23:38:14 +01:00
Shroominic
f0c45a7ce4 fix other potential reserved balance problems 2026-01-04 23:35:26 +01:00
Shroominic
fc8ccf63ba finalize_without_usage when client disconnects 2026-01-04 23:34:29 +01:00
9qeklajc
eeb70e4ee5 Merge pull request #289 from Routstr/282-better-filltering
#282 more filter options
2026-01-03 23:27:22 +01:00
9qeklajc
a3b410b467 Merge pull request #284 from Routstr/274-do-not-charge-when-empty-content
#274 do not charge user for empty response by upstream
2026-01-03 23:26:54 +01:00
Shroominic
9e9bc5bff8 fix pytests 2026-01-03 22:49:46 +01:00
9qeklajc
bdf0e2c192 #282 more filter options 2026-01-03 22:49:35 +01:00
Shroominic
6d780ef96d feat: disable provider discovery by default 2026-01-03 22:12:55 +01:00
Shroominic
b70b94b9b4 feat: add admin password warning log 2026-01-03 22:12:23 +01:00
9qeklajc
334453f934 #274 do not change user for empty response by upstream 2026-01-03 21:27:19 +01:00
Shroominic
5a4ba60072 Merge remote-tracking branch 'origin/mock-upstream-with-testnut-mint' into v0.2.2 2025-12-28 11:18:08 +01:00
shroominic
f1fa7d094f routstr/v0.2.1
v0.2.1
2025-12-27 22:08:51 +01:00
shroominic
eed5bc5b04 Merge pull request #278 from Routstr/openai-responses-api
OpenAI responses api
2025-12-27 22:02:51 +01:00
Shroominic
f4b014cb05 Merge remote-tracking branch 'origin/examples' into openai-responses-api 2025-12-27 21:39:19 +01:00
9qeklajc
4418d87664 Merge branch 'v0.2.1' into openai-responses-api 2025-12-26 22:37:07 +01:00
shroominic
634a473f50 Merge pull request #276 from Routstr/force-sats-pricing
Force sats pricing
2025-12-26 12:36:02 +01:00
Shroominic
ea655b748b optimize startup time 2025-12-26 12:33:42 +01:00
9qeklajc
2d247ddc8b fmt 2025-12-26 10:29:51 +01:00
9qeklajc
c064452aea Merge branch 'v0.2.1' into force-sats-pricing 2025-12-26 10:27:11 +01:00
9qeklajc
1c6a603042 Merge pull request #271 from Routstr/embedding-with-aliases
Embedding with aliases
2025-12-26 10:26:38 +01:00
9qeklajc
84b0007b05 force sats pricig 2025-12-26 09:55:15 +01:00
Shroominic
525476ccfa fix alias not displaying correctly 2025-12-25 22:34:52 +01:00
Shroominic
b54812cb04 fix: alias_id not displayed correctly by dashboard 2025-12-25 22:28:26 +01:00
Shroominic
ee508cbb3a rm model cleanup 2025-12-25 21:50:09 +01:00
Shroominic
0c61fdee07 ruff format 2025-12-25 21:48:20 +01:00
Shroominic
b9418db31f rm model cleanup task 2025-12-25 21:47:17 +01:00
Shroominic
71e7c2171b add missing migration 2025-12-25 19:36:26 +01:00
Shroominic
a3e8d5fd38 ignore cloudflare headers from logs 2025-12-24 15:09:03 +01:00
Shroominic
41fd2e2dfc fix typing 2025-12-24 12:12:32 +01:00
Shroominic
2c404c66d6 ruff fix 2025-12-24 12:10:51 +01:00
Shroominic
0fa3e77f9a more examples for devs and testing 2025-12-24 12:09:50 +01:00
9qeklajc
5b8e56f590 Merge branch 'v0.2.1' into embedding-with-aliases
# Conflicts:
#	routstr/upstream/base.py
2025-12-23 23:43:46 +01:00
9qeklajc
a6d0bd1a19 update aliases 2025-12-23 23:43:16 +01:00
shroominic
9594e9fb52 Merge pull request #251 from Routstr/embeddings-integration
Embeddings integration
2025-12-23 20:53:26 +01:00
9qeklajc
b01c7b2e56 Merge branch 'v0.2.1' into embeddings 2025-12-22 21:04:28 +01:00
Shroominic
dd4ed7541f undo 43e9732 2025-12-22 11:31:19 +01:00
Shroominic
cc42534a97 manual alias due to openrouter api bug 2025-12-22 11:30:51 +01:00
shroominic
d203370f01 Merge pull request #265 from Routstr/update-cost-calculation
use cost field if available
2025-12-22 10:20:35 +01:00
Shroominic
e39742c429 rm completion_image pricing with manual overrides 2025-12-22 09:35:23 +01:00
Shroominic
301dd81215 include openrouters upstream_inference_cost + manually add gemini image completion cost 2025-12-21 09:55:25 +01:00
9qeklajc
5416cefd87 use cost field if available 2025-12-20 16:16:23 +01:00
Shroominic
d41c214d9e fix typing 2025-12-20 14:02:56 +01:00
Shroominic
ec0fcfb48b mock-upstream-with-testnut-mint 2025-12-20 13:54:14 +01:00
shroominic
8edc3512c1 Merge pull request #258 from Routstr/remove-flaky-wallet-tests
Remove flaky wallet tests
2025-12-19 10:54:26 +01:00
9qeklajc
82d2627c60 added reponse api 2025-12-15 21:11:43 +01:00
9qeklajc
43e97326e0 fix model naming issue in response 2025-12-12 20:14:43 +01:00
9qeklajc
06770a0702 Merge branch 'v0.2.1' into embeddings-integration 2025-12-12 19:44:45 +01:00
Shroominic
590fb4bc2c ruff fix 2025-12-11 14:13:41 +08:00
Shroominic
5db9abc3ce remove flaky wallet tests 2025-12-11 14:12:34 +08:00
shroominic
c11cc107c8 Merge pull request #248 from Routstr/feature/dynamic-settings
feat(ui): make admin settings dynamic based on backend response
2025-12-11 14:04:49 +08:00
Shroominic
547365894d add simple test 2025-12-11 13:58:57 +08:00
shroominic
19b5f2889a Merge pull request #249 from Routstr/refactor-remove-unused-functions
refactor: rm unused functions
2025-12-11 13:30:59 +08:00
Shroominic
52601f89bd ruff fix 2025-12-11 13:28:28 +08:00
shroominic
72b281b815 Merge branch 'v0.2.1' into refactor-remove-unused-functions 2025-12-11 13:24:59 +08:00
Shroominic
c0176a5274 Merge branch 'v0.2.1' into embeddings-integration 2025-12-11 13:06:20 +08:00
Shroominic
329d22363f Merge branch 'v0.2.1' into embeddings-integration 2025-12-11 12:59:41 +08:00
shroominic
87b1443c23 Merge pull request #253 from Routstr/lightning
create and topup token with lightning
2025-12-11 12:58:36 +08:00
Shroominic
29129f8953 prettier 2025-12-11 12:56:29 +08:00
Shroominic
c97c74a2ee cleanup logs 2025-12-11 12:55:01 +08:00
Shroominic
1e37c42ea0 fix ui build 2025-12-11 12:51:27 +08:00
Shroominic
4c7887fa4e update algorithm logs 2025-12-11 11:04:00 +08:00
Cursor Agent
2cc5063dee Merge branch 'origin/v0.2.1' into lightning to resolve conflicts 2025-12-11 02:59:20 +00:00
shroominic
e4eda59e6a Merge pull request #256 from Routstr/improve-console-logging
Improve console logging
2025-12-11 10:54:05 +08:00
shroominic
7f918eab6a Merge pull request #252 from Routstr/model-fetch-issue
open router free model breaks openrouter model fetching
2025-12-11 10:52:28 +08:00
Shroominic
b8c34afef8 improve logging 2025-12-11 10:50:50 +08:00
Shroominic
5738b1bd99 rm hardcoded excluded models 2025-12-11 10:31:51 +08:00
shroominic
2aee75e7d5 Merge pull request #255 from Routstr/rm-shaky
Rm shaky test
2025-12-11 10:12:33 +08:00
Shroominic
4d67af51ab rm shaky test 2025-12-11 10:08:45 +08:00
shroominic
2f841dfcbc Merge pull request #254 from Routstr/format
make format required & fix ui formating
2025-12-11 10:01:38 +08:00
9qeklajc
7ca69063bd make format required & fix ui formating 2025-12-10 22:51:12 +01:00
9qeklajc
085ff75d1d create and topup token with lightning 2025-12-10 22:47:37 +01:00
Shroominic
21340b2de1 check for valid_pricing 2025-12-10 09:47:13 +08:00
Shroominic
a150d38df7 Merge branch 'v0.2.1' into model-fetch-issue 2025-12-10 09:27:31 +08:00
Shroominic
02ca36141e fix linting typing/errors 2025-12-09 16:12:41 +08:00
Shroominic
20bf5e35a4 feat: add custom models and overrides 2025-12-09 15:59:07 +08:00
Shroominic
5a67e6b4d6 ignore openrouter/bodybuilder 2025-12-09 15:39:04 +08:00
Shroominic
34d1e4b041 populate upstream_provider_id 2025-12-09 15:03:16 +08:00
Shroominic
b88ef858ee ignore ppq auto model 2025-12-09 14:38:12 +08:00
Shroominic
a363c7a0b1 feat: openrouter balance fetch + generic get_balance 2025-12-09 14:28:07 +08:00
Shroominic
4ac257db0d fix model fetching issue 2025-12-09 13:50:49 +08:00
Shroominic
fd5ec01a99 fix ppq models matching incorrectly 2025-12-09 13:50:32 +08:00
9qeklajc
bc8c08c468 open router free model breaks openrouter model fetching 2025-12-05 22:48:38 +01:00
9qeklajc
d6648d3337 Merge pull request #239 from Routstr/gemini-upstream
gemini upstream
2025-12-03 22:29:09 +01:00
9qeklajc
9438bc957f clean up 2025-12-03 22:24:43 +01:00
9qeklajc
5d2219880d embedding integration 2025-12-03 22:23:03 +01:00
9qeklajc
195da0c9da embedding integration 2025-12-03 22:22:57 +01:00
9qeklajc
8df0c17bc3 embedding integration 2025-12-03 22:20:31 +01:00
9qeklajc
7bc9ee0653 Merge branch 'gemini-upstream' into embeddings 2025-12-03 22:01:39 +01:00
9qeklajc
355f8601c1 embedding integration 2025-12-03 21:58:31 +01:00
9qeklajc
e7f677b315 clean up 2025-12-03 20:26:14 +01:00
9qeklajc
d7611e74c3 remove model prefix for gemini 2025-12-03 20:24:36 +01:00
9qeklajc
d0790dcb22 use openrouter model matching 2025-12-03 19:36:06 +01:00
Shroominic
6b4b3924a1 feat(ui): make admin settings dynamic based on backend response
This update changes the admin settings page to dynamically render settings fields based on the JSON response from the backend, rather than hardcoding them. This ensures that new settings added to the backend are automatically available in the UI without code changes.

- Specific handling for known fields like name, description, urls, keys, mints, and relays remains to provide a polished UX.
- All other fields are rendered dynamically based on their type (boolean, number, string, array).
- Sensitive fields (keys, passwords) are automatically masked.
- Specific internal/unused fields are ignored.
2025-12-02 15:23:00 +08:00
Shroominic
2b4f71cc4f Merge branch 'v0.2.1' into gemini-upstream 2025-12-02 12:46:52 +08:00
Shroominic
80359d0854 add required openai dep 2025-12-02 12:42:33 +08:00
Shroominic
a226a78222 rm unused functions 2025-12-02 12:40:54 +08:00
Shroominic
42c2ddb355 optimize async startup process 2025-12-02 12:34:50 +08:00
Shroominic
65e7702f90 merge ppq integration 2025-12-02 12:32:49 +08:00
shroominic
253f419ada Merge pull request #234 from Routstr/ppq-integration
PPQ.ai provider integration
2025-12-02 12:04:03 +08:00
shroominic
83818e1097 Merge pull request #246 from Routstr/fix-reserved–balance-race-condition
Fix reserved–balance race condition
2025-12-02 11:58:52 +08:00
Shroominic
f116a5ed38 update provider fee label 2025-12-02 11:55:39 +08:00
Shroominic
ee185f56e5 editable provider fee 2025-12-02 11:54:48 +08:00
Shroominic
b76598ea41 fix reserved balance race condition bug 2025-12-01 17:27:58 +08:00
Shroominic
432fe48f36 Merge branch 'v0.2.1' into gemini-upstream 2025-11-28 13:21:56 +09:00
Shroominic
7b6e00edd8 ruff format 2025-11-28 13:21:25 +09:00
shroominic
e59335db4d Merge pull request #233 from GitHappens2Me/fix/logging_redaction
Fixed false positives in SecurityFilter
2025-11-28 13:08:08 +09:00
Shroominic
6f00478580 fix typing issues 2025-11-28 12:59:49 +09:00
Shroominic
4ed4f610f4 fix linting 2025-11-28 12:57:05 +09:00
Shroominic
30fd369bfa Merge branch 'v0.2.1' into fix/logging_redaction 2025-11-28 12:55:18 +09:00
Shroominic
8208a870a2 fix linting errors 2025-11-28 12:54:21 +09:00
9qeklajc
5eb4a40395 fix 2025-11-24 22:24:43 +01:00
9qeklajc
95ffc612ca openai lib 2025-11-24 22:07:28 +01:00
9qeklajc
afa4a59bb6 improve performance 2025-11-24 21:20:40 +01:00
9qeklajc
a67cd2d604 Merge pull request #243 from Routstr/fix-display-when-no-model-exists
display all providers
2025-11-24 21:02:00 +01:00
9qeklajc
d6fdf0c581 display all providers 2025-11-24 21:00:04 +01:00
Shroominic
ed15f61392 removed the unused /_register route 2025-11-23 15:14:18 -08:00
Shroominic
684ab639ac fix auto redirect to /login 2025-11-23 15:00:54 -08:00
shroominic
0d698e9e75 Merge pull request #242 from Routstr/login-dark-mode
make login page dark mode
2025-11-23 14:37:39 -08:00
Shroominic
f5be9878d8 v0.2.1 bump 2025-11-23 14:32:54 -08:00
shroominic
30fc8d91f6 Merge pull request #241 from Routstr/cursor/recreate-branch-with-original-commits-gemini-3-pro-preview-bbe5
unify stats and logs page with original commits
2025-11-23 14:29:58 -08:00
Cursor Agent
f7d6a0e349 Merge branch 'origin/v0.2.1' into cursor/recreate-branch-with-original-commits-gemini-3-pro-preview-bbe5 2025-11-23 22:23:00 +00:00
Shroominic
2cbf7b41f3 fix build error 2025-11-23 14:08:07 -08:00
Shroominic
d142ca52c7 fix date picker 2025-11-23 21:51:36 +00:00
Shroominic
c5b448b1ff fix search 2025-11-23 21:51:36 +00:00
Shroominic
6f4e57eeff feat(ui): apply global currency formatting to stats components
- Update UsageSummaryCards to use global currency store and formatting
- Update RevenueByModelTable to use global currency store and formatting
- Ensure consistent sat/msat/usd display across all dashboard metrics
2025-11-23 21:51:36 +00:00
Shroominic
3371b03068 feat(ui): update currency toggle to minimalistic dropdown
- Replace toggle group with dropdown menu for currency selection
- Update currency formatting to round USD to 2 decimals
- Improve header space utilization
2025-11-23 21:51:36 +00:00
Shroominic
64b45a75c7 feat(ui): implement global currency selection
- Add global currency store (sat/msat/usd)
- Add currency toggle to site header
- Update currency formatting logic (integers for sats, 2 decimals for USD)
- Connect dashboard and balances pages to global currency state
2025-11-23 21:51:36 +00:00
Shroominic
dcfd398008 feat(ui): add balance summary to main dashboard
- Create DashboardBalanceSummary component for quick balance overview
- Add balance summary cards to top of main dashboard
- Separate current balance view from time-filtered usage analytics
2025-11-23 21:51:36 +00:00
Shroominic
85962aa45a refactor(ui): restructure dashboard layout
- Move wallet balances to dedicated /balances page
- Promote usage statistics to main dashboard (/)
- Update sidebar navigation
- Update backend routes for new page structure
- Remove old usage page
2025-11-23 21:51:36 +00:00
Shroominic
d0aa91aa51 feat(ui): enhance dashboard visualizations
- Replace usage metrics line charts with gradient area charts
- Redesign summary cards with improved styling and icons
- Add revenue share progress bar to models table
- Format numbers in tables for better readability
2025-11-23 21:51:36 +00:00
Shroominic
f13638387f fix provider getattr 2025-11-23 21:51:36 +00:00
Shroominic
e154f65e16 fix revenue tracking problems 2025-11-23 21:51:36 +00:00
Shroominic
86b1ba0228 fix typing+linting 2025-11-23 21:51:36 +00:00
Cursor Agent
eb83b3c51a refactor: unify logs and usage implementation 2025-11-23 21:51:36 +00:00
Shroominic
2438f3231f fix docker build 2025-11-23 21:51:16 +00:00
Cursor Agent
13640d13d1 Merge remote-tracking branch 'remotes/origin/cursor/add-usage-tracking-dashboard-and-apis-32d6' into recreated-feature-branch 2025-11-23 21:51:16 +00:00
Shroominic
ec60dbe568 half undo 83a49a3 to not introduce extra db-settings 2025-11-23 12:29:00 -08:00
9qeklajc
12515cb0e3 clean up 2025-11-22 21:00:58 +01:00
9qeklajc
0f23335de9 first shot 2025-11-22 20:07:26 +01:00
shroominic
edd7bd40c5 Merge pull request #224 from GitHappens2Me/minor-fixes
Minor spelling / formatting fixes
2025-11-21 21:27:18 -08:00
Shroominic
dacabaa5d4 fix pytests 2025-11-21 18:06:48 -08:00
shroominic
6d6f66d6d0 Merge pull request #236 from Routstr/node-landingpage-clean
api cheat sheet
2025-11-21 17:57:09 -08:00
shroominic
8a70e19430 Merge pull request #231 from GitHappens2Me/fix/stream-encoding
fixed streaming responses for unsupported content-encoding
2025-11-21 17:49:02 -08:00
shroominic
19575eb1b9 Merge branch 'v0.2.1' into minor-fixes 2025-11-21 17:47:37 -08:00
shroominic
e71baeb6d6 Merge pull request #232 from Routstr/rm-cdk-python-binaries
rm accidental push of cdk-python binaries
2025-11-21 17:46:39 -08:00
shroominic
83a49a3c4a Merge pull request #202 from Routstr/feature/configurable-model-exclusions
Feature/configurable model exclusions
2025-11-21 17:44:20 -08:00
shroominic
071444f9d0 Merge pull request #200 from Routstr/fix/remove-models-from-base-url-output
Fix/remove models from base url output
2025-11-21 17:43:40 -08:00
Cursor Agent
177ea25723 Merge main into feature/configurable-model-exclusions
- Resolved merge conflicts in .env.example and routstr/payment/models.py
- Kept configurable model exclusions approach from feature branch
- Added new excluded models (sonoma-dusk-alpha, sonoma-sky-alpha) to settings.py defaults
- Updated .env.example to include all excluded models
- Maintained compatibility with main branch changes including UI configuration
2025-11-22 01:31:47 +00:00
Shroominic
028e73951e make login page dark mode 2025-11-20 17:53:53 -08:00
Shroominic
1a2b52ab90 fix ui build 2025-11-20 17:42:37 -08:00
Shroominic
f45ff16674 api cheat sheet 2025-11-20 15:58:44 -08:00
Shroominic
bd0764ee0d easy create account and topup over the dashboard for providers that support it 2025-11-18 14:38:51 -08:00
GitHappens2Me
c5c032bd2c fixed false positive redactions and added unit tests 2025-11-18 18:58:26 +01:00
Shroominic
b717c9739a ppq upstream 2025-11-17 20:47:33 -08:00
Shroominic
0bcf7bb948 rm accidental push of cdk-python binaries 2025-11-17 12:26:00 -08:00
GitHappens2Me
dbffef62e6 fixed streaming responses for unsupported content-encoding 2025-11-17 19:08:25 +01:00
9qeklajc
38356d7bb3 add copy button & order entries 2025-11-16 10:46:10 +01:00
Cursor Agent
99d98ffb2c feat: Add usage tracking dashboard and API
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-11-15 23:49:36 +00:00
9qeklajc
26110a68dd add logs page 2025-11-15 15:59:08 +01:00
9qeklajc
23ff99d41f copy button 2025-11-15 12:53:58 +01:00
9qeklajc
3e7d4c6e86 add date picker 2025-11-15 11:46:24 +01:00
9qeklajc
34cdbfe446 add logs page 2025-11-15 11:32:18 +01:00
shroominic
14ae4ecce3 v0.2.0c
fix calculate_usd_max_costs
2025-11-14 20:27:25 +08:00
Shroominic
a8b6d4866f v0.2.0c 2025-11-14 20:15:36 +08:00
Shroominic
924f93c18d fiiixXXXXXX 2025-11-14 19:42:20 +08:00
Shroominic
7c2ac805c8 fix calculate_usd_max_costs 2025-11-14 17:09:50 +08:00
shroominic
bb9b632ceb Merge pull request #223 from Routstr/v0.2.0b
V0.2.0b
2025-11-14 16:08:46 +08:00
Shroominic
50c43e9b07 fix vision prompt discounted max_cost calculation 2025-11-13 16:47:26 +08:00
Shroominic
a4c092d8dc rm fetch_models 2025-11-13 16:09:35 +08:00
Shroominic
94e7b2b4d2 Fix settings override bug: allow False and 0 from database
Fixes #217 - Database values of False and 0 are now properly respected
instead of being ignored. Removed 'and v' check that incorrectly treated
these legitimate config values as 'empty'. Now only truly empty values
(None, empty string, empty list, empty dict) are ignored in favor of env.
2025-11-13 16:03:56 +08:00
9qeklajc
637f3459c5 Merge pull request #222 from Routstr/update-deps
Update ui deps
2025-11-12 23:42:06 +01:00
GitHappens2Me
a5ac510cd0 Merge remote-tracking branch 'upstream/main' into minor-fixes 2025-11-11 15:50:19 +01:00
GitHappens2Me
fe66f249f0 small fixes 2025-11-11 15:42:26 +01:00
Shroominic
9a52e30470 get api key link 2025-11-11 17:31:19 +08:00
Shroominic
30d62bf65c fix linting 2025-11-11 17:24:21 +08:00
Shroominic
29b088c035 bump v0.2.0b 2025-11-11 17:21:52 +08:00
Shroominic
d16b0d5190 fix groq +xai model fetching 2025-11-11 17:19:04 +08:00
9qeklajc
9b2a4a8ff8 update package lock 2025-11-11 09:21:24 +01:00
Shroominic
320cfe82fd auto populate providers from available classes 2025-11-11 16:10:42 +08:00
9qeklajc
8d4691e7f6 update dep. package 2025-11-11 09:00:26 +01:00
9qeklajc
d7c5d7ce41 remove cashu dep 2025-11-11 08:56:47 +01:00
shroominic
2da2f96118 Merge pull request #218 from Routstr/v0.2.0-final
add docs, fix anthropic upstream model alias problem
2025-11-11 13:48:05 +08:00
Shroominic
8cb73f4528 fix import error 2025-11-11 13:40:59 +08:00
Shroominic
e3b146b83f Merge branch 'upstream-refactor' into v0.2.0 2025-11-11 13:09:02 +08:00
Shroominic
b7e4fbf739 fix model alias problem for anthropic 2025-11-11 12:43:02 +08:00
Shroominic
2c8ba93312 experiments 2025-11-11 10:24:34 +08:00
9qeklajc
cfe03d6dcb remove redundant setting & doc 2025-11-10 22:59:32 +01:00
9qeklajc
80b6acbf4b add docs 2025-11-10 22:40:35 +01:00
9qeklajc
bd88a84cd4 Merge pull request #216 from Routstr/v0.2.0-final
update doc
2025-11-09 21:37:11 +01:00
9qeklajc
45f5ba96a8 update doc 2025-11-09 21:36:42 +01:00
Shroominic
4b935a6f4d more upstream + model fetchin wip 2025-11-08 12:30:12 +08:00
Shroominic
c9f458b8ba experimentation to get better fetching algorighm 2025-11-06 18:27:17 +08:00
Shroominic
d9d2e17e5d refactor upstream files and classes 2025-11-06 17:23:37 +08:00
9qeklajc
3d6bd65a64 Merge pull request #215 from Routstr/v0.2.0-final
* fix model filtering
* cleanup desing
2025-11-05 09:35:10 +01:00
9qeklajc
8c08be9e11 better ui 2025-11-05 00:12:23 +01:00
9qeklajc
c49da9bf84 ignore error 2025-11-04 23:50:55 +01:00
9qeklajc
c2b97f8e3b use enabled flag 2025-11-04 23:48:38 +01:00
9qeklajc
74480df47d different check 2025-11-04 23:48:24 +01:00
9qeklajc
f5c9cde852 clean up 2025-11-04 23:46:38 +01:00
9qeklajc
88fbefbd18 fmt 2025-11-04 23:29:07 +01:00
9qeklajc
ded82cd729 fix model endpoint & filter query 2025-11-04 23:16:08 +01:00
9qeklajc
1e90b223cf fix model filter 2025-11-04 22:45:18 +01:00
9qeklajc
b8a1d69924 fmt 2025-11-04 22:45:07 +01:00
9qeklajc
33b19ba98b better model naming 2025-11-04 22:28:48 +01:00
shroominic
64bf8aec3f Merge pull request #214 from Routstr/v0.2.0-dev
v0.2.0
2025-11-03 22:36:05 +08:00
shroominic
3a69491ac0 Merge pull request #205 from routstr/v0.2.0-final
V0.2.0
2025-11-03 22:33:07 +08:00
Shroominic
44286067ae fix typing 2025-11-03 22:24:46 +08:00
Shroominic
1c7cbf64ef ruff fmt 2025-11-03 22:24:27 +08:00
Shroominic
0c3beba74f optional primary_mint_unit setting for special msat only mints 2025-11-03 13:53:44 +08:00
9qeklajc
1b6188b130 better model update 2025-11-02 23:42:18 +01:00
9qeklajc
4859dbd163 fix models filtering 2025-11-02 23:42:03 +01:00
9qeklajc
a3d9022e2b add generic upstream 2025-11-02 11:50:13 +01:00
Shroominic
dc4dbb4cff max_tokens must be an integer 2025-11-02 15:47:26 +08:00
Shroominic
fc97e75602 Merge branch 'v0.2.1-pricing-algorithm' without version bump
- Add model/provider selection based on pricing algorithm
- Simplify models/providers UI
- Implement cost-based model selection
- Keep version at 0.2.0
2025-11-02 14:38:47 +08:00
Shroominic
293f8471b3 add: msat/sat/usd unit toggle 2025-11-02 13:34:57 +08:00
Shroominic
beedfdc1d2 rm outdated admin cookie auth 2025-11-02 12:27:12 +08:00
Shroominic
6e7be9695e fetch provider-types from node api 2025-11-02 12:05:39 +08:00
9qeklajc
01d40009a3 ui build test 2025-11-01 15:29:21 +01:00
9qeklajc
557080d0b2 fix ui bild 2025-11-01 15:19:39 +01:00
Shroominic
baa2e7038c rm dev version tag 2025-11-01 13:48:10 +08:00
Shroominic
39047361d6 model/provider selection based on pricing 2025-11-01 13:47:23 +08:00
Shroominic
57c0defbd7 bump v0.2.1 2025-11-01 13:46:59 +08:00
Shroominic
887bd14774 simplify models/providers UI 2025-11-01 13:35:21 +08:00
Shroominic
b37645cfbe ignore mypy cache 2025-11-01 13:35:00 +08:00
Shroominic
4df56571be fix typing + linting issues 2025-11-01 13:33:44 +08:00
9qeklajc
ff4c2c418c fix ollama request 2025-10-31 22:54:32 +01:00
Shroominic
db0f6e65ef fix typing issue 2025-10-30 12:47:04 +08:00
Shroominic
aa9019538f fix preloading warning 2025-10-30 12:42:16 +08:00
Shroominic
4537e21ae0 update lockfile 2025-10-30 12:40:55 +08:00
Shroominic
079918a0cd fix preloading warnings 2025-10-30 12:39:02 +08:00
Shroominic
a88f999dc5 add Node to title 2025-10-30 12:38:51 +08:00
Shroominic
968ebaf865 make api base url configurable if not set 2025-10-30 12:22:25 +08:00
Shroominic
32253964dc rm id + redacted api key info 2025-10-30 12:20:54 +08:00
Shroominic
74f4cb3a31 fix admin password sourcing 2025-10-30 11:17:02 +08:00
Shroominic
0cf0606822 increase refreshInterval 2025-10-30 11:16:32 +08:00
9qeklajc
b6fbe3b810 do not response with disabled models 2025-10-29 22:48:09 +01:00
Shroominic
68d62e08df add root_fallback if admin ui not visible 2025-10-29 13:43:03 +08:00
Shroominic
d62ec19ddf fix IntegrityError 2025-10-29 13:34:16 +08:00
Shroominic
e754fd506f fmt 2025-10-29 13:28:01 +08:00
Shroominic
39a0a2939a refactor old tests 2025-10-29 13:27:44 +08:00
Shroominic
7d95a6187a ruff fmt 2025-10-27 14:09:48 +08:00
Shroominic
862df136d6 Merge v0.1.4 changes from 'origin/main' into v0.2.0-final 2025-10-27 14:07:37 +08:00
Shroominic
a72653a401 fix alias precedence so glm-4.6 maps to normal model, not glm-4.6:exacto 2025-10-27 13:21:10 +08:00
Shroominic
aa6747d444 pin python version 2025-10-27 12:38:55 +08:00
Shroominic
62305c3416 error handling in price updates 2025-10-27 12:37:10 +08:00
Shroominic
f3c0212ae3 rm-dep: cashu-ts 2025-10-27 12:34:01 +08:00
9qeklajc
d8d91ab57b better balance display 2025-10-26 00:39:36 +02:00
9qeklajc
a516c2f04e fix build 2025-10-26 00:28:59 +02:00
9qeklajc
bcadf2961c test 2025-10-25 23:56:04 +02:00
9qeklajc
221838a750 fix out 2025-10-25 23:44:34 +02:00
9qeklajc
59cd84acbc improve 2025-10-25 23:23:54 +02:00
9qeklajc
f7fc5ba5d7 fix url 2025-10-25 22:47:34 +02:00
9qeklajc
8b0ced22e5 add dark mode 2025-10-25 00:21:41 +02:00
9qeklajc
8bb1e0d321 fix mobile view 2025-10-25 00:16:14 +02:00
9qeklajc
2f2f3ce098 fix redirection 2025-10-24 23:26:56 +02:00
9qeklajc
17af3aa2eb fix test 2025-10-24 22:59:18 +02:00
9qeklajc
2c507796eb fix build 2025-10-24 22:25:16 +02:00
9qeklajc
c3c4e886c8 fix proxy x-cashu model forwarding 2025-10-24 22:20:03 +02:00
9qeklajc
430bf8a610 clean up 2025-10-24 22:11:40 +02:00
9qeklajc
cf9a6d83ff add model filtering 2025-10-24 22:04:58 +02:00
9qeklajc
82e01e3e13 add temp balances 2025-10-24 21:31:35 +02:00
9qeklajc
2ed72d16da fix to request correct model 2025-10-24 21:13:18 +02:00
9qeklajc
65a211388e move to different repo 2025-10-24 19:56:56 +02:00
9qeklajc
014eba550d clean up 2025-10-24 17:56:41 +02:00
9qeklajc
86e4b99297 use different endpoint for models 2025-10-24 17:19:15 +02:00
9qeklajc
3c81a77ea2 fmt 2025-10-24 16:40:37 +02:00
9qeklajc
8a09c4cf7e refactor updatreams 2025-10-24 16:40:27 +02:00
9qeklajc
05f3ce1a43 clean up 2025-10-24 15:56:03 +02:00
9qeklajc
f3e8718660 fix icon 2025-10-24 15:55:48 +02:00
9qeklajc
5279eadec3 latest 2025-10-24 15:22:58 +02:00
9qeklajc
e146b4a08a better passwrod setting 2025-10-24 15:22:45 +02:00
9qeklajc
fac8db9cfb add static pages 2025-10-24 14:39:22 +02:00
9qeklajc
57cace0e77 better setting ui 2025-10-24 14:37:56 +02:00
9qeklajc
2121b36e79 make static ui 2025-10-24 14:27:47 +02:00
9qeklajc
90f56357d9 fmt 2025-10-24 14:20:27 +02:00
9qeklajc
793f33efb4 add settings 2025-10-24 13:53:32 +02:00
9qeklajc
769ad00e42 better price validation 2025-10-24 13:43:28 +02:00
9qeklajc
3716184ea9 clean up 2025-10-24 13:11:05 +02:00
9qeklajc
5f38f1a36b add delete and disable options 2025-10-24 13:06:39 +02:00
9qeklajc
b85b81ac22 update input 2025-10-24 12:49:10 +02:00
9qeklajc
307e81bd61 fix model update 2025-10-24 12:37:11 +02:00
9qeklajc
65c19bc375 clean up 2025-10-24 11:50:45 +02:00
9qeklajc
f9eaf48f45 add ollama upstream 2025-10-23 21:14:40 +02:00
9qeklajc
b3f3f68dd9 Merge branch 'v0.2.0-dev' into v0.2.0-final 2025-10-23 20:09:30 +02:00
9qeklajc
3420ac73d7 fmt 2025-10-23 20:09:07 +02:00
Shroominic
ce77e888ba fully add anthropic provider 2025-10-23 14:05:58 +08:00
Shroominic
28fda20b6e fix wallet concurrency topup issue 2025-10-22 12:34:20 +08:00
Shroominic
7e173d779f ignore .todo files 2025-10-22 12:28:26 +08:00
Shroominic
855061cc49 fix tests due to refactor 2025-10-22 12:18:28 +08:00
Shroominic
5b80dfacda testing clients 2025-10-22 12:18:00 +08:00
Shroominic
8950cc5b7f rm redundant try except 2025-10-22 12:07:27 +08:00
Shroominic
dc0f7d7f3b fix max_cost discount 2025-10-22 11:54:17 +08:00
9qeklajc
45c8bfce1d merge 2025-10-21 23:21:03 +02:00
9qeklajc
4d28586d13 add ui code 2025-10-21 22:07:47 +02:00
Shroominic
0da08fb945 refactor pricing, provider fees, realtime model map updates 2025-10-20 12:45:02 +08:00
shroominic
af6ecbdd3e Merge pull request #186 from Routstr/v0.1.4
V0.1.4
2025-10-20 10:11:09 +08:00
Shroominic
4641f40278 custom version suffix 2025-10-18 13:33:41 +08:00
shroominic
0c1fa27257 Merge pull request #189 from kwsantiago/kwsantiago/188-fee-not-applying-to-usd
fix: upstream provider fee not being applied to USD pricing
2025-10-18 13:30:17 +08:00
Shroominic
24aceb2b07 ruff fmt 2025-10-18 13:29:49 +08:00
Shroominic
229702a983 ruff fmt 2025-10-18 13:29:37 +08:00
Shroominic
eda1f8dadf set defaul model refresh interval 2025-10-18 13:29:17 +08:00
Shroominic
71ed601269 fix: max_tokens as string should not work 2025-10-18 13:18:59 +08:00
Shroominic
a8b7554334 fix: mints env var not initialized properly 2025-10-18 13:18:59 +08:00
Shroominic
e0341b06a5 pin python version to 3.11 2025-10-18 13:18:59 +08:00
Shroominic
be7e1ff4a3 maybe fix 2025-10-18 13:18:59 +08:00
Shroominic
8960501f6b enable sqlite WAL mode 2025-10-18 13:18:59 +08:00
Shroominic
8d9c9d93eb fix tests 2025-10-18 13:18:59 +08:00
Shroominic
6c66a37dfa ruff fmt 2025-10-18 13:18:59 +08:00
Shroominic
a33193e4a3 dontations endpoint 2025-10-18 13:18:59 +08:00
redshift
da6e0a4c59 Fixed the bug where refund amount is below 1 sat for a sat mint 2025-10-18 13:18:59 +08:00
Shroominic
764bc3d7e8 fix periodic payouts 2025-10-18 13:18:59 +08:00
Shroominic
1557a7c58d remove need for ADMIN_PASSWORD, added in initial setup 2025-10-18 13:18:59 +08:00
Shroominic
d69a8f76e1 improved upstream error message handling 2025-10-18 13:18:59 +08:00
Shroominic
78821929a6 v1/providers redirect to v1/providers/ 2025-10-18 13:18:59 +08:00
Shroominic
401728582f filter out openrouter spam models 2025-10-18 13:18:59 +08:00
Shroominic
98aa886477 add models.json batch paste 2025-10-18 13:18:59 +08:00
Shroominic
81ac14bbc5 models admin dashboard 2025-10-18 13:18:59 +08:00
Shroominic
036e04467e v0.1.4-dev 2025-10-18 13:18:59 +08:00
Shroominic
6d6651acbe fix: max_tokens as string should not work 2025-10-18 13:10:09 +08:00
Shroominic
19972bc8fc fix: mints env var not initialized properly 2025-10-18 13:09:50 +08:00
redshift
fe2b1846f7 Add EXCLUDED_MODEL_IDS configuration to .env.example
- Document the new EXCLUDED_MODEL_IDS environment variable
- Show example with default excluded model IDs
- Maintain existing configuration structure
2025-10-17 04:12:05 +00:00
redshift
859b31e2bc Update tests to reflect removal of models field from base URL output
- Remove models field from required fields list in root endpoint test
- Add explicit test to ensure models field is not present
- Update test comments to reference issue #184

Related to #184
2025-10-17 04:11:48 +00:00
redshift
80a7f5d2eb Replace hardcoded model exclusions with configurable setting
- Remove hardcoded model ID checks for openrouter/auto and google/gemini-2.5-pro-exp-03-25
- Use settings.excluded_model_ids list for model filtering
- Maintain backward compatibility with existing exclusion logic
- Add proper error handling for settings access
2025-10-17 04:11:46 +00:00
redshift
fa0b2834c5 Remove models variable from base URL output
- Remove the deprecated models field from /v1/info endpoint
- The models field was kept for back-compatibility but is now removed
- Users should use the dedicated /v1/models endpoint instead

Fixes #184
2025-10-17 04:10:30 +00:00
redshift
d693b559c0 Add configurable excluded model IDs setting
- Add excluded_model_ids field to Settings class
- Support comma-separated list via EXCLUDED_MODEL_IDS env var
- Include default exclusions for openrouter/auto and google/gemini-2.5-pro-exp-03-25
2025-10-17 04:10:25 +00:00
Shroominic
4d09867a9a pin python version to 3.11 2025-10-16 16:03:39 +08:00
shroominic
78d99a7462 Merge pull request #194 from Routstr/fix-x-cashu-msat-refund-bug
Fix x cashu msat refund bug
2025-10-16 16:03:12 +08:00
Shroominic
523816db30 maybe fix 2025-10-16 15:37:38 +08:00
Shroominic
7e6e0f806b enable sqlite WAL mode 2025-10-16 11:00:52 +08:00
Kyle Santiago
9096a6c30d Create test_fee_consistency.py 2025-10-13 21:42:33 -04:00
Shroominic
61a0559f8e Add upstream providers management and model integration
- Created migration scripts to establish the `upstream_providers` table and integrate it with the `models` table.
- Enhanced the proxy functionality to support dynamic upstream provider initialization and model resolution.
- Implemented API endpoints for managing upstream providers, including CRUD operations and model fetching.
- Refactored pricing calculations to accommodate upstream provider overrides and ensure accurate cost estimation.
- Updated the admin interface to allow for easy management of upstream providers and their associated models.
2025-10-13 17:17:54 +08:00
Shroominic
4b36fb8d6f change version 2025-10-12 13:03:29 +08:00
Kyle Santiago
6538427782 fix: upstream provider fee not being applied to USD pricing 2025-10-09 19:31:02 -04:00
Shroominic
123353bf60 first step for multi upstream, subclassing 2025-10-07 11:36:52 +08:00
Shroominic
8beab0e28b move functions into class to be overwritten by subclasses 2025-10-06 16:40:36 +08:00
Shroominic
384a149600 fix tests 2025-10-04 20:52:15 +08:00
Shroominic
c56a06f3df fix tests 2025-10-04 20:49:53 +08:00
Shroominic
325abad736 ruff fmt 2025-10-04 20:49:45 +08:00
Shroominic
719c091145 ruff fmt 2025-10-04 20:19:04 +08:00
Shroominic
c90abe9e79 update version 2025-10-04 20:12:28 +08:00
Shroominic
36d55216fe fix tests 2025-10-04 17:38:05 +08:00
Shroominic
f657859249 refactor upstream functions into UpstreamProvider class 2025-10-04 17:38:00 +08:00
Shroominic
f8090f5c35 dontations endpoint 2025-10-02 14:55:02 +08:00
shroominic
b2e5da4b46 Merge pull request #180 from Routstr/sh1ftred-patch-1
Fixed the bug where refund amount is below 1 sat for a sat mint
2025-09-23 12:40:43 +01:00
redshift
729f00caa2 Fixed the bug where refund amount is below 1 sat for a sat mint 2025-09-22 16:55:16 +00:00
Shroominic
54d7a5a247 fix periodic payouts 2025-09-22 17:54:28 +01:00
redshift
ecbe3158c0 Merge pull request #179 from Routstr/503-mint-service-bug
Fixed the 503 mint service unavailable bug
2025-09-22 16:27:36 +00:00
redshift
bac50f2150 Fixed the 503 mint service unavailable bug
This flag fetch all keysets from the db into the wallet, which is wrong given that we're initiating it for a mint. And load_mint sets the first keyset from all the keysets from the db, irrespective of the unit and the mint. 

Fixes #175
2025-09-22 16:25:02 +00:00
Shroominic
150f3084c1 remove need for ADMIN_PASSWORD, added in initial setup 2025-09-22 16:40:48 +01:00
Shroominic
d4e1715613 improved upstream error message handling 2025-09-22 16:36:52 +01:00
Shroominic
c2a26a3c00 v1/providers redirect to v1/providers/ 2025-09-22 16:36:35 +01:00
Shroominic
8e0ae6cbc8 filter out openrouter spam models 2025-09-22 13:46:07 +01:00
Shroominic
18a055ee2c add models.json batch paste 2025-09-22 12:58:53 +01:00
Shroominic
c5289364e4 models admin dashboard 2025-09-22 12:47:39 +01:00
Shroominic
5fb583be55 v0.1.4-dev 2025-09-22 11:11:36 +01:00
shroominic
4bf02226dc Merge pull request #172 from Routstr/fix-max-tokens-string-int
fix max-tokens coming in as string
2025-09-16 13:05:39 +01:00
Shroominic
c8f4e7d3e5 fix max-tokens coming in as string 2025-09-16 13:00:38 +01:00
shroominic
3d4a8e0e14 Merge pull request #169 from Routstr/dev
## Routstr v0.1.3 — 2025-09-15

### Highlights

- **DB-backed settings with live updates**: Env vars seed on first run; DB is the source of truth (except `DATABASE_URL`). Manage via Admin UI or `PATCH /admin/api/settings`.
- **Models moved to database** with background sats-pricing computation and optional periodic refresh.
- **Smarter max-cost reservation**: Discount based on prompt tokens and `max_tokens` using `TOLERANCE_PERCENTAGE`, with a `MIN_REQUEST_MSAT` floor.
- **Faster startup and provider discovery**; `.onion` endpoints use Tor proxy by default.

### Breaking changes

- **Configuration is DB-first** after bootstrap. Env changes won’t auto-apply (besides `DATABASE_URL`).
- **Models endpoint**: Use `/v1/models` for canonical model info; `/v1/info` keeps an empty `models` field for back-compat.
- **Legacy pricing envs** are auto-mapped but deprecated:
  - `MODEL_BASED_PRICING` → `!FIXED_PRICING`
  - `COST_PER_REQUEST` → `FIXED_COST_PER_REQUEST`
  - `COST_PER_1K_*` → `FIXED_PER_1K_*`

### Added

- **SettingsService** with env→computed→DB merge; runtime edits; app metadata (name/description) applied to OpenAPI.
- **Automatic migrations** on startup; new tables: `settings`, `models`.
- **Per-model sats pricing** and per-request maximums computed in the background.
- **Pricing controls**: `FIXED_PRICING`, `FIXED_COST_PER_REQUEST`, optional per-1k token overrides, `MIN_REQUEST_MSAT`.
- **Azure chat support**: Optional `CHAT_COMPLETIONS_API_VERSION` adds `api-version` to `/chat/completions`.
- **Derive `NPUB` from `NSEC`** during bootstrap if not provided.

### Changed / Improvements

- **Proxy header hardening**: Strip sensitive headers; replace Authorization with upstream key when configured.
- **Discovery**: Read `RELAYS` from settings; Tor proxy defaults for `.onion` via `TOR_PROXY_URL`; faster announcement fetching.
- **Admin**: Improved auth/log UX; fixed log search; show mint balances in dashboard.
- **Performance**: Optimized startup; lazy DB info loading.

### Fixed

- **Refunds**: Correct msat→sat conversion; configurable refund cache TTL via `REFUND_CACHE_TTL_SECONDS`; better error mapping for mint outages.
- **Balances**: Reserved balance reporting and negative-reserved edge cases.
- **Tests**: Updated to patch settings and use fixed-pricing flags.

### Upgrade notes (from v0.1.2)

1. Ensure `DATABASE_URL`, `UPSTREAM_BASE_URL`, and `ADMIN_PASSWORD` are set. Optionally set `UPSTREAM_API_KEY`, `RELAYS`, `CASHU_MINTS`, `TOR_PROXY_URL`.
2. Start the service; automatic Alembic migrations will run (`settings`, `models` tables).
3. Configure settings via Admin UI (`/admin`) or `PATCH /admin/api/settings`. Env changes after first run won’t apply automatically.
4. Prefer new pricing envs or a `models.json` at `MODELS_PATH`. Legacy envs are mapped but will be removed in a future release.
5. Optional tuning: `TOLERANCE_PERCENTAGE`, `MIN_REQUEST_MSAT`, `PRICING_REFRESH_INTERVAL_SECONDS`, `MODELS_REFRESH_INTERVAL_SECONDS`, `ENABLE_*_REFRESH`.
6. For Azure, set `CHAT_COMPLETIONS_API_VERSION`.

### References

- Configuration: `docs/getting-started/configuration.md`
- API: `docs/api/endpoints.md`
- Custom pricing: `docs/advanced/custom-pricing.md`

### Stats and credits

- 42 files changed, +1819/−832 lines.
- Merges: #170 token-discount-max-cost, #171 move-models-to-db.
- Contributors: Shroominic.
2025-09-16 12:43:54 +01:00
Shroominic
8b9d53be3c fix deps issue 2025-09-15 18:04:35 +01:00
Shroominic
11a01dae89 fix pytests 2025-09-15 18:01:01 +01:00
Shroominic
9ab269dd8f ⬆️ v0.1.3 2025-09-15 17:55:20 +01:00
Shroominic
c263d38732 add reserved balance info 2025-09-15 17:38:51 +01:00
Shroominic
c4f84cd1e6 Merge remote-tracking branch 'refs/remotes/origin/dev' into dev 2025-09-11 13:40:36 +01:00
shroominic
0478b1c360 Merge pull request #171 from Routstr/move-models-to-db
Move models to db
2025-09-11 13:40:04 +01:00
Shroominic
633ae39622 optimize startup time and announcement fetching 2025-09-11 13:39:29 +01:00
Shroominic
84ff9f5dc3 update default relays 2025-09-11 13:38:04 +01:00
Shroominic
88ec3395c7 fix: missing mints balances in admin dashboard 2025-09-11 12:51:03 +01:00
Shroominic
49485435ad fix admin log search 2025-09-11 12:46:06 +01:00
Shroominic
d80df28b23 rm not needed setting 2025-09-11 12:42:23 +01:00
Shroominic
4cdbf23ec0 fixes 2025-09-10 13:28:48 +01:00
Shroominic
73d3613301 move MODELS to DB 2025-09-10 13:28:43 +01:00
shroominic
6993e6b156 Merge pull request #170 from Routstr/token-discount-max-cost
max cost discount
2025-09-09 18:30:14 +01:00
Shroominic
6d1525748d fix tests 2025-09-09 18:27:48 +01:00
Shroominic
57ac38e6dc fix test patches 2025-09-09 14:27:25 +01:00
Shroominic
2e42af61bb rm tolerance percentage args 2025-09-09 14:00:39 +01:00
Shroominic
7dfc2f4056 max cost discount 2025-09-09 13:58:52 +01:00
Shroominic
2b045c95c7 dev version indicator 2025-09-08 14:52:52 +01:00
Shroominic
665b3f9a16 lazy load db information 2025-09-08 13:43:17 +01:00
Shroominic
dcbaf7413a edit settings over admin dashboard 2025-09-08 12:57:29 +01:00
Shroominic
918a083a12 cleanup admin auth 2025-09-08 12:48:27 +01:00
Shroominic
7c9265b40d feat(settings): derive NPUB from NSEC during bootstrap (similar to ONION discovery) 2025-09-08 12:29:41 +01:00
Shroominic
44030ddbe5 docs: simplify discovery config (RELAYS only); drop OpenRouter BASE_URL mentions 2025-09-08 12:26:22 +01:00
Shroominic
be372c48ea refactor(settings): remove NIP-91 fields; keep relays under discovery; drop openrouter_base_url 2025-09-08 12:24:32 +01:00
Shroominic
939a991d6b chore: update version/readme; minor auth logging and settings usage 2025-09-08 10:53:41 +01:00
Shroominic
bb9991e7a5 docs: update configuration/pricing docs and compose with FIXED_* vars 2025-09-08 10:53:32 +01:00
Shroominic
496900baa6 test: update tests to patch settings and use fixed pricing flags 2025-09-08 10:53:23 +01:00
Shroominic
8f22826d63 fix(balance): correct sat refund conversion from msats and use settings for TTL 2025-09-08 10:53:11 +01:00
Shroominic
15e61d1760 refactor(proxy): use settings for upstream base; integrate new pricing flow and header prep 2025-09-08 10:53:02 +01:00
Shroominic
a4f28db887 refactor(wallet): use settings for cashu mints, primary mint, and payout address 2025-09-08 10:52:54 +01:00
Shroominic
db2c6eb98a refactor(payment): switch to settings-based pricing and upstream config; support fixed vs model pricing 2025-09-08 10:52:47 +01:00
Shroominic
8286f4f0cc refactor(discovery,nip91): read relays and config from settings; default Tor proxy; cleanup 2025-09-08 10:52:39 +01:00
Shroominic
7761d144f6 feat(core): initialize and use SettingsService; update admin settings API; hook app metadata from settings 2025-09-08 10:52:30 +01:00
Shroominic
14718843de feat(settings): add DB-backed Settings and SettingsService with env merge 2025-09-08 10:52:21 +01:00
Shroominic
a28277a0a8 rm 2025-09-06 14:57:12 +01:00
Shroominic
d2b8a4e78b update env example 2025-09-06 14:57:09 +01:00
Shroominic
ac24f10cb0 cleanup compose 2025-09-06 14:50:55 +01:00
shroominic
54fe4e27c0 ⬆️ v0.1.2 2025-09-04 21:50:05 +01:00
Shroominic
cc9e387d3a ⬆️ v0.1.2 2025-09-04 21:47:13 +01:00
shroominic
f499be5c15 Merge pull request #160 from Routstr/azure-openai-compatibility
Azure OpenAI compatibility
2025-09-04 21:41:05 +01:00
shroominic
12b88de226 Merge pull request #162 from Routstr/fix/reserved-balance-finalization
fix: always finalize reserved_balance; robust streaming finalization …
2025-09-04 21:40:44 +01:00
shroominic
a2ea4e60b0 Merge pull request #118 from Routstr/cursor/discover-and-announce-routstr-providers-3b9a
Discover and announce routstr providers
2025-09-04 21:40:24 +01:00
Shroominic
619f601cd4 mint_url -> mint_urls 2025-09-04 17:05:23 +01:00
Shroominic
f4dbea95f7 rm logs 2025-09-04 16:48:37 +01:00
Shroominic
b28aa20257 fix: always finalize reserved_balance; robust streaming finalization without usage or interruptions 2025-09-04 15:59:53 +01:00
Shroominic
331a3bebbd not change version here 2025-09-04 15:33:32 +01:00
Shroominic
9b70919d65 handle potential rate limiting 2025-09-04 15:29:28 +01:00
Shroominic
f09d1991f0 reduce timeout 2025-09-04 15:29:28 +01:00
Shroominic
e7995dc6b0 ƒormat tests 2025-09-04 15:29:28 +01:00
Shroominic
a62446dbd2 fix tests 2025-09-04 15:29:28 +01:00
Shroominic
1daa5a9a6b add nostr lib 2025-09-04 15:29:28 +01:00
Shroominic
983ab04e6d update id to d_tag 2025-09-04 15:29:28 +01:00
Shroominic
f94035a74f refactor to nostr library and fixes 2025-09-04 15:29:28 +01:00
Shroominic
c6b0f4e46d add http to onion url 2025-09-04 15:29:28 +01:00
Shroominic
3368eb6ef2 skipping localhost:8000 provider 2025-09-04 15:29:28 +01:00
Shroominic
fa026ee580 dev version indicator 2025-09-04 15:29:28 +01:00
Shroominic
951eb32c2b cached /refund 2025-09-04 15:29:28 +01:00
Shroominic
124fbedf2a update core config docs 2025-09-04 15:29:06 +01:00
Shroominic
fa4b78abec azure openai docs 2025-09-04 15:29:06 +01:00
Shroominic
207cafe285 azure openai compatibility 2025-09-04 15:29:06 +01:00
shroominic
ad068877ac Merge pull request #155 from Routstr/cached-/refund
cached /refund
2025-09-04 14:19:53 +01:00
Shroominic
c0d115b1ab update providers bg task 2025-09-03 16:52:13 +01:00
Shroominic
e28ff2cc68 Merge branch 'cursor/discover-and-announce-routstr-providers-3b9a' into dev 2025-09-03 09:11:40 +01:00
Shroominic
f5ef3770f6 add tor url as default 2025-09-02 21:47:04 +01:00
Shroominic
8e4a08e969 fix event replacement 2025-09-02 15:33:25 +01:00
Shroominic
4b40e9010d correct env var names 2025-09-01 21:42:25 +01:00
Shroominic
0fc4e3cb97 fix logging 2025-09-01 21:41:38 +01:00
Shroominic
b87a3fd88c rm readme section 2025-08-29 13:09:56 -03:00
Shroominic
8f9730d87f use partial pubkey for d tag 2025-08-29 13:09:07 -03:00
Shroominic
8f6f1d6d22 fix env vars + open timeout 2025-08-29 13:04:42 -03:00
Shroominic
3834720333 fix env vars 2025-08-29 13:03:44 -03:00
Shroominic
501b6e6292 fix provider discovery 2025-08-29 12:22:35 -03:00
Shroominic
828e7ced10 fix publishing 2025-08-29 12:22:11 -03:00
Shroominic
7e988f94e9 Merge branch 'main' into cursor/discover-and-announce-routstr-providers-3b9a 2025-08-29 11:40:26 -03:00
Shroominic
8462b64f79 cached /refund 2025-08-25 15:03:43 -03:00
shroominic
65084b1783 Merge pull request #154 from Routstr/cursor/document-entire-repository-for-users-and-contributors-43ef
mkdocs build fix
2025-08-25 02:15:32 -03:00
Shroominic
2311aa981e fix 2025-08-25 02:13:40 -03:00
shroominic
56485bffc3 Merge pull request #153 from Routstr/cursor/document-entire-repository-for-users-and-contributors-43ef
Document entire repository for users and contributors
2025-08-25 01:58:24 -03:00
Shroominic
f1d4dd6ff4 edits 2025-08-25 01:56:09 -03:00
Shroominic
11c8902dde edits 2025-08-25 01:48:46 -03:00
Shroominic
bedca877ce fmt 2025-08-25 01:10:10 -03:00
Shroominic
ea01122652 github pages 2025-08-25 01:09:44 -03:00
Shroominic
efa3e18bf9 routstr core docs 2025-08-25 01:09:39 -03:00
Shroominic
131ccdf5e4 coming soon 2025-08-25 01:09:27 -03:00
Shroominic
9622346cc2 todo 2025-08-25 01:09:20 -03:00
Shroominic
6595b30701 fix 2025-08-25 01:09:16 -03:00
Cursor Agent
d4c1389249 docs: Add comprehensive documentation for Routstr Core project
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-25 01:17:40 +00:00
shroominic
73e701895d Merge pull request #151 from Routstr/v0.1.1b
⬆️ v0.1.1b
2025-08-23 17:21:20 -03:00
Shroominic
394bfe1762 ⬆️ v0.1.1b 2025-08-23 17:20:26 -03:00
shroominic
f66e83d58a Merge pull request #142 from Routstr/no-need-for-async
no need for async on get_proofs_per_mint_and_unit
2025-08-23 17:18:41 -03:00
shroominic
14e0f821df Merge pull request #150 from Routstr/final-fixes-cleanup
Final fixes cleanup
2025-08-23 17:18:04 -03:00
Shroominic
9ffd44eac6 undo cursor agent stuff 2025-08-23 17:13:38 -03:00
Shroominic
73e6e259bc Merge remote-tracking branch 'refs/remotes/origin/final-fixes-cleanup' into final-fixes-cleanup 2025-08-23 17:08:01 -03:00
Cursor Agent
5061d69f57 Replace print statements with structured logging across multiple files
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-23 20:04:19 +00:00
Shroominic
b478f29ee7 fix tests last time 2025-08-23 17:02:58 -03:00
Shroominic
9a6d976ee5 fix tests 2025-08-23 17:01:35 -03:00
Shroominic
8175188b06 ruff fmt 2025-08-23 17:01:28 -03:00
Shroominic
6a746eb3df add max cost tolerance 2025-08-23 16:54:00 -03:00
Shroominic
441ed82a82 fix payout of user balance, remove prints 2025-08-23 16:49:27 -03:00
Shroominic
93f2bf98b8 typing 2025-08-23 16:48:53 -03:00
shroominic
0ad60853d4 Merge pull request #149 from Routstr/v0.1.1
⬆️ v0.1.1
2025-08-23 13:32:13 -03:00
shroominic
d1268c3026 ⬆️ v0.1.1 2025-08-23 16:31:48 +00:00
shroominic
763813507a Merge pull request #146 from Routstr/reserved-balance-and-fixes
Reserved balance and fixes
2025-08-23 13:25:32 -03:00
Cursor Agent
05b27315ee Remove async from get_proofs_per_mint_and_unit call in withdraw function
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-23 16:22:33 +00:00
shroominic
88d6e22918 Update pyproject.toml 2025-08-23 13:15:55 -03:00
shroominic
14b73c2dcc Delete coverage.xml 2025-08-23 13:15:10 -03:00
shroominic
cb35168587 Delete pytest.xml 2025-08-23 13:14:49 -03:00
Cursor Agent
06050196d4 Add pytest coverage and XML reporting configuration
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-22 21:00:18 +00:00
Shroominic
d7e35887de fix max_cost race contition when price changes 2025-08-22 17:22:48 -03:00
Shroominic
6c53c0661c Merge remote-tracking branch 'origin/HEAD' into reserved-balance-and-fixes 2025-08-22 16:43:36 -03:00
Shroominic
00f5ee1dfe fix tests 2025-08-22 16:41:53 -03:00
Shroominic
74d603dc12 edit insufficient balance error msg 2025-08-22 16:07:00 -03:00
shroominic
cb91136192 Merge pull request #147 from Routstr/fix-balance-refund-on-error
Apply proxy.py changes from reserved-balance-and-fixes branch
2025-08-22 15:53:42 -03:00
shroominic
3fd6eee9af Merge pull request #148 from Routstr/small-fixes
Apply .env.example and Dockerfile changes from reserved-balance-and-f…
2025-08-22 15:53:01 -03:00
Shroominic
8c6a6f65a5 fix ruff linting 2025-08-22 15:52:21 -03:00
Shroominic
dbc4f68ea3 Revert proxy.py changes - moved to proxy-changes-only branch 2025-08-22 15:38:42 -03:00
Shroominic
4eac998bc3 Revert .env.example and Dockerfile changes - moved to separate branches 2025-08-22 15:37:48 -03:00
Shroominic
2b94185918 Apply .env.example and Dockerfile changes from reserved-balance-and-fixes branch 2025-08-22 15:36:39 -03:00
Shroominic
6d46f86964 Apply proxy.py changes from reserved-balance-and-fixes branch 2025-08-22 15:29:20 -03:00
Shroominic
62dd42f418 more loggging 2025-08-22 15:27:13 -03:00
Shroominic
a42f3b63f3 add reserved balance test 2025-08-22 15:26:55 -03:00
Shroominic
44c2dd1e30 undo Field edit 2025-08-22 15:22:34 -03:00
Shroominic
cf64210eeb Merge branch 'main' into reserved-balance-and-fixes 2025-08-22 15:21:36 -03:00
Shroominic
8ed75325a1 change tests to work 2025-08-22 15:19:13 -03:00
Shroominic
f2b73f5600 fix reserved_balance/total_balance logic 2025-08-22 15:18:46 -03:00
shroominic
44d8b8f738 Merge pull request #145 from Routstr/fix-swap_to_primary_mint
fix swap_to_primary_mint float bug + fee calc bug
2025-08-22 11:53:14 -03:00
shroominic
be4616e608 Merge pull request #144 from Routstr/improve-balance-api
improve balance api
2025-08-22 11:50:16 -03:00
shroominic
cc52857a9e undo changes test.yml 2025-08-22 11:33:17 -03:00
shroominic
45a81eabf2 Delete pytest.xml 2025-08-22 11:31:46 -03:00
shroominic
558b6339d9 Delete coverage.xml 2025-08-22 11:31:32 -03:00
Shroominic
b4c69df891 fix logs 2025-08-22 11:24:31 -03:00
Shroominic
de1f40b350 wip todos 2025-08-22 11:23:09 -03:00
Shroominic
5831c3e4d4 undo total_balance change 2025-08-22 11:22:59 -03:00
Shroominic
d036b7ac24 fix revert_pay_for_request 2025-08-22 11:19:34 -03:00
Cursor Agent
a629b903e0 Add test coverage and JUnit XML reporting to CI workflow
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-22 13:59:36 +00:00
shroominic
ed99996985 fix swap_to_primary_mint float bug + fee calc bug 2025-08-22 13:45:16 +00:00
shroominic
be9a71b3e9 improve balance api 2025-08-21 21:50:10 +00:00
Shroominic
5a0ecad6f1 no need for async 2025-08-21 15:54:29 -03:00
shroominic
e9302bdfcb Merge pull request #141 from Routstr/improve-latency-by-3s
improve latency by 3s
2025-08-21 15:38:29 -03:00
Shroominic
3af531aacd improve latency by 3s 2025-08-21 15:36:51 -03:00
shroominic
47d65b57f7 Merge pull request #140 from Routstr/fix-wallet-problems
batch checkstates + lru cache for wallet objects
2025-08-21 14:12:19 -03:00
Shroominic
983b3a1b23 fix pytests 2025-08-20 13:34:28 -03:00
Shroominic
35134e5401 fix caching 2025-08-20 10:39:27 -03:00
Shroominic
cb72d73ced fix 2025-08-20 10:25:33 -03:00
Shroominic
dbd9b72a23 batch checkstates + lru cache for wallet objects 2025-08-20 10:18:44 -03:00
Shroominic
6a835047d3 comment out .env.example wip 2025-08-19 16:54:33 -03:00
Shroominic
4a338505cc reserved balance wip 2025-08-19 16:54:17 -03:00
Shroominic
0270bf2ca6 optimize docker build 2025-08-19 16:53:55 -03:00
Shroominic
9ae0e4a238 discovery wip 2025-08-18 12:45:57 -03:00
Shroominic
5e442d139c Merge remote-tracking branch 'origin/main' into cursor/discover-and-announce-routstr-providers-3b9a 2025-08-18 12:44:07 -03:00
shroominic
f784a2a36d Merge pull request #139 from Routstr/x-routstr-request-id-header
x-routstr-request-id header
2025-08-18 12:14:49 -03:00
Shroominic
78c0ee73b7 Merge branch 'main' into cursor/discover-and-announce-routstr-providers-3b9a 2025-08-18 11:21:05 -03:00
Shroominic
31f53f7904 fix pytests 2025-08-17 22:07:38 -03:00
Shroominic
9f485e4dbb x-routstr-request-id header 2025-08-17 22:03:35 -03:00
shroominic
cea6ddcd03 Merge pull request #138 from Routstr/132-fix-v1balancerefund-bugs
132 fix v1balancerefund bugs
2025-08-17 17:11:47 -03:00
Shroominic
7fce5318b6 fmt 2025-08-17 17:09:17 -03:00
Shroominic
56cc14928c fix tests 2025-08-17 17:08:40 -03:00
Shroominic
97cdc0dbcd fix msat balance convertsion 2025-08-17 16:59:21 -03:00
Shroominic
cf24cefc1f fix msat/sat balance indicator 2025-08-17 16:59:06 -03:00
Shroominic
71dbbe44dd change /models to include_in_schema=False 2025-08-17 16:57:39 -03:00
shroominic
8e8c32151d Merge pull request #135 from Routstr/133-admin-dashboard-multi-currency
multi mint admin dashboard
2025-08-17 15:50:01 -03:00
Shroominic
84b903a6c2 multi mint admin dashboard 2025-08-17 15:48:12 -03:00
shroominic
f8665400cd Merge pull request #131 from Routstr/129-implement-periodic_payout-to-lnurl
Implement periodic payout to owners lnurl
2025-08-16 16:34:45 -03:00
Shroominic
9e41f05742 fix refund 2025-08-16 16:33:06 -03:00
Shroominic
150918c5a7 refactor wallet, periodic payouts and LNURL payments 2025-08-15 16:50:30 -03:00
Shroominic
9f89e0485e add LNURL helpers 2025-08-15 16:49:04 -03:00
Shroominic
ff3d268192 add balances_for_mint_and_unit db method 2025-08-15 16:48:43 -03:00
shroominic
d5800335a6 Merge pull request #128 from Routstr/124-msat---sat-non-trusted-swap-not-working
fixxxx msat -> sat non trusted swap not working #124
2025-08-14 14:09:09 -03:00
Shroominic
1858f7dd28 fixxxx 2025-08-13 23:37:21 -03:00
shroominic
c300082fd7 Merge pull request #127 from Routstr/fix-msat-refund
Fix msat refund
2025-08-13 17:56:22 -03:00
Shroominic
192518c9df fix tests 2025-08-13 17:54:04 -03:00
Shroominic
558d442cd1 dont include fees 2025-08-13 17:11:36 -03:00
Shroominic
07257d5682 refund api keys with mint+currency details 2025-08-13 16:52:26 -03:00
Shroominic
f4d6762baa init api keys with refund mint+currency details 2025-08-13 16:52:06 -03:00
Shroominic
dda016669f add refund mint+currency to api table 2025-08-13 16:51:00 -03:00
shroominic
5d3d80c386 Merge pull request #126 from Routstr/fix-and-improve-logging
Fix and improve logging
2025-08-12 20:26:32 -03:00
shroominic
9bab7aee7d rm prints 2025-08-12 15:41:05 -03:00
Shroominic
6924f6c18a rm alembic logging 2025-08-12 15:31:17 -03:00
Shroominic
8360d2a6fd feat: add log investigation feature and modern UI to admin dashboard
- Add 'Investigate Logs' button with modal for request ID input
- Implement /admin/logs/{request_id} endpoint for log viewing
- Parse and display JSON log entries with formatting
- Search through last 7 days of log files
- Add modern minimal CSS design system
- Improve typography with system font stack
- Add card layouts, shadows, and rounded corners
- Implement smooth transitions and hover effects
- Add emoji icons for visual interest
- Rename 'User's API Keys' to 'Temporary Balances'
2025-08-12 15:30:02 -03:00
Shroominic
de5c2bd502 feat: add comprehensive logging to proxy endpoints
- Log complete request flow from entry to completion
- Add detailed bearer token validation logging
- Track payment processing with balance changes
- Log streaming vs non-streaming response detection
- Add categorized error logging (connection, timeout, network)
- Use key hash for secure tracking without exposing full keys
- Log response type analysis for chat completions
- Track failed request payment reversals
2025-08-12 15:29:45 -03:00
Shroominic
eeb458f8fe feat: enhance logging in payment processing modules
- Add comprehensive logging for cost calculations and pricing
- Log token validation with secure previews (first 20 chars)
- Add detailed logging for X-Cashu token processing flow
- Implement specific error categorization for CASHU errors
- Add logging for streaming response handling and usage extraction
- Log refund processing with retry attempts
- Track header modifications in upstream requests
- Include request IDs in error responses
2025-08-12 15:29:30 -03:00
Shroominic
185f060b54 feat: integrate logging into FastAPI application
- Initialize logging configuration at startup
- Add LoggingMiddleware for request tracking
- Add structured logging for application lifecycle events
- Register exception handlers for better error responses
- Add proper error handling in startup/shutdown
2025-08-12 15:29:14 -03:00
Shroominic
9c4b827d74 feat: add comprehensive logging infrastructure
- Add centralized logging configuration with daily rotation
- Implement custom TRACE log level for detailed debugging
- Add security filter to redact sensitive data (tokens, keys, passwords)
- Add request ID tracking via middleware and context variables
- Add version filter to include package version in all logs
- Implement structured JSON logging for production
- Add exception handlers with request ID tracking
- Configure Rich console handler for development
2025-08-12 15:29:01 -03:00
Shroominic
b552aea520 fix folder name 2025-08-12 15:02:22 -03:00
Shroominic
5173e0f133 convert prints to logging 2025-08-12 15:02:15 -03:00
Shroominic
67348d7fb2 rm alembic logging override 2025-08-12 14:36:22 -03:00
shroominic
4c15114fc0 Merge pull request #125 from Routstr/migrate-docker-smoothly
Migrate docker smoothly
2025-08-11 14:43:00 -03:00
Shroominic
3d42960d20 fmt 2025-08-11 14:42:22 -03:00
Shroominic
4c3d11f09c migrate-docker-smoothly 2025-08-11 14:41:44 -03:00
shroominic
432fa25ff8 Merge pull request #122 from Routstr/fix-msat-bearer-payments
fix msat Bearer payments
2025-08-11 00:02:21 -03:00
Shroominic
c35464fb11 fix tests 2025-08-10 23:58:58 -03:00
Shroominic
bb32844104 Merge remote-tracking branch 'origin/main' into fix-msat-bearer-payments 2025-08-10 23:44:49 -03:00
shroominic
ec5eeae49f Merge pull request #123 from Routstr/fix-ci
fix CI
2025-08-10 23:43:55 -03:00
Shroominic
d75939b547 fix CI 2025-08-10 23:41:56 -03:00
Shroominic
8a8aeeab81 Merge branch 'main' into fix-msat-bearer-payments 2025-08-10 23:39:21 -03:00
shroominic
7eb88346db Merge pull request #117 from Routstr/chdir-router-routstr
change dir 'router' -> 'routstr'
2025-08-10 23:38:14 -03:00
Shroominic
0451ca5bd9 fix msat Bearer payments 2025-08-10 23:32:35 -03:00
Shroominic
a87793b395 Merge remote-tracking branch 'origin/main' into chdir-router-routstr 2025-08-10 22:02:16 -03:00
shroominic
6b9417aa68 Merge pull request #120 from Routstr/fix-bug-in-sats-pricing-calculation
fix: bug in sats pricing calculation
2025-08-10 21:38:56 -03:00
Shroominic
277924c777 fix: bug in sats pricing calculation 2025-08-10 20:40:08 -03:00
Cursor Agent
dedb519d9a Remove RIP-02 support, focus on NIP-91 provider discovery
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-10 19:06:01 +00:00
Cursor Agent
2a934dbda4 Support RIP-02 alongside NIP-91 for provider discovery protocol
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-10 18:50:54 +00:00
Cursor Agent
d1cb123f91 Merge branch 'main' into chdir-router-routstr - Resolved conflict by keeping deletion of setup.py 2025-08-10 18:50:50 +00:00
Cursor Agent
6b1f432c58 Remove RIP-02 support, migrate to NIP-91 provider discovery protocol
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-10 18:48:09 +00:00
Cursor Agent
8dc683e6ed Update provider discovery and test data to match RIP-02 event structure
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-10 18:18:32 +00:00
Cursor Agent
dff80b9e5d Add NIP-91 provider discovery and auto-announcement support
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-08-10 18:08:24 +00:00
shroominic
f2890d819e Merge pull request #115 from Routstr/shroominic-patch-1
fix container.yml
2025-08-10 13:49:06 -03:00
Shroominic
48b40e196b add core tag, still maintain deprecated proxy tag 2025-08-10 13:48:44 -03:00
shroominic
7b405258ad Merge pull request #113 from Routstr/comprehensive-CONTRIBUTING.md-file
comprehensive CONTRIBUTING.md file
2025-08-09 15:02:44 -03:00
shroominic
491dd48eee Merge pull request #116 from Routstr/fix-migrations
Fix migrations
2025-08-09 15:01:49 -03:00
Shroominic
88a0a41201 fix 2025-08-09 14:57:55 -03:00
Shroominic
503e861621 change dir 'router' -> 'routstr' 2025-08-09 14:55:26 -03:00
Shroominic
1cfa8cbee4 only run if not there yet 2025-08-09 14:28:41 -03:00
Shroominic
194c1d457d rm setup 2025-08-09 14:23:29 -03:00
shroominic
efb5247cdf fix container.yml 2025-08-09 14:00:18 -03:00
shroominic
37c00d1e55 Merge pull request #29 from Routstr/codex/add-alembic-migrations-for-sqlmodel
Add Alembic migrations
2025-08-09 13:55:21 -03:00
Shroominic
34859d94d3 usuful migration to test 2025-08-09 13:50:27 -03:00
Shroominic
2698d0fa81 auto run migrations on startup 2025-08-09 13:50:05 -03:00
Shroominic
12d2d3714f rm non shortcuts 2025-08-09 13:49:17 -03:00
Shroominic
b3208ffe61 fix typing 2025-08-09 13:49:07 -03:00
Shroominic
c31f0f1603 add db migration info 2025-08-09 13:43:49 -03:00
Shroominic
65ef300a06 add alembic 2025-08-09 13:43:39 -03:00
Shroominic
13c70722b5 initial migrartion 2025-08-09 13:31:07 -03:00
Shroominic
c2acaec288 add sqlmodel 2025-08-09 13:31:00 -03:00
Shroominic
1850f7b5b9 fixes 2025-08-09 13:28:20 -03:00
Shroominic
30756816c4 Merge main into codex/add-alembic-migrations-for-sqlmodel 2025-08-09 13:08:24 -03:00
Shroominic
59924661cd comprehensive CONTRIBUTING.md file 2025-08-09 12:54:46 -03:00
shroominic
c85d6423be Merge pull request #78 from kwsantiago/kwsantiago/62-comprehensive-tests
feat: Implement Comprehensive Integration Tests
2025-08-09 12:46:38 -03:00
Shroominic
e5e4888dba makefile + test improvements 2025-08-09 12:44:21 -03:00
Shroominic
941bb5f052 fix linting 2025-08-09 12:43:44 -03:00
Shroominic
94de9c68c8 fix docker/local testmode 2025-08-09 12:43:31 -03:00
Kyle
8bb2191fcc ruff check 2025-08-08 23:35:55 -04:00
Kyle
a85d7157a3 fix ruff check 2025-08-08 23:26:01 -04:00
Kyle
1036a6d85a fix edge cases for tests 2025-08-08 19:00:59 -04:00
Kyle
580dd375b6 test fixes 2025-08-08 18:26:25 -04:00
Kyle
ead00ec25a Merge remote-tracking branch 'upstream/main' into kwsantiago/62-comprehensive-tests 2025-08-08 17:26:48 -04:00
shroominic
922b0f15a6 Merge pull request #108 from Routstr/fix-proof-selection-bug
fix proof selection bug
2025-08-08 00:03:05 -03:00
Shroominic
286c0a7d02 fix proof selection bug 2025-08-08 00:01:32 -03:00
Kyle
f0bb897f75 test fixes 2025-08-06 23:48:13 -04:00
Kyle
2aebef7722 test fixes 2025-08-06 23:38:18 -04:00
Shroominic
eb5d832719 fix mypy + ruff linting 2025-08-06 23:25:06 -03:00
Kyle
9ad4661111 Merge remote-tracking branch 'upstream/main' into kwsantiago/62-comprehensive-tests 2025-08-06 21:45:01 -04:00
Kyle
e032723294 new unit tests 2025-08-06 21:44:56 -04:00
Kyle
00b8933758 cleanup 2025-08-06 21:44:49 -04:00
Kyle
2918548990 cleanup 2025-08-06 21:33:00 -04:00
shroominic
dd9a133a99 Merge pull request #105 from Routstr/fix-models-auto-fetch
Fix models auto fetch
2025-08-06 22:15:28 -03:00
Shroominic
2e8140daa0 rm models.example usage 2025-08-06 22:14:57 -03:00
Shroominic
ba987485f2 rm redundant info logs 2025-08-06 22:14:40 -03:00
Shroominic
5484e606b8 rm relay-data 2025-08-06 22:14:27 -03:00
Shroominic
e12c62a133 Merge branch 'main' into kwsantiago/62-comprehensive-tests 2025-08-06 20:56:11 -03:00
shroominic
2cfd537d2e Merge pull request #104 from Routstr/auto-load-models
auto load models in memory
2025-08-06 20:47:58 -03:00
Shroominic
0558270c05 dump wip stash 2025-08-06 20:31:55 -03:00
Shroominic
0babd93504 auto load models in memory 2025-08-06 19:50:08 -03:00
shroominic
6fdbdb2bba Merge pull request #102 from Routstr/fix-x-cashu-refund-when-payment-failed-to-be-validated
Fix x cashu refund when payment failed to be validated
2025-08-06 19:31:14 -03:00
shroominic
53c699043c Merge pull request #98 from Routstr/fixed-top-provider-bug-again
fixed a bug with top provider not being null but the variables inside…
2025-08-06 19:28:46 -03:00
shroominic
6b02a40a06 Merge pull request #101 from Routstr/model-collecting
update model collecting to filter by source
2025-08-06 19:28:20 -03:00
Shroominic
e4ee03f7a0 fix mypy 2025-08-06 19:23:50 -03:00
shroominic
25371d2acc Merge pull request #100 from Routstr/fix-openai-upstream-model-name-response
fix resolve model name response
2025-08-06 19:21:06 -03:00
shroominic
6986723388 Merge pull request #95 from Routstr/fix-xcashu-refund
fix refund
2025-08-06 19:19:20 -03:00
shroominic
c505db3596 Merge pull request #99 from Routstr/local-models-networking
Added netowrking to access locally run models
2025-08-06 19:18:28 -03:00
9qeklajc
77e6896ff3 fix 2025-08-07 00:11:47 +02:00
9qeklajc
477c67ebeb remove redundant changes 2025-08-07 00:10:45 +02:00
9qeklajc
67604242de revert 2025-08-07 00:09:30 +02:00
9qeklajc
6454b604eb update to collect all openrouter models 2025-08-06 22:58:20 +02:00
9qeklajc
632857b52a fix fmt 2025-08-06 22:57:02 +02:00
9qeklajc
a8c7897fd9 update model collecting to filter by source 2025-08-06 22:53:31 +02:00
9qeklajc
1a841432c4 fix resolve model name response 2025-08-06 22:50:21 +02:00
redshift
037f4222fc Added netowrking to access locally run models 2025-08-06 16:36:28 -03:00
redshift
17414065d1 fixed a bug with top provider not being null but the variables inside were null 2025-08-06 13:28:05 -03:00
shroominic
d1aef23626 release-v-0-1-0
v 0 1 0
2025-08-05 21:53:46 -03:00
Shroominic
a6b756cff0 update introduction 2025-08-05 21:53:04 -03:00
Shroominic
f428410a24 update example env 2025-08-05 21:52:31 -03:00
Shroominic
ba74932120 default CASHU_MINTS 2025-08-05 21:52:22 -03:00
Shroominic
63dc47008f better error msg 2025-08-05 21:50:04 -03:00
Shroominic
9612461fe9 ⬆️ v0.1.0 2025-08-05 20:18:42 -03:00
Shroominic
ca748edaf5 rm refund task 2025-08-05 20:16:47 -03:00
Shroominic
95e320f823 rm annoying not needed logs 2025-08-05 20:11:42 -03:00
9qeklajc
5d82dc4d26 Merge pull request #94 from Routstr/add-missing-endpoint-for-model-discovery
fix model discovery
2025-08-06 00:53:03 +02:00
9qeklajc
013f17a5aa revert 2025-08-06 00:51:29 +02:00
9qeklajc
384cc2be21 clean up 2025-08-06 00:50:25 +02:00
9qeklajc
76ea74e8ce fix refund 2025-08-06 00:49:34 +02:00
9qeklajc
b7576663b5 fix model discovery 2025-08-05 19:41:16 +02:00
redshift
92318f0b00 Merge pull request #92 from Routstr/fix-context-length-cost-calc
Fix context length cost calculation
2025-08-05 17:01:11 +00:00
Shroominic
5dce680d11 merge 2025-08-05 12:50:27 -03:00
Kyle Santiago
eef07fabfa Merge remote-tracking branch 'upstream/main' into kwsantiago/62-comprehensive-tests 2025-08-04 19:26:24 -04:00
Kyle Santiago
bd354ac3e8 pydantic v1 + import fixes 2025-08-04 19:26:19 -04:00
Shroominic
01beaa93c6 pydantic v1 2025-08-04 14:40:10 -03:00
Shroominic
3845b11951 fix tests 2025-08-04 01:21:14 -03:00
Shroominic
5afa0ba4bc fix context length cost calculation 2025-08-03 23:57:00 -03:00
Shroominic
490e74e39d wip xcashu test 2025-08-03 20:37:05 -03:00
Shroominic
d1d2417197 integration testing script (copied from sixty nuts) 2025-08-03 20:36:22 -03:00
Shroominic
0bba63ca4d move unit tests 2025-08-03 20:35:59 -03:00
Shroominic
91d39f3abe testing environment 2025-08-03 20:35:36 -03:00
Shroominic
1fd4badff4 undo change 2025-08-03 16:02:37 -03:00
Shroominic
f91bed3532 simplify topup token validation 2025-08-03 16:01:53 -03:00
redshift
da7c852210 Merge pull request #90 from Routstr/fix-max_cost_for_model-bug
This fixed a bug where the max cost was deducted from user's balance before prompt completion and max cost was later added to user's balance BUT if the bitcoin price changed in this short period of time, the max cost later added would be different and user's balance would be deducted more or less than actual costs. 

Bug found by @sh1ftred while working on evals.
2025-08-03 19:00:12 +00:00
Shroominic
346c239b06 Merge branch 'main' into kwsantiago/62-comprehensive-tests 2025-08-03 15:55:43 -03:00
Shroominic
7323b2f536 fix max_cost_for_model bug 2025-08-02 23:32:15 -03:00
shroominic
9bcaa84014 Merge pull request #89 from Routstr/option-to-set-provider-fees
option to set provider fees
2025-08-02 22:11:15 -03:00
Shroominic
22aa55e3d6 option to set provider fees 2025-08-02 22:10:55 -03:00
shroominic
416730a3e8 Merge pull request #88 from Routstr/deserialize_token_from_string
simplify deserialize_token_from_string
2025-08-02 22:08:32 -03:00
Shroominic
51001bb31a simplify deserialize_token_from_string 2025-08-02 22:08:04 -03:00
shroominic
dbbb1b51b8 Merge pull request #87 from Routstr/dev-logging
dev logging
2025-08-02 22:07:09 -03:00
Shroominic
01e14d3f35 dev logging 2025-08-02 22:05:19 -03:00
redshift
586a8f19e0 Merge pull request #86 from Routstr/rounding-msats
Fixed rounding problem as msats were rounding down instead of roundin…
2025-08-03 00:58:06 +00:00
redshift
1482fef8e1 Fixed rounding problem as msats were rounding down instead of rounding up. 2025-08-02 21:55:18 -03:00
shroominic
bc3f4d611a Merge pull request #85 from Routstr/multi-mint-support
multimints
2025-08-02 21:34:37 -03:00
Shroominic
f8e805088b fix multimint 2025-08-02 21:32:54 -03:00
shroominic
ac648cfc43 Merge pull request #83 from Routstr/cleanup
Cleanup and smaller fixes
2025-08-02 14:58:32 -03:00
Shroominic
1cb88603ec raise if mint not supported 2025-08-02 14:58:06 -03:00
Shroominic
b4e919ebba better error responses 2025-08-02 14:25:29 -03:00
Shroominic
731bd8d274 fix "sat" xcashu cost calculation 2025-08-02 14:25:20 -03:00
Shroominic
658fc0c4f5 improve logging 2025-08-02 14:05:22 -03:00
Shroominic
2a56998450 refactor names and folders 2025-08-02 14:04:37 -03:00
Shroominic
d94a27d16e rm unused 2025-08-02 13:11:21 -03:00
Shroominic
6bc963de77 rm comments 2025-08-02 13:10:56 -03:00
Shroominic
a7831952b9 fix tests 2025-08-02 12:33:42 -03:00
Shroominic
bc12d23f5b fix linting errors 2025-08-02 00:08:16 -03:00
Shroominic
7f9555227f bump stable release version 2025-08-02 00:06:46 -03:00
Shroominic
c81f024d7d wip provider publish script 2025-08-02 00:06:31 -03:00
Shroominic
bc651f67e8 update discovery 2025-08-02 00:06:20 -03:00
Shroominic
13d6d5bd60 fix cashuA parsing 2025-08-02 00:05:37 -03:00
Shroominic
b313017492 fix mint advertisment 2025-08-01 22:31:40 -03:00
Shroominic
c6d7939d0a update examples 2025-08-01 22:31:18 -03:00
Shroominic
72b6d5afbd rm 2025-08-01 22:31:07 -03:00
shroominic
4755a8c0e0 Merge pull request #82 from Routstr/fixpytests
fix pytests
2025-08-01 19:16:07 -03:00
Shroominic
ca29526761 fix pytests 2025-08-01 19:15:31 -03:00
shroominic
07600b9ff8 Merge pull request #80 from Routstr/nutshell-wallet
Temp Nutshell wallet
2025-08-01 19:00:12 -03:00
Shroominic
1b1279f111 fix tests 2025-08-01 18:59:33 -03:00
redshift
179e27953c Update README.md 2025-08-01 21:54:07 +00:00
Shroominic
e0c93cafd6 rm old cashu 2025-08-01 18:52:40 -03:00
Shroominic
4266f70256 fix balance 2025-08-01 18:46:21 -03:00
Shroominic
a968b8a086 fix dashboard 2025-08-01 18:11:59 -03:00
Shroominic
00c1e58504 admin dashboard 2025-08-01 17:15:47 -03:00
Shroominic
ff5b512bb8 fix console logging 2025-08-01 15:58:22 -03:00
Shroominic
067deda386 fix credit balance 2025-08-01 15:58:11 -03:00
Shroominic
7137a662e6 fix credit balance 2025-08-01 15:57:42 -03:00
Shroominic
6cabd736f4 rm PRIMARY MINT URL 2025-08-01 15:01:27 -03:00
Shroominic
1bbff6c057 fix lints 2025-08-01 14:56:15 -03:00
Shroominic
e8bed5b20f fix local cashu wallet location 2025-08-01 14:03:14 -03:00
Shroominic
8317af969c fix marshmallow package 2025-08-01 13:50:12 -03:00
Shroominic
ed7f86ce01 nutshell impl 2025-08-01 13:44:57 -03:00
Shroominic
848af6992a refactor 2025-08-01 12:51:31 -03:00
Shroominic
27c5721cd8 fix logging in interface 2025-08-01 12:42:14 -03:00
Shroominic
90f8fb6a0d fix deps 2025-08-01 12:41:33 -03:00
Kyle Santiago
a88bd0b807 fixes for failing CI tests 2025-07-31 14:24:17 -04:00
Kyle Santiago
588ad0e7e9 Merge upstream main and resolve uv.lock conflict 2025-07-31 14:16:33 -04:00
9qeklajc
ee38d0011c Merge pull request #77 from Routstr/add-logging
add logging
2025-07-30 08:01:28 +02:00
9qeklajc
f37d9c9c73 revert changes 2025-07-29 22:44:58 +02:00
9qeklajc
6d12af65b1 retry send when failed 2025-07-27 23:24:47 +02:00
Kyle Santiago
a2cbc6060d fix failing CI tests 2025-07-26 18:53:01 -04:00
Kyle Santiago
45c7adf65c fix for real testmint 2025-07-26 15:18:31 -04:00
Kyle Santiago
d253195d06 feat: Implement Comprehensive Integration Tests 2025-07-26 14:53:59 -04:00
shroominic
b1e0d82d61 Update models.py 2025-06-13 14:46:33 +02:00
shroominic
93ebbc77d8 Update models.py 2025-06-13 14:45:05 +02:00
shroominic
5dfc4fcba9 Update models.py 2025-06-13 14:44:22 +02:00
shroominic
c183006317 Merge branch 'main' into codex/add-alembic-migrations-for-sqlmodel 2025-06-13 14:43:21 +02:00
shroominic
dabf20db4b Fix alembic config and migration 2025-06-09 23:46:19 +02:00
325 changed files with 71961 additions and 5764 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.env
.venv
.git
.gitignore
.dockerignore
compose.yml
compose.testing.yml
.todo
.github
.vscode
.DS_Store
**/node_modules
ui/.next

View File

@@ -1,48 +1,43 @@
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"
# 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

View File

@@ -25,13 +25,11 @@ 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: .
push: true
tags: |
${{ env.IMAGE_TAG }}
ghcr.io/routstr/proxy:latest
ghcr.io/routstr/core:latest

56
.github/workflows/docs.yml vendored Normal file
View 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

View File

@@ -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: "18"
cache: "pnpm"
cache-dependency-path: ui/pnpm-lock.yaml
- name: Install UI dependencies
working-directory: ./ui
run: pnpm install --frozen-lockfile
- name: Run UI format check
working-directory: ./ui
run: pnpm run format-check
- name: Run UI linting
working-directory: ./ui
run: pnpm run lint
- name: Run UI build
working-directory: ./ui
run: pnpm run build

16
.gitignore vendored
View File

@@ -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
View File

@@ -0,0 +1 @@
3.11

5
.todo
View File

@@ -1,5 +0,0 @@
- test if currency and payment amount is correct
- test payout
- make tor work
-

370
CONTRIBUTING.md Normal file
View 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.

View File

@@ -12,8 +12,10 @@ RUN apk add --no-cache \
RUN apk add git
COPY uv.lock pyproject.toml ./
RUN mkdir -p /routstr
RUN uv sync
RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060
# RUN uv sync
WORKDIR /app
@@ -24,4 +26,4 @@ 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"]

285
Makefile Normal file
View 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) routstr/ --ignore-missing-imports
format:
@echo "✨ Formatting code..."
$(RUFF) format .
$(RUFF) check --fix .
type-check:
@echo "🔎 Running type checks..."
$(MYPY) routstr/ --ignore-missing-imports
# 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) routstr/ --ignore-missing-imports --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)

194
README.md
View File

@@ -1,159 +1,69 @@
# Routstr Payment Proxy
Routstr is a FastAPI-based reverse proxy that sits in front of any OpenAI-compatible API. It handles pay-per-request billing using the [Cashu](https://cashu.space/) eCash protocol on Bitcoin and tracks usage in a local SQL database.
[![License](https://img.shields.io/github/license/routstr/routstr-core?style=flat-square)](LICENSE)
[![Stars](https://img.shields.io/github/stars/routstr/routstr-core?style=flat-square)](https://github.com/routstr/routstr-core/stargazers)
[![Issues](https://img.shields.io/github/issues/routstr/routstr-core?style=flat-square)](https://github.com/routstr/routstr-core/issues)
[![Release](https://img.shields.io/github/v/release/routstr/routstr-core?style=flat-square)](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 and configure upstream access in the dashboard.
```bash
docker run -d \
--name routstr-proxy \
-p 8000:8000 \
ghcr.io/routstr/proxy:latest
```
## 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
View 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
View 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
View 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

View File

@@ -1,17 +1,35 @@
version: '3.8'
services:
router:
ui:
env_file:
- .env
build:
context: ./ui
dockerfile: Dockerfile.build
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
NEXT_PUBLIC_ADMIN_API_KEY: ${NEXT_PUBLIC_ADMIN_API_KEY:-}
volumes:
- ./ui_out:/output
command:
["sh", "-c", "mkdir -p /output && cp -r /app/built/. /output/ && echo 'UI build copied to mounted volume' && ls -la /output/ && echo 'UI built and ready' && tail -f /dev/null"]
routstr:
build: .
depends_on:
- ui
volumes:
- .:/app
- ./logs:/app/logs
- tor-data:/var/lib/tor:ro
- ./ui_out:/app/ui_out:ro
env_file:
- .env
environment:
- 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 +37,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
View 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

566
docs/api/endpoints.md Normal file
View File

@@ -0,0 +1,566 @@
# 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"
}
```
### 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
View 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
View File

@@ -0,0 +1,312 @@
# API Reference Overview
Routstr Core provides a complete OpenAI-compatible API with additional endpoints for payment management. This reference covers all available endpoints, authentication methods, and response formats.
## Base URL
```
https://api.routstr.com/v1
```
All API endpoints are prefixed with `/v1` for versioning.
## Authentication
Routstr uses API keys for authentication. Include your key in the Authorization header:
```bash
Authorization: Bearer sk-...
```
### API Key Format
- Prefix: `sk-`
- Length: 32 characters
- Example: `sk-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p`
### Cashu Tokens as Authentication
You can also use a Cashu eCash token directly in the `Authorization` header. The server hashes the token internally; this hash represents your API key identity and carries the token's balance.
```bash
Authorization: Bearer cashuAeyJ0b2tlbiI6W3...
```
## Content Types
### Request
- **Required**: `Content-Type: application/json`
- **Encoding**: UTF-8
- **Maximum Size**: 10MB (configurable)
### Response
- **Type**: `application/json` or `text/event-stream` (for streaming)
- **Encoding**: UTF-8
- **Compression**: gzip (if accepted)
## Rate Limiting
Rate limits are applied per API key:
- **Requests**: 1000 per minute
- **Tokens**: 1,000,000 per hour
- **Concurrent**: 10 simultaneous requests
Rate limit headers:
```
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1640995200
```
## Error Responses
All errors follow a consistent format:
```json
{
"error": {
"type": "insufficient_balance",
"message": "Insufficient balance for request",
"code": "payment_required",
"details": {
"required": 154,
"available": 100
}
}
}
```
### Error Types
| Type | Status Code | Description |
|------|-------------|-------------|
| `invalid_request` | 400 | Malformed request |
| `authentication_failed` | 401 | Invalid or missing API key |
| `insufficient_balance` | 402 | Not enough balance |
| `forbidden` | 403 | Access denied |
| `not_found` | 404 | Resource not found |
| `rate_limit_exceeded` | 429 | Too many requests |
| `internal_error` | 500 | Server error |
| `upstream_error` | 502 | Upstream API error |
## Endpoint Categories
### AI/ML Endpoints
Standard OpenAI-compatible endpoints:
- **Models**: `/v1/models`
- **Responses**: `/v1/responses`
- **Chat Completions**: `/v1/chat/completions`
- **Embeddings**: `/v1/embeddings`
- **Completions**: `/v1/completions` *(planned)*
- **Images**: `/v1/images/generations` *(planned)*
- **Audio**: `/v1/audio/transcriptions` *(planned)*
### Payment Endpoints
Routstr-specific payment management:
- **Balance**: `/v1/balance/*`
- **Node Info**: `/v1/info`
### Admin Endpoints
Protected administrative functions:
- **Dashboard**: `/admin/`
- **API Management**: `/admin/api/*`
## Request Headers
### Standard Headers
| Header | Required | Description |
|--------|----------|-------------|
| `Authorization` | Yes | Bearer token with API key |
| `Content-Type` | Yes | Must be `application/json` |
| `Accept` | No | Response format preference |
| `Accept-Encoding` | No | Compression support |
| `X-Request-ID` | No | Client-provided request ID |
### Custom Headers
| Header | Description |
|--------|-------------|
| `X-Cashu` | eCash token for per-request payment |
#### X-Cashu: Stateless Per-Request Payment
Instead of using `Authorization: Bearer sk-...`, you can send a Cashu token directly in the `X-Cashu` header. The response will include an `X-Cashu-Refund` header with your change.
```bash
curl https://api.routstr.com/v1/chat/completions \
-H "X-Cashu: cashuA3s8jKx9..." \
-H "Content-Type: application/json" \
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]}'
```
The response includes your change in the same header:
```
X-Cashu: cashuA7k2mNp4...
```
This is fully stateless—no session, no `/v1/balance/refund` call needed. However, **streaming does not work with `X-Cashu`** because the refund can only be calculated after the full response is generated.
## Response Headers
### Standard Headers
| Header | Description |
|--------|-------------|
| `Content-Type` | Response format |
| `Content-Length` | Response size |
| `X-Request-ID` | Unique request identifier |
| `X-Cashu` | Change token (when request used `X-Cashu` header) |
## Streaming Responses
For endpoints supporting streaming, responses use Server-Sent Events:
```
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":" there"},"finish_reason":null}]}
data: [DONE]
```
## OpenAPI Specification
The complete OpenAPI 3.0 specification is available at:
```
GET /openapi.json
```
Interactive documentation:
```
GET /docs # Swagger UI
GET /redoc # ReDoc
```
## SDK Support
Routstr is compatible with official OpenAI SDKs:
### Python
```python
from openai import OpenAI
client = OpenAI(
api_key="sk-...",
base_url="https://your-node.com/v1"
)
```
### JavaScript/TypeScript
```javascript
import OpenAI from 'openai';
const openai = new OpenAI({
apiKey: 'sk-...',
baseURL: 'https://your-node.com/v1'
});
```
### cURL
```bash
curl https://your-node.com/v1/chat/completions \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello"}]}'
```
## Webhook Support
Configure webhooks for events:
```json
POST /v1/webhooks
{
"url": "https://your-app.com/webhook",
"events": ["balance.low", "key.expired"],
"secret": "whsec_your_secret"
}
```
Events are sent with signature verification:
```
X-Webhook-Signature: sha256=...
```
## API Versioning
- Current version: `v1`
- Version in URL path: `/v1/endpoint`
## Status Codes
| Code | Meaning |
|------|---------|
| 200 | Success |
| 201 | Created |
| 204 | No content |
| 400 | Bad request |
| 401 | Unauthorized |
| 402 | Payment required |
| 403 | Forbidden |
| 404 | Not found |
| 429 | Rate limited |
| 500 | Server error |
| 502 | Upstream error |
| 503 | Service unavailable |
## CORS Support
CORS is enabled with configurable origins:
```
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
```
## Compression
Responses are compressed with gzip when:
- Client sends `Accept-Encoding: gzip`
- Response is larger than 1KB
- Content type is compressible
## Batch Requests *(planned)*
Process multiple operations in one request. Coming soon.
## Node Info
Get node metadata:
```
GET /v1/info
```
Supported models and pricing are available at `/v1/models`.
## Next Steps
- [Authentication](authentication.md) - Detailed auth guide
- [Endpoints](endpoints.md) - Complete endpoint reference
- [Errors](errors.md) - Error handling guide
- [Integration Guide](../client/integration.md) - Code examples

143
docs/client/integration.md Normal file
View 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
View File

@@ -0,0 +1,142 @@
# Introduction to Routstr
Welcome to the Routstr Core User Guide. This guide will help you understand how to use Routstr to access AI APIs with Bitcoin micropayments.
## What You'll Learn
- How the payment system works (Cashu eCash)
- Creating and managing API keys (Ephemeral Sessions)
- Making API calls through Routstr
- Using the admin dashboard
## Prerequisites
### 💰 Wallet
Cashu ([cashu.me](https://cashu.me)) or Lightning ([Strike](https://strike.me), Cash App, etc.)
### 🌐 Provider
A Routstr node, e.g. `https://api.routstr.com`
### 🤖 Client
OpenAI SDK, Claude Code, Cursor, or any OpenAI-compatible tool
---
## How Routstr Works
Routstr is a **Payment Proxy**. It sits between your code and the AI provider.
### Traditional API vs Routstr
| Traditional | Routstr |
|---|---|
| Credit Card Required | Bitcoin / Lightning / eCash |
| Monthly Billing | Pay-per-request (Real-time) |
| KYC / Account | No Account / Private |
| Single Provider | Aggregated Providers |
### Key Concepts
#### 1. Cashu eCash
Digital bearer tokens backed by Bitcoin. They are instant, private, and have no fees for internal transfers. Routstr uses these tokens as the "credits" for API requests.
#### 2. Ephemeral Sessions (API Keys)
Instead of a permanent account, you create a **Session**.
- You fund a session with eCash or Lightning.
- Routstr gives you an `api_key` (`sk-...`) representing that session.
- You use the `api_key` until funds run out or you finish your task.
- You can **refund** the remaining balance back to your wallet at any time.
#### 3. Millisats (msats)
Everything is priced in **millisatoshis**.
- 1 Satoshi (sat) = 1,000 msats.
- This allows for extremely precise pricing (e.g., 0.05 sats per prompt).
---
## Workflow: Zero to Intelligence
### 1. Fund a Session
You need an `api_key` with a balance.
**Easiest: Use the Web UI**
Visit the node's root page (e.g., [api.routstr.com](https://api.routstr.com)) or [chat.routstr.com](https://chat.routstr.com) → Settings to create a key visually with Lightning.
**Option A: Lightning Invoice (CLI)**
Generate an invoice and pay it with any Lightning wallet.
```bash
curl -X POST https://api.routstr.com/lightning/invoice \
-d '{"amount_sats": 1000, "purpose": "create"}'
```
*Returns an invoice (`bolt11`) and an ID. Once paid, the status endpoint returns your `api_key`.*
**Option B: Cashu Token (Best for privacy & devs)**
If you have a Cashu wallet, you can copy a token string (`cashuA...`) and use it directly.
- **Direct Usage**: Use the token *as* your API key in the `Authorization` header.
- **Import**: Or exchange it for a standard `sk-...` key:
```bash
curl "https://api.routstr.com/v1/balance/create?initial_balance_token=cashuA..."
```
*Returns your `api_key` immediately.*
### 2. Configure Your Client
Use the standard OpenAI SDK, just changing the `base_url` and `api_key`.
```python
from openai import OpenAI
client = OpenAI(
base_url="https://api.routstr.com/v1",
api_key="sk-7f8e9d..." # The key from Step 1
)
```
### 3. Make Requests
```python
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Explain quantum computing."}]
)
```
### 4. Withdraw Change
When you are done, get your change back as a Cashu token.
```bash
curl -X POST https://api.routstr.com/v1/balance/refund \
-H "Authorization: Bearer sk-7f8e9d..."
```
*Returns a `token` that you can paste back into Nutstash or Minibits to reclaim your funds.*
---
## Supported Features
- **Responses**: `/v1/responses` (OpenAI Responses API)
- **Chat Completions**: `/v1/chat/completions` (Streaming supported)
- **Embeddings**: `/v1/embeddings`
- **Models**: `/v1/models` (List available models and prices)
## Next Steps
- **[Payment Flow](payments.md)**: Detailed breakdown of the funding lifecycle.
- **[Models & Pricing](../provider/pricing.md)**: How costs are calculated.
- **[Admin Dashboard](../provider/dashboard.md)**: Managing your node if you are the operator.

97
docs/client/payments.md Normal file
View File

@@ -0,0 +1,97 @@
# Payment Flow
Routstr uses a **Pre-paid, Ephemeral** payment model. Pay first, use the funds, withdraw the rest. No accounts, no credit cards, no trails.
```
💰 Deposit → 🤖 Use AI → 💸 Withdraw Change
```
## 1. Creating a Balance (Deposit)
To start making requests, you must create a "Balance" (represented by an API Key).
### Method A: Lightning Network (Bolt11)
**Ideal for**: Users connecting from a standard Lightning wallet (Strike, Cash App, WoS).
1. **Request Invoice**:
`POST /lightning/invoice` with `{"amount_sats": 5000, "purpose": "create"}`.
2. **Pay Invoice**: User scans and pays the QR code/bolt11 string.
3. **Receive Key**: Routstr detects the payment and issues a new API Key (`sk-...`) pre-loaded with 5,000 sats (5,000,000 msats).
### Method B: Cashu Token Import
**Ideal for**: Private, instant access or automated agents.
1. **Generate Token**: User creates a token in their local wallet (e.g., 1000 sats).
2. **Import**: `GET /v1/balance/create?initial_balance_token=cashuA...`
3. **Receive Key**: Routstr claims the token and issues an API Key (`sk-...`) with that balance.
---
## 2. Consuming Funds (Inference)
Every time you make a request to `/v1/chat/completions` (or others), the cost is deducted from your balance **in real-time**.
### Cost Calculation
`Cost = (Input_Tokens * Price_Input) + (Output_Tokens * Price_Output) + Request_Fee`
- Prices are defined per model (see `/v1/models`).
- If you stream the response, the balance is deducted incrementally or finalized at the end of the stream.
- If your balance hits 0 mid-stream, the connection is closed.
### Headers
Routstr checks the `Authorization: Bearer sk-...` header to identify which balance to charge.
---
## 3. Topping Up
If your balance runs low, you don't need a new key. You can top up the existing one.
### Via Lightning
`POST /lightning/invoice` with `{"amount_sats": 1000, "purpose": "topup", "api_key": "sk-..."}`.
*Once paid, the funds are added to your existing key.*
### Via Cashu
`POST /v1/balance/topup` with `{"cashu_token": "..."}` and `Authorization: Bearer sk-...`.
---
## 4. Refund (Withdrawal)
Don't leave large balances sitting on a node—it's a hot wallet. When you're done, get your sats back.
### Endpoint
`POST /v1/balance/refund`
**Headers**:
`Authorization: Bearer sk-...`
**Response**:
```json
{
"token": "cashuAeyJ0b2tlbiI6W3sibWludCI6...",
"msats": "450000"
}
```
You can verify the refund was successful by checking that the API Key is now invalid or has 0 balance. Copy the `token` string and paste it into your Cashu wallet to claim the Bitcoin.
---
## Summary
| Step | Action | Result |
|------|--------|--------|
| 💰 **Deposit** | Pay Lightning invoice or import Cashu | Get `sk-...` key |
| 🤖 **Use** | Make API requests | Balance decreases |
| 💸 **Refund** | Call `/v1/balance/refund` | Get Cashu token back |
That's it. No monthly bills, no surprise charges, no data harvesting.

View 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

View 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
View 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! 🚀

View 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
View 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
View 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) |

View 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

View File

@@ -0,0 +1,122 @@
# 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.
---
## Admin Dashboard (Primary)
Access the dashboard at `/admin/` on your node.
### Upstream Providers
Connect to your AI provider(s):
| Setting | Description |
|---------|-------------|
| **Upstream URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
| **API Key** | Your provider's API key |
### Node Identity
How your node appears to clients:
| Setting | Description |
|---------|-------------|
| **Name** | Display name (e.g., "Fast GPT-4 Node") |
| **Description** | Brief description of your service |
### Pricing
Control your profit margins:
| Setting | Description | Default |
|---------|-------------|---------|
| **Fixed Pricing** | Charge flat rate per request vs. per-token | Off |
| **Exchange Fee** | Buffer for BTC volatility | 1.005 (0.5%) |
| **Upstream Fee** | Your profit markup | 1.10 (10%) |
See [Pricing](pricing.md) for detailed strategies.
### Cashu Mints
Which mints to accept payments from:
| Setting | Description |
|---------|-------------|
| **Mints** | List of trusted Cashu mint URLs |
### Lightning Withdrawals
Automatic profit withdrawal:
| Setting | Description |
|---------|-------------|
| **Lightning Address** | Your LN address for withdrawals |
### Security
| Setting | Description |
|---------|-------------|
| **Admin Password** | Password for dashboard access |
### Nostr Discovery
Announce your node on the network:
| Setting | Description |
|---------|-------------|
| **Npub** | Your Nostr public key |
| **Nsec** | Your Nostr private key (for signing) |
| **Relays** | Relays to publish announcements |
See [Discovery](discovery.md) for details.
---
## Environment Variables (Optional)
Use environment variables for:
- **Automated deployments** (CI/CD, infrastructure-as-code)
- **Secrets management** (external secret stores)
- **Initial bootstrap** (set once, manage via dashboard later)
### All Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `UPSTREAM_BASE_URL` | Upstream API endpoint | — |
| `UPSTREAM_API_KEY` | Upstream API key | — |
| `ADMIN_PASSWORD` | Dashboard password | (none) |
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///keys.db` |
| `NAME` | Node display name | `ARoutstrNode` |
| `DESCRIPTION` | Node description | `A Routstr Node` |
| `NPUB` | Nostr public key (bech32) | — |
| `NSEC` | Nostr private key | — |
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
| `RELAYS` | Nostr relays (comma-separated) | (default set) |
### Priority
Environment variables are read on startup. Dashboard settings override them and persist in the database. Once you change a setting in the dashboard, the env var is ignored for that setting.
---
## Models
Manage which AI models you offer:
1. Go to **Models** in the dashboard
2. Models are auto-discovered from your upstream
3. For each model, you can:
- **Enable/Disable** — hide expensive models you don't want to serve
- **Override pricing** — set custom per-token rates
- **Create aliases** — friendly names for models
See [Pricing](pricing.md) for per-model pricing strategies.

208
docs/provider/dashboard.md Normal file
View File

@@ -0,0 +1,208 @@
# Admin Dashboard
The Admin Dashboard is your command center for managing your Routstr provider node. Configure providers, monitor earnings, manage models, and withdraw profits—all from a web interface.
**URL**: `http://your-node:8000/admin/`
---
## Overview Tab
The main dashboard view shows your node's financial status at a glance.
### Wallet Summary
| Metric | Description |
|--------|-------------|
| **Total Wallet** | All Bitcoin currently held by your node |
| **User Balances** | Funds belonging to active client sessions |
| **Your Balance** | Your profit: `Total - User Balances` |
### Mint Status
Shows connected Cashu mints and their balances. Each mint displays:
- Connection status
- Balance in sats/msats
- Unit type
<!-- TODO: Screenshot of Overview tab -->
---
## Sessions Tab
View and manage active client sessions (API keys).
### Session List
| Column | Description |
|--------|-------------|
| **Hashed Key** | Privacy-preserving identifier (not the actual key) |
| **Balance** | Remaining funds in the session |
| **Spent** | Total amount spent by this session |
| **Requests** | Number of API calls made |
| **Created** | When the session was created |
| **Expires** | Auto-expiry time (if set) |
### Actions
- **View Details** — See full session history
- **Revoke** — Terminate a session (remaining balance returns to your wallet)
<!-- TODO: Screenshot of Sessions tab -->
---
## Models Tab
Manage which AI models you offer to clients.
### Model List
Shows all models available from your upstream provider(s):
| Column | Description |
|--------|-------------|
| **Model ID** | The model identifier (e.g., `gpt-4o`) |
| **Enabled** | Whether clients can use this model |
| **Input Price** | Cost per 1M input tokens (USD) |
| **Output Price** | Cost per 1M output tokens (USD) |
| **Custom** | Whether pricing is overridden |
### Actions
- **Import Models** — Fetch latest model list from upstream
- **Enable/Disable** — Toggle model availability
- **Edit Pricing** — Override default pricing for a model
- **Create Alias** — Map a friendly name to a model
### Editing a Model
Click on any model to configure:
| Field | Description |
|-------|-------------|
| **Enabled** | Show this model to clients |
| **Prompt Price** | Custom price per 1M input tokens (USD) |
| **Completion Price** | Custom price per 1M output tokens (USD) |
| **Alias** | Alternative name for this model |
<!-- TODO: Screenshot of Models tab -->
<!-- TODO: Screenshot of Model edit modal -->
---
## Settings Tab
Configure all node settings. Changes take effect immediately.
### Upstream
Connect to your AI provider:
| Field | Description |
|-------|-------------|
| **Base URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
| **API Key** | Your provider's secret key |
<!-- TODO: Screenshot of Upstream settings -->
### Node Identity
| Field | Description |
|-------|-------------|
| **Name** | Public display name |
| **Description** | Brief description of your service |
| **Npub** | Nostr public key for discovery |
### Pricing
| Field | Description |
|-------|-------------|
| **Fixed Pricing** | Toggle flat-rate vs. per-token pricing |
| **Fixed Cost** | Sats per request (when fixed pricing enabled) |
| **Exchange Fee** | Multiplier for BTC volatility buffer |
| **Upstream Fee** | Your profit margin multiplier |
**Example**: With Exchange Fee `1.005` and Upstream Fee `1.10`:
- Upstream cost: $30/1M tokens
- Your price: $30 × 1.005 × 1.10 = $33.17/1M tokens
### Cashu Mints
Manage which mints you accept payments from:
- **Add Mint** — Enter a mint URL
- **Remove Mint** — Stop accepting from a mint
- **Test Connection** — Verify mint is reachable
<!-- TODO: Screenshot of Mints settings -->
### Lightning
| Field | Description |
|-------|-------------|
| **Lightning Address** | Your LN address for automatic withdrawals |
### Nostr Discovery
| Field | Description |
|-------|-------------|
| **Nsec** | Private key for signing announcements |
| **Relays** | Where to publish your node advertisement |
### Security
| Field | Description |
|-------|-------------|
| **Admin Password** | Password for dashboard access |
!!! warning "Set a Password"
The dashboard has no password by default. Always set one for production nodes.
<!-- TODO: Screenshot of Security settings -->
---
## Withdraw Tab
Withdraw your profits to a Lightning wallet.
### Steps
1. **Select Mint** — Choose which mint to withdraw from
2. **Enter Amount** — How many sats to withdraw
3. **Generate Token** — Creates a Cashu token
4. **Redeem** — Paste the token into your Cashu wallet and melt to Lightning
<!-- TODO: Screenshot of Withdraw tab -->
### Alternative: Lightning Address
If you've configured a Lightning Address in Settings, profits can be automatically swept to your wallet (coming soon).
---
## Logs Tab
View node logs for debugging without SSH access.
### Features
- **Filter by Level** — Error, Warning, Info, Debug
- **Search** — Find specific entries
- **Time Range** — View logs from specific periods
- **Auto-refresh** — Watch logs in real-time
### Common Log Entries
| Entry | Meaning |
|-------|---------|
| `Upstream request failed` | Problem connecting to your AI provider |
| `Invalid token` | Client sent an invalid Cashu token |
| `Session expired` | API key reached its time limit |
| `Insufficient balance` | Client ran out of funds mid-request |
<!-- TODO: Screenshot of Logs tab -->

196
docs/provider/deployment.md Normal file
View File

@@ -0,0 +1,196 @@
# Deployment
Production deployment guide for Routstr Provider nodes.
## Docker Compose (Recommended)
For production, use Docker Compose with persistent storage and optional Tor support.
### Basic Setup
Create a `compose.yml`:
```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
```
Start the node:
```bash
docker compose up -d
```
Then configure everything via the [Admin Dashboard](http://localhost:8000/admin/).
---
## 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
```bash
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
docker build -t routstr-local .
```

View 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
View 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

View File

@@ -0,0 +1,99 @@
# 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. Start the Node
```bash
docker run -d \
--name routstr \
-p 8000:8000 \
-v routstr-data:/app/data \
ghcr.io/routstr/proxy:latest
```
Verify it's running:
```bash
curl http://localhost:8000/v1/info
```
---
## 2. Configure via Dashboard
Open the **Admin Dashboard** at [http://localhost:8000/admin/](http://localhost:8000/admin/).
!!! note "Default Access"
The dashboard has no password by default. Set one immediately in Settings for production use.
### 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
View 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
View File

@@ -0,0 +1,4 @@
mkdocs>=1.5.3
mkdocs-material>=9.5.0
mkdocs-mermaid2-plugin>=1.1.1
pymdown-extensions>=10.5

View File

@@ -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()

View 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())

View 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())

View 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())

View 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())

View 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)

View 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
View 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()

View 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)

View 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)

View 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()

View 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
View 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
View 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
)

73
migrations/env.py Normal file
View 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
View 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"}

View File

@@ -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 ###

View 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 ###

View File

@@ -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 ###

View 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"]),
)

View 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")

View 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")

View 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 ###

View 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")

View File

@@ -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,
)

View File

@@ -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")

View File

@@ -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),
)

View 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")

View File

@@ -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")

View 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
View 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

View File

@@ -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"
]
}
]

View File

@@ -1,17 +1,26 @@
[project]
name = "routstr"
version = "0.0.1"
version = "0.3.0"
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",
"secp256k1",
"marshmallow>=3.13,<4.0",
"websockets>=12.0",
"nostr>=0.0.2",
"mdurl==0.1.2",
"pillow>=10",
"openai>=1.98.0",
]
[dependency-groups]
@@ -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,7 @@ check_untyped_defs = true
disallow_untyped_calls = true
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.uv.sources]
routstr = { workspace = true }
secp256k1 = { git = "https://github.com/saschanaz/secp256k1-py", branch = "upgrade060" }

View File

@@ -1,7 +0,0 @@
import dotenv
dotenv.load_dotenv()
from .main import app as fastapi_app # noqa
__all__ = ["fastapi_app"]

View File

@@ -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"
)

View File

@@ -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()

View File

@@ -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,
}
},
)

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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},
)

View File

@@ -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}

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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",
}
},
)

View File

@@ -1,128 +0,0 @@
import asyncio
import os
import httpx
from .logging.logging_config import get_logger
logger = get_logger(__name__)
# artifical spread to cover conversion fees
EXCHANGE_FEE = float(os.environ.get("EXCHANGE_FEE", "1.005")) # 0.5% default
logger.info("Price module initialized", extra={"exchange_fee": EXCHANGE_FEE})
async def kraken_btc_usd(client: httpx.AsyncClient) -> float | None:
"""Fetch BTC/USD price from Kraken API."""
api = "https://api.kraken.com/0/public/Ticker?pair=XBTUSD"
try:
logger.debug("Fetching BTC price from Kraken")
response = await client.get(api)
price_data = response.json()
price = float(price_data["result"]["XXBTZUSD"]["c"][0])
return price
except (httpx.RequestError, KeyError) as e:
logger.warning(
"Kraken API error",
extra={
"error": str(e),
"error_type": type(e).__name__,
"exchange": "kraken",
},
)
return None
async def coinbase_btc_usd(client: httpx.AsyncClient) -> float | None:
"""Fetch BTC/USD price from Coinbase API."""
api = "https://api.coinbase.com/v2/prices/BTC-USD/spot"
try:
logger.debug("Fetching BTC price from Coinbase")
response = await client.get(api)
price_data = response.json()
price = float(price_data["data"]["amount"])
return price
except (httpx.RequestError, KeyError) as e:
logger.warning(
"Coinbase API error",
extra={
"error": str(e),
"error_type": type(e).__name__,
"exchange": "coinbase",
},
)
return None
async def binance_btc_usdt(client: httpx.AsyncClient) -> float | None:
"""Fetch BTC/USDT price from Binance API."""
api = "https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT"
try:
logger.debug("Fetching BTC price from Binance")
response = await client.get(api)
price_data = response.json()
price = float(price_data["price"])
return price
except (httpx.RequestError, KeyError) as e:
logger.warning(
"Binance API error",
extra={
"error": str(e),
"error_type": type(e).__name__,
"exchange": "binance",
},
)
return None
async def btc_usd_ask_price() -> float:
"""Get the highest BTC/USD price from multiple exchanges with fee adjustment."""
logger.debug("Starting BTC price aggregation from multiple exchanges")
async with httpx.AsyncClient(timeout=30.0) as client:
try:
prices = await asyncio.gather(
kraken_btc_usd(client),
coinbase_btc_usd(client),
binance_btc_usdt(client),
)
valid_prices = [price for price in prices if price is not None]
if not valid_prices:
logger.error("No valid BTC prices obtained from any exchange")
raise ValueError("Unable to fetch BTC price from any exchange")
max_price = max(valid_prices)
final_price = max_price * EXCHANGE_FEE
return final_price
except Exception as e:
logger.error(
"Error in BTC price aggregation",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise
async def sats_usd_ask_price() -> float:
"""Get the USD price per satoshi."""
logger.debug("Calculating satoshi price from BTC price")
try:
btc_price = await btc_usd_ask_price()
sats_price = btc_price / 100_000_000
return sats_price
except Exception as e:
logger.error(
"Error calculating satoshi price",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise

View File

@@ -1,730 +0,0 @@
import json
import re
import traceback
from typing import AsyncGenerator
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from router.logging.logging_config import get_logger
from router.payment.helpers import (
UPSTREAM_BASE_URL,
check_token_balance,
create_error_response,
prepare_upstream_headers,
)
from router.payment.x_cashu import x_cashu_handler
from .auth import (
adjust_payment_for_tokens,
pay_for_request,
revert_pay_for_request,
validate_bearer_key,
)
from .db import ApiKey, AsyncSession, create_session, get_session
logger = get_logger(__name__)
proxy_router = APIRouter()
async def handle_streaming_chat_completion(
response: httpx.Response, key: ApiKey, session: AsyncSession
) -> StreamingResponse:
"""Handle streaming chat completion responses with token-based pricing."""
logger.info(
"Processing streaming chat completion",
extra={
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"response_status": response.status_code,
},
)
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
# Store all chunks to analyze
stored_chunks = []
async for chunk in response.aiter_bytes():
# Store chunk for later analysis
stored_chunks.append(chunk)
# Pass through each chunk to client
yield chunk
logger.debug(
"Streaming completed, analyzing usage data",
extra={
"key_hash": key.hashed_key[:8] + "...",
"chunks_count": len(stored_chunks),
},
)
# Process stored chunks to find usage data
# Start from the end and work backwards
for i in range(len(stored_chunks) - 1, -1, -1):
chunk = stored_chunks[i]
if not chunk or chunk == b"":
continue
try:
# Split by "data: " to get individual SSE events
events = re.split(b"data: ", chunk)
for event_data in events:
if (
not event_data
or event_data.strip() == b"[DONE]"
or event_data.strip() == b""
):
continue
try:
data = json.loads(event_data)
if (
"usage" in data
and data["usage"] is not None
and isinstance(data["usage"], dict)
):
logger.info(
"Found usage data in streaming response",
extra={
"key_hash": key.hashed_key[:8] + "...",
"usage_data": data["usage"],
"model": data.get("model", "unknown"),
},
)
# Found usage data, calculate cost
# Create a new session for this operation
async with create_session() as new_session:
# Re-fetch the key in the new session
fresh_key = await new_session.get(
key.__class__, key.hashed_key
)
if fresh_key:
try:
cost_data = await adjust_payment_for_tokens(
fresh_key, data, new_session
)
logger.info(
"Token adjustment completed for streaming",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost_data": cost_data,
"balance_after_adjustment": fresh_key.balance,
},
)
# Format as SSE and yield
cost_json = json.dumps({"cost": cost_data})
yield f"data: {cost_json}\n\n".encode()
except Exception as cost_error:
logger.error(
"Error adjusting payment for streaming tokens",
extra={
"error": str(cost_error),
"error_type": type(cost_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
break
except json.JSONDecodeError:
continue
except Exception as e:
logger.error(
"Error processing streaming response chunk",
extra={
"error": str(e),
"error_type": type(e).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
return StreamingResponse(
stream_with_cost(),
status_code=response.status_code,
headers=dict(response.headers),
)
async def handle_non_streaming_chat_completion(
response: httpx.Response, key: ApiKey, session: AsyncSession
) -> Response:
"""Handle non-streaming chat completion responses with token-based pricing."""
logger.info(
"Processing non-streaming chat completion",
extra={
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"response_status": response.status_code,
},
)
try:
content = await response.aread()
response_json = json.loads(content)
logger.debug(
"Parsed response JSON",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": response_json.get("model", "unknown"),
"has_usage": "usage" in response_json,
},
)
cost_data = await adjust_payment_for_tokens(key, response_json, session)
response_json["cost"] = cost_data
logger.info(
"Token adjustment completed for non-streaming",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost_data": cost_data,
"model": response_json.get("model", "unknown"),
"balance_after_adjustment": key.balance,
},
)
# Keep only standard headers that are safe to pass through
allowed_headers = {
"content-type",
"cache-control",
"date",
"vary",
"access-control-allow-origin",
"access-control-allow-methods",
"access-control-allow-headers",
"access-control-allow-credentials",
"access-control-expose-headers",
"access-control-max-age",
}
response_headers = {
k: v for k, v in response.headers.items() if k.lower() in allowed_headers
}
return Response(
content=json.dumps(response_json).encode(),
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),
"key_hash": key.hashed_key[:8] + "...",
"content_preview": content[:200].decode(errors="ignore")
if content
else "empty",
},
)
raise
except Exception as e:
logger.error(
"Error processing non-streaming chat completion",
extra={
"error": str(e),
"error_type": type(e).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
async def forward_to_upstream(
request: Request,
path: str,
headers: dict,
request_body: bytes | None,
key: ApiKey,
session: AsyncSession,
) -> 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.info(
"Forwarding request to upstream",
extra={
"url": url,
"method": request.method,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"has_request_body": request_body is not None,
},
)
client = httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=None, # No timeout - requests can take as long as needed
)
try:
# Use the pre-read body if available, otherwise stream
if request_body is not None:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=request_body,
params=request.query_params,
),
stream=True,
)
else:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=request.stream(),
params=request.query_params,
),
stream=True,
)
logger.info(
"Received upstream response",
extra={
"status_code": response.status_code,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"content_type": response.headers.get("content-type", "unknown"),
},
)
# For chat completions, we need to handle token-based pricing
if path.endswith("chat/completions"):
# Check if client requested streaming
client_wants_streaming = False
if request_body:
try:
request_data = json.loads(request_body)
client_wants_streaming = request_data.get("stream", False)
logger.debug(
"Chat completion request analysis",
extra={
"client_wants_streaming": client_wants_streaming,
"model": request_data.get("model", "unknown"),
"key_hash": key.hashed_key[:8] + "...",
},
)
except json.JSONDecodeError:
logger.warning(
"Failed to parse request body JSON for streaming detection"
)
# Handle both streaming and non-streaming responses
content_type = response.headers.get("content-type", "")
upstream_is_streaming = "text/event-stream" in content_type
is_streaming = client_wants_streaming and upstream_is_streaming
logger.debug(
"Response type analysis",
extra={
"is_streaming": is_streaming,
"client_wants_streaming": client_wants_streaming,
"upstream_is_streaming": upstream_is_streaming,
"content_type": content_type,
"key_hash": key.hashed_key[:8] + "...",
},
)
if is_streaming and response.status_code == 200:
# Process streaming response and extract cost from the last chunk
result = await handle_streaming_chat_completion(response, key, session)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
result.background = background_tasks
return result
elif response.status_code == 200:
# Handle non-streaming response
try:
return await handle_non_streaming_chat_completion(
response, key, session
)
finally:
await response.aclose()
await client.aclose()
# For all other responses, stream the response
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,
"key_hash": key.hashed_key[:8] + "...",
},
)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
background=background_tasks,
)
except httpx.RequestError as exc:
await client.aclose()
error_type = type(exc).__name__
error_details = str(exc)
logger.error(
"HTTP request error to upstream",
extra={
"error_type": error_type,
"error_details": error_details,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
"key_hash": key.hashed_key[:8] + "...",
},
)
# Provide more specific error messages based on the error type
if isinstance(exc, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
elif isinstance(exc, httpx.TimeoutException):
error_message = "Upstream service request timed out"
elif isinstance(exc, httpx.NetworkError):
error_message = "Network error while connecting to upstream service"
else:
error_message = f"Error connecting to upstream service: {error_type}"
return create_error_response("upstream_error", error_message, 502)
except Exception as exc:
await client.aclose()
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),
"key_hash": key.hashed_key[:8] + "...",
"traceback": tb,
},
)
return create_error_response(
"internal_error", "An unexpected server error occurred", 500
)
@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:
"""Main proxy endpoint handler."""
logger.info(
"Received proxy request",
extra={
"method": request.method,
"path": path,
"client_host": request.client.host if request.client else "unknown",
"user_agent": request.headers.get("user-agent", "unknown")[:100],
},
)
request_body = await request.body()
headers = dict(request.headers)
# Parse JSON body if present, handle empty/invalid JSON
request_body_dict = {}
if request_body:
try:
request_body_dict = json.loads(request_body)
logger.debug(
"Request body parsed",
extra={
"path": path,
"body_keys": list(request_body_dict.keys()),
"model": request_body_dict.get("model", "not_specified"),
},
)
except json.JSONDecodeError as e:
logger.error(
"Invalid JSON in request body",
extra={
"error": str(e),
"path": path,
"body_preview": request_body[:200].decode(errors="ignore")
if request_body
else "empty",
},
)
return Response(
content=json.dumps(
{"error": {"type": "invalid_request_error", "code": "invalid_json"}}
),
status_code=400,
media_type="application/json",
)
# Check token balance for all requests to get currency unit
try:
unit = check_token_balance(headers, request_body_dict)
logger.debug(
"Token balance check completed", extra={"path": path, "unit": unit}
)
except HTTPException as e:
logger.warning(
"Token balance check failed",
extra={"path": path, "status_code": e.status_code, "detail": str(e.detail)},
)
raise
# Handle authentication
if x_cashu := headers.get("x-cashu", None):
logger.info(
"Processing X-Cashu payment",
extra={
"path": path,
"token_preview": x_cashu[:20] + "..." if len(x_cashu) > 20 else x_cashu,
},
)
return await x_cashu_handler(request, x_cashu, path)
elif auth := headers.get("authorization", None):
logger.debug(
"Processing bearer token authentication",
extra={
"path": path,
"token_preview": auth[:20] + "..." if len(auth) > 20 else auth,
},
)
key = await get_bearer_token_key(headers, path, session, auth)
else:
if request.method not in ["GET"]:
logger.warning(
"Unauthorized request - no authentication provided",
extra={"method": request.method, "path": path},
)
return Response(
content=json.dumps({"detail": "Unauthorized"}),
status_code=401,
media_type="application/json",
)
logger.debug("Processing unauthenticated GET request", extra={"path": path})
# Prepare headers for upstream
headers = prepare_upstream_headers(dict(request.headers))
return await forward_get_to_upstream(request, path, headers)
cost_per_request = 0
# Only pay for request if we have request body data (for completions endpoints)
if request_body_dict:
logger.info(
"Processing payment for request",
extra={
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"key_balance_before": key.balance,
"model": request_body_dict.get("model", "unknown"),
},
)
try:
await pay_for_request(key, session, request_body_dict)
logger.info(
"Payment processed successfully",
extra={
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"key_balance_after": key.balance,
"model": request_body_dict.get("model", "unknown"),
},
)
except Exception as e:
logger.error(
"Payment processing failed",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
# Prepare headers for upstream
headers = prepare_upstream_headers(dict(request.headers))
# Forward to upstream and handle response
response = await forward_to_upstream(
request, path, headers, request_body, key, session
)
if response.status_code != 200:
await revert_pay_for_request(key, session, cost_per_request)
logger.warning(
"Upstream request failed, revert payment",
extra={
"status_code": response.status_code,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
},
)
return response
async def get_bearer_token_key(
headers: dict, path: str, session: AsyncSession, auth: str
) -> ApiKey:
"""Handle bearer token authentication proxy requests."""
bearer_key = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
refund_address = headers.get("Refund-LNURL", None)
key_expiry_time = headers.get("Key-Expiry-Time", None)
logger.debug(
"Processing bearer token",
extra={
"path": path,
"has_refund_address": bool(refund_address),
"has_expiry_time": bool(key_expiry_time),
"bearer_key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
},
)
# Validate key_expiry_time header
if key_expiry_time:
try:
key_expiry_time = int(key_expiry_time) # type: ignore
logger.debug(
"Key expiry time validated",
extra={"expiry_time": key_expiry_time, "path": path},
)
except ValueError:
logger.error(
"Invalid Key-Expiry-Time header",
extra={"key_expiry_time": key_expiry_time, "path": path},
)
raise HTTPException(
status_code=400,
detail="Invalid Key-Expiry-Time: must be a valid Unix timestamp",
)
if not refund_address:
logger.error(
"Missing Refund-LNURL header with Key-Expiry-Time",
extra={"path": path, "expiry_time": key_expiry_time},
)
raise HTTPException(
status_code=400,
detail="Error: Refund-LNURL header required when using Key-Expiry-Time",
)
else:
key_expiry_time = None
try:
key = await validate_bearer_key(
bearer_key,
session,
refund_address,
key_expiry_time, # type: ignore
)
logger.info(
"Bearer token validated successfully",
extra={
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
},
)
return key
except Exception as e:
logger.error(
"Bearer token validation failed",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"bearer_key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
},
)
raise
async def forward_get_to_upstream(
request: Request,
path: str,
headers: dict,
) -> 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.info(
"Forwarding GET request to upstream",
extra={"url": url, "method": request.method, "path": path},
)
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,
),
)
logger.info(
"GET request forwarded successfully",
extra={"path": path, "status_code": response.status_code},
)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
)
except Exception as exc:
tb = traceback.format_exc()
logger.error(
"Error forwarding GET request",
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
)

3
routstr/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .core.main import app as fastapi_app # noqa
__all__ = ["fastapi_app"]

246
routstr/algorithm.py Normal file
View File

@@ -0,0 +1,246 @@
"""Model prioritization algorithm for selecting cheapest upstream providers."""
from typing import TYPE_CHECKING
from .core.logging import get_logger
if TYPE_CHECKING:
from .payment.models import Model
from .upstream import BaseUpstreamProvider
logger = get_logger(__name__)
def calculate_model_cost_score(model: "Model") -> float:
"""Calculate a representative cost score for a model.
This score is used to compare models when multiple providers offer the same model.
Lower scores indicate cheaper models.
The score is calculated as a weighted average of:
- Input token cost (weighted by typical input usage)
- Output token cost (weighted by typical output usage)
- Fixed request cost
Args:
model: Model instance with pricing information
Returns:
Float representing the cost score. Lower is better.
"""
pricing = model.pricing
# Weight costs by typical usage patterns
# Assume average request: 1000 input tokens, 500 output tokens
TYPICAL_INPUT_TOKENS = 1000.0
TYPICAL_OUTPUT_TOKENS = 500.0
# Calculate weighted cost in USD
input_cost = pricing.prompt * (TYPICAL_INPUT_TOKENS / 1000.0)
output_cost = pricing.completion * (TYPICAL_OUTPUT_TOKENS / 1000.0)
request_cost = pricing.request
# Include additional costs if present
image_cost = (
getattr(pricing, "image", 0.0) * 0.1
) # Weight lower as not every request uses images
web_search_cost = getattr(pricing, "web_search", 0.0) * 0.1
reasoning_cost = getattr(pricing, "internal_reasoning", 0.0) * 0.2
total_cost = (
input_cost
+ output_cost
+ request_cost
+ image_cost
+ web_search_cost
+ reasoning_cost
)
return total_cost
def get_provider_penalty(provider: "BaseUpstreamProvider") -> float:
"""Calculate a penalty multiplier for certain providers.
This allows applying policy-based adjustments beyond pure cost.
For example, preferring certain providers for reliability or features.
Args:
provider: UpstreamProvider instance
Returns:
Float multiplier to apply to cost (1.0 = no penalty, >1.0 = penalize)
"""
# Default: no penalty
penalty = 1.0
# Check if this is OpenRouter (can be identified by base URL)
base_url = getattr(provider, "base_url", "")
if "openrouter.ai" in base_url.lower():
# Small penalty for OpenRouter to prefer other providers when costs are very close
# This maintains the original behavior of preferring non-OpenRouter providers
penalty = 1.001 # 0.1% penalty
return penalty
def create_model_mappings(
upstreams: list["BaseUpstreamProvider"],
overrides_by_id: dict[str, tuple],
disabled_model_ids: set[str],
) -> tuple[
dict[str, "Model"], dict[str, list["BaseUpstreamProvider"]], dict[str, "Model"]
]:
"""Create optimal model mappings based on cost and provider preferences.
This is the main entry point for the algorithm. It processes all upstream providers
and creates three mappings based on cost optimization:
1. model_instances: alias -> Model (all model aliases mapped to their Model objects)
2. provider_map: alias -> List[UpstreamProvider] (sorted list of providers for each alias)
3. unique_models: base_id -> Model (unique models without provider prefixes)
The algorithm:
- Processes non-OpenRouter providers first (they're typically cheaper)
- Then processes OpenRouter models (they can still win if cheaper)
- For each model alias, collects all candidates and sorts them by priority and cost.
Args:
upstreams: List of all upstream provider instances
overrides_by_id: Dict of model overrides from database {model_id: (ModelRow, fee)}
disabled_model_ids: Set of model IDs that should be excluded
Returns:
Tuple of (model_instances, provider_map, unique_models)
"""
from .payment.models import _row_to_model
from .upstream.helpers import resolve_model_alias
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
unique_models: dict[str, "Model"] = {}
# Separate OpenRouter from other providers
openrouter: "BaseUpstreamProvider" | None = None
other_upstreams: list["BaseUpstreamProvider"] = []
for upstream in upstreams:
base_url = getattr(upstream, "base_url", "")
if base_url == "https://openrouter.ai/api/v1":
openrouter = upstream
else:
other_upstreams.append(upstream)
def get_base_model_id(model_id: str) -> str:
"""Get base model ID by removing provider prefix."""
return model_id.split("/", 1)[1] if "/" in model_id else model_id
def _add_candidate(
alias: str, model: "Model", provider: "BaseUpstreamProvider"
) -> None:
"""Add candidate model/provider for an alias."""
alias_lower = alias.lower()
if alias_lower not in candidates:
candidates[alias_lower] = []
candidates[alias_lower].append((model, provider))
def process_provider_models(
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
) -> None:
"""Process all models from a given provider."""
upstream_prefix = getattr(upstream, "upstream_name", None)
for model in upstream.get_cached_models():
if not model.enabled or model.id in disabled_model_ids:
continue
# Apply overrides if present
if model.id in overrides_by_id:
override_row, provider_fee = overrides_by_id[model.id]
model_to_use = _row_to_model(
override_row, apply_provider_fee=True, provider_fee=provider_fee
)
else:
model_to_use = model
# Add to unique models
base_id = get_base_model_id(model_to_use.id)
if not is_openrouter or base_id not in unique_models:
unique_model = model_to_use.copy(
update={
"id": base_id,
"upstream_provider_id": upstream.provider_type,
}
)
unique_models[base_id] = unique_model
# Get all aliases for this model
aliases = resolve_model_alias(
model_to_use.id,
model_to_use.canonical_slug,
alias_ids=model_to_use.alias_ids,
)
# Add prefixed alias if applicable
if upstream_prefix and "/" not in model_to_use.id:
prefixed_id = f"{upstream_prefix}/{model_to_use.id}"
if prefixed_id not in aliases:
aliases.append(prefixed_id)
# Try to set each alias
for alias in aliases:
_add_candidate(alias, model_to_use, upstream)
# Process non-OpenRouter providers first
for upstream in other_upstreams:
process_provider_models(upstream, is_openrouter=False)
# Process OpenRouter last
if openrouter:
process_provider_models(openrouter, is_openrouter=True)
# Sort candidates and build final maps
model_instances: dict[str, "Model"] = {}
provider_map: dict[str, list["BaseUpstreamProvider"]] = {}
def alias_priority(model: "Model", alias: str) -> int:
"""Rank how strong the mapping of alias->model is."""
model_base = get_base_model_id(model.id)
if model_base == alias:
return 3
if model.canonical_slug:
canonical_base = get_base_model_id(model.canonical_slug)
if canonical_base == alias:
return 2
return 1
for alias, items in candidates.items():
# Sort key: (priority DESC, cost ASC)
# Using negative cost for DESC sort overall to keep high priority first
def sort_key(item: tuple["Model", "BaseUpstreamProvider"]) -> tuple[int, float]:
model, provider = item
priority = alias_priority(model, alias)
cost = calculate_model_cost_score(model)
penalty = get_provider_penalty(provider)
adjusted_cost = cost * penalty
return (priority, -adjusted_cost)
items.sort(key=sort_key, reverse=True)
best_model, best_provider = items[0]
model_instances[alias] = best_model
provider_map[alias] = [p for _, p in items]
# Log provider distribution (using top provider for stats)
provider_counts: dict[str, int] = {}
for providers in provider_map.values():
if providers:
provider = providers[0]
provider_name = getattr(provider, "upstream_name", "unknown")
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
logger.debug(
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",
extra={"provider_distribution": provider_counts},
)
return model_instances, provider_map, unique_models

817
routstr/auth.py Normal file
View File

@@ -0,0 +1,817 @@
import hashlib
import math
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlmodel import col, update
from .core import get_logger
from .core.db import ApiKey, AsyncSession
from .core.settings import settings
from .payment.cost_calculation import (
CostData,
CostDataError,
MaxCostData,
calculate_cost,
)
from .wallet import credit_balance, deserialize_token_from_string
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()
token_obj = deserialize_token_from_string(bearer_key)
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),
},
)
if token_obj.mint in settings.cashu_mints:
refund_currency = token_obj.unit
refund_mint_url = token_obj.mint
else:
refund_currency = "sat"
refund_mint_url = settings.primary_mint
new_key = ApiKey(
hashed_key=hashed_key,
balance=0,
refund_address=refund_address,
key_expiry_time=key_expiry_time,
refund_currency=refund_currency,
refund_mint_url=refund_mint_url,
)
session.add(new_key)
try:
await session.flush()
except IntegrityError:
await session.rollback()
logger.info(
"Concurrent key creation detected, fetching existing key",
extra={"key_hash": hashed_key[:8] + "..."},
)
existing_key = await session.get(ApiKey, hashed_key)
if not existing_key:
raise Exception("Failed to fetch existing key after IntegrityError")
if key_expiry_time is not None:
existing_key.key_expiry_time = key_expiry_time
if refund_address is not None:
existing_key.refund_address = refund_address
return existing_key
logger.debug(
"New key created, starting token redemption",
extra={"key_hash": hashed_key[:8] + "..."},
)
logger.info(
"AUTH: About to call credit_balance",
extra={"token_preview": bearer_key[:50]},
)
try:
msats = await credit_balance(bearer_key, new_key, session)
logger.info(
"AUTH: credit_balance returned successfully", extra={"msats": msats}
)
except Exception as credit_error:
logger.error(
"AUTH: credit_balance failed",
extra={
"error": str(credit_error),
"error_type": type(credit_error).__name__,
},
)
raise credit_error
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 get_billing_key(key: ApiKey, session: AsyncSession) -> ApiKey:
"""Returns the key that should be charged for the request."""
if key.parent_key_hash:
parent = await session.get(ApiKey, key.parent_key_hash)
if parent:
# We want to keep the total_requests and total_spent on the child key
# but use the balance and reserved_balance of the parent.
# However, pay_for_request updates reserved_balance and total_requests.
# To stay simple, we charge the parent's balance and update parent's total_requests.
return parent
else:
logger.error(
"Parent key not found for child key",
extra={
"child_key_hash": key.hashed_key[:8] + "...",
"parent_key_hash": key.parent_key_hash[:8] + "...",
},
)
return key
async def pay_for_request(
key: ApiKey, cost_per_request: int, session: AsyncSession
) -> int:
"""Process payment for a request."""
billing_key = await get_billing_key(key, session)
logger.info(
"Processing payment for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"current_balance": billing_key.balance,
"required_cost": cost_per_request,
"sufficient_balance": billing_key.balance >= cost_per_request,
},
)
if billing_key.total_balance < cost_per_request:
logger.warning(
"Insufficient balance for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"balance": billing_key.balance,
"reserved_balance": billing_key.reserved_balance,
"required": cost_per_request,
"shortfall": cost_per_request - billing_key.total_balance,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.total_balance} available. (reserved: {billing_key.reserved_balance})",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
logger.debug(
"Charging base cost for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"cost": cost_per_request,
"balance_before": billing_key.balance,
},
)
# Charge the base cost for the request atomically to avoid race conditions
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
.values(
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
total_requests=col(ApiKey.total_requests) + 1,
)
)
result = await session.exec(stmt) # type: ignore[call-overload]
# Also increment total_requests on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_requests=col(ApiKey.total_requests) + 1)
)
await session.exec(child_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] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"required_cost": cost_per_request,
"current_balance": billing_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. {billing_key.balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
logger.info(
"Payment processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"charged_amount": cost_per_request,
"new_balance": billing_key.balance,
"total_spent": billing_key.total_spent,
"total_requests": billing_key.total_requests,
},
)
return cost_per_request
async def revert_pay_for_request(
key: ApiKey, session: AsyncSession, cost_per_request: int
) -> None:
billing_key = await get_billing_key(key, session)
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
total_requests=col(ApiKey.total_requests) - 1,
)
)
result = await session.exec(stmt) # type: ignore[call-overload]
# Also decrement total_requests on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_requests=col(ApiKey.total_requests) - 1)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to revert payment - insufficient reserved balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"cost_to_revert": cost_per_request,
"current_reserved_balance": billing_key.reserved_balance,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"failed to revert request payment: {cost_per_request} mSats required. {billing_key.balance} available.",
"type": "payment_error",
"code": "payment_error",
}
},
)
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
async def adjust_payment_for_tokens(
key: ApiKey, response_data: dict, session: AsyncSession, deducted_max_cost: int
) -> 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.
"""
billing_key = await get_billing_key(key, session)
model = response_data.get("model", "unknown")
logger.debug(
"Starting payment adjustment for tokens",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
"deducted_max_cost": deducted_max_cost,
"current_balance": billing_key.balance,
"has_usage": "usage" in response_data,
},
)
async def release_reservation_only() -> None:
"""Fallback to release reservation without charging when main update fails."""
try:
release_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost
)
)
await session.exec(release_stmt) # type: ignore[call-overload]
await session.commit()
logger.warning(
"Released reservation without charging (fallback)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
},
)
except Exception as e:
logger.error(
"Failed to release reservation in fallback",
extra={
"error": str(e),
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
},
)
match await calculate_cost(response_data, deducted_max_cost, session):
case MaxCostData() as cost:
logger.debug(
"Using max cost data (no token adjustment)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
"max_cost": cost.total_msats,
},
)
# Finalize by releasing reservation and charging max cost
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
balance=col(ApiKey.balance) - cost.total_msats,
total_spent=col(ApiKey.total_spent) + cost.total_msats,
)
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_spent=col(ApiKey.total_spent) + cost.total_msats)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to finalize max-cost payment - retrying reservation release",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
"current_reserved_balance": billing_key.reserved_balance,
"total_cost": cost.total_msats,
"model": model,
},
)
await release_reservation_only()
else:
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
logger.info(
"Max cost payment finalized",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"charged_amount": cost.total_msats,
"new_balance": billing_key.balance,
"model": model,
},
)
return cost.dict()
case CostData() as cost:
# 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 - deducted_max_cost
total_cost_msats: int = math.ceil(cost.total_msats)
logger.info(
"Calculated token-based cost",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
"token_cost": cost.total_msats,
"deducted_max_cost": deducted_max_cost,
"cost_difference": cost_difference,
"input_msats": cost.input_msats,
"output_msats": cost.output_msats,
},
)
if cost_difference == 0:
logger.debug(
"Finalizing with exact reserved cost",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
},
)
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
balance=col(ApiKey.balance) - total_cost_msats,
total_spent=col(ApiKey.total_spent) + total_cost_msats,
)
)
await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
return cost.dict()
# this should never happen why do we handle this???
if cost_difference > 0:
# Need to charge more than reserved, finalize by releasing reservation and charging total
logger.info(
"Additional charge required for token usage",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"additional_charge": cost_difference,
"current_balance": billing_key.balance,
"sufficient_balance": billing_key.balance >= cost_difference,
"model": model,
},
)
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
balance=col(ApiKey.balance) - total_cost_msats,
total_spent=col(ApiKey.total_spent) + total_cost_msats,
)
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount:
cost.total_msats = total_cost_msats
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
logger.info(
"Finalized payment with additional charge",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"charged_amount": total_cost_msats,
"new_balance": billing_key.balance,
"model": model,
},
)
else:
logger.warning(
"Failed to finalize additional charge - releasing reservation",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"attempted_charge": total_cost_msats,
"model": model,
},
)
await release_reservation_only()
else:
# Refund some of the base cost
refund = abs(cost_difference)
logger.info(
"Refunding excess payment",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"refund_amount": refund,
"current_balance": billing_key.balance,
"model": model,
},
)
refund_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
balance=col(ApiKey.balance) - total_cost_msats,
total_spent=col(ApiKey.total_spent) + total_cost_msats,
)
)
result = await session.exec(refund_stmt) # type: ignore[call-overload]
# Also update total_spent on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to finalize payment - releasing reservation",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
"current_reserved_balance": billing_key.reserved_balance,
"total_cost": total_cost_msats,
"model": model,
},
)
await release_reservation_only()
else:
cost.total_msats = total_cost_msats
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
logger.info(
"Refund processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"refunded_amount": refund,
"new_balance": billing_key.balance,
"final_cost": cost.total_msats,
"model": model,
},
)
return cost.dict()
case CostDataError() as error:
logger.error(
"Cost calculation error during payment adjustment - releasing reservation",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
"error_message": error.message,
"error_code": error.code,
},
)
await release_reservation_only()
raise HTTPException(
status_code=400,
detail={
"error": {
"message": error.message,
"type": "invalid_request_error",
"code": error.code,
}
},
)
# Fallback: should not reach here, but release reservation just in case
logger.error(
"Unexpected fallback in adjust_payment_for_tokens - releasing reservation",
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
)
await release_reservation_only()
return {
"base_msats": deducted_max_cost,
"input_msats": 0,
"output_msats": 0,
"total_msats": deducted_max_cost,
}

339
routstr/balance.py Normal file
View File

@@ -0,0 +1,339 @@
import asyncio
import hashlib
from time import monotonic
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from .auth import validate_bearer_key
from .core.db import ApiKey, AsyncSession, get_session
from .core.logging import get_logger
from .core.settings import settings
from .lightning import lightning_router
from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
router = APIRouter()
balance_router = APIRouter(prefix="/v1/balance")
logger = get_logger(__name__)
async def get_key_from_header(
authorization: Annotated[str, Header(...)],
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>'",
)
async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
from .auth import get_billing_key
billing_key = await get_billing_key(key, session)
return {
"api_key": "sk-" + key.hashed_key,
"balance": billing_key.balance,
"reserved": billing_key.reserved_balance,
"is_child": key.parent_key_hash is not None,
"parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None,
"total_requests": key.total_requests,
"total_spent": key.total_spent,
}
# TODO: remove this endpoint when frontend is updated
@router.get("/", include_in_schema=False)
async def account_info(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
return await get_balance_info(key, session)
# TODO: Implement POST /v1/wallet/create endpoint
# This endpoint should accept:
# - cashu_token (required): The eCash token to deposit
# - refund_lnurl (optional): LNURL for refunds (instead of refund_address in validate_bearer_key)
# - refund_expiry (optional): Expiry timestamp for the key (maps to key_expiry_time in validate_bearer_key)
# The endpoint should:
# 1. Create a new wallet/API key from the cashu_token
# 2. Store refund_lnurl and refund_expiry in the database
# 3. Return the API key (rstr_...) and balance
# Note: validate_bearer_key already supports refund_address and key_expiry_time params
@router.get("/create")
async def create_balance(
initial_balance_token: str, session: AsyncSession = Depends(get_session)
) -> dict:
key = await validate_bearer_key(initial_balance_token, session)
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
}
@router.get("/info")
async def wallet_info(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
return await get_balance_info(key, session)
class TopupRequest(BaseModel):
cashu_token: str
@router.post("/topup")
async def topup_wallet_endpoint(
cashu_token: str | None = None,
topup_request: TopupRequest | None = None,
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict[str, int]:
from .auth import get_billing_key
billing_key = await get_billing_key(key, session)
if topup_request is not None:
cashu_token = topup_request.cashu_token
if cashu_token is None:
raise HTTPException(status_code=400, detail="A cashu_token is required.")
cashu_token = cashu_token.replace("\n", "").replace("\r", "").replace("\t", "")
if len(cashu_token) < 10 or "cashu" not in cashu_token:
raise HTTPException(status_code=400, detail="Invalid token format")
try:
amount_msats = await credit_balance(cashu_token, billing_key, session)
except ValueError as e:
error_msg = str(e)
if "already spent" in error_msg.lower():
raise HTTPException(status_code=400, detail="Token already spent")
elif "invalid" in error_msg.lower() or "decode" in error_msg.lower():
raise HTTPException(status_code=400, detail="Invalid token format")
else:
raise HTTPException(status_code=400, detail="Failed to redeem token")
except Exception:
raise HTTPException(status_code=500, detail="Internal server error")
return {"msats": amount_msats}
_REFUND_CACHE_TTL_SECONDS: int = settings.refund_cache_ttl_seconds
_refund_cache_lock: asyncio.Lock = asyncio.Lock()
_refund_cache: dict[str, tuple[float, dict[str, str]]] = {}
def _cache_key_for_authorization(authorization: str) -> str:
return hashlib.sha256(authorization.strip().encode()).hexdigest()
async def _refund_cache_get(authorization: str) -> dict[str, str] | None:
key = _cache_key_for_authorization(authorization)
async with _refund_cache_lock:
item = _refund_cache.get(key)
if item is None:
return None
expires_at, value = item
if expires_at <= monotonic():
del _refund_cache[key]
return None
return value
async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None:
key = _cache_key_for_authorization(authorization)
expiry = monotonic() + _REFUND_CACHE_TTL_SECONDS
async with _refund_cache_lock:
_refund_cache[key] = (expiry, value)
@router.post("/refund")
async def refund_wallet_endpoint(
authorization: Annotated[str, Header(...)],
session: AsyncSession = Depends(get_session),
) -> dict[str, str]:
if not authorization.startswith("Bearer "):
raise HTTPException(
status_code=401,
detail="Invalid authorization. Use 'Bearer <cashu-token>' or 'Bearer <api-key>'",
)
bearer_value: str = authorization[7:]
if cached := await _refund_cache_get(bearer_value):
return cached
key: ApiKey = await validate_bearer_key(bearer_value, session)
if key.parent_key_hash:
raise HTTPException(
status_code=400,
detail="Cannot refund child key. Please refund the parent key instead.",
)
remaining_balance_msats: int = key.total_balance
if key.refund_currency == "sat":
remaining_balance = remaining_balance_msats // 1000
else:
remaining_balance = remaining_balance_msats
if remaining_balance_msats > 0 and remaining_balance <= 0:
raise HTTPException(status_code=400, detail="Balance too small to refund")
elif remaining_balance <= 0:
raise HTTPException(status_code=400, detail="No balance to refund")
# Perform refund operation first, before modifying balance
try:
if key.refund_address:
from .core.settings import settings as global_settings
await send_to_lnurl(
remaining_balance,
key.refund_currency or "sat",
key.refund_mint_url or global_settings.primary_mint,
key.refund_address,
)
result = {"recipient": key.refund_address}
else:
refund_currency = key.refund_currency or "sat"
token = await send_token(
remaining_balance, refund_currency, key.refund_mint_url
)
result = {"token": token}
if key.refund_currency == "sat":
result["sats"] = str(remaining_balance_msats // 1000)
else:
result["msats"] = str(remaining_balance_msats)
except HTTPException:
# Re-raise HTTP exceptions (like 400 for balance too small)
raise
except Exception as e:
# If refund fails, don't modify the database
error_msg = str(e)
if (
"mint" in error_msg.lower()
or "connection" in error_msg.lower()
or isinstance(e, Exception)
and "ConnectError" in str(type(e))
):
raise HTTPException(status_code=503, detail="Mint service unavailable")
else:
raise HTTPException(status_code=500, detail="Refund failed")
await _refund_cache_set(bearer_value, result)
await session.delete(key)
await session.commit()
return result
@router.post("/donate")
async def donate(token: str, ref: str | None = None) -> str:
try:
amount, unit, _ = await recieve_token(token)
if ref:
logger.info(
"donation received", extra={"ref": ref, "amount": amount, "unit": unit}
)
return "Thanks!"
except Exception:
return "Invalid token."
class ChildKeyRequest(BaseModel):
count: int
@router.post("/child-key")
async def create_child_key(
payload: ChildKeyRequest,
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
"""Creates one or more child API keys that use the parent's balance."""
# Log incoming request for debugging
logger.debug(f"Child key creation request: count={payload.count}")
count = payload.count
if count < 1 or count > 50:
raise HTTPException(status_code=400, detail="Count must be between 1 and 50.")
# Check if this is already a child key
if key.parent_key_hash:
raise HTTPException(
status_code=400,
detail="Cannot create a child key for another child key.",
)
cost_per_key = settings.child_key_cost
total_cost = cost_per_key * count
if key.total_balance < total_cost:
raise HTTPException(
status_code=402,
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
)
# Deduct cost from parent
key.balance -= total_cost
key.total_spent += total_cost
session.add(key)
# Generate new keys
import secrets
new_keys = []
for _ in range(count):
new_key_raw = secrets.token_hex(32)
new_key_hash = new_key_raw # We use the raw key as the hash for sk- keys
child_key = ApiKey(
hashed_key=new_key_hash,
balance=0,
parent_key_hash=key.hashed_key,
)
session.add(child_key)
new_keys.append("sk-" + new_key_hash)
await session.commit()
response_data = {
"api_keys": new_keys,
"count": count,
"cost_msats": total_cost,
"cost_sats": total_cost // 1000,
"parent_balance": key.balance,
"parent_balance_sats": key.balance // 1000,
}
logger.debug(f"Child key creation response: {response_data}")
return response_data
@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"
)
balance_router.include_router(lightning_router)
balance_router.include_router(router)
deprecated_wallet_router = APIRouter(prefix="/v1/wallet", include_in_schema=False)
deprecated_wallet_router.include_router(router)

3
routstr/core/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .logging import get_logger
__all__ = ["get_logger"]

3160
routstr/core/admin.py Normal file

File diff suppressed because it is too large Load Diff

196
routstr/core/db.py Normal file
View File

@@ -0,0 +1,196 @@
import os
import time
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from alembic import command
from alembic.config import Config
from sqlalchemy import UniqueConstraint
from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlmodel import Field, Relationship, SQLModel, func, select, update
from sqlmodel.ext.asyncio.session import AsyncSession
from .logging import get_logger
logger = get_logger(__name__)
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)")
reserved_balance: int = Field(
default=0, description="Reserved 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)
refund_mint_url: str | None = Field(
default=None,
description="URL of the mint used to create the cashu-token",
)
refund_currency: str | None = Field(
default=None,
description="Currency of the cashu-token",
)
parent_key_hash: str | None = Field(
default=None, foreign_key="api_keys.hashed_key", index=True
)
@property
def total_balance(self) -> int:
return self.balance - self.reserved_balance
async def reset_all_reserved_balances(session: AsyncSession) -> None:
logger.info("Resetting all reserved balances to 0")
stmt = update(ApiKey).values(reserved_balance=0)
await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
logger.info("Reserved balances reset successfully")
class ModelRow(SQLModel, table=True): # type: ignore
__tablename__ = "models"
id: str = Field(primary_key=True)
upstream_provider_id: int = Field(
primary_key=True, foreign_key="upstream_providers.id", ondelete="CASCADE"
)
name: str = Field()
created: int = Field()
description: str = Field()
context_length: int = Field()
architecture: str = Field()
pricing: str = Field()
sats_pricing: str | None = Field(default=None)
per_request_limits: str | None = Field(default=None)
top_provider: str | None = Field(default=None)
canonical_slug: str | None = Field(default=None, description="Canonical model slug")
alias_ids: str | None = Field(
default=None, description="JSON array of model alias IDs"
)
enabled: bool = Field(default=True, description="Whether this model is enabled")
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
class LightningInvoice(SQLModel, table=True): # type: ignore
__tablename__ = "lightning_invoices"
id: str = Field(primary_key=True, description="Unique invoice identifier")
bolt11: str = Field(description="BOLT11 invoice string", unique=True)
amount_sats: int = Field(description="Amount in satoshis")
description: str = Field(description="Invoice description")
payment_hash: str = Field(description="Payment hash for tracking", unique=True)
status: str = Field(
default="pending", description="pending, paid, expired, cancelled"
)
api_key_hash: str | None = Field(
default=None, description="Associated API key hash for topup operations"
)
purpose: str = Field(description="create or topup")
created_at: int = Field(
default_factory=lambda: int(time.time()), description="Unix timestamp"
)
expires_at: int = Field(description="Unix timestamp when invoice expires")
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
__tablename__ = "upstream_providers"
__table_args__ = (
UniqueConstraint("base_url", "api_key", name="uq_upstream_providers_base_url_api_key"),
)
id: int | None = Field(default=None, primary_key=True)
provider_type: str = Field(
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
)
base_url: str = Field(description="Base URL of the upstream API")
api_key: str = Field(description="API key for the upstream provider")
api_version: str | None = Field(
default=None, description="API version for Azure OpenAI"
)
enabled: bool = Field(default=True, description="Whether this provider is enabled")
provider_fee: float = Field(
default=1.01, description="Provider fee multiplier (default 1%)"
)
models: list["ModelRow"] = Relationship(
back_populates="upstream_provider",
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
)
async def balances_for_mint_and_unit(
db_session: AsyncSession, mint_url: str, unit: str
) -> int:
query = select(func.sum(ApiKey.balance)).where(
ApiKey.refund_mint_url == mint_url, ApiKey.refund_currency == unit
)
result = await db_session.exec(query)
return result.one() or 0
async def init_db() -> None:
"""Initializes the database and creates tables if they don't exist."""
async with engine.begin() as conn:
if DATABASE_URL.startswith("sqlite"):
await conn.exec_driver_sql("PRAGMA journal_mode=WAL")
await conn.run_sync(SQLModel.metadata.create_all)
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
def run_migrations() -> None:
"""Run Alembic migrations programmatically."""
import pathlib
try:
# Get the path to the alembic.ini file
project_root = pathlib.Path(__file__).resolve().parents[2]
alembic_ini_path = project_root / "alembic.ini"
if not alembic_ini_path.exists():
raise FileNotFoundError(
f"Alembic configuration file not found at {alembic_ini_path}"
)
# Create Alembic config object
alembic_cfg = Config(str(alembic_ini_path))
# Set the database URL in the config
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Run migrations to the latest revision
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully")
except Exception as e:
logger.error(
"Database migration failed",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise

View File

@@ -0,0 +1,66 @@
from fastapi import Request
from fastapi.responses import JSONResponse
from .logging import get_logger
logger = get_logger(__name__)
class UpstreamError(Exception):
"""Exception raised when an upstream provider fails."""
def __init__(self, message: str, status_code: int = 502):
self.message = message
self.status_code = status_code
super().__init__(message)
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle HTTP exceptions and include request ID in response."""
request_id = getattr(request.state, "request_id", "unknown")
# Get status code and detail - works for both FastAPI and Starlette HTTPException
status_code = getattr(exc, "status_code", 500)
detail = getattr(exc, "detail", str(exc))
logger.warning(
"HTTP exception",
extra={
"request_id": request_id,
"status_code": status_code,
"detail": detail,
"path": request.url.path,
},
)
return JSONResponse(
status_code=status_code,
content={
"detail": detail,
"request_id": request_id,
},
)
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle general exceptions and include request ID in response."""
request_id = getattr(request.state, "request_id", "unknown")
logger.error(
"Unhandled exception",
extra={
"request_id": request_id,
"error": str(exc),
"error_type": type(exc).__name__,
"path": request.url.path,
},
exc_info=True,
)
return JSONResponse(
status_code=500,
content={
"detail": "Internal server error, please contact support with the request ID.",
"request_id": request_id,
},
)

511
routstr/core/log_manager.py Normal file
View File

@@ -0,0 +1,511 @@
import json
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterator
from .logging import get_logger
logger = get_logger(__name__)
class LogManager:
def __init__(self, logs_dir: Path = Path("logs")):
self.logs_dir = logs_dir
def _yield_log_entries(
self,
hours_back: int | None = None,
specific_date: str | None = None,
reverse_files: bool = False,
max_files: int | None = None,
) -> Iterator[dict[str, Any]]:
"""
Yields log entries from files.
Args:
hours_back: specific number of hours to look back.
specific_date: specific date string (YYYY-MM-DD) to look at.
reverse_files: if True, process files in reverse order (newest first).
max_files: maximum number of log files to process (most recent if reverse_files is True).
"""
if not self.logs_dir.exists():
return
log_files = []
cutoff_date = None
if specific_date:
log_file = self.logs_dir / f"app_{specific_date}.log"
if log_file.exists():
log_files.append(log_file)
else:
log_files = sorted(self.logs_dir.glob("app_*.log"))
if reverse_files:
log_files.reverse()
# If we only care about hours back, we can optimize file selection
if hours_back is not None:
cutoff_date = datetime.now(timezone.utc) - timedelta(hours=hours_back)
filtered_files = []
for log_path in log_files:
try:
file_date_str = log_path.stem.split("_")[1]
file_date = datetime.strptime(
file_date_str, "%Y-%m-%d"
).replace(tzinfo=timezone.utc)
# Include file if it's from the same day or after the cutoff day
if file_date >= cutoff_date.replace(
hour=0, minute=0, second=0, microsecond=0
):
filtered_files.append(log_path)
except Exception:
continue
log_files = filtered_files
if max_files is not None and len(log_files) > max_files:
log_files = log_files[:max_files]
for log_file in log_files:
try:
with open(log_file, "r") as f:
# For reverse search, we might want to read lines in reverse?
# But usually logs are append-only.
# If reverse_files is True, we iterate files newest to oldest.
# But lines within file are still oldest to newest unless we reverse them.
lines = f.readlines()
if reverse_files:
lines.reverse()
for line in lines:
try:
entry = json.loads(line.strip())
if cutoff_date:
timestamp_str = entry.get("asctime", "")
if not timestamp_str:
continue
log_time = datetime.strptime(
timestamp_str, "%Y-%m-%d %H:%M:%S"
)
log_time = log_time.replace(tzinfo=timezone.utc)
if log_time < cutoff_date:
continue
yield entry
except json.JSONDecodeError:
continue
except Exception as e:
logger.error(f"Error processing log file {log_file}: {e}")
continue
def search_logs(
self,
date: str | None = None,
level: str | None = None,
request_id: str | None = None,
search_text: str | None = None,
status_codes: list[int] | None = None,
methods: list[str] | None = None,
endpoints: list[str] | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
"""
Search through log files and return matching entries.
"""
log_entries: list[dict[str, Any]] = []
# Use reverse=True to get newest logs first by default
# If date is specified, we only look at that file
search_text_lower = search_text.lower() if search_text else None
# We iterate efficiently
iterator = self._yield_log_entries(
specific_date=date,
reverse_files=True if not date else False,
max_files=7 if not date else None,
)
# If we are searching globally (no date), we might want to limit how far back we go?
# PR 228 did: "glob("app_*.log") sorted by mtime reverse [:7]" (last 7 files)
# My _yield_log_entries with reverse_files=True does all files.
# Let's rely on limit to stop us.
# Optimization: if we are not searching by date, maybe limit to last 7 files inside _yield?
# For now, let's just iterate.
for log_data in iterator:
if not self._matches_filters(
log_data,
level,
request_id,
search_text_lower,
status_codes,
methods,
endpoints,
):
continue
log_entries.append(log_data)
if len(log_entries) >= limit:
break
# Sort by time descending (newest first)
log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=True)
return log_entries
def _matches_filters(
self,
log_data: dict[str, Any],
level: str | None,
request_id: str | None,
search_text_lower: str | None,
status_codes: list[int] | None = None,
methods: list[str] | None = None,
endpoints: list[str] | None = None,
) -> bool:
if level and log_data.get("levelname", "").upper() != level.upper():
return False
if request_id and log_data.get("request_id") != request_id:
return False
if status_codes:
entry_status = log_data.get("status_code")
if entry_status is not None:
try:
if int(entry_status) not in status_codes:
return False
except (ValueError, TypeError):
return False
else:
return False
if methods:
entry_method = log_data.get("method", "").upper()
if entry_method not in [m.upper() for m in methods]:
return False
if endpoints:
entry_path = log_data.get("path", "")
matched = False
for endpoint in endpoints:
clean_endpoint = endpoint.lstrip("/")
if entry_path.startswith(clean_endpoint):
matched = True
break
if clean_endpoint in entry_path:
matched = True
break
if not matched:
return False
if search_text_lower:
message = str(log_data.get("message", "")).lower()
name = str(log_data.get("name", "")).lower()
pathname = str(log_data.get("pathname", "")).lower()
if (
search_text_lower not in message
and search_text_lower not in name
and search_text_lower not in pathname
):
return False
return True
def get_usage_summary(self, hours: int = 24) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
return self._calculate_summary_stats(entries)
def get_usage_metrics(self, interval: int = 15, hours: int = 24) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
return self._aggregate_metrics_by_time(entries, interval, hours)
def get_error_details(self, hours: int = 24, limit: int = 100) -> dict:
errors: list[dict] = []
# Iterate newest to oldest for errors?
# yield_log_entries sorts files by name (date) ascending by default.
# usage stats logic usually expects ascending time for aggregation (though dictionaries don't care).
# For error details "last N errors", we probably want newest first.
# Using list() loads everything into memory, which is what PR 229 did.
# For optimization, we could use reverse iterator.
# Let's just stick to PR 229 logic which filters 'ERROR' level.
entries = self._yield_log_entries(hours_back=hours) # oldest to newest
for entry in entries:
if entry.get("levelname", "").upper() == "ERROR":
timestamp_str = entry.get("asctime", "")
errors.append(
{
"timestamp": timestamp_str,
"message": entry.get("message", ""),
"error_type": entry.get("error_type", "unknown"),
"pathname": entry.get("pathname", ""),
"lineno": entry.get("lineno", 0),
"request_id": entry.get("request_id", ""),
}
)
# Sort reverse time
errors.sort(key=lambda x: x["timestamp"], reverse=True)
return {"errors": errors[:limit], "total_count": len(errors)}
def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
model_stats: dict[str, dict[str, int | float]] = defaultdict(
lambda: {
"revenue_msats": 0,
"refunds_msats": 0,
"requests": 0,
"successful": 0,
"failed": 0,
}
)
for entry in entries:
try:
model = entry.get("model", "unknown")
if not isinstance(model, str):
model = "unknown"
message = entry.get("message", "").lower()
if "received proxy request" in message:
model_stats[model]["requests"] += 1
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
model_stats[model]["successful"] += 1
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
model_stats[model]["revenue_msats"] += actual_cost
if "revert payment" in message or "upstream request failed" in message:
model_stats[model]["failed"] += 1
if "revert payment" in message:
max_cost = entry.get("max_cost_for_model", 0)
if isinstance(max_cost, (int, float)) and max_cost > 0:
model_stats[model]["refunds_msats"] += max_cost
except Exception:
continue
models: list[dict[str, Any]] = []
total_revenue = 0.0
for model, stats in model_stats.items():
revenue_msats = float(stats["revenue_msats"])
refunds_msats = float(stats["refunds_msats"])
revenue_sats = revenue_msats / 1000
refunds_sats = refunds_msats / 1000
net_revenue_sats = revenue_sats - refunds_sats
total_revenue += net_revenue_sats
requests = int(stats["requests"])
successful = int(stats["successful"])
models.append(
{
"model": model,
"revenue_sats": revenue_sats,
"refunds_sats": refunds_sats,
"net_revenue_sats": net_revenue_sats,
"requests": requests,
"successful": successful,
"failed": int(stats["failed"]),
"avg_revenue_per_request": (
revenue_sats / successful if successful > 0 else 0
),
}
)
models.sort(key=lambda x: float(x["net_revenue_sats"]), reverse=True)
return {
"models": models[:limit],
"total_revenue_sats": total_revenue,
"total_models": len(models),
}
def _calculate_summary_stats(self, entries: list[dict]) -> dict:
stats: dict[str, Any] = {
"total_entries": 0,
"total_requests": 0,
"successful_chat_completions": 0,
"failed_requests": 0,
"total_errors": 0,
"total_warnings": 0,
"payment_processed": 0,
"upstream_errors": 0,
"unique_models": set(),
"error_types": defaultdict(int),
"revenue_msats": 0.0,
"refunds_msats": 0.0,
}
for entry in entries:
try:
stats["total_entries"] += 1
message = entry.get("message", "").lower()
level = entry.get("levelname", "").upper()
if level == "ERROR":
stats["total_errors"] += 1
if "error_type" in entry:
stats["error_types"][str(entry["error_type"])] += 1
elif level == "WARNING":
stats["total_warnings"] += 1
if "received proxy request" in message:
stats["total_requests"] += 1
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
stats["successful_chat_completions"] += 1
if "upstream request failed" in message or "revert payment" in message:
stats["failed_requests"] += 1
if "payment processed successfully" in message:
stats["payment_processed"] += 1
if "upstream" in message and level == "ERROR":
stats["upstream_errors"] += 1
if "model" in entry:
model = entry["model"]
if isinstance(model, str) and model != "unknown":
stats["unique_models"].add(model)
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
stats["revenue_msats"] += float(actual_cost)
if "revert payment" in message:
max_cost = entry.get("max_cost_for_model", 0)
if isinstance(max_cost, (int, float)) and max_cost > 0:
stats["refunds_msats"] += float(max_cost)
except Exception:
continue
revenue_sats = stats["revenue_msats"] / 1000
refunds_sats = stats["refunds_msats"] / 1000
net_revenue_sats = revenue_sats - refunds_sats
total_requests = stats["total_requests"]
successful = stats["successful_chat_completions"]
return {
"total_entries": stats["total_entries"],
"total_requests": total_requests,
"successful_chat_completions": successful,
"failed_requests": stats["failed_requests"],
"total_errors": stats["total_errors"],
"total_warnings": stats["total_warnings"],
"payment_processed": stats["payment_processed"],
"upstream_errors": stats["upstream_errors"],
"unique_models_count": len(stats["unique_models"]),
"unique_models": sorted(list(stats["unique_models"])),
"error_types": dict(stats["error_types"]),
"success_rate": (successful / total_requests * 100)
if total_requests > 0
else 0,
"revenue_msats": stats["revenue_msats"],
"refunds_msats": stats["refunds_msats"],
"revenue_sats": revenue_sats,
"refunds_sats": refunds_sats,
"net_revenue_msats": stats["revenue_msats"] - stats["refunds_msats"],
"net_revenue_sats": net_revenue_sats,
"avg_revenue_per_request_msats": (
stats["revenue_msats"] / successful if successful > 0 else 0
),
"refund_rate": (
(stats["failed_requests"] / total_requests * 100)
if total_requests > 0
else 0
),
}
def _aggregate_metrics_by_time(
self, entries: list[dict], interval_minutes: int, hours_back: int
) -> dict:
time_buckets: dict[str, dict[str, Any]] = defaultdict(
lambda: {"requests": 0, "errors": 0, "revenue_msats": 0.0}
)
for entry in entries:
try:
timestamp_str = entry.get("asctime", "")
if not timestamp_str:
continue
log_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
log_time = log_time.replace(tzinfo=timezone.utc)
# Round down to nearest interval
minutes = log_time.minute
rounded_minutes = (minutes // interval_minutes) * interval_minutes
bucket_time = log_time.replace(
minute=rounded_minutes, second=0, microsecond=0
)
bucket_key = bucket_time.strftime("%Y-%m-%d %H:%M:%S")
bucket = time_buckets[bucket_key]
message = entry.get("message", "").lower()
level = entry.get("levelname", "").upper()
if "received proxy request" in message:
bucket["requests"] += 1
if level == "ERROR":
bucket["errors"] += 1
if (
"completed for streaming" in message
or "completed for non-streaming" in message
):
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
bucket["revenue_msats"] += float(actual_cost)
except Exception:
continue
result = []
for bucket_key in sorted(time_buckets.keys()):
result.append({"timestamp": bucket_key, **time_buckets[bucket_key]})
return {
"metrics": result,
"interval_minutes": interval_minutes,
"hours_back": hours_back,
"total_buckets": len(result),
}
log_manager = LogManager()

View File

@@ -1,3 +1,40 @@
"""
Logging configuration for Routstr.
CRITICAL LOG MESSAGES FOR USAGE STATISTICS:
===========================================
The following log messages are parsed by the usage tracking system (routstr/core/admin.py).
DO NOT modify or remove these messages without updating the usage tracking logic:
1. "Received proxy request" (INFO) - routstr/proxy.py
- Used to count total incoming requests
- Includes model information in context
2. "Payment adjustment completed for streaming" (INFO) - routstr/upstream/base.py
"Payment adjustment completed for non-streaming" (INFO) - routstr/upstream/base.py
- Used to track successful completions and revenue
- The 'cost_data.total_msats' field is extracted for revenue calculation
- Must include 'cost_data' in extra dict
3. "Payment processed successfully" (INFO) - routstr/auth.py
- Used to count successful payment processing events
- Tracks payment-related metrics
4. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py
- Used to track failed requests and refunds
- The 'max_cost_for_model' field is extracted for refund calculation
- Must include 'max_cost_for_model' in extra dict
5. Any ERROR level logs with "upstream" in the message
- Used to count upstream provider errors
- Helps identify service reliability issues
If you need to modify these messages, ensure you also update the parsing logic in:
- routstr/core/admin.py:_aggregate_metrics_by_time()
- routstr/core/admin.py:_get_summary_stats()
- routstr/core/admin.py:get_revenue_by_model()
"""
import logging.config
import logging.handlers
import os
@@ -8,6 +45,21 @@ from pathlib import Path
from typing import Any
from pythonjsonlogger import jsonlogger
from rich.logging import RichHandler
# Define custom TRACE level
TRACE_LEVEL = 5
logging.addLevelName(TRACE_LEVEL, "TRACE")
def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
"""Log with TRACE level"""
if self.isEnabledFor(TRACE_LEVEL):
self._log(TRACE_LEVEL, message, args, **kwargs)
# Add the trace method to Logger class
setattr(logging.Logger, "trace", trace)
class DailyRotatingFileHandler(logging.handlers.TimedRotatingFileHandler):
@@ -74,7 +126,7 @@ def get_package_version() -> str:
return version
current_path = current_path.parent
# Fallback: try the simple path resolution (3 levels up for router/logging/logging_config.py)
# Fallback: try the simple path resolution (3 levels up for routstr/logging/logging_config.py)
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
if pyproject_path.exists():
with open(pyproject_path, "rb") as f:
@@ -100,6 +152,23 @@ class VersionFilter(logging.Filter):
return True
class RequestIdFilter(logging.Filter):
"""Filter to add request ID to all log records."""
def filter(self, record: logging.LogRecord) -> bool:
"""Add request ID to the log record if available."""
try:
# Import here to avoid circular imports
from .middleware import request_id_context
request_id = request_id_context.get(None)
record.request_id = request_id if request_id else "no-request-id"
except ImportError:
# If middleware isn't available yet, just use default
record.request_id = "no-request-id"
return True
class SecurityFilter(logging.Filter):
"""Filter to remove sensitive information from logs."""
@@ -123,21 +192,24 @@ class SecurityFilter(logging.Filter):
"""Filter out sensitive information from log records."""
try:
message = record.getMessage()
standalone_patterns = [
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
r"nsec[a-z0-9]+", # Nostr Public / Private Key
]
for pattern in standalone_patterns:
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
for key in self.SENSITIVE_KEYS:
if key in message.lower():
patterns = [
rf"{key}[:\s=]+([a-zA-Z0-9_\-\.]+)", # key: value or key=value
rf'{key}[:\s=]+["\']([^"\']+)["\']', # key: "value" or key='value'
r"Bearer\s+([a-zA-Z0-9_\-\.]+)", # Bearer token
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
key_patterns = [
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
]
for pattern in patterns:
for pattern in key_patterns:
message = re.sub(
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
)
record.msg = message
record.args = ()
@@ -149,13 +221,43 @@ class SecurityFilter(logging.Filter):
def get_log_level() -> str:
"""Get log level from environment variable."""
return os.environ.get("LOG_LEVEL", "INFO").upper()
try:
from .settings import settings
level = settings.log_level.upper()
except Exception:
level = os.environ.get("LOG_LEVEL", "INFO").upper()
# Validate log level - if invalid, default to INFO
valid_levels = {"TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
if level not in valid_levels:
level = "INFO"
return level
def should_enable_console_logging() -> bool:
"""Check if console logging should be enabled."""
try:
from .settings import settings
return bool(settings.enable_console_logging)
except Exception:
return os.environ.get("ENABLE_CONSOLE_LOGGING", "true").lower() in (
"true",
"1",
"yes",
)
def setup_logging() -> None:
"""Configure centralized logging for the application."""
log_level = get_log_level()
console_enabled = should_enable_console_logging()
# Determine which handlers to use
handlers = ["file"]
if console_enabled:
handlers.append("console")
LOGGING_CONFIG = {
"version": 1,
@@ -163,27 +265,24 @@ def setup_logging() -> None:
"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",
"format": "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d %(version)s %(request_id)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"filters": {
"version_filter": {"()": VersionFilter},
"request_id_filter": {"()": RequestIdFilter},
"security_filter": {"()": SecurityFilter},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"()": RichHandler,
"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"],
"show_time": False,
"show_path": False,
"rich_tracebacks": True,
"markup": True,
"filters": ["request_id_filter", "security_filter"],
},
"file": {
"()": DailyRotatingFileHandler,
@@ -194,53 +293,88 @@ def setup_logging() -> None:
"interval": 1, # Every 1 day
"backupCount": 30, # Keep 30 days of logs
"atTime": None, # Rotate at midnight (00:00)
"filters": ["version_filter", "security_filter"],
"filters": ["version_filter", "request_id_filter", "security_filter"],
},
},
"loggers": {
"router": {
"routstr": {
"level": log_level,
"handlers": ["console", "file"],
"handlers": handlers,
"propagate": False,
},
"router.payment": {
"routstr.payment": {
"level": log_level,
"handlers": ["console", "file"],
"handlers": handlers,
"propagate": False,
},
"router.cashu": {
"routstr.proxy": {
"level": log_level,
"handlers": ["console", "file"],
"handlers": handlers,
"propagate": False,
},
"router.proxy": {
"routstr.auth": {
"level": log_level,
"handlers": ["console", "file"],
"handlers": handlers,
"propagate": False,
},
"router.auth": {
"routstr.payment.models": {
"level": log_level,
"handlers": ["console", "file"],
"handlers": handlers,
"propagate": False,
},
"routstr.core.exceptions": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"routstr.core.middleware": {
"level": log_level,
"handlers": ["file"],
"propagate": False,
},
# Suppress verbose third-party logging
"httpx": {
"level": "WARNING",
"handlers": ["console"],
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"openai": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"httpcore": {
"level": "WARNING",
"handlers": ["console"],
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"websockets": {
"level": "WARNING",
"handlers": [],
"propagate": False,
},
"uvicorn.access": {
"level": "WARNING",
"handlers": ["console"],
"handlers": ["file"],
"propagate": False,
},
"uvicorn.error": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
"aiosqlite": {"level": "ERROR", "handlers": [], "propagate": False},
"alembic": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
},
"root": {"level": log_level, "handlers": ["console"]},
"root": {
"level": log_level,
"handlers": ["console"] if console_enabled else [],
},
}
os.makedirs("logs", exist_ok=True)

78
routstr/core/main.py Normal file
View File

@@ -0,0 +1,78 @@
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from starlette.exceptions import HTTPException
from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_router
from ..payment.models import models_router
from ..proxy import proxy_router
from .admin import admin_router
from .exceptions import general_exception_handler, http_exception_handler
from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware
from .settings import settings as global_settings
from .tasks import lifespan
from .ui import setup_ui
# Initialize logging first
setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.3.0-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.3.0"
app = FastAPI(version=__version__, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=global_settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["x-routstr-request-id"],
)
# Add logging middleware
app.add_middleware(LoggingMiddleware)
# Add exception handlers
app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore
app.add_exception_handler(Exception, general_exception_handler)
@app.get("/v1/info")
async def info() -> dict:
return {
"name": global_settings.name,
"description": global_settings.description,
"version": __version__,
"npub": global_settings.npub,
"mints": global_settings.cashu_mints,
"http_url": global_settings.http_url,
"onion_url": global_settings.onion_url,
"child_key_cost_msats": global_settings.child_key_cost,
}
@app.get("/v1/providers")
async def providers() -> RedirectResponse:
return RedirectResponse("/v1/providers/")
# Setup UI serving
setup_ui(app)
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)

135
routstr/core/middleware.py Normal file
View File

@@ -0,0 +1,135 @@
import time
import uuid
from contextvars import ContextVar
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from .logging import get_logger
logger = get_logger(__name__)
# Context variable to store request ID across async context
request_id_context: ContextVar[str | None] = ContextVar("request_id")
class LoggingMiddleware(BaseHTTPMiddleware):
"""Middleware to log detailed request and response information."""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
# Generate request ID
request_id = str(uuid.uuid4())
request.state.request_id = request_id
# Set request ID in context for logging
token = request_id_context.set(request_id)
# Start timing
start_time = time.time()
# Log request details
request_body = None
if request.method in ["POST", "PUT", "PATCH"]:
try:
# Only read body for non-streaming requests
if hasattr(request, "_body"):
request_body = await request.body()
except Exception:
pass
# Extract request info
client_host = None
if request.client:
client_host = request.client.host
# Log incoming request
logger.info(
"Incoming request",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"query_params": dict(request.query_params),
"client_host": client_host,
"headers": {
k: v
for k, v in request.headers.items()
if k.lower()
not in [
"authorization",
"x-cashu",
"cookie",
"cf-connecting-ip",
"cf-ipcountry",
"x-forwarded-for",
"x-real-ip",
]
},
"body_size": len(request_body) if request_body else 0,
},
)
# Log at TRACE level for full body (security filter will redact sensitive data)
if request_body and hasattr(logger, "exception"):
logger.exception(
"Request body",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"body": request_body.decode("utf-8", errors="ignore")[
:1000
], # Limit size
},
)
# Process request
try:
response = await call_next(request)
# Calculate duration
duration = time.time() - start_time
# Log response
logger.info(
"Request completed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": round(duration * 1000, 2),
"client_host": client_host,
},
)
if hasattr(response, "headers"):
response.headers["x-routstr-request-id"] = request_id
return response
except Exception as e:
# Calculate duration
duration = time.time() - start_time
# Log error
logger.error(
"Request failed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"duration_ms": round(duration * 1000, 2),
"client_host": client_host,
"error": str(e),
"error_type": type(e).__name__,
},
exc_info=True,
)
raise
finally:
# Reset context
request_id_context.reset(token)
__all__ = ["LoggingMiddleware", "request_id_context"]

312
routstr/core/settings.py Normal file
View File

@@ -0,0 +1,312 @@
from __future__ import annotations
import asyncio
import json
import os
from datetime import datetime, timezone
from typing import Any
from pydantic.v1 import BaseModel, BaseSettings, Field
from sqlmodel.ext.asyncio.session import AsyncSession
class Settings(BaseSettings):
class Config:
case_sensitive = True
@classmethod
def parse_env_var(cls, field_name: str, raw_value: str) -> Any: # type: ignore[override]
if field_name in {"cashu_mints", "cors_origins", "relays"}:
v = str(raw_value).strip()
if v == "":
return []
return [p.strip() for p in v.split(",") if p.strip()]
return raw_value
# Core
upstream_base_url: str = Field(default="", env="UPSTREAM_BASE_URL")
upstream_api_key: str = Field(default="", env="UPSTREAM_API_KEY")
admin_password: str = Field(default="", env="ADMIN_PASSWORD")
# Node info
name: str = Field(default="ARoutstrNode", env="NAME")
description: str = Field(default="A Routstr Node", env="DESCRIPTION")
npub: str = Field(default="", env="NPUB")
http_url: str = Field(default="", env="HTTP_URL")
onion_url: str = Field(default="", env="ONION_URL")
# Cashu
cashu_mints: list[str] = Field(default_factory=list, env="CASHU_MINTS")
receive_ln_address: str = Field(default="", env="RECEIVE_LN_ADDRESS")
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT")
# Pricing
# Default behavior: derive pricing from MODELS
# If fixed_pricing is True -> use fixed_cost_per_request and ignore tokens
# If fixed_per_1k_* are set (non-zero) -> override model token pricing when model-based
fixed_pricing: bool = Field(default=False, env="FIXED_PRICING")
fixed_cost_per_request: int = Field(default=1, env="FIXED_COST_PER_REQUEST")
fixed_per_1k_input_tokens: int = Field(default=0, env="FIXED_PER_1K_INPUT_TOKENS")
fixed_per_1k_output_tokens: int = Field(default=0, env="FIXED_PER_1K_OUTPUT_TOKENS")
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
child_key_cost: int = Field(default=1000, env="CHILD_KEY_COST")
# Minimum per-request charge in millisatoshis when model pricing is free/zero
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
reset_reserved_balance_on_startup: bool = Field(
default=True, env="RESET_RESERVED_BALANCE_ON_STARTUP"
) # deactivate in horizontal scaling setups
# Network
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL")
providers_refresh_interval_seconds: int = Field(
default=0, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
)
pricing_refresh_interval_seconds: int = Field(
default=120, env="PRICING_REFRESH_INTERVAL_SECONDS"
)
models_refresh_interval_seconds: int = Field(
default=360, env="MODELS_REFRESH_INTERVAL_SECONDS"
)
enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH")
enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH")
refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS")
# Logging
log_level: str = Field(default="INFO", env="LOG_LEVEL")
enable_console_logging: bool = Field(default=True, env="ENABLE_CONSOLE_LOGGING")
# Other
chat_completions_api_version: str = Field(
default="", env="CHAT_COMPLETIONS_API_VERSION"
)
models_path: str = Field(default="models.json", env="MODELS_PATH")
source: str = Field(default="", env="SOURCE")
# Secrets / optional runtime controls
provider_id: str = Field(default="", env="PROVIDER_ID")
nsec: str = Field(default="", env="NSEC")
# Discovery
relays: list[str] = Field(default_factory=list, env="RELAYS")
def _compute_primary_mint(cashu_mints: list[str]) -> str:
return cashu_mints[0] if cashu_mints else "https://mint.minibits.cash/Bitcoin"
def resolve_bootstrap() -> Settings:
base = Settings() # Reads env with custom parse_env_var
# Back-compat env mapping
try:
# Map MODEL_BASED_PRICING -> fixed_pricing (inverted)
if "MODEL_BASED_PRICING" in os.environ and "FIXED_PRICING" not in os.environ:
mbp_raw = os.environ.get("MODEL_BASED_PRICING", "").strip().lower()
mbp = mbp_raw in {"1", "true", "yes", "on"}
base.fixed_pricing = not mbp
# Map COST_PER_REQUEST -> fixed_cost_per_request if new not provided
if (
"COST_PER_REQUEST" in os.environ
and "FIXED_COST_PER_REQUEST" not in os.environ
):
try:
base.fixed_cost_per_request = int(
os.environ["COST_PER_REQUEST"].strip()
)
except Exception:
pass
# Map COST_PER_1K_* -> FIXED_PER_1K_*
if (
"COST_PER_1K_INPUT_TOKENS" in os.environ
and "FIXED_PER_1K_INPUT_TOKENS" not in os.environ
):
try:
base.fixed_per_1k_input_tokens = int(
os.environ["COST_PER_1K_INPUT_TOKENS"].strip()
)
except Exception:
pass
if (
"COST_PER_1K_OUTPUT_TOKENS" in os.environ
and "FIXED_PER_1K_OUTPUT_TOKENS" not in os.environ
):
try:
base.fixed_per_1k_output_tokens = int(
os.environ["COST_PER_1K_OUTPUT_TOKENS"].strip()
)
except Exception:
pass
except Exception:
pass
if not base.onion_url:
try:
from ..nip91 import discover_onion_url_from_tor # type: ignore
discovered = discover_onion_url_from_tor()
if discovered:
base.onion_url = discovered
except Exception:
pass
# Derive NPUB from NSEC if not provided
if not base.npub and base.nsec:
try:
from nostr.key import PrivateKey # type: ignore
if base.nsec.startswith("nsec"):
pk = PrivateKey.from_nsec(base.nsec)
elif len(base.nsec) == 64:
pk = PrivateKey(bytes.fromhex(base.nsec))
else:
pk = None
if pk is not None:
try:
base.npub = pk.public_key.bech32()
except Exception:
# Fallback to hex if bech32 not available
base.npub = pk.public_key.hex()
except Exception:
pass
if not base.cors_origins:
base.cors_origins = ["*"]
if not base.primary_mint:
base.primary_mint = _compute_primary_mint(base.cashu_mints)
return base
class SettingsRow(BaseModel):
id: int
data: dict[str, Any]
updated_at: datetime | None = None
# Single, concrete settings instance that callers import directly
settings: Settings = resolve_bootstrap()
class SettingsService:
_current: Settings | None = None
_lock: asyncio.Lock = asyncio.Lock()
@classmethod
def get(cls) -> Settings:
if cls._current is None:
raise RuntimeError("SettingsService not initialized")
return cls._current
@classmethod
async def initialize(cls, db_session: AsyncSession) -> Settings:
async with cls._lock:
from sqlmodel import text
await db_session.exec( # type: ignore
text(
"CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY, data TEXT NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
)
)
row = await db_session.exec( # type: ignore
text("SELECT id, data, updated_at FROM settings WHERE id = 1")
)
row = row.first()
env_resolved = resolve_bootstrap()
if row is None:
await db_session.exec( # type: ignore
text(
"INSERT INTO settings (id, data, updated_at) VALUES (1, :data, :updated_at)"
).bindparams(
data=json.dumps(env_resolved.dict()),
updated_at=datetime.now(timezone.utc),
)
)
await db_session.commit()
cls._current = settings
# Update the existing instance in-place for all live importers
for k, v in env_resolved.dict().items():
setattr(settings, k, v)
return cls._current
db_id, db_data, _updated_at = row
try:
db_json = (
json.loads(db_data) if isinstance(db_data, str) else dict(db_data)
)
except Exception:
db_json = {}
merged_dict: dict[str, Any] = dict(env_resolved.dict())
merged_dict.update(
{k: v for k, v in db_json.items() if v not in (None, "", [], {})}
)
# Ensure primary_mint is consistent with cashu_mints if not explicitly set
if not merged_dict.get("primary_mint"):
merged_dict["primary_mint"] = _compute_primary_mint(
merged_dict.get("cashu_mints", [])
)
if any(k not in db_json for k in merged_dict.keys()):
await db_session.exec( # type: ignore
text(
"UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1"
).bindparams(
data=json.dumps(merged_dict),
updated_at=datetime.now(timezone.utc),
)
)
await db_session.commit()
# Update the existing instance in-place for all live importers
for k, v in merged_dict.items():
setattr(settings, k, v)
cls._current = settings
return cls._current
@classmethod
async def update(
cls, partial: dict[str, Any], db_session: AsyncSession
) -> Settings:
async with cls._lock:
current = cls.get()
candidate_dict = {**current.dict(), **partial}
candidate = Settings(**candidate_dict)
from sqlmodel import text
# Ensure primary_mint reflects candidate mints if missing
if not candidate.primary_mint:
candidate.primary_mint = _compute_primary_mint(candidate.cashu_mints)
await db_session.exec( # type: ignore
text(
"UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1"
).bindparams(
data=json.dumps(candidate.dict()),
updated_at=datetime.now(timezone.utc),
)
)
await db_session.commit()
# Update in-place
for k, v in candidate.dict().items():
setattr(settings, k, v)
cls._current = settings
return settings
@classmethod
async def reload_from_db(cls, db_session: AsyncSession) -> Settings:
async with cls._lock:
from sqlmodel import text
row = await db_session.exec(text("SELECT data FROM settings WHERE id = 1")) # type: ignore
row = row.first()
if row is None:
raise RuntimeError("Settings row missing")
(data_str,) = row
data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str)
# Update in-place
for k, v in data.items():
setattr(settings, k, v)
cls._current = settings
return settings

143
routstr/core/tasks.py Normal file
View File

@@ -0,0 +1,143 @@
import asyncio
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI
# when other refactor is merged:
# from ..nostr import announce_provider, providers_cache_refresher
from ..discovery import providers_cache_refresher
from ..nip91 import announce_provider
from ..payment.models import update_sats_pricing
from ..payment.price import update_prices_periodically
from ..proxy import initialize_upstreams, refresh_model_maps_periodically
from ..wallet import periodic_payout
from .db import create_session, init_db, run_migrations
from .logging import get_logger
from .settings import SettingsService
from .settings import settings as global_settings
logger = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger.info("Application startup initiated", extra={"version": app.version})
btc_price_task = None
pricing_task = None
payout_task = None
nip91_task = None
providers_task = None
models_refresh_task = None
model_maps_refresh_task = None
try:
# Run database migrations on startup
run_migrations()
# Initialize database connection pools
# This creates any tables that might not be tracked by migrations yet
await init_db()
# Initialize application settings (env -> computed -> DB precedence)
async with create_session() as session:
s = await SettingsService.initialize(session)
if s.reset_reserved_balance_on_startup:
from .db import reset_all_reserved_balances
await reset_all_reserved_balances(session)
if not s.admin_password:
logger.warning(
f"Admin password is not set. Visit {s.http_url or 'http://localhost:8000'}/admin to set the password."
)
# Apply app metadata from settings
try:
app.title = s.name
app.description = s.description
except Exception:
pass
# await ensure_models_bootstrapped()
from ..payment.price import _update_prices
from ..proxy import get_upstreams
from ..upstream.helpers import refresh_upstreams_models_periodically
_update_prices_task = asyncio.create_task(_update_prices())
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
# ensure both setup tasks complete
await asyncio.gather(
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
)
btc_price_task = asyncio.create_task(update_prices_periodically())
pricing_task = asyncio.create_task(update_sats_pricing())
if global_settings.models_refresh_interval_seconds > 0:
models_refresh_task = asyncio.create_task(
refresh_upstreams_models_periodically(get_upstreams())
)
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
payout_task = asyncio.create_task(periodic_payout())
if global_settings.nsec:
nip91_task = asyncio.create_task(announce_provider())
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
yield
except asyncio.CancelledError:
# Expected during shutdown
pass
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")
if btc_price_task is not None:
btc_price_task.cancel()
if pricing_task is not None:
pricing_task.cancel()
if payout_task is not None:
payout_task.cancel()
if nip91_task is not None:
nip91_task.cancel()
if providers_task is not None:
providers_task.cancel()
if models_refresh_task is not None:
models_refresh_task.cancel()
if model_maps_refresh_task is not None:
model_maps_refresh_task.cancel()
try:
tasks_to_wait = []
if btc_price_task is not None:
tasks_to_wait.append(btc_price_task)
if pricing_task is not None:
tasks_to_wait.append(pricing_task)
if payout_task is not None:
tasks_to_wait.append(payout_task)
if nip91_task is not None:
tasks_to_wait.append(nip91_task)
if providers_task is not None:
tasks_to_wait.append(providers_task)
if models_refresh_task is not None:
tasks_to_wait.append(models_refresh_task)
if model_maps_refresh_task is not None:
tasks_to_wait.append(model_maps_refresh_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, 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__},
)

Some files were not shown because too many files have changed in this diff Show More