Compare commits

...

455 Commits

Author SHA1 Message Date
9qeklajc
bd8b01e866 resolve review comments 2026-06-13 23:26:23 +02:00
9qeklajc
4b74a9cf81 explicit cache 2026-06-13 22:35:55 +02:00
9qeklajc
3be2da6728 update to all providers 2026-06-13 21:06:56 +02:00
9qeklajc
17d690e77e Merge branch 'fix/cached-token-overcharge' into fix-reservation 2026-06-13 21:06:42 +02:00
9qeklajc
bf05c96dfc Merge pull request #552 from Routstr/fix-reset-reserve-balance
Fix reset reserve balance
2026-06-12 21:52:39 +02:00
9qeklajc
ed6e0c0189 Merge main into fix-reset-reserve-balance
Resolve pay_for_request conflict: keep main's atomic child-key
balance_limit guard and post-rowcount-check ordering, and stamp
reserved_at on both billing and child reservations.
2026-06-12 21:08:53 +02:00
9qeklajc
412bfe479c Merge pull request #547 from jeroenubbink/fix/child-key-balance-limit-atomic
fix: enforce balance_limit atomically on child key requests
2026-06-12 20:23:25 +02:00
9qeklajc
861fda21b7 resolve review comments 2026-06-12 20:20:38 +02:00
9qeklajc
919dcf5535 make key reservation reset predictable 2026-06-12 19:45:39 +02:00
Jeroen Ubbink
cbc424e8e7 build: type-check the entire repo in make targets, matching CI
CI runs 'uv run mypy .' while the Makefile only checked routstr/, so test
files could pass locally and fail the pipeline. lint, type-check and
ci-lint now check everything; --ignore-missing-imports is dropped since
the CI invocation passes without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:22:28 +02:00
Jeroen Ubbink
068fb3572f refactor: drop unused session parameter from calculate_cost
The session was needed when model pricing lived in the DB (73d3613) and has
been dead since pricing moved to the in-memory model map (0da08fb), yet every
caller was still obliged to supply one. get_x_cashu_cost even opened a DB
session per x-cashu request solely to feed it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:17:11 +02:00
Jeroen Ubbink
eaf74edbba fix: bill cached input tokens at their real rates across vendor dialects
Cached prompt tokens were billed at the full input rate whenever a vendor's
usage dialect or cache pricing was unknown, overcharging DeepSeek topups
~5-10x on agentic workloads (hits are 10x cheaper upstream) and silently
mispricing OpenAI cached reads and Anthropic cache writes the same way.

Two root causes, two fixes:

- Usage dialects: DeepSeek reports prompt_cache_hit_tokens /
  prompt_cache_miss_tokens, which billing never parsed. Usage normalization
  now lives in payment/usage.py as a union parser over the known,
  non-colliding dialects (OpenAI prompt_tokens_details, Anthropic additive
  cache fields, DeepSeek hit/miss), producing one canonical NormalizedUsage.
  Providers expose it as an overridable BaseUpstreamProvider.normalize_usage
  hook — the escape hatch for future vendors whose fields genuinely
  conflict — and every settlement call site passes the provider's result
  through, so calculate_cost holds no vendor knowledge of its own.

- Cache rates: the OpenRouter model feed omits input_cache_read/-write for
  most DeepSeek models (and e.g. openai/gpt-4o), so billing fell back to the
  full input rate. Missing rates are now backfilled from litellm's bundled
  cost map before the provider fee is applied; the input-rate fallback
  remains only as the documented last resort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:17:11 +02:00
Jeroen Ubbink
28c4008892 fix: enforce balance_limit atomically on child key requests
The Python pre-check on balance_limit provided no concurrency guarantee —
two concurrent requests could both pass the check on stale in-memory state
and both proceed to reserve, exceeding the limit.

Add the balance_limit guard to the child key UPDATE's WHERE clause so the
enforcement is atomic. The HTTP 402 is raised without an explicit rollback:
since session.commit() was never called, the uncommitted billing key update
is discarded when the session context manager closes on exception exit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:37:39 +02:00
9qeklajc
a16bc1220c Merge pull request #546 from Routstr/fix-parent-key-leak-to-child-key
do not leak parent key to child key
2026-06-07 15:27:32 +02:00
9qeklajc
5d3e687876 Merge pull request #545 from Routstr/secure-test-endpoint
secure endpoint
2026-06-07 15:25:53 +02:00
9qeklajc
68a7cd1dfb do not leak parent key to child key 2026-06-07 15:25:13 +02:00
9qeklajc
d96dc20e86 secure endpoint 2026-06-07 15:18:49 +02:00
9qeklajc
934afaa091 Merge pull request #544 from Routstr/pr-reactive-correction
feat: reactive request-correction retry for recoverable upstream 400s
2026-06-07 15:09:18 +02:00
9qeklajc
9ef01acac9 Merge branch 'main' into pr-reactive-correction 2026-06-07 13:10:29 +02:00
9qeklajc
cbd38c15fe Merge pull request #543 from Routstr/pr-sse-parser
fix: buffer-based SSE parser for all supported providers
2026-06-07 13:06:16 +02:00
9qeklajc
06c8a071a5 feat: reactive request-correction retry for recoverable upstream 400s 2026-06-07 13:00:19 +02:00
9qeklajc
9e9c2bde57 fix: buffer-based SSE parser for all supported providers
Replace the fragile `re.split(b"data: ")` streaming parser with a
buffered, event-delimited parser in both the chat-completion and
Responses-API streamers. Events are accumulated until the SSE blank-line
delimiter, so parsing is independent of network chunk boundaries.

Fixes:
- OpenRouter `: OPENROUTER PROCESSING` keepalive comments no longer leak
  to clients as `data: : ...` (the `Unexpected token ':'` client crash).
- JSON payloads split across TCP reads are reassembled before parsing.
- CRLF framing (Gemini native alt=sse) handled.
- Combined content+usage chunks (Gemini thinking models over the
  OpenAI-compat endpoint) forward content once and still report usage in
  the cost trailer, instead of dropping the assistant message.
- Multi-line non-JSON `data` blocks are re-prefixed per line so they stay
  valid SSE framing for the client.

Adds tests/unit/test_streaming_sse_providers.py driving the real
generator against per-provider on-the-wire framing, and switches the
integration token-mint fallback to secrets.token_hex to kill a
PRNG/clock collision flake.
2026-06-07 12:58:54 +02:00
9qeklajc
222fd6ed45 Merge pull request #542 from Routstr/fix-model-provider-field-response
make sure model provider field naming is correct
2026-06-07 11:32:01 +02:00
9qeklajc
4abd751f5f make sure model provider field naming is correct 2026-06-07 11:01:53 +02:00
9qeklajc
8f89171db1 Merge pull request #541 from jeroenubbink/fix/lightning-invoice-key-constraints
fix: persist and propagate key constraints from Lightning invoices
2026-06-07 10:51:29 +02:00
9qeklajc
5dc9d60bec Merge pull request #540 from jeroenubbink/fix/child-key-atomic-balance-deduction
fix: make child key balance deduction atomic
2026-06-04 10:09:26 +02:00
Jeroen Ubbink
feb76bc89d fix: persist and propagate key constraints from Lightning invoices
LightningInvoice had no columns for balance_limit, balance_limit_reset,
or validity_date. SQLModel silently dropped these constructor kwargs, so
create_api_key_from_invoice always produced an unconstrained key.

Add the three columns to LightningInvoice with a migration, and wire them
through to the ApiKey in create_api_key_from_invoice, matching the pattern
already used in the child key creation path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:58:40 +02:00
Jeroen Ubbink
3b7f96560b fix: make child key balance deduction atomic
The previous in-memory deduction (key.balance -= cost) was a read-modify-
write on stale state, allowing two concurrent create_child_key() calls to
both pass the balance check and both succeed, effectively charging the
parent only once for two child keys.

Replace with an atomic UPDATE ... WHERE balance - reserved_balance >= cost
and check rowcount, matching the pattern already used in pay_for_request.
Also adds a concurrent integration test that reproduces the race and
confirms the fix holds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 12:02:12 +02:00
9qeklajc
23e206c93a Merge pull request #538 from Routstr/revert-sse-refactoring
revert sse refactoring
2026-06-02 14:00:48 +02:00
9qeklajc
3e0ca0daf9 revert sse refactoring 2026-06-02 13:58:43 +02:00
9qeklajc
d7bf1d6582 Merge pull request #536 from Routstr/add-created-date-to-api-key
add api key creation date
2026-06-02 00:20:57 +02:00
9qeklajc
955090bd91 Merge pull request #535 from Routstr/prevent-zero-balance-token
prevent zero balance token
2026-06-01 23:34:51 +02:00
9qeklajc
379c319e0d fix test 2026-06-01 23:30:20 +02:00
9qeklajc
aaa80d47bc add api key creation date 2026-06-01 23:27:26 +02:00
9qeklajc
9633483fa4 prevent zero balance token 2026-06-01 22:47:09 +02:00
9qeklajc
9aa3408905 Merge pull request #528 from bilthon/refactor/remove-refund-by-token-hash
Remove deprecated cashu-refund-by-hash endpoint
2026-05-31 16:39:22 +02:00
9qeklajc
31ff1fd90c Merge pull request #519 from Routstr/sse-buffer-refactor
refactor: rewrite SSE parsing with buffered double-newline delimiter
2026-05-31 11:13:10 +02:00
9qeklajc
b9622689ea Merge remote-tracking branch 'origin/main' into sse-buffer-refactor
# Conflicts:
#	routstr/upstream/base.py
2026-05-30 22:55:55 +02:00
9qeklajc
f9e8a3250d fix import 2026-05-30 21:28:54 +02:00
9qeklajc
ade2d19be6 Merge pull request #534 from Routstr/wrap-long-log-info
wrap long log to not overflow
2026-05-30 20:48:50 +02:00
9qeklajc
c4d0a1afba wrap long log to not overflow 2026-05-30 20:02:44 +02:00
9qeklajc
81817355cc Merge pull request #533 from Routstr/add-git-dep
add missing git dep. to display correct commit
2026-05-30 17:58:03 +02:00
root
d345d3b53f add missing git dep. to display correct commit 2026-05-30 17:56:03 +02:00
9qeklajc
9fe13e9733 Merge pull request #532 from Routstr/fix-compose
fix compose file
2026-05-30 17:31:23 +02:00
9qeklajc
ee79a305ba revert 2026-05-30 17:20:16 +02:00
9qeklajc
ed2c8c9fe2 fix compose file 2026-05-30 17:00:57 +02:00
9qeklajc
9161256d31 Merge pull request #529 from Routstr/rip-08-lightning-invoice
better handling invoice payment
2026-05-30 16:39:11 +02:00
9qeklajc
1a8f407142 Merge pull request #531 from Routstr/remove-secp256-dep
remove secp256k1 dependency
2026-05-29 13:34:31 +02:00
9qeklajc
eddc070628 remove secp256k1 dependency 2026-05-28 21:09:36 +02:00
9qeklajc
f7bd250c97 better handling invoice payment 2026-05-28 20:09:48 +02:00
Bilthon
f2ef63da62 refactor(balance): remove unreachable cashu-refund-by-hash endpoint 2026-05-28 10:25:25 -05:00
9qeklajc
29cfeaed9a Merge pull request #523 from Routstr/rip-08-lightning-invoice
add rop-08 lightning invoice support
2026-05-21 00:28:41 +02:00
9qeklajc
e2b46dab9f Merge pull request #522 from Routstr/fix-balance-info
use correct var to report balance info
2026-05-21 00:28:28 +02:00
9qeklajc
632244e54f add rop-08 lightning invoice support 2026-05-20 23:24:24 +02:00
9qeklajc
3251664513 use correct var to report balance info 2026-05-20 23:00:03 +02:00
9qeklajc
ee16cd0495 Merge pull request #518 from Routstr/tee-fixes
fix: tee GET passthrough and encoding fix
2026-05-19 22:31:19 +02:00
redshift
f80d59182f refactor: rewrite SSE parsing with buffered double-newline delimiter
- Replace regex split on 'data: ' with proper SSE buffering using \n\n
  as the event separator, handling partial chunks across boundaries
- Fix [DONE] detection to match 'data: [DONE]' instead of bare '[DONE]'
- Add debug logging for SSE buffer size, parsed events, and stream end
- Distinguish billing-only (usage) chunks from content-bearing chunks;
  hold back usage-only chunks for later cost metadata injection
- Extract JSON payload from 'data: ...' prefix instead of raw part
- Gracefully pass through non-JSON SSE events
2026-05-19 10:54:16 +08:00
redshift
70ef3c357c fixed the encoding bug. 2026-05-19 10:53:59 +08:00
redshift
e4165f1dab passing through tee get requests 2026-05-19 10:53:59 +08:00
9qeklajc
41edae52b0 Merge pull request #513 from Routstr/add-provider-field-to-response
add provider field to response
2026-05-18 22:06:10 +02:00
9qeklajc
cb16da5543 Merge pull request #517 from Routstr/selinux-podman-fix
fix: add SELinux :z labels and user root to podman-compose volumes
2026-05-18 20:28:10 +02:00
redshift
d52b727bce fix: add SELinux :z labels and user root to podman-compose volumes
- Added :z (shared SELinux label) to all host bind-mount volumes
  so containers can write when SELinux is enforcing
- Set user: root on the ui service for compatibility with
  rootless podman's UID mapping
