mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-31 07:46:15 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b5524f720 |
@@ -59,30 +59,25 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: ui/pnpm-lock.yaml
|
||||
cache: "npm"
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
|
||||
- name: Install UI dependencies
|
||||
working-directory: ./ui
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: npm ci
|
||||
|
||||
- name: Run UI format check
|
||||
working-directory: ./ui
|
||||
run: pnpm run format-check
|
||||
run: npm run format-check
|
||||
|
||||
- name: Run UI linting
|
||||
working-directory: ./ui
|
||||
run: pnpm run lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run UI build
|
||||
working-directory: ./ui
|
||||
run: pnpm run build
|
||||
run: npm run build
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.2.2"
|
||||
version = "0.2.1"
|
||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+20
-50
@@ -441,29 +441,6 @@ async def adjust_payment_for_tokens(
|
||||
},
|
||||
)
|
||||
|
||||
async def release_reservation_only() -> None:
|
||||
"""Fallback to release reservation without charging when main update fails."""
|
||||
try:
|
||||
release_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost)
|
||||
)
|
||||
await session.exec(release_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
logger.warning(
|
||||
"Released reservation without charging (fallback)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to release reservation in fallback",
|
||||
extra={"error": str(e), "key_hash": key.hashed_key[:8] + "..."},
|
||||
)
|
||||
|
||||
match await calculate_cost(response_data, deducted_max_cost, session):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
@@ -488,7 +465,7 @@ async def adjust_payment_for_tokens(
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize max-cost payment - retrying reservation release",
|
||||
"Failed to finalize max-cost payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
@@ -497,7 +474,6 @@ async def adjust_payment_for_tokens(
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await release_reservation_only()
|
||||
else:
|
||||
await session.refresh(key)
|
||||
logger.info(
|
||||
@@ -592,14 +568,13 @@ async def adjust_payment_for_tokens(
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to finalize additional charge - releasing reservation",
|
||||
"Failed to finalize additional charge (concurrent operation)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"attempted_charge": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await release_reservation_only()
|
||||
else:
|
||||
# Refund some of the base cost
|
||||
refund = abs(cost_difference)
|
||||
@@ -628,7 +603,7 @@ async def adjust_payment_for_tokens(
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize payment - releasing reservation",
|
||||
"Failed to finalize payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
@@ -637,27 +612,28 @@ async def adjust_payment_for_tokens(
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
await release_reservation_only()
|
||||
else:
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(key)
|
||||
# Still return the cost data even if we couldn't properly finalize
|
||||
# The reservation was already made, so the user has paid
|
||||
|
||||
logger.info(
|
||||
"Refund processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"refunded_amount": refund,
|
||||
"new_balance": key.balance,
|
||||
"final_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Refund processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"refunded_amount": refund,
|
||||
"new_balance": key.balance,
|
||||
"final_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
return cost.dict()
|
||||
|
||||
case CostDataError() as error:
|
||||
logger.error(
|
||||
"Cost calculation error during payment adjustment - releasing reservation",
|
||||
"Cost calculation error during payment adjustment",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
@@ -665,7 +641,6 @@ async def adjust_payment_for_tokens(
|
||||
"error_code": error.code,
|
||||
},
|
||||
)
|
||||
await release_reservation_only()
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -677,12 +652,7 @@ async def adjust_payment_for_tokens(
|
||||
}
|
||||
},
|
||||
)
|
||||
# Fallback: should not reach here, but release reservation just in case
|
||||
logger.error(
|
||||
"Unexpected fallback in adjust_payment_for_tokens - releasing reservation",
|
||||
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
||||
)
|
||||
await release_reservation_only()
|
||||
# Fallback return to satisfy type checker; execution should not reach here
|
||||
return {
|
||||
"base_msats": deducted_max_cost,
|
||||
"input_msats": 0,
|
||||
|
||||
+3
-26
@@ -142,7 +142,6 @@ async def refund_wallet_endpoint(
|
||||
authorization: Annotated[str, Header(...)],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
logger.info("Refund request received", extra={"authorization": authorization})
|
||||
if not authorization.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
@@ -155,10 +154,8 @@ async def refund_wallet_endpoint(
|
||||
return cached
|
||||
|
||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||
remaining_balance_msats: int = key.balance
|
||||
|
||||
remaining_balance_msats: int = key.total_balance
|
||||
|
||||
logger.info("Refunding key: %s", key.dict())
|
||||
if key.refund_currency == "sat":
|
||||
remaining_balance = remaining_balance_msats // 1000
|
||||
else:
|
||||
@@ -174,15 +171,6 @@ async def refund_wallet_endpoint(
|
||||
if key.refund_address:
|
||||
from .core.settings import settings as global_settings
|
||||
|
||||
logger.info(
|
||||
"Sending refund to lnurl",
|
||||
extra={
|
||||
"remaining_balance": remaining_balance,
|
||||
"refund_currency": key.refund_currency,
|
||||
"refund_mint_url": key.refund_mint_url,
|
||||
"refund_address": key.refund_address,
|
||||
},
|
||||
)
|
||||
await send_to_lnurl(
|
||||
remaining_balance,
|
||||
key.refund_currency or "sat",
|
||||
@@ -191,16 +179,7 @@ async def refund_wallet_endpoint(
|
||||
)
|
||||
result = {"recipient": key.refund_address}
|
||||
else:
|
||||
logger.info(
|
||||
"Sending refund as token",
|
||||
extra={
|
||||
"remaining_balance": remaining_balance,
|
||||
"refund_currency": key.refund_currency,
|
||||
"refund_mint_url": key.refund_mint_url,
|
||||
},
|
||||
)
|
||||
refund_currency = key.refund_currency or "sat"
|
||||
|
||||
token = await send_token(
|
||||
remaining_balance, refund_currency, key.refund_mint_url
|
||||
)
|
||||
@@ -211,12 +190,10 @@ async def refund_wallet_endpoint(
|
||||
else:
|
||||
result["msats"] = str(remaining_balance_msats)
|
||||
|
||||
except HTTPException as e:
|
||||
logger.error("Refund failed", extra={"exception": e})
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions (like 400 for balance too small)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Refund failed", extra={"exception": e})
|
||||
# If refund fails, don't modify the database
|
||||
error_msg = str(e)
|
||||
if (
|
||||
@@ -227,7 +204,7 @@ async def refund_wallet_endpoint(
|
||||
):
|
||||
raise HTTPException(status_code=503, detail="Mint service unavailable")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Refund failed: " + error_msg)
|
||||
raise HTTPException(status_code=500, detail="Refund failed")
|
||||
|
||||
await _refund_cache_set(bearer_value, result)
|
||||
|
||||
|
||||
+1
-9
@@ -6,7 +6,7 @@ from typing import AsyncGenerator
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||
from sqlmodel import Field, Relationship, SQLModel, func, select, update
|
||||
from sqlmodel import Field, Relationship, SQLModel, func, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .logging import get_logger
|
||||
@@ -53,14 +53,6 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
return self.balance - self.reserved_balance
|
||||
|
||||
|
||||
async def reset_all_reserved_balances(session: AsyncSession) -> None:
|
||||
logger.info("Resetting all reserved balances to 0")
|
||||
stmt = update(ApiKey).values(reserved_balance=0)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
logger.info("Reserved balances reset successfully")
|
||||
|
||||
|
||||
class ModelRow(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "models"
|
||||
id: str = Field(primary_key=True)
|
||||
|
||||
@@ -33,9 +33,9 @@ setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
if os.getenv("VERSION_SUFFIX") is not None:
|
||||
__version__ = f"0.2.2-{os.getenv('VERSION_SUFFIX')}"
|
||||
__version__ = f"0.2.1-{os.getenv('VERSION_SUFFIX')}"
|
||||
else:
|
||||
__version__ = "0.2.2"
|
||||
__version__ = "0.2.1"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -61,10 +61,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# Initialize application settings (env -> computed -> DB precedence)
|
||||
async with create_session() as session:
|
||||
s = await SettingsService.initialize(session)
|
||||
if s.reset_reserved_balance_on_startup:
|
||||
from .db import reset_all_reserved_balances
|
||||
|
||||
await reset_all_reserved_balances(session)
|
||||
|
||||
# Apply app metadata from settings
|
||||
try:
|
||||
|
||||
@@ -6,7 +6,7 @@ import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from pydantic.v1 import BaseModel, BaseSettings, Field
|
||||
from pydantic.v1 import BaseModel, BaseSettings, Field, validator
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
|
||||
@@ -37,9 +37,19 @@ class Settings(BaseSettings):
|
||||
|
||||
# Cashu
|
||||
cashu_mints: list[str] = Field(default_factory=list, env="CASHU_MINTS")
|
||||
|
||||
@validator("cashu_mints", pre=True, each_item=True)
|
||||
def normalize_mint_url(cls, v: str) -> str:
|
||||
if isinstance(v, str):
|
||||
return v.rstrip("/")
|
||||
return v
|
||||
|
||||
receive_ln_address: str = Field(default="", env="RECEIVE_LN_ADDRESS")
|
||||
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
|
||||
primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT")
|
||||
disable_testnut_mock_upstream: bool = Field(
|
||||
default=False, env="DISABLE_TESTNUT_MOCK_UPSTREAM"
|
||||
)
|
||||
|
||||
# Pricing
|
||||
# Default behavior: derive pricing from MODELS
|
||||
@@ -54,9 +64,6 @@ class Settings(BaseSettings):
|
||||
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
|
||||
# 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
|
||||
|
||||
# Network
|
||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
|
||||
|
||||
@@ -283,7 +283,7 @@ async def raw_send_to_lnurl(
|
||||
f"({min_sendable_sat} - {max_sendable_sat} {unit})"
|
||||
)
|
||||
|
||||
estimated_fees_sat = int(max(math.ceil((amount_msat / 1000) * 0.01), 2)) + 1
|
||||
estimated_fees_sat = int(max(math.ceil((amount_msat / 1000) * 0.01), 2))
|
||||
estimated_fees_msat = estimated_fees_sat * 1000
|
||||
final_amount = amount_msat - estimated_fees_msat
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from .core.db import (
|
||||
create_session,
|
||||
get_session,
|
||||
)
|
||||
from .core.settings import settings
|
||||
from .payment.helpers import (
|
||||
calculate_discounted_max_cost,
|
||||
check_token_balance,
|
||||
@@ -25,6 +26,7 @@ from .payment.helpers import (
|
||||
from .payment.models import Model
|
||||
from .upstream import BaseUpstreamProvider
|
||||
from .upstream.helpers import init_upstreams
|
||||
from .wallet import deserialize_token_from_string
|
||||
|
||||
logger = get_logger(__name__)
|
||||
proxy_router = APIRouter()
|
||||
@@ -146,6 +148,27 @@ async def proxy(
|
||||
else:
|
||||
model_id = request_body_dict.get("model", "unknown")
|
||||
|
||||
if (
|
||||
"https://testnut.cashu.space" in settings.cashu_mints
|
||||
and not settings.disable_testnut_mock_upstream
|
||||
):
|
||||
try:
|
||||
token_str = None
|
||||
if x_cashu_header := headers.get("x-cashu"):
|
||||
token_str = x_cashu_header
|
||||
elif auth_header := headers.get("authorization"):
|
||||
parts = auth_header.split(" ")
|
||||
if len(parts) > 1 and not parts[1].startswith("sk-"):
|
||||
token_str = parts[1]
|
||||
|
||||
if token_str:
|
||||
token_obj = deserialize_token_from_string(token_str)
|
||||
if token_obj.mint == "https://testnut.cashu.space":
|
||||
model_id = "mock/gpt-420-mock"
|
||||
request_body_dict["model"] = model_id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
model_obj = get_model_instance(model_id)
|
||||
if not model_obj:
|
||||
return create_error_response(
|
||||
|
||||
@@ -447,11 +447,6 @@ class BaseUpstreamProvider:
|
||||
async with create_session() as new_session:
|
||||
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
||||
if not fresh_key:
|
||||
logger.warning(
|
||||
"Key not found when finalizing streaming payment",
|
||||
extra={"key_hash": key.hashed_key[:8] + "..."},
|
||||
)
|
||||
usage_finalized = True
|
||||
return None
|
||||
try:
|
||||
fallback: dict = {
|
||||
@@ -480,7 +475,6 @@ class BaseUpstreamProvider:
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
usage_finalized = True
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -590,10 +584,8 @@ class BaseUpstreamProvider:
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
await finalize_without_usage()
|
||||
raise
|
||||
finally:
|
||||
if not usage_finalized:
|
||||
await finalize_without_usage()
|
||||
|
||||
# Remove inaccurate encoding headers from upstream response
|
||||
response_headers = dict(response.headers)
|
||||
@@ -746,11 +738,6 @@ class BaseUpstreamProvider:
|
||||
async with create_session() as new_session:
|
||||
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
||||
if not fresh_key:
|
||||
logger.warning(
|
||||
"Key not found when finalizing Responses API streaming payment",
|
||||
extra={"key_hash": key.hashed_key[:8] + "..."},
|
||||
)
|
||||
usage_finalized = True
|
||||
return None
|
||||
try:
|
||||
fallback: dict = {
|
||||
@@ -779,7 +766,6 @@ class BaseUpstreamProvider:
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
usage_finalized = True
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -904,10 +890,8 @@ class BaseUpstreamProvider:
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
await finalize_without_usage()
|
||||
raise
|
||||
finally:
|
||||
if not usage_finalized:
|
||||
await finalize_without_usage()
|
||||
|
||||
# Remove inaccurate encoding headers from upstream response
|
||||
response_headers = dict(response.headers)
|
||||
@@ -1026,44 +1010,6 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
raise
|
||||
|
||||
async def _finalize_generic_streaming_payment(
|
||||
self, key_hash: str, max_cost: int, path: str
|
||||
) -> None:
|
||||
"""Background task to finalize payment for generic streaming requests."""
|
||||
async with create_session() as session:
|
||||
key = await session.get(ApiKey, key_hash)
|
||||
if not key:
|
||||
logger.warning(
|
||||
"Key not found during background payment finalization",
|
||||
extra={"key_hash": key_hash[:8] + "..."},
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Finalize with "unknown" model and no usage to release reservation/charge max cost
|
||||
await adjust_payment_for_tokens(
|
||||
key,
|
||||
{"model": "unknown", "usage": None},
|
||||
session,
|
||||
max_cost,
|
||||
)
|
||||
logger.info(
|
||||
"Finalized generic streaming payment in background",
|
||||
extra={
|
||||
"path": path,
|
||||
"key_hash": key_hash[:8] + "...",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error finalizing generic streaming payment in background",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"key_hash": key_hash[:8] + "...",
|
||||
"path": path,
|
||||
},
|
||||
)
|
||||
|
||||
async def forward_request(
|
||||
self,
|
||||
request: Request,
|
||||
@@ -1215,12 +1161,6 @@ class BaseUpstreamProvider:
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
background_tasks.add_task(client.aclose)
|
||||
background_tasks.add_task(
|
||||
self._finalize_generic_streaming_payment,
|
||||
key.hashed_key,
|
||||
max_cost_for_model,
|
||||
path,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Streaming non-chat response",
|
||||
@@ -1424,15 +1364,9 @@ class BaseUpstreamProvider:
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
background_tasks.add_task(client.aclose)
|
||||
background_tasks.add_task(
|
||||
self._finalize_generic_streaming_payment,
|
||||
key.hashed_key,
|
||||
max_cost_for_model,
|
||||
path,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Streaming non-Responses API response",
|
||||
"Streaming non-chat response",
|
||||
extra={
|
||||
"path": path,
|
||||
"status_code": response.status_code,
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
from typing import AsyncIterator
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from ..core.db import ApiKey, AsyncSession
|
||||
from ..payment.models import Architecture, Model, Pricing
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
|
||||
class MockUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Fack Mock Upstream provider specifically for Testing."""
|
||||
|
||||
provider_type = "mock"
|
||||
|
||||
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:
|
||||
if path.endswith("chat/completions"):
|
||||
is_streaming = False
|
||||
if request_body:
|
||||
request_data = json.loads(request_body)
|
||||
is_streaming = request_data.get("stream", False)
|
||||
|
||||
if is_streaming:
|
||||
|
||||
async def fake_streaming_response(
|
||||
chunk_size: int | None = None,
|
||||
) -> AsyncIterator[bytes]:
|
||||
suffix = random.randint(1000, 9999)
|
||||
req_id = f"gen-mock-stream-{suffix}"
|
||||
created = 1766138895
|
||||
model = "mock/gpt-420-mock"
|
||||
|
||||
def make_chunk(
|
||||
delta: dict,
|
||||
finish_reason: str | None = None,
|
||||
usage: dict | None = None,
|
||||
) -> bytes:
|
||||
chunk = {
|
||||
"id": req_id,
|
||||
"provider": "MockProvider",
|
||||
"model": model,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": delta,
|
||||
"finish_reason": finish_reason,
|
||||
"native_finish_reason": "completed"
|
||||
if finish_reason
|
||||
else None,
|
||||
"logprobs": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
if usage:
|
||||
chunk["usage"] = usage
|
||||
return f"data: {json.dumps(chunk)}\n\n".encode()
|
||||
|
||||
# 1. Initial chunk
|
||||
yield make_chunk({"role": "assistant", "content": ""})
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
# 2. Reasoning chunks
|
||||
reasoning_tokens = ["Mock", " reason", "ing", "..."]
|
||||
for token in reasoning_tokens:
|
||||
delta = {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"reasoning": token,
|
||||
"reasoning_details": [
|
||||
{
|
||||
"type": "reasoning.summary",
|
||||
"summary": token,
|
||||
"format": "openai-responses-v1",
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
yield make_chunk(delta)
|
||||
await asyncio.sleep(0.03)
|
||||
|
||||
# 3. Content chunks
|
||||
content_tokens = ["This", " is", " a", " mock", " stream", "."]
|
||||
for token in content_tokens:
|
||||
yield make_chunk({"role": "assistant", "content": token})
|
||||
await asyncio.sleep(0.03)
|
||||
|
||||
# 4. Finish chunk
|
||||
yield make_chunk(
|
||||
{"role": "assistant", "content": ""}, finish_reason="stop"
|
||||
)
|
||||
|
||||
# 5. Usage chunk
|
||||
usage_data = {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
"cost": 0.001,
|
||||
"is_byok": False,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 0,
|
||||
"audio_tokens": 0,
|
||||
"video_tokens": 0,
|
||||
},
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": None,
|
||||
"upstream_inference_prompt_cost": 0,
|
||||
"upstream_inference_completions_cost": 0.001,
|
||||
},
|
||||
"completion_tokens_details": {
|
||||
"reasoning_tokens": 10,
|
||||
"image_tokens": 0,
|
||||
},
|
||||
}
|
||||
|
||||
usage_chunk = {
|
||||
"id": req_id,
|
||||
"provider": "MockProvider",
|
||||
"model": model,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {"role": "assistant", "content": ""},
|
||||
"finish_reason": None,
|
||||
"native_finish_reason": None,
|
||||
"logprobs": None,
|
||||
}
|
||||
],
|
||||
"usage": usage_data,
|
||||
}
|
||||
yield f"data: {json.dumps(usage_chunk)}\n\n".encode()
|
||||
|
||||
# 6. DONE
|
||||
yield b"data: [DONE]\n\n"
|
||||
|
||||
# 7. Cost
|
||||
cost_chunk = {
|
||||
"cost": {
|
||||
"base_msats": 0,
|
||||
"input_msats": 2,
|
||||
"output_msats": 10,
|
||||
"total_msats": 12,
|
||||
}
|
||||
}
|
||||
yield f"data: {json.dumps(cost_chunk)}\n\n".encode()
|
||||
|
||||
return StreamingResponse(
|
||||
fake_streaming_response(),
|
||||
200,
|
||||
)
|
||||
|
||||
else:
|
||||
suffix = random.randint(1000, 9999)
|
||||
content_dict = {
|
||||
"id": f"gen-mock-{suffix}",
|
||||
"provider": "MockProvider",
|
||||
"model": "mock/gpt-5-mini",
|
||||
"object": "chat.completion",
|
||||
"created": 1766138655,
|
||||
"choices": [
|
||||
{
|
||||
"logprobs": None,
|
||||
"finish_reason": "length",
|
||||
"native_finish_reason": "max_output_tokens",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": f"Mock Content {suffix}",
|
||||
"refusal": None,
|
||||
"reasoning": f"Mock Reasoning {suffix}",
|
||||
"reasoning_details": [
|
||||
{
|
||||
"format": "openai-responses-v1",
|
||||
"index": 0,
|
||||
"type": "reasoning.summary",
|
||||
"summary": f"Mock Summary {suffix}",
|
||||
},
|
||||
{
|
||||
"id": f"rs_mock_{suffix}",
|
||||
"format": "openai-responses-v1",
|
||||
"index": 0,
|
||||
"type": "reasoning.encrypted",
|
||||
"data": "mock_encrypted_data",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 10,
|
||||
"total_tokens": 20,
|
||||
"cost": 0,
|
||||
"is_byok": False,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 0,
|
||||
"audio_tokens": 0,
|
||||
"video_tokens": 0,
|
||||
},
|
||||
"cost_details": {
|
||||
"upstream_inference_cost": None,
|
||||
"upstream_inference_prompt_cost": 0,
|
||||
"upstream_inference_completions_cost": 0,
|
||||
},
|
||||
"completion_tokens_details": {
|
||||
"reasoning_tokens": 5,
|
||||
"image_tokens": 0,
|
||||
},
|
||||
},
|
||||
"cost": {
|
||||
"base_msats": 0,
|
||||
"input_msats": 0,
|
||||
"output_msats": 0,
|
||||
"total_msats": 0,
|
||||
},
|
||||
}
|
||||
return Response(json.dumps(content_dict).encode(), 200)
|
||||
|
||||
elif path.endswith("embeddings"):
|
||||
raise NotImplementedError
|
||||
elif path.endswith("responses"):
|
||||
raise NotImplementedError
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
return [
|
||||
Model(
|
||||
id="mock/gpt-420-mock",
|
||||
name="mock/gpt-420-mock",
|
||||
created=0,
|
||||
description="mock model for testing",
|
||||
context_length=8192,
|
||||
architecture=Architecture(
|
||||
modality="text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(prompt=0.01, completion=0.01),
|
||||
),
|
||||
]
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
return "fake-model"
|
||||
|
||||
async def get_balance(self) -> float | None:
|
||||
return 420.69
|
||||
@@ -187,44 +187,11 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
|
||||
)
|
||||
|
||||
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",
|
||||
@@ -235,9 +202,6 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
|
||||
},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if not payment_finalized:
|
||||
await finalize_payment()
|
||||
|
||||
return StreamingResponse(
|
||||
stream_with_cost(),
|
||||
|
||||
@@ -218,6 +218,17 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
results = await asyncio.gather(*tasks)
|
||||
upstreams = [p for p in results if p is not None]
|
||||
|
||||
if (
|
||||
"https://testnut.cashu.space" in settings.cashu_mints
|
||||
and not settings.disable_testnut_mock_upstream
|
||||
):
|
||||
from .fake import MockUpstreamProvider
|
||||
|
||||
mock_provider = MockUpstreamProvider("mock", "mock")
|
||||
await mock_provider.refresh_models_cache()
|
||||
upstreams.append(mock_provider)
|
||||
logger.info("Initialized MockUpstreamProvider for testnut mint")
|
||||
|
||||
return upstreams
|
||||
|
||||
|
||||
|
||||
+7
-9
@@ -40,23 +40,16 @@ async def recieve_token(
|
||||
async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]:
|
||||
"""Internal send function - returns amount and serialized token"""
|
||||
wallet: Wallet = await get_wallet(mint_url or settings.primary_mint, unit)
|
||||
logger.info("Sending", extra={"amount": amount, "unit": unit, "mint_url": mint_url})
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
wallet, mint_url or settings.primary_mint, unit
|
||||
)
|
||||
logger.info("Proofs", extra={"proofs": proofs})
|
||||
logger.info(
|
||||
"Selecting to send",
|
||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||
)
|
||||
|
||||
send_proofs, _ = await wallet.select_to_send(
|
||||
proofs, amount, set_reserved=True, include_fees=False
|
||||
)
|
||||
logger.info("Send proofs", extra={"send_proofs": send_proofs})
|
||||
token = await wallet.serialize_proofs(
|
||||
send_proofs, include_dleq=False, legacy=False, memo=None
|
||||
)
|
||||
logger.info("Token created", extra={"token": token})
|
||||
return amount, token
|
||||
|
||||
|
||||
@@ -88,7 +81,7 @@ async def swap_to_primary_mint(
|
||||
amount_msat = token_amount
|
||||
else:
|
||||
raise ValueError("Invalid unit")
|
||||
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2)) + 1
|
||||
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2))
|
||||
amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000
|
||||
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
|
||||
|
||||
@@ -320,6 +313,11 @@ async def periodic_payout() -> None:
|
||||
try:
|
||||
async with db.create_session() as session:
|
||||
for mint_url in settings.cashu_mints:
|
||||
if (
|
||||
mint_url == "https://testnut.cashu.space"
|
||||
and not settings.disable_testnut_mock_upstream
|
||||
):
|
||||
continue
|
||||
for unit in ["sat", "msat"]:
|
||||
wallet = await get_wallet(mint_url, unit)
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = input("Enter routstr URL: ")
|
||||
API_KEY = input("Enter key or token: ")
|
||||
|
||||
|
||||
async def get_balance(client: httpx.AsyncClient) -> int:
|
||||
response = await client.get("/v1/balance/info")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
print(f"Current Balance Info: {data}")
|
||||
return data.get("reserved", 0)
|
||||
|
||||
|
||||
async def reproduce() -> None:
|
||||
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=BASE_URL, headers=headers, timeout=30.0
|
||||
) as client:
|
||||
print("Checking initial balance...")
|
||||
try:
|
||||
initial_reserved = await get_balance(client)
|
||||
except Exception as e:
|
||||
print(f"Failed to get balance: {e}")
|
||||
return
|
||||
|
||||
print("\nStarting streaming request...")
|
||||
try:
|
||||
# Create a separate client for the stream so we can close it independently if needed,
|
||||
# but usually just breaking the loop and exiting the context manager is enough.
|
||||
# However, to be sure we simulate a harsh disconnect, we can just cancel the task or close the client.
|
||||
|
||||
async with client.stream(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "gpt-5-nano",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Write a long poem about the ocean.",
|
||||
}
|
||||
],
|
||||
"stream": True,
|
||||
},
|
||||
) as response:
|
||||
print(f"Stream status: {response.status_code}")
|
||||
if response.status_code != 200:
|
||||
err_bytes = await response.aread()
|
||||
try:
|
||||
err_str = err_bytes.decode()
|
||||
except Exception:
|
||||
err_str = repr(err_bytes)
|
||||
print(f"Error: {err_str}")
|
||||
return
|
||||
|
||||
print("Stream started. Reading a few chunks...")
|
||||
count = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
print(f"Received chunk: {len(chunk)} bytes")
|
||||
count += 1
|
||||
if count >= 3:
|
||||
print("Simulating client disconnect (breaking stream)...")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"Stream interrupted (expected): {e}")
|
||||
|
||||
# Wait a bit for the server to realize we disconnected (though with asyncio it might be immediate or depend on keepalive)
|
||||
print("\nWaiting for server to process disconnect...")
|
||||
await asyncio.sleep(21)
|
||||
|
||||
print("\nChecking final balance...")
|
||||
try:
|
||||
final_reserved = await get_balance(client)
|
||||
except Exception:
|
||||
# Retry once if connection was closed
|
||||
async with httpx.AsyncClient(
|
||||
base_url=BASE_URL, headers=headers, timeout=30.0
|
||||
) as new_client:
|
||||
final_reserved = await get_balance(new_client)
|
||||
|
||||
if final_reserved > initial_reserved:
|
||||
print(
|
||||
f"\n[FAIL] Bug reproduced! Reserved balance increased: {initial_reserved} -> {final_reserved}"
|
||||
)
|
||||
print(f"Accumulated reserved balance: {final_reserved - initial_reserved}")
|
||||
else:
|
||||
print(
|
||||
f"\n[PASS] Reserved balance released correctly: {initial_reserved} -> {final_reserved}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(reproduce())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
+3
-8
@@ -4,22 +4,17 @@ FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||
RUN npm i
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN pnpm run build
|
||||
RUN npm run build
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+3
-5
@@ -6,14 +6,12 @@ RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY package.json package-lock.json* pnpm-lock.yaml* ./
|
||||
RUN npm ci
|
||||
|
||||
# Build the UI
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
@@ -29,7 +27,7 @@ ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Build the application
|
||||
RUN pnpm run build && \
|
||||
RUN npm run build && \
|
||||
echo "UI build completed at $(date)"
|
||||
|
||||
# Use the builder stage as the final stage
|
||||
|
||||
@@ -208,12 +208,7 @@ function ProviderBalance({
|
||||
return <Skeleton className='h-9 w-24' />;
|
||||
}
|
||||
|
||||
if (
|
||||
error ||
|
||||
!balanceData?.ok ||
|
||||
balanceData.balance_data === undefined ||
|
||||
balanceData.balance_data === null
|
||||
) {
|
||||
if (error || !balanceData?.ok || !balanceData.balance_data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Generated
+9257
File diff suppressed because it is too large
Load Diff
+9
-10
@@ -15,7 +15,6 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.10",
|
||||
"@radix-ui/react-avatar": "^1.1.6",
|
||||
@@ -41,17 +40,17 @@
|
||||
"@radix-ui/react-toggle": "^1.1.6",
|
||||
"@radix-ui/react-toggle-group": "^1.1.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.3",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "15.5.9",
|
||||
"lucide-react": "^0.501.0",
|
||||
"next": "15.3.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode.react": "^4.2.0",
|
||||
@@ -68,21 +67,21 @@
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tanstack/react-query-devtools": "^5.91.2",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tanstack/react-query-devtools": "^5.74.4",
|
||||
"@types/node": "^20",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-next": "15.5.9",
|
||||
"eslint-config-next": "15.3.1",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+544
-544
File diff suppressed because it is too large
Load Diff
+1
-7
@@ -22,12 +22,6 @@
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user