Compare commits

..
Author SHA1 Message Date
9qeklajc 3c13be20cb no key creation when refund 2026-05-13 22:34:24 +02:00
9qeklajcandGitHub 9e33d3b100 Merge pull request #503 from Routstr/fix-docker-build
add missing path
2026-05-10 11:21:04 +02:00
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 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
9qeklajcandGitHub 91e5198a94 Merge pull request #498 from Routstr/fix-docker-build
fix-docker-build
2026-05-10 08:43:02 +02:00
9qeklajc a4d048f2b5 fix-docker-build 2026-05-10 08:31:05 +02:00
9qeklajc 164ed775c8 support /message/count_tokens endpoint 2026-05-09 14:43:03 +02:00
10 changed files with 427 additions and 44 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ FROM node:23-alpine AS ui-builder
WORKDIR /app/ui
# Install pnpm
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
RUN corepack enable pnpm && corepack prepare pnpm@10.15.0 --activate
# Copy UI source
COPY ui/package.json ui/pnpm-lock.yaml* ./
+22 -5
View File
@@ -211,8 +211,20 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None:
_refund_cache[key] = (expiry, value)
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
session: AsyncSession, hashed_key: str, balance: int, reserved_balance: int, mint_url: str
) -> None:
"""Restore balance after a failed refund mint attempt."""
restore_stmt = (
@@ -227,7 +239,7 @@ async def _restore_balance(
await session.commit()
logger.info(
"refund_wallet_endpoint: balance restored after mint failure",
extra={"hashed_key": hashed_key, "restored_balance": balance},
extra={"hashed_key": hashed_key, "restored_balance": balance, "mint_url": mint_url},
)
@@ -282,7 +294,12 @@ async def refund_wallet_endpoint(
)
bearer_value: str = authorization[7:]
key: ApiKey = await validate_bearer_key(bearer_value, session)
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 key.total_balance <= 0:
if cached := await _refund_cache_get(bearer_value):
@@ -373,11 +390,11 @@ async def refund_wallet_endpoint(
except HTTPException:
# Minting failed — restore the debited balance
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved)
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved, key.refund_mint_url or "")
raise
except Exception as e:
# Minting failed — restore the debited balance
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved)
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved, key.refund_mint_url or "")
error_msg = str(e)
if (
"mint" in error_msg.lower()
+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__)
@@ -2196,6 +2197,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")
@@ -3394,6 +3401,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",
)
+8 -8
View File
@@ -372,17 +372,17 @@ async def test_refund_rejects_concurrent_topup_on_same_key(
topup_amount_sat = 500
topup_token = await testmint_wallet.mint_tokens(topup_amount_sat)
validate_called = asyncio.Event()
key_looked_up = asyncio.Event()
allow_refund_to_continue = asyncio.Event()
original_validate_bearer_key = balance_module.validate_bearer_key
original_lookup = balance_module._lookup_key_no_create
delayed_once = False
async def delayed_validate_bearer_key(*args: Any, **kwargs: Any) -> ApiKey:
async def delayed_lookup_key_no_create(*args: Any, **kwargs: Any) -> ApiKey | None:
nonlocal delayed_once
key = await original_validate_bearer_key(*args, **kwargs)
if not delayed_once:
key = await original_lookup(*args, **kwargs)
if not delayed_once and key is not None:
delayed_once = True
validate_called.set()
key_looked_up.set()
await allow_refund_to_continue.wait()
return key
@@ -390,7 +390,7 @@ async def test_refund_rejects_concurrent_topup_on_same_key(
return await authenticated_client.post("/v1/wallet/refund")
async def issue_topup() -> Any:
await validate_called.wait()
await key_looked_up.wait()
try:
return await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": topup_token}
@@ -399,7 +399,7 @@ async def test_refund_rejects_concurrent_topup_on_same_key(
allow_refund_to_continue.set()
with patch(
"routstr.balance.validate_bearer_key", new=delayed_validate_bearer_key
"routstr.balance._lookup_key_no_create", new=delayed_lookup_key_no_create
):
refund_response, topup_response = await asyncio.gather(
issue_refund(), issue_topup()
+51 -5
View File
@@ -168,12 +168,12 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No
refund_token = "cashuArefund_apikey_token"
session = MagicMock()
session.get = AsyncMock(return_value=key)
session.exec = AsyncMock(return_value=_update_result(1))
session.add = MagicMock()
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()) as mock_store,
@@ -203,12 +203,12 @@ async def test_apikey_refund_logs_token() -> None:
refund_token = "cashuAlogged_token"
session = MagicMock()
session.get = AsyncMock(return_value=key)
session.exec = AsyncMock(return_value=_update_result(1))
session.add = MagicMock()
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
@@ -232,12 +232,12 @@ async def test_apikey_refund_log_includes_path() -> None:
refund_token = "cashuApath_token"
session = MagicMock()
session.get = AsyncMock(return_value=key)
session.exec = AsyncMock(return_value=_update_result(1))
session.add = MagicMock()
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
@@ -269,6 +269,7 @@ async def test_apikey_refund_rejects_on_concurrent_balance_change() -> None:
key = _make_api_key(balance=5000, refund_currency="sat")
session = MagicMock()
session.get = AsyncMock(return_value=key)
# Debit returns rowcount=0 → balance changed concurrently
session.exec = AsyncMock(return_value=_update_result(0))
session.commit = AsyncMock()
@@ -276,7 +277,6 @@ async def test_apikey_refund_rejects_on_concurrent_balance_change() -> None:
mock_send_token = AsyncMock(return_value="cashuAshould_not_be_minted")
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", mock_send_token),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
@@ -333,11 +333,11 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
# First exec call = debit (succeeds), second = restore
session = MagicMock()
session.get = AsyncMock(return_value=key)
session.exec = AsyncMock(side_effect=[_update_result(1), _update_result(1)])
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(side_effect=Exception("mint down"))),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
@@ -355,3 +355,49 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
assert exc_info.value.status_code == 503
# Verify two exec calls: debit + restore
assert session.exec.await_count == 2
# ---------------------------------------------------------------------------
# no-create guarantee: fresh Cashu/unknown sk- tokens must not create API keys
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_refund_fresh_cashu_bearer_returns_401() -> None:
"""Fresh Cashu token not in DB must get 401, never create a new ApiKey."""
from fastapi import HTTPException
session = MagicMock()
session.get = AsyncMock(return_value=None)
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer cashuAfresh_never_deposited_token",
x_cashu=None,
session=session,
)
assert exc_info.value.status_code == 401
session.get.assert_awaited_once()
# No add/commit → no key was persisted
session.add.assert_not_called()
session.commit.assert_not_called()
@pytest.mark.asyncio
async def test_refund_unknown_sk_bearer_returns_401() -> None:
"""Unknown sk- key not in DB must get 401."""
from fastapi import HTTPException
session = MagicMock()
session.get = AsyncMock(return_value=None)
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer sk-unknownhash",
x_cashu=None,
session=session,
)
assert exc_info.value.status_code == 401
session.get.assert_awaited_once()
+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
# ---------------------------------------------------------------------------
+3
View File
@@ -6,6 +6,9 @@ const nextConfig: NextConfig = {
images: {
unoptimized: true,
},
turbopack: {
root: __dirname,
},
};
export default nextConfig;
+7
View File
@@ -1,5 +1,6 @@
{
"name": "routstr-service",
"packageManager": "pnpm@10.15.0",
"version": "0.1.0",
"private": true,
"scripts": {
@@ -88,5 +89,11 @@
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.2.0",
"typescript": "^5.9.3"
},
"pnpm": {
"onlyBuiltDependencies": [
"sharp",
"unrs-resolver"
]
}
}