2026-05-19 01:44:51 +08:00
9qeklajc
6a2abd3439 Merge pull request #515 from Routstr/fix-value-not-set-issue
fix missing field initialization
2026-05-17 14:46:33 +02:00
9qeklajc
1e4ed75179 fix missing field initialization 2026-05-17 14:43:50 +02:00
9qeklajc
efb5719679 add provider field to response 2026-05-17 14:39:16 +02:00
9qeklajc
3a1902b2b5 Merge pull request #512 from Routstr/sidebar-display-logo
better logo display
2026-05-16 23:56:20 +02:00
9qeklajc
ba1978f830 better logo display 2026-05-16 23:52:03 +02:00
9qeklajc
2f51d62a46 Merge pull request #511 from Routstr/enforce-provider-fee-calculation
enforce fee calculation
2026-05-16 23:17:13 +02:00
9qeklajc
1ca344ba7b enforce fee calculation 2026-05-16 23:13:47 +02:00
9qeklajc
dda9b848ba Merge pull request #510 from Routstr/display-correct-version
display correct commit
2026-05-16 21:14:26 +02:00
9qeklajc
033d8c5a38 display correct commit 2026-05-16 19:10:45 +02:00
9qeklajc
e73cdffdc5 Merge pull request #508 from Routstr/display-official-version
display version
2026-05-16 15:46:43 +02:00
9qeklajc
e32b332eb5 Merge pull request #509 from Routstr/reduce-verbose-logs
less verbose logs and more precise
2026-05-16 15:41:20 +02:00
9qeklajc
89b84392a4 lint 2026-05-16 15:40:58 +02:00
9qeklajc
f37ff30598 display version 2026-05-16 15:35:16 +02:00
9qeklajc
547f45c185 less verbose logs and more precise 2026-05-16 15:29:41 +02:00
9qeklajc
a6e81a83cb Merge pull request #507 from Routstr/fix-provider-forwarding-upstream
Fix provider forwarding upstream
2026-05-15 20:45:19 +02:00
9qeklajc
e2143aa173 Revert "fix home nav"
This reverts commit 2eb257c2a6.
2026-05-14 15:51:20 +02:00
9qeklajc
2eb257c2a6 fix home nav 2026-05-14 15:47:59 +02:00
9qeklajc
490687bb71 Merge branch 'main' into fix-provider-forwarding-upstream 2026-05-14 15:40:52 +02:00
9qeklajc
966613847e update not found proxy 2026-05-14 15:40:49 +02:00
9qeklajc
d030d86f9a Merge pull request #506 from Routstr/fix-swapping
fix swapping
2026-05-14 14:32:39 +02:00
9qeklajc
d9ab46b3bb make sure when topup from untrusted mint to refund from primary mint 2026-05-14 13:52:25 +02:00
9qeklajc
6e596e3860 Merge pull request #504 from Routstr/do-not-create-empty-token
no key creation when refund
2026-05-13 22:48:16 +02:00
9qeklajc
3c13be20cb no key creation when refund 2026-05-13 22:34:24 +02:00
9qeklajc
9d5e14903b fix swapping 2026-05-13 21:49:03 +02:00
9qeklajc
9e33d3b100 Merge pull request #503 from Routstr/fix-docker-build
add missing path
2026-05-10 11:21:04 +02:00
9qeklajc
c88665f6d1 Merge pull request #500 from Routstr/enforce-loading-keysets
enforce load mint keyset
2026-05-10 11:20:53 +02:00
9qeklajc
a8157b3e2d add missing path 2026-05-10 11:18:27 +02:00
9qeklajc
a07e6723d9 Merge pull request #502 from Routstr/fix-docker-build
pin pnpm version
2026-05-10 10:44:44 +02:00
9qeklajc
5bc7b741bc pin pnpm version 2026-05-10 10:42:17 +02:00
9qeklajc
1db37cf084 Merge pull request #501 from Routstr/fix-docker-build
build fix
2026-05-10 10:20:13 +02:00
9qeklajc
6c5b103149 build fix 2026-05-10 10:18:07 +02:00
9qeklajc
27ec052c17 Merge pull request #499 from Routstr/emulate-claude-token-count
support /message/count_tokens endpoint
2026-05-10 08:45:58 +02:00
9qeklajc
00b813f6a8 clean up 2026-05-10 08:43:47 +02:00
9qeklajc
91e5198a94 Merge pull request #498 from Routstr/fix-docker-build
fix-docker-build
2026-05-10 08:43:02 +02:00
9qeklajc
27f1cc3c42 enforce load mint keyset 2026-05-10 08:32:19 +02:00
9qeklajc
a4d048f2b5 fix-docker-build 2026-05-10 08:31:05 +02:00
9qeklajc
752d4f3803 Merge pull request #496 from Routstr/fix-provider-forwarding-upstream
fix provder forwarded path & wrong html response
2026-05-09 14:47:35 +02:00
9qeklajc
164ed775c8 support /message/count_tokens endpoint 2026-05-09 14:43:03 +02:00
9qeklajc
9d905758ec added page not found redirection 2026-05-09 14:07:45 +02:00
9qeklajc
0d1cb66855 Merge pull request #494 from Routstr/payment-configuration
configure payment
2026-05-09 12:07:49 +02:00
9qeklajc
6e9932e0ac fix provder forwarded path & wrong html response 2026-05-09 12:05:51 +02:00
9qeklajc
59773e972b configure payment 2026-05-09 00:00:18 +02:00
9qeklajc
7fd0cdf987 Merge pull request #491 from Routstr/check-version-crrectly
check-version-correclty
2026-05-07 00:13:17 +02:00
9qeklajc
b1947d660a check-version-correclty 2026-05-07 00:08:54 +02:00
9qeklajc
d45a9edcba Merge pull request #490 from Routstr/add-cache-token-to-calculation
add-missing-cache-token-to-calculation
2026-05-06 22:54:34 +02:00
9qeklajc
4dbbb45240 add-missing-cache-token-to-calculation 2026-05-06 22:49:23 +02:00
9qeklajc
a286b5efa0 Merge pull request #488 from Routstr/match-on-forwarded-model-id-not-model-id
Match on forwarded model id not model
2026-05-06 21:54:17 +02:00
9qeklajc
26a0b04aef strict fallback 2026-05-06 00:46:42 +02:00
9qeklajc
f7ccc25a7f forward correct model id 2026-05-05 23:57:41 +02:00
9qeklajc
8dd0ece501 Merge pull request #487 from Routstr/use-full-image-for-ghci-
use full docker image for ghci
2026-05-05 22:55:42 +02:00
9qeklajc
10969e0719 update deployment 2026-05-05 22:40:47 +02:00
9qeklajc
b633455071 use full docker image for ghci 2026-05-05 22:15:13 +02:00
9qeklajc
e280d1f8b1 Merge pull request #479 from Routstr/litellm-integration-for-anthropic-messages-forwarding
Litellm integration for anthropic messages forwarding
2026-05-04 23:58:31 +02:00
9qeklajc
81ad9f604b clean up 2026-05-04 21:29:41 +02:00
9qeklajc
2345691176 fix gemini upstream claude func calls 2026-05-04 21:17:09 +02:00
9qeklajc
a7615bc827 improve gemini upstream to forward /messages endpoint correctly 2026-05-04 00:48:52 +02:00
9qeklajc
befdc5307e add logs for missing token usage 2026-05-03 22:40:06 +02:00
9qeklajc
145777ffd0 clean up 2026-05-03 15:35:49 +02:00
9qeklajc
37c2bea93d make sure to forward to the right upstream 2026-05-03 15:15:34 +02:00
9qeklajc
985e765285 fix gemini api 2026-05-02 21:24:27 +02:00
9qeklajc
a4b1330627 clean up impl. & simplify 2026-05-02 16:46:39 +02:00
9qeklajc
7b69284812 Merge branch 'main' into litellm-integration-for-anthropic-messages-forwarding 2026-05-01 23:09:52 +02:00
9qeklajc
7fb1da7b58 Merge pull request #483 from Routstr/more-logging-cleanup
remove and clean up redundant logs
2026-05-01 23:09:35 +02:00
9qeklajc
38f9923469 remove and clean up redundant logs 2026-05-01 23:07:43 +02:00
9qeklajc
5b986a3e15 Merge branch 'main' into litellm-integration-for-anthropic-messages-forwarding 2026-05-01 20:55:02 +02:00
9qeklajc
56d9ff6b3f Merge pull request #482 from Routstr/better-url-request-handling
handle urls correctly
2026-05-01 20:54:48 +02:00
9qeklajc
15b31e7c53 handle urls correctly 2026-05-01 17:08:05 +02:00
9qeklajc
d0dcc3219d Merge branch 'main' into litellm-integration-for-anthropic-messages-forwarding 2026-05-01 16:49:28 +02:00
9qeklajc
52db6fd308 Merge pull request #481 from Routstr/display-running-node-version
display commit if node not aligned with release tag
2026-05-01 16:49:15 +02:00
9qeklajc
ceca0e8efc display commit if node not aligned with release tag 2026-05-01 16:47:34 +02:00
9qeklajc
309bc873e6 Merge branch 'main' into litellm-integration-for-anthropic-messages-forwarding 2026-05-01 16:30:13 +02:00
9qeklajc
89109dc209 Merge pull request #480 from Routstr/clean-up-logging
do not logs redundant infos
2026-05-01 16:29:53 +02:00
9qeklajc
234ea19cad do not logs redundant infos 2026-05-01 16:23:30 +02:00
9qeklajc
489b6eba14 revert 2026-04-28 01:01:23 +02:00
9qeklajc
66421cd17f normalize response 2026-04-28 00:44:22 +02:00
9qeklajc
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
9qeklajc
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
9qeklajc
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
9qeklajc
fa6d3c76d0 Merge pull request #474 from Routstr/fix-field-label
use correct field label
2026-04-26 00:05:13 +02:00
9qeklajc
ede1804d4b use correct field label 2026-04-25 23:57:24 +02:00
9qeklajc
3392e8d4cb Merge pull request #473 from Routstr/bump-release-version
release v0.4.3
2026-04-25 11:56:48 +02:00
9qeklajc
b5174d9753 release v0.4.3 2026-04-25 11:54:54 +02:00
9qeklajc
1f2ff8a99c added admin token 2026-04-25 11:18:58 +02:00
9qeklajc
0c60644ba2 Merge pull request #472 from Routstr/fix-revision
fix migration
2026-04-24 23:08:06 +02:00
9qeklajc
aca8d43a61 fix migration 2026-04-24 23:05:09 +02:00
9qeklajc
7fe4c1963b Merge pull request #470 from Routstr/add-dev-cut
update migration rev id
2026-04-24 15:21:32 +02:00
9qeklajc
9a0919f149 update migration rev id 2026-04-24 15:19:53 +02:00
9qeklajc
d69ab913d4 Merge pull request #419 from Routstr/add-dev-cut
add-dev-cut
2026-04-23 23:22:23 +02:00
9qeklajc
42b8c332df Merge pull request #467 from Routstr/fix-concurent-refund-with-adding-token-history
Fix concurent refund with adding token history
2026-04-22 23:45:38 +02:00
9qeklajc
aa682bf8ec fix test 2026-04-22 23:43:11 +02:00
9qeklajc
c53e72e80a update migration 2026-04-22 23:31:49 +02:00
9qeklajc
afd81aeca2 reduce retries 2026-04-22 23:14:45 +02:00
9qeklajc
4e03145323 clean up 2026-04-22 23:13:29 +02:00
9qeklajc
169686681f Merge branch 'main' into add-dev-cut 2026-04-22 23:03:35 +02:00
9qeklajc
fa7d2804bb add test 2026-04-22 23:00:04 +02:00
9qeklajc
ccabc5d06d Merge branch 'main' into fix-response-error-forwarding 2026-04-22 22:17:59 +02:00
9qeklajc
cb68227c88 fix race cond. test 2026-04-22 22:17:41 +02:00
9qeklajc
c72fc7dd56 Merge pull request #466 from Routstr/add-detailed-response
fix forwarding upstream error responses
2026-04-22 22:16:41 +02:00
9qeklajc
8c6d1f89dc fix forwarding upstream error responses 2026-04-22 22:12:44 +02:00
9qeklajc
1ab7e54bd7 improve history collection 2026-04-22 22:09:13 +02:00
9qeklajc
42dceb0cd6 make table scrollable 2026-04-22 21:57:29 +02:00
9qeklajc
05115c3387 add api-key history and fix race condition while topup 2026-04-22 21:50:22 +02:00
9qeklajc
c0c8cafd00 fix forwarding upstream error responses 2026-04-20 16:45:05 +02:00
9qeklajc
16d6d66d17 Merge pull request #460 from Routstr/fix-test-interface-ui
Fix test interface UI
2026-04-19 15:29:00 +02:00
9qeklajc
3681fc8aab no advanced testing for now 2026-04-19 15:22:47 +02:00
9qeklajc
d95c09e00c fix test model connection 2026-04-19 15:21:23 +02:00
9qeklajc
3cdef5ec17 Merge pull request #459 from Routstr/add-detailed-logging
add logging reason for bad not succeed requests
2026-04-18 00:33:39 +02:00
9qeklajc
58e1620347 add logging reason for bad not succeed requests 2026-04-18 00:30:19 +02:00
9qeklajc
42b5a5e6c8 Merge pull request #458 from Routstr/add-cost-usage-to-messages-endpoint
Add cost usage to messages endpoint
2026-04-16 17:36:18 +02:00
9qeklajc
d84d249f2c Merge branch 'main' into add-dev-cut
# Conflicts:
#	routstr/auth.py
2026-04-15 23:07:37 +02:00
9qeklajc
8b6393f794 clean up 2026-04-15 21:56:30 +02:00
9qeklajc
81146710ba revert 2026-04-15 21:32:33 +02:00
9qeklajc
25f39897d7 mirror logic from chat completion 2026-04-15 20:45:13 +02:00
9qeklajc
45bdfaee58 best effort to be compatible with legacy code 2026-04-15 20:18:56 +02:00
9qeklajc
a1ae6e94e9 match model when versioned 2026-04-15 20:05:36 +02:00
9qeklajc
eebcc67c85 add cost usages 2026-04-15 17:19:13 +02:00
9qeklajc
3f9e7f7728 Merge pull request #457 from Routstr/fix-ppq-model-fetching
fix ppq model fetching
2026-04-14 18:49:26 +02:00
9qeklajc
a5b5549edd fix ppq model fetching 2026-04-14 18:46:38 +02:00
9qeklajc
22eec0162c Merge pull request #456 from Routstr/fix-serializing-broken-chunks
make streaming serialization more robust
2026-04-14 00:45:48 +02:00
9qeklajc
9f55da9bb8 make streaming serialization more robust 2026-04-14 00:44:10 +02:00
9qeklajc
16dce9ea81 Merge pull request #455 from Routstr/check-primary-mint
enforce no swap when token from primary mint
2026-04-14 00:32:06 +02:00
9qeklajc
963ee04619 enforce no swap when token from primary mint 2026-04-14 00:29:54 +02:00
9qeklajc
b874b1f01c Merge pull request #454 from Routstr/response-id-should-be-set
upstream drops id field which breaks opencode flow
2026-04-13 23:27:41 +02:00
9qeklajc
b6cca3d3a0 upstream drops id field which breaks opencode flow 2026-04-13 23:11:07 +02:00
9qeklajc
86ebc84f4c Merge pull request #453 from Routstr/remove-hardcoded-fee
remove hardcoded deduction (legacy code)
2026-04-13 22:47:23 +02:00
9qeklajc
8a74c0543f remove hardcoded deduction (legacy code) 2026-04-13 22:41:04 +02:00
9qeklajc
3d16a9b988 Merge pull request #449 from Routstr/apikey-transactions
add apikey refund tracking
2026-04-12 22:38:40 +02:00
9qeklajc
40f98b99aa set collected state 2026-04-12 22:36:52 +02:00
9qeklajc
7fcae5b08d Merge pull request #451 from Routstr/feature/update-deployment-docs
Updated deployment docs
2026-04-12 11:23:10 +02:00
redshift
f50cb31749 Updated deployment docs 2026-04-11 22:15:50 +01:00
9qeklajc
85a1ea4b4c add apikey refund tracking 2026-04-10 15:34:19 +02:00
9qeklajc
0f0f8c40bf Merge pull request #447 from Routstr/436-update-model-id
add support to using custom model ids
2026-04-10 14:18:24 +02:00
9qeklajc
a40d224ee7 Merge pull request #446 from Routstr/improve-negative-balance-handling
Improve negative balance handling
2026-04-10 14:14:22 +02:00
9qeklajc
a637acd8f4 use db fixture 2026-04-09 23:08:01 +02:00
9qeklajc
58be0c7976 clean u 2026-04-09 20:42:36 +02:00
9qeklajc
d934f3eead clean up 2026-04-09 20:41:59 +02:00
9qeklajc
74b58e5fa3 add some tests and improve payment handling 2026-04-09 20:25:43 +02:00
9qeklajc
c1497e0cfe Merge pull request #444 from Routstr/add-model-id-to-validation-log
add model id to validation log
2026-04-09 01:13:07 +02:00
9qeklajc
30db582321 add model id to validation log 2026-04-08 00:36:05 +02:00
9qeklajc
a1018776e9 make sure no negative balance can happen 2026-04-08 00:16:55 +02:00
9qeklajc
5ef499e2ae Merge pull request #443 from Routstr/437-clean-up-logging
do not log client host address
2026-04-07 00:44:07 +02:00
9qeklajc
c7c802c610 do not log client host address 2026-04-07 00:23:53 +02:00
9qeklajc
685368bb0a add support to using custom model ids 2026-04-06 20:09:12 +02:00
9qeklajc
55e240d92a Merge pull request #435 from Routstr/add-missing-sat-cost-in-x-cashu
add sat cost to x-cashu response
2026-04-05 01:35:17 +02:00
9qeklajc
7da4ad3818 add sat cost to x-cashu response 2026-04-05 01:20:58 +02:00
9qeklajc
453337cb2c update default payout 2026-04-05 00:36:59 +02:00
9qeklajc
9e1934bfda Merge pull request #434 from Routstr/opencode-display-usage-correctly
Opencode display usage correctly
2026-04-05 00:34:22 +02:00
9qeklajc
31a9730cd8 clean up 2026-04-05 00:30:24 +02:00
9qeklajc
d686e0e851 Merge pull request #433 from Routstr/update-x-cashu-swept-default-to-one-week
x-cashu swept default to one week
2026-04-05 00:23:37 +02:00
9qeklajc
7708ed1c8b x-cashu swept default to one week 2026-04-05 00:21:53 +02:00
9qeklajc
22ede7636a added opencode display usage correctly 2026-04-03 23:37:04 +02:00
9qeklajc
c9a5af0ffa Merge pull request #432 from Routstr/fix-usage-msat-pricing
set in/out msat prices correctly
2026-04-03 23:14:17 +02:00
9qeklajc
072079b33d set in/out msat prices correctly 2026-04-03 22:26:48 +02:00
9qeklajc
2abc28d295 Merge pull request #431 from Routstr/add-refund-success-log
added refund success log
2026-04-02 13:31:01 +02:00
9qeklajc
7879b68cad added refund success log 2026-04-02 13:18:55 +02:00
9qeklajc
9eff340c57 Merge pull request #430 from Routstr/add-missing-messages-endpoint
add missing messages endpoint
2026-04-01 23:29:35 +02:00
9qeklajc
933ba105d8 add missing messages endpoint 2026-04-01 23:27:15 +02:00
9qeklajc
205dc6c3b3 Merge pull request #429 from Routstr/fix-reset-refund-while-reserved-balance-not-zero
refund only when reserved balance is zero
2026-04-01 17:21:57 +02:00
9qeklajc
c1ada39278 refund only when reserved balance is zero 2026-04-01 17:17:36 +02:00
9qeklajc
200b32f1ff Merge pull request #428 from Routstr/simplify-x-cashu-refund
Simplify x cashu refund
2026-04-01 15:23:59 +02:00
9qeklajc
2b623a86c3 Merge pull request #423 from Routstr/fix-display-api-key-infos
Fix display api key infos
2026-04-01 15:18:35 +02:00
9qeklajc
a68998e8ff clean up 2026-04-01 15:13:38 +02:00
9qeklajc
905ae67015 update tests 2026-04-01 15:11:43 +02:00
9qeklajc
6fde846be2 refund x cashu 2026-04-01 15:04:38 +02:00
9qeklajc
a833cf429e add refund x-cashu to default refund endpoint 2026-03-29 21:22:09 +02:00
9qeklajc
c5a205cf98 clean up 2026-03-29 11:40:17 +02:00
9qeklajc
62185fbd38 fix build 2026-03-25 20:07:36 +01:00
9qeklajc
236854bfe4 update lightining address 2026-03-25 10:25:46 +01:00
9qeklajc
a7886c528f Merge branch 'main' into add-dev-cut 2026-03-25 10:24:55 +01:00
9qeklajc
bb97e8dedb use react query 2026-03-25 10:21:18 +01:00
9qeklajc
a7455a48d2 Merge pull request #421 from Routstr/v0.4.1
fix fee calculation
2026-03-25 10:20:14 +01:00
redshift
a090632804 Merge pull request #424 from Routstr/fix/models-trailing-slash
fix: add trailing slash routes for /models endpoint
2026-03-24 22:31:14 +00:00
redshift
d6b755ff81 Merge pull request #425 from Routstr/main
syncign before merge
2026-03-24 22:28:37 +00:00
9qeklajc
a1c2a785a1 better displaying errors 2026-03-23 22:36:16 +01:00
redshift
dc8ebe66e0 docs: add other mints balance tracking plan 2026-03-23 21:28:45 +00:00
9qeklajc
750193e99d clean up ui 2026-03-23 22:20:36 +01:00
9qeklajc
4ee654331f fix refunding not displayed & clean up 2026-03-23 22:01:54 +01:00
9qeklajc
b8fcf5bcc8 update node version 2026-03-23 21:05:42 +01:00
9qeklajc
2ce8981f0c Merge pull request #422 from Routstr/fix-fee-calculation-for-same-mint
improve mint fee calculation
2026-03-23 21:02:32 +01:00
9qeklajc
b76ff62f1c improve mint fee calculation 2026-03-23 20:58:01 +01:00
9qeklajc
73ebfe8975 Merge pull request #420 from Routstr/fix-fee-calculation-for-same-mint
no fees when same mint
2026-03-23 20:12:57 +01:00
9qeklajc
3dae03d731 no fees when same mint 2026-03-23 20:10:19 +01:00
9qeklajc
a7b815b29f add-dev-cut 2026-03-23 20:07:24 +01:00
redshift
8a1f909083 Merge pull request #415 from Routstr/v0.4.0
Fix admin models route conflict
2026-03-23 15:00:33 +00:00
redshift
a6c6d3e034 fix: add trailing slash routes for /models endpoint
The /models/ endpoint with trailing slash was being caught by the proxy
catch-all route instead of the models router, causing 'Model unknown not
found' errors. Add explicit routes for both /models and /v1/models with
and without trailing slashes.
2026-03-23 13:15:59 +00:00
Evan Yang
f2a0473fb3 Fix admin models route conflict 2026-03-23 20:29:18 +08:00
redshift
33d2ef9ef1 Merge pull request #353 from Routstr/v0.4.0
V0.4.0
2026-03-23 11:39:22 +00:00
9qeklajc
f1c3b515fe Merge pull request #413 from Routstr/fix-linting-issue
fix linting error
2026-03-22 20:02:03 +01:00
9qeklajc
74ddc48ff5 fix linting error 2026-03-22 19:59:39 +01:00
9qeklajc
6db11ed88f update python 2026-03-18 23:40:34 +01:00
9qeklajc
4d302baeb5 update to v0.4.0 2026-03-18 23:31:25 +01:00
9qeklajc
063df51ced Merge pull request #411 from Routstr/408-fix-swap
fix swapping the primary mint
2026-03-18 22:17:29 +01:00
9qeklajc
e97fa60b27 Merge pull request #407 from Routstr/v0.4.0-analytics
V0.4.0 analytics
2026-03-18 21:48:44 +01:00
9qeklajc
42b3840df6 Merge branch 'v0.4.0' into v0.4.0-analytics
# Conflicts:
#	routstr/core/settings.py
2026-03-18 21:43:18 +01:00
9qeklajc
a0b2b9466c Merge pull request #409 from Routstr/cache-x-cashu-tokens
Cache x cashu tokens
2026-03-16 23:07:38 +01:00
9qeklajc
9a553de011 Merge pull request #410 from Routstr/allow-x-cashu-header
Allow x cashu header
2026-03-16 22:18:01 +01:00
9qeklajc
901a6a9ba2 fix swapping the primary mint 2026-03-16 21:40:43 +01:00
9qeklajc
691927a996 expose x-cashu header 2026-03-16 21:16:13 +01:00
9qeklajc
8fcecf2c1f Merge branch 'v0.4.0' into cache-x-cashu-tokens
# Conflicts:
#	routstr/core/admin.py
2026-03-16 21:07:44 +01:00
9qeklajc
aed967dc44 update ui 2026-03-16 21:06:13 +01:00
9qeklajc
ed8102d533 update ui 2026-03-14 16:59:42 +01:00
9qeklajc
8fa8475cca lint&fmt 2026-03-14 16:59:32 +01:00
9qeklajc
4723b9db4d fix storing in/out trans. 2026-03-14 16:56:44 +01:00
9qeklajc
358ff25899 Merge pull request #406 from Routstr/enforce-cheapest-model-prices
make sure the models with lowest prices are forwarded
2026-03-13 23:07:30 +01:00
9qeklajc
7267bb87b9 fix tests 2026-03-13 23:04:17 +01:00
9qeklajc
173f5fbcbd make sure the models with lowest prices are forwarded 2026-03-13 22:59:09 +01:00
9qeklajc
9006709f8d Merge pull request #404 from Routstr/add-missing-refund-button
add missing refund button
2026-03-13 22:39:39 +01:00
9qeklajc
7d48b36be8 add missing refund button 2026-03-13 22:23:50 +01:00
9qeklajc
8b3fdaa545 Merge pull request #403 from Routstr/fix-admin-routstr-balance-timeout
Handle Routstr admin balance timeouts
2026-03-13 21:13:19 +01:00
9qeklajc
b43d57df23 Merge pull request #402 from Routstr/fix/admin-routstr-balance-timeout
fix: handle Routstr balance timeouts
2026-03-13 21:12:14 +01:00
9qeklajc
b9a5a9276e Merge pull request #401 from Routstr/fix/routstr-provider-sats-balance
fix: show Routstr provider balances in sats
2026-03-13 21:11:15 +01:00
9qeklajc
a79fdf7212 clean up 2026-03-13 21:09:51 +01:00
Shroominic
9560050946 test: type routstr topup async client mock 2026-03-13 19:08:45 +08:00
Shroominic
dd88e9b172 test: annotate admin balance integration session 2026-03-13 18:51:30 +08:00
Shroominic
e31b45fa9e test: type dummy async client exit hook 2026-03-13 18:51:30 +08:00
Shroominic
1ed8b29d64 style: format provider balance placeholder 2026-03-13 18:51:30 +08:00
Shroominic
59f8d31719 Handle Routstr admin balance timeouts 2026-03-13 18:45:18 +08:00
Shroominic
93a368b1a2 fix(admin): handle routstr balance timeouts 2026-03-13 18:39:06 +08:00
Shroominic
d3dd346853 fix: remove unrelated balance endpoint changes 2026-03-13 18:20:08 +08:00
Shroominic
0198569a9a fix: retry transient Routstr top-up invoice failures 2026-03-13 18:17:19 +08:00
Shroominic
7e648cb5c2 fix: use sats for Routstr top-up amounts 2026-03-13 17:46:57 +08:00
Shroominic
deb75624f3 fix: show Routstr provider balances in sats 2026-03-13 17:36:52 +08:00
Evan Yang
c5cb562165 chore: drop non-analytics branch drift 2026-03-13 17:00:15 +08:00
Evan Yang
8a89a38864 fix: restore analytics branch test and admin flows 2026-03-13 16:39:44 +08:00
Evan Yang
09e7f1f0bf Increase snapshot model limit to 20 2026-03-13 15:55:29 +08:00
Evan Yang
d9d082ad5c Use per-metric top-model selection for usage mix 2026-03-13 15:55:29 +08:00
Evan Yang
9cd4ff5c21 Simplify analytics sharing to single snapshot with multi-window payloads 2026-03-13 15:55:29 +08:00
Evan Yang
11eb20a2d1 fix(ci): resolve backend type/lint and ui format issues 2026-03-13 15:55:29 +08:00
Evan Yang
a63e81db06 fix(ui): improve admin usage chart timestamp precision 2026-03-13 15:55:29 +08:00
Evan Yang
8fc1b6484c Add Nostr analytics snapshots and expand stats model coverage 2026-03-13 15:55:29 +08:00
Evan Yang
9bc3feff62 Enhance payment and analytics tracking by adding input and output token metrics across various components 2026-03-13 15:55:21 +08:00
Evan Yang
cb22968ff3 Refactor log management and usage analytics 2026-03-13 15:55:16 +08:00
Evan Yang
e3bca39815 Remove max_points plumbing from dashboard analytics 2026-03-13 15:55:09 +08:00
Evan Yang
958f28fd82 Optimize dashboard analytics pipeline and clean UI data flow 2026-03-13 15:54:59 +08:00
Evan Yang
c8c30d7cfd Implement caching in LogManager for improved performance and optimize log entry retrieval 2026-03-13 15:53:52 +08:00
9qeklajc
2c2124952f add transaction view 2026-03-11 23:39:22 +01:00
9qeklajc
f56ba92ae8 Merge branch 'v0.4.0' into cache-x-cashu-tokens 2026-03-11 22:53:15 +01:00
9qeklajc
1fa71ee806 Merge pull request #396 from Routstr/v0.4.0-ui-update
V0.4.0 UI update
2026-03-11 22:48:05 +01:00
9qeklajc
97d164e181 fix build 2026-03-11 22:46:23 +01:00
9qeklajc
9f3c915dec fmt 2026-03-11 22:45:13 +01:00
9qeklajc
4ff6218140 Merge branch 'v0.4.0' into v0.4.0-ui-update
# Conflicts:
#	routstr/core/log_manager.py
#	ui/app/page.tsx
#	ui/app/providers/page.tsx
2026-03-11 22:44:56 +01:00
9qeklajc
67ea6aec43 Fix TypeScript error in chart.tsx key prop 2026-03-11 22:10:29 +01:00
9qeklajc
41f346417a Regenerate pnpm-lock.yaml for GitHub Actions 2026-03-11 22:06:05 +01:00
9qeklajc
8a373276ce cache x-cashu tokens 2026-03-11 22:03:05 +01:00
9qeklajc
4857d741de Merge pull request #370 from Routstr/add-routstr-provider
Add routstr provider
2026-03-11 21:30:19 +01:00
9qeklajc
eabe17cc26 clean up ui 2026-03-11 21:05:44 +01:00
9qeklajc
8f298ae9c1 add auto topup 2026-03-11 20:50:52 +01:00
9qeklajc
e5f3b7d755 fmt 2026-03-10 17:44:25 +01:00
9qeklajc
3796e29cb4 auto top up later 2026-03-10 17:36:51 +01:00
Evan Yang
812f50ea28 Fix ESLint peer dependency for npm install 2026-03-10 11:43:56 +08:00
9qeklajc
ce5fa136c2 improve ui 2026-03-09 22:41:38 +01:00
9qeklajc
4643aefb22 no auth for routstr node to check and topup balance 2026-03-09 22:13:33 +01:00
Evan Yang
5aa902fd4a Clean up 2026-03-09 20:47:50 +08:00
Evan Yang
db144e0903 Trim analytics backend changes from UI branch 2026-03-09 20:09:08 +08:00
Evan Yang
e20b20dbca Restore dashboard analytics data path 2026-03-09 16:57:15 +08:00
Evan Yang
aa700443d3 Remove unrelated file changes 2026-03-09 15:56:27 +08:00
Evan Yang
7e89b5bc6d Remove unrelated non-UI branch changes 2026-03-09 15:37:13 +08:00
Evan Yang
8dbf036558 Remove model usage analytics from UI update branch 2026-03-09 15:26:07 +08:00
9qeklajc
12f2cfecc9 update ui 2026-03-08 22:19:00 +01:00
9qeklajc
a0f3378cf5 clean up 2026-03-08 21:52:22 +01:00
Evan Yang
20b4c3a641 Restore analytics dashboard and polish mobile shell 2026-03-07 18:52:17 +08:00
Evan Yang
88f7ee734f Fix dashboard auth loading state on split UI branch 2026-03-07 18:16:29 +08:00
Evan Yang
326a0086b7 Fix chart container sizing warnings 2026-03-07 18:15:44 +08:00
Evan Yang
eb46651373 Fix UI formatting for CI 2026-03-07 18:15:44 +08:00
Evan Yang
226188e22d Refine admin UI and models routing 2026-03-07 18:15:44 +08:00
Evan Yang
89fc48eea2 Refine model pricing and mobile list layout 2026-03-07 18:15:29 +08:00
Evan Yang
f66d98e6b5 chore(release): bump version to 0.4.0 2026-03-07 18:15:29 +08:00
Evan Yang
cfea4f9cd1 Update Node.js version in GitHub Actions workflow from 18 to 20 2026-03-07 18:15:29 +08:00
Evan Yang
c74a877ab5 Fix build and formatting 2026-03-07 18:15:29 +08:00
Evan Yang
83a76e86fe Update dashboard UI/UX 2026-03-07 18:15:29 +08:00
9qeklajc
66975ee271 Merge branch 'v0.4.0' into add-routstr-provider 2026-03-06 23:08:30 +01:00
9qeklajc
5788d63892 Merge pull request #394 from Routstr/fix-refund-token
fix refund token multiple times
2026-03-06 23:06:14 +01:00
9qeklajc
d0c7cc6bd9 fix refund token multiple times 2026-03-06 23:02:47 +01:00
9qeklajc
40096867d9 Merge pull request #390 from Routstr/fix-negative-reserve
fix negative reserve balance
2026-03-06 18:02:13 +01:00
9qeklajc
e002c0b66f clean up 2026-03-06 17:59:49 +01:00
9qeklajc
608549d051 Merge pull request #357 from Routstr/fix/azure-kimi-routing-v040-on-v0.4.0
Fix Azure Kimi routing and DB override model mapping
2026-03-06 16:58:57 +01:00
9qeklajc
72f389c8d2 Merge pull request #354 from Routstr/fix/azure-remote-model-filter-by-id-v040
fix(admin): filter provider remote models by id
2026-03-06 16:58:02 +01:00
9qeklajc
97daddb95a clean prints 2026-03-03 23:59:14 +01:00
9qeklajc
847f4b07a5 Merge branch 'v0.4.0' into add-routstr-provider 2026-03-03 23:58:19 +01:00
9qeklajc
4bce04ada4 Merge pull request #391 from Routstr/fix/cashu-402-not-wrapped
Fix Cashu insufficient-balance responses being wrapped as 401
2026-03-03 23:56:20 +01:00
9qeklajc
f1e2448620 Merge pull request #388 from Routstr/fix/skip-auth-preflight-balance
Skip preflight balance checks for Authorization tokens
2026-03-03 23:53:41 +01:00
redshift
28340a152c fix cashu auth to preserve insufficient balance status 2026-03-03 22:20:42 +00:00
redshift
e28f6118e7 Merge v0.4.0 into fix/skip-auth-preflight-balance 2026-03-03 15:03:31 +00:00
9qeklajc
9fb6f54d12 fix negative reserve balance 2026-03-03 15:29:07 +01:00
9qeklajc
3cb8d7b5dd Merge pull request #377 from Routstr/split-all
split all spaces
2026-03-03 00:47:54 +01:00
9qeklajc
ede076b881 Merge pull request #374 from Routstr/add-cost-details
Add cost details
2026-03-03 00:47:40 +01:00
redshift
449a0951f9 skip preflight balance checks for Authorization tokens 2026-03-02 14:39:55 +00:00
9qeklajc
8c9ede2272 split all spaces 2026-02-25 17:57:17 +01:00
redshift
e794b09614 Add sats cost and remaining balance to response 2026-02-22 15:44:37 +01:00
9qeklajc
24f6519267 Merge pull request #372 from Routstr/fix-migration
quick fix for failed migration (first time setup)
2026-02-19 13:37:21 +01:00
9qeklajc
f57beb6411 quick fix for failed migration 2026-02-19 13:27:09 +01:00
9qeklajc
3e41e59a1d Merge pull request #371 from Routstr/add-reverted-changes
revert missing check
2026-02-19 12:54:28 +01:00
9qeklajc
002d750830 revert missing check 2026-02-19 12:37:41 +01:00
9qeklajc
8c2eb55760 Merge branch 'main' into v0.4.0
# Conflicts:
#	docs/provider/quickstart.md
2026-02-18 23:50:09 +01:00
9qeklajc
350714f23a default child key price to zero 2026-02-18 23:45:37 +01:00
9qeklajc
9cc2e84f7c Merge branch 'v0.4.0' into add-routstr-provider
# Conflicts:
#	tests/integration/test_child_keys_api.py
#	ui/components/landing/api-key-manager.tsx
#	ui/components/landing/cashu-payment-workflow.tsx
#	ui/components/landing/cheat-sheet.tsx
#	ui/components/landing/key-info-details.tsx
2026-02-18 23:24:32 +01:00
9qeklajc
6fbd479bdc Merge pull request #367 from Routstr/fixed-max-cost-discount-bug
Fixed max cost discount bug
2026-02-16 21:34:12 +01:00
9qeklajc
f67c26935a Merge pull request #360 from Routstr/custom-models-fix
Custom models were only showing up in the DB but now in the v1/models output
2026-02-16 21:31:56 +01:00
red
9ce91f58a9 fxied the bug by calculating the same way as _calculate_usd_max_costs in models.py 2026-02-16 09:18:12 +00:00
red
bb82361434 fixed include disabledd 2026-02-16 09:14:35 +00:00
9qeklajc
5f1d67e87e Merge pull request #365 from Routstr/child-key-details
Child key details
2026-02-15 17:28:57 +01:00
9qeklajc
b2106ad1e6 fix build 2026-02-15 17:23:16 +01:00
9qeklajc
709a4ba0dc lint 2026-02-15 17:16:10 +01:00
9qeklajc
21f421b212 fmt 2026-02-15 17:09:46 +01:00
9qeklajc
b3c5e4cbf6 add doc 2026-02-15 17:06:07 +01:00
9qeklajc
1d95379328 add child keys details to view 2026-02-15 17:06:01 +01:00
9qeklajc
342a7f7f16 add doc 2026-02-15 17:05:30 +01:00
9qeklajc
7694f20006 add child keys details to view 2026-02-15 16:51:26 +01:00
9qeklajc
3ea0a267dd add routstr logic 2026-02-15 16:13:29 +01:00
9qeklajc
68e537f6fb improive topup 2026-02-14 01:43:33 +01:00
9qeklajc
814c39898d display qrcode 2026-02-14 01:21:12 +01:00
9qeklajc
fdd4f12f5c add simple ui to routstr topup 2026-02-14 01:17:34 +01:00
9qeklajc
6957d8c0d9 update design 2026-02-13 23:39:02 +01:00
9qeklajc
2c358276bf add setting jsonb 2026-02-13 23:02:24 +01:00
9qeklajc
48529f672a Merge pull request #364 from Routstr/update-main-doc
add missing info
2026-02-13 22:29:09 +01:00
9qeklajc
030c2f65e4 add missing info 2026-02-13 22:22:41 +01:00
9qeklajc
3510402af2 Merge pull request #363 from Routstr/update-main-doc
update doc
2026-02-13 21:26:26 +01:00
9qeklajc
5f376d716d clean up 2026-02-13 21:15:33 +01:00
9qeklajc
d889274f84 update doc 2026-02-12 23:25:59 +01:00
9qeklajc
0c0f19d854 Merge pull request #362 from Routstr/update-main-doc
Update main doc
2026-02-12 21:23:53 +01:00
9qeklajc
b61bffc666 Merge pull request #361 from Routstr/update-docs
update doc
2026-02-12 21:20:17 +01:00
9qeklajc
af658136d4 add docker file 2026-02-12 21:19:19 +01:00
9qeklajc
8973627b5f update doc 2026-02-12 21:19:12 +01:00
9qeklajc
25f427033a add docker file 2026-02-12 21:18:22 +01:00
9qeklajc
d3dd8318e4 update doc 2026-02-12 21:15:44 +01:00
9qeklajc
c5fd386c1e add routstr provider 2026-02-12 11:19:40 +01:00
redshift
52c7f17215 fixed build errors 2 2026-02-11 02:54:16 +00:00
redshift
2c03302055 fixed build errors 2026-02-11 02:32:21 +00:00
redshift
c3221f2a31 Fixed custom models not showing up in the v1/models output 2026-02-11 02:26:24 +00:00
Evan Yang
ce9834d7ec fix: harden azure routing and model override mapping 2026-02-10 19:15:25 +08:00
9qeklajc
6ebe73f2f7 Fix Azure Kimi routing and DB override model mapping 2026-02-10 07:32:54 +00:00
Evan Yang
98aecb08f9 fix(admin): filter provider remote models by id 2026-02-10 01:04:14 +08:00
9qeklajc
58fa063c6b Merge pull request #352 from Routstr/fix-pyament-finalization
enforce payment finalization
2026-02-08 23:20:20 +01:00
9qeklajc
7c94f60797 Merge pull request #351 from Routstr/child-key-expiration
Child key expiration
2026-02-08 23:20:10 +01:00
9qeklajc
4cb4c6dfec fix test 2026-02-08 23:06:48 +01:00
9qeklajc
512b686e5f fmt 2026-02-08 22:56:38 +01:00
9qeklajc
f495a10eeb keys with different config and better reset 2026-02-08 22:54:59 +01:00
9qeklajc
d1692edb63 no balance limit for parent key 2026-02-07 00:13:57 +01:00
9qeklajc
8f81bcd2fc fix do not remove key after refund 2026-02-05 18:48:46 +01:00
9qeklajc
6a5ed9d063 fmt 2026-02-05 01:20:10 +01:00
9qeklajc
b9890e6ad5 fmt 2026-02-05 01:19:11 +01:00
9qeklajc
e4b8293d41 lint 2026-02-05 01:03:25 +01:00
9qeklajc
ba5f9fc181 improvve key logic 2026-02-04 23:10:50 +01:00
9qeklajc
795fff61e0 child-key-expiration 2026-02-02 22:29:54 +01:00
shroominic
f9bfd4f0d2 routstr/v0.3.0
v0.3.0
2026-02-02 16:44:31 +08:00
9qeklajc
5683382ada Merge pull request #341 from Routstr/refactor/remove-unused-code
refactor: remove unused code
2026-02-01 23:09:33 +01:00
9qeklajc
c92372dafd Merge pull request #342 from Routstr/refactor/remove-deprecated-admin-html
refactor: remove deprecated admin html
2026-02-01 23:09:06 +01:00
9qeklajc
b58dd78fde Merge pull request #339 from Routstr/refactor/nostr-discovery
refactor: nostr logic
2026-02-01 23:06:09 +01:00
9qeklajc
4c31bf9767 enforce payment finalization 2026-02-01 23:01:47 +01:00
shroominic
42efa3c1ba Merge pull request #337 from Routstr/no-default-next-public-api-url
No default next public api url
2026-01-31 07:42:14 +08:00
shroominic
74b1d39d5c Merge pull request #332 from Routstr/missing-delete-button
Missing delete button
2026-01-31 07:42:04 +08:00
shroominic
4df4976f44 Merge pull request #331 from Routstr/batch-override-models
batch override models
2026-01-31 07:41:54 +08:00
Shroominic
4aa57959bf prettier 2026-01-31 07:38:05 +08:00
Shroominic
1af39f043f fix tests 2026-01-31 07:37:39 +08:00
Shroominic
1751cd3b47 remove deprecated htmx endpoints 2026-01-31 07:23:58 +08:00
Shroominic
b1facd58d5 rm test checking unused functions 2026-01-31 07:21:44 +08:00
Shroominic
6288d6fef7 more unused code lmao 2026-01-31 07:09:04 +08:00
Shroominic
a1223ad610 rm unused code lol 2026-01-31 07:08:54 +08:00
Shroominic
c75f170ed0 Refactor: Move Discovery and Nostr logic to routstr/nostr package 2026-01-31 06:59:27 +08:00
Shroominic
c6e401c3f6 Merge branch 'main' into v0.3.0
# Conflicts:
#	routstr/proxy.py
2026-01-30 10:54:35 +08:00
shroominic
1b3b206a20 Merge pull request #336 from Routstr/prevent-payout-race-contition
prevent payout race condition
2026-01-30 10:49:44 +08:00
Shroominic
248937e05f comment out NEXT_PUBLIC_API_URL by default 2026-01-30 10:48:37 +08:00
shroominic
b9b477e5eb Merge pull request #335 from Routstr/fix-recurring-payout-error
Fix recurring payout error
2026-01-30 10:06:37 +08:00
Shroominic
ace8cf960c prevent payout race condition 2026-01-30 10:05:37 +08:00
Shroominic
31898192b2 added missing delete button for custom models 2026-01-29 13:05:56 +08:00
Shroominic
e50facc835 prettier 2026-01-29 11:05:46 +08:00
Shroominic
55dc485705 Merge branch 'v0.3.0' into batch-override-models 2026-01-29 11:03:43 +08:00
Shroominic
80559a57d5 fix linting 2026-01-29 10:55:07 +08:00
Shroominic
8e9f6647e7 batch override models 2026-01-29 09:08:26 +08:00
shroominic
bf91f401af Merge pull request #322 from Routstr/tiny-docs-fixes
tiny docs fixes
2026-01-24 21:17:57 +08:00
Shroominic
60e0eebd05 tiny docs fixes 2026-01-24 21:16:58 +08:00
shroominic
8fff716bb2 Merge pull request #321 from Routstr/fix-readme
fix readme
2026-01-24 18:14:45 +08:00
Shroominic
b2ef15a406 fix readme 2026-01-24 18:12:32 +08:00
shroominic
f8fdcf3bf3 Merge pull request #320 from Routstr/improved-docs
Improved docs
2026-01-24 18:10:41 +08:00
Shroominic
58c4d3bf5f improved readme 2026-01-24 18:08:45 +08:00
Shroominic
2210e9be6c rm old docs 2026-01-24 17:55:50 +08:00
Shroominic
9c66789665 improved docs 2026-01-24 17:55:45 +08:00
shroominic
85aa8fbbc5 Merge pull request #315 from Routstr/314-fix-timeout-error
make sure to refund
2026-01-23 09:42:59 +08:00
9qeklajc
701b870d63 make sure to refund 2026-01-22 13:44:04 +01:00
328 changed files with 46987 additions and 24907 deletions

