mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-31 15:56:14 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
379c319e0d | ||
|
|
9633483fa4 | ||
|
|
9aa3408905 | ||
|
|
31ff1fd90c | ||
|
|
b9622689ea | ||
|
|
f9e8a3250d | ||
|
|
ade2d19be6 | ||
|
|
c4d0a1afba | ||
|
|
81817355cc | ||
|
|
d345d3b53f | ||
|
|
9fe13e9733 | ||
|
|
f2ef63da62 | ||
|
|
f80d59182f |
@@ -2,6 +2,7 @@ FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libsecp256k1-dev \
|
||||
|
||||
@@ -20,6 +20,7 @@ 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 \
|
||||
|
||||
@@ -155,7 +155,7 @@ 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -341,6 +341,11 @@ async def validate_bearer_key(
|
||||
"Token redemption returned zero or negative amount",
|
||||
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
|
||||
)
|
||||
# Defense-in-depth: credit_balance now refuses to commit on a
|
||||
# zero/negative redemption, but if a row was nonetheless
|
||||
# persisted, drop it so we never leave an orphan zero-balance key.
|
||||
await session.delete(new_key)
|
||||
await session.commit()
|
||||
raise Exception("Token redemption failed")
|
||||
|
||||
await session.refresh(new_key)
|
||||
|
||||
@@ -609,26 +609,6 @@ async def reset_child_key_spent(
|
||||
return {"success": True, "message": "Child key balance reset successfully."}
|
||||
|
||||
|
||||
@router.get("/cashu-refund/{payment_token_hash}")
|
||||
async def get_cashu_refund(
|
||||
payment_token_hash: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Retrieve a stored Cashu refund token by the hash of the original payment token."""
|
||||
result = await session.get(CashuTransaction, payment_token_hash)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Refund not found")
|
||||
if result.swept:
|
||||
raise HTTPException(status_code=410, detail="Refund has been swept")
|
||||
result.collected = True
|
||||
session.add(result)
|
||||
await session.commit()
|
||||
return {
|
||||
"refund_token": result.token,
|
||||
"amount": result.amount,
|
||||
"unit": result.unit,
|
||||
}
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/{path:path}",
|
||||
|
||||
+109
-88
@@ -1,9 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import traceback
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator, AsyncIterator
|
||||
@@ -718,55 +716,101 @@ class BaseUpstreamProvider:
|
||||
pass
|
||||
|
||||
try:
|
||||
sse_buffer = b""
|
||||
async for chunk in response.aiter_bytes():
|
||||
# Split chunk into SSE events
|
||||
parts = re.split(b"data: ", chunk)
|
||||
for i, part in enumerate(parts):
|
||||
if not part:
|
||||
sse_buffer += chunk
|
||||
logger.debug(
|
||||
"[chat] SSE chunk from upstream",
|
||||
extra={"chunk_size": len(chunk), "buffer_size": len(sse_buffer)},
|
||||
)
|
||||
# SSE events are separated by \n\n (double newline).
|
||||
# Process complete events and keep incomplete ones in the buffer.
|
||||
while b"\n\n" in sse_buffer:
|
||||
event_raw, sse_buffer = sse_buffer.split(b"\n\n", 1)
|
||||
event_raw = event_raw.strip()
|
||||
if not event_raw:
|
||||
continue
|
||||
|
||||
stripped_part = part.strip()
|
||||
if not stripped_part:
|
||||
continue
|
||||
logger.debug(
|
||||
"[chat] SSE event from upstream",
|
||||
extra={"event": event_raw[:500].decode("utf-8", errors="replace")},
|
||||
)
|
||||
|
||||
if stripped_part == b"[DONE]":
|
||||
if event_raw == b"data: [DONE]":
|
||||
logger.debug("[chat] SSE [DONE] seen")
|
||||
done_seen = True
|
||||
continue
|
||||
|
||||
# Extract the JSON payload from "data: {...}"
|
||||
data_prefix = b"data: "
|
||||
if event_raw.startswith(data_prefix):
|
||||
payload_bytes = event_raw[len(data_prefix):]
|
||||
else:
|
||||
payload_bytes = event_raw
|
||||
|
||||
try:
|
||||
# Only parse if it looks like a JSON object to avoid SSE control messages or partials
|
||||
if part.strip().startswith(b"{") and part.strip().endswith(
|
||||
b"}"
|
||||
):
|
||||
obj = json.loads(part)
|
||||
if isinstance(obj, dict):
|
||||
self._apply_provider_field(obj)
|
||||
if obj.get("model"):
|
||||
last_model_seen = str(obj.get("model"))
|
||||
if requested_model:
|
||||
obj["model"] = requested_model
|
||||
if (
|
||||
"id" not in obj
|
||||
or not isinstance(obj["id"], str)
|
||||
or obj["id"] == "existing-id"
|
||||
):
|
||||
if not hasattr(self, "_current_stream_id"):
|
||||
self._current_stream_id = (
|
||||
f"chatcmpl-{uuid.uuid4()}"
|
||||
)
|
||||
obj["id"] = self._current_stream_id
|
||||
if isinstance(obj.get("usage"), dict):
|
||||
obj = json.loads(payload_bytes)
|
||||
if isinstance(obj, dict):
|
||||
self._apply_provider_field(obj)
|
||||
if obj.get("model"):
|
||||
last_model_seen = str(obj.get("model"))
|
||||
if requested_model:
|
||||
obj["model"] = requested_model
|
||||
if (
|
||||
"id" not in obj
|
||||
or not isinstance(obj["id"], str)
|
||||
or obj["id"] == "existing-id"
|
||||
):
|
||||
if not hasattr(self, "_current_stream_id"):
|
||||
self._current_stream_id = (
|
||||
f"chatcmpl-{uuid.uuid4()}"
|
||||
)
|
||||
obj["id"] = self._current_stream_id
|
||||
if isinstance(obj.get("usage"), dict):
|
||||
# Check if this chunk has actual content (vs. billing-only chunks)
|
||||
choices = obj.get("choices") or []
|
||||
has_content = any(
|
||||
c.get("delta", {}).get("content")
|
||||
or c.get("delta", {}).get("reasoning")
|
||||
for c in choices
|
||||
)
|
||||
if not has_content:
|
||||
# Billing-only chunk — hold back for cost metadata injection
|
||||
logger.debug(
|
||||
"[chat] Holding back usage-only chunk",
|
||||
extra={"model": obj.get("model"), "completion_tokens": obj.get("usage", {}).get("completion_tokens", 0)},
|
||||
)
|
||||
usage_chunk_data = obj
|
||||
continue
|
||||
yield b"data: " + json.dumps(obj).encode() + b"\n\n"
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
# Content-bearing chunk — yield it, but also save usage for later injection
|
||||
logger.debug(
|
||||
"[chat] Content chunk with usage — yielding",
|
||||
extra={"model": obj.get("model"), "completion_tokens": obj.get("usage", {}).get("completion_tokens", 0)},
|
||||
)
|
||||
if not usage_chunk_data:
|
||||
usage_chunk_data = obj
|
||||
logger.debug(
|
||||
"[chat] Yielding chunk to client",
|
||||
extra={"model": obj.get("model")},
|
||||
)
|
||||
yield b"data: " + json.dumps(obj).encode() + b"\n\n"
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"[chat] JSON parse failed for event",
|
||||
extra={"error": str(e), "event_preview": event_raw[:200].decode("utf-8", errors="replace")},
|
||||
)
|
||||
|
||||
prefix = (
|
||||
b"data: " if (i > 0 or chunk.startswith(b"data: ")) else b""
|
||||
)
|
||||
yield prefix + part
|
||||
# If JSON parsing failed but it looks like valid SSE, pass through
|
||||
if event_raw.startswith(b"data: "):
|
||||
yield event_raw + b"\n\n"
|
||||
else:
|
||||
yield b"data: " + event_raw + b"\n\n"
|
||||
|
||||
logger.debug(
|
||||
"[chat] Upstream stream ended",
|
||||
extra={"usage_chunk_data": bool(usage_chunk_data), "last_model": last_model_seen},
|
||||
)
|
||||
|
||||
async with create_session() as session:
|
||||
fresh_key = await session.get(key.__class__, key.hashed_key)
|
||||
@@ -1069,23 +1113,30 @@ class BaseUpstreamProvider:
|
||||
pass
|
||||
|
||||
try:
|
||||
sse_buffer = b""
|
||||
async for chunk in response.aiter_bytes():
|
||||
# Split chunk into SSE events
|
||||
parts = re.split(b"data: ", chunk)
|
||||
for i, part in enumerate(parts):
|
||||
if not part:
|
||||
sse_buffer += chunk
|
||||
# SSE events are separated by \n\n (double newline).
|
||||
# Process complete events and keep incomplete ones in the buffer.
|
||||
while b"\n\n" in sse_buffer:
|
||||
event_raw, sse_buffer = sse_buffer.split(b"\n\n", 1)
|
||||
event_raw = event_raw.strip()
|
||||
if not event_raw:
|
||||
continue
|
||||
|
||||
stripped_part = part.strip()
|
||||
if not stripped_part:
|
||||
continue
|
||||
|
||||
if stripped_part == b"[DONE]":
|
||||
if event_raw == b"data: [DONE]":
|
||||
done_seen = True
|
||||
continue
|
||||
|
||||
# Extract the JSON payload from "data: {...}"
|
||||
data_prefix = b"data: "
|
||||
if event_raw.startswith(data_prefix):
|
||||
payload_bytes = event_raw[len(data_prefix):]
|
||||
else:
|
||||
payload_bytes = event_raw
|
||||
|
||||
try:
|
||||
obj = json.loads(part)
|
||||
obj = json.loads(payload_bytes)
|
||||
if isinstance(obj, dict):
|
||||
self._apply_provider_field(obj)
|
||||
if obj.get("model"):
|
||||
@@ -1111,13 +1162,17 @@ class BaseUpstreamProvider:
|
||||
):
|
||||
usage_chunk_data = obj
|
||||
continue
|
||||
|
||||
yield b"data: " + json.dumps(obj).encode() + b"\n\n"
|
||||
continue
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
prefix = (
|
||||
b"data: " if (i > 0 or chunk.startswith(b"data: ")) else b""
|
||||
)
|
||||
yield prefix + part
|
||||
# Pass through non-JSON SSE events
|
||||
if event_raw.startswith(b"data: "):
|
||||
yield event_raw + b"\n\n"
|
||||
else:
|
||||
yield b"data: " + event_raw + b"\n\n"
|
||||
|
||||
# Always emit a cost-bearing data chunk
|
||||
async with create_session() as session:
|
||||
@@ -1835,7 +1890,6 @@ class BaseUpstreamProvider:
|
||||
max_cost_for_model: int,
|
||||
model_obj: Model,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Dispatch /v1/messages via litellm for x-cashu payments.
|
||||
@@ -1857,7 +1911,6 @@ class BaseUpstreamProvider:
|
||||
max_cost_for_model,
|
||||
requested_model,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id,
|
||||
)
|
||||
|
||||
@@ -1884,7 +1937,6 @@ class BaseUpstreamProvider:
|
||||
refund_amount,
|
||||
unit,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
@@ -2071,7 +2123,6 @@ class BaseUpstreamProvider:
|
||||
max_cost_for_model: int,
|
||||
requested_model: str | None,
|
||||
mint: str | None,
|
||||
payment_token_hash: str | None,
|
||||
request_id: str | None,
|
||||
) -> StreamingResponse:
|
||||
"""Buffer a litellm stream end-to-end, compute cost, then replay.
|
||||
@@ -2175,7 +2226,6 @@ class BaseUpstreamProvider:
|
||||
refund_amount,
|
||||
unit,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
@@ -2940,7 +2990,6 @@ class BaseUpstreamProvider:
|
||||
amount: int,
|
||||
unit: str,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> str:
|
||||
"""Create and send a refund token to the user.
|
||||
@@ -2949,7 +2998,6 @@ class BaseUpstreamProvider:
|
||||
amount: Refund amount
|
||||
unit: Unit of the refund (sat or msat)
|
||||
mint: Optional mint URL for the refund token
|
||||
payment_token_hash: Optional SHA-256 hash of the original payment token for storage
|
||||
request_id: Optional HTTP request ID for tracking
|
||||
|
||||
Returns:
|
||||
@@ -3041,7 +3089,6 @@ class BaseUpstreamProvider:
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> StreamingResponse:
|
||||
"""Handle streaming response for X-Cashu payment, calculating refund if needed.
|
||||
@@ -3052,7 +3099,6 @@ class BaseUpstreamProvider:
|
||||
amount: Payment amount received
|
||||
unit: Payment unit (sat or msat)
|
||||
max_cost_for_model: Maximum cost for the model
|
||||
payment_token_hash: Optional hash of original payment token for refund storage
|
||||
|
||||
Returns:
|
||||
StreamingResponse with refund token in header if applicable
|
||||
@@ -3143,7 +3189,6 @@ class BaseUpstreamProvider:
|
||||
refund_amount,
|
||||
unit,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
@@ -3222,7 +3267,6 @@ class BaseUpstreamProvider:
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> Response:
|
||||
"""Handle non-streaming response for X-Cashu payment, calculating refund if needed.
|
||||
@@ -3233,7 +3277,6 @@ class BaseUpstreamProvider:
|
||||
amount: Payment amount received
|
||||
unit: Payment unit (sat or msat)
|
||||
max_cost_for_model: Maximum cost for the model
|
||||
payment_token_hash: Optional hash of original payment token for refund storage
|
||||
|
||||
Returns:
|
||||
Response with refund token in header if applicable
|
||||
@@ -3303,7 +3346,6 @@ class BaseUpstreamProvider:
|
||||
refund_amount,
|
||||
unit,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
@@ -3375,7 +3417,6 @@ class BaseUpstreamProvider:
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> StreamingResponse | Response:
|
||||
"""Handle chat completion response for X-Cashu payment, detecting streaming vs non-streaming.
|
||||
@@ -3419,7 +3460,6 @@ class BaseUpstreamProvider:
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
else:
|
||||
@@ -3430,7 +3470,6 @@ class BaseUpstreamProvider:
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
@@ -3460,7 +3499,6 @@ class BaseUpstreamProvider:
|
||||
max_cost_for_model: int,
|
||||
model_obj: Model,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Forward request paid with X-Cashu token to upstream service.
|
||||
|
||||
@@ -3499,7 +3537,6 @@ class BaseUpstreamProvider:
|
||||
max_cost_for_model=max_cost_for_model,
|
||||
model_obj=model_obj,
|
||||
mint=mint,
|
||||
payment_token_hash=payment_token_hash,
|
||||
request_id=getattr(request.state, "request_id", None),
|
||||
)
|
||||
|
||||
@@ -3569,7 +3606,6 @@ class BaseUpstreamProvider:
|
||||
amount,
|
||||
unit,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=getattr(request.state, "request_id", None),
|
||||
)
|
||||
|
||||
@@ -3619,7 +3655,6 @@ class BaseUpstreamProvider:
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=getattr(request.state, "request_id", None),
|
||||
)
|
||||
background_tasks = BackgroundTasks()
|
||||
@@ -3695,7 +3730,6 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
|
||||
try:
|
||||
payment_token_hash = hashlib.sha256(x_cashu_token.encode()).hexdigest()
|
||||
headers = dict(request.headers)
|
||||
amount, unit, mint = await recieve_token(x_cashu_token)
|
||||
headers = self.prepare_headers(dict(request.headers))
|
||||
@@ -3728,7 +3762,6 @@ class BaseUpstreamProvider:
|
||||
max_cost_for_model,
|
||||
model_obj,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
@@ -3788,7 +3821,6 @@ class BaseUpstreamProvider:
|
||||
max_cost_for_model: int,
|
||||
model_obj: Model,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Forward Responses API request paid with X-Cashu token to upstream service.
|
||||
|
||||
@@ -3864,7 +3896,6 @@ class BaseUpstreamProvider:
|
||||
amount,
|
||||
unit,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=getattr(request.state, "request_id", None),
|
||||
)
|
||||
|
||||
@@ -3909,7 +3940,6 @@ class BaseUpstreamProvider:
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=getattr(request.state, "request_id", None),
|
||||
)
|
||||
background_tasks = BackgroundTasks()
|
||||
@@ -3960,7 +3990,6 @@ class BaseUpstreamProvider:
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> StreamingResponse | Response:
|
||||
"""Handle Responses API completion response for X-Cashu payment.
|
||||
@@ -4005,7 +4034,6 @@ class BaseUpstreamProvider:
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
else:
|
||||
@@ -4016,7 +4044,6 @@ class BaseUpstreamProvider:
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
@@ -4044,7 +4071,6 @@ class BaseUpstreamProvider:
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> StreamingResponse:
|
||||
"""Handle streaming Responses API response for X-Cashu payment.
|
||||
@@ -4131,7 +4157,6 @@ class BaseUpstreamProvider:
|
||||
refund_amount,
|
||||
unit,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
@@ -4210,7 +4235,6 @@ class BaseUpstreamProvider:
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None = None,
|
||||
payment_token_hash: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> Response:
|
||||
"""Handle non-streaming Responses API response for X-Cashu payment."""
|
||||
@@ -4279,7 +4303,6 @@ class BaseUpstreamProvider:
|
||||
refund_amount,
|
||||
unit,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
request_id=request_id,
|
||||
)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
@@ -4376,7 +4399,6 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
|
||||
try:
|
||||
payment_token_hash = hashlib.sha256(x_cashu_token.encode()).hexdigest()
|
||||
headers = dict(request.headers)
|
||||
amount, unit, mint = await recieve_token(x_cashu_token)
|
||||
headers = self.prepare_headers(dict(request.headers))
|
||||
@@ -4409,7 +4431,6 @@ class BaseUpstreamProvider:
|
||||
max_cost_for_model,
|
||||
model_obj,
|
||||
mint,
|
||||
payment_token_hash,
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
|
||||
@@ -403,6 +403,20 @@ async def credit_balance(
|
||||
"credit_balance: Converted to msat", extra={"amount_msat": amount}
|
||||
)
|
||||
|
||||
# Guard against zero/negative redemptions (empty or dust tokens, or
|
||||
# swap-to-primary-mint amounts that net to <= 0 after fees). Raising here
|
||||
# — before the UPDATE/commit below — leaves any freshly-created, still
|
||||
# uncommitted ApiKey row to be rolled back when the request session
|
||||
# closes, instead of persisting an orphan key with balance 0.
|
||||
if amount <= 0:
|
||||
logger.error(
|
||||
"credit_balance: Redeemed amount is zero or negative; refusing to credit",
|
||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||
)
|
||||
raise ValueError(
|
||||
f"Redeemed token amount must be positive, got {amount} msats"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"credit_balance: Updating balance",
|
||||
extra={"old_balance": key.balance, "credit_amount": amount},
|
||||
|
||||
@@ -436,9 +436,9 @@ async def test_topup_with_zero_amount_token( # type: ignore[no-untyped-def]
|
||||
"/v1/wallet/topup", params={"cashu_token": token}
|
||||
)
|
||||
|
||||
# Should succeed but add 0 msats
|
||||
assert response.status_code == 200
|
||||
assert response.json()["msats"] == 0
|
||||
# Zero/negative redemptions are refused to avoid crediting empty
|
||||
# or dust tokens (and to prevent orphan zero-balance keys).
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -693,7 +693,6 @@ async def test_x_cashu_non_streaming_dispatches_and_refunds_overpaid_amount() ->
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
mint="https://mint.example",
|
||||
payment_token_hash="hash123",
|
||||
request_id="req-1",
|
||||
)
|
||||
|
||||
@@ -972,7 +971,6 @@ async def test_forward_x_cashu_request_routes_messages_via_litellm() -> None:
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
mint="https://mint",
|
||||
payment_token_hash="h",
|
||||
)
|
||||
|
||||
mock_helper.assert_awaited_once()
|
||||
@@ -1041,7 +1039,6 @@ async def test_forward_x_cashu_request_handles_count_tokens_locally() -> None:
|
||||
max_cost_for_model=10_000,
|
||||
model_obj=model,
|
||||
mint="https://mint",
|
||||
payment_token_hash="h",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -108,6 +108,39 @@ async def test_credit_balance() -> None:
|
||||
assert mock_session.refresh.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credit_balance_rejects_zero_amount() -> None:
|
||||
"""A zero/dust redemption must raise BEFORE any commit, so no orphan
|
||||
zero-balance key (balance 0, total_spent 0, total_requests 0) is persisted."""
|
||||
token_data = {
|
||||
"token": [{"mint": "http://mint:3338", "proofs": [{"amount": 0}]}],
|
||||
"unit": "sat",
|
||||
}
|
||||
token_json = json.dumps(token_data)
|
||||
token_b64 = base64.urlsafe_b64encode(token_json.encode()).decode()
|
||||
token_str = f"cashuA{token_b64}"
|
||||
|
||||
mock_key = Mock()
|
||||
mock_key.balance = 0
|
||||
mock_key.hashed_key = "test_hash"
|
||||
mock_session = AsyncMock()
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
|
||||
with patch(
|
||||
"routstr.wallet.recieve_token",
|
||||
return_value=(0, "sat", "http://mint:3338"),
|
||||
):
|
||||
with pytest.raises(ValueError, match="must be positive"):
|
||||
await credit_balance(token_str, mock_key, mock_session)
|
||||
|
||||
# Critically: no balance UPDATE and no commit happened, so the caller's
|
||||
# uncommitted key row rolls back instead of persisting as an orphan.
|
||||
assert not mock_session.exec.called
|
||||
assert not mock_session.commit.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_to_primary_mint_insufficient_for_fees() -> None:
|
||||
"""Token amount is less than melt_quote.amount + melt_quote.fee_reserve."""
|
||||
|
||||
@@ -64,7 +64,6 @@ async def test_non_streaming_includes_cost_sats() -> None:
|
||||
unit="msat",
|
||||
max_cost_for_model=10000,
|
||||
mint=None,
|
||||
payment_token_hash=None,
|
||||
)
|
||||
|
||||
body = json.loads(response.body)
|
||||
@@ -170,7 +169,6 @@ async def test_streaming_includes_cost_sats_in_usage_chunk() -> None:
|
||||
unit="msat",
|
||||
max_cost_for_model=10000,
|
||||
mint=None,
|
||||
payment_token_hash=None,
|
||||
)
|
||||
|
||||
chunks = await _collect_streaming(response)
|
||||
|
||||
@@ -71,8 +71,8 @@ export function LogDetailsDialog({
|
||||
<div className='space-y-6'>
|
||||
<div>
|
||||
<h4 className='mb-2 text-sm font-medium'>Message</h4>
|
||||
<div className='bg-muted max-h-48 overflow-auto rounded-md p-3'>
|
||||
<pre className='font-mono text-sm break-all whitespace-pre'>
|
||||
<div className='bg-muted max-h-96 overflow-auto rounded-md p-3'>
|
||||
<pre className='font-mono text-sm break-words whitespace-pre-wrap'>
|
||||
{log.message}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -113,8 +113,8 @@ export function LogDetailsDialog({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className='bg-muted max-h-32 overflow-auto rounded p-2'>
|
||||
<pre className='font-mono text-sm break-all whitespace-pre-wrap'>
|
||||
<div className='bg-muted max-h-64 overflow-auto rounded p-2'>
|
||||
<pre className='font-mono text-sm break-words whitespace-pre-wrap'>
|
||||
{String(log[field as keyof LogEntry] || 'N/A')}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -132,13 +132,13 @@ export function LogDetailsDialog({
|
||||
<span className='text-muted-foreground truncate text-xs font-medium uppercase'>
|
||||
{field}
|
||||
</span>
|
||||
<div className='bg-muted max-h-48 overflow-auto rounded p-2'>
|
||||
<div className='bg-muted max-h-80 overflow-auto rounded p-2'>
|
||||
{typeof log[field] === 'object' ? (
|
||||
<pre className='font-mono text-xs break-all whitespace-pre-wrap'>
|
||||
<pre className='font-mono text-xs break-words whitespace-pre-wrap'>
|
||||
{JSON.stringify(log[field], null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<pre className='font-mono text-sm break-all whitespace-pre-wrap'>
|
||||
<pre className='font-mono text-sm break-words whitespace-pre-wrap'>
|
||||
{String(log[field] || 'N/A')}
|
||||
</pre>
|
||||
)}
|
||||
@@ -173,8 +173,8 @@ export function LogDetailsDialog({
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='bg-muted max-h-64 overflow-auto rounded-md p-4'>
|
||||
<pre className='text-xs break-all whitespace-pre-wrap'>
|
||||
<div className='bg-muted max-h-[32rem] overflow-auto rounded-md p-4'>
|
||||
<pre className='text-xs break-words whitespace-pre-wrap'>
|
||||
{JSON.stringify(log, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user