Compare commits

...

176 Commits

Author SHA1 Message Date
9qeklajc
eabe17cc26 clean up ui 2026-03-11 21:05:44 +01:00
9qeklajc
8f298ae9c1 add auto topup 2026-03-11 20:50:52 +01:00
9qeklajc
e5f3b7d755 fmt 2026-03-10 17:44:25 +01:00
9qeklajc
3796e29cb4 auto top up later 2026-03-10 17:36:51 +01:00
9qeklajc
ce5fa136c2 improve ui 2026-03-09 22:41:38 +01:00
9qeklajc
4643aefb22 no auth for routstr node to check and topup balance 2026-03-09 22:13:33 +01:00
9qeklajc
12f2cfecc9 update ui 2026-03-08 22:19:00 +01:00
9qeklajc
a0f3378cf5 clean up 2026-03-08 21:52:22 +01:00
9qeklajc
66975ee271 Merge branch 'v0.4.0' into add-routstr-provider 2026-03-06 23:08:30 +01:00
9qeklajc
5788d63892 Merge pull request #394 from Routstr/fix-refund-token
fix refund token multiple times
2026-03-06 23:06:14 +01:00
9qeklajc
d0c7cc6bd9 fix refund token multiple times 2026-03-06 23:02:47 +01:00
9qeklajc
40096867d9 Merge pull request #390 from Routstr/fix-negative-reserve
fix negative reserve balance
2026-03-06 18:02:13 +01:00
9qeklajc
e002c0b66f clean up 2026-03-06 17:59:49 +01:00
9qeklajc
608549d051 Merge pull request #357 from Routstr/fix/azure-kimi-routing-v040-on-v0.4.0
Fix Azure Kimi routing and DB override model mapping
2026-03-06 16:58:57 +01:00
9qeklajc
72f389c8d2 Merge pull request #354 from Routstr/fix/azure-remote-model-filter-by-id-v040
fix(admin): filter provider remote models by id
2026-03-06 16:58:02 +01:00
9qeklajc
97daddb95a clean prints 2026-03-03 23:59:14 +01:00
9qeklajc
847f4b07a5 Merge branch 'v0.4.0' into add-routstr-provider 2026-03-03 23:58:19 +01:00
9qeklajc
4bce04ada4 Merge pull request #391 from Routstr/fix/cashu-402-not-wrapped
Fix Cashu insufficient-balance responses being wrapped as 401
2026-03-03 23:56:20 +01:00
9qeklajc
f1e2448620 Merge pull request #388 from Routstr/fix/skip-auth-preflight-balance
Skip preflight balance checks for Authorization tokens
2026-03-03 23:53:41 +01:00
redshift
28340a152c fix cashu auth to preserve insufficient balance status 2026-03-03 22:20:42 +00:00
redshift
e28f6118e7 Merge v0.4.0 into fix/skip-auth-preflight-balance 2026-03-03 15:03:31 +00:00
9qeklajc
9fb6f54d12 fix negative reserve balance 2026-03-03 15:29:07 +01:00
9qeklajc
3cb8d7b5dd Merge pull request #377 from Routstr/split-all
split all spaces
2026-03-03 00:47:54 +01:00
9qeklajc
ede076b881 Merge pull request #374 from Routstr/add-cost-details
Add cost details
2026-03-03 00:47:40 +01:00
redshift
449a0951f9 skip preflight balance checks for Authorization tokens 2026-03-02 14:39:55 +00:00
9qeklajc
8c9ede2272 split all spaces 2026-02-25 17:57:17 +01:00
redshift
e794b09614 Add sats cost and remaining balance to response 2026-02-22 15:44:37 +01:00
9qeklajc
24f6519267 Merge pull request #372 from Routstr/fix-migration
quick fix for failed migration (first time setup)
2026-02-19 13:37:21 +01:00
9qeklajc
f57beb6411 quick fix for failed migration 2026-02-19 13:27:09 +01:00
9qeklajc
3e41e59a1d Merge pull request #371 from Routstr/add-reverted-changes
revert missing check
2026-02-19 12:54:28 +01:00
9qeklajc
002d750830 revert missing check 2026-02-19 12:37:41 +01:00
9qeklajc
8c2eb55760 Merge branch 'main' into v0.4.0
# Conflicts:
#	docs/provider/quickstart.md
2026-02-18 23:50:09 +01:00
9qeklajc
350714f23a default child key price to zero 2026-02-18 23:45:37 +01:00
9qeklajc
9cc2e84f7c Merge branch 'v0.4.0' into add-routstr-provider
# Conflicts:
#	tests/integration/test_child_keys_api.py
#	ui/components/landing/api-key-manager.tsx
#	ui/components/landing/cashu-payment-workflow.tsx
#	ui/components/landing/cheat-sheet.tsx
#	ui/components/landing/key-info-details.tsx
2026-02-18 23:24:32 +01:00
9qeklajc
6fbd479bdc Merge pull request #367 from Routstr/fixed-max-cost-discount-bug
Fixed max cost discount bug
2026-02-16 21:34:12 +01:00
9qeklajc
f67c26935a Merge pull request #360 from Routstr/custom-models-fix
Custom models were only showing up in the DB but now in the v1/models output
2026-02-16 21:31:56 +01:00
red
9ce91f58a9 fxied the bug by calculating the same way as _calculate_usd_max_costs in models.py 2026-02-16 09:18:12 +00:00
red
bb82361434 fixed include disabledd 2026-02-16 09:14:35 +00:00
9qeklajc
5f1d67e87e Merge pull request #365 from Routstr/child-key-details
Child key details
2026-02-15 17:28:57 +01:00
9qeklajc
b2106ad1e6 fix build 2026-02-15 17:23:16 +01:00
9qeklajc
709a4ba0dc lint 2026-02-15 17:16:10 +01:00
9qeklajc
21f421b212 fmt 2026-02-15 17:09:46 +01:00
9qeklajc
b3c5e4cbf6 add doc 2026-02-15 17:06:07 +01:00
9qeklajc
1d95379328 add child keys details to view 2026-02-15 17:06:01 +01:00
9qeklajc
342a7f7f16 add doc 2026-02-15 17:05:30 +01:00
9qeklajc
7694f20006 add child keys details to view 2026-02-15 16:51:26 +01:00
9qeklajc
3ea0a267dd add routstr logic 2026-02-15 16:13:29 +01:00
9qeklajc
68e537f6fb improive topup 2026-02-14 01:43:33 +01:00
9qeklajc
814c39898d display qrcode 2026-02-14 01:21:12 +01:00
9qeklajc
fdd4f12f5c add simple ui to routstr topup 2026-02-14 01:17:34 +01:00
9qeklajc
6957d8c0d9 update design 2026-02-13 23:39:02 +01:00
9qeklajc
2c358276bf add setting jsonb 2026-02-13 23:02:24 +01:00
9qeklajc
48529f672a Merge pull request #364 from Routstr/update-main-doc
add missing info
2026-02-13 22:29:09 +01:00
9qeklajc
030c2f65e4 add missing info 2026-02-13 22:22:41 +01:00
9qeklajc
3510402af2 Merge pull request #363 from Routstr/update-main-doc
update doc
2026-02-13 21:26:26 +01:00
9qeklajc
5f376d716d clean up 2026-02-13 21:15:33 +01:00
9qeklajc
d889274f84 update doc 2026-02-12 23:25:59 +01:00
9qeklajc
0c0f19d854 Merge pull request #362 from Routstr/update-main-doc
Update main doc
2026-02-12 21:23:53 +01:00
9qeklajc
b61bffc666 Merge pull request #361 from Routstr/update-docs
update doc
2026-02-12 21:20:17 +01:00
9qeklajc
af658136d4 add docker file 2026-02-12 21:19:19 +01:00
9qeklajc
8973627b5f update doc 2026-02-12 21:19:12 +01:00
9qeklajc
25f427033a add docker file 2026-02-12 21:18:22 +01:00
9qeklajc
d3dd8318e4 update doc 2026-02-12 21:15:44 +01:00
9qeklajc
c5fd386c1e add routstr provider 2026-02-12 11:19:40 +01:00
redshift
52c7f17215 fixed build errors 2 2026-02-11 02:54:16 +00:00
redshift
2c03302055 fixed build errors 2026-02-11 02:32:21 +00:00
redshift
c3221f2a31 Fixed custom models not showing up in the v1/models output 2026-02-11 02:26:24 +00:00
Evan Yang
ce9834d7ec fix: harden azure routing and model override mapping 2026-02-10 19:15:25 +08:00
9qeklajc
6ebe73f2f7 Fix Azure Kimi routing and DB override model mapping 2026-02-10 07:32:54 +00:00
Evan Yang
98aecb08f9 fix(admin): filter provider remote models by id 2026-02-10 01:04:14 +08:00
9qeklajc
58fa063c6b Merge pull request #352 from Routstr/fix-pyament-finalization
enforce payment finalization
2026-02-08 23:20:20 +01:00
9qeklajc
7c94f60797 Merge pull request #351 from Routstr/child-key-expiration
Child key expiration
2026-02-08 23:20:10 +01:00
9qeklajc
4cb4c6dfec fix test 2026-02-08 23:06:48 +01:00
9qeklajc
512b686e5f fmt 2026-02-08 22:56:38 +01:00
9qeklajc
f495a10eeb keys with different config and better reset 2026-02-08 22:54:59 +01:00
9qeklajc
d1692edb63 no balance limit for parent key 2026-02-07 00:13:57 +01:00
9qeklajc
8f81bcd2fc fix do not remove key after refund 2026-02-05 18:48:46 +01:00
9qeklajc
6a5ed9d063 fmt 2026-02-05 01:20:10 +01:00
9qeklajc
b9890e6ad5 fmt 2026-02-05 01:19:11 +01:00
9qeklajc
e4b8293d41 lint 2026-02-05 01:03:25 +01:00
9qeklajc
ba5f9fc181 improvve key logic 2026-02-04 23:10:50 +01:00
9qeklajc
795fff61e0 child-key-expiration 2026-02-02 22:29:54 +01:00
shroominic
f9bfd4f0d2 routstr/v0.3.0
v0.3.0
2026-02-02 16:44:31 +08:00
9qeklajc
5683382ada Merge pull request #341 from Routstr/refactor/remove-unused-code
refactor: remove unused code
2026-02-01 23:09:33 +01:00
9qeklajc
c92372dafd Merge pull request #342 from Routstr/refactor/remove-deprecated-admin-html
refactor: remove deprecated admin html
2026-02-01 23:09:06 +01:00
9qeklajc
b58dd78fde Merge pull request #339 from Routstr/refactor/nostr-discovery
refactor: nostr logic
2026-02-01 23:06:09 +01:00
9qeklajc
4c31bf9767 enforce payment finalization 2026-02-01 23:01:47 +01:00
shroominic
42efa3c1ba Merge pull request #337 from Routstr/no-default-next-public-api-url
No default next public api url
2026-01-31 07:42:14 +08:00
shroominic
74b1d39d5c Merge pull request #332 from Routstr/missing-delete-button
Missing delete button
2026-01-31 07:42:04 +08:00
shroominic
4df4976f44 Merge pull request #331 from Routstr/batch-override-models
batch override models
2026-01-31 07:41:54 +08:00
Shroominic
4aa57959bf prettier 2026-01-31 07:38:05 +08:00
Shroominic
1af39f043f fix tests 2026-01-31 07:37:39 +08:00
Shroominic
1751cd3b47 remove deprecated htmx endpoints 2026-01-31 07:23:58 +08:00
Shroominic
b1facd58d5 rm test checking unused functions 2026-01-31 07:21:44 +08:00
Shroominic
6288d6fef7 more unused code lmao 2026-01-31 07:09:04 +08:00
Shroominic
a1223ad610 rm unused code lol 2026-01-31 07:08:54 +08:00
Shroominic
c75f170ed0 Refactor: Move Discovery and Nostr logic to routstr/nostr package 2026-01-31 06:59:27 +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
248937e05f comment out NEXT_PUBLIC_API_URL by default 2026-01-30 10:48:37 +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
31898192b2 added missing delete button for custom models 2026-01-29 13:05:56 +08:00
Shroominic
e50facc835 prettier 2026-01-29 11:05:46 +08:00
Shroominic
55dc485705 Merge branch 'v0.3.0' into batch-override-models 2026-01-29 11:03:43 +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
80559a57d5 fix linting 2026-01-29 10:55:07 +08:00
Shroominic
8e9f6647e7 batch override models 2026-01-29 09:08:26 +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
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
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
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
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
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
Shroominic
50eabafa57 remove performance tests due to unpredictable behaviour 2026-01-05 12:12:04 +01:00
Shroominic
7d829af681 fix types 2026-01-05 00:06:53 +01:00
Shroominic
9e9bc5bff8 fix pytests 2026-01-03 22:49:46 +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
shroominic
f1fa7d094f routstr/v0.2.1
v0.2.1
2025-12-27 22:08:51 +01:00
112 changed files with 9031 additions and 11021 deletions

50
Dockerfile.full Normal file
View File

@@ -0,0 +1,50 @@
# Multi-stage Dockerfile for Routstr (includes UI build)
# Stage 1: Build the UI
FROM node:23-alpine AS ui-builder
WORKDIR /app/ui
# Install pnpm
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
# Copy UI source
COPY ui/package.json ui/pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY ui/ ./
ENV NEXT_TELEMETRY_DISABLED=1
# Next.js build produces a static export in 'out' directory
RUN pnpm run build
# Stage 2: Build the Routstr Node
FROM ghcr.io/astral-sh/uv:python3.11-alpine AS runner
# Install system dependencies
RUN apk add --no-cache \
pkgconf \
build-base \
automake \
autoconf \
libtool \
m4 \
perl \
git
WORKDIR /app
# Copy the rest of the application (required for uv sync to find the package)
COPY . .
# Install dependencies including the specific secp256k1 branch
RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060
RUN uv sync --no-dev
# Copy the built UI from the ui-builder stage
COPY --from=ui-builder /app/ui/out ./ui_out
ENV PORT=8000
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
# Run the application
CMD ["/app/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]

283
README.md
View File

@@ -1,256 +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
### cURL
- **Cashu Wallet Integration** Accept Lightning payments and redeem eCash tokens before forwarding requests
- **API Key Management** Hashed keys stored in SQLite with balance tracking and optional expiry/refund address
- **Model-Based Pricing** Convert USD prices in `models.json` to sats using live BTC/USD rates
- **Admin Dashboard** Simple HTML interface at `/admin/` to view balances and API keys
- **Discovery** Fetch available providers from Nostr relays using NIP-91 protocol
- **NIP-91 Auto-Announcement** Automatically announce this provider to Nostr relays when NSEC is provided
- **Docker Support** Provided `Dockerfile` and `compose.yml` for running with an optional Tor hidden service
```bash
curl https://api.routstr.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "x-cashu: cashuBo2FteCJodHRwczovL21..." \
-d '{
"model": "gpt-5-nano",
"messages": [{"role": "user", "content": "hello"}]
}'
```
## Getting Started
## Quick Start (Docker)
### Running the proxy using Docker
If you are a node runner, start a Routstr Core instance and configure upstream access in the dashboard.
```bash
docker run -d \
--name routstr-proxy \
-p 8000:8000 \
-e UPSTREAM_BASE_URL=https://api.openai.com/v1 \
-e UPSTREAM_API_KEY=your-openai-api-key \
ghcr.io/routstr/proxy:latest
--name routstr-proxy \
-p 8000:8000 \
ghcr.io/routstr/proxy:latest
```
### Development Requirements
- Python 3.11+
- [uv](https://github.com/astral-sh/uv) package manager (used in development)
### Installation
```bash
uv sync # install dependencies
```
Create a `.env` file based on `.env.example` and fill in the required values:
## Development
```bash
make setup
cp .env.example .env
fastapi run routstr
```
### Running Locally
```bash
fastapi run routstr --host 0.0.0.0 --port 8000
```
The service forwards requests to `UPSTREAM_BASE_URL`. Supply the upstream API key via the `UPSTREAM_API_KEY` environment variable if required.
### Docker
```bash
docker compose up --build
```
This builds the image and also starts a Tor container exposing the API as a hidden service.
## Environment Variables
The most common settings are shown below. See `.env.example` for the full list.
### Core Settings
- `UPSTREAM_BASE_URL` URL of the OpenAI-compatible service
- `UPSTREAM_API_KEY` API key for the upstream service (optional)
- `FIXED_PRICING` Set to `true` to use a fixed per-request price; `false` (default) uses model pricing from `models.json`
- `ADMIN_PASSWORD` Password for the `/admin/` dashboard
- `CASHU_MINTS` Comma-separated list of Cashu mint URLs
- `NAME` Name of the proxy
- `DESCRIPTION` Description of the proxy
- `NPUB` Nostr public key of the proxy
- `HTTP_URL` Public-facing URL of the proxy
- `ONION_URL` Tor hidden service URL of the proxy
- `NEXT_PUBLIC_API_URL` - UI Configuration for Next.js frontend (proxy URL, default: 'http://127.0.0.1:8000' )
## Database Migrations
The application uses Alembic for database schema management and **automatically runs migrations on startup**. This ensures your database is always up-to-date when deploying new versions.
### Automatic Migrations in Production
When the FastAPI application starts, it automatically:
1. Runs all pending database migrations
2. Updates the schema to the latest version
3. Logs the migration status
This means you don't need to manually run migrations when deploying - just restart the application and migrations will be applied automatically.
### Manual Migration Commands
For development or troubleshooting, you can use these Makefile commands:
```bash
make db-upgrade # Apply all pending migrations
make db-downgrade # Downgrade one migration
make db-current # Show current migration revision
make db-history # Show migration history
make db-migrate # Auto-generate new migration from model changes
make db-revision # Create empty migration file
make db-heads # Show current migration heads
make db-clean # Clean migration cache files
```
### Creating New Migrations
When you modify SQLModel models:
```bash
# Auto-generate a migration from model changes
make db-migrate
# Enter a descriptive message when prompted
# Review the generated migration file in migrations/versions/
# Edit if needed, then test with:
make db-upgrade
```
## Admin UI
Routstr includes a modern Next.js admin dashboard that's served directly from the Python backend as static files - no separate Node.js server required.
### Building the UI
```bash
make ui-build
```
This compiles the Next.js application into static HTML, CSS, and JavaScript files in `ui/out/`.
### Accessing the Dashboard
Once built, the UI is automatically served by the FastAPI backend:
- **Dashboard**: `http://localhost:8000/`
- **Login**: `http://localhost:8000/login`
- **Models Management**: `http://localhost:8000/model`
- **Providers Management**: `http://localhost:8000/providers`
- **Settings**: `http://localhost:8000/settings`
The dashboard provides:
- Real-time wallet balance monitoring
- Model pricing configuration
- Upstream provider management
- Transaction history
- System settings
**Authentication**: Use the `ADMIN_PASSWORD` environment variable to access the dashboard.
## Withdrawing Balance
Go to the admin dashboard at `http://localhost:8000/` and login with your `ADMIN_PASSWORD` to withdraw your balance as a Cashu token.
## Example Client
`example.py` shows how to use the proxy with the official OpenAI client:
```bash
CASHU_TOKEN=<redeemable token> python example.py
```
The script sends streaming chat completions and pays for each request using the provided token.
## Running Tests
```bash
uv run pytest
```
The tests create a temporary SQLite database and mock the Cashu wallet. See `tests/README.md` for more details.
## Future Features
### Nut-24 Header Support (Coming Soon)
We're implementing support for the Cashu Nut-24 specification, which will enable per-request token exchange with automatic change handling:
```mermaid
graph TD
A["Client Request<br/>x-cashu: token"] --> B[Proxy Validates Token]
B --> C{Token ≥ Minimum Amount?}
C -->|No| F[Return 402 Payment Required]
C -->|Yes| D[Calculate Request Cost]
D --> E[Process Request]
E --> G[Forward to Upstream API]
G --> H[Receive API Response]
H --> I[Calculate Change]
I --> J["Return Response<br/>x-cashu: change_token"]
F --> K[End]
J --> K
```
**Key Benefits:**
- **Per-Request Payments** Send exact tokens for each API call
- **Automatic Change** Receive change tokens in response headers
- **No Pre-funding** No need to maintain account balances
- **Precise Billing** Pay only for actual usage with msat-level precision
- **Minimum Amount Protection** Proxy enforces minimum token value to prevent dust attacks
**Header Format:**
- **Request**: `x-cashu: <ecash_token>` Token to spend for this request (must meet minimum amount)
- **Response**: `x-cashu: <change_token>` Change token if payment exceeds cost
**Implementation Note:**
The proxy should implement either a dedicated endpoint to communicate minimum eCash requirements per request, or extend the existing `models.json` to include minimum token amounts per model. This allows clients to autonomously determine the appropriate token amount to send with each request.
**Compatible Clients:**
To use this feature, you'll need a client that handles both OpenAI API calls and eCash header management. The following clients provide seamless integration:
- **[routstr-chat](https://github.com/routstr/routstr-chat)** chat app for the routstr network
- **[otrta-client](https://github.com/routstr/otrta-client)** rust web app for the routstr network
clients automatically:
- **Handle eCash Headers** Add `x-cashu` tokens to requests and process change tokens
- **Manage Wallets** Maintain your Cashu wallet
- **Configure Proxy** Set Routstr proxy endpoints
- **Top-up Balances** Automatically request ecash when tokens run low and redeem ecash tokens
This approach eliminates the need for account management while maintaining the security and privacy benefits of eCash payments.
## License
This project is licensed under the terms of the GPLv3. See the `LICENSE` file for the full license text.

View File

@@ -6,7 +6,7 @@ services:
context: ./ui
dockerfile: Dockerfile.build
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
# NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
NEXT_PUBLIC_ADMIN_API_KEY: ${NEXT_PUBLIC_ADMIN_API_KEY:-}
volumes:
- ./ui_out:/output

View File

@@ -1,567 +0,0 @@
# Custom Pricing
This guide covers advanced pricing strategies and customization options for Routstr Core.
## Pricing Models Overview
Routstr supports three pricing models:
1. **Fixed Pricing** - Simple per-request fee
2. **Token-Based Pricing** - Charge per input/output token
3. **Model-Based Pricing** - Dynamic pricing from models.json
## Model-Based Pricing
### Configuration
Enable model-based pricing (default behavior):
```bash
# .env
FIXED_PRICING=false
MODELS_PATH=/app/config/models.json
EXCHANGE_FEE=1.005 # 0.5% exchange fee
UPSTREAM_PROVIDER_FEE=1.05 # 5% provider margin
```
### Custom Models File
Create a `models.json` with your pricing:
```json
{
"models": [
{
"id": "gpt-4",
"name": "GPT-4",
"description": "Advanced reasoning model",
"context_length": 8192,
"pricing": {
"prompt": "0.03", // USD per 1K tokens
"completion": "0.06", // USD per 1K tokens
"request": "0.0001", // Fixed per-request fee
"image": "0", // For multimodal models
"web_search": "0.005", // Additional features
"internal_reasoning": "0.01"
},
"supported_features": [
"function_calling",
"vision",
"json_mode"
],
"deprecation_date": null,
"replacement_model": null
},
{
"id": "custom-model",
"name": "Custom Fine-tuned Model",
"pricing": {
"prompt": "0.001",
"completion": "0.002",
"request": "0.00005"
},
"minimum_charge": "0.0001" // Minimum charge per request
}
],
"default_pricing": {
"prompt": "0.002",
"completion": "0.002",
"request": "0"
}
}
```
### Dynamic Price Updates
Automatically fetch prices from providers:
```python
# scripts/update_prices.py
import asyncio
import httpx
import json
async def fetch_openrouter_models():
"""Fetch current model pricing from OpenRouter."""
async with httpx.AsyncClient() as client:
response = await client.get(
"https://openrouter.ai/api/v1/models"
)
return response.json()
async def update_models_json():
"""Update local models.json with latest prices."""
data = await fetch_openrouter_models()
models = []
for model in data['data']:
models.append({
"id": model['id'],
"name": model['name'],
"pricing": {
"prompt": model['pricing']['prompt'],
"completion": model['pricing']['completion'],
"request": model['pricing'].get('request', '0')
},
"context_length": model.get('context_length', 4096)
})
with open('models.json', 'w') as f:
json.dump({"models": models}, f, indent=2)
# Run periodically
if __name__ == "__main__":
asyncio.run(update_models_json())
```
## Token-Based Pricing
### Configuration
Set up token-based pricing overrides:
```bash
# .env
FIXED_PRICING=false # use model pricing
FIXED_COST_PER_REQUEST=1 # optional base fee
FIXED_PER_1K_INPUT_TOKENS=5 # optional override
FIXED_PER_1K_OUTPUT_TOKENS=15 # optional override
```
### Custom Token Counting
Override default token counting:
```python
from tiktoken import encoding_for_model
class CustomTokenCounter:
def __init__(self):
self.encodings = {}
def count_tokens(
self,
text: str,
model: str
) -> int:
"""Custom token counting logic."""
# Cache encodings
if model not in self.encodings:
try:
self.encodings[model] = encoding_for_model(model)
except:
# Fallback encoding
self.encodings[model] = encoding_for_model("gpt-3.5-turbo")
encoding = self.encodings[model]
# Special handling for certain content
if text.startswith("```"):
# Code blocks might need special handling
tokens = encoding.encode(text)
return len(tokens) * 1.1 # 10% markup for code
return len(encoding.encode(text))
```
## Advanced Pricing Strategies
### Time-Based Pricing
Implement peak/off-peak pricing:
```python
from datetime import datetime
import pytz
class TimeBased PricingStrategy:
def __init__(self):
self.timezone = pytz.timezone('US/Eastern')
self.peak_hours = [(9, 17)] # 9 AM - 5 PM
self.peak_multiplier = 1.5
self.weekend_discount = 0.8
def get_price_multiplier(self) -> float:
"""Calculate price multiplier based on time."""
now = datetime.now(self.timezone)
# Weekend discount
if now.weekday() >= 5: # Saturday or Sunday
return self.weekend_discount
# Peak hours surcharge
hour = now.hour
for start, end in self.peak_hours:
if start <= hour < end:
return self.peak_multiplier
# Off-peak standard pricing
return 1.0
def apply_to_cost(self, base_cost: int) -> int:
"""Apply time-based pricing to cost."""
multiplier = self.get_price_multiplier()
return int(base_cost * multiplier)
```
### Model-Specific Surcharges
Add custom fees for specific models:
```python
class ModelSurchargeStrategy:
def __init__(self):
self.surcharges = {
"gpt-4-turbo": 1.1, # 10% premium
"claude-3-opus": 1.15, # 15% premium
"dall-e-3-hd": 1.25, # 25% premium for HD
}
self.discounts = {
"gpt-3.5-turbo": 0.95, # 5% discount
"deprecated-model": 0.8, # 20% discount
}
def get_model_multiplier(self, model: str) -> float:
"""Get price multiplier for model."""
if model in self.surcharges:
return self.surcharges[model]
elif model in self.discounts:
return self.discounts[model]
return 1.0
```
### Geographic Pricing
Adjust pricing based on client location:
```python
import geoip2.database
class GeographicPricingStrategy:
def __init__(self):
self.reader = geoip2.database.Reader('GeoLite2-Country.mmdb')
self.country_multipliers = {
'US': 1.0,
'GB': 1.0,
'DE': 1.0,
'IN': 0.7, # 30% discount
'BR': 0.8, # 20% discount
'NG': 0.6, # 40% discount
}
self.default_multiplier = 0.9
def get_country_multiplier(self, ip_address: str) -> float:
"""Get price multiplier based on country."""
try:
response = self.reader.country(ip_address)
country_code = response.country.iso_code
return self.country_multipliers.get(
country_code,
self.default_multiplier
)
except:
return 1.0 # Default pricing if lookup fails
```
## Cost Calculation Pipeline
### Implementing Custom Calculator
```python
from abc import ABC, abstractmethod
class CostCalculator(ABC):
@abstractmethod
async def calculate(
self,
request_data: dict,
usage_data: dict,
context: dict
) -> CostResult:
pass
class CompositeCostCalculator(CostCalculator):
"""Combine multiple pricing strategies."""
def __init__(self):
self.strategies = [
BaseCostCalculator(),
TimeBasedPricingStrategy(),
ModelSurchargeStrategy(),
GeographicPricingStrategy()
]
async def calculate(
self,
request_data: dict,
usage_data: dict,
context: dict
) -> CostResult:
# Start with base cost
base_cost = await self.strategies[0].calculate(
request_data, usage_data, context
)
# Apply each strategy
final_cost = base_cost.total_msats
breakdown = {"base": base_cost.total_msats}
for strategy in self.strategies[1:]:
multiplier = await strategy.get_multiplier(context)
adjustment = final_cost * (multiplier - 1)
final_cost += adjustment
breakdown[strategy.__class__.__name__] = adjustment
return CostResult(
total_msats=int(final_cost),
breakdown=breakdown
)
```
### Integration with Routstr
```python
# In routstr/payment/cost_calculation.py
async def calculate_request_cost(
model: str,
prompt_tokens: int,
completion_tokens: int,
request_type: str,
context: dict
) -> CostData:
"""Enhanced cost calculation with custom strategies."""
# Use custom calculator if configured
if os.getenv("USE_CUSTOM_PRICING", "false").lower() == "true":
calculator = CompositeCostCalculator()
result = await calculator.calculate(
request_data={
"model": model,
"type": request_type
},
usage_data={
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens
},
context=context
)
return result
# Fall back to standard calculation
return standard_calculate_cost(...)
```
## Monitoring Pricing
### Price Analytics
Track pricing effectiveness:
```python
class PricingAnalytics:
async def analyze_pricing(
self,
start_date: datetime,
end_date: datetime
):
"""Analyze pricing performance."""
# Average cost per request by model
model_costs = await self.get_average_costs_by_model(
start_date, end_date
)
# Revenue by pricing strategy
strategy_revenue = await self.get_revenue_by_strategy(
start_date, end_date
)
# Price elasticity
elasticity = await self.calculate_price_elasticity()
return {
"model_costs": model_costs,
"strategy_revenue": strategy_revenue,
"price_elasticity": elasticity,
"recommendations": self.generate_recommendations(
model_costs, elasticity
)
}
```
### A/B Testing Prices
Test different pricing strategies:
```python
class PricingExperiment:
def __init__(self):
self.experiments = {
"exp_001": {
"name": "10% discount test",
"group_a": {"multiplier": 1.0},
"group_b": {"multiplier": 0.9},
"allocation": 0.5 # 50/50 split
}
}
def assign_group(self, api_key_id: int) -> str:
"""Assign API key to experiment group."""
# Consistent assignment based on key ID
import hashlib
hash_value = int(hashlib.md5(
str(api_key_id).encode()
).hexdigest()[:8], 16)
return "group_b" if (hash_value % 100) < 50 else "group_a"
def get_experiment_multiplier(
self,
api_key_id: int,
experiment_id: str
) -> float:
"""Get price multiplier for experiment."""
experiment = self.experiments.get(experiment_id)
if not experiment:
return 1.0
group = self.assign_group(api_key_id)
return experiment[group]["multiplier"]
```
## Configuration Examples
### Enterprise Pricing
```json
{
"models": [
{
"id": "gpt-4-enterprise",
"name": "GPT-4 Enterprise",
"pricing": {
"prompt": "0.02",
"completion": "0.04"
},
"minimum_commitment": "1000", // $1000/month minimum
"sla": {
"uptime": "99.9%",
"support_response": "1 hour",
"dedicated_capacity": true
}
}
],
"enterprise_features": {
"priority_queue": true,
"custom_models": true,
"audit_logs": true,
"sso": true
}
}
```
### Budget-Friendly Options
```json
{
"models": [
{
"id": "gpt-3.5-turbo-budget",
"name": "GPT-3.5 Turbo Budget",
"pricing": {
"prompt": "0.0005",
"completion": "0.001"
},
"restrictions": {
"max_tokens_per_request": 1000,
"requests_per_minute": 10,
"peak_hours_blocked": true
}
}
],
"prepaid_packages": [
{
"name": "Starter Pack",
"price_usd": 10,
"tokens_included": 10000000,
"expires_days": 30
}
]
}
```
## Troubleshooting
### Price Calculation Issues
```python
# Debug pricing
async def debug_price_calculation(
model: str,
tokens: dict,
api_key_id: int
):
"""Debug price calculation step by step."""
print(f"Model: {model}")
print(f"Tokens: {tokens}")
# Base price
base_price = get_model_price(model)
print(f"Base price: {base_price}")
# Token cost
token_cost = calculate_token_cost(base_price, tokens)
print(f"Token cost: {token_cost}")
# Strategies
strategies = get_active_strategies()
for strategy in strategies:
multiplier = await strategy.get_multiplier(api_key_id)
print(f"{strategy.name}: {multiplier}x")
# Final cost
final_cost = apply_all_strategies(token_cost, api_key_id)
print(f"Final cost: {final_cost} msats")
return final_cost
```
### Common Issues
1. **Prices Not Updating**
- Check `MODELS_PATH` is correct
- Verify file permissions
- Check background task logs
2. **Wrong Currency Conversion**
- Verify BTC/USD rate source
- Check `EXCHANGE_FEE` setting
- Monitor rate update frequency
3. **Discounts Not Applied**
- Verify strategy configuration
- Check API key metadata
- Review transaction history
## Best Practices
1. **Transparent Pricing**
- Publish pricing clearly
- Show cost breakdowns
- Notify of price changes
2. **Fair Pricing**
- Regular competitive analysis
- Consider user feedback
- Offer budget options
3. **Performance**
- Cache price calculations
- Optimize database queries
- Monitor calculation time
## Next Steps
- [Migrations](migrations.md) - Database migration guide
- [API Endpoints](../api/endpoints.md) - Pricing endpoints
- [Monitoring](../user-guide/admin-dashboard.md) - Track pricing metrics

View File

@@ -1,646 +0,0 @@
# Database Migrations
This guide covers database schema management using Alembic migrations in Routstr Core.
## Overview
Routstr uses Alembic for database migrations with these features:
- **Automatic migrations** on startup
- **Version control** for schema changes
- **Rollback capability** for safety
- **Support for multiple databases** (SQLite, PostgreSQL)
## Automatic Migrations
### Startup Behavior
Migrations run automatically when Routstr starts:
```python
# In routstr/core/main.py
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
logger.info("Running database migrations")
run_migrations() # Automatic migration
await init_db() # Initialize connection pool
# ... rest of startup
```
This ensures:
- ✅ Database is always up-to-date
- ✅ No manual migration steps in production
- ✅ Zero-downtime deployments
- ✅ Backwards compatibility
### Migration Safety
Migrations are designed to be safe:
- Idempotent (can run multiple times)
- Non-destructive by default
- Tested before release
- Reversible when possible
## Creating Migrations
### Auto-generating from Models
After modifying SQLModel classes:
```bash
# Generate migration from model changes
make db-migrate
# You'll be prompted for a description
Enter migration message: Add user preferences table
# Review generated file
cat migrations/versions/xxxx_add_user_preferences_table.py
```
### Manual Migrations
For complex changes, create manually:
```bash
# Create empty migration
alembic revision -m "Complex data transformation"
# Edit the generated file
vim migrations/versions/xxxx_complex_data_transformation.py
```
### Migration Template
```python
"""Add user preferences table
Revision ID: a1b2c3d4e5f6
Revises: f6e5d4c3b2a1
Create Date: 2024-01-15 10:30:00.123456
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers
revision = 'a1b2c3d4e5f6'
down_revision = 'f6e5d4c3b2a1'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Apply migration."""
# Create new table
op.create_table(
'userpreferences',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('api_key_id', sa.Integer(), nullable=False),
sa.Column('theme', sa.String(), nullable=True),
sa.Column('notifications_enabled', sa.Boolean(), default=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['api_key_id'], ['apikey.id'], )
)
# Create index
op.create_index(
'ix_userpreferences_api_key_id',
'userpreferences',
['api_key_id']
)
def downgrade() -> None:
"""Revert migration."""
op.drop_index('ix_userpreferences_api_key_id', table_name='userpreferences')
op.drop_table('userpreferences')
```
## Common Migration Patterns
### Adding Columns
Add column with default value:
```python
def upgrade():
# Add nullable column first
op.add_column(
'apikey',
sa.Column('last_rotation', sa.DateTime(), nullable=True)
)
# Populate existing rows
connection = op.get_bind()
connection.execute(
"UPDATE apikey SET last_rotation = created_at WHERE last_rotation IS NULL"
)
# Make non-nullable if needed
op.alter_column('apikey', 'last_rotation', nullable=False)
```
### Renaming Columns
Safe column rename:
```python
def upgrade():
# SQLite doesn't support ALTER COLUMN, so we need a workaround
with op.batch_alter_table('apikey') as batch_op:
batch_op.alter_column('old_name', new_column_name='new_name')
```
### Adding Indexes
Performance-improving indexes:
```python
def upgrade():
# Single column index
op.create_index(
'ix_transaction_timestamp',
'transaction',
['timestamp']
)
# Composite index
op.create_index(
'ix_transaction_key_time',
'transaction',
['api_key_id', 'timestamp']
)
# Partial index (PostgreSQL only)
op.create_index(
'ix_apikey_active',
'apikey',
['balance'],
postgresql_where='balance > 0'
)
```
### Data Migrations
Transform existing data:
```python
def upgrade():
# Add new column
op.add_column(
'apikey',
sa.Column('key_type', sa.String(), nullable=True)
)
# Migrate data
connection = op.get_bind()
result = connection.execute('SELECT id, metadata FROM apikey')
for row in result:
key_type = 'premium' if row.metadata.get('premium') else 'standard'
connection.execute(
f"UPDATE apikey SET key_type = '{key_type}' WHERE id = {row.id}"
)
# Make column non-nullable
op.alter_column('apikey', 'key_type', nullable=False)
```
### Enum Types
Add enum column:
```python
from enum import Enum
class KeyStatus(str, Enum):
ACTIVE = "active"
SUSPENDED = "suspended"
EXPIRED = "expired"
def upgrade():
# Create enum type (PostgreSQL)
key_status_enum = sa.Enum(KeyStatus, name='keystatus')
key_status_enum.create(op.get_bind(), checkfirst=True)
# Add column
op.add_column(
'apikey',
sa.Column(
'status',
key_status_enum,
nullable=False,
server_default='active'
)
)
```
## Database-Specific Considerations
### SQLite Limitations
SQLite has limitations requiring workarounds:
```python
def upgrade():
# SQLite doesn't support ALTER COLUMN directly
# Use batch_alter_table for compatibility
with op.batch_alter_table('apikey') as batch_op:
batch_op.alter_column(
'balance',
type_=sa.BigInteger(), # Change from Integer
existing_type=sa.Integer()
)
```
### PostgreSQL Features
Leverage PostgreSQL-specific features:
```python
def upgrade():
# Use JSONB for better performance
op.add_column(
'apikey',
sa.Column('metadata', sa.JSON().with_variant(
sa.dialects.postgresql.JSONB(), 'postgresql'
))
)
# Add GIN index for JSONB queries
op.create_index(
'ix_apikey_metadata',
'apikey',
['metadata'],
postgresql_using='gin'
)
# Add check constraint
op.create_check_constraint(
'ck_apikey_balance_positive',
'apikey',
'balance >= 0'
)
```
## Migration Commands
### Running Migrations
```bash
# Apply all pending migrations
make db-upgrade
# Upgrade to specific revision
alembic upgrade a1b2c3d4e5f6
# Upgrade one revision
alembic upgrade +1
```
### Checking Status
```bash
# Show current revision
make db-current
# Output: a1b2c3d4e5f6 (head)
# Show migration history
make db-history
# Output:
# a1b2c3d4e5f6 -> b2c3d4e5f6a7 (head), Add user preferences
# f6e5d4c3b2a1 -> a1b2c3d4e5f6, Add indexes
# e5d4c3b2a1f6 -> f6e5d4c3b2a1, Initial schema
```
### Rolling Back
```bash
# Rollback one migration
make db-downgrade
# Rollback to specific revision
alembic downgrade f6e5d4c3b2a1
# Rollback all (dangerous!)
alembic downgrade base
```
## Testing Migrations
### Unit Testing
Test migrations in isolation:
```python
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect
def test_migration_add_user_preferences():
"""Test user preferences migration."""
# Create test database
engine = create_engine("sqlite:///:memory:")
# Run migrations up to previous version
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", str(engine.url))
command.upgrade(alembic_cfg, "f6e5d4c3b2a1")
# Verify state before migration
inspector = inspect(engine)
tables = inspector.get_table_names()
assert "userpreferences" not in tables
# Run target migration
command.upgrade(alembic_cfg, "a1b2c3d4e5f6")
# Verify state after migration
inspector = inspect(engine)
tables = inspector.get_table_names()
assert "userpreferences" in tables
# Check columns
columns = {col['name'] for col in inspector.get_columns('userpreferences')}
assert columns == {'id', 'api_key_id', 'theme', 'notifications_enabled', 'created_at'}
# Test downgrade
command.downgrade(alembic_cfg, "f6e5d4c3b2a1")
inspector = inspect(engine)
tables = inspector.get_table_names()
assert "userpreferences" not in tables
```
### Integration Testing
Test with real data:
```python
async def test_migration_with_data():
"""Test migration preserves existing data."""
# Setup test database with data
async with test_engine.begin() as conn:
# Insert test data
await conn.execute(
"INSERT INTO apikey (key_hash, balance) VALUES ('test', 1000)"
)
# Run migration
run_migrations()
# Verify data integrity
async with test_engine.connect() as conn:
result = await conn.execute("SELECT * FROM apikey WHERE key_hash = 'test'")
row = result.first()
assert row.balance == 1000
assert row.key_type == 'standard' # New column with default
```
## Production Deployment
### Zero-Downtime Migrations
Strategy for seamless updates:
1. **Make migrations backwards compatible**
```python
# Good: Add nullable column
op.add_column('apikey', sa.Column('new_field', sa.String(), nullable=True))
# Bad: Drop column immediately
# op.drop_column('apikey', 'old_field')
```
2. **Deploy in phases**
```bash
# Phase 1: Deploy code that works with both schemas
# Phase 2: Run migration
# Phase 3: Deploy code that requires new schema
# Phase 4: Clean up deprecated columns
```
3. **Use feature flags**
```python
if feature_enabled('use_new_schema'):
# Use new column
query = select(APIKey.new_field)
else:
# Use old column
query = select(APIKey.old_field)
```
### Migration Monitoring
Track migration execution:
```python
# Add to migration
def upgrade():
start_time = time.time()
logger.info(f"Starting migration {revision}")
try:
# Migration logic here
op.create_table(...)
duration = time.time() - start_time
logger.info(f"Migration {revision} completed in {duration:.2f}s")
except Exception as e:
logger.error(f"Migration {revision} failed: {e}")
raise
```
### Backup Before Migration
Always backup before major changes:
```bash
#!/bin/bash
# backup_before_migration.sh
# Backup database
if [[ "$DATABASE_URL" == *"sqlite"* ]]; then
cp database.db "backup_$(date +%Y%m%d_%H%M%S).db"
else
pg_dump $DATABASE_URL > "backup_$(date +%Y%m%d_%H%M%S).sql"
fi
# Run migration
alembic upgrade head
# Verify
alembic current
```
## Troubleshooting
### Common Issues
**Migration Conflicts**
```bash
# Multiple heads detected
alembic heads
# a1b2c3d4e5f6 (head)
# b2c3d4e5f6a7 (head)
# Merge heads
alembic merge -m "Merge migrations" a1b2c3 b2c3d4
```
**Failed Migration**
```python
# Add rollback logic
def upgrade():
try:
op.create_table(...)
except Exception as e:
# Clean up partial changes
op.drop_table('partial_table', checkfirst=True)
raise
def downgrade():
# Ensure clean rollback
op.drop_table('new_table', checkfirst=True)
```
**Lock Timeout**
```python
# Add timeout handling
def upgrade():
connection = op.get_bind()
# Set timeout (PostgreSQL)
connection.execute("SET lock_timeout = '10s'")
try:
op.add_column(...)
except OperationalError as e:
if 'lock timeout' in str(e):
logger.error("Migration failed due to lock timeout")
raise
```
### Recovery Procedures
If migration fails in production:
1. **Check current state**
```bash
alembic current
alembic history
```
2. **Manual rollback if needed**
```sql
-- Check migration table
SELECT * FROM alembic_version;
-- Force version if necessary
UPDATE alembic_version SET version_num = 'previous_version';
```
3. **Fix and retry**
```bash
# Fix migration file
vim migrations/versions/problematic_migration.py
# Retry
alembic upgrade head
```
## Best Practices
### Migration Guidelines
1. **Keep migrations small and focused**
- One logical change per migration
- Easier to review and rollback
2. **Test migrations thoroughly**
- Test upgrade and downgrade
- Test with production-like data
- Test database-specific features
3. **Document breaking changes**
```python
"""BREAKING: Change balance column type
This migration requires application update.
Deploy order:
1. Update application to handle both int and bigint
2. Run this migration
3. Update application to use only bigint
"""
```
4. **Make migrations idempotent**
```python
def upgrade():
# Check if column exists
inspector = inspect(op.get_bind())
columns = [col['name'] for col in inspector.get_columns('apikey')]
if 'new_column' not in columns:
op.add_column(
'apikey',
sa.Column('new_column', sa.String())
)
```
### Performance Considerations
1. **Add indexes concurrently (PostgreSQL)**
```python
def upgrade():
# Create index without locking table
op.create_index(
'ix_large_table_column',
'large_table',
['column'],
postgresql_concurrently=True
)
```
2. **Batch large updates**
```python
def upgrade():
connection = op.get_bind()
# Process in batches
batch_size = 1000
offset = 0
while True:
result = connection.execute(
f"UPDATE apikey SET processed = true "
f"WHERE id IN (SELECT id FROM apikey WHERE processed = false LIMIT {batch_size})"
)
if result.rowcount == 0:
break
offset += batch_size
time.sleep(0.1) # Prevent overload
```
## Next Steps
- [Testing Guide](../contributing/testing.md) - Testing migrations
- [Deployment](../getting-started/docker.md) - Production deployment

View File

@@ -1,598 +0,0 @@
# Nostr Discovery
Routstr Core integrates with Nostr (Notes and Other Stuff Transmitted by Relays) for decentralized provider discovery. This enables users to find Routstr nodes without relying on centralized directories.
## Overview
Nostr integration provides:
- **Decentralized Discovery**: Find providers through relay network
- **Cryptographic Identity**: Providers identified by public keys
- **Real-time Updates**: Live provider status and pricing
- **Censorship Resistance**: No central point of control
## How It Works
```mermaid
graph LR
A[Routstr Node] --> B[Nostr Relay]
B --> C[Nostr Relay]
B --> D[Nostr Relay]
E[User Client] --> B
E --> C
E --> D
B --> F[Provider List]
C --> F
D --> F
```
Providers announce themselves by publishing signed events to Nostr relays. Clients can query these relays to discover available providers.
## Provider Configuration
### Setting Up Nostr Identity
1. **Generate Nostr Keys**
```bash
# Using nostril or similar tool
nostril --generate-keypair
# Output:
# Private key (nsec): nsec1abc...
# Public key (npub): npub1xyz...
```
2. **Configure Environment**
```bash
# .env
NPUB=npub1xyz... # Your public key
NSEC=nsec1abc... # Your private key (keep secret!)
NAME=Lightning AI Gateway
DESCRIPTION=Fast and reliable AI API with Bitcoin payments
HTTP_URL=https://api.lightning-ai.com
ONION_URL=http://lightningai.onion
```
### Publishing to Nostr
Routstr automatically publishes provider information to configured relays:
```python
# Published event structure (NIP-89)
{
"kind": 31990, # Application handler event
"pubkey": "your_public_key",
"content": {
"name": "Lightning AI Gateway",
"description": "Fast and reliable AI API",
"endpoints": {
"http": "https://api.lightning-ai.com",
"onion": "http://lightningai.onion"
},
"models": ["gpt-3.5-turbo", "gpt-4", "claude-3"],
"pricing": {
"gpt-3.5-turbo": {
"prompt_sats_per_1k": 3,
"completion_sats_per_1k": 4
}
},
"cashu_mints": [
"https://mint.minibits.cash/Bitcoin"
]
},
"tags": [
["d", "routstr"],
["t", "ai-api"],
["t", "bitcoin"],
["p", "payment-proxy"]
]
}
```
### Relay Configuration
Configure which relays to publish to:
```python
# Default relays
DEFAULT_RELAYS = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://relay.routstr.com",
"wss://nos.lol"
]
# Custom relay configuration
RELAYS=wss://relay1.com,wss://relay2.com
```
## Client Discovery
### Using the Discovery Endpoint
Find providers through the API:
```bash
GET /v1/providers
Response:
{
"providers": [
{
"name": "Lightning AI Gateway",
"npub": "npub1xyz...",
"description": "Fast and reliable AI API",
"endpoints": {
"http": "https://api.lightning-ai.com",
"onion": "http://lightningai.onion"
},
"models": ["gpt-3.5-turbo", "gpt-4"],
"pricing": {
"gpt-3.5-turbo": {
"prompt_sats_per_1k": 3,
"completion_sats_per_1k": 4
}
},
"last_seen": "2024-01-01T12:00:00Z",
"reliability_score": 0.99
}
]
}
```
### Direct Nostr Queries
Query Nostr relays directly:
```python
import json
import websocket
def discover_providers(relay_url: str):
"""Discover Routstr providers from Nostr relay."""
ws = websocket.create_connection(relay_url)
# Subscribe to provider events
subscription = {
"kinds": [31990],
"tags": {
"d": ["routstr"]
}
}
ws.send(json.dumps(["REQ", "sub1", subscription]))
providers = []
while True:
response = json.loads(ws.recv())
if response[0] == "EVENT":
event = response[2]
providers.append(parse_provider_event(event))
elif response[0] == "EOSE": # End of stored events
break
ws.close()
return providers
```
### JavaScript/TypeScript
```typescript
import { SimplePool } from 'nostr-tools';
async function discoverProviders(): Promise<Provider[]> {
const pool = new SimplePool();
const relays = [
'wss://relay.damus.io',
'wss://relay.nostr.band'
];
const filter = {
kinds: [31990],
'#d': ['routstr']
};
const events = await pool.list(relays, [filter]);
return events.map(event => ({
name: event.content.name,
npub: nip19.npubEncode(event.pubkey),
url: event.content.endpoints.http,
models: event.content.models,
pricing: event.content.pricing
}));
}
```
## Provider Ranking
### Reliability Scoring
Providers are ranked based on:
```python
class ProviderScore:
def calculate(self, provider: Provider) -> float:
score = 1.0
# Uptime (based on recent checks)
uptime_ratio = provider.successful_pings / provider.total_pings
score *= uptime_ratio
# Response time
if provider.avg_response_time < 500: # ms
score *= 1.0
elif provider.avg_response_time < 1000:
score *= 0.9
else:
score *= 0.7
# Model availability
model_score = len(provider.models) / 10 # Max 10 models
score *= min(1.0, 0.5 + model_score * 0.5)
# Price competitiveness
if provider.is_cheapest_for_any_model():
score *= 1.1
return min(1.0, score)
```
### Provider Selection
Choose optimal provider:
```python
def select_provider(
providers: list[Provider],
model: str,
requirements: dict
) -> Provider:
"""Select best provider for requirements."""
# Filter by model availability
candidates = [p for p in providers if model in p.models]
# Filter by requirements
if requirements.get('tor_required'):
candidates = [p for p in candidates if p.onion_url]
if requirements.get('max_price_per_1k'):
max_price = requirements['max_price_per_1k']
candidates = [
p for p in candidates
if p.pricing[model]['prompt_sats_per_1k'] <= max_price
]
# Sort by score
candidates.sort(key=lambda p: p.reliability_score, reverse=True)
return candidates[0] if candidates else None
```
## Publishing Updates
### Automatic Updates
Routstr publishes updates when:
- Node starts up
- Configuration changes
- Models are added/removed
- Pricing updates
### Manual Publishing
Force publish current state:
```python
async def publish_provider_info():
"""Manually publish provider information."""
event = create_provider_event(
name=os.getenv("NAME"),
description=os.getenv("DESCRIPTION"),
models=get_available_models(),
pricing=get_current_pricing()
)
await publish_to_relays(event, RELAYS)
```
### Event Lifecycle
```python
# Publish every 6 hours
@periodic_task(hours=6)
async def update_nostr_presence():
"""Keep provider information fresh."""
try:
await publish_provider_info()
logger.info("Updated Nostr presence")
except Exception as e:
logger.error(f"Failed to update Nostr: {e}")
# Delete on shutdown
async def remove_nostr_presence():
"""Remove provider from discovery."""
deletion_event = create_deletion_event()
await publish_to_relays(deletion_event, RELAYS)
```
## Security Considerations
### Key Management
1. **Secure Storage**
```python
# Never log private keys
SENSITIVE_VARS = ['NSEC', 'ADMIN_PASSWORD']
def sanitize_env(env_dict: dict) -> dict:
return {
k: '***' if k in SENSITIVE_VARS else v
for k, v in env_dict.items()
}
```
2. **Key Rotation**
```bash
# Generate new keys
nostril --generate-keypair
# Update configuration
# Publish transition event
# Update all references
```
### Event Validation
Verify provider events:
```python
def validate_provider_event(event: dict) -> bool:
"""Validate provider announcement."""
# Check signature
if not verify_signature(event):
return False
# Check required fields
required = ['name', 'endpoints', 'models', 'pricing']
content = json.loads(event['content'])
if not all(field in content for field in required):
return False
# Verify endpoints are reachable
if not await check_endpoints(content['endpoints']):
return False
return True
```
### Relay Security
Choose relays carefully:
```python
TRUSTED_RELAYS = {
'wss://relay.damus.io': {
'operator': 'Damus',
'reputation': 'high',
'filters_spam': True
},
'wss://relay.nostr.band': {
'operator': 'Nostr.Band',
'reputation': 'high',
'paid_tier': True
}
}
```
## Advanced Features
### Multi-Relay Broadcasting
Ensure wide distribution:
```python
async def broadcast_to_relays(event: dict, relays: list[str]):
"""Broadcast event to multiple relays."""
tasks = []
for relay in relays:
task = asyncio.create_task(
publish_to_relay(event, relay)
)
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
successful = sum(1 for r in results if not isinstance(r, Exception))
logger.info(f"Published to {successful}/{len(relays)} relays")
```
### Provider Metadata
Extended metadata in events:
```json
{
"kind": 31990,
"content": {
"name": "Lightning AI",
"description": "Enterprise AI API",
"metadata": {
"established": "2024-01-01",
"total_requests": 1000000,
"average_response_ms": 250,
"supported_features": [
"streaming",
"function_calling",
"vision",
"embeddings"
],
"certifications": ["SOC2", "GDPR"],
"contact": {
"nostr": "npub1contact...",
"email": "support@lightning-ai.com"
}
}
}
}
```
### Discovery Filters
Advanced filtering options:
```python
# Find providers with specific features
GET /v1/providers?features=streaming,vision&max_price=5&min_reliability=0.95
# Response includes filtered results
{
"providers": [...],
"filters_applied": {
"features": ["streaming", "vision"],
"max_price_sats_per_1k": 5,
"min_reliability": 0.95
},
"total_providers": 50,
"matching_providers": 12
}
```
## Monitoring
### Discovery Metrics
Track discovery performance:
```python
class DiscoveryMetrics:
def __init__(self):
self.relay_health = {}
self.provider_count = 0
self.query_latency = []
async def check_relay_health(self, relay_url: str):
"""Monitor relay connectivity."""
start = time.time()
try:
await connect_to_relay(relay_url)
latency = time.time() - start
self.relay_health[relay_url] = {
'status': 'healthy',
'latency_ms': latency * 1000
}
except Exception as e:
self.relay_health[relay_url] = {
'status': 'unhealthy',
'error': str(e)
}
```
### Provider Monitoring
```python
@periodic_task(minutes=5)
async def monitor_providers():
"""Check provider health."""
providers = await discover_providers()
for provider in providers:
try:
# Test endpoint
response = await test_provider_endpoint(provider.http_url)
# Update metrics
await update_provider_metrics(
provider.npub,
success=response.status_code == 200,
response_time=response.elapsed
)
except Exception as e:
logger.warning(f"Provider {provider.name} check failed: {e}")
```
## Troubleshooting
### No Providers Found
```python
# Debug discovery issues
async def debug_discovery():
"""Diagnose discovery problems."""
issues = []
# Check relay connectivity
for relay in RELAYS:
if not await can_connect_to_relay(relay):
issues.append(f"Cannot connect to {relay}")
# Check event publishing
if not await verify_own_events_visible():
issues.append("Own events not visible on relays")
# Check filters
if len(await get_all_provider_events()) == 0:
issues.append("No provider events on any relay")
return issues
```
### Relay Connection Issues
```bash
# Test relay connection
wscat -c wss://relay.damus.io
# Send subscription
["REQ","test",{"kinds":[31990],"#d":["routstr"]}]
```
## Best Practices
### For Providers
1. **Consistent Identity**
- Use same npub across services
- Maintain profile metadata
- Verify identity on multiple platforms
2. **Regular Updates**
- Publish status every few hours
- Update pricing promptly
- Remove stale information
3. **Relay Diversity**
- Publish to 5+ relays
- Include regional relays
- Monitor relay health
### For Clients
1. **Verify Providers**
- Check multiple relays
- Verify endpoints work
- Monitor reliability over time
2. **Cache Discovery**
- Cache provider list
- Refresh periodically
- Handle stale data gracefully
3. **Fallback Options**
- Keep backup providers
- Handle discovery failures
- Support manual configuration
## Next Steps
- [Tor Support](tor.md) - Anonymous provider access
- [Custom Pricing](custom-pricing.md) - Dynamic pricing strategies
- [API Reference](../api/endpoints.md) - Discovery API details

View File

@@ -1,530 +0,0 @@
# Tor Support
Routstr Core includes built-in support for Tor hidden services, enabling anonymous access to your API and enhanced privacy for users.
## Overview
Tor support provides:
- **Anonymous Access**: Hidden service (.onion) address
- **Enhanced Privacy**: No IP address logging
- **Censorship Resistance**: Accessible from restricted networks
- **Optional Usage**: Regular HTTP/HTTPS access remains available
## Docker Setup
### Using Docker Compose
The included `compose.yml` automatically sets up Tor:
```yaml
version: '3.8'
services:
routstr:
build: .
environment:
- TOR_PROXY_URL=socks5://tor:9050
ports:
- 8000:8000
tor:
image: ghcr.io/hundehausen/tor-hidden-service:latest
volumes:
- tor-data:/var/lib/tor
environment:
- HS_ROUTER=routstr:8000:80
depends_on:
- routstr
volumes:
tor-data:
```
Start with:
```bash
docker compose up -d
```
### Getting Your Onion Address
After starting, retrieve your hidden service address:
```bash
# View Tor logs
docker compose logs tor
# Or directly from the hostname file
docker exec tor cat /var/lib/tor/hidden_service/hostname
```
Your onion address will look like:
```
roustrjfsdgfiueghsklchg.onion
```
## Manual Tor Setup
### Install Tor
```bash
# Ubuntu/Debian
sudo apt-get install tor
# macOS
brew install tor
# Start Tor
sudo systemctl start tor
```
### Configure Hidden Service
Edit `/etc/tor/torrc`:
```bash
# Hidden service configuration
HiddenServiceDir /var/lib/tor/routstr/
HiddenServicePort 80 127.0.0.1:8000
# Optional: Restrict to v3 addresses
HiddenServiceVersion 3
```
Restart Tor:
```bash
sudo systemctl restart tor
```
Get onion address:
```bash
sudo cat /var/lib/tor/routstr/hostname
```
## Client Configuration
### Using Tor with Python
```python
import httpx
from openai import OpenAI
# Configure SOCKS proxy
proxies = {
"http://": "socks5://127.0.0.1:9050",
"https://": "socks5://127.0.0.1:9050"
}
# Create client with Tor
http_client = httpx.Client(proxies=proxies)
client = OpenAI(
api_key="sk-...",
base_url="http://roustrjfsdgfiueghsklchg.onion/v1",
http_client=http_client
)
# Use normally
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hello via Tor!"}]
)
```
### Using Tor with cURL
```bash
# Install torify
sudo apt-get install torsocks
# Make request through Tor
torify curl http://roustrjfsdgfiueghsklchg.onion/v1/models
# Or with explicit proxy
curl --socks5 127.0.0.1:9050 http://roustrjfsdgfiueghsklchg.onion/v1/models
```
### JavaScript/Node.js
```javascript
import { SocksProxyAgent } from 'socks-proxy-agent';
import OpenAI from 'openai';
// Create SOCKS agent
const agent = new SocksProxyAgent('socks5://127.0.0.1:9050');
// Configure OpenAI client
const openai = new OpenAI({
apiKey: 'sk-...',
baseURL: 'http://roustrjfsdgfiueghsklchg.onion/v1',
httpAgent: agent
});
// Use normally
const response = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: 'Hello via Tor!' }]
});
```
## Configuration
### Environment Variables
Configure Tor proxy for outgoing connections:
```bash
# .env
TOR_PROXY_URL=socks5://tor:9050 # Docker
# or
TOR_PROXY_URL=socks5://127.0.0.1:9050 # Local
```
### Publishing Onion Address
Make your onion address discoverable:
```bash
# .env
ONION_URL=http://roustrjfsdgfiueghsklchg.onion
```
This will be included in:
- `/v1/info` endpoint
- Nostr announcements
- Admin dashboard
## Security Considerations
### Hidden Service Security
1. **Keep Private Key Secure**
```bash
# Backup hidden service keys
sudo tar -czf tor-keys-backup.tar.gz /var/lib/tor/routstr/
# Restore to maintain same address
sudo tar -xzf tor-keys-backup.tar.gz -C /
```
2. **Access Control**
```bash
# Restrict to authenticated clients
HiddenServiceAuthorizeClient stealth client1,client2
```
3. **Rate Limiting**
```bash
# In torrc
HiddenServiceMaxStreams 100
HiddenServiceMaxStreamsCloseCircuit 1
```
### Operational Security
1. **Separate Tor Instance**
```yaml
# Use dedicated Tor container
tor:
image: ghcr.io/hundehausen/tor-hidden-service:latest
restart: always
networks:
- tor_network
```
2. **Monitor Tor Health**
```python
async def check_tor_connection():
"""Verify Tor connectivity."""
try:
async with httpx.AsyncClient(
proxies={"all://": TOR_PROXY_URL}
) as client:
response = await client.get(
"https://check.torproject.org/api/ip"
)
data = response.json()
return data.get("IsTor", False)
except Exception:
return False
```
3. **Logging Considerations**
```python
# Don't log .onion addresses with IPs
def sanitize_logs(message: str) -> str:
# Remove IP addresses when .onion is present
if ".onion" in message:
message = re.sub(r'\d+\.\d+\.\d+\.\d+', '[IP]', message)
return message
```
## Performance Optimization
### Connection Pooling
```python
# Reuse Tor circuits
class TorConnectionPool:
def __init__(self, proxy_url: str):
self.proxy_url = proxy_url
self._clients = []
async def get_client(self) -> httpx.AsyncClient:
if not self._clients:
client = httpx.AsyncClient(
proxies={"all://": self.proxy_url},
timeout=httpx.Timeout(30.0),
limits=httpx.Limits(
max_keepalive_connections=5,
max_connections=10
)
)
self._clients.append(client)
return self._clients[0]
```
### Circuit Management
```python
# Rotate Tor circuits periodically
async def rotate_tor_circuit():
"""Signal Tor to create new circuit."""
async with httpx.AsyncClient() as client:
# Tor control port (requires configuration)
response = await client.post(
"http://localhost:9051",
data="AUTHENTICATE\r\nSIGNAL NEWNYM\r\n"
)
```
### Caching Strategies
```python
# Cache responses for Tor users
@lru_cache(maxsize=1000)
def get_cached_response(
endpoint: str,
params_hash: str
) -> Optional[dict]:
"""Cache frequently accessed data."""
# Longer cache for Tor users due to latency
return cache.get(f"tor:{endpoint}:{params_hash}")
```
## Monitoring
### Tor Metrics
Track Tor-specific metrics:
```python
class TorMetrics:
def __init__(self):
self.tor_requests = 0
self.tor_errors = 0
self.circuit_builds = 0
self.average_latency = 0
async def record_request(
self,
duration: float,
success: bool
):
self.tor_requests += 1
if not success:
self.tor_errors += 1
# Update average latency
self.average_latency = (
(self.average_latency * (self.tor_requests - 1) + duration)
/ self.tor_requests
)
```
### Health Checks
```python
@router.get("/health/tor")
async def tor_health():
"""Check Tor service health."""
checks = {
"tor_proxy": await check_tor_proxy(),
"hidden_service": await check_hidden_service(),
"circuit_established": await check_circuit()
}
status = "healthy" if all(checks.values()) else "unhealthy"
return {
"status": status,
"checks": checks,
"metrics": {
"tor_requests_total": metrics.tor_requests,
"tor_error_rate": metrics.tor_errors / max(metrics.tor_requests, 1),
"average_latency_ms": metrics.average_latency * 1000
}
}
```
## Troubleshooting
### Common Issues
**Hidden Service Not Accessible**
```bash
# Check Tor logs
docker compose logs tor
# or
sudo journalctl -u tor
# Verify service is running
sudo systemctl status tor
# Test locally
curl --socks5 127.0.0.1:9050 http://your-onion.onion/v1/info
```
**Slow Connection**
- Tor adds 3+ hops of latency
- Use connection pooling
- Implement aggressive caching
- Consider increasing timeouts
**Connection Errors**
```python
# Implement Tor-specific retry logic
async def tor_retry(func, max_retries=5):
for attempt in range(max_retries):
try:
return await func()
except httpx.ProxyError:
if attempt < max_retries - 1:
# Exponential backoff for circuit building
await asyncio.sleep(2 ** attempt)
else:
raise
```
### Debugging
Enable Tor debug logging:
```bash
# In torrc
Log debug file /var/log/tor/debug.log
# Monitor in real-time
tail -f /var/log/tor/debug.log
```
## Best Practices
### For Operators
1. **Backup Hidden Service Keys**
- Store securely offline
- Enables service recovery
- Maintains same .onion address
2. **Monitor Tor Health**
- Check circuit establishment
- Track request latency
- Alert on failures
3. **Separate Concerns**
- Run Tor in separate container
- Isolate from main application
- Use internal networks
### For Users
1. **Verify Onion Addresses**
- Check against multiple sources
- Bookmark verified addresses
- Watch for phishing
2. **Handle Higher Latency**
- Increase client timeouts
- Implement retries
- Use connection pooling
3. **Enhance Privacy**
- Use Tor Browser for web access
- Avoid mixing Tor/clearnet
- Don't include identifying info
## Advanced Configuration
### Multi-Hop Onion Services
For extra security, chain multiple Tor instances:
```yaml
# compose.yml
services:
tor-entry:
image: tor:latest
command: tor -f /etc/tor/torrc.entry
tor-middle:
image: tor:latest
command: tor -f /etc/tor/torrc.middle
tor-exit:
image: tor:latest
command: tor -f /etc/tor/torrc.exit
```
### Onion Service Authentication
Require client authorization:
```bash
# Generate client auth
openssl rand -base64 32 > client_auth_key
# In torrc
HiddenServiceDir /var/lib/tor/routstr/
HiddenServicePort 80 127.0.0.1:8000
HiddenServiceAuthorizeClient stealth payments
```
### Load Balancing
Distribute load across multiple instances:
```nginx
# Onion service nginx config
upstream routstr_backends {
server routstr1:8000;
server routstr2:8000;
server routstr3:8000;
}
server {
listen 80;
location / {
proxy_pass http://routstr_backends;
}
}
```
## Next Steps
- [Nostr Discovery](nostr.md) - Announce your onion service
- [Docker Setup](../getting-started/docker.md) - Container configuration

View File

@@ -425,4 +425,4 @@ All API key usage is logged:
- [Endpoints](endpoints.md) - Complete endpoint reference
- [Errors](errors.md) - Error handling guide
- [Using the API](../user-guide/using-api.md) - Integration examples
- [Using the API](../client/integration.md) - Integration examples

View File

@@ -360,6 +360,42 @@ POST /v1/wallet/create
}
```
### Get Key Information
Get current balance, consumption data, and child keys for an API key.
```http
GET /v1/balance/info
Authorization: Bearer sk-...
```
**Response:**
```json
{
"api_key": "sk-abc...",
"balance": 8500000,
"reserved": 0,
"is_child": false,
"parent_key": null,
"total_requests": 42,
"total_spent": 1500000,
"balance_limit": null,
"balance_limit_reset": null,
"validity_date": null,
"child_keys": [
{
"api_key": "sk-child1...",
"total_requests": 10,
"total_spent": 500000,
"balance_limit": 1000000,
"balance_limit_reset": "daily",
"validity_date": 1738000000
}
]
}
```
### Check Balance
Get current wallet balance.
@@ -434,6 +470,42 @@ Authorization: Bearer sk-...
}
```
### Create Child Key
Creates one or more child API keys that share the parent's balance. Each child key creation costs a fixed amount (configurable).
```http
POST /v1/balance/child-key
Authorization: Bearer sk-...
```
**Request Body:**
```json
{
"count": 1
}
```
**Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `count` | integer | Yes | - | Number of child keys to create (1-50) |
**Response:**
```json
{
"api_keys": ["sk-abc...", "sk-def..."],
"count": 2,
"cost_msats": 2000,
"cost_sats": 2,
"parent_balance": 98000,
"parent_balance_sats": 98
}
```
## Provider Discovery
## Admin Settings
@@ -527,4 +599,4 @@ Rate limit information is included in response headers.
- [Errors](errors.md) - Error handling reference
- [Authentication](authentication.md) - Auth details
- [Examples](../user-guide/using-api.md) - Code examples
- [Integration Guide](../client/integration.md) - Code examples

View File

@@ -589,4 +589,4 @@ class ErrorMetrics:
- [Authentication](authentication.md) - Auth error details
- [Endpoints](endpoints.md) - Endpoint-specific errors
- [Examples](../user-guide/using-api.md) - Error handling examples
- [Integration Guide](../client/integration.md) - Error handling examples

View File

@@ -99,19 +99,19 @@ All errors follow a consistent format:
Standard OpenAI-compatible endpoints:
- **Chat Completions**: `/v1/chat/completions`
- **Completions**: `/v1/completions` *(Coming soon)*
- **Embeddings**: `/v1/embeddings` *(Coming soon)*
- **Images**: `/v1/images/generations` *(Coming soon)*
- **Audio**: `/v1/audio/transcriptions` *(Coming soon)*
- **Models**: `/v1/models`
- **Responses**: `/v1/responses`
- **Chat Completions**: `/v1/chat/completions`
- **Embeddings**: `/v1/embeddings`
- **Completions**: `/v1/completions` *(planned)*
- **Images**: `/v1/images/generations` *(planned)*
- **Audio**: `/v1/audio/transcriptions` *(planned)*
### Payment Endpoints
Routstr-specific payment management:
- **Wallet**: `/v1/wallet/*`
- **Balance**: `/v1/balance`
- **Balance**: `/v1/balance/*`
- **Node Info**: `/v1/info`
### Admin Endpoints
@@ -137,9 +137,25 @@ Protected administrative functions:
| Header | Description |
|--------|-------------|
| `X-Routstr-Version` | API version override |
| `X-Cashu` | eCash token for per-request payment |
| `X-Max-Cost` | Maximum acceptable cost in sats |
#### X-Cashu: Stateless Per-Request Payment
Instead of using `Authorization: Bearer sk-...`, you can send a Cashu token directly in the `X-Cashu` header. The response will include an `X-Cashu-Refund` header with your change.
```bash
curl https://api.routstr.com/v1/chat/completions \
-H "X-Cashu: cashuA3s8jKx9..." \
-H "Content-Type: application/json" \
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]}'
```
The response includes your change in the same header:
```
X-Cashu: cashuA7k2mNp4...
```
This is fully stateless—no session, no `/v1/balance/refund` call needed. However, **streaming does not work with `X-Cashu`** because the refund can only be calculated after the full response is generated.
## Response Headers
@@ -149,16 +165,8 @@ Protected administrative functions:
|--------|-------------|
| `Content-Type` | Response format |
| `Content-Length` | Response size |
| `X-Routstr-Request-ID` | Unique request identifier |
| `X-Routstr-Version` | API version used |
### Cost Headers
| Header | Description |
|--------|-------------|
| `X-Routstr-Cost` | Request cost in sats |
| `X-Routstr-Balance` | Remaining balance |
| `X-Cashu` | Change token (if applicable) |
| `X-Request-ID` | Unique request identifier |
| `X-Cashu` | Change token (when request used `X-Cashu` header) |
## Streaming Responses
@@ -245,8 +253,6 @@ X-Webhook-Signature: sha256=...
- Current version: `v1`
- Version in URL path: `/v1/endpoint`
- Override with header: `X-Routstr-Version: v2`
- Deprecation notices: 6 months
## Status Codes
@@ -284,82 +290,23 @@ Responses are compressed with gzip when:
- Response is larger than 1KB
- Content type is compressible
## Pagination
## Batch Requests *(planned)*
List endpoints support pagination:
Process multiple operations in one request. Coming soon.
## Node Info
Get node metadata:
```
GET /v1/transactions?limit=50&offset=100
GET /v1/info
```
Response includes pagination metadata:
```json
{
"data": [...],
"has_more": true,
"total": 500,
"limit": 50,
"offset": 100
}
```
## Field Filtering
Select specific fields in responses:
```
GET /v1/models?fields=id,name,pricing
```
## Batch Requests
Process multiple operations in one request:
```json
POST /v1/batch
{
"requests": [
{"method": "POST", "endpoint": "/chat/completions", "body": {...}},
{"method": "GET", "endpoint": "/models"},
{"method": "GET", "endpoint": "/balance"}
]
}
```
## Idempotency
Prevent duplicate operations:
```
Idempotency-Key: unique-request-id
```
Keys are stored for 24 hours.
## Health Check
Monitor service status:
```
GET /health
Response:
{
"status": "healthy",
"version": "0.2.0",
"timestamp": "2024-01-01T00:00:00Z",
"checks": {
"database": "ok",
"upstream": "ok",
"mint": "ok"
}
}
```
Supported models and pricing are available at `/v1/models`.
## Next Steps
- [Authentication](authentication.md) - Detailed auth guide
- [Endpoints](endpoints.md) - Complete endpoint reference
- [Errors](errors.md) - Error handling guide
- [Examples](../user-guide/using-api.md) - Code examples
- [Integration Guide](../client/integration.md) - Code examples

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

@@ -4,7 +4,7 @@ This document describes the high-level architecture of Routstr Core, helping con
## System Overview
Routstr Core is a FastAPI-based reverse proxy that adds Bitcoin micropayments to OpenAI-compatible APIs.
Routstr Core is a FastAPI-based reverse proxy that adds Bitcoin micropayments to OpenAI-compatible APIs and can optionally announce providers via Nostr.
```mermaid
graph TB
@@ -20,7 +20,7 @@ graph TB
Auth[Auth Module]
Payment[Payment Module]
Proxy[Proxy Module]
DB[(SQLite DB)]
DB[(SQLModel DB)]
API --> Auth
Auth --> Payment
@@ -40,57 +40,87 @@ graph TB
The main application is initialized in `routstr/core/main.py`:
- **Lifespan Management**: Handles startup/shutdown tasks
- **Middleware**: CORS, logging, error handling
- **Routers**: Modular endpoint organization
- **Background Tasks**: Price updates, automatic payouts
- **Lifespan Management**: Runs migrations, initializes DB, refreshes pricing/models, starts background tasks
- **Middleware**: CORS and request logging
- **Routers**: Admin, pricing/models, balance/wallet, providers discovery, proxy
- **Background Tasks**: Price refresh, model map refresh, payouts, node announcements, provider discovery refresh
### Authentication System
Located in `routstr/auth.py`, handles:
- **API Key Validation**: Hashed key storage and lookup
- **Balance Checking**: Ensures sufficient funds
- **Rate Limiting**: Optional request throttling
- **Token Redemption**: Converts eCash to balance
- **API Key Validation**: SHA-256 hashed key lookup and persistence
- **Balance Checking**: Ensures sufficient funds before requests
- **Token Redemption**: Converts Cashu tokens to balance
### Payment Processing
The `routstr/payment/` module manages:
- **Cost Calculation**: Token-based or fixed pricing
- **Model Pricing**: Dynamic pricing from models.json
- **Currency Conversion**: BTC/USD rate management
- **Fee Application**: Exchange and provider fees
- **Model Pricing**: Derived from upstream providers and DB overrides
- **Currency Conversion**: BTC/USD price refresh and conversion
- **Fee Application**: Provider fee applied to upstream model pricing
### Request Proxying
`routstr/proxy.py` handles:
- **Request Forwarding**: Preserves headers and body
- **Response Streaming**: Efficient memory usage
- **Usage Tracking**: Counts tokens and costs
- **Error Handling**: Graceful upstream failures
- **Request Forwarding**: Forwards requests to selected upstream providers
- **Response Streaming**: Streaming and non-streaming paths
- **Usage Tracking**: Adjusts costs after upstream responses
- **Error Handling**: Maps upstream errors to consistent responses
### Database Layer
Using SQLModel in `routstr/core/db.py`:
```python
# Core models
APIKey:
- id: Primary key
- key_hash: Hashed API key
# Core tables
ApiKey:
- hashed_key: Primary key (SHA-256 of key or Cashu token)
- balance: Current balance (msats)
- created_at: Timestamp
- metadata: JSON field
- reserved_balance: Reserved balance (msats)
- refund_address: Optional LNURL for refunds
- key_expiry_time: Optional refund expiry timestamp
- total_spent: Total spent (msats)
- total_requests: Request count
- refund_mint_url: Mint URL for refunds
- refund_currency: Refund currency
Transaction:
UpstreamProviderRow:
- id: Primary key
- api_key_id: Foreign key
- amount: Transaction amount
- type: deposit/usage/withdrawal
- timestamp: When occurred
- provider_type: openai/anthropic/azure/openrouter/etc.
- base_url: Provider API base URL
- api_key: Provider API key
- api_version: Optional API version
- enabled: Provider enabled flag
- provider_fee: Provider fee multiplier
ModelRow:
- id: Model ID
- upstream_provider_id: Provider foreign key
- name: Model name
- architecture: JSON
- pricing: JSON
- sats_pricing: JSON
- per_request_limits: JSON
- top_provider: JSON
- canonical_slug: Canonical model slug
- alias_ids: Model aliases
- enabled: Model enabled flag
LightningInvoice:
- id: Primary key
- bolt11: Invoice
- amount_sats: Amount in sats
- payment_hash: Payment hash
- status: pending/paid/expired/cancelled
- api_key_hash: Optional associated API key
- purpose: create/topup
- created_at: Unix timestamp
- expires_at: Unix timestamp
- paid_at: Unix timestamp
```
## Request Flow
@@ -107,10 +137,10 @@ sequenceDiagram
C->>R: API Request + Key
R->>D: Validate Key
D-->>R: Key Info + Balance
R->>R: Check Balance
R->>R: Reserve Max Cost
R->>P: Forward Request
P-->>R: AI Response
R->>D: Deduct Cost
R->>D: Finalize Cost (adjust by usage)
R-->>C: Return Response
```
@@ -124,36 +154,20 @@ sequenceDiagram
participant M as Cashu Mint
participant D as Database
C->>R: Create Key Request + Token
R->>W: Validate Token
C->>R: Request + Cashu Token
R->>W: Redeem Token
W->>M: Verify with Mint
M-->>W: Token Valid
W-->>R: Token Amount
R->>D: Create Key + Balance
R-->>C: Return API Key
R->>D: Create/Update Key + Balance
R-->>C: Continue Request
```
## Key Design Decisions
### 1. Async Architecture
Everything is async for maximum performance:
```python
async def handle_request(request: Request) -> Response:
# Non-blocking database queries
api_key = await get_api_key(request.headers["Authorization"])
# Concurrent operations
balance_check, rate_limit = await asyncio.gather(
check_balance(api_key),
check_rate_limit(api_key)
)
# Stream response without blocking
async for chunk in proxy_request(request):
yield chunk
```
The system is async end-to-end, with background tasks for pricing refresh, provider discovery, model map refresh, and payouts.
### 2. Modular Design
@@ -166,82 +180,46 @@ Components are loosely coupled:
### 3. Error Handling
Graceful degradation and clear error messages:
```python
class RoustrError(Exception):
"""Base exception with structured error response"""
status_code: int = 500
error_type: str = "internal_error"
class InsufficientBalanceError(RoustrError):
status_code = 402
error_type = "insufficient_balance"
```
Exceptions are handled by FastAPI exception handlers to return consistent JSON responses with a request ID.
### 4. Database Migrations
Using Alembic for schema management:
- Auto-migrations on startup
- Version control for schema changes
- Rollback capability
- Zero-downtime updates
Alembic migrations are run on startup, and tables are created for any models not tracked by migrations.
## Security Architecture
### API Key Security
- **Storage**: SHA-256 hashed keys
- **Generation**: Cryptographically secure random
- **Validation**: Constant-time comparison
- **Rotation**: Support for key expiry
- **Generation**: Cryptographically secure random (when creating new keys)
- **Validation**: Hash lookup in DB
- **Expiry**: Optional refund flow via `key_expiry_time` and `refund_address`
### Payment Security
- **Token Validation**: Cryptographic verification
- **Double-Spend Prevention**: Mint verification
- **Balance Protection**: Atomic transactions
- **Audit Trail**: All transactions logged
- **Token Validation**: Cashu token redemption via mint
- **Balance Protection**: Atomic updates and reserved balance tracking
- **Audit Trail**: Structured logging of payments and adjustments
### Network Security
- **HTTPS**: Enforced in production
- **CORS**: Configurable origins
- **Rate Limiting**: Per-key limits
- **Input Validation**: Pydantic models
## Performance Considerations
### Caching Strategy
```python
# Model pricing cache
@lru_cache(maxsize=100)
def get_model_price(model_id: str) -> ModelPrice:
return MODELS.get(model_id)
# Balance cache with TTL
balance_cache = TTLCache(maxsize=1000, ttl=60)
```
Model and provider selections are cached in process memory and refreshed on a schedule.
### Database Optimization
- **Connection Pooling**: Reuse connections
- **Indexed Queries**: Key lookups are O(1)
- **Batch Operations**: Group updates
- **Async I/O**: Non-blocking queries
- **Atomic Updates**: Balance reservation and finalization updates
### Streaming Responses
Efficient memory usage for large responses:
```python
async def stream_response(upstream_response):
async for chunk in upstream_response.aiter_bytes():
# Process chunk without loading full response
yield process_chunk(chunk)
```
Streaming responses are forwarded from upstream providers with usage tracking hooks.
## Extension Points
@@ -273,8 +251,6 @@ async def stream_response(upstream_response):
- Mock external dependencies
- Test business logic in isolation
- Fast execution (< 1 second per test)
- High coverage target (> 80%)
### Integration Tests
@@ -285,10 +261,7 @@ async def stream_response(upstream_response):
### Performance Tests
- Load testing with locust
- Memory profiling
- Database query optimization
- Response time benchmarks
- Response time benchmarks (as needed)
## Monitoring and Observability
@@ -308,41 +281,17 @@ logger.info("api_request", extra={
### Metrics Collection
Key metrics tracked:
- Request rate by endpoint
- Token usage by model
- Balance changes
- Error rates
- Response times
Structured logs are emitted for requests, pricing, and payment events.
### Health Checks
```python
@app.get("/health")
async def health_check():
return {
"status": "healthy",
"version": __version__,
"database": await check_db(),
"upstream": await check_upstream(),
"mints": await check_mints()
}
```
Use `/v1/info` for basic service metadata and configuration visibility.
## Deployment Architecture
### Container Structure
```dockerfile
# Multi-stage build
FROM python:3.11-slim AS builder
# Install dependencies
FROM python:3.11-slim
# Copy only runtime needs
# Run as non-root user
```
See `core/Dockerfile` for the current container build configuration.
### Environment Configuration
@@ -352,10 +301,9 @@ FROM python:3.11-slim
### Scaling Considerations
- **Horizontal**: Multiple instances behind load balancer
- **Horizontal**: Multiple instances behind a load balancer
- **Vertical**: Async handles high concurrency
- **Database**: Consider PostgreSQL for scale
- **Caching**: Redis for distributed cache
- **Database**: Configure `DATABASE_URL` for external databases
## Future Architecture

View File

@@ -7,10 +7,13 @@ This guide provides a detailed overview of Routstr Core's codebase organization
```
routstr-core/
├── routstr/ # Main application package
│ ├── __init__.py # Package initialization, loads .env
│ ├── auth.py # Authentication and authorization
│ ├── __init__.py # Package initialization, exports FastAPI app
│ ├── algorithm.py # Model selection/mapping logic
│ ├── auth.py # Bearer/Cashu auth and payment handling
│ ├── balance.py # Balance management endpoints
│ ├── discovery.py # Nostr relay discovery
│ ├── lightning.py # Lightning invoice topups
│ ├── nip91.py # Node announcement logic
│ ├── proxy.py # Request proxying logic
│ ├── wallet.py # Cashu wallet operations
│ │
@@ -18,19 +21,23 @@ routstr-core/
│ │ ├── __init__.py
│ │ ├── admin.py # Admin dashboard and API
│ │ ├── db.py # Database models and connection
│ │ ├── exceptions.py # Custom exception classes
│ │ ├── exceptions.py # Exception handlers
│ │ ├── logging.py # Structured logging setup
│ │ ├── main.py # FastAPI app initialization
│ │ └── middleware.py # HTTP middleware components
│ │
── payment/ # Payment processing
├── __init__.py
├── cost_calculation.py # Usage cost calculation
├── helpers.py # Payment utilities
├── lnurl.py # Lightning URL support
├── models.py # Model pricing management
── price.py # BTC/USD price handling
└── x_cashu.py # Cashu header protocol
── payment/ # Payment processing
├── __init__.py
├── cost_calculation.py # Usage cost calculation
├── helpers.py # Payment utilities
├── lnurl.py # Lightning URL support
├── models.py # Model pricing management
── price.py # BTC/USD price handling
│ └── upstream/ # Upstream provider integrations
│ ├── base.py # Base provider logic
│ ├── helpers.py # Provider init and model refresh
│ └── ... # Provider implementations
├── tests/ # Test suite
│ ├── __init__.py
@@ -45,7 +52,12 @@ routstr-core/
│ └── versions/ # Migration files
├── scripts/ # Utility scripts
── models_meta.py # Fetch model pricing
── models_meta.py # Fetch model pricing
│ └── ... # Build/update helpers
├── examples/ # Example clients
├── testing-clients/ # HTML test clients
├── ui/ # Next.js admin UI
├── docs/ # Documentation
├── logs/ # Application logs (git ignored)
@@ -71,30 +83,27 @@ routstr-core/
#### `routstr/__init__.py`
```python
# Loads environment variables
import dotenv
dotenv.load_dotenv()
# Exports FastAPI app
from .core.main import app as fastapi_app
__all__ = ["fastapi_app"]
```
#### `routstr/core/main.py`
```python
# FastAPI application setup
app = FastAPI(
title="Routstr Node",
lifespan=lifespan, # Manages startup/shutdown
)
app = FastAPI(version=__version__, lifespan=lifespan)
# Middleware registration
app.add_middleware(CORSMiddleware, ...)
app.add_middleware(LoggingMiddleware)
# Router inclusion
app.include_router(models_router)
app.include_router(admin_router)
app.include_router(balance_router)
app.include_router(deprecated_wallet_router)
app.include_router(providers_router)
app.include_router(proxy_router)
```
@@ -102,29 +111,24 @@ app.include_router(proxy_router)
#### `routstr/auth.py`
Handles API key validation and authorization:
Handles bearer key validation and payment lifecycle (bearer or Cashu token):
```python
class APIKeyAuth:
"""FastAPI dependency for API key authentication"""
async def __call__(self, request: Request) -> APIKey:
# Extract and validate API key
# Check balance
# Return authenticated key object
# Usage in routes:
@router.get("/protected")
async def protected_route(api_key: APIKey = Depends(APIKeyAuth())):
pass
async def validate_bearer_key(
bearer_key: str,
session: AsyncSession,
refund_address: Optional[str] = None,
key_expiry_time: Optional[int] = None,
) -> ApiKey:
"""Validate bearer API key or redeem Cashu token into a balance."""
```
Key functions:
- `create_api_key()` - Generate new API keys
- `validate_api_key()` - Verify and retrieve key
- `check_balance()` - Ensure sufficient funds
- `update_last_used()` - Track usage
- `validate_bearer_key()` - Validate API key or Cashu token
- `pay_for_request()` - Reserve max cost before upstream call
- `adjust_payment_for_tokens()` - Adjust final cost after response
- `revert_pay_for_request()` - Refund on upstream failure
### Payment Processing
@@ -133,56 +137,30 @@ Key functions:
Calculates request costs:
```python
def calculate_request_cost(
model: str,
prompt_tokens: int,
completion_tokens: int,
**kwargs
) -> CostData:
"""Calculate cost in millisatoshis"""
# Model-based or fixed pricing
# Token counting
# Fee application
# Currency conversion
async def calculate_cost(
response_data: dict, max_cost: int, session: AsyncSession
) -> CostData | MaxCostData | CostDataError:
"""Calculate cost in millisatoshis from response usage or model pricing."""
```
#### `routstr/payment/models.py`
Manages model pricing data:
Manages model pricing, database overrides, and pricing refresh:
```python
class ModelPrice:
class Model(BaseModel):
id: str
name: str
pricing: dict[str, float] # USD prices
context_length: int
# Global model registry
MODELS: dict[str, ModelPrice] = load_models()
pricing: Pricing
sats_pricing: Pricing | None = None
# Dynamic price updates
async def update_sats_pricing():
"""Background task to update BTC prices"""
"""Periodic task to update sats pricing for providers and overrides."""
```
#### `routstr/payment/x_cashu.py`
#### `routstr/proxy.py` + `routstr/upstream/*`
Implements Cashu payment protocol:
```python
class XCashuHandler:
"""Handle x-cashu header payments"""
async def process_request_payment(
self,
token: str,
estimated_cost: int
) -> PaymentResult:
# Validate token
# Check minimum amount
# Process payment
# Generate change
```
The `x-cashu` header is handled by the proxy route and delegated to upstream providers.
### Request Proxying
@@ -191,17 +169,11 @@ class XCashuHandler:
Core proxy functionality:
```python
@router.api_route("/{path:path}", methods=ALL_METHODS)
async def proxy_request(
request: Request,
path: str,
api_key: APIKey = Depends(APIKeyAuth())
) -> Response:
"""Forward requests to upstream provider"""
# Build upstream request
# Stream response
# Track usage
# Deduct costs
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
async def proxy(
request: Request, path: str, session: AsyncSession = Depends(get_session)
) -> Response | StreamingResponse:
"""Forward requests to upstream provider and charge usage."""
```
Key features:
@@ -215,27 +187,29 @@ Key features:
#### `routstr/core/db.py`
SQLModel definitions:
SQLModel definitions (selected):
```python
class APIKey(SQLModel, table=True):
id: int | None = Field(primary_key=True)
key_hash: str = Field(index=True, unique=True)
balance: int # millisatoshis
total_deposited: int = 0
class ApiKey(SQLModel, table=True):
hashed_key: str = Field(primary_key=True)
balance: int
reserved_balance: int = 0
refund_address: str | None = None
key_expiry_time: int | None = None
total_spent: int = 0
created_at: datetime
expires_at: datetime | None = None
metadata: dict = Field(default_factory=dict, sa_column=Column(JSON))
total_requests: int = 0
class Transaction(SQLModel, table=True):
id: int | None = Field(primary_key=True)
api_key_id: int = Field(foreign_key="apikey.id")
amount: int # can be negative
balance_after: int
type: TransactionType
description: str
timestamp: datetime
class LightningInvoice(SQLModel, table=True):
id: str = Field(primary_key=True)
bolt11: str
amount_sats: int
status: str
class UpstreamProviderRow(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
provider_type: str
base_url: str
api_key: str
```
### Admin Interface
@@ -245,18 +219,17 @@ class Transaction(SQLModel, table=True):
Web dashboard and admin API:
```python
@admin_router.get("/admin/")
@admin_router.get("/admin")
async def admin_dashboard(request: Request):
"""Render admin HTML interface"""
# Authentication check
# Load statistics
# Render template
@admin_router.post("/admin/api/withdraw")
@admin_router.post("/admin/withdraw")
async def withdraw_balance(
api_key: str,
amount: int | None = None
) -> WithdrawalResponse:
request: Request, withdraw_request: WithdrawRequest
) -> dict[str, str]:
"""Generate eCash token for withdrawal"""
```
@@ -271,25 +244,14 @@ Features:
#### `routstr/wallet.py`
Cashu wallet operations:
Cashu wallet operations (function-based):
```python
class WalletManager:
"""Manage Cashu wallet instances"""
async def redeem_token(
self,
token: str,
mint_url: str | None = None
) -> int:
"""Redeem eCash token and return value"""
async def create_token(
self,
amount: int,
mint_url: str
) -> str:
"""Create eCash token for withdrawal"""
async def recieve_token(token: str) -> tuple[int, str, str]:
"""Redeem eCash token and return amount/unit/mint."""
async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str:
"""Create eCash token for withdrawal."""
```
### Utility Modules
@@ -301,12 +263,9 @@ Structured logging configuration:
```python
def setup_logging():
"""Configure JSON structured logging"""
# Set log level
# Configure formatters
# Add handlers
class RequestIdMiddleware:
"""Add request ID to all logs"""
class RequestIdFilter(logging.Filter):
"""Attach request ID to log records."""
```
#### `routstr/core/middleware.py`
@@ -316,27 +275,18 @@ HTTP middleware components:
```python
class LoggingMiddleware:
"""Log all HTTP requests/responses"""
class ErrorHandlingMiddleware:
"""Consistent error responses"""
```
#### `routstr/core/exceptions.py`
Custom exception hierarchy:
Exception handlers:
```python
class RoustrError(Exception):
"""Base exception with error details"""
status_code: int
error_type: str
detail: str
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""HTTP exception handler with request ID"""
class PaymentError(RoustrError):
"""Payment-related errors"""
class UpstreamError(RoustrError):
"""Upstream API errors"""
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Fallback exception handler with request ID"""
```
## Configuration Files
@@ -348,7 +298,7 @@ Project metadata and dependencies:
```toml
[project]
name = "routstr"
version = "0.2.0"
version = "0.2.2"
dependencies = [
"fastapi[standard]>=0.115",
"sqlmodel>=0.0.24",
@@ -409,13 +359,13 @@ Using FastAPI's DI system:
```python
# Define dependency
async def get_db() -> AsyncSession:
async with async_session() as session:
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSession(engine, expire_on_commit=False) as session:
yield session
# Use in routes
@router.get("/items")
async def get_items(db: AsyncSession = Depends(get_db)):
async def get_items(db: AsyncSession = Depends(get_session)):
result = await db.execute(select(Item))
return result.scalars().all()
```

View File

@@ -22,22 +22,7 @@ git clone https://github.com/YOUR_USERNAME/routstr-core.git
cd routstr-core
```
### 2. Install uv
We use [uv](https://github.com/astral-sh/uv) for fast, reliable Python package management:
```bash
# Using the installer script
curl -LsSf https://astral.sh/uv/install.sh | sh
# Or with pip
pip install uv
# Or with Homebrew (macOS)
brew install uv
```
### 3. Set Up Environment
### 2. Set Up Environment
Run the setup command:
@@ -47,13 +32,13 @@ make setup
This will:
- ✅ Install uv if not present
- ✅ Install [uv](https://github.com/astral-sh/uv) if not present
- ✅ Create a virtual environment
- ✅ Install all dependencies
- ✅ Install dev tools (mypy, ruff, pytest)
- ✅ Install project in editable mode
### 4. Configure Environment
### 3. Configure Environment
Create your environment file:
@@ -71,7 +56,7 @@ ADMIN_PASSWORD=development-password
DATABASE_URL=sqlite+aiosqlite:///dev.db
```
### 5. Verify Installation
### 4. Verify Installation
Run these commands to verify your setup:
@@ -168,42 +153,92 @@ Understanding the codebase:
```
routstr-core/
├── routstr/ # Main package
│ ├── __init__.py # Package initialization
│ ├── __init__.py
│ ├── algorithm.py # Provider selection algorithms
│ ├── auth.py # Authentication logic
│ ├── balance.py # Balance management
│ ├── balance.py # Balance management API
│ ├── discovery.py # Nostr discovery
│ ├── lightning.py # Lightning invoice handling
│ ├── nip91.py # Node announcement implementation
│ ├── proxy.py # Request proxying
│ ├── wallet.py # Cashu wallet integration
│ │
│ ├── core/ # Core modules
│ │ ├── admin.py # Admin dashboard
│ │ ├── db.py # Database models
│ │ ├── admin.py # Admin dashboard API
│ │ ├── db.py # Database models (SQLModel)
│ │ ├── exceptions.py # Custom exceptions
│ │ ├── log_manager.py # Log management
│ │ ├── logging.py # Logging setup
│ │ ├── main.py # FastAPI app
│ │ ── middleware.py # HTTP middleware
│ │ ├── main.py # FastAPI app entry
│ │ ── middleware.py # HTTP middleware
│ │ └── settings.py # Configuration
│ │
── payment/ # Payment processing
├── cost_calculation.py # Cost logic
├── helpers.py # Utilities
├── lnurl.py # Lightning URLs
├── models.py # Model pricing
── price.py # BTC pricing
└── x_cashu.py # Cashu headers
── payment/ # Payment processing
├── cost_calculation.py
├── helpers.py
├── lnurl.py # LNURL support
├── models.py # Model pricing
── price.py # BTC/USD rates
│ └── upstream/ # Upstream providers
│ ├── base.py # Base provider class
│ ├── helpers.py # Shared utilities
│ ├── openai.py # OpenAI
│ ├── anthropic.py # Anthropic
│ ├── gemini.py # Google Gemini
│ ├── openrouter.py # OpenRouter
│ └── ... # More providers
├── ui/ # Admin dashboard (Next.js)
│ ├── app/ # Next.js app router
│ │ ├── page.tsx # Landing page
│ │ ├── balances/ # Balance management
│ │ ├── logs/ # Request logs viewer
│ │ ├── model/ # Model configuration
│ │ ├── providers/ # Upstream providers
│ │ ├── settings/ # Node settings
│ │ └── transactions/ # Transaction history
│ ├── components/ # React components
│ │ ├── ui/ # shadcn/ui primitives
│ │ ├── landing/ # Landing page components
│ │ └── settings/ # Settings components
│ └── lib/ # Utilities & API client
│ ├── api/ # Backend API client
│ ├── auth/ # Auth context
│ └── hooks/ # React hooks
├── tests/ # Test suite
│ ├── unit/ # Unit tests
│ └── integration/ # Integration tests
├── migrations/ # Alembic migrations
├── scripts/ # Utility scripts
├── docs/ # Documentation
├── examples/ # Usage examples
├── migrations/ # Database migrations
├── scripts/ # Utility scripts
── docs/ # Documentation
├── Makefile # Dev commands
├── pyproject.toml # Project config
└── compose.yml # Docker setup
├── Makefile # Dev commands
├── pyproject.toml # Project config
── compose.yml # Docker setup
```
### Admin Dashboard (UI)
The admin dashboard is a Next.js app using:
- **Next.js 14** with App Router
- **shadcn/ui** for components
- **Tailwind CSS** for styling
- **pnpm** for package management
```bash
# Development
cd ui
pnpm install
pnpm dev # http://localhost:3000
# Build for production
pnpm build
```
The UI is served by the FastAPI backend at `/admin/` when built. Use `make build-ui` to build and copy to the backend.
## Common Tasks
### Adding a New Endpoint
@@ -383,7 +418,7 @@ rm -rf test_*.db
Now that you're set up:
1. Read the [Architecture Overview](architecture.md)
3. Check [open issues](https://github.com/routstr/routstr-core/issues)
4. Start with a small contribution
2. Check [open issues](https://github.com/routstr/routstr-core/issues)
3. Start with a small contribution
Happy coding! 🚀

View File

@@ -6,576 +6,228 @@ This guide covers testing practices, patterns, and tools used in Routstr Core de
We follow these principles:
- **Test Behavior, Not Implementation** - Tests should survive refactoring
- **Fast Feedback** - Unit tests run in milliseconds
- **Reliable Tests** - No flaky tests allowed
- **Clear Failures** - Tests should clearly indicate what broke
- Test behavior, not implementation
- Fast feedback
- Reliable tests
- Clear failures
## Test Structure
```
tests/
├── __init__.py
├── conftest.py # Shared fixtures and configuration
├── unit/ # Fast, isolated unit tests
│ ├── test_auth.py
│ ├── test_balance.py
│ ├── test_cost_calculation.py
│ ├── test_models.py
── test_wallet.py
└── integration/ # Component integration tests
├── test_api_endpoints.py
├── test_payment_flow.py
├── test_proxy_streaming.py
── test_real_mint.py
├── integration/
├── conftest.py
├── utils.py
│ ├── test_wallet_topup.py
│ ├── test_wallet_refund.py
│ ├── test_wallet_information.py
│ ├── test_proxy_get_endpoints.py
── test_proxy_post_endpoints.py
│ └── ... more integration tests
├── unit/
├── test_algorithm.py
├── test_fee_consistency.py
── test_image_tokens.py
│ ├── test_logging_securityfilter.py
│ ├── test_payment_helpers.py
│ ├── test_settings.py
│ ├── test_wallet.py
│ └── ... more unit tests
└── run_integration.py
```
## Running Tests
### Quick Test Commands
### Make Targets
```bash
# Run all tests (unit + integration with mocks)
make test
# Unit tests only
make test-unit
# Integration tests with mocks (fast)
make test-integration
# Integration tests with Docker services
make test-integration-docker
# Fast tests only (skip slow and Docker tests)
make test-fast
# Performance tests
make test-performance
# Coverage
make test-coverage
```
### Direct pytest Commands
```bash
# Run all tests
make test
pytest
# Run unit tests only (fast)
make test-unit
# Run a specific test file
pytest tests/unit/test_wallet.py -v
# Run integration tests
make test-integration
# Run a specific test
pytest tests/unit/test_wallet.py::test_get_balance -v
# Run with coverage
make test-coverage
# Run specific test file
uv run pytest tests/unit/test_auth.py -v
# Run specific test
uv run pytest tests/unit/test_auth.py::test_create_api_key -v
# Run tests matching pattern
uv run pytest -k "balance" -v
# Run tests matching a pattern
pytest -k "wallet" -v
```
### Test Markers
## Test Modes (Integration)
Use markers to categorize tests:
Integration tests support two execution modes:
```python
@pytest.mark.slow
async def test_heavy_computation():
pass
- Mock mode (default): uses in-memory mocks, no Docker required
- Docker mode: uses real Docker services (Cashu mint, mock OpenAI, Nostr relay)
@pytest.mark.requires_docker
async def test_real_services():
pass
Use the runner script for Docker mode:
# Run without slow tests
pytest -m "not slow"
```bash
./tests/run_integration.py
```
Or manually:
```bash
docker-compose -f compose.testing.yml up -d
USE_LOCAL_SERVICES=1 pytest tests/integration/ -v
docker-compose -f compose.testing.yml down -v
```
## Test Markers
Markers are defined in `pyproject.toml`:
- `integration`
- `unit`
- `slow`
- `requires_docker`
- `requires_real_mint`
- `performance`
- `asyncio`
Examples:
```bash
# Skip slow tests
pytest -m "not slow" -v
# Run only integration tests
pytest -m "integration"
pytest -m "integration" -v
# Run performance tests
pytest -m "performance" -v
```
## Fixtures and Utilities
### Core Integration Fixtures
Defined in `tests/integration/conftest.py`:
- `integration_client` - Async HTTP client for the FastAPI app
- `authenticated_client` - Client with a pre-created API key
- `testmint_wallet` - Test wallet for generating Cashu tokens
- `db_snapshot` - Database state snapshot/diff helper
- `create_api_key` - Helper to create API keys for tests
- `integration_engine`, `integration_session` - Async DB engine/session
- `background_tasks_controller` - Control background tasks in tests
- `mock_upstream_server` - Mock upstream API responses
### Integration Utilities
Defined in `tests/integration/utils.py`:
- `CashuTokenGenerator`
- `ResponseValidator`
- `PerformanceValidator`
- `ConcurrencyTester`
- `DatabaseStateValidator`
- `MockServiceBuilder`
- `TestDataBuilder`
## Writing Tests
### Unit Test Example
```python
# tests/unit/test_auth.py
import pytest
from routstr.auth import create_api_key, validate_api_key
from routstr.algorithm import calculate_model_cost_score
from routstr.payment.models import Architecture, Model, Pricing
class TestAPIKeyAuth:
"""Test API key authentication functionality"""
async def test_create_api_key(self, test_db):
"""Test creating a new API key"""
# Arrange
initial_balance = 10000
# Act
api_key = await create_api_key(
balance=initial_balance,
name="Test Key"
)
# Assert
assert api_key.key.startswith("sk-")
assert len(api_key.key) == 32
assert api_key.balance == initial_balance
async def test_validate_invalid_key(self, test_db):
"""Test validation fails for invalid key"""
# Act & Assert
with pytest.raises(InvalidAPIKeyError):
await validate_api_key("invalid_key")
def test_calculate_model_cost_score_basic() -> None:
model = Model(
id="test-model",
name="Test test-model",
created=1234567890,
description="Test model",
context_length=8192,
architecture=Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="gpt",
instruct_type=None,
),
pricing=Pricing(
prompt=0.001,
completion=0.002,
request=0.0,
image=0.0,
web_search=0.0,
internal_reasoning=0.0,
),
)
assert calculate_model_cost_score(model) == 0.002
```
### Integration Test Example
```python
# tests/integration/test_payment_flow.py
import pytest
from httpx import AsyncClient
class TestPaymentFlow:
"""Test end-to-end payment flows"""
@pytest.mark.asyncio
async def test_token_redemption_flow(
self,
app_client: AsyncClient,
mock_cashu_wallet
):
"""Test complete token redemption and API usage"""
# Arrange
token = create_test_token(amount=5000)
mock_cashu_wallet.redeem.return_value = 5000
# Act - Create API key
response = await app_client.post(
"/v1/wallet/create",
json={"cashu_token": token}
)
# Assert - Key created
assert response.status_code == 200
api_key = response.json()["api_key"]
assert response.json()["balance"] == 5000
# Act - Use API key
response = await app_client.post(
"/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json={
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "Hi"}]
}
)
# Assert - Request successful
assert response.status_code == 200
assert "choices" in response.json()
```
## Test Fixtures
### Common Fixtures
Located in `tests/conftest.py`:
```python
@pytest.fixture
async def test_db():
"""Provide a clean test database"""
# Create in-memory SQLite database
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async_session = sessionmaker(engine, class_=AsyncSession)
async with async_session() as session:
yield session
await engine.dispose()
@pytest.fixture
async def app_client(test_db):
"""Provide test client with test database"""
app.dependency_overrides[get_db] = lambda: test_db
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
@pytest.fixture
def mock_cashu_wallet(mocker):
"""Mock Cashu wallet for testing"""
mock = mocker.patch("routstr.wallet.Wallet")
mock.return_value.redeem.return_value = 1000
return mock
```
### Using Fixtures
```python
async def test_with_fixtures(
test_db, # Get test database
app_client, # Get test HTTP client
mock_cashu_wallet # Get mocked wallet
):
# Use fixtures in test
pass
```
## Mocking Strategies
### Mocking External Services
```python
# Mock upstream API
@pytest.fixture
def mock_openai(mocker):
mock_response = mocker.Mock()
mock_response.json.return_value = {
"choices": [{
"message": {"content": "Hello!"},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30
}
}
mocker.patch(
"httpx.AsyncClient.post",
return_value=mock_response
)
# Mock Cashu mint
@pytest.fixture
def mock_mint(mocker):
mint = mocker.patch("routstr.wallet.Mint")
mint.return_value.check_proof_state.return_value = True
return mint
```
### Mocking Time
```python
from freezegun import freeze_time
@freeze_time("2024-01-01 12:00:00")
async def test_time_dependent():
# Time is frozen during test
key = await create_api_key(expires_in_days=30)
assert key.expires_at == datetime(2024, 1, 31, 12, 0, 0)
```
## Test Patterns
### Testing Async Code
```python
# Always mark async tests
@pytest.mark.integration
@pytest.mark.asyncio
async def test_async_function():
result = await async_operation()
assert result == expected
# Test async context managers
async def test_async_context():
async with create_resource() as resource:
assert resource.is_active
async def test_wallet_topup(
authenticated_client: AsyncClient,
testmint_wallet: object,
db_snapshot: object,
) -> None:
await db_snapshot.capture()
token = await testmint_wallet.mint_tokens(1000)
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
assert response.status_code == 200
diff = await db_snapshot.diff()
assert len(diff["api_keys"]["modified"]) == 1
```
### Testing Exceptions
```python
# Test specific exception
async def test_raises_specific_error():
with pytest.raises(InsufficientBalanceError) as exc_info:
await deduct_balance(api_key, amount=999999)
assert "Insufficient balance" in str(exc_info.value)
assert exc_info.value.status_code == 402
# Test exception details
async def test_exception_details():
with pytest.raises(RoustrError) as exc_info:
await risky_operation()
error = exc_info.value
assert error.error_type == "validation_error"
assert error.detail == "Invalid input"
```
### Testing Streaming Responses
```python
async def test_streaming_response():
"""Test streaming chat completion"""
chunks = []
async with app_client.stream(
"POST",
"/v1/chat/completions",
json={"model": "gpt-3.5-turbo", "stream": True}
) as response:
async for line in response.aiter_lines():
if line.startswith("data: "):
chunks.append(json.loads(line[6:]))
# Verify chunks
assert len(chunks) > 0
assert chunks[-1] == "[DONE]"
```
### Database Testing
```python
async def test_database_transaction(test_db):
"""Test atomic transactions"""
async with test_db.begin():
# Create test data
api_key = APIKey(key_hash="test", balance=1000)
test_db.add(api_key)
await test_db.flush()
# Test rollback
try:
async with test_db.begin_nested():
api_key.balance = -100 # Invalid
await test_db.flush()
raise ValueError("Rollback")
except ValueError:
pass
# Verify rollback worked
await test_db.refresh(api_key)
assert api_key.balance == 1000
```
## Performance Testing
### Benchmark Tests
```python
@pytest.mark.benchmark
def test_performance(benchmark):
"""Benchmark critical functions"""
result = benchmark(expensive_function, arg1, arg2)
assert result == expected
# Run benchmarks
pytest --benchmark-only
```
### Load Testing
```python
# tests/integration/test_load.py
async def test_concurrent_requests(app_client):
"""Test handling multiple concurrent requests"""
async def make_request(i):
response = await app_client.get(f"/test/{i}")
return response.status_code
# Make 100 concurrent requests
tasks = [make_request(i) for i in range(100)]
results = await asyncio.gather(*tasks)
# All should succeed
assert all(status == 200 for status in results)
```
## Test Data
### Factories
```python
# tests/factories.py
from datetime import datetime, timedelta
def create_test_api_key(**kwargs):
"""Factory for test API keys"""
defaults = {
"key": f"sk-test-{uuid4().hex[:8]}",
"balance": 10000,
"created_at": datetime.utcnow(),
"expires_at": datetime.utcnow() + timedelta(days=30)
}
defaults.update(kwargs)
return APIKey(**defaults)
def create_test_token(amount: int = 1000, mint: str = None):
"""Factory for test Cashu tokens"""
# Create valid test token structure
return base64.encode(...)
```
### Test Constants
```python
# tests/constants.py
TEST_MODELS = {
"gpt-3.5-turbo": {
"prompt": 0.0015,
"completion": 0.002
},
"gpt-4": {
"prompt": 0.03,
"completion": 0.06
}
}
TEST_API_KEY = "sk-test-1234567890"
TEST_MINT_URL = "https://testmint.example.com"
```
## Coverage
### Running Coverage
## Debugging Tips
```bash
# Generate coverage report
make test-coverage
# Show print output
pytest -s tests/unit/test_wallet.py
# View HTML report
open htmlcov/index.html
# Coverage with specific tests
pytest --cov=routstr --cov-report=html tests/unit/
```
### Coverage Configuration
In `pyproject.toml`:
```toml
[tool.coverage.run]
source = ["routstr"]
omit = ["tests/*", "*/migrations/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
"if TYPE_CHECKING:"
]
```
## Debugging Tests
### Print Debugging
```python
# Use -s flag to see print output
pytest -s tests/unit/test_auth.py
# In test
async def test_debug():
print(f"Value: {value}") # Will show with -s
assert value == expected
```
### Interactive Debugging
```python
# Drop into debugger on failure
pytest --pdb
# Set breakpoint in test
async def test_debug():
import pdb; pdb.set_trace()
# Execution stops here
```
### Logging in Tests
```python
# Enable debug logging in tests
import logging
logging.basicConfig(level=logging.DEBUG)
# Or use caplog fixture
async def test_logging(caplog):
with caplog.at_level(logging.INFO):
await function_that_logs()
assert "Expected message" in caplog.text
```
## CI/CD Integration
### GitHub Actions
Tests run automatically on:
- Pull requests
- Pushes to main
- Nightly schedules
See `.github/workflows/test.yml` for configuration.
### Pre-commit Hooks
Install pre-commit hooks:
```bash
pre-commit install
# Run manually
pre-commit run --all-files
```
## Best Practices
### Do's
1. ✅ Write tests first (TDD)
2. ✅ Keep tests simple and focused
3. ✅ Use descriptive test names
4. ✅ Test edge cases
5. ✅ Mock external dependencies
6. ✅ Use fixtures for setup
7. ✅ Assert specific values
### Don'ts
1. ❌ Don't test implementation details
2. ❌ Don't use production services
3. ❌ Don't rely on test order
4. ❌ Don't ignore flaky tests
5. ❌ Don't skip error cases
6. ❌ Don't use hard-coded waits
7. ❌ Don't commit commented tests
## Troubleshooting
### Common Issues
**Async Test Errors**
```python
# Wrong
def test_async(): # Missing async
await function()
# Right
async def test_async():
await function()
```
**Database State**
```python
# Ensure clean state
@pytest.fixture(autouse=True)
async def cleanup(test_db):
yield
# Cleanup after each test
await test_db.execute("DELETE FROM apikey")
await test_db.commit()
```
**Mock Not Working**
```python
# Check import path
mocker.patch("routstr.wallet.Wallet") # Full path
# Not just "Wallet"
```
- Docker mode failures: check `docker ps` and `docker-compose -f compose.testing.yml logs`
- Connection errors: make sure ports 3338, 3000, 8000, and 8088 are free
- Slow tests: use `pytest -m "not slow"` or `make test-fast`
## Next Steps
- See [Architecture](architecture.md) for system design
- Read [Setup Guide](setup.md) for environment setup
- See [Architecture](architecture.md)
- Read [Setup Guide](setup.md)

View File

@@ -1,260 +0,0 @@
# Configuration
Routstr Core is configured via a single settings row in the database. Environment variables are only used on first run to seed that row (with a few computed defaults like `ONION_URL`). After that, the database is the source of truth. You can update settings at runtime via the admin API. `DATABASE_URL` is always env-only.
## Environment Variables
### Core Settings
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `UPSTREAM_BASE_URL` | Base URL of the OpenAI-compatible API to proxy | - | ✅ |
| `UPSTREAM_API_KEY` | API key for the upstream service | - | ❌ |
| `ADMIN_PASSWORD` | Password for admin dashboard access | - | ⚠️ |
### Node Information
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `NAME` | Public name of your Routstr node | `ARoutstrNode` | ❌ |
| `DESCRIPTION` | Description of your node | `A Routstr Node` | ❌ |
| `NPUB` | Nostr public key for node identity | - | ❌ |
| `HTTP_URL` | Public HTTP URL of your node | - | ❌ |
| `ONION_URL` | Tor hidden service URL | - | ❌ |
### Cashu Configuration
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `CASHU_MINTS` | Comma-separated list of trusted Cashu mint URLs | `https://mint.minibits.cash/Bitcoin` | ❌ |
| `RECEIVE_LN_ADDRESS` | Lightning address for automatic payouts | - | ❌ |
### Pricing Configuration
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `FIXED_PRICING` | Force fixed per-request pricing (ignore model token pricing) | `false` | ❌ |
| `FIXED_COST_PER_REQUEST` | Fixed cost per API request in sats | `1` | ❌ |
| `FIXED_PER_1K_INPUT_TOKENS` | Optional override: sats per 1000 input tokens | `0` | ❌ |
| `FIXED_PER_1K_OUTPUT_TOKENS` | Optional override: sats per 1000 output tokens | `0` | ❌ |
| `EXCHANGE_FEE` | Exchange rate markup (1.005 = 0.5% fee) | `1.005` | ❌ |
| `UPSTREAM_PROVIDER_FEE` | Provider fee markup (1.05 = 5% fee) | `1.05` | ❌ |
### Network & Discovery
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `CORS_ORIGINS` | Comma-separated list of allowed CORS origins | `*` | ❌ |
| `TOR_PROXY_URL` | SOCKS5 proxy URL for Tor connections | `socks5://127.0.0.1:9050` | ❌ |
| `RELAYS` | Comma-separated nostr relays used for provider discovery | sane defaults | ❌ |
| `PROVIDERS_REFRESH_INTERVAL_SECONDS` | Provider cache refresh interval | `300` | ❌ |
### Logging Configuration
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `LOG_LEVEL` | Logging level (DEBUG, INFO, WARNING, ERROR) | `INFO` | ❌ |
| `ENABLE_CONSOLE_LOGGING` | Enable console log output | `true` | ❌ |
### Other
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `CHAT_COMPLETIONS_API_VERSION` | Append `api-version` to `/chat/completions` (Azure OpenAI) | - | ❌ |
| `DATABASE_URL` | SQLite database connection string | `sqlite+aiosqlite:///keys.db` | ❌ |
| `REFUND_CACHE_TTL_SECONDS` | Cache TTL for refund responses (seconds) | `3600` | ❌ |
## Configuration Examples
### Basic OpenAI Proxy
```bash
# .env
UPSTREAM_BASE_URL=https://api.openai.com/v1
UPSTREAM_API_KEY=sk-...
ADMIN_PASSWORD=my-secure-password
```
### Custom AI Provider
```bash
# .env
UPSTREAM_BASE_URL=https://api.anthropic.com/v1
UPSTREAM_API_KEY=your-anthropic-key
MODELS_PATH=/app/config/anthropic-models.json
```
### Azure OpenAI (optional)
```bash
# .env
UPSTREAM_BASE_URL=https://<resource>.openai.azure.com/openai/deployments/<deployment>
UPSTREAM_API_KEY=<azure_api_key>
CHAT_COMPLETIONS_API_VERSION=2024-05-01-preview
```
### High-Security Setup
```bash
# .env
UPSTREAM_BASE_URL=https://api.openai.com/v1
UPSTREAM_API_KEY=sk-...
ADMIN_PASSWORD=very-long-secure-password-here
CORS_ORIGINS=https://myapp.com,https://app.myapp.com
TOR_PROXY_URL=socks5://tor:9050
LOG_LEVEL=WARNING
```
### Public Node Configuration
```bash
# .env
NAME=Lightning AI Gateway
DESCRIPTION=Fast and reliable AI API access with Bitcoin payments
NPUB=npub1abcd...
HTTP_URL=https://api.lightning-ai.com
ONION_URL=http://lightningai.onion
CASHU_MINTS=https://mint1.com,https://mint2.com
```
## Pricing
- Default: pricing comes from your `models.json`.
- Force fixed per-request pricing: set `FIXED_PRICING=true` and `FIXED_COST_PER_REQUEST`.
- Optional token overrides when using model pricing: set
`FIXED_PER_1K_INPUT_TOKENS` and/or `FIXED_PER_1K_OUTPUT_TOKENS`.
- Legacy envs are still accepted and mapped automatically:
`MODEL_BASED_PRICING``!FIXED_PRICING`, `COST_PER_REQUEST``FIXED_COST_PER_REQUEST`,
`COST_PER_1K_*``FIXED_PER_1K_*`.
Example fixed pricing:
```bash
FIXED_PRICING=true
FIXED_COST_PER_REQUEST=10
```
## Custom Models Configuration
Create a `models.json` file:
```json
{
"models": [
{
"id": "gpt-4",
"name": "GPT-4",
"pricing": {
"prompt": "0.00003",
"completion": "0.00006",
"request": "0"
}
},
{
"id": "gpt-3.5-turbo",
"name": "GPT-3.5 Turbo",
"pricing": {
"prompt": "0.0000015",
"completion": "0.000002",
"request": "0"
}
}
]
}
```
## Security Best Practices
### Admin Password
Generate a strong password:
```bash
openssl rand -base64 32
```
### API Keys
- Rotate upstream API keys regularly
- Use read-only keys when possible
- Monitor key usage
### Network Security
- Restrict CORS origins in production
- Use HTTPS for public endpoints
- Enable Tor for anonymity
### Database Security
- Regular backups
- Encrypted storage volumes
- Restricted file permissions
## Troubleshooting
### Check Current Configuration
```bash
# View all environment variables
docker exec routstr env | sort
# Test configuration
curl http://localhost:8000/v1/info
```
### Common Issues
**Missing Upstream URL**
```
ERROR: UPSTREAM_BASE_URL not set
Solution: Set UPSTREAM_BASE_URL in .env
```
**Invalid Cashu Mint**
```
ERROR: Failed to connect to mint
Solution: Verify CASHU_MINTS URLs are accessible
```
**Database Errors**
```
ERROR: Database connection failed
Solution: Check DATABASE_URL and file permissions
```
## Advanced Configuration
### Multiple Mints
Configure fallback mints:
```bash
CASHU_MINTS=https://primary.mint,https://backup1.mint,https://backup2.mint
```
### Custom Database
Use PostgreSQL instead of SQLite:
```bash
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/routstr
```
### Proxy Settings
For corporate environments:
```bash
HTTP_PROXY=http://proxy.company.com:8080
HTTPS_PROXY=http://proxy.company.com:8080
```
## Next Steps
- [User Guide](../user-guide/introduction.md) - Start using Routstr
- [Admin Dashboard](../user-guide/admin-dashboard.md) - Manage your node
- [Custom Pricing](../advanced/custom-pricing.md) - Advanced pricing strategies

View File

@@ -1,337 +0,0 @@
# Docker Setup
This guide covers deploying Routstr Core using Docker for production environments.
## Docker Images
Official images are available on GitHub Container Registry:
```bash
ghcr.io/routstr/proxy:latest
```
## Basic Docker Run
### Minimal Setup
```bash
docker run -d \
--name routstr \
-p 8000:8000 \
-e UPSTREAM_BASE_URL=https://api.openai.com/v1 \
-e UPSTREAM_API_KEY=sk-... \
-e ADMIN_PASSWORD=secure-password \
ghcr.io/routstr/proxy:latest
```
### With Persistent Storage
```bash
docker run -d \
--name routstr \
-p 8000:8000 \
-v routstr-data:/app/data \
-v routstr-logs:/app/logs \
-e UPSTREAM_BASE_URL=https://api.openai.com/v1 \
-e UPSTREAM_API_KEY=sk-... \
-e DATABASE_URL=sqlite+aiosqlite:///data/keys.db \
ghcr.io/routstr/proxy:latest
```
## Docker Compose Setup
### Basic Configuration
Create `compose.yml`:
```yaml
version: '3.8'
services:
routstr:
image: ghcr.io/routstr/proxy:latest
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- ./logs:/app/logs
env_file:
- .env
restart: unless-stopped
```
### With Tor Hidden Service
The included `compose.yml` provides Tor support:
```yaml
version: '3.8'
services:
routstr:
build: . # Or use image: ghcr.io/routstr/proxy:latest
volumes:
- .:/app
- ./logs:/app/logs
env_file:
- .env
environment:
- TOR_PROXY_URL=socks5://tor:9050
ports:
- 8000:8000
extra_hosts:
- "host.docker.internal:host-gateway"
tor:
image: ghcr.io/hundehausen/tor-hidden-service:latest
volumes:
- tor-data:/var/lib/tor
environment:
- HS_ROUTER=routstr:8000:80
depends_on:
- routstr
volumes:
tor-data:
```
### Environment File
Create `.env` file:
```bash
# Required
UPSTREAM_BASE_URL=https://api.openai.com/v1
UPSTREAM_API_KEY=your-api-key
ADMIN_PASSWORD=secure-admin-password
# Cashu Configuration
CASHU_MINTS=https://mint.minibits.cash/Bitcoin
# Optional
NAME=My Routstr Node
DESCRIPTION=Pay-per-use AI API proxy
NPUB=npub1...
HTTP_URL=https://api.mynode.com
ONION_URL=http://mynode.onion
# Pricing (optional)
FIXED_PRICING=false
EXCHANGE_FEE=1.005
UPSTREAM_PROVIDER_FEE=1.05
```
## Building Custom Image
### Dockerfile Overview
The provided Dockerfile:
- Uses Alpine Linux for small size
- Installs required dependencies for secp256k1
- Runs as non-root user
- Exposes port 8000
### Build Locally
```bash
# Clone repository
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
# Build image
docker build -t my-routstr:latest .
# Run custom image
docker run -d \
--name routstr \
-p 8000:8000 \
--env-file .env \
my-routstr:latest
```
## Deployment Considerations
### Resource Requirements
- **CPU**: 1-2 cores recommended
- **Memory**: 512MB-1GB
- **Storage**: 1GB + database growth
- **Network**: Low latency to upstream provider
### Health Checks
Add health check to compose.yml:
```yaml
services:
routstr:
# ... other config ...
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/v1/info"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
### Reverse Proxy Setup
#### Nginx Example
```nginx
server {
listen 443 ssl http2;
server_name api.yournode.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# For streaming responses
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
}
}
```
#### Caddy Example
```caddy
api.yournode.com {
reverse_proxy localhost:8000 {
flush_interval -1
}
}
```
## Monitoring
### Log Management
View logs:
```bash
# Docker
docker logs -f routstr
# Docker Compose
docker compose logs -f routstr
# Log files
tail -f ./logs/routstr.log
```
### Metrics
Monitor key metrics:
- Request count and latency
- Token validation success rate
- Upstream API errors
- Database size growth
## Backup and Recovery
### Database Backup
```bash
# Backup SQLite database
docker exec routstr sqlite3 /app/data/keys.db ".backup /app/data/backup.db"
# Copy backup locally
docker cp routstr:/app/data/backup.db ./backup-$(date +%Y%m%d).db
```
### Restore from Backup
```bash
# Stop service
docker compose down
# Restore database
docker cp ./backup.db routstr:/app/data/keys.db
# Restart service
docker compose up -d
```
## Security Considerations
### Environment Variables
- Never commit `.env` files
- Use Docker secrets for sensitive data
- Rotate API keys regularly
- Use strong admin passwords
### Network Security
- Use HTTPS/TLS termination
- Restrict admin interface access
- Enable firewall rules
- Monitor for suspicious activity
### Container Security
- Run as non-root user
- Use read-only filesystem where possible
- Limit container capabilities
- Keep base image updated
## Troubleshooting
### Container Won't Start
```bash
# Check logs
docker logs routstr
# Verify environment
docker exec routstr env | grep -E "(UPSTREAM|CASHU|ADMIN)"
# Test database connection
docker exec routstr sqlite3 /app/data/keys.db ".tables"
```
### Permission Issues
```bash
# Fix volume permissions
sudo chown -R 1000:1000 ./data ./logs
```
### Network Issues
```bash
# Test upstream connectivity
docker exec routstr curl -I https://api.openai.com
# Check DNS resolution
docker exec routstr nslookup api.openai.com
```
## Production Checklist
- [ ] Set strong `ADMIN_PASSWORD`
- [ ] Configure proper `UPSTREAM_BASE_URL` and `UPSTREAM_API_KEY`
- [ ] Set up persistent volumes for data and logs
- [ ] Configure reverse proxy with TLS
- [ ] Set up monitoring and alerting
- [ ] Implement backup strategy
- [ ] Test disaster recovery
- [ ] Document deployment process
## Next Steps
- [Configuration Guide](configuration.md) - All environment variables
- [Admin Dashboard](../user-guide/admin-dashboard.md) - Manage your node

View File

@@ -1,156 +0,0 @@
# Overview
Routstr Core is a powerful payment proxy that brings Bitcoin micropayments to AI APIs. This overview will help you understand the core concepts and architecture.
## Core Concepts
### Payment Proxy
Routstr acts as a transparent proxy between your application and OpenAI-compatible APIs. It:
- Intercepts API requests
- Validates payment tokens
- Forwards requests to the upstream provider
- Tracks usage and deducts costs
- Returns responses to the client
### Cashu eCash Protocol
[Cashu](https://cashu.space) is a Bitcoin eCash protocol that enables:
- **Privacy**: Payments are unlinkable and untraceable
- **Instant Settlement**: No waiting for blockchain confirmations
- **Micropayments**: Send fractions of a satoshi
- **Offline Capability**: Tokens can be transferred without internet
### Lightning Network Integration
Routstr connects to the Lightning Network through Cashu mints, enabling:
- Fast Bitcoin deposits and withdrawals
- Global payment reach
- Low transaction fees
- No minimum payment amounts
## Architecture
### System Components
```mermaid
graph TB
subgraph "Client Side"
A[AI Application]
B[OpenAI SDK]
C[eCash Wallet]
end
subgraph "Routstr Core"
D[FastAPI Server]
E[Auth Module]
F[Payment Module]
G[Proxy Module]
H[SQLite Database]
end
subgraph "External Services"
I[Upstream AI Provider]
J[Cashu Mint]
K[Bitcoin/Lightning]
end
A --> B
B --> D
C --> D
D --> E
E --> F
F --> J
D --> G
G --> I
D --> H
J --> K
```
### Key Modules
1. **Authentication** (`auth.py`)
- API key validation
- Balance checking
- Request authorization
2. **Payment Processing** (`payment/`)
- Token validation
- Cost calculation
- Balance updates
- Pricing models
3. **Proxy Handler** (`proxy.py`)
- Request forwarding
- Response streaming
- Usage tracking
- Error handling
4. **Wallet Management** (`wallet.py`)
- Cashu wallet integration
- Token redemption
- Balance management
- Automatic payouts
5. **Admin Interface** (`core/admin.py`)
- Web dashboard
- Balance viewing
- Key management
- Withdrawal interface
## Payment Flow
### Standard Flow (API Key)
1. User deposits eCash tokens to create an API key
2. Client sends requests with the API key
3. Routstr checks balance and forwards request
4. Cost is deducted based on actual usage
5. Response is returned to client
### Per-Request Flow (Coming Soon)
1. Client includes eCash token in request header
2. Routstr validates token meets minimum amount
3. Request is processed
4. Change is returned in response header
5. No account or balance needed
## Supported Features
### API Compatibility
- ✅ Chat completions (streaming and non-streaming)
- ✅ Text completions
- ✅ Embeddings
- ✅ Image generation
- ✅ Audio transcription/translation
- ✅ Model listing
- ✅ Custom endpoints
### Payment Features
- ✅ Multiple Cashu mint support
- ✅ Automatic balance tracking
- ✅ Model-based pricing
- ✅ USD to BTC conversion
- ✅ Configurable fees
- ✅ Balance withdrawals
### Operational Features
- ✅ Docker deployment
- ✅ Tor hidden service support
- ✅ Nostr relay discovery
- ✅ Database migrations
- ✅ Comprehensive logging
- ✅ Admin dashboard
## Next Steps
- [Quick Start](quickstart.md) - Get running in minutes
- [Docker Setup](docker.md) - Deploy with containers
- [Configuration](configuration.md) - Customize your instance

View File

@@ -1,226 +0,0 @@
# Quick Start
Get Routstr Core up and running in minutes with Docker or local development setup.
## Prerequisites
- Docker and Docker Compose (for production)
- Python 3.11+ (for development)
- A Cashu-compatible wallet (optional for testing)
## Option 1: Docker (Recommended)
### Quick Run
The fastest way to start Routstr Core:
```bash
docker run -d \
--name routstr-proxy \
-p 8000:8000 \
-e UPSTREAM_BASE_URL=https://api.openai.com/v1 \
-e UPSTREAM_API_KEY=your-openai-api-key \
ghcr.io/routstr/proxy:latest
```
### Docker Compose
For a full setup with Tor support:
1. Clone the repository:
```bash
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
```
2. Create environment file:
```bash
cp .env.example .env
# Edit .env with your settings
```
3. Start the services:
```bash
docker compose up -d
```
This will start:
- Routstr proxy on port 8000
- Tor hidden service (optional)
- Automatic database migrations
### Verify Installation
Check that Routstr is running:
```bash
curl http://localhost:8000/v1/info
```
You should see:
```json
{
"name": "ARoutstrNode",
"description": "A Routstr Node",
"version": "0.2.0",
"npub": "",
"mints": ["https://mint.minibits.cash/Bitcoin"],
"models": {...}
}
```
## Option 2: Local Development
### Install Dependencies
1. Install [uv](https://github.com/astral-sh/uv) package manager:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
2. Clone and setup:
```bash
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
uv sync
```
3. Configure environment:
```bash
cp .env.example .env
# Edit .env with your settings
```
### Run the Server
```bash
fastapi run routstr --host 0.0.0.0 --port 8000
```
## First API Call
### 1. Get an eCash Token
You'll need a Cashu token to pay for API calls. Options:
- Use a [Cashu wallet](https://cashu.space) to create tokens
- Get test tokens from a testnet mint
- Use the example token (for testing only)
### 2. Create an API Key
Send your eCash token to create an API key:
```bash
curl -X POST http://localhost:8000/v1/wallet/create \
-H "Content-Type: application/json" \
-d '{
"cashu_token": "cashuAeyJ0b2..."
}'
```
Response:
```json
{
"api_key": "rUvK7...",
"balance": 10000
}
```
### 3. Make an API Call
Use your API key like a normal OpenAI key:
```python
import openai
client = openai.OpenAI(
api_key="rUvK7...", # Your Routstr API key
base_url="http://localhost:8000/v1"
)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Hello!"}]
)
print(response.choices[0].message.content)
```
## Example Client
Run the included example:
```bash
CASHU_TOKEN="your-token" python example.py
```
This demonstrates:
- Creating an API key from a token
- Making streaming chat requests
- Automatic balance deduction
## Testing the Setup
### Check Available Models
```bash
curl http://localhost:8000/v1/models
```
### View Admin Dashboard
Open <http://localhost:8000/admin/> in your browser.
Default password is set in `ADMIN_PASSWORD` environment variable.
### Monitor Logs
Docker:
```bash
docker compose logs -f routstr
```
Local:
```bash
# Logs are in ./logs/ directory
tail -f logs/routstr.log
```
## Common Issues
### Connection Refused
- Ensure the service is running: `docker ps`
- Check firewall settings
- Verify port 8000 is not in use
### Invalid API Key
- Ensure you've created an API key with sufficient balance
- Check the token was valid and had value
- Verify the mint URL is accessible
### Upstream Errors
- Check `UPSTREAM_BASE_URL` is correct
- Verify `UPSTREAM_API_KEY` if required
- Test upstream service directly
## Next Steps
- [Configuration Guide](configuration.md) - Customize settings
- [Docker Setup](docker.md) - Production deployment
- [User Guide](../user-guide/introduction.md) - Detailed usage

View File

@@ -1,53 +0,0 @@
# UI Configuration
This guide explains how to configure the Routstr UI for different environments.
## Environment Variables
The UI uses Next.js environment variables to configure API endpoints and authentication.
### Centralized Configuration
This project uses a centralized configuration approach with a single `.env` file in the project root. This file contains both backend and frontend configuration variables.
Create or update your `.env` file in the project root:
```bash
# .env (in project root)
# UI Configuration (NEXT_PUBLIC_ variables are exposed to the browser)
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
```
### Development vs Production
The same `.env` file is used for both development and production. Simply change the values:
**Development:**
```bash
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
```
**Production:**
```bash
NEXT_PUBLIC_API_URL=https://api.yourroutstr.com
```
## Building the UI
The build process automatically reads configuration from the root `.env` file:
```bash
# From the project root
make ui-build
# or
./scripts/build-ui.sh
```
The build script will automatically:
- Load `NEXT_PUBLIC_*` variables from the root `.env` file
- Use them during the Next.js build process
- Display warnings if the `.env` file is missing

View File

@@ -1,83 +1,44 @@
# Routstr Core Documentation
Welcome to the official documentation for **Routstr Core** - a FastAPI-based reverse proxy that enables Bitcoin micropayments for OpenAI-compatible APIs using the Cashu eCash protocol.
**Routstr** is a decentralized protocol for permissionless AI inference. It enables an open marketplace where anyone can buy and sell compute using **Bitcoin eCash (Cashu)**.
## What is Routstr Core?
---
Routstr Core is a payment proxy that sits between API clients and OpenAI-compatible services. It enables:
## 🐣 For Clients (Users & Builders)
- **Pay-per-request billing** using Bitcoin eCash tokens
- **Seamless integration** with existing OpenAI clients
- **Privacy-preserving payments** through the Cashu protocol
- **Flexible pricing models** with per-token or per-request billing
- **Multi-provider support** for various AI model providers
If you want to use AI models in your application without accounts or KYC.
### Key Features
- **[Introduction](client/introduction.md)**: How the ecosystem works.
- **[Payment Flow](client/payments.md)**: Funding sessions, topping up, and refunds.
- **[Integration Guide](client/integration.md)**: Code examples for Python, JS, and cURL.
- 🪙 **Cashu Wallet Integration** - Accept Lightning payments and redeem eCash tokens
- 🔑 **API Key Management** - Secure key storage with balance tracking
- 💰 **Dynamic Pricing** - Model-based pricing with live BTC/USD conversion
- 🎛️ **Admin Dashboard** - Web interface for balance and key management
- 🌐 **Nostr Discovery** - Find providers through decentralized relay network
- 🐋 **Docker Support** - Easy deployment with optional Tor hidden service
-**Lightning Fast** - Minimal latency overhead for API requests
## 🦁 For Providers (Node Operators)
## How It Works
If you want to run a node, resell API access, or monetize hardware.
```mermaid
sequenceDiagram
participant Client
participant Routstr as Routstr Proxy
participant DB as Database
participant Upstream as AI Provider
participant Wallet as Cashu Wallet
- **[Quick Start](provider/quickstart.md)**: Deploy a node in 5 minutes.
- **[Deployment](provider/deployment.md)**: Production Docker setup.
- **[Configuration](provider/configuration.md)**: Environment variables and settings.
- **[Dashboard](provider/dashboard.md)**: Managing your node visually.
- **[Pricing Strategy](provider/pricing.md)**: Setting margins and fees.
- **[Discovery](provider/discovery.md)**: Announcing your node on Nostr.
- **[Tor Support](provider/tor.md)**: Running an anonymous hidden service.
Client->>Routstr: API Request + eCash Token
Routstr->>Wallet: Validate & Redeem Token
Wallet-->>Routstr: Token Value (sats)
Routstr->>DB: Store/Update Balance
Routstr->>Upstream: Forward API Request
Upstream-->>Routstr: API Response + Usage Data
Routstr->>DB: Deduct Actual Cost
Routstr-->>Client: API Response
```
---
## Quick Links
## 🔌 API Reference
<div class="grid cards" markdown>
- **[Overview](api/overview.md)**: Base URL, headers, and standards.
- **[Endpoints](api/endpoints.md)**: Full list of REST endpoints.
- **[Authentication](api/authentication.md)**: Handling API keys and tokens.
- **[Errors](api/errors.md)**: Status codes and debugging.
- :rocket: **[Quick Start](getting-started/quickstart.md)**
## 🛠️ Contributing
Get up and running with Docker in minutes
- **[Architecture](contributing/architecture.md)**: System design.
- **[Setup](contributing/setup.md)**: Development environment.
- **[Testing](contributing/testing.md)**: Running tests.
- :gear: **[Configuration](getting-started/configuration.md)**
---
Learn about environment variables and settings
- :book: **[User Guide](user-guide/introduction.md)**
Comprehensive guide for using Routstr
- :hammer: **[Contributing](contributing/setup.md)**
Help improve Routstr Core
</div>
## Use Cases
- **AI Application Developers** - Add Bitcoin payments to your AI apps without managing infrastructure
- **API Resellers** - Resell API access with custom pricing and profit margins
- **Privacy-Focused Users** - Access AI models without revealing personal information
- **Micropayment Experiments** - Test new business models with instant, small payments
## Getting Help
- 📖 Browse the [User Guide](user-guide/introduction.md) for detailed usage instructions
- 🐛 Report issues on [GitHub](https://github.com/routstr/routstr-core/issues)
- 💬 Join the community discussions
- 🔧 Check the [API Reference](api/overview.md) for technical details
## License
Routstr Core is open source software licensed under the GPLv3. See the [LICENSE](https://github.com/routstr/routstr-core/blob/main/LICENSE) file for details.
*Powered by [Cashu](https://cashu.space) and [Nostr](https://nostr.com).*

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

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

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

@@ -0,0 +1,203 @@
# Deployment
Production deployment guide for Routstr Provider nodes.
## Docker Compose (Recommended)
For production, use Docker Compose with persistent storage and optional Tor support.
### Unified Setup (All-in-one)
To build and run the node with the UI integrated in a single container using the multi-stage build:
```bash
docker build -f Dockerfile.full -t routstr-full .
docker run -d -p 8000:8000 --env-file .env routstr-full
```
### Advanced Setup (Separated UI & Node)
Use the included `compose.yml` for a more flexible setup that separates the UI build process from the node execution. This is useful for development or when you want to manage Tor as a separate service.
```bash
docker compose up -d
```
This will:
1. **Build the UI**: Compiles the frontend and copies it to a shared volume.
2. **Start Routstr**: Runs the Python node, mounting the built UI.
3. **Start Tor**: Provides anonymous access via a `.onion` address.
---
## With Tor (Anonymous Access)
Add Tor to serve your node as a hidden service—no port forwarding needed.
```yaml
services:
routstr:
image: ghcr.io/routstr/proxy:latest
container_name: routstr
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- ./logs:/app/logs
environment:
- TOR_PROXY_URL=socks5://tor:9050
depends_on:
- tor
tor:
image: ghcr.io/hundehausen/tor-hidden-service:latest
container_name: tor
restart: unless-stopped
volumes:
- ./tor-data:/var/lib/tor
environment:
- HS_ROUTER=routstr:8000:80
```
After starting, find your `.onion` address:
```bash
docker exec tor cat /var/lib/tor/hidden_service/hostname
```
See [Tor Support](tor.md) for details.
---
## Pre-Configuration (Optional)
While everything can be configured via the dashboard, you can pre-configure settings with environment variables for automated deployments.
### Using Environment Variables
```yaml
services:
routstr:
image: ghcr.io/routstr/proxy:latest
environment:
# Pre-configure upstream (optional)
- UPSTREAM_BASE_URL=https://api.openai.com/v1
- UPSTREAM_API_KEY=sk-proj-...
# Secure the dashboard (recommended)
- ADMIN_PASSWORD=your-secure-password
# Node identity
- NAME=My Provider Node
- DESCRIPTION=Fast GPT-4 access via Lightning
# Lightning withdrawals
- RECEIVE_LN_ADDRESS=me@walletofsatoshi.com
volumes:
- ./data:/app/data
```
### Using an .env File
```yaml
services:
routstr:
image: ghcr.io/routstr/proxy:latest
env_file:
- .env
volumes:
- ./data:/app/data
```
Example `.env`:
```bash
UPSTREAM_BASE_URL=https://api.openai.com/v1
UPSTREAM_API_KEY=sk-proj-...
ADMIN_PASSWORD=change-me
NAME=My Provider Node
RECEIVE_LN_ADDRESS=me@walletofsatoshi.com
```
See [Configuration](configuration.md) for all available options.
---
## Persistence
Routstr stores all data in `/app/data`:
| Path | Contents |
|------|----------|
| `keys.db` | SQLite database (settings, API keys, sessions) |
| `.wallet/` | Cashu wallet data (your Bitcoin!) |
!!! warning "Back Up Your Data"
The `./data` volume contains your wallet. Losing it means losing funds. Back up regularly.
---
## Reverse Proxy (Optional)
For custom domains and SSL, use a reverse proxy like Caddy or nginx.
### Caddy Example
```
api.yournode.com {
reverse_proxy localhost:8000
}
```
### nginx Example
```nginx
server {
listen 443 ssl;
server_name api.yournode.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
---
## Updates
Pull the latest image and restart:
```bash
docker compose pull
docker compose up -d
```
---
## Building from Source
### Unified Image (UI + Node)
The easiest way to build everything from source into a single production-ready image:
```bash
docker build -f Dockerfile.full -t routstr-full .
```
### Individual Components
If you prefer building them separately or using Docker Compose:
```bash
# Build using compose
docker compose build
# Or build the node only (requires manual UI build first)
docker build -t routstr-node .
```

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

136
docs/provider/quickstart.md Normal file
View File

@@ -0,0 +1,136 @@
# Quick Start
Start earning Bitcoin by selling AI access in under 5 minutes.
## What You'll Build
A **Routstr Provider Node** acts as a gateway that:
1. **Connects** to upstream AI providers (OpenAI, Anthropic, OpenRouter, etc.)
2. **Accepts** Bitcoin payments via Cashu eCash
3. **Serves** AI requests to clients on the network
You bring the API keys, Routstr handles the billing, payments, and client management.
!!! tip "Future: Node-to-Node Routing"
In future versions, you'll be able to run a node that connects to other Routstr nodes—eliminating the need to configure upstream providers yourself. For now, you'll need your own API credentials.
---
## Prerequisites
- [Docker](https://docs.docker.com/get-docker/) installed
- API credentials from at least one AI provider (OpenAI, Anthropic, OpenRouter, etc.)
---
## 1. Prepare Configuration
Create a `.env` file in the root of the project to store your secrets:
```bash
# Initial Admin Password
ADMIN_PASSWORD=mysecretpassword
# Node Identity
NAME="My AI Node"
DESCRIPTION="Fast access to models"
# Lightning Payouts
RECEIVE_LN_ADDRESS=yourname@wallet.com
```
## 2. Start the Node
You can run the pre-built image directly:
```bash
docker run -d \
--name routstr \
-p 8000:8000 \
--env-file .env \
-v routstr-data:/app/data \
ghcr.io/routstr/proxy:latest
```
*Note: The pre-built image does not contain the UI. For the all-in-one experience with the Admin Dashboard, use the Build from Source instructions below.*
### Build from Source (Recommended)
If you want to build the node and UI yourself from source, use the unified Dockerfile:
```bash
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
# Edit your .env with ADMIN_PASSWORD and API keys
cp .env.example .env
nano .env
docker build -f Dockerfile.full -t routstr-local .
docker run -d -p 8000:8000 --env-file .env --name routstr routstr-local
```
Verify it's running:
```bash
curl http://localhost:8000/v1/info
```
---
## 3. Configure via Dashboard
Open the **Admin Dashboard** at [http://localhost:8000/admin/](http://localhost:8000/admin/).
!!! note "Login"
Use the `ADMIN_PASSWORD` you defined in your `.env` file to log in. If you didn't set one, the dashboard will prompt you to set one on first visit.
### Connect Your AI Providers
1. Navigate to **Settings****Upstream**
2. Enter your upstream URL (e.g., `https://api.openai.com/v1`)
3. Enter your API key
4. Save
### Set Your Profit Margin
1. Go to **Settings****Pricing**
2. Configure your markup (default is 10%)
3. Optionally set a fixed price per request instead
### Secure the Dashboard
1. Go to **Settings****Admin**
2. Set a strong password
3. Save and re-login
---
## 3. Start Earning
Once configured, your node is live. Clients pay you in Bitcoin (via Cashu tokens) for every AI request.
### Monitor Your Earnings
The dashboard shows:
- **Total Wallet**: All Bitcoin held by your node
- **User Balances**: Funds belonging to active client sessions
- **Your Balance**: Your profit (`Total - User Balances`)
### Withdraw Profits
1. Go to **Withdraw** in the dashboard
2. Select amount and mint
3. Generate a Cashu token
4. Redeem to your Lightning wallet
---
## Next Steps
- **[Deployment](deployment.md)**: Production setup with Docker Compose and Tor
- **[Dashboard Guide](dashboard.md)**: Full reference for all dashboard features
- **[Pricing](pricing.md)**: Configure pricing strategies and per-model overrides
- **[Discovery](discovery.md)**: Announce your node on Nostr for clients to find you

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

View File

@@ -1,216 +0,0 @@
# Admin Dashboard
The Routstr admin dashboard is a modern web interface for managing your node, monitoring wallet balances, configuring AI models and providers, and handling Bitcoin Lightning payments through Cashu eCash.
## Accessing the Dashboard
### Authentication
The dashboard is protected by password authentication:
1. Navigate to `/admin/` in your browser
2. Enter the admin password
3. Optional: Configure custom base URL if not pre-configured
4. Click "Login"
The interface supports both environment-configured URLs and manual URL entry for deployment flexibility.
## Dashboard Overview
The main dashboard consists of four primary sections accessible through a collapsible sidebar:
- **Dashboard** - Wallet balance monitoring and fund management
- **Models** - AI model management and testing
- **Providers** - Upstream provider configuration
- **Settings** - Node configuration and admin preferences
### Navigation
## Dashboard Page
### Wallet Balance Management
#### Balance Display Options
Switch between display units using the toggle buttons:
- **msat** - Millisatoshis (highest precision)
- **sat** - Satoshis (standard Bitcoin unit)
- **usd** - US Dollar equivalent (when exchange rate available)
#### Balance Overview
The dashboard displays three key metrics:
- **Your Balance (Total)** - Available funds for node operator
- **Total Wallet** - Combined balance across all Cashu mints
- **User Balance** - Funds held for API key holders
#### Detailed Balance Breakdown
View balances by mint with the following information:
| Column | Description |
| ----------- | ------------------------------------- |
| Mint / Unit | Cashu mint URL and currency unit |
| Wallet | Total funds in this mint |
| Users | Funds belonging to API key holders |
| Owner | Your available funds (Wallet - Users) |
### Temporary Balances
Monitor API key activity with:
- **Summary Cards** - Total balance, total spent, total requests
- **Search Functionality** - Filter by key hash or refund address
- **Detailed Table** - Individual key balances with expiry times
- **Auto-refresh** - Updates every 60 seconds
### Fund Management
#### Withdrawing Funds
To withdraw your available balance:
1. Click the **Withdraw** button
2. Select which mint to withdraw from
3. Specify the amount (or withdraw full balance)
4. Click **Generate Token**
5. Copy the generated eCash token
6. Import the token into your Cashu wallet
#### Real-time Updates
- Balances refresh automatically every 30 seconds
- Manual refresh option available
- Live Bitcoin/USD exchange rate integration
- Error handling for mint connectivity issues
## Models Management Page
### Model Organization
Models are organized by provider groups with tabs:
- **All Models** - Combined view of all available models
- **Provider-specific tabs** - Individual providers (OpenRouter, Azure, etc.)
- Badge indicators showing active/total model counts
### Model Management Features
#### Individual Model Operations
For each model you can:
- **Toggle Enable/Disable** - Control model availability
- **View Details** - Context length, pricing, description
- **Edit Configuration** - Model-specific settings
- **Status Indicators** - Green badges for enabled, gray for disabled
#### Bulk Operations
- **Select All/Deselect All** - Quick selection controls
- **Bulk Enable/Disable** - Mass model management
- **Bulk Delete** - Remove model overrides
- **Provider-level Actions** - Apply settings to all models in a provider
#### Model Information Display
- **Model Types** - Text, embedding, image, audio, multimodal indicators
- **Pricing Information** - Per-million-token costs for input/output
- **Context Length** - Maximum tokens supported
- **API Key Status** - Whether credentials are configured
- **Free Model Indicators** - No-cost models clearly marked
## Providers Management Page
### Upstream Provider Configuration
Manage AI provider connections and credentials:
#### Provider Types Supported
- **OpenRouter** - Multi-model aggregator
- **Azure OpenAI** - Microsoft's OpenAI service
- **OpenAI** - Direct OpenAI integration
- **Custom Providers** - Any OpenAI-compatible API
#### Adding New Providers
1. Click **Add Provider**
2. Select **Provider Type** from dropdown
3. Enter **Base URL** (auto-populated for known providers)
4. Add **API Key** for authentication
5. Set **API Version** (required for Azure)
6. Toggle **Enabled** status
7. Click **Create**
#### Provider Management
**Provider Cards Display:**
- Provider type and status (Enabled/Disabled)
- Base URL configuration
- Action buttons (Models, Edit, Delete)
**Available Actions:**
- **Edit** - Modify provider configuration
- **Delete** - Remove provider (with confirmation)
- **View Models** - Expand model discovery interface
- **Enable/Disable** - Toggle provider availability
#### Model Discovery
Each provider shows two types of models:
**Provided Models Tab:**
- Auto-discovered from provider's catalog
- Read-only model information
- Real-time availability updates
**Custom Models Tab:**
- Manually configured model overrides
- Extend or override provider catalog
- Individual enable/disable controls
## Settings Page
### Node Configuration
Configure core node settings and preferences:
#### Basic Information
- **Node Name** - Identifier for your node
- **Node Description** - Descriptive text for your service
- **HTTP URL** - Public HTTP endpoint
- **Onion URL** - Tor hidden service address
#### Nostr Integration
- **Public Key (npub)** - Your Nostr public identity
- **Private Key (nsec)** - Nostr private key with show/hide toggle
- **Nostr Relays** - Configure relays for provider announcements
#### Cashu Mint Management
- **Add Mint URLs** - Configure multiple Cashu mint endpoints
- **Remove Mints** - Delete unused mint configurations
- **Mint Validation** - Verify mint endpoint connectivity
#### Settings Features
- **Real-time Save** - Changes apply immediately
- **Validation** - Form validation with error feedback
- **Secure Fields** - Password masking with reveal toggles
- **Reload Functionality** - Refresh configuration from server
## Next Steps
- [Payment Flow](payment-flow.md) - Understanding Bitcoin payment processing
- [Using the API](using-api.md) - Making API requests to your node
- [Models & Pricing](models-pricing.md) - Configuring model pricing and fees
- [API Reference](../api/overview.md) - Complete API documentation

View File

@@ -1,245 +0,0 @@
# User Guide Introduction
Welcome to the Routstr Core User Guide. This guide will help you understand how to use Routstr to access AI APIs with Bitcoin micropayments.
## What You'll Learn
- How the payment system works
- Creating and managing API keys
- Making API calls through Routstr
- Using the admin dashboard
- Managing your balance
## Prerequisites
Before starting, you'll need:
1. **A Running Routstr Instance**
- Either your own deployment or access to a public node
- The base URL (e.g., `https://api.routstr.com/v1`)
2. **A Cashu Wallet** (optional but recommended)
- [Nutstash](https://nutstash.app) - Web wallet
- [Minibits](https://www.minibits.cash) - Mobile wallet
- [Cashu.me](https://cashu.me) - Simple web wallet
3. **An API Client**
- OpenAI Python/JavaScript SDK
- Any HTTP client (curl, Postman, etc.)
- Your application code
## How Routstr Works
### Traditional API Access
```mermaid
graph LR
A[Your App] --> B[OpenAI API]
B --> A
```
- Direct connection to provider
- Monthly billing
- Credit card required
- Usage limits
### With Routstr
```mermaid
graph LR
A[Your App] --> B[Routstr Proxy]
B --> C[OpenAI API]
C --> B
B --> A
D[Bitcoin/eCash] --> B
```
- Pay per request with Bitcoin
- No credit card needed
- Anonymous payments
- Instant settlement
## Key Concepts
### eCash Tokens
- Digital bearer tokens backed by Bitcoin
- Can be sent like cash - whoever has the token owns it
- Redeemable at Cashu mints for Bitcoin
- Perfect for micropayments
### API Keys
- Created by depositing eCash tokens
- Track your balance and usage
- Can be topped up anytime
- Optional expiry and refund address
### Balance Management
- Measured in millisatoshis (msats)
- 1 Bitcoin = 100,000,000 sats = 100,000,000,000 msats
- Deducted based on actual usage
- Withdrawable as eCash tokens
## Typical Workflow
### 1. Get Bitcoin/eCash
Options:
- Buy Bitcoin and deposit to a Cashu mint
- Receive eCash tokens from someone else
- Use a testnet mint for testing
### 2. Use Your eCash
You have two options for using your eCash tokens with Routstr:
#### Option A: Create a Persistent Wallet
Create a wallet with an API key for multiple requests:
```bash
POST /v1/wallet/create
{
"cashu_token": "cashuAeyJ0..."
}
```
This returns an API key (`sk-...`) and your balance. The wallet persists between requests.
#### Option B: Direct Token Usage
Use your Cashu token directly as the API key:
```python
client = OpenAI(
api_key="cashuAeyJ0...", # Your Cashu token directly
base_url="https://api.routstr.com/v1"
)
```
Routstr automatically converts the token to access the associated wallet. Each request consumes from the token's balance.
### 3. Make API Calls
With either method:
```python
# Using persistent wallet API key
client = OpenAI(
api_key="sk-...",
base_url="https://api.routstr.com/v1"
)
# Or using Cashu token directly
client = OpenAI(
api_key="cashuAeyJ0...",
base_url="https://api.routstr.com/v1"
)
```
### 4. Monitor Usage
- Check balance: `GET /v1/wallet/balance`
- View admin dashboard
- Track costs per request
### 5. Withdraw Funds
When done, withdraw remaining balance as eCash through the admin interface.
## Supported Endpoints
Routstr supports all standard OpenAI endpoints:
-`/v1/chat/completions` - Chat models
- 🚧 `/v1/completions` - Text completion (Coming soon)
- 🚧 `/v1/embeddings` - Text embeddings (Coming soon)
- 🚧 `/v1/images/generations` - Image generation (Coming soon)
- 🚧 `/v1/audio/transcriptions` - Audio to text (Coming soon)
- 🚧 `/v1/audio/translations` - Audio translation (Coming soon)
-`/v1/models` - List available models
- ✅ Custom provider endpoints
## Cost Structure
### Pricing Models
1. **Fixed Cost Per Request**
- Simple flat fee per API call
- Good for uniform usage
2. **Token-Based Pricing**
- Pay per input/output token
- More accurate for varied usage
3. **Model-Based Pricing**
- Different rates per model
- Reflects actual provider costs
### Cost Calculation
```
Total Cost = Base Fee + (Input Tokens * Input Rate) + (Output Tokens * Output Rate)
```
Fees may include:
- Exchange rate markup (BTC/USD conversion)
- Provider margin
- Node operator fee
## Getting Support
### Documentation
- This user guide for general usage
- [API Reference](../api/overview.md) for technical details
- [Contributing Guide](../contributing/setup.md) for developers
### Community
- GitHub Issues for bugs and features
- Nostr for decentralized discussion
- Node operator contact info
### Troubleshooting
Common issues and solutions:
- [Payment Flow](payment-flow.md) - Understanding the payment process
- [Using the API](using-api.md) - API integration guide
- [Admin Dashboard](admin-dashboard.md) - Managing your node
## Security Considerations
### API Key Security
- Treat API keys like passwords
- Never share or commit them
- Rotate keys regularly
- Use environment variables
### Payment Security
- eCash tokens are bearer instruments
- Verify mint trustworthiness
- Keep backups of tokens
- Use small amounts for testing
### Network Security
- Always use HTTPS connections
- Verify SSL certificates
- Consider using Tor for privacy
- Monitor for unusual activity
## Next Steps
Ready to start? Continue with:
1. [Payment Flow](payment-flow.md) - Detailed payment process
2. [Using the API](using-api.md) - Making your first calls
3. [Admin Dashboard](admin-dashboard.md) - Managing your account

View File

@@ -1,466 +0,0 @@
# Models & Pricing
Understanding how Routstr calculates costs is essential for managing your API usage efficiently. This guide explains the pricing models and how to configure them.
## Pricing Models
Routstr supports three pricing models:
### 1. Fixed Pricing
Simple per-request charging:
```bash
FIXED_PRICING=true
FIXED_COST_PER_REQUEST=10 # 10 sats per request
```
**Best for:**
- Uniform API usage
- Simple applications
- Predictable costs
### 2. Token-Based Pricing
Charge based on actual token usage:
```bash
FIXED_PRICING=false # use model pricing
FIXED_COST_PER_REQUEST=1 # optional base fee
FIXED_PER_1K_INPUT_TOKENS=5 # optional override
FIXED_PER_1K_OUTPUT_TOKENS=15 # optional override
```
**Best for:**
- Varied request sizes
- Fair usage billing
- Cost optimization
### 3. Model-Based Pricing
Dynamic pricing based on model costs:
```bash
FIXED_PRICING=false
EXCHANGE_FEE=1.005 # 0.5% exchange fee
UPSTREAM_PROVIDER_FEE=1.05 # 5% provider fee
```
**Best for:**
- Multiple models
- Market-based pricing
- Automatic updates
## Model Configuration
### Default Models
Routstr includes pricing for popular models:
| Model | Input ($/1K) | Output ($/1K) | Context | Notes |
|-------|--------------|---------------|---------|-------|
| gpt-3.5-turbo | $0.0015 | $0.002 | 16K | Fast, economical |
| gpt-4 | $0.03 | $0.06 | 8K | Advanced reasoning |
| gpt-4-turbo | $0.01 | $0.03 | 128K | Large context |
| claude-3-opus | $0.015 | $0.075 | 200K | Best quality |
| claude-3-sonnet | $0.003 | $0.015 | 200K | Balanced |
| llama-2-70b | $0.0007 | $0.0009 | 4K | Open source |
### Custom Models File
Create `models.json` to override defaults:
```json
{
"models": [
{
"id": "gpt-4-vision",
"name": "GPT-4 Vision",
"pricing": {
"prompt": "0.00003",
"completion": "0.00006",
"request": "0",
"image": "0.00255"
},
"context_length": 128000,
"supports_vision": true
},
{
"id": "custom-model",
"name": "My Custom Model",
"pricing": {
"prompt": "0.001",
"completion": "0.002",
"request": "0.0001"
},
"context_length": 8192
}
]
}
```
### Auto-updating Models
Fetch latest models from OpenRouter:
```bash
# Update models from API
python scripts/models_meta.py
# Or manually
curl https://openrouter.ai/api/v1/models > models.json
```
## Cost Calculation
### Understanding the Formula
```
Base Cost = (Input Tokens × Input Rate) + (Output Tokens × Output Rate) + Request Fee
Bitcoin Price = Current BTC/USD rate (e.g., $50,000)
Sats Cost = (Base Cost / Bitcoin Price) × 100,000,000
Final Cost = Sats Cost × Exchange Fee × Provider Fee
```
### Example Calculations
**Example 1: Simple Chat (gpt-3.5-turbo)**
```
Input: 50 tokens
Output: 150 tokens
Model rates: $0.0015/1K input, $0.002/1K output
USD Cost = (50/1000 × 0.0015) + (150/1000 × 0.002)
= $0.000075 + $0.0003
= $0.000375
At $50,000/BTC: 0.75 sats
With 5.5% total fees: 0.79 sats
```
**Example 2: Large Context (gpt-4)**
```
Input: 2,000 tokens
Output: 500 tokens
Model rates: $0.03/1K input, $0.06/1K output
USD Cost = (2000/1000 × 0.03) + (500/1000 × 0.06)
= $0.06 + $0.03
= $0.09
At $50,000/BTC: 180 sats
With 5.5% total fees: 190 sats
```
**Example 3: Image Generation (dall-e-3)**
```
Model: dall-e-3
Size: 1024x1024
Quality: standard
Cost: $0.04 per image
At $50,000/BTC: 80 sats
With 5.5% fees: 84 sats
```
## Fee Structure
### Exchange Fee
Covers Bitcoin/USD conversion costs:
```bash
EXCHANGE_FEE=1.005 # 0.5% default
```
Factors:
- Exchange rate volatility
- Conversion costs
- Price update frequency
### Provider Fee
Node operator's margin:
```bash
UPSTREAM_PROVIDER_FEE=1.05 # 5% default
```
Covers:
- Infrastructure costs
- Maintenance
- Support
- Profit margin
### Calculating Total Fees
```
Total Multiplier = EXCHANGE_FEE × UPSTREAM_PROVIDER_FEE
Example: 1.005 × 1.05 = 1.05525 (5.525% total)
```
## Special Pricing
### Image Models
Image generation uses per-image pricing:
| Model | Size | Quality | Price |
|-------|------|---------|-------|
| dall-e-2 | 256x256 | - | $0.016 |
| dall-e-2 | 512x512 | - | $0.018 |
| dall-e-2 | 1024x1024 | - | $0.02 |
| dall-e-3 | 1024x1024 | standard | $0.04 |
| dall-e-3 | 1024x1024 | hd | $0.08 |
| dall-e-3 | 1024x1792 | standard | $0.08 |
| dall-e-3 | 1024x1792 | hd | $0.12 |
### Audio Models
Audio pricing by duration:
| Model | Type | Price |
|-------|------|-------|
| whisper-1 | Transcription | $0.006/minute |
| whisper-1 | Translation | $0.006/minute |
| tts-1 | Text-to-speech | $0.015/1K chars |
| tts-1-hd | HD speech | $0.03/1K chars |
### Embedding Models
Lower costs for embeddings:
| Model | Price/1K tokens |
|-------|-----------------|
| text-embedding-3-small | $0.00002 |
| text-embedding-3-large | $0.00013 |
| text-embedding-ada-002 | $0.0001 |
## Monitoring Costs
### Per-Request Tracking
Each API response includes usage data:
```json
{
"usage": {
"prompt_tokens": 50,
"completion_tokens": 150,
"total_tokens": 200
},
"x-routstr-cost": {
"sats": 79,
"usd": 0.000375,
"breakdown": {
"prompt_cost": 15,
"completion_cost": 60,
"fees": 4
}
}
}
```
### Daily Summaries
View in admin dashboard:
- Total requests
- Token usage by model
- Cost distribution
- Trending patterns
### Cost Alerts
Set up notifications:
```python
# Example monitoring script
def check_daily_spend(api_key):
balance_start = get_balance(api_key, "00:00")
balance_now = get_balance(api_key)
spent = balance_start - balance_now
if spent > DAILY_LIMIT:
send_alert(f"Daily spend exceeded: {spent} sats")
```
## Optimization Strategies
### Model Selection
Choose the right model for each task:
| Task | Recommended Model | Why |
|------|-------------------|-----|
| Simple Q&A | gpt-3.5-turbo | Fast, cheap, sufficient |
| Code generation | gpt-4 | Better reasoning |
| Summarization | claude-3-haiku | Good balance |
| Creative writing | claude-3-opus | Best quality |
| Embeddings | text-embedding-3-small | Optimized for vectors |
### Prompt Engineering
Reduce costs with efficient prompts:
```python
# Expensive
prompt = """
You are an AI assistant. Your task is to help users.
Please provide detailed, comprehensive answers.
Now, answer this question: What is 2+2?
"""
# Economical
prompt = "Calculate: 2+2"
```
### Caching Strategies
Implement smart caching:
```python
# Cache embedding results
@lru_cache(maxsize=1000)
def get_embedding(text):
return client.embeddings.create(
model="text-embedding-3-small",
input=text
)
# Cache common responses
COMMON_RESPONSES = {
"greeting": "Hello! How can I help you?",
"goodbye": "Goodbye! Have a great day!"
}
```
### Batch Processing
Process multiple items efficiently:
```python
# Instead of multiple calls
for item in items:
response = client.chat.completions.create(...)
# Use single call with formatted prompt
prompt = "\n".join([f"{i+1}. {item}" for i, item in enumerate(items)])
response = client.chat.completions.create(
messages=[{"role": "user", "content": f"Process these items:\n{prompt}"}]
)
```
## Custom Pricing Rules
### Time-Based Pricing
Implement off-peak discounts:
```python
def calculate_multiplier():
hour = datetime.now().hour
if 2 <= hour <= 6: # 2 AM - 6 AM
return 0.8 # 20% discount
elif 18 <= hour <= 22: # 6 PM - 10 PM
return 1.2 # 20% premium
return 1.0
```
### Model-Specific Rules
Custom pricing logic:
```python
def adjust_model_price(model, base_price):
# Premium for latest models
if "turbo" in model or "latest" in model:
return base_price * 1.1
# Discount for older models
if "legacy" in model:
return base_price * 0.8
return base_price
```
## Pricing Transparency
### Public Pricing Page
Display current rates:
```html
<!-- Available at /pricing -->
<table>
<tr>
<th>Model</th>
<th>Input (sats/1K)</th>
<th>Output (sats/1K)</th>
</tr>
<!-- Dynamically generated from models.json -->
</table>
```
### Cost Estimation API
Provide cost estimates:
```bash
POST /v1/estimate
{
"model": "gpt-4",
"prompt_tokens": 500,
"max_tokens": 200
}
Response:
{
"estimated_cost_sats": 45,
"breakdown": {
"prompt": 30,
"completion": 12,
"fees": 3
}
}
```
## Troubleshooting
### Pricing Mismatches
**Issue**: Costs don't match expectations
- Check current BTC/USD rate
- Verify fee settings
- Review model configuration
**Issue**: Models not found
- Update models.json
- Check model ID spelling
- Verify upstream support
### Fee Calculations
**Issue**: Fees seem too high
- Review EXCHANGE_FEE setting
- Check UPSTREAM_PROVIDER_FEE
- Calculate total multiplier
## Next Steps
- [API Reference](../api/overview.md) - Technical details
- [Custom Pricing](../advanced/custom-pricing.md) - Advanced configuration
- [Contributing](../contributing/setup.md) - Help improve Routstr

View File

@@ -1,373 +0,0 @@
# Payment Flow
Understanding how payments work in Routstr is key to using the system effectively. This guide explains the payment process in detail.
## Overview
Routstr uses a pre-funded account model where:
1. Users deposit eCash tokens to create an API key
2. Each API request deducts from the balance
3. Users can withdraw remaining balance as eCash
## Creating an API Key
### Step 1: Obtain eCash Token
Get a Cashu token from any compatible source:
**Option A: From a Cashu Wallet**
```bash
# Example: Creating a 10,000 sat token
cashu send 10000
```
**Option B: Lightning Invoice**
```bash
# Some mints support direct Lightning deposits
curl -X POST https://mint.example.com/v1/mint/quote/bolt11 \
-d '{"amount": 10000, "unit": "sat"}'
```
**Option C: Test Tokens**
```bash
# Get test tokens from testnet mints
# Check mint documentation for faucets
```
### Step 2: Create API Key
**Note: The POST /v1/wallet/create endpoint is coming soon. Currently, you can use Cashu tokens directly as API keys in the Authorization header.**
Send your token to Routstr:
```bash
curl -X POST https://api.routstr.com/v1/wallet/create \
-H "Content-Type: application/json" \
-d '{
"cashu_token": "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vbWlu..."
}'
```
**Request Parameters:**
- `cashu_token` (required): The eCash token to deposit
**Response:**
```json
{
"api_key": "sk-1234567890abcdef",
"balance": 10000000,
"created_at": "2024-01-01T00:00:00Z"
}
```
### Step 3: Verify Balance
Check your key's balance:
```bash
curl -X GET https://api.routstr.com/v1/wallet/balance \
-H "Authorization: Bearer sk-1234567890abcdef"
```
Response:
```json
{
"balance": 10000000,
"total_deposited": 10000000,
"total_spent": 0,
"last_used": null
}
```
## Making API Requests
### Cost Calculation
Costs are calculated based on:
1. **Request Type**
- Chat completions
- Embeddings
- Image generation
- Audio processing
2. **Token Usage**
- Input tokens (prompt)
- Output tokens (response)
- Model-specific rates
3. **Additional Costs**
- Base request fee
- Image generation fees
- Audio processing time
### Example: Chat Completion
```python
import openai
client = openai.OpenAI(
api_key="sk-1234567890abcdef",
base_url="https://api.routstr.com/v1"
)
# Make request
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{"role": "user", "content": "Hello, how are you?"}
]
)
# Check usage
print(f"Input tokens: {response.usage.prompt_tokens}")
print(f"Output tokens: {response.usage.completion_tokens}")
print(f"Total tokens: {response.usage.total_tokens}")
```
### Cost Breakdown
For the above request:
```
Model: gpt-3.5-turbo
Input tokens: 13
Output tokens: 27
Model rates: $0.0015/1K input, $0.002/1K output
USD Cost = (13/1000 * 0.0015) + (27/1000 * 0.002) = $0.0000735
BTC/USD Rate: $50,000
BTC Cost = 0.0000735 / 50000 = 0.00000000147 BTC = 147 sats
With fees (5%): 154 sats
Final cost: 154 sats
```
## Balance Management
### Monitoring Usage
Track your usage in real-time:
```bash
# Get current balance
curl -X GET https://api.routstr.com/v1/wallet/balance \
-H "Authorization: Bearer your-api-key"
# View recent transactions (through admin dashboard)
# Access at https://api.routstr.com/admin/
```
### Low Balance Handling
When balance is insufficient:
```json
{
"error": {
"type": "insufficient_balance",
"message": "Insufficient balance. Current: 100 sats, Required: 154 sats",
"code": "payment_required"
}
}
```
### Topping Up
Add funds to existing key:
```bash
curl -X POST https://api.routstr.com/v1/wallet/topup \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"cashu_token": "cashuAeyJ0b2..."
}'
```
## Withdrawing Balance
### Via Admin Dashboard
1. Navigate to `/admin/`
2. Enter admin password
3. Find your API key
4. Click "Withdraw"
5. Receive eCash token
### Via API (if enabled)
```bash
curl -X POST https://api.routstr.com/v1/wallet/withdraw \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"mint": "https://mint.minibits.cash/Bitcoin"
}'
```
## Payment Security
### Token Validation
Routstr validates tokens by:
1. Checking signature validity
2. Verifying with the issuing mint
3. Ensuring no double-spending
4. Confirming sufficient value
### Failed Payments
Common failure reasons:
- Invalid token signature
- Already spent token
- Untrusted mint
- Network issues with mint
### Refund Policy
- Unused balance can be withdrawn anytime
- Expired keys with balance can be refunded
- Node operators may have additional policies
## Advanced Features
### Multi-Mint Support
Routstr accepts tokens from multiple mints:
```bash
CASHU_MINTS=https://mint1.com,https://mint2.com,https://mint3.com
```
Benefits:
- Redundancy if one mint is down
- User choice of mints
- Geographic distribution
### Automatic Payouts
Configure automatic Lightning payouts:
```bash
RECEIVE_LN_ADDRESS=satoshi@getalby.com
```
When enabled:
- Balances above threshold are swept
- Converted to Lightning payments
- Sent to configured address
### Per-Request Payments (Coming Soon)
Future support for Nut-24 headers:
```bash
curl -X POST https://api.routstr.com/v1/chat/completions \
-H "x-cashu: cashuAeyJ0..." \
-H "Content-Type: application/json" \
-d '{...}'
```
Response includes change:
```
HTTP/1.1 200 OK
x-cashu: cashuAeyJjaGFuZ2Ui...
```
## Best Practices
### API Key Management
1. **Separate Keys per Application**
- Easier tracking
- Better security
- Independent budgets
2. **Set Expiration Dates**
- Automatic cleanup
- Security improvement
- Budget control
3. **Monitor Balances**
- Set up alerts
- Regular checks
- Usage analytics
### Cost Optimization
1. **Choose Appropriate Models**
- Smaller models for simple tasks
- Larger models only when needed
2. **Optimize Prompts**
- Concise, clear instructions
- Avoid unnecessary tokens
3. **Use Streaming**
- Early termination possible
- Better user experience
### Security
1. **Secure Storage**
- Environment variables
- Secrets management
- Never in code
2. **Network Security**
- Always use HTTPS
- Verify certificates
- Consider Tor for privacy
3. **Regular Rotation**
- Change keys periodically
- Withdraw unused funds
- Audit usage logs
## Troubleshooting
### Payment Rejected
**Error:** "Invalid token"
- Check token format
- Verify mint is trusted
- Ensure not already spent
**Error:** "Insufficient value"
- Token value too low
- Check current pricing
- Add larger token
### Balance Discrepancies
- Allow for price fluctuations
- Check model pricing updates
- Review transaction history
### Mint Issues
- Try different mint from list
- Check mint status
- Contact mint operator
## Next Steps
- [Using the API](using-api.md) - Integration guide
- [Admin Dashboard](admin-dashboard.md) - Account management
- [Models & Pricing](models-pricing.md) - Cost details

View File

@@ -1,545 +0,0 @@
# Using the API
This guide shows how to integrate Routstr with your applications using various programming languages and tools.
## API Compatibility
Routstr maintains full compatibility with the OpenAI API, meaning:
- Existing OpenAI client libraries work without modification
- Only the base URL and API key need to change
- All parameters and responses match OpenAI's format
## Basic Setup
### Python
Using the official OpenAI Python library:
```python
from openai import OpenAI
# Initialize client with Routstr endpoint
client = OpenAI(
api_key="sk-...",
base_url="https://api.routstr.com/v1"
)
# Use exactly like OpenAI
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello!"}
]
)
print(response.choices[0].message.content)
```
### JavaScript/TypeScript
Using the official OpenAI Node.js library:
```javascript
import OpenAI from 'openai';
// Initialize client
const openai = new OpenAI({
apiKey: 'sk-...',
baseURL: 'https://api.routstr.com/v1'
});
// Make a request
async function main() {
const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Hello!' }
]
});
console.log(completion.choices[0].message.content);
}
main();
```
### cURL
Direct HTTP requests:
```bash
curl https://api.routstr.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-..." \
-d '{
"model": "gpt-3.5-turbo",
"messages": [
{"role": "user", "content": "Hello!"}
]
}'
```
## Common Use Cases
### Chat Completions
Standard chat with conversation history:
```python
messages = []
def chat(user_input):
# Add user message
messages.append({"role": "user", "content": user_input})
# Get AI response
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=messages,
temperature=0.7,
max_tokens=150
)
# Add AI response to history
ai_message = response.choices[0].message
messages.append({"role": "assistant", "content": ai_message.content})
return ai_message.content
# Usage
print(chat("What's the weather like?"))
print(chat("How should I dress?")) # Maintains context
```
### Streaming Responses
For real-time output:
```python
stream = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Write a short story"}],
stream=True
)
for chunk in stream:
if chunk.choices[0].delta.content is not None:
print(chunk.choices[0].delta.content, end="", flush=True)
```
### Function Calling
Using OpenAI's function calling feature:
```python
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["location"]
}
}
}]
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "What's the weather in Tokyo?"}],
tools=tools,
tool_choice="auto"
)
# Check if function was called
if response.choices[0].message.tool_calls:
tool_call = response.choices[0].message.tool_calls[0]
print(f"Function: {tool_call.function.name}")
print(f"Arguments: {tool_call.function.arguments}")
```
### Embeddings
Generate text embeddings:
```python
response = client.embeddings.create(
model="text-embedding-3-small",
input="The quick brown fox jumps over the lazy dog"
)
embedding = response.data[0].embedding
print(f"Embedding dimension: {len(embedding)}")
```
### Image Generation
Create images with DALL-E:
```python
response = client.images.generate(
model="dall-e-3",
prompt="A futuristic city with flying cars",
size="1024x1024",
quality="standard",
n=1
)
image_url = response.data[0].url
print(f"Image URL: {image_url}")
```
### Audio Transcription
Convert speech to text:
```python
with open("audio.mp3", "rb") as audio_file:
response = client.audio.transcriptions.create(
model="whisper-1",
file=audio_file,
response_format="text"
)
print(response.text)
```
## Error Handling
### Balance Errors
Handle insufficient balance gracefully:
```python
try:
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}]
)
except Exception as e:
if "insufficient_balance" in str(e):
print("Low balance! Please top up your API key.")
# Implement top-up logic
else:
raise
```
### Rate Limiting
Implement exponential backoff:
```python
import time
from typing import Optional
def make_request_with_retry(
func,
max_retries: int = 3,
initial_delay: float = 1.0
) -> Optional[any]:
for attempt in range(max_retries):
try:
return func()
except Exception as e:
if "rate_limit" in str(e) and attempt < max_retries - 1:
delay = initial_delay * (2 ** attempt)
print(f"Rate limited. Waiting {delay}s...")
time.sleep(delay)
else:
raise
return None
```
### Connection Errors
Handle network issues:
```python
import httpx
# Configure timeout and retries
client = OpenAI(
api_key="sk-...",
base_url="https://your-node.com/v1",
timeout=httpx.Timeout(60.0, connect=5.0),
max_retries=2
)
```
## Advanced Features
### Using Tor
Route requests through Tor for privacy:
```python
import httpx
# Configure Tor proxy
proxies = {
"http://": "socks5://127.0.0.1:9050",
"https://": "socks5://127.0.0.1:9050"
}
http_client = httpx.Client(proxies=proxies)
client = OpenAI(
api_key="sk-...",
base_url="http://your-onion-address.onion/v1",
http_client=http_client
)
```
### Custom Headers
Add custom headers if needed:
```python
import httpx
class CustomClient(httpx.Client):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.headers["X-Custom-Header"] = "value"
client = OpenAI(
api_key="sk-...",
base_url="https://your-node.com/v1",
http_client=CustomClient()
)
```
### Azure OpenAI compatibility
To use Azure OpenAI through Routstr with minimal changes:
- Set `UPSTREAM_BASE_URL` to your Azure deployments URL, for example: `https://<resource>.openai.azure.com/openai/deployments/<deployment>`
- Set `CHAT_COMPLETIONS_API_VERSION=2024-05-01-preview`
When this env var is set, Routstr automatically appends `api-version=2024-05-01-preview` to all upstream `/chat/completions` requests.
### Async Operations
For high-performance applications:
```python
import asyncio
from openai import AsyncOpenAI
async_client = AsyncOpenAI(
api_key="sk-...",
base_url="https://your-node.com/v1"
)
async def process_messages(messages):
tasks = []
for msg in messages:
task = async_client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": msg}]
)
tasks.append(task)
responses = await asyncio.gather(*tasks)
return [r.choices[0].message.content for r in responses]
# Run async
messages = ["Hello", "How are you?", "What's 2+2?"]
results = asyncio.run(process_messages(messages))
```
## Best Practices
### 1. Environment Variables
Never hardcode API keys:
```python
import os
from openai import OpenAI
client = OpenAI(
api_key=os.getenv("ROUTSTR_API_KEY"),
base_url=os.getenv("ROUTSTR_BASE_URL", "https://api.routstr.com/v1")
)
```
### 2. Error Logging
Implement comprehensive logging:
```python
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
try:
response = client.chat.completions.create(...)
logger.info(f"Request successful. Tokens used: {response.usage.total_tokens}")
except Exception as e:
logger.error(f"API request failed: {e}")
raise
```
### 3. Cost Tracking
Monitor your usage:
```python
class UsageTracker:
def __init__(self):
self.total_tokens = 0
self.total_requests = 0
def track(self, response):
self.total_tokens += response.usage.total_tokens
self.total_requests += 1
# Estimate cost (example rates)
cost_per_1k = 0.002 # $0.002 per 1K tokens
estimated_cost = (self.total_tokens / 1000) * cost_per_1k
logger.info(f"Total usage: {self.total_tokens} tokens, "
f"${estimated_cost:.4f} (~{estimated_cost * 50000:.0f} sats)")
tracker = UsageTracker()
response = client.chat.completions.create(...)
tracker.track(response)
```
### 4. Caching Responses
Reduce costs with intelligent caching:
```python
import hashlib
import json
from functools import lru_cache
@lru_cache(maxsize=100)
def cached_completion(prompt: str, model: str = "gpt-3.5-turbo"):
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0 # Deterministic for caching
)
return response.choices[0].message.content
# Repeated calls with same prompt use cache
result1 = cached_completion("What is 2+2?")
result2 = cached_completion("What is 2+2?") # From cache, no API call
```
## Testing
### Mock Responses
For development without spending sats:
```python
class MockOpenAI:
class Completions:
def create(self, **kwargs):
return type('Response', (), {
'choices': [type('Choice', (), {
'message': type('Message', (), {
'content': 'Mock response'
})()
})],
'usage': type('Usage', (), {
'total_tokens': 10
})()
})()
def __init__(self):
self.chat = type('Chat', (), {
'completions': self.Completions()
})()
# Use mock in tests
if os.getenv('TESTING'):
client = MockOpenAI()
else:
client = OpenAI(...)
```
### Integration Tests
Test your Routstr integration:
```python
def test_routstr_connection():
try:
# Test models endpoint
models = client.models.list()
assert len(models.data) > 0
# Test simple completion
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "test"}],
max_tokens=5
)
assert response.choices[0].message.content
print("✅ Routstr integration working!")
return True
except Exception as e:
print(f"❌ Integration test failed: {e}")
return False
```
## Troubleshooting
### Common Issues
**SSL Certificate Errors**
```python
# For development only - not for production!
import ssl
import httpx
client = OpenAI(
api_key="sk-...",
base_url="https://localhost:8000/v1",
http_client=httpx.Client(verify=False)
)
```
**Timeout Issues**
```python
# Increase timeout for slow connections
client = OpenAI(
api_key="sk-...",
base_url="https://your-node.com/v1",
timeout=httpx.Timeout(120.0) # 2 minutes
)
```
**Debugging Requests**
```python
import logging
import httpx
# Enable debug logging
logging.basicConfig(level=logging.DEBUG)
httpx_logger = logging.getLogger("httpx")
httpx_logger.setLevel(logging.DEBUG)
```
## Next Steps
- [Admin Dashboard](admin-dashboard.md) - Manage your account
- [Models & Pricing](models-pricing.md) - Understanding costs
- [API Reference](../api/overview.md) - Technical details

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

View File

@@ -0,0 +1,37 @@
"""add key management and reset fields to api_keys
Revision ID: 06f81c0fc88d
Revises: c2d3e4f5a6b7
Create Date: 2026-02-04 22:44:03.311983
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "06f81c0fc88d"
down_revision = "c2d3e4f5a6b7"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("api_keys", sa.Column("balance_limit", sa.Integer(), nullable=True))
op.add_column(
"api_keys",
sa.Column(
"balance_limit_reset", sqlmodel.sql.sqltypes.AutoString(), nullable=True
),
)
op.add_column(
"api_keys", sa.Column("balance_limit_reset_date", sa.Integer(), nullable=True)
)
op.add_column("api_keys", sa.Column("validity_date", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("api_keys", "validity_date")
op.drop_column("api_keys", "balance_limit_reset_date")
op.drop_column("api_keys", "balance_limit_reset")
op.drop_column("api_keys", "balance_limit")

View File

@@ -0,0 +1,32 @@
"""add provider_settings to upstream_providers
Revision ID: 614c0a740e68
Revises: 06f81c0fc88d
Create Date: 2026-02-13 22:36:53.608737
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "614c0a740e68"
down_revision = "06f81c0fc88d"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Check if column exists before adding it
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [c["name"] for c in inspector.get_columns("upstream_providers")]
if "provider_settings" not in columns:
op.add_column(
"upstream_providers",
sa.Column("provider_settings", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("upstream_providers", "provider_settings")

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

@@ -77,29 +77,27 @@ extra:
nav:
- Home: index.md
- Getting Started:
- Overview: getting-started/overview.md
- Quick Start: getting-started/quickstart.md
- Docker Setup: getting-started/docker.md
- Configuration: getting-started/configuration.md
- User Guide:
- Introduction: user-guide/introduction.md
- Payment Flow: user-guide/payment-flow.md
- Using the API: user-guide/using-api.md
- Admin Dashboard: user-guide/admin-dashboard.md
- Models & Pricing: user-guide/models-pricing.md
- Contributing:
- Setup Development: contributing/setup.md
- Architecture: contributing/architecture.md
- Code Structure: contributing/code-structure.md
- Testing: contributing/testing.md
- Overview: overview.md
- Client Guide:
- Introduction: client/introduction.md
- Payment Flow: client/payments.md
- Integration: client/integration.md
- Provider Guide:
- Quick Start: provider/quickstart.md
- Dashboard: provider/dashboard.md
- Deployment: provider/deployment.md
- Configuration: provider/configuration.md
- Pricing: provider/pricing.md
- Advanced Pricing: provider/advanced-pricing.md
- Discovery: provider/discovery.md
- Tor Support: provider/tor.md
- API Reference:
- Overview: api/overview.md
- Authentication: api/authentication.md
- Endpoints: api/endpoints.md
- Errors: api/errors.md
- Advanced:
- Tor Support: advanced/tor.md
- Nostr Discovery: advanced/nostr.md
- Custom Pricing: advanced/custom-pricing.md
- Migrations: advanced/migrations.md
- Contributing:
- Setup Development: contributing/setup.md
- Architecture: contributing/architecture.md
- Code Structure: contributing/code-structure.md
- Testing: contributing/testing.md

View File

@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.2.2"
version = "0.3.0"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -84,93 +84,26 @@ def get_provider_penalty(provider: "BaseUpstreamProvider") -> float:
return penalty
def should_prefer_model(
candidate_model: "Model",
candidate_provider: "BaseUpstreamProvider",
current_model: "Model",
current_provider: "BaseUpstreamProvider",
alias: str,
) -> bool:
"""Determine if candidate model should replace current model for an alias.
This is the core decision function for model prioritization. It considers:
1. Alias matching quality (exact match vs. canonical slug match)
2. Model cost (lower is better)
3. Provider penalties (e.g., slight preference against OpenRouter)
Args:
candidate_model: The new model being considered
candidate_provider: Provider offering the candidate model
current_model: The currently selected model for this alias
current_provider: Provider offering the current model
alias: The model alias being mapped
Returns:
True if candidate should replace current, False otherwise
"""
def get_base_model_id(model_id: str) -> str:
"""Get base model ID by removing provider prefix."""
return model_id.split("/", 1)[1] if "/" in model_id else model_id
def alias_priority(model: "Model") -> int:
"""Rank how strong the mapping of alias->model is.
Highest priority when alias exactly equals the model ID without provider prefix.
Next when alias equals canonical slug without prefix. Otherwise lowest.
"""
model_base = get_base_model_id(model.id)
if model_base == alias:
return 3
if model.canonical_slug:
canonical_base = get_base_model_id(model.canonical_slug)
if canonical_base == alias:
return 2
return 1
candidate_alias_priority = alias_priority(candidate_model)
current_alias_priority = alias_priority(current_model)
# If candidate has better alias match, prefer it regardless of cost
if candidate_alias_priority > current_alias_priority:
return True
# If current has better alias match, keep it regardless of cost
if current_alias_priority > candidate_alias_priority:
return False
# Same alias priority - compare costs
candidate_cost = calculate_model_cost_score(candidate_model)
current_cost = calculate_model_cost_score(current_model)
# Apply provider penalties
candidate_adjusted = candidate_cost * get_provider_penalty(candidate_provider)
current_adjusted = current_cost * get_provider_penalty(current_provider)
# Prefer lower adjusted cost
should_replace = candidate_adjusted < current_adjusted
return should_replace
def create_model_mappings(
upstreams: list["BaseUpstreamProvider"],
overrides_by_id: dict[str, tuple],
disabled_model_ids: set[str],
) -> tuple[dict[str, "Model"], dict[str, "BaseUpstreamProvider"], dict[str, "Model"]]:
) -> tuple[
dict[str, "Model"], dict[str, list["BaseUpstreamProvider"]], dict[str, "Model"]
]:
"""Create optimal model mappings based on cost and provider preferences.
This is the main entry point for the algorithm. It processes all upstream providers
and creates three mappings based on cost optimization:
1. model_instances: alias -> Model (all model aliases mapped to their Model objects)
2. provider_map: alias -> UpstreamProvider (which provider to use for each alias)
2. provider_map: alias -> List[UpstreamProvider] (sorted list of providers for each alias)
3. unique_models: base_id -> Model (unique models without provider prefixes)
The algorithm:
- Processes non-OpenRouter providers first (they're typically cheaper)
- Then processes OpenRouter models (they can still win if cheaper)
- For each model alias, uses should_prefer_model() to select the best provider
- For each model alias, collects all candidates and sorts them by priority and cost.
Args:
upstreams: List of all upstream provider instances
@@ -183,9 +116,15 @@ def create_model_mappings(
from .payment.models import _row_to_model
from .upstream.helpers import resolve_model_alias
model_instances: dict[str, "Model"] = {}
provider_map: dict[str, "BaseUpstreamProvider"] = {}
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
unique_models: dict[str, "Model"] = {}
seen_model_provider: set[tuple[str, str]] = set()
providers_by_db_id: dict[int, "BaseUpstreamProvider"] = {}
for upstream in upstreams:
db_id = getattr(upstream, "db_id", None)
if isinstance(db_id, int):
providers_by_db_id[db_id] = upstream
# Separate OpenRouter from other providers
openrouter: "BaseUpstreamProvider" | None = None
@@ -202,30 +141,31 @@ def create_model_mappings(
"""Get base model ID by removing provider prefix."""
return model_id.split("/", 1)[1] if "/" in model_id else model_id
def _maybe_set_alias(
def get_provider_identity(upstream: "BaseUpstreamProvider") -> str:
"""Get a stable provider identity used for deduplication."""
db_id = getattr(upstream, "db_id", None)
if isinstance(db_id, int):
return f"db:{db_id}"
provider_type = str(getattr(upstream, "provider_type", "") or "").lower()
base_url = str(getattr(upstream, "base_url", "") or "").lower()
return f"{provider_type}|{base_url}"
def _add_candidate(
alias: str, model: "Model", provider: "BaseUpstreamProvider"
) -> None:
"""Set alias to model/provider if not set or if new model is preferred."""
"""Add candidate model/provider for an alias."""
alias_lower = alias.lower()
existing_model = model_instances.get(alias_lower)
if not existing_model:
# No existing mapping, set it
model_instances[alias_lower] = model
provider_map[alias_lower] = provider
else:
# Check if candidate should replace existing
existing_provider = provider_map[alias_lower]
if should_prefer_model(
model, provider, existing_model, existing_provider, alias
):
model_instances[alias_lower] = model
provider_map[alias_lower] = provider
if alias_lower not in candidates:
candidates[alias_lower] = []
candidates[alias_lower].append((model, provider))
def process_provider_models(
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
) -> None:
"""Process all models from a given provider."""
upstream_prefix = getattr(upstream, "upstream_name", None)
provider_key = get_provider_identity(upstream)
for model in upstream.get_cached_models():
if not model.enabled or model.id in disabled_model_ids:
@@ -266,21 +206,135 @@ def create_model_mappings(
# Try to set each alias
for alias in aliases:
_maybe_set_alias(alias, model_to_use, upstream)
_add_candidate(alias, model_to_use, upstream)
seen_model_provider.add((model_to_use.id.lower(), provider_key))
# Process non-OpenRouter providers first (they're typically cheaper)
# Process non-OpenRouter providers first
for upstream in other_upstreams:
process_provider_models(upstream, is_openrouter=False)
# Process OpenRouter last - models only win if they're cheaper or better matched
# Process OpenRouter last
if openrouter:
process_provider_models(openrouter, is_openrouter=True)
# Log provider distribution
# Include enabled DB overrides even when provider discovery misses models.
# This is important for deployment-based providers like Azure.
for model_id, override_data in overrides_by_id.items():
if model_id in disabled_model_ids:
continue
override_row, provider_fee = override_data
upstream_provider_id = getattr(override_row, "upstream_provider_id", None)
if not isinstance(upstream_provider_id, int):
continue
upstream_for_override = providers_by_db_id.get(upstream_provider_id)
if upstream_for_override is None:
continue
provider_key = get_provider_identity(upstream_for_override)
dedupe_key = (model_id.lower(), provider_key)
if dedupe_key in seen_model_provider:
continue
try:
model_to_use = _row_to_model(
override_row, apply_provider_fee=True, provider_fee=provider_fee
)
except Exception as exc:
logger.warning(
"Skipping invalid model override while building model mappings",
extra={
"model_id": model_id,
"upstream_provider_id": upstream_provider_id,
"error": str(exc),
"error_type": type(exc).__name__,
},
)
continue
if not model_to_use.enabled:
continue
base_id = get_base_model_id(model_to_use.id)
is_openrouter = (
getattr(upstream_for_override, "base_url", "")
== "https://openrouter.ai/api/v1"
)
if not is_openrouter or base_id not in unique_models:
unique_model = model_to_use.copy(
update={
"id": base_id,
"upstream_provider_id": upstream_for_override.provider_type,
}
)
unique_models[base_id] = unique_model
try:
aliases = resolve_model_alias(
model_to_use.id,
model_to_use.canonical_slug,
alias_ids=model_to_use.alias_ids,
)
except Exception as exc:
logger.warning(
"Skipping model aliases for invalid override model",
extra={
"model_id": model_id,
"upstream_provider_id": upstream_provider_id,
"error": str(exc),
"error_type": type(exc).__name__,
},
)
continue
upstream_prefix = getattr(upstream_for_override, "upstream_name", None)
if upstream_prefix and "/" not in model_to_use.id:
prefixed_id = f"{upstream_prefix}/{model_to_use.id}"
if prefixed_id not in aliases:
aliases.append(prefixed_id)
for alias in aliases:
_add_candidate(alias, model_to_use, upstream_for_override)
seen_model_provider.add(dedupe_key)
# Sort candidates and build final maps
model_instances: dict[str, "Model"] = {}
provider_map: dict[str, list["BaseUpstreamProvider"]] = {}
def alias_priority(model: "Model", alias: str) -> int:
"""Rank how strong the mapping of alias->model is."""
model_base = get_base_model_id(model.id)
if model_base == alias:
return 3
if model.canonical_slug:
canonical_base = get_base_model_id(model.canonical_slug)
if canonical_base == alias:
return 2
return 1
for alias, items in candidates.items():
# Sort key: (priority DESC, cost ASC)
# Using negative cost for DESC sort overall to keep high priority first
def sort_key(item: tuple["Model", "BaseUpstreamProvider"]) -> tuple[int, float]:
model, provider = item
priority = alias_priority(model, alias)
cost = calculate_model_cost_score(model)
penalty = get_provider_penalty(provider)
adjusted_cost = cost * penalty
return (priority, -adjusted_cost)
items.sort(key=sort_key, reverse=True)
best_model, best_provider = items[0]
model_instances[alias] = best_model
provider_map[alias] = [p for _, p in items]
# Log provider distribution (using top provider for stats)
provider_counts: dict[str, int] = {}
for provider in provider_map.values():
provider_name = getattr(provider, "upstream_name", "unknown")
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
for providers in provider_map.values():
if providers:
provider = providers[0]
provider_name = getattr(provider, "upstream_name", "unknown")
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
logger.debug(
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",

View File

@@ -1,10 +1,14 @@
import asyncio
import hashlib
import math
import random
import time
from datetime import datetime
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlmodel import col, update
from sqlmodel import col, select, update
from .core import get_logger
from .core.db import ApiKey, AsyncSession
@@ -24,16 +28,60 @@ logger = get_logger(__name__)
# PREPAID_BALANCE = int(os.environ.get("PREPAID_BALANCE", "0")) * 1000 # Convert to msats
async def check_and_reset_limit(key: ApiKey, session: AsyncSession) -> bool:
"""Checks if a key's balance limit should be reset based on its policy."""
if key.balance_limit is not None and key.balance_limit_reset:
now = int(time.time())
reset_date = key.balance_limit_reset_date or 0
should_reset = False
if key.balance_limit_reset == "daily":
if (
datetime.fromtimestamp(now).date()
> datetime.fromtimestamp(reset_date).date()
):
should_reset = True
elif key.balance_limit_reset == "weekly":
if (
datetime.fromtimestamp(now).isocalendar()[:2]
> datetime.fromtimestamp(reset_date).isocalendar()[:2]
):
should_reset = True
elif key.balance_limit_reset == "monthly":
dt_now = datetime.fromtimestamp(now)
dt_reset = datetime.fromtimestamp(reset_date)
if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month:
should_reset = True
if should_reset:
logger.info(
"Resetting balance limit for key",
extra={
"key_hash": key.hashed_key[:8] + "...",
"policy": key.balance_limit_reset,
"old_spent": key.total_spent,
},
)
key.total_spent = 0
key.balance_limit_reset_date = now
session.add(key)
await session.flush()
return True
return False
async def validate_bearer_key(
bearer_key: str,
session: AsyncSession,
refund_address: Optional[str] = None,
key_expiry_time: Optional[int] = None,
min_cost: int = 0,
) -> ApiKey:
"""
Validates the provided API key using SQLModel.
If it's a cashu key, it redeems it and stores its hash and balance.
Otherwise checks if the hash of the key exists.
Includes a balance check against min_cost for limited keys.
"""
logger.debug(
"Starting bearer key validation",
@@ -43,6 +91,7 @@ async def validate_bearer_key(
else bearer_key,
"has_refund_address": bool(refund_address),
"has_expiry_time": bool(key_expiry_time),
"min_cost": min_cost,
},
)
@@ -97,6 +146,50 @@ async def validate_bearer_key(
},
)
# Check and reset limit if needed
await check_and_reset_limit(existing_key, session)
# Early check: Billing balance check (Parent balance)
billing_key = await get_billing_key(existing_key, session)
if min_cost > 0 and billing_key.total_balance < min_cost:
logger.warning(
"Insufficient billing balance during validation",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"balance": billing_key.total_balance,
"required": min_cost,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {min_cost} mSats required for this model. {billing_key.total_balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
# Early check: Spending limit check (Child key limit)
if (
min_cost > 0
and existing_key.balance_limit is not None
and existing_key.total_spent + existing_key.reserved_balance + min_cost
> existing_key.balance_limit
):
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Balance limit exceeded: {existing_key.balance_limit} mSats limit. {existing_key.total_spent} already spent ({existing_key.reserved_balance} reserved), {min_cost} minimum required for this model.",
"type": "insufficient_quota",
"code": "balance_limit_exceeded",
}
},
)
return existing_key
else:
logger.warning(
@@ -152,6 +245,19 @@ async def validate_bearer_key(
},
)
# Early check: Billing balance check
if min_cost > 0 and existing_key.total_balance < min_cost:
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {min_cost} mSats required for this model. {existing_key.total_balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
return existing_key
logger.info(
@@ -242,6 +348,8 @@ async def validate_bearer_key(
)
return new_key
except HTTPException:
raise
except Exception as e:
logger.error(
"Cashu token redemption failed",
@@ -286,30 +394,57 @@ async def validate_bearer_key(
)
async def get_billing_key(key: ApiKey, session: AsyncSession) -> ApiKey:
"""Returns the key that should be charged for the request."""
if key.parent_key_hash:
parent = await session.get(ApiKey, key.parent_key_hash)
if parent:
# We want to keep the total_requests and total_spent on the child key
# but use the balance and reserved_balance of the parent.
# However, pay_for_request updates reserved_balance and total_requests.
# To stay simple, we charge the parent's balance and update parent's total_requests.
return parent
else:
logger.error(
"Parent key not found for child key",
extra={
"child_key_hash": key.hashed_key[:8] + "...",
"parent_key_hash": key.parent_key_hash[:8] + "...",
},
)
return key
async def pay_for_request(
key: ApiKey, cost_per_request: int, session: AsyncSession
) -> int:
"""Process payment for a request."""
# Ensure cost_per_request is at least the minimum allowed request cost
cost_per_request = max(cost_per_request, settings.min_request_msat)
billing_key = await get_billing_key(key, session)
logger.info(
"Processing payment for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"current_balance": key.balance,
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"current_balance": billing_key.balance,
"required_cost": cost_per_request,
"sufficient_balance": key.balance >= cost_per_request,
"sufficient_balance": billing_key.balance >= cost_per_request,
},
)
if key.total_balance < cost_per_request:
if billing_key.total_balance < cost_per_request:
logger.warning(
"Insufficient balance for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"balance": key.balance,
"reserved_balance": key.reserved_balance,
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"balance": billing_key.balance,
"reserved_balance": billing_key.reserved_balance,
"required": cost_per_request,
"shortfall": cost_per_request - key.total_balance,
"shortfall": cost_per_request - billing_key.total_balance,
},
)
@@ -317,26 +452,78 @@ async def pay_for_request(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.total_balance} available. (reserved: {key.reserved_balance})",
"message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.total_balance} available. (reserved: {billing_key.reserved_balance})",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
# Check validity date
if key.validity_date is not None:
if time.time() > key.validity_date:
logger.warning(
"Key validity date expired",
extra={
"key_hash": key.hashed_key[:8] + "...",
"validity_date": key.validity_date,
"current_time": time.time(),
},
)
raise HTTPException(
status_code=403,
detail={
"error": {
"message": "API key has expired (validity date reached).",
"type": "invalid_request_error",
"code": "key_expired",
}
},
)
# Check balance limit for child keys (or any key with a limit)
if key.balance_limit is not None:
await check_and_reset_limit(key, session)
if (
key.total_spent + key.reserved_balance + cost_per_request
> key.balance_limit
):
logger.warning(
"Balance limit exceeded",
extra={
"key_hash": key.hashed_key[:8] + "...",
"total_spent": key.total_spent,
"reserved": key.reserved_balance,
"balance_limit": key.balance_limit,
"required": cost_per_request,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent ({key.reserved_balance} reserved), {cost_per_request} required for this request.",
"type": "insufficient_quota",
"code": "balance_limit_exceeded",
}
},
)
logger.debug(
"Charging base cost for request",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"cost": cost_per_request,
"balance_before": key.balance,
"balance_before": billing_key.balance,
},
)
# Charge the base cost for the request atomically to avoid race conditions
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.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,
@@ -344,6 +531,19 @@ async def pay_for_request(
)
)
result = await session.exec(stmt) # type: ignore[call-overload]
# Also increment total_requests and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
total_requests=col(ApiKey.total_requests) + 1,
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
@@ -351,8 +551,9 @@ async def pay_for_request(
"Concurrent request depleted balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"required_cost": cost_per_request,
"current_balance": key.balance,
"current_balance": billing_key.balance,
},
)
@@ -361,23 +562,26 @@ async def pay_for_request(
status_code=402,
detail={
"error": {
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
"message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance",
}
},
)
await session.refresh(key)
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
logger.info(
"Payment processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"charged_amount": cost_per_request,
"new_balance": key.balance,
"total_spent": key.total_spent,
"total_requests": key.total_requests,
"new_balance": billing_key.balance,
"total_spent": billing_key.total_spent,
"total_requests": billing_key.total_requests,
},
)
@@ -386,10 +590,15 @@ async def pay_for_request(
async def revert_pay_for_request(
key: ApiKey, session: AsyncSession, cost_per_request: int
) -> None:
) -> bool:
"""Revert a previously reserved payment. Returns True if revert succeeded,
False if the reservation was already released (prevents negative reserved_balance)."""
billing_key = await get_billing_key(key, session)
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.where(col(ApiKey.reserved_balance) >= cost_per_request)
.values(
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
total_requests=col(ApiKey.total_requests) - 1,
@@ -397,27 +606,36 @@ async def revert_pay_for_request(
)
result = await session.exec(stmt) # type: ignore[call-overload]
# Also decrement total_requests and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.reserved_balance) >= cost_per_request)
.values(
total_requests=col(ApiKey.total_requests) - 1,
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to revert payment - insufficient reserved balance",
logger.warning(
"Revert skipped - reservation already released (no-op to prevent negative reserved_balance)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"cost_to_revert": cost_per_request,
"current_reserved_balance": key.reserved_balance,
"current_reserved_balance": billing_key.reserved_balance,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"failed to revert request payment: {cost_per_request} mSats required. {key.balance} available.",
"type": "payment_error",
"code": "payment_error",
}
},
)
await session.refresh(key)
return False
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
return True
async def adjust_payment_for_tokens(
@@ -428,15 +646,17 @@ async def adjust_payment_for_tokens(
This is called after the initial payment and the upstream request is complete.
Returns cost data to be included in the response.
"""
billing_key = await get_billing_key(key, session)
model = response_data.get("model", "unknown")
logger.debug(
"Starting payment adjustment for tokens",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
"deducted_max_cost": deducted_max_cost,
"current_balance": key.balance,
"current_balance": billing_key.balance,
"has_usage": "usage" in response_data,
},
)
@@ -446,22 +666,54 @@ async def adjust_payment_for_tokens(
try:
release_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
.values(
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost
)
)
await session.exec(release_stmt) # type: ignore[call-overload]
result = await session.exec(release_stmt) # type: ignore[call-overload]
# Also release on child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_release_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost
)
)
await session.exec(child_release_stmt) # type: ignore[call-overload]
await session.commit()
logger.warning(
"Released reservation without charging (fallback)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
},
)
if result.rowcount == 0: # type: ignore[union-attr]
logger.warning(
"Release reservation skipped - already released (no-op to prevent negative reserved_balance)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
},
)
else:
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] + "..."},
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):
@@ -470,6 +722,7 @@ async def adjust_payment_for_tokens(
"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,
},
@@ -477,7 +730,7 @@ async def adjust_payment_for_tokens(
# Finalize by releasing reservation and charging max cost
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
balance=col(ApiKey.balance) - cost.total_msats,
@@ -485,27 +738,45 @@ async def adjust_payment_for_tokens(
)
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
total_spent=col(ApiKey.total_spent) + cost.total_msats,
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to finalize max-cost payment - retrying reservation release",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
"current_reserved_balance": key.reserved_balance,
"current_reserved_balance": billing_key.reserved_balance,
"total_cost": cost.total_msats,
"model": model,
},
)
await release_reservation_only()
else:
await session.refresh(key)
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": key.balance,
"new_balance": billing_key.balance,
"model": model,
},
)
@@ -521,6 +792,7 @@ async def adjust_payment_for_tokens(
"Calculated token-based cost",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
"token_cost": cost.total_msats,
"deducted_max_cost": deducted_max_cost,
@@ -533,11 +805,15 @@ async def adjust_payment_for_tokens(
if cost_difference == 0:
logger.debug(
"Finalizing with exact reserved cost",
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
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) == key.hashed_key)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
@@ -546,8 +822,24 @@ async def adjust_payment_for_tokens(
)
)
await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
total_spent=col(ApiKey.total_spent) + total_cost_msats,
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
await session.refresh(key)
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???
@@ -557,16 +849,17 @@ async def adjust_payment_for_tokens(
"Additional charge required for token usage",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"additional_charge": cost_difference,
"current_balance": key.balance,
"sufficient_balance": key.balance >= cost_difference,
"current_balance": billing_key.balance,
"sufficient_balance": billing_key.balance >= cost_difference,
"model": model,
},
)
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
@@ -575,18 +868,35 @@ async def adjust_payment_for_tokens(
)
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
total_spent=col(ApiKey.total_spent) + total_cost_msats,
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount:
cost.total_msats = total_cost_msats
await session.refresh(key)
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": key.balance,
"new_balance": billing_key.balance,
"model": model,
},
)
@@ -595,6 +905,7 @@ async def adjust_payment_for_tokens(
"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,
},
@@ -607,15 +918,16 @@ async def adjust_payment_for_tokens(
"Refunding excess payment",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"refund_amount": refund,
"current_balance": key.balance,
"current_balance": billing_key.balance,
"model": model,
},
)
refund_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
@@ -624,6 +936,20 @@ async def adjust_payment_for_tokens(
)
)
result = await session.exec(refund_stmt) # type: ignore[call-overload]
# Also update total_spent and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
total_spent=col(ApiKey.total_spent) + total_cost_msats,
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
@@ -631,8 +957,9 @@ async def adjust_payment_for_tokens(
"Failed to finalize payment - releasing reservation",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
"current_reserved_balance": key.reserved_balance,
"current_reserved_balance": billing_key.reserved_balance,
"total_cost": total_cost_msats,
"model": model,
},
@@ -640,14 +967,17 @@ async def adjust_payment_for_tokens(
await release_reservation_only()
else:
cost.total_msats = total_cost_msats
await session.refresh(key)
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": key.balance,
"new_balance": billing_key.balance,
"final_cost": cost.total_msats,
"model": model,
},
@@ -677,15 +1007,67 @@ async def adjust_payment_for_tokens(
}
},
)
# Fallback: should not reach here, but release reservation just in case
logger.error(
"Unexpected fallback in adjust_payment_for_tokens - releasing reservation",
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
)
await release_reservation_only()
return {
"base_msats": deducted_max_cost,
"input_msats": 0,
"output_msats": 0,
"total_msats": deducted_max_cost,
}
# All calculate_cost variants are handled above.
raise AssertionError("Unreachable: unhandled calculate_cost result")
async def periodic_key_reset() -> None:
"""Background task to reset key limits based on their policy."""
from .core.db import create_session
while True:
try:
interval = 3600 # Run every hour
jitter = 300
await asyncio.sleep(interval + random.uniform(0, jitter))
except asyncio.CancelledError:
break
try:
async with create_session() as session:
# Find all keys that have a reset policy
stmt = select(ApiKey).where(ApiKey.balance_limit_reset.is_not(None)) # type: ignore
keys = (await session.exec(stmt)).all()
now = int(time.time())
updated_count = 0
for key in keys:
reset_date = key.balance_limit_reset_date or 0
should_reset = False
if key.balance_limit_reset == "daily":
if (
datetime.fromtimestamp(now).date()
> datetime.fromtimestamp(reset_date).date()
):
should_reset = True
elif key.balance_limit_reset == "weekly":
if (
datetime.fromtimestamp(now).isocalendar()[:2]
> datetime.fromtimestamp(reset_date).isocalendar()[:2]
):
should_reset = True
elif key.balance_limit_reset == "monthly":
dt_now = datetime.fromtimestamp(now)
dt_reset = datetime.fromtimestamp(reset_date)
if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month:
should_reset = True
if should_reset:
key.total_spent = 0
key.balance_limit_reset_date = now
session.add(key)
updated_count += 1
if updated_count > 0:
await session.commit()
logger.info(
"Periodic key reset complete",
extra={"keys_reset": updated_count},
)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in periodic_key_reset: {e}")

View File

@@ -1,12 +1,14 @@
import asyncio
import hashlib
import time
from time import monotonic
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from sqlmodel import select
from .auth import validate_bearer_key
from .auth import get_billing_key, validate_bearer_key
from .core.db import ApiKey, AsyncSession, get_session
from .core.logging import get_logger
from .core.settings import settings
@@ -32,14 +34,49 @@ async def get_key_from_header(
)
async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
billing_key = await get_billing_key(key, session)
info = {
"api_key": "sk-" + key.hashed_key,
"balance": billing_key.balance,
"reserved": billing_key.reserved_balance,
"is_child": key.parent_key_hash is not None,
"parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None,
"total_requests": key.total_requests,
"total_spent": key.total_spent,
"balance_limit": key.balance_limit,
"balance_limit_reset": key.balance_limit_reset,
"validity_date": key.validity_date,
}
if not key.parent_key_hash:
# Fetch child keys if this is a parent key
statement = select(ApiKey).where(ApiKey.parent_key_hash == key.hashed_key)
results = await session.exec(statement)
child_keys = results.all()
if child_keys:
info["child_keys"] = [
{
"api_key": "sk-" + ck.hashed_key,
"total_requests": ck.total_requests,
"total_spent": ck.total_spent,
"balance_limit": ck.balance_limit,
"balance_limit_reset": ck.balance_limit_reset,
"validity_date": ck.validity_date,
}
for ck in child_keys
]
return info
# TODO: remove this endpoint when frontend is updated
@router.get("/", include_in_schema=False)
async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
"reserved": key.reserved_balance,
}
async def account_info(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
return await get_balance_info(key, session)
# TODO: Implement POST /v1/wallet/create endpoint
@@ -56,9 +93,24 @@ async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
@router.get("/create")
async def create_balance(
initial_balance_token: str, session: AsyncSession = Depends(get_session)
initial_balance_token: str,
balance_limit: int | None = None,
balance_limit_reset: str | None = None,
validity_date: int | None = None,
session: AsyncSession = Depends(get_session),
) -> dict:
key = await validate_bearer_key(initial_balance_token, session)
if balance_limit is not None or balance_limit_reset or validity_date:
key.balance_limit = balance_limit
key.balance_limit_reset = balance_limit_reset
key.validity_date = validity_date
if balance_limit_reset:
key.balance_limit_reset_date = int(time.time())
session.add(key)
await session.commit()
await session.refresh(key)
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
@@ -66,12 +118,11 @@ async def create_balance(
@router.get("/info")
async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
"reserved": key.reserved_balance,
}
async def wallet_info(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
return await get_balance_info(key, session)
class TopupRequest(BaseModel):
@@ -85,6 +136,8 @@ async def topup_wallet_endpoint(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict[str, int]:
billing_key = await get_billing_key(key, session)
if topup_request is not None:
cashu_token = topup_request.cashu_token
if cashu_token is None:
@@ -94,7 +147,7 @@ async def topup_wallet_endpoint(
if len(cashu_token) < 10 or "cashu" not in cashu_token:
raise HTTPException(status_code=400, detail="Invalid token format")
try:
amount_msats = await credit_balance(cashu_token, key, session)
amount_msats = await credit_balance(cashu_token, billing_key, session)
except ValueError as e:
error_msg = str(e)
if "already spent" in error_msg.lower():
@@ -150,11 +203,18 @@ async def refund_wallet_endpoint(
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.total_balance <= 0:
if cached := await _refund_cache_get(bearer_value):
return cached
if key.parent_key_hash:
raise HTTPException(
status_code=400,
detail="Cannot refund child key. Please refund the parent key instead.",
)
remaining_balance_msats: int = key.total_balance
if key.refund_currency == "sat":
@@ -209,7 +269,9 @@ async def refund_wallet_endpoint(
await _refund_cache_set(bearer_value, result)
await session.delete(key)
key.balance = 0
key.reserved_balance = 0
session.add(key)
await session.commit()
return result
@@ -228,6 +290,117 @@ async def donate(token: str, ref: str | None = None) -> str:
return "Invalid token."
class ChildKeyRequest(BaseModel):
count: int
balance_limit: int | None = None
balance_limit_reset: str | None = None
validity_date: int | None = None
@router.post("/child-key")
async def create_child_key(
payload: ChildKeyRequest,
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
"""Creates one or more child API keys that use the parent's balance."""
# Log incoming request for debugging
logger.debug(f"Child key creation request: count={payload.count}")
count = payload.count
if count < 1 or count > 50:
raise HTTPException(status_code=400, detail="Count must be between 1 and 50.")
# Check if this is already a child key
if key.parent_key_hash:
raise HTTPException(
status_code=400,
detail="Cannot create a child key for another child key.",
)
cost_per_key = settings.child_key_cost
total_cost = cost_per_key * count
if key.total_balance < total_cost:
raise HTTPException(
status_code=402,
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
)
# Deduct cost from parent
key.balance -= total_cost
key.total_spent += total_cost
session.add(key)
# Generate new keys
import secrets
new_keys = []
for _ in range(count):
new_key_raw = secrets.token_hex(32)
new_key_hash = new_key_raw # We use the raw key as the hash for sk- keys
child_key = ApiKey(
hashed_key=new_key_hash,
balance=0,
parent_key_hash=key.hashed_key,
balance_limit=payload.balance_limit,
balance_limit_reset=payload.balance_limit_reset,
balance_limit_reset_date=int(time.time())
if payload.balance_limit_reset
else None,
validity_date=payload.validity_date,
)
session.add(child_key)
new_keys.append("sk-" + new_key_hash)
await session.commit()
response_data = {
"api_keys": new_keys,
"count": count,
"cost_msats": total_cost,
"cost_sats": total_cost // 1000,
"parent_balance": key.balance,
"parent_balance_sats": key.balance // 1000,
}
logger.debug(f"Child key creation response: {response_data}")
return response_data
class ChildKeyResetRequest(BaseModel):
child_key: str
@router.post("/child-key/reset")
async def reset_child_key_spent(
payload: ChildKeyResetRequest,
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
"""Resets the total_spent of a child key. Must be called by the parent."""
child_key_raw = payload.child_key
if child_key_raw.startswith("sk-"):
child_key_raw = child_key_raw[3:]
child_key = await session.get(ApiKey, child_key_raw)
if not child_key:
raise HTTPException(status_code=404, detail="Child key not found.")
if child_key.parent_key_hash != key.hashed_key:
raise HTTPException(
status_code=403, detail="Unauthorized. You are not the parent of this key."
)
child_key.total_spent = 0
if child_key.balance_limit_reset:
child_key.balance_limit_reset_date = int(time.time())
session.add(child_key)
await session.commit()
return {"success": True, "message": "Child key balance reset successfully."}
@router.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "DELETE"],

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,13 @@
import os
import pathlib
import sqlite3
import time
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from alembic import command
from alembic.config import Config
from sqlalchemy import UniqueConstraint
from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlmodel import Field, Relationship, SQLModel, func, select, update
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -47,6 +50,25 @@ class ApiKey(SQLModel, table=True): # type: ignore
default=None,
description="Currency of the cashu-token",
)
parent_key_hash: str | None = Field(
default=None, foreign_key="api_keys.hashed_key", index=True
)
balance_limit: int | None = Field(
default=None,
description="Max spendable balance in msats for this key (mostly for child keys)",
)
balance_limit_reset: str | None = Field(
default=None,
description="Reset policy for balance limit (manual, daily, monthly, etc.)",
)
balance_limit_reset_date: int | None = Field(
default=None,
description="Unix timestamp of the last time the balance limit was reset",
)
validity_date: int | None = Field(
default=None,
description="Unix timestamp after which the key is no longer valid",
)
@property
def total_balance(self) -> int:
@@ -108,11 +130,16 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
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(unique=True, description="Base URL of the upstream API")
base_url: str = Field(description="Base URL of the upstream API")
api_key: str = Field(description="API key for the upstream provider")
api_version: str | None = Field(
default=None, description="API version for Azure OpenAI"
@@ -121,6 +148,9 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
provider_fee: float = Field(
default=1.01, description="Provider fee multiplier (default 1%)"
)
provider_settings: str | None = Field(
default=None, description="JSON string for provider-specific settings"
)
models: list["ModelRow"] = Relationship(
back_populates="upstream_provider",
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
@@ -156,11 +186,53 @@ async def create_session() -> AsyncGenerator[AsyncSession, None]:
yield session
def fix_cashu_migrations() -> None:
"""
Fixes Cashu wallet migrations that are not idempotent.
This specifically addresses the 'duplicate column name: public_keys' error
in the keysets table of Cashu's internal SQLite databases.
"""
project_root = pathlib.Path(__file__).resolve().parents[2]
wallet_dir = project_root / ".wallet"
if not wallet_dir.exists() or not wallet_dir.is_dir():
return
logger.info("Checking Cashu wallet databases for migration idempotency")
for db_file in wallet_dir.glob("*.sqlite3"):
try:
conn = sqlite3.connect(db_file)
cursor = conn.cursor()
# Check if keysets table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='keysets'"
)
if not cursor.fetchone():
conn.close()
continue
# Check if public_keys column exists
cursor.execute("PRAGMA table_info(keysets)")
columns = [info[1] for info in cursor.fetchall()]
if "public_keys" not in columns:
logger.info(f"Adding missing public_keys column to {db_file.name}")
cursor.execute("ALTER TABLE keysets ADD COLUMN public_keys TEXT")
conn.commit()
conn.close()
except Exception as e:
logger.warning(f"Could not check/fix Cashu database {db_file}: {e}")
def run_migrations() -> None:
"""Run Alembic migrations programmatically."""
import pathlib
try:
# Run Cashu migration fix first
fix_cashu_migrations()
# Get the path to the alembic.ini file
project_root = pathlib.Path(__file__).resolve().parents[2]
alembic_ini_path = project_root / "alembic.ini"

View File

@@ -6,6 +6,15 @@ from .logging import get_logger
logger = get_logger(__name__)
class UpstreamError(Exception):
"""Exception raised when an upstream provider fails."""
def __init__(self, message: str, status_code: int = 502):
self.message = message
self.status_code = status_code
super().__init__(message)
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle HTTP exceptions and include request ID in response."""
request_id = getattr(request.state, "request_id", "unknown")

View File

@@ -19,6 +19,7 @@ class LogManager:
specific_date: str | None = None,
reverse_files: bool = False,
max_files: int | None = None,
window_center: datetime | None = None,
) -> Iterator[dict[str, Any]]:
"""
Yields log entries from files.
@@ -28,6 +29,7 @@ class LogManager:
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).
window_center: datetime object to center a 5-month window around.
"""
if not self.logs_dir.exists():
return
@@ -41,6 +43,36 @@ class LogManager:
log_files.append(log_file)
else:
log_files = sorted(self.logs_dir.glob("app_*.log"))
if window_center:
# Calculate the 5 months: [center-2, center-1, center, center+1, center+2]
allowed_month_years = []
cur_m = window_center.month
cur_y = window_center.year
for offset in range(-2, 3):
m = cur_m + offset
y = cur_y
while m <= 0:
m += 12
y -= 1
while m > 12:
m -= 12
y += 1
allowed_month_years.append(f"{y}-{m:02d}")
filtered_files = []
for log_path in log_files:
try:
# Stem is "app_YYYY-MM-DD"
file_date_str = log_path.stem.split("_")[1]
file_month_year = file_date_str[:7] # YYYY-MM
if file_month_year in allowed_month_years:
filtered_files.append(log_path)
except Exception:
continue
log_files = filtered_files
if reverse_files:
log_files.reverse()
@@ -217,11 +249,19 @@ class LogManager:
return True
def get_usage_summary(self, hours: int = 24) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
entries = list(
self._yield_log_entries(
hours_back=hours, window_center=datetime.now(timezone.utc)
)
)
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))
entries = list(
self._yield_log_entries(
hours_back=hours, window_center=datetime.now(timezone.utc)
)
)
return self._aggregate_metrics_by_time(entries, interval, hours)
def get_error_details(self, hours: int = 24, limit: int = 100) -> dict:
@@ -236,7 +276,9 @@ class LogManager:
# Let's just stick to PR 229 logic which filters 'ERROR' level.
entries = self._yield_log_entries(hours_back=hours) # oldest to newest
entries = self._yield_log_entries(
hours_back=hours, window_center=datetime.now(timezone.utc)
) # oldest to newest
for entry in entries:
if entry.get("levelname", "").upper() == "ERROR":
@@ -257,7 +299,11 @@ class LogManager:
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))
entries = list(
self._yield_log_entries(
hours_back=hours, window_center=datetime.now(timezone.utc)
)
)
model_stats: dict[str, dict[str, int | float]] = defaultdict(
lambda: {

View File

@@ -10,15 +10,14 @@ from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException
from ..auth import periodic_key_reset
from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_cache_refresher, providers_router
from ..nip91 import announce_provider
from ..payment.models import (
models_router,
update_sats_pricing,
)
from ..nostr import announce_provider, providers_cache_refresher
from ..nostr.discovery import providers_router
from ..payment.models import models_router, update_sats_pricing
from ..payment.price import update_prices_periodically
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
from ..upstream.auto_topup import periodic_auto_topup
from ..wallet import periodic_payout
from .admin import admin_router
from .db import create_session, init_db, run_migrations
@@ -33,9 +32,9 @@ setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.2.2-{os.getenv('VERSION_SUFFIX')}"
__version__ = f"0.3.0-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.2.2"
__version__ = "0.3.0"
@asynccontextmanager
@@ -49,6 +48,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
providers_task = None
models_refresh_task = None
model_maps_refresh_task = None
key_reset_task = None
auto_topup_task = None
try:
# Run database migrations on startup
@@ -66,6 +67,11 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
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
@@ -99,6 +105,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
nip91_task = asyncio.create_task(announce_provider())
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
key_reset_task = asyncio.create_task(periodic_key_reset())
auto_topup_task = asyncio.create_task(periodic_auto_topup())
yield
@@ -128,6 +136,10 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
models_refresh_task.cancel()
if model_maps_refresh_task is not None:
model_maps_refresh_task.cancel()
if key_reset_task is not None:
key_reset_task.cancel()
if auto_topup_task is not None:
auto_topup_task.cancel()
try:
tasks_to_wait = []
@@ -145,6 +157,10 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
tasks_to_wait.append(models_refresh_task)
if model_maps_refresh_task is not None:
tasks_to_wait.append(model_maps_refresh_task)
if key_reset_task is not None:
tasks_to_wait.append(key_reset_task)
if auto_topup_task is not None:
tasks_to_wait.append(auto_topup_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
@@ -186,6 +202,7 @@ async def info() -> dict:
"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,
}

View File

@@ -52,6 +52,7 @@ class Settings(BaseSettings):
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
child_key_cost: int = Field(default=0, env="CHILD_KEY_COST")
# Minimum per-request charge in millisatoshis when model pricing is free/zero
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
reset_reserved_balance_on_startup: bool = Field(
@@ -62,7 +63,7 @@ class Settings(BaseSettings):
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL")
providers_refresh_interval_seconds: int = Field(
default=300, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
default=0, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
)
pricing_refresh_interval_seconds: int = Field(
default=120, env="PRICING_REFRESH_INTERVAL_SECONDS"
@@ -142,7 +143,7 @@ def resolve_bootstrap() -> Settings:
pass
if not base.onion_url:
try:
from ..nip91 import discover_onion_url_from_tor # type: ignore
from ..nostr.listing import discover_onion_url_from_tor # type: ignore
discovered = discover_onion_url_from_tor()
if discovered:

View File

@@ -23,6 +23,9 @@ class InvoiceCreateRequest(BaseModel):
api_key: str | None = Field(
default=None, description="Required for topup operations"
)
balance_limit: int | None = Field(default=None)
balance_limit_reset: str | None = Field(default=None)
validity_date: int | None = Field(default=None)
class InvoiceCreateResponse(BaseModel):
@@ -94,6 +97,9 @@ async def create_invoice(
status="pending",
api_key_hash=request.api_key[3:] if request.api_key else None,
purpose=request.purpose,
balance_limit=request.balance_limit,
balance_limit_reset=request.balance_limit_reset,
validity_date=request.validity_date,
expires_at=expires_at,
)

View File

@@ -0,0 +1,4 @@
from .discovery import providers_cache_refresher
from .listing import announce_provider
__all__ = ["providers_cache_refresher", "announce_provider"]

View File

@@ -6,10 +6,10 @@ from typing import Any
import httpx
import websockets
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException
from .core.logging import get_logger
from .core.settings import settings
from ..core.logging import get_logger
from ..core.settings import settings
logger = get_logger(__name__)
@@ -72,8 +72,6 @@ async def query_nostr_relay_for_providers(
elif data[0] == "NOTICE":
try:
msg = str(data[1])
if len(msg) > 200:
msg = msg[:200] + "..."
logger.debug(f"Relay notice: {msg}")
except Exception:
logger.debug("Relay notice received")
@@ -389,6 +387,9 @@ async def get_providers(
Return cached providers. If include_json, return provider+health; otherwise provider only.
Optional filter by pubkey.
"""
if settings.providers_refresh_interval_seconds == 0:
raise HTTPException(status_code=404, detail="Provider discovery is disabled")
cache = await get_cache()
if not cache:
await refresh_providers_cache(pubkey=pubkey)

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
NIP-91: Routstr Provider Discoverability Implementation
Listing: Routstr Provider Discoverability Implementation
Automatically announces this Routstr proxy instance to Nostr relays.
"""
@@ -18,15 +18,15 @@ from nostr.key import PrivateKey
from nostr.message_type import ClientMessageType
from nostr.relay_manager import RelayManager
from .core import get_logger
from .core.settings import settings
from ..core import get_logger
from ..core.settings import settings
logger = get_logger(__name__)
def get_app_version() -> str | None:
try:
from .core.main import __version__ as imported_version
from ..core.main import __version__ as imported_version
return imported_version
except Exception:
@@ -71,7 +71,7 @@ def nsec_to_keypair(nsec: str) -> tuple[str, str] | None:
return None
def create_nip91_event(
def create_listing_event(
private_key_hex: str,
provider_id: str,
endpoint_urls: list[str],
@@ -80,7 +80,7 @@ def create_nip91_event(
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Create a NIP-91 compliant provider announcement event (kind:38421).
Create a listing provider announcement event (kind:38421).
Args:
private_key_hex: 32-byte hex private key for signing
@@ -164,14 +164,14 @@ def events_semantically_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
return True
async def query_nip91_events(
async def query_listing_events(
relay_url: str,
pubkey: str,
provider_id: str | None = None,
timeout: int = 30,
) -> tuple[list[dict[str, Any]], bool]:
"""
Query a Nostr relay for NIP-91 provider announcements (kind:38421) via nostr library.
Query a Nostr relay for listing provider announcements (kind:38421) via nostr library.
Returns a tuple of (events, ok) where ok indicates whether the relay interaction
succeeded without transport-level errors.
@@ -188,7 +188,7 @@ async def query_nip91_events(
flt = Filter(kinds=[38421], authors=[pubkey], limit=10)
filters = Filters([flt])
sub_id = f"nip91_{int(time.time())}"
sub_id = f"routstr_listing_{int(time.time())}"
rm.add_subscription(sub_id, filters)
req: list[Any] = [ClientMessageType.REQUEST, sub_id]
req.extend(filters.to_json_array())
@@ -294,7 +294,7 @@ async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) ->
async def query_single_relay(relay_url: str) -> list[dict[str, Any]]:
try:
events, _ok = await query_nip91_events(relay_url, public_key_hex, None)
events, _ok = await query_listing_events(relay_url, public_key_hex, None)
return events
except Exception:
return []
@@ -330,7 +330,7 @@ async def publish_to_relay(
timeout: int = 30,
) -> bool:
"""
Publish a NIP-91 event to a nostr relay via nostr library.
Publish a listing event to a nostr relay via nostr library.
"""
def _sync_publish() -> bool:
@@ -341,7 +341,7 @@ async def publish_to_relay(
time.sleep(1.0)
# Publish the event as-is via publish_message to preserve signature
rm.publish_message(json.dumps(["EVENT", event]))
logger.debug(f"Sent NIP-91 event {event.get('id', '')} to {relay_url}")
logger.debug(f"Sent listing event {event.get('id', '')} to {relay_url}")
time.sleep(1.0)
return True
except Exception as e:
@@ -364,13 +364,13 @@ async def announce_provider() -> None:
# Check for NSEC in environment (use NSEC only)
nsec = settings.nsec
if not nsec:
logger.info("Nostr private key not found (NSEC), skipping NIP-91 announcement")
logger.info("Nostr private key not found (NSEC), skipping listing announcement")
return
# Convert NSEC to keypair
keypair = nsec_to_keypair(nsec)
if not keypair:
logger.error("Failed to parse NSEC, skipping NIP-91 announcement")
logger.error("Failed to parse NSEC, skipping listing announcement")
return
private_key_hex, public_key_hex = keypair
@@ -409,7 +409,7 @@ async def announce_provider() -> None:
if not endpoint_urls:
logger.warning(
"No valid endpoints configured (HTTP_URL/ONION_URL). Skipping NIP-91 publish."
"No valid endpoints configured (HTTP_URL/ONION_URL). Skipping listing publish."
)
return
@@ -434,7 +434,7 @@ async def announce_provider() -> None:
# Create the candidate event that we would publish
version_str = get_app_version()
candidate_event = create_nip91_event(
candidate_event = create_listing_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
@@ -474,7 +474,7 @@ async def announce_provider() -> None:
if _should_skip(relay_url):
logger.debug(f"Skipping {relay_url} due to backoff")
continue
events, ok = await query_nip91_events(relay_url, public_key_hex, provider_id)
events, ok = await query_listing_events(relay_url, public_key_hex, provider_id)
if ok:
_register_success(relay_url)
existing_events.extend(events)
@@ -489,7 +489,7 @@ async def announce_provider() -> None:
if not all_match:
logger.debug(
"No matching NIP-91 announcement found or differences detected; publishing update"
"No matching listing announcement found or differences detected; publishing update"
)
success_count = 0
for relay_url in relay_urls:
@@ -502,11 +502,11 @@ async def announce_provider() -> None:
else:
_register_failure(relay_url)
logger.info(
f"Published NIP-91 announcement to {success_count}/{len(relay_urls)} relays"
f"Published listing announcement to {success_count}/{len(relay_urls)} relays"
)
else:
logger.debug(
"Matching NIP-91 announcement already present; skipping publish on startup"
"Matching listing announcement already present; skipping publish on startup"
)
# Re-announce periodically (every 24 hours)
@@ -518,7 +518,7 @@ async def announce_provider() -> None:
# Build fresh candidate event for comparison
version_str = get_app_version()
candidate_event = create_nip91_event(
candidate_event = create_listing_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
@@ -533,7 +533,7 @@ async def announce_provider() -> None:
if _should_skip(relay_url):
logger.debug(f"Skipping {relay_url} due to backoff")
continue
events, ok = await query_nip91_events(
events, ok = await query_listing_events(
relay_url, public_key_hex, provider_id
)
if ok:
@@ -549,7 +549,7 @@ async def announce_provider() -> None:
if all_match:
logger.debug(
"Matching NIP-91 announcement already present; skipping periodic re-announce"
"Matching listing announcement already present; skipping periodic re-announce"
)
continue
@@ -567,8 +567,8 @@ async def announce_provider() -> None:
_register_failure(relay_url)
except asyncio.CancelledError:
logger.info("NIP-91 announcement task cancelled")
logger.info("Listing announcement task cancelled")
break
except Exception as e:
logger.debug(f"Error in NIP-91 announcement loop: {type(e).__name__}")
logger.debug(f"Error in listing announcement loop: {type(e).__name__}")
# Continue running despite errors

View File

@@ -15,6 +15,7 @@ class CostData(BaseModel):
input_msats: int
output_msats: int
total_msats: int
total_usd: float = 0.0
class MaxCostData(CostData):
@@ -61,6 +62,7 @@ async def calculate_cost( # todo: can be sync
input_msats=0,
output_msats=0,
total_msats=0,
total_usd=0.0,
)
usage_data = response_data["usage"]
@@ -101,6 +103,7 @@ async def calculate_cost( # todo: can be sync
input_msats=-1, # Cost field doesn't break down by token type
output_msats=-1,
total_msats=cost_in_msats,
total_usd=usd_cost,
)
except Exception as e:
logger.warning(
@@ -210,6 +213,7 @@ async def calculate_cost( # todo: can be sync
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(input_msats + output_msats)
total_usd = (token_based_cost / 1000.0) * sats_usd_price()
logger.info(
"Calculated token-based cost",
@@ -219,6 +223,7 @@ async def calculate_cost( # todo: can be sync
"input_cost_msats": input_msats,
"output_cost_msats": output_msats,
"total_cost_msats": token_based_cost,
"total_usd": total_usd,
"model": response_data.get("model", "unknown"),
},
)
@@ -228,4 +233,5 @@ async def calculate_cost( # todo: can be sync
input_msats=int(input_msats),
output_msats=int(output_msats),
total_msats=token_based_cost,
total_usd=total_usd,
)

View File

@@ -29,15 +29,13 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
},
)
elif auth := headers.get("authorization", None):
cashu_token = auth.split(" ")[1] if len(auth.split(" ")) > 1 else ""
logger.debug(
"Using Authorization header token",
"Skipping preflight token balance check for Authorization header",
extra={
"token_preview": cashu_token[:20] + "..."
if len(cashu_token) > 20
else cashu_token
"auth_preview": auth[:20] + "..." if len(auth) > 20 else auth,
},
)
return
else:
logger.error("No authentication token provided")
raise HTTPException(status_code=401, detail="Unauthorized")
@@ -75,7 +73,7 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
if max_cost_for_model > amount_msat:
raise HTTPException(
status_code=413,
status_code=402,
detail={
"reason": "Insufficient balance",
"amount_required_msat": max_cost_for_model,
@@ -169,9 +167,32 @@ async def calculate_discounted_max_cost(
tol = settings.tolerance_percentage
tol_factor = max(0.0, 1 - float(tol) / 100.0)
max_prompt_allowed_sats = model_pricing.max_prompt_cost * tol_factor
max_completion_allowed_sats = model_pricing.max_completion_cost * tol_factor
if model_obj:
prompt_token_limit: int | None = None
if model_obj.top_provider and (
model_obj.top_provider.context_length
or model_obj.top_provider.max_completion_tokens
):
cl = model_obj.top_provider.context_length
mct = model_obj.top_provider.max_completion_tokens
if cl and mct:
prompt_token_limit = max(0, cl - mct)
elif cl:
prompt_token_limit = cl
elif mct:
prompt_token_limit = 0
elif model_obj.context_length:
prompt_token_limit = model_obj.context_length
if prompt_token_limit is not None:
max_prompt_allowed_sats = (
prompt_token_limit * model_pricing.prompt * tol_factor
)
adjusted = max_cost_for_model
if messages := body.get("messages"):

View File

@@ -25,82 +25,6 @@ class LNURLError(Exception):
"""LNURL related errors."""
def parse_lightning_invoice_amount(invoice: str, currency: str = "sat") -> int:
"""Parse Lightning invoice (BOLT-11) to extract amount in specified currency units.
Args:
invoice: BOLT-11 Lightning invoice string
currency: Target currency unit ("sat" or "msat")
Returns:
Amount in the specified currency unit
Raises:
LNURLError: If invoice format is invalid or amount cannot be parsed
"""
invoice = invoice.lower().strip()
if not invoice.startswith("ln"):
raise LNURLError("Invalid Lightning invoice format")
# Find the network part (bc, tb, etc.)
network_start = 2
while network_start < len(invoice) and invoice[network_start] not in "0123456789":
network_start += 1
if network_start >= len(invoice):
raise LNURLError("Invalid Lightning invoice format")
# Parse amount and multiplier
amount_str = ""
multiplier = ""
i = network_start
# Extract numeric part
while i < len(invoice) and invoice[i].isdigit():
amount_str += invoice[i]
i += 1
# Extract multiplier if present
if i < len(invoice) and invoice[i] in "munp":
multiplier = invoice[i]
i += 1
# Check if we have the required "1" separator
if i >= len(invoice) or invoice[i] != "1":
raise LNURLError("Invalid Lightning invoice format")
if not amount_str:
raise LNURLError("Lightning invoice amount not specified")
# Convert to base units
try:
amount = int(amount_str)
except ValueError:
raise LNURLError("Invalid Lightning invoice amount")
# Apply multiplier to get millisatoshis
if multiplier == "m": # milli = 10^-3
amount_msat = amount * 100_000_000 # amount is in BTC * 10^-3
elif multiplier == "u": # micro = 10^-6
amount_msat = amount * 100_000 # amount is in BTC * 10^-6
elif multiplier == "n": # nano = 10^-9
amount_msat = amount * 100 # amount is in BTC * 10^-9
elif multiplier == "p": # pico = 10^-12
amount_msat = amount // 10 # amount is in BTC * 10^-12
else:
# No multiplier means the amount is in BTC
amount_msat = amount * 100_000_000_000 # Convert BTC to msat
# Convert to target currency unit
if currency == "msat":
return amount_msat
elif currency == "sat":
return amount_msat // 1000
else:
raise LNURLError(f"Unsupported currency for Lightning: {currency}")
async def decode_lnurl(lnurl: str) -> str:
"""Decode LNURL to get the actual URL.
@@ -291,9 +215,7 @@ async def raw_send_to_lnurl(
lnurl_data["callback_url"], final_amount
)
melt_quote_resp = await wallet.melt_quote(
invoice=bolt11_invoice, amount_msat=final_amount
)
melt_quote_resp = await wallet.melt_quote(invoice=bolt11_invoice)
if amount:
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)

View File

@@ -143,14 +143,6 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
return []
def is_openrouter_upstream() -> bool:
try:
base = (settings.upstream_base_url or "").strip().rstrip("/")
except Exception:
return False
return base.lower() == "https://openrouter.ai/api/v1"
def _row_to_model(
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
) -> Model:
@@ -203,33 +195,11 @@ def _row_to_model(
return model
def _model_to_row_payload(model: Model) -> dict[str, str | int | bool | None]:
return {
"id": model.id,
"name": model.name,
"created": model.created,
"description": model.description,
"context_length": model.context_length,
"architecture": json.dumps(model.architecture.dict()),
"pricing": json.dumps(model.pricing.dict()),
"sats_pricing": json.dumps(model.sats_pricing.dict())
if model.sats_pricing
else None,
"per_request_limits": json.dumps(model.per_request_limits)
if model.per_request_limits is not None
else None,
"top_provider": json.dumps(model.top_provider.dict())
if model.top_provider is not None
else None,
"enabled": model.enabled,
"upstream_provider_id": model.upstream_provider_id,
}
async def list_models(
session: AsyncSession,
upstream_id: int,
include_disabled: bool = False,
apply_fees: bool = True,
) -> list[Model]:
from sqlmodel import select
@@ -247,28 +217,20 @@ async def list_models(
return [
_row_to_model(
r,
apply_provider_fee=True,
apply_provider_fee=apply_fees,
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
if r.upstream_provider_id in providers_by_id
else 1.01,
)
for r in rows
if include_disabled
or (
r.upstream_provider_id in providers_by_id
and providers_by_id[r.upstream_provider_id].enabled
)
]
async def get_model_by_id(
model_id: str, provider_id: int, session: AsyncSession
) -> Model | None:
from ..core.db import UpstreamProviderRow
row = await session.get(ModelRow, (model_id, provider_id))
if not row or not row.enabled:
return None
provider = await session.get(UpstreamProviderRow, provider_id)
provider_fee = provider.provider_fee if provider else 1.01
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
"""Calculate max costs in USD based on model context/token limits.

View File

@@ -79,15 +79,29 @@ async def _fetch_btc_usd_price() -> float:
"""Fetch the lowest BTC/USD price from multiple exchanges."""
async with httpx.AsyncClient(timeout=30.0) as client:
try:
prices = await asyncio.gather(
_kraken_btc_usd(client),
_coinbase_btc_usd(client),
_binance_btc_usdt(client),
)
valid_prices = [price for price in prices if price is not None]
tasks = [
asyncio.create_task(_kraken_btc_usd(client)),
asyncio.create_task(_coinbase_btc_usd(client)),
asyncio.create_task(_binance_btc_usdt(client)),
]
valid_prices: list[float] = []
for future in asyncio.as_completed(tasks):
price = await future
if price is not None:
valid_prices.append(price)
if len(valid_prices) >= 2:
break
for task in tasks:
if not task.done():
task.cancel()
if not valid_prices:
logger.error("No valid BTC prices obtained from any exchange")
raise ValueError("Unable to fetch BTC price from any exchange")
return min(valid_prices)
except Exception as e:
logger.error(

View File

@@ -16,6 +16,8 @@ from .core.db import (
create_session,
get_session,
)
from .core.exceptions import UpstreamError
from .core.settings import settings
from .payment.helpers import (
calculate_discounted_max_cost,
check_token_balance,
@@ -31,7 +33,9 @@ proxy_router = APIRouter()
_upstreams: list[BaseUpstreamProvider] = []
_model_instances: dict[str, Model] = {} # All aliases -> Model
_provider_map: dict[str, BaseUpstreamProvider] = {} # All aliases -> Provider
_provider_map: dict[
str, list[BaseUpstreamProvider]
] = {} # All aliases -> List[Provider]
_unique_models: dict[str, Model] = {} # Unique model.id -> Model (no duplicates)
@@ -68,8 +72,8 @@ def get_model_instance(model_id: str) -> Model | None:
return _model_instances.get(model_id.lower())
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
"""Get UpstreamProvider for model ID from global cache."""
def get_provider_for_model(model_id: str) -> list[BaseUpstreamProvider] | None:
"""Get UpstreamProvider list for model ID from global cache."""
return _provider_map.get(model_id.lower())
@@ -96,6 +100,8 @@ async def refresh_model_maps() -> None:
disabled_model_ids: set[str] = set()
for provider in provider_rows:
if not provider.enabled:
continue
for model in provider.models:
if model.enabled:
overrides_by_id[model.id] = (model, provider.provider_fee)
@@ -132,11 +138,6 @@ async def proxy(
) -> Response | StreamingResponse:
headers = dict(request.headers)
if "x-cashu" not in headers and "authorization" not in headers.keys():
return create_error_response(
"unauthorized", "Unauthorized", 401, request=request
)
is_responses_api = path.startswith("v1/responses") or path.startswith("responses")
request_body = await request.body()
request_body_dict = parse_request_body_json(request_body, path)
@@ -147,13 +148,14 @@ async def proxy(
model_id = request_body_dict.get("model", "unknown")
model_obj = get_model_instance(model_id)
if not model_obj:
return create_error_response(
"invalid_model", f"Model '{model_id}' not found", 400, request=request
)
upstream = get_provider_for_model(model_id)
if not upstream:
upstreams = get_provider_for_model(model_id)
if not upstreams:
return create_error_response(
"invalid_model",
f"No provider found for model '{model_id}'",
@@ -161,26 +163,52 @@ async def proxy(
request=request,
)
# todo figure out cost calculation since fallback provider is usually not the same price
# Use first provider for initial checks/cost calculation
# primary_upstream = upstreams[0]
_max_cost_for_model = await get_max_cost_for_model(
model=model_id, session=session, model_obj=model_obj
)
max_cost_for_model = await calculate_discounted_max_cost(
_max_cost_for_model, request_body_dict, model_obj=model_obj
)
# Ensure max_cost_for_model is at least the minimum allowed request cost
max_cost_for_model = max(max_cost_for_model, settings.min_request_msat)
check_token_balance(headers, request_body_dict, max_cost_for_model)
if x_cashu := headers.get("x-cashu", None):
if is_responses_api:
return await upstream.handle_x_cashu_responses(
request, x_cashu, path, max_cost_for_model, model_obj
)
else:
return await upstream.handle_x_cashu(
request, x_cashu, path, max_cost_for_model, model_obj
)
last_error = None
for i, upstream in enumerate(upstreams):
try:
if is_responses_api:
return await upstream.handle_x_cashu_responses(
request, x_cashu, path, max_cost_for_model, model_obj
)
else:
return await upstream.handle_x_cashu(
request, x_cashu, path, max_cost_for_model, model_obj
)
except UpstreamError as e:
logger.warning(
f"Upstream {upstream.provider_type} failed (x-cashu): {e}"
)
if i == len(upstreams) - 1:
last_error = e
continue
return create_error_response(
"upstream_error",
str(last_error) if last_error else "All upstreams failed",
502,
request=request,
)
elif auth := headers.get("authorization", None):
key = await get_bearer_token_key(headers, path, session, auth)
key = await get_bearer_token_key(
headers, path, session, auth, max_cost_for_model
)
else:
if request.method not in ["GET"]:
@@ -192,63 +220,178 @@ async def proxy(
)
logger.debug("Processing unauthenticated GET request", extra={"path": path})
headers = upstream.prepare_headers(dict(request.headers))
return await upstream.forward_get_request(request, path, headers)
last_error_response = None
for i, upstream in enumerate(upstreams):
try:
headers = upstream.prepare_headers(dict(request.headers))
response = await upstream.forward_get_request(request, path, headers)
if response.status_code in [502, 429] and i < len(upstreams) - 1:
error_message = ""
try:
if hasattr(response, "body"):
body_bytes = response.body
data = json.loads(body_bytes)
if "error" in data:
error_data = data["error"]
if isinstance(error_data, dict):
error_message = error_data.get("message", "")
elif isinstance(error_data, str):
error_message = error_data
except Exception:
pass
await upstream.on_upstream_error_redirect(
response.status_code, error_message
)
logger.warning(
f"Upstream {upstream.provider_type} returned {response.status_code} (GET), trying next provider",
extra={
"status_code": response.status_code,
"upstream": upstream.provider_type,
},
)
continue
return response
except UpstreamError as e:
logger.warning(f"Upstream {upstream.provider_type} failed (GET): {e}")
if i == len(upstreams) - 1:
last_error_response = create_error_response(
"upstream_error", str(e), 502, request=request
)
continue
return last_error_response or create_error_response(
"upstream_error", "All upstreams failed", 502, request=request
)
if request_body_dict:
await pay_for_request(key, max_cost_for_model, session)
headers = upstream.prepare_headers(dict(request.headers))
for i, upstream in enumerate(upstreams):
headers = upstream.prepare_headers(dict(request.headers))
if is_responses_api:
response = await upstream.forward_responses_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
else:
response = await upstream.forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
try:
try:
if is_responses_api:
response = await upstream.forward_responses_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
else:
response = await upstream.forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
except UpstreamError:
# Let the outer UpstreamError handler manage retry/revert
raise
except Exception as e:
# Unexpected error (not an upstream failure) — revert and propagate
logger.error(
"Unexpected error in upstream request, reverting payment",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"max_cost_for_model": max_cost_for_model,
},
)
await revert_pay_for_request(key, session, max_cost_for_model)
raise
if response.status_code != 200:
await revert_pay_for_request(key, session, max_cost_for_model)
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,
"max_cost_for_model": max_cost_for_model,
"upstream_headers": response.headers
if hasattr(response, "headers")
else None,
},
)
# Return the mapped error response generated earlier rather than masking with 502
return response
if response.status_code != 200:
# Check if we should retry (502 Upstream Error or 429 Rate Limit)
should_retry = response.status_code in [502, 429, 400, 401, 403, 404]
if should_retry and i < len(upstreams) - 1:
error_message = ""
try:
if hasattr(response, "body"):
body_bytes = response.body
data = json.loads(body_bytes)
if "error" in data:
error_data = data["error"]
if isinstance(error_data, dict):
error_message = error_data.get("message", "")
elif isinstance(error_data, str):
error_message = error_data
except Exception:
pass
return response
await upstream.on_upstream_error_redirect(
response.status_code, error_message
)
logger.warning(
f"Upstream {upstream.provider_type} returned {response.status_code}, trying next provider",
extra={
"status_code": response.status_code,
"upstream": upstream.provider_type,
},
)
continue
# 4xx error (user error), or other non-retryable error, or last provider failed
await revert_pay_for_request(key, session, max_cost_for_model)
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,
"max_cost_for_model": max_cost_for_model,
"upstream_headers": response.headers
if hasattr(response, "headers")
else None,
},
)
return response
return response
except UpstreamError as e:
logger.warning(
f"Upstream {upstream.provider_type} failed: {e}",
extra={"retry": i < len(upstreams) - 1},
)
# If this was the last provider
if i == len(upstreams) - 1:
await revert_pay_for_request(key, session, max_cost_for_model)
return create_error_response(
"upstream_error", str(e), 502, request=request
)
# Otherwise loop continues to next provider
continue
# Should not be reached given logic above
return create_error_response(
"upstream_error", "All upstreams failed", 502, request=request
)
async def get_bearer_token_key(
headers: dict, path: str, session: AsyncSession, auth: str
headers: dict, path: str, session: AsyncSession, auth: str, min_cost: int = 0
) -> ApiKey:
"""Handle bearer token authentication proxy requests."""
bearer_key = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
parts = auth.split()
bearer_key = parts[1] if len(parts) > 1 and parts[0].lower() == "bearer" else ""
refund_address = headers.get("Refund-LNURL", None)
key_expiry_time = headers.get("Key-Expiry-Time", None)
@@ -261,6 +404,7 @@ async def get_bearer_token_key(
"bearer_key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
"min_cost": min_cost,
},
)
@@ -299,6 +443,7 @@ async def get_bearer_token_key(
session,
refund_address,
key_expiry_time, # type: ignore
min_cost=min_cost,
)
logger.info(
"Bearer token validated successfully",

View File

@@ -10,6 +10,7 @@ from .openai import OpenAIUpstreamProvider
from .openrouter import OpenRouterUpstreamProvider
from .perplexity import PerplexityUpstreamProvider
from .ppqai import PPQAIUpstreamProvider
from .routstr import RoutstrUpstreamProvider
from .xai import XAIUpstreamProvider
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
@@ -24,6 +25,7 @@ upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
OpenRouterUpstreamProvider,
PerplexityUpstreamProvider,
PPQAIUpstreamProvider,
RoutstrUpstreamProvider,
XAIUpstreamProvider,
]
"""List of all upstream classes"""

View File

@@ -0,0 +1,157 @@
import asyncio
import json
from sqlmodel import select
from ..core import get_logger
from ..core.db import UpstreamProviderRow, create_session
from ..wallet import send_token
from .routstr import RoutstrUpstreamProvider
logger = get_logger(__name__)
# Check every 60 seconds
AUTO_TOPUP_INTERVAL_SECONDS = 60
async def periodic_auto_topup() -> None:
"""Background task that monitors Routstr provider balances and auto-tops up when below threshold.
For each Routstr provider with auto_topup enabled in provider_settings:
1. Checks the upstream balance via get_balance()
2. If balance < topup_threshold, creates a cashu token from the configured mint
3. Sends the token to the upstream provider via topup()
"""
# Wait for initial startup to complete
await asyncio.sleep(30)
logger.info("Auto top-up worker started")
while True:
try:
await _run_auto_topup_cycle()
except Exception as e:
logger.error(
"Auto top-up cycle failed",
extra={"error": str(e), "error_type": type(e).__name__},
)
await asyncio.sleep(AUTO_TOPUP_INTERVAL_SECONDS)
async def _run_auto_topup_cycle() -> None:
"""Single cycle: check all eligible providers and top up if needed."""
async with create_session() as session:
query = select(UpstreamProviderRow).where(
UpstreamProviderRow.provider_type == "routstr",
UpstreamProviderRow.enabled == True, # noqa: E712
)
result = await session.exec(query)
providers = result.all()
for row in providers:
try:
await _check_and_topup(row)
except Exception as e:
logger.error(
"Auto top-up failed for provider",
extra={
"provider_id": row.id,
"base_url": row.base_url,
"error": str(e),
"error_type": type(e).__name__,
},
)
async def _check_and_topup(row: UpstreamProviderRow) -> None:
"""Check a single provider's balance and top up if below threshold."""
# Parse provider settings
settings: dict = {}
if row.provider_settings:
try:
settings = json.loads(row.provider_settings)
except (json.JSONDecodeError, TypeError):
return
if not settings.get("auto_topup"):
return
threshold = settings.get("topup_threshold")
amount = settings.get("topup_amount_limit")
mint_url = settings.get("topup_mint_url")
if not threshold or not amount or not mint_url:
logger.warning(
"Auto top-up enabled but missing configuration",
extra={
"provider_id": row.id,
"has_threshold": bool(threshold),
"has_amount": bool(amount),
"has_mint": bool(mint_url),
},
)
return
if not row.api_key:
return
# Instantiate provider and check balance
provider = RoutstrUpstreamProvider.from_db_row(row)
balance = await provider.get_balance()
if balance is None:
logger.warning(
"Could not fetch balance for auto top-up",
extra={"provider_id": row.id, "base_url": row.base_url},
)
return
if balance >= threshold * 1000:
return
# Balance is below threshold - create token and top up
logger.info(
"Auto top-up triggered",
extra={
"provider_id": row.id,
"balance": balance,
"threshold": threshold,
"topup_amount": amount,
"mint_url": mint_url,
},
)
print(amount, mint_url)
try:
token = await send_token(amount, "sat", mint_url)
except Exception as e:
logger.error(
"Failed to create cashu token for auto top-up",
extra={
"provider_id": row.id,
"amount": amount,
"mint_url": mint_url,
"error": str(e),
},
)
return
result = await provider.topup(token)
if "error" in result:
logger.error(
"Auto top-up upstream call failed",
extra={
"provider_id": row.id,
"error": result["error"],
},
)
else:
logger.info(
"Auto top-up completed successfully",
extra={
"provider_id": row.id,
"amount": amount,
"new_balance_approx": balance + amount,
},
)

View File

@@ -4,6 +4,7 @@ from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
from ..payment.models import Model
class AzureUpstreamProvider(BaseUpstreamProvider):
@@ -58,19 +59,52 @@ class AzureUpstreamProvider(BaseUpstreamProvider):
"platform_url": cls.platform_url,
}
def prepare_headers(self, request_headers: dict) -> dict:
"""Prepare headers for Azure OpenAI, adding api-key."""
headers = super().prepare_headers(request_headers)
if self.api_key:
headers["api-key"] = self.api_key
headers.pop("Authorization", None)
headers.pop("authorization", None)
return headers
def prepare_params(
self, path: str, query_params: Mapping[str, str] | None
) -> Mapping[str, str]:
"""Prepare query parameters for Azure OpenAI, adding API version.
Args:
path: Request path
query_params: Original query parameters from the client
Returns:
Query parameters dict with Azure API version added for chat completions
"""
"""Prepare query parameters for Azure OpenAI, adding API version."""
params = dict(query_params or {})
if path.endswith("chat/completions"):
params["api-version"] = self.api_version
version = (self.api_version or "").replace("\ufeff", "").strip()
if not version or version.lower() == "v1":
version = "2024-02-15-preview"
params["api-version"] = version
return params
def normalize_request_path(
self, path: str, model_obj: "Model | None" = None
) -> str:
"""Build Azure deployment-specific request path."""
clean_path = super().normalize_request_path(path, model_obj).lstrip("/")
if model_obj is None:
return clean_path
deployment_id = getattr(
model_obj, "canonical_slug", None
) or self.transform_model_name(model_obj.id)
deployment_id = deployment_id.split("/")[-1]
return f"openai/deployments/{deployment_id}/{clean_path}"
def get_request_base_url(
self, path: str, model_obj: "Model | None" = None
) -> str:
"""Use endpoint root, stripping accidental /openai/v1 suffix if present."""
base_url = self.base_url.rstrip("/")
marker = "/openai/v1"
if marker in base_url:
base_url = base_url.split(marker, 1)[0].rstrip("/")
return base_url
def transform_model_name(self, model_id: str) -> str:
"""Extract deployment name from model ID."""
if "/" in model_id:
return model_id.split("/")[-1]
return model_id

View File

@@ -5,20 +5,18 @@ import json
import re
import traceback
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Mapping
from typing import Mapping
import httpx
from fastapi import BackgroundTasks, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from sqlmodel import select
from ..auth import adjust_payment_for_tokens
from ..core import get_logger
from ..core.db import ApiKey, AsyncSession, create_session
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow, create_session
from ..core.exceptions import UpstreamError
from ..payment.cost_calculation import (
CostData,
CostDataError,
@@ -31,6 +29,7 @@ from ..payment.models import (
Pricing,
_calculate_usd_max_costs,
_update_model_sats_pricing,
list_models,
)
from ..payment.price import sats_usd_price
from ..wallet import recieve_token, send_token
@@ -198,6 +197,23 @@ class BaseUpstreamProvider:
"""
return model_id
def normalize_request_path(self, path: str, model_obj: Model | None = None) -> str:
"""Normalize request path before forwarding to upstream."""
if path.startswith("v1/"):
return path.replace("v1/", "", 1)
return path
def get_request_base_url(
self, path: str, model_obj: Model | None = None
) -> str:
"""Get upstream base URL used when building forwarding URL."""
return self.base_url.rstrip("/")
def build_request_url(self, path: str, model_obj: Model | None = None) -> str:
"""Build full upstream URL from normalized path."""
clean_path = path.lstrip("/")
return f"{self.get_request_base_url(path, model_obj)}/{clean_path}"
def prepare_responses_request_body(
self, body: bytes | None, model_obj: Model
) -> bytes | None:
@@ -340,6 +356,20 @@ class BaseUpstreamProvider:
message = preview[:500]
return message, upstream_code
async def on_upstream_error_redirect(
self, status_code: int, error_message: str
) -> None:
"""Hook called when the proxy redirects to another provider due to an error.
Subclasses can implement this to perform actions like disabling the provider
if it's out of balance.
Args:
status_code: The HTTP status code returned by the upstream
error_message: The error message extracted from the upstream response
"""
pass
async def map_upstream_error_response(
self, request: Request, path: str, upstream_response: httpx.Response
) -> Response:
@@ -412,7 +442,11 @@ class BaseUpstreamProvider:
)
async def handle_streaming_chat_completion(
self, response: httpx.Response, key: ApiKey, max_cost_for_model: int
self,
response: httpx.Response,
key: ApiKey,
max_cost_for_model: int,
background_tasks: BackgroundTasks,
) -> StreamingResponse:
"""Handle streaming chat completion responses with token usage tracking and cost adjustment.
@@ -436,164 +470,133 @@ class BaseUpstreamProvider:
async def stream_with_cost(
max_cost_for_model: int,
) -> AsyncGenerator[bytes, None]:
stored_chunks: list[bytes] = []
usage_finalized: bool = False
last_model_seen: str | None = None
usage_chunk_data: dict | None = None
done_seen: bool = False
async def finalize_without_usage() -> bytes | None:
async def finalize_db_only() -> None:
nonlocal usage_finalized
if usage_finalized:
return None
return
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if not fresh_key:
logger.warning(
"Key not found when finalizing streaming payment",
extra={"key_hash": key.hashed_key[:8] + "..."},
)
usage_finalized = True
return None
return
try:
fallback: dict = {
"model": last_model_seen or "unknown",
"usage": None,
}
cost_data = await adjust_payment_for_tokens(
fresh_key, fallback, new_session, max_cost_for_model
await adjust_payment_for_tokens(
fresh_key,
{"model": last_model_seen or "unknown", "usage": None},
new_session,
max_cost_for_model,
)
usage_finalized = True
logger.info(
"Finalized streaming payment without explicit usage",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost_data": cost_data,
"balance_after_adjustment": fresh_key.balance,
},
)
return f"data: {json.dumps({'cost': cost_data})}\n\n".encode()
except Exception as cost_error:
logger.error(
"Error finalizing payment without usage",
extra={
"error": str(cost_error),
"error_type": type(cost_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
usage_finalized = True
return None
try:
async for chunk in response.aiter_bytes():
stored_chunks.append(chunk)
try:
for part in re.split(b"data: ", chunk):
if not part or part.strip() in (b"[DONE]", b""):
continue
try:
obj = json.loads(part)
if isinstance(obj, dict) and obj.get("model"):
last_model_seen = str(obj.get("model"))
except json.JSONDecodeError:
pass
except Exception:
pass
yield chunk
try:
async for chunk in response.aiter_bytes():
# Split chunk into SSE events
parts = re.split(b"data: ", chunk)
for i, part in enumerate(parts):
if not part:
continue
logger.debug(
"Streaming completed, analyzing usage data",
extra={
"key_hash": key.hashed_key[:8] + "...",
"chunks_count": len(stored_chunks),
},
)
stripped_part = part.strip()
if not stripped_part:
continue
for i in range(len(stored_chunks) - 1, -1, -1):
chunk = stored_chunks[i]
if not chunk:
continue
try:
events = re.split(b"data: ", chunk)
for event_data in events:
if not event_data or event_data.strip() in (b"[DONE]", b""):
continue
try:
data = json.loads(event_data)
if isinstance(data, dict) and data.get("model"):
last_model_seen = str(data.get("model"))
if isinstance(data, dict) and isinstance(
data.get("usage"), dict
):
async with create_session() as new_session:
fresh_key = await new_session.get(
key.__class__, key.hashed_key
)
if fresh_key:
try:
cost_data = (
await adjust_payment_for_tokens(
fresh_key,
data,
new_session,
max_cost_for_model,
)
)
usage_finalized = True
logger.info(
"Payment adjustment completed for streaming",
extra={
"key_hash": key.hashed_key[:8]
+ "...",
"cost_data": cost_data,
"model": last_model_seen,
"balance_after_adjustment": fresh_key.balance,
},
)
yield f"data: {json.dumps({'cost': cost_data})}\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] + "...",
},
if stripped_part == b"[DONE]":
done_seen = True
continue
try:
obj = json.loads(part)
if isinstance(obj, dict):
if obj.get("model"):
last_model_seen = str(obj.get("model"))
if isinstance(obj.get("usage"), dict):
# Hold this chunk back to merge cost later
usage_chunk_data = obj
continue
except json.JSONDecodeError:
pass
prefix = (
b"data: " if (i > 0 or chunk.startswith(b"data: ")) else b""
)
yield prefix + part
# Stream finished, process usage if found
if usage_chunk_data:
async with create_session() as session:
fresh_key = await session.get(key.__class__, key.hashed_key)
if fresh_key:
try:
cost_data = await adjust_payment_for_tokens(
fresh_key,
usage_chunk_data,
session,
max_cost_for_model,
)
remaining_balance_msats = fresh_key.balance
# Merge cost into usage
usage_chunk_data["usage"]["cost"] = cost_data.get(
"total_usd", 0.0
)
usage_chunk_data["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
usage_chunk_data["usage"]["remaining_balance_msats"] = (
remaining_balance_msats
)
# Keep detailed cost in metadata
usage_chunk_data["metadata"] = usage_chunk_data.get(
"metadata", {}
)
usage_chunk_data["metadata"]["routstr"] = {
"cost": cost_data
}
usage_chunk_data["metadata"]["routstr"]["cost"][
"sats_cost"
] = cost_data.get("total_msats", 0) // 1000
usage_chunk_data["metadata"]["routstr"]["cost"][
"remaining_balance_msats"
] = remaining_balance_msats
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
usage_finalized = True
except Exception as e:
logger.exception(
"Error during usage finalization",
extra={
"key_hash": key.hashed_key[:8] + "...",
"error": str(e),
},
)
# Fallback: yield original usage chunk if adjustment fails
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
if not usage_finalized:
maybe_cost_event = await finalize_without_usage()
if maybe_cost_event is not None:
yield maybe_cost_event
await finalize_db_only()
if done_seen:
yield b"data: [DONE]\n\n"
except Exception as stream_error:
logger.warning(
"Streaming interrupted; finalizing without usage",
"Streaming interrupted; finalizing in background",
extra={
"error": str(stream_error),
"error_type": type(stream_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
finally:
if not usage_finalized:
await finalize_without_usage()
# Create a background task to ensure finalization happens
# even if the generator is closed early
background_tasks.add_task(finalize_db_only)
# Remove inaccurate encoding headers from upstream response
response_headers = dict(response.headers)
@@ -633,6 +636,7 @@ class BaseUpstreamProvider:
},
)
content: bytes | None = None
try:
content = await response.aread()
response_json = json.loads(content)
@@ -649,7 +653,32 @@ class BaseUpstreamProvider:
cost_data = await adjust_payment_for_tokens(
key, response_json, session, deducted_max_cost
)
await session.refresh(key)
remaining_balance_msats = key.balance
# Merge cost into usage for OpenCode
if "usage" in response_json:
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
response_json["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["usage"]["remaining_balance_msats"] = (
remaining_balance_msats
)
# Keep detailed cost
response_json["metadata"] = response_json.get("metadata", {})
response_json["metadata"]["routstr"] = {"cost": cost_data}
response_json["metadata"]["routstr"]["cost"]["sats_cost"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["metadata"]["routstr"]["cost"]["remaining_balance_msats"] = (
remaining_balance_msats
)
response_json["cost"] = cost_data
response_json["cost"]["sats_cost"] = cost_data.get("total_msats", 0) // 1000
response_json["cost"]["remaining_balance_msats"] = remaining_balance_msats
logger.info(
"Payment adjustment completed for non-streaming",
@@ -734,180 +763,154 @@ class BaseUpstreamProvider:
async def stream_with_responses_cost(
max_cost_for_model: int,
) -> AsyncGenerator[bytes, None]:
stored_chunks: list[bytes] = []
usage_finalized: bool = False
last_model_seen: str | None = None
reasoning_tokens: int = 0
usage_chunk_data: dict | None = None
done_seen: bool = False
async def finalize_without_usage() -> bytes | None:
async def finalize_db_only() -> None:
nonlocal usage_finalized
if usage_finalized:
return None
return
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if not fresh_key:
logger.warning(
"Key not found when finalizing Responses API streaming payment",
extra={"key_hash": key.hashed_key[:8] + "..."},
)
usage_finalized = True
return None
return
try:
fallback: dict = {
"model": last_model_seen or "unknown",
"usage": None,
}
cost_data = await adjust_payment_for_tokens(
fresh_key, fallback, new_session, max_cost_for_model
await adjust_payment_for_tokens(
fresh_key,
{"model": last_model_seen or "unknown", "usage": None},
new_session,
max_cost_for_model,
)
usage_finalized = True
logger.info(
"Finalized Responses API streaming payment without explicit usage",
extra={
"key_hash": key.hashed_key[:8] + "...",
"cost_data": cost_data,
"balance_after_adjustment": fresh_key.balance,
},
)
return f"data: {json.dumps({'cost': cost_data})}\\n\\n".encode()
except Exception as cost_error:
logger.error(
"Error finalizing Responses API payment without usage",
extra={
"error": str(cost_error),
"error_type": type(cost_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
usage_finalized = True
return None
try:
async for chunk in response.aiter_bytes():
stored_chunks.append(chunk)
try:
for part in re.split(b"data: ", chunk):
if not part or part.strip() in (b"[DONE]", b""):
continue
try:
obj = json.loads(part)
if isinstance(obj, dict):
if obj.get("model"):
last_model_seen = str(obj.get("model"))
# Track reasoning tokens for Responses API
if usage := obj.get("usage", {}):
if (
isinstance(usage, dict)
and "reasoning_tokens" in usage
):
reasoning_tokens += usage.get(
"reasoning_tokens", 0
)
except json.JSONDecodeError:
pass
except Exception:
pass
yield chunk
try:
async for chunk in response.aiter_bytes():
# Split chunk into SSE events
parts = re.split(b"data: ", chunk)
for i, part in enumerate(parts):
if not part:
continue
logger.debug(
"Responses API streaming completed, analyzing usage data",
extra={
"key_hash": key.hashed_key[:8] + "...",
"chunks_count": len(stored_chunks),
"reasoning_tokens": reasoning_tokens,
},
)
stripped_part = part.strip()
if not stripped_part:
continue
# Process final usage data
for i in range(len(stored_chunks) - 1, -1, -1):
chunk = stored_chunks[i]
if not chunk:
continue
try:
events = re.split(b"data: ", chunk)
for event_data in events:
if not event_data or event_data.strip() in (b"[DONE]", b""):
continue
try:
data = json.loads(event_data)
if isinstance(data, dict) and data.get("model"):
last_model_seen = str(data.get("model"))
if isinstance(data, dict) and isinstance(
data.get("usage"), dict
):
# Include reasoning tokens in usage calculation
async with create_session() as new_session:
fresh_key = await new_session.get(
key.__class__, key.hashed_key
if stripped_part == b"[DONE]":
done_seen = True
continue
try:
obj = json.loads(part)
if isinstance(obj, dict):
if obj.get("model"):
last_model_seen = str(obj.get("model"))
# Track reasoning tokens for Responses API
if usage := obj.get("usage", {}):
if (
isinstance(usage, dict)
and "reasoning_tokens" in usage
):
reasoning_tokens += usage.get(
"reasoning_tokens", 0
)
if fresh_key:
try:
cost_data = (
await adjust_payment_for_tokens(
fresh_key,
data,
new_session,
max_cost_for_model,
)
)
usage_finalized = True
logger.info(
"Payment adjustment completed for Responses API streaming",
extra={
"key_hash": key.hashed_key[:8]
+ "...",
"cost_data": cost_data,
"model": last_model_seen,
"reasoning_tokens": reasoning_tokens,
"balance_after_adjustment": fresh_key.balance,
},
)
yield f"data: {json.dumps({'cost': cost_data})}\\n\\n".encode()
except Exception as cost_error:
logger.error(
"Error adjusting payment for Responses API 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 Responses API streaming response chunk",
extra={
"error": str(e),
"error_type": type(e).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
# Responses API usage is in response.completed/incomplete events
chunk_type = obj.get("type", "")
if chunk_type in (
"response.completed",
"response.incomplete",
):
usage_chunk_data = obj
continue
except json.JSONDecodeError:
pass
prefix = (
b"data: " if (i > 0 or chunk.startswith(b"data: ")) else b""
)
yield prefix + part
# Stream finished, process usage if found
if usage_chunk_data:
async with create_session() as session:
fresh_key = await session.get(key.__class__, key.hashed_key)
if fresh_key:
try:
cost_data = await adjust_payment_for_tokens(
fresh_key,
usage_chunk_data,
session,
max_cost_for_model,
)
remaining_balance_msats = fresh_key.balance
# Merge cost into usage chunk
if (
"response" in usage_chunk_data
and "usage" in usage_chunk_data["response"]
):
usage_chunk_data["response"]["usage"]["cost"] = (
cost_data.get("total_usd", 0.0)
)
usage_chunk_data["response"]["usage"][
"cost_sats"
] = cost_data.get("total_msats", 0) // 1000
usage_chunk_data["response"]["usage"][
"remaining_balance_msats"
] = remaining_balance_msats
elif "usage" in usage_chunk_data:
usage_chunk_data["usage"]["cost"] = cost_data.get(
"total_usd", 0.0
)
usage_chunk_data["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
usage_chunk_data["usage"][
"remaining_balance_msats"
] = remaining_balance_msats
# Keep detailed cost in metadata
usage_chunk_data["metadata"] = usage_chunk_data.get(
"metadata", {}
)
usage_chunk_data["metadata"]["routstr"] = {
"cost": cost_data
}
usage_chunk_data["metadata"]["routstr"]["cost"][
"sats_cost"
] = cost_data.get("total_msats", 0) // 1000
usage_chunk_data["metadata"]["routstr"]["cost"][
"remaining_balance_msats"
] = remaining_balance_msats
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
usage_finalized = True
except Exception:
# Fallback: yield original usage chunk if adjustment fails
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
if not usage_finalized:
maybe_cost_event = await finalize_without_usage()
if maybe_cost_event is not None:
yield maybe_cost_event
await finalize_db_only()
if done_seen:
yield b"data: [DONE]\n\n"
except Exception as stream_error:
logger.warning(
"Responses API streaming interrupted; finalizing without usage",
"Responses API streaming interrupted; finalizing in background",
extra={
"error": str(stream_error),
"error_type": type(stream_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
finally:
if not usage_finalized:
await finalize_without_usage()
await finalize_db_only()
# Remove inaccurate encoding headers from upstream response
response_headers = dict(response.headers)
@@ -947,6 +950,7 @@ class BaseUpstreamProvider:
},
)
content: bytes | None = None
try:
content = await response.aread()
response_json = json.loads(content)
@@ -966,7 +970,32 @@ class BaseUpstreamProvider:
cost_data = await adjust_payment_for_tokens(
key, response_json, session, deducted_max_cost
)
await session.refresh(key)
remaining_balance_msats = key.balance
# Merge cost into usage for OpenCode
if "usage" in response_json:
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
response_json["usage"]["cost_sats"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["usage"]["remaining_balance_msats"] = (
remaining_balance_msats
)
# Keep detailed cost
response_json["metadata"] = response_json.get("metadata", {})
response_json["metadata"]["routstr"] = {"cost": cost_data}
response_json["metadata"]["routstr"]["cost"]["sats_cost"] = (
cost_data.get("total_msats", 0) // 1000
)
response_json["metadata"]["routstr"]["cost"]["remaining_balance_msats"] = (
remaining_balance_msats
)
response_json["cost"] = cost_data
response_json["cost"]["sats_cost"] = cost_data.get("total_msats", 0) // 1000
response_json["cost"]["remaining_balance_msats"] = remaining_balance_msats
logger.info(
"Payment adjustment completed for non-streaming Responses API",
@@ -1089,10 +1118,8 @@ class BaseUpstreamProvider:
Returns:
Response or StreamingResponse from upstream with cost tracking
"""
if path.startswith("v1/"):
path = path.replace("v1/", "")
url = f"{self.base_url}/{path}"
path = self.normalize_request_path(path, model_obj)
url = self.build_request_url(path, model_obj)
transformed_body = self.prepare_request_body(request_body, model_obj)
@@ -1148,6 +1175,14 @@ class BaseUpstreamProvider:
)
if response.status_code != 200:
if response.status_code >= 500:
await response.aclose()
await client.aclose()
raise UpstreamError(
f"Upstream returned status {response.status_code}",
status_code=response.status_code,
)
try:
mapped_error = await self.map_upstream_error_response(
request, path, response
@@ -1193,12 +1228,12 @@ class BaseUpstreamProvider:
)
if is_streaming and response.status_code == 200:
result = await self.handle_streaming_chat_completion(
response, key, max_cost_for_model
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
result = await self.handle_streaming_chat_completion(
response, key, max_cost_for_model, background_tasks
)
result.background = background_tasks
return result
@@ -1238,6 +1273,9 @@ class BaseUpstreamProvider:
background=background_tasks,
)
except UpstreamError:
raise
except httpx.RequestError as exc:
await client.aclose()
error_type = type(exc).__name__
@@ -1256,6 +1294,7 @@ class BaseUpstreamProvider:
},
)
# Don't revert here — proxy.py owns payment revert to avoid double-revert
if isinstance(exc, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
elif isinstance(exc, httpx.TimeoutException):
@@ -1265,9 +1304,7 @@ class BaseUpstreamProvider:
else:
error_message = f"Error connecting to upstream service: {error_type}"
return create_error_response(
"upstream_error", error_message, 502, request=request
)
raise UpstreamError(error_message, status_code=502)
except Exception as exc:
await client.aclose()
@@ -1287,11 +1324,9 @@ class BaseUpstreamProvider:
},
)
return create_error_response(
"internal_error",
"An unexpected server error occurred",
500,
request=request,
# Don't revert here — proxy.py owns payment revert to avoid double-revert
raise UpstreamError(
"An unexpected server error occurred", status_code=500
)
async def forward_responses_request(
@@ -1320,11 +1355,8 @@ class BaseUpstreamProvider:
Returns:
Response or StreamingResponse from upstream with cost tracking
"""
# Remove v1/ prefix if present for Responses API
if path.startswith("v1/"):
path = path.replace("v1/", "")
url = f"{self.base_url}/{path}"
path = self.normalize_request_path(path, model_obj)
url = self.build_request_url(path, model_obj)
transformed_body = self.prepare_responses_request_body(request_body, model_obj)
@@ -1380,6 +1412,14 @@ class BaseUpstreamProvider:
)
if response.status_code != 200:
if response.status_code >= 500:
await response.aclose()
await client.aclose()
raise UpstreamError(
f"Upstream returned status {response.status_code}",
status_code=response.status_code,
)
try:
mapped_error = await self.map_upstream_error_response(
request, path, response
@@ -1447,6 +1487,9 @@ class BaseUpstreamProvider:
background=background_tasks,
)
except UpstreamError:
raise
except httpx.RequestError as exc:
await client.aclose()
error_type = type(exc).__name__
@@ -1465,6 +1508,7 @@ class BaseUpstreamProvider:
},
)
# Don't revert here — proxy.py owns payment revert to avoid double-revert
if isinstance(exc, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
elif isinstance(exc, httpx.TimeoutException):
@@ -1474,9 +1518,7 @@ class BaseUpstreamProvider:
else:
error_message = f"Error connecting to upstream service: {error_type}"
return create_error_response(
"upstream_error", error_message, 502, request=request
)
raise UpstreamError(error_message, status_code=502)
except Exception as exc:
await client.aclose()
@@ -1496,11 +1538,9 @@ class BaseUpstreamProvider:
},
)
return create_error_response(
"internal_error",
"An unexpected server error occurred",
500,
request=request,
# Don't revert here — proxy.py owns payment revert to avoid double-revert
raise UpstreamError(
"An unexpected server error occurred", status_code=500
)
async def forward_get_request(
@@ -1519,10 +1559,8 @@ class BaseUpstreamProvider:
Returns:
StreamingResponse from upstream
"""
if path.startswith("v1/"):
path = path.replace("v1/", "")
url = f"{self.base_url}/{path}"
path = self.normalize_request_path(path)
url = self.build_request_url(path)
logger.info(
"Forwarding GET request to upstream",
@@ -3049,18 +3087,44 @@ class BaseUpstreamProvider:
async def refresh_models_cache(self) -> None:
"""Refresh the in-memory models cache from upstream API."""
try:
models = await self.fetch_models()
models_with_fees = [self._apply_provider_fee_to_model(m) for m in models]
async with create_session() as session:
stmt = select(UpstreamProviderRow).where(
UpstreamProviderRow.base_url == self.base_url,
UpstreamProviderRow.api_key == self.api_key
)
result = await session.exec(stmt)
try:
sats_to_usd = sats_usd_price()
self._models_cache = [
_update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees
]
except Exception:
self._models_cache = models_with_fees
# .first() returns the object or None if not found
provider = result.first()
if not provider or not provider.id:
raise HTTPException(status_code=404, detail="Provider not found")
self._models_by_id = {m.id: m for m in self._models_cache}
db_models = await list_models(
session=session,
upstream_id=provider.id,
include_disabled=False,
apply_fees=False,
)
db_model_ids: set[str] = {model.id for model in db_models}
models = await self.fetch_models()
model_ids = [model.id for model in models]
diff = set(db_model_ids) - set(model_ids)
for db_model_id in diff:
found_db_model = next((model_obj for model_obj in db_models if model_obj.id == db_model_id))
models.append(found_db_model)
models_with_fees = [self._apply_provider_fee_to_model(m) for m in models]
try:
sats_to_usd = sats_usd_price()
self._models_cache = [
_update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees
]
except Exception:
self._models_cache = models_with_fees
self._models_by_id = {m.id: m for m in self._models_cache}
except Exception as e:
logger.error(

View File

@@ -259,7 +259,15 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
cost_data = await adjust_payment_for_tokens(
key, openai_format_response, session, max_cost_for_model
)
await session.refresh(key)
remaining_balance_msats = key.balance
openai_format_response["cost"] = cost_data
openai_format_response["cost"]["sats_cost"] = (
cost_data.get("total_msats", 0) // 1000
)
openai_format_response["cost"]["remaining_balance_msats"] = (
remaining_balance_msats
)
logger.info(
"Gemini non-streaming payment completed",

View File

@@ -102,6 +102,8 @@ async def get_all_models_with_overrides(
)
for row in override_rows
if row.upstream_provider_id is not None
and row.upstream_provider_id in providers_by_id
and providers_by_id[row.upstream_provider_id].enabled
}
all_models: dict[str, Model] = {}
@@ -203,6 +205,9 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
provider = _instantiate_provider(provider_row)
if provider:
# Keep provider DB id on runtime instance so model mapping can
# bind DB overrides to the correct upstream.
setattr(provider, "db_id", provider_row.id)
await provider.refresh_models_cache()
logger.debug(
f"Initialized {provider_row.provider_type} provider",
@@ -234,7 +239,7 @@ async def _seed_providers_from_settings(
from . import upstream_provider_classes
providers_to_add: list[UpstreamProviderRow] = []
seeded_base_urls: set[str] = set()
seeded_provider_keys: set[tuple[str, str]] = set()
provider_classes_by_type = {
cls.provider_type: cls
@@ -259,7 +264,8 @@ async def _seed_providers_from_settings(
base_url = provider_class.default_base_url # type: ignore[attr-defined]
result = await session.exec(
select(UpstreamProviderRow).where(
UpstreamProviderRow.base_url == base_url
UpstreamProviderRow.base_url == base_url,
UpstreamProviderRow.api_key == api_key,
)
)
if not result.first():
@@ -271,13 +277,15 @@ async def _seed_providers_from_settings(
enabled=True,
)
)
seeded_base_urls.add(base_url)
seeded_provider_keys.add((base_url, api_key))
ollama_base_url = os.environ.get("OLLAMA_BASE_URL")
if ollama_base_url:
ollama_api_key = os.environ.get("OLLAMA_API_KEY", "")
result = await session.exec(
select(UpstreamProviderRow).where(
UpstreamProviderRow.base_url == ollama_base_url
UpstreamProviderRow.base_url == ollama_base_url,
UpstreamProviderRow.api_key == ollama_api_key,
)
)
if not result.first():
@@ -285,18 +293,20 @@ async def _seed_providers_from_settings(
UpstreamProviderRow(
provider_type="ollama",
base_url=ollama_base_url,
api_key=os.environ.get("OLLAMA_API_KEY", ""),
api_key=ollama_api_key,
enabled=True,
)
)
seeded_base_urls.add(ollama_base_url)
seeded_provider_keys.add((ollama_base_url, ollama_api_key))
if settings.chat_completions_api_version and settings.upstream_base_url:
base_url = settings.upstream_base_url
if base_url not in seeded_base_urls:
api_key = settings.upstream_api_key
if (base_url, api_key) not in seeded_provider_keys:
result = await session.exec(
select(UpstreamProviderRow).where(
UpstreamProviderRow.base_url == base_url
UpstreamProviderRow.base_url == base_url,
UpstreamProviderRow.api_key == api_key,
)
)
if not result.first():
@@ -304,19 +314,21 @@ async def _seed_providers_from_settings(
UpstreamProviderRow(
provider_type="azure",
base_url=base_url,
api_key=settings.upstream_api_key,
api_key=api_key,
api_version=settings.chat_completions_api_version,
enabled=True,
)
)
seeded_base_urls.add(base_url)
seeded_provider_keys.add((base_url, api_key))
if settings.upstream_base_url and settings.upstream_api_key:
base_url = settings.upstream_base_url
if base_url not in seeded_base_urls:
api_key = settings.upstream_api_key
if (base_url, api_key) not in seeded_provider_keys:
result = await session.exec(
select(UpstreamProviderRow).where(
UpstreamProviderRow.base_url == base_url
UpstreamProviderRow.base_url == base_url,
UpstreamProviderRow.api_key == api_key,
)
)
if not result.first():
@@ -324,11 +336,11 @@ async def _seed_providers_from_settings(
UpstreamProviderRow(
provider_type="custom",
base_url=base_url,
api_key=settings.upstream_api_key,
api_key=api_key,
enabled=True,
)
)
seeded_base_urls.add(base_url)
seeded_provider_keys.add((base_url, api_key))
for provider in providers_to_add:
session.add(provider)

View File

@@ -3,13 +3,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import httpx
from fastapi import Request
from fastapi.responses import Response, StreamingResponse
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
from ..core.db import UpstreamProviderRow
from ..payment.models import Model
from ..core.logging import get_logger
@@ -67,38 +65,11 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
"""Strip 'ollama/' prefix for Ollama API compatibility."""
return model_id.removeprefix("ollama/")
async def forward_request(
self,
request: Request,
path: str,
headers: dict,
request_body: bytes | None,
key: ApiKey,
max_cost_for_model: int,
session: AsyncSession,
model_obj: Model,
) -> Response | StreamingResponse:
"""Override to use OpenAI-compatible endpoint for proxy requests."""
if path.startswith("v1/"):
path = path.replace("v1/", "")
original_base_url = self.base_url
self.base_url = f"{self.base_url}/v1"
try:
result = await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
return result
finally:
self.base_url = original_base_url
def get_request_base_url(
self, path: str, model_obj: Model | None = None
) -> str:
"""Route proxy traffic through Ollama's OpenAI-compatible /v1 endpoint."""
return f"{self.base_url.rstrip('/')}/v1"
async def fetch_models(self) -> list[Model]:
"""Fetch models from Ollama API using /api/tags endpoint."""

View File

@@ -196,6 +196,37 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
)
return []
async def on_upstream_error_redirect(
self, status_code: int, error_message: str
) -> None:
if "insufficient balance" in error_message.lower():
logger.warning(
f"Disabling PPQ.AI provider ({self.base_url}) due to insufficient balance",
extra={"error": error_message},
)
from sqlmodel import select
from ..core.db import UpstreamProviderRow, create_session
async with create_session() as session:
statement = select(UpstreamProviderRow).where(
UpstreamProviderRow.base_url == self.base_url,
UpstreamProviderRow.api_key == self.api_key,
)
result = await session.exec(statement)
provider = result.first()
if provider:
provider.enabled = False
session.add(provider)
await session.commit()
# Trigger re-initialization of providers
# Import here to avoid circular dependency
from ..proxy import reinitialize_upstreams
await reinitialize_upstreams()
async def create_account(self) -> dict[str, object]:
"""Create a new PPQ.AI account.
@@ -252,7 +283,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
)
async with httpx.AsyncClient(timeout=30.0) as client:
print(f"Payload: {payload}", "sending to", url)
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
invoice_data = response.json()

169
routstr/upstream/routstr.py Normal file
View File

@@ -0,0 +1,169 @@
from typing import TYPE_CHECKING, Any
import httpx
from ..core import get_logger
from ..payment.models import Model
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
logger = get_logger(__name__)
class RoutstrUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider for communicating with another Routstr instance."""
provider_type = "routstr"
default_base_url = None
platform_url = None
def __init__(
self,
base_url: str,
api_key: str,
provider_fee: float = 1.01,
provider_settings: dict | None = None,
):
"""Initialize Routstr provider.
Args:
base_url: Base URL of the upstream Routstr instance
api_key: API key for the upstream Routstr instance
provider_fee: Provider fee multiplier
provider_settings: Provider-specific settings (auto-topup, etc.)
"""
# Ensure base_url doesn't end with /v1 as BaseUpstreamProvider appends it if needed
# but Routstr paths are usually absolute from base.
super().__init__(
base_url=base_url.rstrip("/"),
api_key=api_key,
provider_fee=provider_fee,
)
self.settings = provider_settings or {}
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "RoutstrUpstreamProvider":
import json
settings = {}
if provider_row.provider_settings:
try:
settings = json.loads(provider_row.provider_settings)
except Exception:
pass
return cls(
base_url=provider_row.base_url,
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
provider_settings=settings,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "Routstr Node",
"default_base_url": "",
"fixed_base_url": False,
"platform_url": cls.platform_url,
"can_create_account": False,
"can_topup": True,
"can_show_balance": True,
}
async def get_balance(self) -> float | None:
"""Fetch balance from the upstream Routstr node.
Returns:
Balance in satoshis, or None if failed
"""
url = f"{self.base_url}/v1/balance/info"
headers = {"Authorization": f"Bearer {self.api_key}"}
async with httpx.AsyncClient() as client:
try:
response = await client.get(url, headers=headers, timeout=10.0)
response.raise_for_status()
data = response.json()
# Routstr balance info usually contains 'balance' in msats or sats
# Check for msats and convert to sats
if "balance_msats" in data:
return float(data["balance_msats"]) / 1000.0
return float(data.get("balance", 0))
except Exception as e:
logger.error(
"Failed to fetch balance from upstream Routstr",
extra={"url": url, "error": str(e)},
)
return None
async def topup(self, cashu_token: str) -> dict[str, Any]:
"""Top up balance on the upstream Routstr node.
Args:
cashu_token: Cashu token to deposit
Returns:
Dict containing top-up result
"""
url = f"{self.base_url}/v1/balance/topup"
headers = {"Authorization": f"Bearer {self.api_key}"}
payload = {"cashu_token": cashu_token}
async with httpx.AsyncClient() as client:
try:
response = await client.post(
url, headers=headers, json=payload, timeout=30.0
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(
"Failed to topup upstream Routstr",
extra={"url": url, "error": str(e)},
)
return {"error": str(e)}
async def fetch_models(self) -> list[Model]:
"""Fetch models from the upstream Routstr node."""
url = f"{self.base_url}/v1/models"
async with httpx.AsyncClient() as client:
try:
response = await client.get(url, headers={}, timeout=15.0)
response.raise_for_status()
data = response.json()
models = data.get("data", [])
return [Model(**m) for m in models]
except Exception as e:
logger.error(
"Failed to fetch models from upstream Routstr",
extra={"url": url, "error": str(e)},
)
return []
async def refund_balance(self) -> dict[str, Any]:
"""Request a refund from the upstream Routstr node.
Returns:
Dict containing refund result and token
"""
url = f"{self.base_url}/v1/balance/refund"
headers = {"Authorization": f"Bearer {self.api_key}"}
async with httpx.AsyncClient() as client:
try:
response = await client.post(url, headers=headers, timeout=30.0)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(
"Failed to request refund from upstream Routstr",
extra={"url": url, "error": str(e)},
)
return {"error": str(e)}

View File

@@ -319,6 +319,7 @@ async def periodic_payout() -> None:
wallet, mint_url, unit, not_reserved=True
)
proofs = await slow_filter_spend_proofs(proofs, wallet)
await asyncio.sleep(5)
user_balance = await db.balances_for_mint_and_unit(
session, mint_url, unit
)
@@ -344,8 +345,6 @@ async def periodic_payout() -> None:
"amount_received": amount_received,
},
)
await asyncio.sleep(5)
except Exception as e:
logger.error(
f"Error sending payout: {type(e).__name__}",

View File

@@ -0,0 +1,141 @@
import secrets
from typing import Any
import pytest
from fastapi import HTTPException
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import adjust_payment_for_tokens, pay_for_request
from routstr.balance import ChildKeyRequest, create_child_key
from routstr.core.db import ApiKey
from routstr.core.settings import settings
@pytest.mark.asyncio
async def test_child_key_flow(integration_session: AsyncSession) -> None:
# 1. Create a parent key with balance
parent_raw = "parent_test_key_" + secrets.token_hex(4)
parent_key = ApiKey(
hashed_key=parent_raw,
balance=10000, # 10 sats
)
integration_session.add(parent_key)
await integration_session.commit()
await integration_session.refresh(parent_key)
# Mock settings
settings.child_key_cost = 1000 # 1 sat
# 2. Call create_child_key
result = await create_child_key(
ChildKeyRequest(count=1), parent_key, integration_session
)
assert "api_keys" in result
assert result["cost_msats"] == 1000
assert result["parent_balance"] == 9000
child_key_raw = result["api_keys"][0][3:] # remove sk-
# 3. Verify child key exists in DB
child_key_db = await integration_session.get(ApiKey, child_key_raw)
assert child_key_db is not None
assert child_key_db.parent_key_hash == parent_key.hashed_key
assert child_key_db.balance == 0
# 4. Test payment with child key
cost = 500
await pay_for_request(child_key_db, cost, integration_session)
# Refresh keys
await integration_session.refresh(parent_key)
await integration_session.refresh(child_key_db)
# Parent should be charged
assert parent_key.reserved_balance == 500
assert parent_key.total_requests == 1
# Child should have total_requests incremented
assert child_key_db.total_requests == 1
# 5. Test adjustment
response_data = {"model": "test-model", "usage": {"total_tokens": 10}}
# Mock calculate_cost
import routstr.auth
from routstr.payment.cost_calculation import CostData
async def mock_calculate_cost(*args: Any, **kwargs: Any) -> CostData:
return CostData(
base_msats=0, input_msats=200, output_msats=200, total_msats=400
)
# Patch calculate_cost
original_calculate_cost = routstr.auth.calculate_cost
routstr.auth.calculate_cost = mock_calculate_cost
try:
adjustment = await adjust_payment_for_tokens(
child_key_db, response_data, integration_session, 500
)
assert adjustment["total_msats"] == 400
# Refresh keys
await integration_session.refresh(parent_key)
await integration_session.refresh(child_key_db)
# Parent should have updated balance and total_spent
assert parent_key.reserved_balance == 0
assert parent_key.balance == 9000 - 400
assert (
parent_key.total_spent == 1400
) # 1000 for child key creation + 400 for request
# Child should also have total_spent updated
assert child_key_db.total_spent == 400
finally:
routstr.auth.calculate_cost = original_calculate_cost
@pytest.mark.asyncio
async def test_child_key_insufficient_balance(
integration_session: AsyncSession,
) -> None:
parent_key = ApiKey(
hashed_key="poor_parent_" + secrets.token_hex(4),
balance=500,
)
integration_session.add(parent_key)
await integration_session.commit()
await integration_session.refresh(parent_key)
settings.child_key_cost = 1000
with pytest.raises(HTTPException) as exc:
await create_child_key(
ChildKeyRequest(count=1), parent_key, integration_session
)
assert exc.value.status_code == 402
@pytest.mark.asyncio
async def test_child_key_cannot_create_child(integration_session: AsyncSession) -> None:
parent_key = ApiKey(
hashed_key="parent_" + secrets.token_hex(4),
balance=10000,
)
child_key = ApiKey(
hashed_key="child_" + secrets.token_hex(4),
balance=0,
parent_key_hash=parent_key.hashed_key,
)
integration_session.add(parent_key)
integration_session.add(child_key)
await integration_session.commit()
await integration_session.refresh(child_key)
with pytest.raises(HTTPException) as exc:
await create_child_key(ChildKeyRequest(count=1), child_key, integration_session)
assert exc.value.status_code == 400
assert "Cannot create a child key for another child key" in str(exc.value.detail)

View File

@@ -0,0 +1,97 @@
from typing import Any
import pytest
from httpx import AsyncClient
@pytest.mark.integration
@pytest.mark.asyncio
async def test_wallet_info_returns_child_keys(
integration_client: AsyncClient,
authenticated_client: AsyncClient,
integration_session: Any,
) -> None:
"""Test that GET /v1/wallet/info returns child keys for a parent key"""
# 1. Get parent info to find its hashed_key
response = await authenticated_client.get("/v1/wallet/info")
assert response.status_code == 200
parent_data = response.json()
parent_data["api_key"]
# 2. Create child keys for this parent
# We need to use the parent's authentication for this
child_payload = {"count": 2, "balance_limit": 1000, "balance_limit_reset": "daily"}
create_response = await authenticated_client.post(
"/v1/wallet/child-key", json=child_payload
)
assert create_response.status_code == 200
create_data = create_response.json()
child_keys = create_data["api_keys"]
assert len(child_keys) == 2
# 3. Call /info again and check for child_keys
info_response = await authenticated_client.get("/v1/wallet/info")
assert info_response.status_code == 200
info_data = info_response.json()
assert "child_keys" in info_data
assert len(info_data["child_keys"]) == 2
# Verify child key details
for ck in info_data["child_keys"]:
assert ck["api_key"] in child_keys
assert ck["balance_limit"] == 1000
assert ck["balance_limit_reset"] == "daily"
assert "total_spent" in ck
assert "total_requests" in ck
@pytest.mark.integration
@pytest.mark.asyncio
async def test_wallet_info_child_key_no_child_keys(
integration_client: AsyncClient,
authenticated_client: AsyncClient,
integration_session: Any,
) -> None:
"""Test that GET /v1/wallet/info for a child key does NOT return child_keys"""
# 1. Create a child key
child_payload = {"count": 1}
create_response = await authenticated_client.post(
"/v1/wallet/child-key", json=child_payload
)
assert create_response.status_code == 200
child_key = create_response.json()["api_keys"][0]
# 2. Use the child key to get its info
integration_client.headers["Authorization"] = f"Bearer {child_key}"
info_response = await integration_client.get("/v1/wallet/info")
assert info_response.status_code == 200
info_data = info_response.json()
assert info_data["is_child"] is True
assert "child_keys" not in info_data
@pytest.mark.integration
@pytest.mark.asyncio
async def test_account_info_root_returns_child_keys(
authenticated_client: AsyncClient,
) -> None:
"""Test that GET / returns child keys for a parent key (root endpoint)"""
# 1. Create a child key
child_payload = {"count": 1}
await authenticated_client.post("/v1/wallet/child-key", json=child_payload)
# 2. Call root endpoint /v1/balance/
# Note: routstr/balance.py defines router = APIRouter()
# and it is included in balance_router with prefix /v1/balance
# The endpoint is @router.get("/")
response = await authenticated_client.get("/v1/balance/")
assert response.status_code == 200
data = response.json()
assert "child_keys" in data
assert len(data["child_keys"]) >= 1

View File

@@ -10,7 +10,6 @@ from httpx import AsyncClient
from .utils import (
CashuTokenGenerator,
PerformanceValidator,
ResponseValidator,
)
@@ -159,30 +158,7 @@ async def test_error_handling(
assert response.status_code == 401
@pytest.mark.integration
@pytest.mark.asyncio
async def test_performance_requirements(integration_client: AsyncClient) -> None:
"""Test that endpoints meet performance requirements"""
validator = PerformanceValidator()
# Test info endpoint performance
for i in range(50):
start = validator.start_timing("info_endpoint")
response = await integration_client.get("/")
validator.end_timing("info_endpoint", start)
assert response.status_code == 200
# Validate 95th percentile is under 500ms
result = validator.validate_response_time(
"info_endpoint", max_duration=0.5, percentile=0.95
)
assert result["valid"], (
f"Performance requirement failed: "
f"95th percentile was {result['percentile_time']:.3f}s "
f"(required < {result['max_allowed']}s)"
)
@pytest.mark.integration

View File

@@ -267,10 +267,9 @@ async def test_admin_endpoint_unauthenticated(
"""Test GET /admin/ endpoint redirects to /"""
await db_snapshot.capture()
response = await integration_client.get("/admin/")
response = await integration_client.get("/admin/api/settings")
assert response.status_code == 307
assert response.headers.get("location") == "/"
assert response.status_code == 403
diff = await db_snapshot.diff()
assert len(diff["api_keys"]["added"]) == 0

View File

@@ -0,0 +1,142 @@
import time
from datetime import datetime, timedelta
import pytest
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import pay_for_request
from routstr.core.db import ApiKey
@pytest.mark.asyncio
async def test_key_validity_date(integration_session: AsyncSession) -> None:
# 1. Create a key that is expired
expired_time = int(time.time()) - 3600
key = ApiKey(hashed_key="expired_key", balance=1000, validity_date=expired_time)
integration_session.add(key)
await integration_session.commit()
# 2. Try to pay for a request - should fail
with pytest.raises(Exception) as excinfo:
await pay_for_request(key, 100, integration_session)
assert "expired" in str(excinfo.value).lower()
@pytest.mark.asyncio
async def test_key_balance_limit(integration_session: AsyncSession) -> None:
# 1. Create a key with a balance limit
key = ApiKey(
hashed_key="limited_key", balance=10000, balance_limit=500, total_spent=450
)
integration_session.add(key)
await integration_session.commit()
# 2. Try to pay for a request that exceeds the limit
with pytest.raises(Exception) as excinfo:
await pay_for_request(key, 100, integration_session)
assert "limit exceeded" in str(excinfo.value).lower()
# 3. Try to pay for a request that fits
await pay_for_request(key, 50, integration_session)
await integration_session.refresh(key)
# Note: total_spent is updated in adjust_payment_for_tokens,
# but pay_for_request checks it.
# In our current logic, pay_for_request checks (total_spent + cost) > balance_limit.
@pytest.mark.asyncio
async def test_key_daily_reset_policy(integration_session: AsyncSession) -> None:
# 1. Create a key with a daily reset policy and old reset date
yesterday = int((datetime.now() - timedelta(days=1)).timestamp())
key = ApiKey(
hashed_key="daily_reset_key",
balance=10000,
balance_limit=1000,
balance_limit_reset="daily",
balance_limit_reset_date=yesterday,
total_spent=900,
)
integration_session.add(key)
await integration_session.commit()
# 2. Pay for a request - should trigger reset first because it's a new day
# Request is 200, total_spent is 900. 900+200 > 1000,
# but reset should happen making total_spent 0, then 0+200 < 1000.
await pay_for_request(key, 200, integration_session)
await integration_session.refresh(key)
assert key.total_spent == 0 # Reset in pay_for_request happens before charging
# Wait, the charging logic in pay_for_request increments parent/billing_key's total_requests,
# but total_spent is updated in adjust_payment_for_tokens.
# However, the reset logic sets total_spent to 0.
assert key.balance_limit_reset_date is not None
assert key.balance_limit_reset_date > yesterday
@pytest.mark.asyncio
async def test_periodic_key_reset_job(integration_session: AsyncSession) -> None:
# 1. Create multiple keys needing reset
yesterday = int((datetime.now() - timedelta(days=1)).timestamp())
key1 = ApiKey(
hashed_key="job_reset_key_1",
balance=1000,
balance_limit=1000,
balance_limit_reset="daily",
balance_limit_reset_date=yesterday,
total_spent=500,
)
key2 = ApiKey(
hashed_key="job_reset_key_2",
balance=1000,
balance_limit=1000,
balance_limit_reset="daily",
balance_limit_reset_date=yesterday,
total_spent=800,
)
integration_session.add(key1)
integration_session.add(key2)
await integration_session.commit()
# 2. Run the periodic reset logic manually (mocking the background task loop)
# We can't easily run the actual loop because it has a sleep,
# but we can test the logic inside.
# Implementation of periodic_key_reset logic for testing:
stmt = select(ApiKey).where(ApiKey.balance_limit_reset != None) # noqa: E711
keys = (await integration_session.exec(stmt)).all()
now = int(time.time())
for k in keys:
if k.hashed_key in ["job_reset_key_1", "job_reset_key_2"]:
k.total_spent = 0
k.balance_limit_reset_date = now
integration_session.add(k)
await integration_session.commit()
# 3. Verify resets
await integration_session.refresh(key1)
await integration_session.refresh(key2)
assert key1.total_spent == 0
assert key2.total_spent == 0
@pytest.mark.asyncio
async def test_refund_does_not_delete_key(integration_session: AsyncSession) -> None:
# This requires mocking the router call or testing the logic in balance.py
from routstr.balance import ApiKey
key = ApiKey(hashed_key="refund_test_key", balance=1000, reserved_balance=100)
integration_session.add(key)
await integration_session.commit()
# Logic from refund_wallet_endpoint:
key.balance = 0
key.reserved_balance = 0
integration_session.add(key)
await integration_session.commit()
# Verify key still exists
fetched_key = await integration_session.get(ApiKey, "refund_test_key")
assert fetched_key is not None
assert fetched_key.balance == 0
assert fetched_key.reserved_balance == 0

View File

@@ -9,6 +9,7 @@ import gc
import statistics
import time
from typing import Any, Dict, List
from unittest.mock import patch
import psutil
import pytest
@@ -105,42 +106,46 @@ class TestPerformanceBaseline:
("GET", "/v1/wallet/info", authenticated_client, None),
]
# Warm up
for _ in range(10):
await integration_client.get("/")
# Enable provider discovery for this test
with patch(
"routstr.core.settings.settings.providers_refresh_interval_seconds", 300
):
# Warm up
for _ in range(10):
await integration_client.get("/")
# Test each endpoint
for method, path, client, data in endpoints:
response_times = []
# Test each endpoint
for method, path, client, data in endpoints:
response_times = []
for i in range(100):
start = time.time()
for i in range(100):
start = time.time()
if method == "GET":
response = await client.get(path)
else:
response = await client.post(path, json=data)
if method == "GET":
response = await client.get(path)
else:
response = await client.post(path, json=data)
duration = time.time() - start
response_times.append(duration * 1000) # Convert to ms
duration = time.time() - start
response_times.append(duration * 1000) # Convert to ms
assert response.status_code in [200, 201]
assert response.status_code in [200, 201]
if i % 10 == 0:
metrics.record_system_metrics()
if i % 10 == 0:
metrics.record_system_metrics()
# Verify 95th percentile < 500ms
p95 = sorted(response_times)[int(len(response_times) * 0.95)]
assert p95 < 500, (
f"{method} {path} p95 response time {p95}ms exceeds 500ms limit"
)
# Verify 95th percentile < 500ms
p95 = sorted(response_times)[int(len(response_times) * 0.95)]
assert p95 < 500, (
f"{method} {path} p95 response time {p95}ms exceeds 500ms limit"
)
print(f"\n{method} {path}:")
print(f" Mean: {statistics.mean(response_times):.2f}ms")
print(f" P95: {p95:.2f}ms")
print(
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
)
print(f"\n{method} {path}:")
print(f" Mean: {statistics.mean(response_times):.2f}ms")
print(f" P95: {p95:.2f}ms")
print(
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
)
@pytest.mark.integration

View File

@@ -3,15 +3,15 @@ Integration tests for provider management functionality.
Tests GET /v1/providers/ endpoint for listing and managing providers.
"""
from typing import Any
from typing import Any, Generator
from unittest.mock import patch
import pytest
from httpx import AsyncClient
from routstr.discovery import _PROVIDERS_CACHE
from routstr.nostr.discovery import _PROVIDERS_CACHE
from .utils import PerformanceValidator, ResponseValidator
from .utils import ResponseValidator
@pytest.fixture(autouse=True)
@@ -19,6 +19,15 @@ def _clear_providers_cache() -> None:
_PROVIDERS_CACHE.clear()
@pytest.fixture(autouse=True)
def _enable_provider_discovery() -> Generator[None, Any, Any]:
"""Enable provider discovery for all tests in this module"""
with patch(
"routstr.core.settings.settings.providers_refresh_interval_seconds", 300
):
yield
@pytest.mark.integration
@pytest.mark.asyncio
async def test_providers_endpoint_default_response(
@@ -62,9 +71,10 @@ async def test_providers_endpoint_default_response(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
# Configure mock to return appropriate responses
mock_fetch.side_effect = lambda url: mock_fetch_responses.get(
url, {"status_code": 500, "json": {"error": "Unknown provider"}}
@@ -126,9 +136,10 @@ async def test_providers_endpoint_with_include_json(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {
"status_code": 200,
"json": mock_provider_response,
@@ -200,9 +211,10 @@ async def test_providers_data_structure_validation(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = mock_health_response
response = await integration_client.get("/v1/providers/?include_json=true")
@@ -247,7 +259,8 @@ async def test_providers_endpoint_no_providers_found(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
response = await integration_client.get("/v1/providers/")
@@ -308,10 +321,11 @@ async def test_providers_endpoint_offline_providers(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch(
"routstr.discovery.fetch_provider_health",
"routstr.nostr.discovery.fetch_provider_health",
side_effect=mock_fetch_provider_health,
):
response = await integration_client.get("/v1/providers/?include_json=true")
@@ -377,9 +391,10 @@ async def test_providers_endpoint_duplicate_urls(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {
"status_code": 200,
"endpoint": "root",
@@ -416,7 +431,8 @@ async def test_providers_endpoint_nostr_relay_failures(
raise Exception("Connection to relay failed")
with patch(
"routstr.discovery.query_nostr_relay_for_providers", side_effect=failing_query
"routstr.nostr.discovery.query_nostr_relay_for_providers",
side_effect=failing_query,
):
response = await integration_client.get("/v1/providers/")
@@ -454,9 +470,10 @@ async def test_providers_endpoint_malformed_urls(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
response = await integration_client.get("/v1/providers/")
@@ -486,9 +503,10 @@ async def test_providers_endpoint_response_format(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Test default format
@@ -518,46 +536,6 @@ async def test_providers_endpoint_response_format(
assert isinstance(data_json["providers"], list)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_providers_endpoint_performance(integration_client: AsyncClient) -> None:
"""Test providers endpoint meets performance requirements"""
# Mock quick responses to avoid network delays
mock_events: list[dict[str, Any]] = [
{
"id": f"event{i}",
"content": f"Provider: http://provider{i}.onion",
"created_at": 1234567890 + i,
}
for i in range(5)
]
validator = PerformanceValidator()
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Test multiple requests
for i in range(10):
start = validator.start_timing("providers_endpoint")
response = await integration_client.get("/v1/providers/")
validator.end_timing("providers_endpoint", start)
assert response.status_code == 200
# Validate performance (should be fast with mocked dependencies)
perf_result = validator.validate_response_time(
"providers_endpoint",
max_duration=2.0, # Allow more time since it involves multiple operations
percentile=0.95,
)
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_providers_endpoint_concurrent_requests(
@@ -576,9 +554,10 @@ async def test_providers_endpoint_concurrent_requests(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Create concurrent requests
@@ -618,9 +597,10 @@ async def test_providers_endpoint_parameter_validation(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Test various parameter values
@@ -670,9 +650,10 @@ async def test_no_database_changes_during_provider_operations(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers",
return_value=mock_events,
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Make multiple requests with different parameters

View File

@@ -18,7 +18,6 @@ from routstr.core.db import ApiKey
from .utils import (
ConcurrencyTester,
PerformanceValidator,
)
@@ -172,10 +171,11 @@ async def test_proxy_get_unauthorized_access(integration_client: AsyncClient) ->
assert response.status_code == 200 # GET requests are allowed
# Test 2: POST requests without auth should return 401
# Note: Model validation happens before auth, so missing model returns 400
response = await integration_client.post(
"/v1/chat/completions", json={"test": "data"}
)
assert response.status_code == 401
assert response.status_code in [400, 401] # Accept both for now
# Test 3: POST with invalid API key
# Note: After refactor, model validation may happen before auth validation
@@ -551,41 +551,6 @@ async def test_proxy_get_concurrent_requests(
assert response.status_code == 200
@pytest.mark.integration
@pytest.mark.asyncio
async def test_proxy_get_performance_requirements(
integration_client: AsyncClient, authenticated_client: AsyncClient
) -> None:
"""Test that GET proxy requests meet performance requirements"""
validator = PerformanceValidator()
with patch("httpx.AsyncClient.request") as mock_request:
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.json = MagicMock(return_value={"performance": "test"})
mock_response.text = '{"performance": "test"}'
mock_response.iter_bytes = AsyncMock(return_value=[b'{"performance": "test"}'])
mock_request.return_value = mock_response
# Test multiple requests for performance measurement
for i in range(20):
start = validator.start_timing("proxy_get")
response = await authenticated_client.get(f"/v1/perf-test-{i}")
validator.end_timing("proxy_get", start)
assert response.status_code == 200
# Validate performance requirements
perf_result = validator.validate_response_time(
"proxy_get",
max_duration=1.0, # Should complete within 1 second
percentile=0.95,
)
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_proxy_get_response_format_preservation(

View File

@@ -15,7 +15,6 @@ from httpx import ASGITransport, AsyncClient
from .utils import (
ConcurrencyTester,
PerformanceValidator,
)
@@ -290,55 +289,7 @@ async def test_proxy_post_unauthorized_access(integration_client: AsyncClient) -
assert response.status_code in [400, 401]
@pytest.mark.integration
@pytest.mark.asyncio
async def test_proxy_post_performance(
integration_client: AsyncClient, authenticated_client: AsyncClient
) -> None:
"""Test POST endpoint performance requirements"""
test_payload = {
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "Performance test"}],
}
validator = PerformanceValidator()
with patch("httpx.AsyncClient.send") as mock_send:
# Mock fast responses
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
yield b'{"choices": [{"message": {"content": "Fast"}}], "usage": {"total_tokens": 5}}'
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
response_data = {
"choices": [{"message": {"content": "Fast"}}],
"usage": {"total_tokens": 5},
}
mock_response.text = json.dumps(response_data)
mock_response.json = AsyncMock(return_value=response_data)
mock_response.iter_bytes = mock_iter_bytes
mock_response.aiter_bytes = mock_iter_bytes
mock_send.return_value = mock_response
# Run multiple requests for performance measurement
for i in range(20):
start = validator.start_timing("proxy_post")
response = await authenticated_client.post(
"/v1/chat/completions", json=test_payload
)
validator.end_timing("proxy_post", start)
assert response.status_code == 200
# Validate performance
perf_result = validator.validate_response_time(
"proxy_post",
max_duration=1.5, # Allow slightly more time for POST
percentile=0.95,
)
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
@pytest.mark.integration

View File

@@ -133,13 +133,16 @@ async def test_reserved_balance_with_successful_requests(
@pytest.mark.asyncio
async def test_insufficient_reserved_balance_for_revert(
async def test_revert_with_zero_reserved_balance_is_noop(
integration_session: AsyncSession,
) -> None:
"""Test revert_pay_for_request behavior with insufficient reserved balance."""
"""Test that revert_pay_for_request is a no-op when reserved_balance is 0.
Previously this would drive reserved_balance negative. With the floor guard,
it should return False and leave reserved_balance at 0.
"""
from routstr.auth import revert_pay_for_request
# Create key with zero reserved balance
unique_key = f"test_revert_key_{uuid.uuid4().hex[:8]}"
test_key = ApiKey(
hashed_key=unique_key,
@@ -149,17 +152,211 @@ async def test_insufficient_reserved_balance_for_revert(
integration_session.add(test_key)
await integration_session.commit()
# Try to revert more than available
# Note: Current implementation allows reserved_balance to go negative
await revert_pay_for_request(test_key, integration_session, 100)
# Try to revert more than available — should be a no-op
result = await revert_pay_for_request(test_key, integration_session, 100)
# Refresh to get updated values
await integration_session.refresh(test_key)
# Current implementation allows negative reserved balance
assert test_key.reserved_balance == -100, (
f"Expected reserved_balance to be -100, got: {test_key.reserved_balance}"
assert result is False, "Revert should return False when reservation already released"
assert test_key.reserved_balance == 0, (
f"Reserved balance should remain 0, got: {test_key.reserved_balance}"
)
assert test_key.total_requests == -1, (
f"Expected total_requests to be -1, got: {test_key.total_requests}"
assert test_key.total_requests == 0, (
f"Total requests should remain 0, got: {test_key.total_requests}"
)
@pytest.mark.asyncio
async def test_revert_with_sufficient_reserved_balance_succeeds(
integration_session: AsyncSession,
) -> None:
"""Test that revert_pay_for_request works correctly when there is enough reserved balance."""
from routstr.auth import revert_pay_for_request
unique_key = f"test_revert_ok_{uuid.uuid4().hex[:8]}"
test_key = ApiKey(
hashed_key=unique_key,
balance=5000,
reserved_balance=500,
total_requests=3,
)
integration_session.add(test_key)
await integration_session.commit()
result = await revert_pay_for_request(test_key, integration_session, 500)
await integration_session.refresh(test_key)
assert result is True, "Revert should return True on success"
assert test_key.reserved_balance == 0, (
f"Reserved balance should be 0, got: {test_key.reserved_balance}"
)
assert test_key.total_requests == 2, (
f"Total requests should be 2, got: {test_key.total_requests}"
)
assert test_key.balance == 5000, "Balance should not change on revert"
@pytest.mark.asyncio
async def test_revert_partial_reserved_balance_is_noop(
integration_session: AsyncSession,
) -> None:
"""Test that reverting more than the current reserved_balance is a no-op."""
from routstr.auth import revert_pay_for_request
unique_key = f"test_revert_partial_{uuid.uuid4().hex[:8]}"
test_key = ApiKey(
hashed_key=unique_key,
balance=5000,
reserved_balance=50,
total_requests=1,
)
integration_session.add(test_key)
await integration_session.commit()
# Try to revert 500 when only 50 is reserved — should be no-op
result = await revert_pay_for_request(test_key, integration_session, 500)
await integration_session.refresh(test_key)
assert result is False, "Revert should fail when cost > reserved_balance"
assert test_key.reserved_balance == 50, (
f"Reserved balance should stay at 50, got: {test_key.reserved_balance}"
)
assert test_key.total_requests == 1, (
f"Total requests should stay at 1, got: {test_key.total_requests}"
)
@pytest.mark.asyncio
async def test_double_revert_prevented(
integration_session: AsyncSession,
) -> None:
"""Test that calling revert twice doesn't drive reserved_balance negative.
This simulates the double-revert scenario where both upstream/base.py
and proxy.py attempt to revert the same reservation.
"""
from routstr.auth import revert_pay_for_request
unique_key = f"test_double_revert_{uuid.uuid4().hex[:8]}"
test_key = ApiKey(
hashed_key=unique_key,
balance=10000,
reserved_balance=500,
total_requests=5,
)
integration_session.add(test_key)
await integration_session.commit()
# First revert — should succeed
result1 = await revert_pay_for_request(test_key, integration_session, 500)
await integration_session.refresh(test_key)
assert result1 is True
assert test_key.reserved_balance == 0
assert test_key.total_requests == 4
# Second revert of the same amount — should be no-op
result2 = await revert_pay_for_request(test_key, integration_session, 500)
await integration_session.refresh(test_key)
assert result2 is False, "Second revert should be a no-op"
assert test_key.reserved_balance == 0, (
f"Reserved balance should stay 0, got: {test_key.reserved_balance}"
)
assert test_key.total_requests == 4, (
f"Total requests should stay 4, got: {test_key.total_requests}"
)
@pytest.mark.asyncio
async def test_sequential_reverts_never_go_negative(
integration_session: AsyncSession,
) -> None:
"""Test that multiple reverts don't cause negative reserved_balance.
Simulates the double-revert scenario where multiple code paths
attempt to revert the same reservation.
"""
from routstr.auth import revert_pay_for_request
unique_key = f"test_multi_revert_{uuid.uuid4().hex[:8]}"
test_key = ApiKey(
hashed_key=unique_key,
balance=10000,
reserved_balance=500,
total_requests=5,
)
integration_session.add(test_key)
await integration_session.commit()
# Run 5 sequential reverts for the same 500 reservation
results = []
for _ in range(5):
r = await revert_pay_for_request(test_key, integration_session, 500)
results.append(r)
await integration_session.refresh(test_key)
# Exactly one should succeed, rest should be no-ops
success_count = sum(1 for r in results if r is True)
assert success_count == 1, (
f"Exactly one revert should succeed, got {success_count} successes"
)
assert test_key.reserved_balance == 0, (
f"Reserved balance should be 0, got: {test_key.reserved_balance}"
)
assert test_key.reserved_balance >= 0, (
f"Reserved balance went negative: {test_key.reserved_balance}"
)
@pytest.mark.asyncio
async def test_child_key_revert_floor_guard(
integration_session: AsyncSession,
) -> None:
"""Test that child key reserved_balance also has floor guard on revert."""
from routstr.auth import revert_pay_for_request
parent_key_hash = f"test_parent_{uuid.uuid4().hex[:8]}"
child_key_hash = f"test_child_{uuid.uuid4().hex[:8]}"
parent_key = ApiKey(
hashed_key=parent_key_hash,
balance=10000,
reserved_balance=500,
total_requests=3,
)
child_key = ApiKey(
hashed_key=child_key_hash,
balance=0,
reserved_balance=500,
total_requests=3,
parent_key_hash=parent_key_hash,
)
integration_session.add(parent_key)
integration_session.add(child_key)
await integration_session.commit()
# First revert succeeds
result1 = await revert_pay_for_request(child_key, integration_session, 500)
await integration_session.refresh(parent_key)
await integration_session.refresh(child_key)
assert result1 is True
assert parent_key.reserved_balance == 0
assert child_key.reserved_balance == 0
# Second revert is a no-op for both parent and child
result2 = await revert_pay_for_request(child_key, integration_session, 500)
await integration_session.refresh(parent_key)
await integration_session.refresh(child_key)
assert result2 is False
assert parent_key.reserved_balance == 0, (
f"Parent reserved_balance should stay 0, got: {parent_key.reserved_balance}"
)
assert child_key.reserved_balance == 0, (
f"Child reserved_balance should stay 0, got: {child_key.reserved_balance}"
)

View File

@@ -3,7 +3,7 @@ Integration tests for wallet information retrieval endpoints.
Tests GET /v1/wallet/ and GET /v1/wallet/info endpoints with various scenarios.
"""
import time
from datetime import datetime, timedelta
from typing import Any
@@ -367,30 +367,4 @@ async def test_wallet_info_with_special_characters_in_headers(
# Note: Current implementation doesn't return refund_address in response
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.slow
async def test_wallet_endpoints_performance(authenticated_client: AsyncClient) -> None:
"""Test wallet endpoints meet performance requirements"""
# Warm up
await authenticated_client.get("/v1/wallet/")
# Measure response times
response_times = []
for _ in range(50):
start_time = time.time()
response = await authenticated_client.get("/v1/wallet/")
end_time = time.time()
assert response.status_code == 200
response_times.append(end_time - start_time)
# Calculate statistics
avg_time = sum(response_times) / len(response_times)
max_time = max(response_times)
# Performance assertions
assert avg_time < 0.1 # Average should be under 100ms
assert max_time < 0.5 # No request should take more than 500ms

View File

@@ -65,9 +65,10 @@ async def test_full_balance_refund_returns_cashu_token(
except Exception as e:
pytest.fail(f"Invalid Cashu token format: {e}")
# Try to use the API key - should fail since it's been deleted
# Try to use the API key - should still work but have 0 balance
response = await authenticated_client.get("/v1/wallet/")
assert response.status_code == 401
assert response.status_code == 200
assert response.json()["balance"] == 0
# The refund token has been validated above by decoding it
# The API key deletion has been verified by the 401 response
@@ -261,17 +262,16 @@ async def test_database_state_after_refund(
response = await authenticated_client.post("/v1/wallet/refund")
assert response.status_code == 200
# Verify key is deleted after refund
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
)
assert result.scalar_one_or_none() is None
# Refresh the key to get the updated balance from the database
await integration_session.refresh(key_before)
# Count total keys to ensure only the specific one was deleted
# Verify key balance is 0 after refund
assert key_before.balance == 0
# Count total keys to ensure it wasn't deleted
result = await integration_session.execute(select(ApiKey))
remaining_keys = result.scalars().all()
# Should have no keys left (assuming clean test environment)
assert len(remaining_keys) == 0
assert len(remaining_keys) == 1
@pytest.mark.integration
@@ -388,9 +388,10 @@ async def test_refund_during_active_usage(
# Refund should succeed
assert refund_response.status_code == 200
# Further usage should fail
# Further usage should return 200 but with 0 balance
response = await authenticated_client.get("/v1/wallet/")
assert response.status_code == 401
assert response.status_code == 200
assert response.json()["balance"] == 0
@pytest.mark.integration
@@ -535,44 +536,3 @@ async def test_refund_with_expired_key(
# Should still allow manual refund
assert response.status_code == 200
assert response.json()["recipient"] == "expired@ln.address"
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.slow
async def test_refund_performance(
integration_client: AsyncClient, testmint_wallet: Any
) -> None:
"""Test refund endpoint performance"""
import time
# Create multiple API keys
api_keys = []
for i in range(10):
token = await testmint_wallet.mint_tokens(100 + i)
# Use cashu token as Bearer auth to create API key
integration_client.headers["Authorization"] = f"Bearer {token}"
response = await integration_client.get("/v1/wallet/info")
assert response.status_code == 200
api_keys.append(response.json()["api_key"])
# Measure refund times
refund_times = []
for api_key in api_keys:
integration_client.headers["Authorization"] = f"Bearer {api_key}"
start_time = time.time()
response = await integration_client.post("/v1/wallet/refund")
end_time = time.time()
assert response.status_code == 200
refund_times.append(end_time - start_time)
# Performance assertions
avg_time = sum(refund_times) / len(refund_times)
max_time = max(refund_times)
assert avg_time < 0.5 # Average under 500ms
assert max_time < 1.0 # No refund takes more than 1 second

View File

@@ -1,16 +1,19 @@
"""Tests for the model prioritization algorithm."""
import os
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
# Set required env vars before importing
os.environ["UPSTREAM_BASE_URL"] = "http://test"
os.environ["UPSTREAM_API_KEY"] = "test"
from routstr.algorithm import ( # noqa: E402
calculate_model_cost_score,
create_model_mappings,
get_provider_penalty,
should_prefer_model,
)
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
@@ -46,11 +49,21 @@ def create_test_model(
)
def create_test_provider(name: str, base_url: str = "http://test.com") -> Mock:
def create_test_provider(
name: str,
base_url: str = "http://test.com",
*,
db_id: int | None = None,
models: list[Model] | None = None,
upstream_name: str | None = None,
) -> Mock:
"""Helper to create a test provider mock."""
provider = Mock()
provider.provider_type = name
provider.base_url = base_url
provider.db_id = db_id
provider.upstream_name = upstream_name or name
provider.get_cached_models.return_value = models or []
return provider
@@ -102,98 +115,78 @@ def test_get_provider_penalty_openrouter() -> None:
assert penalty == 1.001
def test_should_prefer_model_cheaper_wins() -> None:
"""Test that cheaper model is preferred."""
cheap_model = create_test_model("cheap", prompt_price=0.001, completion_price=0.002)
expensive_model = create_test_model(
"expensive", prompt_price=0.03, completion_price=0.06
def test_create_model_mappings_includes_db_override_for_missing_cached_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Model overrides should still map when provider discovery misses the model."""
provider = create_test_provider(
"azure",
"https://example.openai.azure.com/openai/v1",
db_id=7,
models=[],
)
override_model = create_test_model("azure/gpt-4o")
override_model.canonical_slug = "azure-deployment"
def fake_row_to_model(*args, **kwargs) -> Model: # type: ignore[no-untyped-def]
return override_model
monkeypatch.setattr("routstr.payment.models._row_to_model", fake_row_to_model)
override_row = SimpleNamespace(id="azure/gpt-4o", upstream_provider_id=7, enabled=True)
model_instances, provider_map, unique_models = create_model_mappings(
upstreams=[provider],
overrides_by_id={"azure/gpt-4o": (override_row, 1.01)},
disabled_model_ids=set(),
)
provider1 = create_test_provider("provider1")
provider2 = create_test_provider("provider2")
assert "azure/gpt-4o" in model_instances
assert provider_map["azure/gpt-4o"] == [provider]
assert "gpt-4o" in unique_models
# Cheaper model should win
assert should_prefer_model(
cheap_model, provider1, expensive_model, provider2, "test-alias"
def test_create_model_mappings_dedupes_with_provider_identity_not_provider_type(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Different provider instances of same type should both survive dedupe."""
provider_a_model = create_test_model(
"azure/gpt-4o", prompt_price=0.01, completion_price=0.01
)
provider_a = create_test_provider(
"azure",
"https://a.openai.azure.com/openai/v1",
db_id=1,
models=[provider_a_model],
upstream_name="azure-a",
)
provider_b = create_test_provider(
"azure",
"https://b.openai.azure.com/openai/v1",
db_id=2,
models=[],
upstream_name="azure-b",
)
# More expensive model should not win
assert not should_prefer_model(
expensive_model, provider2, cheap_model, provider1, "test-alias"
override_model = create_test_model(
"azure/gpt-4o", prompt_price=0.001, completion_price=0.001
)
override_model.canonical_slug = "azure-b-deployment"
def fake_row_to_model(*args, **kwargs) -> Model: # type: ignore[no-untyped-def]
return override_model
monkeypatch.setattr("routstr.payment.models._row_to_model", fake_row_to_model)
override_row = SimpleNamespace(id="azure/gpt-4o", upstream_provider_id=2, enabled=True)
_, provider_map, _ = create_model_mappings(
upstreams=[provider_a, provider_b],
overrides_by_id={"azure/gpt-4o": (override_row, 1.01)},
disabled_model_ids=set(),
)
def test_should_prefer_model_exact_match_wins() -> None:
"""Test that exact alias match beats cheaper price."""
# Make model IDs match the alias differently
exact_match = create_test_model(
"test-model", prompt_price=0.03, completion_price=0.06
)
no_match = create_test_model(
"other-model", prompt_price=0.001, completion_price=0.002
)
provider1 = create_test_provider("provider1")
provider2 = create_test_provider("provider2")
# Exact match should win even though it's more expensive
assert should_prefer_model(
exact_match, provider1, no_match, provider2, "test-model"
)
def test_should_prefer_model_openrouter_slight_penalty() -> None:
"""Test that OpenRouter has slight penalty compared to other providers."""
model1 = create_test_model("model1", prompt_price=0.001, completion_price=0.002)
model2 = create_test_model("model2", prompt_price=0.001, completion_price=0.002)
regular_provider = create_test_provider("regular", "http://provider.com")
openrouter_provider = create_test_provider(
"openrouter", "https://openrouter.ai/api/v1"
)
# Regular provider should be preferred over OpenRouter at same cost
assert should_prefer_model(
model1, regular_provider, model2, openrouter_provider, "test-alias"
)
# OpenRouter should not replace regular provider at same cost
assert not should_prefer_model(
model2, openrouter_provider, model1, regular_provider, "test-alias"
)
def test_should_prefer_model_openrouter_can_win_if_cheaper() -> None:
"""Test that OpenRouter can still win if significantly cheaper."""
cheap_model = create_test_model(
"cheap", prompt_price=0.0001, completion_price=0.0002
)
expensive_model = create_test_model(
"expensive", prompt_price=0.03, completion_price=0.06
)
regular_provider = create_test_provider("regular", "http://provider.com")
openrouter_provider = create_test_provider(
"openrouter", "https://openrouter.ai/api/v1"
)
# OpenRouter should win if it's much cheaper (even with penalty)
assert should_prefer_model(
cheap_model,
openrouter_provider,
expensive_model,
regular_provider,
"test-alias",
)
def test_should_prefer_model_same_cost_first_wins() -> None:
"""Test that when costs are identical, current model is kept."""
model1 = create_test_model("model1", prompt_price=0.001, completion_price=0.002)
model2 = create_test_model("model2", prompt_price=0.001, completion_price=0.002)
provider1 = create_test_provider("provider1")
provider2 = create_test_provider("provider2")
# When costs are equal, should not replace
assert not should_prefer_model(model2, provider2, model1, provider1, "test-alias")
providers_for_alias = provider_map["azure/gpt-4o"]
assert provider_a in providers_for_alias
assert provider_b in providers_for_alias
assert len(providers_for_alias) == 2

View File

@@ -1,155 +0,0 @@
"""Unit tests for model row payload conversion.
This module tests that _model_to_row_payload correctly serializes model data
for database storage. Pricing is stored as-is without fee application.
Fees are now applied per-provider when reading from the database.
Key behaviors tested:
1. Pricing is stored as-is without fee application
2. All model fields are correctly serialized to JSON
3. Optional fields are handled correctly (None values)
4. Pricing structure is preserved
5. Original model objects are not mutated
"""
import json
import os
import pytest
# Set required env vars before importing
os.environ["UPSTREAM_BASE_URL"] = "http://test"
os.environ["UPSTREAM_API_KEY"] = "test"
from routstr.payment.models import ( # noqa: E402
Architecture,
Model,
Pricing,
_model_to_row_payload,
)
@pytest.fixture
def base_architecture() -> Architecture:
"""Provide standard architecture for test models."""
return Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="gpt",
instruct_type="chat",
)
@pytest.fixture
def standard_pricing() -> Pricing:
"""Provide standard USD pricing with known values for testing."""
return Pricing(
prompt=0.001,
completion=0.002,
request=0.01,
image=0.05,
web_search=0.03,
internal_reasoning=0.015,
max_prompt_cost=10.0,
max_completion_cost=20.0,
max_cost=30.0,
)
@pytest.fixture
def standard_model(base_architecture: Architecture, standard_pricing: Pricing) -> Model:
"""Create a standard test model with known pricing."""
return Model(
id="test-model-standard",
name="Test Model Standard",
created=1234567890,
description="A standard test model",
context_length=8192,
architecture=base_architecture,
pricing=standard_pricing,
)
def test_pricing_stored_without_fees(standard_model: Model) -> None:
"""Verify pricing is stored as-is without any fee application."""
payload = _model_to_row_payload(standard_model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
assert pricing["prompt"] == pytest.approx(0.001, rel=1e-9)
assert pricing["completion"] == pytest.approx(0.002, rel=1e-9)
assert pricing["request"] == pytest.approx(0.01, rel=1e-9)
assert pricing["image"] == pytest.approx(0.05, rel=1e-9)
assert pricing["web_search"] == pytest.approx(0.03, rel=1e-9)
assert pricing["internal_reasoning"] == pytest.approx(0.015, rel=1e-9)
assert pricing["max_prompt_cost"] == pytest.approx(10.0, rel=1e-9)
assert pricing["max_completion_cost"] == pytest.approx(20.0, rel=1e-9)
assert pricing["max_cost"] == pytest.approx(30.0, rel=1e-9)
def test_zero_value_pricing_fields(base_architecture: Architecture) -> None:
"""Verify that zero-value pricing fields are stored correctly."""
zero_pricing = Pricing(
prompt=0.0,
completion=0.0,
request=0.0,
image=0.0,
web_search=0.0,
internal_reasoning=0.0,
max_prompt_cost=0.0,
max_completion_cost=0.0,
max_cost=0.0,
)
model = Model(
id="test-model-zero",
name="Test Model Zero",
created=1234567890,
description="A model with zero pricing",
context_length=8192,
architecture=base_architecture,
pricing=zero_pricing,
)
payload = _model_to_row_payload(model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
assert pricing["prompt"] == pytest.approx(0.0, rel=1e-9)
assert pricing["completion"] == pytest.approx(0.0, rel=1e-9)
assert pricing["request"] == pytest.approx(0.0, rel=1e-9)
def test_payload_structure_unchanged(standard_model: Model) -> None:
"""Verify that payload structure matches expectations."""
payload = _model_to_row_payload(standard_model)
assert "id" in payload
assert "name" in payload
assert "created" in payload
assert "description" in payload
assert "context_length" in payload
assert "architecture" in payload
assert "pricing" in payload
assert "sats_pricing" in payload
assert "per_request_limits" in payload
assert "top_provider" in payload
assert "enabled" in payload
assert "upstream_provider_id" in payload
assert isinstance(payload["architecture"], str)
assert isinstance(payload["pricing"], str)
def test_original_model_not_mutated(standard_model: Model) -> None:
"""Verify that the original model object is not mutated."""
original_prompt = standard_model.pricing.prompt
original_completion = standard_model.pricing.completion
_model_to_row_payload(standard_model)
assert standard_model.pricing.prompt == original_prompt
assert standard_model.pricing.completion == original_completion

View File

@@ -0,0 +1,82 @@
"""Tests for Azure upstream provider request normalization."""
from routstr.payment.models import Architecture, Model, Pricing
from routstr.upstream.azure import AzureUpstreamProvider
def create_test_model(model_id: str, canonical_slug: str | None = None) -> Model:
return Model(
id=model_id,
name=model_id,
created=0,
description="test",
context_length=8192,
architecture=Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="test",
instruct_type=None,
),
pricing=Pricing(
prompt=0.001,
completion=0.001,
request=0.0,
image=0.0,
web_search=0.0,
internal_reasoning=0.0,
),
canonical_slug=canonical_slug,
)
def test_prepare_headers_uses_azure_api_key_header() -> None:
provider = AzureUpstreamProvider(
base_url="https://example.openai.azure.com",
api_key="azure-key",
api_version="2024-02-15-preview",
)
headers = provider.prepare_headers({"Authorization": "Bearer user-token"})
assert headers["api-key"] == "azure-key"
assert "Authorization" not in headers
assert "authorization" not in headers
def test_prepare_params_normalizes_azure_api_version() -> None:
provider = AzureUpstreamProvider(
base_url="https://example.openai.azure.com",
api_key="azure-key",
api_version="\ufeff v1 ",
)
params = provider.prepare_params("chat/completions", {})
assert params["api-version"] == "2024-02-15-preview"
def test_normalize_request_path_includes_deployment_id() -> None:
provider = AzureUpstreamProvider(
base_url="https://example.openai.azure.com/openai/v1",
api_key="azure-key",
api_version="2024-02-15-preview",
)
model = create_test_model("azure/gpt-4o", canonical_slug="deploy-gpt4o")
path = provider.normalize_request_path("v1/chat/completions", model)
assert path == "openai/deployments/deploy-gpt4o/chat/completions"
def test_get_request_base_url_strips_openai_v1_suffix() -> None:
provider = AzureUpstreamProvider(
base_url="https://example.openai.azure.com/openai/v1",
api_key="azure-key",
api_version="2024-02-15-preview",
)
model = create_test_model("azure/gpt-4o")
base_url = provider.get_request_base_url("chat/completions", model)
assert base_url == "https://example.openai.azure.com"

View File

@@ -161,6 +161,8 @@ export default function DashboardPage() {
<SelectItem value='24'>Last 24 Hours</SelectItem>
<SelectItem value='72'>Last 3 Days</SelectItem>
<SelectItem value='168'>Last Week</SelectItem>
<SelectItem value='720'>Last Month</SelectItem>
<SelectItem value='8760'>Last Year</SelectItem>
</SelectContent>
</Select>
<Select value={interval} onValueChange={setInterval}>
@@ -172,6 +174,8 @@ export default function DashboardPage() {
<SelectItem value='15'>15 Minutes</SelectItem>
<SelectItem value='30'>30 Minutes</SelectItem>
<SelectItem value='60'>1 Hour</SelectItem>
<SelectItem value='1440'>1 Day</SelectItem>
<SelectItem value='10080'>1 Week</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleRefresh} variant='outline' size='icon'>

File diff suppressed because it is too large Load Diff

View File

@@ -248,7 +248,9 @@ export function AddProviderModelDialog({
const pricing = model.pricing as Record<string, number>;
const topProvider = model.top_provider as Record<string, unknown> | null;
form.setValue('id', model.id);
if (!isOverride) {
form.setValue('id', model.id);
}
form.setValue('name', model.name);
form.setValue('description', model.description || '');
form.setValue('context_length', model.context_length);
@@ -425,7 +427,7 @@ export function AddProviderModelDialog({
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{!isEdit && !isOverride && (
{!isEdit && (
<div className='bg-muted/30 rounded-md border p-3'>
<div className='mb-2 text-sm font-medium'>Presets</div>
<div className='grid gap-2 sm:grid-cols-3 sm:items-start'>
@@ -488,8 +490,9 @@ export function AddProviderModelDialog({
</Popover>
</div>
<div className='text-muted-foreground mt-1 text-xs'>
Prefill fields from a preset model definition, then adjust as
needed.
{isOverride
? 'Apply pricing and settings from a preset model (keeping the model ID unchanged).'
: 'Prefill fields from a preset model definition, then adjust as needed.'}
</div>
</div>
)}
@@ -805,45 +808,6 @@ export function AddProviderModelDialog({
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_prompt_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Prompt Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_completion_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Completion Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Max Total Cost</FormLabel>
<FormControl>
<Input type='number' step='0.0001' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>

View File

@@ -0,0 +1,144 @@
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner';
import { AdminService } from '@/lib/api/services/admin';
import { Loader2, Database } from 'lucide-react';
export interface BatchOverrideDialogProps {
providerId: number;
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function BatchOverrideDialog({
providerId,
isOpen,
onClose,
onSuccess,
}: BatchOverrideDialogProps) {
const [jsonInput, setJsonInput] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const sampleJson = {
models: [
{
id: 'model-id-1',
name: 'Model Name 1',
description: 'Description...',
created: Math.floor(Date.now() / 1000),
context_length: 8192,
architecture: {
modality: 'text',
input_modalities: ['text'],
output_modalities: ['text'],
tokenizer: '',
instruct_type: null,
},
pricing: {
prompt: 0.0,
completion: 0.0,
request: 0.0,
image: 0.0,
web_search: 0.0,
internal_reasoning: 0.0,
},
enabled: true,
},
],
};
const handleBatchOverride = async () => {
if (!jsonInput.trim()) {
toast.error('Please enter JSON content');
return;
}
setIsSubmitting(true);
try {
let data;
try {
data = JSON.parse(jsonInput);
} catch {
throw new Error('Invalid JSON format');
}
if (!data.models || !Array.isArray(data.models)) {
throw new Error('JSON match follow structure: { "models": [...] }');
}
const result = await AdminService.batchOverrideProviderModels(
providerId,
data.models
);
if (result.ok) {
toast.success(result.message || 'Batch override successful');
onSuccess();
onClose();
setJsonInput('');
} else {
throw new Error('Batch override failed');
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'Batch override failed';
toast.error(message);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='sm:max-w-[800px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Database className='h-4 w-4' />
Batch Override Models
</DialogTitle>
<DialogDescription>
Paste a JSON object with a &quot;models&quot; array containing model
definitions. Existing models with the same ID will be updated.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<Textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
placeholder={JSON.stringify(sampleJson, null, 2)}
className='min-h-[400px] font-mono text-xs'
/>
</div>
<DialogFooter>
<Button variant='outline' onClick={onClose}>
Cancel
</Button>
<Button onClick={handleBatchOverride} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Processing...
</>
) : (
'Batch Override'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,546 @@
'use client';
import { useState } from 'react';
import { WalletService } from '@/lib/api/services/wallet';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import {
Key,
Copy,
Check,
Loader2,
RotateCcw,
Plus,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { KeyOptions } from './key-options';
interface KeyConfig {
id: string;
count: number;
balanceLimit: string;
balanceLimitReset: string;
validityDate: string;
}
interface ChildKeyCreatorProps {
baseUrl?: string;
apiKey?: string;
onApiKeyChange?: (apiKey: string) => void;
costPerKeyMsats?: number;
}
export function ChildKeyCreator({
baseUrl,
apiKey: propApiKey,
onApiKeyChange,
costPerKeyMsats,
}: ChildKeyCreatorProps) {
const [internalApiKey, setInternalApiKey] = useState('');
const [loading, setLoading] = useState(false);
const [configs, setConfigs] = useState<KeyConfig[]>([
{
id: crypto.randomUUID(),
count: 1,
balanceLimit: '',
balanceLimitReset: '',
validityDate: '',
},
]);
const [childKeyToCheck, setChildKeyToCheck] = useState('');
const [checking, setChecking] = useState(false);
const [keyStatus, setKeyStatus] = useState<{
total_spent: number;
balance_limit: number | null;
validity_date: number | null;
is_expired: boolean;
is_drained: boolean;
} | null>(null);
const [newKeys, setNewKeys] = useState<string[]>([]);
const [resultInfo, setResultInfo] = useState<{
cost_msats: number;
parent_balance: number;
} | null>(null);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const activeApiKey = propApiKey ?? internalApiKey;
const handleApiKeyChange = (val: string) => {
setInternalApiKey(val);
onApiKeyChange?.(val);
};
const addConfig = () => {
setConfigs([
...configs,
{
id: crypto.randomUUID(),
count: 1,
balanceLimit: '',
balanceLimitReset: '',
validityDate: '',
},
]);
};
const removeConfig = (id: string) => {
if (configs.length > 1) {
setConfigs(configs.filter((c) => c.id !== id));
}
};
const updateConfig = (id: string, updates: Partial<KeyConfig>) => {
setConfigs(configs.map((c) => (c.id === id ? { ...c, ...updates } : c)));
};
const handleCreateKey = async () => {
if (!activeApiKey && baseUrl) {
toast.error('Please provide a Parent API key first');
return;
}
setLoading(true);
try {
let allNewKeys: string[] = [];
let totalCost = 0;
let lastParentBalance = 0;
for (const config of configs) {
const requestedCount = Math.max(1, Math.min(50, Number(config.count)));
const result = await WalletService.createChildKey(
baseUrl,
activeApiKey,
requestedCount,
config.balanceLimit ? parseInt(config.balanceLimit) : undefined,
config.balanceLimitReset || undefined,
config.validityDate
? Math.floor(
new Date(config.validityDate + 'T23:59:59').getTime() / 1000
)
: undefined
);
if (result.api_keys) {
allNewKeys = [...allNewKeys, ...result.api_keys];
}
totalCost += result.cost_msats;
lastParentBalance = result.parent_balance;
}
setNewKeys(allNewKeys);
setResultInfo({
cost_msats: totalCost,
parent_balance: lastParentBalance,
});
toast.success(
`${allNewKeys.length} child API key${
allNewKeys.length > 1 ? 's' : ''
} created successfully`
);
} catch (error) {
console.error('Failed to create child key:', error);
toast.error(
error instanceof Error ? error.message : 'Failed to create child key'
);
} finally {
setLoading(false);
}
};
const handleCheckKey = async () => {
if (!childKeyToCheck) {
toast.error('Please provide a Child API key to check');
return;
}
setChecking(true);
setKeyStatus(null);
try {
const baseUrlToUse = baseUrl || '';
const response = await fetch(`${baseUrlToUse}/v1/balance/info`, {
headers: {
Authorization: `Bearer ${childKeyToCheck}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch key info');
}
const info = await response.json();
const now = Math.floor(Date.now() / 1000);
setKeyStatus({
total_spent: info.total_spent,
balance_limit: info.balance_limit,
validity_date: info.validity_date,
is_expired: info.validity_date ? now > info.validity_date : false,
is_drained: info.balance_limit
? info.total_spent >= info.balance_limit
: false,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to check child key'
);
} finally {
setChecking(false);
}
};
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key);
setCopiedKey(key);
toast.success('API key copied to clipboard');
setTimeout(() => setCopiedKey(null), 2000);
};
const copyAllToClipboard = () => {
navigator.clipboard.writeText(newKeys.join('\n'));
toast.success('All API keys copied to clipboard');
};
return (
<div className='space-y-6'>
<Card>
<CardHeader>
<div className='flex items-center justify-between'>
<div className='space-y-1'>
<CardTitle>Create Child API Key</CardTitle>
<CardDescription>
Generate secondary API keys that share your account balance.
</CardDescription>
</div>
{costPerKeyMsats !== undefined && (
<div className='text-right'>
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
Unit Cost
</p>
<p className='text-primary text-sm font-bold'>
{costPerKeyMsats / 1000} sats
</p>
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className='space-y-4'>
{baseUrl && (
<div className='space-y-2'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
Parent API Key
</label>
<Input
value={activeApiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
/>
</div>
)}
<div className='flex flex-col gap-6'>
{configs.map((config) => (
<div
key={config.id}
className='bg-muted/30 relative space-y-4 rounded-lg border p-4 pt-6'
>
{configs.length > 1 && (
<Button
variant='ghost'
size='icon'
className='text-destructive hover:bg-destructive/10 hover:text-destructive absolute top-2 right-2 h-7 w-7'
onClick={() => removeConfig(config.id)}
>
<Trash2 className='h-4 w-4' />
</Button>
)}
<div className='flex flex-col gap-4 sm:flex-row sm:items-end'>
<div className='w-full space-y-2 sm:w-32'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
Number of keys
</label>
<Input
type='number'
min={1}
max={50}
value={config.count}
onChange={(e) => {
const val = parseInt(e.target.value);
updateConfig(config.id, {
count: isNaN(val)
? 1
: Math.max(1, Math.min(50, val)),
});
}}
className='h-9'
/>
</div>
<div className='flex-1'>
<KeyOptions
balanceLimit={config.balanceLimit}
setBalanceLimit={(val) =>
updateConfig(config.id, { balanceLimit: val })
}
validityDate={config.validityDate}
setValidityDate={(val) =>
updateConfig(config.id, { validityDate: val })
}
balanceLimitReset={config.balanceLimitReset}
setBalanceLimitReset={(val) =>
updateConfig(config.id, { balanceLimitReset: val })
}
/>
</div>
</div>
</div>
))}
<div className='flex justify-center'>
<Button
variant='outline'
size='sm'
onClick={addConfig}
className='gap-2 border-dashed'
>
<Plus className='h-4 w-4' />
Add Another Configuration
</Button>
</div>
<div className='flex flex-wrap items-center justify-between gap-4'>
<div className='text-muted-foreground text-xs'>
{costPerKeyMsats && (
<p>
Total Cost:{' '}
<span className='text-foreground font-medium'>
{costPerKeyMsats *
configs.reduce(
(acc, c) => acc + Number(c.count),
0
)}{' '}
mSats
</span>
</p>
)}
</div>
<Button
onClick={handleCreateKey}
disabled={loading || (!!baseUrl && !activeApiKey)}
className='w-full min-w-[140px] sm:w-auto'
>
{loading ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Creating...
</>
) : (
<>
<Key className='mr-2 h-4 w-4' />
Generate{' '}
{configs.reduce(
(acc, c) => acc + Number(c.count),
0
)}{' '}
Keys
</>
)}
</Button>
</div>
</div>
<p className='text-muted-foreground text-xs'>
Each key creation has a small one-time fee.
</p>
{newKeys.length > 0 && (
<div className='mt-6 space-y-4'>
<Alert className='border-green-200 bg-green-50 dark:border-green-900/20 dark:bg-green-900/10'>
<AlertTitle className='text-green-800 dark:text-green-400'>
{newKeys.length} New API Key{newKeys.length > 1 ? 's' : ''}{' '}
Generated
</AlertTitle>
<AlertDescription className='text-green-700 dark:text-green-500'>
Copy {newKeys.length > 1 ? 'these keys' : 'this key'} now.
You won&apos;t be able to see them again.
{resultInfo && (
<div className='mt-2 font-medium opacity-80'>
Total Cost: {resultInfo.cost_msats / 1000} sats | New
Balance: {resultInfo.parent_balance / 1000} sats
</div>
)}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-xs font-medium uppercase'>
Generated Keys ({newKeys.length})
</span>
{newKeys.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-7 text-[10px] uppercase'
onClick={copyAllToClipboard}
>
<Copy className='mr-1 h-3 w-3' />
Copy All
</Button>
)}
</div>
<div className='grid gap-2'>
{newKeys.map((key, index) => (
<div
key={index}
className='group relative flex items-center gap-2'
>
<code className='bg-muted/50 flex-1 rounded border p-2.5 font-mono text-[10px] break-all sm:text-xs'>
{key}
</code>
<Button
size='icon'
variant='ghost'
className='h-8 w-8 shrink-0'
onClick={() => copyToClipboard(key)}
>
{copiedKey === key ? (
<Check className='h-3.5 w-3.5 text-green-500' />
) : (
<Copy className='h-3.5 w-3.5 opacity-50 group-hover:opacity-100' />
)}
</Button>
</div>
))}
</div>
</div>
{newKeys.length > 3 && (
<div className='space-y-2'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
Bulk Export (All Keys)
</label>
<div className='relative'>
<textarea
readOnly
value={newKeys.join('\n')}
rows={Math.min(newKeys.length, 6)}
className='bg-muted/30 w-full rounded-md border p-3 font-mono text-[10px] focus:outline-none'
/>
<Button
size='sm'
variant='secondary'
className='absolute right-2 bottom-2 h-7 text-[10px]'
onClick={copyAllToClipboard}
>
Copy Bulk
</Button>
</div>
</div>
)}
</div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className='text-lg'>Check Child Key Status</CardTitle>
<CardDescription>
View the current spending, limit, and expiration status of any child
key.
</CardDescription>
</CardHeader>
<CardContent>
<div className='space-y-4'>
<div className='space-y-2'>
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
Child API Key
</label>
<Input
value={childKeyToCheck}
onChange={(e) => setChildKeyToCheck(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
/>
</div>
<Button
onClick={handleCheckKey}
disabled={checking || !childKeyToCheck}
variant='outline'
className='w-full'
>
{checking ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Checking...
</>
) : (
<>
<RotateCcw className='mr-2 h-4 w-4' />
Check Status
</>
)}
</Button>
{keyStatus && (
<div className='bg-muted/30 mt-4 space-y-3 rounded-lg border p-4 text-sm'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Total Spent:</span>
<span className='font-mono font-medium'>
{keyStatus.total_spent} mSats
</span>
</div>
{keyStatus.balance_limit !== null && (
<div className='flex justify-between'>
<span className='text-muted-foreground'>Limit:</span>
<span className='font-mono font-medium'>
{keyStatus.balance_limit} mSats
</span>
</div>
)}
{keyStatus.validity_date !== null && (
<div className='flex justify-between'>
<span className='text-muted-foreground'>Expires:</span>
<span className='font-mono font-medium'>
{new Date(
keyStatus.validity_date * 1000
).toLocaleDateString()}
</span>
</div>
)}
<div className='flex gap-2 pt-2'>
{keyStatus.is_drained && (
<Badge variant='destructive'>Drained</Badge>
)}
{keyStatus.is_expired && (
<Badge variant='destructive'>Expired</Badge>
)}
{!keyStatus.is_drained && !keyStatus.is_expired && (
<Badge className='bg-green-600 hover:bg-green-700'>
Active
</Badge>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { Zap, Calendar, Shield } from 'lucide-react';
import { Input } from '@/components/ui/input';
interface KeyOptionsProps {
balanceLimit: string;
setBalanceLimit: (val: string) => void;
validityDate: string;
setValidityDate: (val: string) => void;
balanceLimitReset: string;
setBalanceLimitReset: (val: string) => void;
showBalanceLimit?: boolean;
}
export function KeyOptions({
balanceLimit,
setBalanceLimit,
validityDate,
setValidityDate,
balanceLimitReset,
setBalanceLimitReset,
showBalanceLimit = true,
}: KeyOptionsProps) {
return (
<div className='grid gap-4 sm:grid-cols-3'>
{showBalanceLimit && (
<div className='space-y-2'>
<label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Zap className='h-3 w-3' />
Balance Limit (mSats)
</label>
<Input
type='number'
placeholder='No limit'
value={balanceLimit}
onChange={(e) => setBalanceLimit(e.target.value)}
className='h-9 text-xs'
/>
</div>
)}
<div className='space-y-2'>
<label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Calendar className='h-3 w-3' />
Validity Date
</label>
<Input
type='date'
value={validityDate}
onChange={(e) => setValidityDate(e.target.value)}
className='h-9 text-xs'
/>
</div>
<div className='space-y-2'>
<label className='text-muted-foreground flex items-center gap-1.5 text-[0.7rem] tracking-wider uppercase'>
<Shield className='h-3 w-3' />
Reset Policy
</label>
<select
value={balanceLimitReset}
onChange={(e) => setBalanceLimitReset(e.target.value)}
className='bg-background border-input flex h-9 w-full rounded-md border px-3 py-1 text-xs shadow-sm transition-colors'
>
<option value=''>None</option>
<option value='daily'>Daily</option>
<option value='weekly'>Weekly</option>
<option value='monthly'>Monthly</option>
</select>
</div>
</div>
);
}

View File

@@ -7,19 +7,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
};
type RefundReceipt = {
token?: string;
recipient?: string;
sats?: string;
msats?: string;
};
import type { WalletSnapshot, ChildKeyInfo } from './key-info-details';
import type { RefundReceipt } from './cashu-payment-workflow';
interface ApiKeyManagerProps {
baseUrl: string;
@@ -51,12 +40,28 @@ async function fetchWalletInfo(
api_key: string;
balance: number;
reserved?: number;
is_child: boolean;
parent_key: string | null;
total_requests: number;
total_spent: number;
balance_limit: number | null;
balance_limit_reset: string | null;
validity_date: number | null;
child_keys?: ChildKeyInfo[];
};
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
}

View File

@@ -8,14 +8,10 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { KeyOptions } from '@/components/key-options';
import type { ChildKeyInfo, WalletSnapshot } from './key-info-details';
type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
};
type RefundReceipt = {
export type RefundReceipt = {
token?: string;
recipient?: string;
sats?: string;
@@ -53,12 +49,28 @@ async function fetchWalletInfo(
api_key: string;
balance: number;
reserved?: number;
is_child: boolean;
parent_key: string | null;
total_requests: number;
total_spent: number;
balance_limit: number | null;
balance_limit_reset: string | null;
validity_date: number | null;
child_keys?: ChildKeyInfo[];
};
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
}
@@ -86,9 +98,11 @@ export function CashuPaymentWorkflow({
const [isTopupLoading, setIsTopupLoading] = useState(false);
const [isRefunding, setIsRefunding] = useState(false);
const [isSyncingBalance, setIsSyncingBalance] = useState(false);
const [hasInteractedCreate, setHasInteractedCreate] = useState(false);
const [hasInteractedManage, setHasInteractedManage] = useState(false);
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
const [balanceLimit, setBalanceLimit] = useState<string>('');
const [balanceLimitReset, setBalanceLimitReset] = useState<string>('');
const [validityDate, setValidityDate] = useState<string>('');
const activeApiKey = apiKeyInput.trim();
@@ -121,6 +135,15 @@ export function CashuPaymentWorkflow({
const params = new URLSearchParams({
initial_balance_token: initialToken.trim(),
});
if (balanceLimit) params.append('balance_limit', balanceLimit);
if (balanceLimitReset)
params.append('balance_limit_reset', balanceLimitReset);
if (validityDate) {
const timestamp = Math.floor(
new Date(validityDate + 'T23:59:59').getTime() / 1000
);
params.append('validity_date', timestamp.toString());
}
const response = await fetch(
`${baseUrl}/v1/balance/create?${params.toString()}`,
{
@@ -135,11 +158,25 @@ export function CashuPaymentWorkflow({
const payload = (await response.json()) as {
api_key: string;
balance: number;
is_child: boolean;
parent_key: string | null;
total_requests: number;
total_spent: number;
balance_limit: number | null;
balance_limit_reset: string | null;
validity_date: number | null;
};
const snapshot: WalletSnapshot = {
apiKey: payload.api_key,
balanceMsats: payload.balance ?? 0,
reservedMsats: 0,
isChild: payload.is_child ?? false,
parentKey: payload.parent_key ?? null,
totalRequests: payload.total_requests ?? 0,
totalSpent: payload.total_spent ?? 0,
balanceLimit: payload.balance_limit ?? null,
balanceLimitReset: payload.balance_limit_reset ?? null,
validityDate: payload.validity_date ?? null,
};
setApiKeyInput(snapshot.apiKey);
@@ -154,7 +191,14 @@ export function CashuPaymentWorkflow({
} finally {
setIsCreatingKey(false);
}
}, [initialToken, baseUrl, onApiKeyCreated]);
}, [
initialToken,
baseUrl,
onApiKeyCreated,
balanceLimit,
balanceLimitReset,
validityDate,
]);
const handleSyncBalance = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
@@ -256,11 +300,10 @@ export function CashuPaymentWorkflow({
[apiKey, onApiKeyChanged, onWalletInfoUpdated]
);
const showCreateDetails =
hasInteractedCreate || initialToken.trim().length > 0;
const showManageDetails = hasInteractedManage || Boolean(walletInfo);
const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0;
const canTopup = Boolean(activeApiKey);
const showCreateDetails = initialToken.trim().length > 0;
return (
<Card>
@@ -285,12 +328,21 @@ export function CashuPaymentWorkflow({
value={initialToken}
onChange={(event) => setInitialToken(event.target.value)}
placeholder='cashuA1...'
rows={showCreateDetails ? 4 : 2}
rows={4}
className='font-mono text-sm transition-all duration-200'
onFocus={() => setHasInteractedCreate(true)}
/>
{showCreateDetails && (
<div className='flex flex-wrap gap-2'>
<div className='space-y-4'>
<KeyOptions
balanceLimit={balanceLimit}
setBalanceLimit={setBalanceLimit}
validityDate={validityDate}
setValidityDate={setValidityDate}
balanceLimitReset={balanceLimitReset}
setBalanceLimitReset={setBalanceLimitReset}
showBalanceLimit={false}
/>
<div className='flex flex-wrap items-center gap-3'>
<Button
onClick={handleCreateKey}
disabled={isCreatingKey}
@@ -298,11 +350,13 @@ export function CashuPaymentWorkflow({
>
{isCreatingKey ? 'Creating…' : 'Create API key'}
</Button>
<span className='text-muted-foreground text-xs'>
<span className='text-muted-foreground text-[0.7rem] leading-relaxed'>
Redeems instantly and returns <code>sk-</code> key.
<br />
Optional limits can be set above for enhanced security.
</span>
</div>
)}
</div>
</section>
<Separator />

View File

@@ -11,27 +11,24 @@ import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ConfigurationService } from '@/lib/api/services/configuration';
import { CashuPaymentWorkflow } from './cashu-payment-workflow';
import {
CashuPaymentWorkflow,
type RefundReceipt,
} from './cashu-payment-workflow';
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
import { ApiKeyManager } from './api-key-manager';
import { KeyInfoDetails, type WalletSnapshot } from './key-info-details';
import { ChildKeyCreator } from '@/components/child-key-creator';
type NodeInfo = {
name: string;
description: string;
version: string;
npub?: string | null;
mints: string[];
http_url?: string | null;
onion_url?: string | null;
};
type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
};
type RefundReceipt = {
name?: string;
description?: string;
version?: string;
http_url?: string;
onion_url?: string;
npub?: string;
mints?: string[];
child_key_cost_msats?: number;
token?: string;
recipient?: string;
sats?: string;
@@ -283,7 +280,7 @@ export function CheatSheet(): JSX.Element {
Cashu mints
</p>
<div className='flex flex-wrap gap-2'>
{nodeInfo.mints.length ? (
{nodeInfo.mints?.length ? (
nodeInfo.mints.map((mint) => (
<Badge
key={mint}
@@ -362,10 +359,12 @@ export function CheatSheet(): JSX.Element {
</section>
<Tabs defaultValue='cashu' className='w-full'>
<TabsList className='grid w-full grid-cols-3'>
<TabsTrigger value='cashu'>Cashu Payments</TabsTrigger>
<TabsTrigger value='lightning'>Lightning Payments</TabsTrigger>
<TabsList className='grid w-full grid-cols-5'>
<TabsTrigger value='cashu'>Cashu</TabsTrigger>
<TabsTrigger value='lightning'>Lightning</TabsTrigger>
<TabsTrigger value='manage'>Manage Keys</TabsTrigger>
<TabsTrigger value='details'>Key Details</TabsTrigger>
<TabsTrigger value='child-keys'>Child Keys</TabsTrigger>
</TabsList>
<TabsContent value='cashu' className='space-y-4'>
@@ -422,6 +421,25 @@ export function CheatSheet(): JSX.Element {
</Card>
)}
</TabsContent>
<TabsContent value='details' className='space-y-4'>
<KeyInfoDetails
baseUrl={normalizedBaseUrl}
apiKey={apiKeyInput}
walletInfo={walletInfo}
onApiKeyChanged={handleApiKeyChanged}
onWalletInfoUpdated={handleWalletInfoUpdated}
/>
</TabsContent>
<TabsContent value='child-keys' className='space-y-4'>
<ChildKeyCreator
baseUrl={normalizedBaseUrl}
apiKey={apiKeyInput}
onApiKeyChange={handleApiKeyChanged}
costPerKeyMsats={nodeInfo?.child_key_cost_msats}
/>
</TabsContent>
</Tabs>
</main>
</div>

View File

@@ -0,0 +1,429 @@
'use client';
import { type JSX, useState, useCallback, useEffect } from 'react';
import {
Copy,
RefreshCcw,
ShieldCheck,
History,
Users,
RotateCcw,
KeyRound,
} from 'lucide-react';
import { toast } from 'sonner';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { WalletService } from '@/lib/api/services/wallet';
export type ChildKeyInfo = {
api_key: string;
total_requests: number;
total_spent: number;
balance_limit: number | null;
balance_limit_reset: string | null;
validity_date: number | null;
};
export type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
isChild: boolean;
parentKey: string | null;
totalRequests: number;
totalSpent: number;
balanceLimit: number | null;
balanceLimitReset: string | null;
validityDate: number | null;
childKeys?: ChildKeyInfo[];
};
interface KeyInfoDetailsProps {
baseUrl: string;
apiKey?: string;
walletInfo?: WalletSnapshot | null;
onApiKeyChanged?: (apiKey: string) => void;
onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void;
}
export function KeyInfoDetails({
baseUrl,
apiKey = '',
walletInfo = null,
onApiKeyChanged,
onWalletInfoUpdated,
}: KeyInfoDetailsProps): JSX.Element {
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isResetting, setIsResetting] = useState<string | null>(null);
// Sync internal state with props if they change
useEffect(() => {
setApiKeyInput(apiKey);
}, [apiKey]);
const fetchDetails = useCallback(
async (keyToFetch: string) => {
setIsRefreshing(true);
try {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
headers: { Authorization: `Bearer ${keyToFetch}` },
});
if (!response.ok) {
throw new Error('Failed to fetch key info');
}
const payload = await response.json();
const snapshot: WalletSnapshot = {
apiKey: payload.api_key || keyToFetch,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
onWalletInfoUpdated?.(snapshot);
toast.success('Key details synced');
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to fetch details'
);
} finally {
setIsRefreshing(false);
}
},
[baseUrl, onWalletInfoUpdated]
);
const handleRefresh = async () => {
if (!apiKeyInput) return;
await fetchDetails(apiKeyInput);
};
const handleKeyChange = (newKey: string) => {
setApiKeyInput(newKey);
onApiKeyChanged?.(newKey);
// Optionally clear info when key changes
if (newKey !== apiKey) {
onWalletInfoUpdated?.(null);
}
};
const handleCopy = (value: string) => {
navigator.clipboard.writeText(value);
toast.success('Copied to clipboard');
};
const handleResetSpent = async (childKey: string) => {
if (!walletInfo || walletInfo.isChild) return;
setIsResetting(childKey);
try {
await WalletService.resetChildKeySpent(baseUrl, apiKeyInput, childKey);
toast.success('Child key spent reset');
await fetchDetails(apiKeyInput);
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to reset child key'
);
} finally {
setIsResetting(null);
}
};
const formatSats = (msats: number) =>
new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
const formatMsats = (msats: number) =>
new Intl.NumberFormat('en-US').format(msats);
const formatDate = (timestamp: number | null) =>
timestamp ? new Date(timestamp * 1000).toLocaleDateString() : 'Never';
return (
<div className='space-y-6'>
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<KeyRound className='text-primary h-5 w-5' />
Key Information
</CardTitle>
<CardDescription>
Enter an API key to view its balance, consumption, and child keys.
</CardDescription>
</CardHeader>
<CardContent>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
value={apiKeyInput}
onChange={(e) => handleKeyChange(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<Button
variant='outline'
size='icon'
className='h-10 w-10 shrink-0'
onClick={() => handleCopy(apiKeyInput)}
disabled={!apiKeyInput}
>
<Copy className='h-4 w-4' />
</Button>
<Button
variant='secondary'
size='sm'
className='min-w-[80px] gap-1'
onClick={handleRefresh}
disabled={isRefreshing || !apiKeyInput}
>
<RefreshCcw
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
/>
{isRefreshing ? 'Syncing...' : 'Sync'}
</Button>
</div>
</div>
</CardContent>
</Card>
{walletInfo && (
<>
<div className='grid gap-4 md:grid-cols-2'>
<Card>
<CardHeader className='pb-2'>
<CardTitle className='flex items-center gap-2 text-lg'>
<ShieldCheck className='text-primary h-5 w-5' />
Status & Identity
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Type</span>
<Badge variant={walletInfo.isChild ? 'secondary' : 'default'}>
{walletInfo.isChild ? 'Child Key' : 'Parent Key'}
</Badge>
</div>
{walletInfo.parentKey && (
<div className='space-y-1'>
<span className='text-muted-foreground text-xs tracking-wider uppercase'>
Parent Key
</span>
<div className='flex items-center gap-2'>
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
{walletInfo.parentKey}
</code>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(walletInfo.parentKey!)}
>
<Copy className='h-4 w-4' />
</Button>
</div>
</div>
)}
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Validity
</span>
<span className='text-sm font-medium'>
{formatDate(walletInfo.validityDate)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spendable Balance
</span>
<span className='text-primary font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className='pb-2'>
<CardTitle className='flex items-center gap-2 text-lg'>
<History className='text-primary h-5 w-5' />
Consumption
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Requests
</span>
<span className='font-mono text-sm font-medium'>
{walletInfo.totalRequests}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Spent
</span>
<div className='text-right'>
<p className='font-mono text-sm font-medium'>
{formatSats(walletInfo.totalSpent)} sats
</p>
<p className='text-muted-foreground font-mono text-[0.6rem]'>
{formatMsats(walletInfo.totalSpent)} msats
</p>
</div>
</div>
{walletInfo.balanceLimit !== null && (
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spend Limit
</span>
<span className='font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceLimit)} sats
</span>
</div>
{walletInfo.balanceLimitReset && (
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Reset Policy
</span>
<Badge variant='outline' className='capitalize'>
{walletInfo.balanceLimitReset}
</Badge>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
{!walletInfo.isChild &&
walletInfo.childKeys &&
walletInfo.childKeys.length > 0 && (
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-lg'>
<Users className='text-primary h-5 w-5' />
Child Keys ({walletInfo.childKeys.length})
</CardTitle>
<CardDescription>
Secondary keys using this account&apos;s balance
</CardDescription>
</CardHeader>
<CardContent>
<div className='space-y-4'>
{walletInfo.childKeys.map((ck) => (
<div
key={ck.api_key}
className='space-y-3 rounded-lg border p-4'
>
<div className='flex items-center justify-between gap-4'>
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
{ck.api_key}
</code>
<div className='flex gap-1'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(ck.api_key)}
>
<Copy className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='icon'
className='text-destructive h-8 w-8'
title='Reset consumption'
disabled={isResetting === ck.api_key}
onClick={() => handleResetSpent(ck.api_key)}
>
{isResetting === ck.api_key ? (
<RefreshCcw className='h-4 w-4 animate-spin' />
) : (
<RotateCcw className='h-4 w-4' />
)}
</Button>
</div>
</div>
<div className='grid grid-cols-2 gap-4 text-xs sm:grid-cols-5'>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
Requests
</p>
<p className='font-mono font-medium'>
{ck.total_requests}
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
Spent
</p>
<p className='font-mono font-medium'>
{formatSats(ck.total_spent)} sats
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
Limit
</p>
<p className='font-mono font-medium'>
{ck.balance_limit
? `${formatSats(ck.balance_limit)} sats`
: 'None'}
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
Policy
</p>
<p className='font-medium capitalize'>
{ck.balance_limit_reset || 'None'}
</p>
</div>
<div>
<p className='text-muted-foreground text-[0.6rem] tracking-wider uppercase'>
Expires
</p>
<p className='font-medium'>
{formatDate(ck.validity_date)}
</p>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
<div className='flex justify-center'>
<Button
variant='ghost'
size='sm'
onClick={handleRefresh}
disabled={isRefreshing}
className='text-muted-foreground'
>
<RefreshCcw
className={`mr-2 h-3 w-3 ${isRefreshing ? 'animate-spin' : ''}`}
/>
Last synced: {new Date().toLocaleTimeString()}
</Button>
</div>
</>
)}
</div>
);
}

View File

@@ -10,12 +10,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Separator } from '@/components/ui/separator';
type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
};
import { KeyOptions } from '@/components/key-options';
import type { WalletSnapshot } from './key-info-details';
type LightningInvoice = {
invoice_id: string;
@@ -89,7 +85,10 @@ export function LightningPaymentWorkflow({
const [isTopupping, setIsTopupping] = useState(false);
const [isRecovering, setIsRecovering] = useState(false);
const [hasInteractedCreate, setHasInteractedCreate] = useState(false);
const [balanceLimit, setBalanceLimit] = useState<string>('');
const [balanceLimitReset, setBalanceLimitReset] = useState<string>('');
const [validityDate, setValidityDate] = useState<string>('');
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
const [hasInteractedRecover, setHasInteractedRecover] = useState(false);
@@ -166,13 +165,29 @@ export function LightningPaymentWorkflow({
setIsCreating(true);
try {
const payload: {
amount_sats: number;
purpose: string;
balance_limit?: number;
balance_limit_reset?: string;
validity_date?: number;
} = {
amount_sats: amount,
purpose: 'create',
};
if (balanceLimit) payload.balance_limit = parseInt(balanceLimit);
if (balanceLimitReset) payload.balance_limit_reset = balanceLimitReset;
if (validityDate) {
payload.validity_date = Math.floor(
new Date(validityDate + 'T23:59:59').getTime() / 1000
);
}
const response = await fetch(`${baseUrl}/v1/balance/lightning/invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount_sats: amount,
purpose: 'create',
}),
body: JSON.stringify(payload),
});
if (!response.ok) {
@@ -195,6 +210,13 @@ export function LightningPaymentWorkflow({
apiKey: status.api_key,
balanceMsats: status.amount_sats * 1000,
reservedMsats: 0,
isChild: false,
parentKey: null,
totalRequests: 0,
totalSpent: 0,
balanceLimit: null,
balanceLimitReset: null,
validityDate: null,
};
onApiKeyCreated?.(status.api_key, walletInfo);
setCreatedApiKey(status.api_key);
@@ -214,7 +236,15 @@ export function LightningPaymentWorkflow({
} finally {
setIsCreating(false);
}
}, [createAmount, baseUrl, pollInvoiceStatus, onApiKeyCreated]);
}, [
createAmount,
baseUrl,
pollInvoiceStatus,
onApiKeyCreated,
balanceLimit,
balanceLimitReset,
validityDate,
]);
const handleTopupInvoice = useCallback(async (): Promise<void> => {
const amount = parseInt(topupAmount);
@@ -261,6 +291,13 @@ export function LightningPaymentWorkflow({
apiKey: status.api_key,
balanceMsats: status.amount_sats * 1000,
reservedMsats: 0,
isChild: false,
parentKey: null,
totalRequests: 0,
totalSpent: 0,
balanceLimit: null,
balanceLimitReset: null,
validityDate: null,
};
onApiKeyCreated?.(status.api_key, walletInfo);
setTopupApiKeyResult(status.api_key);
@@ -315,6 +352,13 @@ export function LightningPaymentWorkflow({
apiKey: status.api_key,
balanceMsats: status.amount_sats * 1000,
reservedMsats: 0,
isChild: false,
parentKey: null,
totalRequests: 0,
totalSpent: 0,
balanceLimit: null,
balanceLimitReset: null,
validityDate: null,
};
onApiKeyCreated?.(status.api_key, walletInfo);
setRecoveredApiKey(status.api_key);
@@ -333,14 +377,13 @@ export function LightningPaymentWorkflow({
}
}, [recoverInvoice, baseUrl, onApiKeyCreated]);
const showCreateDetails =
hasInteractedCreate || createAmount.trim().length > 0;
const showTopupDetails =
hasInteractedTopup ||
topupAmount.trim().length > 0 ||
topupApiKey.trim().length > 0;
const showRecoverDetails =
hasInteractedRecover || recoverInvoice.trim().length > 0;
const showCreateDetails = createAmount.trim().length > 0;
return (
<Card>
@@ -367,9 +410,18 @@ export function LightningPaymentWorkflow({
onChange={(event) => setCreateAmount(event.target.value)}
placeholder='Amount in sats (e.g., 1000)'
className='text-sm'
onFocus={() => setHasInteractedCreate(true)}
/>
{showCreateDetails && (
<div className='space-y-4'>
<KeyOptions
balanceLimit={balanceLimit}
setBalanceLimit={setBalanceLimit}
validityDate={validityDate}
setValidityDate={setValidityDate}
balanceLimitReset={balanceLimitReset}
setBalanceLimitReset={setBalanceLimitReset}
showBalanceLimit={false}
/>
<div className='space-y-3'>
<Button
onClick={handleCreateInvoice}
@@ -473,7 +525,7 @@ export function LightningPaymentWorkflow({
</div>
)}
</div>
)}
</div>
</section>
<Separator />

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