View File

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

View File

@@ -14,6 +14,14 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Resolve git metadata
id: gitmeta
run: |
echo "sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
echo "tag=$(git describe --tags --exact-match HEAD 2>/dev/null || true)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
@@ -29,7 +37,11 @@ jobs:
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile.full
push: true
build-args: |
GIT_COMMIT=${{ steps.gitmeta.outputs.sha }}
GIT_TAG=${{ steps.gitmeta.outputs.tag }}
tags: |
ghcr.io/routstr/proxy:latest
ghcr.io/routstr/core:latest

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

View File

@@ -1,26 +1,29 @@
FROM ghcr.io/astral-sh/uv:python3.11-alpine
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
# Install system dependencies required for secp256k1
RUN apk add --no-cache \
pkgconf \
build-base \
automake \
autoconf \
libtool \
m4 \
perl
RUN apk add git
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
build-essential \
pkg-config \
libsecp256k1-dev \
autoconf \
automake \
libtool \
&& rm -rf /var/lib/apt/lists/*
COPY uv.lock pyproject.toml ./
RUN mkdir -p /routstr
RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060
# RUN uv sync
RUN uv sync --frozen --no-dev --no-install-project
WORKDIR /app
COPY . .
ARG GIT_COMMIT=""
ARG GIT_TAG=""
ENV GIT_COMMIT=${GIT_COMMIT}
ENV GIT_TAG=${GIT_TAG}
ENV PORT=8000
ENV PYTHONUNBUFFERED=1

53
Dockerfile.full Normal file
View File

@@ -0,0 +1,53 @@
# Multi-stage Dockerfile for Routstr (includes UI build)
# Stage 1: Build the UI
FROM node:23-alpine AS ui-builder
WORKDIR /app/ui
# Install pnpm
RUN corepack enable pnpm && corepack prepare pnpm@10.15.0 --activate
# Copy UI source
COPY ui/package.json ui/pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY ui/ ./
ENV NEXT_TELEMETRY_DISABLED=1
# Next.js build produces a static export in 'out' directory
RUN pnpm run build
# Stage 2: Build the Routstr Node
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS runner
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
build-essential \
pkg-config \
libsecp256k1-dev \
autoconf \
automake \
libtool \
&& rm -rf /var/lib/apt/lists/*
COPY uv.lock pyproject.toml ./
RUN uv sync --no-dev --no-install-project
WORKDIR /app
COPY . .
# Copy the built UI from the ui-builder stage
COPY --from=ui-builder /app/ui/out ./ui_out
ARG GIT_COMMIT=""
ARG GIT_TAG=""
ENV GIT_COMMIT=${GIT_COMMIT}
ENV GIT_TAG=${GIT_TAG}
ENV PORT=8000
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
# Run the application
CMD ["/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]

View File

@@ -98,7 +98,7 @@ docker-down:
lint:
@echo "🔍 Running linting checks..."
$(RUFF) check .
$(MYPY) routstr/ --ignore-missing-imports
$(MYPY) .
format:
@echo "✨ Formatting code..."
@@ -107,7 +107,7 @@ format:
type-check:
@echo "🔎 Running type checks..."
$(MYPY) routstr/ --ignore-missing-imports
$(MYPY) .
# Development setup
dev-setup:
@@ -234,7 +234,7 @@ ci-test:
ci-lint:
@echo "🤖 Running CI linting..."
$(RUFF) check . --exit-non-zero-on-fix
$(MYPY) routstr/ --ignore-missing-imports --no-error-summary
$(MYPY) . --no-error-summary
# Debug helpers
test-debug:

299
README.md
View File

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

View File

@@ -6,10 +6,11 @@ services:
context: ./ui
dockerfile: Dockerfile.build
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
# NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000}
NEXT_PUBLIC_ADMIN_API_KEY: ${NEXT_PUBLIC_ADMIN_API_KEY:-}
user: root
volumes:
- ./ui_out:/output
- ./ui_out:/output:z
command:
["sh", "-c", "mkdir -p /output && cp -r /app/built/. /output/ && echo 'UI build copied to mounted volume' && ls -la /output/ && echo 'UI built and ready' && tail -f /dev/null"]
@@ -18,10 +19,10 @@ services:
depends_on:
- ui
volumes:
- .:/app
- ./logs:/app/logs
- .:/app:z
- ./logs:/app/logs:z
- tor-data:/var/lib/tor:ro
- ./ui_out:/app/ui_out:ro
- ./ui_out:/app/ui_out:ro,z
env_file:
- .env
environment:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -360,6 +360,44 @@ POST /v1/wallet/create
}
```
### Get Key Information
Get current balance, consumption data, and child keys for an API key.
```http
GET /v1/balance/info
Authorization: Bearer sk-...
```
**Response:**
```json
{
"api_key": "sk-abc...",
"balance": 8500000,
"reserved": 0,
"is_child": false,
"parent_key": null,
"total_requests": 42,
"total_spent": 1500000,
"balance_limit": null,
"balance_limit_reset": null,
"validity_date": null,
"child_keys": [
{
"api_key": "sk-child1...",
"total_requests": 10,
"total_spent": 500000,
"balance_limit": 1000000,
"balance_limit_reset": "daily",
"validity_date": 1738000000
}
]
}
```
`balance` is the spendable balance used by request admission.
### Check Balance
Get current wallet balance.
@@ -563,4 +601,4 @@ Rate limit information is included in response headers.
- [Errors](errors.md) - Error handling reference
- [Authentication](authentication.md) - Auth details
- [Examples](../user-guide/using-api.md) - Code examples
- [Integration Guide](../client/integration.md) - Code examples

View File

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

View File

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

143
docs/client/integration.md Normal file
View File

@@ -0,0 +1,143 @@
# Using the API
Routstr is **OpenAI-compatible**. Almost any AI application, SDK, or tool that supports custom endpoints will work out of the box. Just change two things:
```
BASE_URL → https://api.routstr.com/v1
API_KEY → sk-... or cashuA...
```
**Both work as API keys:**
- `sk-7f8e9d...` — Session key (from Lightning invoice or Cashu import)
- `cashuA3s8j...` — Raw Cashu token (use directly from your wallet)
If the app lets you set a base URL and API key, you're good to go.
---
## Quick Setup Examples
### OpenAI SDK (Python/JS)
```python
client = OpenAI(base_url="https://api.routstr.com/v1", api_key="sk-...") # or any provider's URL
```
### Claude Code
```bash
export ANTHROPIC_BASE_URL=https://api.routstr.com/v1
export ANTHROPIC_AUTH_TOKEN=sk-...
```
### Any OpenAI-compatible app
Look for "Custom API endpoint", "Base URL", or "OpenAI-compatible" in settings. Paste the URL and key.
---
## Detailed Examples
### Python (Official SDK)
```python
from openai import OpenAI
# 1. Initialize with Routstr URL and your funded key
client = OpenAI(
base_url="https://api.routstr.com/v1",
api_key="sk-7f8e9d..."
)
# 2. Call the API normally
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello!"}],
stream=True
)
for chunk in response:
if chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="")
```
### Node.js
```javascript
import OpenAI from 'openai';
// You can use a session key OR a raw Cashu token directly
const openai = new OpenAI({
baseURL: 'https://api.routstr.com/v1',
apiKey: 'cashuA3s8jKx9...', // or 'sk-7f8e9d...'
});
async function main() {
const completion = await openai.chat.completions.create({
messages: [{ role: 'user', content: 'Say this is a test' }],
model: 'gpt-3.5-turbo',
});
console.log(completion.choices[0]);
}
main();
```
### cURL
```bash
# Works with session key or raw Cashu token
curl https://api.routstr.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer cashuA3s8jKx9..." \
-d '{
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Hello!"}]
}'
```
---
## Error Handling
### Insufficient Balance (402 Payment Required)
If your session runs out of funds, the API will return a `402` error.
```json
{
"error": {
"message": "Insufficient balance. Current: 1000 msat, Required: 5000 msat",
"type": "insufficient_balance",
"code": 402
}
}
```
**Action**: Top up your key using the `/lightning/invoice` (topup purpose) or `/v1/balance/topup` endpoints.
### Rate Limiting
Routstr passes through rate limits from the upstream provider. Handle `429 Too Many Requests` with standard exponential backoff.
---
## Advanced: Tor Access
If the node is running as a hidden service, use a SOCKS5 proxy (like `127.0.0.1:9050`).
**Python:**
```python
import httpx
from openai import OpenAI
proxy_mounts = {
"http://": httpx.HTTPTransport(proxy="socks5://127.0.0.1:9050"),
"https://": httpx.HTTPTransport(proxy="socks5://127.0.0.1:9050"),
}
client = OpenAI(
base_url="http://verylongonionaddress.onion/v1",
api_key="sk-...",
http_client=httpx.Client(mounts=proxy_mounts),
)
```

142
docs/client/introduction.md Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

76
docs/overview.md Normal file
View File

@@ -0,0 +1,76 @@
# Overview
Routstr is a decentralized protocol for **permissionless, private, and censorship-resistant AI inference**. It creates an open marketplace where anyone can sell llm-tokens and anyone can buy them using privacy-preserving micropayments.
By combining **Nostr** (for censorship-resistant discovery and communication) and **Cashu** (for private, instant Bitcoin eCash payments), Routstr effectively removes the "middleman" from the AI ecosystem.
## How it Works
The network consists of independent **Providers** (Sellers) and **Clients** (Buyers). There is no central server, no login, and no credit card required.
1. **Discovery (Nostr)**: Providers announce their availability, models (e.g., `gpt-4o`, `deepseek-r1`), and prices on the Nostr network.
2. **Payment (Cashu)**: Clients pay providers directly using Bitcoin eCash (Cashu tokens). These payments are untraceable and settle instantly.
3. **Inference (Proxy)**: The Provider acts as a gateway (or runs local hardware), executing the AI model and returning the result to the Client.
## Who is this for?
The documentation is split into two paths depending on your goal:
### 🐣 I want to BUILD on Routstr (Client)
You are a developer building an AI agent, a chat app, or a script, and you want access to AI models without API keys, subscriptions, or KYC.
* **No Accounts**: Just get a wallet.
* **Privacy**: Your requests are mixed with thousands of others; providers can't profile you.
* **Choice**: Switch between hundreds of providers instantly for the best price/performance.
👉 **[Go to Client Guide](client/introduction.md)**
### 🦁 I want to RUN a Node (Provider)
You have API credits (OpenAI, Anthropic, etc.) or GPU capacity and want to earn Bitcoin by selling AI access to the network.
* **Monetize API Keys**: Connect your OpenAI/Anthropic/OpenRouter accounts and earn sats on every request.
* **Monetize Hardware**: Run local models (via vLLM, Ollama) and sell access.
* **Permissionless**: No approval needed. Start the container, configure via dashboard, start earning.
!!! note "Coming Soon"
Future versions will support node-to-node routing—run a gateway without needing your own AI provider credentials.
👉 **[Go to Provider Guide](provider/quickstart.md)**
---
## Architecture
Routstr is built on a modular stack defined by the [Routstr Improvement Protocols (RIPs)](https://github.com/routstr/rips).
```mermaid
flowchart LR
subgraph Client
A[App / Agent]
end
subgraph Provider
B[Routstr Node<br/>Proxy + Auth + Billing]
end
subgraph Upstream
C[OpenAI / Anthropic<br/>vLLM / Ollama / ...]
end
A -- "Request +<br/>Cashu Token" --> B
B -- "Forward<br/>Request" --> C
C -- "Response +<br/>Usage" --> B
B -- "Response +<br/>Refund Token" --> A
```
## Why Routstr?
| Feature | Closed AI | Routstr |
| :--- | :--- | :--- |
| **Access** | Account, KYC, Credit Card | Permissionless, Bitcoin-native |
| **Privacy** | Full Logging & Tracking | Blinded Payments, Ephemeral Sessions |
| **Resilience** | Single Point of Failure | Decentralized Network |
| **Pricing** | Fixed, Monopolistic | Dynamic, Market-driven |
| **Global** | Geofenced | Borderless (Tor/I2P supported) |

View File

@@ -0,0 +1,114 @@
# Advanced Pricing
Advanced pricing strategies for fine-tuned control over your revenue model.
---
## Default Behavior
By default, Routstr:
1. **Fetches costs** from your upstream provider
2. **Applies markup** using your fee settings
3. **Converts to sats** using real-time BTC price
**Formula**: `Price = Upstream Cost × Exchange Fee × Upstream Fee`
---
## Strategy 1: Fixed Per-Request
Charge a flat fee regardless of model or tokens used.
**Configure in Dashboard****Settings****Pricing**:
- Enable **Fixed Pricing**
- Set **Fixed Cost Per Request** (in sats)
**Use cases**:
- Internal tools with predictable usage
- Simple "pay once, get response" APIs
- Subscription-like tiers
---
## Strategy 2: Fixed Per-Token
Override dynamic pricing with global per-token rates.
**Configure in Dashboard****Settings****Pricing**:
| Setting | Description |
|---------|-------------|
| **Fixed Per 1K Input** | Sats per 1,000 prompt tokens |
| **Fixed Per 1K Output** | Sats per 1,000 completion tokens |
When set to non-zero values, these override model-specific pricing for all models.
---
## Strategy 3: Per-Model Custom Pricing
Set specific prices for individual models, overriding both upstream cost and global fees.
**Configure in Dashboard****Models**:
1. Click on a model (e.g., `gpt-4`)
2. Enter **Prompt Price** and **Completion Price** (USD per 1M tokens)
3. Save
**Example**: OpenAI charges $30/1M for GPT-4. Set your price to $35/1M to lock in a margin regardless of fee settings.
---
## Minimum Charge
Prevent dust transactions and spam:
| Setting | Description | Default |
|---------|-------------|---------|
| **Min Request Cost** | Minimum charge in msats | 1000 (1 sat) |
If a request's calculated cost falls below this (e.g., very short prompts), the client pays the minimum.
---
## Combining Strategies
Strategies apply in order of specificity:
1. **Per-model override** (highest priority)
2. **Fixed per-token rates**
3. **Dynamic pricing with fees** (default)
4. **Fixed per-request** (overrides all above if enabled)
**Example setup**:
- Dynamic pricing as default (10% markup)
- GPT-4 locked at $35/1M (premium model)
- Claude Haiku at 5 sats/1K tokens (budget option)
- Minimum 1 sat per request
---
## Pricing for Profit
### High-Volume Strategy
Lower margins, more clients:
- Exchange Fee: 1.002 (0.2%)
- Upstream Fee: 1.05 (5%)
### Premium Strategy
Higher margins, fewer clients:
- Exchange Fee: 1.01 (1%)
- Upstream Fee: 1.25 (25%)
### Mixed Strategy
- Cheap models (GLM-4.7-Flash, Seed-1.6): Low margin to attract volume
- Premium models (GPT-5-Pro, Claude-Opus): High margin for profit

View File

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

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

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

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

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

View File

@@ -0,0 +1,95 @@
# Discovery
Routstr uses **Nostr** as a decentralized directory for service discovery. Your node announces its presence, models, and pricing on Nostr relays, allowing clients to find you without a central server.
---
## How It Works
1. **Provider Advertisement (Kind 38421)**: Your node periodically publishes an event with its URL, models, and pricing
2. **Client Discovery**: Clients query relays for these events to find suitable providers
---
## Configuration
Configure discovery in **Dashboard****Settings****Nostr**.
### Required Settings
| Field | Description |
|-------|-------------|
| **Npub** | Your node's public identity (clients use this to verify your node) |
| **Nsec** | Your node's private key (used to sign advertisements) |
| **Relays** | Where to publish your announcements |
### Default Relays
If not configured, Routstr publishes to:
- `wss://relay.damus.io`
- `wss://relay.nostr.band`
- `wss://nos.lol`
---
## Advertisement Format
Your node publishes events like:
```json
{
"kind": 38421,
"content": {
"name": "My Routstr Node",
"description": "Fast GPT-4 access via Lightning",
"endpoints": {
"http": "https://api.mynode.com",
"onion": "http://xyz...onion"
},
"models": ["gpt-4", "claude-3-opus"],
"pricing": { ... }
},
"tags": [
["d", "routstr-provider"],
["g", "US"]
]
}
```
---
## Tor Integration
If you're running with Tor (see [Tor Support](tor.md)), your `.onion` address is automatically included in announcements. This allows clients to connect anonymously.
---
## Verify Your Announcements
Check if your node is broadcasting:
1. Copy your `Npub`
2. Search on [Nostr.band](https://nostr.band) or [Primal](https://primal.net)
3. Look for Kind 38421 events
---
## Generating Keys
If you don't have a Nostr identity:
1. Use any Nostr client (e.g., [Primal](https://primal.net), [Damus](https://damus.io))
2. Create an account
3. Export your keys (npub and nsec)
4. Enter them in the dashboard
Or generate keys programmatically:
```python
from nostr_sdk import Keys
keys = Keys.generate()
print(f"npub: {keys.public_key().to_bech32()}")
print(f"nsec: {keys.secret_key().to_bech32()}")
```

91
docs/provider/pricing.md Normal file
View File

@@ -0,0 +1,91 @@
# Pricing
Routstr's pricing engine lets you act as a retailer of AI compute. You pay upstream providers (OpenAI, Anthropic, etc.) at their rates and sell to clients with your markup.
---
## Pricing Strategies
Configure these in **Dashboard****Settings****Pricing**.
### Dynamic Pricing (Default)
Passes through upstream costs plus your percentage markup.
**Formula**: `Client Price = Upstream Cost × Exchange Fee × Upstream Fee`
| Setting | Description | Default |
|---------|-------------|---------|
| **Exchange Fee** | Buffer for BTC price volatility | 1.005 (0.5%) |
| **Upstream Fee** | Your profit margin | 1.10 (10%) |
**Example**: GPT-4 costs $30/1M tokens from OpenAI. With default settings:
- Price: $30 × 1.005 × 1.10 = $33.17/1M tokens
- At $60k BTC: ~55,000 sats/1M tokens
### Fixed Pricing
Charge a flat rate per request, regardless of model or token count.
| Setting | Description |
|---------|-------------|
| **Fixed Pricing** | Enable flat-rate mode |
| **Fixed Cost** | Sats per request |
**Best for**: Simple proxies, internal tools, or subscription-like access.
---
## Per-Model Pricing
Override pricing for specific models in **Dashboard****Models**.
1. Click on a model
2. Enter custom **Prompt Price** and **Completion Price** (USD per 1M tokens)
3. Save
This overrides both the upstream cost and your global markup for that model.
**Example**: Lock GPT-4 at $35/1M tokens regardless of OpenAI's actual rate or your fee settings.
---
## Token-Based Overrides
Set global fixed rates per token (overrides dynamic pricing for all models):
| Setting | Description |
|---------|-------------|
| **Fixed Per 1K Input** | Sats per 1,000 prompt tokens |
| **Fixed Per 1K Output** | Sats per 1,000 completion tokens |
---
## Minimum Charge
Prevent spam with a minimum cost per request:
| Setting | Description | Default |
|---------|-------------|---------|
| **Min Request Cost** | Minimum charge in msats | 1000 (1 sat) |
If a request's calculated cost is lower than this, the client pays the minimum instead.
---
## Cost Tracking
Routstr tracks balances in **millisats (msats)** for precision with cheap models.
- 1 sat = 1,000 msats
- API responses include cost in msats
- Lightning withdrawals round down to whole sats
### Client Verification (RIP-05)
Clients can verify charges:
1. Fetch `/v1/models` for your advertised rates
2. Calculate expected cost from token counts
3. Compare to `x-routstr-cost` response header

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

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

50
docs/provider/tor.md Normal file
View File

@@ -0,0 +1,50 @@
# Tor Support
Running Routstr as a **Tor Hidden Service** allows you to offer API access anonymously and bypass NAT/firewalls without port forwarding.
## Automatic Setup (Docker)
The standard `compose.yml` includes a Tor container pre-configured to serve your node.
1. **Start the stack**: `docker compose up -d`
2. **Wait**: Tor takes about 30 seconds to generate keys and bootstrap.
3. **Find your address**:
```bash
docker exec tor cat /var/lib/tor/hidden_service/hostname
```
Output: `v2xyz...longaddress.onion`
Routstr will automatically detect this address (via the `discover_onion_url_from_tor` logic) and include it in:
- The `/v1/info` endpoint.
- Nostr announcements (RIP-02).
## Manual Setup
If you are running outside Docker or managing Tor yourself:
1. **Install Tor**: `sudo apt install tor`
2. **Edit `torrc`**:
```
HiddenServiceDir /var/lib/tor/routstr/
HiddenServicePort 80 127.0.0.1:8000
```
3. **Restart Tor**: `sudo systemctl restart tor`
4. **Get Address**: `sudo cat /var/lib/tor/routstr/hostname`
5. **Configure Routstr**:
Set `ONION_URL=http://youraddress.onion` in your `.env` file so the node knows its own address.
## Client Usage
Clients connecting to your `.onion` address must route traffic through SOCKS5.
**Python Example:**
```python
import httpx
from openai import OpenAI
client = OpenAI(
base_url="http://youraddress.onion/v1",
api_key="sk-...",
http_client=httpx.Client(proxy="socks5://127.0.0.1:9050")
)
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
"""Add balance_limit, balance_limit_reset, validity_date to lightning_invoices
Revision ID: a2b3c4d5e6f7
Revises: f1a2b3c4d5e6
Create Date: 2026-06-03 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "a2b3c4d5e6f7"
down_revision = "f1a2b3c4d5e6"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("lightning_invoices", sa.Column("balance_limit", sa.Integer(), nullable=True))
op.add_column("lightning_invoices", sa.Column("balance_limit_reset", sa.String(), nullable=True))
op.add_column("lightning_invoices", sa.Column("validity_date", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("lightning_invoices", "validity_date")
op.drop_column("lightning_invoices", "balance_limit_reset")
op.drop_column("lightning_invoices", "balance_limit")

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
"""add reserved_at to api_keys
Revision ID: b5e7c9d1f3a2
Revises: a2b3c4d5e6f7
Create Date: 2026-06-12 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "b5e7c9d1f3a2"
down_revision = "a2b3c4d5e6f7"
branch_labels = None
depends_on = None
def upgrade() -> None:
# existing keys keep NULL
# New reservations populate it via pay_for_request.
op.add_column("api_keys", sa.Column("reserved_at", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("api_keys", "reserved_at")

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
"""add created_at to api_keys
Revision ID: f1a2b3c4d5e6
Revises: cli_tokens_001
Create Date: 2026-06-01 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f1a2b3c4d5e6"
down_revision = "cli_tokens_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Nullable on purpose: existing keys keep NULL (unknown creation time) and
# sort last; new keys get populated by the model's default_factory.
op.add_column("api_keys", sa.Column("created_at", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("api_keys", "created_at")

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.3.0"
version = "0.4.3"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"
@@ -13,14 +13,14 @@ dependencies = [
"greenlet>=3.2.1",
"alembic>=1.13",
"python-json-logger>=2.0.0",
"cashu",
"secp256k1",
"cashu>=0.20",
"marshmallow>=3.13,<4.0",
"websockets>=12.0",
"nostr>=0.0.2",
"mdurl==0.1.2",
"pillow>=10",
"openai>=1.98.0",
"litellm>=1.55.0",
]
[dependency-groups]
@@ -86,4 +86,3 @@ disallow_untyped_decorators = true
[tool.uv.sources]
routstr = { workspace = true }
secp256k1 = { git = "https://github.com/saschanaz/secp256k1-py", branch = "upgrade060" }

View File

@@ -118,12 +118,32 @@ def create_model_mappings(
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
unique_models: dict[str, "Model"] = {}
seen_model_provider: set[tuple[str, str]] = set()
providers_by_db_id: dict[int, "BaseUpstreamProvider"] = {}
for upstream in upstreams:
db_id = getattr(upstream, "db_id", None)
if isinstance(db_id, int):
providers_by_db_id[db_id] = upstream
# Group upstreams by URL and keep only the one with the lowest fee for each URL
upstreams_by_url: dict[str, list["BaseUpstreamProvider"]] = {}
for upstream in upstreams:
url = getattr(upstream, "base_url", "")
if url not in upstreams_by_url:
upstreams_by_url[url] = []
upstreams_by_url[url].append(upstream)
filtered_upstreams: list["BaseUpstreamProvider"] = []
for providers in upstreams_by_url.values():
best_provider = min(providers, key=lambda p: p.provider_fee)
filtered_upstreams.append(best_provider)
# Separate OpenRouter from other providers
openrouter: "BaseUpstreamProvider" | None = None
other_upstreams: list["BaseUpstreamProvider"] = []
for upstream in upstreams:
for upstream in filtered_upstreams:
base_url = getattr(upstream, "base_url", "")
if base_url == "https://openrouter.ai/api/v1":
openrouter = upstream
@@ -134,6 +154,16 @@ def create_model_mappings(
"""Get base model ID by removing provider prefix."""
return model_id.split("/", 1)[1] if "/" in model_id else model_id
def get_provider_identity(upstream: "BaseUpstreamProvider") -> str:
"""Get a stable provider identity used for deduplication."""
db_id = getattr(upstream, "db_id", None)
if isinstance(db_id, int):
return f"db:{db_id}"
provider_type = str(getattr(upstream, "provider_type", "") or "").lower()
base_url = str(getattr(upstream, "base_url", "") or "").lower()
return f"{provider_type}|{base_url}"
def _add_candidate(
alias: str, model: "Model", provider: "BaseUpstreamProvider"
) -> None:
@@ -148,6 +178,7 @@ def create_model_mappings(
) -> None:
"""Process all models from a given provider."""
upstream_prefix = getattr(upstream, "upstream_name", None)
provider_key = get_provider_identity(upstream)
for model in upstream.get_cached_models():
if not model.enabled or model.id in disabled_model_ids:
@@ -164,14 +195,15 @@ def create_model_mappings(
# Add to unique models
base_id = get_base_model_id(model_to_use.id)
if not is_openrouter or base_id not in unique_models:
unique_key = model_to_use.forwarded_model_id or base_id
if not is_openrouter or unique_key not in unique_models:
unique_model = model_to_use.copy(
update={
"id": base_id,
"upstream_provider_id": upstream.provider_type,
}
)
unique_models[base_id] = unique_model
unique_models[unique_key] = unique_model
# Get all aliases for this model
aliases = resolve_model_alias(
@@ -186,9 +218,14 @@ def create_model_mappings(
if prefixed_id not in aliases:
aliases.append(prefixed_id)
# Register forwarded_model_id as a routable alias
if model_to_use.forwarded_model_id and model_to_use.forwarded_model_id not in aliases:
aliases.append(model_to_use.forwarded_model_id)
# Try to set each alias
for alias in aliases:
_add_candidate(alias, model_to_use, upstream)
seen_model_provider.add((model_to_use.id.lower(), provider_key))
# Process non-OpenRouter providers first
for upstream in other_upstreams:
@@ -198,12 +235,115 @@ def create_model_mappings(
if openrouter:
process_provider_models(openrouter, is_openrouter=True)
# Include enabled DB overrides even when provider discovery misses models.
# This is important for deployment-based providers like Azure.
for model_id, override_data in overrides_by_id.items():
if model_id in disabled_model_ids:
continue
override_row, provider_fee = override_data
upstream_provider_id = getattr(override_row, "upstream_provider_id", None)
if not isinstance(upstream_provider_id, int):
continue
upstream_for_override = providers_by_db_id.get(upstream_provider_id)
if upstream_for_override is None:
continue
provider_key = get_provider_identity(upstream_for_override)
dedupe_key = (model_id.lower(), provider_key)
if dedupe_key in seen_model_provider:
continue
try:
model_to_use = _row_to_model(
override_row, apply_provider_fee=True, provider_fee=provider_fee
)
except Exception as exc:
logger.warning(
"Skipping invalid model override while building model mappings",
extra={
"model_id": model_id,
"upstream_provider_id": upstream_provider_id,
"error": str(exc),
"error_type": type(exc).__name__,
},
)
continue
if not model_to_use.enabled:
continue
base_id = get_base_model_id(model_to_use.id)
unique_key = model_to_use.forwarded_model_id or base_id
is_openrouter = (
getattr(upstream_for_override, "base_url", "")
== "https://openrouter.ai/api/v1"
)
if not is_openrouter or unique_key not in unique_models:
unique_model = model_to_use.copy(
update={
"id": base_id,
"upstream_provider_id": upstream_for_override.provider_type,
}
)
unique_models[unique_key] = unique_model
try:
aliases = resolve_model_alias(
model_to_use.id,
model_to_use.canonical_slug,
alias_ids=model_to_use.alias_ids,
)
except Exception as exc:
logger.warning(
"Skipping model aliases for invalid override model",
extra={
"model_id": model_id,
"upstream_provider_id": upstream_provider_id,
"error": str(exc),
"error_type": type(exc).__name__,
},
)
continue
upstream_prefix = getattr(upstream_for_override, "upstream_name", None)
if upstream_prefix and "/" not in model_to_use.id:
prefixed_id = f"{upstream_prefix}/{model_to_use.id}"
if prefixed_id not in aliases:
aliases.append(prefixed_id)
# Register forwarded_model_id as a routable alias
if model_to_use.forwarded_model_id and model_to_use.forwarded_model_id not in aliases:
aliases.append(model_to_use.forwarded_model_id)
for alias in aliases:
_add_candidate(alias, model_to_use, upstream_for_override)
seen_model_provider.add(dedupe_key)
# Sort candidates and build final maps
model_instances: dict[str, "Model"] = {}
provider_map: dict[str, list["BaseUpstreamProvider"]] = {}
def alias_priority(model: "Model", alias: str) -> int:
"""Rank how strong the mapping of alias->model is."""
"""Rank how strong the mapping of alias->model is.
forwarded_model_id is the most specific identifier (set per-provider
instance), so a match there should beat a model_id match. This way,
when multiple providers have the same model_id but different
forwarded_model_ids, the one whose forwarded_model_id equals the
requested alias wins.
"""
if (
model.forwarded_model_id
and model.forwarded_model_id.lower() == alias
):
return 5
if (
model.id
and model.id.lower() == alias
):
return 4
model_base = get_base_model_id(model.id)
if model_base == alias:
return 3

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,22 @@
import asyncio
import hashlib
import time
from time import monotonic
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlmodel import col, or_, select, update
from .auth import validate_bearer_key
from .core.db import ApiKey, AsyncSession, get_session
from .auth import get_billing_key, validate_bearer_key
from .core.db import (
ApiKey,
AsyncSession,
CashuTransaction,
get_session,
store_cashu_transaction,
)
from .core.logging import get_logger
from .core.settings import settings
from .lightning import lightning_router
@@ -33,19 +42,41 @@ async def get_key_from_header(
async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
from .auth import get_billing_key
billing_key = await get_billing_key(key, session)
return {
info = {
"api_key": "sk-" + key.hashed_key,
"balance": billing_key.balance,
"balance": billing_key.total_balance,
"reserved": billing_key.reserved_balance,
"is_child": key.parent_key_hash is not None,
"parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None,
"total_requests": key.total_requests,
"total_spent": key.total_spent,
"balance_limit": key.balance_limit,
"balance_limit_reset": key.balance_limit_reset,
"validity_date": key.validity_date,
}
if key.parent_key_hash:
info["parent_key_preview"] = key.parent_key_hash[:8] + "..."
else:
# Fetch child keys if this is a parent key
statement = select(ApiKey).where(ApiKey.parent_key_hash == key.hashed_key)
results = await session.exec(statement)
child_keys = results.all()
if child_keys:
info["child_keys"] = [
{
"api_key": "sk-" + ck.hashed_key,
"total_requests": ck.total_requests,
"total_spent": ck.total_spent,
"balance_limit": ck.balance_limit,
"balance_limit_reset": ck.balance_limit_reset,
"validity_date": ck.validity_date,
}
for ck in child_keys
]
return info
# TODO: remove this endpoint when frontend is updated
@router.get("/", include_in_schema=False)
@@ -70,9 +101,24 @@ async def account_info(
@router.get("/create")
async def create_balance(
initial_balance_token: str, session: AsyncSession = Depends(get_session)
initial_balance_token: str,
balance_limit: int | None = None,
balance_limit_reset: str | None = None,
validity_date: int | None = None,
session: AsyncSession = Depends(get_session),
) -> dict:
key = await validate_bearer_key(initial_balance_token, session)
if balance_limit is not None or balance_limit_reset or validity_date:
key.balance_limit = balance_limit
key.balance_limit_reset = balance_limit_reset
key.validity_date = validity_date
if balance_limit_reset:
key.balance_limit_reset_date = int(time.time())
session.add(key)
await session.commit()
await session.refresh(key)
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
@@ -98,8 +144,6 @@ async def topup_wallet_endpoint(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict[str, int]:
from .auth import get_billing_key
billing_key = await get_billing_key(key, session)
if topup_request is not None:
@@ -118,9 +162,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}
@@ -154,23 +212,99 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None:
_refund_cache[key] = (expiry, value)
@router.post("/refund")
async def _lookup_key_no_create(
bearer_value: str, session: AsyncSession
) -> ApiKey | None:
"""Look up an existing API key without creating one Used by the refund endpoint"""
if bearer_value.startswith("sk-"):
return await session.get(ApiKey, bearer_value[3:])
if bearer_value.startswith("cashu"):
hashed = hashlib.sha256(bearer_value.encode()).hexdigest()
return await session.get(ApiKey, hashed)
return None
async def _restore_balance(
session: AsyncSession, hashed_key: str, balance: int, reserved_balance: int, mint_url: str
) -> 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, "mint_url": mint_url},
)
@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 | None = await _lookup_key_no_create(bearer_value, session)
if key is None:
raise HTTPException(
status_code=401,
detail="Key not found. Deposit first via /v1/wallet/create before requesting a refund.",
)
if cached := await _refund_cache_get(bearer_value):
return cached
key: ApiKey = await validate_bearer_key(bearer_value, session)
if key.total_balance <= 0:
if cached := await _refund_cache_get(bearer_value):
return cached
if key.parent_key_hash:
raise HTTPException(
@@ -178,6 +312,39 @@ async def refund_wallet_endpoint(
detail="Cannot refund child key. Please refund the parent key instead.",
)
if key.reserved_balance > 0:
# Release the reservation if it is stale
cutoff = int(time.time()) - settings.stale_reservation_timeout_seconds
stale_release_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.reserved_balance) > 0)
.where(
or_(
col(ApiKey.reserved_at).is_(None),
col(ApiKey.reserved_at) < cutoff,
)
)
.values(reserved_balance=0, reserved_at=None)
)
stale_result = await session.exec(stale_release_stmt) # type: ignore[call-overload]
await session.commit()
if stale_result.rowcount == 0:
raise HTTPException(
status_code=400,
detail="Cannot refund key. There are ongoing requests for this api key.",
)
await session.refresh(key)
logger.warning(
"refund_wallet_endpoint: released stale reservation before refund",
extra={
"hashed_key": key.hashed_key,
"stale_timeout_seconds": settings.stale_reservation_timeout_seconds,
},
)
remaining_balance_msats: int = key.total_balance
if key.refund_currency == "sat":
@@ -190,22 +357,51 @@ 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, reserved_at=None)
)
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 ---
# Proofs from untrusted mints are swapped to primary_mint on receive.
# Use primary_mint unless key.refund_mint_url is an explicitly trusted mint.
effective_refund_mint = (
key.refund_mint_url
if key.refund_mint_url and key.refund_mint_url in settings.cashu_mints
else settings.primary_mint
)
try:
if key.refund_address:
from .core.settings import settings as global_settings
await send_to_lnurl(
remaining_balance,
key.refund_currency or "sat",
key.refund_mint_url or global_settings.primary_mint,
effective_refund_mint,
key.refund_address,
)
result = {"recipient": key.refund_address}
else:
refund_currency = key.refund_currency or "sat"
token = await send_token(
remaining_balance, refund_currency, key.refund_mint_url
remaining_balance, refund_currency, effective_refund_mint
)
result = {"token": token}
@@ -214,30 +410,109 @@ 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, key.refund_mint_url or "")
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, key.refund_mint_url or "")
error_msg = str(e)
logger.error(
"refund_wallet_endpoint: mint/send failed",
extra={
"error": error_msg,
"error_type": type(e).__name__,
"hashed_key": key.hashed_key,
"remaining_balance": remaining_balance,
"refund_currency": key.refund_currency,
"refund_mint_url": key.refund_mint_url,
"has_refund_address": bool(key.refund_address),
},
)
if (
"mint" in error_msg.lower()
or "connection" in error_msg.lower()
or isinstance(e, Exception)
and "ConnectError" in str(type(e))
or "ConnectError" in str(type(e))
):
raise HTTPException(status_code=503, detail="Mint service unavailable")
raise HTTPException(status_code=503, detail=f"Mint service unavailable: {error_msg}")
else:
raise HTTPException(status_code=500, detail="Refund failed")
raise HTTPException(status_code=500, detail=f"Refund failed: {error_msg}")
await _refund_cache_set(bearer_value, result)
await session.delete(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:
@@ -253,6 +528,9 @@ async def donate(token: str, ref: str | None = None) -> str:
class ChildKeyRequest(BaseModel):
count: int
balance_limit: int | None = None
balance_limit_reset: str | None = None
validity_date: int | None = None
@router.post("/child-key")
@@ -285,10 +563,24 @@ async def create_child_key(
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
)
# Deduct cost from parent
key.balance -= total_cost
key.total_spent += total_cost
session.add(key)
# Deduct cost from parent atomically — guards against concurrent requests
# that both pass the balance check above on stale in-memory state.
deduct_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= total_cost)
.values(
balance=col(ApiKey.balance) - total_cost,
total_spent=col(ApiKey.total_spent) + total_cost,
)
)
result = await session.exec(deduct_stmt) # type: ignore[call-overload]
if result.rowcount == 0:
raise HTTPException(
status_code=402,
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
)
# Generate new keys
import secrets
@@ -302,11 +594,18 @@ async def create_child_key(
hashed_key=new_key_hash,
balance=0,
parent_key_hash=key.hashed_key,
balance_limit=payload.balance_limit,
balance_limit_reset=payload.balance_limit_reset,
balance_limit_reset_date=int(time.time())
if payload.balance_limit_reset
else None,
validity_date=payload.validity_date,
)
session.add(child_key)
new_keys.append("sk-" + new_key_hash)
await session.commit()
await session.refresh(key)
response_data = {
"api_keys": new_keys,
@@ -320,6 +619,40 @@ async def create_child_key(
return response_data
class ChildKeyResetRequest(BaseModel):
child_key: str
@router.post("/child-key/reset")
async def reset_child_key_spent(
payload: ChildKeyResetRequest,
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
"""Resets the total_spent of a child key. Must be called by the parent."""
child_key_raw = payload.child_key
if child_key_raw.startswith("sk-"):
child_key_raw = child_key_raw[3:]
child_key = await session.get(ApiKey, child_key_raw)
if not child_key:
raise HTTPException(status_code=404, detail="Child key not found.")
if child_key.parent_key_hash != key.hashed_key:
raise HTTPException(
status_code=403, detail="Unauthorized. You are not the parent of this key."
)
child_key.total_spent = 0
if child_key.balance_limit_reset:
child_key.balance_limit_reset_date = int(time.time())
session.add(child_key)
await session.commit()
return {"success": True, "message": "Child key balance reset successfully."}
@router.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "DELETE"],
@@ -332,7 +665,7 @@ async def wallet_catch_all(path: str) -> NoReturn:
)
balance_router.include_router(lightning_router)
balance_router.include_router(lightning_router, include_in_schema=False)
balance_router.include_router(router)
deprecated_wallet_router = APIRouter(prefix="/v1/wallet", include_in_schema=False)

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,18 @@
import os
import pathlib
import sqlite3
import time
import uuid
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from alembic import command
from alembic.config import Config
from alembic.util.exc import CommandError
from sqlalchemy import UniqueConstraint
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlmodel import Field, Relationship, SQLModel, func, select, update
from sqlmodel import Field, Relationship, SQLModel, col, func, select, update
from sqlmodel.ext.asyncio.session import AsyncSession
from .logging import get_logger
@@ -28,6 +33,14 @@ class ApiKey(SQLModel, table=True): # type: ignore
reserved_balance: int = Field(
default=0, description="Reserved balance in millisatoshis (msats)"
)
reserved_at: int | None = Field(
default=None,
description=(
"Unix timestamp of the most recent balance reservation. Used to "
"detect and release stale reservations (e.g. after client "
"disconnects). NULL when no reservation has been made yet."
),
)
refund_address: str | None = Field(
default=None,
description="Lightning address to refund remaining balance after key expires",
@@ -40,6 +53,14 @@ class ApiKey(SQLModel, table=True): # type: ignore
default=0, description="Total spent in millisatoshis (msats)"
)
total_requests: int = Field(default=0)
created_at: int | None = Field(
default_factory=lambda: int(time.time()),
nullable=True,
description=(
"Unix timestamp when the key was created. Nullable: keys created "
"before this column existed have no value and sort last."
),
)
refund_mint_url: str | None = Field(
default=None,
description="URL of the mint used to create the cashu-token",
@@ -51,6 +72,22 @@ class ApiKey(SQLModel, table=True): # type: ignore
parent_key_hash: str | None = Field(
default=None, foreign_key="api_keys.hashed_key", index=True
)
balance_limit: int | None = Field(
default=None,
description="Max spendable balance in msats for this key (mostly for child keys)",
)
balance_limit_reset: str | None = Field(
default=None,
description="Reset policy for balance limit (manual, daily, monthly, etc.)",
)
balance_limit_reset_date: int | None = Field(
default=None,
description="Unix timestamp of the last time the balance limit was reset",
)
validity_date: int | None = Field(
default=None,
description="Unix timestamp after which the key is no longer valid",
)
@property
def total_balance(self) -> int:
@@ -58,11 +95,34 @@ class ApiKey(SQLModel, table=True): # type: ignore
async def reset_all_reserved_balances(session: AsyncSession) -> None:
logger.info("Resetting all reserved balances to 0")
stmt = update(ApiKey).values(reserved_balance=0)
stmt = update(ApiKey).values(reserved_balance=0, reserved_at=None)
await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
logger.info("Reserved balances reset successfully")
logger.info("Reset reserved balances on startup")
async def release_stale_reservations(
session: AsyncSession, max_age_seconds: int
) -> int:
"""Release reservations whose last reserve is older than max_age_seconds.
"""
cutoff = int(time.time()) - max_age_seconds
stmt = (
update(ApiKey)
.where(col(ApiKey.reserved_balance) > 0)
.where(col(ApiKey.reserved_at).is_not(None))
.where(col(ApiKey.reserved_at) < cutoff)
.values(reserved_balance=0, reserved_at=None)
)
result = await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
released = int(result.rowcount or 0)
if released:
logger.warning(
"Released stale balance reservations",
extra={"released_keys": released, "max_age_seconds": max_age_seconds},
)
return released
class ModelRow(SQLModel, table=True): # type: ignore
@@ -85,6 +145,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")
@@ -108,12 +172,93 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
)
expires_at: int = Field(description="Unix timestamp when invoice expires")
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
balance_limit: int | None = Field(
default=None,
description="Max spendable msats for the created key",
)
balance_limit_reset: str | None = Field(
default=None,
description="Reset policy for balance limit (daily, weekly, monthly)",
)
validity_date: int | None = Field(
default=None,
description="Unix timestamp after which the created key expires",
)
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__ = (
UniqueConstraint("base_url", "api_key", name="uq_upstream_providers_base_url_api_key"),
UniqueConstraint(
"base_url", "api_key", name="uq_upstream_providers_base_url_api_key"
),
)
id: int | None = Field(default=None, primary_key=True)
provider_type: str = Field(
@@ -128,12 +273,75 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
provider_fee: float = Field(
default=1.01, description="Provider fee multiplier (default 1%)"
)
provider_settings: str | None = Field(
default=None, description="JSON string for provider-specific settings"
)
models: list["ModelRow"] = Relationship(
back_populates="upstream_provider",
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
)
class RoutstrFee(SQLModel, table=True): # type: ignore
__tablename__ = "routstr_fees"
id: int = Field(default=1, primary_key=True)
accumulated_msats: int = Field(default=0)
total_paid_msats: int = Field(default=0)
last_paid_at: int | None = Field(default=None)
class CliToken(SQLModel, table=True): # type: ignore
"""Long-lived authorization token for CLI/agent use against admin endpoints."""
__tablename__ = "cli_tokens"
id: str = Field(
primary_key=True, default_factory=lambda: uuid.uuid4().hex
)
token: str = Field(unique=True, index=True, description="Bearer token value")
name: str = Field(description="Human-readable label for this token")
created_at: int = Field(default_factory=lambda: int(time.time()))
last_used_at: int | None = Field(default=None)
expires_at: int | None = Field(
default=None, description="Optional expiry unix timestamp; null = never expires"
)
async def accumulate_routstr_fee(session: AsyncSession, amount_msats: int) -> None:
stmt = (
update(RoutstrFee)
.where(col(RoutstrFee.id) == 1)
.values(accumulated_msats=RoutstrFee.accumulated_msats + amount_msats)
)
result = await session.exec(stmt) # type: ignore[call-overload]
if result.rowcount == 0:
session.add(RoutstrFee(id=1, accumulated_msats=amount_msats))
await session.commit()
async def get_routstr_fee(session: AsyncSession) -> RoutstrFee:
fee = await session.get(RoutstrFee, 1)
if fee is None:
fee = RoutstrFee(id=1, accumulated_msats=0, total_paid_msats=0)
session.add(fee)
await session.commit()
await session.refresh(fee)
return fee
async def reset_routstr_fee(session: AsyncSession, paid_msats: int) -> None:
stmt = (
update(RoutstrFee)
.where(col(RoutstrFee.id) == 1)
.values(
accumulated_msats=RoutstrFee.accumulated_msats - paid_msats,
total_paid_msats=RoutstrFee.total_paid_msats + paid_msats,
last_paid_at=int(time.time()),
)
)
await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
async def balances_for_mint_and_unit(
db_session: AsyncSession, mint_url: str, unit: str
) -> int:
@@ -163,11 +371,64 @@ async def create_session() -> AsyncGenerator[AsyncSession, None]:
yield session
def fix_cashu_migrations() -> None:
"""
Fixes Cashu wallet migrations that are not idempotent.
This specifically addresses the 'duplicate column name: public_keys' error
in the keysets table of Cashu's internal SQLite databases.
"""
project_root = pathlib.Path(__file__).resolve().parents[2]
wallet_dir = project_root / ".wallet"
if not wallet_dir.exists() or not wallet_dir.is_dir():
return
logger.info("Checking Cashu wallet databases for migration idempotency")
for db_file in wallet_dir.glob("*.sqlite3"):
try:
conn = sqlite3.connect(db_file)
cursor = conn.cursor()
# Check if keysets table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='keysets'"
)
if not cursor.fetchone():
conn.close()
continue
# Check if public_keys column exists
cursor.execute("PRAGMA table_info(keysets)")
columns = [info[1] for info in cursor.fetchall()]
if "public_keys" not in columns:
logger.info(f"Adding missing public_keys column to {db_file.name}")
cursor.execute("ALTER TABLE keysets ADD COLUMN public_keys TEXT")
conn.commit()
conn.close()
except Exception as e:
logger.warning(f"Could not check/fix Cashu database {db_file}: {e}")
def _clear_alembic_version() -> None:
"""Clear the alembic_version table so stamp/upgrade can proceed."""
sync_url = DATABASE_URL.replace("+aiosqlite", "")
from sqlalchemy import create_engine, text
eng = create_engine(sync_url)
with eng.begin() as conn:
conn.execute(text("DELETE FROM alembic_version"))
eng.dispose()
def run_migrations() -> None:
"""Run Alembic migrations programmatically."""
import pathlib
try:
# Run Cashu migration fix first
fix_cashu_migrations()
# Get the path to the alembic.ini file
project_root = pathlib.Path(__file__).resolve().parents[2]
alembic_ini_path = project_root / "alembic.ini"
@@ -183,8 +444,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")

View File

@@ -22,16 +22,20 @@ async def http_exception_handler(request: Request, exc: Exception) -> JSONRespon
# Get status code and detail - works for both FastAPI and Starlette HTTPException
status_code = getattr(exc, "status_code", 500)
detail = getattr(exc, "detail", str(exc))
path = request.url.path
logger.warning(
"HTTP exception",
extra={
"request_id": request_id,
"status_code": status_code,
"detail": detail,
"path": request.url.path,
},
)
# 4xx is client behaviour; the uvicorn access log already records it.
# Only 5xx warrants a server-side warning/error log here.
if status_code >= 500:
logger.error(
f"HTTP {status_code} on {path}: {detail}",
extra={
"request_id": request_id,
"status_code": status_code,
"detail": detail,
"path": path,
},
)
return JSONResponse(
status_code=status_code,

File diff suppressed because it is too large Load Diff

View File

@@ -3,50 +3,61 @@ 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
import logging.handlers
import os
import re
import sys
import tomllib
from datetime import datetime
from pathlib import Path
from typing import Any
from pythonjsonlogger import jsonlogger
from rich.console import Console
from rich.logging import RichHandler
# Only use RichHandler when stdout is a real TTY. In non-TTY contexts
# (docker logs, pipes, CI) Rich pads every line to width and wraps long
# records, producing visually-empty trailing whitespace and split records.
# A plain StreamHandler avoids both problems.
_stdout_is_tty = sys.stdout.isatty()
_console = Console(soft_wrap=True) if _stdout_is_tty else None
# Define custom TRACE level
TRACE_LEVEL = 5
logging.addLevelName(TRACE_LEVEL, "TRACE")
@@ -259,6 +270,26 @@ def setup_logging() -> None:
if console_enabled:
handlers.append("console")
if _stdout_is_tty:
console_handler: dict[str, Any] = {
"()": RichHandler,
"level": log_level,
"show_time": False,
"show_path": False,
"rich_tracebacks": True,
"markup": True,
"console": _console,
"filters": ["request_id_filter", "security_filter"],
}
else:
console_handler = {
"class": "logging.StreamHandler",
"level": log_level,
"formatter": "plain",
"stream": "ext://sys.stdout",
"filters": ["request_id_filter", "security_filter"],
}
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
@@ -268,6 +299,10 @@ def setup_logging() -> None:
"format": "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d %(version)s %(request_id)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"plain": {
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"filters": {
"version_filter": {"()": VersionFilter},
@@ -275,15 +310,7 @@ def setup_logging() -> None:
"security_filter": {"()": SecurityFilter},
},
"handlers": {
"console": {
"()": RichHandler,
"level": log_level,
"show_time": False,
"show_path": False,
"rich_tracebacks": True,
"markup": True,
"filters": ["request_id_filter", "security_filter"],
},
"console": console_handler,
"file": {
"()": DailyRotatingFileHandler,
"level": log_level,

View File

@@ -1,5 +1,4 @@
import asyncio
import os
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator
@@ -9,31 +8,38 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException
from starlette.responses import Response as StarletteResponse
from starlette.types import Scope
from ..auth import periodic_key_reset, periodic_stale_reservation_sweep
from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_cache_refresher, providers_router
from ..nip91 import announce_provider
from ..lightning import lightning_router, periodic_invoice_watcher
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 ..upstream.litellm_routing import configure_litellm
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
from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware
from .not_found import _NOT_FOUND_HTML, not_found_catch_all # noqa: F401
from .settings import SettingsService
from .settings import settings as global_settings
from .version import __version__
# Initialize logging first
setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.3.0-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.3.0"
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
@@ -43,11 +49,22 @@ 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
stale_reservation_task = None
auto_topup_task = None
refund_sweep_task = None
routstr_fee_task = None
invoice_watcher_task = None
try:
# Apply litellm-wide settings (drop_params, chat-completions URL,
# debug logging) before any upstream provider dispatches a request.
configure_litellm()
# Run database migrations on startup
run_migrations()
@@ -92,15 +109,27 @@ 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())
stale_reservation_task = asyncio.create_task(
periodic_stale_reservation_sweep()
)
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())
invoice_watcher_task = asyncio.create_task(periodic_invoice_watcher())
yield
@@ -124,12 +153,26 @@ 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:
models_refresh_task.cancel()
if model_maps_refresh_task is not None:
model_maps_refresh_task.cancel()
if key_reset_task is not None:
key_reset_task.cancel()
if stale_reservation_task is not None:
stale_reservation_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()
if invoice_watcher_task is not None:
invoice_watcher_task.cancel()
try:
tasks_to_wait = []
@@ -141,12 +184,26 @@ 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:
tasks_to_wait.append(models_refresh_task)
if model_maps_refresh_task is not None:
tasks_to_wait.append(model_maps_refresh_task)
if key_reset_task is not None:
tasks_to_wait.append(key_reset_task)
if stale_reservation_task is not None:
tasks_to_wait.append(stale_reservation_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 invoice_watcher_task is not None:
tasks_to_wait.append(invoice_watcher_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
@@ -158,6 +215,23 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
)
class _ImmutableStaticFiles(StaticFiles):
"""Static files with long Cache-Control for content-hashed Next.js assets.
Files under `/_next/static/` are emitted with content hashes in their
filenames and never mutate, so we serve them with a one-year immutable
cache header so browsers and CDNs stop revalidating on every reload.
"""
async def get_response(self, path: str, scope: Scope) -> StarletteResponse:
response = await super().get_response(path, scope)
if response.status_code == 200:
response.headers["Cache-Control"] = (
"public, max-age=31536000, immutable"
)
return response
app = FastAPI(version=__version__, lifespan=lifespan)
@@ -167,7 +241,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
@@ -204,7 +278,7 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
app.mount(
"/_next",
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
_ImmutableStaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
name="next-static",
)
@@ -212,100 +286,70 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
async def serve_root_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
# Add explicit route for /index.txt to redirect to /
# Serve the App Router RSC payload for the home page.
@app.get("/index.txt", include_in_schema=False)
async def redirect_index_txt() -> RedirectResponse:
return RedirectResponse("/")
async def serve_root_rsc() -> FileResponse:
return FileResponse(
UI_DIST_PATH / "index.txt", media_type="text/x-component"
)
# Next.js is built with `trailingSlash: true`, so all UI page URLs end
# with a slash (e.g. `/login/`). The proxy router catches `/{path:path}`
# before FastAPI's `redirect_slashes` logic can normalize the URL, so we
# must register both the with-slash and without-slash variants here.
UI_PAGES = (
"dashboard",
"login",
"model",
"providers",
"settings",
"transactions",
"balances",
"logs",
"usage",
"unauthorized",
)
def _register_ui_page(name: str) -> None:
page_dir = UI_DIST_PATH / name
index_html = page_dir / "index.html"
index_txt = page_dir / "index.txt"
async def serve_page() -> FileResponse:
return FileResponse(index_html)
async def serve_page_rsc() -> FileResponse:
return FileResponse(index_txt, media_type="text/x-component")
app.add_api_route(
f"/{name}",
serve_page,
methods=["GET"],
include_in_schema=False,
name=f"serve_{name}_ui",
)
app.add_api_route(
f"/{name}/",
serve_page,
methods=["GET"],
include_in_schema=False,
name=f"serve_{name}_ui_slash",
)
app.add_api_route(
f"/{name}/index.txt",
serve_page_rsc,
methods=["GET"],
include_in_schema=False,
name=f"serve_{name}_rsc",
)
for _page in UI_PAGES:
_register_ui_page(_page)
@app.get("/admin")
async def admin_redirect() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
@app.get("/dashboard", include_in_schema=False)
async def serve_dashboard_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "index.html")
@app.get("/login", include_in_schema=False)
async def serve_login_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "login" / "index.html")
# Add explicit route for /login/index.txt to redirect to /login
@app.get("/login/index.txt", include_in_schema=False)
async def redirect_login_index_txt() -> RedirectResponse:
return RedirectResponse("/login")
@app.get("/model", include_in_schema=False)
async def serve_models_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "model" / "index.html")
# Add explicit route for /model/index.txt to redirect to /model
@app.get("/model/index.txt", include_in_schema=False)
async def redirect_model_index_txt() -> RedirectResponse:
return RedirectResponse("/model")
@app.get("/providers", include_in_schema=False)
async def serve_providers_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
# Add explicit route for /providers/index.txt to redirect to /providers
@app.get("/providers/index.txt", include_in_schema=False)
async def redirect_providers_index_txt() -> RedirectResponse:
return RedirectResponse("/providers")
@app.get("/settings", include_in_schema=False)
async def serve_settings_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
# Add explicit route for /settings/index.txt to redirect to /settings
@app.get("/settings/index.txt", include_in_schema=False)
async def redirect_settings_index_txt() -> RedirectResponse:
return RedirectResponse("/settings")
@app.get("/transactions", include_in_schema=False)
async def serve_transactions_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
# Add explicit route for /transactions/index.txt to redirect to /transactions
@app.get("/transactions/index.txt", include_in_schema=False)
async def redirect_transactions_index_txt() -> RedirectResponse:
return RedirectResponse("/transactions")
@app.get("/balances", include_in_schema=False)
async def serve_balances_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "balances" / "index.html")
# Add explicit route for /balances/index.txt to redirect to /balances
@app.get("/balances/index.txt", include_in_schema=False)
async def redirect_balances_index_txt() -> RedirectResponse:
return RedirectResponse("/balances")
@app.get("/logs", include_in_schema=False)
async def serve_logs_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "logs" / "index.html")
# Add explicit route for /logs/index.txt to redirect to /logs
@app.get("/logs/index.txt", include_in_schema=False)
async def redirect_logs_index_txt() -> RedirectResponse:
return RedirectResponse("/logs")
@app.get("/usage", include_in_schema=False)
async def serve_usage_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
# Add explicit route for /usage/index.txt to redirect to /usage
@app.get("/usage/index.txt", include_in_schema=False)
async def redirect_usage_index_txt() -> RedirectResponse:
return RedirectResponse("/usage")
@app.get("/unauthorized", include_in_schema=False)
async def serve_unauthorized_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
@app.get("/unauthorized/index.txt", include_in_schema=False)
async def redirect_unauthorized_index_txt() -> RedirectResponse:
return RedirectResponse("/unauthorized")
@app.get("/favicon.ico", include_in_schema=False)
async def serve_favicon() -> FileResponse:
icon_path = UI_DIST_PATH / "icon.ico"
@@ -317,9 +361,6 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
async def serve_icon() -> FileResponse:
return FileResponse(UI_DIST_PATH / "icon.ico")
app.mount(
"/static", StaticFiles(directory=UI_DIST_PATH, check_dir=True), name="ui-static"
)
else:
logger.warning(
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
@@ -339,6 +380,7 @@ else:
app.include_router(models_router)
app.include_router(admin_router)
app.include_router(balance_router)
app.include_router(lightning_router)
app.include_router(deprecated_wallet_router)
app.include_router(providers_router)
app.include_router(proxy_router)

View File

@@ -14,8 +14,54 @@ logger = get_logger(__name__)
request_id_context: ContextVar[str | None] = ContextVar("request_id")
# Methods that are never logged: HEAD requests are health probes from
# monitoring/load balancers, OPTIONS are CORS preflights — both are framework
# chatter, not user-meaningful events.
_SKIP_LOG_METHODS: frozenset[str] = frozenset({"HEAD", "OPTIONS"})
# Path prefixes to skip. Includes Next.js static chunks and the admin
# dashboard's internal polling API (/admin/api/*) which the UI hits on a timer
# to refresh balances, logs, providers, etc. — high volume, low diagnostic
# value. Mutating admin actions are recorded separately in the audit log.
_SKIP_LOG_PREFIXES: tuple[str, ...] = (
"/_next/",
"/admin/api/",
)
# Exact paths to skip. RSC payload prefetches (`*/index.txt`) fire automatically
# as the user hovers near `<Link>`s, and `/v1/wallet/info` is polled by the UI.
_SKIP_LOG_EXACT: frozenset[str] = frozenset(
{
"/favicon.ico",
"/icon.ico",
"/v1/wallet/info",
"/index.txt",
"/login/index.txt",
"/model/index.txt",
"/providers/index.txt",
"/settings/index.txt",
"/transactions/index.txt",
"/balances/index.txt",
"/logs/index.txt",
"/usage/index.txt",
"/unauthorized/index.txt",
}
)
def _should_log(method: str, path: str) -> bool:
if method in _SKIP_LOG_METHODS:
return False
if path in _SKIP_LOG_EXACT:
return False
return not any(path.startswith(prefix) for prefix in _SKIP_LOG_PREFIXES)
class LoggingMiddleware(BaseHTTPMiddleware):
"""Middleware to log detailed request and response information."""
"""Middleware to log proxy interactions and page navigation.
Skips logging for static assets and Next.js chunks to avoid noise.
"""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
# Generate request ID
@@ -25,62 +71,20 @@ class LoggingMiddleware(BaseHTTPMiddleware):
# Set request ID in context for logging
token = request_id_context.set(request_id)
path = request.url.path
should_log = _should_log(request.method, path)
# Start timing
start_time = time.time()
# Log request details
request_body = None
if request.method in ["POST", "PUT", "PATCH"]:
try:
# Only read body for non-streaming requests
if hasattr(request, "_body"):
request_body = await request.body()
except Exception:
pass
# Extract request info
client_host = None
if request.client:
client_host = request.client.host
# Log incoming request
logger.info(
"Incoming request",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"query_params": dict(request.query_params),
"client_host": client_host,
"headers": {
k: v
for k, v in request.headers.items()
if k.lower()
not in [
"authorization",
"x-cashu",
"cookie",
"cf-connecting-ip",
"cf-ipcountry",
"x-forwarded-for",
"x-real-ip",
]
},
"body_size": len(request_body) if request_body else 0,
},
)
# Log at TRACE level for full body (security filter will redact sensitive data)
if request_body and hasattr(logger, "exception"):
logger.exception(
"Request body",
if should_log:
logger.info(
"Incoming request",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"body": request_body.decode("utf-8", errors="ignore")[
:1000
], # Limit size
"path": path,
"query_params": dict(request.query_params),
},
)
@@ -88,39 +92,33 @@ class LoggingMiddleware(BaseHTTPMiddleware):
try:
response = await call_next(request)
# Calculate duration
duration = time.time() - start_time
# Log response
logger.info(
"Request completed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": round(duration * 1000, 2),
"client_host": client_host,
},
)
if should_log:
duration = time.time() - start_time
logger.info(
"Request completed",
extra={
"request_id": request_id,
"method": request.method,
"path": path,
"status_code": response.status_code,
"duration_ms": round(duration * 1000, 2),
},
)
if hasattr(response, "headers"):
response.headers["x-routstr-request-id"] = request_id
return response
except Exception as e:
# Calculate duration
# Always log failures, even for skipped paths, so we don't lose errors.
duration = time.time() - start_time
# Log error
logger.error(
"Request failed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"path": path,
"duration_ms": round(duration * 1000, 2),
"client_host": client_host,
"error": str(e),
"error_type": type(e).__name__,
},

55
routstr/core/not_found.py Normal file
View File

@@ -0,0 +1,55 @@
"""Shared 404 handler used by the proxy catch-all and tests."""
from __future__ import annotations
from pathlib import Path
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
_NOT_FOUND_HTML_FILE = Path(__file__).parent.parent.parent / "ui_out" / "404.html"
def _read_not_found_html() -> str | None:
try:
return _NOT_FOUND_HTML_FILE.read_text(encoding="utf-8")
except OSError:
return None
_NOT_FOUND_HTML: str | None = _read_not_found_html()
def build_not_found_response(request: Request, path: str) -> Response:
"""Return a 404 response.
HTML 404 page only for GET requests from browsers (Accept: text/html).
All POST requests and API clients receive a JSON 404.
"""
accept = request.headers.get("accept", "").lower()
prefers_html = (
request.method == "GET"
and "text/html" in accept
and "application/json" not in accept
)
request_id = getattr(request.state, "request_id", "unknown")
if prefers_html and _NOT_FOUND_HTML is not None:
return HTMLResponse(content=_NOT_FOUND_HTML, status_code=404)
return JSONResponse(
status_code=404,
content={
"error": {
"message": f"Path '/{path}' not found",
"type": "not_found",
"code": 404,
},
"request_id": request_id,
},
)
async def not_found_catch_all(request: Request, path: str) -> Response:
"""ASGI handler form of :func:`build_not_found_response`."""
return build_not_found_response(request, path)

View File

@@ -41,6 +41,15 @@ class Settings(BaseSettings):
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT")
# Lightning payout configuration
# Minimum available balance (in satoshis) before profit is paid out over
# Lightning
min_payout_sat: int = Field(default=210, gt=0, env="MIN_PAYOUT_SAT")
# Interval (seconds) between periodic payout attempts. Must be positive.
payout_interval_seconds: int = Field(
default=900, gt=0, env="PAYOUT_INTERVAL_SECONDS"
)
# Pricing
# Default behavior: derive pricing from MODELS
# If fixed_pricing is True -> use fixed_cost_per_request and ignore tokens
@@ -52,12 +61,18 @@ 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(
default=True, env="RESET_RESERVED_BALANCE_ON_STARTUP"
) # deactivate in horizontal scaling setups
# Reservations older than this are considered leaked (client disconnect,
# crash, abandoned stream) and released by the background sweeper and the
# refund endpoint.
stale_reservation_timeout_seconds: int = Field(
default=300, env="STALE_RESERVATION_TIMEOUT_SECONDS"
)
# Network
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
@@ -74,6 +89,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 +108,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:
@@ -143,7 +173,7 @@ def resolve_bootstrap() -> Settings:
pass
if not base.onion_url:
try:
from ..nip91 import discover_onion_url_from_tor # type: ignore
from ..nostr.listing import discover_onion_url_from_tor # type: ignore
discovered = discover_onion_url_from_tor()
if discovered:
@@ -231,16 +261,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 +283,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 +306,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 +340,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

81
routstr/core/version.py Normal file
View File

@@ -0,0 +1,81 @@
"""Application version resolution.
Priority order:
1. ``VERSION_SUFFIX`` env var (manual override; preserves prior behaviour).
2. Bare base version when HEAD is on the matching release tag (detected via
``GIT_TAG`` env or ``git describe --tags --exact-match HEAD``).
3. ``GIT_COMMIT`` env var (build-time injection) -> ``<base>+g<sha>``.
4. Local ``.git`` lookup (source checkouts) -> ``<base>+g<sha>``.
5. Fallback: bare base version.
The ``+g<sha>`` form is PEP 440 local-version syntax so the result remains a
valid package version.
"""
from __future__ import annotations
import os
import subprocess
from functools import lru_cache
from pathlib import Path
BASE_VERSION = "0.4.3"
_REPO_ROOT = Path(__file__).resolve().parents[2]
_GIT_TIMEOUT_SECONDS = 2.0
def _run_git(*args: str) -> str | None:
try:
result = subprocess.run( # noqa: S603 - fixed argv, no shell
["git", *args],
cwd=_REPO_ROOT,
check=False,
capture_output=True,
text=True,
timeout=_GIT_TIMEOUT_SECONDS,
)
except (FileNotFoundError, subprocess.SubprocessError, OSError):
return None
if result.returncode != 0:
return None
return result.stdout.strip() or None
def _git_short_sha() -> str | None:
sha = os.getenv("GIT_COMMIT", "").strip()
if sha:
return sha[:7]
return _run_git("rev-parse", "--short=7", "HEAD")
def _on_tagged_release() -> bool:
tag = os.getenv("GIT_TAG", "").strip()
if tag:
return tag.lstrip("v") == BASE_VERSION
described = _run_git("describe", "--tags", "--exact-match", "HEAD")
if described and described.lstrip("v") == BASE_VERSION:
return True
return False
@lru_cache(maxsize=1)
def get_version() -> str:
suffix = os.getenv("VERSION_SUFFIX")
if suffix is not None:
return f"{BASE_VERSION}-{suffix}"
if _on_tagged_release():
return BASE_VERSION
sha = _git_short_sha()
if not sha:
return BASE_VERSION
return f"{BASE_VERSION}+g{sha}"
__version__ = get_version()
__all__ = ["BASE_VERSION", "__version__", "get_version"]

View File

@@ -1,13 +1,14 @@
import asyncio
import hashlib
import secrets
import time
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field
from sqlmodel import select
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from .core.db import ApiKey, LightningInvoice, get_session
from .core.db import ApiKey, LightningInvoice, create_session, get_session
from .core.logging import get_logger
from .core.settings import settings
from .wallet import get_wallet
@@ -19,10 +20,27 @@ lightning_router = APIRouter(prefix="/lightning")
class InvoiceCreateRequest(BaseModel):
amount_sats: int = Field(gt=0, le=1_000_000, description="Amount in satoshis")
purpose: str = Field(description="create or topup", pattern="^(create|topup)$")
api_key: str | None = Field(
default=None, description="Required for topup operations"
purpose: str = Field(
default="create",
description="create or topup",
pattern="^(create|topup)$",
)
api_key: str | None = Field(
default=None,
description="Deprecated: legacy field for topup. Prefer Authorization header.",
)
balance_limit: int | None = Field(default=None)
balance_limit_reset: str | None = Field(default=None)
validity_date: int | None = Field(default=None)
def _extract_bearer_api_key(authorization: str | None) -> str | None:
if not authorization:
return None
token = authorization.strip()
if token.lower().startswith("bearer "):
token = token[7:].strip()
return token or None
class InvoiceCreateResponse(BaseModel):
@@ -61,18 +79,21 @@ def generate_invoice_id() -> str:
@lightning_router.post("/invoice", response_model=InvoiceCreateResponse)
async def create_invoice(
request: InvoiceCreateRequest,
authorization: str | None = Header(default=None),
session: AsyncSession = Depends(get_session),
) -> InvoiceCreateResponse:
if request.purpose == "topup" and not request.api_key:
raise HTTPException(
status_code=400, detail="api_key is required for topup operations"
)
api_key_token = _extract_bearer_api_key(authorization) or request.api_key
if request.purpose == "topup" and request.api_key:
if not request.api_key.startswith("sk-"):
if request.purpose == "topup":
if not api_key_token:
raise HTTPException(
status_code=401,
detail="Authorization bearer api key is required for topup",
)
if not api_key_token.startswith("sk-"):
raise HTTPException(status_code=400, detail="Invalid API key format")
api_key = await session.get(ApiKey, request.api_key[3:])
api_key = await session.get(ApiKey, api_key_token[3:])
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
@@ -92,8 +113,11 @@ async def create_invoice(
description=description,
payment_hash=payment_hash,
status="pending",
api_key_hash=request.api_key[3:] if request.api_key else None,
api_key_hash=api_key_token[3:] if api_key_token else None,
purpose=request.purpose,
balance_limit=request.balance_limit,
balance_limit_reset=request.balance_limit_reset,
validity_date=request.validity_date,
expires_at=expires_at,
)
@@ -136,13 +160,13 @@ async def get_invoice_status(
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
if invoice.status == "pending":
await check_invoice_payment(invoice, session)
if invoice.status == "pending" and int(time.time()) > invoice.expires_at:
invoice.status = "expired"
await session.commit()
if invoice.status == "pending":
await check_invoice_payment(invoice, session)
api_key = None
if invoice.status == "paid" and invoice.purpose == "create":
if invoice.api_key_hash:
@@ -245,6 +269,9 @@ async def create_api_key_from_invoice(
balance=invoice.amount_sats * 1000, # Convert to msats
refund_currency="sat",
refund_mint_url=settings.primary_mint,
balance_limit=invoice.balance_limit,
balance_limit_reset=invoice.balance_limit_reset,
validity_date=invoice.validity_date,
)
session.add(api_key)
@@ -268,3 +295,41 @@ async def topup_api_key_from_invoice(
api_key.balance += invoice.amount_sats * 1000 # Convert to msats
await session.flush()
INVOICE_WATCH_INTERVAL_SECONDS = 5
INVOICE_WATCH_BATCH_LIMIT = 100
async def periodic_invoice_watcher() -> None:
"""Background task: detect paid Lightning invoices and credit balances.
Removes the need for clients to poll the status endpoint after paying.
"""
while True:
try:
async with create_session() as session:
now = int(time.time())
result = await session.exec(
select(LightningInvoice)
.where(
LightningInvoice.status == "pending",
col(LightningInvoice.expires_at) > now,
)
.limit(INVOICE_WATCH_BATCH_LIMIT)
)
pending = result.all()
for invoice in pending:
try:
await check_invoice_payment(invoice, session)
except Exception as e:
logger.error(
"Invoice watcher failed for invoice",
extra={"invoice_id": invoice.id, "error": str(e)},
)
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(f"Invoice watcher loop error: {e}")
await asyncio.sleep(INVOICE_WATCH_INTERVAL_SECONDS)

View File

@@ -0,0 +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", "publish_usage_analytics"]

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

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

View File

@@ -8,8 +8,8 @@ import httpx
import websockets
from fastapi import APIRouter, HTTPException
from .core.logging import get_logger
from .core.settings import settings
from ..core.logging import get_logger
from ..core.settings import settings
logger = get_logger(__name__)
@@ -72,8 +72,6 @@ async def query_nostr_relay_for_providers(
elif data[0] == "NOTICE":
try:
msg = str(data[1])
if len(msg) > 200:
msg = msg[:200] + "..."
logger.debug(f"Relay notice: {msg}")
except Exception:
logger.debug("Relay notice received")

View File

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

View File

@@ -3,9 +3,17 @@ import math
from pydantic.v1 import BaseModel
from ..core import get_logger
from ..core.db import AsyncSession
from ..core.settings import settings
from .price import sats_usd_price
from .usage import normalize_usage, parse_token_count
__all__ = [
"CostData",
"CostDataError",
"MaxCostData",
"calculate_cost",
"parse_token_count",
]
logger = get_logger(__name__)
@@ -16,6 +24,12 @@ class CostData(BaseModel):
output_msats: int
total_msats: int
total_usd: float = 0.0
input_tokens: int = 0
output_tokens: int = 0
cache_read_input_tokens: int = 0
cache_creation_input_tokens: int = 0
cache_read_msats: int = 0
cache_creation_msats: int = 0
class MaxCostData(CostData):
@@ -27,11 +41,11 @@ class CostDataError(BaseModel):
code: str
async def calculate_cost( # todo: can be sync
response_data: dict, max_cost: int, session: AsyncSession
async def calculate_cost(
response_data: dict,
max_cost: int,
) -> CostData | MaxCostData | CostDataError:
"""
Calculate the cost of an API request based on token usage.
"""Calculate the cost of an API request based on token usage.
Args:
response_data: Response data containing usage information
@@ -39,6 +53,9 @@ async def calculate_cost( # todo: can be sync
Returns:
Cost data or error information
The response's usage object is normalized with the default union parser;
this function holds no vendor-dialect knowledge of its own.
"""
logger.debug(
"Starting cost calculation",
@@ -49,12 +66,21 @@ async def calculate_cost( # todo: can be sync
},
)
if "usage" not in response_data or response_data["usage"] is None:
usage = normalize_usage(response_data.get("usage"))
if usage is None:
logger.warning(
"No usage data in response, using base cost only",
"No usage data in response — billing at MaxCostData with zero "
"tokens. Dashboard will show this request as `(0+0)`. Most "
"common cause: upstream stream did not include a final usage "
"chunk (OpenAI-compat backends require "
"`stream_options.include_usage=true`).",
extra={
"max_cost_msats": max_cost,
"model": response_data.get("model", "unknown"),
"response_keys": sorted(response_data.keys())
if isinstance(response_data, dict)
else None,
},
)
return MaxCostData(
@@ -63,47 +89,58 @@ async def calculate_cost( # todo: can be sync
output_msats=0,
total_msats=0,
total_usd=0.0,
input_tokens=0,
output_tokens=0,
cache_read_input_tokens=0,
cache_creation_input_tokens=0,
cache_read_msats=0,
cache_creation_msats=0,
)
usage_data = response_data["usage"]
usage_data = response_data.get("usage") or {}
if not isinstance(usage_data, dict):
usage_data = {}
usd_cost = 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
)
# Fallback to cost field if upstream_inference_cost is 0
if usd_cost == 0 and "cost" in usage_data:
try:
usd_cost = float(usage_data.get("cost", 0) or 0)
except Exception:
pass
input_tokens = usage.input_tokens
output_tokens = usage.output_tokens
cache_read_tokens = usage.cache_read_tokens
cache_creation_tokens = usage.cache_write_tokens
# Try USD cost first
usd_cost = _resolve_usd_cost(usage_data, response_data)
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)
logger.info(
"Using cost from usage data/details",
if input_tokens == 0 and output_tokens == 0:
logger.warning(
"Upstream reported a USD cost but no token counts — "
"billing the USD-derived cost while the dashboard will "
"show this request as `(0+0)` tokens. Check that the "
"upstream actually emits `usage.input_tokens` and "
"`usage.output_tokens` (OpenAI-compat streams require "
"`stream_options.include_usage=true`).",
extra={
"usd_cost": usd_cost,
"cost_in_sats": cost_in_sats,
"cost_in_msats": cost_in_msats,
"model": response_data.get("model", "unknown"),
"usd_cost": usd_cost,
"usage_keys": sorted(usage_data.keys())
if isinstance(usage_data, dict)
else None,
},
)
return CostData(
base_msats=-1,
input_msats=-1, # Cost field doesn't break down by token type
output_msats=-1,
total_msats=cost_in_msats,
total_usd=usd_cost,
try:
input_usd = _coerce_usd(
usage_data.get("cost_details", {}).get("input_cost", 0)
)
output_usd = _coerce_usd(
usage_data.get("cost_details", {}).get("output_cost", 0)
)
return _calculate_from_usd_cost(
usd_cost,
input_usd,
output_usd,
input_tokens,
cache_read_tokens,
cache_creation_tokens,
output_tokens,
response_data,
)
except Exception as e:
logger.warning(
@@ -114,69 +151,35 @@ async def calculate_cost( # todo: can be sync
"model": response_data.get("model", "unknown"),
},
)
# 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
)
# Fall back to token-based pricing
try:
pricing_rates = _get_pricing_rates(response_data)
except ValueError as e:
return CostDataError(message=str(e), code="pricing_error")
if not settings.fixed_pricing:
response_model = response_data.get("model", "")
logger.debug(
"Using model-based pricing",
extra={"model": response_model},
)
if pricing_rates is None:
input_rate = float(settings.fixed_per_1k_input_tokens) * 1000.0
output_rate = float(settings.fixed_per_1k_output_tokens) * 1000.0
cache_read_rate = input_rate
cache_creation_rate = input_rate
else:
input_rate, output_rate, cache_read_rate, cache_creation_rate = pricing_rates
from ..proxy import get_model_instance
model_obj = get_model_instance(response_model)
if not model_obj:
logger.error(
"Invalid model in response",
extra={"response_model": response_model},
)
return CostDataError(
message=f"Invalid model in response: {response_model}",
code="model_not_found",
)
if not model_obj.sats_pricing:
logger.error(
"Model pricing not defined",
extra={"model": response_model, "model_id": response_model},
)
return CostDataError(
message="Model pricing not defined", code="pricing_not_found"
)
try:
mspp = float(model_obj.sats_pricing.prompt)
mspc = float(model_obj.sats_pricing.completion)
except Exception:
return CostDataError(message="Invalid pricing data", code="pricing_invalid")
MSATS_PER_1K_INPUT_TOKENS = mspp * 1_000_000.0
MSATS_PER_1K_OUTPUT_TOKENS = mspc * 1_000_000.0
logger.info(
"Applied model-specific pricing",
extra={
"model": response_model,
"input_price_msats_per_1k": MSATS_PER_1K_INPUT_TOKENS,
"output_price_msats_per_1k": MSATS_PER_1K_OUTPUT_TOKENS,
},
)
if not (MSATS_PER_1K_OUTPUT_TOKENS and MSATS_PER_1K_INPUT_TOKENS):
if not (input_rate and output_rate):
logger.warning(
"No token pricing configured, using base cost",
"No token pricing configured — billing at flat MaxCostData. "
"Token counts %s in the upstream response but cannot be "
"priced; the request will appear in dashboards with the "
"raw counts and a fixed max-cost charge.",
"are present"
if (input_tokens > 0 or output_tokens > 0)
else "are zero",
extra={
"base_cost_msats": max_cost,
"model": response_data.get("model", "unknown"),
"input_tokens": input_tokens,
"output_tokens": output_tokens,
},
)
return MaxCostData(
@@ -184,35 +187,218 @@ 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,
cache_read_input_tokens=cache_read_tokens,
cache_creation_input_tokens=cache_creation_tokens,
cache_read_msats=0,
cache_creation_msats=0,
)
input_tokens = usage_data.get("prompt_tokens", 0)
output_tokens = usage_data.get("completion_tokens", 0)
# 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)
return _calculate_from_tokens(
input_tokens,
output_tokens,
cache_read_tokens,
cache_creation_tokens,
input_rate,
output_rate,
cache_read_rate,
cache_creation_rate,
response_data,
)
# 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)
# ============================================================================
# Helper Functions (ordered by call sequence in calculate_cost)
# ============================================================================
def _coerce_usd(value: object) -> float:
"""Coerce a value to USD float, handling various formats safely."""
if value is None or isinstance(value, bool):
return 0.0
if not isinstance(value, (int, float, str)):
return 0.0
try:
return max(0.0, float(value))
except (TypeError, ValueError):
return 0.0
def _resolve_usd_cost(usage_data: dict, response_data: dict) -> float:
"""Resolve USD cost with clear priority order.
Priority: cost_details.total_cost → total_cost → cost (in both usage and response).
"""
cost_details = usage_data.get("cost_details")
if isinstance(cost_details, dict):
cost = _coerce_usd(cost_details.get("total_cost"))
if cost > 0:
return cost
for source in [usage_data, response_data]:
if not isinstance(source, dict):
continue
for field in ("total_cost", "cost"):
cost = _coerce_usd(source.get(field))
if cost > 0:
return cost
return 0.0
def _get_pricing_rates(
response_data: dict,
) -> tuple[float, float, float, float] | None:
"""Get model-based pricing rates or None if using fixed pricing.
Returns: (input_rate, output_rate, cache_read_rate, cache_write_rate)
"""
if settings.fixed_pricing:
return None
from ..proxy import get_model_instance
response_model = response_data.get("model", "")
model_obj = get_model_instance(response_model)
if not model_obj:
logger.error("Invalid model in response", extra={"response_model": response_model})
raise ValueError(f"Invalid model: {response_model}")
if not model_obj.sats_pricing:
logger.error(
"Model pricing not defined",
extra={"model": response_model, "model_id": response_model},
)
raise ValueError("Model pricing not defined")
try:
mspp = float(model_obj.sats_pricing.prompt)
mspc = float(model_obj.sats_pricing.completion)
mscr = float(model_obj.sats_pricing.input_cache_read or 0)
mscw = float(model_obj.sats_pricing.input_cache_write or 0)
mspp_1k = mspp * 1_000_000.0
mspc_1k = mspc * 1_000_000.0
mscr_1k = mscr * 1_000_000.0 if mscr > 0 else mspp_1k
mscw_1k = mscw * 1_000_000.0 if mscw > 0 else mspp_1k
logger.info(
"Applied model-specific pricing",
extra={
"model": response_model,
"input_price_msats_per_1k": mspp_1k,
"output_price_msats_per_1k": mspc_1k,
"cache_read_price_msats_per_1k": mscr_1k,
"cache_write_price_msats_per_1k": mscw_1k,
},
)
return mspp_1k, mspc_1k, mscr_1k, mscw_1k
except Exception as e:
logger.error("Invalid pricing data", extra={"error": str(e)})
raise ValueError("Invalid pricing data") from e
def _resolve_provider_fee(model_id: str) -> float:
"""Resolve the provider fee multiplier for the given model id.
Falls back to 1.0 (no markup) when the provider cannot be resolved so
the USD cost path never silently double-applies or omits the fee.
"""
from ..proxy import get_provider_for_model
if not model_id:
return 1.0
providers = get_provider_for_model(model_id)
if not providers:
return 1.0
return float(providers[0].provider_fee)
def _calculate_from_usd_cost(
usd_cost: float,
input_usd: float,
output_usd: float,
input_tokens: int,
cache_read_tokens: int,
cache_creation_tokens: int,
output_tokens: int,
response_data: dict,
) -> CostData:
"""Calculate cost from USD figures, deriving input/output split from tokens."""
provider_fee = _resolve_provider_fee(response_data.get("model", ""))
usd_cost = usd_cost * provider_fee
input_usd = input_usd * provider_fee
output_usd = output_usd * provider_fee
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)
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:
effective_input_tokens = (
input_tokens + cache_read_tokens + cache_creation_tokens
)
total_tokens = effective_input_tokens + output_tokens
input_msats = (
int(cost_in_msats * effective_input_tokens / total_tokens)
if total_tokens > 0
else 0
)
output_msats = cost_in_msats - input_msats
logger.info(
"Using cost from usage data/details",
extra={
"usd_cost": usd_cost,
"cost_in_sats": cost_in_sats,
"cost_in_msats": cost_in_msats,
"model": response_data.get("model", "unknown"),
},
)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
return CostData(
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,
cache_read_input_tokens=cache_read_tokens,
cache_creation_input_tokens=cache_creation_tokens,
cache_read_msats=0,
cache_creation_msats=0,
)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(input_msats + output_msats)
def _calculate_from_tokens(
input_tokens: int,
output_tokens: int,
cache_read_tokens: int,
cache_creation_tokens: int,
input_rate: float,
output_rate: float,
cache_read_rate: float,
cache_creation_rate: float,
response_data: dict,
) -> CostData:
"""Calculate cost from token counts using pricing rates."""
calc_input_msats = round(input_tokens / 1000 * input_rate, 3)
calc_output_msats = round(output_tokens / 1000 * output_rate, 3)
calc_cache_read_msats = round(cache_read_tokens / 1000 * cache_read_rate, 3)
calc_cache_write_msats = round(
cache_creation_tokens / 1000 * cache_creation_rate, 3
)
token_based_cost = math.ceil(
calc_input_msats
+ calc_output_msats
+ calc_cache_read_msats
+ calc_cache_write_msats
)
total_usd = (token_based_cost / 1000.0) * sats_usd_price()
logger.info(
@@ -220,8 +406,12 @@ 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,
"cache_read_input_tokens": cache_read_tokens,
"cache_creation_input_tokens": cache_creation_tokens,
"input_cost_msats": calc_input_msats,
"output_cost_msats": calc_output_msats,
"cache_read_cost_msats": calc_cache_read_msats,
"cache_creation_cost_msats": calc_cache_write_msats,
"total_cost_msats": token_based_cost,
"total_usd": total_usd,
"model": response_data.get("model", "unknown"),
@@ -230,8 +420,14 @@ 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,
cache_read_input_tokens=cache_read_tokens,
cache_creation_input_tokens=cache_creation_tokens,
cache_read_msats=int(calc_cache_read_msats),
cache_creation_msats=int(calc_cache_write_msats),
)

View File

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

View File

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

View File

@@ -3,11 +3,12 @@ import json
import random
import httpx
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, Request
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
@@ -16,6 +17,24 @@ logger = get_logger(__name__)
models_router = APIRouter()
_MODEL_TEST_ENDPOINT_PATHS = {
"chat-completions": "chat/completions",
"completions": "completions",
"embeddings": "embeddings",
"responses": "responses",
}
# Cap the caller-supplied test payload to avoid forwarding oversized bodies
# upstream on the operator's credentials.
_MODEL_TEST_MAX_REQUEST_BYTES = 64 * 1024
async def _require_admin_api(request: Request) -> None:
"""Require admin auth without creating an import-time cycle with core.admin."""
from ..core.admin import require_admin_api
await require_admin_api(request)
class Architecture(BaseModel):
modality: str
@@ -60,11 +79,54 @@ 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)
def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
"""Fill missing cache rates from litellm's bundled cost map.
The OpenRouter model feed omits ``input_cache_read``/``input_cache_write``
for many models (most DeepSeek entries, openai/gpt-4o, ...). Without a
cache rate, billing falls back to the full input rate, which overcharges
cache reads (DeepSeek hits are 10x cheaper) and undercharges Anthropic
cache writes (1.25x). litellm ships per-model USD rates keyed by the exact
OpenRouter id (deepseek/deepseek-chat) or by the bare model name
(gpt-4o, claude-sonnet-4-5), so both spellings are tried.
Rates already present (e.g. provided by OpenRouter) are authoritative and
never overwritten. Unknown models are returned unchanged.
"""
needs_read = (pricing.input_cache_read or 0.0) <= 0.0
needs_write = (pricing.input_cache_write or 0.0) <= 0.0
if not (needs_read or needs_write):
return pricing
import litellm
info: dict | None = None
for key in (model_id, model_id.split("/", 1)[-1]):
candidate = litellm.model_cost.get(key)
if isinstance(candidate, dict):
info = candidate
break
if info is None:
return pricing
updated = Pricing.parse_obj(pricing.dict())
if needs_read:
read_rate = info.get("cache_read_input_token_cost")
if isinstance(read_rate, (int, float)) and read_rate > 0:
updated.input_cache_read = float(read_rate)
if needs_write:
write_rate = info.get("cache_creation_input_token_cost")
if isinstance(write_rate, (int, float)) and write_rate > 0:
updated.input_cache_write = float(write_rate)
return updated
def _has_valid_pricing(model: dict) -> bool:
"""Check if model has valid pricing (not free, no negative values)."""
pricing = model.get("pricing", {})
@@ -143,14 +205,6 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
return []
def is_openrouter_upstream() -> bool:
try:
base = (settings.upstream_base_url or "").strip().rstrip("/")
except Exception:
return False
return base.lower() == "https://openrouter.ai/api/v1"
def _row_to_model(
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
) -> Model:
@@ -185,6 +239,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:
@@ -203,29 +258,6 @@ def _row_to_model(
return model
def _model_to_row_payload(model: Model) -> dict[str, str | int | bool | None]:
return {
"id": model.id,
"name": model.name,
"created": model.created,
"description": model.description,
"context_length": model.context_length,
"architecture": json.dumps(model.architecture.dict()),
"pricing": json.dumps(model.pricing.dict()),
"sats_pricing": json.dumps(model.sats_pricing.dict())
if model.sats_pricing
else None,
"per_request_limits": json.dumps(model.per_request_limits)
if model.per_request_limits is not None
else None,
"top_provider": json.dumps(model.top_provider.dict())
if model.top_provider is not None
else None,
"enabled": model.enabled,
"upstream_provider_id": model.upstream_provider_id,
}
async def list_models(
session: AsyncSession,
upstream_id: int,
@@ -262,21 +294,6 @@ async def list_models(
]
async def get_model_by_id(
model_id: str, provider_id: int, session: AsyncSession
) -> Model | None:
from ..core.db import UpstreamProviderRow
row = await session.get(ModelRow, (model_id, provider_id))
if not row or not row.enabled:
return None
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider or not provider.enabled:
return None
provider_fee = provider.provider_fee if provider else 1.01
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
"""Calculate max costs in USD based on model context/token limits.
@@ -375,6 +392,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(
@@ -393,6 +411,9 @@ async def _update_sats_pricing_once() -> None:
from ..proxy import get_upstreams, refresh_model_maps
upstreams = get_upstreams()
if not upstreams:
return
sats_to_usd = sats_usd_price()
updated_count = 0
@@ -402,11 +423,14 @@ async def _update_sats_pricing_once() -> None:
for m in upstream.get_cached_models()
]
upstream._models_cache = updated_models
upstream._models_by_id = {m.id: m for m in updated_models}
upstream._models_by_id = {m.forwarded_model_id or m.id: m for m in updated_models}
updated_count += len(updated_models)
if updated_count > 0:
logger.info("Updated sats pricing", extra={"models_updated": updated_count})
logger.info(
f"Updated sats pricing for {updated_count} models",
extra={"models_updated": updated_count},
)
await refresh_model_maps()
@@ -448,11 +472,110 @@ 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", dependencies=[Depends(_require_admin_api)]
)
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,
}
endpoint_path = _MODEL_TEST_ENDPOINT_PATHS.get(payload.endpoint_type)
if endpoint_path is None:
raise HTTPException(status_code=400, detail="Unsupported 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
try:
request_size = len(json.dumps(request_data).encode("utf-8"))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="Invalid request_data")
if request_size > _MODEL_TEST_MAX_REQUEST_BYTES:
raise HTTPException(status_code=413, detail="request_data too large")
base_url = provider.base_url.rstrip("/")
url = f"{base_url}/{endpoint_path}"
logger.info(
"admin model test",
extra={
"model_id": payload.model_id,
"forwarded_model_id": actual_model_id,
"endpoint_type": payload.endpoint_type,
"upstream_provider_id": model_row.upstream_provider_id,
"request_bytes": request_size,
},
)
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}

131
routstr/payment/usage.py Normal file
View File

@@ -0,0 +1,131 @@
"""Vendor-agnostic normalization of upstream usage objects.
Upstream providers report token usage in vendor dialects that differ in field
names and in whether cached tokens are included in the input count:
* OpenAI / Azure / xAI / Groq / Moonshot / Qwen / Gemini-compat: cache reads in
``prompt_tokens_details.cached_tokens``, included in ``prompt_tokens``.
* OpenRouter: same as OpenAI plus cache *writes* in
``prompt_tokens_details.cache_write_tokens``, also included in
``prompt_tokens``.
* litellm-normalized: same nesting, but names the write field
``prompt_tokens_details.cache_creation_tokens`` (and additionally mirrors the
Anthropic top-level fields), with ``prompt_tokens`` as the grand total.
* Anthropic native: ``cache_read_input_tokens`` / ``cache_creation_input_tokens``
top-level, additive to (not included in) ``input_tokens``.
* DeepSeek: ``prompt_cache_hit_tokens`` / ``prompt_cache_miss_tokens``, with
``prompt_tokens = hit + miss``.
What decides whether cached tokens must be subtracted out of the input count is
**which prompt field the vendor uses**, not which cache field appears:
* ``prompt_tokens`` present -> cached + cache-write tokens are *included* in it
(OpenAI family, DeepSeek, OpenRouter, litellm); subtract both so
``input_tokens`` holds only the regular-rate portion.
* only ``input_tokens`` (Anthropic native) -> cached tokens are *additive*;
leave ``input_tokens`` untouched.
``normalize_usage`` maps all of them onto one canonical ``NormalizedUsage``
shape so billing code needs no vendor knowledge. The known dialects' field
names do not collide, so a single union parser is safe; a vendor whose fields
would genuinely conflict needs a dedicated branch here.
"""
from pydantic.v1 import BaseModel
class NormalizedUsage(BaseModel):
"""Canonical token usage: input_tokens never includes cached tokens."""
input_tokens: int = 0
output_tokens: int = 0
cache_read_tokens: int = 0
cache_write_tokens: int = 0
def parse_token_count(value: object) -> int:
"""Parse a token count from various formats (int, float, str, bool)."""
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
def _first_token_count(usage_data: dict, *fields: str) -> int:
"""Return the first positive token count among the given fields."""
for field in fields:
value = parse_token_count(usage_data.get(field, 0))
if value > 0:
return value
return 0
def _extract_cache_tokens(usage_data: dict) -> tuple[int, int]:
"""Pull (cache_read, cache_write) across all known dialects.
Precedence (highest first), independent for reads and writes:
* Anthropic top-level: ``cache_read_input_tokens`` /
``cache_creation_input_tokens``.
* Nested ``prompt_tokens_details``: ``cached_tokens`` for reads;
``cache_creation_tokens`` (litellm) or ``cache_write_tokens``
(OpenRouter) for writes.
* DeepSeek: ``prompt_cache_hit_tokens`` for reads (no write concept).
"""
cache_read = parse_token_count(usage_data.get("cache_read_input_tokens", 0))
cache_write = parse_token_count(usage_data.get("cache_creation_input_tokens", 0))
prompt_details = usage_data.get("prompt_tokens_details")
if isinstance(prompt_details, dict):
if not cache_read:
cache_read = parse_token_count(prompt_details.get("cached_tokens", 0))
if not cache_write:
cache_write = _first_token_count(
prompt_details, "cache_creation_tokens", "cache_write_tokens"
)
if not cache_read:
# DeepSeek: prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens
cache_read = parse_token_count(usage_data.get("prompt_cache_hit_tokens", 0))
return cache_read, cache_write
def normalize_usage(usage_data: object) -> NormalizedUsage | None:
"""Map a vendor usage dict onto the canonical shape, or None if absent.
Cached reads and writes are subtracted from the input count exactly once,
only for dialects that report a ``prompt_tokens`` grand total that already
includes them (OpenAI family, DeepSeek, OpenRouter, litellm). Anthropic
native reports them additively under ``input_tokens`` and is left untouched.
"""
if not isinstance(usage_data, dict):
return None
output_tokens = _first_token_count(
usage_data, "completion_tokens", "output_tokens"
)
cache_read, cache_write = _extract_cache_tokens(usage_data)
# ``prompt_tokens`` is the inclusive grand total; ``input_tokens`` (Anthropic
# native) excludes cached tokens. The field chosen decides whether to subtract.
if "prompt_tokens" in usage_data:
input_tokens = parse_token_count(usage_data.get("prompt_tokens", 0))
input_tokens = max(0, input_tokens - cache_read - cache_write)
else:
input_tokens = parse_token_count(usage_data.get("input_tokens", 0))
return NormalizedUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_tokens=cache_read,
cache_write_tokens=cache_write,
)

View File

@@ -1,3 +1,4 @@
import asyncio
import json
from typing import Any
@@ -17,6 +18,8 @@ from .core.db import (
get_session,
)
from .core.exceptions import UpstreamError
from .core.not_found import build_not_found_response
from .core.settings import settings
from .payment.helpers import (
calculate_discounted_max_cost,
check_token_balance,
@@ -26,6 +29,7 @@ from .payment.helpers import (
from .payment.models import Model
from .upstream import BaseUpstreamProvider
from .upstream.helpers import init_upstreams
from .upstream.request_correction import correct_request, extract_error_message
logger = get_logger(__name__)
proxy_router = APIRouter()
@@ -68,7 +72,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:
@@ -131,27 +153,79 @@ async def refresh_model_maps_periodically() -> None:
)
_API_PATH_PREFIXES = (
"v1/",
"responses",
"chat/",
"completions",
"models",
"embeddings",
"audio/",
"images/",
"moderations",
"providers",
"tee/",
)
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
async def proxy(
request: Request, path: str, session: AsyncSession = Depends(get_session)
) -> Response | StreamingResponse:
headers = dict(request.headers)
# GET requests must hit a known API prefix; otherwise return a 404 (HTML
# for browsers, JSON for API clients). POST requests are always forwarded
# so that OpenAI-style endpoints work with or without the `v1/` prefix
# (e.g. `/chat/completions` as well as `/v1/chat/completions`).
if request.method == "GET" and not path.startswith(_API_PATH_PREFIXES):
return build_not_found_response(request, path)
if "x-cashu" not in headers and "authorization" not in headers.keys():
return create_error_response(
"unauthorized", "Unauthorized", 401, request=request
)
headers = dict(request.headers)
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)
# /tee/* GET requests (e.g. attestation) don't map to models — just
# forward to all enabled upstreams without model/cost/auth lookups.
if request.method == "GET" and path.startswith("tee/"):
all_upstreams = _upstreams
last_error_response = None
for i, upstream in enumerate(all_upstreams):
try:
headers = upstream.prepare_headers(dict(request.headers))
response = await upstream.forward_get_request(request, path, headers)
if response.status_code in [502, 429] and i < len(all_upstreams) - 1:
logger.warning(
"Upstream %s returned %s for tee GET %s, trying next",
upstream.provider_type,
response.status_code,
path,
)
continue
return response
except UpstreamError as e:
logger.warning(
"Upstream %s failed for tee GET %s: %s",
upstream.provider_type,
path,
e,
)
if i == len(all_upstreams) - 1:
last_error_response = create_error_response(
"upstream_error", str(e), 502, request=request
)
continue
return last_error_response or create_error_response(
"upstream_error", "All upstreams failed", 502, request=request
)
if is_responses_api:
model_id = extract_model_from_responses_request(request_body_dict)
else:
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
@@ -176,6 +250,9 @@ async def proxy(
max_cost_for_model = await calculate_discounted_max_cost(
_max_cost_for_model, request_body_dict, model_obj=model_obj
)
# Ensure max_cost_for_model is at least the minimum allowed request cost
max_cost_for_model = max(max_cost_for_model, settings.min_request_msat)
check_token_balance(headers, request_body_dict, max_cost_for_model)
if x_cashu := headers.get("x-cashu", None):
@@ -192,7 +269,15 @@ async def proxy(
)
except UpstreamError as e:
logger.warning(
f"Upstream {upstream.provider_type} failed (x-cashu): {e}"
"Upstream %s failed (x-cashu) for model=%s: %s",
upstream.provider_type,
model_id,
e,
extra={
"provider": upstream.provider_type,
"model": model_id,
"status_code": e.status_code,
},
)
if i == len(upstreams) - 1:
last_error = e
@@ -206,7 +291,9 @@ async def proxy(
)
elif auth := headers.get("authorization", None):
key = await get_bearer_token_key(headers, path, session, auth)
key = await get_bearer_token_key(
headers, path, session, auth, max_cost_for_model, model_id
)
else:
if request.method not in ["GET"]:
@@ -267,32 +354,86 @@ async def proxy(
if request_body_dict:
await pay_for_request(key, max_cost_for_model, session)
# Tracks request params already removed in response to upstream rejections,
# shared across providers so a stripped param stays stripped on failover and
# the reactive retry can never loop unboundedly.
already_stripped: set[str] = set()
for i, upstream in enumerate(upstreams):
headers = upstream.prepare_headers(dict(request.headers))
try:
if is_responses_api:
response = await upstream.forward_responses_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
else:
response = await upstream.forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
while True:
try:
if is_responses_api:
response = await upstream.forward_responses_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
else:
response = await upstream.forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
except UpstreamError:
# Let the outer UpstreamError handler manage retry/revert
raise
except Exception as e:
# Unexpected error (not an upstream failure) — revert and propagate
logger.error(
"Unexpected error in upstream request, reverting payment",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
"max_cost_for_model": max_cost_for_model,
},
)
await revert_pay_for_request(key, session, max_cost_for_model)
raise
# Reactive recovery: some models reject one specific request
# param (e.g. newer Anthropic models deprecating `temperature`).
# When the upstream 400s naming such a param, strip it from the
# body and retry the SAME upstream. ``already_stripped`` bounds
# this to one retry per distinct param so it always terminates.
if response.status_code == 400:
correction = correct_request(
request_body,
extract_error_message(response),
already_stripped,
)
if correction is not None:
request_body, bad_param = correction.body, correction.label
already_stripped.add(bad_param)
logger.warning(
"Upstream %s rejected param '%s' for model=%s; "
"stripping and retrying same upstream",
upstream.provider_type,
bad_param,
model_id,
extra={
"provider": upstream.provider_type,
"model": model_id,
"stripped_param": bad_param,
"path": path,
},
)
continue
break
if response.status_code != 200:
# Check if we should retry (502 Upstream Error or 429 Rate Limit)
@@ -317,10 +458,14 @@ async def proxy(
)
logger.warning(
f"Upstream {upstream.provider_type} returned {response.status_code}, trying next provider",
"Upstream %s returned %s for model=%s, trying next provider",
upstream.provider_type,
response.status_code,
model_id,
extra={
"status_code": response.status_code,
"upstream": upstream.provider_type,
"provider": upstream.provider_type,
"model": model_id,
},
)
continue
@@ -328,26 +473,53 @@ async def proxy(
# 4xx error (user error), or other non-retryable error, or last provider failed
await revert_pay_for_request(key, session, max_cost_for_model)
logger.warning(
"Upstream request failed, revert payment",
"Upstream request failed, revert payment "
"(provider=%s model=%s status=%s path=%s)",
upstream.provider_type,
model_id,
response.status_code,
path,
extra={
"status_code": response.status_code,
"path": path,
"provider": upstream.provider_type,
"model": model_id,
"key_hash": key.hashed_key[:8] + "...",
"key_balance": key.balance,
"max_cost_for_model": max_cost_for_model,
"upstream_headers": response.headers
if hasattr(response, "headers")
else None,
},
)
return response
return response
except asyncio.CancelledError:
logger.warning(
"Client disconnected mid-request, reverting reservation",
extra={
"path": path,
"model": model_id,
"key_hash": key.hashed_key[:8] + "...",
"max_cost_for_model": max_cost_for_model,
},
)
await asyncio.shield(
revert_pay_for_request(key, session, max_cost_for_model)
)
raise
except UpstreamError as e:
logger.warning(
f"Upstream {upstream.provider_type} failed: {e}",
extra={"retry": i < len(upstreams) - 1},
"Upstream %s failed for model=%s: %s",
upstream.provider_type,
model_id,
e,
extra={
"provider": upstream.provider_type,
"model": model_id,
"status_code": e.status_code,
"retry": i < len(upstreams) - 1,
},
)
# If this was the last provider
@@ -367,10 +539,16 @@ async def proxy(
async def get_bearer_token_key(
headers: dict, path: str, session: AsyncSession, auth: str
headers: dict,
path: str,
session: AsyncSession,
auth: str,
min_cost: int = 0,
model_id: str = "unknown",
) -> ApiKey:
"""Handle bearer token authentication proxy requests."""
bearer_key = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
parts = auth.split()
bearer_key = parts[1] if len(parts) > 1 and parts[0].lower() == "bearer" else ""
refund_address = headers.get("Refund-LNURL", None)
key_expiry_time = headers.get("Key-Expiry-Time", None)
@@ -383,6 +561,7 @@ async def get_bearer_token_key(
"bearer_key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
"min_cost": min_cost,
},
)
@@ -421,6 +600,7 @@ async def get_bearer_token_key(
session,
refund_address,
key_expiry_time, # type: ignore
min_cost=min_cost,
)
logger.info(
"Bearer token validated successfully",
@@ -432,15 +612,16 @@ async def get_bearer_token_key(
)
return key
except Exception as e:
key_preview = bearer_key[:20] + "..." if len(bearer_key) > 20 else bearer_key
logger.error(
"Bearer token validation failed",
f"Bearer token validation failed: {type(e).__name__}: {e} path={path} model={model_id!r} min_cost={min_cost} key={key_preview!r}",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"bearer_key_preview": bearer_key[:20] + "..."
if len(bearer_key) > 20
else bearer_key,
"model_id": model_id,
"min_cost_msat": min_cost,
"bearer_key_preview": key_preview,
},
)
raise

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
"""Inject explicit prompt-cache breakpoints into OpenAI-shaped requests.
Some upstreams cache *explicitly*: the request must carry
``cache_control: {"type": "ephemeral"}`` markers on the content blocks that
should be cached. Two model families use this identical wire format:
* **Anthropic Claude** — direct or via OpenRouter's ``anthropic/*`` models.
* **Alibaba's explicit-cache models on OpenRouter** — ``qwen/qwen3-max``,
``qwen/qwen-plus``, ``qwen/qwen3.6-plus``, ``qwen/qwen3-coder-plus``,
``qwen/qwen3-coder-flash`` and ``deepseek/deepseek-v3.2`` — which OpenRouter
documents as using "the same syntax as Anthropic explicit caching".
Every other provider routstr proxies (OpenAI, Azure, xAI/Grok, Groq, Moonshot,
default DeepSeek, Gemini implicit, Fireworks) caches *automatically* and needs
no markers — they are left untouched.
A client that doesn't know it is talking to one of these models *through*
routstr (e.g. an OpenAI-compatible coding agent pointed at a routstr URL) never
emits the markers — it only adds them when it recognises the provider as
OpenRouter. So caching silently never engages over routstr even though the same
client caches fine talking to OpenRouter directly.
This module restores caching by stamping the standard breakpoints onto the
forwarded body — the system prompt, the last tool, and the last conversation
message (the format allows up to four; we use three, matching the common
agent convention) — but only when the client supplied none of its own, so
explicit client control always wins. The caller is responsible for only
applying this toward an upstream that accepts the markers (OpenRouter /
Anthropic), so they never leak to a provider that would reject them.
"""
from __future__ import annotations
from typing import Any
# The single ephemeral marker stamped onto each chosen breakpoint. A 5-minute
# TTL (the default for ``ephemeral``) — deliberately not the 1h tier, which
# carries a higher cache-write premium and should stay opt-in.
EPHEMERAL_CACHE_CONTROL: dict[str, str] = {"type": "ephemeral"}
# Alibaba's explicit-cache models on OpenRouter. Matched as substrings of the
# model id (any spelling routstr carries). Snapshot endpoints that OpenRouter
# documents as *not* supporting explicit caching (e.g. ``qwen3.5-plus-02-15``)
# are different families and deliberately absent from this list.
_ALIBABA_EXPLICIT_CACHE_SLUGS: tuple[str, ...] = (
"qwen3-max",
"qwen-plus",
"qwen3.6-plus",
"qwen3-coder-plus",
"qwen3-coder-flash",
"deepseek-v3.2",
)
def is_explicit_cache_model(model_id: str | None, *fallbacks: str | None) -> bool:
"""True when the target model uses the explicit ``cache_control`` dialect.
Covers the Claude family (broadly — every Claude model supports it) and
Alibaba's documented explicit-cache models, across the id spellings routstr
carries: the OpenRouter id (``anthropic/claude-...``, ``qwen/qwen3-max``),
the bare upstream id, and any forwarded/canonical alias.
"""
for candidate in (model_id, *fallbacks):
if not candidate:
continue
lowered = candidate.lower()
if "claude" in lowered or "anthropic/" in lowered:
return True
if any(slug in lowered for slug in _ALIBABA_EXPLICIT_CACHE_SLUGS):
return True
return False
def _has_cache_control(obj: Any) -> bool:
"""Recursively detect any client-supplied ``cache_control`` marker."""
if isinstance(obj, dict):
if "cache_control" in obj:
return True
return any(_has_cache_control(v) for v in obj.values())
if isinstance(obj, list):
return any(_has_cache_control(v) for v in obj)
return False
def body_has_cache_control(data: dict) -> bool:
"""True when the request already carries cache_control on messages/tools."""
return _has_cache_control(data.get("messages")) or _has_cache_control(
data.get("tools")
)
def _stamp_text_content(message: dict) -> bool:
"""Add the ephemeral marker to a message's last text block.
A string content is promoted to the array form Anthropic requires for
cache markers; an existing array gets the marker on its last text part.
Returns True when a marker was placed.
"""
content = message.get("content")
if isinstance(content, str):
if not content:
return False
message["content"] = [
{
"type": "text",
"text": content,
"cache_control": dict(EPHEMERAL_CACHE_CONTROL),
}
]
return True
if isinstance(content, list):
for part in reversed(content):
if isinstance(part, dict) and part.get("type") == "text":
part["cache_control"] = dict(EPHEMERAL_CACHE_CONTROL)
return True
return False
def _stamp_system_prompt(messages: list) -> None:
for message in messages:
if isinstance(message, dict) and message.get("role") in (
"system",
"developer",
):
_stamp_text_content(message)
return
def _stamp_last_tool(tools: Any) -> None:
if isinstance(tools, list) and tools and isinstance(tools[-1], dict):
tools[-1]["cache_control"] = dict(EPHEMERAL_CACHE_CONTROL)
def _stamp_last_conversation_message(messages: list) -> None:
for message in reversed(messages):
if isinstance(message, dict) and message.get("role") in ("user", "assistant"):
if _stamp_text_content(message):
return
def inject_anthropic_cache_breakpoints(data: dict) -> bool:
"""Stamp ephemeral cache breakpoints onto an OpenAI-shaped chat body.
Mutates ``data`` in place (the established convention in
``prepare_request_body``) and returns True when anything changed. No-ops
when the body isn't chat-shaped or the client already set cache_control.
"""
messages = data.get("messages")
if not isinstance(messages, list) or not messages:
return False
if body_has_cache_control(data):
return False
_stamp_system_prompt(messages)
_stamp_last_tool(data.get("tools"))
_stamp_last_conversation_message(messages)
return True

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

View File

@@ -0,0 +1,108 @@
"""Local handling of Anthropic ``/v1/messages/count_tokens`` for upstreams
that do not natively expose the endpoint.
Most non-Anthropic upstreams (OpenAI-compat, Gemini OpenAI-compat,
OpenRouter chat-completions, generic providers) return 400/404 when asked
to ``POST /messages/count_tokens``. Claude Code and other Anthropic SDK
clients call this endpoint before each turn to size context windows and
trigger compaction, so a failure breaks the whole chat.
We answer locally. ``litellm.token_counter`` understands the Anthropic
message shape and the per-model tokenizers, so we prefer it. If it raises
(unknown model, encoding lookup failure, ...), we fall back to the
project's own ``estimate_tokens`` heuristic, which is always defined and
never raises.
"""
from __future__ import annotations
import json
from typing import Any
import litellm
from fastapi.responses import Response
from ..core import get_logger
from ..payment.helpers import estimate_tokens
from ..payment.models import Model
logger = get_logger(__name__)
def _parse_request_body(request_body: bytes | None) -> dict[str, Any]:
if not request_body:
return {}
try:
parsed = json.loads(request_body)
except (ValueError, TypeError):
return {}
return parsed if isinstance(parsed, dict) else {}
def _count_with_litellm(model: str, body: dict[str, Any]) -> int:
messages = body.get("messages")
if not isinstance(messages, list):
messages = []
system = body.get("system")
if isinstance(system, str) and system:
messages = [{"role": "system", "content": system}, *messages]
elif isinstance(system, list):
text = "".join(
block.get("text", "")
for block in system
if isinstance(block, dict) and block.get("type") == "text"
)
if text:
messages = [{"role": "system", "content": text}, *messages]
tools = body.get("tools") if isinstance(body.get("tools"), list) else None
return int(
litellm.token_counter(
model=model,
messages=messages,
tools=tools,
)
)
def count_tokens_locally(
request_body: bytes | None,
model_obj: Model | None,
) -> Response:
"""Return an Anthropic-compatible count_tokens response without
touching the upstream. Always returns 200; never raises."""
body = _parse_request_body(request_body)
model_name = ""
if model_obj is not None:
model_name = model_obj.forwarded_model_id or model_obj.id or ""
if not model_name:
body_model = body.get("model")
if isinstance(body_model, str):
model_name = body_model
input_tokens: int
try:
input_tokens = _count_with_litellm(model_name, body)
except Exception as exc:
messages = body.get("messages")
fallback_messages = messages if isinstance(messages, list) else []
input_tokens = estimate_tokens(fallback_messages)
logger.debug(
"litellm token_counter failed; using local estimator",
extra={
"model": model_name,
"error": str(exc),
"error_type": type(exc).__name__,
"estimated_tokens": input_tokens,
},
)
payload = {"input_tokens": max(0, int(input_tokens))}
return Response(
content=json.dumps(payload).encode(),
status_code=200,
media_type="application/json",
)

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

View File

@@ -1,17 +1,13 @@
from __future__ import annotations
import json
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Any
from fastapi import Request
from fastapi.responses import Response, StreamingResponse
from . import gemini_messages
from .base import BaseUpstreamProvider
from .clients.gemini import GeminiClient
if TYPE_CHECKING:
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
from ..core.db import UpstreamProviderRow
from ..payment.models import Model
from ..core.logging import get_logger
@@ -20,9 +16,19 @@ logger = get_logger(__name__)
class GeminiUpstreamProvider(BaseUpstreamProvider):
"""Gemini provider — proxies through Gemini's OpenAI-compat surface.
The chat-completions, embeddings, and models paths all flow through
:meth:`BaseUpstreamProvider.forward_request`; we only override
``get_request_base_url`` to redirect to ``{base}/openai/...`` and
``_dispatch_anthropic_messages`` to inject thought-signatures on the
/v1/messages path (see :mod:`gemini_messages` for that rationale).
"""
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,
@@ -39,7 +45,7 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
@property
def client(self) -> GeminiClient:
"""Get or create the Gemini API client."""
"""Get or create the Gemini API client (used for the models listing)."""
if self._client is None:
self._client = GeminiClient(api_key=self.api_key)
return self._client
@@ -65,240 +71,66 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
}
def transform_model_name(self, model_id: str) -> str:
return model_id.removeprefix("gemini/")
"""Reduce a routstr model id to the bare upstream Gemini name.
async def forward_request(
Gemini's OpenAI-compat surface expects the literal model id
(e.g. ``gemini-3.1-flash-lite-preview``) — no ``gemini/`` provider
prefix and no ``google/`` vendor sub-prefix. Take the last path
segment so we tolerate any of:
``gemini-2.0-flash``
``gemini/gemini-2.0-flash``
``gemini/google/gemini-3.1-flash-lite-preview``
"""
return model_id.rsplit("/", 1)[-1]
@property
def compat_base_url(self) -> str:
"""Gemini's OpenAI-compat surface, regardless of what's stored.
Stored ``base_url`` may be ``.../v1beta`` (the native Gemini API
root) or ``.../v1beta/openai`` (already pointed at the compat
surface). Normalize to the latter.
"""
return self.base_url.rstrip("/").removesuffix("/openai") + "/openai"
def get_request_base_url(
self, path: str, model_obj: "Model | None" = None
) -> str:
"""Route every proxied request to the OpenAI-compat surface.
Required because the stored ``base_url`` typically points at the
native Gemini API (``/v1beta``), but :meth:`forward_request`
forwards OpenAI-shaped paths (``/chat/completions``,
``/embeddings``, ``/models``) which only exist under the
``/openai`` subtree.
"""
return self.compat_base_url
async def _dispatch_anthropic_messages(
self,
request: Request,
path: str,
headers: dict,
request_body: bytes | None,
key: ApiKey,
max_cost_for_model: int,
session: AsyncSession,
model_obj: Model,
) -> Response | StreamingResponse:
# Remove provider prefix from model ID for Gemini API
if "/" in model_obj.id:
model_obj.id = model_obj.id.split("/", 1)[1]
model_obj: "Model",
*,
log_extra: dict[str, Any] | None = None,
) -> tuple[bool, Any, str | None]:
"""Dispatch /v1/messages via the gemini-specific httpx path.
if not path.startswith("chat/completions"):
return await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
if not request_body:
return await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
try:
openai_data = json.loads(request_body)
messages = openai_data.get("messages", [])
temperature = openai_data.get("temperature")
max_tokens = openai_data.get("max_tokens")
top_p = openai_data.get("top_p")
is_streaming = openai_data.get("stream", False)
logger.info(
"Processing Gemini request with client abstraction",
extra={
"model": model_obj.id,
"is_streaming": is_streaming,
"message_count": len(messages),
"key_hash": key.hashed_key[:8] + "...",
},
)
if is_streaming:
final_usage_data: dict | None = None
def usage_callback(usage_data: dict[str, Any]) -> None:
"""Callback to capture usage data during streaming"""
nonlocal final_usage_data
final_usage_data = usage_data
async def completion_callback(
model: str, usage_data: dict[str, Any] | None
) -> None:
"""Callback to handle payment when streaming completes"""
nonlocal final_usage_data
if usage_data:
final_usage_data = usage_data
payment_data = {
"model": model,
"usage": final_usage_data,
}
from ..auth import adjust_payment_for_tokens
from ..core.db import create_session
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if fresh_key:
try:
cost_data = await adjust_payment_for_tokens(
fresh_key,
payment_data,
new_session,
max_cost_for_model,
)
logger.info(
"Gemini streaming payment finalized",
extra={
"cost_data": cost_data,
"usage_data": final_usage_data,
"key_hash": key.hashed_key[:8] + "...",
},
)
except Exception as cost_error:
logger.error(
"Error finalizing Gemini streaming payment",
extra={
"error": str(cost_error),
"key_hash": key.hashed_key[:8] + "...",
},
)
response_generator = self.client.generate_content_stream(
model=model_obj.id,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
usage_callback=usage_callback,
completion_callback=completion_callback,
)
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
payment_finalized = False
async def finalize_payment() -> None:
nonlocal payment_finalized
if payment_finalized:
return
from ..auth import adjust_payment_for_tokens
from ..core.db import create_session
async with create_session() as new_session:
fresh_key = await new_session.get(
key.__class__, key.hashed_key
)
if fresh_key:
try:
await adjust_payment_for_tokens(
fresh_key,
{
"model": model_obj.id,
"usage": final_usage_data,
},
new_session,
max_cost_for_model,
)
payment_finalized = True
except Exception as cost_error:
logger.error(
"Error finalizing Gemini streaming payment in fallback",
extra={
"error": str(cost_error),
"key_hash": key.hashed_key[:8] + "...",
},
)
try:
async for chunk in response_generator:
sse_data = f"data: {json.dumps(chunk)}\n\n"
yield sse_data.encode()
except Exception as e:
logger.error(
"Error in Gemini streaming response",
extra={
"error": str(e),
"error_type": type(e).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
finally:
if not payment_finalized:
await finalize_payment()
return StreamingResponse(
stream_with_cost(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
else:
openai_format_response = await self.client.generate_content(
model=model_obj.id,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
)
from ..auth import adjust_payment_for_tokens
cost_data = await adjust_payment_for_tokens(
key, openai_format_response, session, max_cost_for_model
)
openai_format_response["cost"] = cost_data
logger.info(
"Gemini non-streaming payment completed",
extra={
"cost_data": cost_data,
"model": model_obj.id,
"key_hash": key.hashed_key[:8] + "...",
},
)
return Response(
content=json.dumps(openai_format_response),
media_type="application/json",
headers={"Cache-Control": "no-cache"},
)
except Exception as e:
logger.error(
"Error in Gemini forward_request",
extra={
"error": str(e),
"error_type": type(e).__name__,
"path": path,
"key_hash": key.hashed_key[:8] + "...",
},
)
return await super().forward_request(
request,
path,
headers,
request_body,
key,
max_cost_for_model,
session,
model_obj,
)
See :mod:`routstr.upstream.gemini_messages` for the full rationale
(thought-signature injection, why litellm + the openai SDK can't
carry the required ``extra_content`` field).
"""
return await gemini_messages.dispatch_gemini_messages(
request_body=request_body,
model_obj=model_obj,
base_url=self.compat_base_url,
api_key=self.api_key,
transform_model_name=self.transform_model_name,
log_extra=log_extra,
)
async def _fetch_provider_models(self) -> dict:
"""Fetch models from Gemini API."""
"""Fetch models from Gemini API via the OpenAI-compat client."""
try:
models_data = await self.client.list_models()

View File

@@ -0,0 +1,480 @@
"""Custom /v1/messages dispatcher for Gemini's OpenAI-compat endpoint.
Why this exists
---------------
Gemini 2.5 / 3 thinking models reject inbound ``functionCall`` parts that
lack a ``thought_signature`` field once any prior turn in the conversation
contains a function call. Anthropic-Messages clients (Claude Code etc.)
have no concept of thought signatures, so multi-turn tool conversations
fail with::
Function call is missing a thought_signature in functionCall parts.
Google's published escape hatch (https://ai.google.dev/gemini-api/docs/
thought-signatures, FAQ #1) is the dummy signature
``"skip_thought_signature_validator"`` placed at
``tool_calls[i].extra_content.google.thought_signature`` for every tool
call in the request. The hatch is documented specifically for
"transferring a trace from a different model that does not include thought
signatures" — exactly our case.
Why we can't reach the wire via litellm
---------------------------------------
``litellm.anthropic.messages.acreate`` flows through the openai SDK, whose
pydantic ``ChatCompletionMessageToolCall`` model silently drops unknown
fields like ``extra_content``. Litellm has no openai-compat translator
that emits ``extra_content.google.thought_signature``. So we bypass both
litellm and the openai SDK at the transport layer.
Pipeline
--------
1. Translate Anthropic body → OpenAI body via litellm's
``AnthropicAdapter`` (the same translator
``litellm.anthropic.messages.acreate`` uses internally).
2. Inject ``extra_content.google.thought_signature`` on every
``tool_calls[]`` entry.
3. Set ``reasoning_effort="none"`` to disable Gemini's thinking pass.
4. POST directly to ``{base_url}/chat/completions`` with ``stream=true``
via ``httpx`` (preserves arbitrary fields verbatim).
5. Translate OpenAI streaming chunks → Anthropic SSE events.
"""
from __future__ import annotations
import json
import uuid
from collections.abc import AsyncGenerator, AsyncIterator
from typing import Any, Callable
import httpx
from ..core import get_logger
from ..core.exceptions import UpstreamError
from ..payment.models import Model
from .messages_dispatch import (
ANTHROPIC_ONLY_FIELDS,
aggregate_anthropic_events_to_message,
)
logger = get_logger(__name__)
DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
# Mapping: OpenAI finish_reason → Anthropic stop_reason
_FINISH_TO_STOP = {
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
"function_call": "tool_use",
"content_filter": "refusal",
}
def inject_thought_signatures(messages: list[dict]) -> None:
"""Add ``extra_content.google.thought_signature`` to every tool_call.
Mutates ``messages`` in place. Idempotent: existing signatures are not
overwritten.
"""
for msg in messages:
tool_calls = msg.get("tool_calls")
if not isinstance(tool_calls, list):
continue
for tc in tool_calls:
if not isinstance(tc, dict):
continue
extra = tc.get("extra_content")
if not isinstance(extra, dict):
extra = tc["extra_content"] = {}
google_cfg = extra.get("google")
if not isinstance(google_cfg, dict):
google_cfg = extra["google"] = {}
google_cfg.setdefault("thought_signature", DUMMY_THOUGHT_SIGNATURE)
def _translate_anthropic_to_openai(body: dict, model: str) -> dict:
"""Use litellm's translator to convert an Anthropic /messages body to
OpenAI /chat/completions kwargs.
Imported lazily because the litellm internal path is heavy and not
needed for any other code path in routstr.
"""
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import ( # noqa: E501
AnthropicAdapter,
)
kwargs = {"model": model, **body}
translated = AnthropicAdapter().translate_completion_input_params(kwargs)
if translated is None:
raise UpstreamError(
"Failed to translate Anthropic body to OpenAI format",
status_code=500,
)
return dict(translated)
def _sse_event(event_type: str, payload: dict) -> bytes:
return f"event: {event_type}\ndata: {json.dumps(payload)}\n\n".encode()
async def _openai_chunks_to_anthropic_events(
line_iter: AsyncIterator[str], requested_model: str | None
) -> AsyncGenerator[bytes, None]:
"""Translate an OpenAI chat-completions SSE byte stream into the
Anthropic-Messages SSE event sequence.
Maintains per-chunk state across:
* one optional text content block (lazy-opened on first text delta)
* any number of tool_use blocks indexed by openai's ``delta.tool_calls[].index``
* final ``stop_reason`` / ``usage`` carried out via ``message_delta`` /
``message_stop``
"""
msg_id = f"msg_{uuid.uuid4().hex[:24]}"
started = False
text_block_idx: int | None = None
tool_block_indices: dict[int, int] = {}
next_block_idx = 0
final_finish_reason: str | None = None
final_usage: dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
def open_text_block() -> bytes:
nonlocal text_block_idx, next_block_idx
text_block_idx = next_block_idx
next_block_idx += 1
return _sse_event(
"content_block_start",
{
"type": "content_block_start",
"index": text_block_idx,
"content_block": {"type": "text", "text": ""},
},
)
def open_tool_block(delta_idx: int, tc: dict) -> bytes:
nonlocal next_block_idx
block_idx = next_block_idx
next_block_idx += 1
tool_block_indices[delta_idx] = block_idx
fn = tc.get("function") or {}
return _sse_event(
"content_block_start",
{
"type": "content_block_start",
"index": block_idx,
"content_block": {
"type": "tool_use",
"id": tc.get("id") or f"toolu_{uuid.uuid4().hex[:24]}",
"name": fn.get("name") or "",
"input": {},
},
},
)
def close_block(idx: int) -> bytes:
return _sse_event(
"content_block_stop",
{"type": "content_block_stop", "index": idx},
)
async for raw_line in line_iter:
line = raw_line.strip()
if not line:
continue
if not line.startswith("data:"):
continue
payload = line[5:].lstrip()
if not payload or payload == "[DONE]":
continue
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
if not isinstance(chunk, dict):
continue
if not started:
started = True
yield _sse_event(
"message_start",
{
"type": "message_start",
"message": {
"id": chunk.get("id") or msg_id,
"type": "message",
"role": "assistant",
"model": requested_model or chunk.get("model") or "",
"content": [],
"stop_reason": None,
"stop_sequence": None,
"usage": {
"input_tokens": 0,
"output_tokens": 0,
},
},
},
)
usage = chunk.get("usage")
if isinstance(usage, dict):
in_tok = usage.get("prompt_tokens") or usage.get("input_tokens") or 0
out_tok = usage.get("completion_tokens") or usage.get("output_tokens") or 0
if in_tok:
final_usage["input_tokens"] = int(in_tok)
if out_tok:
final_usage["output_tokens"] = int(out_tok)
choices = chunk.get("choices") or []
if not choices:
continue
choice = choices[0] if isinstance(choices[0], dict) else {}
delta = choice.get("delta") or {}
if not isinstance(delta, dict):
delta = {}
# Text delta
text = delta.get("content")
if isinstance(text, str) and text:
if text_block_idx is None:
yield open_text_block()
yield _sse_event(
"content_block_delta",
{
"type": "content_block_delta",
"index": text_block_idx,
"delta": {"type": "text_delta", "text": text},
},
)
# Tool call deltas
tool_calls_delta = delta.get("tool_calls")
if isinstance(tool_calls_delta, list):
for tc in tool_calls_delta:
if not isinstance(tc, dict):
continue
d_idx = int(tc.get("index") or 0)
if d_idx not in tool_block_indices:
yield open_tool_block(d_idx, tc)
block_idx = tool_block_indices[d_idx]
fn = tc.get("function") or {}
args = fn.get("arguments")
if isinstance(args, str) and args:
yield _sse_event(
"content_block_delta",
{
"type": "content_block_delta",
"index": block_idx,
"delta": {
"type": "input_json_delta",
"partial_json": args,
},
},
)
finish = choice.get("finish_reason")
if finish:
final_finish_reason = finish
# Close any open content blocks
if text_block_idx is not None:
yield close_block(text_block_idx)
for block_idx in tool_block_indices.values():
yield close_block(block_idx)
# message_delta with stop_reason and usage
stop_reason = _FINISH_TO_STOP.get(final_finish_reason or "", "end_turn")
yield _sse_event(
"message_delta",
{
"type": "message_delta",
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
"usage": final_usage,
},
)
yield _sse_event("message_stop", {"type": "message_stop"})
async def _post_and_stream(
base_url: str,
api_key: str,
payload: dict,
log_extra: dict[str, Any] | None,
) -> tuple[httpx.AsyncClient, httpx.Response]:
"""POST to upstream chat-completions and return (client, response) for
streaming. Caller is responsible for closing both."""
url = f"{base_url.rstrip('/')}/chat/completions"
client = httpx.AsyncClient(timeout=httpx.Timeout(120.0, read=120.0))
try:
request = client.build_request(
"POST",
url,
json=payload,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
)
response = await client.send(request, stream=True)
except Exception as exc:
await client.aclose()
logger.error(
"Gemini messages dispatch HTTP error",
extra={"error": str(exc), "url": url, **(log_extra or {})},
)
raise UpstreamError(
f"Failed to reach Gemini upstream: {exc}", status_code=502
) from exc
if response.status_code >= 400:
try:
body_bytes = await response.aread()
finally:
await response.aclose()
await client.aclose()
body_text = body_bytes.decode("utf-8", errors="replace")
logger.error(
"Gemini messages dispatch upstream error",
extra={
"status_code": response.status_code,
"body": body_text[:1000],
"url": url,
**(log_extra or {}),
},
)
raise UpstreamError(
f"Upstream error via gemini compat: {body_text}",
status_code=response.status_code,
)
return client, response
async def dispatch_gemini_messages(
*,
request_body: bytes | None,
model_obj: Model,
base_url: str,
api_key: str,
transform_model_name: Callable[[str], str],
log_extra: dict[str, Any] | None = None,
) -> tuple[bool, Any, str | None]:
"""Dispatch a /v1/messages request to Gemini's OpenAI-compat endpoint
with thought-signature injection.
Returns ``(client_stream, result, requested_model)`` where ``result``
is either an ``AsyncIterator[bytes]`` of Anthropic-format SSE events
(for streaming clients) or an Anthropic Message dict (after the caller
aggregates).
"""
if not request_body:
raise UpstreamError(
"Missing request body for /v1/messages", status_code=400
)
try:
body: dict = json.loads(request_body)
except json.JSONDecodeError as exc:
raise UpstreamError(
f"Invalid JSON in /v1/messages body: {exc}", status_code=400
) from exc
body.pop("model", None)
client_stream = bool(body.pop("stream", False))
# Anthropic-Messages-only fields that don't translate to OpenAI
# Chat Completions. litellm's translator passes through unknown
# top-level fields verbatim and Gemini's compat surface 400s on
# unknown names like ``context_management`` / ``output_config``.
dropped: dict[str, Any] = {}
for field in ANTHROPIC_ONLY_FIELDS:
if field in body:
dropped[field] = body.pop(field)
if dropped:
logger.debug(
"Dropped anthropic-only fields before gemini compat dispatch",
extra={"dropped_keys": sorted(dropped.keys())},
)
requested_model = (
(model_obj.forwarded_model_id or model_obj.id) if model_obj else None
)
upstream_model = transform_model_name(model_obj.id)
openai_kwargs = _translate_anthropic_to_openai(body, upstream_model)
messages = openai_kwargs.get("messages") or []
if isinstance(messages, list):
inject_thought_signatures(messages)
# Disable Gemini's thinking pass; the dummy signature already lifts
# validation, but skipping thinking entirely avoids degraded model
# output and keeps tool-calling deterministic.
openai_kwargs.setdefault("reasoning_effort", "none")
openai_kwargs["stream"] = True
openai_kwargs["model"] = upstream_model
# OpenAI-compat backends (including Gemini's) only emit a final
# ``usage`` chunk when the request opts in via this flag. Without it
# the cost-calculation pipeline can't read real token counts and
# falls back to MaxCostData billing.
existing_stream_options = openai_kwargs.get("stream_options")
merged_stream_options = (
dict(existing_stream_options)
if isinstance(existing_stream_options, dict)
else {}
)
merged_stream_options.setdefault("include_usage", True)
openai_kwargs["stream_options"] = merged_stream_options
logger.info(
"Dispatching /v1/messages via gemini compat (httpx)",
extra={
"model": upstream_model,
"client_stream": client_stream,
"messages_with_tool_calls": sum(
1 for m in messages if isinstance(m, dict) and m.get("tool_calls")
),
**(log_extra or {}),
},
)
http_client, response = await _post_and_stream(
base_url, api_key, openai_kwargs, log_extra
)
async def line_iter() -> AsyncGenerator[str, None]:
try:
async for line in response.aiter_lines():
yield line
finally:
await response.aclose()
await http_client.aclose()
anthropic_event_iter = _openai_chunks_to_anthropic_events(
line_iter(), requested_model
)
if not client_stream:
# Aggregate the Anthropic SSE byte stream into a single Message dict
# so the rest of the pipeline (cost calc, metadata injection,
# response building) can treat it identically to a non-streaming
# litellm response.
try:
aggregated = await aggregate_anthropic_events_to_message(
anthropic_event_iter
)
except Exception as exc:
logger.error(
"Failed to aggregate Gemini compat events into message",
extra={"error": str(exc), **(log_extra or {})},
)
raise UpstreamError(
f"Failed to aggregate upstream stream: {exc}",
status_code=502,
) from exc
return client_stream, aggregated, requested_model
return client_stream, anthropic_event_iter, requested_model

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

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:
@@ -188,13 +197,14 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
existing_providers = result.all()
if not existing_providers:
logger.info(
"No upstream providers found in database, seeding from settings"
)
await _seed_providers_from_settings(session, settings)
await session.commit()
result = await session.exec(select(UpstreamProviderRow))
existing_providers = result.all()
if existing_providers:
logger.info(
f"Seeded {len(existing_providers)} upstream providers from settings"
)
async def _init_single_provider(
provider_row: UpstreamProviderRow,
@@ -205,6 +215,9 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
provider = _instantiate_provider(provider_row)
if provider:
# Keep provider DB id on runtime instance so model mapping can
# bind DB overrides to the correct upstream.
setattr(provider, "db_id", provider_row.id)
await provider.refresh_models_cache()
logger.debug(
f"Initialized {provider_row.provider_type} provider",

View File

@@ -0,0 +1,162 @@
"""Map an upstream `base_url` to the correct litellm provider prefix.
Used by `BaseUpstreamProvider.get_litellm_provider_prefix` so that custom /
generic provider rows (which inherit the base class default) get routed to
the right litellm backend instead of falling back to `openai/`.
The table is compiled from litellm 1.74's
`litellm/litellm_core_utils/get_llm_provider_logic.py` (the
`openai_compatible_endpoints` table) plus the providers documented at
https://docs.litellm.ai/docs/providers. Substring match is used so that
URLs with paths, ports, regional subdomains, etc. all resolve correctly.
Order matters: more specific needles must appear before more generic ones
(e.g. ``openai.azure.com`` before ``api.openai.com``).
"""
from __future__ import annotations
import os
from urllib.parse import urlsplit
import litellm
DEFAULT_PREFIX = "openai/"
LITELLM_HOST_PREFIX_MAP: tuple[tuple[str, str], ...] = (
# Azure must win over api.openai.com because the host ends with
# `openai.azure.com` and we don't want it picked up as plain OpenAI.
("openai.azure.com", "azure/"),
# Google
("generativelanguage.googleapis.com", "gemini/"),
("aiplatform.googleapis.com", "vertex_ai/"),
# First-class providers with native litellm prefixes
("api.openai.com", "openai/"),
("api.anthropic.com", "anthropic/"),
("api.groq.com", "groq/"),
("api.fireworks.ai", "fireworks_ai/"),
("api.x.ai", "xai/"),
("api.perplexity.ai", "perplexity/"),
("openrouter.ai", "openrouter/"),
("api.deepseek.com", "deepseek/"),
("api.together.xyz", "together_ai/"),
("codestral.mistral.ai", "codestral/"),
("api.mistral.ai", "mistral/"),
("api.cohere.com", "cohere_chat/"),
("api.cohere.ai", "cohere_chat/"),
("api.deepinfra.com", "deepinfra/"),
("api.endpoints.anyscale.com", "anyscale/"),
("api.cerebras.ai", "cerebras/"),
("inference.baseten.co", "baseten/"),
("api.sambanova.ai", "sambanova/"),
("api.ai21.com", "ai21_chat/"),
("api.friendli.ai", "friendliai/"),
("api.galadriel.com", "galadriel/"),
("api.llama.com", "meta_llama/"),
("api.featherless.ai", "featherless_ai/"),
("inference.api.nscale.com", "nscale/"),
("dashscope-intl.aliyuncs.com", "dashscope/"),
("api.moonshot.ai", "moonshot/"),
("api.moonshot.cn", "moonshot/"),
("api.minimax.io", "minimax/"),
("api.minimaxi.com", "minimax/"),
("platform.publicai.co", "publicai/"),
("api.synthetic.new", "synthetic/"),
("api.stima.tech", "apertis/"),
("nano-gpt.com", "nano-gpt/"),
("api.poe.com", "poe/"),
("llm.chutes.ai", "chutes/"),
("api.v0.dev", "v0/"),
("api.lambda.ai", "lambda_ai/"),
("api.hyperbolic.xyz", "hyperbolic/"),
("ai-gateway.vercel.sh", "vercel_ai_gateway/"),
("api.inference.wandb.ai", "wandb/"),
("integrate.api.nvidia.com", "nvidia_nim/"),
("api.studio.nebius.com", "nebius/"),
("api.novita.ai", "novita/"),
("ark.cn-beijing.volces.com", "volcengine/"),
("api.voyageai.com", "voyage/"),
("api.jina.ai", "jina_ai/"),
("api.aimlapi.com", "aiml/"),
("api.snowflakecomputing.com", "snowflake/"),
("databricks.com", "databricks/"),
("huggingface.co", "huggingface/"),
)
# Substrings that indicate an Ollama deployment regardless of port/scheme.
OLLAMA_HOST_HINTS: tuple[str, ...] = (
"localhost:11434",
"127.0.0.1:11434",
"ollama",
)
def detect_litellm_prefix(
base_url: str | None, default: str = DEFAULT_PREFIX
) -> str:
"""Return the litellm provider prefix (`"<provider>/"`) for `base_url`.
Falls back to `default` when the host doesn't match any known provider.
The default is `openai/` because every unmatched OpenAI-compatible
server is, by definition, an OpenAI-compatible server.
"""
if not base_url:
return default
parsed = urlsplit(base_url)
host = parsed.netloc.lower() or base_url.lower()
for needle, prefix in LITELLM_HOST_PREFIX_MAP:
if needle in host:
return prefix
if any(hint in host for hint in OLLAMA_HOST_HINTS):
return "ollama_chat/"
return default
_configured = False
def configure_litellm() -> None:
"""Apply litellm global settings used by the messages-dispatch path.
Idempotent: safe to call from both app startup and module-level
initializers without side effects on the second invocation.
Settings applied:
* ``LITELLM_DEBUG=1`` enables litellm's verbose debug logger.
* Forces the Anthropic-messages adapter to call OpenAI Chat Completions
(POST ``/chat/completions``) instead of the Responses API (POST
``/responses``) for ``openai/``-prefixed providers. OpenAI-compatible
upstreams like Google's generativelanguage compat endpoint expose
``/chat/completions`` but not ``/responses``, which would 404. Set
``LITELLM_USE_RESPONSES_API_FOR_ANTHROPIC_MESSAGES=1`` to opt out.
* Silently drops Anthropic-Messages-only parameters (``thinking``,
``cache_control``, ``context_management``, ...) when translating to
providers that don't accept them, instead of raising
``UnsupportedParamsError``. Set ``LITELLM_STRICT_PARAMS=1`` to opt
out.
"""
global _configured
if _configured:
return
if os.getenv("LITELLM_DEBUG") == "1":
try:
litellm._turn_on_debug() # type: ignore[no-untyped-call]
except Exception:
pass
if os.getenv("LITELLM_USE_RESPONSES_API_FOR_ANTHROPIC_MESSAGES") != "1":
try:
litellm.use_chat_completions_url_for_anthropic_messages = True
except Exception:
pass
if os.getenv("LITELLM_STRICT_PARAMS") != "1":
litellm.drop_params = True
_configured = True

View File

@@ -0,0 +1,561 @@
"""Pure helpers for translating ``/v1/messages`` to upstream chat completions
via litellm.
This module owns the litellm/Anthropic-Messages translation layer:
* SSE parsing (``parse_sse_blocks``, ``events_from_chunk``)
* Payload coercion (``coerce_litellm_payload``)
* Stream aggregation (``aggregate_anthropic_events_to_message``) — drains
a streamed Anthropic event sequence into a single Message dict
* Per-event annotation for streaming (``annotate_event``,
``stream_annotated_events``) — handles the model-rewrite + token-tally
bookkeeping shared by the bearer-key and x-cashu streaming paths
* The dispatch entry point (``dispatch_anthropic_messages``)
* Refund math (``compute_refund``)
Nothing in here touches ``BaseUpstreamProvider``; the thin instance methods
on the provider class forward to these functions and only retain logic that
genuinely needs ``self`` (cost adjustment, metadata injection, refund
sending).
"""
from __future__ import annotations
import json
from collections.abc import AsyncGenerator, AsyncIterator
from typing import Any, Callable, NamedTuple, cast
import litellm
from ..core import get_logger
from ..core.exceptions import UpstreamError
from ..payment.models import Model
logger = get_logger(__name__)
# Anthropic-Messages-only fields that don't translate to OpenAI
# Chat Completions. ``litellm.drop_params`` only filters *known*
# unsupported params; these newer/extension fields get passed through
# verbatim and the upstream rejects them with a 400. Pop them here so the
# request reaches the upstream cleanly.
ANTHROPIC_ONLY_FIELDS: tuple[str, ...] = (
"thinking",
"cache_control",
"context_management",
"output_config",
"mcp_servers",
"service_tier",
"anthropic_version",
"anthropic_beta",
)
def coerce_litellm_payload(payload: object) -> dict:
"""Convert a litellm event into a plain dict.
Non-streaming responses come back as Anthropic-shaped pydantic models
or dicts. Streaming may yield raw bytes/str (SSE-encoded); those go
through ``events_from_chunk`` instead, not here.
"""
if isinstance(payload, dict):
return dict(payload)
if hasattr(payload, "model_dump"):
return cast(dict, payload.model_dump())
raise TypeError(f"Cannot coerce {type(payload).__name__} to dict")
def parse_sse_blocks(buffer: bytes) -> tuple[list[dict], bytes]:
"""Parse complete SSE event blocks out of a byte buffer.
Returns (events, remaining_buffer). Events are JSON objects parsed from
one or more ``data:`` lines per block. Comments, blank lines, and
``[DONE]`` sentinels are ignored. A trailing partial block is preserved
in remaining_buffer.
"""
events: list[dict] = []
while True:
sep = buffer.find(b"\n\n")
if sep < 0:
sep_rn = buffer.find(b"\r\n\r\n")
if sep_rn < 0:
break
block = buffer[:sep_rn]
buffer = buffer[sep_rn + 4 :]
else:
block = buffer[:sep]
buffer = buffer[sep + 2 :]
data_lines: list[str] = []
for raw_line in block.replace(b"\r\n", b"\n").split(b"\n"):
line = raw_line.decode("utf-8", errors="replace")
if line.startswith(":"):
continue
if line.startswith("data:"):
data_lines.append(line[5:].lstrip())
if not data_lines:
continue
payload = "\n".join(data_lines).strip()
if not payload or payload == "[DONE]":
continue
try:
obj = json.loads(payload)
except json.JSONDecodeError:
continue
if isinstance(obj, dict):
events.append(obj)
return events, buffer
def events_from_chunk(
chunk: object, sse_buffer: bytes
) -> tuple[list[dict], bytes]:
"""Normalize a stream chunk into one or more event dicts.
``litellm.anthropic.messages.acreate(stream=True)`` yields raw SSE
bytes in practice; some adapters yield strings or typed events. Handle
all three.
"""
if isinstance(chunk, (bytes, bytearray)):
sse_buffer += bytes(chunk)
events, sse_buffer = parse_sse_blocks(sse_buffer)
return events, sse_buffer
if isinstance(chunk, str):
sse_buffer += chunk.encode("utf-8")
events, sse_buffer = parse_sse_blocks(sse_buffer)
return events, sse_buffer
return [coerce_litellm_payload(chunk)], sse_buffer
async def aggregate_anthropic_events_to_message(
iterator: AsyncIterator[Any],
) -> dict:
"""Drain an Anthropic-Messages event iterator into a single Message dict.
Produces the shape ``litellm.anthropic.messages.acreate(stream=False)``
would have returned. Used to transparently stream from upstream while
still returning a non-streaming response to the client. Lets us
sidestep upstream quirks (e.g. Fireworks rejects ``max_tokens > 4096``
unless ``stream=true``) without leaking that into client-visible
behavior.
"""
sse_buffer = b""
message: dict = {}
blocks: list[dict] = []
partial_json: dict[int, str] = {}
final_stop_reason: str | None = None
final_stop_sequence: str | None = None
final_usage: dict[str, Any] = {}
final_model: str | None = None
async for chunk in iterator:
events, sse_buffer = events_from_chunk(chunk, sse_buffer)
for event in events:
etype = event.get("type")
if etype == "message_start":
raw = event.get("message") or {}
if isinstance(raw, dict):
message = dict(raw)
existing = message.get("content")
blocks = list(existing) if isinstance(existing, list) else []
usage = message.get("usage")
if isinstance(usage, dict):
final_usage = dict(usage)
if isinstance(message.get("model"), str):
final_model = message["model"]
elif etype == "content_block_start":
idx = int(event.get("index") or 0)
cb = event.get("content_block") or {}
cb_dict = dict(cb) if isinstance(cb, dict) else {}
while len(blocks) <= idx:
blocks.append({})
blocks[idx] = cb_dict
elif etype == "content_block_delta":
idx = int(event.get("index") or 0)
if idx >= len(blocks):
continue
delta = event.get("delta") or {}
if not isinstance(delta, dict):
continue
dtype = delta.get("type")
block = blocks[idx]
if dtype == "text_delta":
block["text"] = (block.get("text") or "") + (
delta.get("text") or ""
)
elif dtype == "input_json_delta":
partial_json[idx] = partial_json.get(idx, "") + (
delta.get("partial_json") or ""
)
elif dtype == "thinking_delta":
block["thinking"] = (block.get("thinking") or "") + (
delta.get("thinking") or ""
)
elif dtype == "signature_delta":
block["signature"] = (block.get("signature") or "") + (
delta.get("signature") or ""
)
elif etype == "content_block_stop":
idx = int(event.get("index") or 0)
raw_json = partial_json.pop(idx, None)
if raw_json is not None and idx < len(blocks):
try:
blocks[idx]["input"] = (
json.loads(raw_json) if raw_json else {}
)
except json.JSONDecodeError:
blocks[idx]["input"] = raw_json
elif etype == "message_delta":
delta = event.get("delta") or {}
if isinstance(delta, dict):
if "stop_reason" in delta:
final_stop_reason = delta.get("stop_reason")
if "stop_sequence" in delta:
final_stop_sequence = delta.get("stop_sequence")
usage = event.get("usage")
if isinstance(usage, dict):
final_usage.update(usage)
# message_stop: nothing to merge
if not message:
# Upstream returned no message_start; expose what we can so the
# client at least sees the assembled content.
message = {
"id": "",
"type": "message",
"role": "assistant",
"content": [],
}
message["content"] = blocks
if final_model and not message.get("model"):
message["model"] = final_model
if final_stop_reason is not None:
message["stop_reason"] = final_stop_reason
if final_stop_sequence is not None:
message["stop_sequence"] = final_stop_sequence
if final_usage:
existing_usage = message.get("usage")
merged = dict(existing_usage) if isinstance(existing_usage, dict) else {}
merged.update(final_usage)
message["usage"] = merged
return message
class AnnotatedEvent(NamedTuple):
"""One Anthropic SSE event after model-rewrite + token-tally bookkeeping.
``sse_bytes`` is the wire-ready ``event:`` / ``data:`` block; the two
streaming paths in ``BaseUpstreamProvider`` consume ``sse_bytes`` plus
the tallies and only differ in whether they stream live or buffer
first.
``cache_read_input_tokens`` and ``cache_creation_input_tokens`` are
surfaced separately so the cost path can price them against the cache
rate rather than fold them silently into the regular input bucket.
``total_cost`` / ``input_cost`` / ``output_cost`` carry any
USD cost figures the upstream attached to this event (from
``usage.cost``, ``usage.total_cost``, or ``usage.cost_details``) so the
streaming paths can re-embed them in the rebuilt ``usage`` dict and let
``calculate_cost`` convert directly USD→sats instead of falling back to
token-based math.
"""
event: dict
sse_bytes: bytes
input_tokens: int
output_tokens: int
cache_read_input_tokens: int
cache_creation_input_tokens: int
total_cost: float
input_cost: float
output_cost: float
model: str | None
def annotate_event(event: dict, requested_model: str | None) -> AnnotatedEvent:
"""Rewrite ``model`` fields and extract per-event token / model info.
Mutates ``event`` in place when ``requested_model`` is set so the
upstream's true model name doesn't leak to the client.
"""
if requested_model:
msg = event.get("message")
if isinstance(msg, dict) and "model" in msg:
msg["model"] = requested_model
if "model" in event:
event["model"] = requested_model
in_tokens = 0
out_tokens = 0
cache_read_tokens = 0
cache_create_tokens = 0
total_cost = 0.0
input_cost = 0.0
output_cost = 0.0
model: str | None = None
def _coerce_float(value: object) -> float:
if value is None or isinstance(value, bool):
return 0.0
if not isinstance(value, (int, float, str)):
return 0.0
try:
return max(0.0, float(value))
except (TypeError, ValueError):
return 0.0
def _accumulate(usage: dict) -> None:
nonlocal in_tokens, out_tokens, cache_read_tokens, cache_create_tokens
nonlocal total_cost, input_cost, output_cost
in_tokens += int(usage.get("input_tokens") or 0)
out_tokens += int(usage.get("output_tokens") or 0)
cache_read_tokens += int(usage.get("cache_read_input_tokens") or 0)
cache_create_tokens += int(usage.get("cache_creation_input_tokens") or 0)
total_cost += _coerce_float(usage.get("total_cost"))
input_cost += _coerce_float(usage.get("input_cost"))
output_cost += _coerce_float(usage.get("output_cost"))
msg_for_meta = event.get("message")
if isinstance(msg_for_meta, dict):
if msg_for_meta.get("model"):
model = str(msg_for_meta["model"])
usage = msg_for_meta.get("usage")
if isinstance(usage, dict):
_accumulate(usage)
if isinstance(event.get("usage"), dict):
_accumulate(event["usage"])
# Some upstreams (notably OpenRouter-style proxies) attach cost fields
# directly at the event root rather than inside ``usage``.
for field in ("total_cost", "cost"):
total_cost = max(total_cost, _coerce_float(event.get(field)))
input_cost = max(input_cost, _coerce_float(event.get("input_cost")))
output_cost = max(output_cost, _coerce_float(event.get("output_cost")))
root_cost_details = event.get("cost_details")
if isinstance(root_cost_details, dict):
total_cost = max(
total_cost,
_coerce_float(root_cost_details.get("total_cost")),
)
input_cost = max(
input_cost,
_coerce_float(root_cost_details.get("input_cost")),
)
output_cost = max(
output_cost,
_coerce_float(root_cost_details.get("output_cost")),
)
event_type = str(event.get("type") or "")
payload = json.dumps(event)
if event_type:
sse_bytes = f"event: {event_type}\ndata: {payload}\n\n".encode()
else:
sse_bytes = f"data: {payload}\n\n".encode()
return AnnotatedEvent(
event,
sse_bytes,
in_tokens,
out_tokens,
cache_read_tokens,
cache_create_tokens,
total_cost,
input_cost,
output_cost,
model,
)
async def stream_annotated_events(
iterator: AsyncIterator[Any],
requested_model: str | None,
) -> AsyncGenerator[AnnotatedEvent, None]:
"""Yield annotated, SSE-serialized events from a litellm stream.
Both streaming paths in ``BaseUpstreamProvider`` consume this; the only
divergence between them — yield-as-you-go vs buffer-then-replay — stays
in the caller.
"""
sse_buffer = b""
async for chunk in iterator:
events, sse_buffer = events_from_chunk(chunk, sse_buffer)
for event in events:
yield annotate_event(event, requested_model)
def embed_usd_costs(
usage: dict,
total_cost: float,
input_cost: float,
output_cost: float,
) -> None:
"""Mutate ``usage`` so ``calculate_cost`` will pick up the USD totals.
Mirrors the upstream shape: when any USD figure is present, attach
``cost`` (used by the simple-fallback branch in ``calculate_cost``) and
a ``cost_details`` block (used by the preferred branch — also gives the
input/output USD split when we have one).
"""
if total_cost <= 0 and input_cost <= 0 and output_cost <= 0:
return
cost_details: dict[str, float] = {}
effective_total = total_cost
if effective_total <= 0 and (input_cost > 0 or output_cost > 0):
effective_total = input_cost + output_cost
if effective_total > 0:
cost_details["total_cost"] = effective_total
usage["cost"] = effective_total
if input_cost > 0:
cost_details["input_cost"] = input_cost
if output_cost > 0:
cost_details["output_cost"] = output_cost
if cost_details:
usage["cost_details"] = cost_details
def compute_refund(amount: int, unit: str, cost_msats: int) -> int:
if unit == "msat":
return amount - cost_msats
if unit == "sat":
return amount - (cost_msats + 999) // 1000
raise ValueError(f"Invalid unit: {unit}")
async def dispatch_anthropic_messages(
*,
request_body: bytes | None,
model_obj: Model,
base_url: str,
api_key: str,
provider_prefix: str,
transform_model_name: Callable[[str], str],
log_extra: dict[str, Any] | None = None,
) -> tuple[bool, Any, str | None]:
"""Call ``litellm.anthropic.messages.acreate`` and return
``(client_stream, result, requested_model)``.
Shared by the bearer-key and x-cashu paths. Raises :class:`UpstreamError`
on bad input or upstream failure.
"""
if not request_body:
raise UpstreamError(
"Missing request body for /v1/messages", status_code=400
)
try:
body: dict = json.loads(request_body)
except json.JSONDecodeError as exc:
raise UpstreamError(
f"Invalid JSON in /v1/messages body: {exc}", status_code=400
) from exc
body.pop("model", None)
# `stream` here is what the **client** asked for. Upstream is always
# streamed (see `upstream_stream` below); when the client asked for a
# non-streaming response we drain and aggregate the events into a
# single Anthropic Message dict before returning. This sidesteps
# provider-specific non-streaming caps (e.g. Fireworks rejects
# `max_tokens > 4096` unless `stream=true`).
client_stream = bool(body.pop("stream", False))
upstream_stream = True
dropped: dict[str, Any] = {}
for field in ANTHROPIC_ONLY_FIELDS:
if field in body:
dropped[field] = body.pop(field)
if dropped:
logger.debug(
"Dropped anthropic-only fields before litellm dispatch",
extra={"dropped_keys": sorted(dropped.keys())},
)
# Convention: `model.id` is the canonical upstream model name;
# `forwarded_model_id` is the public alias the internal API exposes
# and echoes back to the client.
requested_model = (
(model_obj.forwarded_model_id or model_obj.id) if model_obj else None
)
upstream_model = transform_model_name(model_obj.id)
litellm_model = f"{provider_prefix}{upstream_model}"
kwargs: dict = {
"model": litellm_model,
"api_base": base_url,
"api_key": api_key,
"stream": upstream_stream,
**body,
}
logger.info(
"Dispatching /v1/messages via litellm",
extra={
"model": litellm_model,
"resolved_provider": provider_prefix.rstrip("/"),
"client_stream": client_stream,
"upstream_stream": upstream_stream,
**(log_extra or {}),
},
)
try:
result = await litellm.anthropic.messages.acreate(**kwargs)
except Exception as exc:
exc_message = getattr(exc, "message", None) or str(exc) or repr(exc)
exc_status = getattr(exc, "status_code", None)
exc_response = getattr(exc, "response", None)
response_text = None
if exc_response is not None:
try:
response_text = getattr(exc_response, "text", str(exc_response))
except Exception:
response_text = "<unreadable>"
logger.error(
"litellm dispatch failed",
extra={
"error": exc_message,
"error_type": type(exc).__name__,
"status_code": exc_status,
"llm_provider": getattr(exc, "llm_provider", None),
"body": getattr(exc, "body", None),
"response_text": response_text,
"model": litellm_model,
"api_base": base_url,
},
)
raise UpstreamError(
f"Upstream error via litellm: {exc_message}",
status_code=exc_status if isinstance(exc_status, int) else 502,
) from exc
if not client_stream and hasattr(result, "__aiter__"):
# Client asked for a non-streaming response but we always stream
# from upstream — drain the events into a single Anthropic Message
# dict so the rest of the pipeline can treat it as if upstream had
# returned non-streaming. Some litellm adapters return a
# non-streaming dict even when ``stream=True``; in that case,
# leave the result as-is.
try:
aggregated: Any = await aggregate_anthropic_events_to_message(
cast(AsyncIterator[Any], result)
)
except Exception as exc:
logger.error(
"Failed to aggregate streamed events into message",
extra={
"error": str(exc),
"error_type": type(exc).__name__,
"model": litellm_model,
},
)
raise UpstreamError(
f"Failed to aggregate upstream stream: {exc}",
status_code=502,
) from exc
return client_stream, aggregated, requested_model
return client_stream, result, requested_model

View File

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

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