Compare commits

...

252 Commits

Author SHA1 Message Date
9qeklajc
a2db3e2d57 fmt 2026-04-26 22:19:30 +02:00
9qeklajc
da487a850e Merge branch 'main' into admin-token 2026-04-26 00:09:00 +02:00
9qeklajc
fa6d3c76d0 Merge pull request #474 from Routstr/fix-field-label
use correct field label
2026-04-26 00:05:13 +02:00
9qeklajc
ede1804d4b use correct field label 2026-04-25 23:57:24 +02:00
9qeklajc
3392e8d4cb Merge pull request #473 from Routstr/bump-release-version
release v0.4.3
2026-04-25 11:56:48 +02:00
9qeklajc
b5174d9753 release v0.4.3 2026-04-25 11:54:54 +02:00
9qeklajc
1f2ff8a99c added admin token 2026-04-25 11:18:58 +02:00
9qeklajc
0c60644ba2 Merge pull request #472 from Routstr/fix-revision
fix migration
2026-04-24 23:08:06 +02:00
9qeklajc
aca8d43a61 fix migration 2026-04-24 23:05:09 +02:00
9qeklajc
7fe4c1963b Merge pull request #470 from Routstr/add-dev-cut
update migration rev id
2026-04-24 15:21:32 +02:00
9qeklajc
9a0919f149 update migration rev id 2026-04-24 15:19:53 +02:00
9qeklajc
d69ab913d4 Merge pull request #419 from Routstr/add-dev-cut
add-dev-cut
2026-04-23 23:22:23 +02:00
9qeklajc
42b8c332df Merge pull request #467 from Routstr/fix-concurent-refund-with-adding-token-history
Fix concurent refund with adding token history
2026-04-22 23:45:38 +02:00
9qeklajc
aa682bf8ec fix test 2026-04-22 23:43:11 +02:00
9qeklajc
c53e72e80a update migration 2026-04-22 23:31:49 +02:00
9qeklajc
afd81aeca2 reduce retries 2026-04-22 23:14:45 +02:00
9qeklajc
4e03145323 clean up 2026-04-22 23:13:29 +02:00
9qeklajc
169686681f Merge branch 'main' into add-dev-cut 2026-04-22 23:03:35 +02:00
9qeklajc
fa7d2804bb add test 2026-04-22 23:00:04 +02:00
9qeklajc
ccabc5d06d Merge branch 'main' into fix-response-error-forwarding 2026-04-22 22:17:59 +02:00
9qeklajc
cb68227c88 fix race cond. test 2026-04-22 22:17:41 +02:00
9qeklajc
c72fc7dd56 Merge pull request #466 from Routstr/add-detailed-response
fix forwarding upstream error responses
2026-04-22 22:16:41 +02:00
9qeklajc
8c6d1f89dc fix forwarding upstream error responses 2026-04-22 22:12:44 +02:00
9qeklajc
1ab7e54bd7 improve history collection 2026-04-22 22:09:13 +02:00
9qeklajc
42dceb0cd6 make table scrollable 2026-04-22 21:57:29 +02:00
9qeklajc
05115c3387 add api-key history and fix race condition while topup 2026-04-22 21:50:22 +02:00
9qeklajc
c0c8cafd00 fix forwarding upstream error responses 2026-04-20 16:45:05 +02:00
9qeklajc
16d6d66d17 Merge pull request #460 from Routstr/fix-test-interface-ui
Fix test interface UI
2026-04-19 15:29:00 +02:00
9qeklajc
3681fc8aab no advanced testing for now 2026-04-19 15:22:47 +02:00
9qeklajc
d95c09e00c fix test model connection 2026-04-19 15:21:23 +02:00
9qeklajc
3cdef5ec17 Merge pull request #459 from Routstr/add-detailed-logging
add logging reason for bad not succeed requests
2026-04-18 00:33:39 +02:00
9qeklajc
58e1620347 add logging reason for bad not succeed requests 2026-04-18 00:30:19 +02:00
9qeklajc
42b5a5e6c8 Merge pull request #458 from Routstr/add-cost-usage-to-messages-endpoint
Add cost usage to messages endpoint
2026-04-16 17:36:18 +02:00
9qeklajc
d84d249f2c Merge branch 'main' into add-dev-cut
# Conflicts:
#	routstr/auth.py
2026-04-15 23:07:37 +02:00
9qeklajc
8b6393f794 clean up 2026-04-15 21:56:30 +02:00
9qeklajc
81146710ba revert 2026-04-15 21:32:33 +02:00
9qeklajc
25f39897d7 mirror logic from chat completion 2026-04-15 20:45:13 +02:00
9qeklajc
45bdfaee58 best effort to be compatible with legacy code 2026-04-15 20:18:56 +02:00
9qeklajc
a1ae6e94e9 match model when versioned 2026-04-15 20:05:36 +02:00
9qeklajc
eebcc67c85 add cost usages 2026-04-15 17:19:13 +02:00
9qeklajc
3f9e7f7728 Merge pull request #457 from Routstr/fix-ppq-model-fetching
fix ppq model fetching
2026-04-14 18:49:26 +02:00
9qeklajc
a5b5549edd fix ppq model fetching 2026-04-14 18:46:38 +02:00
9qeklajc
22eec0162c Merge pull request #456 from Routstr/fix-serializing-broken-chunks
make streaming serialization more robust
2026-04-14 00:45:48 +02:00
9qeklajc
9f55da9bb8 make streaming serialization more robust 2026-04-14 00:44:10 +02:00
9qeklajc
16dce9ea81 Merge pull request #455 from Routstr/check-primary-mint
enforce no swap when token from primary mint
2026-04-14 00:32:06 +02:00
9qeklajc
963ee04619 enforce no swap when token from primary mint 2026-04-14 00:29:54 +02:00
9qeklajc
b874b1f01c Merge pull request #454 from Routstr/response-id-should-be-set
upstream drops id field which breaks opencode flow
2026-04-13 23:27:41 +02:00
9qeklajc
b6cca3d3a0 upstream drops id field which breaks opencode flow 2026-04-13 23:11:07 +02:00
9qeklajc
86ebc84f4c Merge pull request #453 from Routstr/remove-hardcoded-fee
remove hardcoded deduction (legacy code)
2026-04-13 22:47:23 +02:00
9qeklajc
8a74c0543f remove hardcoded deduction (legacy code) 2026-04-13 22:41:04 +02:00
9qeklajc
3d16a9b988 Merge pull request #449 from Routstr/apikey-transactions
add apikey refund tracking
2026-04-12 22:38:40 +02:00
9qeklajc
40f98b99aa set collected state 2026-04-12 22:36:52 +02:00
9qeklajc
7fcae5b08d Merge pull request #451 from Routstr/feature/update-deployment-docs
Updated deployment docs
2026-04-12 11:23:10 +02:00
redshift
f50cb31749 Updated deployment docs 2026-04-11 22:15:50 +01:00
9qeklajc
85a1ea4b4c add apikey refund tracking 2026-04-10 15:34:19 +02:00
9qeklajc
0f0f8c40bf Merge pull request #447 from Routstr/436-update-model-id
add support to using custom model ids
2026-04-10 14:18:24 +02:00
9qeklajc
a40d224ee7 Merge pull request #446 from Routstr/improve-negative-balance-handling
Improve negative balance handling
2026-04-10 14:14:22 +02:00
9qeklajc
a637acd8f4 use db fixture 2026-04-09 23:08:01 +02:00
9qeklajc
58be0c7976 clean u 2026-04-09 20:42:36 +02:00
9qeklajc
d934f3eead clean up 2026-04-09 20:41:59 +02:00
9qeklajc
74b58e5fa3 add some tests and improve payment handling 2026-04-09 20:25:43 +02:00
9qeklajc
c1497e0cfe Merge pull request #444 from Routstr/add-model-id-to-validation-log
add model id to validation log
2026-04-09 01:13:07 +02:00
9qeklajc
30db582321 add model id to validation log 2026-04-08 00:36:05 +02:00
9qeklajc
a1018776e9 make sure no negative balance can happen 2026-04-08 00:16:55 +02:00
9qeklajc
5ef499e2ae Merge pull request #443 from Routstr/437-clean-up-logging
do not log client host address
2026-04-07 00:44:07 +02:00
9qeklajc
c7c802c610 do not log client host address 2026-04-07 00:23:53 +02:00
9qeklajc
685368bb0a add support to using custom model ids 2026-04-06 20:09:12 +02:00
9qeklajc
55e240d92a Merge pull request #435 from Routstr/add-missing-sat-cost-in-x-cashu
add sat cost to x-cashu response
2026-04-05 01:35:17 +02:00
9qeklajc
7da4ad3818 add sat cost to x-cashu response 2026-04-05 01:20:58 +02:00
9qeklajc
453337cb2c update default payout 2026-04-05 00:36:59 +02:00
9qeklajc
9e1934bfda Merge pull request #434 from Routstr/opencode-display-usage-correctly
Opencode display usage correctly
2026-04-05 00:34:22 +02:00
9qeklajc
31a9730cd8 clean up 2026-04-05 00:30:24 +02:00
9qeklajc
d686e0e851 Merge pull request #433 from Routstr/update-x-cashu-swept-default-to-one-week
x-cashu swept default to one week
2026-04-05 00:23:37 +02:00
9qeklajc
7708ed1c8b x-cashu swept default to one week 2026-04-05 00:21:53 +02:00
9qeklajc
22ede7636a added opencode display usage correctly 2026-04-03 23:37:04 +02:00
9qeklajc
c9a5af0ffa Merge pull request #432 from Routstr/fix-usage-msat-pricing
set in/out msat prices correctly
2026-04-03 23:14:17 +02:00
9qeklajc
072079b33d set in/out msat prices correctly 2026-04-03 22:26:48 +02:00
9qeklajc
2abc28d295 Merge pull request #431 from Routstr/add-refund-success-log
added refund success log
2026-04-02 13:31:01 +02:00
9qeklajc
7879b68cad added refund success log 2026-04-02 13:18:55 +02:00
9qeklajc
9eff340c57 Merge pull request #430 from Routstr/add-missing-messages-endpoint
add missing messages endpoint
2026-04-01 23:29:35 +02:00
9qeklajc
933ba105d8 add missing messages endpoint 2026-04-01 23:27:15 +02:00
9qeklajc
205dc6c3b3 Merge pull request #429 from Routstr/fix-reset-refund-while-reserved-balance-not-zero
refund only when reserved balance is zero
2026-04-01 17:21:57 +02:00
9qeklajc
c1ada39278 refund only when reserved balance is zero 2026-04-01 17:17:36 +02:00
9qeklajc
200b32f1ff Merge pull request #428 from Routstr/simplify-x-cashu-refund
Simplify x cashu refund
2026-04-01 15:23:59 +02:00
9qeklajc
2b623a86c3 Merge pull request #423 from Routstr/fix-display-api-key-infos
Fix display api key infos
2026-04-01 15:18:35 +02:00
9qeklajc
a68998e8ff clean up 2026-04-01 15:13:38 +02:00
9qeklajc
905ae67015 update tests 2026-04-01 15:11:43 +02:00
9qeklajc
6fde846be2 refund x cashu 2026-04-01 15:04:38 +02:00
9qeklajc
a833cf429e add refund x-cashu to default refund endpoint 2026-03-29 21:22:09 +02:00
9qeklajc
c5a205cf98 clean up 2026-03-29 11:40:17 +02:00
9qeklajc
62185fbd38 fix build 2026-03-25 20:07:36 +01:00
9qeklajc
236854bfe4 update lightining address 2026-03-25 10:25:46 +01:00
9qeklajc
a7886c528f Merge branch 'main' into add-dev-cut 2026-03-25 10:24:55 +01:00
9qeklajc
bb97e8dedb use react query 2026-03-25 10:21:18 +01:00
9qeklajc
a7455a48d2 Merge pull request #421 from Routstr/v0.4.1
fix fee calculation
2026-03-25 10:20:14 +01:00
redshift
a090632804 Merge pull request #424 from Routstr/fix/models-trailing-slash
fix: add trailing slash routes for /models endpoint
2026-03-24 22:31:14 +00:00
redshift
d6b755ff81 Merge pull request #425 from Routstr/main
syncign before merge
2026-03-24 22:28:37 +00:00
9qeklajc
a1c2a785a1 better displaying errors 2026-03-23 22:36:16 +01:00
redshift
dc8ebe66e0 docs: add other mints balance tracking plan 2026-03-23 21:28:45 +00:00
9qeklajc
750193e99d clean up ui 2026-03-23 22:20:36 +01:00
9qeklajc
4ee654331f fix refunding not displayed & clean up 2026-03-23 22:01:54 +01:00
9qeklajc
b8fcf5bcc8 update node version 2026-03-23 21:05:42 +01:00
9qeklajc
2ce8981f0c Merge pull request #422 from Routstr/fix-fee-calculation-for-same-mint
improve mint fee calculation
2026-03-23 21:02:32 +01:00
9qeklajc
b76ff62f1c improve mint fee calculation 2026-03-23 20:58:01 +01:00
9qeklajc
73ebfe8975 Merge pull request #420 from Routstr/fix-fee-calculation-for-same-mint
no fees when same mint
2026-03-23 20:12:57 +01:00
9qeklajc
3dae03d731 no fees when same mint 2026-03-23 20:10:19 +01:00
9qeklajc
a7b815b29f add-dev-cut 2026-03-23 20:07:24 +01:00
redshift
8a1f909083 Merge pull request #415 from Routstr/v0.4.0
Fix admin models route conflict
2026-03-23 15:00:33 +00:00
redshift
a6c6d3e034 fix: add trailing slash routes for /models endpoint
The /models/ endpoint with trailing slash was being caught by the proxy
catch-all route instead of the models router, causing 'Model unknown not
found' errors. Add explicit routes for both /models and /v1/models with
and without trailing slashes.
2026-03-23 13:15:59 +00:00
Evan Yang
f2a0473fb3 Fix admin models route conflict 2026-03-23 20:29:18 +08:00
redshift
33d2ef9ef1 Merge pull request #353 from Routstr/v0.4.0
V0.4.0
2026-03-23 11:39:22 +00:00
9qeklajc
f1c3b515fe Merge pull request #413 from Routstr/fix-linting-issue
fix linting error
2026-03-22 20:02:03 +01:00
9qeklajc
74ddc48ff5 fix linting error 2026-03-22 19:59:39 +01:00
9qeklajc
6db11ed88f update python 2026-03-18 23:40:34 +01:00
9qeklajc
4d302baeb5 update to v0.4.0 2026-03-18 23:31:25 +01:00
9qeklajc
063df51ced Merge pull request #411 from Routstr/408-fix-swap
fix swapping the primary mint
2026-03-18 22:17:29 +01:00
9qeklajc
e97fa60b27 Merge pull request #407 from Routstr/v0.4.0-analytics
V0.4.0 analytics
2026-03-18 21:48:44 +01:00
9qeklajc
42b3840df6 Merge branch 'v0.4.0' into v0.4.0-analytics
# Conflicts:
#	routstr/core/settings.py
2026-03-18 21:43:18 +01:00
9qeklajc
a0b2b9466c Merge pull request #409 from Routstr/cache-x-cashu-tokens
Cache x cashu tokens
2026-03-16 23:07:38 +01:00
9qeklajc
9a553de011 Merge pull request #410 from Routstr/allow-x-cashu-header
Allow x cashu header
2026-03-16 22:18:01 +01:00
9qeklajc
901a6a9ba2 fix swapping the primary mint 2026-03-16 21:40:43 +01:00
9qeklajc
691927a996 expose x-cashu header 2026-03-16 21:16:13 +01:00
9qeklajc
8fcecf2c1f Merge branch 'v0.4.0' into cache-x-cashu-tokens
# Conflicts:
#	routstr/core/admin.py
2026-03-16 21:07:44 +01:00
9qeklajc
aed967dc44 update ui 2026-03-16 21:06:13 +01:00
9qeklajc
ed8102d533 update ui 2026-03-14 16:59:42 +01:00
9qeklajc
8fa8475cca lint&fmt 2026-03-14 16:59:32 +01:00
9qeklajc
4723b9db4d fix storing in/out trans. 2026-03-14 16:56:44 +01:00
9qeklajc
358ff25899 Merge pull request #406 from Routstr/enforce-cheapest-model-prices
make sure the models with lowest prices are forwarded
2026-03-13 23:07:30 +01:00
9qeklajc
7267bb87b9 fix tests 2026-03-13 23:04:17 +01:00
9qeklajc
173f5fbcbd make sure the models with lowest prices are forwarded 2026-03-13 22:59:09 +01:00
9qeklajc
9006709f8d Merge pull request #404 from Routstr/add-missing-refund-button
add missing refund button
2026-03-13 22:39:39 +01:00
9qeklajc
7d48b36be8 add missing refund button 2026-03-13 22:23:50 +01:00
9qeklajc
8b3fdaa545 Merge pull request #403 from Routstr/fix-admin-routstr-balance-timeout
Handle Routstr admin balance timeouts
2026-03-13 21:13:19 +01:00
9qeklajc
b43d57df23 Merge pull request #402 from Routstr/fix/admin-routstr-balance-timeout
fix: handle Routstr balance timeouts
2026-03-13 21:12:14 +01:00
9qeklajc
b9a5a9276e Merge pull request #401 from Routstr/fix/routstr-provider-sats-balance
fix: show Routstr provider balances in sats
2026-03-13 21:11:15 +01:00
9qeklajc
a79fdf7212 clean up 2026-03-13 21:09:51 +01:00
Shroominic
9560050946 test: type routstr topup async client mock 2026-03-13 19:08:45 +08:00
Shroominic
dd88e9b172 test: annotate admin balance integration session 2026-03-13 18:51:30 +08:00
Shroominic
e31b45fa9e test: type dummy async client exit hook 2026-03-13 18:51:30 +08:00
Shroominic
1ed8b29d64 style: format provider balance placeholder 2026-03-13 18:51:30 +08:00
Shroominic
59f8d31719 Handle Routstr admin balance timeouts 2026-03-13 18:45:18 +08:00
Shroominic
93a368b1a2 fix(admin): handle routstr balance timeouts 2026-03-13 18:39:06 +08:00
Shroominic
d3dd346853 fix: remove unrelated balance endpoint changes 2026-03-13 18:20:08 +08:00
Shroominic
0198569a9a fix: retry transient Routstr top-up invoice failures 2026-03-13 18:17:19 +08:00
Shroominic
7e648cb5c2 fix: use sats for Routstr top-up amounts 2026-03-13 17:46:57 +08:00
Shroominic
deb75624f3 fix: show Routstr provider balances in sats 2026-03-13 17:36:52 +08:00
Evan Yang
c5cb562165 chore: drop non-analytics branch drift 2026-03-13 17:00:15 +08:00
Evan Yang
8a89a38864 fix: restore analytics branch test and admin flows 2026-03-13 16:39:44 +08:00
Evan Yang
09e7f1f0bf Increase snapshot model limit to 20 2026-03-13 15:55:29 +08:00
Evan Yang
d9d082ad5c Use per-metric top-model selection for usage mix 2026-03-13 15:55:29 +08:00
Evan Yang
9cd4ff5c21 Simplify analytics sharing to single snapshot with multi-window payloads 2026-03-13 15:55:29 +08:00
Evan Yang
11eb20a2d1 fix(ci): resolve backend type/lint and ui format issues 2026-03-13 15:55:29 +08:00
Evan Yang
a63e81db06 fix(ui): improve admin usage chart timestamp precision 2026-03-13 15:55:29 +08:00
Evan Yang
8fc1b6484c Add Nostr analytics snapshots and expand stats model coverage 2026-03-13 15:55:29 +08:00
Evan Yang
9bc3feff62 Enhance payment and analytics tracking by adding input and output token metrics across various components 2026-03-13 15:55:21 +08:00
Evan Yang
cb22968ff3 Refactor log management and usage analytics 2026-03-13 15:55:16 +08:00
Evan Yang
e3bca39815 Remove max_points plumbing from dashboard analytics 2026-03-13 15:55:09 +08:00
Evan Yang
958f28fd82 Optimize dashboard analytics pipeline and clean UI data flow 2026-03-13 15:54:59 +08:00
Evan Yang
c8c30d7cfd Implement caching in LogManager for improved performance and optimize log entry retrieval 2026-03-13 15:53:52 +08:00
9qeklajc
2c2124952f add transaction view 2026-03-11 23:39:22 +01:00
9qeklajc
f56ba92ae8 Merge branch 'v0.4.0' into cache-x-cashu-tokens 2026-03-11 22:53:15 +01:00
9qeklajc
1fa71ee806 Merge pull request #396 from Routstr/v0.4.0-ui-update
V0.4.0 UI update
2026-03-11 22:48:05 +01:00
9qeklajc
97d164e181 fix build 2026-03-11 22:46:23 +01:00
9qeklajc
9f3c915dec fmt 2026-03-11 22:45:13 +01:00
9qeklajc
4ff6218140 Merge branch 'v0.4.0' into v0.4.0-ui-update
# Conflicts:
#	routstr/core/log_manager.py
#	ui/app/page.tsx
#	ui/app/providers/page.tsx
2026-03-11 22:44:56 +01:00
9qeklajc
67ea6aec43 Fix TypeScript error in chart.tsx key prop 2026-03-11 22:10:29 +01:00
9qeklajc
41f346417a Regenerate pnpm-lock.yaml for GitHub Actions 2026-03-11 22:06:05 +01:00
9qeklajc
8a373276ce cache x-cashu tokens 2026-03-11 22:03:05 +01:00
9qeklajc
4857d741de Merge pull request #370 from Routstr/add-routstr-provider
Add routstr provider
2026-03-11 21:30:19 +01:00
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
Evan Yang
812f50ea28 Fix ESLint peer dependency for npm install 2026-03-10 11:43:56 +08: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
Evan Yang
5aa902fd4a Clean up 2026-03-09 20:47:50 +08:00
Evan Yang
db144e0903 Trim analytics backend changes from UI branch 2026-03-09 20:09:08 +08:00
Evan Yang
e20b20dbca Restore dashboard analytics data path 2026-03-09 16:57:15 +08:00
Evan Yang
aa700443d3 Remove unrelated file changes 2026-03-09 15:56:27 +08:00
Evan Yang
7e89b5bc6d Remove unrelated non-UI branch changes 2026-03-09 15:37:13 +08:00
Evan Yang
8dbf036558 Remove model usage analytics from UI update branch 2026-03-09 15:26:07 +08: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
Evan Yang
20b4c3a641 Restore analytics dashboard and polish mobile shell 2026-03-07 18:52:17 +08:00
Evan Yang
88f7ee734f Fix dashboard auth loading state on split UI branch 2026-03-07 18:16:29 +08:00
Evan Yang
326a0086b7 Fix chart container sizing warnings 2026-03-07 18:15:44 +08:00
Evan Yang
eb46651373 Fix UI formatting for CI 2026-03-07 18:15:44 +08:00
Evan Yang
226188e22d Refine admin UI and models routing 2026-03-07 18:15:44 +08:00
Evan Yang
89fc48eea2 Refine model pricing and mobile list layout 2026-03-07 18:15:29 +08:00
Evan Yang
f66d98e6b5 chore(release): bump version to 0.4.0 2026-03-07 18:15:29 +08:00
Evan Yang
cfea4f9cd1 Update Node.js version in GitHub Actions workflow from 18 to 20 2026-03-07 18:15:29 +08:00
Evan Yang
c74a877ab5 Fix build and formatting 2026-03-07 18:15:29 +08:00
Evan Yang
83a76e86fe Update dashboard UI/UX 2026-03-07 18:15:29 +08: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
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
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
240 changed files with 31359 additions and 15063 deletions

View File

@@ -14,6 +14,7 @@ UPSTREAM_API_KEY=your-upstream-api-key
# HTTP_URL=https://api.mynode.com
# ONION_URL=http://mynode.onion (auto fetched from compose)
# RELAYS="wss://relay.damus.io,wss://relay.nostr.band,wss://eden.nostr.land,wss://relay.routstr.com"
# ENABLE_ANALYTICS_SHARING=true
# CASHU_MINTS="https://mint.minibits.cash/Bitcoin,https://mint.cubabitcoin.org,https://ecashmint.otrta.me"
# RECEIVE_LN_ADDRESS=

View File

@@ -67,7 +67,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
node-version: "20"
cache: "pnpm"
cache-dependency-path: ui/pnpm-lock.yaml

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

View File

@@ -51,14 +51,26 @@ curl https://api.routstr.com/v1/chat/completions \
## Quick Start (Docker)
If you are a node runner, start a Routstr Core instance and configure upstream access in the dashboard.
If you are a node runner, start a Routstr Core instance using Docker Compose:
```bash
docker run -d \
--name routstr-proxy \
-p 8000:8000 \
ghcr.io/routstr/proxy:latest
```
1. **Prepare your `.env`**:
```bash
ADMIN_PASSWORD=mysecretpassword
NAME="My AI Node"
DESCRIPTION="Fast access to models"
NSEC=yournsec
RECEIVE_LN_ADDRESS=yourname@wallet.com
```
2. **Start the services**:
```bash
docker compose up -d
```
3. **Configure**:
Open [http://localhost:8000/admin/](http://localhost:8000/admin/) to connect your AI providers and set pricing.
For full instructions, see the **[Provider Quick Start Guide](https://docs.routstr.com/provider/quickstart/)**.
## Development

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.

View File

@@ -6,6 +6,32 @@ For automated deployments, you can optionally pre-configure settings via environ
---
## 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.
@@ -14,29 +40,29 @@ Access the dashboard at `/admin/` on your node.
Connect to your AI provider(s):
| Setting | Description |
|---------|-------------|
| Setting | Description |
| ---------------- | ------------------------------------------------ |
| **Upstream URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
| **API Key** | Your provider's API key |
| **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 |
| 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%) |
| 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.
@@ -44,33 +70,34 @@ See [Pricing](pricing.md) for detailed strategies.
Which mints to accept payments from:
| Setting | Description |
|---------|-------------|
| Setting | Description |
| --------- | ------------------------------- |
| **Mints** | List of trusted Cashu mint URLs |
### Lightning Withdrawals
Automatic profit withdrawal:
| Setting | Description |
|---------|-------------|
| Setting | Description |
| --------------------- | ------------------------------- |
| **Lightning Address** | Your LN address for withdrawals |
### Security
| Setting | Description |
|---------|-------------|
| 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 |
| Setting | Description |
| ---------- | ------------------------------------ |
| **Npub** | Your Nostr public key |
| **Nsec** | Your Nostr private key (for signing) |
| **Relays** | Relays to publish announcements |
| **Share Analytics** | Publish aggregate usage stats to Nostr |
See [Discovery](discovery.md) for details.
@@ -86,21 +113,22 @@ Use environment variables for:
### 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) |
| Variable | Description | Default |
| -------------------- | --------------------------------- | ------------------------------------ |
| `UPSTREAM_BASE_URL` | Upstream API endpoint | — |
| `UPSTREAM_API_KEY` | Upstream API key | — |
| `ADMIN_PASSWORD` | Dashboard password | (none) |
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///keys.db` |
| `NAME` | Node display name | `ARoutstrNode` |
| `DESCRIPTION` | Node description | `A Routstr Node` |
| `NPUB` | Nostr public key (bech32) | — |
| `NSEC` | Nostr private key | — |
| `ENABLE_ANALYTICS_SHARING` | Enable usage analytics sharing to Nostr | `true` |
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
| `RELAYS` | Nostr relays (comma-separated) | (default set) |
### Priority

View File

@@ -152,6 +152,7 @@ Manage which mints you accept payments from:
|-------|-------------|
| **Nsec** | Private key for signing announcements |
| **Relays** | Where to publish your node advertisement |
| **Share Analytics** | Toggle publishing aggregate usage stats to Nostr |
### Security

View File

@@ -6,30 +6,16 @@ Production deployment guide for Routstr Provider nodes.
For production, use Docker Compose with persistent storage and optional Tor support.
### Basic Setup
Create a `compose.yml`:
```yaml
services:
routstr:
image: ghcr.io/routstr/proxy:latest
container_name: routstr
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- ./logs:/app/logs
```
Start the node:
Use the included `compose.yml` for a flexible setup that handles both the UI and the node execution. This is useful for development or when you want to manage Tor as a separate service.
```bash
docker compose up -d
```
Then configure everything via the [Admin Dashboard](http://localhost:8000/admin/).
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.
---
@@ -189,8 +175,16 @@ docker compose up -d
## Building from Source
### Using Docker Compose
The easiest way to build everything from source:
```bash
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
docker build -t routstr-local .
docker compose build
```
### Individual Components
If you prefer building the node only (requires manual UI build first):
```bash
docker build -t routstr-node .
```

View File

@@ -13,7 +13,7 @@ A **Routstr Provider Node** acts as a gateway that:
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.
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.
---
@@ -24,14 +24,30 @@ You bring the API keys, Routstr handles the billing, payments, and client manage
---
## 1. Start the Node
## 1. Prepare Configuration
Create a `.env` file in the root of the project to store your secrets:
```bash
docker run -d \
--name routstr \
-p 8000:8000 \
-v routstr-data:/app/data \
ghcr.io/routstr/proxy:latest
# Initial Admin Password
ADMIN_PASSWORD=mysecretpassword
# Node Identity
NAME="My AI Node"
DESCRIPTION="Fast access to models"
NSEC=yournsec
# Lightning Payouts
RECEIVE_LN_ADDRESS=yourname@wallet.com
```
## 2. Start the Node
The recommended way to run Routstr is using Docker Compose, which handles the node, the UI, and optional services like Tor.
```bash
docker compose up -d
```
Verify it's running:
@@ -40,14 +56,23 @@ Verify it's running:
curl http://localhost:8000/v1/info
```
### Build from Source (Optional)
If you've cloned the repository and want to build the images yourself:
```bash
docker compose build
docker compose up -d
```
---
## 2. Configure via Dashboard
## 3. Configure via Dashboard
Open the **Admin Dashboard** at [http://localhost:8000/admin/](http://localhost:8000/admin/).
!!! note "Default Access"
The dashboard has no password by default. Set one immediately in Settings for production use.
!!! 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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,32 @@
"""add routstr_fees table
Revision ID: 02650cd6f028
Revises: c3d4e5f6a7b8
Create Date: 2026-04-24 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "02650cd6f028"
down_revision = "c3d4e5f6a7b8"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"routstr_fees",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("accumulated_msats", sa.Integer(), nullable=False, server_default="0"),
sa.Column("total_paid_msats", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_paid_at", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
# Seed with a single row
op.execute("INSERT INTO routstr_fees (id, accumulated_msats, total_paid_msats) VALUES (1, 0, 0)")
def downgrade() -> None:
op.drop_table("routstr_fees")

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 @@
"""add cashu_transactions table
Revision ID: a776ca70e5fe
Revises: 614c0a740e68
Create Date: 2026-03-11 22:00:01.554762
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "a776ca70e5fe"
down_revision = "614c0a740e68"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"cashu_transactions",
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("amount", sa.Integer(), nullable=False),
sa.Column("unit", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column(
"type",
sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
server_default="out",
),
sa.Column("request_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.Integer(), nullable=False),
sa.Column("collected", sa.Boolean(), nullable=False),
sa.Column("swept", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("cashu_transactions")

View File

@@ -0,0 +1,33 @@
"""add forwarded_model_id to models
Revision ID: b1c2d3e4f5a6
Revises: a776ca70e5fe
Create Date: 2026-04-05 00:00:00.000000
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "b1c2d3e4f5a6"
down_revision = "a776ca70e5fe"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"models",
sa.Column(
"forwarded_model_id",
sqlmodel.sql.sqltypes.AutoString(),
nullable=True,
),
)
# Backfill: set forwarded_model_id = id for all existing rows
op.execute("UPDATE models SET forwarded_model_id = id WHERE forwarded_model_id IS NULL")
def downgrade() -> None:
op.drop_column("models", "forwarded_model_id")

View File

@@ -0,0 +1,36 @@
"""add source to cashu_transactions
Revision ID: c3d4e5f6a7b8
Revises: b1c2d3e4f5a6
Create Date: 2026-04-10 00:00:00.000000
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "c3d4e5f6a7b8"
down_revision = "b1c2d3e4f5a6"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col["name"] for col in inspector.get_columns("cashu_transactions")]
if "source" not in columns:
op.add_column(
"cashu_transactions",
sa.Column(
"source",
sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
server_default="x-cashu",
),
)
def downgrade() -> None:
op.drop_column("cashu_transactions", "source")

View File

@@ -0,0 +1,34 @@
"""add cli_tokens table
Revision ID: cli_tokens_001
Revises: e8f9a0b1c2d3
Create Date: 2026-04-25 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "cli_tokens_001"
down_revision = "e8f9a0b1c2d3"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"cli_tokens",
sa.Column("id", sa.String(), primary_key=True, nullable=False),
sa.Column("token", sa.String(), nullable=False, unique=True),
sa.Column("name", sa.String(), nullable=False),
sa.Column("created_at", sa.Integer(), nullable=False),
sa.Column("last_used_at", sa.Integer(), nullable=True),
sa.Column("expires_at", sa.Integer(), nullable=True),
)
op.create_index("ix_cli_tokens_token", "cli_tokens", ["token"], unique=True)
def downgrade() -> None:
op.drop_index("ix_cli_tokens_token", table_name="cli_tokens")
op.drop_table("cli_tokens")

View File

@@ -0,0 +1,46 @@
"""add api key link to cashu_transactions
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-04-20 00:00:00.000000
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "d4e5f6a7b8c9"
down_revision = "c3d4e5f6a7b8"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col["name"] for col in inspector.get_columns("cashu_transactions")]
indexes = {index["name"] for index in inspector.get_indexes("cashu_transactions")}
if "api_key_hashed_key" not in columns:
op.add_column(
"cashu_transactions",
sa.Column(
"api_key_hashed_key",
sqlmodel.sql.sqltypes.AutoString(),
nullable=True,
),
)
if "ix_cashu_transactions_api_key_hashed_key" not in indexes:
op.create_index(
"ix_cashu_transactions_api_key_hashed_key",
"cashu_transactions",
["api_key_hashed_key"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_cashu_transactions_api_key_hashed_key", table_name="cashu_transactions")
op.drop_column("cashu_transactions", "api_key_hashed_key")

View File

@@ -0,0 +1,20 @@
"""merge heads: routstr_fees + api_key_to_cashu_transactions
Revision ID: e8f9a0b1c2d3
Revises: 02650cd6f028, d4e5f6a7b8c9
Create Date: 2026-04-24 00:00:00.000000
"""
# revision identifiers, used by Alembic.
revision = "e8f9a0b1c2d3"
down_revision = ("02650cd6f028", "d4e5f6a7b8c9")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -0,0 +1,355 @@
# Plan: Track unsupported incoming mints in `other_mints` and include them in balances
## Goal
When a Cashu token arrives from a mint that is **not** in `settings.cashu_mints`, we currently create/use a wallet for that mint and swap value into the primary mint. Any change/surplus left behind after `melt()` remains in the foreign `token_wallet`, but that balance is not surfaced by `fetch_all_balances()` because it only iterates over configured mints.
This plan adds persistent tracking for those foreign mints in a new database table called `other_mints`, and updates balance reporting to include them.
---
## Current behavior
### Incoming unsupported mint flow
In `routstr/wallet.py`:
- `recieve_token()` deserializes the token
- if `token_obj.mint not in settings.cashu_mints`, it calls `swap_to_primary_mint(token_obj, wallet)`
- `swap_to_primary_mint()` calls `token_wallet.melt(...)` to pay the primary mint invoice
### Important detail: change is retained, not discarded
The underlying Cashu wallet library keeps any melt change:
- `Wallet.melt()` constructs blank outputs for change
- when the melt succeeds, returned change is reconstructed into proofs
- those proofs are appended to `self.proofs` and stored in the wallet DB
So surplus from unsupported mints is **not discarded**, but it may become invisible operationally.
### Visibility problem
`fetch_all_balances()` currently only loops over:
- `settings.cashu_mints`
- units `sat` and `msat`
This means balances left on unsupported mints are not shown in admin balance reporting.
---
## Proposed design
## 1. Add a new DB table: `other_mints`
Add a small table in `routstr/core/db.py` to persist unsupported mints we have seen in incoming tokens.
Suggested schema:
- `mint_url: str` primary key
- `created_at: int`
- `last_seen_at: int`
Minimal model:
```python
class OtherMint(SQLModel, table=True):
__tablename__ = "other_mints"
mint_url: str = Field(primary_key=True)
created_at: int = Field(default_factory=lambda: int(time.time()))
last_seen_at: int = Field(default_factory=lambda: int(time.time()))
```
Why minimal:
- the only required function is mint discovery/tracking
- unit handling can remain dynamic via existing balance queries over `sat` and `msat`
---
## 2. Add DB helpers for `other_mints`
In `routstr/core/db.py`, add helper functions:
### `register_other_mint(mint_url: str) -> None`
Behavior:
- if the mint is not present, insert it
- if it already exists, update `last_seen_at`
### `list_other_mints(session) -> list[str]`
Behavior:
- return all tracked unsupported mint URLs
Optional later:
- `delete_other_mint(...)`
- admin cleanup helpers
---
## 3. Register unsupported mints during token receipt
Update `recieve_token()` in `routstr/wallet.py`.
Current logic:
```python
if token_obj.mint not in settings.cashu_mints:
return await swap_to_primary_mint(token_obj, wallet)
```
Planned logic:
```python
if token_obj.mint not in settings.cashu_mints:
await db.register_other_mint(token_obj.mint)
return await swap_to_primary_mint(token_obj, wallet)
```
Why here:
- this is the earliest reliable point where we know the mint came in via an actual token
- this is exactly the path that can leave foreign-mint change behind
- it avoids needing to infer unsupported mints later from wallet internals
---
## 4. Update `fetch_all_balances()` to include `other_mints`
Current behavior only includes configured mints.
Planned behavior:
- load tracked unsupported mints from DB
- combine them with `settings.cashu_mints`
- dedupe while preserving order
- fetch balances for all tracked mints across requested units
Conceptual flow:
```python
tracked_mints = dedupe(settings.cashu_mints + other_mints_from_db)
```
Then existing per-mint/per-unit balance logic can remain mostly unchanged.
This ensures that retained change on unsupported mints becomes visible in admin balance reporting.
---
## 5. Add a balance source marker
Extend `BalanceDetail` in `routstr/wallet.py` to identify whether a balance row comes from a configured mint or an `other_mints` entry.
Suggested field:
- `source: str` with values:
- `"configured"`
- `"other"`
Updated shape:
```python
class BalanceDetail(TypedDict, total=False):
mint_url: str
unit: str
source: str
wallet_balance: int
user_balance: int
owner_balance: int
error: str
```
Why this helps:
- admin can distinguish normal configured wallet balances from foreign/unsupported balances
- avoids confusion if unexpected mint URLs show up in the balances API/UI
---
## 6. Admin/API impact
Backend impact is minimal because `/admin/api/balances` already returns `fetch_all_balances()` output.
Effects:
- supported mints continue to show as before
- tracked unsupported mints will also appear
- UI can optionally display the new `source` field
No API contract break is expected if the frontend ignores unknown fields.
---
## 7. Payout behavior: do not change in phase 1
`periodic_payout()` currently only iterates over `settings.cashu_mints`.
Recommendation for this change:
- **do not** expand `periodic_payout()` to include `other_mints` yet
- only improve visibility through balance reporting
Reason:
- automatic payout from unsupported/foreign mints may be operationally undesirable
- visibility should come first, automation second
Possible future phase:
- add optional sweeping/payout support for `other_mints`
- or provide an admin-triggered withdrawal/sweep flow
---
## 8. Logging improvements (optional)
Optional follow-up improvement in `swap_to_primary_mint()`:
- capture the return value from `token_wallet.melt(...)`
- if feasible, log any reported change amount
- otherwise, rely on wallet balance reporting to surface residual amounts
This is useful but not required for the first implementation.
---
## Files to change
### `routstr/core/db.py`
Add:
- `OtherMint` SQLModel
- `register_other_mint()`
- `list_other_mints()`
### `migrations/versions/<new_revision>_add_other_mints_table.py`
Create migration to add the `other_mints` table.
### `routstr/wallet.py`
Update:
- `recieve_token()` to register unsupported mints
- `BalanceDetail` to include `source`
- `fetch_all_balances()` to include both configured and tracked unsupported mints
### `routstr/core/admin.py`
Likely no backend changes required unless a dedicated `other_mints` API is desired.
---
## Behavior rules
### Register a mint when
- an incoming token is processed
- the token mint is not in `settings.cashu_mints`
### Do not remove automatically when
- balance reaches zero
Reason:
- historical visibility is useful
- avoids flapping entries in the admin balance list
- mint may receive additional unsupported tokens later
Potential future enhancement:
- admin endpoint to prune zero-balance `other_mints`
---
## Edge cases
### A mint later becomes configured
If a mint in `other_mints` is later added to `settings.cashu_mints`:
- deduplication prevents duplicate balance rows
- `source` should resolve to `configured`
### Unsupported mint with zero balance
A tracked unsupported mint may show zero balances.
Initial recommendation:
- allow it to appear
- consider later filtering zero-balance `other` rows if the UI becomes noisy
### Units
Balance fetching can continue to query both `sat` and `msat` for each tracked mint.
If a mint has no proofs in one unit, current error/zero handling can continue to apply.
---
## Test plan
### DB tests
- registering a new unsupported mint inserts a row
- registering the same mint again updates `last_seen_at` without duplication
- listing other mints returns expected mint URLs
### Wallet tests
#### `recieve_token()`
- when mint is unsupported, `db.register_other_mint()` is called before swap
- when mint is configured, `db.register_other_mint()` is not called
#### `fetch_all_balances()`
- includes configured mints
- includes `other_mints` from DB
- dedupes if a mint exists in both configured and other lists
- sets `source` correctly
### Regression tests
- existing trusted mint balance reporting remains unchanged
- `/admin/api/balances` continues to work
---
## Recommended implementation order
1. Add `OtherMint` model to `routstr/core/db.py`
2. Add Alembic migration for `other_mints`
3. Add `register_other_mint()` and `list_other_mints()` helpers
4. Update `recieve_token()` to register unsupported mints
5. Update `fetch_all_balances()` to union configured + tracked other mints
6. Add `source` to `BalanceDetail`
7. Add/adjust tests
---
## Summary
This change solves an operational visibility problem:
- unsupported incoming mints can leave retained change in foreign wallets
- those funds are currently preserved but not surfaced in balance reporting
- introducing `other_mints` makes those mints discoverable and auditable
- expanding `fetch_all_balances()` ensures their balances are visible in admin tooling
Recommended scope for the first pass:
- track unsupported mints in DB
- include them in balance reporting
- mark them as `source="other"`
- do not yet change payout/sweeping behavior

View File

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

View File

@@ -118,12 +118,32 @@ def create_model_mappings(
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
unique_models: dict[str, "Model"] = {}
seen_model_provider: set[tuple[str, str]] = set()
providers_by_db_id: dict[int, "BaseUpstreamProvider"] = {}
for upstream in upstreams:
db_id = getattr(upstream, "db_id", None)
if isinstance(db_id, int):
providers_by_db_id[db_id] = upstream
# Group upstreams by URL and keep only the one with the lowest fee for each URL
upstreams_by_url: dict[str, list["BaseUpstreamProvider"]] = {}
for upstream in upstreams:
url = getattr(upstream, "base_url", "")
if url not in upstreams_by_url:
upstreams_by_url[url] = []
upstreams_by_url[url].append(upstream)
filtered_upstreams: list["BaseUpstreamProvider"] = []
for providers in upstreams_by_url.values():
best_provider = min(providers, key=lambda p: p.provider_fee)
filtered_upstreams.append(best_provider)
# Separate OpenRouter from other providers
openrouter: "BaseUpstreamProvider" | None = None
other_upstreams: list["BaseUpstreamProvider"] = []
for upstream in upstreams:
for upstream in filtered_upstreams:
base_url = getattr(upstream, "base_url", "")
if base_url == "https://openrouter.ai/api/v1":
openrouter = upstream
@@ -134,6 +154,16 @@ 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 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:
@@ -148,6 +178,7 @@ def create_model_mappings(
) -> 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:
@@ -186,9 +217,14 @@ def create_model_mappings(
if prefixed_id not in aliases:
aliases.append(prefixed_id)
# Register forwarded_model_id as a routable alias
if model_to_use.forwarded_model_id and model_to_use.forwarded_model_id not in aliases:
aliases.append(model_to_use.forwarded_model_id)
# Try to set each alias
for alias in aliases:
_add_candidate(alias, model_to_use, upstream)
seen_model_provider.add((model_to_use.id.lower(), provider_key))
# Process non-OpenRouter providers first
for upstream in other_upstreams:
@@ -198,6 +234,89 @@ def create_model_mappings(
if openrouter:
process_provider_models(openrouter, is_openrouter=True)
# Include enabled DB overrides even when provider discovery misses models.
# This is important for deployment-based providers like Azure.
for model_id, override_data in overrides_by_id.items():
if model_id in disabled_model_ids:
continue
override_row, provider_fee = override_data
upstream_provider_id = getattr(override_row, "upstream_provider_id", None)
if not isinstance(upstream_provider_id, int):
continue
upstream_for_override = providers_by_db_id.get(upstream_provider_id)
if upstream_for_override is None:
continue
provider_key = get_provider_identity(upstream_for_override)
dedupe_key = (model_id.lower(), provider_key)
if dedupe_key in seen_model_provider:
continue
try:
model_to_use = _row_to_model(
override_row, apply_provider_fee=True, provider_fee=provider_fee
)
except Exception as exc:
logger.warning(
"Skipping invalid model override while building model mappings",
extra={
"model_id": model_id,
"upstream_provider_id": upstream_provider_id,
"error": str(exc),
"error_type": type(exc).__name__,
},
)
continue
if not model_to_use.enabled:
continue
base_id = get_base_model_id(model_to_use.id)
is_openrouter = (
getattr(upstream_for_override, "base_url", "")
== "https://openrouter.ai/api/v1"
)
if not is_openrouter or base_id not in unique_models:
unique_model = model_to_use.copy(
update={
"id": base_id,
"upstream_provider_id": upstream_for_override.provider_type,
}
)
unique_models[base_id] = unique_model
try:
aliases = resolve_model_alias(
model_to_use.id,
model_to_use.canonical_slug,
alias_ids=model_to_use.alias_ids,
)
except Exception as exc:
logger.warning(
"Skipping model aliases for invalid override model",
extra={
"model_id": model_id,
"upstream_provider_id": upstream_provider_id,
"error": str(exc),
"error_type": type(exc).__name__,
},
)
continue
upstream_prefix = getattr(upstream_for_override, "upstream_name", None)
if upstream_prefix and "/" not in model_to_use.id:
prefixed_id = f"{upstream_prefix}/{model_to_use.id}"
if prefixed_id not in aliases:
aliases.append(prefixed_id)
# Register forwarded_model_id as a routable alias
if model_to_use.forwarded_model_id and model_to_use.forwarded_model_id not in aliases:
aliases.append(model_to_use.forwarded_model_id)
for alias in aliases:
_add_candidate(alias, model_to_use, upstream_for_override)
seen_model_provider.add(dedupe_key)
# Sort candidates and build final maps
model_instances: dict[str, "Model"] = {}
provider_map: dict[str, list["BaseUpstreamProvider"]] = {}

View File

@@ -7,11 +7,12 @@ from datetime import datetime
from typing import Optional
from fastapi import HTTPException
from sqlalchemy import case
from sqlalchemy.exc import IntegrityError
from sqlmodel import col, select, update
from .core import get_logger
from .core.db import ApiKey, AsyncSession
from .core.db import ApiKey, AsyncSession, accumulate_routstr_fee
from .core.settings import settings
from .payment.cost_calculation import (
CostData,
@@ -22,6 +23,13 @@ from .payment.cost_calculation import (
from .wallet import credit_balance, deserialize_token_from_string
logger = get_logger(__name__)
payments_logger = get_logger("routstr.payments")
# Routstr platform fee constants
ROUTSTR_FEE_PERCENT: float = 2.1
ROUTSTR_LN_ADDRESS: str = "npub130mznv74rxs032peqym6g3wqavh472623mt3z5w73xq9r6qqdufs7ql29s@npub.cash"
ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS: int = 900
ROUTSTR_FEE_DEFAULT_PAYOUT: int = 200
# TODO: implement prepaid api key (not like it was before)
# PREPAID_API_KEY = os.environ.get("PREPAID_API_KEY", None)
@@ -348,6 +356,8 @@ async def validate_bearer_key(
)
return new_key
except HTTPException:
raise
except Exception as e:
logger.error(
"Cashu token redemption failed",
@@ -370,12 +380,12 @@ async def validate_bearer_key(
},
)
key_preview = bearer_key[:10] + "..." if len(bearer_key) > 10 else bearer_key
logger.error(
"Invalid API key format",
f"Invalid API key format: preview={key_preview!r} length={len(bearer_key)} "
f"(expected 'sk-...' or 'cashu...' token)",
extra={
"key_preview": bearer_key[:10] + "..."
if len(bearer_key) > 10
else bearer_key,
"key_preview": key_preview,
"key_length": len(bearer_key),
},
)
@@ -384,7 +394,7 @@ async def validate_bearer_key(
status_code=401,
detail={
"error": {
"message": "Invalid API key",
"message": "Invalid API key format. Expected an 'sk-...' API key or a 'cashu...' token.",
"type": "invalid_request_error",
"code": "invalid_api_key",
}
@@ -582,18 +592,33 @@ async def pay_for_request(
"total_requests": billing_key.total_requests,
},
)
payments_logger.info(
"RESERVE",
extra={
"event": "reserve",
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"cost_reserved": cost_per_request,
"balance": billing_key.balance,
"reserved_balance": billing_key.reserved_balance,
"total_spent": billing_key.total_spent,
},
)
return cost_per_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) == 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,
@@ -607,6 +632,7 @@ async def revert_pay_for_request(
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,
@@ -616,8 +642,8 @@ async def revert_pay_for_request(
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] + "...",
@@ -625,19 +651,22 @@ async def revert_pay_for_request(
"current_reserved_balance": billing_key.reserved_balance,
},
)
raise HTTPException(
status_code=402,
detail={
"error": {
"message": f"failed to revert request payment: {cost_per_request} mSats required. {billing_key.balance} available.",
"type": "payment_error",
"code": "payment_error",
}
},
)
return False
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
payments_logger.info(
"REVERT",
extra={
"event": "revert",
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"cost_reverted": cost_per_request,
"balance": billing_key.balance,
"reserved_balance": billing_key.reserved_balance,
},
)
return True
async def adjust_payment_for_tokens(
@@ -669,17 +698,19 @@ async def adjust_payment_for_tokens(
release_stmt = (
update(ApiKey)
.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
@@ -688,14 +719,24 @@ async def adjust_payment_for_tokens(
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] + "...",
"billing_key_hash": billing_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",
@@ -706,6 +747,17 @@ async def adjust_payment_for_tokens(
},
)
async def _accumulate_fee(total_cost_msats: int) -> None:
if total_cost_msats > 0 and ROUTSTR_FEE_PERCENT > 0:
fee_msats = math.ceil(total_cost_msats * ROUTSTR_FEE_PERCENT / 100)
try:
await accumulate_routstr_fee(session, fee_msats)
except Exception as e:
logger.warning(
"Failed to accumulate Routstr fee",
extra={"error": str(e), "fee_msats": fee_msats},
)
match await calculate_cost(response_data, deducted_max_cost, session):
case MaxCostData() as cost:
logger.debug(
@@ -718,11 +770,32 @@ async def adjust_payment_for_tokens(
},
)
# Finalize by releasing reservation and charging max cost
if billing_key.reserved_balance < deducted_max_cost:
logger.error(
"reserved_balance below deducted_max_cost before MaxCost finalization — clamping to 0",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"reserved_balance": billing_key.reserved_balance,
"deducted_max_cost": deducted_max_cost,
"total_cost_msats": cost.total_msats,
"balance": billing_key.balance,
"total_spent": billing_key.total_spent,
"model": model,
},
)
safe_reserved = case(
(col(ApiKey.reserved_balance) >= deducted_max_cost,
col(ApiKey.reserved_balance) - deducted_max_cost),
else_=0,
)
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
reserved_balance=safe_reserved,
balance=col(ApiKey.balance) - cost.total_msats,
total_spent=col(ApiKey.total_spent) + cost.total_msats,
)
@@ -731,13 +804,17 @@ async def adjust_payment_for_tokens(
# Also update total_spent and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_safe_reserved = case(
(col(ApiKey.reserved_balance) >= deducted_max_cost,
col(ApiKey.reserved_balance) - deducted_max_cost),
else_=0,
)
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,
reserved_balance=child_safe_reserved,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
@@ -766,10 +843,30 @@ async def adjust_payment_for_tokens(
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"charged_amount": cost.total_msats,
"input_tokens": cost.input_tokens,
"output_tokens": cost.output_tokens,
"new_balance": billing_key.balance,
"model": model,
},
)
await _accumulate_fee(cost.total_msats)
payments_logger.info(
"FINALIZE",
extra={
"event": "finalize",
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
"cost_reserved": deducted_max_cost,
"cost_charged": cost.total_msats,
"input_tokens": cost.input_tokens,
"output_tokens": cost.output_tokens,
"balance": billing_key.balance,
"reserved_balance": billing_key.reserved_balance,
"total_spent": billing_key.total_spent,
"finalize_type": "max_cost",
},
)
return cost.dict()
case CostData() as cost:
@@ -789,6 +886,8 @@ async def adjust_payment_for_tokens(
"cost_difference": cost_difference,
"input_msats": cost.input_msats,
"output_msats": cost.output_msats,
"input_tokens": cost.input_tokens,
"output_tokens": cost.output_tokens,
},
)
@@ -801,12 +900,32 @@ async def adjust_payment_for_tokens(
"model": model,
},
)
if billing_key.reserved_balance < deducted_max_cost:
logger.error(
"reserved_balance below deducted_max_cost on exact-cost finalization — clamping to 0",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"reserved_balance": billing_key.reserved_balance,
"deducted_max_cost": deducted_max_cost,
"total_cost_msats": total_cost_msats,
"balance": billing_key.balance,
"total_spent": billing_key.total_spent,
"model": model,
},
)
exact_safe_reserved = case(
(col(ApiKey.reserved_balance) >= deducted_max_cost,
col(ApiKey.reserved_balance) - deducted_max_cost),
else_=0,
)
finalize_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
reserved_balance=exact_safe_reserved,
balance=col(ApiKey.balance) - total_cost_msats,
total_spent=col(ApiKey.total_spent) + total_cost_msats,
)
@@ -815,13 +934,17 @@ async def adjust_payment_for_tokens(
# Also update total_spent and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_exact_safe_reserved = case(
(col(ApiKey.reserved_balance) >= deducted_max_cost,
col(ApiKey.reserved_balance) - deducted_max_cost),
else_=0,
)
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,
reserved_balance=child_exact_safe_reserved,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
@@ -830,44 +953,56 @@ async def adjust_payment_for_tokens(
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
return cost.dict()
# this should never happen why do we handle this???
if cost_difference > 0:
# Need to charge more than reserved, finalize by releasing reservation and charging total
logger.info(
"Additional charge required for token usage",
await _accumulate_fee(total_cost_msats)
payments_logger.info(
"FINALIZE",
extra={
"event": "finalize",
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"additional_charge": cost_difference,
"current_balance": billing_key.balance,
"sufficient_balance": billing_key.balance >= cost_difference,
"model": model,
"cost_reserved": deducted_max_cost,
"cost_charged": total_cost_msats,
"input_tokens": cost.input_tokens,
"output_tokens": cost.output_tokens,
"balance": billing_key.balance,
"reserved_balance": billing_key.reserved_balance,
"total_spent": billing_key.total_spent,
"finalize_type": "exact",
},
)
return cost.dict()
# actual cost exceeded discounted reservation (due to tolerance_percentage)
if cost_difference > 0:
# Always release the reservation and charge min(actual_cost, balance).
# Using a CASE expression makes this a single atomic UPDATE — no
# multi-level fallback needed and balance can never go negative.
chargeable = case(
(col(ApiKey.balance) >= total_cost_msats, total_cost_msats),
else_=col(ApiKey.balance),
)
finalize_stmt = (
update(ApiKey)
.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,
balance=col(ApiKey.balance) - total_cost_msats,
total_spent=col(ApiKey.total_spent) + total_cost_msats,
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
balance=col(ApiKey.balance) - chargeable,
total_spent=col(ApiKey.total_spent) + chargeable,
)
)
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)
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
.values(
total_spent=col(ApiKey.total_spent) + total_cost_msats,
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
total_spent=col(ApiKey.total_spent) + min(billing_key.balance, total_cost_msats),
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
@@ -875,11 +1010,10 @@ async def adjust_payment_for_tokens(
await session.commit()
if result.rowcount:
cost.total_msats = total_cost_msats
await session.refresh(billing_key)
if billing_key.hashed_key != key.hashed_key:
await session.refresh(key)
cost.total_msats = total_cost_msats
logger.info(
"Finalized payment with additional charge",
extra={
@@ -890,9 +1024,29 @@ async def adjust_payment_for_tokens(
"model": model,
},
)
await _accumulate_fee(total_cost_msats)
payments_logger.info(
"FINALIZE",
extra={
"event": "finalize",
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
"cost_reserved": deducted_max_cost,
"cost_charged": total_cost_msats,
"input_tokens": cost.input_tokens,
"output_tokens": cost.output_tokens,
"balance": billing_key.balance,
"reserved_balance": billing_key.reserved_balance,
"total_spent": billing_key.total_spent,
"finalize_type": "overrun",
},
)
else:
# Guard fired: reservation was already released by a concurrent
# finalization for this key. Nothing left to do.
logger.warning(
"Failed to finalize additional charge - releasing reservation",
"Finalization skipped - reservation already released",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
@@ -900,7 +1054,6 @@ async def adjust_payment_for_tokens(
"model": model,
},
)
await release_reservation_only()
else:
# Refund some of the base cost
refund = abs(cost_difference)
@@ -915,12 +1068,33 @@ async def adjust_payment_for_tokens(
},
)
if billing_key.reserved_balance < deducted_max_cost:
logger.error(
"reserved_balance below deducted_max_cost on refund finalization — clamping to 0",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"reserved_balance": billing_key.reserved_balance,
"deducted_max_cost": deducted_max_cost,
"total_cost_msats": total_cost_msats,
"refund_amount": refund,
"balance": billing_key.balance,
"total_spent": billing_key.total_spent,
"model": model,
},
)
refund_safe_reserved = case(
(col(ApiKey.reserved_balance) >= deducted_max_cost,
col(ApiKey.reserved_balance) - deducted_max_cost),
else_=0,
)
refund_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
reserved_balance=refund_safe_reserved,
balance=col(ApiKey.balance) - total_cost_msats,
total_spent=col(ApiKey.total_spent) + total_cost_msats,
)
@@ -929,13 +1103,17 @@ async def adjust_payment_for_tokens(
# Also update total_spent and reserved_balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_refund_safe_reserved = case(
(col(ApiKey.reserved_balance) >= deducted_max_cost,
col(ApiKey.reserved_balance) - deducted_max_cost),
else_=0,
)
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,
reserved_balance=child_refund_safe_reserved,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
@@ -972,6 +1150,25 @@ async def adjust_payment_for_tokens(
"model": model,
},
)
await _accumulate_fee(total_cost_msats)
payments_logger.info(
"FINALIZE",
extra={
"event": "finalize",
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"model": model,
"cost_reserved": deducted_max_cost,
"cost_charged": total_cost_msats,
"refunded": refund,
"input_tokens": cost.input_tokens,
"output_tokens": cost.output_tokens,
"balance": billing_key.balance,
"reserved_balance": billing_key.reserved_balance,
"total_spent": billing_key.total_spent,
"finalize_type": "refund",
},
)
return cost.dict()
@@ -997,18 +1194,8 @@ 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:

View File

@@ -5,10 +5,18 @@ from time import monotonic
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlmodel import col, select, update
from .auth import validate_bearer_key
from .core.db import ApiKey, AsyncSession, get_session
from .auth import get_billing_key, validate_bearer_key
from .core.db import (
ApiKey,
AsyncSession,
CashuTransaction,
get_session,
store_cashu_transaction,
)
from .core.logging import get_logger
from .core.settings import settings
from .lightning import lightning_router
@@ -34,10 +42,8 @@ async def get_key_from_header(
async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
from .auth import get_billing_key
billing_key = await get_billing_key(key, session)
return {
info = {
"api_key": "sk-" + key.hashed_key,
"balance": billing_key.balance,
"reserved": billing_key.reserved_balance,
@@ -50,6 +56,26 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
"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)
@@ -117,8 +143,6 @@ async def topup_wallet_endpoint(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict[str, int]:
from .auth import get_billing_key
billing_key = await get_billing_key(key, session)
if topup_request is not None:
@@ -137,9 +161,23 @@ async def topup_wallet_endpoint(
raise HTTPException(status_code=400, detail="Token already spent")
elif "invalid" in error_msg.lower() or "decode" in error_msg.lower():
raise HTTPException(status_code=400, detail="Invalid token format")
elif "insufficient" in error_msg.lower() or "melt fee" in error_msg.lower():
raise HTTPException(
status_code=400,
detail=f"Token value is too small to cover swap fees. {error_msg}",
)
elif "failed to melt" in error_msg.lower():
raise HTTPException(
status_code=400,
detail=f"Failed to swap foreign mint token. {error_msg}",
)
else:
raise HTTPException(status_code=400, detail="Failed to redeem token")
except Exception:
raise HTTPException(status_code=400, detail=f"Failed to redeem token: {error_msg}")
except Exception as e:
logger.error(
"topup_wallet_endpoint: unhandled error",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise HTTPException(status_code=500, detail="Internal server error")
return {"msats": amount_msats}
@@ -173,23 +211,82 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None:
_refund_cache[key] = (expiry, value)
@router.post("/refund")
async def _restore_balance(
session: AsyncSession, hashed_key: str, balance: int, reserved_balance: int
) -> None:
"""Restore balance after a failed refund mint attempt."""
restore_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == hashed_key)
.values(
balance=col(ApiKey.balance) + balance,
reserved_balance=col(ApiKey.reserved_balance) + reserved_balance,
)
)
await session.exec(restore_stmt) # type: ignore[call-overload]
await session.commit()
logger.info(
"refund_wallet_endpoint: balance restored after mint failure",
extra={"hashed_key": hashed_key, "restored_balance": balance},
)
@router.post("/refund", response_model=None)
async def refund_wallet_endpoint(
authorization: Annotated[str, Header(...)],
authorization: Annotated[str | None, Header()] = None,
x_cashu: Annotated[str | None, Header()] = None,
session: AsyncSession = Depends(get_session),
) -> dict[str, str]:
if not authorization.startswith("Bearer "):
) -> JSONResponse | dict[str, str]:
if x_cashu:
# Find the "in" transaction by the original payment token
in_tx_result = await session.exec(
select(CashuTransaction).where(
CashuTransaction.token == x_cashu,
CashuTransaction.type == "in",
)
)
in_tx = in_tx_result.first()
if in_tx is None:
raise HTTPException(status_code=404, detail="Refund not found")
# Use the request_id to find the associated "out" (refund) transaction
if in_tx.request_id is None:
raise HTTPException(status_code=404, detail="Refund not found")
out_tx_result = await session.exec(
select(CashuTransaction).where(
CashuTransaction.request_id == in_tx.request_id,
CashuTransaction.type == "out",
)
)
out_tx = out_tx_result.first()
if out_tx is None:
raise HTTPException(status_code=404, detail="Refund not found")
if out_tx.swept:
raise HTTPException(status_code=410, detail="Refund has been swept")
out_tx.collected = True
session.add(out_tx)
await session.commit()
body: dict[str, str] = {"token": out_tx.token}
if out_tx.unit == "sat":
body["sats"] = str(out_tx.amount)
else:
body["msats"] = str(out_tx.amount)
return JSONResponse(content=body, headers={"X-Cashu": out_tx.token})
if authorization is None or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=401,
detail="Invalid authorization. Use 'Bearer <cashu-token>' or 'Bearer <api-key>'",
)
bearer_value: str = authorization[7:]
key: ApiKey = await validate_bearer_key(bearer_value, session)
if cached := await _refund_cache_get(bearer_value):
return cached
if key.total_balance <= 0:
if cached := await _refund_cache_get(bearer_value):
return cached
if key.parent_key_hash:
raise HTTPException(
@@ -197,6 +294,12 @@ async def refund_wallet_endpoint(
detail="Cannot refund child key. Please refund the parent key instead.",
)
if key.reserved_balance > 0:
raise HTTPException(
status_code=400,
detail="Cannot refund key. There are ongoing requests for this api key.",
)
remaining_balance_msats: int = key.total_balance
if key.refund_currency == "sat":
@@ -209,7 +312,31 @@ async def refund_wallet_endpoint(
elif remaining_balance <= 0:
raise HTTPException(status_code=400, detail="No balance to refund")
# Perform refund operation first, before modifying balance
# Capture values before debit — the session may refresh key after commit
pre_debit_balance = key.balance
pre_debit_reserved = key.reserved_balance
# --- DEBIT FIRST: atomically zero the balance before minting tokens ---
# This prevents the race where a concurrent topup/spend happens between
# reading the balance and minting the refund token (double-spend).
debit_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.balance) == pre_debit_balance)
.where(col(ApiKey.reserved_balance) == pre_debit_reserved)
.values(balance=0, reserved_balance=0)
)
debit_result = await session.exec(debit_stmt) # type: ignore[call-overload]
await session.commit()
if debit_result.rowcount == 0:
# Balance changed between read and debit — another request is active
raise HTTPException(
status_code=409,
detail="Balance changed concurrently. Please retry the refund.",
)
# --- MINT: balance is locked at zero, safe to create the refund token ---
try:
if key.refund_address:
from .core.settings import settings as global_settings
@@ -233,11 +360,24 @@ async def refund_wallet_endpoint(
else:
result["msats"] = str(remaining_balance_msats)
if "token" in result:
logger.info(
"refund_wallet_endpoint: cashu token issued",
extra={
"path": "/v1/wallet/refund",
"token": result["token"],
"amount": remaining_balance,
"currency": key.refund_currency or "sat",
},
)
except HTTPException:
# Re-raise HTTP exceptions (like 400 for balance too small)
# Minting failed — restore the debited balance
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved)
raise
except Exception as e:
# If refund fails, don't modify the database
# Minting failed — restore the debited balance
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved)
error_msg = str(e)
if (
"mint" in error_msg.lower()
@@ -251,14 +391,67 @@ async def refund_wallet_endpoint(
await _refund_cache_set(bearer_value, result)
key.balance = 0
key.reserved_balance = 0
session.add(key)
await session.commit()
if "token" in result:
try:
await store_cashu_transaction(
token=result["token"],
amount=remaining_balance,
unit=key.refund_currency or "sat",
mint_url=key.refund_mint_url,
typ="out",
collected=False,
source="apikey",
api_key_hashed_key=key.hashed_key,
)
except Exception:
pass # store_cashu_transaction already logs
logger.info(
"refund_wallet_endpoint: refund successful",
extra={
"refunded_msats": remaining_balance_msats,
"previous_reserved_balance": key.reserved_balance,
},
)
return result
@router.get("/history")
async def wallet_history(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict[str, list[dict[str, str | int | bool | None]]]:
if key.parent_key_hash:
raise HTTPException(
status_code=400,
detail="Cannot view child key history. Please use the parent key instead.",
)
result = await session.exec(
select(CashuTransaction)
.where(CashuTransaction.api_key_hashed_key == key.hashed_key)
.order_by(col(CashuTransaction.created_at).desc())
)
transactions = result.all()
return {
"transactions": [
{
"id": tx.id,
"type": tx.type,
"source": tx.source,
"amount": tx.amount,
"unit": tx.unit,
"mint_url": tx.mint_url,
"created_at": tx.created_at,
"collected": tx.collected,
"swept": tx.swept,
}
for tx in transactions
]
}
@router.post("/donate")
async def donate(token: str, ref: str | None = None) -> str:
try:
@@ -383,6 +576,27 @@ async def reset_child_key_spent(
return {"success": True, "message": "Child key balance reset successfully."}
@router.get("/cashu-refund/{payment_token_hash}")
async def get_cashu_refund(
payment_token_hash: str,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Retrieve a stored Cashu refund token by the hash of the original payment token."""
result = await session.get(CashuTransaction, payment_token_hash)
if result is None:
raise HTTPException(status_code=404, detail="Refund not found")
if result.swept:
raise HTTPException(status_code=410, detail="Refund has been swept")
result.collected = True
session.add(result)
await session.commit()
return {
"refund_token": result.token,
"amount": result.amount,
"unit": result.unit,
}
@router.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "DELETE"],

View File

@@ -1,3 +1,4 @@
import asyncio
import json
import secrets
from datetime import datetime, timezone
@@ -16,7 +17,14 @@ from ..wallet import (
send_token,
slow_filter_spend_proofs,
)
from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session
from .db import (
ApiKey,
CashuTransaction,
CliToken,
ModelRow,
UpstreamProviderRow,
create_session,
)
from .log_manager import log_manager
from .logging import get_logger
from .settings import SettingsService, settings
@@ -27,14 +35,31 @@ admin_router = APIRouter(prefix="/admin", include_in_schema=False)
admin_sessions: dict[str, int] = {}
ADMIN_SESSION_DURATION = 3600
# Usage analytics remain queryable up to 12 months.
MAX_USAGE_ANALYTICS_HOURS = 365 * 24
def require_admin_api(request: Request) -> None:
async def require_admin_api(request: Request) -> None:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1]
expiry = admin_sessions.get(token)
if expiry and expiry > int(datetime.now(timezone.utc).timestamp()):
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=403, detail="Unauthorized")
token = auth_header.split(" ", 1)[1]
now_ts = int(datetime.now(timezone.utc).timestamp())
# 1) Short-lived session token (in-memory)
expiry = admin_sessions.get(token)
if expiry and expiry > now_ts:
return
# 2) Long-lived CLI token (DB-backed)
async with create_session() as session:
result = await session.exec(select(CliToken).where(CliToken.token == token))
cli_token = result.first()
if cli_token and (cli_token.expires_at is None or cli_token.expires_at > now_ts):
cli_token.last_used_at = now_ts
session.add(cli_token)
await session.commit()
return
raise HTTPException(status_code=403, detail="Unauthorized")
@@ -233,6 +258,73 @@ async def admin_logout(request: Request) -> dict[str, object]:
return {"ok": True}
# ─── CLI Tokens (long-lived bearer tokens for CLI/agent use) ───
class CliTokenCreate(BaseModel):
name: str
expires_in_days: int | None = None
@admin_router.get("/api/cli-tokens", dependencies=[Depends(require_admin_api)])
async def list_cli_tokens() -> list[dict[str, object]]:
async with create_session() as session:
result = await session.exec(select(CliToken))
tokens = result.all()
return [
{
"id": t.id,
"name": t.name,
"token_preview": f"{t.token[:8]}...{t.token[-4:]}",
"created_at": t.created_at,
"last_used_at": t.last_used_at,
"expires_at": t.expires_at,
}
for t in tokens
]
@admin_router.post("/api/cli-tokens", dependencies=[Depends(require_admin_api)])
async def create_cli_token(payload: CliTokenCreate) -> dict[str, object]:
name = (payload.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Name is required")
raw_token = secrets.token_urlsafe(32)
expires_at: int | None = None
if payload.expires_in_days is not None and payload.expires_in_days > 0:
expires_at = int(datetime.now(timezone.utc).timestamp()) + (
payload.expires_in_days * 86400
)
async with create_session() as session:
cli_token = CliToken(token=raw_token, name=name, expires_at=expires_at)
session.add(cli_token)
await session.commit()
await session.refresh(cli_token)
return {
"id": cli_token.id,
"name": cli_token.name,
"token": raw_token, # full token returned only on creation
"created_at": cli_token.created_at,
"expires_at": cli_token.expires_at,
}
@admin_router.delete(
"/api/cli-tokens/{token_id}", dependencies=[Depends(require_admin_api)]
)
async def revoke_cli_token(token_id: str) -> dict[str, object]:
async with create_session() as session:
cli_token = await session.get(CliToken, token_id)
if not cli_token:
raise HTTPException(status_code=404, detail="Token not found")
await session.delete(cli_token)
await session.commit()
return {"ok": True, "deleted_id": token_id}
class WithdrawRequest(BaseModel):
amount: int
mint_url: str | None = None
@@ -286,6 +378,7 @@ class ModelCreate(BaseModel):
canonical_slug: str | None = None
alias_ids: list[str] | None = None
enabled: bool = True
forwarded_model_id: str | None = None
@admin_router.post(
@@ -330,6 +423,7 @@ async def upsert_provider_model(
json.dumps(payload.alias_ids) if payload.alias_ids else None
)
existing_row.enabled = payload.enabled
existing_row.forwarded_model_id = payload.forwarded_model_id or payload.id
session.add(existing_row)
await session.commit()
@@ -362,6 +456,7 @@ async def upsert_provider_model(
),
upstream_provider_id=provider_id,
enabled=payload.enabled,
forwarded_model_id=payload.forwarded_model_id or payload.id,
)
session.add(row)
await session.commit()
@@ -456,18 +551,18 @@ async def batch_override_provider_models(
logger.info(
f"BATCH_OVERRIDE called: provider_id={provider_id}, count={len(payload.models)}"
)
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
overridden_count = 0
for model_data in payload.models:
# Try to get existing model regardless of whether it's enabled or not
existing_row = await session.get(ModelRow, (model_data.id, provider_id))
if existing_row:
# Update existing
existing_row.name = model_data.name
@@ -483,7 +578,9 @@ async def batch_override_provider_models(
else None
)
existing_row.top_provider = (
json.dumps(model_data.top_provider) if model_data.top_provider else None
json.dumps(model_data.top_provider)
if model_data.top_provider
else None
)
existing_row.canonical_slug = model_data.canonical_slug
existing_row.alias_ids = (
@@ -508,23 +605,32 @@ async def batch_override_provider_models(
else None
),
top_provider=(
json.dumps(model_data.top_provider) if model_data.top_provider else None
json.dumps(model_data.top_provider)
if model_data.top_provider
else None
),
canonical_slug=model_data.canonical_slug,
alias_ids=(
json.dumps(model_data.alias_ids) if model_data.alias_ids else None
json.dumps(model_data.alias_ids)
if model_data.alias_ids
else None
),
upstream_provider_id=provider_id,
enabled=model_data.enabled,
)
session.add(row)
overridden_count += 1
await session.commit()
await refresh_model_maps()
return {"ok": True, "count": overridden_count, "message": f"Successfully batch overridden {overridden_count} models"}
return {
"ok": True,
"count": overridden_count,
"message": f"Successfully batch overridden {overridden_count} models",
}
class UpstreamProviderCreate(BaseModel):
provider_type: str
@@ -533,6 +639,7 @@ class UpstreamProviderCreate(BaseModel):
api_version: str | None = None
enabled: bool = True
provider_fee: float = 1.01
provider_settings: dict | None = None
class UpstreamProviderUpdate(BaseModel):
@@ -542,6 +649,7 @@ class UpstreamProviderUpdate(BaseModel):
api_version: str | None = None
enabled: bool | None = None
provider_fee: float | None = None
provider_settings: dict | None = None
@admin_router.get("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
@@ -558,6 +666,9 @@ async def get_upstream_providers() -> list[dict[str, object]]:
"api_version": p.api_version,
"enabled": p.enabled,
"provider_fee": p.provider_fee,
"provider_settings": json.loads(p.provider_settings)
if p.provider_settings
else None,
}
for p in providers
]
@@ -570,12 +681,14 @@ async def create_upstream_provider(
async with create_session() as session:
result = await session.exec(
select(UpstreamProviderRow).where(
UpstreamProviderRow.base_url == payload.base_url
UpstreamProviderRow.base_url == payload.base_url,
UpstreamProviderRow.api_key == payload.api_key,
)
)
if result.first():
raise HTTPException(
status_code=409, detail="Provider with this base URL already exists"
status_code=409,
detail="Provider with this base URL and API key already exists",
)
provider = UpstreamProviderRow(
@@ -585,6 +698,9 @@ async def create_upstream_provider(
api_version=payload.api_version,
enabled=payload.enabled,
provider_fee=payload.provider_fee,
provider_settings=json.dumps(payload.provider_settings)
if payload.provider_settings
else None,
)
session.add(provider)
await session.commit()
@@ -600,6 +716,7 @@ async def create_upstream_provider(
"api_version": provider.api_version,
"enabled": provider.enabled,
"provider_fee": provider.provider_fee,
"provider_settings": payload.provider_settings,
}
@@ -619,6 +736,9 @@ async def get_upstream_provider(provider_id: int) -> dict[str, object]:
"api_version": provider.api_version,
"enabled": provider.enabled,
"provider_fee": provider.provider_fee,
"provider_settings": json.loads(provider.provider_settings)
if provider.provider_settings
else None,
}
@@ -645,6 +765,8 @@ async def update_upstream_provider(
provider.enabled = payload.enabled
if payload.provider_fee is not None:
provider.provider_fee = payload.provider_fee
if payload.provider_settings is not None:
provider.provider_settings = json.dumps(payload.provider_settings)
session.add(provider)
await session.commit()
@@ -660,6 +782,9 @@ async def update_upstream_provider(
"api_version": provider.api_version,
"enabled": provider.enabled,
"provider_fee": provider.provider_fee,
"provider_settings": json.loads(provider.provider_settings)
if provider.provider_settings
else None,
}
@@ -718,7 +843,7 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
db_model_ids = {model.id for model in db_models}
filtered_remote_models = [
m for m in upstream_models if m.name not in db_model_ids
m for m in upstream_models if m.id not in db_model_ids
]
return {
@@ -781,6 +906,47 @@ class TopupRequest(BaseModel):
amount: int
class TopupTokenRequest(BaseModel):
token: str
@admin_router.post(
"/api/upstream-providers/{provider_id}/topup-token",
dependencies=[Depends(require_admin_api)],
)
async def topup_provider_with_token(
provider_id: int, payload: TopupTokenRequest
) -> dict:
"""Redeem a Cashu token for an upstream provider."""
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
import httpx
async with httpx.AsyncClient() as client:
clean_url = provider.base_url.rstrip("/")
headers = {}
if provider.api_key:
headers["Authorization"] = f"Bearer {provider.api_key}"
resp = await client.post(
f"{clean_url}/v1/balance/topup",
json={"cashu_token": payload.token},
headers=headers,
)
if resp.status_code == 200:
return {"ok": True, "message": "Token redeemed successfully"}
else:
logger.error(f"Upstream token topup failed: {resp.text}")
try:
error_detail = resp.json()
except Exception:
error_detail = resp.text
return {"ok": False, "message": f"Upstream error: {error_detail}"}
@admin_router.post(
"/api/upstream-providers/{provider_id}/topup",
dependencies=[Depends(require_admin_api)],
@@ -796,18 +962,84 @@ async def initiate_provider_topup(
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
status_code=400, detail="Could not instantiate provider"
)
try:
logger.info(
f"Initiating top-up for provider {provider_id}",
extra={"amount": payload.amount},
)
# For Routstr providers, we might be doing a Lightning top-up or a direct token transfer
if provider.provider_type == "routstr":
# UI sends sats for Routstr topup
import httpx
async with httpx.AsyncClient() as client:
clean_url = provider.base_url.rstrip("/")
request_json = {
"amount_sats": int(payload.amount),
"purpose": "topup",
"api_key": provider.api_key,
}
headers = (
{"Authorization": f"Bearer {provider.api_key}"}
if provider.api_key
else {}
)
last_status_code = 500
last_error_detail: object = "Failed to create top-up invoice"
# Some upstream Routstr nodes fail the first invoice request after warm-up
# and succeed immediately on retry. Retry once here so the UI stays single-click.
for attempt in range(2):
resp = await client.post(
f"{clean_url}/v1/balance/lightning/invoice",
json=request_json,
headers=headers,
)
if resp.status_code == 200:
data = resp.json()
return {
"ok": True,
"topup_data": {
"payment_request": data.get("bolt11"),
"invoice_id": data.get("invoice_id"),
"status": "pending",
},
}
logger.error(
f"Upstream topup request failed: {resp.text}",
extra={
"provider_id": provider_id,
"attempt": attempt + 1,
"status_code": resp.status_code,
},
)
try:
last_error_detail = resp.json()
except Exception:
last_error_detail = resp.text
last_status_code = resp.status_code
if resp.status_code < 500 or attempt == 1:
break
await asyncio.sleep(0.2)
raise HTTPException(
status_code=last_status_code, detail=last_error_detail
)
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
status_code=400, detail="Could not instantiate provider"
)
topup_data = await upstream_instance.initiate_topup(payload.amount)
logger.info(
"Top-up initiated successfully",
extra={
@@ -858,6 +1090,25 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
# For Routstr providers, proxy the status check
if provider.provider_type == "routstr":
import httpx
async with httpx.AsyncClient() as client:
clean_url = provider.base_url.rstrip("/")
resp = await client.get(
f"{clean_url}/v1/balance/lightning/invoice/{invoice_id}/status",
headers={"Authorization": f"Bearer {provider.api_key}"}
if provider.api_key
else {},
)
if resp.status_code == 200:
status_data = resp.json()
return {"ok": True, "paid": status_data.get("status") == "paid"}
else:
logger.error(f"Upstream status check failed: {resp.text}")
return {"ok": False, "paid": False}
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
@@ -885,7 +1136,7 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
dependencies=[Depends(require_admin_api)],
)
async def get_provider_balance(provider_id: int) -> dict[str, object]:
"""Get the current account balance for the upstream provider."""
"""Get the current balance for an upstream provider account."""
from ..upstream.helpers import _instantiate_provider
async with create_session() as session:
@@ -893,6 +1144,61 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]:
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
# For Routstr providers, proxy the balance check
if provider.provider_type == "routstr":
import httpx
clean_url = provider.base_url.rstrip("/")
headers = {}
if provider.api_key:
headers["Authorization"] = f"Bearer {provider.api_key}"
async with httpx.AsyncClient(timeout=10.0) as client:
try:
resp = await client.get(
f"{clean_url}/v1/balance/info",
headers=headers,
)
except httpx.TimeoutException as exc:
logger.error(
"Timed out fetching Routstr provider balance",
extra={
"provider_id": provider_id,
"base_url": clean_url,
"upstream_url": f"{clean_url}/v1/balance/info",
"error": str(exc),
},
)
raise HTTPException(
status_code=504,
detail="Timed out contacting upstream Routstr provider",
) from exc
except httpx.RequestError as exc:
logger.error(
"Failed to fetch Routstr provider balance",
extra={
"provider_id": provider_id,
"base_url": clean_url,
"upstream_url": f"{clean_url}/v1/balance/info",
"error": str(exc),
},
)
raise HTTPException(
status_code=502,
detail="Failed to contact upstream Routstr provider",
) from exc
if resp.status_code == 200:
data = resp.json()
# Return balance in sats
balance = data.get("balance", 0)
if isinstance(balance, (int, float)):
return {"ok": True, "balance_data": balance // 1000}
return {"ok": True, "balance_data": balance}
else:
logger.error(f"Failed to fetch Routstr balance: {resp.text}")
return {"ok": False, "balance_data": None}
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
@@ -930,18 +1236,55 @@ async def get_usage_metrics(
default=15, ge=1, le=1440, description="Time interval in minutes"
),
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
default=24,
ge=1,
le=MAX_USAGE_ANALYTICS_HOURS,
description="Hours of history to analyze",
),
) -> dict:
"""Get usage metrics aggregated by time interval."""
return log_manager.get_usage_metrics(interval=interval, hours=hours)
@admin_router.get("/api/usage/dashboard", dependencies=[Depends(require_admin_api)])
async def get_usage_dashboard(
request: Request,
interval: int = Query(
default=15, ge=1, le=1440, description="Time interval in minutes"
),
hours: int = Query(
default=24,
ge=1,
le=MAX_USAGE_ANALYTICS_HOURS,
description="Hours of history to analyze",
),
error_limit: int = Query(
default=100, ge=1, le=1000, description="Maximum number of errors to return"
),
model_limit: int = Query(
default=20, ge=1, le=100, description="Maximum number of models to return"
),
) -> dict:
"""
Get all dashboard analytics in one request.
This runs one combined aggregation pass and avoids repeated scans.
"""
return log_manager.get_usage_dashboard(
interval=interval,
hours=hours,
error_limit=error_limit,
model_limit=model_limit,
)
@admin_router.get("/api/usage/summary", dependencies=[Depends(require_admin_api)])
async def get_usage_summary(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
default=24,
ge=1,
le=MAX_USAGE_ANALYTICS_HOURS,
description="Hours of history to analyze",
),
) -> dict:
"""Get summary statistics for the specified time period."""
@@ -952,7 +1295,10 @@ async def get_usage_summary(
async def get_error_details(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
default=24,
ge=1,
le=MAX_USAGE_ANALYTICS_HOURS,
description="Hours of history to analyze",
),
limit: int = Query(
default=100, ge=1, le=1000, description="Maximum number of errors to return"
@@ -968,7 +1314,10 @@ async def get_error_details(
async def get_revenue_by_model(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
default=24,
ge=1,
le=MAX_USAGE_ANALYTICS_HOURS,
description="Hours of history to analyze",
),
limit: int = Query(
default=20, ge=1, le=100, description="Maximum number of models to return"
@@ -1062,3 +1411,129 @@ async def get_log_dates_api(request: Request) -> dict[str, object]:
continue
return {"dates": dates}
@admin_router.get("/api/transactions", dependencies=[Depends(require_admin_api)])
async def get_transactions_api(
type: str | None = None,
status: str | None = None,
search: str | None = None,
source: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict:
async with create_session() as session:
from sqlmodel import col, func
base = select(CashuTransaction)
if type:
base = base.where(CashuTransaction.type == type)
if source:
if source == "x-cashu":
base = base.where(
(CashuTransaction.source == "x-cashu")
| (CashuTransaction.source == None) # noqa: E711
)
else:
base = base.where(CashuTransaction.source == source)
if status:
if status == "collected":
base = base.where(CashuTransaction.collected == True) # noqa: E712
elif status == "swept":
base = base.where(CashuTransaction.swept == True) # noqa: E712
elif status == "pending":
base = base.where(
CashuTransaction.collected == False, # noqa: E712
CashuTransaction.swept == False, # noqa: E712
)
if search:
search_pattern = f"%{search}%"
base = base.where(
(col(CashuTransaction.id).like(search_pattern))
| (col(CashuTransaction.token).like(search_pattern))
| (col(CashuTransaction.request_id).like(search_pattern))
| (col(CashuTransaction.api_key_hashed_key).like(search_pattern))
)
count_result = await session.exec(
select(func.count()).select_from(base.subquery())
)
total = count_result.one()
stmt = base.order_by(col(CashuTransaction.created_at).desc()).offset(offset).limit(limit)
results = await session.exec(stmt)
transactions = results.all()
return {
"transactions": [tx.dict() for tx in transactions],
"total": total,
}
@admin_router.post(
"/api/upstream-providers/{provider_id}/routstr/refund",
dependencies=[Depends(require_admin_api)],
)
async def refund_routstr_provider_balance(provider_id: int) -> dict[str, object]:
"""Refund balance from an upstream Routstr provider back to the local wallet."""
from ..upstream.helpers import _instantiate_provider
from ..upstream.routstr import RoutstrUpstreamProvider
async with create_session() as session:
provider_row = await session.get(UpstreamProviderRow, provider_id)
if not provider_row:
raise HTTPException(status_code=404, detail="Provider not found")
if provider_row.provider_type != "routstr":
raise HTTPException(
status_code=400, detail="Refund only supported for Routstr providers"
)
provider = _instantiate_provider(provider_row)
if not isinstance(provider, RoutstrUpstreamProvider):
raise HTTPException(status_code=400, detail="Invalid provider instance")
try:
# Request refund from upstream
data = await provider.refund_balance()
if "error" in data:
# If the upstream returned an OpenAI-style error (like the model unknown error)
# it means the request likely didn't even reach the refund endpoint handler
# but was intercepted by the proxy layer.
error_info = data.get("error", {})
message = (
error_info.get("message")
if isinstance(error_info, dict)
else str(error_info)
)
return {
"ok": False,
"message": f"Upstream refund failed: {message}",
}
token = data.get("token")
if not token:
return {"ok": False, "message": "Upstream did not return a token"}
# Receive token into local wallet
from ..wallet import recieve_token
try:
# Use current wallet to receive
await recieve_token(token)
return {
"ok": True,
"message": "Successfully received refund from upstream provider",
}
except Exception as e:
logger.error(f"Failed to receive refund token: {e}")
return {
"ok": False,
"message": f"Failed to receive refund token: {str(e)}",
"token": token,
}
except Exception as e:
logger.exception(f"Refund failed for provider {provider_id}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,13 +1,18 @@
import os
import pathlib
import sqlite3
import time
import uuid
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from alembic import command
from alembic.config import Config
from alembic.util.exc import CommandError
from sqlalchemy import UniqueConstraint
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlmodel import Field, Relationship, SQLModel, func, select, update
from sqlmodel import Field, Relationship, SQLModel, col, func, select, update
from sqlmodel.ext.asyncio.session import AsyncSession
from .logging import get_logger
@@ -101,6 +106,10 @@ class ModelRow(SQLModel, table=True): # type: ignore
default=None, description="JSON array of model alias IDs"
)
enabled: bool = Field(default=True, description="Whether this model is enabled")
forwarded_model_id: str | None = Field(
default=None,
description="Model ID to use when forwarding requests to upstream provider. Defaults to id if not set.",
)
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
@@ -126,6 +135,73 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
class CashuTransaction(SQLModel, table=True): # type: ignore
__tablename__ = "cashu_transactions"
id: str = Field(
primary_key=True,
default_factory=lambda: uuid.uuid4().hex,
description="Unique transaction identifier",
)
token: str = Field(description="Serialized Cashu token")
amount: int = Field(description="Amount in the token's unit")
unit: str = Field(description="Token unit (sat or msat)")
mint_url: str | None = Field(default=None, description="Mint URL for the token")
type: str = Field(default="out", description="Transaction type: in or out")
request_id: str | None = Field(default=None, description="Associated request ID")
created_at: int = Field(
default_factory=lambda: int(time.time()),
description="Unix timestamp",
)
collected: bool = Field(default=False)
swept: bool = Field(default=False)
source: str = Field(
default="x-cashu",
description="Payment source: x-cashu or apikey",
)
api_key_hashed_key: str | None = Field(
default=None,
foreign_key="api_keys.hashed_key",
index=True,
description="Associated API key hash for wallet history",
)
async def store_cashu_transaction(
token: str,
amount: int,
unit: str,
mint_url: str | None = None,
typ: str = "out",
request_id: str | None = None,
collected: bool = False,
created_at: int | None = None,
source: str = "x-cashu",
api_key_hashed_key: str | None = None,
) -> None:
try:
async with create_session() as session:
tx = CashuTransaction(
token=token,
amount=amount,
unit=unit,
mint_url=mint_url,
type=typ,
request_id=request_id,
collected=collected,
created_at=created_at or int(time.time()),
source=source,
api_key_hashed_key=api_key_hashed_key,
)
session.add(tx)
await session.commit()
except Exception as e:
logger.warning(
f"Failed to store cashu transaction: {e} (type={typ})",
extra={"error": str(e), "type": typ},
)
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
__tablename__ = "upstream_providers"
__table_args__ = (
@@ -146,12 +222,75 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
provider_fee: float = Field(
default=1.01, description="Provider fee multiplier (default 1%)"
)
provider_settings: str | None = Field(
default=None, description="JSON string for provider-specific settings"
)
models: list["ModelRow"] = Relationship(
back_populates="upstream_provider",
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
)
class RoutstrFee(SQLModel, table=True): # type: ignore
__tablename__ = "routstr_fees"
id: int = Field(default=1, primary_key=True)
accumulated_msats: int = Field(default=0)
total_paid_msats: int = Field(default=0)
last_paid_at: int | None = Field(default=None)
class CliToken(SQLModel, table=True): # type: ignore
"""Long-lived authorization token for CLI/agent use against admin endpoints."""
__tablename__ = "cli_tokens"
id: str = Field(
primary_key=True, default_factory=lambda: uuid.uuid4().hex
)
token: str = Field(unique=True, index=True, description="Bearer token value")
name: str = Field(description="Human-readable label for this token")
created_at: int = Field(default_factory=lambda: int(time.time()))
last_used_at: int | None = Field(default=None)
expires_at: int | None = Field(
default=None, description="Optional expiry unix timestamp; null = never expires"
)
async def accumulate_routstr_fee(session: AsyncSession, amount_msats: int) -> None:
stmt = (
update(RoutstrFee)
.where(col(RoutstrFee.id) == 1)
.values(accumulated_msats=RoutstrFee.accumulated_msats + amount_msats)
)
result = await session.exec(stmt) # type: ignore[call-overload]
if result.rowcount == 0:
session.add(RoutstrFee(id=1, accumulated_msats=amount_msats))
await session.commit()
async def get_routstr_fee(session: AsyncSession) -> RoutstrFee:
fee = await session.get(RoutstrFee, 1)
if fee is None:
fee = RoutstrFee(id=1, accumulated_msats=0, total_paid_msats=0)
session.add(fee)
await session.commit()
await session.refresh(fee)
return fee
async def reset_routstr_fee(session: AsyncSession, paid_msats: int) -> None:
stmt = (
update(RoutstrFee)
.where(col(RoutstrFee.id) == 1)
.values(
accumulated_msats=RoutstrFee.accumulated_msats - paid_msats,
total_paid_msats=RoutstrFee.total_paid_msats + paid_msats,
last_paid_at=int(time.time()),
)
)
await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
async def balances_for_mint_and_unit(
db_session: AsyncSession, mint_url: str, unit: str
) -> int:
@@ -181,11 +320,64 @@ async def create_session() -> AsyncGenerator[AsyncSession, None]:
yield session
def fix_cashu_migrations() -> None:
"""
Fixes Cashu wallet migrations that are not idempotent.
This specifically addresses the 'duplicate column name: public_keys' error
in the keysets table of Cashu's internal SQLite databases.
"""
project_root = pathlib.Path(__file__).resolve().parents[2]
wallet_dir = project_root / ".wallet"
if not wallet_dir.exists() or not wallet_dir.is_dir():
return
logger.info("Checking Cashu wallet databases for migration idempotency")
for db_file in wallet_dir.glob("*.sqlite3"):
try:
conn = sqlite3.connect(db_file)
cursor = conn.cursor()
# Check if keysets table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='keysets'"
)
if not cursor.fetchone():
conn.close()
continue
# Check if public_keys column exists
cursor.execute("PRAGMA table_info(keysets)")
columns = [info[1] for info in cursor.fetchall()]
if "public_keys" not in columns:
logger.info(f"Adding missing public_keys column to {db_file.name}")
cursor.execute("ALTER TABLE keysets ADD COLUMN public_keys TEXT")
conn.commit()
conn.close()
except Exception as e:
logger.warning(f"Could not check/fix Cashu database {db_file}: {e}")
def _clear_alembic_version() -> None:
"""Clear the alembic_version table so stamp/upgrade can proceed."""
sync_url = DATABASE_URL.replace("+aiosqlite", "")
from sqlalchemy import create_engine, text
eng = create_engine(sync_url)
with eng.begin() as conn:
conn.execute(text("DELETE FROM alembic_version"))
eng.dispose()
def run_migrations() -> None:
"""Run Alembic migrations programmatically."""
import pathlib
try:
# 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"
@@ -201,8 +393,30 @@ def run_migrations() -> None:
# Set the database URL in the config
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Run migrations to the latest revision
command.upgrade(alembic_cfg, "head")
try:
command.upgrade(alembic_cfg, "head")
except CommandError as e:
if "Can't locate revision" in str(e):
logger.warning(
"Database stamped with unknown revision (likely from another branch). "
"Re-stamping to current head.",
extra={"error": str(e)},
)
_clear_alembic_version()
command.stamp(alembic_cfg, "head")
else:
raise
except OperationalError as e:
if "duplicate column name" in str(e).lower():
logger.warning(
"Migration hit a column that already exists (likely added via "
"create_all on another branch). Stamping to current head.",
extra={"error": str(e)},
)
_clear_alembic_version()
command.stamp(alembic_cfg, "head")
else:
raise
logger.info("Database migrations completed successfully")

File diff suppressed because it is too large Load Diff

View File

@@ -3,36 +3,38 @@ Logging configuration for Routstr.
CRITICAL LOG MESSAGES FOR USAGE STATISTICS:
===========================================
The following log messages are parsed by the usage tracking system (routstr/core/admin.py).
The following log messages are parsed by the usage tracking system
(routstr/core/usage_analytics_store.py and routstr/core/log_manager.py).
DO NOT modify or remove these messages without updating the usage tracking logic:
1. "Received proxy request" (INFO) - routstr/proxy.py
- Used to count total incoming requests
- Includes model information in context
2. "Payment adjustment completed for streaming" (INFO) - routstr/upstream/base.py
"Payment adjustment completed for non-streaming" (INFO) - routstr/upstream/base.py
2. "Calculated token-based cost" (INFO) - routstr/auth.py
- Used to track successful completions and revenue
- The 'cost_data.total_msats' field is extracted for revenue calculation
- Must include 'cost_data' in extra dict
- The 'token_cost', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
3. "Payment processed successfully" (INFO) - routstr/auth.py
3. "Max cost payment finalized" (INFO) - routstr/auth.py
- Used as the successful completion fallback when token usage is unavailable
- The 'charged_amount', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
4. "Payment processed successfully" (INFO) - routstr/auth.py
- Used to count successful payment processing events
- Tracks payment-related metrics
4. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py
5. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py
- Used to track failed requests and refunds
- The 'max_cost_for_model' field is extracted for refund calculation
- Must include 'max_cost_for_model' in extra dict
5. Any ERROR level logs with "upstream" in the message
6. Any ERROR level logs with "upstream" in the message
- Used to count upstream provider errors
- Helps identify service reliability issues
If you need to modify these messages, ensure you also update the parsing logic in:
- routstr/core/admin.py:_aggregate_metrics_by_time()
- routstr/core/admin.py:_get_summary_stats()
- routstr/core/admin.py:get_revenue_by_model()
- routstr/core/usage_analytics_store.py
- routstr/core/log_manager.py
"""
import logging.config

View File

@@ -12,12 +12,17 @@ from starlette.exceptions import HTTPException
from ..auth import periodic_key_reset
from ..balance import balance_router, deprecated_wallet_router
from ..nostr import announce_provider, providers_cache_refresher
from ..nostr import (
announce_provider,
providers_cache_refresher,
publish_usage_analytics,
)
from ..nostr.discovery import providers_router
from ..payment.models import models_router, update_sats_pricing
from ..payment.price import update_prices_periodically
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
from ..wallet import periodic_payout
from ..upstream.auto_topup import periodic_auto_topup
from ..wallet import periodic_payout, periodic_refund_sweep, periodic_routstr_fee_payout
from .admin import admin_router
from .db import create_session, init_db, run_migrations
from .exceptions import general_exception_handler, http_exception_handler
@@ -31,9 +36,9 @@ setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.3.0-{os.getenv('VERSION_SUFFIX')}"
__version__ = f"0.4.3-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.3.0"
__version__ = "0.4.3"
@asynccontextmanager
@@ -44,10 +49,14 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
pricing_task = None
payout_task = None
nip91_task = None
analytics_task = None
providers_task = None
models_refresh_task = None
model_maps_refresh_task = None
key_reset_task = None
auto_topup_task = None
refund_sweep_task = None
routstr_fee_task = None
try:
# Run database migrations on startup
@@ -101,9 +110,13 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
payout_task = asyncio.create_task(periodic_payout())
if global_settings.nsec:
nip91_task = asyncio.create_task(announce_provider())
analytics_task = asyncio.create_task(publish_usage_analytics())
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
key_reset_task = asyncio.create_task(periodic_key_reset())
auto_topup_task = asyncio.create_task(periodic_auto_topup())
refund_sweep_task = asyncio.create_task(periodic_refund_sweep())
routstr_fee_task = asyncio.create_task(periodic_routstr_fee_payout())
yield
@@ -127,6 +140,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
payout_task.cancel()
if nip91_task is not None:
nip91_task.cancel()
if analytics_task is not None:
analytics_task.cancel()
if providers_task is not None:
providers_task.cancel()
if models_refresh_task is not None:
@@ -135,6 +150,12 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
model_maps_refresh_task.cancel()
if key_reset_task is not None:
key_reset_task.cancel()
if auto_topup_task is not None:
auto_topup_task.cancel()
if refund_sweep_task is not None:
refund_sweep_task.cancel()
if routstr_fee_task is not None:
routstr_fee_task.cancel()
try:
tasks_to_wait = []
@@ -146,6 +167,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
tasks_to_wait.append(payout_task)
if nip91_task is not None:
tasks_to_wait.append(nip91_task)
if analytics_task is not None:
tasks_to_wait.append(analytics_task)
if providers_task is not None:
tasks_to_wait.append(providers_task)
if models_refresh_task is not None:
@@ -154,6 +177,12 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
tasks_to_wait.append(model_maps_refresh_task)
if key_reset_task is not None:
tasks_to_wait.append(key_reset_task)
if auto_topup_task is not None:
tasks_to_wait.append(auto_topup_task)
if refund_sweep_task is not None:
tasks_to_wait.append(refund_sweep_task)
if routstr_fee_task is not None:
tasks_to_wait.append(routstr_fee_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
@@ -174,7 +203,7 @@ app.add_middleware(
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["x-routstr-request-id"],
expose_headers=["x-routstr-request-id", "x-cashu"],
)
# Add logging middleware

View File

@@ -38,11 +38,6 @@ class LoggingMiddleware(BaseHTTPMiddleware):
except Exception:
pass
# Extract request info
client_host = None
if request.client:
client_host = request.client.host
# Log incoming request
logger.info(
"Incoming request",
@@ -51,7 +46,6 @@ class LoggingMiddleware(BaseHTTPMiddleware):
"method": request.method,
"path": request.url.path,
"query_params": dict(request.query_params),
"client_host": client_host,
"headers": {
k: v
for k, v in request.headers.items()
@@ -100,7 +94,6 @@ class LoggingMiddleware(BaseHTTPMiddleware):
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": round(duration * 1000, 2),
"client_host": client_host,
},
)
if hasattr(response, "headers"):
@@ -120,7 +113,6 @@ class LoggingMiddleware(BaseHTTPMiddleware):
"method": request.method,
"path": request.url.path,
"duration_ms": round(duration * 1000, 2),
"client_host": client_host,
"error": str(e),
"error_type": type(e).__name__,
},

View File

@@ -52,7 +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=1000, env="CHILD_KEY_COST")
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(
@@ -74,6 +74,7 @@ class Settings(BaseSettings):
enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH")
enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH")
refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS")
refund_sweep_ttl_seconds: int = Field(default=604800, env="REFUND_SWEEP_TTL_SECONDS")
# Logging
log_level: str = Field(default="INFO", env="LOG_LEVEL")
@@ -92,6 +93,20 @@ class Settings(BaseSettings):
# Discovery
relays: list[str] = Field(default_factory=list, env="RELAYS")
enable_analytics_sharing: bool = Field(
default=True, env="ENABLE_ANALYTICS_SHARING"
)
def _normalize_settings_data(data: dict[str, Any]) -> dict[str, Any]:
"""Discard unknown keys from persisted settings."""
normalized: dict[str, Any] = {}
known_fields = Settings.__fields__
for key, value in data.items():
if key in known_fields:
normalized[key] = value
return normalized
def _compute_primary_mint(cashu_mints: list[str]) -> str:
@@ -231,16 +246,21 @@ class SettingsService:
db_id, db_data, _updated_at = row
try:
db_json = (
db_json_raw = (
json.loads(db_data) if isinstance(db_data, str) else dict(db_data)
)
if not isinstance(db_json_raw, dict):
db_json_raw = {}
except Exception:
db_json = {}
db_json_raw = {}
db_json = _normalize_settings_data(db_json_raw)
valid_fields = set(env_resolved.dict().keys())
merged_dict: dict[str, Any] = dict(env_resolved.dict())
merged_dict.update(
{k: v for k, v in db_json.items() if v not in (None, "", [], {})}
{k: v for k, v in db_json.items() if v not in (None, "", [], {}) and k in valid_fields}
)
merged_dict = Settings(**merged_dict).dict()
# Ensure primary_mint is consistent with cashu_mints if not explicitly set
if not merged_dict.get("primary_mint"):
@@ -248,7 +268,7 @@ class SettingsService:
merged_dict.get("cashu_mints", [])
)
if any(k not in db_json for k in merged_dict.keys()):
if db_json_raw != merged_dict:
await db_session.exec( # type: ignore
text(
"UPDATE settings SET data = :data, updated_at = :updated_at WHERE id = 1"
@@ -271,7 +291,7 @@ class SettingsService:
) -> Settings:
async with cls._lock:
current = cls.get()
candidate_dict = {**current.dict(), **partial}
candidate_dict = {**current.dict(), **_normalize_settings_data(partial)}
candidate = Settings(**candidate_dict)
from sqlmodel import text
@@ -305,8 +325,10 @@ class SettingsService:
raise RuntimeError("Settings row missing")
(data_str,) = row
data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str)
valid_fields = set(settings.dict().keys())
# Update in-place
for k, v in data.items():
setattr(settings, k, v)
if k in valid_fields:
setattr(settings, k, v)
cls._current = settings
return settings

File diff suppressed because it is too large Load Diff

View File

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

419
routstr/nostr/analytics.py Normal file
View File

@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""
Nostr usage analytics publisher.
Publishes a single replaceable analytics snapshot for each provider.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import time
from typing import Any
from nostr.event import Event
from nostr.key import PrivateKey
from ..core import get_logger
from ..core.log_manager import log_manager
from ..core.settings import settings
from .listing import nsec_to_keypair, publish_to_relay
logger = get_logger(__name__)
ANALYTICS_KIND = 38422
ANALYTICS_SCHEMA = "routstr.analytics.snapshot.v1"
DEFAULT_RELAYS = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
"wss://nos.lol",
]
PUBLISH_INTERVAL_SECONDS = 15 * 60
DISABLED_POLL_SECONDS = 60
DASHBOARD_WINDOW_HOURS = 24
DASHBOARD_INTERVAL_MINUTES = 60
MODEL_LIMIT = 20
WINDOW_DEFINITIONS: tuple[tuple[str, int, int], ...] = (
("24h", 24, 60),
("7d", 7 * 24, 6 * 60),
("30d", 30 * 24, 24 * 60),
("3m", 90 * 24, 24 * 60),
("1y", 365 * 24, 7 * 24 * 60),
)
def _event_to_dict(ev: Event) -> dict[str, Any]:
return {
"id": ev.id,
"pubkey": ev.public_key,
"created_at": ev.created_at,
"kind": int(ev.kind) if not isinstance(ev.kind, int) else ev.kind,
"tags": ev.tags,
"content": ev.content,
"sig": ev.signature,
}
def _resolve_provider_id(public_key_hex: str) -> str:
explicit_provider_id = (settings.provider_id or "").strip()
if explicit_provider_id:
return explicit_provider_id
return public_key_hex[:12]
def _resolve_endpoint_urls() -> list[str]:
urls: list[str] = []
http_url = (settings.http_url or "").strip()
onion_url = (settings.onion_url or "").strip()
if http_url and http_url != "http://localhost:8000":
urls.append(http_url)
if onion_url:
if onion_url.endswith(".onion") and not (
onion_url.startswith("http://") or onion_url.startswith("https://")
):
onion_url = f"http://{onion_url}"
urls.append(onion_url)
return urls
def _resolve_relays() -> list[str]:
configured = [url.strip() for url in settings.relays if url.strip()]
return configured if configured else list(DEFAULT_RELAYS)
def _to_int(value: Any) -> int:
if isinstance(value, bool):
return int(value)
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
try:
return int(float(value))
except ValueError:
return 0
return 0
def _to_float(value: Any) -> float:
if isinstance(value, bool):
return float(int(value))
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value)
except ValueError:
return 0.0
return 0.0
def _aggregate_top_model_usage(
model_usage_mix: dict[str, Any],
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
top_models_raw = model_usage_mix.get("top_models", [])
mix_metrics_raw = model_usage_mix.get("metrics", [])
top_models = [model for model in top_models_raw if isinstance(model, str)]
metrics = [row for row in mix_metrics_raw if isinstance(row, dict)]
model_totals: dict[str, dict[str, float | int]] = {
model: {
"successful_requests": 0,
"revenue_msats": 0.0,
"total_tokens": 0,
}
for model in top_models
}
others = {
"successful_requests": 0,
"revenue_msats": 0.0,
"total_tokens": 0,
}
for metric in metrics:
model_counts = metric.get("model_counts", {})
model_revenue = metric.get("model_revenue_msats", {})
model_tokens = metric.get("model_tokens", {})
if isinstance(model_counts, dict):
for model, count in model_counts.items():
if model in model_totals:
model_totals[model]["successful_requests"] += _to_int(count)
if isinstance(model_revenue, dict):
for model, amount in model_revenue.items():
if model in model_totals:
model_totals[model]["revenue_msats"] += _to_float(amount)
if isinstance(model_tokens, dict):
for model, token_count in model_tokens.items():
if model in model_totals:
model_totals[model]["total_tokens"] += _to_int(token_count)
others["successful_requests"] += _to_int(metric.get("others", 0))
others["revenue_msats"] += _to_float(metric.get("others_revenue_msats", 0.0))
others["total_tokens"] += _to_int(metric.get("others_tokens", 0))
model_rows = [
{
"model": model,
"successful_requests": int(values["successful_requests"]),
"revenue_msats": float(values["revenue_msats"]),
"total_tokens": int(values["total_tokens"]),
}
for model, values in model_totals.items()
]
model_rows.sort(
key=lambda row: _to_int(row.get("successful_requests", 0)),
reverse=True,
)
return model_rows, others
def _build_summary_payload(summary: dict[str, Any]) -> dict[str, Any]:
return {
"total_requests": _to_int(summary.get("total_requests", 0)),
"successful_chat_completions": _to_int(
summary.get("successful_chat_completions", 0)
),
"failed_requests": _to_int(summary.get("failed_requests", 0)),
"success_rate": _to_float(summary.get("success_rate", 0.0)),
"unique_models_count": _to_int(summary.get("unique_models_count", 0)),
"input_tokens": _to_int(summary.get("input_tokens", 0)),
"output_tokens": _to_int(summary.get("output_tokens", 0)),
"total_tokens": _to_int(summary.get("total_tokens", 0)),
"revenue_msats": _to_float(summary.get("revenue_msats", 0.0)),
"refunds_msats": _to_float(summary.get("refunds_msats", 0.0)),
"net_revenue_msats": _to_float(summary.get("net_revenue_msats", 0.0)),
"revenue_sats": _to_float(summary.get("revenue_sats", 0.0)),
"refunds_sats": _to_float(summary.get("refunds_sats", 0.0)),
"net_revenue_sats": _to_float(summary.get("net_revenue_sats", 0.0)),
}
def _build_window_payload(
*,
hours: int,
interval_minutes: int,
model_limit: int,
) -> dict[str, Any]:
dashboard = log_manager.get_usage_dashboard(
interval=interval_minutes,
hours=hours,
error_limit=1,
model_limit=model_limit,
)
summary = dashboard.get("summary", {})
model_usage_mix = dashboard.get("model_usage_mix", {})
summary_payload = _build_summary_payload(summary if isinstance(summary, dict) else {})
usage_mix_payload = model_usage_mix if isinstance(model_usage_mix, dict) else {}
top_model_usage, others_usage = _aggregate_top_model_usage(usage_mix_payload)
return {
"window_hours": hours,
"interval_minutes": interval_minutes,
"summary": summary_payload,
"model_usage_mix": usage_mix_payload,
"top_model_usage": top_model_usage,
"others_usage": others_usage,
}
def build_stats_snapshot_payload(
provider_id: str,
*,
public_key_hex: str,
generated_at: int,
window_hours: int = DASHBOARD_WINDOW_HOURS,
interval_minutes: int = DASHBOARD_INTERVAL_MINUTES,
model_limit: int = MODEL_LIMIT,
) -> dict[str, Any]:
_ = (window_hours, interval_minutes)
windows: dict[str, dict[str, Any]] = {}
for key, hours, window_interval_minutes in WINDOW_DEFINITIONS:
windows[key] = _build_window_payload(
hours=hours,
interval_minutes=window_interval_minutes,
model_limit=model_limit,
)
primary_window = windows.get("24h", {})
summary_payload = (
primary_window.get("summary", {})
if isinstance(primary_window.get("summary", {}), dict)
else {}
)
usage_mix_payload = (
primary_window.get("model_usage_mix", {})
if isinstance(primary_window.get("model_usage_mix", {}), dict)
else {}
)
top_model_usage = (
primary_window.get("top_model_usage", [])
if isinstance(primary_window.get("top_model_usage", []), list)
else []
)
others_usage = (
primary_window.get("others_usage", {})
if isinstance(primary_window.get("others_usage", {}), dict)
else {}
)
return {
"schema": ANALYTICS_SCHEMA,
"generated_at": generated_at,
"provider_id": provider_id,
"pubkey": public_key_hex,
"npub": settings.npub or "",
"endpoint_urls": _resolve_endpoint_urls(),
"window_hours": DASHBOARD_WINDOW_HOURS,
"interval_minutes": DASHBOARD_INTERVAL_MINUTES,
"summary": summary_payload,
"model_usage_mix": usage_mix_payload,
"top_model_usage": top_model_usage,
"others_usage": others_usage,
"windows": windows,
}
def create_stats_snapshot_event(
private_key_hex: str,
provider_id: str,
payload_json: str,
*,
d_tag: str,
) -> dict[str, Any]:
private_key = PrivateKey(bytes.fromhex(private_key_hex))
tags = [
["d", d_tag],
["provider", provider_id],
["schema", ANALYTICS_SCHEMA],
]
event = Event(
public_key=private_key.public_key.hex(),
content=payload_json,
kind=ANALYTICS_KIND,
tags=tags,
)
private_key.sign_event(event)
return _event_to_dict(event)
def _fingerprint_payload(payload: dict[str, Any]) -> str:
normalized = dict(payload)
# Ignore generated timestamp for semantic dedupe.
normalized.pop("generated_at", None)
payload_json = json.dumps(normalized, separators=(",", ":"), sort_keys=True)
return hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
async def publish_usage_analytics() -> None:
last_payload_hash: str | None = None
parsed_nsec: str | None = None
private_key_hex: str | None = None
public_key_hex: str | None = None
provider_id: str | None = None
warned_missing_nsec = False
logger.info("Usage analytics sharing task started")
while True:
try:
if not settings.enable_analytics_sharing:
await asyncio.sleep(DISABLED_POLL_SECONDS)
continue
nsec = (settings.nsec or "").strip()
if not nsec:
if not warned_missing_nsec:
logger.info("NSEC is not configured; skipping analytics sharing to Nostr")
warned_missing_nsec = True
await asyncio.sleep(DISABLED_POLL_SECONDS)
continue
warned_missing_nsec = False
if nsec != parsed_nsec or private_key_hex is None or public_key_hex is None:
keypair = nsec_to_keypair(nsec)
if not keypair:
logger.error("Invalid NSEC; analytics sharing is paused")
await asyncio.sleep(DISABLED_POLL_SECONDS)
continue
private_key_hex, public_key_hex = keypair
parsed_nsec = nsec
provider_id = _resolve_provider_id(public_key_hex)
last_payload_hash = None
if private_key_hex is None or public_key_hex is None:
await asyncio.sleep(DISABLED_POLL_SECONDS)
continue
relay_urls = _resolve_relays()
if not relay_urls:
logger.warning("No Nostr relays configured; analytics sharing skipped")
await asyncio.sleep(DISABLED_POLL_SECONDS)
continue
resolved_provider_id = provider_id or _resolve_provider_id(public_key_hex)
now_ts = int(time.time())
payload = build_stats_snapshot_payload(
resolved_provider_id,
public_key_hex=public_key_hex,
generated_at=now_ts,
)
payload_hash = _fingerprint_payload(payload)
if last_payload_hash == payload_hash:
await asyncio.sleep(PUBLISH_INTERVAL_SECONDS)
continue
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
d_tag = f"{resolved_provider_id}:stats"
event = create_stats_snapshot_event(
private_key_hex,
resolved_provider_id,
payload_json,
d_tag=d_tag,
)
success_count = 0
for relay_url in relay_urls:
if await publish_to_relay(relay_url, event):
success_count += 1
if success_count > 0:
last_payload_hash = payload_hash
logger.info(
"Published analytics snapshot (success=%s/%s provider=%s)",
success_count,
len(relay_urls),
resolved_provider_id,
extra={
"relay_success_count": success_count,
"relay_total": len(relay_urls),
"provider_id": resolved_provider_id,
},
)
await asyncio.sleep(PUBLISH_INTERVAL_SECONDS)
except asyncio.CancelledError:
logger.info("Usage analytics sharing task cancelled")
break
except Exception as e:
logger.error(
"Usage analytics sharing error",
extra={"error": str(e), "error_type": type(e).__name__},
)
await asyncio.sleep(DISABLED_POLL_SECONDS)

View File

@@ -16,6 +16,8 @@ class CostData(BaseModel):
output_msats: int
total_msats: int
total_usd: float = 0.0
input_tokens: int = 0
output_tokens: int = 0
class MaxCostData(CostData):
@@ -63,17 +65,64 @@ async def calculate_cost( # todo: can be sync
output_msats=0,
total_msats=0,
total_usd=0.0,
input_tokens=0,
output_tokens=0,
)
usage_data = response_data["usage"]
usd_cost = 0.0
def parse_token_count(value: object) -> int:
if isinstance(value, bool):
return 0
if isinstance(value, int):
return max(0, value)
if isinstance(value, float):
return max(0, int(value))
if isinstance(value, str):
try:
return max(0, int(float(value)))
except ValueError:
return 0
return 0
input_tokens = parse_token_count(usage_data.get("prompt_tokens", 0))
output_tokens = parse_token_count(usage_data.get("completion_tokens", 0))
input_tokens = (
input_tokens
if input_tokens != 0
else parse_token_count(usage_data.get("input_tokens", 0))
)
output_tokens = (
output_tokens
if output_tokens != 0
else parse_token_count(usage_data.get("output_tokens", 0))
)
input_tokens = (
input_tokens
if input_tokens != 0
else parse_token_count(response_data.get("usage", {}).get("input_tokens", 0))
)
output_tokens = (
output_tokens
if output_tokens != 0
else parse_token_count(response_data.get("usage", {}).get("output_tokens", 0))
)
usd_cost = 0.0
input_usd = 0.0
output_usd = 0.0
# Prioritize cost_details.upstream_inference_cost
if "cost_details" in usage_data:
usd_cost = float(
usage_data["cost_details"].get("upstream_inference_cost", 0) or 0
)
input_usd = float(
usage_data["cost_details"].get("upstream_inference_prompt_cost", 0) or 0
)
output_usd = float(
usage_data["cost_details"].get("upstream_inference_completions_cost", 0)
or 0
)
# Fallback to cost field if upstream_inference_cost is 0
if usd_cost == 0 and "cost" in usage_data:
@@ -82,12 +131,34 @@ async def calculate_cost( # todo: can be sync
except Exception:
pass
MSATS_PER_1K_INPUT_TOKENS: float = (
float(settings.fixed_per_1k_input_tokens) * 1000.0
)
MSATS_PER_1K_OUTPUT_TOKENS: float = (
float(settings.fixed_per_1k_output_tokens) * 1000.0
)
if usd_cost > 0:
try:
sats_per_usd = 1.0 / sats_usd_price()
cost_in_sats = usd_cost * sats_per_usd
cost_in_msats = math.ceil(cost_in_sats * 1000)
input_msats = 0
output_msats = 0
if input_usd > 0 or output_usd > 0:
input_msats = int((input_usd * sats_per_usd) * 1000)
output_msats = int((output_usd * sats_per_usd) * 1000)
else:
total_tokens = input_tokens + output_tokens
if total_tokens > 0:
input_ratio = input_tokens / total_tokens
input_msats = int(cost_in_msats * input_ratio)
output_msats = cost_in_msats - input_msats
else:
output_msats = cost_in_msats
logger.info(
"Using cost from usage data/details",
extra={
@@ -99,11 +170,13 @@ async def calculate_cost( # todo: can be sync
)
return CostData(
base_msats=-1,
input_msats=-1, # Cost field doesn't break down by token type
output_msats=-1,
base_msats=0,
input_msats=input_msats,
output_msats=output_msats,
total_msats=cost_in_msats,
total_usd=usd_cost,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
except Exception as e:
logger.warning(
@@ -116,13 +189,6 @@ async def calculate_cost( # todo: can be sync
)
# Fall through to token-based calculation
MSATS_PER_1K_INPUT_TOKENS: float = (
float(settings.fixed_per_1k_input_tokens) * 1000.0
)
MSATS_PER_1K_OUTPUT_TOKENS: float = (
float(settings.fixed_per_1k_output_tokens) * 1000.0
)
if not settings.fixed_pricing:
response_model = response_data.get("model", "")
logger.debug(
@@ -184,35 +250,14 @@ async def calculate_cost( # todo: can be sync
input_msats=0,
output_msats=0,
total_msats=max_cost,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
input_tokens = usage_data.get("prompt_tokens", 0)
output_tokens = usage_data.get("completion_tokens", 0)
calc_input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
# added for response api
input_tokens = (
input_tokens if input_tokens != 0 else usage_data.get("input_tokens", 0)
)
output_tokens = (
output_tokens if output_tokens != 0 else usage_data.get("output_tokens", 0)
)
# added for response api
input_tokens = (
input_tokens
if input_tokens != 0
else response_data.get("usage", {}).get("input_tokens", 0)
)
output_tokens = (
output_tokens
if output_tokens != 0
else response_data.get("usage", {}).get("output_tokens", 0)
)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(input_msats + output_msats)
calc_output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(calc_input_msats + calc_output_msats)
total_usd = (token_based_cost / 1000.0) * sats_usd_price()
logger.info(
@@ -220,8 +265,8 @@ async def calculate_cost( # todo: can be sync
extra={
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"input_cost_msats": input_msats,
"output_cost_msats": output_msats,
"input_cost_msats": calc_input_msats,
"output_cost_msats": calc_output_msats,
"total_cost_msats": token_based_cost,
"total_usd": total_usd,
"model": response_data.get("model", "unknown"),
@@ -230,8 +275,10 @@ async def calculate_cost( # todo: can be sync
return CostData(
base_msats=0,
input_msats=int(input_msats),
output_msats=int(output_msats),
input_msats=int(calc_input_msats),
output_msats=int(calc_output_msats),
total_msats=token_based_cost,
total_usd=total_usd,
input_tokens=input_tokens,
output_tokens=output_tokens,
)

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

@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends
from pydantic.v1 import BaseModel
from sqlmodel.ext.asyncio.session import AsyncSession
from ..core.db import ModelRow, get_session
from ..core.db import ModelRow, UpstreamProviderRow, get_session
from ..core.logging import get_logger
from ..core.settings import settings
from .price import sats_usd_price
@@ -60,6 +60,7 @@ class Model(BaseModel):
upstream_provider_id: int | str | None = None
canonical_slug: str | None = None
alias_ids: list[str] | None = None
forwarded_model_id: str | None = None
def __hash__(self) -> int:
return hash(self.id)
@@ -177,6 +178,7 @@ def _row_to_model(
upstream_provider_id=row.upstream_provider_id,
canonical_slug=getattr(row, "canonical_slug", None),
alias_ids=json.loads(row.alias_ids) if row.alias_ids else None,
forwarded_model_id=getattr(row, "forwarded_model_id", None) or row.id,
)
if apply_provider_fee:
@@ -329,6 +331,7 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
upstream_provider_id=model.upstream_provider_id,
canonical_slug=model.canonical_slug,
alias_ids=model.alias_ids,
forwarded_model_id=model.forwarded_model_id,
)
except Exception as e:
logger.error(
@@ -402,11 +405,89 @@ async def update_sats_pricing() -> None:
logger.error(f"Error updating sats pricing: {e}")
class ModelTestRequest(BaseModel):
model_id: str
endpoint_type: str
request_data: dict
@models_router.post("/api/models/test")
async def test_model(
payload: ModelTestRequest,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Test a model by sending a request through its configured upstream provider."""
from sqlmodel import select
result = await session.execute(
select(ModelRow).where(ModelRow.id == payload.model_id)
)
model_row = result.scalars().first()
if not model_row:
return {
"success": False,
"error": f"Model '{payload.model_id}' not found in database",
"status_code": 404,
}
provider = await session.get(UpstreamProviderRow, model_row.upstream_provider_id)
if not provider:
return {
"success": False,
"error": "Upstream provider not found",
"status_code": 404,
}
base_url = provider.base_url.rstrip("/")
if payload.endpoint_type == "chat-completions":
url = f"{base_url}/chat/completions"
else:
url = f"{base_url}/{payload.endpoint_type}"
actual_model_id = model_row.forwarded_model_id or model_row.id
request_data = dict(payload.request_data)
request_data["model"] = actual_model_id
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {provider.api_key}",
}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, json=request_data, headers=headers)
try:
response_data = response.json()
except Exception:
response_data = {"raw": response.text}
return {
"success": response.status_code < 400,
"data": response_data,
"status_code": response.status_code,
}
except Exception as e:
return {
"success": False,
"error": str(e),
"status_code": 500,
}
@models_router.get("/v1/models")
@models_router.get("/models", include_in_schema=False)
@models_router.get("/v1/models/", include_in_schema=False)
@models_router.get("/models")
@models_router.get("/models/", include_in_schema=False)
async def models(session: AsyncSession = Depends(get_session)) -> dict:
"""Get all available models from all providers with database overrides applied."""
from ..proxy import get_unique_models
items = get_unique_models()
return {"data": items}
data = []
for model in items:
m = model.dict()
if model.forwarded_model_id:
m["id"] = model.forwarded_model_id
data.append(m)
return {"data": data}

View File

@@ -69,7 +69,25 @@ def get_upstreams() -> list[BaseUpstreamProvider]:
def get_model_instance(model_id: str) -> Model | None:
"""Get Model instance by ID from global cache."""
return _model_instances.get(model_id.lower())
if not model_id:
return None
model_id_lower = model_id.lower()
# Try exact match first
if model := _model_instances.get(model_id_lower):
return model
# Try stripping common version suffixes (e.g., -20251222)
# This handles cases where upstream returns a specific version
# but we only track the base model name.
import re
base_model_id = re.sub(r"-\d{8}$", "", model_id_lower)
if base_model_id != model_id_lower:
if model := _model_instances.get(base_model_id):
return model
return None
def get_provider_for_model(model_id: str) -> list[BaseUpstreamProvider] | None:
@@ -138,11 +156,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)
@@ -153,6 +166,7 @@ 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
@@ -211,7 +225,7 @@ async def proxy(
elif auth := headers.get("authorization", None):
key = await get_bearer_token_key(
headers, path, session, auth, max_cost_for_model
headers, path, session, auth, max_cost_for_model, model_id
)
else:
@@ -300,9 +314,13 @@ async def proxy(
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(
"Upstream request failed, ensuring payment is reverted",
"Unexpected error in upstream request, reverting payment",
extra={
"error": str(e),
"error_type": type(e).__name__,
@@ -387,10 +405,16 @@ async def proxy(
async def get_bearer_token_key(
headers: dict, path: str, session: AsyncSession, auth: str, min_cost: int = 0
headers: dict,
path: str,
session: AsyncSession,
auth: str,
min_cost: int = 0,
model_id: str = "unknown",
) -> 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)
@@ -454,15 +478,16 @@ async def get_bearer_token_key(
)
return key
except Exception as e:
key_preview = bearer_key[:20] + "..." if len(bearer_key) > 20 else bearer_key
logger.error(
"Bearer token validation failed",
f"Bearer token validation failed: {type(e).__name__}: {e} path={path} model={model_id!r} min_cost={min_cost} key={key_preview!r}",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"bearer_key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
"model_id": model_id,
"min_cost_msat": min_cost,
"bearer_key_preview": key_preview,
},
)
raise

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

File diff suppressed because it is too large Load Diff

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

@@ -205,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",

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

@@ -1,9 +1,9 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
import httpx
from pydantic import BaseModel
from pydantic import BaseModel, Field
from ..core.logging import get_logger
from ..payment.models import Architecture, Model, Pricing, async_fetch_openrouter_models
@@ -16,18 +16,20 @@ logger = get_logger(__name__)
class PPQAIModelPricing(BaseModel):
ui: dict[str, float]
api: dict[str, float]
ui: Optional[dict[str, float]] = None
api: Optional[dict[str, float]] = None
input_per_1M_tokens: Optional[float] = Field(None, alias="input_per_1M_tokens")
output_per_1M_tokens: Optional[float] = Field(None, alias="output_per_1M_tokens")
class PPQAIModel(BaseModel):
id: str
provider: str
provider: Optional[str] = None
name: str
created_at: int
context_length: int
pricing: PPQAIModelPricing
popular: bool
popular: bool = False
class PPQAIUpstreamProvider(BaseUpstreamProvider):
@@ -134,31 +136,54 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
)
if or_model:
if input_price := ppqai_model.pricing.api.get(
"input_per_1M"
):
input_price = None
if ppqai_model.pricing.api:
input_price = ppqai_model.pricing.api.get(
"input_per_1M"
)
elif ppqai_model.pricing.input_per_1M_tokens:
input_price = ppqai_model.pricing.input_per_1M_tokens
if input_price is not None:
or_model.pricing.prompt = input_price / 1_000_000
if output_price := ppqai_model.pricing.api.get(
"output_per_1M"
):
output_price = None
if ppqai_model.pricing.api:
output_price = ppqai_model.pricing.api.get(
"output_per_1M"
)
elif ppqai_model.pricing.output_per_1M_tokens:
output_price = ppqai_model.pricing.output_per_1M_tokens
if output_price is not None:
or_model.pricing.completion = output_price / 1_000_000
if cl := ppqai_model.context_length:
or_model.context_length = cl
models.append(or_model)
else:
input_price = ppqai_model.pricing.api.get(
"input_per_1M", 0.0
)
output_price = ppqai_model.pricing.api.get(
"output_per_1M", 0.0
)
input_price = 0.0
if ppqai_model.pricing.api:
input_price = ppqai_model.pricing.api.get(
"input_per_1M", 0.0
)
elif ppqai_model.pricing.input_per_1M_tokens:
input_price = ppqai_model.pricing.input_per_1M_tokens
output_price = 0.0
if ppqai_model.pricing.api:
output_price = ppqai_model.pricing.api.get(
"output_per_1M", 0.0
)
elif ppqai_model.pricing.output_per_1M_tokens:
output_price = ppqai_model.pricing.output_per_1M_tokens
models.append(
Model(
id=ppqai_model.id,
name=ppqai_model.name,
created=ppqai_model.created_at // 1000,
description=f"{ppqai_model.provider} model",
description=f"{ppqai_model.provider or 'PPQ.AI'} model",
context_length=ppqai_model.context_length,
architecture=Architecture(
modality="text->text",
@@ -283,7 +308,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}"} if self.api_key else {}
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

@@ -1,13 +1,14 @@
import asyncio
import math
import time
from typing import TypedDict
from cashu.core.base import Proof, Token
from cashu.wallet.helpers import deserialize_token_from_string
from cashu.wallet.wallet import Wallet
from sqlmodel import col, update
from sqlmodel import col, select, update
from .core import db, get_logger
from .core.db import store_cashu_transaction
from .core.settings import settings
from .payment.lnurl import raw_send_to_lnurl
@@ -34,6 +35,7 @@ async def recieve_token(
wallet.verify_proofs_dleq(token_obj.proofs)
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
return token_obj.amount, token_obj.unit, token_obj.mint
@@ -58,15 +60,86 @@ async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str
return token
async def _calculate_swap_amount(
amount_msat: int,
token_unit: str,
token_mint_url: str,
token_wallet: Wallet,
primary_wallet: Wallet,
) -> int:
"""
Calculate the amount to mint on the primary mint after accounting for
potential swap fees (melt fees) on the foreign mint.
"""
if settings.primary_mint_unit == "sat":
receive_amount = amount_msat // 1000
else:
receive_amount = amount_msat
if token_mint_url == settings.primary_mint:
logger.info(
"swap_to_primary_mint: skipping fee estimation (same mint)",
extra={"minted_amount": receive_amount},
)
return int(receive_amount)
logger.info(
"swap_to_primary_mint: estimating fees",
extra={
"dummy_amount": receive_amount,
"unit": settings.primary_mint_unit,
},
)
try:
dummy_mint_quote = await primary_wallet.request_mint(receive_amount)
dummy_melt_quote = await token_wallet.melt_quote(dummy_mint_quote.request)
fee_reserve = dummy_melt_quote.fee_reserve
if token_unit == "sat":
fee_msat = fee_reserve * 1000
else:
fee_msat = fee_reserve
amount_msat_after_fee = amount_msat - fee_msat
if settings.primary_mint_unit == "sat":
minted_amount = int(amount_msat_after_fee // 1000)
else:
minted_amount = int(amount_msat_after_fee)
if minted_amount <= 0:
raise ValueError(f"Fees ({fee_reserve} {token_unit}) exceed token amount")
logger.info(
"swap_to_primary_mint: fee estimation result",
extra={
"token_amount_sat": amount_msat // 1000,
"estimated_fee_sat": fee_msat // 1000,
"minted_amount": minted_amount,
"minted_unit": settings.primary_mint_unit,
},
)
return minted_amount
except Exception as e:
logger.error(
"swap_to_primary_mint: fee estimation failed",
extra={"error": str(e)},
)
raise ValueError(f"Failed to estimate fees: {e}") from e
async def swap_to_primary_mint(
token_obj: Token, token_wallet: Wallet
) -> tuple[int, str, str]:
logger.info(
"swap_to_primary_mint",
"swap_to_primary_mint: starting",
extra={
"mint": token_obj.mint,
"amount": token_obj.amount,
"foreign_mint": token_obj.mint,
"token_amount": token_obj.amount,
"unit": token_obj.unit,
"primary_mint": settings.primary_mint,
},
)
# Ensure amount is an integer
@@ -81,24 +154,118 @@ async def swap_to_primary_mint(
amount_msat = token_amount
else:
raise ValueError("Invalid unit")
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2)) + 1
amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
if settings.primary_mint_unit == "sat":
minted_amount = int(amount_msat_after_fee // 1000)
else:
minted_amount = int(amount_msat_after_fee)
# If the token is already from the primary mint, we don't need to swap
# and we definitely don't want to calculate or pay fees.
if token_obj.mint == settings.primary_mint:
logger.info(
"swap_to_primary_mint: token already on primary mint, skipping swap",
extra={
"mint": token_obj.mint,
"amount": token_amount,
"unit": token_obj.unit,
},
)
await token_wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
return token_amount, token_obj.unit, token_obj.mint
minted_amount = await _calculate_swap_amount(
amount_msat,
token_obj.unit,
token_obj.mint,
token_wallet,
primary_wallet,
)
mint_quote = await primary_wallet.request_mint(minted_amount)
logger.info(
"swap_to_primary_mint: mint quote received",
extra={"mint_quote_id": mint_quote.quote},
)
melt_quote = await token_wallet.melt_quote(mint_quote.request)
_ = await token_wallet.melt(
proofs=token_obj.proofs,
invoice=mint_quote.request,
fee_reserve_sat=melt_quote.fee_reserve,
quote_id=melt_quote.quote,
total_needed = melt_quote.amount + melt_quote.fee_reserve
logger.info(
"swap_to_primary_mint: melt quote received",
extra={
"melt_quote_id": melt_quote.quote,
"melt_amount": melt_quote.amount,
"melt_fee_reserve": melt_quote.fee_reserve,
"total_needed": total_needed,
"token_amount": token_amount,
},
)
if total_needed > token_amount:
logger.warning(
"swap_to_primary_mint: insufficient token amount for melt fees",
extra={
"token_amount": token_amount,
"melt_amount": melt_quote.amount,
"melt_fee_reserve": melt_quote.fee_reserve,
"total_needed": total_needed,
"shortfall": total_needed - token_amount,
},
)
raise ValueError(
f"Token amount ({token_amount} {token_obj.unit}) is insufficient to cover "
f"melt fees. Needed: {total_needed} {token_obj.unit} "
f"(amount: {melt_quote.amount} + fee: {melt_quote.fee_reserve})"
)
try:
_ = await token_wallet.melt(
proofs=token_obj.proofs,
invoice=mint_quote.request,
fee_reserve_sat=melt_quote.fee_reserve,
quote_id=melt_quote.quote,
)
except Exception as e:
logger.error(
"swap_to_primary_mint: melt failed",
extra={
"error": str(e),
"error_type": type(e).__name__,
"foreign_mint": token_obj.mint,
"token_amount": token_amount,
"melt_quote_id": melt_quote.quote,
"total_needed": total_needed,
},
)
raise ValueError(
f"Failed to melt token from foreign mint {token_obj.mint}: {e}"
) from e
logger.info(
"swap_to_primary_mint: melt succeeded, minting on primary",
extra={"minted_amount": minted_amount, "mint_quote_id": mint_quote.quote},
)
try:
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
except Exception as e:
logger.error(
"swap_to_primary_mint: mint on primary failed after successful melt",
extra={
"error": str(e),
"error_type": type(e).__name__,
"minted_amount": minted_amount,
"mint_quote_id": mint_quote.quote,
},
)
raise
logger.info(
"swap_to_primary_mint: completed successfully",
extra={
"foreign_mint": token_obj.mint,
"primary_mint": settings.primary_mint,
"original_amount": token_amount,
"minted_amount": minted_amount,
"unit": settings.primary_mint_unit,
},
)
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
return int(minted_amount), settings.primary_mint_unit, settings.primary_mint
@@ -113,6 +280,8 @@ async def credit_balance(
try:
amount, unit, mint_url = await recieve_token(cashu_token)
original_amount = amount
original_unit = unit
logger.info(
"credit_balance: Token redeemed successfully",
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
@@ -144,6 +313,19 @@ async def credit_balance(
extra={"new_balance": key.balance},
)
try:
await store_cashu_transaction(
token=cashu_token,
amount=original_amount,
unit=original_unit,
mint_url=mint_url,
typ="in",
source="apikey",
api_key_hashed_key=key.hashed_key,
)
except Exception:
pass
logger.info(
"Cashu token successfully redeemed and stored",
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
@@ -352,6 +534,101 @@ async def periodic_payout() -> None:
)
async def periodic_refund_sweep() -> None:
while True:
await asyncio.sleep(60 * 60) # every hour
try:
cutoff = int(time.time()) - settings.refund_sweep_ttl_seconds
async with db.create_session() as session:
stmt = select(db.CashuTransaction).where(
db.CashuTransaction.type == "out",
db.CashuTransaction.collected == False, # noqa: E712
db.CashuTransaction.swept == False, # noqa: E712
db.CashuTransaction.created_at < cutoff,
)
results = await session.exec(stmt)
refunds = results.all()
for refund in refunds:
try:
await recieve_token(refund.token)
refund.swept = True
session.add(refund)
logger.info(
"Swept uncollected refund",
extra={
"id": refund.id,
"amount": refund.amount,
"unit": refund.unit,
},
)
except Exception as e:
error_msg = str(e).lower()
if "already spent" in error_msg:
refund.collected = True
session.add(refund)
logger.info(
"Refund already spent (client collected), marking swept",
extra={
"id": refund.id,
},
)
else:
logger.warning(
"Failed to sweep refund",
extra={
"id": refund.id,
"error": str(e),
},
)
await session.commit()
except Exception as e:
logger.error(
"Error in periodic refund sweep",
extra={"error": str(e), "error_type": type(e).__name__},
)
async def periodic_routstr_fee_payout() -> None:
from .auth import (
ROUTSTR_FEE_DEFAULT_PAYOUT,
ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS,
ROUTSTR_LN_ADDRESS,
)
if not ROUTSTR_LN_ADDRESS:
logger.info("ROUTSTR_LN_ADDRESS not set, skipping fee payout")
return
while True:
await asyncio.sleep(ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS)
try:
async with db.create_session() as session:
fee = await db.get_routstr_fee(session)
accumulated_sats = fee.accumulated_msats // 1000
if accumulated_sats >= ROUTSTR_FEE_DEFAULT_PAYOUT:
wallet = await get_wallet(settings.primary_mint, "sat")
proofs = get_proofs_per_mint_and_unit(
wallet, settings.primary_mint, "sat", not_reserved=True
)
amount_received = await raw_send_to_lnurl(
wallet, proofs, ROUTSTR_LN_ADDRESS, "sat", amount=accumulated_sats
)
paid_msats = accumulated_sats * 1000
await db.reset_routstr_fee(session, paid_msats)
logger.info(
"Routstr fee payout sent",
extra={
"accumulated_sats": accumulated_sats,
"amount_received": amount_received,
},
)
except Exception as e:
logger.error(
f"Error in Routstr fee payout: {type(e).__name__}",
extra={"error": str(e)},
)
async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
wallet = await get_wallet(mint, unit)
proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id]

View File

@@ -380,6 +380,13 @@ async def integration_session(
yield session
@pytest_asyncio.fixture
async def patched_db_engine(integration_engine: Any) -> AsyncGenerator[None, None]:
"""Patch the global db engine so create_session() uses the test engine."""
with patch("routstr.core.db.engine", integration_engine):
yield
class DatabaseSnapshot:
"""Utility to capture and compare database states"""

View File

@@ -0,0 +1,80 @@
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.admin import admin_sessions
from routstr.core.db import UpstreamProviderRow
async def _create_routstr_provider() -> UpstreamProviderRow:
return UpstreamProviderRow(
provider_type="routstr",
base_url="https://upstream.example",
api_key="",
enabled=True,
)
def _admin_headers() -> dict[str, str]:
token = "test-admin-token"
admin_sessions[token] = int(
(datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()
)
return {"Authorization": f"Bearer {token}"}
@pytest.mark.integration
@pytest.mark.asyncio
async def test_admin_routstr_provider_balance_timeout_returns_504(
integration_client: httpx.AsyncClient,
integration_session: AsyncSession,
) -> None:
provider = await _create_routstr_provider()
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info")
timeout_error = httpx.ConnectTimeout("Connect timeout", request=request)
with patch(
"httpx.AsyncHTTPTransport.handle_async_request",
new=AsyncMock(side_effect=timeout_error),
):
response = await integration_client.get(
f"/admin/api/upstream-providers/{provider.id}/balance",
headers=_admin_headers(),
)
assert response.status_code == 504
assert response.json()["detail"] == "Timed out contacting upstream Routstr provider"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_admin_routstr_provider_balance_request_error_returns_502(
integration_client: httpx.AsyncClient,
integration_session: AsyncSession,
) -> None:
provider = await _create_routstr_provider()
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info")
request_error = httpx.ConnectError("Connection failed", request=request)
with patch(
"httpx.AsyncHTTPTransport.handle_async_request",
new=AsyncMock(side_effect=request_error),
):
response = await integration_client.get(
f"/admin/api/upstream-providers/{provider.id}/balance",
headers=_admin_headers(),
)
assert response.status_code == 502
assert response.json()["detail"] == "Failed to contact upstream Routstr provider"

View File

@@ -0,0 +1,369 @@
"""
Integration tests for the balance-goes-negative bug in adjust_payment_for_tokens.
Root cause: when actual token cost exceeds the discounted reservation
(cost_difference > 0, caused by tolerance_percentage discounting the reservation),
the finalization UPDATE had no WHERE guard on balance, allowing balance to go negative.
Fix: added `.where(col(ApiKey.balance) >= total_cost_msats)` so the UPDATE is a no-op
when balance is insufficient, then falls back to charging only deducted_max_cost.
"""
import uuid
from unittest.mock import patch
import pytest
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.db import ApiKey
from routstr.payment.cost_calculation import CostData
def _make_key(balance: int, reserved: int) -> ApiKey:
return ApiKey(
hashed_key=f"test_{uuid.uuid4().hex}",
balance=balance,
reserved_balance=reserved,
total_spent=0,
total_requests=1,
)
async def _refresh(session: AsyncSession, key: ApiKey) -> ApiKey:
await session.refresh(key)
return key
# ---------------------------------------------------------------------------
# Helper: build a CostData where token cost > deducted_max_cost
# ---------------------------------------------------------------------------
def _cost_data(total_msats: int) -> CostData:
return CostData(
base_msats=0,
input_msats=total_msats // 2,
output_msats=total_msats - total_msats // 2,
total_msats=total_msats,
total_usd=0.0,
input_tokens=100,
output_tokens=100,
)
# ---------------------------------------------------------------------------
# Test 1 — exact reproduction of the bug
#
# Setup: balance == deducted_max_cost (user has just enough for the reservation,
# nothing extra). Actual token cost is 1% higher (tolerance_percentage).
#
# Before fix: balance -= total_cost_msats → goes negative.
# After fix: WHERE balance >= total_cost_msats fails → fallback charges
# deducted_max_cost → balance reaches 0, never negative.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_balance_never_negative_when_cost_exceeds_reservation(
integration_session: AsyncSession,
) -> None:
"""Balance must not go negative when actual token cost > discounted reservation."""
from routstr.auth import adjust_payment_for_tokens
deducted_max_cost = 990 # reserved (1% below true max of 1000)
actual_token_cost = 1000 # actual cost at true max
# User has balance exactly equal to the reservation — tight budget
key = _make_key(balance=deducted_max_cost, reserved=deducted_max_cost)
integration_session.add(key)
await integration_session.commit()
response_data = {"model": "test-model", "usage": {"prompt_tokens": 100, "completion_tokens": 100}}
with patch(
"routstr.auth.calculate_cost",
return_value=_cost_data(actual_token_cost),
):
await adjust_payment_for_tokens(key, response_data, integration_session, deducted_max_cost)
await _refresh(integration_session, key)
assert key.balance >= 0, f"Balance went negative: {key.balance}"
assert key.reserved_balance >= 0, f"Reserved balance went negative: {key.reserved_balance}"
assert key.reserved_balance == 0, "Reservation must be fully released after finalization"
# ---------------------------------------------------------------------------
# Test 2 — balance is ZERO after the reservation is accounted for
# (absolute floor case)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_balance_floor_at_zero_on_overrun(
integration_session: AsyncSession,
) -> None:
"""When balance exactly covers deducted_max_cost and cost overruns, balance reaches 0 not negative."""
from routstr.auth import adjust_payment_for_tokens
deducted_max_cost = 500
actual_token_cost = 550 # 10% overrun
key = _make_key(balance=500, reserved=500)
integration_session.add(key)
await integration_session.commit()
response_data = {"model": "test-model", "usage": {"prompt_tokens": 50, "completion_tokens": 50}}
with patch(
"routstr.auth.calculate_cost",
return_value=_cost_data(actual_token_cost),
):
await adjust_payment_for_tokens(key, response_data, integration_session, deducted_max_cost)
await _refresh(integration_session, key)
assert key.balance == 0, (
f"Expected balance=0 (charged deducted_max_cost fallback), got {key.balance}"
)
assert key.reserved_balance == 0, f"Reserved balance should be 0, got {key.reserved_balance}"
# Fallback charges deducted_max_cost
assert key.total_spent == deducted_max_cost, (
f"Expected total_spent={deducted_max_cost}, got {key.total_spent}"
)
# ---------------------------------------------------------------------------
# Test 3 — balance has enough room: full token cost should be charged
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_full_cost_charged_when_balance_sufficient_for_overrun(
integration_session: AsyncSession,
) -> None:
"""When balance covers total_cost_msats, the full amount is charged (not just deducted_max_cost)."""
from routstr.auth import adjust_payment_for_tokens
deducted_max_cost = 990
actual_token_cost = 1000
# User has extra balance beyond the reservation
key = _make_key(balance=2000, reserved=990)
integration_session.add(key)
await integration_session.commit()
response_data = {"model": "test-model", "usage": {"prompt_tokens": 100, "completion_tokens": 100}}
with patch(
"routstr.auth.calculate_cost",
return_value=_cost_data(actual_token_cost),
):
await adjust_payment_for_tokens(key, response_data, integration_session, deducted_max_cost)
await _refresh(integration_session, key)
assert key.balance >= 0, f"Balance went negative: {key.balance}"
assert key.reserved_balance == 0, f"Reservation not released: {key.reserved_balance}"
assert key.total_spent == actual_token_cost, (
f"Expected full charge of {actual_token_cost}, got {key.total_spent}"
)
assert key.balance == 2000 - actual_token_cost, (
f"Expected balance={2000 - actual_token_cost}, got {key.balance}"
)
# ---------------------------------------------------------------------------
# Test 4 — concurrent finalizations with cost overrun
#
# Multiple requests finish concurrently. Each has a small overrun.
# None should drive balance negative.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_concurrent_cost_overruns_never_negative(
integration_session: AsyncSession,
patched_db_engine: None,
) -> None:
"""Concurrent finalization with cost overruns must never produce negative balance."""
import asyncio
from routstr.auth import adjust_payment_for_tokens, pay_for_request
from routstr.core.db import create_session
deducted_max_cost = 990
actual_token_cost = 1000
n_requests = 5
# Fund the key with exactly enough for n_requests reservations + a tiny buffer
starting_balance = deducted_max_cost * n_requests
key_hash = f"test_concurrent_{uuid.uuid4().hex}"
async with create_session() as session:
key = ApiKey(
hashed_key=key_hash,
balance=starting_balance,
reserved_balance=0,
total_spent=0,
total_requests=0,
)
session.add(key)
await session.commit()
# Reserve n_requests slots (sequentially, as pay_for_request is atomic)
async with create_session() as session:
key_to_reserve = await session.get(ApiKey, key_hash)
assert key_to_reserve is not None
for _ in range(n_requests):
await pay_for_request(key_to_reserve, deducted_max_cost, session)
await session.refresh(key_to_reserve)
# Now finalize all concurrently with cost overrun
async def finalize() -> None:
response_data = {
"model": "test-model",
"usage": {"prompt_tokens": 100, "completion_tokens": 100},
}
async with create_session() as session:
fresh_key = await session.get(ApiKey, key_hash)
assert fresh_key is not None
with patch(
"routstr.auth.calculate_cost",
return_value=_cost_data(actual_token_cost),
):
await adjust_payment_for_tokens(
fresh_key, response_data, session, deducted_max_cost
)
await asyncio.gather(*[finalize() for _ in range(n_requests)])
async with create_session() as session:
final_key = await session.get(ApiKey, key_hash)
assert final_key is not None
assert final_key.balance >= 0, (
f"Balance went negative after concurrent overruns: {final_key.balance}"
)
assert final_key.reserved_balance == 0, (
f"Reserved balance not fully released: {final_key.reserved_balance}"
)
assert final_key.total_spent <= starting_balance, (
f"Total spent ({final_key.total_spent}) exceeds starting balance ({starting_balance})"
)
# Every request must have been charged at least deducted_max_cost — no free inference.
assert final_key.total_spent == starting_balance, (
f"Expected total_spent={starting_balance} (all {n_requests} reservations charged), "
f"got {final_key.total_spent} — at least one request got free inference"
)
# ---------------------------------------------------------------------------
# Test 5 — overrun with no balance at all (reserved_balance == balance)
# simulates a user who topped up to exactly the reservation floor
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_zero_free_balance_overrun_is_safe(
integration_session: AsyncSession,
) -> None:
"""User with zero free balance (all reserved) should never go negative on overrun."""
from routstr.auth import adjust_payment_for_tokens
deducted_max_cost = 1000
actual_token_cost = 1050
# balance == reserved_balance: zero free balance
key = _make_key(balance=1000, reserved=1000)
integration_session.add(key)
await integration_session.commit()
response_data = {"model": "test-model", "usage": {"prompt_tokens": 50, "completion_tokens": 100}}
with patch(
"routstr.auth.calculate_cost",
return_value=_cost_data(actual_token_cost),
):
await adjust_payment_for_tokens(key, response_data, integration_session, deducted_max_cost)
await _refresh(integration_session, key)
assert key.balance >= 0, f"Balance went negative: {key.balance}"
assert key.reserved_balance >= 0, f"Reserved balance went negative: {key.reserved_balance}"
# ---------------------------------------------------------------------------
# Test 6 — parallel requests: second finalization must not get free inference
#
# Root cause of the bug fixed in auth.py:
# `.where(col(ApiKey.balance) >= total_cost_msats)` ignores other requests'
# reservations, so after Request A charges total_cost_msats, balance can drop
# below deducted_max_cost, causing Request B's fallback to release for free.
#
# Fix: use `balance - reserved_balance + deducted_max_cost >= total_cost_msats`
# so the check accounts for concurrent reservations.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_parallel_requests_no_free_inference(
integration_session: AsyncSession,
patched_db_engine: None,
) -> None:
"""Second parallel finalization must be charged even when first depleted free balance."""
import asyncio
from routstr.auth import adjust_payment_for_tokens
from routstr.core.db import create_session
deducted_max_cost = 100
actual_token_cost = 150 # overrun: 50 more than reserved
# Fund the key with exactly 2 * deducted_max_cost.
# Both requests pre-reserved 100 each → balance=200, reserved=200, free=0.
# Old check (balance >= total_cost_msats):
# Request A: 200 >= 150 ✓ → charges 150 → balance=50, reserved=100
# Request B: 50 >= 150 ✗ → fallback: 50 >= 100 ✗ → releases FREE
# New check (balance - reserved + deducted >= total_cost_msats):
# Both fall to fallback (0 free balance).
# Both charge deducted_max_cost=100 → total_spent=200, balance=0.
starting_balance = deducted_max_cost * 2
key_hash = f"test_parallel_no_free_{uuid.uuid4().hex}"
async with create_session() as session:
key = ApiKey(
hashed_key=key_hash,
balance=starting_balance,
reserved_balance=deducted_max_cost * 2, # both slots pre-reserved
total_spent=0,
total_requests=2,
)
session.add(key)
await session.commit()
async def finalize() -> None:
response_data = {
"model": "test-model",
"usage": {"prompt_tokens": 50, "completion_tokens": 100},
}
async with create_session() as session:
fresh_key = await session.get(ApiKey, key_hash)
assert fresh_key is not None
with patch(
"routstr.auth.calculate_cost",
return_value=_cost_data(actual_token_cost),
):
await adjust_payment_for_tokens(
fresh_key, response_data, session, deducted_max_cost
)
await asyncio.gather(finalize(), finalize())
async with create_session() as session:
final_key = await session.get(ApiKey, key_hash)
assert final_key is not None
assert final_key.balance >= 0, f"Balance went negative: {final_key.balance}"
assert final_key.reserved_balance == 0, (
f"Reserved balance not released: {final_key.reserved_balance}"
)
# Both requests must have been charged — no free inference.
assert final_key.total_spent == starting_balance, (
f"Expected total_spent={starting_balance} (both reservations charged), "
f"got {final_key.total_spent} — one request got free inference"
)

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

@@ -0,0 +1,302 @@
"""Integration tests for CLI token management (/admin/api/cli-tokens).
Covers:
- GET /admin/api/cli-tokens — list (preview only, no full token)
- POST /admin/api/cli-tokens — create (returns full token once)
- DELETE /admin/api/cli-tokens/{id} — revoke
- Using a CLI token as Bearer auth against admin endpoints
- Expiry enforcement (expired tokens are rejected by require_admin_api)
- last_used_at bump on successful use
- Auth failures: missing token, wrong token, revoked token
"""
from __future__ import annotations
import secrets
import time
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlmodel import select
from routstr.core.admin import admin_sessions
from routstr.core.db import AsyncSession, CliToken
# ──────────────────────────────────────────────────────────────────────────────
# Fixtures
# ──────────────────────────────────────────────────────────────────────────────
@pytest_asyncio.fixture
async def admin_session_token() -> AsyncGenerator[str, None]:
"""Inject a short-lived admin session token into admin_sessions."""
token = secrets.token_urlsafe(24)
admin_sessions[token] = int(time.time()) + 3600
yield token
admin_sessions.pop(token, None)
@pytest_asyncio.fixture
async def admin_client(
integration_client: AsyncClient, admin_session_token: str
) -> AsyncClient:
"""An integration_client pre-authenticated with an admin session token."""
integration_client.headers["Authorization"] = f"Bearer {admin_session_token}"
return integration_client
# ──────────────────────────────────────────────────────────────────────────────
# Creation
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.integration
@pytest.mark.asyncio
async def test_create_cli_token_returns_full_token_once(
admin_client: AsyncClient,
) -> None:
"""POST /admin/api/cli-tokens returns the raw token only on creation."""
resp = await admin_client.post(
"/admin/api/cli-tokens",
json={"name": "my-laptop"},
)
assert resp.status_code == 200
body = resp.json()
assert body["name"] == "my-laptop"
assert isinstance(body["id"], str) and body["id"]
assert isinstance(body["token"], str) and len(body["token"]) >= 32
assert body["expires_at"] is None
assert isinstance(body["created_at"], int)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_create_cli_token_with_expiry(admin_client: AsyncClient) -> None:
"""expires_in_days sets expires_at ~= now + days * 86400."""
before = int(time.time())
resp = await admin_client.post(
"/admin/api/cli-tokens",
json={"name": "ci-runner", "expires_in_days": 7},
)
assert resp.status_code == 200
body = resp.json()
assert body["expires_at"] is not None
delta = body["expires_at"] - before
# Allow 10s jitter around 7 * 86400
assert 7 * 86400 - 10 <= delta <= 7 * 86400 + 10
@pytest.mark.integration
@pytest.mark.asyncio
async def test_create_cli_token_rejects_empty_name(
admin_client: AsyncClient,
) -> None:
resp = await admin_client.post(
"/admin/api/cli-tokens", json={"name": " "}
)
assert resp.status_code == 400
@pytest.mark.integration
@pytest.mark.asyncio
async def test_create_cli_token_requires_admin(
integration_client: AsyncClient,
) -> None:
"""No admin token / no bearer → 403."""
resp = await integration_client.post(
"/admin/api/cli-tokens", json={"name": "no-auth"}
)
assert resp.status_code == 403
# ──────────────────────────────────────────────────────────────────────────────
# Listing
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.integration
@pytest.mark.asyncio
async def test_list_cli_tokens_returns_preview_not_full_token(
admin_client: AsyncClient,
) -> None:
"""Listing never leaks the raw token."""
create = await admin_client.post(
"/admin/api/cli-tokens", json={"name": "secret-keeper"}
)
assert create.status_code == 200
full_token = create.json()["token"]
resp = await admin_client.get("/admin/api/cli-tokens")
assert resp.status_code == 200
items = resp.json()
assert any(t["name"] == "secret-keeper" for t in items)
for t in items:
# No 'token' field, only 'token_preview'
assert "token" not in t
assert "token_preview" in t
assert full_token not in t["token_preview"]
assert "..." in t["token_preview"]
@pytest.mark.integration
@pytest.mark.asyncio
async def test_list_cli_tokens_requires_admin(
integration_client: AsyncClient,
) -> None:
resp = await integration_client.get("/admin/api/cli-tokens")
assert resp.status_code == 403
# ──────────────────────────────────────────────────────────────────────────────
# Using a CLI token as admin auth
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.integration
@pytest.mark.asyncio
async def test_cli_token_authorizes_admin_endpoints(
admin_client: AsyncClient,
integration_client: AsyncClient,
integration_session: AsyncSession,
) -> None:
"""A freshly-created CLI token can be used as Bearer on admin endpoints."""
create = await admin_client.post(
"/admin/api/cli-tokens", json={"name": "cli-auth"}
)
assert create.status_code == 200
cli_token = create.json()["token"]
token_id = create.json()["id"]
# Use a NEW client to isolate the header from admin_session_token
integration_client.headers["Authorization"] = f"Bearer {cli_token}"
resp = await integration_client.get("/admin/api/cli-tokens")
assert resp.status_code == 200
# last_used_at should be populated after use
row = await integration_session.get(CliToken, token_id)
assert row is not None
assert row.last_used_at is not None
assert row.last_used_at >= row.created_at
@pytest.mark.integration
@pytest.mark.asyncio
async def test_expired_cli_token_is_rejected(
admin_client: AsyncClient,
integration_client: AsyncClient,
integration_session: AsyncSession,
) -> None:
"""A CLI token with expires_at in the past → 403."""
create = await admin_client.post(
"/admin/api/cli-tokens",
json={"name": "will-expire", "expires_in_days": 1},
)
assert create.status_code == 200
cli_token = create.json()["token"]
token_id = create.json()["id"]
# Force-expire it in the DB
row = await integration_session.get(CliToken, token_id)
assert row is not None
row.expires_at = int(time.time()) - 1
integration_session.add(row)
await integration_session.commit()
integration_client.headers["Authorization"] = f"Bearer {cli_token}"
resp = await integration_client.get("/admin/api/cli-tokens")
assert resp.status_code == 403
@pytest.mark.integration
@pytest.mark.asyncio
async def test_invalid_bearer_token_is_rejected(
integration_client: AsyncClient,
) -> None:
integration_client.headers["Authorization"] = "Bearer not-a-real-token"
resp = await integration_client.get("/admin/api/cli-tokens")
assert resp.status_code == 403
# ──────────────────────────────────────────────────────────────────────────────
# Revocation
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.integration
@pytest.mark.asyncio
async def test_revoke_cli_token_removes_auth(
admin_client: AsyncClient,
integration_client: AsyncClient,
integration_session: AsyncSession,
) -> None:
"""After DELETE, the token no longer authorizes."""
create = await admin_client.post(
"/admin/api/cli-tokens", json={"name": "to-revoke"}
)
token_id = create.json()["id"]
cli_token = create.json()["token"]
revoke = await admin_client.delete(f"/admin/api/cli-tokens/{token_id}")
assert revoke.status_code == 200
assert revoke.json() == {"ok": True, "deleted_id": token_id}
# Row is gone
row = await integration_session.get(CliToken, token_id)
assert row is None
# Can no longer be used for auth
integration_client.headers["Authorization"] = f"Bearer {cli_token}"
resp = await integration_client.get("/admin/api/cli-tokens")
assert resp.status_code == 403
@pytest.mark.integration
@pytest.mark.asyncio
async def test_revoke_unknown_cli_token_returns_404(
admin_client: AsyncClient,
) -> None:
resp = await admin_client.delete("/admin/api/cli-tokens/does-not-exist")
assert resp.status_code == 404
@pytest.mark.integration
@pytest.mark.asyncio
async def test_revoke_cli_token_requires_admin(
integration_client: AsyncClient,
) -> None:
resp = await integration_client.delete("/admin/api/cli-tokens/anything")
assert resp.status_code == 403
# ──────────────────────────────────────────────────────────────────────────────
# Lifecycle / uniqueness
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.integration
@pytest.mark.asyncio
async def test_multiple_tokens_are_independent(
admin_client: AsyncClient,
integration_session: AsyncSession,
) -> None:
"""Creating N tokens yields N unique tokens that all live in DB."""
names = ["dev-a", "dev-b", "dev-c"]
raw_tokens: list[str] = []
ids: list[str] = []
for name in names:
r = await admin_client.post(
"/admin/api/cli-tokens", json={"name": name}
)
assert r.status_code == 200
raw_tokens.append(r.json()["token"])
ids.append(r.json()["id"])
# All unique
assert len(set(raw_tokens)) == len(raw_tokens)
assert len(set(ids)) == len(ids)
# All in DB
result = await integration_session.exec(
select(CliToken).where(CliToken.name.in_(names)) # type: ignore[attr-defined]
)
rows = result.all()
assert {r.name for r in rows} == set(names)

View File

@@ -0,0 +1,240 @@
"""
Tests showing how a user hits "Insufficient balance: X mSats required for this model"
when their balance is too low for the model's cost.
The log line that triggered this:
WARNING Insufficient billing balance during validation
ERROR Bearer token validation failed: HTTPException: 402:
{'error': {'message': 'Insufficient balance: 622888 mSats required
for this model. 20320 available.', ...}}
This happens in validate_bearer_key (auth.py) when:
billing_key.total_balance < min_cost (model's max cost)
and also in pay_for_request when the atomic UPDATE finds no available balance.
"""
import uuid
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from httpx import AsyncClient
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.db import ApiKey
def _key(balance: int, reserved: int = 0) -> ApiKey:
return ApiKey(
hashed_key=f"test_{uuid.uuid4().hex}",
balance=balance,
reserved_balance=reserved,
total_spent=0,
)
# ---------------------------------------------------------------------------
# Test 1 — simplest case: balance < model cost → pay_for_request raises 402
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pay_for_request_raises_402_when_balance_too_low(
integration_session: AsyncSession,
) -> None:
"""
User has 20_000 msats. Model costs 622_888 msats.
pay_for_request must raise HTTP 402 with a clear message.
"""
from routstr.auth import pay_for_request
model_cost = 622_888
user_balance = 20_000
key = _key(balance=user_balance)
integration_session.add(key)
await integration_session.commit()
with pytest.raises(HTTPException) as exc_info:
await pay_for_request(key, model_cost, integration_session)
assert exc_info.value.status_code == 402
detail = exc_info.value.detail
assert isinstance(detail, dict)
error = detail["error"]
assert error["code"] == "insufficient_balance"
assert str(model_cost) in error["message"]
assert str(user_balance) in error["message"]
# Balance must be untouched
await integration_session.refresh(key)
assert key.balance == user_balance
assert key.reserved_balance == 0
# ---------------------------------------------------------------------------
# Test 2 — balance is zero
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pay_for_request_raises_402_on_zero_balance(
integration_session: AsyncSession,
) -> None:
"""User with zero balance cannot make any request."""
from routstr.auth import pay_for_request
key = _key(balance=0)
integration_session.add(key)
await integration_session.commit()
with pytest.raises(HTTPException) as exc_info:
await pay_for_request(key, 1_000, integration_session)
assert exc_info.value.status_code == 402
detail = exc_info.value.detail
assert isinstance(detail, dict)
assert detail["error"]["code"] == "insufficient_balance"
# ---------------------------------------------------------------------------
# Test 3 — all balance is reserved (total_balance = balance - reserved = 0)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pay_for_request_raises_402_when_all_balance_reserved(
integration_session: AsyncSession,
) -> None:
"""
User has 50_000 msats balance but 50_000 is already reserved for in-flight
requests. Free balance (total_balance) = 0. Should get 402.
"""
from routstr.auth import pay_for_request
key = _key(balance=50_000, reserved=50_000)
integration_session.add(key)
await integration_session.commit()
with pytest.raises(HTTPException) as exc_info:
await pay_for_request(key, 1_000, integration_session)
assert exc_info.value.status_code == 402
# Balance and reserved must be untouched
await integration_session.refresh(key)
assert key.balance == 50_000
assert key.reserved_balance == 50_000
# ---------------------------------------------------------------------------
# Test 4 — balance just one msat below model cost
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pay_for_request_raises_402_one_msat_short(
integration_session: AsyncSession,
) -> None:
"""Off-by-one: balance is exactly model_cost - 1."""
from routstr.auth import pay_for_request
model_cost = 10_000
key = _key(balance=model_cost - 1)
integration_session.add(key)
await integration_session.commit()
with pytest.raises(HTTPException) as exc_info:
await pay_for_request(key, model_cost, integration_session)
assert exc_info.value.status_code == 402
await integration_session.refresh(key)
assert key.balance == model_cost - 1 # untouched
assert key.reserved_balance == 0
# ---------------------------------------------------------------------------
# Test 5 — balance exactly equal to model cost → succeeds
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pay_for_request_succeeds_when_balance_equals_cost(
integration_session: AsyncSession,
) -> None:
"""Balance == model cost: the request should be reserved successfully."""
from routstr.auth import pay_for_request
model_cost = 10_000
key = _key(balance=model_cost)
integration_session.add(key)
await integration_session.commit()
# Should not raise
await pay_for_request(key, model_cost, integration_session)
await integration_session.refresh(key)
assert key.reserved_balance == model_cost
assert key.balance == model_cost # balance unchanged, only reserved goes up
# ---------------------------------------------------------------------------
# Test 6 — HTTP layer returns 402 JSON with the right shape
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_http_402_response_shape_on_insufficient_balance(
integration_client: AsyncClient,
integration_session: AsyncSession,
) -> None:
"""
End-to-end: POST /v1/chat/completions with a key whose balance is far below
the mocked model cost returns HTTP 402 with the expected JSON error body.
Matches exactly the log snippet in the bug report:
'Insufficient balance: X mSats required for this model. Y available.'
"""
from unittest.mock import AsyncMock, MagicMock
model_cost = 622_888
user_balance = 20_320
key = _key(balance=user_balance)
integration_session.add(key)
await integration_session.commit()
# Minimal model stub so proxy routing doesn't 400 before reaching balance check
mock_model = MagicMock()
mock_model.sats_pricing = None
# Upstream stub — never reached because balance check fires first
mock_upstream = MagicMock()
mock_upstream.prepare_headers = MagicMock(return_value={})
with (
patch("routstr.proxy.get_model_instance", return_value=mock_model),
patch("routstr.proxy.get_provider_for_model", return_value=[mock_upstream]),
# Patch where it is used (proxy imports it at module level)
patch(
"routstr.proxy.get_max_cost_for_model",
new=AsyncMock(return_value=model_cost),
),
):
response = await integration_client.post(
"/v1/chat/completions",
headers={"Authorization": f"Bearer sk-{key.hashed_key}"},
json={
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hello"}],
},
)
assert response.status_code == 402
body = response.json()
# FastAPI wraps HTTPException detail under "detail"
error = body["detail"]["error"]
assert error["code"] == "insufficient_balance"
assert error["type"] == "insufficient_quota"
assert str(model_cost) in error["message"]
assert str(user_balance) in error["message"]
# Balance must be completely untouched
await integration_session.refresh(key)
assert key.balance == user_balance
assert key.reserved_balance == 0
assert key.total_spent == 0

View File

@@ -0,0 +1,143 @@
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, cast
from unittest.mock import patch
import pytest
from routstr.core.db import AsyncSession, ModelRow, UpstreamProviderRow
from routstr.payment.models import Architecture, Model, Pricing
from routstr.proxy import refresh_model_maps
from routstr.upstream.base import BaseUpstreamProvider
@pytest.mark.integration
@pytest.mark.asyncio
async def test_enforce_lowest_provider_fee_for_same_url(
integration_session: Any,
) -> None:
"""Test that the algorithm selects the provider with the lowest fee when URLs match."""
# 1. Create two providers with the same URL but different fees
url = "https://api.example.com"
p1 = UpstreamProviderRow(
provider_type="custom",
base_url=url,
api_key="key1",
enabled=True,
provider_fee=1.01,
)
p2 = UpstreamProviderRow(
provider_type="custom",
base_url=url,
api_key="key2",
enabled=True,
provider_fee=1.05,
)
integration_session.add(p1)
integration_session.add(p2)
await integration_session.commit()
await integration_session.refresh(p1)
await integration_session.refresh(p2)
assert p1.id is not None
assert p2.id is not None
# 2. Add a model for each provider
m1 = ModelRow(
id="model-a",
name="Model A",
created=1,
description="desc",
context_length=100,
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
pricing='{"prompt": 1.0, "completion": 1.0}',
upstream_provider_id=p1.id,
enabled=True,
)
m2 = ModelRow(
id="model-a",
name="Model A",
created=1,
description="desc",
context_length=100,
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
pricing='{"prompt": 1.0, "completion": 1.0}',
upstream_provider_id=p2.id,
enabled=True,
)
integration_session.add(m1)
integration_session.add(m2)
await integration_session.commit()
# 3. Create mock provider instances
class MockProvider(BaseUpstreamProvider):
db_id: int
def __init__(self, db_id: int, base_url: str, api_key: str, fee: float):
super().__init__(base_url, api_key, fee)
self.db_id = db_id
self.provider_type = "custom"
def get_cached_models(self) -> list[Model]:
return [
Model(
id="model-a",
name="Model A",
created=1,
description="desc",
context_length=100,
architecture=Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="tiktoken",
instruct_type="chat",
),
pricing=Pricing(prompt=1.0, completion=1.0),
enabled=True,
upstream_provider_id=self.db_id,
)
]
async def refresh_models_cache(self) -> None:
pass
def prepare_headers(self, request_headers: dict[str, str]) -> dict[str, str]:
return request_headers
# 4. Inject mock providers into the proxy
from routstr import proxy
assert p1.id is not None
assert p2.id is not None
# Need to patch proxy._upstreams and proxy.create_session
mp1: MockProvider = MockProvider(p1.id, url, "key1", 1.01)
mp2: MockProvider = MockProvider(p2.id, url, "key2", 1.05)
with (
patch("routstr.proxy._upstreams", [mp1, mp2]),
patch("routstr.proxy.create_session") as mock_session_factory,
):
# Configure mock_session_factory to return a session that uses the test engine
@asynccontextmanager
async def mock_create_session() -> AsyncGenerator[AsyncSession, None]:
yield integration_session
mock_session_factory.return_value = mock_create_session()
await refresh_model_maps()
# 5. Check which provider is selected for 'model-a'
provider_map = proxy.get_provider_for_model("model-a")
# Assertions
assert provider_map is not None
assert len(provider_map) >= 1
# Check the first one, cast to MockProvider to access db_id
best_provider = cast(MockProvider, provider_map[0])
assert best_provider.db_id == p1.id
assert best_provider.provider_fee == 1.01

View File

@@ -3,12 +3,16 @@ Integration tests for provider management functionality.
Tests GET /v1/providers/ endpoint for listing and managing providers.
"""
import time
from types import TracebackType
from typing import Any, Generator
from unittest.mock import patch
import pytest
from httpx import AsyncClient
from routstr.core.admin import admin_sessions
from routstr.core.db import UpstreamProviderRow
from routstr.nostr.discovery import _PROVIDERS_CACHE
from .utils import ResponseValidator
@@ -678,3 +682,88 @@ async def test_no_database_changes_during_provider_operations(
assert final_diff["api_keys"]["added"] == []
assert final_diff["api_keys"]["modified"] == []
assert final_diff["api_keys"]["removed"] == []
@pytest.mark.integration
@pytest.mark.asyncio
async def test_admin_routstr_topup_retries_transient_upstream_failure(
integration_client: AsyncClient,
integration_session: Any,
) -> None:
admin_token = "test-admin-token"
admin_sessions[admin_token] = int(time.time()) + 3600
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
provider = UpstreamProviderRow(
provider_type="routstr",
base_url="https://node.example",
api_key="sk-upstream-test",
enabled=True,
provider_fee=1.01,
)
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
class MockResponse:
def __init__(self, status_code: int, data: dict[str, Any] | None = None):
self.status_code = status_code
self._data = data or {}
self.text = str(self._data)
def json(self) -> dict[str, Any]:
return self._data
class MockAsyncClient:
def __init__(self) -> None:
self.calls = 0
async def __aenter__(self) -> "MockAsyncClient":
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
return None
async def post(
self, url: str, json: dict[str, Any], headers: dict[str, str]
) -> MockResponse:
self.calls += 1
assert url == "https://node.example/v1/balance/lightning/invoice"
assert json["amount_sats"] == 10
assert json["purpose"] == "topup"
assert json["api_key"] == "sk-upstream-test"
assert headers["Authorization"] == "Bearer sk-upstream-test"
if self.calls == 1:
return MockResponse(500, {"detail": "warmup failure"})
return MockResponse(
200,
{
"bolt11": "lnbc1testinvoice",
"invoice_id": "invoice-123",
},
)
mock_client = MockAsyncClient()
try:
with patch("httpx.AsyncClient", return_value=mock_client):
response = await integration_client.post(
f"/admin/api/upstream-providers/{provider.id}/topup",
json={"amount": 10},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["topup_data"]["payment_request"] == "lnbc1testinvoice"
assert data["topup_data"]["invoice_id"] == "invoice-123"
assert mock_client.calls == 2
finally:
admin_sessions.pop(admin_token, None)

View File

@@ -171,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
@@ -550,9 +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_response_format_preservation(

View File

@@ -0,0 +1,268 @@
"""
Tests for the reservation lifecycle:
1. Reserve → reserved_balance increases, available (total_balance) decreases.
2. Reserve → revert → reserved_balance restored, balance untouched.
3. Reserve → finalise → reserved_balance released, balance charged.
4. Two parallel reserves, only one fits → second blocked with 402.
5. Three parallel reserves, two fit, third blocked with 402.
6. Sequential reserves until balance exhausted → next request blocked.
Reservation invariant enforced by the atomic WHERE clause in pay_for_request:
balance - reserved_balance >= cost_per_request
"""
import asyncio
import uuid
import pytest
from fastapi import HTTPException
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import pay_for_request, revert_pay_for_request
from routstr.core.db import ApiKey, create_session
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_key(balance: int, reserved: int = 0) -> ApiKey:
return ApiKey(
hashed_key=f"test_{uuid.uuid4().hex}",
balance=balance,
reserved_balance=reserved,
total_spent=0,
total_requests=0,
)
async def _persist(session: AsyncSession, key: ApiKey) -> ApiKey:
session.add(key)
await session.commit()
await session.refresh(key)
return key
# ---------------------------------------------------------------------------
# Test 1 — Reserve: reserved_balance increases, available balance decreases
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_reserve_increases_reserved_balance(
integration_session: AsyncSession,
) -> None:
"""pay_for_request must increment reserved_balance by cost_per_request."""
cost = 100
key = await _persist(integration_session, _make_key(balance=500))
await pay_for_request(key, cost, integration_session)
await integration_session.refresh(key)
assert key.reserved_balance == cost
assert key.balance == 500 # balance column is NOT decremented on reserve
assert key.total_balance == 500 - cost # available = balance - reserved
# ---------------------------------------------------------------------------
# Test 2 — Revert: reserved_balance restored, balance untouched
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_revert_releases_reservation(
integration_session: AsyncSession,
) -> None:
"""revert_pay_for_request must release the reservation without touching balance."""
cost = 150
key = await _persist(integration_session, _make_key(balance=300))
await pay_for_request(key, cost, integration_session)
await integration_session.refresh(key)
assert key.reserved_balance == cost
await revert_pay_for_request(key, integration_session, cost)
await integration_session.refresh(key)
assert key.reserved_balance == 0
assert key.balance == 300 # balance unchanged after revert
# ---------------------------------------------------------------------------
# Test 3 — Finalise: reservation released + balance charged
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_finalise_releases_reservation_and_charges_balance(
integration_session: AsyncSession,
) -> None:
"""adjust_payment_for_tokens must zero reserved_balance and deduct actual cost."""
from unittest.mock import patch
from routstr.auth import adjust_payment_for_tokens
from routstr.payment.cost_calculation import CostData
cost = 100
actual = 80 # actual < reserved → refund path
key = await _persist(integration_session, _make_key(balance=500))
await pay_for_request(key, cost, integration_session)
await integration_session.refresh(key)
assert key.reserved_balance == cost
cost_data = CostData(
base_msats=0,
input_msats=40,
output_msats=40,
total_msats=actual,
total_usd=0.0,
input_tokens=50,
output_tokens=50,
)
response_data = {"model": "test-model", "usage": {"prompt_tokens": 50, "completion_tokens": 50}}
with patch("routstr.auth.calculate_cost", return_value=cost_data):
await adjust_payment_for_tokens(key, response_data, integration_session, cost)
await integration_session.refresh(key)
assert key.reserved_balance == 0
assert key.balance == 500 - actual
assert key.total_spent == actual
# ---------------------------------------------------------------------------
# Test 4 — Concurrent: second parallel reserve blocked when balance exhausted
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_concurrent_second_reserve_blocked_when_balance_exhausted(
patched_db_engine: None,
) -> None:
"""When two requests race for the same balance, only one succeeds; the other gets 402."""
cost = 300
key_hash = f"test_concurrent_{uuid.uuid4().hex}"
async with create_session() as session:
key = ApiKey(
hashed_key=key_hash,
balance=300, # exactly enough for ONE reservation
reserved_balance=0,
total_spent=0,
total_requests=0,
)
session.add(key)
await session.commit()
results: list[str] = []
async def attempt_reserve() -> None:
async with create_session() as session:
fresh_key = await session.get(ApiKey, key_hash)
assert fresh_key is not None
try:
await pay_for_request(fresh_key, cost, session)
results.append("success")
except HTTPException as exc:
assert exc.status_code == 402
results.append("blocked")
await asyncio.gather(attempt_reserve(), attempt_reserve())
assert sorted(results) == ["blocked", "success"], (
f"Expected exactly one success and one 402, got: {results}"
)
async with create_session() as session:
final = await session.get(ApiKey, key_hash)
assert final is not None
# reserved_balance must equal exactly one reservation (not two)
assert final.reserved_balance == cost, (
f"Expected reserved_balance={cost}, got {final.reserved_balance}"
)
assert final.balance == 300, "Balance column must not be modified by reservation"
# ---------------------------------------------------------------------------
# Test 5 — Concurrent: three requests, two fit, third blocked
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_three_parallel_reserves_third_blocked(
patched_db_engine: None,
) -> None:
"""Balance covers two reservations exactly; the third concurrent request must be blocked."""
cost = 100
key_hash = f"test_three_parallel_{uuid.uuid4().hex}"
async with create_session() as session:
key = ApiKey(
hashed_key=key_hash,
balance=200, # fits exactly 2 reservations of 100
reserved_balance=0,
total_spent=0,
total_requests=0,
)
session.add(key)
await session.commit()
results: list[str] = []
async def attempt_reserve() -> None:
async with create_session() as session:
fresh_key = await session.get(ApiKey, key_hash)
assert fresh_key is not None
try:
await pay_for_request(fresh_key, cost, session)
results.append("success")
except HTTPException as exc:
assert exc.status_code == 402
results.append("blocked")
await asyncio.gather(
attempt_reserve(),
attempt_reserve(),
attempt_reserve(),
)
successes = results.count("success")
blocked = results.count("blocked")
assert successes == 2, f"Expected 2 successes, got {successes}: {results}"
assert blocked == 1, f"Expected 1 blocked, got {blocked}: {results}"
async with create_session() as session:
final = await session.get(ApiKey, key_hash)
assert final is not None
assert final.reserved_balance == cost * 2, (
f"Expected reserved_balance={cost * 2}, got {final.reserved_balance}"
)
assert final.balance == 200, "Balance column must not be modified by reservation"
# ---------------------------------------------------------------------------
# Test 6 — Sequential exhaustion: reserve until empty, next request blocked
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_sequential_reserves_block_when_balance_exhausted(
integration_session: AsyncSession,
) -> None:
"""Repeated reservations should block as soon as available balance drops below cost."""
cost = 100
key = await _persist(integration_session, _make_key(balance=250))
# First two succeed (100 + 100 = 200 ≤ 250)
await pay_for_request(key, cost, integration_session)
await pay_for_request(key, cost, integration_session)
await integration_session.refresh(key)
assert key.reserved_balance == 200
assert key.total_balance == 50 # 250 - 200
# Third: only 50 available, need 100 → blocked
with pytest.raises(HTTPException) as exc_info:
await pay_for_request(key, cost, integration_session)
assert exc_info.value.status_code == 402
await integration_session.refresh(key)
assert key.reserved_balance == 200 # unchanged after failed reserve

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

@@ -13,7 +13,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import ApiKey, CashuTransaction
@pytest.mark.integration
@@ -356,6 +356,70 @@ async def test_concurrent_refund_requests(
assert len(successful) + len(failed) == 5
@pytest.mark.integration
@pytest.mark.asyncio
async def test_refund_rejects_concurrent_topup_on_same_key(
authenticated_client: AsyncClient,
testmint_wallet: Any,
) -> None:
"""Test refund returns 409 when a concurrent topup changes the balance first."""
from routstr import balance as balance_module
wallet_response = await authenticated_client.get("/v1/wallet/")
assert wallet_response.status_code == 200
initial_balance = wallet_response.json()["balance"]
topup_amount_sat = 500
topup_token = await testmint_wallet.mint_tokens(topup_amount_sat)
validate_called = asyncio.Event()
allow_refund_to_continue = asyncio.Event()
original_validate_bearer_key = balance_module.validate_bearer_key
delayed_once = False
async def delayed_validate_bearer_key(*args: Any, **kwargs: Any) -> ApiKey:
nonlocal delayed_once
key = await original_validate_bearer_key(*args, **kwargs)
if not delayed_once:
delayed_once = True
validate_called.set()
await allow_refund_to_continue.wait()
return key
async def issue_refund() -> Any:
return await authenticated_client.post("/v1/wallet/refund")
async def issue_topup() -> Any:
await validate_called.wait()
try:
return await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": topup_token}
)
finally:
allow_refund_to_continue.set()
with patch(
"routstr.balance.validate_bearer_key", new=delayed_validate_bearer_key
):
refund_response, topup_response = await asyncio.gather(
issue_refund(), issue_topup()
)
assert topup_response.status_code == 200
assert topup_response.json()["msats"] == topup_amount_sat * 1000
assert refund_response.status_code == 409
assert (
refund_response.json()["detail"]
== "Balance changed concurrently. Please retry the refund."
)
final_balance_response = await authenticated_client.get("/v1/wallet/")
assert final_balance_response.status_code == 200
assert final_balance_response.json()["balance"] == (
initial_balance + topup_amount_sat * 1000
)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_refund_during_active_usage(
@@ -394,6 +458,42 @@ async def test_refund_during_active_usage(
assert response.json()["balance"] == 0
@pytest.mark.integration
@pytest.mark.asyncio
async def test_wallet_history_returns_apikey_transactions(
authenticated_client: AsyncClient,
testmint_wallet: Any,
integration_session: Any,
) -> None:
wallet_response = await authenticated_client.get("/v1/wallet/")
api_key = wallet_response.json()["api_key"]
hashed_key = api_key[3:] if api_key.startswith("sk-") else api_key
topup_token = await testmint_wallet.mint_tokens(250)
topup_response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": topup_token}
)
assert topup_response.status_code == 200
refund_response = await authenticated_client.post("/v1/wallet/refund")
assert refund_response.status_code == 200
history_response = await authenticated_client.get("/v1/wallet/history")
assert history_response.status_code == 200
transactions = history_response.json()["transactions"]
assert len(transactions) >= 2
assert all("api_key_hashed_key" not in tx for tx in transactions)
assert {tx["type"] for tx in transactions} >= {"in", "out"}
db_result = await integration_session.execute(
select(CashuTransaction).where(
CashuTransaction.api_key_hashed_key == hashed_key
)
)
db_transactions = db_result.scalars().all()
assert len(db_transactions) >= 2
@pytest.mark.integration
@pytest.mark.asyncio
async def test_mint_unavailability_handling(

View File

@@ -11,7 +11,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import ApiKey, CashuTransaction
from .utils import (
CashuTokenGenerator,
@@ -71,6 +71,16 @@ async def test_topup_with_valid_token( # type: ignore[no-untyped-def]
assert db_key.balance == new_balance
assert db_key.balance == initial_balance + (topup_amount * 1000)
tx_result = await integration_session.execute(
select(CashuTransaction).where(
CashuTransaction.token == token,
CashuTransaction.type == "in",
)
)
tx = tx_result.scalar_one()
assert tx.api_key_hashed_key == hashed_key
assert tx.source == "apikey"
@pytest.mark.integration
@pytest.mark.asyncio

View File

@@ -1,14 +1,18 @@
"""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,
)
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
@@ -45,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
@@ -99,3 +113,80 @@ def test_get_provider_penalty_openrouter() -> None:
provider = create_test_provider("openrouter", "https://openrouter.ai/api/v1")
penalty = get_provider_penalty(provider)
assert penalty == 1.001
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(),
)
assert "azure/gpt-4o" in model_instances
assert provider_map["azure/gpt-4o"] == [provider]
assert "gpt-4o" in unique_models
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",
)
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(),
)
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

357
tests/unit/test_balance.py Normal file
View File

@@ -0,0 +1,357 @@
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.responses import JSONResponse
from routstr.balance import refund_wallet_endpoint
from routstr.core.db import ApiKey, CashuTransaction
from routstr.wallet import credit_balance
def _make_cashu_tx(
token: str,
amount: int,
unit: str,
type: str = "out",
request_id: str | None = "req-abc",
swept: bool = False,
collected: bool = False,
) -> CashuTransaction:
tx = CashuTransaction(token=token, amount=amount, unit=unit, type=type, request_id=request_id)
tx.swept = swept
tx.collected = collected
return tx
def _exec_result(tx: CashuTransaction | None) -> MagicMock:
result = MagicMock()
result.first.return_value = tx
return result
def _update_result(rowcount: int) -> MagicMock:
result = MagicMock()
result.rowcount = rowcount
return result
@pytest.mark.asyncio
async def test_refund_x_cashu_returns_token() -> None:
x_cashu_token = "cashuAtest_token_value"
in_tx = _make_cashu_tx(token=x_cashu_token, amount=0, unit="msat", type="in", request_id="req-abc")
out_tx = _make_cashu_tx(token="cashuArefund_token", amount=1000, unit="msat", type="out", request_id="req-abc")
session = MagicMock()
session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)])
session.add = MagicMock()
session.commit = AsyncMock()
result = await refund_wallet_endpoint(
authorization="Bearer sk-somekey",
x_cashu=x_cashu_token,
session=session,
)
assert isinstance(result, JSONResponse)
body = json.loads(result.body)
assert body["token"] == "cashuArefund_token"
assert body["msats"] == "1000"
assert result.headers["X-Cashu"] == "cashuArefund_token"
assert out_tx.collected is True
@pytest.mark.asyncio
async def test_refund_x_cashu_sat_unit() -> None:
x_cashu_token = "cashuAsat_token"
in_tx = _make_cashu_tx(token=x_cashu_token, amount=0, unit="sat", type="in", request_id="req-sat")
out_tx = _make_cashu_tx(token="cashuArefund_sat", amount=500, unit="sat", type="out", request_id="req-sat")
session = MagicMock()
session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)])
session.add = MagicMock()
session.commit = AsyncMock()
result = await refund_wallet_endpoint(
authorization="Bearer sk-somekey",
x_cashu=x_cashu_token,
session=session,
)
assert isinstance(result, JSONResponse)
body = json.loads(result.body)
assert body["token"] == "cashuArefund_sat"
assert body["sats"] == "500"
assert "msats" not in body
assert result.headers["X-Cashu"] == "cashuArefund_sat"
@pytest.mark.asyncio
async def test_refund_x_cashu_not_found_raises_404() -> None:
from fastapi import HTTPException
session = MagicMock()
session.exec = AsyncMock(return_value=_exec_result(None))
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer sk-somekey",
x_cashu="cashuAmissing_token",
session=session,
)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_refund_x_cashu_swept_raises_410() -> None:
from fastapi import HTTPException
in_tx = _make_cashu_tx(token="cashuAswept_token", amount=0, unit="msat", type="in", request_id="req-swept")
out_tx = _make_cashu_tx(token="cashuAswept", amount=100, unit="msat", type="out", request_id="req-swept", swept=True)
session = MagicMock()
session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)])
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer sk-somekey",
x_cashu="cashuAswept_token",
session=session,
)
assert exc_info.value.status_code == 410
# ---------------------------------------------------------------------------
# source field defaults
# ---------------------------------------------------------------------------
def test_cashu_transaction_source_defaults_to_x_cashu() -> None:
tx = CashuTransaction(token="cashuAtest", amount=100, unit="msat")
assert tx.source == "x-cashu"
def test_cashu_transaction_source_can_be_apikey() -> None:
tx = CashuTransaction(token="cashuAtest", amount=100, unit="msat", source="apikey")
assert tx.source == "apikey"
# ---------------------------------------------------------------------------
# apikey-based refund: token logging and CashuTransaction storage
# ---------------------------------------------------------------------------
def _make_api_key(
balance: int = 5000,
refund_currency: str | None = "sat",
refund_mint_url: str | None = "https://mint.example.com",
refund_address: str | None = None,
parent_key_hash: str | None = None,
) -> ApiKey:
key = ApiKey(hashed_key="testhash")
key.balance = balance
key.reserved_balance = 0
key.refund_currency = refund_currency
key.refund_mint_url = refund_mint_url
key.refund_address = refund_address
key.parent_key_hash = parent_key_hash
key.total_spent = 0
key.total_requests = 0
return key
@pytest.mark.asyncio
async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> None:
key = _make_api_key(balance=5000, refund_currency="sat")
refund_token = "cashuArefund_apikey_token"
session = MagicMock()
session.exec = AsyncMock(return_value=_update_result(1))
session.add = MagicMock()
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()) as mock_store,
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
):
result = await refund_wallet_endpoint(
authorization="Bearer sk-testhash",
x_cashu=None,
session=session,
)
assert isinstance(result, dict)
assert result["token"] == refund_token
mock_store.assert_awaited_once()
call_kwargs = mock_store.call_args.kwargs
assert call_kwargs["source"] == "apikey"
assert call_kwargs["token"] == refund_token
assert call_kwargs["typ"] == "out"
assert call_kwargs["api_key_hashed_key"] == key.hashed_key
@pytest.mark.asyncio
async def test_apikey_refund_logs_token() -> None:
key = _make_api_key(balance=5000, refund_currency="sat")
refund_token = "cashuAlogged_token"
session = MagicMock()
session.exec = AsyncMock(return_value=_update_result(1))
session.add = MagicMock()
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
patch("routstr.balance.logger") as mock_logger,
):
await refund_wallet_endpoint(
authorization="Bearer sk-testhash",
x_cashu=None,
session=session,
)
calls = [str(c) for c in mock_logger.info.call_args_list]
assert any("cashu token issued" in c for c in calls)
@pytest.mark.asyncio
async def test_apikey_refund_log_includes_path() -> None:
key = _make_api_key(balance=5000, refund_currency="sat")
refund_token = "cashuApath_token"
session = MagicMock()
session.exec = AsyncMock(return_value=_update_result(1))
session.add = MagicMock()
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
patch("routstr.balance.logger") as mock_logger,
):
await refund_wallet_endpoint(
authorization="Bearer sk-testhash",
x_cashu=None,
session=session,
)
# Find the "cashu token issued" call and verify extra contains the path
token_issued_calls = [
c for c in mock_logger.info.call_args_list
if c.args and "cashu token issued" in c.args[0]
]
assert len(token_issued_calls) == 1
extra = token_issued_calls[0].kwargs.get("extra", {})
assert extra.get("path") == "/v1/wallet/refund"
@pytest.mark.asyncio
async def test_apikey_refund_rejects_on_concurrent_balance_change() -> None:
"""When the debit CAS fails (rowcount=0), no token is minted and 409 is returned."""
from fastapi import HTTPException
key = _make_api_key(balance=5000, refund_currency="sat")
session = MagicMock()
# Debit returns rowcount=0 → balance changed concurrently
session.exec = AsyncMock(return_value=_update_result(0))
session.commit = AsyncMock()
mock_send_token = AsyncMock(return_value="cashuAshould_not_be_minted")
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", mock_send_token),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
):
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer sk-testhash",
x_cashu=None,
session=session,
)
assert exc_info.value.status_code == 409
# Crucially: send_token must NOT have been called
mock_send_token.assert_not_awaited()
@pytest.mark.asyncio
async def test_credit_balance_stores_apikey_transaction_history() -> None:
key = _make_api_key(balance=1000)
session = MagicMock()
session.exec = AsyncMock(return_value=_update_result(1))
session.commit = AsyncMock()
session.refresh = AsyncMock()
with (
patch(
"routstr.wallet.recieve_token",
AsyncMock(return_value=(100, "sat", "https://mint.example")),
),
patch("routstr.wallet.store_cashu_transaction", AsyncMock()) as mock_store,
):
amount = await credit_balance("cashuAtopup_token", key, session)
assert amount == 100_000
mock_store.assert_awaited_once()
call_kwargs = mock_store.call_args.kwargs
assert call_kwargs["typ"] == "in"
assert call_kwargs["source"] == "apikey"
assert call_kwargs["api_key_hashed_key"] == key.hashed_key
assert call_kwargs["amount"] == 100
assert call_kwargs["unit"] == "sat"
assert call_kwargs["token"] == "cashuAtopup_token"
assert call_kwargs["mint_url"] == "https://mint.example"
@pytest.mark.asyncio
async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
"""When debit succeeds but minting fails, balance must be restored."""
from fastapi import HTTPException
key = _make_api_key(balance=5000, refund_currency="sat")
# First exec call = debit (succeeds), second = restore
session = MagicMock()
session.exec = AsyncMock(side_effect=[_update_result(1), _update_result(1)])
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(side_effect=Exception("mint down"))),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
patch("routstr.balance.logger"),
):
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer sk-testhash",
x_cashu=None,
session=session,
)
assert exc_info.value.status_code == 503
# Verify two exec calls: debit + restore
assert session.exec.await_count == 2

View File

@@ -0,0 +1,267 @@
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from routstr.nostr import analytics
def test_aggregate_top_model_usage_sums_metrics() -> None:
model_usage_mix = {
"top_models": ["openai/gpt-4o", "anthropic/claude-3.5-sonnet"],
"metrics": [
{
"model_counts": {
"openai/gpt-4o": 4,
"anthropic/claude-3.5-sonnet": 2,
},
"model_revenue_msats": {
"openai/gpt-4o": 1500,
"anthropic/claude-3.5-sonnet": 700,
},
"model_tokens": {
"openai/gpt-4o": 1200,
"anthropic/claude-3.5-sonnet": 600,
},
"others": 1,
"others_revenue_msats": 300,
"others_tokens": 200,
},
{
"model_counts": {
"openai/gpt-4o": 3,
"anthropic/claude-3.5-sonnet": 1,
},
"model_revenue_msats": {
"openai/gpt-4o": 1000,
"anthropic/claude-3.5-sonnet": 500,
},
"model_tokens": {
"openai/gpt-4o": 800,
"anthropic/claude-3.5-sonnet": 300,
},
"others": 2,
"others_revenue_msats": 450,
"others_tokens": 350,
},
],
}
rows, others = analytics._aggregate_top_model_usage(model_usage_mix)
assert rows == [
{
"model": "openai/gpt-4o",
"successful_requests": 7,
"revenue_msats": 2500.0,
"total_tokens": 2000,
},
{
"model": "anthropic/claude-3.5-sonnet",
"successful_requests": 3,
"revenue_msats": 1200.0,
"total_tokens": 900,
},
]
assert others == {
"successful_requests": 3,
"revenue_msats": 750.0,
"total_tokens": 550,
}
def test_build_stats_snapshot_payload_schema_and_shape(monkeypatch: Any) -> None:
seen_windows: set[tuple[int, int]] = set()
def fake_usage_dashboard(
*, interval: int, hours: int, error_limit: int, model_limit: int
) -> dict[str, Any]:
seen_windows.add((hours, interval))
assert error_limit == 1
assert model_limit == 20
return {
"summary": {
"total_requests": hours,
"successful_chat_completions": max(1, hours - 1),
"failed_requests": 2,
"success_rate": 90.0,
"unique_models_count": 2,
"input_tokens": 2000,
"output_tokens": 1000,
"total_tokens": 3000,
"revenue_msats": 9000.0,
"refunds_msats": 1000.0,
"net_revenue_msats": 8000.0,
"revenue_sats": 9.0,
"refunds_sats": 1.0,
"net_revenue_sats": 8.0,
},
"model_usage_mix": {
"top_models": ["openai/gpt-4o"],
"metrics": [
{
"timestamp": "2026-03-02 10:00:00",
"model_counts": {"openai/gpt-4o": hours},
"model_revenue_msats": {"openai/gpt-4o": float(hours * 100)},
"model_tokens": {"openai/gpt-4o": hours * 10},
"others": 4,
"others_revenue_msats": 1800.0,
"others_tokens": 400,
}
],
},
}
monkeypatch.setattr(
analytics.log_manager, "get_usage_dashboard", fake_usage_dashboard
)
monkeypatch.setattr(analytics.settings, "npub", "npub1example")
monkeypatch.setattr(analytics.settings, "http_url", "https://node.example.com")
monkeypatch.setattr(analytics.settings, "onion_url", "")
payload = analytics.build_stats_snapshot_payload(
"provider123",
public_key_hex="ab" * 32,
generated_at=1772451600,
)
assert payload["schema"] == analytics.ANALYTICS_SCHEMA
assert payload["provider_id"] == "provider123"
assert payload["window_hours"] == 24
assert payload["interval_minutes"] == 60
assert payload["endpoint_urls"] == ["https://node.example.com"]
assert seen_windows == {
(24, 60),
(7 * 24, 6 * 60),
(30 * 24, 24 * 60),
(90 * 24, 24 * 60),
(365 * 24, 7 * 24 * 60),
}
assert set(payload["windows"].keys()) == {"24h", "7d", "30d", "3m", "1y"}
assert payload["windows"]["1y"]["interval_minutes"] == 7 * 24 * 60
assert payload["summary"]["total_requests"] == 24
assert payload["top_model_usage"] == [
{
"model": "openai/gpt-4o",
"successful_requests": 24,
"revenue_msats": 2400.0,
"total_tokens": 240,
}
]
assert payload["others_usage"] == {
"successful_requests": 4,
"revenue_msats": 1800.0,
"total_tokens": 400,
}
def test_create_stats_snapshot_event_tags() -> None:
private_key_hex = "11" * 32
event = analytics.create_stats_snapshot_event(
private_key_hex,
"provider123",
payload_json='{"schema":"routstr.analytics.snapshot.v1"}',
d_tag="provider123:stats",
)
tags = event["tags"]
assert ["d", "provider123:stats"] in tags
assert ["provider", "provider123"] in tags
assert ["schema", analytics.ANALYTICS_SCHEMA] in tags
assert all(tag[0] != "period" for tag in tags)
def test_fingerprint_payload_ignores_generated_at() -> None:
a = {"schema": analytics.ANALYTICS_SCHEMA, "generated_at": 1000, "summary": {"x": 1}}
b = {"schema": analytics.ANALYTICS_SCHEMA, "generated_at": 2000, "summary": {"x": 1}}
assert analytics._fingerprint_payload(a) == analytics._fingerprint_payload(b)
@pytest.mark.asyncio
async def test_publish_usage_analytics_skips_when_disabled(monkeypatch: Any) -> None:
delays: list[int] = []
async def fake_sleep(seconds: int) -> None:
delays.append(seconds)
raise asyncio.CancelledError()
def fail_build(*args: Any, **kwargs: Any) -> dict[str, Any]:
raise AssertionError("build_stats_snapshot_payload should not be called")
monkeypatch.setattr(analytics.settings, "enable_analytics_sharing", False)
monkeypatch.setattr(analytics, "build_stats_snapshot_payload", fail_build)
monkeypatch.setattr(analytics.asyncio, "sleep", fake_sleep)
await analytics.publish_usage_analytics()
assert delays == [analytics.DISABLED_POLL_SECONDS]
@pytest.mark.asyncio
async def test_publish_usage_analytics_skips_without_nsec(monkeypatch: Any) -> None:
delays: list[int] = []
async def fake_sleep(seconds: int) -> None:
delays.append(seconds)
raise asyncio.CancelledError()
def fail_build(*args: Any, **kwargs: Any) -> dict[str, Any]:
raise AssertionError("build_stats_snapshot_payload should not be called")
monkeypatch.setattr(analytics.settings, "enable_analytics_sharing", True)
monkeypatch.setattr(analytics.settings, "nsec", "")
monkeypatch.setattr(analytics, "build_stats_snapshot_payload", fail_build)
monkeypatch.setattr(analytics.asyncio, "sleep", fake_sleep)
await analytics.publish_usage_analytics()
assert delays == [analytics.DISABLED_POLL_SECONDS]
@pytest.mark.asyncio
async def test_publish_usage_analytics_dedupes_unchanged_payload(monkeypatch: Any) -> None:
published_events: list[dict[str, Any]] = []
sleep_calls = 0
async def fake_sleep(seconds: int) -> None:
nonlocal sleep_calls
sleep_calls += 1
if sleep_calls >= 2:
raise asyncio.CancelledError()
def fake_build_payload(
provider_id: str,
*,
public_key_hex: str,
generated_at: int,
window_hours: int = 24,
interval_minutes: int = 60,
model_limit: int = 10,
) -> dict[str, Any]:
_ = (public_key_hex, generated_at, window_hours, interval_minutes, model_limit)
return {
"schema": analytics.ANALYTICS_SCHEMA,
"generated_at": generated_at,
"provider_id": provider_id,
"summary": {"total_requests": 1},
}
async def fake_publish(relay_url: str, event: dict[str, Any]) -> bool:
_ = relay_url
published_events.append(event)
return True
monkeypatch.setattr(analytics.settings, "enable_analytics_sharing", True)
monkeypatch.setattr(analytics.settings, "nsec", "11" * 32)
monkeypatch.setattr(analytics.settings, "relays", ["wss://relay.example.com"])
monkeypatch.setattr(analytics.settings, "provider_id", "")
monkeypatch.setattr(analytics, "build_stats_snapshot_payload", fake_build_payload)
monkeypatch.setattr(analytics, "publish_to_relay", fake_publish)
monkeypatch.setattr(analytics.asyncio, "sleep", fake_sleep)
await analytics.publish_usage_analytics()
assert len(published_events) == 1
assert ["schema", analytics.ANALYTICS_SCHEMA] in published_events[0].get("tags", [])

View File

@@ -2,6 +2,7 @@ import os
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import text
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.settings import SettingsService
@@ -11,6 +12,7 @@ from routstr.core.settings import SettingsService
async def test_settings_seed_from_env_and_persist() -> None:
os.environ["UPSTREAM_BASE_URL"] = "https://api.test/v1"
os.environ.pop("ONION_URL", None)
os.environ.pop("ENABLE_ANALYTICS_SHARING", None)
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with AsyncSession(engine, expire_on_commit=False) as session:
@@ -19,19 +21,53 @@ async def test_settings_seed_from_env_and_persist() -> None:
assert settings.upstream_base_url == "https://api.test/v1"
# ONION_URL may be empty if not discoverable
assert isinstance(settings.onion_url, str)
assert settings.enable_analytics_sharing is True
@pytest.mark.asyncio
async def test_settings_db_precedence_over_env() -> None:
os.environ["UPSTREAM_BASE_URL"] = "https://api.env/v1"
os.environ["ENABLE_ANALYTICS_SHARING"] = "true"
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with AsyncSession(engine, expire_on_commit=False) as session:
_ = await SettingsService.initialize(session)
updated = await SettingsService.update({"name": "DBName"}, session)
updated = await SettingsService.update(
{"name": "DBName", "enable_analytics_sharing": False}, session
)
assert updated.name == "DBName"
assert updated.enable_analytics_sharing is False
# Change env and re-initialize; DB should still win
os.environ["NAME"] = "EnvName"
os.environ["ENABLE_ANALYTICS_SHARING"] = "true"
again = await SettingsService.initialize(session)
assert again.name == "DBName"
assert again.enable_analytics_sharing is False
@pytest.mark.asyncio
async def test_settings_initialize_discards_unknown_keys() -> None:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with AsyncSession(engine, expire_on_commit=False) as session:
_ = await SettingsService.initialize(session)
# Simulate older persisted key name and an unknown key.
await session.exec( # type: ignore
text(
"UPDATE settings SET data = :data WHERE id = 1"
).bindparams(
data='{"name":"LegacyNode","nostr_analytics_enabled":false,"unknown_key":123}'
)
)
await session.commit()
reloaded = await SettingsService.initialize(session)
assert reloaded.name == "LegacyNode"
assert reloaded.enable_analytics_sharing is True
row = await session.exec(text("SELECT data FROM settings WHERE id = 1")) # type: ignore
stored_data = row.first()[0]
assert '"enable_analytics_sharing": true' in stored_data
assert "nostr_analytics_enabled" not in stored_data
assert "unknown_key" not in stored_data

View File

@@ -0,0 +1,109 @@
import json
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock
import pytest
from routstr.core.db import ApiKey
from routstr.upstream.base import BaseUpstreamProvider
@pytest.mark.asyncio
async def test_stream_with_id_injection() -> None:
"""Test that stream_with_cost correctly injects IDs into complete JSON chunks but skips partials."""
provider = BaseUpstreamProvider(
base_url="https://api.example.com", api_key="test_key"
)
# Mock response with mixed chunks:
# 1. Complete JSON without ID
# 2. Partial JSON (should be passed through)
# 3. Complete JSON with ID (should be preserved or updated if requested_model is set)
# 4. [DONE] message
chunks = [
b'data: {"choices": [{"delta": {"content": "Hello"}}]}\n\n',
b'data: {"choices": [{"delta": {"content": "', # Partial
b'world"}}]}\n\n',
b'data: {"id": "existing-id", "choices": [{"delta": {"content": "!"}}]}\n\n',
b"data: [DONE]\n\n",
]
async def aiter_bytes() -> AsyncGenerator[bytes, None]:
for chunk in chunks:
yield chunk
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "text/event-stream"}
mock_response.aiter_bytes = aiter_bytes
key = MagicMock(spec=ApiKey)
key.hashed_key = "test_hash"
key.balance = 1000
background_tasks = MagicMock()
# We need to mock adjust_payment_for_tokens since it's called at the end
with MagicMock():
from routstr.upstream import base
# Mocking the module-level function used in the generator
base.adjust_payment_for_tokens = AsyncMock(
return_value={"total_usd": 0.1, "total_msats": 100}
)
base.create_session = MagicMock()
streaming_response = await provider.handle_streaming_chat_completion(
response=mock_response,
key=key,
max_cost_for_model=100,
background_tasks=background_tasks,
requested_model="test-model",
)
results = []
async for chunk in streaming_response.body_iterator:
results.append(chunk)
# Parse results
parsed_results = []
for r in results:
if isinstance(r, bytes) and r.startswith(b"data: "):
data = r[6:].decode().strip()
if data == "[DONE]":
parsed_results.append(data)
else:
try:
parsed_results.append(json.loads(data))
except (json.JSONDecodeError, UnicodeDecodeError):
parsed_results.append(
data
) # Keep as string if it failed to parse
# Verifications
# 1. First chunk should have an injected ID and the requested model
assert isinstance(parsed_results[0], dict)
assert "id" in parsed_results[0]
assert parsed_results[0]["id"].startswith("chatcmpl-")
assert parsed_results[0]["model"] == "test-model"
# 2. Second chunk was partial, should be passed as-is
# In current implementation, re.split(b"data: ", b'data: {...') gives ['', '{...']
# The first empty part is skipped. The second part is processed.
# Check that we have results
assert len(parsed_results) >= 4
# Find the chunk that was "existing-id"
id_chunk = next(
r
for r in parsed_results
if isinstance(r, dict)
and "choices" in r
and r["choices"][0]["delta"].get("content") == "!"
)
assert id_chunk["id"] == parsed_results[0]["id"]
assert id_chunk["model"] == "test-model"
# 4. [DONE] should be there
assert "[DONE]" in parsed_results

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

@@ -0,0 +1,76 @@
from types import TracebackType
from unittest.mock import Mock
import httpx
import pytest
from routstr.upstream.routstr import RoutstrUpstreamProvider
class DummyAsyncClient:
def __init__(self, response: Mock | None = None, error: Exception | None = None):
self.response = response
self.error = error
self.calls: list[dict[str, object]] = []
async def __aenter__(self) -> "DummyAsyncClient":
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> bool:
return False
async def get(
self, url: str, headers: dict[str, str], timeout: float
) -> Mock:
self.calls.append({"url": url, "headers": headers, "timeout": timeout})
if self.error is not None:
raise self.error
assert self.response is not None
return self.response
@pytest.mark.asyncio
async def test_get_balance_omits_auth_header_when_api_key_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
response = Mock()
response.json.return_value = {"balance_msats": 42000}
response.raise_for_status.return_value = None
client = DummyAsyncClient(response=response)
monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client)
provider = RoutstrUpstreamProvider(base_url="https://node.example", api_key="")
balance = await provider.get_balance()
assert balance == 42.0
assert client.calls == [
{
"url": "https://node.example/v1/balance/info",
"headers": {},
"timeout": 10.0,
}
]
@pytest.mark.asyncio
async def test_get_balance_returns_none_on_connect_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client = DummyAsyncClient(error=httpx.ConnectTimeout("timed out"))
monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client)
provider = RoutstrUpstreamProvider(
base_url="https://node.example",
api_key="secret",
)
balance = await provider.get_balance()
assert balance is None

View File

@@ -108,6 +108,92 @@ async def test_credit_balance() -> None:
assert mock_session.refresh.called
@pytest.mark.asyncio
async def test_swap_to_primary_mint_insufficient_for_fees() -> None:
"""Token amount is less than melt_quote.amount + melt_quote.fee_reserve."""
from routstr.wallet import swap_to_primary_mint
mock_token = Mock()
mock_token.mint = "http://foreign:3338"
mock_token.unit = "sat"
mock_token.amount = 404
mock_token.keysets = ["keyset1"]
mock_token.proofs = [{"amount": 404}]
mock_token_wallet = Mock()
mock_token_wallet.load_mint = AsyncMock()
mock_token_wallet.load_proofs = AsyncMock()
mock_primary_wallet = Mock()
mock_primary_wallet.load_mint = AsyncMock()
mock_primary_wallet.load_proofs = AsyncMock()
mock_mint_quote = Mock()
mock_mint_quote.quote = "mint_quote_123"
mock_mint_quote.request = "lnbc1..."
mock_primary_wallet.request_mint = AsyncMock(return_value=mock_mint_quote)
mock_melt_quote = Mock()
mock_melt_quote.quote = "melt_quote_123"
mock_melt_quote.amount = 400
mock_melt_quote.fee_reserve = 12 # total needed: 412 > 404
mock_token_wallet.melt_quote = AsyncMock(return_value=mock_melt_quote)
from routstr.core.settings import settings
with patch.object(settings, "primary_mint", "http://primary:3338"):
with patch.object(settings, "primary_mint_unit", "sat"):
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
with pytest.raises(ValueError, match="insufficient to cover melt fees"):
await swap_to_primary_mint(mock_token, mock_token_wallet)
# melt should never have been called
mock_token_wallet.melt.assert_not_called()
@pytest.mark.asyncio
async def test_swap_to_primary_mint_melt_error_wrapped() -> None:
"""Melt failure from cashu lib is wrapped as ValueError."""
from routstr.wallet import swap_to_primary_mint
mock_token = Mock()
mock_token.mint = "http://foreign:3338"
mock_token.unit = "sat"
mock_token.amount = 5000
mock_token.keysets = ["keyset1"]
mock_token.proofs = [{"amount": 5000}]
mock_token_wallet = Mock()
mock_token_wallet.load_mint = AsyncMock()
mock_token_wallet.load_proofs = AsyncMock()
mock_primary_wallet = Mock()
mock_primary_wallet.load_mint = AsyncMock()
mock_primary_wallet.load_proofs = AsyncMock()
mock_mint_quote = Mock()
mock_mint_quote.quote = "mint_quote_456"
mock_mint_quote.request = "lnbc1..."
mock_primary_wallet.request_mint = AsyncMock(return_value=mock_mint_quote)
mock_melt_quote = Mock()
mock_melt_quote.quote = "melt_quote_456"
mock_melt_quote.amount = 4940
mock_melt_quote.fee_reserve = 50 # total 4990 < 5000, passes fee check
mock_token_wallet.melt_quote = AsyncMock(return_value=mock_melt_quote)
mock_token_wallet.melt = AsyncMock(
side_effect=Exception("Provided: 5000, needed: 5100 (Code: 11000)")
)
from routstr.core.settings import settings
with patch.object(settings, "primary_mint", "http://primary:3338"):
with patch.object(settings, "primary_mint_unit", "sat"):
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
with pytest.raises(ValueError, match="Failed to melt token"):
await swap_to_primary_mint(mock_token, mock_token_wallet)
@pytest.mark.asyncio
async def test_recieve_token_untrusted_mint() -> None:
mock_wallet = Mock()
@@ -131,3 +217,104 @@ async def test_recieve_token_untrusted_mint() -> None:
assert amount == 900
assert unit == "sat"
assert mint == "http://mint:3338"
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_swap_to_primary_mint_already_on_primary() -> None:
from routstr.core.settings import settings
from routstr.wallet import swap_to_primary_mint
mock_token = Mock()
mock_token.mint = settings.primary_mint
mock_token.amount = 1000
mock_token.unit = "sat"
mock_token.proofs = []
mock_token_wallet = Mock()
mock_token_wallet.split = AsyncMock(return_value=None)
mock_token_wallet.request_mint = AsyncMock()
mock_token_wallet.melt_quote = AsyncMock()
with patch("routstr.wallet.get_wallet", AsyncMock(return_value=mock_token_wallet)):
amount, unit, mint = await swap_to_primary_mint(mock_token, mock_token_wallet)
assert amount == 1000
assert unit == "sat"
assert mint == settings.primary_mint
mock_token_wallet.split.assert_called_once()
mock_token_wallet.request_mint.assert_not_called()
mock_token_wallet.melt_quote.assert_not_called()
async def test_swap_to_primary_mint_success() -> None:
"""Test successful swap with dynamic fee calculation."""
from routstr.wallet import swap_to_primary_mint
mock_token = Mock()
mock_token.mint = "http://foreign:3338"
mock_token.unit = "sat"
mock_token.amount = 1000
mock_token.keysets = ["keyset1"]
mock_token.proofs = [{"amount": 1000}]
mock_token_wallet = Mock()
mock_token_wallet.load_mint = AsyncMock()
mock_token_wallet.load_proofs = AsyncMock()
mock_primary_wallet = Mock()
mock_primary_wallet.load_mint = AsyncMock()
mock_primary_wallet.load_proofs = AsyncMock()
# Mocks for the estimation phase
# 1. request_mint(dummy_amount=1000) -> invoice_dummy
# 2. melt_quote(invoice_dummy) -> fee=10
# Mocks for the execution phase
# 3. request_mint(minted_amount=990) -> invoice_real
# 4. melt_quote(invoice_real) -> amount=990, fee=10
# 5. melt() -> success
# 6. mint() -> success
mock_mint_quote_dummy = Mock(quote="dummy_quote", request="lnbc_dummy")
mock_mint_quote_real = Mock(quote="real_quote", request="lnbc_real")
# side_effect for request_mint to return dummy then real
mock_primary_wallet.request_mint = AsyncMock(
side_effect=[mock_mint_quote_dummy, mock_mint_quote_real]
)
mock_melt_quote_dummy = Mock(amount=1000, fee_reserve=10)
mock_melt_quote_real = Mock(amount=990, fee_reserve=10)
# side_effect for melt_quote
mock_token_wallet.melt_quote = AsyncMock(
side_effect=[mock_melt_quote_dummy, mock_melt_quote_real]
)
mock_token_wallet.melt = AsyncMock(return_value="melted_proofs")
mock_primary_wallet.mint = AsyncMock(return_value="minted_proofs")
from routstr.core.settings import settings
with patch.object(settings, "primary_mint", "http://primary:3338"):
with patch.object(settings, "primary_mint_unit", "sat"):
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
amount, unit, mint = await swap_to_primary_mint(
mock_token, mock_token_wallet
)
assert amount == 990 # 1000 - 10
assert unit == "sat"
assert mint == "http://primary:3338"
# Verify call order/counts
assert mock_primary_wallet.request_mint.call_count == 2
# First call with full amount for estimation
mock_primary_wallet.request_mint.assert_any_call(1000)
# Second call with calculated amount
mock_primary_wallet.request_mint.assert_any_call(990)
assert mock_token_wallet.melt_quote.call_count == 2
assert mock_token_wallet.melt.called
assert mock_primary_wallet.mint.called

View File

@@ -0,0 +1,216 @@
import json
import os
from unittest.mock import AsyncMock, patch
import httpx
import pytest
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
os.environ.setdefault("UPSTREAM_API_KEY", "test")
from routstr.payment.cost_calculation import CostData # noqa: E402
from routstr.upstream.base import BaseUpstreamProvider # noqa: E402
def _make_provider() -> BaseUpstreamProvider:
return BaseUpstreamProvider(base_url="http://test", api_key="test-key")
def _make_httpx_response(status_code: int = 200) -> httpx.Response:
return httpx.Response(status_code, headers={})
def _make_cost_data(total_msats: int = 5000) -> CostData:
return CostData(
base_msats=0,
input_msats=3000,
output_msats=2000,
total_msats=total_msats,
total_usd=0.00025,
input_tokens=100,
output_tokens=50,
)
# ---------------------------------------------------------------------------
# Non-streaming (chat completions)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_non_streaming_includes_cost_sats() -> None:
provider = _make_provider()
cost_data = _make_cost_data(total_msats=5000)
response_body = {
"model": "gpt-4o",
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50,
"total_tokens": 150,
"cost": 0.00025,
},
}
content_str = json.dumps(response_body)
httpx_response = _make_httpx_response()
with (
patch.object(provider, "get_x_cashu_cost", new=AsyncMock(return_value=cost_data)),
patch.object(provider, "send_refund", new=AsyncMock(return_value="cashuA_refund_token")),
):
response = await provider.handle_x_cashu_non_streaming_response(
content_str=content_str,
response=httpx_response,
amount=10000,
unit="msat",
max_cost_for_model=10000,
mint=None,
payment_token_hash=None,
)
body = json.loads(response.body)
assert "cost_sats" in body["usage"]
assert body["usage"]["cost_sats"] == 5 # 5000 msats // 1000
@pytest.mark.asyncio
async def test_non_streaming_cost_sats_value_rounds_down() -> None:
provider = _make_provider()
cost_data = _make_cost_data(total_msats=1999)
response_body = {"model": "gpt-4o", "usage": {"prompt_tokens": 10}}
content_str = json.dumps(response_body)
with (
patch.object(provider, "get_x_cashu_cost", new=AsyncMock(return_value=cost_data)),
patch.object(provider, "send_refund", new=AsyncMock(return_value="cashuA_refund_token")),
):
response = await provider.handle_x_cashu_non_streaming_response(
content_str=content_str,
response=_make_httpx_response(),
amount=10000,
unit="msat",
max_cost_for_model=10000,
)
body = json.loads(response.body)
assert body["usage"]["cost_sats"] == 1 # 1999 // 1000
@pytest.mark.asyncio
async def test_non_streaming_preserves_existing_usage_fields() -> None:
provider = _make_provider()
cost_data = _make_cost_data(total_msats=3000)
response_body = {
"model": "gpt-4o",
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50,
"total_tokens": 150,
"cost": 0.00015,
},
}
with (
patch.object(provider, "get_x_cashu_cost", new=AsyncMock(return_value=cost_data)),
patch.object(provider, "send_refund", new=AsyncMock(return_value="cashuA_refund_token")),
):
response = await provider.handle_x_cashu_non_streaming_response(
content_str=json.dumps(response_body),
response=_make_httpx_response(),
amount=10000,
unit="msat",
max_cost_for_model=10000,
)
body = json.loads(response.body)
usage = body["usage"]
assert usage["prompt_tokens"] == 100
assert usage["completion_tokens"] == 50
assert usage["total_tokens"] == 150
assert usage["cost"] == 0.00015
assert usage["cost_sats"] == 3
# ---------------------------------------------------------------------------
# Streaming (chat completions)
# ---------------------------------------------------------------------------
async def _collect_streaming(response: object) -> list[str]:
chunks: list[str] = []
async for chunk in response.body_iterator: # type: ignore[attr-defined]
if isinstance(chunk, bytes):
chunks.append(chunk.decode("utf-8"))
else:
chunks.append(str(chunk))
return chunks
@pytest.mark.asyncio
async def test_streaming_includes_cost_sats_in_usage_chunk() -> None:
provider = _make_provider()
cost_data = _make_cost_data(total_msats=7000)
usage_chunk = {
"id": "chatcmpl-123",
"model": "gpt-4o",
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
}
content_str = "\n".join([
'data: {"id":"chatcmpl-123","model":"gpt-4o","choices":[]}',
f"data: {json.dumps(usage_chunk)}",
"data: [DONE]",
])
with patch.object(provider, "get_x_cashu_cost", new=AsyncMock(return_value=cost_data)):
response = await provider.handle_x_cashu_streaming_response(
content_str=content_str,
response=_make_httpx_response(),
amount=10000,
unit="msat",
max_cost_for_model=10000,
mint=None,
payment_token_hash=None,
)
chunks = await _collect_streaming(response)
full_output = "".join(chunks)
usage_line = next(
line for line in full_output.split("\n") if '"usage"' in line and "cost_sats" in line
)
data_json = json.loads(usage_line.lstrip("data: ").strip())
assert data_json["usage"]["cost_sats"] == 7 # 7000 // 1000
@pytest.mark.asyncio
async def test_streaming_non_usage_chunks_unmodified() -> None:
provider = _make_provider()
cost_data = _make_cost_data(total_msats=2000)
regular_chunk = {"id": "chatcmpl-123", "model": "gpt-4o", "choices": [{"delta": {"content": "hi"}}]}
usage_chunk = {"id": "chatcmpl-123", "model": "gpt-4o", "usage": {"prompt_tokens": 10}}
content_str = "\n".join([
f"data: {json.dumps(regular_chunk)}",
f"data: {json.dumps(usage_chunk)}",
"data: [DONE]",
])
with patch.object(provider, "get_x_cashu_cost", new=AsyncMock(return_value=cost_data)):
response = await provider.handle_x_cashu_streaming_response(
content_str=content_str,
response=_make_httpx_response(),
amount=10000,
unit="msat",
max_cost_for_model=10000,
)
chunks = await _collect_streaming(response)
lines = [
line for line in "".join(chunks).split("\n")
if line.startswith("data: ") and line != "data: [DONE]"
]
regular_line_data = json.loads(lines[0][6:])
# regular chunk should not have cost_sats injected
assert "cost_sats" not in regular_line_data.get("usage", {})

View File

@@ -1,11 +0,0 @@
{
"extends": [
"next",
"next/core-web-vitals",
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"plugins": ["react", "@typescript-eslint"]
}

1
ui/.gitignore vendored
View File

@@ -42,3 +42,4 @@ next-env.d.ts
# favicon conflicts
/app/favicon.ico
.pnpm-store/

5
ui/.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
pnpm-lock.yaml
.next
node_modules
out
next-env.d.ts

View File

@@ -2,12 +2,11 @@
import { useCurrencyStore } from '@/lib/stores/currency';
import { useQuery } from '@tanstack/react-query';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { DetailedWalletBalance } from '@/components/detailed-wallet-balance';
import { TemporaryBalances } from '@/components/temporary-balances';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
export default function BalancesPage() {
const { displayUnit } = useCurrencyStore();
@@ -22,39 +21,26 @@ export default function BalancesPage() {
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset className='p-0'>
<SiteHeader />
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<h1 className='text-3xl font-bold tracking-tight'>Balances</h1>
<p className='text-muted-foreground mt-2'>
Monitor and manage wallet balances
</p>
</div>
{/* Global currency toggle is now in SiteHeader */}
</div>
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
<div className='space-y-6'>
<PageHeader
title='Balances'
description='Monitor and manage wallet balances across cashu mints and temporary stores.'
/>
<div className='grid gap-6'>
<div className='col-span-full'>
<DetailedWalletBalance
refreshInterval={30000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
<div className='col-span-full'>
<TemporaryBalances
refreshInterval={60000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
</div>
<div className='grid gap-6'>
<DetailedWalletBalance
refreshInterval={30000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
<TemporaryBalances
refreshInterval={60000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
</SidebarInset>
</SidebarProvider>
</div>
</AppPageShell>
);
}

View File

@@ -1,13 +1,133 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@custom-variant dark (&:is(.dark *, .red *));
:root {
--radius: 0.625rem;
--font-geist-sans:
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica,
Arial, 'Apple Color Emoji', 'Segoe UI Emoji';
--font-geist-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
.red {
--background: oklch(0.14 0.03 24);
--foreground: oklch(0.9 0.05 28);
--card: oklch(0.17 0.04 24);
--card-foreground: oklch(0.9 0.05 28);
--popover: oklch(0.17 0.04 24);
--popover-foreground: oklch(0.9 0.05 28);
--primary: oklch(0.78 0.14 25);
--primary-foreground: oklch(0.14 0.03 24);
--secondary: oklch(0.22 0.05 24);
--secondary-foreground: oklch(0.9 0.05 28);
--muted: oklch(0.22 0.05 24);
--muted-foreground: oklch(0.72 0.04 26);
--accent: oklch(0.25 0.08 25);
--accent-foreground: oklch(0.92 0.05 28);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(0.72 0.11 25 / 24%);
--input: oklch(0.75 0.12 25 / 28%);
--ring: oklch(0.62 0.12 24);
}
html.red {
color-scheme: dark;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: Geist, sans-serif;
--font-mono: Geist Mono, monospace;
--color-muted: var(--muted);
--color-accent: var(--accent);
--color-border: var(--border);
--color-card: var(--card);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-ring: var(--ring);
--color-input: var(--input);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-muted-foreground: var(--muted-foreground);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -21,194 +141,34 @@
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-serif: Georgia, serif;
--radius: 0.5rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.5rem;
--background: oklch(0.99 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(0.99 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0 0 0);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.94 0 0);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.44 0 0);
--accent: oklch(0.94 0 0);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(0.63 0.19 23.03);
--border: oklch(0.92 0 0);
--input: oklch(0.94 0 0);
--ring: oklch(0 0 0);
--chart-1: oklch(0.81 0.17 75.35);
--chart-2: oklch(0.55 0.22 264.53);
--chart-3: oklch(0.72 0 0);
--chart-4: oklch(0.92 0 0);
--chart-5: oklch(0.56 0 0);
--sidebar: oklch(0.99 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0 0 0);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.94 0 0);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(0.94 0 0);
--sidebar-ring: oklch(0 0 0);
--destructive-foreground: oklch(1 0 0);
--font-sans: Geist, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Geist Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0.18;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 1px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-sm:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow-md:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
--shadow-lg:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
--shadow-xl:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
--tracking-normal: 0em;
body {
font-family: var(--font-sans);
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(1 0 0);
--card: oklch(0.14 0 0);
--card-foreground: oklch(1 0 0);
--popover: oklch(0.18 0 0);
--popover-foreground: oklch(1 0 0);
--primary: oklch(1 0 0);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.25 0 0);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.23 0 0);
--muted-foreground: oklch(0.72 0 0);
--accent: oklch(0.32 0 0);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.69 0.2 23.91);
--border: oklch(0.26 0 0);
--input: oklch(0.32 0 0);
--ring: oklch(0.72 0 0);
--chart-1: oklch(0.81 0.17 75.35);
--chart-2: oklch(0.58 0.21 260.84);
--chart-3: oklch(0.56 0 0);
--chart-4: oklch(0.44 0 0);
--chart-5: oklch(0.92 0 0);
--sidebar: oklch(0.18 0 0);
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(1 0 0);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.32 0 0);
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(0.32 0 0);
--sidebar-ring: oklch(0.72 0 0);
--destructive-foreground: oklch(0 0 0);
--radius: 0.5rem;
--font-sans: Geist, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Geist Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0.18;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 1px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-sm:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow-md:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
--shadow-lg:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
--shadow-xl:
0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
html,
body {
max-width: 100%;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}
/* Custom animations */
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
button,
[type='button'],
[type='submit'],
[type='reset'],
[role='button'] {
cursor: pointer;
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}

View File

@@ -1,23 +1,10 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { GeistMono } from 'geist/font/mono';
import { GeistSans } from 'geist/font/sans';
import './globals.css';
import { Providers } from './providers';
import { SuppressHydrationWarning } from '@/components/suppress-hydration-warning';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
preload: false,
display: 'swap',
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
preload: false,
display: 'swap',
});
export const metadata: Metadata = {
title: 'Routstr',
description: 'Routstr model management',
@@ -34,7 +21,7 @@ export default function RootLayout({
return (
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
className={`${GeistSans.variable} ${GeistMono.variable} font-sans antialiased`}
>
<SuppressHydrationWarning>
<Providers>{children}</Providers>

View File

@@ -5,16 +5,10 @@ import type { ChangeEvent, FormEvent, ReactElement } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { adminLogin } from '@/lib/api/services/auth';
import { ConfigurationService } from '@/lib/api/services/configuration';
import { toast } from 'sonner';
import { AuthPageShell } from '@/components/auth-page-shell';
export default function AdminLoginPage(): ReactElement {
const router = useRouter();
@@ -78,51 +72,42 @@ export default function AdminLoginPage(): ReactElement {
};
return (
<div className='bg-background text-foreground flex min-h-screen items-center justify-center px-4 py-12'>
<Card className='border-border/60 bg-card/90 w-full max-w-md border shadow-2xl shadow-black/30 backdrop-blur'>
<CardHeader className='space-y-1'>
<CardTitle className='text-center text-2xl font-bold'>
Admin Login
</CardTitle>
<CardDescription className='text-center'>
Enter your admin password to access the dashboard
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className='space-y-4'>
{allowCustomBaseUrl && (
<div className='space-y-2'>
<Input
type='text'
placeholder='API URL (https://api.example.com)'
value={baseUrl}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setBaseUrl(event.target.value)
}
disabled={isLoading}
required
/>
</div>
)}
<div className='space-y-2'>
<Input
type='password'
placeholder='Admin Password'
value={password}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setPassword(event.target.value)
}
disabled={isLoading}
autoFocus
required
/>
</div>
<Button type='submit' className='w-full' disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
</form>
</CardContent>
</Card>
</div>
<AuthPageShell
title='Admin Login'
description='Enter your admin password to access the dashboard.'
>
<form onSubmit={handleSubmit} className='space-y-4'>
{allowCustomBaseUrl && (
<div className='space-y-2'>
<Input
type='text'
placeholder='API URL (https://api.example.com)'
value={baseUrl}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setBaseUrl(event.target.value)
}
disabled={isLoading}
required
/>
</div>
)}
<div className='space-y-2'>
<Input
type='password'
placeholder='Admin Password'
value={password}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setPassword(event.target.value)
}
disabled={isLoading}
autoFocus
required
/>
</div>
<Button type='submit' className='w-full' disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
</form>
</AuthPageShell>
);
}

View File

@@ -10,18 +10,8 @@ import {
} from '@/components/ui/dialog';
import { Copy, Check } from 'lucide-react';
import { useState } from 'react';
interface LogEntry {
asctime: string;
name: string;
levelname: string;
message: string;
pathname: string;
lineno: number;
version: string;
request_id: string;
[key: string]: string | number | object | undefined;
}
import { getLogLevelBadgeVariant } from '@/lib/utils/log-level';
import type { LogEntry } from './types';
interface LogDetailsDialogProps {
log: LogEntry | null;
@@ -29,24 +19,6 @@ interface LogDetailsDialogProps {
onClose: () => void;
}
const getLevelColor = (level: string): string => {
switch (level.toUpperCase()) {
case 'TRACE':
case 'DEBUG':
return 'bg-gray-100 text-gray-800 border-gray-200';
case 'INFO':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'WARNING':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'ERROR':
return 'bg-red-100 text-red-800 border-red-200';
case 'CRITICAL':
return 'bg-purple-100 text-purple-800 border-purple-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
export function LogDetailsDialog({
log,
isOpen,
@@ -79,10 +51,13 @@ export function LogDetailsDialog({
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className='max-h-[90vh] w-[95vw] max-w-[95vw] overflow-hidden'>
<DialogContent className='max-h-[92svh] w-full max-w-none overflow-hidden md:max-w-4xl'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Badge variant='outline' className={getLevelColor(log.levelname)}>
<Badge
variant={getLogLevelBadgeVariant(log.levelname)}
className='uppercase'
>
{log.levelname}
</Badge>
<span>Log Entry Details</span>
@@ -92,7 +67,7 @@ export function LogDetailsDialog({
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[75vh] w-full overflow-x-auto'>
<ScrollArea className='h-[70svh] w-full overflow-x-auto sm:h-[75vh]'>
<div className='space-y-6'>
<div>
<h4 className='mb-2 text-sm font-medium'>Message</h4>

View File

@@ -1,41 +1,13 @@
import { Badge } from '@/components/ui/badge';
import { Eye } from 'lucide-react';
interface LogEntry {
asctime: string;
name: string;
levelname: string;
message: string;
pathname: string;
lineno: number;
version: string;
request_id: string;
[key: string]: string | number | object | undefined;
}
import { ChevronRight } from 'lucide-react';
import { getLogLevelBadgeVariant } from '@/lib/utils/log-level';
import type { LogEntry } from './types';
interface LogEntryCardProps {
entry: LogEntry;
onClick: (entry: LogEntry) => void;
}
const getLevelColor = (level: string): string => {
switch (level.toUpperCase()) {
case 'TRACE':
case 'DEBUG':
return 'bg-gray-100 text-gray-800 border-gray-200';
case 'INFO':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'WARNING':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'ERROR':
return 'bg-red-100 text-red-800 border-red-200';
case 'CRITICAL':
return 'bg-purple-100 text-purple-800 border-purple-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
const extraFields = Object.keys(entry).filter(
(key) =>
@@ -50,73 +22,58 @@ export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
'request_id',
].includes(key)
);
const hasRequestId =
Boolean(entry.request_id) && entry.request_id !== 'no-request-id';
const shortPath = entry.pathname.split('/').pop() || entry.pathname;
return (
<div
className='bg-card hover:bg-accent/50 group mb-4 cursor-pointer overflow-hidden rounded-lg border p-3 transition-colors duration-200 sm:p-4'
className='bg-card hover:bg-accent/35 group mb-2 cursor-pointer rounded-lg border p-2.5 transition-colors duration-150 sm:p-3'
onClick={() => onClick(entry)}
>
<div className='mb-3 flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex min-w-0 flex-wrap items-center gap-2'>
<Badge variant='outline' className={getLevelColor(entry.levelname)}>
{entry.levelname}
</Badge>
<span className='text-muted-foreground truncate text-xs sm:text-sm'>
{entry.asctime}
</span>
<Badge variant='secondary' className='truncate text-xs'>
{entry.name}
</Badge>
</div>
<div className='flex min-w-0 items-center gap-2'>
<div className='text-muted-foreground truncate text-xs'>
{entry.pathname}:{entry.lineno}
</div>
<Eye className='text-muted-foreground h-4 w-4 flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100' />
</div>
</div>
<div className='mb-2 line-clamp-3 overflow-hidden font-mono text-xs break-words sm:text-sm'>
{entry.message}
</div>
{entry.request_id && entry.request_id !== 'no-request-id' && (
<div className='mb-2 min-w-0'>
<div className='inline-block max-w-full'>
<Badge variant='outline' className='text-xs'>
<span className='inline-block max-w-[250px] truncate sm:max-w-[400px]'>
Request ID: {entry.request_id}
</span>
<div className='flex min-w-0 items-start justify-between gap-2'>
<div className='min-w-0 flex-1 space-y-1.5'>
<div className='flex min-w-0 flex-wrap items-center gap-1.5'>
<Badge
variant={getLogLevelBadgeVariant(entry.levelname)}
className='h-5 px-1.5 text-[10px] uppercase'
>
{entry.levelname}
</Badge>
<span className='text-muted-foreground text-[11px]'>
{entry.asctime}
</span>
<Badge
variant='secondary'
className='h-5 max-w-[11rem] truncate px-1.5 text-[10px]'
>
{entry.name}
</Badge>
</div>
</div>
)}
{extraFields.length > 0 && (
<div className='mt-3 min-w-0 border-t pt-3'>
<div className='mb-2 text-xs font-medium'>Additional Fields:</div>
<div className='grid grid-cols-1 gap-2'>
{extraFields.slice(0, 4).map((key) => (
<div
key={key}
className='min-w-0 overflow-hidden text-xs break-words'
>
<span className='font-medium break-all'>{key}:</span>{' '}
<span className='text-muted-foreground break-all'>
{typeof entry[key] === 'object'
? JSON.stringify(entry[key])
: String(entry[key])}
</span>
</div>
))}
{extraFields.length > 4 && (
<div className='text-muted-foreground text-xs'>
...and {extraFields.length - 4} more fields
</div>
)}
<p className='line-clamp-1 font-mono text-xs break-words sm:text-sm'>
{entry.message}
</p>
<div className='text-muted-foreground flex min-w-0 flex-wrap items-center gap-1.5 text-[11px]'>
{hasRequestId ? (
<span className='inline-block max-w-[14rem] truncate rounded border px-1.5 py-0.5 font-mono text-[10px] sm:max-w-[20rem]'>
{entry.request_id}
</span>
) : null}
<span className='truncate'>
{shortPath}:{entry.lineno}
</span>
{extraFields.length > 0 ? (
<span>{extraFields.length} extra</span>
) : null}
</div>
</div>
)}
<div className='pt-0.5'>
<ChevronRight className='text-muted-foreground h-4 w-4 opacity-50 transition-opacity group-hover:opacity-90' />
</div>
</div>
</div>
);
}

View File

@@ -1,3 +1,11 @@
import {
useEffect,
useState,
type ChangeEvent,
type KeyboardEvent,
} from 'react';
import { format } from 'date-fns';
import { CalendarIcon, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Card,
@@ -21,20 +29,8 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { Badge } from '@/components/ui/badge';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Checkbox } from '@/components/ui/checkbox';
import { CalendarIcon, Filter, X, Plus } from 'lucide-react';
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
import { MultiSelectCommandFilter } from './multi-select-command-filter';
interface LogFiltersProps {
selectedDate: string;
@@ -97,32 +93,12 @@ const ENDPOINT_OPTIONS = [
'/embeddings/models',
];
interface FilterBadgeProps {
value: string;
onRemove: (value: string) => void;
}
function FilterBadge({ value, onRemove }: FilterBadgeProps) {
return (
<Badge
variant='secondary'
className='flex items-center gap-1 px-1 font-normal'
>
{value}
<button
type='button'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove(value);
}}
className='hover:bg-muted-foreground/20 rounded-full'
>
<X className='h-3 w-3' />
</button>
</Badge>
);
}
const STATUS_4XX_CODES = STATUS_CODE_OPTIONS.filter((code) =>
code.startsWith('4')
);
const STATUS_5XX_CODES = STATUS_CODE_OPTIONS.filter((code) =>
code.startsWith('5')
);
export function LogFilters({
selectedDate,
@@ -151,7 +127,7 @@ export function LogFilters({
const [isCustom, setIsCustom] = useState<boolean>(!isPreset);
const [date, setDate] = useState<Date | undefined>(
selectedDate && selectedDate !== 'all'
? new Date(selectedDate + 'T00:00:00')
? new Date(`${selectedDate}T00:00:00`)
: undefined
);
@@ -162,6 +138,7 @@ export function LogFilters({
useEffect(() => {
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
setIsCustom(!currentIsPreset);
if (!currentIsPreset) {
setCustomLimit(limit.toString());
}
@@ -170,88 +147,79 @@ export function LogFilters({
useEffect(() => {
if (selectedDate === 'all' || !selectedDate) {
setDate(undefined);
} else {
const d = new Date(selectedDate + 'T00:00:00');
setDate(isNaN(d.getTime()) ? undefined : d);
return;
}
const parsedDate = new Date(`${selectedDate}T00:00:00`);
setDate(Number.isNaN(parsedDate.getTime()) ? undefined : parsedDate);
}, [selectedDate]);
const handleLimitChange = (value: string) => {
if (value === 'custom') {
setIsCustom(true);
setCustomLimit(limit.toString());
} else {
setIsCustom(false);
setCustomLimit('');
onLimitChange(Number(value));
return;
}
setIsCustom(false);
setCustomLimit('');
onLimitChange(Number(value));
};
const handleCustomLimitChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setCustomLimit(value);
const handleCustomLimitChange = (event: ChangeEvent<HTMLInputElement>) => {
setCustomLimit(event.target.value);
};
const handleCustomLimitApply = () => {
const numValue = parseInt(customLimit);
if (!isNaN(numValue) && numValue > 0) {
onLimitChange(numValue);
} else {
setIsCustom(false);
setCustomLimit('');
onLimitChange(100);
const numericValue = Number.parseInt(customLimit, 10);
if (!Number.isNaN(numericValue) && numericValue > 0) {
onLimitChange(numericValue);
return;
}
setIsCustom(false);
setCustomLimit('');
onLimitChange(100);
};
const handleCustomLimitKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === 'Enter') {
const handleCustomLimitKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleCustomLimitApply();
}
};
const handleDateSelect = (selectedDate: Date | undefined) => {
setDate(selectedDate);
if (selectedDate) {
onDateChange(format(selectedDate, 'yyyy-MM-dd'));
} else {
onDateChange('all');
}
};
const handleDateSelect = (nextDate: Date | undefined) => {
setDate(nextDate);
const toggleSelection = (
current: string[],
value: string,
onChange: (val: string[]) => void
) => {
if (current.includes(value)) {
onChange(current.filter((v) => v !== value));
} else {
onChange([...current, value]);
if (nextDate) {
onDateChange(format(nextDate, 'yyyy-MM-dd'));
return;
}
onDateChange('all');
};
const handleQuickStatusCode = (range: '4xx' | '5xx') => {
const codes = STATUS_CODE_OPTIONS.filter((c) => c.startsWith(range[0]));
const newSelection = new Set([...selectedStatusCodes]);
const allIncluded = codes.every((c) => selectedStatusCodes.includes(c));
const rangeCodes = range === '4xx' ? STATUS_4XX_CODES : STATUS_5XX_CODES;
const nextSelection = new Set(selectedStatusCodes);
const allSelected = rangeCodes.every((code) =>
selectedStatusCodes.includes(code)
);
if (allIncluded) {
codes.forEach((c) => newSelection.delete(c));
if (allSelected) {
rangeCodes.forEach((code) => nextSelection.delete(code));
} else {
codes.forEach((c) => newSelection.add(c));
rangeCodes.forEach((code) => nextSelection.add(code));
}
onStatusCodesChange(Array.from(newSelection));
onStatusCodesChange(Array.from(nextSelection));
};
return (
<Card className='mb-6'>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Filter className='h-5 w-5' />
Filters
</CardTitle>
<CardTitle>Filters</CardTitle>
<CardDescription>
Filter logs by date, level, request ID, text search, status code,
method, endpoint and limit
@@ -314,336 +282,62 @@ export function LogFilters({
</Select>
</div>
<div className='space-y-2'>
<Label>Status Codes</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1'>
{selectedStatusCodes.length > 0 ? (
selectedStatusCodes.map((code) => (
<FilterBadge
key={code}
value={code}
onRemove={(val) =>
toggleSelection(
selectedStatusCodes,
val,
onStatusCodesChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>All codes</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-64 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add status code...'
value={statusSearch}
onValueChange={setStatusSearch}
/>
<CommandList>
{selectedStatusCodes.length > 0 && (
<CommandGroup heading='Selected'>
{selectedStatusCodes.map((code) => (
<CommandItem
key={`selected-${code}`}
onSelect={() =>
toggleSelection(
selectedStatusCodes,
code,
onStatusCodesChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{code}
</CommandItem>
))}
</CommandGroup>
)}
{statusSearch &&
!STATUS_CODE_OPTIONS.includes(statusSearch) &&
!selectedStatusCodes.includes(statusSearch) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
if (/^\d+$/.test(statusSearch)) {
toggleSelection(
selectedStatusCodes,
statusSearch,
onStatusCodesChange
);
setStatusSearch('');
}
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{statusSearch}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading='Quick Filters'>
<CommandItem
onSelect={() => handleQuickStatusCode('4xx')}
>
<Checkbox
checked={STATUS_CODE_OPTIONS.filter((c) =>
c.startsWith('4')
).every((c) => selectedStatusCodes.includes(c))}
className='mr-2'
/>
4xx Errors
</CommandItem>
<CommandItem
onSelect={() => handleQuickStatusCode('5xx')}
>
<Checkbox
checked={STATUS_CODE_OPTIONS.filter((c) =>
c.startsWith('5')
).every((c) => selectedStatusCodes.includes(c))}
className='mr-2'
/>
5xx Errors
</CommandItem>
</CommandGroup>
<CommandGroup heading='Common Codes'>
{STATUS_CODE_OPTIONS.filter(
(code) => !selectedStatusCodes.includes(code)
).map((code) => (
<CommandItem
key={code}
onSelect={() =>
toggleSelection(
selectedStatusCodes,
code,
onStatusCodesChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{code}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<MultiSelectCommandFilter
label='Status Codes'
emptyLabel='All codes'
selectedValues={selectedStatusCodes}
onSelectedValuesChange={onStatusCodesChange}
options={STATUS_CODE_OPTIONS}
searchValue={statusSearch}
onSearchValueChange={setStatusSearch}
searchPlaceholder='Search or add status code...'
popoverClassName='w-[min(16rem,calc(100vw-2rem))] p-0'
optionsGroupLabel='Common Codes'
canAddCustom={(value) => /^\d+$/.test(value)}
quickFilters={[
{
label: '4xx Errors',
checked: STATUS_4XX_CODES.every((code) =>
selectedStatusCodes.includes(code)
),
onSelect: () => handleQuickStatusCode('4xx'),
},
{
label: '5xx Errors',
checked: STATUS_5XX_CODES.every((code) =>
selectedStatusCodes.includes(code)
),
onSelect: () => handleQuickStatusCode('5xx'),
},
]}
/>
<div className='space-y-2'>
<Label>HTTP Methods</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1'>
{selectedMethods.length > 0 ? (
selectedMethods.map((method) => (
<FilterBadge
key={method}
value={method}
onRemove={(val) =>
toggleSelection(
selectedMethods,
val,
onMethodsChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>All methods</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-64 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add method...'
value={methodSearch}
onValueChange={setMethodSearch}
/>
<CommandList>
{selectedMethods.length > 0 && (
<CommandGroup heading='Selected'>
{selectedMethods.map((method) => (
<CommandItem
key={`selected-${method}`}
onSelect={() =>
toggleSelection(
selectedMethods,
method,
onMethodsChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{method}
</CommandItem>
))}
</CommandGroup>
)}
{methodSearch &&
!METHOD_OPTIONS.includes(methodSearch.toUpperCase()) &&
!selectedMethods.includes(methodSearch.toUpperCase()) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
toggleSelection(
selectedMethods,
methodSearch.toUpperCase(),
onMethodsChange
);
setMethodSearch('');
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{methodSearch.toUpperCase()}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{METHOD_OPTIONS.filter(
(method) => !selectedMethods.includes(method)
).map((method) => (
<CommandItem
key={method}
onSelect={() =>
toggleSelection(
selectedMethods,
method,
onMethodsChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{method}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<MultiSelectCommandFilter
label='HTTP Methods'
emptyLabel='All methods'
selectedValues={selectedMethods}
onSelectedValuesChange={onMethodsChange}
options={METHOD_OPTIONS}
searchValue={methodSearch}
onSearchValueChange={setMethodSearch}
searchPlaceholder='Search or add method...'
popoverClassName='w-[min(16rem,calc(100vw-2rem))] p-0'
optionsGroupLabel='Methods'
normalizeCustomValue={(value) => value.toUpperCase()}
/>
<div className='space-y-2'>
<Label>Endpoints</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1 overflow-hidden'>
{selectedEndpoints.length > 0 ? (
selectedEndpoints.map((endpoint) => (
<FilterBadge
key={endpoint}
value={endpoint}
onRemove={(val) =>
toggleSelection(
selectedEndpoints,
val,
onEndpointsChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>
All endpoints
</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-80 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add endpoint pattern...'
value={endpointSearch}
onValueChange={setEndpointSearch}
/>
<CommandList>
{selectedEndpoints.length > 0 && (
<CommandGroup heading='Selected'>
{selectedEndpoints.map((endpoint) => (
<CommandItem
key={`selected-${endpoint}`}
onSelect={() =>
toggleSelection(
selectedEndpoints,
endpoint,
onEndpointsChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{endpoint}
</CommandItem>
))}
</CommandGroup>
)}
{endpointSearch &&
!ENDPOINT_OPTIONS.includes(endpointSearch) &&
!selectedEndpoints.includes(endpointSearch) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
toggleSelection(
selectedEndpoints,
endpointSearch,
onEndpointsChange
);
setEndpointSearch('');
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{endpointSearch}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading='Common Endpoints'>
{ENDPOINT_OPTIONS.filter(
(endpoint) => !selectedEndpoints.includes(endpoint)
).map((endpoint) => (
<CommandItem
key={endpoint}
onSelect={() =>
toggleSelection(
selectedEndpoints,
endpoint,
onEndpointsChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{endpoint}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<MultiSelectCommandFilter
label='Endpoints'
emptyLabel='All endpoints'
selectedValues={selectedEndpoints}
onSelectedValuesChange={onEndpointsChange}
options={ENDPOINT_OPTIONS}
searchValue={endpointSearch}
onSearchValueChange={setEndpointSearch}
searchPlaceholder='Search or add endpoint pattern...'
popoverClassName='w-[min(20rem,calc(100vw-2rem))] p-0'
optionsGroupLabel='Common Endpoints'
/>
<div className='space-y-2'>
<Label htmlFor='request-id'>Request ID</Label>
@@ -652,7 +346,7 @@ export function LogFilters({
type='text'
placeholder='Search by request ID'
value={requestId}
onChange={(e) => onRequestIdChange(e.target.value)}
onChange={(event) => onRequestIdChange(event.target.value)}
/>
</div>
@@ -668,14 +362,14 @@ export function LogFilters({
type='text'
placeholder='Search in message and name'
value={searchText}
onChange={(e) => onSearchTextChange(e.target.value)}
onChange={(event) => onSearchTextChange(event.target.value)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='limit'>Limit</Label>
{isCustom ? (
<div className='flex gap-2'>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
id='limit'
type='number'
@@ -686,7 +380,7 @@ export function LogFilters({
onKeyDown={handleCustomLimitKeyDown}
onBlur={handleCustomLimitApply}
autoFocus
className='flex-1'
className='flex-1 sm:flex-auto'
/>
<Button
type='button'
@@ -695,6 +389,7 @@ export function LogFilters({
onClick={() => {
setIsCustom(false);
setCustomLimit('');
if (!isPreset) {
onLimitChange(100);
}
@@ -712,12 +407,11 @@ export function LogFilters({
<SelectValue placeholder='Select limit' />
</SelectTrigger>
<SelectContent>
<SelectItem value='25'>25</SelectItem>
<SelectItem value='50'>50</SelectItem>
<SelectItem value='100'>100</SelectItem>
<SelectItem value='200'>200</SelectItem>
<SelectItem value='500'>500</SelectItem>
<SelectItem value='1000'>1000</SelectItem>
{PRESET_LIMITS.map((preset) => (
<SelectItem key={preset} value={preset}>
{preset}
</SelectItem>
))}
<SelectItem value='custom'>Custom...</SelectItem>
</SelectContent>
</Select>
@@ -727,8 +421,7 @@ export function LogFilters({
)}
</div>
<div className='space-y-2'>
<Label>&nbsp;</Label>
<div className='flex items-end sm:col-span-2 lg:col-span-1'>
<Button
onClick={onClearFilters}
variant='outline'

View File

@@ -0,0 +1,182 @@
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, X } from 'lucide-react';
interface QuickFilterOption {
label: string;
checked: boolean;
onSelect: () => void;
}
interface MultiSelectCommandFilterProps {
label: string;
emptyLabel: string;
selectedValues: string[];
onSelectedValuesChange: (values: string[]) => void;
options: string[];
searchValue: string;
onSearchValueChange: (value: string) => void;
searchPlaceholder: string;
popoverClassName?: string;
selectedGroupLabel?: string;
customGroupLabel?: string;
quickGroupLabel?: string;
optionsGroupLabel?: string;
quickFilters?: QuickFilterOption[];
normalizeCustomValue?: (value: string) => string;
canAddCustom?: (value: string) => boolean;
}
function FilterBadge({ value }: { value: string }) {
return (
<Badge
variant='secondary'
className='flex items-center gap-1 px-1 font-normal'
>
{value}
<X className='h-3 w-3 opacity-70' aria-hidden='true' />
</Badge>
);
}
export function MultiSelectCommandFilter({
label,
emptyLabel,
selectedValues,
onSelectedValuesChange,
options,
searchValue,
onSearchValueChange,
searchPlaceholder,
popoverClassName = 'w-64 p-0',
selectedGroupLabel = 'Selected',
customGroupLabel = 'Custom',
quickGroupLabel = 'Quick Filters',
optionsGroupLabel = 'Options',
quickFilters = [],
normalizeCustomValue,
canAddCustom,
}: MultiSelectCommandFilterProps) {
const toggleSelection = (value: string) => {
if (selectedValues.includes(value)) {
onSelectedValuesChange(selectedValues.filter((item) => item !== value));
return;
}
onSelectedValuesChange([...selectedValues, value]);
};
const normalizedSearch = normalizeCustomValue
? normalizeCustomValue(searchValue)
: searchValue;
const canShowCustomAction =
normalizedSearch.length > 0 &&
!options.includes(normalizedSearch) &&
!selectedValues.includes(normalizedSearch) &&
(canAddCustom ? canAddCustom(normalizedSearch) : true);
return (
<div className='space-y-2'>
<Label>{label}</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1 overflow-hidden'>
{selectedValues.length > 0 ? (
selectedValues.map((value) => (
<FilterBadge key={value} value={value} />
))
) : (
<span className='text-muted-foreground'>{emptyLabel}</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className={popoverClassName} align='start'>
<Command>
<CommandInput
placeholder={searchPlaceholder}
value={searchValue}
onValueChange={onSearchValueChange}
/>
<CommandList>
{selectedValues.length > 0 && (
<CommandGroup heading={selectedGroupLabel}>
{selectedValues.map((value) => (
<CommandItem
key={`selected-${value}`}
onSelect={() => toggleSelection(value)}
>
<Checkbox checked={true} className='mr-2' />
{value}
</CommandItem>
))}
</CommandGroup>
)}
{canShowCustomAction && (
<CommandGroup heading={customGroupLabel}>
<CommandItem
onSelect={() => {
toggleSelection(normalizedSearch);
onSearchValueChange('');
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{normalizedSearch}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
{quickFilters.length > 0 && (
<CommandGroup heading={quickGroupLabel}>
{quickFilters.map((filter) => (
<CommandItem key={filter.label} onSelect={filter.onSelect}>
<Checkbox checked={filter.checked} className='mr-2' />
{filter.label}
</CommandItem>
))}
</CommandGroup>
)}
<CommandGroup heading={optionsGroupLabel}>
{options
.filter((option) => !selectedValues.includes(option))
.map((option) => (
<CommandItem
key={option}
onSelect={() => toggleSelection(option)}
>
<Checkbox checked={false} className='mr-2' />
{option}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -2,8 +2,6 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { Button } from '@/components/ui/button';
import {
Card,
@@ -14,9 +12,18 @@ import {
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { Skeleton } from '@/components/ui/skeleton';
import { FileText, RefreshCw } from 'lucide-react';
import { apiClient } from '@/lib/api/client';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
import { LogEntry, LogsResponse } from './types';
import { LogFilters } from './log-filters';
import { LogEntryCard } from './log-entry-card';
@@ -135,126 +142,125 @@ export default function LogsPage() {
setIsDialogOpen(true);
};
const hasActiveFilters =
selectedDate !== 'all' ||
selectedLevel !== 'all' ||
Boolean(requestId) ||
Boolean(searchText) ||
selectedStatusCodes.length > 0 ||
selectedMethods.length > 0 ||
selectedEndpoints.length > 0;
const activeFilterDescription = [
selectedDate !== 'all' ? `date ${selectedDate}` : null,
selectedLevel !== 'all' ? `level ${selectedLevel}` : null,
requestId ? `request ID ${requestId}` : null,
searchText ? `text "${searchText}"` : null,
selectedStatusCodes.length > 0
? `status ${selectedStatusCodes.join(', ')}`
: null,
selectedMethods.length > 0 ? `method ${selectedMethods.join(', ')}` : null,
selectedEndpoints.length > 0
? `endpoint ${selectedEndpoints.join(', ')}`
: null,
]
.filter(Boolean)
.join(' • ');
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset className='overflow-x-hidden p-0'>
<SiteHeader />
<div className='container max-w-6xl overflow-x-hidden px-3 py-4 sm:px-4 sm:py-8 md:px-6 lg:px-8'>
<div className='mb-6 flex flex-col gap-3 sm:mb-8 sm:gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<h1 className='flex items-center gap-2 text-2xl font-bold tracking-tight sm:text-3xl'>
<FileText className='h-6 w-6 sm:h-8 sm:w-8' />
System Logs
</h1>
<p className='text-muted-foreground mt-1 text-sm sm:mt-2 sm:text-base'>
View and filter application logs
</p>
</div>
<Button
onClick={() => refetchLogs()}
variant='outline'
size='sm'
className='self-start'
>
<AppPageShell contentClassName='mx-auto w-full max-w-5xl overflow-x-hidden'>
<div className='space-y-6'>
<PageHeader
title='System Logs'
description='View and filter application logs.'
actions={
<Button onClick={() => refetchLogs()} variant='outline' size='sm'>
<RefreshCw className='mr-2 h-4 w-4' />
Refresh
</Button>
</div>
}
/>
<LogFilters
selectedDate={selectedDate}
selectedLevel={selectedLevel}
requestId={requestId}
searchText={searchText}
selectedStatusCodes={selectedStatusCodes}
selectedMethods={selectedMethods}
selectedEndpoints={selectedEndpoints}
limit={limit}
onDateChange={setSelectedDate}
onLevelChange={setSelectedLevel}
onRequestIdChange={setRequestId}
onSearchTextChange={setSearchText}
onStatusCodesChange={setSelectedStatusCodes}
onMethodsChange={setSelectedMethods}
onEndpointsChange={setSelectedEndpoints}
onLimitChange={setLimit}
onClearFilters={handleClearFilters}
/>
<LogFilters
selectedDate={selectedDate}
selectedLevel={selectedLevel}
requestId={requestId}
searchText={searchText}
selectedStatusCodes={selectedStatusCodes}
selectedMethods={selectedMethods}
selectedEndpoints={selectedEndpoints}
limit={limit}
onDateChange={setSelectedDate}
onLevelChange={setSelectedLevel}
onRequestIdChange={setRequestId}
onSearchTextChange={setSearchText}
onStatusCodesChange={setSelectedStatusCodes}
onMethodsChange={setSelectedMethods}
onEndpointsChange={setSelectedEndpoints}
onLimitChange={setLimit}
onClearFilters={handleClearFilters}
/>
<Card>
<CardHeader>
<CardTitle className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
<span className='text-lg sm:text-xl'>Log Entries</span>
{logsData && (
<Badge variant='secondary' className='text-xs sm:text-sm'>
{logsData.logs.length} entries
</Badge>
)}
</CardTitle>
{(selectedDate !== 'all' ||
selectedLevel !== 'all' ||
requestId ||
searchText ||
selectedStatusCodes.length > 0 ||
selectedMethods.length > 0 ||
selectedEndpoints.length > 0) && (
<CardDescription className='text-xs sm:text-sm'>
Showing logs
{selectedDate !== 'all' && ` for ${selectedDate}`}
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
{requestId && ` with request ID ${requestId}`}
{searchText && ` matching "${searchText}"`}
{selectedStatusCodes.length > 0 &&
` with status ${selectedStatusCodes.join(', ')}`}
{selectedMethods.length > 0 &&
` with method ${selectedMethods.join(', ')}`}
{selectedEndpoints.length > 0 &&
` with endpoint ${selectedEndpoints.join(', ')}`}
</CardDescription>
<Card>
<CardHeader>
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
<CardTitle>Log Entries</CardTitle>
{logsData && (
<Badge variant='secondary'>
{logsData.logs.length} entries
</Badge>
)}
</CardHeader>
<CardContent className='overflow-hidden p-3 sm:p-6'>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<RefreshCw className='h-6 w-6 animate-spin' />
<span className='ml-2 text-sm sm:text-base'>
Loading logs...
</span>
</div>
{hasActiveFilters && (
<CardDescription>
Showing logs filtered by {activeFilterDescription}
</CardDescription>
)}
</CardHeader>
<CardContent className='overflow-hidden'>
{isLoading ? (
<div className='space-y-2'>
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton
key={`logs-loading-${index}`}
className='h-16 w-full rounded-lg'
/>
))}
</div>
) : logsData?.logs && logsData.logs.length > 0 ? (
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
<div className='space-y-2 pr-3'>
{logsData.logs.map((entry, index) => (
<LogEntryCard
key={`${entry.request_id}-${entry.asctime}-${entry.lineno}-${index}`}
entry={entry}
onClick={handleLogClick}
/>
))}
</div>
) : logsData?.logs && logsData.logs.length > 0 ? (
<>
<ScrollArea className='h-[500px] w-full sm:h-[600px]'>
<div className='space-y-2 pr-3'>
{logsData.logs.map((entry, index) => (
<LogEntryCard
key={`${entry.request_id}-${entry.asctime}-${entry.lineno}-${index}`}
entry={entry}
onClick={handleLogClick}
/>
))}
</div>
</ScrollArea>
</>
) : (
<div className='text-muted-foreground py-8 text-center'>
<FileText className='mx-auto mb-4 h-10 w-10 opacity-50 sm:h-12 sm:w-12' />
<p className='text-sm sm:text-base'>No log entries found</p>
<p className='text-xs sm:text-sm'>
Try adjusting your filters or check back later
</p>
</div>
)}
</CardContent>
</Card>
</ScrollArea>
) : (
<Empty className='py-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<FileText className='h-4 w-4' />
</EmptyMedia>
<EmptyTitle>No log entries found</EmptyTitle>
<EmptyDescription>
Try adjusting your filters or check back later.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</CardContent>
</Card>
<LogDetailsDialog
log={selectedLog}
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
/>
</div>
</SidebarInset>
</SidebarProvider>
<LogDetailsDialog
log={selectedLog}
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
/>
</div>
</AppPageShell>
);
}

View File

@@ -1,329 +1,5 @@
'use client';
import { ModelsPage } from '@/components/models-page';
import { ModelSelector } from '@/components/ModelSelector';
import { ModelTester } from '@/components/ModelTester';
import { ApiEndpointTester } from '@/components/ApiEndpointTester';
import { ModelSearchFilter } from '@/components/ModelSearchFilter';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useQuery } from '@tanstack/react-query';
import { AdminService } from '@/lib/api/services/admin';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle, Users, Globe } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { useMemo, useState } from 'react';
import type { Model } from '@/lib/api/schemas/models';
import { groupAndSortModelsByProvider } from '@/lib/utils/modelSort';
export default function ModelsPage() {
const [filteredModels, setFilteredModels] = useState<Model[]>([]);
const {
data: modelsData,
isLoading: isLoadingModels,
error: modelsError,
} = useQuery({
queryKey: ['admin-models-with-providers'],
queryFn: () => AdminService.getModelsWithProviders(),
refetchOnWindowFocus: false,
});
const { models = [], groups = [] } = modelsData || {};
const groupedModels = useMemo(() => {
return groupAndSortModelsByProvider(models);
}, [models]);
const groupDataMap = useMemo(() => {
return new Map(groups.map((group) => [group.provider, group]));
}, [groups]);
const providerInfo = useMemo(() => {
const allProviders = new Set([
...Object.keys(groupedModels),
...groups.map((g) => g.provider),
]);
console.log(allProviders);
return Array.from(allProviders).map((provider) => {
const providerModels = groupedModels[provider] || [];
const groupData = groupDataMap.get(provider);
const activeModels = providerModels.filter(
(m) => m.isEnabled && !m.soft_deleted
).length;
const totalModels = providerModels.length;
return {
provider,
activeModels,
totalModels,
groupData,
hasGroupUrl: !!groupData?.group_url,
hasGroupApiKey: !!groupData?.group_api_key,
};
});
}, [groupedModels, groupDataMap, groups]);
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset>
<SiteHeader />
<div className='flex flex-1 flex-col'>
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
<div className='mb-6 flex items-center justify-between'>
<h1 className='text-2xl font-bold tracking-tight'>
Model Management & API Testing
</h1>
</div>
<Tabs defaultValue='manage' className='w-full'>
<TabsList className='grid w-full grid-cols-3'>
<TabsTrigger value='manage'>Manage Models</TabsTrigger>
{/*<TabsTrigger value='test-basic'>Basic Testing</TabsTrigger>
<TabsTrigger value='test-api'>API Endpoints</TabsTrigger> */}
</TabsList>
<TabsContent value='manage' className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Manage your AI models organized by provider groups. Configure
API keys, and organize models by provider groups.
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[60px] w-full' />
<Skeleton className='h-[400px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models. Please try refreshing the page.
</AlertDescription>
</Alert>
) : (
<Tabs defaultValue='all' className='w-full'>
<div className='space-y-4'>
{/* Provider Tabs Navigation */}
<div className='overflow-x-auto rounded-lg border p-1'>
<TabsList className='grid w-full max-w-full min-w-max auto-cols-fr grid-flow-col gap-1 sm:gap-2'>
<TabsTrigger
value='all'
className='flex items-center gap-1 text-xs whitespace-nowrap sm:gap-2 sm:text-sm'
>
<Globe className='h-3 w-3 sm:h-4 sm:w-4' />
<span className='hidden sm:inline'>All Models</span>
<span className='sm:hidden'>All</span>
<Badge variant='secondary' className='ml-1 text-xs'>
{models.length}
</Badge>
</TabsTrigger>
{providerInfo.map(
({ provider, activeModels, totalModels }) => (
<TabsTrigger
key={provider}
value={provider}
className='flex min-w-fit items-center gap-1 text-xs whitespace-nowrap sm:gap-2 sm:text-sm'
>
<Users className='h-3 w-3 sm:h-4 sm:w-4' />
<span className='max-w-20 truncate sm:max-w-none'>
{provider}
</span>
<div className='flex items-center gap-1'>
<Badge
variant='secondary'
className='ml-1 text-xs'
>
{activeModels}/{totalModels}
</Badge>
</div>
</TabsTrigger>
)
)}
</TabsList>
</div>
{/* All Models Tab */}
<TabsContent value='all'>
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Overview of all models across all provider groups.
</div>
<ModelSearchFilter
models={models}
onFilteredModelsChange={setFilteredModels}
/>
<ModelSelector
filteredModels={filteredModels}
showDeleteAllButton={true}
/>
</div>
</TabsContent>
{providerInfo.map(
({ provider, totalModels, groupData }) => {
const providerModels = groupedModels[provider] || [];
return (
<TabsContent key={provider} value={provider}>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div>
<h3 className='flex items-center gap-2 text-lg font-semibold'>
<Users className='h-5 w-5' />
{provider}
</h3>
<div className='text-muted-foreground flex items-center gap-4 text-sm'>
{providerModels.filter(
(m) => m.soft_deleted
).length > 0 && (
<span className='text-orange-600'>
{
providerModels.filter(
(m) => m.soft_deleted
).length
}{' '}
disabled
</span>
)}
{groupData?.group_url && (
<span className='flex items-center gap-1'>
<Globe className='h-3 w-3' />
{groupData.group_url}
</span>
)}
{totalModels === 0 && (
<span className='text-muted-foreground'>
No models configured
</span>
)}
</div>
</div>
</div>
{totalModels === 0 && (
<Alert className='mb-4'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
<div className='space-y-2'>
<p className='font-medium'>
No models found for this provider
</p>
<div className='space-y-1 text-sm'>
<p className='font-medium'>
Common issues:
</p>
<ul className='ml-2 list-inside list-disc space-y-1'>
<li>
<strong>API credentials:</strong>{' '}
Check if the API key is correct
and has the right permissions
</li>
<li>
<strong>Base URL:</strong> Verify
the base URL is correct for your
provider
</li>
<li>
<strong>Network access:</strong>{' '}
Ensure the server can reach the
provider&apos;s API endpoint
</li>
<li>
<strong>Provider status:</strong>{' '}
The upstream provider might be
temporarily unavailable
</li>
</ul>
{groupData?.group_url && (
<p className='text-muted-foreground mt-2 text-xs'>
Current endpoint:{' '}
<code className='bg-muted rounded px-1'>
{groupData.group_url}
</code>
</p>
)}
</div>
</div>
</AlertDescription>
</Alert>
)}
<ModelSelector
filterProvider={provider}
groupData={groupData}
showProviderActions={true}
showDeleteAllButton={false}
/>
</div>
</TabsContent>
);
}
)}
</div>
</Tabs>
)}
</TabsContent>
<TabsContent value='test-basic' className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Test model credentials and connectivity with basic chat
completion requests through the secure proxy (resolves CORS
and Docker network issues). Models can be tested even without
API keys configured (useful for free models or when
authentication is handled elsewhere).
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[200px] w-full' />
<Skeleton className='h-[100px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models for testing. Please try refreshing
the page.
</AlertDescription>
</Alert>
) : (
<ModelTester models={models} />
)}
</TabsContent>
<TabsContent value='test-api' className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Comprehensive testing of all OpenAI API endpoints including
chat completions, embeddings, image generation, audio
synthesis, and model listing through the secure proxy
(resolves CORS and Docker network issues). Models can be
tested with or without API keys configured.
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[300px] w-full' />
<Skeleton className='h-[200px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models for API testing. Please try
refreshing the page.
</AlertDescription>
</Alert>
) : (
<ApiEndpointTester models={models} />
)}
</TabsContent>
</Tabs>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
export default function ModelPage() {
return <ModelsPage />;
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,11 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState, type ReactNode } from 'react';
import { Toaster } from 'sonner';
import { AuthProvider } from '@/lib/auth/AuthContext';
import { ProtectedRoute } from '@/lib/auth/ProtectedRoute';
import { AuthProvider } from '@/lib/auth/auth-context';
import { ProtectedRoute } from '@/lib/auth/protected-route';
import { ThemeProvider } from '@/components/theme-provider';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
interface ProvidersProps {
children: ReactNode;
@@ -34,12 +35,14 @@ export function Providers({ children }: ProvidersProps) {
enableSystem
disableTransitionOnChange
>
<AuthProvider>
<ProtectedRoute>
{children}
<Toaster position='top-right' />
</ProtectedRoute>
</AuthProvider>
<TooltipProvider>
<AuthProvider>
<ProtectedRoute>
{children}
<Toaster position='top-right' />
</ProtectedRoute>
</AuthProvider>
</TooltipProvider>
<ReactQueryDevtools initialIsOpen={false} />
</ThemeProvider>
</QueryClientProvider>

File diff suppressed because it is too large Load Diff

View File

@@ -4,37 +4,35 @@ import * as React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ServerConfigSettings } from '@/components/settings/server-config-settings';
import { AdminSettings } from '@/components/settings/admin-settings';
import { SiteHeader } from '@/components/site-header';
import { AppSidebar } from '@/components/app-sidebar';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { Toaster } from 'sonner';
import { CliTokensSettings } from '@/components/settings/cli-tokens-settings';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
export default function SettingsPage() {
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset>
<SiteHeader />
<div className='flex flex-1 flex-col'>
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
<div className='flex items-center'>
<h1 className='text-2xl font-bold tracking-tight'>Settings</h1>
</div>
<Tabs defaultValue='admin' className='w-full'>
<TabsList className='mb-4'>
<TabsTrigger value='admin'>Admin Settings</TabsTrigger>
</TabsList>
<TabsContent value='server'>
<ServerConfigSettings />
</TabsContent>
<TabsContent value='admin'>
<AdminSettings />
</TabsContent>
</Tabs>
</div>
</div>
</SidebarInset>
<Toaster />
</SidebarProvider>
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
<div className='space-y-6'>
<PageHeader
title='Settings'
description='Manage admin authentication, service metadata, and upstream forwarding.'
/>
<Tabs defaultValue='admin' className='w-full'>
<TabsList variant='line' className='mb-4 w-full'>
<TabsTrigger value='admin'>Admin Settings</TabsTrigger>
<TabsTrigger value='server'>Server Config</TabsTrigger>
<TabsTrigger value='cli-tokens'>CLI Tokens</TabsTrigger>
</TabsList>
<TabsContent value='server'>
<ServerConfigSettings />
</TabsContent>
<TabsContent value='admin'>
<AdminSettings />
</TabsContent>
<TabsContent value='cli-tokens'>
<CliTokensSettings />
</TabsContent>
</Tabs>
</div>
</AppPageShell>
);
}

View File

@@ -1,20 +1,28 @@
'use client';
import { useState } from 'react';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { useQuery } from '@tanstack/react-query';
import { Skeleton } from '@/components/ui/skeleton';
import { useState, useEffect } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
import {
AlertCircle,
Copy,
RefreshCw,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
@@ -23,331 +31,530 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import {
RefreshCw,
Search,
ArrowDownLeft,
ArrowUpRight,
Copy,
Check,
Receipt,
Key,
Zap,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { AdminService, type Transaction } from '@/lib/api/services/admin';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { apiClient } from '@/lib/api/client';
interface Transaction {
id: string;
created_at: string;
token: string;
amount: string;
}
const STORAGE_KEY = 'routstr-transaction-filters';
interface PaginatedTransactionsResponse {
function TransactionTable({
transactions,
copiedId,
onCopy,
getStatusBadge,
}: {
transactions: Transaction[];
total: number;
page: number;
per_page: number;
total_pages: number;
copiedId: string | null;
onCopy: (text: string, id: string) => void;
getStatusBadge: (tx: Transaction) => React.ReactNode;
}) {
if (transactions.length === 0) {
return (
<Empty className='py-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Receipt className='h-4 w-4' />
</EmptyMedia>
<EmptyTitle>No transactions found</EmptyTitle>
<EmptyDescription>
Try adjusting your filters or check back later.
</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
return (
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
<div className='min-w-[800px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>API Key</TableHead>
<TableHead>Request ID</TableHead>
<TableHead>Mint</TableHead>
<TableHead>Date</TableHead>
<TableHead className='text-right'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.map((tx) => (
<TableRow key={tx.id}>
<TableCell>
<div className='flex items-center gap-2'>
{tx.type === 'in' ? (
<ArrowDownLeft className='h-4 w-4 text-green-500' />
) : (
<ArrowUpRight className='h-4 w-4 text-blue-500' />
)}
<span className='capitalize'>{tx.type}</span>
</div>
</TableCell>
<TableCell className='font-mono'>
{tx.amount} {tx.unit}
</TableCell>
<TableCell>{getStatusBadge(tx)}</TableCell>
<TableCell>
{tx.api_key_hashed_key ? (
<div className='flex items-center gap-1 text-xs'>
<span className='max-w-[120px] truncate font-mono'>
{tx.api_key_hashed_key.slice(0, 12)}...
</span>
<Button
variant='ghost'
size='icon'
className='h-4 w-4'
onClick={() =>
onCopy(tx.api_key_hashed_key!, tx.id + '-apikey')
}
>
{copiedId === tx.id + '-apikey' ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
</div>
) : (
<span className='text-muted-foreground text-xs'></span>
)}
</TableCell>
<TableCell>
{tx.request_id ? (
<div className='flex items-center gap-1 text-xs'>
<span className='max-w-[150px] truncate font-mono'>
{tx.request_id}
</span>
<Button
variant='ghost'
size='icon'
className='h-4 w-4'
onClick={() => onCopy(tx.request_id!, tx.id + '-req')}
>
{copiedId === tx.id + '-req' ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
</div>
) : (
<span className='text-muted-foreground text-xs'></span>
)}
</TableCell>
<TableCell>
<div className='flex max-w-[150px] items-center gap-1 truncate text-xs'>
<span className='truncate'>{tx.mint_url}</span>
</div>
</TableCell>
<TableCell className='text-xs whitespace-nowrap'>
{format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')}
</TableCell>
<TableCell className='text-right'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => onCopy(tx.token, tx.id + '-token')}
title='Copy Token'
>
{copiedId === tx.id + '-token' ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<ScrollBar orientation='horizontal' />
</ScrollArea>
);
}
const TransactionService = {
getAllTransactions: async (): Promise<Transaction[]> => {
try {
const response = await apiClient.get<Transaction[]>('/api/transactions');
return response || [];
} catch (error) {
console.error('Failed to fetch transactions:', error);
throw new Error('Failed to fetch transactions');
}
},
getPaginatedTransactions: async (
page: number,
perPage: number
): Promise<PaginatedTransactionsResponse> => {
try {
const response = await apiClient.get<PaginatedTransactionsResponse>(
`/api/transactions/paginated/${page}/${perPage}`
);
return response;
} catch (error) {
console.error('Failed to fetch paginated transactions:', error);
throw new Error('Failed to fetch paginated transactions');
}
},
getRecentTransactions: async (limit: number): Promise<Transaction[]> => {
try {
const response = await apiClient.get<Transaction[]>(
`/api/transactions/recent/${limit}`
);
return response || [];
} catch (error) {
console.error('Failed to fetch recent transactions:', error);
throw new Error('Failed to fetch recent transactions');
}
},
};
export default function TransactionsPage() {
const [currentPage, setCurrentPage] = useState(1);
const perPage = 20;
const [search, setSearch] = useState('');
const [type, setType] = useState<string>('all');
const [status, setStatus] = useState<string>('all');
const [copiedId, setCopiedId] = useState<string | null>(null);
// Fetch paginated transactions data
const {
data: paginationData,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['transactions', currentPage, perPage],
// Load filters from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.search) setSearch(parsed.search);
if (parsed.type) setType(parsed.type);
if (parsed.status) setStatus(parsed.status);
} catch (e) {
console.error('Failed to load filters from localStorage', e);
}
}
}, []);
// Save filters to localStorage whenever they change
useEffect(() => {
const filters = { search, type, status };
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
}, [search, type, status]);
const PAGE_SIZE = 50;
const [activeTab, setActiveTab] = useState<string>('x-cashu');
const [xcashuPage, setXcashuPage] = useState(0);
const [apikeyPage, setApikeyPage] = useState(0);
const typeParam = type === 'all' ? undefined : type;
const statusParam = status === 'all' ? undefined : status;
const searchParam = search || undefined;
const xcashuQuery = useQuery({
queryKey: [
'transactions',
'x-cashu',
typeParam,
statusParam,
searchParam,
xcashuPage,
],
queryFn: () =>
TransactionService.getPaginatedTransactions(currentPage, perPage),
refetchOnWindowFocus: false,
retry: 1,
staleTime: 30000, // 30 seconds
AdminService.getTransactions(
typeParam,
statusParam,
searchParam,
'x-cashu',
PAGE_SIZE,
xcashuPage * PAGE_SIZE
),
placeholderData: keepPreviousData,
});
const transactions = paginationData?.transactions || [];
const totalPages = paginationData?.total_pages || 0;
const total = paginationData?.total || 0;
const apikeyQuery = useQuery({
queryKey: [
'transactions',
'apikey',
typeParam,
statusParam,
searchParam,
apikeyPage,
],
queryFn: () =>
AdminService.getTransactions(
typeParam,
statusParam,
searchParam,
'apikey',
PAGE_SIZE,
apikeyPage * PAGE_SIZE
),
placeholderData: keepPreviousData,
});
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
const handleClearFilters = () => {
setSearch('');
setType('all');
setStatus('all');
setXcashuPage(0);
setApikeyPage(0);
};
const formatAmount = (amount: string) => {
return `${parseInt(amount).toLocaleString()} msats`;
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text);
setCopiedId(id);
toast.success('Copied to clipboard');
setTimeout(() => setCopiedId(null), 2000);
};
const truncateToken = (token: string) => {
if (token.length <= 20) return token;
return `${token.slice(0, 10)}...${token.slice(-10)}`;
const getStatusBadge = (tx: Transaction) => {
if (tx.swept)
return (
<Badge
variant='outline'
className='border-orange-500/20 bg-orange-500/10 text-orange-500'
>
Swept
</Badge>
);
if (tx.collected)
return (
<Badge
variant='outline'
className='border-green-500/20 bg-green-500/10 text-green-500'
>
Collected
</Badge>
);
return (
<Badge
variant='outline'
className='border-blue-500/20 bg-blue-500/10 text-blue-500'
>
Pending
</Badge>
);
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success('Token copied to clipboard!');
} catch (error) {
console.error('Failed to copy to clipboard:', error);
toast.error('Failed to copy token');
const hasActiveFilters =
type !== 'all' || status !== 'all' || Boolean(search);
const activeFilterDescription = [
type !== 'all' ? `type ${type === 'in' ? 'incoming' : 'outgoing'}` : null,
status !== 'all' ? `status ${status}` : null,
search ? `search "${search}"` : null,
]
.filter(Boolean)
.join(' • ');
// Reset pages when filters change
useEffect(() => {
setXcashuPage(0);
setApikeyPage(0);
}, [type, status, search]);
const isRefetching = xcashuQuery.isRefetching || apikeyQuery.isRefetching;
const renderCardContent = (
query: typeof xcashuQuery,
page: number,
setPage: (p: number) => void
) => {
if (query.isLoading) {
return (
<div className='space-y-2'>
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton
key={`tx-loading-${index}`}
className='h-16 w-full rounded-lg'
/>
))}
</div>
);
}
};
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages) {
setCurrentPage(page);
}
};
const transactions = query.data?.transactions ?? [];
const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / PAGE_SIZE);
const goToPrevious = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const goToNext = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
return (
<>
{totalPages > 1 && (
<div className='flex flex-col gap-2 border-b pb-3 sm:flex-row sm:items-center sm:justify-between'>
<span className='text-muted-foreground text-xs sm:text-sm'>
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)}{' '}
of {total}
</span>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
<ChevronLeft className='h-4 w-4' />
<span className='hidden sm:inline'>Previous</span>
</Button>
<span className='text-xs sm:text-sm'>
{page + 1} / {totalPages}
</span>
<Button
variant='outline'
size='sm'
disabled={page >= totalPages - 1}
onClick={() => setPage(page + 1)}
>
<span className='hidden sm:inline'>Next</span>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
</div>
)}
<TransactionTable
transactions={transactions}
copiedId={copiedId}
onCopy={copyToClipboard}
getStatusBadge={getStatusBadge}
/>
</>
);
};
return (
<TooltipProvider>
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset>
<SiteHeader />
<div className='flex flex-1 flex-col'>
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
<div className='mb-6 flex items-center justify-between'>
<div>
<h1 className='text-2xl font-bold tracking-tight'>
Transaction History
</h1>
<p className='text-muted-foreground text-sm'>
View all Cashu token transactions processed by the system
</p>
</div>
<Button
onClick={() => refetch()}
variant='outline'
size='sm'
disabled={isLoading}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`}
<AppPageShell contentClassName='mx-auto w-full max-w-5xl overflow-x-hidden'>
<div className='space-y-6'>
<PageHeader
title='Cashu Transactions'
description='View all incoming and outgoing Cashu token transactions.'
actions={
<Button
onClick={() => {
xcashuQuery.refetch();
apikeyQuery.refetch();
}}
variant='outline'
size='sm'
disabled={isRefetching}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isRefetching ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
}
/>
<Card className='mb-6'>
<CardHeader>
<CardTitle>Filters</CardTitle>
<CardDescription>
Filter transactions by type, status, or search text
</CardDescription>
</CardHeader>
<CardContent>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
<div className='space-y-2'>
<Label htmlFor='search'>Search</Label>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
id='search'
placeholder='Search by ID, token, request ID or key hash...'
className='pl-8'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
Refresh
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='type'>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger>
<SelectValue placeholder='Type' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Types</SelectItem>
<SelectItem value='in'>Incoming (Payments)</SelectItem>
<SelectItem value='out'>Outgoing (Refunds)</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='status'>Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder='Status' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Statuses</SelectItem>
<SelectItem value='pending'>Pending</SelectItem>
<SelectItem value='collected'>Collected</SelectItem>
<SelectItem value='swept'>Swept</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex items-end sm:col-span-2 lg:col-span-1'>
<Button
onClick={handleClearFilters}
variant='outline'
className='w-full'
>
Clear Filters
</Button>
</div>
</div>
</CardContent>
</Card>
{isLoading ? (
<div className='space-y-4'>
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className='h-[120px] w-full' />
))}
</div>
) : error ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load transactions.{' '}
{error instanceof Error
? error.message
: 'Please check if the server is running and try refreshing the page.'}
</AlertDescription>
</Alert>
) : transactions.length === 0 ? (
<div className='py-8 text-center'>
<p className='text-muted-foreground'>
No transactions found.
</p>
</div>
) : (
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div className='text-muted-foreground text-sm'>
Showing {(currentPage - 1) * perPage + 1} to{' '}
{Math.min(currentPage * perPage, total)} of {total}{' '}
transactions
</div>
<div className='text-muted-foreground text-sm'>
Page {currentPage} of {totalPages}
</div>
</div>
<Tabs
defaultValue='x-cashu'
value={activeTab}
onValueChange={setActiveTab}
>
<TabsList className='mb-4'>
<TabsTrigger value='x-cashu' className='flex items-center gap-2'>
<Zap className='h-4 w-4' />
X-Cashu
{xcashuQuery.data && (
<Badge variant='secondary' className='ml-1'>
{xcashuQuery.data.total}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value='apikey' className='flex items-center gap-2'>
<Key className='h-4 w-4' />
API Key
{apikeyQuery.data && (
<Badge variant='secondary' className='ml-1'>
{apikeyQuery.data.total}
</Badge>
)}
</TabsTrigger>
</TabsList>
<div className='rounded-md border'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-[100px]'>ID</TableHead>
<TableHead>Date & Time</TableHead>
<TableHead>Amount</TableHead>
<TableHead className='w-[400px]'>
Cashu Token
</TableHead>
<TableHead className='w-[60px]'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.map((transaction) => (
<TableRow key={transaction.id}>
<TableCell className='font-mono text-xs'>
{transaction.id.slice(0, 8)}
</TableCell>
<TableCell>
<div className='text-sm'>
{formatDate(transaction.created_at)}
</div>
</TableCell>
<TableCell>
<Badge variant='secondary'>
{formatAmount(transaction.amount)}
</Badge>
</TableCell>
<TableCell>
<div className='flex items-center gap-2'>
<Tooltip>
<TooltipTrigger asChild>
<p className='max-w-[300px] cursor-pointer truncate rounded px-1 py-0.5 font-mono text-xs hover:bg-gray-100'>
{truncateToken(transaction.token)}
</p>
</TooltipTrigger>
<TooltipContent className='max-w-md break-all'>
<p className='font-mono text-xs'>
{transaction.token}
</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<TableCell>
<Button
variant='ghost'
size='sm'
onClick={() =>
copyToClipboard(transaction.token)
}
className='h-8 w-8 p-0'
>
<Copy className='h-4 w-4' />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className='flex items-center justify-between'>
<div className='text-muted-foreground text-sm'>
Page {currentPage} of {totalPages}
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
size='sm'
onClick={goToPrevious}
disabled={currentPage === 1}
>
<ChevronLeft className='mr-1 h-4 w-4' />
Previous
</Button>
{/* Page Numbers */}
<div className='flex items-center space-x-1'>
{Array.from(
{ length: Math.min(5, totalPages) },
(_, i) => {
const pageNumber =
currentPage <= 3
? i + 1
: currentPage >= totalPages - 2
? totalPages - 4 + i
: currentPage - 2 + i;
if (pageNumber < 1 || pageNumber > totalPages)
return null;
return (
<Button
key={pageNumber}
variant={
currentPage === pageNumber
? 'default'
: 'outline'
}
size='sm'
onClick={() => goToPage(pageNumber)}
className='h-9 w-9 p-0'
>
{pageNumber}
</Button>
);
}
)}
</div>
<Button
variant='outline'
size='sm'
onClick={goToNext}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className='ml-1 h-4 w-4' />
</Button>
</div>
</div>
<TabsContent value='x-cashu'>
<Card>
<CardHeader>
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
<CardTitle>X-Cashu Transaction History</CardTitle>
{hasActiveFilters && (
<CardDescription>
Filtered by {activeFilterDescription}
</CardDescription>
)}
</div>
)}
</div>
</div>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
</CardHeader>
<CardContent className='overflow-hidden'>
{renderCardContent(xcashuQuery, xcashuPage, setXcashuPage)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value='apikey'>
<Card>
<CardHeader>
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
<CardTitle>API Key Transaction History</CardTitle>
{hasActiveFilters && (
<CardDescription>
Filtered by {activeFilterDescription}
</CardDescription>
)}
</div>
</CardHeader>
<CardContent className='overflow-hidden'>
{renderCardContent(apikeyQuery, apikeyPage, setApikeyPage)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</AppPageShell>
);
}

View File

@@ -3,30 +3,34 @@
import { Button } from '@/components/ui/button';
import { useRouter } from 'next/navigation';
import { ShieldAlertIcon } from 'lucide-react';
import { AuthPageShell } from '@/components/auth-page-shell';
export default function UnauthorizedPage() {
const router = useRouter();
return (
<div className='bg-background flex min-h-screen flex-col items-center justify-center p-4'>
<div className='flex max-w-md flex-col items-center space-y-6 text-center'>
<ShieldAlertIcon className='text-destructive h-24 w-24' />
<h1 className='text-4xl font-bold'>Access Denied</h1>
<p className='text-muted-foreground text-lg'>
You don&apos;t have permission to access this page. Please contact
your administrator if you believe this is an error.
<AuthPageShell
title='Access Denied'
description="You don't have permission to access this page."
>
<div className='flex flex-col items-center gap-6'>
<ShieldAlertIcon className='text-destructive h-20 w-20' />
<p className='text-muted-foreground text-center text-sm'>
Contact your administrator if you believe this is an error.
</p>
<div className='flex gap-4'>
<Button onClick={() => router.push('/')}>Go to Dashboard</Button>
<Button variant='outline' onClick={() => router.back()}>
<div className='flex w-full flex-col gap-3 sm:w-auto sm:flex-row'>
<Button onClick={() => router.push('/')} className='w-full sm:w-auto'>
Go to Dashboard
</Button>
<Button
variant='outline'
onClick={() => router.back()}
className='w-full sm:w-auto'
>
Go Back
</Button>
</div>
</div>
</div>
</AuthPageShell>
);
}

View File

@@ -1,15 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "stone",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -17,5 +19,5 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"registries": {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,470 +0,0 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { type Model } from '@/lib/api/schemas/models';
import { AdminService } from '@/lib/api/services/admin';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Edit3, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Switch } from '@/components/ui/switch';
const EditModelFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
context_length: z.coerce.number().min(0),
prompt: z.coerce.number().min(0),
completion: z.coerce.number().min(0),
enabled: z.boolean(),
});
type EditModelFormData = z.infer<typeof EditModelFormSchema>;
const roundToFiveDecimals = (value: number | undefined | null): number => {
if (value === undefined || value === null || isNaN(value)) {
return 0;
}
return Math.round(value * 100000) / 100000;
};
interface EditModelFormProps {
model: Model;
providerId?: number;
onModelUpdate?: () => void;
onCancel?: () => void;
isOpen: boolean;
}
interface AdminModelData {
id: string;
name: string;
description?: string;
created: number;
context_length: number;
architecture: {
modality: string;
input_modalities: string[];
output_modalities: string[];
tokenizer: string;
instruct_type: string | null;
};
pricing: {
prompt: number;
completion: number;
request: number;
image: number;
web_search: number;
internal_reasoning: number;
};
per_request_limits: null | undefined;
top_provider: null | undefined;
upstream_provider_id: number;
enabled: boolean;
}
export function EditModelForm({
model,
providerId,
onModelUpdate,
onCancel,
isOpen,
}: EditModelFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [adminModelData, setAdminModelData] = useState<AdminModelData | null>(
null
);
const [isNewOverride, setIsNewOverride] = useState(false);
const form = useForm<EditModelFormData>({
resolver: zodResolver(EditModelFormSchema),
defaultValues: {
name: model.name,
description: model.description || '',
context_length: model.contextLength || 4096,
prompt: roundToFiveDecimals(model.input_cost),
completion: roundToFiveDecimals(model.output_cost),
enabled: model.isEnabled !== false,
},
});
const loadAdminModel = useCallback(async () => {
if (!providerId) {
console.error('loadAdminModel called without providerId');
return;
}
try {
console.log('Loading admin model:', {
providerId,
modelId: model.full_name,
});
const adminModel = await AdminService.getProviderModel(
providerId,
model.id
);
setAdminModelData(adminModel as AdminModelData);
setIsNewOverride(false);
form.reset({
name: adminModel.name,
description: adminModel.description || '',
context_length: adminModel.context_length,
prompt: roundToFiveDecimals(adminModel.pricing.prompt),
completion: roundToFiveDecimals(adminModel.pricing.completion),
enabled: adminModel.enabled !== false,
});
} catch (error: unknown) {
console.log('Model not in database, will create new override:', error);
setIsNewOverride(true);
setAdminModelData({
id: model.full_name,
name: model.name,
description: model.description || '',
created: Math.floor(Date.now() / 1000),
context_length: model.contextLength || 4096,
architecture: {
modality: model.modelType || 'text',
input_modalities: [model.modelType || 'text'],
output_modalities: [model.modelType || 'text'],
tokenizer: '',
instruct_type: null,
},
pricing: {
prompt: roundToFiveDecimals(model.input_cost),
completion: roundToFiveDecimals(model.output_cost),
request: 0,
image: 0,
web_search: 0,
internal_reasoning: 0,
},
per_request_limits: null,
top_provider: null,
upstream_provider_id: providerId,
enabled: model.isEnabled !== false,
});
form.reset({
name: model.name,
description: model.description || '',
context_length: model.contextLength || 4096,
prompt: roundToFiveDecimals(model.input_cost),
completion: roundToFiveDecimals(model.output_cost),
enabled: model.isEnabled !== false,
});
}
}, [providerId, model, form]);
useEffect(() => {
if (isOpen && providerId) {
loadAdminModel();
} else if (isOpen && !providerId) {
console.error('EditModelForm opened without providerId', {
model,
providerId,
});
toast.error('Missing provider information for this model');
}
}, [isOpen, providerId, model, loadAdminModel]);
const onSubmit = async (data: EditModelFormData) => {
if (!providerId) {
console.error('onSubmit called without providerId', {
model,
providerId,
});
toast.error('Missing provider ID - cannot update model');
return;
}
if (!adminModelData) {
console.error('onSubmit called without adminModelData', {
model,
providerId,
adminModelData,
});
toast.error('Model data not loaded - please try reopening the form');
return;
}
setIsSubmitting(true);
try {
const payload = {
id: adminModelData.id,
name: data.name,
description: data.description || '',
created: adminModelData.created || Math.floor(Date.now() / 1000),
context_length: data.context_length,
architecture: adminModelData.architecture || {
modality: 'text',
input_modalities: ['text'],
output_modalities: ['text'],
tokenizer: '',
instruct_type: null,
},
pricing: {
prompt: roundToFiveDecimals(data.prompt),
completion: roundToFiveDecimals(data.completion),
request: 0,
image: 0,
web_search: 0,
internal_reasoning: 0,
},
per_request_limits: adminModelData.per_request_limits,
top_provider: adminModelData.top_provider,
upstream_provider_id: providerId,
enabled: data.enabled,
};
if (isNewOverride) {
console.log('Creating new model override');
await AdminService.createProviderModel(providerId, payload);
toast.success('Model override created successfully!');
} else {
console.log('Updating existing model override');
await AdminService.updateProviderModel(
providerId,
adminModelData.id,
payload
);
toast.success('Model updated successfully!');
}
onModelUpdate?.();
onCancel?.();
} catch (error) {
const action = isNewOverride ? 'create' : 'update';
toast.error(`Failed to ${action} model. Please try again.`);
console.error(`Error ${action}ing model:`, error);
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (!isSubmitting) {
onCancel?.();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[600px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Edit3 className='h-5 w-5' />
{isNewOverride ? 'Create Model Override' : 'Edit Model Override'}
</DialogTitle>
<DialogDescription>
{isNewOverride
? `Create an override for &quot;${model.name}&quot;`
: `Update the model override for &quot;${model.name}&quot;`}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Display Name *</FormLabel>
<FormControl>
<Input
placeholder='e.g., GPT-4'
{...field}
className='w-full'
/>
</FormControl>
<FormDescription>
Custom display name for the model
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='context_length'
render={({ field }) => (
<FormItem>
<FormLabel>Context Length *</FormLabel>
<FormControl>
<Input
type='number'
min='0'
placeholder='4096'
{...field}
className='w-full'
/>
</FormControl>
<FormDescription>
Maximum context window size
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder='Brief description of the model...'
{...field}
rows={3}
className='w-full'
/>
</FormControl>
<FormDescription>
Optional description or notes about the model
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='prompt'
render={({ field }) => (
<FormItem>
<FormLabel>Input Cost (per 1M tokens) *</FormLabel>
<FormControl>
<Input
type='number'
step='0.00001'
min='0'
placeholder='5.00000'
value={field.value ?? ''}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === '' ? 0 : parseFloat(value));
}}
onBlur={(e) => {
const value = parseFloat(e.target.value);
field.onChange(roundToFiveDecimals(value));
}}
className='w-full'
/>
</FormControl>
<FormDescription>
Cost in USD per 1,000,000 input tokens (max 5 decimals)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='completion'
render={({ field }) => (
<FormItem>
<FormLabel>Output Cost (per 1M tokens) *</FormLabel>
<FormControl>
<Input
type='number'
step='0.00001'
min='0'
placeholder='15.00000'
value={field.value ?? ''}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === '' ? 0 : parseFloat(value));
}}
onBlur={(e) => {
const value = parseFloat(e.target.value);
field.onChange(roundToFiveDecimals(value));
}}
className='w-full'
/>
</FormControl>
<FormDescription>
Cost in USD per 1,000,000 output tokens (max 5 decimals)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>Model Enabled</FormLabel>
<FormDescription>
Enable or disable this model override
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className='flex justify-end gap-2 pt-4'>
<Button
type='button'
variant='outline'
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type='submit' disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{isNewOverride ? 'Creating...' : 'Updating...'}
</>
) : isNewOverride ? (
'Create Override'
) : (
'Update Model'
)}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ManualModelSchema, type ManualModel } from '@/lib/api/schemas/models';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -45,9 +46,10 @@ export function AddModelForm({
isOpen,
}: AddModelFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
type ManualModelInput = z.input<typeof ManualModelSchema>;
const form = useForm<ManualModel>({
resolver: zodResolver(ManualModelSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
const form = useForm<ManualModelInput, unknown, ManualModel>({
resolver: zodResolver(ManualModelSchema),
defaultValues: {
name: '',
full_name: '',

View File

@@ -1,6 +1,6 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -39,7 +39,7 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Switch } from '@/components/ui/switch';
import { Loader2, Plus } from 'lucide-react';
import { Check, Copy, Loader2, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
@@ -64,6 +64,7 @@ const FormSchema = z.object({
instruct_type: z.string().default(''),
canonical_slug: z.string().default(''),
alias_ids_raw: z.string().default(''),
forwarded_model_id: z.string().default(''),
upstream_provider_id: z.string().default(''),
input_cost: z.coerce.number().min(0).default(0),
output_cost: z.coerce.number().min(0).default(0),
@@ -104,6 +105,7 @@ export function AddProviderModelDialog({
const [isPresetOpen, setIsPresetOpen] = useState(false);
const [selectedPresetLabel, setSelectedPresetLabel] =
useState('Select a preset');
const [forwardedModelIdCopied, setForwardedModelIdCopied] = useState(false);
const form = useForm<FormData>({
resolver: zodResolver(FormSchema) as never,
@@ -119,6 +121,7 @@ export function AddProviderModelDialog({
instruct_type: '',
canonical_slug: '',
alias_ids_raw: '',
forwarded_model_id: '',
upstream_provider_id: '',
input_cost: 0,
output_cost: 0,
@@ -180,6 +183,7 @@ export function AddProviderModelDialog({
: '',
canonical_slug: initialData.canonical_slug || '',
alias_ids_raw: listToString(initialData.alias_ids),
forwarded_model_id: initialData.forwarded_model_id || initialData.id,
upstream_provider_id:
typeof initialData.upstream_provider_id === 'string'
? initialData.upstream_provider_id
@@ -223,6 +227,7 @@ export function AddProviderModelDialog({
instruct_type: '',
canonical_slug: '',
alias_ids_raw: '',
forwarded_model_id: '',
upstream_provider_id: '',
input_cost: 0,
output_cost: 0,
@@ -280,6 +285,7 @@ export function AddProviderModelDialog({
);
form.setValue('canonical_slug', model.canonical_slug || '');
form.setValue('alias_ids_raw', listToString(model.alias_ids));
form.setValue('forwarded_model_id', model.forwarded_model_id || model.id);
form.setValue(
'upstream_provider_id',
typeof model.upstream_provider_id === 'string'
@@ -385,6 +391,7 @@ export function AddProviderModelDialog({
canonical_slug: data.canonical_slug?.trim() || null,
alias_ids: listFromString(data.alias_ids_raw || ''),
enabled: data.enabled,
forwarded_model_id: data.forwarded_model_id?.trim() || data.id,
};
if (isEdit) {
@@ -520,6 +527,53 @@ export function AddProviderModelDialog({
)}
/>
<FormField
control={form.control}
name='forwarded_model_id'
render={({ field }) => {
const handleCopy = () => {
const value = field.value || form.getValues('id');
if (!value) return;
navigator.clipboard.writeText(value);
setForwardedModelIdCopied(true);
setTimeout(() => setForwardedModelIdCopied(false), 1500);
};
return (
<FormItem>
<FormLabel>Client Alias ID</FormLabel>
<FormControl>
<div className='flex gap-2'>
<Input
placeholder={
form.watch('id') || 'e.g., openai/gpt-4o'
}
{...field}
/>
<Button
type='button'
variant='outline'
size='icon'
onClick={handleCopy}
title='Copy model ID'
>
{forwardedModelIdCopied ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</div>
</FormControl>
<FormDescription>
Alternate ID that clients can use to reference this
model. Defaults to the model&apos;s own ID.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name='name'

View File

@@ -0,0 +1,699 @@
import type { ChangeEvent, Dispatch, SetStateAction } from 'react';
import Image from 'next/image';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Image as ImageIcon, List, Mic, MicOff, Volume2 } from 'lucide-react';
import type { EndpointType } from '@/components/api-endpoint-types';
interface ApiEndpointFormProps {
selectedEndpoint: EndpointType;
maxTokens: number;
setMaxTokens: Dispatch<SetStateAction<number>>;
temperature: number;
setTemperature: Dispatch<SetStateAction<number>>;
systemMessage: string;
setSystemMessage: Dispatch<SetStateAction<string>>;
userMessage: string;
setUserMessage: Dispatch<SetStateAction<string>>;
visionMaxTokens: number;
setVisionMaxTokens: Dispatch<SetStateAction<number>>;
visionTemperature: number;
setVisionTemperature: Dispatch<SetStateAction<number>>;
visionSystemMessage: string;
setVisionSystemMessage: Dispatch<SetStateAction<string>>;
visionUserMessage: string;
setVisionUserMessage: Dispatch<SetStateAction<string>>;
imageDetail: 'low' | 'high' | 'auto';
setImageDetail: Dispatch<SetStateAction<'low' | 'high' | 'auto'>>;
selectedImage: File | null;
imagePreviewUrl: string | null;
onImageUpload: (event: ChangeEvent<HTMLInputElement>) => void;
onRemoveImage: () => void;
embeddingInput: string;
setEmbeddingInput: Dispatch<SetStateAction<string>>;
encodingFormat: 'float' | 'base64';
setEncodingFormat: Dispatch<SetStateAction<'float' | 'base64'>>;
imageCount: number;
setImageCount: Dispatch<SetStateAction<number>>;
imageSize: '256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792';
setImageSize: Dispatch<
SetStateAction<
'256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792'
>
>;
imageQuality: 'standard' | 'hd';
setImageQuality: Dispatch<SetStateAction<'standard' | 'hd'>>;
imageStyle: 'vivid' | 'natural';
setImageStyle: Dispatch<SetStateAction<'vivid' | 'natural'>>;
imagePrompt: string;
setImagePrompt: Dispatch<SetStateAction<string>>;
speechVoice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
setSpeechVoice: Dispatch<
SetStateAction<'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'>
>;
speechFormat: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
setSpeechFormat: Dispatch<
SetStateAction<'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'>
>;
speechSpeed: number;
setSpeechSpeed: Dispatch<SetStateAction<number>>;
speechInput: string;
setSpeechInput: Dispatch<SetStateAction<string>>;
audioTranscriptionPrompt: string;
setAudioTranscriptionPrompt: Dispatch<SetStateAction<string>>;
audioTemperature: number;
setAudioTemperature: Dispatch<SetStateAction<number>>;
audioLanguage: string;
setAudioLanguage: Dispatch<SetStateAction<string>>;
audioResponseFormat: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt';
setAudioResponseFormat: Dispatch<
SetStateAction<'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'>
>;
isRecording: boolean;
onStartRecording: () => void;
onStopRecording: () => void;
onAudioUpload: (event: ChangeEvent<HTMLInputElement>) => void;
recordedAudio: File | null;
recordingUrl: string | null;
onRemoveAudio: () => void;
}
export function ApiEndpointForm({
selectedEndpoint,
maxTokens,
setMaxTokens,
temperature,
setTemperature,
systemMessage,
setSystemMessage,
userMessage,
setUserMessage,
visionMaxTokens,
setVisionMaxTokens,
visionTemperature,
setVisionTemperature,
visionSystemMessage,
setVisionSystemMessage,
visionUserMessage,
setVisionUserMessage,
imageDetail,
setImageDetail,
selectedImage,
imagePreviewUrl,
onImageUpload,
onRemoveImage,
embeddingInput,
setEmbeddingInput,
encodingFormat,
setEncodingFormat,
imageCount,
setImageCount,
imageSize,
setImageSize,
imageQuality,
setImageQuality,
imageStyle,
setImageStyle,
imagePrompt,
setImagePrompt,
speechVoice,
setSpeechVoice,
speechFormat,
setSpeechFormat,
speechSpeed,
setSpeechSpeed,
speechInput,
setSpeechInput,
audioTranscriptionPrompt,
setAudioTranscriptionPrompt,
audioTemperature,
setAudioTemperature,
audioLanguage,
setAudioLanguage,
audioResponseFormat,
setAudioResponseFormat,
isRecording,
onStartRecording,
onStopRecording,
onAudioUpload,
recordedAudio,
recordingUrl,
onRemoveAudio,
}: ApiEndpointFormProps) {
switch (selectedEndpoint) {
case 'chat-completions':
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='max-tokens'>Max Tokens</Label>
<Input
id='max-tokens'
type='number'
min={1}
max={4000}
value={maxTokens}
onChange={(event) =>
setMaxTokens(parseInt(event.target.value) || 150)
}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='temperature'>Temperature</Label>
<Input
id='temperature'
type='number'
min={0}
max={2}
step={0.1}
value={temperature}
onChange={(event) =>
setTemperature(parseFloat(event.target.value) || 0.7)
}
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='system-message'>System Message (Optional)</Label>
<Textarea
id='system-message'
placeholder='Enter system message...'
value={systemMessage}
onChange={(event) => setSystemMessage(event.target.value)}
rows={2}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='user-message'>Test Message</Label>
<Textarea
id='user-message'
placeholder='Enter your test message...'
value={userMessage}
onChange={(event) => setUserMessage(event.target.value)}
rows={3}
/>
</div>
</div>
);
case 'vision-chat':
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='vision-max-tokens'>Max Tokens</Label>
<Input
id='vision-max-tokens'
type='number'
min={1}
max={4000}
value={visionMaxTokens}
onChange={(event) =>
setVisionMaxTokens(parseInt(event.target.value) || 300)
}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='vision-temperature'>Temperature</Label>
<Input
id='vision-temperature'
type='number'
min={0}
max={2}
step={0.1}
value={visionTemperature}
onChange={(event) =>
setVisionTemperature(parseFloat(event.target.value) || 0.7)
}
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='vision-system-message'>
System Message (Optional)
</Label>
<Textarea
id='vision-system-message'
placeholder='Enter system message...'
value={visionSystemMessage}
onChange={(event) => setVisionSystemMessage(event.target.value)}
rows={2}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='vision-user-message'>Test Message</Label>
<Textarea
id='vision-user-message'
placeholder='Enter your test message...'
value={visionUserMessage}
onChange={(event) => setVisionUserMessage(event.target.value)}
rows={3}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='image-detail'>Image Detail</Label>
<Select
value={imageDetail}
onValueChange={(value: 'low' | 'high' | 'auto') =>
setImageDetail(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='low'>Low</SelectItem>
<SelectItem value='high'>High</SelectItem>
<SelectItem value='auto'>Auto</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='image-upload'>Upload Image (Optional)</Label>
<Input
type='file'
accept='image/*'
onChange={onImageUpload}
className='cursor-pointer'
/>
{selectedImage && (
<div className='mt-2 flex flex-wrap items-center gap-2'>
<ImageIcon className='text-muted-foreground h-5 w-5' />
<span className='text-muted-foreground text-sm'>
Selected image: {selectedImage.name}
</span>
<Button
variant='outline'
size='sm'
onClick={onRemoveImage}
className='ml-0 sm:ml-auto'
>
Remove
</Button>
</div>
)}
{imagePreviewUrl && (
<div className='relative mt-2 aspect-square w-32 overflow-hidden rounded-md border'>
<Image
src={imagePreviewUrl}
alt='Image preview'
fill
className='object-cover'
/>
</div>
)}
</div>
</div>
);
case 'embeddings':
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='encoding-format'>Encoding Format</Label>
<Select
value={encodingFormat}
onValueChange={(value: 'float' | 'base64') =>
setEncodingFormat(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='float'>Float</SelectItem>
<SelectItem value='base64'>Base64</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='embedding-input'>Text Input</Label>
<Textarea
id='embedding-input'
placeholder='Enter text to generate embeddings for...'
value={embeddingInput}
onChange={(event) => setEmbeddingInput(event.target.value)}
rows={4}
/>
</div>
</div>
);
case 'images':
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
<div className='space-y-2'>
<Label htmlFor='image-count'>Number of Images</Label>
<Input
id='image-count'
type='number'
min={1}
max={10}
value={imageCount}
onChange={(event) =>
setImageCount(parseInt(event.target.value) || 1)
}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='image-size'>Size</Label>
<Select
value={imageSize}
onValueChange={(
value:
| '256x256'
| '512x512'
| '1024x1024'
| '1792x1024'
| '1024x1792'
) => setImageSize(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='256x256'>256x256</SelectItem>
<SelectItem value='512x512'>512x512</SelectItem>
<SelectItem value='1024x1024'>1024x1024</SelectItem>
<SelectItem value='1792x1024'>1792x1024</SelectItem>
<SelectItem value='1024x1792'>1024x1792</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='image-quality'>Quality</Label>
<Select
value={imageQuality}
onValueChange={(value: 'standard' | 'hd') =>
setImageQuality(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='standard'>Standard</SelectItem>
<SelectItem value='hd'>HD</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='image-style'>Style</Label>
<Select
value={imageStyle}
onValueChange={(value: 'vivid' | 'natural') =>
setImageStyle(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='vivid'>Vivid</SelectItem>
<SelectItem value='natural'>Natural</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='image-prompt'>Image Prompt</Label>
<Textarea
id='image-prompt'
placeholder='Describe the image you want to generate...'
value={imagePrompt}
onChange={(event) => setImagePrompt(event.target.value)}
rows={3}
/>
</div>
</div>
);
case 'audio-speech':
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
<div className='space-y-2'>
<Label htmlFor='speech-voice'>Voice</Label>
<Select
value={speechVoice}
onValueChange={(
value:
| 'alloy'
| 'echo'
| 'fable'
| 'onyx'
| 'nova'
| 'shimmer'
) => setSpeechVoice(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='alloy'>Alloy</SelectItem>
<SelectItem value='echo'>Echo</SelectItem>
<SelectItem value='fable'>Fable</SelectItem>
<SelectItem value='onyx'>Onyx</SelectItem>
<SelectItem value='nova'>Nova</SelectItem>
<SelectItem value='shimmer'>Shimmer</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='speech-format'>Response Format</Label>
<Select
value={speechFormat}
onValueChange={(
value: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
) => setSpeechFormat(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='mp3'>MP3</SelectItem>
<SelectItem value='opus'>Opus</SelectItem>
<SelectItem value='aac'>AAC</SelectItem>
<SelectItem value='flac'>FLAC</SelectItem>
<SelectItem value='wav'>WAV</SelectItem>
<SelectItem value='pcm'>PCM</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='speech-speed'>Speed</Label>
<Input
id='speech-speed'
type='number'
min={0.25}
max={4.0}
step={0.25}
value={speechSpeed}
onChange={(event) =>
setSpeechSpeed(parseFloat(event.target.value) || 1.0)
}
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='speech-input'>Text to Synthesize</Label>
<Textarea
id='speech-input'
placeholder='Enter text to convert to speech...'
value={speechInput}
onChange={(event) => setSpeechInput(event.target.value)}
rows={4}
/>
</div>
</div>
);
case 'audio-transcription':
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='audio-transcription-prompt'>
Prompt (Optional)
</Label>
<Textarea
id='audio-transcription-prompt'
placeholder='Enter a prompt for the transcription...'
value={audioTranscriptionPrompt}
onChange={(event) =>
setAudioTranscriptionPrompt(event.target.value)
}
rows={2}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='audio-transcription-temperature'>
Temperature
</Label>
<Input
id='audio-transcription-temperature'
type='number'
min={0}
max={1}
step={0.1}
value={audioTemperature}
onChange={(event) =>
setAudioTemperature(parseFloat(event.target.value) || 0.0)
}
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='audio-transcription-language'>
Language (Optional)
</Label>
<Input
id='audio-transcription-language'
placeholder='e.g., en-US, fr-FR'
value={audioLanguage}
onChange={(event) => setAudioLanguage(event.target.value)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='audio-transcription-response-format'>
Response Format
</Label>
<Select
value={audioResponseFormat}
onValueChange={(
value: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'
) => setAudioResponseFormat(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='json'>JSON</SelectItem>
<SelectItem value='text'>Text</SelectItem>
<SelectItem value='srt'>SRT</SelectItem>
<SelectItem value='verbose_json'>Verbose JSON</SelectItem>
<SelectItem value='vtt'>VTT</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Voice Recording</Label>
<div className='flex flex-wrap items-center gap-2'>
<Button
type='button'
variant={isRecording ? 'destructive' : 'default'}
size='sm'
onClick={isRecording ? onStopRecording : onStartRecording}
className='flex items-center gap-2'
>
{isRecording ? (
<>
<MicOff className='h-4 w-4' />
Stop Recording
</>
) : (
<>
<Mic className='h-4 w-4' />
Start Recording
</>
)}
</Button>
{isRecording && (
<Badge variant='destructive' className='gap-1.5'>
<span className='h-2 w-2 animate-pulse rounded-full bg-current' />
Recording...
</Badge>
)}
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='audio-transcription-upload'>
Or Upload Audio File
</Label>
<Input
type='file'
accept='audio/*'
onChange={onAudioUpload}
className='cursor-pointer'
/>
{recordedAudio && (
<div className='mt-2 flex flex-wrap items-center gap-2'>
<Volume2 className='text-muted-foreground h-5 w-5' />
<span className='text-muted-foreground text-sm'>
Selected audio: {recordedAudio.name}
</span>
<Button
variant='outline'
size='sm'
onClick={onRemoveAudio}
className='ml-0 sm:ml-auto'
>
Remove
</Button>
</div>
)}
{recordingUrl && (
<div className='bg-muted mt-2 rounded-md p-4'>
<audio controls src={recordingUrl} className='w-full'>
Your browser does not support the audio element.
</audio>
</div>
)}
</div>
</div>
</div>
);
case 'models':
return (
<div className='space-y-4'>
<Alert>
<List className='h-4 w-4' />
<AlertDescription>
This endpoint lists all available models from the provider. No
additional parameters are required.
</AlertDescription>
</Alert>
</div>
);
default:
return null;
}
}

View File

@@ -0,0 +1,306 @@
import Image from 'next/image';
import { Label } from '@/components/ui/label';
export interface ChatCompletionResponse {
id: string;
object: string;
created: number;
model: string;
choices: {
index: number;
message: {
role: string;
content: string;
};
finish_reason: string;
}[];
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export interface EmbeddingResponse {
object: string;
data: {
object: string;
index: number;
embedding: number[];
}[];
model: string;
usage: {
prompt_tokens: number;
total_tokens: number;
};
}
export interface ImageGenerationResponse {
created: number;
data: {
url: string;
revised_prompt?: string;
}[];
}
export interface AudioResponse {
type: 'audio';
url: string;
size: number;
}
export interface AudioTranscriptionResponse {
text: string;
}
export interface ModelsListResponse {
object: string;
data: {
id: string;
object?: string;
created?: number;
}[];
}
export type ApiResponse =
| ChatCompletionResponse
| EmbeddingResponse
| ImageGenerationResponse
| AudioResponse
| AudioTranscriptionResponse
| ModelsListResponse;
const isAudioResponse = (response: ApiResponse): response is AudioResponse => {
return 'type' in response && response.type === 'audio';
};
const isChatCompletionResponse = (
response: ApiResponse
): response is ChatCompletionResponse => {
return 'choices' in response;
};
const isEmbeddingResponse = (
response: ApiResponse
): response is EmbeddingResponse => {
return (
'data' in response &&
Array.isArray(response.data) &&
response.data.length > 0 &&
'embedding' in response.data[0]
);
};
const isImageGenerationResponse = (
response: ApiResponse
): response is ImageGenerationResponse => {
return (
'data' in response &&
Array.isArray(response.data) &&
response.data.length > 0 &&
'url' in response.data[0]
);
};
const isModelsListResponse = (
response: ApiResponse
): response is ModelsListResponse => {
return (
'data' in response &&
Array.isArray(response.data) &&
response.data.length > 0 &&
'id' in response.data[0]
);
};
const isAudioTranscriptionResponse = (
response: ApiResponse
): response is AudioTranscriptionResponse => {
return 'text' in response;
};
export function ApiEndpointResponse({
response,
}: {
response: ApiResponse | null;
}) {
if (!response) {
return null;
}
if (isAudioResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Generated Audio</Label>
<div className='bg-muted rounded-md p-4'>
<audio controls src={response.url} className='w-full'>
Your browser does not support the audio element.
</audio>
<p className='text-muted-foreground mt-2 text-sm'>
Audio file size: {(response.size / 1024).toFixed(2)} KB
</p>
</div>
</div>
</div>
);
}
if (isAudioTranscriptionResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Transcription Result</Label>
<div className='bg-muted rounded-md p-4'>
<p className='text-sm whitespace-pre-wrap'>
{response.text || 'No transcription result available'}
</p>
</div>
</div>
</div>
);
}
if (isChatCompletionResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Model Response</Label>
<div className='bg-muted rounded-md p-4'>
<p className='text-sm whitespace-pre-wrap'>
{response.choices?.[0]?.message?.content ||
'No content in response'}
</p>
</div>
</div>
{response.usage && (
<div className='space-y-2'>
<Label>Usage Statistics</Label>
<div className='grid grid-cols-1 gap-2 text-sm sm:grid-cols-3 sm:gap-4'>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.prompt_tokens}
</div>
<div className='text-muted-foreground'>Prompt Tokens</div>
</div>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.completion_tokens}
</div>
<div className='text-muted-foreground'>Completion Tokens</div>
</div>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.total_tokens}
</div>
<div className='text-muted-foreground'>Total Tokens</div>
</div>
</div>
</div>
)}
</div>
);
}
if (isEmbeddingResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Embedding Vector</Label>
<div className='bg-muted rounded-md p-4'>
<p className='text-muted-foreground mb-2 text-sm'>
Generated {response.data?.[0]?.embedding?.length || 0} dimensional
embedding vector
</p>
<details className='group'>
<summary className='hover:text-foreground cursor-pointer text-sm'>
Show first 10 values
</summary>
<pre className='mt-2 text-xs'>
{JSON.stringify(
response.data?.[0]?.embedding?.slice(0, 10),
null,
2
)}
...
</pre>
</details>
</div>
</div>
{response.usage && (
<div className='space-y-2'>
<Label>Usage Statistics</Label>
<div className='bg-muted rounded p-2 text-center text-sm'>
<div className='font-semibold'>{response.usage.total_tokens}</div>
<div className='text-muted-foreground'>Total Tokens</div>
</div>
</div>
)}
</div>
);
}
if (isImageGenerationResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Generated Images</Label>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
{response.data?.map((image, index: number) => (
<div key={index} className='space-y-2'>
<div className='relative aspect-square w-full'>
<Image
src={image.url}
alt={`Generated image ${index + 1}`}
fill
className='rounded-md border object-cover'
unoptimized={true}
/>
</div>
{image.revised_prompt && (
<p className='text-muted-foreground text-xs'>
Revised prompt: {image.revised_prompt}
</p>
)}
</div>
))}
</div>
</div>
</div>
);
}
if (isModelsListResponse(response)) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Available Models</Label>
<div className='max-h-60 overflow-auto'>
<div className='grid gap-2'>
{response.data?.map((model) => (
<div key={model.id} className='bg-muted rounded-md p-3'>
<div className='font-medium'>{model.id}</div>
{model.object && (
<div className='text-muted-foreground text-sm'>
Type: {model.object}
</div>
)}
{model.created && (
<div className='text-muted-foreground text-sm'>
Created:{' '}
{new Date(model.created * 1000).toLocaleDateString()}
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,730 @@
'use client';
import React, { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { type Model } from '@/lib/api/schemas/models';
import { ModelService } from '@/lib/api/services/models';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Loader2,
Send,
CheckCircle,
XCircle,
Info,
Key,
Globe,
} from 'lucide-react';
import { toast } from 'sonner';
import {
ApiEndpointResponse,
type ApiResponse,
} from '@/components/api-endpoint-response';
import { ApiEndpointForm } from '@/components/api-endpoint-form';
import {
API_ENDPOINTS,
DEFAULT_REQUESTS,
type EndpointType,
type ChatCompletionRequest,
type EmbeddingRequest,
type ImageGenerationRequest,
type AudioSpeechRequest,
type AudioTranscriptionRequest,
type EndpointRequestData,
} from '@/components/api-endpoint-types';
interface ApiEndpointTesterProps {
models: Model[];
}
export function ApiEndpointTester({ models }: ApiEndpointTesterProps) {
const [selectedModelId, setSelectedModelId] = useState<string>('');
const [selectedEndpoint, setSelectedEndpoint] =
useState<EndpointType>('chat-completions');
const [response, setResponse] = useState<ApiResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Chat Completions state
const [systemMessage, setSystemMessage] = useState(
DEFAULT_REQUESTS['chat-completions'].systemMessage
);
const [userMessage, setUserMessage] = useState(
DEFAULT_REQUESTS['chat-completions'].userMessage
);
const [maxTokens, setMaxTokens] = useState(
DEFAULT_REQUESTS['chat-completions'].maxTokens
);
const [temperature, setTemperature] = useState(
DEFAULT_REQUESTS['chat-completions'].temperature
);
// Vision Chat state
const [visionSystemMessage, setVisionSystemMessage] = useState(
DEFAULT_REQUESTS['vision-chat'].systemMessage
);
const [visionUserMessage, setVisionUserMessage] = useState(
DEFAULT_REQUESTS['vision-chat'].userMessage
);
const [visionMaxTokens, setVisionMaxTokens] = useState(
DEFAULT_REQUESTS['vision-chat'].maxTokens
);
const [visionTemperature, setVisionTemperature] = useState(
DEFAULT_REQUESTS['vision-chat'].temperature
);
const [imageDetail, setImageDetail] = useState<'low' | 'high' | 'auto'>(
DEFAULT_REQUESTS['vision-chat'].imageDetail
);
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
// Voice Recording state
const [isRecording, setIsRecording] = useState(false);
const [recordedAudio, setRecordedAudio] = useState<File | null>(null);
const [recordingUrl, setRecordingUrl] = useState<string | null>(null);
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(
null
);
const [audioTranscriptionPrompt, setAudioTranscriptionPrompt] = useState(
DEFAULT_REQUESTS['audio-transcription'].prompt
);
const [audioResponseFormat, setAudioResponseFormat] = useState<
'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'
>(DEFAULT_REQUESTS['audio-transcription'].response_format);
const [audioTemperature, setAudioTemperature] = useState(
DEFAULT_REQUESTS['audio-transcription'].temperature
);
const [audioLanguage, setAudioLanguage] = useState(
DEFAULT_REQUESTS['audio-transcription'].language
);
// Embeddings state
const [embeddingInput, setEmbeddingInput] = useState(
DEFAULT_REQUESTS.embeddings.input
);
const [encodingFormat, setEncodingFormat] = useState<'float' | 'base64'>(
DEFAULT_REQUESTS.embeddings.encoding_format
);
// Image Generation state
const [imagePrompt, setImagePrompt] = useState(
DEFAULT_REQUESTS.images.prompt
);
const [imageCount, setImageCount] = useState(DEFAULT_REQUESTS.images.n);
const [imageSize, setImageSize] = useState<
'256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792'
>(DEFAULT_REQUESTS.images.size);
const [imageQuality, setImageQuality] = useState<'standard' | 'hd'>(
DEFAULT_REQUESTS.images.quality
);
const [imageStyle, setImageStyle] = useState<'vivid' | 'natural'>(
DEFAULT_REQUESTS.images.style
);
// Audio Speech state
const [speechInput, setSpeechInput] = useState(
DEFAULT_REQUESTS['audio-speech'].input
);
const [speechVoice, setSpeechVoice] = useState<
'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'
>(DEFAULT_REQUESTS['audio-speech'].voice);
const [speechFormat, setSpeechFormat] = useState<
'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
>(DEFAULT_REQUESTS['audio-speech'].response_format);
const [speechSpeed, setSpeechSpeed] = useState(
DEFAULT_REQUESTS['audio-speech'].speed
);
// Fetch model groups for API key resolution
const { data: groups = [] } = useQuery({
queryKey: ['model-groups'],
queryFn: () => ModelService.getModelGroups(),
refetchOnWindowFocus: false,
});
const selectedModel = models.find((model) => model.id === selectedModelId);
// Image upload handler
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.type.startsWith('image/')) {
setSelectedImage(file);
const previewUrl = URL.createObjectURL(file);
setImagePreviewUrl(previewUrl);
} else {
toast.error('Please select a valid image file');
}
}
};
// Audio file upload handler
const handleAudioUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.type.startsWith('audio/')) {
setRecordedAudio(file);
const audioUrl = URL.createObjectURL(file);
setRecordingUrl(audioUrl);
} else {
toast.error('Please select a valid audio file');
}
}
};
// Voice recording functions
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
};
recorder.onstop = () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
const file = new File([blob], 'recording.webm', { type: 'audio/webm' });
setRecordedAudio(file);
const audioUrl = URL.createObjectURL(blob);
setRecordingUrl(audioUrl);
// Stop all tracks to release microphone
stream.getTracks().forEach((track) => track.stop());
};
recorder.start();
setMediaRecorder(recorder);
setIsRecording(true);
toast.success('Recording started');
} catch {
toast.error(
'Failed to start recording. Please check microphone permissions.'
);
}
};
const stopRecording = () => {
if (mediaRecorder) {
mediaRecorder.stop();
setMediaRecorder(null);
setIsRecording(false);
toast.success('Recording stopped');
}
};
// Convert file to base64 for vision API
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
};
// Get effective API key and endpoint URL for the selected model
const getModelCredentials = (model: Model) => {
const group = groups.find((g) => g.provider === model.provider);
// Determine API key (individual takes precedence over group)
const apiKey = model.api_key || group?.group_api_key;
// Determine base endpoint URL
let baseUrl = model.url;
// If model URL is relative and group has a base URL, combine them
if (model.url.startsWith('/') && group?.group_url) {
baseUrl = `${group.group_url.replace(/\/$/, '')}${model.url}`;
}
// Remove any existing endpoint path to get base URL
baseUrl = baseUrl.replace(/\/v1\/.*$/, '').replace(/\/$/, '');
return {
apiKey,
baseUrl,
group,
};
};
const buildEndpointUrl = (baseUrl: string, endpointPath: string) => {
return `${baseUrl}${endpointPath}`;
};
const buildRequest = async (): Promise<EndpointRequestData> => {
if (!selectedModel) return null;
switch (selectedEndpoint) {
case 'chat-completions':
const messages = [];
if (systemMessage.trim()) {
messages.push({
role: 'system' as const,
content: systemMessage.trim(),
});
}
messages.push({ role: 'user' as const, content: userMessage.trim() });
return {
model: selectedModel.name,
messages,
max_tokens: maxTokens,
temperature: temperature,
} as ChatCompletionRequest;
case 'vision-chat':
if (!selectedImage) {
throw new Error('Please select an image for vision analysis');
}
const imageBase64 = await fileToBase64(selectedImage);
const visionMessages = [];
if (visionSystemMessage.trim()) {
visionMessages.push({
role: 'system' as const,
content: visionSystemMessage.trim(),
});
}
visionMessages.push({
role: 'user' as const,
content: [
{
type: 'text' as const,
text: visionUserMessage.trim(),
},
{
type: 'image_url' as const,
image_url: {
url: imageBase64,
detail: imageDetail,
},
},
],
});
return {
model: selectedModel.name,
messages: visionMessages,
max_tokens: visionMaxTokens,
temperature: visionTemperature,
} as ChatCompletionRequest;
case 'embeddings':
return {
model: selectedModel.name,
input: embeddingInput,
encoding_format: encodingFormat,
} as EmbeddingRequest;
case 'images':
return {
model: selectedModel.name,
prompt: imagePrompt,
n: imageCount,
size: imageSize,
quality: imageQuality,
style: imageStyle,
} as ImageGenerationRequest;
case 'audio-speech':
return {
model: selectedModel.name,
input: speechInput,
voice: speechVoice,
response_format: speechFormat,
speed: speechSpeed,
} as AudioSpeechRequest;
case 'audio-transcription':
if (!recordedAudio) {
throw new Error(
'Please record or upload an audio file for transcription'
);
}
return {
model: selectedModel.name,
file: recordedAudio,
prompt: audioTranscriptionPrompt.trim() || undefined,
response_format: audioResponseFormat,
temperature: audioTemperature,
language: audioLanguage.trim() || undefined,
} as AudioTranscriptionRequest;
case 'models':
return null; // No request body needed for models endpoint
default:
return null;
}
};
const testEndpointMutation = useMutation({
mutationFn: async (requestData: EndpointRequestData) => {
if (!selectedModel) {
throw new Error('No model selected');
}
setError(null);
setResponse(null);
try {
const response = await ModelService.testModel(
selectedModel.id,
selectedEndpoint,
requestData
);
if (!response.success) {
throw new Error(response.error || 'Test failed');
}
return response.data as ApiResponse;
} catch (err: unknown) {
const errorMessage =
err instanceof Error
? err.message
: 'Failed to test endpoint via proxy';
throw new Error(errorMessage);
}
},
onSuccess: (data) => {
setResponse(data);
toast.success(
`${API_ENDPOINTS[selectedEndpoint].name} test completed successfully!`
);
},
onError: (err: Error) => {
const errorMessage = err?.message || 'Unknown error occurred';
setError(errorMessage);
toast.error(
`${API_ENDPOINTS[selectedEndpoint].name} test failed: ${errorMessage}`
);
},
});
const handleTest = async () => {
if (!selectedModel) {
toast.error('Please select a model to test');
return;
}
// Validate required fields based on endpoint
if (selectedEndpoint === 'chat-completions' && !userMessage.trim()) {
toast.error('Please enter a test message');
return;
}
if (selectedEndpoint === 'vision-chat') {
if (!visionUserMessage.trim()) {
toast.error('Please enter a test message');
return;
}
if (!selectedImage) {
toast.error('Please select an image for vision analysis');
return;
}
}
if (selectedEndpoint === 'embeddings' && !embeddingInput.trim()) {
toast.error('Please enter text for embedding');
return;
}
if (selectedEndpoint === 'images' && !imagePrompt.trim()) {
toast.error('Please enter an image prompt');
return;
}
if (selectedEndpoint === 'audio-speech' && !speechInput.trim()) {
toast.error('Please enter text for speech synthesis');
return;
}
if (selectedEndpoint === 'audio-transcription' && !recordedAudio) {
toast.error('Please record or upload an audio file for transcription');
return;
}
const requestData = await buildRequest();
testEndpointMutation.mutate(requestData);
};
const enabledModels = Array.from(
new Map(
models.filter((model) => model.isEnabled).map((m) => [m.id, m])
).values()
);
const credentials = selectedModel ? getModelCredentials(selectedModel) : null;
const endpointUrl = credentials
? buildEndpointUrl(
credentials.baseUrl,
API_ENDPOINTS[selectedEndpoint].path
)
: '';
return (
<Card className='w-full'>
<CardHeader>
<CardTitle>API Endpoint Tester</CardTitle>
<CardDescription>
Comprehensive testing of OpenAI-compatible API endpoints through the
secure proxy (resolves CORS and network issues)
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
{/* Model and Endpoint Selection */}
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='model-select'>Select Model</Label>
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger id='model-select'>
<SelectValue placeholder='Choose a model to test...' />
</SelectTrigger>
<SelectContent>
{enabledModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className='flex items-center gap-2'>
<span>{model.name}</span>
<Badge variant='outline' className='text-xs'>
{model.provider}
</Badge>
{model.is_free && (
<Badge variant='secondary' className='text-xs'>
Free
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='endpoint-select'>API Endpoint</Label>
<Select
value={selectedEndpoint}
onValueChange={(value: EndpointType) =>
setSelectedEndpoint(value)
}
>
<SelectTrigger id='endpoint-select'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(API_ENDPOINTS).map(([key, endpoint]) => {
const Icon = endpoint.icon;
return (
<SelectItem key={key} value={key}>
<div className='flex items-center gap-2'>
<Icon className='h-4 w-4' />
<span>{endpoint.name}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</div>
{/* Model Information */}
{selectedModel && credentials && (
<div className='text-muted-foreground bg-muted space-y-2 rounded-md p-3 text-sm'>
<div className='flex items-center gap-2'>
<Globe className='h-4 w-4' />
<span className='break-all'>
<strong>Endpoint:</strong> {endpointUrl}
</span>
</div>
<div className='flex items-center gap-2'>
<Key className='h-4 w-4' />
<span>
<strong>API Key:</strong>{' '}
{credentials.apiKey
? `${credentials.apiKey.substring(0, 8)}...`
: 'Not configured'}
</span>
<Badge
variant={credentials.apiKey ? 'default' : 'destructive'}
className='text-xs'
>
{selectedModel.api_key_type || 'Unknown'}
</Badge>
</div>
<div>
<span>
<strong>Provider:</strong> {selectedModel.provider}
</span>
</div>
<div>
<span>
<strong>Description:</strong>{' '}
{API_ENDPOINTS[selectedEndpoint].description}
</span>
</div>
{!credentials.apiKey && (
<Alert variant='default' className='mt-2'>
<Info className='h-4 w-4' />
<AlertDescription>
No API key configured for this model. Testing may still work
if the model is free or if authentication is handled
elsewhere. For models requiring authentication, please add an
API key to the model or its provider group.
</AlertDescription>
</Alert>
)}
</div>
)}
<ApiEndpointForm
selectedEndpoint={selectedEndpoint}
maxTokens={maxTokens}
setMaxTokens={setMaxTokens}
temperature={temperature}
setTemperature={setTemperature}
systemMessage={systemMessage}
setSystemMessage={setSystemMessage}
userMessage={userMessage}
setUserMessage={setUserMessage}
visionMaxTokens={visionMaxTokens}
setVisionMaxTokens={setVisionMaxTokens}
visionTemperature={visionTemperature}
setVisionTemperature={setVisionTemperature}
visionSystemMessage={visionSystemMessage}
setVisionSystemMessage={setVisionSystemMessage}
visionUserMessage={visionUserMessage}
setVisionUserMessage={setVisionUserMessage}
imageDetail={imageDetail}
setImageDetail={setImageDetail}
selectedImage={selectedImage}
imagePreviewUrl={imagePreviewUrl}
onImageUpload={handleImageUpload}
onRemoveImage={() => {
setSelectedImage(null);
setImagePreviewUrl(null);
}}
embeddingInput={embeddingInput}
setEmbeddingInput={setEmbeddingInput}
encodingFormat={encodingFormat}
setEncodingFormat={setEncodingFormat}
imageCount={imageCount}
setImageCount={setImageCount}
imageSize={imageSize}
setImageSize={setImageSize}
imageQuality={imageQuality}
setImageQuality={setImageQuality}
imageStyle={imageStyle}
setImageStyle={setImageStyle}
imagePrompt={imagePrompt}
setImagePrompt={setImagePrompt}
speechVoice={speechVoice}
setSpeechVoice={setSpeechVoice}
speechFormat={speechFormat}
setSpeechFormat={setSpeechFormat}
speechSpeed={speechSpeed}
setSpeechSpeed={setSpeechSpeed}
speechInput={speechInput}
setSpeechInput={setSpeechInput}
audioTranscriptionPrompt={audioTranscriptionPrompt}
setAudioTranscriptionPrompt={setAudioTranscriptionPrompt}
audioTemperature={audioTemperature}
setAudioTemperature={setAudioTemperature}
audioLanguage={audioLanguage}
setAudioLanguage={setAudioLanguage}
audioResponseFormat={audioResponseFormat}
setAudioResponseFormat={setAudioResponseFormat}
isRecording={isRecording}
onStartRecording={startRecording}
onStopRecording={stopRecording}
onAudioUpload={handleAudioUpload}
recordedAudio={recordedAudio}
recordingUrl={recordingUrl}
onRemoveAudio={() => {
setRecordedAudio(null);
setRecordingUrl(null);
}}
/>
{/* Test Button */}
<Button
onClick={handleTest}
disabled={!selectedModelId || testEndpointMutation.isPending}
className='w-full'
>
{testEndpointMutation.isPending ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Testing {API_ENDPOINTS[selectedEndpoint].name}...
</>
) : (
<>
<Send className='mr-2 h-4 w-4' />
Test {API_ENDPOINTS[selectedEndpoint].name}
</>
)}
</Button>
{/* Results */}
{error && (
<Alert variant='destructive'>
<XCircle className='h-4 w-4' />
<AlertDescription>
<strong>Test Failed:</strong> {error}
</AlertDescription>
</Alert>
)}
{response && (
<Alert>
<CheckCircle className='h-4 w-4' />
<AlertDescription>
<strong>Test Successful!</strong>{' '}
{API_ENDPOINTS[selectedEndpoint].name} endpoint responded
correctly.
</AlertDescription>
</Alert>
)}
<ApiEndpointResponse response={response} />
{response && (
<div className='space-y-2'>
<Label>Raw Response</Label>
<details className='group'>
<summary className='text-muted-foreground hover:text-foreground cursor-pointer text-sm'>
<Info className='mr-1 inline h-4 w-4' />
Show detailed response data
</summary>
<pre className='bg-muted mt-2 max-h-60 overflow-auto rounded-md p-4 text-xs'>
{JSON.stringify(response, null, 2)}
</pre>
</details>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,156 @@
import {
FileText,
Eye,
List,
Image as ImageIcon,
Mic,
Volume2,
} from 'lucide-react';
export const API_ENDPOINTS = {
'chat-completions': {
name: 'Chat Completions',
path: '/chat/completions',
icon: FileText,
description: 'Test conversational AI with chat completion requests',
},
'vision-chat': {
name: 'Vision Chat (Image + Text)',
path: '/chat/completions',
icon: Eye,
description: 'Analyze images with text prompts using vision models',
},
embeddings: {
name: 'Embeddings',
path: '/embeddings',
icon: List,
description: 'Generate embeddings for text input',
},
images: {
name: 'Image Generation',
path: '/images/generations',
icon: ImageIcon,
description: 'Generate images from text prompts',
},
'audio-speech': {
name: 'Text-to-Speech',
path: '/audio/speech',
icon: Mic,
description: 'Convert text to speech audio',
},
'audio-transcription': {
name: 'Audio Transcription',
path: '/audio/transcriptions',
icon: Volume2,
description: 'Transcribe audio files to text',
},
models: {
name: 'List Models',
path: '/models',
icon: List,
description: 'List all available models from the provider',
},
} as const;
export type EndpointType = keyof typeof API_ENDPOINTS;
export interface ChatCompletionRequest {
model: string;
messages: {
role: 'system' | 'user' | 'assistant';
content:
| string
| Array<{
type: 'text' | 'image_url';
text?: string;
image_url?: {
url: string;
detail?: 'low' | 'high' | 'auto';
};
}>;
}[];
max_tokens?: number;
temperature?: number;
}
export interface EmbeddingRequest {
model: string;
input: string | string[];
encoding_format?: 'float' | 'base64';
}
export interface ImageGenerationRequest {
model?: string;
prompt: string;
n?: number;
size?: '256x256' | '512x512' | '1024x1024' | '1792x1024' | '1024x1792';
quality?: 'standard' | 'hd';
style?: 'vivid' | 'natural';
}
export interface AudioSpeechRequest {
model: string;
input: string;
voice: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
response_format?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
speed?: number;
}
export interface AudioTranscriptionRequest {
model: string;
file: File;
prompt?: string;
response_format?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt';
temperature?: number;
language?: string;
}
export const DEFAULT_REQUESTS = {
'chat-completions': {
systemMessage: 'You are a helpful assistant. Please respond concisely.',
userMessage:
'Hello! Can you tell me what model you are and confirm that you are working correctly?',
maxTokens: 150,
temperature: 0.7,
},
'vision-chat': {
systemMessage:
'You are a helpful assistant that can analyze images. Please describe what you see.',
userMessage:
'What do you see in this image? Please provide a detailed description.',
maxTokens: 300,
temperature: 0.7,
imageDetail: 'auto' as const,
},
embeddings: {
input: 'The quick brown fox jumps over the lazy dog.',
encoding_format: 'float' as const,
},
images: {
prompt: 'A beautiful sunset over a mountain landscape',
n: 1,
size: '1024x1024' as const,
quality: 'standard' as const,
style: 'vivid' as const,
},
'audio-speech': {
input: 'Hello, this is a test of the text-to-speech functionality.',
voice: 'alloy' as const,
response_format: 'mp3' as const,
speed: 1.0,
},
'audio-transcription': {
prompt: 'This is a test transcription.',
response_format: 'json' as const,
temperature: 0.0,
language: '',
},
};
export type EndpointRequestData =
| ChatCompletionRequest
| EmbeddingRequest
| ImageGenerationRequest
| AudioSpeechRequest
| AudioTranscriptionRequest
| null;

View File

@@ -0,0 +1,39 @@
'use client';
import { useState, useEffect } from 'react';
import * as React from 'react';
import { Input } from '@/components/ui/input';
interface ApiKeyInputProps extends React.ComponentProps<'input'> {
onApiKeyChange: (apiKey: string) => void;
}
export function ApiKeyInput({
value,
onApiKeyChange,
...props
}: ApiKeyInputProps) {
const [internalValue, setInternalValue] = useState(value || '');
useEffect(() => {
setInternalValue(value || '');
}, [value]);
useEffect(() => {
const handler = setTimeout(() => {
onApiKeyChange(internalValue as string);
}, 300);
return () => clearTimeout(handler);
}, [internalValue, onApiKeyChange]);
return (
<Input
value={internalValue}
onChange={(e) => setInternalValue(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
{...props}
/>
);
}

View File

@@ -0,0 +1,353 @@
'use client';
import { useState, type ReactNode } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import {
DatabaseIcon,
FileTextIcon,
LayoutDashboardIcon,
LogOutIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
ServerIcon,
SettingsIcon,
WalletIcon,
ArrowRightLeftIcon,
} from 'lucide-react';
import Image from 'next/image';
import { toast } from 'sonner';
import { adminLogout } from '@/lib/api/services/auth';
import { Button } from '@/components/ui/button';
import { CurrencyToggle } from '@/components/currency-toggle';
import { ThemeToggle } from '@/components/theme-toggle';
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetTitle,
} from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
interface AppPageShellProps {
children: ReactNode;
className?: string;
contentClassName?: string;
}
const NAV_ITEMS = [
{ title: 'Dashboard', url: '/', icon: LayoutDashboardIcon },
{ title: 'Balances', url: '/balances', icon: WalletIcon },
{ title: 'Logs', url: '/logs', icon: FileTextIcon },
{ title: 'Models', url: '/model', icon: DatabaseIcon },
{ title: 'Providers', url: '/providers', icon: ServerIcon },
{ title: 'Transactions', url: '/transactions', icon: ArrowRightLeftIcon },
{ title: 'Settings', url: '/settings', icon: SettingsIcon },
] as const;
function isActivePath(pathname: string, itemUrl: string): boolean {
if (itemUrl === '/') {
return pathname === '/';
}
return pathname === itemUrl || pathname.startsWith(`${itemUrl}/`);
}
export function AppPageShell({
children,
className,
contentClassName,
}: AppPageShellProps) {
const pathname = usePathname();
const router = useRouter();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const handleLogout = async (): Promise<void> => {
try {
await adminLogout();
toast.success('Logged out successfully');
router.push('/login');
} catch (error) {
console.error('Logout error:', error);
toast.error('Failed to logout');
}
};
return (
<div className='bg-background text-foreground min-h-dvh overflow-x-clip md:h-screen md:overflow-hidden'>
<div className='flex min-h-dvh w-full min-w-0 overflow-x-clip md:h-full'>
<aside
className={cn(
'border-border/60 hidden shrink-0 border-r py-5 transition-[width,padding] duration-300 ease-in-out md:flex md:h-full md:flex-col md:overflow-y-auto',
isSidebarCollapsed ? 'w-16 px-2' : 'w-60 px-4'
)}
>
<div
className={cn(
'px-1 transition-[padding] duration-300 ease-in-out',
isSidebarCollapsed && 'px-0'
)}
>
<div className='flex items-center gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
<Image
src='/icon.ico'
alt='Routstr Node'
width={24}
height={24}
className='shrink-0 rounded-sm'
/>
<div
className={cn(
'min-w-0 overflow-hidden transition-[max-width,opacity,transform] duration-300 ease-in-out',
isSidebarCollapsed
? 'max-w-0 -translate-x-1 opacity-0'
: 'max-w-[11rem] translate-x-0 opacity-100'
)}
>
<h1 className='truncate text-lg font-semibold tracking-tight whitespace-nowrap'>
Routstr Node
</h1>
</div>
</div>
<Button
variant='ghost'
size='icon'
className={cn(
'text-muted-foreground hover:text-foreground h-8 w-8 shrink-0 transition-transform duration-300 ease-in-out',
isSidebarCollapsed ? 'mx-auto' : '-mr-1 ml-auto'
)}
onClick={() => setIsSidebarCollapsed((current) => !current)}
>
{isSidebarCollapsed ? (
<PanelLeftOpenIcon className='h-4 w-4' />
) : (
<PanelLeftCloseIcon className='h-4 w-4' />
)}
<span className='sr-only'>
{isSidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
</span>
</Button>
</div>
</div>
<nav
className={cn(
'mt-5',
isSidebarCollapsed
? 'flex flex-col items-center space-y-2'
: 'space-y-1.5'
)}
>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isActivePath(pathname, item.url);
return (
<Button
key={item.url}
asChild
variant={active ? 'outline' : 'ghost'}
className={cn(
'h-10 rounded-lg transition-[width,padding] duration-300 ease-in-out',
isSidebarCollapsed
? 'mx-auto w-10 justify-center px-0'
: 'w-full justify-start'
)}
>
<Link href={item.url}>
<Icon className='h-4 w-4 shrink-0' />
<span
className={cn(
'overflow-hidden whitespace-nowrap transition-[max-width,opacity,margin] duration-300 ease-in-out',
isSidebarCollapsed
? 'ml-0 max-w-0 opacity-0'
: 'ml-0.5 max-w-[9rem] opacity-100'
)}
>
{item.title}
</span>
</Link>
</Button>
);
})}
</nav>
<div
className={cn(
'mt-auto pt-3 transition-[padding] duration-300 ease-in-out',
isSidebarCollapsed
? 'flex flex-col items-center space-y-1.5'
: 'space-y-2'
)}
>
{isSidebarCollapsed ? (
<>
<CurrencyToggle
compact
menuSide='right'
menuAlign='start'
className='mx-auto'
/>
<ThemeToggle
compact
menuSide='right'
menuAlign='start'
className='mx-auto'
/>
<Button
variant='ghost'
size='sm'
onClick={handleLogout}
className='text-muted-foreground hover:text-foreground mx-auto h-8 w-10 justify-center px-0'
>
<LogOutIcon className='h-4 w-4' />
<span className='sr-only'>Logout</span>
</Button>
</>
) : (
<div className='border-border/60 bg-card/30 space-y-1 rounded-lg border p-1'>
<CurrencyToggle
menuSide='right'
menuAlign='start'
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-8 w-full justify-between rounded-md px-2.5 text-[11px]'
/>
<ThemeToggle
menuSide='right'
menuAlign='start'
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-8 w-full justify-between rounded-md px-2.5 text-[11px]'
/>
<Button
variant='ghost'
size='sm'
onClick={handleLogout}
className='text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-8 w-full justify-start gap-1.5 rounded-md px-2.5 text-[11px]'
>
<LogOutIcon className='h-4 w-4' />
Logout
</Button>
</div>
)}
</div>
</aside>
<section className='relative flex w-full min-w-0 flex-1 flex-col overflow-x-clip md:h-full md:min-h-0'>
<div className='bg-background/80 supports-[backdrop-filter]:bg-background/72 sticky top-0 z-30 flex items-center gap-2 px-3 py-2 backdrop-blur-xl md:hidden'>
<Button
type='button'
variant='outline'
size='sm'
className='h-9 gap-2 rounded-lg px-3'
onClick={() => setIsMobileSidebarOpen(true)}
>
<PanelLeftOpenIcon className='h-4 w-4' />
Menu
</Button>
</div>
<main
className={cn(
'w-full min-w-0 flex-1 overflow-x-clip p-3 pb-4 sm:p-4 md:min-h-0 md:overflow-y-auto md:p-6 md:pb-6',
contentClassName,
className
)}
>
{children}
</main>
</section>
</div>
<Sheet open={isMobileSidebarOpen} onOpenChange={setIsMobileSidebarOpen}>
<SheetContent
side='left'
showCloseButton={false}
className='w-[min(88vw,18rem)] p-0 md:hidden'
>
<SheetTitle className='sr-only'>Navigation sidebar</SheetTitle>
<SheetDescription className='sr-only'>
Browse admin pages and access sidebar controls.
</SheetDescription>
<div className='flex h-full min-h-0 flex-col'>
<div className='border-border/60 px-4 pt-4 pb-3'>
<div className='flex items-center justify-between gap-3'>
<div className='flex min-w-0 items-center gap-2'>
<Image
src='/icon.ico'
alt='Routstr Node'
width={24}
height={24}
className='rounded-sm'
/>
<p className='truncate text-base font-medium tracking-tight'>
Routstr Node
</p>
</div>
<SheetClose asChild>
<Button
variant='ghost'
size='icon'
className='text-muted-foreground hover:text-foreground h-8 w-8 shrink-0'
>
<PanelLeftCloseIcon className='h-4 w-4' />
<span className='sr-only'>Close sidebar</span>
</Button>
</SheetClose>
</div>
</div>
<div className='flex h-full min-h-0 flex-col px-3 pb-3'>
<nav className='space-y-1.5 py-3'>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isActivePath(pathname, item.url);
return (
<Button
key={`mobile-sidebar-${item.url}`}
asChild
variant={active ? 'outline' : 'ghost'}
className='h-10 w-full justify-start rounded-lg'
>
<Link
href={item.url}
onClick={() => setIsMobileSidebarOpen(false)}
>
<Icon className='h-4 w-4' />
{item.title}
</Link>
</Button>
);
})}
</nav>
<div className='border-border/60 bg-card/30 mt-auto space-y-1 rounded-lg border p-1'>
<CurrencyToggle
menuSide='right'
menuAlign='start'
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-9 w-full justify-between rounded-md px-2.5 text-[11px]'
/>
<ThemeToggle
menuSide='right'
menuAlign='start'
className='text-foreground/90 hover:bg-accent/35 border-border/60 bg-background/25 h-9 w-full justify-between rounded-md px-2.5 text-[11px]'
/>
<Button
variant='ghost'
size='sm'
onClick={async () => {
setIsMobileSidebarOpen(false);
await handleLogout();
}}
className='text-muted-foreground hover:bg-destructive/10 hover:text-destructive h-9 w-full justify-start gap-1.5 rounded-md px-2.5 text-[11px]'
>
<LogOutIcon className='h-4 w-4' />
Logout
</Button>
</div>
</div>
</div>
</SheetContent>
</Sheet>
</div>
);
}

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