Compare commits

...
Author SHA1 Message Date
9qeklajc 00b813f6a8 clean up 2026-05-10 08:43:47 +02:00
9qeklajc 164ed775c8 support /message/count_tokens endpoint 2026-05-09 14:43:03 +02:00
9qeklajcandGitHub 0d1cb66855 Merge pull request #494 from Routstr/payment-configuration
configure payment
2026-05-09 12:07:49 +02:00
9qeklajc 59773e972b configure payment 2026-05-09 00:00:18 +02:00
10 changed files with 553 additions and 36 deletions
+11 -3
View File
@@ -78,9 +78,15 @@ Which mints to accept payments from:
Automatic profit withdrawal:
| Setting | Description |
| --------------------- | ------------------------------- |
| **Lightning Address** | Your LN address for withdrawals |
| 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
@@ -126,6 +132,8 @@ Use environment variables for:
| `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) |
+8 -2
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, RootModel
from pydantic.v1 import ValidationError as PydanticValidationError
from sqlmodel import select
from ..payment.models import _row_to_model, list_models
@@ -160,8 +161,13 @@ async def update_settings(request: Request, update: SettingsUpdate) -> dict:
if field in settings_data:
del settings_data[field]
async with create_session() as session:
new_settings = await SettingsService.update(settings_data, session)
try:
async with create_session() as session:
new_settings = await SettingsService.update(settings_data, session)
except PydanticValidationError as e:
# Surface validation issues (e.g. non-positive payout amounts)
# as a clean 400 instead of a 500.
raise HTTPException(status_code=400, detail=e.errors()) from e
data = new_settings.dict()
if "upstream_api_key" in data:
data["upstream_api_key"] = "[REDACTED]" if data["upstream_api_key"] else ""
+9
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
+13
View File
@@ -42,6 +42,7 @@ from ..payment.models import (
from ..payment.price import sats_usd_price
from ..wallet import recieve_token, send_token
from . import messages_dispatch
from .count_tokens import count_tokens_locally
from .litellm_routing import detect_litellm_prefix
logger = get_logger(__name__)
@@ -2159,6 +2160,12 @@ class BaseUpstreamProvider:
"""
path = self.normalize_request_path(path, model_obj)
if (
path.endswith("messages/count_tokens")
and not self.supports_anthropic_messages
):
return count_tokens_locally(request_body, model_obj)
if (
path.endswith("messages")
and not path.endswith("count_tokens")
@@ -3357,6 +3364,12 @@ class BaseUpstreamProvider:
request_body = await request.body()
if (
path.endswith("messages/count_tokens")
and not self.supports_anthropic_messages
):
return count_tokens_locally(request_body, model_obj)
if (
path.endswith("messages")
and not path.endswith("count_tokens")
+108
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",
)
+10 -5
View File
@@ -502,11 +502,11 @@ async def fetch_all_balances(
async def periodic_payout() -> None:
if not settings.receive_ln_address:
logger.warning("RECEIVE_LN_ADDRESS is not set, periodic payout disabled")
return
while True:
await asyncio.sleep(60 * 15)
await asyncio.sleep(settings.payout_interval_seconds)
print(settings.payout_interval_seconds)
if not settings.receive_ln_address:
continue
try:
async with db.create_session() as session:
for mint_url in settings.cashu_mints:
@@ -524,7 +524,12 @@ async def periodic_payout() -> None:
user_balance = user_balance // 1000
proofs_balance = sum(proof.amount for proof in proofs)
available_balance = proofs_balance - user_balance
min_amount = 210 if unit == "sat" else 210000
# Threshold is configured in sats; convert for msat wallets.
min_amount = (
settings.min_payout_sat
if unit == "sat"
else settings.min_payout_sat * 1000
)
if available_balance > min_amount:
amount_received = await raw_send_to_lnurl(
wallet,
+177
View File
@@ -0,0 +1,177 @@
"""Unit tests for the local count_tokens shim.
The shim runs whenever an upstream that does not support Anthropic's
``/v1/messages`` endpoint is asked for a token count. It must always
return a 200 JSON ``{"input_tokens": N}`` response and must never raise.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import patch
from routstr.payment.models import Architecture, Model, Pricing
from routstr.upstream import count_tokens as count_tokens_module
from routstr.upstream.count_tokens import count_tokens_locally
def _make_model(model_id: str = "anthropic/claude-3-5-sonnet") -> Model:
pricing = Pricing(prompt=0.000003, completion=0.000015)
architecture = Architecture(
modality="text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="cl100k_base",
instruct_type=None,
)
return Model(
id=model_id,
name=model_id,
created=0,
description="",
context_length=200_000,
architecture=architecture,
pricing=pricing,
)
def _body(payload: dict[str, Any]) -> bytes:
return json.dumps(payload).encode()
def _read_payload(response: Any) -> dict[str, Any]:
body = response.body if isinstance(response.body, bytes) else bytes(response.body)
return json.loads(body.decode())
def test_returns_input_tokens_for_simple_messages() -> None:
model = _make_model()
request_body = _body(
{
"model": model.id,
"messages": [{"role": "user", "content": "hello world"}],
}
)
response = count_tokens_locally(request_body, model)
assert response.status_code == 200
assert response.media_type == "application/json"
payload = _read_payload(response)
assert "input_tokens" in payload
assert isinstance(payload["input_tokens"], int)
assert payload["input_tokens"] >= 0
def test_falls_back_to_estimator_when_litellm_raises() -> None:
model = _make_model()
request_body = _body(
{
"model": model.id,
"messages": [{"role": "user", "content": "this is a longer message"}],
}
)
with patch.object(
count_tokens_module,
"_count_with_litellm",
side_effect=RuntimeError("boom"),
):
response = count_tokens_locally(request_body, model)
assert response.status_code == 200
payload = _read_payload(response)
assert payload["input_tokens"] >= 1
def test_handles_missing_model_object() -> None:
request_body = _body(
{
"model": "anthropic/claude-3-5-sonnet",
"messages": [{"role": "user", "content": "hi"}],
}
)
response = count_tokens_locally(request_body, None)
assert response.status_code == 200
payload = _read_payload(response)
assert payload["input_tokens"] >= 0
def test_handles_empty_request_body() -> None:
response = count_tokens_locally(b"", _make_model())
assert response.status_code == 200
payload = _read_payload(response)
assert payload["input_tokens"] >= 0
def test_handles_malformed_json() -> None:
response = count_tokens_locally(b"not-json", _make_model())
assert response.status_code == 200
payload = _read_payload(response)
assert payload["input_tokens"] >= 0
def test_includes_system_prompt_in_count() -> None:
model = _make_model()
short = _body(
{
"model": model.id,
"messages": [{"role": "user", "content": "hi"}],
}
)
with_system = _body(
{
"model": model.id,
"system": "You are a helpful assistant with a long preamble " * 10,
"messages": [{"role": "user", "content": "hi"}],
}
)
short_count = _read_payload(count_tokens_locally(short, model))["input_tokens"]
long_count = _read_payload(count_tokens_locally(with_system, model))["input_tokens"]
assert long_count > short_count
def test_supports_anthropic_system_block_list() -> None:
model = _make_model()
request_body = _body(
{
"model": model.id,
"system": [{"type": "text", "text": "be terse" * 50}],
"messages": [{"role": "user", "content": "ok"}],
}
)
response = count_tokens_locally(request_body, model)
payload = _read_payload(response)
assert payload["input_tokens"] > 0
def test_uses_forwarded_model_id_when_present() -> None:
model = _make_model("anthropic/claude-3-5-sonnet")
model.forwarded_model_id = "claude-3-5-sonnet-20241022"
request_body = _body(
{
"model": "ignored",
"messages": [{"role": "user", "content": "hi"}],
}
)
captured: dict[str, Any] = {}
def _capture(model_name: str, body: dict[str, Any]) -> int:
captured["model"] = model_name
return 7
with patch.object(count_tokens_module, "_count_with_litellm", side_effect=_capture):
response = count_tokens_locally(request_body, model)
assert captured["model"] == "claude-3-5-sonnet-20241022"
assert _read_payload(response)["input_tokens"] == 7
+37 -25
View File
@@ -908,7 +908,7 @@ async def test_forward_request_skips_litellm_when_provider_supports_messages() -
@pytest.mark.asyncio
async def test_forward_request_skips_litellm_for_count_tokens() -> None:
async def test_forward_request_handles_count_tokens_locally() -> None:
provider = _make_provider()
key = _make_key()
model = _make_model()
@@ -923,19 +923,25 @@ async def test_forward_request_skips_litellm_for_count_tokens() -> None:
with patch.object(
provider,
"prepare_request_body",
side_effect=RuntimeError("stop here"),
side_effect=AssertionError("upstream should not be called"),
):
with pytest.raises(RuntimeError, match="stop here"):
await provider.forward_request(
request=request,
path="messages/count_tokens",
headers={},
request_body=_anthropic_request_body(),
key=key,
max_cost_for_model=10_000,
session=session,
model_obj=model,
)
response = await provider.forward_request(
request=request,
path="messages/count_tokens",
headers={},
request_body=_anthropic_request_body(),
key=key,
max_cost_for_model=10_000,
session=session,
model_obj=model,
)
assert response.status_code == 200
body = response.body if isinstance(response.body, bytes) else bytes(response.body)
payload = json.loads(body.decode())
assert "input_tokens" in payload
assert isinstance(payload["input_tokens"], int)
assert payload["input_tokens"] >= 0
# ---------------------------------------------------------------------------
@@ -1010,7 +1016,7 @@ async def test_forward_x_cashu_request_skips_litellm_when_native_messages() -> N
@pytest.mark.asyncio
async def test_forward_x_cashu_request_skips_litellm_for_count_tokens() -> None:
async def test_forward_x_cashu_request_handles_count_tokens_locally() -> None:
provider = _make_provider()
model = _make_model()
request = _make_request()
@@ -1024,18 +1030,24 @@ async def test_forward_x_cashu_request_skips_litellm_for_count_tokens() -> None:
with patch.object(
provider,
"prepare_request_body",
side_effect=RuntimeError("stop here"),
side_effect=AssertionError("upstream should not be called"),
):
with pytest.raises(RuntimeError, match="stop here"):
await provider.forward_x_cashu_request(
request=request,
path="v1/messages/count_tokens",
headers={},
amount=5_000,
unit="sat",
max_cost_for_model=10_000,
model_obj=model,
)
response = await provider.forward_x_cashu_request(
request=request,
path="v1/messages/count_tokens",
headers={},
amount=5_000,
unit="sat",
max_cost_for_model=10_000,
model_obj=model,
mint="https://mint",
payment_token_hash="h",
)
assert response.status_code == 200
body = response.body if isinstance(response.body, bytes) else bytes(response.body)
payload = json.loads(body.decode())
assert "input_tokens" in payload
# ---------------------------------------------------------------------------
+50 -1
View File
@@ -1,11 +1,12 @@
import os
import pytest
from pydantic.v1 import ValidationError
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import text
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.settings import SettingsService
from routstr.core.settings import Settings, SettingsService
@pytest.mark.asyncio
@@ -46,6 +47,54 @@ async def test_settings_db_precedence_over_env() -> None:
assert again.enable_analytics_sharing is False
def test_payout_settings_have_sensible_defaults() -> None:
s = Settings()
assert s.min_payout_sat == 210
assert s.payout_interval_seconds == 900
@pytest.mark.parametrize(
"field,bad_value",
[
("min_payout_sat", 0),
("min_payout_sat", -1),
("payout_interval_seconds", 0),
("payout_interval_seconds", -10),
],
)
def test_payout_settings_reject_invalid_values(field: str, bad_value: int) -> None:
kwargs: dict[str, object] = {field: bad_value}
with pytest.raises(ValidationError):
Settings(**kwargs) # type: ignore[arg-type]
def test_payout_settings_accept_custom_positive_values() -> None:
s = Settings(min_payout_sat=500, payout_interval_seconds=60)
assert s.min_payout_sat == 500
assert s.payout_interval_seconds == 60
@pytest.mark.asyncio
async def test_payout_settings_persist_via_settings_service() -> None:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with AsyncSession(engine, expire_on_commit=False) as session:
await SettingsService.initialize(session)
updated = await SettingsService.update(
{"min_payout_sat": 1000, "payout_interval_seconds": 300}, session
)
assert updated.min_payout_sat == 1000
assert updated.payout_interval_seconds == 300
@pytest.mark.asyncio
async def test_payout_settings_update_rejects_invalid() -> None:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with AsyncSession(engine, expire_on_commit=False) as session:
await SettingsService.initialize(session)
with pytest.raises(ValidationError):
await SettingsService.update({"min_payout_sat": 0}, session)
@pytest.mark.asyncio
async def test_settings_initialize_discards_unknown_keys() -> None:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+130
View File
@@ -32,9 +32,18 @@ interface SettingsData {
onion_url?: string;
cashu_mints?: string[];
relays?: string[];
receive_ln_address?: string;
min_payout_sat?: number;
payout_interval_seconds?: number;
[key: string]: unknown;
}
const PAYOUT_KEYS = [
'receive_ln_address',
'min_payout_sat',
'payout_interval_seconds',
] as const;
const HANDLED_KEYS = [
'name',
'description',
@@ -48,6 +57,7 @@ const HANDLED_KEYS = [
'admin_password',
'id',
'updated_at',
...PAYOUT_KEYS,
];
const IGNORED_KEYS = [
@@ -369,6 +379,7 @@ export function AdminSettings() {
const cashuMintsChanged = hasFieldChanged('cashu_mints');
const relaysChanged = hasFieldChanged('relays');
const analyticsSharingChanged = hasFieldChanged('enable_analytics_sharing');
const payoutChanged = PAYOUT_KEYS.some(hasFieldChanged);
const advancedKeys = Object.keys(settings).filter(
(key) => !HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key)
);
@@ -401,8 +412,44 @@ export function AdminSettings() {
setNewRelay('');
};
const resetAnalyticsSharing = () => resetFields(['enable_analytics_sharing']);
const resetPayout = () => resetFields([...PAYOUT_KEYS]);
const resetAdvanced = () => resetFields(advancedKeys);
const payoutFields: ReadonlyArray<{
key: (typeof PAYOUT_KEYS)[number];
label: string;
placeholder: string;
type: 'text' | 'number';
helpText: string;
min?: number;
}> = [
{
key: 'receive_ln_address',
label: 'Lightning Receive Address',
placeholder: 'you@walletofsatoshi.com or LNURL',
type: 'text',
helpText:
'Lightning address (or LNURL) profits are paid out to. Leave empty to disable periodic payouts.',
},
{
key: 'min_payout_sat',
label: 'Minimum Payout (sat)',
placeholder: '210',
type: 'number',
min: 1,
helpText:
'Wallet payouts only fire when at least this many satoshis are available. Must be > 0.',
},
{
key: 'payout_interval_seconds',
label: 'Payout Interval (seconds)',
placeholder: '900',
type: 'number',
min: 1,
helpText: 'How often the payout loop wakes up to check balances.',
},
];
if (loading) {
return (
<div className='space-y-4'>
@@ -620,6 +667,89 @@ export function AdminSettings() {
) : null}
</Card>
{/* Lightning Payout Settings */}
<Card>
<CardHeader>
<CardTitle>Lightning Payout Settings</CardTitle>
<CardDescription>
Tune how node profit is paid out over Lightning. Amounts must be
positive and above your wallet&apos;s minimum-invoice constraints.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{payoutFields.map((field) => {
const value = settings[field.key];
if (field.type === 'number') {
return (
<div key={field.key} className='space-y-2'>
<Label htmlFor={field.key}>{field.label}</Label>
<Input
id={field.key}
type='number'
min={field.min}
value={
typeof value === 'number'
? value
: value === undefined || value === null
? ''
: Number(value)
}
placeholder={field.placeholder}
onChange={(e) => {
const raw = e.target.value;
if (raw === '') {
handleInputChange(field.key, undefined);
} else {
const parsed = Number(raw);
handleInputChange(
field.key,
Number.isFinite(parsed) ? parsed : undefined
);
}
}}
/>
<p className='text-muted-foreground text-xs'>
{field.helpText}
</p>
</div>
);
}
return (
<div key={field.key} className='space-y-2'>
<Label htmlFor={field.key}>{field.label}</Label>
<Input
id={field.key}
value={(value as string) || ''}
placeholder={field.placeholder}
onChange={(e) =>
handleInputChange(field.key, e.target.value)
}
/>
<p className='text-muted-foreground text-xs'>
{field.helpText}
</p>
</div>
);
})}
</CardContent>
{payoutChanged ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={resetPayout}
disabled={loading || saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
{/* Relays */}
<Card>
<CardHeader>