Compare commits

..
Author SHA1 Message Date
9qeklajc c82dd2dbb1 Merge branch 'dynamic-prices' into litellm 2026-04-28 00:46:16 +02:00
9qeklajc 66421cd17f normalize response 2026-04-28 00:44:22 +02:00
9qeklajc 58b468018a update migration 2026-04-28 00:42:12 +02:00
root 2b79831c61 Merge branch 'main' into dynamic-prices 2026-04-28 00:33:13 +02:00
9qeklajcandGitHub cd7de3958c Merge pull request #478 from Routstr/missing-model-pricing
make sure to always emit sats cost
2026-04-28 00:32:32 +02:00
9qeklajc 8c0ac499ef make sure to always emit sats cost 2026-04-28 00:01:27 +02:00
9qeklajc 1d22155e05 fix: default cashu MintInfo Optional fields to None for v2 parsing 2026-04-26 23:03:33 +02:00
9qeklajc ab2fb2afc5 chore: bump cashu to 0.20 for pydantic v2 compat 2026-04-26 23:03:33 +02:00
9qeklajc 884bae9fc4 fix: migrate FastAPI-bound BaseModels to pydantic v2 to fix login 2026-04-26 22:57:31 +02:00
9qeklajc 92d581573d test: add unit tests for litellm messages dispatch 2026-04-26 22:45:22 +02:00
9qeklajc 85a3d3adc0 feat: route /v1/messages via litellm when upstream lacks native support 2026-04-26 22:45:22 +02:00
9qeklajc 2d7f03b2ed fix: switch openai NOT_GIVEN to omit for openai 2.x compat 2026-04-26 22:42:39 +02:00
9qeklajc 37bce70f76 docs: rewrite messages-to-chat-completions plan for litellm approach 2026-04-26 22:34:04 +02:00
9qeklajc 038bc14757 chore: add litellm dependency 2026-04-26 22:34:04 +02:00
9qeklajc 287cbac5f9 Merge branch 'admin-token' into litellm-integration-for-anthropic-messages-forwarding 2026-04-26 22:33:20 +02:00
9qeklajcandGitHub 28d91227af Merge pull request #475 from Routstr/admin-token
Admin token
2026-04-26 22:32:21 +02:00
9qeklajc 846d894a13 chore: switch bare pydantic imports to v1 shim for v2 compat 2026-04-26 22:30:20 +02:00
9qeklajc a2db3e2d57 fmt 2026-04-26 22:19:30 +02:00
9qeklajcandGitHub e43ceb2e43 Merge pull request #477 from Routstr/model-refresh
enforce-models-refresh-from-upstream
2026-04-26 22:03:59 +02:00
9qeklajc 689a07f562 enforce-models-refresh-from-upstream 2026-04-26 21:41:38 +02:00
9qeklajc da487a850e Merge branch 'main' into admin-token 2026-04-26 00:09:00 +02:00
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub d69ab913d4 Merge pull request #419 from Routstr/add-dev-cut
add-dev-cut
2026-04-23 23:22:23 +02:00
9qeklajc 724de338f2 update migration 2026-04-23 00:02:42 +02:00
9qeklajc c98cc30fd4 Merge branch 'main' into dynamic-prices 2026-04-22 23:57:55 +02:00
9qeklajcandGitHub 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
9qeklajcandGitHub 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
root 4691294b24 Merge branch 'main' into dynamic-prices 2026-04-20 13:10:00 +02:00
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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 111060c004 Merge branch 'add-cost-usage-to-messages-endpoint' into dynamic-prices 2026-04-15 21:32:38 +02:00
9qeklajc 81146710ba revert 2026-04-15 21:32:33 +02:00
9qeklajc f2b700a2e6 Merge branch 'add-cost-usage-to-messages-endpoint' into dynamic-prices 2026-04-15 21:27:09 +02:00
9qeklajc 9008b00fc9 revert 2026-04-15 21:27:02 +02:00
9qeklajc d7467dbad2 Merge branch 'add-cost-usage-to-messages-endpoint' into dynamic-prices 2026-04-15 21:22:38 +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 1190a09acb Merge branch 'add-cost-usage-to-messages-endpoint' into dynamic-prices 2026-04-15 19:20:56 +02:00
9qeklajc 29f52116c3 match model when versioned 2026-04-15 19:20:50 +02:00
9qeklajc eebcc67c85 add cost usages 2026-04-15 17:19:13 +02:00
9qeklajcandGitHub 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 85532f32a1 Merge branch 'main' into dynamic-prices 2026-04-14 00:46:00 +02:00
9qeklajcandGitHub 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 ea8257c44b Merge branch 'main' into dynamic-prices 2026-04-14 00:32:22 +02:00
9qeklajcandGitHub 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 d89e740ff8 Merge branch 'main' into dynamic-prices
# Conflicts:
#	routstr/upstream/base.py
2026-04-13 23:52:46 +02:00
9qeklajcandGitHub 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
9qeklajcandGitHub 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 af4be5bfec Merge branch 'main' into dynamic-prices 2026-04-12 22:46:18 +02:00
9qeklajcandGitHub 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 df2a925577 added-dynamic-price-setting 2026-04-12 17:38:35 +02:00
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 200b32f1ff Merge pull request #428 from Routstr/simplify-x-cashu-refund
Simplify x cashu refund
2026-04-01 15:23:59 +02:00
9qeklajcandGitHub 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
9qeklajcandGitHub a7455a48d2 Merge pull request #421 from Routstr/v0.4.1
fix fee calculation
2026-03-25 10:20:14 +01:00
redshiftandGitHub 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
redshiftandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
redshiftandGitHub 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
redshiftandGitHub 33d2ef9ef1 Merge pull request #353 from Routstr/v0.4.0
V0.4.0
2026-03-23 11:39:22 +00:00
9qeklajcandGitHub 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
9qeklajcandGitHub 063df51ced Merge pull request #411 from Routstr/408-fix-swap
fix swapping the primary mint
2026-03-18 22:17:29 +01:00
9qeklajcandGitHub 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
9qeklajcandGitHub a0b2b9466c Merge pull request #409 from Routstr/cache-x-cashu-tokens
Cache x cashu tokens
2026-03-16 23:07:38 +01:00
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 5788d63892 Merge pull request #394 from Routstr/fix-refund-token
fix refund token multiple times
2026-03-06 23:06:14 +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 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 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 c5fd386c1e add routstr provider 2026-02-12 11:19:40 +01:00
246 changed files with 33561 additions and 15257 deletions
+1
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=
+1 -1
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
+19 -7
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
+2
View File
@@ -97,6 +97,7 @@ Announce your node on the network:
| **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.
@@ -122,6 +123,7 @@ Use environment variables for:
| `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` |
+1
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
+5 -18
View File
@@ -6,16 +6,7 @@ Production deployment guide for Routstr Provider nodes.
For production, use Docker Compose with persistent storage and optional Tor support.
### Unified Setup (All-in-one)
To build and run the node with the UI integrated in a single container using the multi-stage build:
```bash
docker build -f Dockerfile.full -t routstr-full .
docker run -d -p 8000:8000 --env-file .env routstr-full
```
### Advanced Setup (Separated UI & Node)
Use the included `compose.yml` for a more flexible setup that separates the UI build process from the node execution. This is useful for development or when you want to manage Tor as a separate service.
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
@@ -184,20 +175,16 @@ docker compose up -d
## Building from Source
### Unified Image (UI + Node)
The easiest way to build everything from source into a single production-ready image:
### Using Docker Compose
The easiest way to build everything from source:
```bash
docker build -f Dockerfile.full -t routstr-full .
docker compose build
```
### Individual Components
If you prefer building them separately or using Docker Compose:
If you prefer building the node only (requires manual UI build first):
```bash
# Build using compose
docker compose build
# Or build the node only (requires manual UI build first)
docker build -t routstr-node .
```
+12 -24
View File
@@ -35,6 +35,7 @@ ADMIN_PASSWORD=mysecretpassword
# Node Identity
NAME="My AI Node"
DESCRIPTION="Fast access to models"
NSEC=yournsec
# Lightning Payouts
RECEIVE_LN_ADDRESS=yourname@wallet.com
@@ -43,32 +44,10 @@ RECEIVE_LN_ADDRESS=yourname@wallet.com
## 2. Start the Node
You can run the pre-built image directly:
The recommended way to run Routstr is using Docker Compose, which handles the node, the UI, and optional services like Tor.
```bash
docker run -d \
--name routstr \
-p 8000:8000 \
--env-file .env \
-v routstr-data:/app/data \
ghcr.io/routstr/proxy:latest
```
*Note: The pre-built image does not contain the UI. For the all-in-one experience with the Admin Dashboard, use the Build from Source instructions below.*
### Build from Source (Recommended)
If you want to build the node and UI yourself from source, use the unified Dockerfile:
```bash
git clone https://github.com/routstr/routstr-core.git
cd routstr-core
# Edit your .env with ADMIN_PASSWORD and API keys
cp .env.example .env
nano .env
docker build -f Dockerfile.full -t routstr-local .
docker run -d -p 8000:8000 --env-file .env --name routstr routstr-local
docker compose up -d
```
Verify it's running:
@@ -77,6 +56,15 @@ 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
```
---
## 3. Configure via Dashboard
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,32 @@
"""add routstr_fees table
Revision ID: 02650cd6f028
Revises: c3d4e5f6a7b8
Create Date: 2026-04-24 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "02650cd6f028"
down_revision = "c3d4e5f6a7b8"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"routstr_fees",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("accumulated_msats", sa.Integer(), nullable=False, server_default="0"),
sa.Column("total_paid_msats", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_paid_at", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
# Seed with a single row
op.execute("INSERT INTO routstr_fees (id, accumulated_msats, total_paid_msats) VALUES (1, 0, 0)")
def downgrade() -> None:
op.drop_table("routstr_fees")
@@ -0,0 +1,32 @@
"""add provider_settings to upstream_providers
Revision ID: 614c0a740e68
Revises: 06f81c0fc88d
Create Date: 2026-02-13 22:36:53.608737
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "614c0a740e68"
down_revision = "06f81c0fc88d"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Check if column exists before adding it
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [c["name"] for c in inspector.get_columns("upstream_providers")]
if "provider_settings" not in columns:
op.add_column(
"upstream_providers",
sa.Column("provider_settings", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("upstream_providers", "provider_settings")
@@ -0,0 +1,57 @@
"""add provider_fee_schedules and provider_fee_default to upstream_providers
Revision ID: 6d2fa295fa43
Revises: cli_tokens_001
Create Date: 2026-04-28 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "6d2fa295fa43"
down_revision = "cli_tokens_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {c["name"] for c in inspector.get_columns("upstream_providers")}
if "provider_fee_default" not in columns:
op.add_column(
"upstream_providers",
sa.Column(
"provider_fee_default",
sa.Float(),
nullable=False,
server_default="1.01",
),
)
# Preserve any custom per-provider fees by copying from provider_fee.
op.execute(
"UPDATE upstream_providers "
"SET provider_fee_default = provider_fee "
"WHERE provider_fee IS NOT NULL"
)
if "provider_fee_schedules" not in columns:
op.add_column(
"upstream_providers",
sa.Column("provider_fee_schedules", sa.Text(), nullable=True),
)
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {c["name"] for c in inspector.get_columns("upstream_providers")}
if "provider_fee_schedules" in columns:
op.drop_column("upstream_providers", "provider_fee_schedules")
if "provider_fee_default" in columns:
op.drop_column("upstream_providers", "provider_fee_default")
@@ -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")
@@ -0,0 +1,33 @@
"""add forwarded_model_id to models
Revision ID: b1c2d3e4f5a6
Revises: a776ca70e5fe
Create Date: 2026-04-05 00:00:00.000000
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "b1c2d3e4f5a6"
down_revision = "a776ca70e5fe"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"models",
sa.Column(
"forwarded_model_id",
sqlmodel.sql.sqltypes.AutoString(),
nullable=True,
),
)
# Backfill: set forwarded_model_id = id for all existing rows
op.execute("UPDATE models SET forwarded_model_id = id WHERE forwarded_model_id IS NULL")
def downgrade() -> None:
op.drop_column("models", "forwarded_model_id")
@@ -0,0 +1,36 @@
"""add source to cashu_transactions
Revision ID: c3d4e5f6a7b8
Revises: b1c2d3e4f5a6
Create Date: 2026-04-10 00:00:00.000000
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "c3d4e5f6a7b8"
down_revision = "b1c2d3e4f5a6"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col["name"] for col in inspector.get_columns("cashu_transactions")]
if "source" not in columns:
op.add_column(
"cashu_transactions",
sa.Column(
"source",
sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
server_default="x-cashu",
),
)
def downgrade() -> None:
op.drop_column("cashu_transactions", "source")
+34
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")
@@ -0,0 +1,46 @@
"""add api key link to cashu_transactions
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-04-20 00:00:00.000000
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "d4e5f6a7b8c9"
down_revision = "c3d4e5f6a7b8"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col["name"] for col in inspector.get_columns("cashu_transactions")]
indexes = {index["name"] for index in inspector.get_indexes("cashu_transactions")}
if "api_key_hashed_key" not in columns:
op.add_column(
"cashu_transactions",
sa.Column(
"api_key_hashed_key",
sqlmodel.sql.sqltypes.AutoString(),
nullable=True,
),
)
if "ix_cashu_transactions_api_key_hashed_key" not in indexes:
op.create_index(
"ix_cashu_transactions_api_key_hashed_key",
"cashu_transactions",
["api_key_hashed_key"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_cashu_transactions_api_key_hashed_key", table_name="cashu_transactions")
op.drop_column("cashu_transactions", "api_key_hashed_key")
@@ -0,0 +1,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
+3 -2
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"
@@ -13,7 +13,7 @@ dependencies = [
"greenlet>=3.2.1",
"alembic>=1.13",
"python-json-logger>=2.0.0",
"cashu",
"cashu>=0.20",
"secp256k1",
"marshmallow>=3.13,<4.0",
"websockets>=12.0",
@@ -21,6 +21,7 @@ dependencies = [
"mdurl==0.1.2",
"pillow>=10",
"openai>=1.98.0",
"litellm>=1.55.0",
]
[dependency-groups]
+22 -1
View File
@@ -126,11 +126,24 @@ def create_model_mappings(
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
@@ -204,6 +217,10 @@ 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)
@@ -292,6 +309,10 @@ 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)
for alias in aliases:
_add_candidate(alias, model_to_use, upstream_for_override)
seen_model_provider.add(dedupe_key)
+226 -39
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)
@@ -372,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),
},
)
@@ -386,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",
}
@@ -584,6 +592,18 @@ 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
@@ -635,6 +655,17 @@ async def revert_pay_for_request(
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
@@ -716,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(
@@ -728,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,
)
@@ -741,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]
@@ -776,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:
@@ -799,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,
},
)
@@ -811,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,
)
@@ -825,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]
@@ -840,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]
@@ -885,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={
@@ -900,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] + "...",
@@ -910,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)
@@ -925,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,
)
@@ -939,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]
@@ -982,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()
+212 -16
View File
@@ -5,11 +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 select
from sqlmodel import col, select, update
from .auth import get_billing_key, validate_bearer_key
from .core.db import ApiKey, AsyncSession, get_session
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
@@ -154,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}
@@ -190,19 +211,77 @@ 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 key.total_balance <= 0:
@@ -215,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":
@@ -227,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
@@ -251,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()
@@ -269,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:
@@ -401,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"],
+581 -64
View File
@@ -1,14 +1,15 @@
import asyncio
import json
import secrets
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from pydantic import BaseModel, RootModel
from sqlmodel import select
from ..payment.models import _row_to_model, list_models
from ..proxy import refresh_model_maps, reinitialize_upstreams
from ..proxy import refresh_model_maps, reinitialize_upstreams, sync_provider_fees
from ..wallet import (
fetch_all_balances,
get_proofs_per_mint_and_unit,
@@ -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")
@@ -117,8 +142,8 @@ async def get_settings(request: Request) -> dict:
return data
class SettingsUpdate(BaseModel):
__root__: dict[str, object]
class SettingsUpdate(RootModel[dict[str, object]]):
pass
class PasswordUpdate(BaseModel):
@@ -129,7 +154,7 @@ class PasswordUpdate(BaseModel):
@admin_router.patch("/api/settings", dependencies=[Depends(require_admin_api)])
async def update_settings(request: Request, update: SettingsUpdate) -> dict:
# Remove sensitive fields from general settings update
settings_data = update.__root__.copy()
settings_data = update.root.copy()
sensitive_fields = ["admin_password", "upstream_api_key", "nsec"]
for field in sensitive_fields:
if field in settings_data:
@@ -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()
@@ -544,6 +639,8 @@ class UpstreamProviderCreate(BaseModel):
api_version: str | None = None
enabled: bool = True
provider_fee: float = 1.01
provider_fee_default: float | None = None
provider_settings: dict | None = None
class UpstreamProviderUpdate(BaseModel):
@@ -553,6 +650,29 @@ class UpstreamProviderUpdate(BaseModel):
api_version: str | None = None
enabled: bool | None = None
provider_fee: float | None = None
provider_fee_default: float | None = None
provider_settings: dict | None = None
def _provider_to_dict(
p: UpstreamProviderRow, redact_key: bool = True
) -> dict[str, object]:
return {
"id": p.id,
"provider_type": p.provider_type,
"base_url": p.base_url,
"api_key": "[REDACTED]" if (redact_key and p.api_key) else (p.api_key or ""),
"api_version": p.api_version,
"enabled": p.enabled,
"provider_fee": p.provider_fee,
"provider_fee_default": p.provider_fee_default,
"provider_settings": json.loads(p.provider_settings)
if p.provider_settings
else None,
"provider_fee_schedules": json.loads(p.provider_fee_schedules)
if p.provider_fee_schedules
else [],
}
@admin_router.get("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
@@ -560,18 +680,7 @@ async def get_upstream_providers() -> list[dict[str, object]]:
async with create_session() as session:
result = await session.exec(select(UpstreamProviderRow))
providers = result.all()
return [
{
"id": p.id,
"provider_type": p.provider_type,
"base_url": p.base_url,
"api_key": "[REDACTED]" if p.api_key else "",
"api_version": p.api_version,
"enabled": p.enabled,
"provider_fee": p.provider_fee,
}
for p in providers
]
return [_provider_to_dict(p) for p in providers]
@admin_router.post("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
@@ -598,22 +707,19 @@ async def create_upstream_provider(
api_version=payload.api_version,
enabled=payload.enabled,
provider_fee=payload.provider_fee,
provider_fee_default=payload.provider_fee_default
if payload.provider_fee_default is not None
else payload.provider_fee,
provider_settings=json.dumps(payload.provider_settings)
if payload.provider_settings
else None,
)
session.add(provider)
await session.commit()
await session.refresh(provider)
await reinitialize_upstreams()
await refresh_model_maps()
return {
"id": provider.id,
"provider_type": provider.provider_type,
"base_url": provider.base_url,
"api_key": "[REDACTED]",
"api_version": provider.api_version,
"enabled": provider.enabled,
"provider_fee": provider.provider_fee,
}
return _provider_to_dict(provider)
@admin_router.get(
@@ -624,15 +730,7 @@ async def get_upstream_provider(provider_id: int) -> dict[str, object]:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
return {
"id": provider.id,
"provider_type": provider.provider_type,
"base_url": provider.base_url,
"api_key": "[REDACTED]" if provider.api_key else "",
"api_version": provider.api_version,
"enabled": provider.enabled,
"provider_fee": provider.provider_fee,
}
return _provider_to_dict(provider)
@admin_router.patch(
@@ -658,22 +756,17 @@ 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_fee_default is not None:
provider.provider_fee_default = payload.provider_fee_default
if payload.provider_settings is not None:
provider.provider_settings = json.dumps(payload.provider_settings)
session.add(provider)
await session.commit()
await session.refresh(provider)
await reinitialize_upstreams()
await refresh_model_maps()
return {
"id": provider.id,
"provider_type": provider.provider_type,
"base_url": provider.base_url,
"api_key": "[REDACTED]",
"api_version": provider.api_version,
"enabled": provider.enabled,
"provider_fee": provider.provider_fee,
}
return _provider_to_dict(provider)
@admin_router.delete(
@@ -691,6 +784,78 @@ async def delete_upstream_provider(provider_id: int) -> dict[str, object]:
return {"ok": True, "deleted_id": provider_id}
@admin_router.get(
"/api/upstream-providers/{provider_id}/fee-schedules",
dependencies=[Depends(require_admin_api)],
)
async def get_fee_schedules(provider_id: int) -> list[dict]:
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")
return (
json.loads(provider.provider_fee_schedules)
if provider.provider_fee_schedules
else []
)
class FeeScheduleUpdate(BaseModel):
schedules: list[dict]
@admin_router.put(
"/api/upstream-providers/{provider_id}/fee-schedules",
dependencies=[Depends(require_admin_api)],
)
async def update_fee_schedules(
provider_id: int, payload: FeeScheduleUpdate
) -> list[dict]:
from ..payment.fee_schedule import FeeTimeRange, validate_no_overlaps
try:
ranges = [FeeTimeRange(**s) for s in payload.schedules]
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid schedule data: {e}")
try:
validate_no_overlaps(ranges)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
serialized = [r.dict() for r in ranges]
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")
provider.provider_fee_schedules = json.dumps(serialized)
session.add(provider)
await session.commit()
await sync_provider_fees()
await refresh_model_maps()
return serialized
@admin_router.delete(
"/api/upstream-providers/{provider_id}/fee-schedules",
dependencies=[Depends(require_admin_api)],
)
async def delete_fee_schedules(provider_id: int) -> dict:
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")
provider.provider_fee_schedules = None
session.add(provider)
await session.commit()
await sync_provider_fees()
await refresh_model_maps()
return {"ok": True}
@admin_router.get("/api/provider-types", dependencies=[Depends(require_admin_api)])
async def get_provider_types() -> list[dict[str, object]]:
"""Get metadata about available provider types including default URLs and whether they're fixed."""
@@ -730,7 +895,9 @@ 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.id not in db_model_ids]
filtered_remote_models = [
m for m in upstream_models if m.id not in db_model_ids
]
return {
"provider": {
@@ -792,6 +959,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)],
@@ -807,18 +1015,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={
@@ -869,6 +1143,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(
@@ -896,7 +1189,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:
@@ -904,6 +1197,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(
@@ -941,18 +1289,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."""
@@ -963,7 +1348,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"
@@ -979,7 +1367,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"
@@ -1073,3 +1464,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))
+180 -4
View File
@@ -2,14 +2,17 @@ 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
@@ -103,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")
@@ -128,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,7 +220,16 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
)
enabled: bool = Field(default=True, description="Whether this provider is enabled")
provider_fee: float = Field(
default=1.01, description="Provider fee multiplier (default 1%)"
default=1.01, description="Active fee multiplier (can be set by schedule)"
)
provider_fee_default: float = Field(
default=1.01, description="Default fee multiplier (outside schedules)"
)
provider_settings: str | None = Field(
default=None, description="JSON string for provider-specific settings"
)
provider_fee_schedules: str | None = Field(
default=None, description="JSON array of fee time ranges (HH:MM UTC)"
)
models: list["ModelRow"] = Relationship(
back_populates="upstream_provider",
@@ -154,6 +237,66 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
)
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:
@@ -224,6 +367,17 @@ def fix_cashu_migrations() -> None:
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."""
try:
@@ -245,8 +399,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
+13 -11
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
+38 -6
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
@@ -94,16 +103,23 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
btc_price_task = asyncio.create_task(update_prices_periodically())
pricing_task = asyncio.create_task(update_sats_pricing())
if global_settings.models_refresh_interval_seconds > 0:
# Pass the accessor (not its current value) so the loop sees providers
# added/changed via reinitialize_upstreams() instead of staying pinned
# to the startup snapshot.
models_refresh_task = asyncio.create_task(
refresh_upstreams_models_periodically(get_upstreams())
refresh_upstreams_models_periodically(get_upstreams)
)
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
payout_task = asyncio.create_task(periodic_payout())
if global_settings.nsec:
nip91_task = asyncio.create_task(announce_provider())
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 +143,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 +153,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 +170,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 +180,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 +206,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
-8
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__,
},
+29 -7
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
+2 -1
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
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)
+89 -42
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,
)
+113
View File
@@ -0,0 +1,113 @@
"""Dynamic provider fee schedule logic.
Supports time-based fee ranges (HH:MM UTC) with overlap validation and active fee resolution.
"""
from __future__ import annotations
import re
from datetime import datetime, timezone
from pydantic.v1 import BaseModel, validator
_HH_MM_RE = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
class FeeTimeRange(BaseModel):
start_time: str # HH:MM UTC
end_time: str # HH:MM UTC
provider_fee: float
@validator("start_time", "end_time")
@classmethod
def validate_time_format(cls, v: str) -> str:
if not _HH_MM_RE.match(v):
raise ValueError(f"Time must be in HH:MM format (00:0023:59), got: {v!r}")
return v
@validator("provider_fee")
@classmethod
def validate_fee(cls, v: float) -> float:
if v <= 0:
raise ValueError(f"provider_fee must be > 0 (got {v})")
return v
def _to_minutes(t: str) -> int:
h, m = map(int, t.split(":"))
return h * 60 + m
def _range_intervals(r: FeeTimeRange) -> list[tuple[int, int]]:
"""Return list of [start, end) minute intervals for this range.
Handles midnight-crossing (e.g. 22:0006:00 → [(1320,1440),(0,360)]).
start == end is treated as a full-day range.
"""
start = _to_minutes(r.start_time)
end = _to_minutes(r.end_time)
if start < end:
return [(start, end)]
if start > end:
return [(start, 1440), (0, end)]
# start == end → full day
return [(0, 1440)]
def _intervals_overlap(a: tuple[int, int], b: tuple[int, int]) -> bool:
return a[0] < b[1] and b[0] < a[1]
def ranges_overlap(a: FeeTimeRange, b: FeeTimeRange) -> bool:
"""Return True if two fee time ranges overlap at any point in the day."""
for ia in _range_intervals(a):
for ib in _range_intervals(b):
if _intervals_overlap(ia, ib):
return True
return False
def validate_no_overlaps(ranges: list[FeeTimeRange]) -> None:
"""Raise ValueError if any two ranges in the list overlap."""
for i in range(len(ranges)):
for j in range(i + 1, len(ranges)):
if ranges_overlap(ranges[i], ranges[j]):
raise ValueError(
f"Fee ranges overlap: [{ranges[i].start_time}{ranges[i].end_time}]"
f" and [{ranges[j].start_time}{ranges[j].end_time}]"
)
def get_active_fee(
ranges: list[FeeTimeRange] | None,
default_fee: float,
*,
_now: datetime | None = None,
) -> float:
"""Return the provider fee for the current UTC time.
Falls back to *default_fee* when no range matches or *ranges* is empty/None.
The *_now* parameter is for testing only.
"""
if not ranges or not isinstance(ranges, list):
return default_fee
now = _now if _now is not None else datetime.now(timezone.utc)
# Normalize to UTC
if now.tzinfo is not None:
now = now.astimezone(timezone.utc)
current = now.hour * 60 + now.minute
for r in ranges:
start = _to_minutes(r.start_time)
end = _to_minutes(r.end_time)
if start < end:
if start <= current < end:
return r.provider_fee
elif start > end: # midnight-crossing
if current >= start or current < end:
return r.provider_fee
else: # full day (start == end)
return r.provider_fee
return default_fee
+85 -3
View File
@@ -4,10 +4,11 @@ import random
import httpx
from fastapi import APIRouter, Depends
from pydantic import BaseModel as V2BaseModel
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 +61,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 +179,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 +332,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 +406,89 @@ async def update_sats_pricing() -> None:
logger.error(f"Error updating sats pricing: {e}")
class ModelTestRequest(V2BaseModel):
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}
+74 -12
View File
@@ -44,6 +44,7 @@ async def initialize_upstreams() -> None:
global _upstreams
_upstreams = await init_upstreams()
logger.info(f"Initialized {len(_upstreams)} upstream providers")
await sync_provider_fees()
await refresh_model_maps()
@@ -55,6 +56,7 @@ async def reinitialize_upstreams() -> None:
"Re-initialized upstream providers from admin action",
extra={"provider_count": len(_upstreams)},
)
await sync_provider_fees()
await refresh_model_maps()
@@ -69,7 +71,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:
@@ -100,6 +120,12 @@ async def refresh_model_maps() -> None:
disabled_model_ids: set[str] = set()
for provider in provider_rows:
# Match with instance in _upstreams to update its state from DB
for upstream in _upstreams:
if getattr(upstream, "db_id", None) == provider.id:
# This updates fee and merges DB models WITHOUT hitting network
await upstream.refresh_models_cache(skip_network=True)
if not provider.enabled:
continue
for model in provider.models:
@@ -115,6 +141,39 @@ async def refresh_model_maps() -> None:
)
async def sync_provider_fees() -> None:
"""Update active provider_fee in database based on schedules and defaults."""
from .payment.fee_schedule import FeeTimeRange, get_active_fee
async with create_session() as session:
result = await session.exec(select(UpstreamProviderRow))
provider_rows = result.all()
updated = False
for p in provider_rows:
schedules = None
if p.provider_fee_schedules:
try:
schedules = [
FeeTimeRange(**s) for s in json.loads(p.provider_fee_schedules)
]
except Exception:
pass
active_fee = get_active_fee(schedules, p.provider_fee_default)
if p.provider_fee != active_fee:
logger.info(
f"Updating active fee for provider {p.id}: {p.provider_fee} -> {active_fee}",
extra={"provider_id": p.id, "active_fee": active_fee},
)
p.provider_fee = active_fee
session.add(p)
updated = True
if updated:
await session.commit()
async def refresh_model_maps_periodically() -> None:
"""Background task to refresh model maps every minute."""
import asyncio
@@ -122,6 +181,7 @@ async def refresh_model_maps_periodically() -> None:
while True:
try:
await asyncio.sleep(60)
await sync_provider_fees()
await refresh_model_maps()
except asyncio.CancelledError:
break
@@ -138,11 +198,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 +208,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 +267,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:
@@ -391,7 +447,12 @@ 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."""
parts = auth.split()
@@ -459,15 +520,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
+2
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"""
+2
View File
@@ -13,6 +13,8 @@ class AnthropicUpstreamProvider(BaseUpstreamProvider):
provider_type = "anthropic"
default_base_url = "https://api.anthropic.com/v1"
platform_url = "https://console.anthropic.com/settings/keys"
supports_anthropic_messages = True
litellm_provider_prefix = "anthropic/"
def __init__(self, api_key: str, provider_fee: float = 1.01):
super().__init__(
+157
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,
},
)
+1
View File
@@ -13,6 +13,7 @@ class AzureUpstreamProvider(BaseUpstreamProvider):
provider_type = "azure"
default_base_url = None
platform_url = "https://portal.azure.com/"
litellm_provider_prefix = "azure/"
def __init__(
self,
+1371 -244
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -26,14 +26,14 @@ class GeminiClient(BaseAPIClient):
max_tokens: int | None = None,
**kwargs: Any,
) -> dict[str, Any]:
from openai import NOT_GIVEN
from openai import omit
response = await self.client.chat.completions.create(
model=model,
messages=messages, # type: ignore
temperature=temperature if temperature is not None else NOT_GIVEN,
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
top_p=kwargs.get("top_p", NOT_GIVEN),
temperature=temperature if temperature is not None else omit,
max_tokens=max_tokens if max_tokens is not None else omit,
top_p=kwargs.get("top_p", omit),
)
return response.model_dump()
@@ -45,7 +45,7 @@ class GeminiClient(BaseAPIClient):
max_tokens: int | None = None,
**kwargs: Any,
) -> AsyncGenerator[dict[str, Any], None]:
from openai import NOT_GIVEN
from openai import omit
usage_callback = kwargs.get("usage_callback")
completion_callback = kwargs.get("completion_callback")
@@ -55,9 +55,9 @@ class GeminiClient(BaseAPIClient):
messages=messages, # type: ignore
stream=True,
stream_options={"include_usage": True},
temperature=temperature if temperature is not None else NOT_GIVEN,
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
top_p=kwargs.get("top_p", NOT_GIVEN),
temperature=temperature if temperature is not None else omit,
max_tokens=max_tokens if max_tokens is not None else omit,
top_p=kwargs.get("top_p", omit),
)
final_usage = None
+1
View File
@@ -12,6 +12,7 @@ class FireworksUpstreamProvider(BaseUpstreamProvider):
provider_type = "fireworks"
default_base_url = "https://api.fireworks.ai/inference/v1"
platform_url = "https://app.fireworks.ai/settings/users/api-keys"
litellm_provider_prefix = "fireworks_ai/"
def __init__(self, api_key: str, provider_fee: float = 1.01):
super().__init__(
+1
View File
@@ -23,6 +23,7 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
provider_type = "gemini"
default_base_url = "https://generativelanguage.googleapis.com/v1beta"
platform_url = "https://aistudio.google.com/app/apikey"
litellm_provider_prefix = "gemini/"
def __init__(
self,
+1
View File
@@ -12,6 +12,7 @@ class GroqUpstreamProvider(BaseUpstreamProvider):
provider_type = "groq"
default_base_url = "https://api.groq.com/openai/v1"
platform_url = "https://console.groq.com/keys"
litellm_provider_prefix = "groq/"
def __init__(self, api_key: str, provider_fee: float = 1.01):
super().__init__(
+13 -4
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import os
import re
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Callable
if TYPE_CHECKING:
from ..core.settings import Settings
@@ -122,12 +122,16 @@ async def get_all_models_with_overrides(
async def refresh_upstreams_models_periodically(
upstreams: list[BaseUpstreamProvider],
upstreams_provider: (
Callable[[], list[BaseUpstreamProvider]] | list[BaseUpstreamProvider]
),
) -> None:
"""Background task to periodically refresh models cache for all providers.
Args:
upstreams: List of upstream provider instances
upstreams_provider: Either a callable returning the live upstream list
(preferred picks up providers added/changed via reinitialize_upstreams),
or a static list (legacy, will go stale after reinitialize_upstreams).
"""
import asyncio
import random
@@ -139,9 +143,14 @@ async def refresh_upstreams_models_periodically(
logger.info("Provider models refresh disabled (interval <= 0)")
return
def _resolve_upstreams() -> list[BaseUpstreamProvider]:
if callable(upstreams_provider):
return upstreams_provider()
return upstreams_provider
while True:
try:
for upstream in upstreams:
for upstream in _resolve_upstreams():
try:
await upstream.refresh_models_cache()
except Exception as e:
+2 -103
View File
@@ -21,6 +21,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
provider_type = "ollama"
default_base_url = "http://localhost:11434"
platform_url = None
litellm_provider_prefix = "ollama_chat/"
def __init__(
self,
@@ -65,9 +66,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
"""Strip 'ollama/' prefix for Ollama API compatibility."""
return model_id.removeprefix("ollama/")
def get_request_base_url(
self, path: str, model_obj: Model | None = None
) -> str:
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"
@@ -166,103 +165,3 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
},
)
return []
async def refresh_models_cache(self) -> None:
"""Refresh the in-memory models cache from upstream API."""
try:
from ..payment.models import _update_model_sats_pricing
from ..payment.price import sats_usd_price
models = await self.fetch_models()
models_with_fees = [self._apply_provider_fee_to_model(m) for m in models]
try:
sats_to_usd = sats_usd_price()
self._models_cache = [
_update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees
]
except Exception:
self._models_cache = models_with_fees
self._models_by_id = {m.id: m for m in self._models_cache}
logger.info(
f"Refreshed models cache for {self.base_url}",
extra={"model_count": len(models)},
)
except Exception as e:
logger.error(
f"Failed to refresh models cache for {self.base_url}",
extra={"error": str(e), "error_type": type(e).__name__},
)
def get_cached_models(self) -> list[Model]:
"""Get cached models for this provider.
Returns:
List of cached Model objects
"""
return self._models_cache
def get_cached_model_by_id(self, model_id: str) -> Model | None:
"""Get a specific cached model by ID.
Args:
model_id: Model identifier
Returns:
Model object or None if not found
"""
return self._models_by_id.get(model_id)
def _apply_provider_fee_to_model(self, model: Model) -> Model:
"""Apply provider fee to model's USD pricing and calculate max costs.
Args:
model: Model object to update
Returns:
Model with provider fee applied to pricing and max costs calculated
"""
from ..payment.models import Model, Pricing, _calculate_usd_max_costs
adjusted_pricing = Pricing.parse_obj(
{k: v * self.provider_fee for k, v in model.pricing.dict().items()}
)
temp_model = Model(
id=model.id,
name=model.name,
created=model.created,
description=model.description,
context_length=model.context_length,
architecture=model.architecture,
pricing=adjusted_pricing,
sats_pricing=None,
per_request_limits=model.per_request_limits,
top_provider=model.top_provider,
enabled=model.enabled,
upstream_provider_id=model.upstream_provider_id,
canonical_slug=model.canonical_slug,
)
(
adjusted_pricing.max_prompt_cost,
adjusted_pricing.max_completion_cost,
adjusted_pricing.max_cost,
) = _calculate_usd_max_costs(temp_model)
return Model(
id=model.id,
name=model.name,
created=model.created,
description=model.description,
context_length=model.context_length,
architecture=model.architecture,
pricing=adjusted_pricing,
sats_pricing=model.sats_pricing,
per_request_limits=model.per_request_limits,
top_provider=model.top_provider,
enabled=model.enabled,
upstream_provider_id=model.upstream_provider_id,
canonical_slug=model.canonical_slug,
)
+2
View File
@@ -15,6 +15,8 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
provider_type = "openrouter"
default_base_url = "https://openrouter.ai/api/v1"
platform_url = "https://openrouter.ai/settings/keys"
supports_anthropic_messages = True
litellm_provider_prefix = "openrouter/"
def __init__(self, api_key: str, provider_fee: float = 1.06):
"""Initialize OpenRouter provider with API key.
+1
View File
@@ -13,6 +13,7 @@ class PerplexityUpstreamProvider(BaseUpstreamProvider):
provider_type = "perplexity"
default_base_url = "https://api.perplexity.ai/"
platform_url = "https://www.perplexity.ai/account/api/keys"
litellm_provider_prefix = "perplexity/"
def __init__(self, api_key: str, provider_fee: float = 1.01):
super().__init__(
+44 -20
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.v1 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
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)}
+1
View File
@@ -13,6 +13,7 @@ class XAIUpstreamProvider(BaseUpstreamProvider):
provider_type = "x-ai"
default_base_url = "https://api.x.ai/v1"
platform_url = "https://console.x.ai/"
litellm_provider_prefix = "xai/"
def __init__(self, api_key: str, provider_fee: float = 1.01):
super().__init__(
+309 -17
View File
@@ -1,16 +1,32 @@
import asyncio
import math
import time
import typing
from typing import TypedDict
from cashu.core.base import Proof, Token
from cashu.core.mint_info import MintInfo as _CashuMintInfo
from cashu.wallet.helpers import deserialize_token_from_string
from cashu.wallet.wallet import Wallet
from sqlmodel import col, update
from pydantic_core import PydanticUndefined
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
# cashu still declares Optional[X] without explicit defaults on MintInfo.
# Under pydantic v2 those are required, but real mints omit many of them.
# Default Optional fields to None at import time so balance fetches don't 422.
for _name, _field in _CashuMintInfo.model_fields.items():
_annot = _field.annotation
_is_optional = typing.get_origin(_annot) is typing.Union and type(
None
) in typing.get_args(_annot)
if _is_optional and _field.default is PydanticUndefined:
_field.default = None
_CashuMintInfo.model_rebuild(force=True)
logger = get_logger(__name__)
@@ -34,6 +50,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 +75,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 +169,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 +295,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 +328,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 +549,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]
+7
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"""
@@ -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"
@@ -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"
)
+302
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)
@@ -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
+190
View File
@@ -0,0 +1,190 @@
"""Integration tests for model price updates when provider fee schedules change."""
import time
from typing import Any, Generator
import pytest
from httpx import AsyncClient
from routstr.core.admin import admin_sessions
ADMIN_TOKEN = "test-admin-token"
def _auth_header() -> dict[str, str]:
return {"Authorization": f"Bearer {ADMIN_TOKEN}"}
@pytest.fixture(autouse=True)
def _inject_admin_session() -> Generator[None, None, None]:
admin_sessions[ADMIN_TOKEN] = int(time.time()) + 3600
yield
admin_sessions.pop(ADMIN_TOKEN, None)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_model_price_updates_on_fee_schedule_change(
integration_client: AsyncClient,
patched_db_engine: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Patch fetch_models to return empty list to avoid network errors
# and allow DB models to be used
from routstr.upstream.base import BaseUpstreamProvider
async def mock_fetch_models(self: BaseUpstreamProvider) -> list:
return []
monkeypatch.setattr(BaseUpstreamProvider, "fetch_models", mock_fetch_models)
# 1. Create a provider
provider_resp = await integration_client.post(
"/admin/api/upstream-providers",
json={
"provider_type": "custom",
"base_url": "https://api.example.com/v1",
"api_key": "test-key",
"enabled": True,
"provider_fee": 1.0,
},
headers=_auth_header(),
)
provider_id = provider_resp.json()["id"]
# 2. Add a model to this provider
model_id = "test-model-price-update"
await integration_client.post(
f"/admin/api/upstream-providers/{provider_id}/models",
json={
"id": model_id,
"name": "Test Model",
"created": int(time.time()),
"description": "Test",
"context_length": 4096,
"architecture": {
"modality": "text",
"input_modalities": ["text"],
"output_modalities": ["text"],
"tokenizer": "gpt2",
"instruct_type": "none",
},
"pricing": {"prompt": 1.0, "completion": 2.0},
"enabled": True,
},
headers=_auth_header(),
)
# 3. Check initial price (should be prompt=1.0 * fee=1.0 = 1.0)
# We use /models endpoint
resp = await integration_client.get("/models")
models = resp.json()["data"]
target = next((m for m in models if m["id"] == model_id), None)
assert target is not None
assert target["pricing"]["prompt"] == 1.0
# 4. Update provider fee schedule to a very high value for the current time
# We'll use a range that covers the whole day to be safe
schedules = [
{"start_time": "00:00", "end_time": "23:59", "provider_fee": 2.5},
]
await integration_client.put(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
json={"schedules": schedules},
headers=_auth_header(),
)
# 5. Check price again - should be updated instantly
resp = await integration_client.get("/models")
models = resp.json()["data"]
target = next((m for m in models if m["id"] == model_id), None)
assert target is not None
# 1.0 * 2.5 = 2.5
assert target["pricing"]["prompt"] == 2.5
# 6. Delete schedules
await integration_client.delete(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
headers=_auth_header(),
)
# 7. Should revert to default fee (1.0)
resp = await integration_client.get("/models")
models = resp.json()["data"]
target = next((m for m in models if m["id"] == model_id), None)
assert target is not None
assert target["pricing"]["prompt"] == 1.0
@pytest.mark.integration
@pytest.mark.asyncio
async def test_upstream_model_price_updates_on_fee_schedule_change(
integration_client: AsyncClient,
patched_db_engine: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from routstr.payment.models import Architecture, Model, Pricing
from routstr.upstream.base import BaseUpstreamProvider
upstream_model_id = "upstream-model-only"
# Mock fetch_models to return a model
async def mock_fetch_models(self: BaseUpstreamProvider) -> list[Model]:
return [
Model(
id=upstream_model_id,
name="Upstream Model",
created=int(time.time()),
description="Test",
context_length=4096,
architecture=Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="gpt2",
instruct_type="none",
),
pricing=Pricing(prompt=1.0, completion=2.0),
enabled=True,
)
]
monkeypatch.setattr(BaseUpstreamProvider, "fetch_models", mock_fetch_models)
# 1. Create a provider
provider_resp = await integration_client.post(
"/admin/api/upstream-providers",
json={
"provider_type": "custom",
"base_url": "https://api.example.com/v1",
"api_key": "test-key-2",
"enabled": True,
"provider_fee": 1.0,
},
headers=_auth_header(),
)
provider_id = provider_resp.json()["id"]
# 2. Check initial price (should be prompt=1.0 * fee=1.0 = 1.0)
resp = await integration_client.get("/models")
models = resp.json()["data"]
target = next((m for m in models if m["id"] == upstream_model_id), None)
assert target is not None
assert target["pricing"]["prompt"] == 1.0
# 3. Update provider fee schedule
schedules = [
{"start_time": "00:00", "end_time": "23:59", "provider_fee": 3.0},
]
await integration_client.put(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
json={"schedules": schedules},
headers=_auth_header(),
)
# 4. Check price again - I expect this to FAIL (still 1.0 instead of 3.0)
resp = await integration_client.get("/models")
models = resp.json()["data"]
target = next((m for m in models if m["id"] == upstream_model_id), None)
assert target is not None
assert target["pricing"]["prompt"] == 3.0
@@ -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, skip_network: bool = False) -> 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
@@ -0,0 +1,402 @@
"""Integration tests for provider fee schedule API endpoints."""
from typing import Any, Generator
import pytest
from httpx import AsyncClient
from routstr.core.admin import admin_sessions
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
ADMIN_TOKEN = "test-admin-token"
def _auth_header() -> dict[str, str]:
return {"Authorization": f"Bearer {ADMIN_TOKEN}"}
async def _create_provider(client: AsyncClient, *, fee: float = 1.02) -> int:
"""Create a test provider and return its ID."""
resp = await client.post(
"/admin/api/upstream-providers",
json={
"provider_type": "custom",
"base_url": "https://api.example.com/v1",
"api_key": "test-key",
"enabled": True,
"provider_fee": fee,
},
headers=_auth_header(),
)
assert resp.status_code == 200, resp.text
return resp.json()["id"]
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _inject_admin_session() -> Generator[None, None, None]:
"""Inject a valid admin session token for all tests."""
import time
admin_sessions[ADMIN_TOKEN] = int(time.time()) + 3600
yield
admin_sessions.pop(ADMIN_TOKEN, None)
@pytest.fixture(autouse=True)
def _patch_reinitialize(monkeypatch: Any) -> None:
async def _noop(*args: Any, **kwargs: Any) -> None:
pass
monkeypatch.setattr("routstr.core.admin.reinitialize_upstreams", _noop)
monkeypatch.setattr("routstr.core.admin.refresh_model_maps", _noop)
# ---------------------------------------------------------------------------
# GET fee schedules
# ---------------------------------------------------------------------------
@pytest.mark.integration
@pytest.mark.asyncio
async def test_get_fee_schedules_empty_for_new_provider(
integration_client: AsyncClient, patched_db_engine: Any
) -> None:
provider_id = await _create_provider(integration_client)
resp = await integration_client.get(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
headers=_auth_header(),
)
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.integration
@pytest.mark.asyncio
async def test_get_fee_schedules_404_for_missing_provider(
integration_client: AsyncClient, patched_db_engine: Any
) -> None:
resp = await integration_client.get(
"/admin/api/upstream-providers/99999/fee-schedules",
headers=_auth_header(),
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# PUT fee schedules
# ---------------------------------------------------------------------------
@pytest.mark.integration
@pytest.mark.asyncio
async def test_put_fee_schedules_success(
integration_client: AsyncClient, patched_db_engine: Any
) -> None:
provider_id = await _create_provider(integration_client)
schedules = [
{"start_time": "08:00", "end_time": "18:00", "provider_fee": 1.05},
{"start_time": "18:00", "end_time": "08:00", "provider_fee": 1.02},
]
resp = await integration_client.put(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
json={"schedules": schedules},
headers=_auth_header(),
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["start_time"] == "08:00"
assert data[0]["provider_fee"] == 1.05
@pytest.mark.integration
@pytest.mark.asyncio
async def test_put_fee_schedules_persisted(
integration_client: AsyncClient, patched_db_engine: Any
) -> None:
"""Saved schedules are returned by a subsequent GET."""
provider_id = await _create_provider(integration_client)
schedules = [{"start_time": "09:00", "end_time": "17:00", "provider_fee": 1.07}]
await integration_client.put(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
json={"schedules": schedules},
headers=_auth_header(),
)
get_resp = await integration_client.get(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
headers=_auth_header(),
)
assert get_resp.status_code == 200
assert get_resp.json()[0]["provider_fee"] == 1.07
@pytest.mark.integration
@pytest.mark.asyncio
async def test_put_fee_schedules_replaces_existing(
integration_client: AsyncClient, patched_db_engine: Any
) -> None:
provider_id = await _create_provider(integration_client)
# Set initial schedule
await integration_client.put(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
json={
"schedules": [
{"start_time": "08:00", "end_time": "12:00", "provider_fee": 1.03}
]
},
headers=_auth_header(),
)
# Replace with different schedule
resp = await integration_client.put(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
json={
"schedules": [
{"start_time": "14:00", "end_time": "20:00", "provider_fee": 1.08}
]
},
headers=_auth_header(),
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["start_time"] == "14:00"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_put_fee_schedules_overlap_rejected(
integration_client: AsyncClient, patched_db_engine: Any
) -> None:
provider_id = await _create_provider(integration_client)
schedules = [
{"start_time": "08:00", "end_time": "14:00", "provider_fee": 1.05},
{"start_time": "12:00", "end_time": "18:00", "provider_fee": 1.03},
]
resp = await integration_client.put(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
json={"schedules": schedules},
headers=_auth_header(),
)
assert resp.status_code == 400
assert "overlap" in resp.json()["detail"].lower()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_put_fee_schedules_invalid_time_format_rejected(
integration_client: AsyncClient, patched_db_engine: Any
) -> None:
provider_id = await _create_provider(integration_client)
resp = await integration_client.put(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
json={
"schedules": [
{"start_time": "8:00", "end_time": "18:00", "provider_fee": 1.05}
]
},
headers=_auth_header(),
)
assert resp.status_code == 400
@pytest.mark.integration
@pytest.mark.asyncio
async def test_put_fee_schedules_invalid_fee_rejected(
integration_client: AsyncClient, patched_db_engine: Any
) -> None:
provider_id = await _create_provider(integration_client)
resp = await integration_client.put(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
json={
"schedules": [
{"start_time": "08:00", "end_time": "18:00", "provider_fee": -0.5}
]
},
headers=_auth_header(),
)
assert resp.status_code == 400
@pytest.mark.integration
@pytest.mark.asyncio
async def test_put_fee_schedules_empty_clears_schedules(
integration_client: AsyncClient, patched_db_engine: Any
) -> None:
provider_id = await _create_provider(integration_client)
# Set a schedule
await integration_client.put(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
json={
"schedules": [
{"start_time": "08:00", "end_time": "18:00", "provider_fee": 1.05}
]
},
headers=_auth_header(),
)
# Clear with empty list
resp = await integration_client.put(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
json={"schedules": []},
headers=_auth_header(),
)
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.integration
@pytest.mark.asyncio
async def test_put_fee_schedules_404_for_missing_provider(
integration_client: AsyncClient, patched_db_engine: Any
) -> None:
resp = await integration_client.put(
"/admin/api/upstream-providers/99999/fee-schedules",
json={"schedules": []},
headers=_auth_header(),
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# DELETE fee schedules
# ---------------------------------------------------------------------------
@pytest.mark.integration
@pytest.mark.asyncio
async def test_delete_fee_schedules(
integration_client: AsyncClient, patched_db_engine: Any
) -> None:
provider_id = await _create_provider(integration_client)
# Add schedules
await integration_client.put(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
json={
"schedules": [
{"start_time": "08:00", "end_time": "18:00", "provider_fee": 1.05}
]
},
headers=_auth_header(),
)
# Delete
del_resp = await integration_client.delete(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
headers=_auth_header(),
)
assert del_resp.status_code == 200
assert del_resp.json()["ok"] is True
# Verify schedules are gone
get_resp = await integration_client.get(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
headers=_auth_header(),
)
assert get_resp.json() == []
@pytest.mark.integration
@pytest.mark.asyncio
async def test_delete_fee_schedules_404_for_missing_provider(
integration_client: AsyncClient, patched_db_engine: Any
) -> None:
resp = await integration_client.delete(
"/admin/api/upstream-providers/99999/fee-schedules",
headers=_auth_header(),
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Fee schedules appear in provider list and detail
# ---------------------------------------------------------------------------
@pytest.mark.integration
@pytest.mark.asyncio
async def test_fee_schedules_in_provider_list(
integration_client: AsyncClient, patched_db_engine: Any
) -> None:
provider_id = await _create_provider(integration_client)
await integration_client.put(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
json={
"schedules": [
{"start_time": "08:00", "end_time": "18:00", "provider_fee": 1.05}
]
},
headers=_auth_header(),
)
list_resp = await integration_client.get(
"/admin/api/upstream-providers", headers=_auth_header()
)
assert list_resp.status_code == 200
providers = list_resp.json()
target = next((p for p in providers if p["id"] == provider_id), None)
assert target is not None
assert len(target["provider_fee_schedules"]) == 1
assert target["provider_fee_schedules"][0]["provider_fee"] == 1.05
@pytest.mark.integration
@pytest.mark.asyncio
async def test_fee_schedules_in_provider_detail(
integration_client: AsyncClient, patched_db_engine: Any
) -> None:
provider_id = await _create_provider(integration_client)
await integration_client.put(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
json={
"schedules": [
{"start_time": "10:00", "end_time": "22:00", "provider_fee": 1.06}
]
},
headers=_auth_header(),
)
detail_resp = await integration_client.get(
f"/admin/api/upstream-providers/{provider_id}", headers=_auth_header()
)
assert detail_resp.status_code == 200
data = detail_resp.json()
assert len(data["provider_fee_schedules"]) == 1
assert data["provider_fee_schedules"][0]["start_time"] == "10:00"
# ---------------------------------------------------------------------------
# Provider deletion clears fee schedules
# ---------------------------------------------------------------------------
@pytest.mark.integration
@pytest.mark.asyncio
async def test_provider_delete_clears_fee_schedules(
integration_client: AsyncClient, patched_db_engine: Any
) -> None:
provider_id = await _create_provider(integration_client)
await integration_client.put(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
json={
"schedules": [
{"start_time": "08:00", "end_time": "18:00", "provider_fee": 1.05}
]
},
headers=_auth_header(),
)
# Delete provider
del_resp = await integration_client.delete(
f"/admin/api/upstream-providers/{provider_id}", headers=_auth_header()
)
assert del_resp.status_code == 200
# Provider is gone → schedule endpoint returns 404
get_resp = await integration_client.get(
f"/admin/api/upstream-providers/{provider_id}/fee-schedules",
headers=_auth_header(),
)
assert get_resp.status_code == 404
@@ -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)
@@ -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(
@@ -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
+101 -1
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(
+11 -1
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
+357
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
+256
View File
@@ -0,0 +1,256 @@
"""Unit tests for routstr.payment.fee_schedule."""
from datetime import datetime, timedelta, timezone
import pytest
from pydantic.v1 import ValidationError
from routstr.payment.fee_schedule import (
FeeTimeRange,
get_active_fee,
ranges_overlap,
validate_no_overlaps,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _r(start: str, end: str, fee: float = 1.05) -> FeeTimeRange:
return FeeTimeRange(start_time=start, end_time=end, provider_fee=fee)
def _now(h: int, m: int = 0) -> datetime:
return datetime(2026, 1, 1, h, m, tzinfo=timezone.utc)
# ---------------------------------------------------------------------------
# FeeTimeRange validation
# ---------------------------------------------------------------------------
class TestFeeTimeRangeValidation:
def test_valid_range(self) -> None:
r = _r("08:00", "18:00", 1.05)
assert r.start_time == "08:00"
assert r.end_time == "18:00"
assert r.provider_fee == 1.05
def test_invalid_start_time_format(self) -> None:
with pytest.raises(ValidationError, match="HH:MM"):
_r("8:00", "18:00")
def test_invalid_end_time_hour_out_of_range(self) -> None:
with pytest.raises(ValidationError):
_r("08:00", "24:00")
def test_invalid_end_time_minute_out_of_range(self) -> None:
with pytest.raises(ValidationError):
_r("08:00", "18:60")
def test_invalid_time_letters(self) -> None:
with pytest.raises(ValidationError):
_r("ab:cd", "18:00")
def test_fee_must_be_positive(self) -> None:
with pytest.raises(ValidationError, match="provider_fee must be > 0"):
_r("08:00", "18:00", fee=0.0)
def test_fee_negative_rejected(self) -> None:
with pytest.raises(ValidationError):
_r("08:00", "18:00", fee=-0.5)
def test_fee_below_one_allowed(self) -> None:
r = _r("08:00", "18:00", fee=0.95)
assert r.provider_fee == 0.95
def test_boundary_times_valid(self) -> None:
r = _r("00:00", "23:59")
assert r.start_time == "00:00"
assert r.end_time == "23:59"
# ---------------------------------------------------------------------------
# ranges_overlap
# ---------------------------------------------------------------------------
class TestRangesOverlap:
def test_non_overlapping_ranges(self) -> None:
assert not ranges_overlap(_r("08:00", "12:00"), _r("12:00", "18:00"))
def test_overlapping_ranges(self) -> None:
assert ranges_overlap(_r("08:00", "14:00"), _r("12:00", "18:00"))
def test_one_contains_the_other(self) -> None:
assert ranges_overlap(_r("08:00", "20:00"), _r("10:00", "18:00"))
def test_identical_ranges_overlap(self) -> None:
assert ranges_overlap(_r("08:00", "12:00"), _r("08:00", "12:00"))
def test_adjacent_non_overlapping(self) -> None:
# end of first == start of second → no overlap (open interval [start, end))
assert not ranges_overlap(_r("06:00", "12:00"), _r("12:00", "18:00"))
def test_midnight_crossing_vs_day_range_overlap(self) -> None:
# 22:0006:00 crosses midnight; 04:0008:00 should overlap (both cover 04:0006:00)
assert ranges_overlap(_r("22:00", "06:00"), _r("04:00", "08:00"))
def test_midnight_crossing_vs_non_overlapping_day_range(self) -> None:
# 22:0006:00 does NOT cover 10:0018:00
assert not ranges_overlap(_r("22:00", "06:00"), _r("10:00", "18:00"))
def test_two_midnight_crossing_ranges_overlap(self) -> None:
assert ranges_overlap(_r("20:00", "04:00"), _r("22:00", "06:00"))
def test_two_midnight_crossing_ranges_non_overlap(self) -> None:
# 21:0023:00 and 23:0021:00 (full day minus one hour): they do overlap
# Let's use a case that genuinely doesn't: 21:0022:00 adjacent
# Actually for two midnight-crossing ranges it's hard to not overlap—let's test equal endpoints
assert not ranges_overlap(_r("22:00", "23:00"), _r("23:00", "01:00"))
# ---------------------------------------------------------------------------
# validate_no_overlaps
# ---------------------------------------------------------------------------
class TestValidateNoOverlaps:
def test_no_overlaps_passes(self) -> None:
validate_no_overlaps(
[_r("00:00", "08:00"), _r("08:00", "16:00"), _r("16:00", "23:59")]
)
def test_overlap_raises(self) -> None:
with pytest.raises(ValueError, match="overlap"):
validate_no_overlaps([_r("08:00", "14:00"), _r("12:00", "18:00")])
def test_single_range_passes(self) -> None:
validate_no_overlaps([_r("08:00", "18:00")])
def test_empty_list_passes(self) -> None:
validate_no_overlaps([])
def test_midnight_crossing_overlap_detected(self) -> None:
with pytest.raises(ValueError, match="overlap"):
validate_no_overlaps([_r("22:00", "06:00"), _r("04:00", "08:00")])
# ---------------------------------------------------------------------------
# get_active_fee
# ---------------------------------------------------------------------------
class TestGetActiveFee:
def test_returns_default_when_no_ranges(self) -> None:
assert get_active_fee(None, 1.01) == 1.01
def test_returns_default_for_empty_list(self) -> None:
assert get_active_fee([], 1.01) == 1.01
def test_returns_matching_fee(self) -> None:
ranges = [_r("08:00", "18:00", fee=1.05)]
assert get_active_fee(ranges, 1.01, _now=_now(12)) == 1.05
def test_returns_default_when_no_match(self) -> None:
ranges = [_r("08:00", "18:00", fee=1.05)]
assert get_active_fee(ranges, 1.01, _now=_now(20)) == 1.01
def test_boundary_start_inclusive(self) -> None:
ranges = [_r("08:00", "18:00", fee=1.05)]
assert get_active_fee(ranges, 1.01, _now=_now(8, 0)) == 1.05
def test_boundary_end_exclusive(self) -> None:
ranges = [_r("08:00", "18:00", fee=1.05)]
assert get_active_fee(ranges, 1.01, _now=_now(18, 0)) == 1.01
def test_midnight_crossing_before_midnight(self) -> None:
ranges = [_r("22:00", "06:00", fee=1.03)]
assert get_active_fee(ranges, 1.01, _now=_now(23)) == 1.03
def test_midnight_crossing_after_midnight(self) -> None:
ranges = [_r("22:00", "06:00", fee=1.03)]
assert get_active_fee(ranges, 1.01, _now=_now(3)) == 1.03
def test_midnight_crossing_outside_range(self) -> None:
ranges = [_r("22:00", "06:00", fee=1.03)]
assert get_active_fee(ranges, 1.01, _now=_now(12)) == 1.01
def test_multiple_ranges_correct_match(self) -> None:
ranges = [
_r("00:00", "08:00", fee=1.02),
_r("08:00", "16:00", fee=1.05),
_r("16:00", "23:59", fee=1.03),
]
assert get_active_fee(ranges, 1.01, _now=_now(10)) == 1.05
assert get_active_fee(ranges, 1.01, _now=_now(2)) == 1.02
assert get_active_fee(ranges, 1.01, _now=_now(20)) == 1.03
def test_first_matching_range_wins(self) -> None:
# When multiple ranges could match (should not happen if validated),
# the first one wins.
ranges = [_r("08:00", "20:00", fee=1.05), _r("10:00", "12:00", fee=1.02)]
assert get_active_fee(ranges, 1.01, _now=_now(11)) == 1.05
# ---------------------------------------------------------------------------
# Timezone-aware inputs (CEST / CET)
# ---------------------------------------------------------------------------
class TestGetActiveFeeTimezones:
"""Verify that tz-aware datetimes are normalised to UTC before matching."""
# CEST = UTC+2 (Central European Summer Time, used ~late March late Oct)
CEST = timezone(timedelta(hours=2))
# CET = UTC+1 (Central European Time, used the rest of the year)
CET = timezone(timedelta(hours=1))
def test_cest_datetime_normalised_to_utc_matches(self) -> None:
# 10:00 CEST == 08:00 UTC — schedule 08:0018:00 should match
now_cest = datetime(2026, 7, 1, 10, 0, tzinfo=self.CEST)
ranges = [_r("08:00", "18:00", fee=1.05)]
assert get_active_fee(ranges, 1.01, _now=now_cest) == 1.05
def test_cest_datetime_normalised_to_utc_no_match(self) -> None:
# 06:00 CEST == 04:00 UTC — schedule 08:0018:00 should NOT match
now_cest = datetime(2026, 7, 1, 6, 0, tzinfo=self.CEST)
ranges = [_r("08:00", "18:00", fee=1.05)]
assert get_active_fee(ranges, 1.01, _now=now_cest) == 1.01
def test_cet_datetime_normalised_to_utc_matches(self) -> None:
# 09:00 CET == 08:00 UTC — schedule 08:0018:00 should match
now_cet = datetime(2026, 1, 15, 9, 0, tzinfo=self.CET)
ranges = [_r("08:00", "18:00", fee=1.05)]
assert get_active_fee(ranges, 1.01, _now=now_cet) == 1.05
def test_cet_datetime_before_utc_range(self) -> None:
# 08:30 CET == 07:30 UTC — schedule 08:0018:00 should NOT match
now_cet = datetime(2026, 1, 15, 8, 30, tzinfo=self.CET)
ranges = [_r("08:00", "18:00", fee=1.05)]
assert get_active_fee(ranges, 1.01, _now=now_cet) == 1.01
def test_cest_midnight_crossing_before_midnight(self) -> None:
# 00:30 CEST == 22:30 UTC — schedule 22:0006:00 UTC should match
now_cest = datetime(2026, 7, 2, 0, 30, tzinfo=self.CEST)
ranges = [_r("22:00", "06:00", fee=1.03)]
assert get_active_fee(ranges, 1.01, _now=now_cest) == 1.03
def test_cest_midnight_crossing_after_midnight(self) -> None:
# 05:00 CEST == 03:00 UTC — schedule 22:0006:00 UTC should match
now_cest = datetime(2026, 7, 2, 5, 0, tzinfo=self.CEST)
ranges = [_r("22:00", "06:00", fee=1.03)]
assert get_active_fee(ranges, 1.01, _now=now_cest) == 1.03
def test_cest_midnight_crossing_outside_range(self) -> None:
# 14:00 CEST == 12:00 UTC — schedule 22:0006:00 UTC should NOT match
now_cest = datetime(2026, 7, 2, 14, 0, tzinfo=self.CEST)
ranges = [_r("22:00", "06:00", fee=1.03)]
assert get_active_fee(ranges, 1.01, _now=now_cest) == 1.01
def test_naive_utc_datetime_still_works(self) -> None:
# Naive datetimes are treated as UTC (defensive fallback path)
now_naive = datetime(2026, 1, 1, 12, 0) # no tzinfo
ranges = [_r("08:00", "18:00", fee=1.05)]
assert get_active_fee(ranges, 1.01, _now=now_naive) == 1.05
@@ -0,0 +1,749 @@
"""Unit tests for the litellm-based /v1/messages dispatch path.
Covers `BaseUpstreamProvider._forward_messages_via_litellm` and the
shortcut wired into `forward_request`.
"""
import json
import os
from typing import Any, AsyncIterator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.responses import Response, StreamingResponse
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
os.environ.setdefault("UPSTREAM_API_KEY", "test")
from routstr.core.db import ApiKey # noqa: E402
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
from routstr.upstream.base import BaseUpstreamProvider # noqa: E402
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_provider(supports_messages: bool = False) -> BaseUpstreamProvider:
provider = BaseUpstreamProvider(base_url="http://test", api_key="upstream-key")
if supports_messages:
provider.supports_anthropic_messages = True
return provider
def _make_key() -> ApiKey:
return ApiKey(hashed_key="abcdef0123" * 4, balance=1_000_000)
def _make_model(model_id: str = "openai/gpt-4o-mini") -> Model:
return Model(
id=model_id,
name=model_id,
forwarded_model_id=model_id,
created=0,
description="",
context_length=8192,
architecture=Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="x",
instruct_type=None,
),
pricing=Pricing(
prompt=0.0,
completion=0.0,
request=0.0,
image=0.0,
web_search=0.0,
internal_reasoning=0.0,
max_cost=0.0,
),
sats_pricing=None,
per_request_limits=None,
top_provider=None,
)
def _make_session() -> Any:
return MagicMock()
def _anthropic_request_body(*, stream: bool = False) -> bytes:
return json.dumps(
{
"model": "openai/gpt-4o-mini",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 64,
"stream": stream,
}
).encode()
def _anthropic_non_stream_response() -> dict:
return {
"id": "msg_123",
"type": "message",
"role": "assistant",
"model": "openai/gpt-4o-mini",
"content": [{"type": "text", "text": "hello!"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 5, "output_tokens": 3},
}
# ---------------------------------------------------------------------------
# Helper / coercion
# ---------------------------------------------------------------------------
def test_coerce_litellm_payload_handles_dict() -> None:
out = BaseUpstreamProvider._coerce_litellm_payload({"a": 1})
assert out == {"a": 1}
def test_coerce_litellm_payload_handles_pydantic_v2() -> None:
obj = MagicMock()
obj.model_dump.return_value = {"x": 42}
# ensure dict() isn't preferred over model_dump
out = BaseUpstreamProvider._coerce_litellm_payload(obj)
assert out == {"x": 42}
def test_parse_sse_blocks_extracts_full_events() -> None:
buffer = (
b"event: message_start\n"
b'data: {"type":"message_start","message":{"id":"m1"}}\n\n'
b"event: message_stop\n"
b'data: {"type":"message_stop"}\n\n'
)
events, remaining = BaseUpstreamProvider._parse_sse_blocks(buffer)
assert remaining == b""
assert events == [
{"type": "message_start", "message": {"id": "m1"}},
{"type": "message_stop"},
]
def test_parse_sse_blocks_preserves_partial_trailing_block() -> None:
buffer = b'data: {"type":"a"}\n\nevent: b\ndata: {"type":"b"}'
events, remaining = BaseUpstreamProvider._parse_sse_blocks(buffer)
assert events == [{"type": "a"}]
assert remaining == b'event: b\ndata: {"type":"b"}'
def test_parse_sse_blocks_skips_done_and_comments() -> None:
buffer = b": ping\n\ndata: [DONE]\n\ndata: {\"type\":\"x\"}\n\n"
events, remaining = BaseUpstreamProvider._parse_sse_blocks(buffer)
assert remaining == b""
assert events == [{"type": "x"}]
def test_events_from_chunk_handles_bytes_chunks() -> None:
provider = _make_provider()
chunk = (
b"event: a\ndata: {\"type\":\"a\"}\n\n"
b"event: b\ndata: {\"type\":\"b\"}"
)
events, buf = provider._events_from_chunk(chunk, b"")
assert events == [{"type": "a"}]
# second event still partial because no trailing \n\n
assert buf == b'event: b\ndata: {"type":"b"}'
# Feed remainder of the second event
events, buf = provider._events_from_chunk(b"\n\n", buf)
assert events == [{"type": "b"}]
assert buf == b""
def test_events_from_chunk_handles_str_chunks() -> None:
provider = _make_provider()
events, buf = provider._events_from_chunk(
'event: a\ndata: {"type":"a"}\n\n', b""
)
assert events == [{"type": "a"}]
assert buf == b""
# ---------------------------------------------------------------------------
# Pydantic serializer warning + usage normalization
# ---------------------------------------------------------------------------
def test_coerce_litellm_payload_silences_pydantic_serializer_warning() -> None:
"""litellm response models emit a benign UserWarning when their nested
pydantic field (e.g. usage = ResponseAPIUsage) holds a plain dict.
_coerce_litellm_payload must silence it locally."""
import warnings as _warnings
obj = MagicMock()
def _emit_warning(*args: Any, **kwargs: Any) -> dict:
_warnings.warn(
"Pydantic serializer warnings:\n Expected `ResponseAPIUsage`",
UserWarning,
stacklevel=2,
)
return {"id": "x", "usage": {"completion_tokens": 5}}
obj.model_dump.side_effect = _emit_warning
with _warnings.catch_warnings(record=True) as caught:
_warnings.simplefilter("always")
out = BaseUpstreamProvider._coerce_litellm_payload(obj)
assert out == {"id": "x", "usage": {"completion_tokens": 5}}
serializer_warnings = [
w for w in caught if "Pydantic serializer warnings" in str(w.message)
]
assert serializer_warnings == [], (
"Pydantic serializer warning should be suppressed at source"
)
def test_normalize_litellm_payload_maps_openai_keys_to_anthropic() -> None:
event = {
"type": "message_delta",
"usage": {
"prompt_tokens": 11,
"completion_tokens": 22,
"total_tokens": 33,
},
"message": {
"model": "x",
"usage": {"prompt_tokens": 7, "completion_tokens": 13},
},
}
out = BaseUpstreamProvider._normalize_litellm_payload(event)
# Top-level usage gets canonical Anthropic keys mirrored in
assert out["usage"]["input_tokens"] == 11
assert out["usage"]["output_tokens"] == 22
# Original keys preserved (non-destructive)
assert out["usage"]["prompt_tokens"] == 11
assert out["usage"]["completion_tokens"] == 22
# Nested message.usage normalized too
assert out["message"]["usage"]["input_tokens"] == 7
assert out["message"]["usage"]["output_tokens"] == 13
# Original event must not be mutated
assert "input_tokens" not in event["usage"]
def test_normalize_litellm_payload_keeps_anthropic_keys_intact() -> None:
event = {
"type": "message_delta",
"usage": {"input_tokens": 4, "output_tokens": 9},
}
out = BaseUpstreamProvider._normalize_litellm_payload(event)
assert out["usage"] == {"input_tokens": 4, "output_tokens": 9}
def test_normalize_litellm_payload_aliases_dumped_usage() -> None:
"""A litellm result whose model_dump emits OpenAI-style usage keys
should be normalized to Anthropic-style keys for downstream cost
reconciliation, with the original keys preserved alongside."""
result = MagicMock()
result.model_dump.return_value = {
"id": "abc",
"model": "openai/gpt-4o-mini",
"usage": {"prompt_tokens": 12, "completion_tokens": 34},
}
out = BaseUpstreamProvider._normalize_litellm_payload(result)
assert out["model"] == "openai/gpt-4o-mini"
assert out["usage"]["input_tokens"] == 12
assert out["usage"]["output_tokens"] == 34
# OpenAI keys preserved alongside the canonical mapping
assert out["usage"]["prompt_tokens"] == 12
# ---------------------------------------------------------------------------
# Provider gating
# ---------------------------------------------------------------------------
def test_default_provider_does_not_support_anthropic_messages() -> None:
assert BaseUpstreamProvider.supports_anthropic_messages is False
assert BaseUpstreamProvider.litellm_provider_prefix == "openai/"
def test_anthropic_provider_supports_native_messages() -> None:
from routstr.upstream.anthropic import AnthropicUpstreamProvider
assert AnthropicUpstreamProvider.supports_anthropic_messages is True
def test_openrouter_provider_supports_native_messages() -> None:
from routstr.upstream.openrouter import OpenRouterUpstreamProvider
assert OpenRouterUpstreamProvider.supports_anthropic_messages is True
def test_provider_prefix_overrides() -> None:
from routstr.upstream.azure import AzureUpstreamProvider
from routstr.upstream.fireworks import FireworksUpstreamProvider
from routstr.upstream.gemini import GeminiUpstreamProvider
from routstr.upstream.groq import GroqUpstreamProvider
from routstr.upstream.ollama import OllamaUpstreamProvider
from routstr.upstream.perplexity import PerplexityUpstreamProvider
from routstr.upstream.xai import XAIUpstreamProvider
assert GroqUpstreamProvider.litellm_provider_prefix == "groq/"
assert XAIUpstreamProvider.litellm_provider_prefix == "xai/"
assert FireworksUpstreamProvider.litellm_provider_prefix == "fireworks_ai/"
assert PerplexityUpstreamProvider.litellm_provider_prefix == "perplexity/"
assert GeminiUpstreamProvider.litellm_provider_prefix == "gemini/"
assert OllamaUpstreamProvider.litellm_provider_prefix == "ollama_chat/"
assert AzureUpstreamProvider.litellm_provider_prefix == "azure/"
# ---------------------------------------------------------------------------
# Non-streaming
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_non_streaming_dispatches_via_litellm_and_returns_anthropic_response() -> (
None
):
provider = _make_provider()
key = _make_key()
model = _make_model()
session = _make_session()
body = _anthropic_request_body(stream=False)
upstream_response = _anthropic_non_stream_response()
async def fake_acreate(**kwargs: Any) -> dict:
# Verify model prefixing and credentials are forwarded correctly
assert kwargs["model"] == "openai/openai/gpt-4o-mini"
assert kwargs["api_base"] == "http://test"
assert kwargs["api_key"] == "upstream-key"
assert kwargs["stream"] is False
assert kwargs["messages"] == [{"role": "user", "content": "hi"}]
assert kwargs["max_tokens"] == 64
return upstream_response
fake_cost = {"total_msats": 1234, "total_usd": 0.0001}
with (
patch(
"litellm.anthropic.messages.acreate",
new=AsyncMock(side_effect=fake_acreate),
),
patch(
"routstr.upstream.base.adjust_payment_for_tokens",
new=AsyncMock(return_value=fake_cost),
),
):
result = await provider._forward_messages_via_litellm(
request_body=body,
key=key,
session=session,
max_cost_for_model=10_000,
model_obj=model,
)
assert isinstance(result, Response)
payload = json.loads(result.body)
assert payload["model"] == "openai/gpt-4o-mini" # mapped back to requested
assert payload["usage"]["input_tokens"] == 5
assert payload["usage"]["output_tokens"] == 3
# Cost metadata injected
assert payload["usage"]["cost"] == 0.0001
assert payload["usage"]["cost_sats"] == 1
# ---------------------------------------------------------------------------
# Streaming
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_streaming_emits_sse_and_reconciles_cost_at_end() -> None:
provider = _make_provider()
key = _make_key()
model = _make_model()
session = _make_session()
body = _anthropic_request_body(stream=True)
async def fake_chunks() -> AsyncIterator[dict]:
yield {
"type": "message_start",
"message": {
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "openai/gpt-4o-mini",
"content": [],
"usage": {"input_tokens": 5, "output_tokens": 0},
},
}
yield {
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": ""},
}
yield {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "hi"},
}
yield {"type": "content_block_stop", "index": 0}
yield {
"type": "message_delta",
"delta": {"stop_reason": "end_turn"},
"usage": {"output_tokens": 7},
}
yield {"type": "message_stop"}
fake_cost = {"total_msats": 4321, "total_usd": 0.00015}
captured_cost_call: dict[str, Any] = {}
async def fake_adjust(
fresh_key: Any, combined_data: Any, sess: Any, max_cost: int
) -> dict:
captured_cost_call["combined_data"] = combined_data
captured_cost_call["max_cost"] = max_cost
return fake_cost
fake_session = MagicMock()
class FakeSessionCtx:
async def __aenter__(self) -> Any:
return fake_session
async def __aexit__(self, *args: Any) -> None:
return None
fake_session.get = AsyncMock(return_value=key)
with (
patch(
"litellm.anthropic.messages.acreate",
new=AsyncMock(return_value=fake_chunks()),
),
patch(
"routstr.upstream.base.adjust_payment_for_tokens",
new=AsyncMock(side_effect=fake_adjust),
),
patch(
"routstr.upstream.base.create_session",
new=lambda: FakeSessionCtx(),
),
):
result = await provider._forward_messages_via_litellm(
request_body=body,
key=key,
session=session,
max_cost_for_model=10_000,
model_obj=model,
)
assert isinstance(result, StreamingResponse)
emitted: list[bytes] = []
async for chunk in result.body_iterator:
if isinstance(chunk, bytes):
emitted.append(chunk)
elif isinstance(chunk, memoryview):
emitted.append(bytes(chunk))
else:
emitted.append(chunk.encode())
joined = b"".join(emitted).decode()
# SSE event header for the first chunk
assert "event: message_start" in joined
assert "event: content_block_delta" in joined
assert "event: message_delta" in joined
assert "event: cost" in joined
# Usage was extracted from message_start (input) and message_delta (output)
combined = captured_cost_call["combined_data"]
assert combined["usage"]["input_tokens"] == 5
assert combined["usage"]["output_tokens"] == 7
assert combined["model"] == "openai/gpt-4o-mini"
@pytest.mark.asyncio
async def test_streaming_handles_iterator_yielding_raw_sse_bytes() -> None:
"""Regression: litellm sometimes yields already-SSE-encoded bytes.
Previously this raised TypeError("Cannot coerce bytes to dict"). The
stream loop must parse SSE blocks (even split across chunks) and still
perform cost reconciliation.
"""
provider = _make_provider()
key = _make_key()
model = _make_model()
session = _make_session()
body = _anthropic_request_body(stream=True)
async def fake_byte_chunks() -> AsyncIterator[bytes]:
# Whole event in one chunk
yield (
b"event: message_start\n"
b'data: {"type":"message_start","message":{"id":"m1",'
b'"model":"openai/gpt-4o-mini",'
b'"usage":{"input_tokens":3,"output_tokens":0}}}\n\n'
)
# Event split across two chunks (boundary inside the data line)
yield b'event: message_delta\ndata: {"type":"message_delta",'
yield b'"delta":{},"usage":{"output_tokens":4}}\n\n'
# SSE comment + DONE sentinel must be ignored
yield b": keepalive\n\ndata: [DONE]\n\n"
yield b"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
fake_cost = {"total_msats": 999, "total_usd": 0.0001}
captured: dict[str, Any] = {}
async def fake_adjust(
fresh_key: Any, combined_data: Any, sess: Any, max_cost: int
) -> dict:
captured["combined_data"] = combined_data
return fake_cost
fake_session = MagicMock()
fake_session.get = AsyncMock(return_value=key)
class FakeSessionCtx:
async def __aenter__(self) -> Any:
return fake_session
async def __aexit__(self, *args: Any) -> None:
return None
with (
patch(
"litellm.anthropic.messages.acreate",
new=AsyncMock(return_value=fake_byte_chunks()),
),
patch(
"routstr.upstream.base.adjust_payment_for_tokens",
new=AsyncMock(side_effect=fake_adjust),
),
patch(
"routstr.upstream.base.create_session",
new=lambda: FakeSessionCtx(),
),
):
result = await provider._forward_messages_via_litellm(
request_body=body,
key=key,
session=session,
max_cost_for_model=10_000,
model_obj=model,
)
assert isinstance(result, StreamingResponse)
emitted: list[bytes] = []
async for chunk in result.body_iterator:
if isinstance(chunk, bytes):
emitted.append(chunk)
elif isinstance(chunk, memoryview):
emitted.append(bytes(chunk))
else:
emitted.append(chunk.encode())
joined = b"".join(emitted).decode()
assert "event: message_start" in joined
assert "event: message_delta" in joined
assert "event: message_stop" in joined
assert "event: cost" in joined
# [DONE] sentinel and SSE comments must NOT be re-emitted as data
assert "[DONE]" not in joined
combined = captured["combined_data"]
assert combined["usage"]["input_tokens"] == 3
assert combined["usage"]["output_tokens"] == 4
assert combined["model"] == "openai/gpt-4o-mini"
@pytest.mark.asyncio
async def test_streaming_normalizes_openai_usage_keys_for_cost() -> None:
"""Regression: when litellm yields chunks with OpenAI-style usage keys
(prompt_tokens/completion_tokens), the stream loop must map them to
Anthropic input_tokens/output_tokens for cost reconciliation."""
provider = _make_provider()
key = _make_key()
model = _make_model()
session = _make_session()
body = _anthropic_request_body(stream=True)
async def fake_chunks() -> AsyncIterator[dict]:
yield {
"type": "message_start",
"message": {
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "openai/gpt-4o-mini",
"content": [],
# OpenAI-style usage on the message
"usage": {"prompt_tokens": 9, "completion_tokens": 0},
},
}
yield {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "hi"},
}
yield {
"type": "message_delta",
"delta": {"stop_reason": "end_turn"},
# OpenAI-style usage on the delta
"usage": {"prompt_tokens": 0, "completion_tokens": 17},
}
yield {"type": "message_stop"}
fake_cost = {"total_msats": 100, "total_usd": 0.0001}
captured: dict[str, Any] = {}
async def fake_adjust(
fresh_key: Any, combined_data: Any, sess: Any, max_cost: int
) -> dict:
captured["combined_data"] = combined_data
return fake_cost
fake_session = MagicMock()
fake_session.get = AsyncMock(return_value=key)
class FakeSessionCtx:
async def __aenter__(self) -> Any:
return fake_session
async def __aexit__(self, *args: Any) -> None:
return None
with (
patch(
"litellm.anthropic.messages.acreate",
new=AsyncMock(return_value=fake_chunks()),
),
patch(
"routstr.upstream.base.adjust_payment_for_tokens",
new=AsyncMock(side_effect=fake_adjust),
),
patch(
"routstr.upstream.base.create_session",
new=lambda: FakeSessionCtx(),
),
):
result = await provider._forward_messages_via_litellm(
request_body=body,
key=key,
session=session,
max_cost_for_model=10_000,
model_obj=model,
)
assert isinstance(result, StreamingResponse)
async for _ in result.body_iterator:
pass
combined = captured["combined_data"]
# Token counts come from the OpenAI-style fields after normalization
assert combined["usage"]["input_tokens"] == 9
assert combined["usage"]["output_tokens"] == 17
# ---------------------------------------------------------------------------
# forward_request gating
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_forward_request_routes_messages_via_litellm() -> None:
provider = _make_provider()
key = _make_key()
model = _make_model()
session = _make_session()
request = MagicMock()
sentinel = Response(content=b'{"ok": true}', media_type="application/json")
with patch.object(
provider,
"_forward_messages_via_litellm",
new=AsyncMock(return_value=sentinel),
) as mock_helper:
result = await provider.forward_request(
request=request,
path="messages",
headers={},
request_body=_anthropic_request_body(),
key=key,
max_cost_for_model=10_000,
session=session,
model_obj=model,
)
mock_helper.assert_awaited_once()
assert result is sentinel
@pytest.mark.asyncio
async def test_forward_request_skips_litellm_when_provider_supports_messages() -> None:
provider = _make_provider(supports_messages=True)
key = _make_key()
model = _make_model()
session = _make_session()
request = MagicMock()
with patch.object(
provider,
"_forward_messages_via_litellm",
new=AsyncMock(side_effect=AssertionError("should not be called")),
):
# The non-litellm path will try to build a URL and call httpx, so we
# short-circuit by patching prepare_request_body to raise — we only
# care that the litellm helper was NOT invoked.
with patch.object(
provider,
"prepare_request_body",
side_effect=RuntimeError("stop here"),
):
with pytest.raises(RuntimeError, match="stop here"):
await provider.forward_request(
request=request,
path="messages",
headers={},
request_body=_anthropic_request_body(),
key=key,
max_cost_for_model=10_000,
session=session,
model_obj=model,
)
@pytest.mark.asyncio
async def test_forward_request_skips_litellm_for_count_tokens() -> None:
provider = _make_provider()
key = _make_key()
model = _make_model()
session = _make_session()
request = MagicMock()
with patch.object(
provider,
"_forward_messages_via_litellm",
new=AsyncMock(side_effect=AssertionError("should not be called")),
):
with patch.object(
provider,
"prepare_request_body",
side_effect=RuntimeError("stop here"),
):
with pytest.raises(RuntimeError, match="stop here"):
await provider.forward_request(
request=request,
path="messages/count_tokens",
headers={},
request_body=_anthropic_request_body(),
key=key,
max_cost_for_model=10_000,
session=session,
model_obj=model,
)
+117
View File
@@ -0,0 +1,117 @@
"""Regression tests for the periodic upstream models refresh loop."""
from __future__ import annotations
import asyncio
import os
from typing import cast
from unittest.mock import AsyncMock
import pytest
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
os.environ.setdefault("UPSTREAM_API_KEY", "test")
from routstr.upstream.base import BaseUpstreamProvider # noqa: E402
class _FakeUpstream:
"""Minimal stand-in for BaseUpstreamProvider used by the refresh loop.
Only ``base_url`` (for error logging) and ``refresh_models_cache`` (the call
under test) are exercised; everything else stays unused.
"""
def __init__(self, name: str) -> None:
self.base_url = f"http://{name}"
self.refresh_models_cache = AsyncMock()
def _make_fake_upstream(name: str) -> BaseUpstreamProvider:
# The loop only uses duck-typed attributes — cast keeps the test type-clean
# without dragging in BaseUpstreamProvider's full constructor.
return cast(BaseUpstreamProvider, _FakeUpstream(name))
@pytest.mark.asyncio
async def test_refresh_loop_picks_up_providers_added_after_startup(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If a provider is added after the loop starts (e.g. via reinitialize_upstreams),
the next loop iteration must refresh it. Previously the loop captured the upstream
list at startup and missed any later additions."""
from routstr.core.settings import settings as global_settings
from routstr.upstream.helpers import refresh_upstreams_models_periodically
# Tight interval so the test finishes quickly.
monkeypatch.setattr(
global_settings, "models_refresh_interval_seconds", 1, raising=False
)
initial_upstream = _make_fake_upstream("initial")
live_list: list[BaseUpstreamProvider] = [initial_upstream]
# Stub out the post-iteration sats-pricing refresh so the loop body has no DB deps.
async def _noop_pricing_refresh() -> None: # pragma: no cover - trivial stub
return None
monkeypatch.setattr(
"routstr.payment.models._update_sats_pricing_once",
_noop_pricing_refresh,
)
task = asyncio.create_task(
refresh_upstreams_models_periodically(lambda: live_list)
)
try:
# Wait for the first iteration to refresh the initial upstream.
for _ in range(40):
if initial_upstream.refresh_models_cache.await_count >= 1: # type: ignore[attr-defined]
break
await asyncio.sleep(0.05)
assert initial_upstream.refresh_models_cache.await_count >= 1, ( # type: ignore[attr-defined]
"loop did not refresh the initial upstream within the timeout"
)
# Simulate reinitialize_upstreams: replace the live list contents with new
# provider instances. The loop must observe the swap on its next tick.
new_upstream = _make_fake_upstream("added-after-startup")
live_list[:] = [new_upstream]
for _ in range(60):
if new_upstream.refresh_models_cache.await_count >= 1: # type: ignore[attr-defined]
break
await asyncio.sleep(0.05)
assert new_upstream.refresh_models_cache.await_count >= 1, ( # type: ignore[attr-defined]
"loop did not refresh the upstream added after startup — "
"regression: list snapshot captured at startup"
)
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_refresh_loop_disabled_when_interval_non_positive(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from routstr.core.settings import settings as global_settings
from routstr.upstream.helpers import refresh_upstreams_models_periodically
monkeypatch.setattr(
global_settings, "models_refresh_interval_seconds", 0, raising=False
)
upstream = _make_fake_upstream("never-refreshed")
# Loop must return immediately without ever touching the upstream.
await asyncio.wait_for(
refresh_upstreams_models_periodically(lambda: [upstream]),
timeout=1.0,
)
upstream.refresh_models_cache.assert_not_awaited() # type: ignore[attr-defined]
+267
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", [])
+37 -1
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
+117
View File
@@ -0,0 +1,117 @@
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}
)
# create_session() is used as an async context manager whose entered
# value exposes an awaitable .get(). Build a mock that behaves that
# way so the post-stream cost-chunk emission can run.
mock_session = MagicMock()
mock_session.get = AsyncMock(return_value=key)
mock_ctx = MagicMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=None)
base.create_session = MagicMock(return_value=mock_ctx)
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
+76
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
+187
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
+216
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", {})
-11
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
View File
@@ -42,3 +42,4 @@ next-env.d.ts
# favicon conflicts
/app/favicon.ico
.pnpm-store/
+5
View File
@@ -0,0 +1,5 @@
pnpm-lock.yaml
.next
node_modules
out
next-env.d.ts
+21 -35
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>
);
}
+138 -178
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;
}
+3 -16
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>
+38 -53
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>
);
}
+8 -33
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>
+45 -88
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>
);
}
+122 -429
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'
+182
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>
);
}
+121 -115
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>
);
}
+3 -327
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 />;
}
+986 -307
View File
File diff suppressed because it is too large Load Diff
+12 -9
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>
+321 -1085
View File
File diff suppressed because it is too large Load Diff
+27 -29
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>
);
}
+514 -307
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>
);
}
+19 -15
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>
);
}
+5 -3
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
-470
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
@@ -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: '',
@@ -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'

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