mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
9 Commits
fix/zero-c
...
the-refact
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec6a3aadf1 | ||
|
|
9094a1a493 | ||
|
|
c9176e5090 | ||
|
|
3d71588fd7 | ||
|
|
21d5e3762b | ||
|
|
7aa035fe37 | ||
|
|
bd3aacd1f5 | ||
|
|
9be8aba417 | ||
|
|
fa313e4534 |
@@ -34,7 +34,7 @@ sequenceDiagram
|
||||
- **Model-Based Pricing** – Convert USD prices in `models.json` to sats using live BTC/USD rates
|
||||
- **Admin Dashboard** – Simple HTML interface at `/admin/` to view balances and API keys
|
||||
- **Discovery** – Fetch available providers from Nostr relays using NIP-91 protocol
|
||||
- **NIP-91 Auto-Announcement** – Automatically announce this provider to Nostr relays when NSEC is provided
|
||||
- **Auto-Listing** – Automatically announce this provider to Nostr relays when NSEC is provided
|
||||
- **Docker Support** – Provided `Dockerfile` and `compose.yml` for running with an optional Tor hidden service
|
||||
|
||||
## Getting Started
|
||||
@@ -99,7 +99,7 @@ The most common settings are shown below. See `.env.example` for the full list.
|
||||
- `NPUB` – Nostr public key of the proxy
|
||||
- `HTTP_URL` – Public-facing URL of the proxy
|
||||
- `ONION_URL` – Tor hidden service URL of the proxy
|
||||
- `NEXT_PUBLIC_API_URL` - UI Configuration for Next.js frontend (proxy URL, default: 'http://127.0.0.1:8000' )
|
||||
- `NEXT_PUBLIC_API_URL` - UI Configuration for Next.js frontend (proxy URL, default: '<http://127.0.0.1:8000>' )
|
||||
|
||||
## Database Migrations
|
||||
|
||||
|
||||
60
migrations/versions/5f6ac1e4fa9f_the_refactor.py
Normal file
60
migrations/versions/5f6ac1e4fa9f_the_refactor.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""the refactor
|
||||
|
||||
Revision ID: 5f6ac1e4fa9f
|
||||
Revises: a1a1a1a1a1a1
|
||||
Create Date: 2025-11-28 13:31:49.796461
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "5f6ac1e4fa9f"
|
||||
down_revision = "a1a1a1a1a1a1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Rename the table
|
||||
op.rename_table("api_keys", "temporary_credit")
|
||||
|
||||
op.add_column(
|
||||
"temporary_credit", sa.Column("created", sa.DateTime(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"temporary_credit",
|
||||
sa.Column("refund_expiration_time", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.drop_column("temporary_credit", "key_expiry_time")
|
||||
op.drop_column("temporary_credit", "total_spent")
|
||||
op.drop_column("temporary_credit", "total_requests")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"temporary_credit",
|
||||
sa.Column(
|
||||
"total_requests",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"temporary_credit",
|
||||
sa.Column(
|
||||
"total_spent",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"temporary_credit", sa.Column("key_expiry_time", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.drop_column("temporary_credit", "refund_expiration_time")
|
||||
op.drop_column("temporary_credit", "created")
|
||||
|
||||
# Revert table rename
|
||||
op.rename_table("temporary_credit", "api_keys")
|
||||
713
routstr/auth.py
713
routstr/auth.py
@@ -1,661 +1,98 @@
|
||||
import hashlib
|
||||
import math
|
||||
from typing import Optional
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import col, update
|
||||
|
||||
from .core import get_logger
|
||||
from .core.db import ApiKey, AsyncSession
|
||||
from .core.db import AsyncSession, TemporaryCredit, get_session
|
||||
from .core.settings import settings
|
||||
from .payment.cost_calculation import (
|
||||
CostData,
|
||||
CostDataError,
|
||||
MaxCostData,
|
||||
calculate_cost,
|
||||
)
|
||||
from .wallet import credit_balance, deserialize_token_from_string
|
||||
from .payment.wallet import credit_balance, deserialize_token_from_string
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# TODO: implement prepaid api key (not like it was before)
|
||||
# PREPAID_API_KEY = os.environ.get("PREPAID_API_KEY", None)
|
||||
# PREPAID_BALANCE = int(os.environ.get("PREPAID_BALANCE", "0")) * 1000 # Convert to msats
|
||||
|
||||
async def api_key_to_credit(key: str, db_session: AsyncSession) -> TemporaryCredit:
|
||||
key = key[3:] if key.startswith("sk-") else key
|
||||
if existing_credit := await db_session.get(TemporaryCredit, key):
|
||||
return existing_credit
|
||||
|
||||
raise HTTPException(status_code=401, detail="Invalid API-KEY")
|
||||
|
||||
|
||||
async def validate_bearer_key(
|
||||
bearer_key: str,
|
||||
session: AsyncSession,
|
||||
refund_address: Optional[str] = None,
|
||||
key_expiry_time: Optional[int] = None,
|
||||
) -> ApiKey:
|
||||
"""
|
||||
Validates the provided API key using SQLModel.
|
||||
If it's a cashu key, it redeems it and stores its hash and balance.
|
||||
Otherwise checks if the hash of the key exists.
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting bearer key validation",
|
||||
extra={
|
||||
"key_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
"has_refund_address": bool(refund_address),
|
||||
"has_expiry_time": bool(key_expiry_time),
|
||||
},
|
||||
async def cashu_token_to_credit(
|
||||
cashu_token: str, db_session: AsyncSession
|
||||
) -> TemporaryCredit:
|
||||
try:
|
||||
token_hash = hashlib.sha256(cashu_token.encode()).hexdigest()
|
||||
token_obj = deserialize_token_from_string(cashu_token)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
|
||||
if existing_credit := await db_session.get(TemporaryCredit, token_hash):
|
||||
return existing_credit
|
||||
|
||||
# create new TemporaryCredit
|
||||
if token_obj.mint in settings.cashu_mints:
|
||||
refund_currency = token_obj.unit
|
||||
refund_mint_url = token_obj.mint
|
||||
else:
|
||||
refund_mint_url = settings.primary_mint
|
||||
refund_currency = "sat"
|
||||
|
||||
new_credit = TemporaryCredit(
|
||||
hashed_key=token_hash,
|
||||
refund_currency=refund_currency,
|
||||
refund_mint_url=refund_mint_url,
|
||||
)
|
||||
db_session.add(new_credit)
|
||||
|
||||
if not bearer_key:
|
||||
logger.error("Empty bearer key provided")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "API key or Cashu token required",
|
||||
"type": "invalid_request_error",
|
||||
"code": "missing_api_key",
|
||||
}
|
||||
},
|
||||
try:
|
||||
await db_session.flush()
|
||||
except IntegrityError: # fallback to api key in case of race condition
|
||||
await db_session.rollback()
|
||||
return await api_key_to_credit(f"sk-{token_hash}", db_session)
|
||||
|
||||
try:
|
||||
msats = await credit_balance(cashu_token, new_credit, db_session)
|
||||
except Exception as e:
|
||||
await db_session.rollback()
|
||||
logger.error(
|
||||
"Token redemption failed",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Token redemption failed")
|
||||
|
||||
if bearer_key.startswith("sk-"):
|
||||
logger.debug(
|
||||
"Processing sk- prefixed API key",
|
||||
extra={"key_preview": bearer_key[:10] + "..."},
|
||||
)
|
||||
|
||||
if existing_key := await session.get(ApiKey, bearer_key[3:]):
|
||||
logger.info(
|
||||
"Existing sk- API key found",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"balance": existing_key.balance,
|
||||
"total_requests": existing_key.total_requests,
|
||||
},
|
||||
)
|
||||
|
||||
if key_expiry_time is not None:
|
||||
existing_key.key_expiry_time = key_expiry_time
|
||||
logger.debug(
|
||||
"Updated key expiry time",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"expiry_time": key_expiry_time,
|
||||
},
|
||||
)
|
||||
|
||||
if refund_address is not None:
|
||||
existing_key.refund_address = refund_address
|
||||
logger.debug(
|
||||
"Updated refund address",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"refund_address_preview": refund_address[:20] + "..."
|
||||
if len(refund_address) > 20
|
||||
else refund_address,
|
||||
},
|
||||
)
|
||||
|
||||
return existing_key
|
||||
else:
|
||||
logger.warning(
|
||||
"sk- API key not found in database",
|
||||
extra={"key_preview": bearer_key[:10] + "..."},
|
||||
)
|
||||
|
||||
if bearer_key.startswith("cashu"):
|
||||
logger.debug(
|
||||
"Processing Cashu token",
|
||||
extra={
|
||||
"token_preview": bearer_key[:20] + "...",
|
||||
"token_type": bearer_key[:6] if len(bearer_key) >= 6 else bearer_key,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
|
||||
token_obj = deserialize_token_from_string(bearer_key)
|
||||
logger.debug(
|
||||
"Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."}
|
||||
)
|
||||
|
||||
if existing_key := await session.get(ApiKey, hashed_key):
|
||||
logger.info(
|
||||
"Existing Cashu token found",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"balance": existing_key.balance,
|
||||
"total_requests": existing_key.total_requests,
|
||||
},
|
||||
)
|
||||
|
||||
if key_expiry_time is not None:
|
||||
existing_key.key_expiry_time = key_expiry_time
|
||||
logger.debug(
|
||||
"Updated key expiry time for existing Cashu key",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"expiry_time": key_expiry_time,
|
||||
},
|
||||
)
|
||||
|
||||
if refund_address is not None:
|
||||
existing_key.refund_address = refund_address
|
||||
logger.debug(
|
||||
"Updated refund address for existing Cashu key",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"refund_address_preview": refund_address[:20] + "..."
|
||||
if len(refund_address) > 20
|
||||
else refund_address,
|
||||
},
|
||||
)
|
||||
|
||||
return existing_key
|
||||
|
||||
logger.info(
|
||||
"Creating new Cashu token entry",
|
||||
extra={
|
||||
"hash_preview": hashed_key[:16] + "...",
|
||||
"has_refund_address": bool(refund_address),
|
||||
"has_expiry_time": bool(key_expiry_time),
|
||||
},
|
||||
)
|
||||
if token_obj.mint in settings.cashu_mints:
|
||||
refund_currency = token_obj.unit
|
||||
refund_mint_url = token_obj.mint
|
||||
else:
|
||||
refund_currency = "sat"
|
||||
refund_mint_url = settings.primary_mint
|
||||
|
||||
new_key = ApiKey(
|
||||
hashed_key=hashed_key,
|
||||
balance=0,
|
||||
refund_address=refund_address,
|
||||
key_expiry_time=key_expiry_time,
|
||||
refund_currency=refund_currency,
|
||||
refund_mint_url=refund_mint_url,
|
||||
)
|
||||
session.add(new_key)
|
||||
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
logger.info(
|
||||
"Concurrent key creation detected, fetching existing key",
|
||||
extra={"key_hash": hashed_key[:8] + "..."},
|
||||
)
|
||||
existing_key = await session.get(ApiKey, hashed_key)
|
||||
if not existing_key:
|
||||
raise Exception("Failed to fetch existing key after IntegrityError")
|
||||
|
||||
if key_expiry_time is not None:
|
||||
existing_key.key_expiry_time = key_expiry_time
|
||||
if refund_address is not None:
|
||||
existing_key.refund_address = refund_address
|
||||
|
||||
return existing_key
|
||||
|
||||
logger.debug(
|
||||
"New key created, starting token redemption",
|
||||
extra={"key_hash": hashed_key[:8] + "..."},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"AUTH: About to call credit_balance",
|
||||
extra={"token_preview": bearer_key[:50]},
|
||||
)
|
||||
try:
|
||||
msats = await credit_balance(bearer_key, new_key, session)
|
||||
logger.info(
|
||||
"AUTH: credit_balance returned successfully", extra={"msats": msats}
|
||||
)
|
||||
except Exception as credit_error:
|
||||
logger.error(
|
||||
"AUTH: credit_balance failed",
|
||||
extra={
|
||||
"error": str(credit_error),
|
||||
"error_type": type(credit_error).__name__,
|
||||
},
|
||||
)
|
||||
raise credit_error
|
||||
|
||||
if msats <= 0:
|
||||
logger.error(
|
||||
"Token redemption returned zero or negative amount",
|
||||
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
|
||||
)
|
||||
raise Exception("Token redemption failed")
|
||||
|
||||
await session.refresh(new_key)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"New Cashu token successfully redeemed and stored",
|
||||
extra={
|
||||
"key_hash": hashed_key[:8] + "...",
|
||||
"redeemed_msats": msats,
|
||||
"final_balance": new_key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
return new_key
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Cashu token redemption failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"token_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Invalid or expired Cashu key: {str(e)}",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
logger.error(
|
||||
"Invalid API key format",
|
||||
extra={
|
||||
"key_preview": bearer_key[:10] + "..."
|
||||
if len(bearer_key) > 10
|
||||
else bearer_key,
|
||||
"key_length": len(bearer_key),
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "Invalid API key",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def pay_for_request(
|
||||
key: ApiKey, cost_per_request: int, session: AsyncSession
|
||||
) -> int:
|
||||
"""Process payment for a request."""
|
||||
await db_session.refresh(new_credit)
|
||||
await db_session.commit()
|
||||
|
||||
logger.info(
|
||||
"Processing payment for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"current_balance": key.balance,
|
||||
"required_cost": cost_per_request,
|
||||
"sufficient_balance": key.balance >= cost_per_request,
|
||||
},
|
||||
"New TemporaryCredit created from CashuToken",
|
||||
extra={"key_hash": token_hash[:8] + "...", "amount_msats": msats},
|
||||
)
|
||||
|
||||
if key.total_balance < cost_per_request:
|
||||
logger.warning(
|
||||
"Insufficient balance for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"balance": key.balance,
|
||||
"reserved_balance": key.reserved_balance,
|
||||
"required": cost_per_request,
|
||||
"shortfall": cost_per_request - key.total_balance,
|
||||
},
|
||||
)
|
||||
return new_credit
|
||||
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.total_balance} available. (reserved: {key.reserved_balance})",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Charging base cost for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"cost": cost_per_request,
|
||||
"balance_before": key.balance,
|
||||
},
|
||||
async def nwc_to_credit(
|
||||
connection_string: str, db_session: AsyncSession
|
||||
) -> TemporaryCredit:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
async def get_credit(
|
||||
authorization: Annotated[str, Header(...)],
|
||||
db_session: AsyncSession = Depends(get_session),
|
||||
) -> TemporaryCredit:
|
||||
authorization = (
|
||||
authorization[7:] if authorization.startswith("Bearer ") else authorization
|
||||
)
|
||||
|
||||
# Charge the base cost for the request atomically to avoid race conditions
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) >= cost_per_request)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Concurrent request depleted balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"required_cost": cost_per_request,
|
||||
"current_balance": key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
# Another concurrent request spent the balance first
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Payment processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost_per_request,
|
||||
"new_balance": key.balance,
|
||||
"total_spent": key.total_spent,
|
||||
"total_requests": key.total_requests,
|
||||
},
|
||||
)
|
||||
|
||||
return cost_per_request
|
||||
|
||||
|
||||
async def revert_pay_for_request(
|
||||
key: ApiKey, session: AsyncSession, cost_per_request: int
|
||||
) -> None:
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
)
|
||||
)
|
||||
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to revert payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"cost_to_revert": cost_per_request,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"failed to revert request payment: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"type": "payment_error",
|
||||
"code": "payment_error",
|
||||
}
|
||||
},
|
||||
)
|
||||
await session.refresh(key)
|
||||
|
||||
|
||||
async def adjust_payment_for_tokens(
|
||||
key: ApiKey, response_data: dict, session: AsyncSession, deducted_max_cost: int
|
||||
) -> dict:
|
||||
"""
|
||||
Adjusts the payment based on token usage in the response.
|
||||
This is called after the initial payment and the upstream request is complete.
|
||||
Returns cost data to be included in the response.
|
||||
"""
|
||||
model = response_data.get("model", "unknown")
|
||||
|
||||
logger.debug(
|
||||
"Starting payment adjustment for tokens",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_balance": key.balance,
|
||||
"has_usage": "usage" in response_data,
|
||||
},
|
||||
)
|
||||
|
||||
match await calculate_cost(response_data, deducted_max_cost, session):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost data (no token adjustment)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"max_cost": cost.total_msats,
|
||||
},
|
||||
)
|
||||
# Finalize by releasing reservation and charging max cost
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - cost.total_msats,
|
||||
total_spent=col(ApiKey.total_spent) + cost.total_msats,
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize max-cost payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"total_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
await session.refresh(key)
|
||||
logger.info(
|
||||
"Max cost payment finalized",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost.total_msats,
|
||||
"new_balance": key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
return cost.dict()
|
||||
|
||||
case CostData() as cost:
|
||||
# If token-based pricing is enabled and base cost is 0, use token-based cost
|
||||
# Otherwise, token cost is additional to the base cost
|
||||
cost_difference = cost.total_msats - deducted_max_cost
|
||||
total_cost_msats: int = math.ceil(cost.total_msats)
|
||||
|
||||
logger.info(
|
||||
"Calculated token-based cost",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"token_cost": cost.total_msats,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"cost_difference": cost_difference,
|
||||
"input_msats": cost.input_msats,
|
||||
"output_msats": cost.output_msats,
|
||||
},
|
||||
)
|
||||
|
||||
if cost_difference == 0:
|
||||
logger.debug(
|
||||
"Finalizing with exact reserved cost",
|
||||
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
||||
)
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - total_cost_msats,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
)
|
||||
)
|
||||
await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
return cost.dict()
|
||||
|
||||
# this should never happen why do we handle this???
|
||||
if cost_difference > 0:
|
||||
# Need to charge more than reserved, finalize by releasing reservation and charging total
|
||||
logger.info(
|
||||
"Additional charge required for token usage",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"additional_charge": cost_difference,
|
||||
"current_balance": key.balance,
|
||||
"sufficient_balance": key.balance >= cost_difference,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - total_cost_msats,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount:
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Finalized payment with additional charge",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"charged_amount": total_cost_msats,
|
||||
"new_balance": key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to finalize additional charge (concurrent operation)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"attempted_charge": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Refund some of the base cost
|
||||
refund = abs(cost_difference)
|
||||
logger.info(
|
||||
"Refunding excess payment",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"refund_amount": refund,
|
||||
"current_balance": key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
refund_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - total_cost_msats,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
)
|
||||
)
|
||||
result = await session.exec(refund_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"total_cost": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
# Still return the cost data even if we couldn't properly finalize
|
||||
# The reservation was already made, so the user has paid
|
||||
|
||||
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",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"error_message": error.message,
|
||||
"error_code": error.code,
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"message": error.message,
|
||||
"type": "invalid_request_error",
|
||||
"code": error.code,
|
||||
}
|
||||
},
|
||||
)
|
||||
# Fallback return to satisfy type checker; execution should not reach here
|
||||
return {
|
||||
"base_msats": deducted_max_cost,
|
||||
"input_msats": 0,
|
||||
"output_msats": 0,
|
||||
"total_msats": deducted_max_cost,
|
||||
}
|
||||
if authorization.startswith("cashu"):
|
||||
return await cashu_token_to_credit(authorization, db_session)
|
||||
elif authorization.startswith("sk-"):
|
||||
return await api_key_to_credit(authorization, db_session)
|
||||
elif authorization.startswith("nwc"):
|
||||
return await nwc_to_credit(authorization, db_session)
|
||||
else:
|
||||
raise HTTPException(status_code=401, detail="Unable to parse bearer token")
|
||||
|
||||
@@ -6,11 +6,12 @@ from typing import Annotated, NoReturn
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .auth import validate_bearer_key
|
||||
from .core.db import ApiKey, AsyncSession, get_session
|
||||
from .auth import get_credit
|
||||
from .core.db import AsyncSession, TemporaryCredit, get_session
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
|
||||
from .payment.lnurl import send_to_lnurl
|
||||
from .payment.wallet import credit_balance, recieve_token, send_token
|
||||
|
||||
router = APIRouter()
|
||||
balance_router = APIRouter(prefix="/v1/balance")
|
||||
@@ -18,26 +19,15 @@ balance_router = APIRouter(prefix="/v1/balance")
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def get_key_from_header(
|
||||
authorization: Annotated[str, Header(...)],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ApiKey:
|
||||
if authorization.startswith("Bearer "):
|
||||
return await validate_bearer_key(authorization[7:], session)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid authorization. Use 'Bearer <cashu-token>' or 'Bearer <api-key>'",
|
||||
)
|
||||
|
||||
|
||||
# TODO: remove this endpoint when frontend is updated
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
async def account_info(
|
||||
credit: TemporaryCredit = Depends(get_credit),
|
||||
) -> dict:
|
||||
return {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
"reserved": key.reserved_balance,
|
||||
"api_key": credit.api_key,
|
||||
"balance": credit.balance,
|
||||
"reserved": credit.reserved_balance,
|
||||
}
|
||||
|
||||
|
||||
@@ -57,19 +47,18 @@ async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
async def create_balance(
|
||||
initial_balance_token: str, session: AsyncSession = Depends(get_session)
|
||||
) -> dict:
|
||||
key = await validate_bearer_key(initial_balance_token, session)
|
||||
return {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
}
|
||||
credit = await get_credit(initial_balance_token, session)
|
||||
return {"api_key": credit.api_key, "balance": credit.balance}
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
async def wallet_info(
|
||||
credit: TemporaryCredit = Depends(get_credit),
|
||||
) -> dict:
|
||||
return {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
"reserved": key.reserved_balance,
|
||||
"api_key": credit.api_key,
|
||||
"balance": credit.balance,
|
||||
"reserved": credit.reserved_balance,
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +70,7 @@ class TopupRequest(BaseModel):
|
||||
async def topup_wallet_endpoint(
|
||||
cashu_token: str | None = None,
|
||||
topup_request: TopupRequest | None = None,
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
credit: TemporaryCredit = Depends(get_credit),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, int]:
|
||||
if topup_request is not None:
|
||||
@@ -93,7 +82,7 @@ async def topup_wallet_endpoint(
|
||||
if len(cashu_token) < 10 or "cashu" not in cashu_token:
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
try:
|
||||
amount_msats = await credit_balance(cashu_token, key, session)
|
||||
amount_msats = await credit_balance(cashu_token, credit, session)
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
if "already spent" in error_msg.lower():
|
||||
@@ -152,10 +141,11 @@ async def refund_wallet_endpoint(
|
||||
if cached := await _refund_cache_get(bearer_value):
|
||||
return cached
|
||||
|
||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||
key: TemporaryCredit = await get_credit(bearer_value, session)
|
||||
remaining_balance_msats: int = key.balance
|
||||
refund_currency = key.refund_currency or "sat"
|
||||
|
||||
if key.refund_currency == "sat":
|
||||
if refund_currency == "sat":
|
||||
remaining_balance = remaining_balance_msats // 1000
|
||||
else:
|
||||
remaining_balance = remaining_balance_msats
|
||||
@@ -172,19 +162,18 @@ async def refund_wallet_endpoint(
|
||||
|
||||
await send_to_lnurl(
|
||||
remaining_balance,
|
||||
key.refund_currency or "sat",
|
||||
refund_currency,
|
||||
key.refund_mint_url or global_settings.primary_mint,
|
||||
key.refund_address,
|
||||
)
|
||||
result = {"recipient": key.refund_address}
|
||||
else:
|
||||
refund_currency = key.refund_currency or "sat"
|
||||
token = await send_token(
|
||||
remaining_balance, refund_currency, key.refund_mint_url
|
||||
)
|
||||
result = {"token": token}
|
||||
|
||||
if key.refund_currency == "sat":
|
||||
if refund_currency == "sat":
|
||||
result["sats"] = str(remaining_balance_msats // 1000)
|
||||
else:
|
||||
result["msats"] = str(remaining_balance_msats)
|
||||
|
||||
@@ -8,16 +8,16 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
|
||||
from ..payment.models import _row_to_model, list_models
|
||||
from ..proxy import refresh_model_maps, reinitialize_upstreams
|
||||
from ..wallet import (
|
||||
from ..models.crud import row_to_model, list_models
|
||||
from ..payment.wallet import (
|
||||
fetch_all_balances,
|
||||
get_proofs_per_mint_and_unit,
|
||||
get_wallet,
|
||||
send_token,
|
||||
slow_filter_spend_proofs,
|
||||
)
|
||||
from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session
|
||||
from ..proxy import refresh_model_maps, reinitialize_upstreams
|
||||
from .db import ModelRow, TemporaryCredit, UpstreamProviderRow, create_session
|
||||
from .log_manager import log_manager
|
||||
from .logging import get_logger
|
||||
from .settings import SettingsService, settings
|
||||
@@ -107,13 +107,13 @@ async def partial_balances(request: Request) -> str:
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/partials/apikeys",
|
||||
"/partials/TemporaryCredits",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def partial_apikeys(request: Request) -> str:
|
||||
async def partial_TemporaryCredits(request: Request) -> str:
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(ApiKey))
|
||||
result = await session.exec(select(TemporaryCredit))
|
||||
api_keys = result.all()
|
||||
|
||||
def fmt_time(ts: int | None) -> str:
|
||||
@@ -124,7 +124,7 @@ async def partial_apikeys(request: Request) -> str:
|
||||
|
||||
rows = "".join(
|
||||
[
|
||||
f"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{fmt_time(key.key_expiry_time)}</td></tr>"
|
||||
f"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.refund_address}</td><td>{key.refund_mint_url}</td><td>{key.refund_currency}</td><td>{fmt_time(key.refund_expiration_time)}</td></tr>"
|
||||
for key in api_keys
|
||||
]
|
||||
)
|
||||
@@ -134,9 +134,9 @@ async def partial_apikeys(request: Request) -> str:
|
||||
<tr>
|
||||
<th>Hashed Key</th>
|
||||
<th>Balance (mSats)</th>
|
||||
<th>Total Spent (mSats)</th>
|
||||
<th>Total Requests</th>
|
||||
<th>Refund Address</th>
|
||||
<th>Refund Mint</th>
|
||||
<th>Currency</th>
|
||||
<th>Refund Time</th>
|
||||
</tr>
|
||||
{rows}
|
||||
@@ -147,17 +147,17 @@ async def partial_apikeys(request: Request) -> str:
|
||||
@admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)])
|
||||
async def get_temporary_balances_api(request: Request) -> list[dict[str, object]]:
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(ApiKey))
|
||||
result = await session.exec(select(TemporaryCredit))
|
||||
api_keys = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"hashed_key": key.hashed_key,
|
||||
"balance": key.balance,
|
||||
"total_spent": key.total_spent,
|
||||
"total_requests": key.total_requests,
|
||||
"refund_address": key.refund_address,
|
||||
"key_expiry_time": key.key_expiry_time,
|
||||
"refund_mint_url": key.refund_mint_url,
|
||||
"refund_currency": key.refund_currency,
|
||||
"refund_expiration_time": key.refund_expiration_time,
|
||||
}
|
||||
for key in api_keys
|
||||
]
|
||||
@@ -777,8 +777,8 @@ async def dashboard(request: Request) -> str:
|
||||
<p><em>Save this token! It represents your withdrawn balance.</em></p>
|
||||
</div>
|
||||
|
||||
<div id="apikeys-table"
|
||||
hx-get="/admin/partials/apikeys"
|
||||
<div id="TemporaryCredits-table"
|
||||
hx-get="/admin/partials/TemporaryCredits"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML">
|
||||
<h2>Temporary Balances</h2>
|
||||
@@ -1763,9 +1763,9 @@ UPSTREAM_PROVIDERS_JS: str = """<!--html-->
|
||||
provider_fee: feeValue ? parseFloat(feeValue) : defaultFee,
|
||||
};
|
||||
|
||||
const apiKey = document.getElementById('provider-api-key').value;
|
||||
if (apiKey) {
|
||||
payload.api_key = apiKey;
|
||||
const TemporaryCredit = document.getElementById('provider-api-key').value;
|
||||
if (TemporaryCredit) {
|
||||
payload.api_key = TemporaryCredit;
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById('provider-save-btn');
|
||||
@@ -1783,7 +1783,7 @@ UPSTREAM_PROVIDERS_JS: str = """<!--html-->
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} else {
|
||||
if (!apiKey) {
|
||||
if (!TemporaryCredit) {
|
||||
throw new Error('API Key is required for new providers');
|
||||
}
|
||||
resp = await fetch('/admin/api/upstream-providers', {
|
||||
@@ -2456,7 +2456,7 @@ async def create_provider_model(
|
||||
await session.refresh(row)
|
||||
|
||||
await refresh_model_maps()
|
||||
return _row_to_model(
|
||||
return row_to_model(
|
||||
row, apply_provider_fee=True, provider_fee=provider.provider_fee
|
||||
).dict() # type: ignore
|
||||
|
||||
@@ -2476,7 +2476,7 @@ async def get_provider_model(provider_id: int, model_id: str) -> dict[str, objec
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Model not found for this provider"
|
||||
)
|
||||
return _row_to_model(
|
||||
return row_to_model(
|
||||
row, apply_provider_fee=True, provider_fee=provider.provider_fee
|
||||
).dict() # type: ignore
|
||||
|
||||
@@ -2525,7 +2525,7 @@ async def update_provider_model(
|
||||
await session.refresh(row)
|
||||
|
||||
if was_disabled and payload.enabled:
|
||||
from ..payment.models import _cleanup_enabled_models_once
|
||||
from ..models.models import _cleanup_enabled_models_once
|
||||
|
||||
try:
|
||||
await _cleanup_enabled_models_once()
|
||||
@@ -2536,7 +2536,7 @@ async def update_provider_model(
|
||||
)
|
||||
|
||||
await refresh_model_maps()
|
||||
return _row_to_model(
|
||||
return row_to_model(
|
||||
row, apply_provider_fee=True, provider_fee=provider.provider_fee
|
||||
).dict() # type: ignore
|
||||
|
||||
@@ -2796,7 +2796,7 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def get_openrouter_presets() -> list[dict[str, object]]:
|
||||
from ..payment.models import async_fetch_openrouter_models
|
||||
from ..models import async_fetch_openrouter_models
|
||||
|
||||
models_data = await async_fetch_openrouter_models()
|
||||
return models_data
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from alembic import command
|
||||
@@ -18,39 +19,40 @@ DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///keys.db")
|
||||
engine = create_async_engine(DATABASE_URL, echo=False) # echo=True for debugging SQL
|
||||
|
||||
|
||||
class ApiKey(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "api_keys"
|
||||
class TemporaryCredit(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "temporary_credit"
|
||||
|
||||
hashed_key: str = Field(primary_key=True)
|
||||
balance: int = Field(default=0, description="Balance in millisatoshis (msats)")
|
||||
reserved_balance: int = Field(
|
||||
default=0, description="Reserved balance in millisatoshis (msats)"
|
||||
)
|
||||
balance: int = Field(default=0, description="Balance in msats")
|
||||
reserved_balance: int = Field(default=0, description="Blocked balance in msats")
|
||||
created: datetime | None = Field(None, description="Timestamp of creation")
|
||||
refund_address: str | None = Field(
|
||||
default=None,
|
||||
description="Lightning address to refund remaining balance after key expires",
|
||||
None, description="Address to refund on expiration"
|
||||
)
|
||||
key_expiry_time: int | None = Field(
|
||||
default=None,
|
||||
description="Unix-timestamp after which the cashu-token's balance gets refunded to the refund_address",
|
||||
)
|
||||
total_spent: int = Field(
|
||||
default=0, description="Total spent in millisatoshis (msats)"
|
||||
)
|
||||
total_requests: int = Field(default=0)
|
||||
refund_mint_url: str | None = Field(
|
||||
default=None,
|
||||
description="URL of the mint used to create the cashu-token",
|
||||
default=None, description="Mint used to issue the refund token"
|
||||
)
|
||||
refund_currency: str | None = Field(
|
||||
default=None,
|
||||
description="Currency of the cashu-token",
|
||||
refund_currency: str | None = Field(None, description="Currency of the cashu-token")
|
||||
refund_expiration_time: int | None = Field(
|
||||
None, description="Refund not allowed after timeout"
|
||||
)
|
||||
|
||||
@property
|
||||
def total_balance(self) -> int:
|
||||
def total_balance_msat(self) -> int:
|
||||
return self.balance - self.reserved_balance
|
||||
|
||||
@property
|
||||
def total_balance_sat(self) -> int:
|
||||
return self.total_balance_msat // 1000
|
||||
|
||||
@property
|
||||
def total_balance(self) -> int:
|
||||
return self.total_balance_msat
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
return "sk-" + self.hashed_key
|
||||
|
||||
|
||||
class ModelRow(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "models"
|
||||
@@ -95,8 +97,9 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
async def balances_for_mint_and_unit(
|
||||
db_session: AsyncSession, mint_url: str, unit: str
|
||||
) -> int:
|
||||
query = select(func.sum(ApiKey.balance)).where(
|
||||
ApiKey.refund_mint_url == mint_url, ApiKey.refund_currency == unit
|
||||
query = select(func.sum(TemporaryCredit.balance)).where(
|
||||
TemporaryCredit.refund_mint_url == mint_url,
|
||||
TemporaryCredit.refund_currency == unit,
|
||||
)
|
||||
result = await db_session.exec(query)
|
||||
return result.one() or 0
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import RedirectResponse
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from ..balance import balance_router, deprecated_wallet_router
|
||||
from ..discovery import providers_cache_refresher, providers_router
|
||||
from ..nip91 import announce_provider
|
||||
from ..payment.models import (
|
||||
cleanup_enabled_models_periodically,
|
||||
models_router,
|
||||
update_sats_pricing,
|
||||
)
|
||||
from ..payment.price import update_prices_periodically
|
||||
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
||||
from ..wallet import periodic_payout
|
||||
from ..models.models import models_router
|
||||
from ..nostr.discovery import providers_router
|
||||
from ..proxy import proxy_router
|
||||
from .admin import admin_router
|
||||
from .db import create_session, init_db, run_migrations
|
||||
from .exceptions import general_exception_handler, http_exception_handler
|
||||
from .logging import get_logger, setup_logging
|
||||
from .middleware import LoggingMiddleware
|
||||
from .settings import SettingsService
|
||||
from .settings import settings as global_settings
|
||||
from .tasks import lifespan
|
||||
from .ui import setup_ui_routes
|
||||
|
||||
# Initialize logging first
|
||||
setup_logging()
|
||||
@@ -39,119 +27,6 @@ else:
|
||||
__version__ = "0.2.1"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logger.info("Application startup initiated", extra={"version": __version__})
|
||||
|
||||
btc_price_task = None
|
||||
pricing_task = None
|
||||
payout_task = None
|
||||
nip91_task = None
|
||||
providers_task = None
|
||||
models_refresh_task = None
|
||||
models_cleanup_task = None
|
||||
model_maps_refresh_task = None
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
# This ensures the database schema is always up-to-date in production
|
||||
# Migrations are idempotent - running them multiple times is safe
|
||||
logger.info("Running database migrations")
|
||||
run_migrations()
|
||||
|
||||
# Initialize database connection pools
|
||||
# This creates any tables that might not be tracked by migrations yet
|
||||
await init_db()
|
||||
|
||||
# Initialize application settings (env -> computed -> DB precedence)
|
||||
async with create_session() as session:
|
||||
s = await SettingsService.initialize(session)
|
||||
|
||||
# Apply app metadata from settings
|
||||
try:
|
||||
app.title = s.name
|
||||
app.description = s.description
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# await ensure_models_bootstrapped()
|
||||
|
||||
from ..payment.price import _update_prices
|
||||
from ..proxy import get_upstreams
|
||||
from ..upstream.helpers import refresh_upstreams_models_periodically
|
||||
|
||||
await _update_prices()
|
||||
await initialize_upstreams()
|
||||
|
||||
btc_price_task = asyncio.create_task(update_prices_periodically())
|
||||
pricing_task = asyncio.create_task(update_sats_pricing())
|
||||
if global_settings.models_refresh_interval_seconds > 0:
|
||||
models_refresh_task = asyncio.create_task(
|
||||
refresh_upstreams_models_periodically(get_upstreams())
|
||||
)
|
||||
models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically())
|
||||
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
|
||||
payout_task = asyncio.create_task(periodic_payout())
|
||||
nip91_task = asyncio.create_task(announce_provider())
|
||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||
|
||||
yield
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Application startup failed",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
logger.info("Application shutdown initiated")
|
||||
|
||||
if btc_price_task is not None:
|
||||
btc_price_task.cancel()
|
||||
if pricing_task is not None:
|
||||
pricing_task.cancel()
|
||||
if payout_task is not None:
|
||||
payout_task.cancel()
|
||||
if nip91_task is not None:
|
||||
nip91_task.cancel()
|
||||
if providers_task is not None:
|
||||
providers_task.cancel()
|
||||
if models_refresh_task is not None:
|
||||
models_refresh_task.cancel()
|
||||
if models_cleanup_task is not None:
|
||||
models_cleanup_task.cancel()
|
||||
if model_maps_refresh_task is not None:
|
||||
model_maps_refresh_task.cancel()
|
||||
|
||||
try:
|
||||
tasks_to_wait = []
|
||||
if btc_price_task is not None:
|
||||
tasks_to_wait.append(btc_price_task)
|
||||
if pricing_task is not None:
|
||||
tasks_to_wait.append(pricing_task)
|
||||
if payout_task is not None:
|
||||
tasks_to_wait.append(payout_task)
|
||||
if nip91_task is not None:
|
||||
tasks_to_wait.append(nip91_task)
|
||||
if providers_task is not None:
|
||||
tasks_to_wait.append(providers_task)
|
||||
if models_refresh_task is not None:
|
||||
tasks_to_wait.append(models_refresh_task)
|
||||
if models_cleanup_task is not None:
|
||||
tasks_to_wait.append(models_cleanup_task)
|
||||
if model_maps_refresh_task is not None:
|
||||
tasks_to_wait.append(model_maps_refresh_task)
|
||||
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
logger.info("Background tasks stopped successfully")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error stopping background tasks",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
|
||||
app = FastAPI(version=__version__, lifespan=lifespan)
|
||||
|
||||
|
||||
@@ -190,144 +65,7 @@ async def providers() -> RedirectResponse:
|
||||
return RedirectResponse("/v1/providers/")
|
||||
|
||||
|
||||
UI_DIST_PATH = Path(__file__).parent.parent.parent / "ui_out"
|
||||
|
||||
if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
|
||||
logger.info(f"Serving static UI from {UI_DIST_PATH}")
|
||||
|
||||
app.mount(
|
||||
"/_next",
|
||||
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
|
||||
name="next-static",
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def serve_root_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
# Add explicit route for /index.txt to redirect to /
|
||||
@app.get("/index.txt", include_in_schema=False)
|
||||
async def redirect_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/")
|
||||
|
||||
@app.get("/admin")
|
||||
async def admin_redirect() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@app.get("/dashboard", include_in_schema=False)
|
||||
async def serve_dashboard_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@app.get("/login", include_in_schema=False)
|
||||
async def serve_login_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "login" / "index.html")
|
||||
|
||||
# Add explicit route for /login/index.txt to redirect to /login
|
||||
@app.get("/login/index.txt", include_in_schema=False)
|
||||
async def redirect_login_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/login")
|
||||
|
||||
@app.get("/model", include_in_schema=False)
|
||||
async def serve_models_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "model" / "index.html")
|
||||
|
||||
# Add explicit route for /model/index.txt to redirect to /model
|
||||
@app.get("/model/index.txt", include_in_schema=False)
|
||||
async def redirect_model_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/model")
|
||||
|
||||
@app.get("/providers", include_in_schema=False)
|
||||
async def serve_providers_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
|
||||
|
||||
# Add explicit route for /providers/index.txt to redirect to /providers
|
||||
@app.get("/providers/index.txt", include_in_schema=False)
|
||||
async def redirect_providers_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/providers")
|
||||
|
||||
@app.get("/settings", include_in_schema=False)
|
||||
async def serve_settings_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
|
||||
|
||||
# Add explicit route for /settings/index.txt to redirect to /settings
|
||||
@app.get("/settings/index.txt", include_in_schema=False)
|
||||
async def redirect_settings_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/settings")
|
||||
|
||||
@app.get("/transactions", include_in_schema=False)
|
||||
async def serve_transactions_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
|
||||
|
||||
# Add explicit route for /transactions/index.txt to redirect to /transactions
|
||||
@app.get("/transactions/index.txt", include_in_schema=False)
|
||||
async def redirect_transactions_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/transactions")
|
||||
|
||||
@app.get("/balances", include_in_schema=False)
|
||||
async def serve_balances_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "balances" / "index.html")
|
||||
|
||||
# Add explicit route for /balances/index.txt to redirect to /balances
|
||||
@app.get("/balances/index.txt", include_in_schema=False)
|
||||
async def redirect_balances_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/balances")
|
||||
|
||||
@app.get("/logs", include_in_schema=False)
|
||||
async def serve_logs_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "logs" / "index.html")
|
||||
|
||||
# Add explicit route for /logs/index.txt to redirect to /logs
|
||||
@app.get("/logs/index.txt", include_in_schema=False)
|
||||
async def redirect_logs_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/logs")
|
||||
|
||||
@app.get("/usage", include_in_schema=False)
|
||||
async def serve_usage_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
|
||||
|
||||
# Add explicit route for /usage/index.txt to redirect to /usage
|
||||
@app.get("/usage/index.txt", include_in_schema=False)
|
||||
async def redirect_usage_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/usage")
|
||||
|
||||
@app.get("/unauthorized", include_in_schema=False)
|
||||
async def serve_unauthorized_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
|
||||
|
||||
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
|
||||
@app.get("/unauthorized/index.txt", include_in_schema=False)
|
||||
async def redirect_unauthorized_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/unauthorized")
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def serve_favicon() -> FileResponse:
|
||||
icon_path = UI_DIST_PATH / "icon.ico"
|
||||
if icon_path.exists():
|
||||
return FileResponse(icon_path)
|
||||
return FileResponse(UI_DIST_PATH / "favicon.ico")
|
||||
|
||||
@app.get("/icon.ico", include_in_schema=False)
|
||||
async def serve_icon() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "icon.ico")
|
||||
|
||||
app.mount(
|
||||
"/static", StaticFiles(directory=UI_DIST_PATH, check_dir=True), name="ui-static"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root_fallback() -> dict:
|
||||
return {
|
||||
"name": global_settings.name,
|
||||
"description": global_settings.description,
|
||||
"version": __version__,
|
||||
"status": "running",
|
||||
"ui": "not available",
|
||||
}
|
||||
|
||||
setup_ui_routes(app)
|
||||
|
||||
app.include_router(models_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
@@ -139,7 +139,7 @@ def resolve_bootstrap() -> Settings:
|
||||
pass
|
||||
if not base.onion_url:
|
||||
try:
|
||||
from ..nip91 import discover_onion_url_from_tor # type: ignore
|
||||
from ..nostr.listing import discover_onion_url_from_tor
|
||||
|
||||
discovered = discover_onion_url_from_tor()
|
||||
if discovered:
|
||||
|
||||
132
routstr/core/tasks.py
Normal file
132
routstr/core/tasks.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from ..models.models import (
|
||||
cleanup_enabled_models_periodically,
|
||||
update_sats_pricing,
|
||||
)
|
||||
from ..nostr import announce_provider, providers_cache_refresher
|
||||
from ..payment.price import update_prices_periodically
|
||||
from ..payment.wallet import periodic_payout
|
||||
from ..proxy import get_upstreams, initialize_upstreams, refresh_model_maps_periodically
|
||||
from ..upstream.helpers import refresh_upstreams_models_periodically
|
||||
from .db import create_session, init_db, run_migrations
|
||||
from .logging import get_logger
|
||||
from .settings import SettingsService
|
||||
from .settings import settings as global_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# Extract version from app if available, or use default/log without it
|
||||
version = getattr(app, "version", "unknown")
|
||||
|
||||
logger.info("Application startup initiated", extra={"version": version})
|
||||
|
||||
btc_price_task = None
|
||||
pricing_task = None
|
||||
payout_task = None
|
||||
listing_task = None
|
||||
providers_task = None
|
||||
models_refresh_task = None
|
||||
models_cleanup_task = None
|
||||
model_maps_refresh_task = None
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
# This ensures the database schema is always up-to-date in production
|
||||
# Migrations are idempotent - running them multiple times is safe
|
||||
logger.info("Running database migrations")
|
||||
run_migrations()
|
||||
|
||||
# Initialize database connection pools
|
||||
# This creates any tables that might not be tracked by migrations yet
|
||||
await init_db()
|
||||
|
||||
# Initialize application settings (env -> computed -> DB precedence)
|
||||
async with create_session() as session:
|
||||
s = await SettingsService.initialize(session)
|
||||
|
||||
# Apply app metadata from settings
|
||||
try:
|
||||
app.title = s.name
|
||||
app.description = s.description
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
|
||||
|
||||
btc_price_task = asyncio.create_task(update_prices_periodically())
|
||||
pricing_task = asyncio.create_task(update_sats_pricing())
|
||||
if global_settings.models_refresh_interval_seconds > 0:
|
||||
models_refresh_task = asyncio.create_task(
|
||||
refresh_upstreams_models_periodically(get_upstreams())
|
||||
)
|
||||
models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically())
|
||||
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
|
||||
payout_task = asyncio.create_task(periodic_payout())
|
||||
listing_task = asyncio.create_task(announce_provider())
|
||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||
|
||||
await _initialize_upstreams_task
|
||||
|
||||
yield
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Application startup failed",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
logger.info("Application shutdown initiated")
|
||||
|
||||
if btc_price_task is not None:
|
||||
btc_price_task.cancel()
|
||||
if pricing_task is not None:
|
||||
pricing_task.cancel()
|
||||
if payout_task is not None:
|
||||
payout_task.cancel()
|
||||
if listing_task is not None:
|
||||
listing_task.cancel()
|
||||
if providers_task is not None:
|
||||
providers_task.cancel()
|
||||
if models_refresh_task is not None:
|
||||
models_refresh_task.cancel()
|
||||
if models_cleanup_task is not None:
|
||||
models_cleanup_task.cancel()
|
||||
if model_maps_refresh_task is not None:
|
||||
model_maps_refresh_task.cancel()
|
||||
|
||||
try:
|
||||
tasks_to_wait = []
|
||||
if btc_price_task is not None:
|
||||
tasks_to_wait.append(btc_price_task)
|
||||
if pricing_task is not None:
|
||||
tasks_to_wait.append(pricing_task)
|
||||
if payout_task is not None:
|
||||
tasks_to_wait.append(payout_task)
|
||||
if listing_task is not None:
|
||||
tasks_to_wait.append(listing_task)
|
||||
if providers_task is not None:
|
||||
tasks_to_wait.append(providers_task)
|
||||
if models_refresh_task is not None:
|
||||
tasks_to_wait.append(models_refresh_task)
|
||||
if models_cleanup_task is not None:
|
||||
tasks_to_wait.append(models_cleanup_task)
|
||||
if model_maps_refresh_task is not None:
|
||||
tasks_to_wait.append(model_maps_refresh_task)
|
||||
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
logger.info("Background tasks stopped successfully")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error stopping background tasks",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
160
routstr/core/ui.py
Normal file
160
routstr/core/ui.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .logging import get_logger
|
||||
from .settings import settings as global_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def setup_ui_routes(app: FastAPI) -> None:
|
||||
UI_DIST_PATH = Path(__file__).parent.parent.parent / "ui_out"
|
||||
|
||||
if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
|
||||
logger.info(f"Serving static UI from {UI_DIST_PATH}")
|
||||
|
||||
app.mount(
|
||||
"/_next",
|
||||
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
|
||||
name="next-static",
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def serve_root_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
# Add explicit route for /index.txt to redirect to /
|
||||
@router.get("/index.txt", include_in_schema=False)
|
||||
async def redirect_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/")
|
||||
|
||||
@router.get("/admin")
|
||||
async def admin_redirect() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@router.get("/dashboard", include_in_schema=False)
|
||||
async def serve_dashboard_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@router.get("/login", include_in_schema=False)
|
||||
async def serve_login_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "login" / "index.html")
|
||||
|
||||
# Add explicit route for /login/index.txt to redirect to /login
|
||||
@router.get("/login/index.txt", include_in_schema=False)
|
||||
async def redirect_login_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/login")
|
||||
|
||||
@router.get("/model", include_in_schema=False)
|
||||
async def serve_models_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "model" / "index.html")
|
||||
|
||||
# Add explicit route for /model/index.txt to redirect to /model
|
||||
@router.get("/model/index.txt", include_in_schema=False)
|
||||
async def redirect_model_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/model")
|
||||
|
||||
@router.get("/providers", include_in_schema=False)
|
||||
async def serve_providers_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
|
||||
|
||||
# Add explicit route for /providers/index.txt to redirect to /providers
|
||||
@router.get("/providers/index.txt", include_in_schema=False)
|
||||
async def redirect_providers_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/providers")
|
||||
|
||||
@router.get("/settings", include_in_schema=False)
|
||||
async def serve_settings_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
|
||||
|
||||
# Add explicit route for /settings/index.txt to redirect to /settings
|
||||
@router.get("/settings/index.txt", include_in_schema=False)
|
||||
async def redirect_settings_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/settings")
|
||||
|
||||
@router.get("/transactions", include_in_schema=False)
|
||||
async def serve_transactions_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
|
||||
|
||||
# Add explicit route for /transactions/index.txt to redirect to /transactions
|
||||
@router.get("/transactions/index.txt", include_in_schema=False)
|
||||
async def redirect_transactions_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/transactions")
|
||||
|
||||
@router.get("/balances", include_in_schema=False)
|
||||
async def serve_balances_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "balances" / "index.html")
|
||||
|
||||
# Add explicit route for /balances/index.txt to redirect to /balances
|
||||
@router.get("/balances/index.txt", include_in_schema=False)
|
||||
async def redirect_balances_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/balances")
|
||||
|
||||
@router.get("/logs", include_in_schema=False)
|
||||
async def serve_logs_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "logs" / "index.html")
|
||||
|
||||
# Add explicit route for /logs/index.txt to redirect to /logs
|
||||
@router.get("/logs/index.txt", include_in_schema=False)
|
||||
async def redirect_logs_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/logs")
|
||||
|
||||
@router.get("/usage", include_in_schema=False)
|
||||
async def serve_usage_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
|
||||
|
||||
# Add explicit route for /usage/index.txt to redirect to /usage
|
||||
@router.get("/usage/index.txt", include_in_schema=False)
|
||||
async def redirect_usage_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/usage")
|
||||
|
||||
@router.get("/unauthorized", include_in_schema=False)
|
||||
async def serve_unauthorized_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
|
||||
|
||||
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
|
||||
@router.get("/unauthorized/index.txt", include_in_schema=False)
|
||||
async def redirect_unauthorized_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/unauthorized")
|
||||
|
||||
@router.get("/favicon.ico", include_in_schema=False)
|
||||
async def serve_favicon() -> FileResponse:
|
||||
icon_path = UI_DIST_PATH / "icon.ico"
|
||||
if icon_path.exists():
|
||||
return FileResponse(icon_path)
|
||||
return FileResponse(UI_DIST_PATH / "favicon.ico")
|
||||
|
||||
@router.get("/icon.ico", include_in_schema=False)
|
||||
async def serve_icon() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "icon.ico")
|
||||
|
||||
app.include_router(router)
|
||||
|
||||
app.mount(
|
||||
"/static",
|
||||
StaticFiles(directory=UI_DIST_PATH, check_dir=True),
|
||||
name="ui-static",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def root_fallback() -> dict:
|
||||
return {
|
||||
"name": global_settings.name,
|
||||
"description": global_settings.description,
|
||||
"version": app.version,
|
||||
"status": "running",
|
||||
"ui": "not available",
|
||||
}
|
||||
|
||||
app.include_router(router)
|
||||
9
routstr/models/__init__.py
Normal file
9
routstr/models/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .metadata import async_fetch_openrouter_models
|
||||
from .models import Model
|
||||
|
||||
__all__ = [
|
||||
"Model",
|
||||
"async_fetch_openrouter_models",
|
||||
]
|
||||
|
||||
# specifically ai models that the node sells and not meaning database models
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .core.logging import get_logger
|
||||
from ..core import get_logger
|
||||
from ..models.crud import row_to_model
|
||||
from ..upstream.helpers import resolve_model_alias
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .payment.models import Model
|
||||
from .upstream import BaseUpstreamProvider
|
||||
from ..upstream import BaseUpstreamProvider
|
||||
from .models import Model
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -153,9 +155,9 @@ def should_prefer_model(
|
||||
# Log provider changes when candidate wins
|
||||
if should_replace:
|
||||
candidate_provider_name = getattr(
|
||||
candidate_provider, "provider_type", "unknown"
|
||||
candidate_provider, "upstream_name", "unknown"
|
||||
)
|
||||
current_provider_name = getattr(current_provider, "provider_type", "unknown")
|
||||
current_provider_name = getattr(current_provider, "upstream_name", "unknown")
|
||||
logger.debug(
|
||||
f"Model selection for alias '{alias}': choosing {candidate_provider_name} "
|
||||
f"(cost: ${candidate_adjusted:.6f}) over {current_provider_name} "
|
||||
@@ -192,8 +194,6 @@ def create_model_mappings(
|
||||
Returns:
|
||||
Tuple of (model_instances, provider_map, unique_models)
|
||||
"""
|
||||
from .payment.models import _row_to_model
|
||||
from .upstream.helpers import resolve_model_alias
|
||||
|
||||
model_instances: dict[str, "Model"] = {}
|
||||
provider_map: dict[str, "BaseUpstreamProvider"] = {}
|
||||
@@ -245,7 +245,7 @@ def create_model_mappings(
|
||||
# Apply overrides if present
|
||||
if model.id in overrides_by_id:
|
||||
override_row, provider_fee = overrides_by_id[model.id]
|
||||
model_to_use = _row_to_model(
|
||||
model_to_use = row_to_model(
|
||||
override_row, apply_provider_fee=True, provider_fee=provider_fee
|
||||
)
|
||||
else:
|
||||
105
routstr/models/crud.py
Normal file
105
routstr/models/crud.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import json
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow
|
||||
from ..payment.price import sats_usd_price
|
||||
from .models import (
|
||||
Architecture,
|
||||
Model,
|
||||
Pricing,
|
||||
TopProvider,
|
||||
_update_model_sats_pricing,
|
||||
calculate_usd_max_costs,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def row_to_model(
|
||||
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
|
||||
) -> Model:
|
||||
architecture = json.loads(row.architecture)
|
||||
pricing = json.loads(row.pricing)
|
||||
per_request_limits = (
|
||||
json.loads(row.per_request_limits) if row.per_request_limits else None
|
||||
)
|
||||
top_provider_dict = json.loads(row.top_provider) if row.top_provider else None
|
||||
|
||||
if apply_provider_fee and isinstance(pricing, dict):
|
||||
pricing = {k: float(v) * provider_fee for k, v in pricing.items()}
|
||||
|
||||
if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0:
|
||||
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
|
||||
|
||||
parsed_pricing = Pricing.parse_obj(pricing)
|
||||
model = Model(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
created=row.created,
|
||||
description=row.description,
|
||||
context_length=row.context_length,
|
||||
architecture=Architecture.parse_obj(architecture),
|
||||
pricing=parsed_pricing,
|
||||
sats_pricing=None,
|
||||
per_request_limits=per_request_limits,
|
||||
top_provider=TopProvider.parse_obj(top_provider_dict)
|
||||
if top_provider_dict
|
||||
else None,
|
||||
enabled=row.enabled,
|
||||
upstream_provider_id=row.upstream_provider_id,
|
||||
canonical_slug=getattr(row, "canonical_slug", None),
|
||||
)
|
||||
|
||||
if apply_provider_fee:
|
||||
(
|
||||
parsed_pricing.max_prompt_cost,
|
||||
parsed_pricing.max_completion_cost,
|
||||
parsed_pricing.max_cost,
|
||||
) = calculate_usd_max_costs(model)
|
||||
|
||||
try:
|
||||
sats_to_usd = sats_usd_price()
|
||||
model = _update_model_sats_pricing(model, sats_to_usd)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not calculate sats pricing: {e}")
|
||||
|
||||
return model
|
||||
|
||||
|
||||
async def list_models(
|
||||
session: AsyncSession,
|
||||
upstream_id: int,
|
||||
include_disabled: bool = False,
|
||||
) -> list[Model]:
|
||||
from sqlmodel import select
|
||||
|
||||
query = select(ModelRow)
|
||||
if upstream_id is not None:
|
||||
query = query.where(ModelRow.upstream_provider_id == upstream_id)
|
||||
if not include_disabled:
|
||||
query = query.where(ModelRow.enabled)
|
||||
|
||||
rows = (await session.exec(query)).all() # type: ignore
|
||||
provider_result = await session.exec(select(UpstreamProviderRow))
|
||||
providers_by_id = {p.id: p for p in provider_result.all()}
|
||||
return [
|
||||
row_to_model(
|
||||
r,
|
||||
apply_provider_fee=True,
|
||||
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
|
||||
if r.upstream_provider_id in providers_by_id
|
||||
else 1.01,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def get_model_by_id(
|
||||
model_id: str, provider_id: int, session: AsyncSession
|
||||
) -> Model | None:
|
||||
row = await session.get(ModelRow, (model_id, provider_id))
|
||||
if not row or not row.enabled:
|
||||
return None
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
provider_fee = provider.provider_fee if provider else 1.01
|
||||
return row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
|
||||
52
routstr/models/metadata.py
Normal file
52
routstr/models/metadata.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
||||
from ..core import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DEFAULT_EXCLUDED_MODEL_IDS: Final[set[str]] = {
|
||||
"openrouter/auto",
|
||||
"google/gemini-2.5-pro-exp-03-25",
|
||||
"opengvlab/internvl3-78b",
|
||||
"openrouter/sonoma-dusk-alpha",
|
||||
"openrouter/sonoma-sky-alpha",
|
||||
}
|
||||
|
||||
|
||||
async def async_fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
"""Asynchronously fetch model information from OpenRouter API."""
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(f"{base_url}/models", timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
models_data: list[dict] = []
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
|
||||
if source_filter:
|
||||
source_prefix = f"{source_filter}/"
|
||||
if not model_id.startswith(source_prefix):
|
||||
continue
|
||||
|
||||
model = dict(model)
|
||||
model["id"] = model_id[len(source_prefix) :]
|
||||
model_id = model["id"]
|
||||
|
||||
if (
|
||||
"(free)" in model.get("name", "")
|
||||
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
|
||||
):
|
||||
continue
|
||||
|
||||
models_data.append(model)
|
||||
|
||||
return models_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error (async) fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
376
routstr/models/models.py
Normal file
376
routstr/models/models.py
Normal file
@@ -0,0 +1,376 @@
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic.v1 import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..core.db import ModelRow, create_session, get_session
|
||||
from ..core.logging import get_logger
|
||||
from ..core.settings import settings
|
||||
from ..payment.price import sats_usd_price
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
models_router = APIRouter()
|
||||
|
||||
|
||||
class Architecture(BaseModel):
|
||||
modality: str
|
||||
input_modalities: list[str]
|
||||
output_modalities: list[str]
|
||||
tokenizer: str
|
||||
instruct_type: str | None
|
||||
|
||||
|
||||
class Pricing(BaseModel):
|
||||
prompt: float
|
||||
completion: float
|
||||
request: float
|
||||
image: float
|
||||
web_search: float
|
||||
internal_reasoning: float
|
||||
max_prompt_cost: float = 0.0 # in sats not msats
|
||||
max_completion_cost: float = 0.0 # in sats not msats
|
||||
max_cost: float = 0.0 # in sats not msats
|
||||
|
||||
|
||||
class TopProvider(BaseModel):
|
||||
context_length: int | None = None
|
||||
max_completion_tokens: int | None = None
|
||||
is_moderated: bool | None = None
|
||||
|
||||
|
||||
class Model(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
created: int
|
||||
description: str
|
||||
context_length: int
|
||||
architecture: Architecture
|
||||
pricing: Pricing
|
||||
sats_pricing: Pricing | None = None
|
||||
per_request_limits: dict | None = None
|
||||
top_provider: TopProvider | None = None
|
||||
enabled: bool = True
|
||||
upstream_provider_id: int | None = None
|
||||
canonical_slug: str | None = None
|
||||
alias_ids: list[str] | None = None
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
def is_openrouter_upstream() -> bool:
|
||||
try:
|
||||
base = (settings.upstream_base_url or "").strip().rstrip("/")
|
||||
except Exception:
|
||||
return False
|
||||
return base.lower() == "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
|
||||
"""Calculate max costs in USD based on model context/token limits.
|
||||
|
||||
Args:
|
||||
model: Model object
|
||||
|
||||
Returns:
|
||||
Tuple of (max_prompt_cost, max_completion_cost, max_cost) in USD
|
||||
"""
|
||||
min_req_msat = max(1, int(getattr(settings, "min_request_msat", 1)))
|
||||
min_req_usd = float(min_req_msat) / 1_000_000.0
|
||||
|
||||
prompt_price = model.pricing.prompt
|
||||
completion_price = model.pricing.completion
|
||||
|
||||
if model.top_provider and (
|
||||
model.top_provider.context_length or model.top_provider.max_completion_tokens
|
||||
):
|
||||
if (cl := model.top_provider.context_length) and (
|
||||
mct := model.top_provider.max_completion_tokens
|
||||
):
|
||||
if cl <= mct:
|
||||
return (
|
||||
cl * prompt_price,
|
||||
cl * completion_price,
|
||||
cl * max(completion_price, prompt_price),
|
||||
)
|
||||
return (
|
||||
cl * prompt_price,
|
||||
mct * completion_price,
|
||||
(cl - mct) * prompt_price + mct * completion_price,
|
||||
)
|
||||
elif cl := model.top_provider.context_length:
|
||||
return (
|
||||
cl * prompt_price,
|
||||
cl * completion_price,
|
||||
cl * max(completion_price, prompt_price),
|
||||
)
|
||||
elif mct := model.top_provider.max_completion_tokens:
|
||||
return (
|
||||
mct * prompt_price,
|
||||
mct * completion_price,
|
||||
mct * completion_price,
|
||||
)
|
||||
elif model.context_length:
|
||||
return (
|
||||
model.context_length * prompt_price,
|
||||
model.context_length * completion_price,
|
||||
model.context_length * max(completion_price, prompt_price),
|
||||
)
|
||||
|
||||
p = prompt_price * 1_000_000
|
||||
c = completion_price * 32_000
|
||||
r = model.pricing.request * 100_000
|
||||
i = model.pricing.image * 100
|
||||
w = model.pricing.web_search * 1000
|
||||
ir = model.pricing.internal_reasoning * 100
|
||||
return (p, c, max(p + c + r + i + w + ir, min_req_usd))
|
||||
|
||||
|
||||
def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
|
||||
"""Update a model's sats_pricing based on USD pricing and exchange rate.
|
||||
|
||||
Args:
|
||||
model: Model object to update
|
||||
sats_to_usd: Current sats to USD exchange rate
|
||||
|
||||
Returns:
|
||||
Updated Model object with new sats_pricing
|
||||
"""
|
||||
try:
|
||||
min_req_msat = max(1, int(getattr(settings, "min_request_msat", 1)))
|
||||
min_req_sats = float(min_req_msat) / 1000.0
|
||||
|
||||
sats = Pricing.parse_obj(
|
||||
{k: v / sats_to_usd for k, v in model.pricing.dict().items()}
|
||||
)
|
||||
|
||||
if sats.request <= 0.0:
|
||||
sats.request = min_req_sats
|
||||
if (sats.max_cost or 0.0) < min_req_sats:
|
||||
sats.max_cost = min_req_sats
|
||||
|
||||
return Model(
|
||||
id=model.id,
|
||||
name=model.name,
|
||||
created=model.created,
|
||||
description=model.description,
|
||||
context_length=model.context_length,
|
||||
architecture=model.architecture,
|
||||
pricing=model.pricing,
|
||||
sats_pricing=sats,
|
||||
per_request_limits=model.per_request_limits,
|
||||
top_provider=model.top_provider,
|
||||
enabled=model.enabled,
|
||||
upstream_provider_id=model.upstream_provider_id,
|
||||
canonical_slug=model.canonical_slug,
|
||||
alias_ids=model.alias_ids,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to update sats pricing for model",
|
||||
extra={
|
||||
"model_id": model.id,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
async def _update_sats_pricing_once() -> None:
|
||||
"""Update sats pricing once for all provider models (in-memory only)."""
|
||||
from ..proxy import get_upstreams
|
||||
|
||||
upstreams = get_upstreams()
|
||||
sats_to_usd = sats_usd_price()
|
||||
|
||||
updated_count = 0
|
||||
for upstream in upstreams:
|
||||
updated_models = [
|
||||
_update_model_sats_pricing(m, sats_to_usd)
|
||||
for m in upstream.get_cached_models()
|
||||
]
|
||||
upstream._models_cache = updated_models
|
||||
upstream._models_by_id = {m.id: m for m in updated_models}
|
||||
updated_count += len(updated_models)
|
||||
|
||||
if updated_count > 0:
|
||||
logger.info("Updated sats pricing", extra={"models_updated": updated_count})
|
||||
|
||||
|
||||
async def update_sats_pricing() -> None:
|
||||
"""Periodically update sats pricing for all provider models and database overrides."""
|
||||
try:
|
||||
if not settings.enable_pricing_refresh:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await _update_sats_pricing_once()
|
||||
|
||||
while True:
|
||||
try:
|
||||
interval = getattr(settings, "pricing_refresh_interval_seconds", 120)
|
||||
jitter = max(0.0, float(interval) * 0.1)
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
try:
|
||||
try:
|
||||
if not settings.enable_pricing_refresh:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await _update_sats_pricing_once()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating sats pricing: {e}")
|
||||
|
||||
|
||||
async def cleanup_enabled_models_periodically() -> None:
|
||||
"""Background task to clean up enabled models that match upstream pricing.
|
||||
|
||||
When model is enabled (enabled=True), remove it from DB if it matches upstream pricing.
|
||||
Keep it in DB only if pricing differs from upstream or if it's disabled.
|
||||
"""
|
||||
interval = getattr(
|
||||
settings, "models_cleanup_interval_seconds", 300
|
||||
) # 5 minutes default
|
||||
if not interval or interval <= 0:
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
await _cleanup_enabled_models_once()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error during enabled models cleanup",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
try:
|
||||
jitter = max(0.0, float(interval) * 0.1)
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
|
||||
async def _cleanup_enabled_models_once() -> None:
|
||||
"""Clean up enabled models that match upstream pricing."""
|
||||
from ..proxy import get_upstreams
|
||||
|
||||
async with create_session() as session:
|
||||
# Get all enabled models from DB
|
||||
result = await session.exec(
|
||||
select(ModelRow).where(
|
||||
ModelRow.enabled, # Only enabled models
|
||||
)
|
||||
)
|
||||
db_models = result.all()
|
||||
|
||||
if not db_models:
|
||||
return
|
||||
|
||||
upstreams = get_upstreams()
|
||||
models_to_remove = []
|
||||
|
||||
for db_model in db_models:
|
||||
# Find corresponding upstream model
|
||||
upstream_model = None
|
||||
for upstream in upstreams:
|
||||
upstream_model = upstream.get_cached_model_by_id(db_model.id)
|
||||
if upstream_model:
|
||||
break
|
||||
|
||||
if not upstream_model:
|
||||
continue
|
||||
|
||||
# Compare pricing to see if they match
|
||||
db_pricing = json.loads(db_model.pricing)
|
||||
upstream_pricing = upstream_model.pricing.dict()
|
||||
|
||||
# Check if pricing matches (with small tolerance for float comparison)
|
||||
pricing_matches = _pricing_matches(db_pricing, upstream_pricing)
|
||||
|
||||
if pricing_matches:
|
||||
models_to_remove.append(db_model)
|
||||
logger.info(
|
||||
f"Removing enabled model {db_model.id} - matches upstream pricing",
|
||||
extra={"model_id": db_model.id},
|
||||
)
|
||||
|
||||
# Remove models that match upstream pricing
|
||||
for model in models_to_remove:
|
||||
await session.delete(model)
|
||||
|
||||
if models_to_remove:
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Cleaned up {len(models_to_remove)} enabled models that match upstream pricing"
|
||||
)
|
||||
|
||||
|
||||
def _pricing_matches(
|
||||
db_pricing: dict, upstream_pricing: dict, tolerance: float = 0.0
|
||||
) -> bool:
|
||||
"""Check if pricing dictionaries match within tolerance."""
|
||||
keys_to_compare = [
|
||||
"prompt",
|
||||
"completion",
|
||||
"request",
|
||||
"image",
|
||||
"web_search",
|
||||
"internal_reasoning",
|
||||
]
|
||||
|
||||
for key in keys_to_compare:
|
||||
db_val = int(float(db_pricing.get(key, 0.0)) * 1000000)
|
||||
upstream_val = int(float(upstream_pricing.get(key, 0.0)) * 1000000)
|
||||
|
||||
if abs(db_val - upstream_val) > tolerance:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _model_to_row_payload(model: Model) -> dict[str, str | int | bool | None]:
|
||||
return {
|
||||
"id": model.id,
|
||||
"name": model.name,
|
||||
"created": model.created,
|
||||
"description": model.description,
|
||||
"context_length": model.context_length,
|
||||
"architecture": json.dumps(model.architecture.dict()),
|
||||
"pricing": json.dumps(model.pricing.dict()),
|
||||
"sats_pricing": json.dumps(model.sats_pricing.dict())
|
||||
if model.sats_pricing
|
||||
else None,
|
||||
"per_request_limits": json.dumps(model.per_request_limits)
|
||||
if model.per_request_limits is not None
|
||||
else None,
|
||||
"top_provider": json.dumps(model.top_provider.dict())
|
||||
if model.top_provider is not None
|
||||
else None,
|
||||
"enabled": model.enabled,
|
||||
"upstream_provider_id": model.upstream_provider_id,
|
||||
}
|
||||
|
||||
|
||||
@models_router.get("/v1/models")
|
||||
@models_router.get("/models", include_in_schema=False)
|
||||
async def models(session: AsyncSession = Depends(get_session)) -> dict:
|
||||
"""Get all available models from all providers with database overrides applied."""
|
||||
from ..proxy import get_unique_models
|
||||
|
||||
items = get_unique_models()
|
||||
return {"data": items}
|
||||
7
routstr/nostr/__init__.py
Normal file
7
routstr/nostr/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .discovery import providers_cache_refresher
|
||||
from .listing import announce_provider
|
||||
|
||||
__all__ = [
|
||||
"providers_cache_refresher",
|
||||
"announce_provider",
|
||||
]
|
||||
@@ -8,8 +8,8 @@ import httpx
|
||||
import websockets
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from ..core.logging import get_logger
|
||||
from ..core.settings import settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -18,15 +18,15 @@ from nostr.key import PrivateKey
|
||||
from nostr.message_type import ClientMessageType
|
||||
from nostr.relay_manager import RelayManager
|
||||
|
||||
from .core import get_logger
|
||||
from .core.settings import settings
|
||||
from ..core import get_logger
|
||||
from ..core.settings import settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_app_version() -> str | None:
|
||||
try:
|
||||
from .core.main import __version__ as imported_version
|
||||
from ..core.main import __version__ as imported_version
|
||||
|
||||
return imported_version
|
||||
except Exception:
|
||||
@@ -71,7 +71,7 @@ def nsec_to_keypair(nsec: str) -> tuple[str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def create_nip91_event(
|
||||
def create_listing_event(
|
||||
private_key_hex: str,
|
||||
provider_id: str,
|
||||
endpoint_urls: list[str],
|
||||
@@ -164,7 +164,7 @@ def events_semantically_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def query_nip91_events(
|
||||
async def query_listing_events(
|
||||
relay_url: str,
|
||||
pubkey: str,
|
||||
provider_id: str | None = None,
|
||||
@@ -188,7 +188,7 @@ async def query_nip91_events(
|
||||
|
||||
flt = Filter(kinds=[38421], authors=[pubkey], limit=10)
|
||||
filters = Filters([flt])
|
||||
sub_id = f"nip91_{int(time.time())}"
|
||||
sub_id = f"routstr_listing_{int(time.time())}"
|
||||
rm.add_subscription(sub_id, filters)
|
||||
req: list[Any] = [ClientMessageType.REQUEST, sub_id]
|
||||
req.extend(filters.to_json_array())
|
||||
@@ -294,7 +294,7 @@ async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) ->
|
||||
|
||||
async def query_single_relay(relay_url: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
events, _ok = await query_nip91_events(relay_url, public_key_hex, None)
|
||||
events, _ok = await query_listing_events(relay_url, public_key_hex, None)
|
||||
return events
|
||||
except Exception:
|
||||
return []
|
||||
@@ -434,7 +434,7 @@ async def announce_provider() -> None:
|
||||
|
||||
# Create the candidate event that we would publish
|
||||
version_str = get_app_version()
|
||||
candidate_event = create_nip91_event(
|
||||
candidate_event = create_listing_event(
|
||||
private_key_hex=private_key_hex,
|
||||
provider_id=provider_id,
|
||||
endpoint_urls=endpoint_urls,
|
||||
@@ -474,7 +474,7 @@ async def announce_provider() -> None:
|
||||
if _should_skip(relay_url):
|
||||
logger.debug(f"Skipping {relay_url} due to backoff")
|
||||
continue
|
||||
events, ok = await query_nip91_events(relay_url, public_key_hex, provider_id)
|
||||
events, ok = await query_listing_events(relay_url, public_key_hex, provider_id)
|
||||
if ok:
|
||||
_register_success(relay_url)
|
||||
existing_events.extend(events)
|
||||
@@ -518,7 +518,7 @@ async def announce_provider() -> None:
|
||||
|
||||
# Build fresh candidate event for comparison
|
||||
version_str = get_app_version()
|
||||
candidate_event = create_nip91_event(
|
||||
candidate_event = create_listing_event(
|
||||
private_key_hex=private_key_hex,
|
||||
provider_id=provider_id,
|
||||
endpoint_urls=endpoint_urls,
|
||||
@@ -533,7 +533,7 @@ async def announce_provider() -> None:
|
||||
if _should_skip(relay_url):
|
||||
logger.debug(f"Skipping {relay_url} due to backoff")
|
||||
continue
|
||||
events, ok = await query_nip91_events(
|
||||
events, ok = await query_listing_events(
|
||||
relay_url, public_key_hex, provider_id
|
||||
)
|
||||
if ok:
|
||||
@@ -1,4 +1,4 @@
|
||||
from .cost_calculation import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
from .cost import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
|
||||
__all__ = [
|
||||
"CostData",
|
||||
|
||||
48
routstr/payment/cashu.py
Normal file
48
routstr/payment/cashu.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from cashu.wallet.helpers import deserialize_token_from_string
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..core import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_token_balance_msat(cashu_token: str) -> int:
|
||||
try:
|
||||
token = deserialize_token_from_string(cashu_token)
|
||||
except Exception:
|
||||
raise HTTPException(401, "Invalid authentication token format")
|
||||
|
||||
if token.unit == "sat":
|
||||
return token.amount * 1000
|
||||
if token.unit == "msat":
|
||||
return token.amount
|
||||
|
||||
raise HTTPException(401, f"Unit {token.unit} not supported yet")
|
||||
|
||||
|
||||
def pre_check_header_token_balance(
|
||||
headers: dict, body: dict, max_cost_for_model: int
|
||||
) -> None:
|
||||
if x_cashu := headers.get("x-cashu", None):
|
||||
cashu_token = x_cashu
|
||||
elif auth := headers.get("authorization", None):
|
||||
cashu_token = auth.split(" ")[1] if len(auth.split(" ")) > 1 else ""
|
||||
else:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# Handle regular API keys (sk-*)
|
||||
if cashu_token.startswith("sk-"):
|
||||
return
|
||||
|
||||
amount_msat = get_token_balance_msat(cashu_token)
|
||||
|
||||
if max_cost_for_model > amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail={
|
||||
"reason": "Insufficient balance",
|
||||
"amount_required_msat": max_cost_for_model,
|
||||
"model": body.get("model", "unknown"),
|
||||
"type": "minimum_balance_required",
|
||||
},
|
||||
)
|
||||
425
routstr/payment/cost.py
Normal file
425
routstr/payment/cost.py
Normal file
@@ -0,0 +1,425 @@
|
||||
import base64
|
||||
import math
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from PIL import Image
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.settings import settings
|
||||
from ..models.models import Model
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CostData(BaseModel):
|
||||
base_msats: int
|
||||
input_msats: int
|
||||
output_msats: int
|
||||
total_msats: int
|
||||
|
||||
|
||||
class MaxCostData(CostData):
|
||||
pass
|
||||
|
||||
|
||||
class CostDataError(BaseModel):
|
||||
message: str
|
||||
code: str
|
||||
|
||||
|
||||
def calculate_cost(
|
||||
response_data: dict, max_cost: int
|
||||
) -> CostData | MaxCostData | CostDataError:
|
||||
"""
|
||||
Calculate the cost of an API request based on token usage.
|
||||
|
||||
Args:
|
||||
response_data: Response data containing usage information
|
||||
max_cost: Maximum cost in millisats
|
||||
|
||||
Returns:
|
||||
Cost data or error information
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting cost calculation",
|
||||
extra={
|
||||
"max_cost_msats": max_cost,
|
||||
"has_usage_data": "usage" in response_data,
|
||||
"response_model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
cost_data = MaxCostData(
|
||||
base_msats=max_cost,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=max_cost,
|
||||
)
|
||||
|
||||
if "usage" not in response_data or response_data["usage"] is None:
|
||||
logger.warning(
|
||||
"No usage data in response, using base cost only",
|
||||
extra={
|
||||
"max_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS: float = (
|
||||
float(settings.fixed_per_1k_input_tokens) * 1000.0
|
||||
)
|
||||
MSATS_PER_1K_OUTPUT_TOKENS: float = (
|
||||
float(settings.fixed_per_1k_output_tokens) * 1000.0
|
||||
)
|
||||
|
||||
if not settings.fixed_pricing:
|
||||
response_model = response_data.get("model", "")
|
||||
logger.debug(
|
||||
"Using model-based pricing",
|
||||
extra={"model": response_model},
|
||||
)
|
||||
|
||||
from ..proxy import get_model_instance
|
||||
|
||||
model_obj = get_model_instance(response_model)
|
||||
|
||||
if not model_obj:
|
||||
logger.error(
|
||||
"Invalid model in response",
|
||||
extra={"response_model": response_model},
|
||||
)
|
||||
return CostDataError(
|
||||
message=f"Invalid model in response: {response_model}",
|
||||
code="model_not_found",
|
||||
)
|
||||
|
||||
if not model_obj.sats_pricing:
|
||||
logger.error(
|
||||
"Model pricing not defined",
|
||||
extra={"model": response_model, "model_id": response_model},
|
||||
)
|
||||
return CostDataError(
|
||||
message="Model pricing not defined", code="pricing_not_found"
|
||||
)
|
||||
|
||||
try:
|
||||
mspp = float(model_obj.sats_pricing.prompt)
|
||||
mspc = float(model_obj.sats_pricing.completion)
|
||||
except Exception:
|
||||
return CostDataError(message="Invalid pricing data", code="pricing_invalid")
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS = mspp * 1_000_000.0
|
||||
MSATS_PER_1K_OUTPUT_TOKENS = mspc * 1_000_000.0
|
||||
|
||||
logger.info(
|
||||
"Applied model-specific pricing",
|
||||
extra={
|
||||
"model": response_model,
|
||||
"input_price_msats_per_1k": MSATS_PER_1K_INPUT_TOKENS,
|
||||
"output_price_msats_per_1k": MSATS_PER_1K_OUTPUT_TOKENS,
|
||||
},
|
||||
)
|
||||
|
||||
if not (MSATS_PER_1K_OUTPUT_TOKENS and MSATS_PER_1K_INPUT_TOKENS):
|
||||
logger.warning(
|
||||
"No token pricing configured, using base cost",
|
||||
extra={
|
||||
"base_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
|
||||
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
|
||||
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
|
||||
|
||||
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||
token_based_cost = math.ceil(input_msats + output_msats)
|
||||
|
||||
logger.info(
|
||||
"Calculated token-based cost",
|
||||
extra={
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"input_cost_msats": input_msats,
|
||||
"output_cost_msats": output_msats,
|
||||
"total_cost_msats": token_based_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return CostData(
|
||||
base_msats=0,
|
||||
input_msats=int(input_msats),
|
||||
output_msats=int(output_msats),
|
||||
total_msats=token_based_cost,
|
||||
)
|
||||
|
||||
|
||||
def get_max_cost_for_model(model_obj: Model) -> int:
|
||||
"""Get the maximum cost for a specific model from providers with overrides."""
|
||||
if settings.fixed_pricing:
|
||||
default_cost_msats = settings.fixed_cost_per_request * 1000
|
||||
return max(settings.min_request_msat, default_cost_msats)
|
||||
|
||||
if model_obj.sats_pricing:
|
||||
max_cost = (
|
||||
model_obj.sats_pricing.max_cost
|
||||
* 1000
|
||||
* (1 - settings.tolerance_percentage / 100)
|
||||
)
|
||||
calculated_msats = int(max_cost)
|
||||
return max(settings.min_request_msat, calculated_msats)
|
||||
|
||||
logger.warning(
|
||||
"Model pricing not found, using fixed cost",
|
||||
extra={
|
||||
"model": model_obj.id,
|
||||
"default_cost_msats": settings.fixed_cost_per_request * 1000,
|
||||
},
|
||||
)
|
||||
return max(settings.min_request_msat, settings.fixed_cost_per_request * 1000)
|
||||
|
||||
|
||||
async def calculate_discounted_max_cost(
|
||||
max_cost_for_model: int,
|
||||
body: dict,
|
||||
model_obj: Any | None = None,
|
||||
) -> int:
|
||||
"""Calculate the discounted max cost for a request using model pricing when available."""
|
||||
if settings.fixed_pricing:
|
||||
return max_cost_for_model
|
||||
|
||||
model = body.get("model", "unknown")
|
||||
|
||||
model_pricing = model_obj.sats_pricing if model_obj else None
|
||||
if not model_pricing:
|
||||
return max_cost_for_model
|
||||
|
||||
tol = settings.tolerance_percentage
|
||||
tol_factor = max(0.0, 1 - float(tol) / 100.0)
|
||||
max_prompt_allowed_sats = model_pricing.max_prompt_cost * tol_factor
|
||||
max_completion_allowed_sats = model_pricing.max_completion_cost * tol_factor
|
||||
|
||||
adjusted = max_cost_for_model
|
||||
|
||||
if messages := body.get("messages"):
|
||||
prompt_tokens = _estimate_tokens(messages)
|
||||
|
||||
image_tokens = await _estimate_image_tokens_in_messages(messages)
|
||||
if image_tokens > 0:
|
||||
logger.debug(
|
||||
"Found images in request",
|
||||
extra={
|
||||
"model": model,
|
||||
"image_tokens": image_tokens,
|
||||
},
|
||||
)
|
||||
prompt_tokens += image_tokens
|
||||
|
||||
estimated_prompt_delta_sats = (
|
||||
max_prompt_allowed_sats - prompt_tokens * model_pricing.prompt
|
||||
)
|
||||
if estimated_prompt_delta_sats > 0:
|
||||
adjusted = adjusted - math.floor(estimated_prompt_delta_sats * 1000)
|
||||
|
||||
max_tokens_raw = body.get("max_tokens", None)
|
||||
if max_tokens_raw is not None:
|
||||
try:
|
||||
max_tokens_int = int(max_tokens_raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Invalid max_tokens; ignoring in cost adjustment",
|
||||
extra={"max_tokens": str(max_tokens_raw)[:64], "model": model},
|
||||
)
|
||||
else:
|
||||
estimated_completion_delta_sats = (
|
||||
max_completion_allowed_sats - max_tokens_int * model_pricing.completion
|
||||
)
|
||||
if estimated_completion_delta_sats > 0:
|
||||
adjusted = adjusted - math.floor(estimated_completion_delta_sats * 1000)
|
||||
|
||||
logger.debug(
|
||||
"Discounted max cost computed",
|
||||
extra={
|
||||
"model": model,
|
||||
"original_msats": max_cost_for_model,
|
||||
"adjusted_msats": adjusted,
|
||||
"tolerance_pct": tol,
|
||||
},
|
||||
)
|
||||
|
||||
return max(0, adjusted)
|
||||
|
||||
|
||||
def _estimate_tokens(messages: list) -> int:
|
||||
"""Estimate tokens for text content, excluding image_url fields."""
|
||||
total = 0
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict):
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
total += len(content)
|
||||
elif isinstance(content, list):
|
||||
total += sum(
|
||||
len(item.get("text", ""))
|
||||
for item in content
|
||||
if isinstance(item, dict) and item.get("type") == "text"
|
||||
)
|
||||
return total // 3
|
||||
|
||||
|
||||
def _get_image_dimensions(image_data: bytes) -> tuple[int, int]:
|
||||
"""Extract image dimensions from image bytes."""
|
||||
try:
|
||||
img = Image.open(BytesIO(image_data))
|
||||
return img.size
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to get image dimensions, using default",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
return (512, 512)
|
||||
|
||||
|
||||
async def _fetch_image_from_url(url: str) -> bytes | None:
|
||||
"""Fetch image from URL."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to fetch image from URL",
|
||||
extra={"error": str(e), "url": url[:100]},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _calculate_image_tokens(width: int, height: int, detail: str = "auto") -> int:
|
||||
"""Calculate image tokens based on OpenAI's vision pricing.
|
||||
|
||||
For low detail: 85 tokens
|
||||
For high detail/auto: 85 base tokens + 170 tokens per 512px tile
|
||||
"""
|
||||
if detail == "low":
|
||||
return 85
|
||||
|
||||
if width > 2048 or height > 2048:
|
||||
aspect_ratio = width / height
|
||||
if width > height:
|
||||
width = 2048
|
||||
height = int(width / aspect_ratio)
|
||||
else:
|
||||
height = 2048
|
||||
width = int(height * aspect_ratio)
|
||||
|
||||
if width > 768 or height > 768:
|
||||
aspect_ratio = width / height
|
||||
if width > height:
|
||||
width = 768
|
||||
height = int(width / aspect_ratio)
|
||||
else:
|
||||
height = 768
|
||||
width = int(height * aspect_ratio)
|
||||
|
||||
tiles_width = (width + 511) // 512
|
||||
tiles_height = (height + 511) // 512
|
||||
num_tiles = tiles_width * tiles_height
|
||||
|
||||
return 85 + (170 * num_tiles)
|
||||
|
||||
|
||||
async def _estimate_image_tokens_in_messages(messages: list) -> int:
|
||||
"""Estimate total tokens for all images in messages.
|
||||
|
||||
Supports both base64 encoded images and image URLs.
|
||||
"""
|
||||
total_image_tokens = 0
|
||||
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
|
||||
content = message.get("content")
|
||||
if not content:
|
||||
continue
|
||||
|
||||
if isinstance(content, str):
|
||||
continue
|
||||
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
|
||||
for content_item in content:
|
||||
if not isinstance(content_item, dict):
|
||||
continue
|
||||
|
||||
content_type = content_item.get("type")
|
||||
if content_type not in ("image_url", "input_image"):
|
||||
continue
|
||||
|
||||
image_url_data = content_item.get("image_url")
|
||||
if not image_url_data:
|
||||
continue
|
||||
|
||||
if isinstance(image_url_data, str):
|
||||
url = image_url_data
|
||||
detail = "auto"
|
||||
elif isinstance(image_url_data, dict):
|
||||
url = image_url_data.get("url", "")
|
||||
detail = image_url_data.get("detail", "auto")
|
||||
else:
|
||||
continue
|
||||
|
||||
if not url:
|
||||
continue
|
||||
|
||||
if url.startswith("data:image/"):
|
||||
try:
|
||||
header, base64_data = url.split(",", 1)
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
width, height = _get_image_dimensions(image_bytes)
|
||||
tokens = _calculate_image_tokens(width, height, detail)
|
||||
total_image_tokens += tokens
|
||||
logger.debug(
|
||||
"Calculated tokens for base64 image",
|
||||
extra={
|
||||
"width": width,
|
||||
"height": height,
|
||||
"detail": detail,
|
||||
"tokens": tokens,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to process base64 image",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
total_image_tokens += 85
|
||||
else:
|
||||
image_bytes_or_none = await _fetch_image_from_url(url)
|
||||
if image_bytes_or_none:
|
||||
width, height = _get_image_dimensions(image_bytes_or_none)
|
||||
tokens = _calculate_image_tokens(width, height, detail)
|
||||
total_image_tokens += tokens
|
||||
logger.debug(
|
||||
"Calculated tokens for URL image",
|
||||
extra={
|
||||
"url": url[:100],
|
||||
"width": width,
|
||||
"height": height,
|
||||
"detail": detail,
|
||||
"tokens": tokens,
|
||||
},
|
||||
)
|
||||
else:
|
||||
total_image_tokens += 85
|
||||
|
||||
return total_image_tokens
|
||||
@@ -1,156 +0,0 @@
|
||||
import math
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession
|
||||
from ..core.settings import settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CostData(BaseModel):
|
||||
base_msats: int
|
||||
input_msats: int
|
||||
output_msats: int
|
||||
total_msats: int
|
||||
|
||||
|
||||
class MaxCostData(CostData):
|
||||
pass
|
||||
|
||||
|
||||
class CostDataError(BaseModel):
|
||||
message: str
|
||||
code: str
|
||||
|
||||
|
||||
async def calculate_cost( # todo: can be sync
|
||||
response_data: dict, max_cost: int, session: AsyncSession
|
||||
) -> CostData | MaxCostData | CostDataError:
|
||||
"""
|
||||
Calculate the cost of an API request based on token usage.
|
||||
|
||||
Args:
|
||||
response_data: Response data containing usage information
|
||||
max_cost: Maximum cost in millisats
|
||||
|
||||
Returns:
|
||||
Cost data or error information
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting cost calculation",
|
||||
extra={
|
||||
"max_cost_msats": max_cost,
|
||||
"has_usage_data": "usage" in response_data,
|
||||
"response_model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
cost_data = MaxCostData(
|
||||
base_msats=max_cost,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=max_cost,
|
||||
)
|
||||
|
||||
if "usage" not in response_data or response_data["usage"] is None:
|
||||
logger.warning(
|
||||
"No usage data in response, using base cost only",
|
||||
extra={
|
||||
"max_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS: float = (
|
||||
float(settings.fixed_per_1k_input_tokens) * 1000.0
|
||||
)
|
||||
MSATS_PER_1K_OUTPUT_TOKENS: float = (
|
||||
float(settings.fixed_per_1k_output_tokens) * 1000.0
|
||||
)
|
||||
|
||||
if not settings.fixed_pricing:
|
||||
response_model = response_data.get("model", "")
|
||||
logger.debug(
|
||||
"Using model-based pricing",
|
||||
extra={"model": response_model},
|
||||
)
|
||||
|
||||
from ..proxy import get_model_instance
|
||||
|
||||
model_obj = get_model_instance(response_model)
|
||||
|
||||
if not model_obj:
|
||||
logger.error(
|
||||
"Invalid model in response",
|
||||
extra={"response_model": response_model},
|
||||
)
|
||||
return CostDataError(
|
||||
message=f"Invalid model in response: {response_model}",
|
||||
code="model_not_found",
|
||||
)
|
||||
|
||||
if not model_obj.sats_pricing:
|
||||
logger.error(
|
||||
"Model pricing not defined",
|
||||
extra={"model": response_model, "model_id": response_model},
|
||||
)
|
||||
return CostDataError(
|
||||
message="Model pricing not defined", code="pricing_not_found"
|
||||
)
|
||||
|
||||
try:
|
||||
mspp = float(model_obj.sats_pricing.prompt)
|
||||
mspc = float(model_obj.sats_pricing.completion)
|
||||
except Exception:
|
||||
return CostDataError(message="Invalid pricing data", code="pricing_invalid")
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS = mspp * 1_000_000.0
|
||||
MSATS_PER_1K_OUTPUT_TOKENS = mspc * 1_000_000.0
|
||||
|
||||
logger.info(
|
||||
"Applied model-specific pricing",
|
||||
extra={
|
||||
"model": response_model,
|
||||
"input_price_msats_per_1k": MSATS_PER_1K_INPUT_TOKENS,
|
||||
"output_price_msats_per_1k": MSATS_PER_1K_OUTPUT_TOKENS,
|
||||
},
|
||||
)
|
||||
|
||||
if not (MSATS_PER_1K_OUTPUT_TOKENS and MSATS_PER_1K_INPUT_TOKENS):
|
||||
logger.warning(
|
||||
"No token pricing configured, using base cost",
|
||||
extra={
|
||||
"base_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
|
||||
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
|
||||
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
|
||||
|
||||
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||
token_based_cost = math.ceil(input_msats + output_msats)
|
||||
|
||||
logger.info(
|
||||
"Calculated token-based cost",
|
||||
extra={
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"input_cost_msats": input_msats,
|
||||
"output_cost_msats": output_msats,
|
||||
"total_cost_msats": token_based_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return CostData(
|
||||
base_msats=0,
|
||||
input_msats=int(input_msats),
|
||||
output_msats=int(output_msats),
|
||||
total_msats=token_based_cost,
|
||||
)
|
||||
@@ -1,396 +1,18 @@
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, Response
|
||||
from fastapi.requests import Request
|
||||
from PIL import Image
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from sqlmodel import col, update
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.settings import settings
|
||||
from ..wallet import deserialize_token_from_string
|
||||
from ..core.db import AsyncSession, TemporaryCredit
|
||||
from ..payment.cost import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> None:
|
||||
if x_cashu := headers.get("x-cashu", None):
|
||||
cashu_token = x_cashu
|
||||
logger.debug(
|
||||
"Using X-Cashu token",
|
||||
extra={
|
||||
"token_preview": cashu_token[:20] + "..."
|
||||
if len(cashu_token) > 20
|
||||
else cashu_token
|
||||
},
|
||||
)
|
||||
elif auth := headers.get("authorization", None):
|
||||
cashu_token = auth.split(" ")[1] if len(auth.split(" ")) > 1 else ""
|
||||
logger.debug(
|
||||
"Using Authorization header token",
|
||||
extra={
|
||||
"token_preview": cashu_token[:20] + "..."
|
||||
if len(cashu_token) > 20
|
||||
else cashu_token
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.error("No authentication token provided")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# Handle empty token
|
||||
if not cashu_token:
|
||||
logger.error("Empty token provided")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "API key or Cashu token required",
|
||||
"type": "invalid_request_error",
|
||||
"code": "missing_api_key",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Handle regular API keys (sk-*)
|
||||
if cashu_token.startswith("sk-"):
|
||||
return
|
||||
|
||||
try:
|
||||
token_obj = deserialize_token_from_string(cashu_token)
|
||||
except Exception:
|
||||
# Invalid token format - let the auth system handle it
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid authentication token format",
|
||||
)
|
||||
|
||||
amount_msat = (
|
||||
token_obj.amount if token_obj.unit == "msat" else token_obj.amount * 1000
|
||||
)
|
||||
|
||||
if max_cost_for_model > amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail={
|
||||
"reason": "Insufficient balance",
|
||||
"amount_required_msat": max_cost_for_model,
|
||||
"model": body.get("model", "unknown"),
|
||||
"type": "minimum_balance_required",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def get_max_cost_for_model(
|
||||
model: str,
|
||||
session: AsyncSession,
|
||||
model_obj: Any | None = None,
|
||||
) -> int:
|
||||
"""Get the maximum cost for a specific model from providers with overrides."""
|
||||
logger.debug(
|
||||
"Getting max cost for model",
|
||||
extra={
|
||||
"model": model,
|
||||
"fixed_pricing": settings.fixed_pricing,
|
||||
},
|
||||
)
|
||||
|
||||
if settings.fixed_pricing:
|
||||
default_cost_msats = settings.fixed_cost_per_request * 1000
|
||||
logger.debug(
|
||||
"Using fixed cost pricing",
|
||||
extra={"cost_msats": default_cost_msats, "model": model},
|
||||
)
|
||||
return max(settings.min_request_msat, default_cost_msats)
|
||||
|
||||
if not model_obj:
|
||||
from ..proxy import get_model_instance
|
||||
|
||||
model_obj = get_model_instance(model)
|
||||
|
||||
if not model_obj:
|
||||
fallback_msats = settings.fixed_cost_per_request * 1000
|
||||
logger.warning(
|
||||
"Model not found in providers or overrides",
|
||||
extra={
|
||||
"requested_model": model,
|
||||
"using_default_cost": fallback_msats,
|
||||
},
|
||||
)
|
||||
return max(settings.min_request_msat, fallback_msats)
|
||||
|
||||
if model_obj.sats_pricing:
|
||||
try:
|
||||
max_cost = (
|
||||
model_obj.sats_pricing.max_cost
|
||||
* 1000
|
||||
* (1 - settings.tolerance_percentage / 100)
|
||||
)
|
||||
logger.debug(
|
||||
"Found model-specific max cost",
|
||||
extra={"model": model, "max_cost_msats": max_cost},
|
||||
)
|
||||
calculated_msats = int(max_cost)
|
||||
return max(settings.min_request_msat, calculated_msats)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error calculating max cost from model pricing",
|
||||
extra={"model": model, "error": str(e)},
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"Model pricing not found, using fixed cost",
|
||||
extra={
|
||||
"model": model,
|
||||
"default_cost_msats": settings.fixed_cost_per_request * 1000,
|
||||
},
|
||||
)
|
||||
return max(settings.min_request_msat, settings.fixed_cost_per_request * 1000)
|
||||
|
||||
|
||||
async def calculate_discounted_max_cost(
|
||||
max_cost_for_model: int,
|
||||
body: dict,
|
||||
model_obj: Any | None = None,
|
||||
) -> int:
|
||||
"""Calculate the discounted max cost for a request using model pricing when available."""
|
||||
if settings.fixed_pricing:
|
||||
return max_cost_for_model
|
||||
|
||||
model = body.get("model", "unknown")
|
||||
|
||||
model_pricing = model_obj.sats_pricing if model_obj else None
|
||||
if not model_pricing:
|
||||
return max_cost_for_model
|
||||
|
||||
tol = settings.tolerance_percentage
|
||||
tol_factor = max(0.0, 1 - float(tol) / 100.0)
|
||||
max_prompt_allowed_sats = model_pricing.max_prompt_cost * tol_factor
|
||||
max_completion_allowed_sats = model_pricing.max_completion_cost * tol_factor
|
||||
|
||||
adjusted = max_cost_for_model
|
||||
|
||||
if messages := body.get("messages"):
|
||||
prompt_tokens = estimate_tokens(messages)
|
||||
|
||||
image_tokens = await estimate_image_tokens_in_messages(messages)
|
||||
if image_tokens > 0:
|
||||
logger.debug(
|
||||
"Found images in request",
|
||||
extra={
|
||||
"model": model,
|
||||
"image_tokens": image_tokens,
|
||||
},
|
||||
)
|
||||
prompt_tokens += image_tokens
|
||||
|
||||
estimated_prompt_delta_sats = (
|
||||
max_prompt_allowed_sats - prompt_tokens * model_pricing.prompt
|
||||
)
|
||||
if estimated_prompt_delta_sats > 0:
|
||||
adjusted = adjusted - math.floor(estimated_prompt_delta_sats * 1000)
|
||||
|
||||
max_tokens_raw = body.get("max_tokens", None)
|
||||
if max_tokens_raw is not None:
|
||||
try:
|
||||
max_tokens_int = int(max_tokens_raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Invalid max_tokens; ignoring in cost adjustment",
|
||||
extra={"max_tokens": str(max_tokens_raw)[:64], "model": model},
|
||||
)
|
||||
else:
|
||||
estimated_completion_delta_sats = (
|
||||
max_completion_allowed_sats - max_tokens_int * model_pricing.completion
|
||||
)
|
||||
if estimated_completion_delta_sats > 0:
|
||||
adjusted = adjusted - math.floor(estimated_completion_delta_sats * 1000)
|
||||
|
||||
logger.debug(
|
||||
"Discounted max cost computed",
|
||||
extra={
|
||||
"model": model,
|
||||
"original_msats": max_cost_for_model,
|
||||
"adjusted_msats": adjusted,
|
||||
"tolerance_pct": tol,
|
||||
},
|
||||
)
|
||||
|
||||
return max(0, adjusted)
|
||||
|
||||
|
||||
def estimate_tokens(messages: list) -> int:
|
||||
"""Estimate tokens for text content, excluding image_url fields."""
|
||||
total = 0
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict):
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
total += len(content)
|
||||
elif isinstance(content, list):
|
||||
total += sum(
|
||||
len(item.get("text", ""))
|
||||
for item in content
|
||||
if isinstance(item, dict) and item.get("type") == "text"
|
||||
)
|
||||
return total // 3
|
||||
|
||||
|
||||
def _get_image_dimensions(image_data: bytes) -> tuple[int, int]:
|
||||
"""Extract image dimensions from image bytes."""
|
||||
try:
|
||||
img = Image.open(BytesIO(image_data))
|
||||
return img.size
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to get image dimensions, using default",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
return (512, 512)
|
||||
|
||||
|
||||
async def _fetch_image_from_url(url: str) -> bytes | None:
|
||||
"""Fetch image from URL."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to fetch image from URL",
|
||||
extra={"error": str(e), "url": url[:100]},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _calculate_image_tokens(width: int, height: int, detail: str = "auto") -> int:
|
||||
"""Calculate image tokens based on OpenAI's vision pricing.
|
||||
|
||||
For low detail: 85 tokens
|
||||
For high detail/auto: 85 base tokens + 170 tokens per 512px tile
|
||||
"""
|
||||
if detail == "low":
|
||||
return 85
|
||||
|
||||
if width > 2048 or height > 2048:
|
||||
aspect_ratio = width / height
|
||||
if width > height:
|
||||
width = 2048
|
||||
height = int(width / aspect_ratio)
|
||||
else:
|
||||
height = 2048
|
||||
width = int(height * aspect_ratio)
|
||||
|
||||
if width > 768 or height > 768:
|
||||
aspect_ratio = width / height
|
||||
if width > height:
|
||||
width = 768
|
||||
height = int(width / aspect_ratio)
|
||||
else:
|
||||
height = 768
|
||||
width = int(height * aspect_ratio)
|
||||
|
||||
tiles_width = (width + 511) // 512
|
||||
tiles_height = (height + 511) // 512
|
||||
num_tiles = tiles_width * tiles_height
|
||||
|
||||
return 85 + (170 * num_tiles)
|
||||
|
||||
|
||||
async def estimate_image_tokens_in_messages(messages: list) -> int:
|
||||
"""Estimate total tokens for all images in messages.
|
||||
|
||||
Supports both base64 encoded images and image URLs.
|
||||
"""
|
||||
total_image_tokens = 0
|
||||
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
|
||||
content = message.get("content")
|
||||
if not content:
|
||||
continue
|
||||
|
||||
if isinstance(content, str):
|
||||
continue
|
||||
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
|
||||
for content_item in content:
|
||||
if not isinstance(content_item, dict):
|
||||
continue
|
||||
|
||||
content_type = content_item.get("type")
|
||||
if content_type not in ("image_url", "input_image"):
|
||||
continue
|
||||
|
||||
image_url_data = content_item.get("image_url")
|
||||
if not image_url_data:
|
||||
continue
|
||||
|
||||
if isinstance(image_url_data, str):
|
||||
url = image_url_data
|
||||
detail = "auto"
|
||||
elif isinstance(image_url_data, dict):
|
||||
url = image_url_data.get("url", "")
|
||||
detail = image_url_data.get("detail", "auto")
|
||||
else:
|
||||
continue
|
||||
|
||||
if not url:
|
||||
continue
|
||||
|
||||
if url.startswith("data:image/"):
|
||||
try:
|
||||
header, base64_data = url.split(",", 1)
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
width, height = _get_image_dimensions(image_bytes)
|
||||
tokens = _calculate_image_tokens(width, height, detail)
|
||||
total_image_tokens += tokens
|
||||
logger.debug(
|
||||
"Calculated tokens for base64 image",
|
||||
extra={
|
||||
"width": width,
|
||||
"height": height,
|
||||
"detail": detail,
|
||||
"tokens": tokens,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to process base64 image",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
total_image_tokens += 85
|
||||
else:
|
||||
image_bytes_or_none = await _fetch_image_from_url(url)
|
||||
if image_bytes_or_none:
|
||||
width, height = _get_image_dimensions(image_bytes_or_none)
|
||||
tokens = _calculate_image_tokens(width, height, detail)
|
||||
total_image_tokens += tokens
|
||||
logger.debug(
|
||||
"Calculated tokens for URL image",
|
||||
extra={
|
||||
"url": url[:100],
|
||||
"width": width,
|
||||
"height": height,
|
||||
"detail": detail,
|
||||
"tokens": tokens,
|
||||
},
|
||||
)
|
||||
else:
|
||||
total_image_tokens += 85
|
||||
|
||||
return total_image_tokens
|
||||
|
||||
|
||||
# TODO: remove and replace with custom HTTPException
|
||||
def create_error_response(
|
||||
error_type: str,
|
||||
message: str,
|
||||
@@ -414,3 +36,380 @@ def create_error_response(
|
||||
media_type="application/json",
|
||||
headers={"X-Cashu": token} if token else {},
|
||||
)
|
||||
|
||||
|
||||
# Request payment handlers, todo: maybe mode somewhere else...
|
||||
|
||||
|
||||
async def pay_for_request(
|
||||
key: TemporaryCredit, cost_per_request: int, session: AsyncSession
|
||||
) -> int:
|
||||
"""Process payment for a request."""
|
||||
|
||||
logger.info(
|
||||
"Processing payment for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"current_balance": key.balance,
|
||||
"required_cost": cost_per_request,
|
||||
"sufficient_balance": key.balance >= cost_per_request,
|
||||
},
|
||||
)
|
||||
|
||||
if key.total_balance_msat < cost_per_request:
|
||||
logger.warning(
|
||||
"Insufficient balance for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"balance": key.balance,
|
||||
"reserved_balance": key.reserved_balance,
|
||||
"required": cost_per_request,
|
||||
"shortfall": cost_per_request - key.total_balance_msat,
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.total_balance} available. (reserved: {key.reserved_balance})",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Charging base cost for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"cost": cost_per_request,
|
||||
"balance_before": key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
# Charge the base cost for the request atomically to avoid race conditions
|
||||
stmt = (
|
||||
update(TemporaryCredit)
|
||||
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
|
||||
.where(
|
||||
col(TemporaryCredit.balance) - col(TemporaryCredit.reserved_balance)
|
||||
>= cost_per_request
|
||||
)
|
||||
.values(
|
||||
reserved_balance=col(TemporaryCredit.reserved_balance) + cost_per_request
|
||||
)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Concurrent request depleted balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"required_cost": cost_per_request,
|
||||
"current_balance": key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
# Another concurrent request spent the balance first
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Payment processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost_per_request,
|
||||
"new_balance": key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
return cost_per_request
|
||||
|
||||
|
||||
async def revert_pay_for_request(
|
||||
key: TemporaryCredit, session: AsyncSession, cost_per_request: int
|
||||
) -> None:
|
||||
stmt = (
|
||||
update(TemporaryCredit)
|
||||
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(TemporaryCredit.reserved_balance) - cost_per_request
|
||||
)
|
||||
)
|
||||
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to revert payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"cost_to_revert": cost_per_request,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"failed to revert request payment: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"type": "payment_error",
|
||||
"code": "payment_error",
|
||||
}
|
||||
},
|
||||
)
|
||||
await session.refresh(key)
|
||||
|
||||
|
||||
async def adjust_payment_for_tokens(
|
||||
key: TemporaryCredit,
|
||||
response_data: dict,
|
||||
session: AsyncSession,
|
||||
deducted_max_cost: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Adjusts the payment based on token usage in the response.
|
||||
This is called after the initial payment and the upstream request is complete.
|
||||
Returns cost data to be included in the response.
|
||||
"""
|
||||
model = response_data.get("model", "unknown")
|
||||
|
||||
logger.debug(
|
||||
"Starting payment adjustment for tokens",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_balance": key.balance,
|
||||
"has_usage": "usage" in response_data,
|
||||
},
|
||||
)
|
||||
|
||||
match calculate_cost(response_data, deducted_max_cost):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost data (no token adjustment)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"max_cost": cost.total_msats,
|
||||
},
|
||||
)
|
||||
# Finalize by releasing reservation and charging max cost
|
||||
finalize_stmt = (
|
||||
update(TemporaryCredit)
|
||||
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(TemporaryCredit.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(TemporaryCredit.balance) - cost.total_msats,
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize max-cost payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"total_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
await session.refresh(key)
|
||||
logger.info(
|
||||
"Max cost payment finalized",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost.total_msats,
|
||||
"new_balance": key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
return cost.dict()
|
||||
|
||||
case CostData() as cost:
|
||||
# If token-based pricing is enabled and base cost is 0, use token-based cost
|
||||
# Otherwise, token cost is additional to the base cost
|
||||
cost_difference = cost.total_msats - deducted_max_cost
|
||||
total_cost_msats: int = math.ceil(cost.total_msats)
|
||||
|
||||
logger.info(
|
||||
"Calculated token-based cost",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"token_cost": cost.total_msats,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"cost_difference": cost_difference,
|
||||
"input_msats": cost.input_msats,
|
||||
"output_msats": cost.output_msats,
|
||||
},
|
||||
)
|
||||
|
||||
if cost_difference == 0:
|
||||
logger.debug(
|
||||
"Finalizing with exact reserved cost",
|
||||
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
||||
)
|
||||
finalize_stmt = (
|
||||
update(TemporaryCredit)
|
||||
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(TemporaryCredit.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(TemporaryCredit.balance) - total_cost_msats,
|
||||
)
|
||||
)
|
||||
await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
return cost.dict()
|
||||
|
||||
# this should never happen why do we handle this???
|
||||
if cost_difference > 0:
|
||||
# Need to charge more than reserved, finalize by releasing reservation and charging total
|
||||
logger.info(
|
||||
"Additional charge required for token usage",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"additional_charge": cost_difference,
|
||||
"current_balance": key.balance,
|
||||
"sufficient_balance": key.balance >= cost_difference,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
finalize_stmt = (
|
||||
update(TemporaryCredit)
|
||||
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(TemporaryCredit.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(TemporaryCredit.balance) - total_cost_msats,
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount:
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Finalized payment with additional charge",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"charged_amount": total_cost_msats,
|
||||
"new_balance": key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to finalize additional charge (concurrent operation)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"attempted_charge": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Refund some of the base cost
|
||||
refund = abs(cost_difference)
|
||||
logger.info(
|
||||
"Refunding excess payment",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"refund_amount": refund,
|
||||
"current_balance": key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
refund_stmt = (
|
||||
update(TemporaryCredit)
|
||||
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(TemporaryCredit.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(TemporaryCredit.balance) - total_cost_msats,
|
||||
)
|
||||
)
|
||||
result = await session.exec(refund_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"total_cost": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
# Still return the cost data even if we couldn't properly finalize
|
||||
# The reservation was already made, so the user has paid
|
||||
|
||||
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",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"error_message": error.message,
|
||||
"error_code": error.code,
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"message": error.message,
|
||||
"type": "invalid_request_error",
|
||||
"code": error.code,
|
||||
}
|
||||
},
|
||||
)
|
||||
# Fallback return to satisfy type checker; execution should not reach here
|
||||
return {
|
||||
"base_msats": deducted_max_cost,
|
||||
"input_msats": 0,
|
||||
"output_msats": 0,
|
||||
"total_msats": deducted_max_cost,
|
||||
}
|
||||
|
||||
@@ -305,3 +305,12 @@ async def raw_send_to_lnurl(
|
||||
quote_id=melt_quote_resp.quote,
|
||||
)
|
||||
return final_amount
|
||||
|
||||
|
||||
async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
|
||||
from .wallet import get_wallet
|
||||
|
||||
wallet = await get_wallet(mint, unit)
|
||||
proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id]
|
||||
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
|
||||
return await raw_send_to_lnurl(wallet, proofs, address, unit)
|
||||
|
||||
@@ -1,723 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from urllib.request import urlopen
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic.v1 import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..core.db import ModelRow, create_session, get_session
|
||||
from ..core.logging import get_logger
|
||||
from ..core.settings import settings
|
||||
from .price import sats_usd_price
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
models_router = APIRouter()
|
||||
|
||||
DEFAULT_EXCLUDED_MODEL_IDS: Final[set[str]] = {
|
||||
"openrouter/auto",
|
||||
"google/gemini-2.5-pro-exp-03-25",
|
||||
"opengvlab/internvl3-78b",
|
||||
"openrouter/sonoma-dusk-alpha",
|
||||
"openrouter/sonoma-sky-alpha",
|
||||
}
|
||||
|
||||
|
||||
class Architecture(BaseModel):
|
||||
modality: str
|
||||
input_modalities: list[str]
|
||||
output_modalities: list[str]
|
||||
tokenizer: str
|
||||
instruct_type: str | None
|
||||
|
||||
|
||||
class Pricing(BaseModel):
|
||||
prompt: float
|
||||
completion: float
|
||||
request: float
|
||||
image: float
|
||||
web_search: float
|
||||
internal_reasoning: float
|
||||
max_prompt_cost: float = 0.0 # in sats not msats
|
||||
max_completion_cost: float = 0.0 # in sats not msats
|
||||
max_cost: float = 0.0 # in sats not msats
|
||||
|
||||
|
||||
class TopProvider(BaseModel):
|
||||
context_length: int | None = None
|
||||
max_completion_tokens: int | None = None
|
||||
is_moderated: bool | None = None
|
||||
|
||||
|
||||
class Model(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
created: int
|
||||
description: str
|
||||
context_length: int
|
||||
architecture: Architecture
|
||||
pricing: Pricing
|
||||
sats_pricing: Pricing | None = None
|
||||
per_request_limits: dict | None = None
|
||||
top_provider: TopProvider | None = None
|
||||
enabled: bool = True
|
||||
upstream_provider_id: int | None = None
|
||||
canonical_slug: str | None = None
|
||||
alias_ids: list[str] | None = None
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
"""Fetches model information from OpenRouter API."""
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
|
||||
try:
|
||||
with urlopen(f"{base_url}/models") as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
models_data: list[dict] = []
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
|
||||
if source_filter:
|
||||
source_prefix = f"{source_filter}/"
|
||||
if not model_id.startswith(source_prefix):
|
||||
continue
|
||||
|
||||
model = dict(model)
|
||||
model["id"] = model_id[len(source_prefix) :]
|
||||
model_id = model["id"]
|
||||
|
||||
if (
|
||||
"(free)" in model.get("name", "")
|
||||
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
|
||||
):
|
||||
continue
|
||||
|
||||
models_data.append(model)
|
||||
|
||||
return models_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def async_fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
"""Asynchronously fetch model information from OpenRouter API."""
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(f"{base_url}/models", timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
models_data: list[dict] = []
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
|
||||
if source_filter:
|
||||
source_prefix = f"{source_filter}/"
|
||||
if not model_id.startswith(source_prefix):
|
||||
continue
|
||||
|
||||
model = dict(model)
|
||||
model["id"] = model_id[len(source_prefix) :]
|
||||
model_id = model["id"]
|
||||
|
||||
if (
|
||||
"(free)" in model.get("name", "")
|
||||
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
|
||||
):
|
||||
continue
|
||||
|
||||
models_data.append(model)
|
||||
|
||||
return models_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error (async) fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def is_openrouter_upstream() -> bool:
|
||||
try:
|
||||
base = (settings.upstream_base_url or "").strip().rstrip("/")
|
||||
except Exception:
|
||||
return False
|
||||
return base.lower() == "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def load_models() -> list[Model]:
|
||||
"""Load model definitions from a JSON file or auto-generate from OpenRouter API.
|
||||
|
||||
The file path can be specified via the ``MODELS_PATH`` environment variable.
|
||||
If a user-provided models.json exists, it will be used. Otherwise, models are
|
||||
automatically fetched from OpenRouter API in memory. If the example file exists
|
||||
and no user file is provided, it will be used as a fallback.
|
||||
"""
|
||||
|
||||
try:
|
||||
models_path = Path(settings.models_path)
|
||||
except Exception:
|
||||
models_path = Path("models.json")
|
||||
|
||||
# Check if user has actively provided a models.json file
|
||||
if models_path.exists():
|
||||
logger.info(f"Loading models from user-provided file: {models_path}")
|
||||
try:
|
||||
with models_path.open("r") as f:
|
||||
data = json.load(f)
|
||||
return [Model(**model) for model in data.get("models", [])] # type: ignore
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading models from {models_path}: {e}")
|
||||
# Fall through to auto-generation
|
||||
|
||||
# Only auto-generate from OpenRouter when upstream is OpenRouter
|
||||
if not is_openrouter_upstream():
|
||||
logger.info(
|
||||
"Skipping auto-generation from OpenRouter because upstream_base_url is not https://openrouter.ai/api/v1"
|
||||
)
|
||||
return []
|
||||
|
||||
logger.info("Auto-generating models from OpenRouter API")
|
||||
try:
|
||||
source_filter = settings.source or None
|
||||
except Exception:
|
||||
source_filter = None
|
||||
source_filter = source_filter if source_filter and source_filter.strip() else None
|
||||
|
||||
models_data = fetch_openrouter_models(source_filter=source_filter)
|
||||
if not models_data:
|
||||
logger.error("Failed to fetch models from OpenRouter API")
|
||||
return []
|
||||
|
||||
logger.info(f"Successfully fetched {len(models_data)} models from OpenRouter API")
|
||||
return [Model(**model) for model in models_data] # type: ignore
|
||||
|
||||
|
||||
def _row_to_model(
|
||||
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
|
||||
) -> Model:
|
||||
architecture = json.loads(row.architecture)
|
||||
pricing = json.loads(row.pricing)
|
||||
per_request_limits = (
|
||||
json.loads(row.per_request_limits) if row.per_request_limits else None
|
||||
)
|
||||
top_provider_dict = json.loads(row.top_provider) if row.top_provider else None
|
||||
|
||||
if apply_provider_fee and isinstance(pricing, dict):
|
||||
pricing = {k: float(v) * provider_fee for k, v in pricing.items()}
|
||||
|
||||
if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0:
|
||||
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
|
||||
|
||||
parsed_pricing = Pricing.parse_obj(pricing)
|
||||
model = Model(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
created=row.created,
|
||||
description=row.description,
|
||||
context_length=row.context_length,
|
||||
architecture=Architecture.parse_obj(architecture),
|
||||
pricing=parsed_pricing,
|
||||
sats_pricing=None,
|
||||
per_request_limits=per_request_limits,
|
||||
top_provider=TopProvider.parse_obj(top_provider_dict)
|
||||
if top_provider_dict
|
||||
else None,
|
||||
enabled=row.enabled,
|
||||
upstream_provider_id=row.upstream_provider_id,
|
||||
canonical_slug=getattr(row, "canonical_slug", None),
|
||||
)
|
||||
|
||||
if apply_provider_fee:
|
||||
(
|
||||
parsed_pricing.max_prompt_cost,
|
||||
parsed_pricing.max_completion_cost,
|
||||
parsed_pricing.max_cost,
|
||||
) = _calculate_usd_max_costs(model)
|
||||
|
||||
try:
|
||||
sats_to_usd = sats_usd_price()
|
||||
model = _update_model_sats_pricing(model, sats_to_usd)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not calculate sats pricing: {e}")
|
||||
|
||||
return model
|
||||
|
||||
|
||||
def _model_to_row_payload(model: Model) -> dict[str, str | int | bool | None]:
|
||||
return {
|
||||
"id": model.id,
|
||||
"name": model.name,
|
||||
"created": model.created,
|
||||
"description": model.description,
|
||||
"context_length": model.context_length,
|
||||
"architecture": json.dumps(model.architecture.dict()),
|
||||
"pricing": json.dumps(model.pricing.dict()),
|
||||
"sats_pricing": json.dumps(model.sats_pricing.dict())
|
||||
if model.sats_pricing
|
||||
else None,
|
||||
"per_request_limits": json.dumps(model.per_request_limits)
|
||||
if model.per_request_limits is not None
|
||||
else None,
|
||||
"top_provider": json.dumps(model.top_provider.dict())
|
||||
if model.top_provider is not None
|
||||
else None,
|
||||
"enabled": model.enabled,
|
||||
"upstream_provider_id": model.upstream_provider_id,
|
||||
}
|
||||
|
||||
|
||||
async def list_models(
|
||||
session: AsyncSession,
|
||||
upstream_id: int,
|
||||
include_disabled: bool = False,
|
||||
) -> list[Model]:
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
query = select(ModelRow)
|
||||
if upstream_id is not None:
|
||||
query = query.where(ModelRow.upstream_provider_id == upstream_id)
|
||||
if not include_disabled:
|
||||
query = query.where(ModelRow.enabled)
|
||||
|
||||
rows = (await session.exec(query)).all() # type: ignore
|
||||
provider_result = await session.exec(select(UpstreamProviderRow))
|
||||
providers_by_id = {p.id: p for p in provider_result.all()}
|
||||
return [
|
||||
_row_to_model(
|
||||
r,
|
||||
apply_provider_fee=True,
|
||||
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
|
||||
if r.upstream_provider_id in providers_by_id
|
||||
else 1.01,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def get_model_by_id(
|
||||
model_id: str, provider_id: int, session: AsyncSession
|
||||
) -> Model | None:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
row = await session.get(ModelRow, (model_id, provider_id))
|
||||
if not row or not row.enabled:
|
||||
return None
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
provider_fee = provider.provider_fee if provider else 1.01
|
||||
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
|
||||
|
||||
|
||||
def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
|
||||
"""Calculate max costs in USD based on model context/token limits.
|
||||
|
||||
Args:
|
||||
model: Model object
|
||||
|
||||
Returns:
|
||||
Tuple of (max_prompt_cost, max_completion_cost, max_cost) in USD
|
||||
"""
|
||||
min_req_msat = max(1, int(getattr(settings, "min_request_msat", 1)))
|
||||
min_req_usd = float(min_req_msat) / 1_000_000.0
|
||||
|
||||
prompt_price = model.pricing.prompt
|
||||
completion_price = model.pricing.completion
|
||||
|
||||
if model.top_provider and (
|
||||
model.top_provider.context_length or model.top_provider.max_completion_tokens
|
||||
):
|
||||
if (cl := model.top_provider.context_length) and (
|
||||
mct := model.top_provider.max_completion_tokens
|
||||
):
|
||||
if cl <= mct:
|
||||
return (
|
||||
cl * prompt_price,
|
||||
cl * completion_price,
|
||||
cl * max(completion_price, prompt_price),
|
||||
)
|
||||
return (
|
||||
cl * prompt_price,
|
||||
mct * completion_price,
|
||||
(cl - mct) * prompt_price + mct * completion_price,
|
||||
)
|
||||
elif cl := model.top_provider.context_length:
|
||||
return (
|
||||
cl * prompt_price,
|
||||
cl * completion_price,
|
||||
cl * max(completion_price, prompt_price),
|
||||
)
|
||||
elif mct := model.top_provider.max_completion_tokens:
|
||||
return (
|
||||
mct * prompt_price,
|
||||
mct * completion_price,
|
||||
mct * completion_price,
|
||||
)
|
||||
elif model.context_length:
|
||||
return (
|
||||
model.context_length * prompt_price,
|
||||
model.context_length * completion_price,
|
||||
model.context_length * max(completion_price, prompt_price),
|
||||
)
|
||||
|
||||
p = prompt_price * 1_000_000
|
||||
c = completion_price * 32_000
|
||||
r = model.pricing.request * 100_000
|
||||
i = model.pricing.image * 100
|
||||
w = model.pricing.web_search * 1000
|
||||
ir = model.pricing.internal_reasoning * 100
|
||||
return (p, c, max(p + c + r + i + w + ir, min_req_usd))
|
||||
|
||||
|
||||
def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
|
||||
"""Update a model's sats_pricing based on USD pricing and exchange rate.
|
||||
|
||||
Args:
|
||||
model: Model object to update
|
||||
sats_to_usd: Current sats to USD exchange rate
|
||||
|
||||
Returns:
|
||||
Updated Model object with new sats_pricing
|
||||
"""
|
||||
try:
|
||||
min_req_msat = max(1, int(getattr(settings, "min_request_msat", 1)))
|
||||
min_req_sats = float(min_req_msat) / 1000.0
|
||||
|
||||
sats = Pricing.parse_obj(
|
||||
{k: v / sats_to_usd for k, v in model.pricing.dict().items()}
|
||||
)
|
||||
|
||||
if sats.request <= 0.0:
|
||||
sats.request = min_req_sats
|
||||
if (sats.max_cost or 0.0) < min_req_sats:
|
||||
sats.max_cost = min_req_sats
|
||||
|
||||
return Model(
|
||||
id=model.id,
|
||||
name=model.name,
|
||||
created=model.created,
|
||||
description=model.description,
|
||||
context_length=model.context_length,
|
||||
architecture=model.architecture,
|
||||
pricing=model.pricing,
|
||||
sats_pricing=sats,
|
||||
per_request_limits=model.per_request_limits,
|
||||
top_provider=model.top_provider,
|
||||
enabled=model.enabled,
|
||||
upstream_provider_id=model.upstream_provider_id,
|
||||
canonical_slug=model.canonical_slug,
|
||||
alias_ids=model.alias_ids,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to update sats pricing for model",
|
||||
extra={
|
||||
"model_id": model.id,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
async def ensure_models_bootstrapped() -> None:
|
||||
async with create_session() as s:
|
||||
existing = (await s.exec(select(ModelRow.id).limit(1))).all() # type: ignore
|
||||
if existing:
|
||||
return
|
||||
|
||||
try:
|
||||
models_path = Path(settings.models_path)
|
||||
except Exception:
|
||||
models_path = Path("models.json")
|
||||
|
||||
models_to_insert: list[dict] = []
|
||||
if models_path.exists():
|
||||
try:
|
||||
with models_path.open("r") as f:
|
||||
data = json.load(f)
|
||||
models_to_insert = data.get("models", [])
|
||||
logger.info(
|
||||
f"Bootstrapping {len(models_to_insert)} models from {models_path}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading models from {models_path}: {e}")
|
||||
|
||||
if not models_to_insert and is_openrouter_upstream():
|
||||
logger.info("Bootstrapping models from OpenRouter API")
|
||||
source_filter = None
|
||||
try:
|
||||
src = settings.source or None
|
||||
source_filter = src if src and src.strip() else None
|
||||
except Exception:
|
||||
pass
|
||||
models_to_insert = fetch_openrouter_models(source_filter=source_filter)
|
||||
elif not models_to_insert:
|
||||
logger.info(
|
||||
"No models.json found and upstream is not OpenRouter; skipping bootstrap"
|
||||
)
|
||||
|
||||
for m in models_to_insert:
|
||||
try:
|
||||
model = Model(**m) # type: ignore
|
||||
except Exception:
|
||||
# Some OpenRouter models include extra fields; only map required ones
|
||||
continue
|
||||
exists = await s.get(ModelRow, model.id)
|
||||
if exists:
|
||||
continue
|
||||
payload = _model_to_row_payload(model)
|
||||
s.add(ModelRow(**payload)) # type: ignore
|
||||
await s.commit()
|
||||
|
||||
|
||||
async def _update_sats_pricing_once() -> None:
|
||||
"""Update sats pricing once for all provider models (in-memory only)."""
|
||||
from ..proxy import get_upstreams
|
||||
|
||||
upstreams = get_upstreams()
|
||||
sats_to_usd = sats_usd_price()
|
||||
|
||||
updated_count = 0
|
||||
for upstream in upstreams:
|
||||
updated_models = [
|
||||
_update_model_sats_pricing(m, sats_to_usd)
|
||||
for m in upstream.get_cached_models()
|
||||
]
|
||||
upstream._models_cache = updated_models
|
||||
upstream._models_by_id = {m.id: m for m in updated_models}
|
||||
updated_count += len(updated_models)
|
||||
|
||||
if updated_count > 0:
|
||||
logger.info("Updated sats pricing", extra={"models_updated": updated_count})
|
||||
|
||||
|
||||
async def update_sats_pricing() -> None:
|
||||
"""Periodically update sats pricing for all provider models and database overrides."""
|
||||
try:
|
||||
if not settings.enable_pricing_refresh:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await _update_sats_pricing_once()
|
||||
|
||||
while True:
|
||||
try:
|
||||
interval = getattr(settings, "pricing_refresh_interval_seconds", 120)
|
||||
jitter = max(0.0, float(interval) * 0.1)
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
try:
|
||||
try:
|
||||
if not settings.enable_pricing_refresh:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await _update_sats_pricing_once()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating sats pricing: {e}")
|
||||
|
||||
|
||||
async def cleanup_enabled_models_periodically() -> None:
|
||||
"""Background task to clean up enabled models that match upstream pricing.
|
||||
|
||||
When model is enabled (enabled=True), remove it from DB if it matches upstream pricing.
|
||||
Keep it in DB only if pricing differs from upstream or if it's disabled.
|
||||
"""
|
||||
interval = getattr(
|
||||
settings, "models_cleanup_interval_seconds", 300
|
||||
) # 5 minutes default
|
||||
if not interval or interval <= 0:
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
await _cleanup_enabled_models_once()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error during enabled models cleanup",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
try:
|
||||
jitter = max(0.0, float(interval) * 0.1)
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
|
||||
async def _cleanup_enabled_models_once() -> None:
|
||||
"""Clean up enabled models that match upstream pricing."""
|
||||
from ..proxy import get_upstreams
|
||||
|
||||
async with create_session() as session:
|
||||
# Get all enabled models from DB
|
||||
result = await session.exec(
|
||||
select(ModelRow).where(
|
||||
ModelRow.enabled, # Only enabled models
|
||||
)
|
||||
)
|
||||
db_models = result.all()
|
||||
|
||||
if not db_models:
|
||||
return
|
||||
|
||||
upstreams = get_upstreams()
|
||||
models_to_remove = []
|
||||
|
||||
for db_model in db_models:
|
||||
# Find corresponding upstream model
|
||||
upstream_model = None
|
||||
for upstream in upstreams:
|
||||
upstream_model = upstream.get_cached_model_by_id(db_model.id)
|
||||
if upstream_model:
|
||||
break
|
||||
|
||||
if not upstream_model:
|
||||
continue
|
||||
|
||||
# Compare pricing to see if they match
|
||||
db_pricing = json.loads(db_model.pricing)
|
||||
upstream_pricing = upstream_model.pricing.dict()
|
||||
|
||||
# Check if pricing matches (with small tolerance for float comparison)
|
||||
pricing_matches = _pricing_matches(db_pricing, upstream_pricing)
|
||||
|
||||
if pricing_matches:
|
||||
models_to_remove.append(db_model)
|
||||
logger.info(
|
||||
f"Removing enabled model {db_model.id} - matches upstream pricing",
|
||||
extra={"model_id": db_model.id},
|
||||
)
|
||||
|
||||
# Remove models that match upstream pricing
|
||||
for model in models_to_remove:
|
||||
await session.delete(model)
|
||||
|
||||
if models_to_remove:
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Cleaned up {len(models_to_remove)} enabled models that match upstream pricing"
|
||||
)
|
||||
|
||||
|
||||
def _pricing_matches(
|
||||
db_pricing: dict, upstream_pricing: dict, tolerance: float = 0.0
|
||||
) -> bool:
|
||||
"""Check if pricing dictionaries match within tolerance."""
|
||||
keys_to_compare = [
|
||||
"prompt",
|
||||
"completion",
|
||||
"request",
|
||||
"image",
|
||||
"web_search",
|
||||
"internal_reasoning",
|
||||
]
|
||||
|
||||
for key in keys_to_compare:
|
||||
db_val = int(float(db_pricing.get(key, 0.0)) * 1000000)
|
||||
upstream_val = int(float(upstream_pricing.get(key, 0.0)) * 1000000)
|
||||
|
||||
if abs(db_val - upstream_val) > tolerance:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def refresh_models_periodically() -> None:
|
||||
"""Background task: periodically fetch OpenRouter models and insert new ones.
|
||||
|
||||
- Respects optional SOURCE filter from settings
|
||||
- Does not overwrite existing rows
|
||||
- Sleeps according to settings.models_refresh_interval_seconds; disabled when 0
|
||||
"""
|
||||
interval = getattr(settings, "models_refresh_interval_seconds", 0)
|
||||
if not interval or interval <= 0:
|
||||
return
|
||||
|
||||
# Only refresh from OpenRouter when upstream is OpenRouter
|
||||
if not is_openrouter_upstream():
|
||||
logger.info("Skipping models refresh: upstream_base_url is not OpenRouter")
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
try:
|
||||
if not settings.enable_models_refresh:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
src = settings.source or None
|
||||
source_filter = src if src and src.strip() else None
|
||||
except Exception:
|
||||
source_filter = None
|
||||
|
||||
models = fetch_openrouter_models(source_filter=source_filter)
|
||||
if not models:
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
async with create_session() as s:
|
||||
result = await s.exec(select(ModelRow.id)) # type: ignore
|
||||
existing_ids = {
|
||||
row[0] if isinstance(row, tuple) else row for row in result.all()
|
||||
}
|
||||
inserted = 0
|
||||
for m in models:
|
||||
try:
|
||||
model = Model(**m) # type: ignore
|
||||
except Exception:
|
||||
continue
|
||||
if model.id in existing_ids:
|
||||
continue
|
||||
payload = _model_to_row_payload(model)
|
||||
try:
|
||||
s.add(ModelRow(**payload)) # type: ignore
|
||||
except Exception:
|
||||
pass
|
||||
inserted += 1
|
||||
if inserted:
|
||||
await s.commit()
|
||||
logger.info(f"Inserted {inserted} new models from OpenRouter")
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error during models refresh",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
try:
|
||||
jitter = max(0.0, float(interval) * 0.1)
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
|
||||
@models_router.get("/v1/models")
|
||||
@models_router.get("/models", include_in_schema=False)
|
||||
async def models(session: AsyncSession = Depends(get_session)) -> dict:
|
||||
"""Get all available models from all providers with database overrides applied."""
|
||||
from ..proxy import get_unique_models
|
||||
|
||||
items = get_unique_models()
|
||||
return {"data": items}
|
||||
@@ -7,9 +7,9 @@ from cashu.wallet.helpers import deserialize_token_from_string
|
||||
from cashu.wallet.wallet import Wallet
|
||||
from sqlmodel import col, update
|
||||
|
||||
from .core import db, get_logger
|
||||
from .core.settings import settings
|
||||
from .payment.lnurl import raw_send_to_lnurl
|
||||
from ..core import db, get_logger
|
||||
from ..core.settings import settings
|
||||
from ..payment.lnurl import raw_send_to_lnurl
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -19,9 +19,7 @@ async def get_balance(unit: str) -> int:
|
||||
return wallet.available_balance.amount
|
||||
|
||||
|
||||
async def recieve_token(
|
||||
token: str,
|
||||
) -> tuple[int, str, str]: # amount, unit, mint_url
|
||||
async def recieve_token(token: str) -> tuple[int, str, str]: # amount, unit, mint_url
|
||||
token_obj = deserialize_token_from_string(token)
|
||||
if len(token_obj.keysets) > 1:
|
||||
raise ValueError("Multiple keysets per token currently not supported")
|
||||
@@ -104,7 +102,7 @@ async def swap_to_primary_mint(
|
||||
|
||||
|
||||
async def credit_balance(
|
||||
cashu_token: str, key: db.ApiKey, session: db.AsyncSession
|
||||
cashu_token: str, credit: db.TemporaryCredit, session: db.AsyncSession
|
||||
) -> int:
|
||||
logger.info(
|
||||
"credit_balance: Starting token redemption",
|
||||
@@ -126,22 +124,22 @@ async def credit_balance(
|
||||
|
||||
logger.info(
|
||||
"credit_balance: Updating balance",
|
||||
extra={"old_balance": key.balance, "credit_amount": amount},
|
||||
extra={"old_balance": credit.balance, "credit_amount": amount},
|
||||
)
|
||||
|
||||
# Use atomic SQL UPDATE to prevent race conditions during concurrent topups
|
||||
stmt = (
|
||||
update(db.ApiKey)
|
||||
.where(col(db.ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(balance=(db.ApiKey.balance) + amount)
|
||||
update(db.TemporaryCredit)
|
||||
.where(col(db.TemporaryCredit.hashed_key) == credit.hashed_key)
|
||||
.values(balance=(db.TemporaryCredit.balance) + amount)
|
||||
)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
await session.refresh(credit)
|
||||
|
||||
logger.info(
|
||||
"credit_balance: Balance updated successfully",
|
||||
extra={"new_balance": key.balance},
|
||||
extra={"new_balance": credit.balance},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -351,34 +349,3 @@ async def periodic_payout() -> None:
|
||||
f"Error sending payout: {type(e).__name__}",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
|
||||
|
||||
async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
|
||||
wallet = await get_wallet(mint, unit)
|
||||
proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id]
|
||||
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
|
||||
return await raw_send_to_lnurl(wallet, proofs, address, unit)
|
||||
|
||||
|
||||
# class Payment:
|
||||
# """
|
||||
# Stores all cashu payment related data
|
||||
# """
|
||||
|
||||
# def __init__(self, token: str) -> None:
|
||||
# self.initial_token = token
|
||||
# amount, unit, mint_url = self.parse_token(token)
|
||||
# self.amount = amount
|
||||
# self.unit = unit
|
||||
# self.mint_url = mint_url
|
||||
|
||||
# self.claimed_proofs = redeem_to_proofs(token)
|
||||
|
||||
# def parse_token(self, token: str) -> tuple[int, CurrencyUnit, str]:
|
||||
# raise NotImplementedError
|
||||
|
||||
# def refund_full(self) -> None:
|
||||
# raise NotImplementedError
|
||||
|
||||
# def refund_partial(self, amount: int) -> None:
|
||||
# raise NotImplementedError
|
||||
190
routstr/proxy.py
190
routstr/proxy.py
@@ -5,24 +5,20 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from sqlmodel import select
|
||||
|
||||
from .algorithm import create_model_mappings
|
||||
from .auth import pay_for_request, revert_pay_for_request, validate_bearer_key
|
||||
from .auth import get_credit
|
||||
from .core import get_logger
|
||||
from .core.db import (
|
||||
ApiKey,
|
||||
AsyncSession,
|
||||
ModelRow,
|
||||
UpstreamProviderRow,
|
||||
create_session,
|
||||
get_session,
|
||||
)
|
||||
from .payment.helpers import (
|
||||
calculate_discounted_max_cost,
|
||||
check_token_balance,
|
||||
create_error_response,
|
||||
get_max_cost_for_model,
|
||||
)
|
||||
from .payment.models import Model
|
||||
from .models.algorithm import create_model_mappings
|
||||
from .models.models import Model
|
||||
from .payment.cashu import pre_check_header_token_balance
|
||||
from .payment.cost import calculate_discounted_max_cost, get_max_cost_for_model
|
||||
from .payment.helpers import pay_for_request, revert_pay_for_request
|
||||
from .upstream import BaseUpstreamProvider
|
||||
from .upstream.helpers import init_upstreams
|
||||
|
||||
@@ -130,18 +126,21 @@ async def refresh_model_maps_periodically() -> None:
|
||||
)
|
||||
|
||||
|
||||
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
|
||||
async def proxy(
|
||||
request: Request, path: str, session: AsyncSession = Depends(get_session)
|
||||
) -> Response | StreamingResponse:
|
||||
async def parse_request(request: Request) -> tuple[dict, dict, bytes]:
|
||||
headers = dict(request.headers)
|
||||
body_bytes = await request.body()
|
||||
body_dict = parse_request_body_json(body_bytes)
|
||||
return headers, body_dict, body_bytes
|
||||
|
||||
|
||||
def ensure_authorization(headers: dict) -> None:
|
||||
if "x-cashu" not in headers and "authorization" not in headers.keys():
|
||||
return create_error_response(
|
||||
"unauthorized", "Unauthorized", 401, request=request
|
||||
)
|
||||
# return create_error_response("unauthorized", "Unauthorized", 401, request=request)
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
logger.info( # TODO: move to middleware, async
|
||||
|
||||
def log_request(request: Request, path: str) -> None:
|
||||
logger.info(
|
||||
"Received proxy request",
|
||||
extra={
|
||||
"method": request.method,
|
||||
@@ -151,58 +150,49 @@ async def proxy(
|
||||
},
|
||||
)
|
||||
|
||||
request_body = await request.body()
|
||||
request_body_dict = parse_request_body_json(request_body, path)
|
||||
|
||||
model_id = request_body_dict.get("model", "unknown")
|
||||
def get_model_and_upstream(request_body: dict) -> tuple[Model, BaseUpstreamProvider]:
|
||||
model_id = request_body.get("model", "unknown")
|
||||
|
||||
model_obj = get_model_instance(model_id)
|
||||
if not model_obj:
|
||||
return create_error_response(
|
||||
"invalid_model", f"Model '{model_id}' not found", 400, request=request
|
||||
)
|
||||
if not (model := get_model_instance(model_id)):
|
||||
raise HTTPException(status_code=400, detail=f"Model '{model_id}' not found")
|
||||
|
||||
upstream = get_provider_for_model(model_id)
|
||||
if not upstream:
|
||||
return create_error_response(
|
||||
"invalid_model",
|
||||
f"No provider found for model '{model_id}'",
|
||||
400,
|
||||
request=request,
|
||||
)
|
||||
if not (upstream := get_provider_for_model(model_id)):
|
||||
raise HTTPException(status_code=400, detail=f"No provider with '{model_id}'")
|
||||
|
||||
_max_cost_for_model = await get_max_cost_for_model(
|
||||
model=model_id, session=session, model_obj=model_obj
|
||||
)
|
||||
return model, upstream
|
||||
|
||||
|
||||
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
|
||||
async def proxy(
|
||||
request: Request,
|
||||
path: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Response | StreamingResponse:
|
||||
headers, body_dict, body_bytes = await parse_request(request)
|
||||
|
||||
ensure_authorization(headers)
|
||||
|
||||
log_request(request, path)
|
||||
|
||||
model, upstream = get_model_and_upstream(body_dict)
|
||||
|
||||
_max_cost_for_model = get_max_cost_for_model(model_obj=model)
|
||||
max_cost_for_model = await calculate_discounted_max_cost(
|
||||
_max_cost_for_model, request_body_dict, model_obj=model_obj
|
||||
_max_cost_for_model, body_dict, model_obj=model
|
||||
)
|
||||
check_token_balance(headers, request_body_dict, max_cost_for_model)
|
||||
pre_check_header_token_balance(headers, body_dict, max_cost_for_model)
|
||||
|
||||
if x_cashu := headers.get("x-cashu", None):
|
||||
return await upstream.handle_x_cashu(
|
||||
request, x_cashu, path, max_cost_for_model, model_obj
|
||||
request, x_cashu, path, max_cost_for_model, model
|
||||
)
|
||||
|
||||
elif auth := headers.get("authorization", None):
|
||||
key = await get_bearer_token_key(headers, path, session, auth)
|
||||
|
||||
else:
|
||||
if request.method not in ["GET"]:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {"type": "invalid_request_error", "code": "unauthorized"}
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug("Processing unauthenticated GET request", extra={"path": path})
|
||||
# TODO: why is this needed? can we remove it?
|
||||
headers = upstream.prepare_headers(dict(request.headers))
|
||||
return await upstream.forward_get_request(request, path, headers)
|
||||
key = await get_credit(auth, session)
|
||||
|
||||
# Only pay for request if we have request body data (for completions endpoints)
|
||||
if request_body_dict:
|
||||
if body_dict:
|
||||
await pay_for_request(key, max_cost_for_model, session)
|
||||
|
||||
# Prepare headers for upstream
|
||||
@@ -213,11 +203,11 @@ async def proxy(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
body_bytes,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
model,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
@@ -241,87 +231,7 @@ async def proxy(
|
||||
return response
|
||||
|
||||
|
||||
async def get_bearer_token_key(
|
||||
headers: dict, path: str, session: AsyncSession, auth: str
|
||||
) -> ApiKey:
|
||||
"""Handle bearer token authentication proxy requests."""
|
||||
bearer_key = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
|
||||
refund_address = headers.get("Refund-LNURL", None)
|
||||
key_expiry_time = headers.get("Key-Expiry-Time", None)
|
||||
|
||||
logger.debug(
|
||||
"Processing bearer token",
|
||||
extra={
|
||||
"path": path,
|
||||
"has_refund_address": bool(refund_address),
|
||||
"has_expiry_time": bool(key_expiry_time),
|
||||
"bearer_key_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
},
|
||||
)
|
||||
|
||||
# Validate key_expiry_time header
|
||||
if key_expiry_time:
|
||||
try:
|
||||
key_expiry_time = int(key_expiry_time) # type: ignore
|
||||
logger.debug(
|
||||
"Key expiry time validated",
|
||||
extra={"expiry_time": key_expiry_time, "path": path},
|
||||
)
|
||||
except ValueError:
|
||||
logger.error(
|
||||
"Invalid Key-Expiry-Time header",
|
||||
extra={"key_expiry_time": key_expiry_time, "path": path},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid Key-Expiry-Time: must be a valid Unix timestamp",
|
||||
)
|
||||
if not refund_address:
|
||||
logger.error(
|
||||
"Missing Refund-LNURL header with Key-Expiry-Time",
|
||||
extra={"path": path, "expiry_time": key_expiry_time},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Error: Refund-LNURL header required when using Key-Expiry-Time",
|
||||
)
|
||||
else:
|
||||
key_expiry_time = None
|
||||
|
||||
try:
|
||||
key = await validate_bearer_key(
|
||||
bearer_key,
|
||||
session,
|
||||
refund_address,
|
||||
key_expiry_time, # type: ignore
|
||||
)
|
||||
logger.info(
|
||||
"Bearer token validated successfully",
|
||||
extra={
|
||||
"path": path,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"key_balance": key.balance,
|
||||
},
|
||||
)
|
||||
return key
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Bearer token validation failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"path": path,
|
||||
"bearer_key_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def parse_request_body_json(request_body: bytes, path: str) -> dict[str, Any]:
|
||||
def parse_request_body_json(request_body: bytes) -> dict[str, Any]:
|
||||
request_body_dict = {}
|
||||
if request_body:
|
||||
try:
|
||||
@@ -341,7 +251,6 @@ def parse_request_body_json(request_body: bytes, path: str) -> dict[str, Any]:
|
||||
logger.debug(
|
||||
"Request body parsed",
|
||||
extra={
|
||||
"path": path,
|
||||
"body_keys": list(request_body_dict.keys()),
|
||||
"model": request_body_dict.get("model", "not_specified"),
|
||||
},
|
||||
@@ -351,7 +260,6 @@ def parse_request_body_json(request_body: bytes, path: str) -> dict[str, Any]:
|
||||
"Invalid JSON in request body",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"path": path,
|
||||
"body_preview": request_body[:200].decode(errors="ignore")
|
||||
if request_body
|
||||
else "empty",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from ..models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -11,28 +11,23 @@ import httpx
|
||||
from fastapi import BackgroundTasks, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
from ..core import get_logger
|
||||
from ..core.db import ApiKey, AsyncSession, create_session
|
||||
from ..core.db import AsyncSession, TemporaryCredit, create_session
|
||||
from ..payment.helpers import adjust_payment_for_tokens
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
from ..payment.cost_calculation import (
|
||||
CostData,
|
||||
CostDataError,
|
||||
MaxCostData,
|
||||
calculate_cost,
|
||||
)
|
||||
from ..payment.helpers import create_error_response
|
||||
from ..payment.models import (
|
||||
from ..models.models import (
|
||||
Model,
|
||||
Pricing,
|
||||
_calculate_usd_max_costs,
|
||||
_update_model_sats_pricing,
|
||||
calculate_usd_max_costs,
|
||||
)
|
||||
from ..payment.cost import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
from ..payment.helpers import create_error_response
|
||||
from ..payment.price import sats_usd_price
|
||||
from ..wallet import recieve_token, send_token
|
||||
from ..payment.wallet import recieve_token, send_token
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -143,7 +138,6 @@ class BaseUpstreamProvider:
|
||||
# Explicitly define the list of supported compression encodings
|
||||
headers["accept-encoding"] = "gzip, deflate, br, identity"
|
||||
|
||||
|
||||
logger.debug(
|
||||
"Headers prepared for upstream",
|
||||
extra={
|
||||
@@ -338,7 +332,7 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
|
||||
async def handle_streaming_chat_completion(
|
||||
self, response: httpx.Response, key: ApiKey, max_cost_for_model: int
|
||||
self, response: httpx.Response, key: TemporaryCredit, max_cost_for_model: int
|
||||
) -> StreamingResponse:
|
||||
"""Handle streaming chat completion responses with token usage tracking and cost adjustment.
|
||||
|
||||
@@ -512,7 +506,7 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
await finalize_without_usage()
|
||||
raise
|
||||
|
||||
|
||||
# Remove inaccurate encoding headers from upstream response
|
||||
response_headers = dict(response.headers)
|
||||
response_headers.pop("content-encoding", None)
|
||||
@@ -521,13 +515,13 @@ class BaseUpstreamProvider:
|
||||
return StreamingResponse(
|
||||
stream_with_cost(max_cost_for_model),
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
headers=response_headers,
|
||||
)
|
||||
|
||||
async def handle_non_streaming_chat_completion(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
key: ApiKey,
|
||||
key: TemporaryCredit,
|
||||
session: AsyncSession,
|
||||
deducted_max_cost: int,
|
||||
) -> Response:
|
||||
@@ -633,7 +627,7 @@ class BaseUpstreamProvider:
|
||||
path: str,
|
||||
headers: dict,
|
||||
request_body: bytes | None,
|
||||
key: ApiKey,
|
||||
key: TemporaryCredit,
|
||||
max_cost_for_model: int,
|
||||
session: AsyncSession,
|
||||
model_obj: Model,
|
||||
@@ -947,45 +941,43 @@ class BaseUpstreamProvider:
|
||||
extra={"model": model, "has_usage": "usage" in response_data},
|
||||
)
|
||||
|
||||
async with create_session() as session:
|
||||
match await calculate_cost(response_data, max_cost_for_model, session):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost pricing",
|
||||
extra={"model": model, "max_cost_msats": cost.total_msats},
|
||||
)
|
||||
return cost
|
||||
case CostData() as cost:
|
||||
logger.debug(
|
||||
"Using token-based pricing",
|
||||
extra={
|
||||
"model": model,
|
||||
"total_cost_msats": cost.total_msats,
|
||||
"input_msats": cost.input_msats,
|
||||
"output_msats": cost.output_msats,
|
||||
},
|
||||
)
|
||||
return cost
|
||||
case CostDataError() as error:
|
||||
logger.error(
|
||||
"Cost calculation error",
|
||||
extra={
|
||||
"model": model,
|
||||
"error_message": error.message,
|
||||
"error_code": error.code,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"message": error.message,
|
||||
"type": "invalid_request_error",
|
||||
"code": error.code,
|
||||
}
|
||||
},
|
||||
)
|
||||
return None
|
||||
match calculate_cost(response_data, max_cost_for_model):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost pricing",
|
||||
extra={"model": model, "max_cost_msats": cost.total_msats},
|
||||
)
|
||||
return cost
|
||||
case CostData() as cost:
|
||||
logger.debug(
|
||||
"Using token-based pricing",
|
||||
extra={
|
||||
"model": model,
|
||||
"total_cost_msats": cost.total_msats,
|
||||
"input_msats": cost.input_msats,
|
||||
"output_msats": cost.output_msats,
|
||||
},
|
||||
)
|
||||
return cost
|
||||
case CostDataError() as error:
|
||||
logger.error(
|
||||
"Cost calculation error",
|
||||
extra={
|
||||
"model": model,
|
||||
"error_message": error.message,
|
||||
"error_code": error.code,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"message": error.message,
|
||||
"type": "invalid_request_error",
|
||||
"code": error.code,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def send_refund(self, amount: int, unit: str, mint: str | None = None) -> str:
|
||||
"""Create and send a refund token to the user.
|
||||
@@ -1687,7 +1679,7 @@ class BaseUpstreamProvider:
|
||||
adjusted_pricing.max_prompt_cost,
|
||||
adjusted_pricing.max_completion_cost,
|
||||
adjusted_pricing.max_cost,
|
||||
) = _calculate_usd_max_costs(temp_model)
|
||||
) = calculate_usd_max_costs(temp_model)
|
||||
|
||||
return Model(
|
||||
id=model.id,
|
||||
|
||||
@@ -10,7 +10,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
|
||||
from ..payment.models import Model
|
||||
from ..models import Model
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -82,7 +82,7 @@ async def get_all_models_with_overrides(
|
||||
"""
|
||||
from sqlmodel import select
|
||||
|
||||
from ..payment.models import _row_to_model
|
||||
from ..models.crud import row_to_model
|
||||
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(ModelRow).where(ModelRow.enabled))
|
||||
@@ -108,7 +108,7 @@ async def get_all_models_with_overrides(
|
||||
for model in upstream.get_cached_models():
|
||||
if model.id in overrides_by_id:
|
||||
override_row, provider_fee = overrides_by_id[model.id]
|
||||
all_models[model.id] = _row_to_model(
|
||||
all_models[model.id] = row_to_model(
|
||||
override_row, apply_provider_fee=True, provider_fee=provider_fee
|
||||
)
|
||||
elif model.enabled:
|
||||
|
||||
@@ -9,8 +9,8 @@ from fastapi.responses import Response, StreamingResponse
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
from ..core.db import AsyncSession, TemporaryCredit, UpstreamProviderRow
|
||||
from ..models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
|
||||
@@ -73,7 +73,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
path: str,
|
||||
headers: dict,
|
||||
request_body: bytes | None,
|
||||
key: ApiKey,
|
||||
key: TemporaryCredit,
|
||||
max_cost_for_model: int,
|
||||
session: AsyncSession,
|
||||
model_obj: Model,
|
||||
@@ -102,7 +102,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from Ollama API using /api/tags endpoint."""
|
||||
from ..payment.models import Architecture, Model, Pricing, TopProvider
|
||||
from ..models.models import Architecture, Model, Pricing, TopProvider
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
@@ -199,7 +199,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
async def refresh_models_cache(self) -> None:
|
||||
"""Refresh the in-memory models cache from upstream API."""
|
||||
try:
|
||||
from ..payment.models import _update_model_sats_pricing
|
||||
from ..models.models import _update_model_sats_pricing
|
||||
from ..payment.price import sats_usd_price
|
||||
|
||||
models = await self.fetch_models()
|
||||
@@ -252,7 +252,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
Returns:
|
||||
Model with provider fee applied to pricing and max costs calculated
|
||||
"""
|
||||
from ..payment.models import Model, Pricing, _calculate_usd_max_costs
|
||||
from ..models.models import Model, Pricing, calculate_usd_max_costs
|
||||
|
||||
adjusted_pricing = Pricing.parse_obj(
|
||||
{k: v * self.provider_fee for k, v in model.pricing.dict().items()}
|
||||
@@ -278,7 +278,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
adjusted_pricing.max_prompt_cost,
|
||||
adjusted_pricing.max_completion_cost,
|
||||
adjusted_pricing.max_cost,
|
||||
) = _calculate_usd_max_costs(temp_model)
|
||||
) = calculate_usd_max_costs(temp_model)
|
||||
|
||||
return Model(
|
||||
id=model.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from ..models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from ..models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from ..models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from ..models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Example crontab entries for Routstr tasks
|
||||
|
||||
# Update models.json daily at 03:15 (optional)
|
||||
# OUTPUT_FILE=/app/models.json SOURCE=openrouter
|
||||
15 3 * * * /usr/local/bin/python /app/scripts/models_meta.py >> /var/log/cron.log 2>&1
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import TypedDict
|
||||
from urllib.request import urlopen
|
||||
|
||||
|
||||
class ModelArchitecture(TypedDict):
|
||||
modality: str
|
||||
input_modalities: list[str]
|
||||
output_modalities: list[str]
|
||||
tokenizer: str
|
||||
instruct_type: str | None
|
||||
|
||||
|
||||
class ModelPricing(TypedDict):
|
||||
prompt: str
|
||||
completion: str
|
||||
request: str
|
||||
image: str
|
||||
web_search: str
|
||||
internal_reasoning: str
|
||||
|
||||
|
||||
class ModelProvider(TypedDict):
|
||||
context_length: int
|
||||
max_completion_tokens: int | None
|
||||
is_moderated: bool
|
||||
|
||||
|
||||
class Model(TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
created: int
|
||||
description: str
|
||||
context_length: int
|
||||
architecture: ModelArchitecture
|
||||
pricing: ModelPricing
|
||||
top_provider: ModelProvider
|
||||
per_request_limits: dict | None
|
||||
|
||||
|
||||
OUTPUT_FILE = os.getenv("OUTPUT_FILE", "models.json")
|
||||
SOURCE = os.getenv("SOURCE")
|
||||
|
||||
|
||||
def fetch_openrouter_models(source_filter: str | None = None) -> list[Model]:
|
||||
"""Fetches model information from OpenRouter API."""
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
with urlopen(f"{base_url}/models") as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
models_data: list[Model] = []
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
|
||||
if source_filter:
|
||||
source_prefix = f"{source_filter}/"
|
||||
if not model_id.startswith(source_prefix):
|
||||
continue
|
||||
|
||||
model = dict(model)
|
||||
model["id"] = model_id[len(source_prefix) :]
|
||||
model_id = model["id"]
|
||||
|
||||
if (
|
||||
"(free)" in model.get("name", "")
|
||||
or model_id == "openrouter/auto"
|
||||
or model_id == "google/gemini-2.5-pro-exp-03-25"
|
||||
):
|
||||
continue
|
||||
|
||||
models_data.append(model)
|
||||
|
||||
return models_data
|
||||
|
||||
|
||||
def main() -> None:
|
||||
source_filter = SOURCE if SOURCE and SOURCE.strip() else None
|
||||
models = fetch_openrouter_models(source_filter=source_filter)
|
||||
|
||||
print(f"Writing {len(models)} models to {OUTPUT_FILE}")
|
||||
|
||||
with open(OUTPUT_FILE, "w") as f:
|
||||
json.dump({"models": models}, f, indent=4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -66,7 +66,7 @@ os.environ.update(test_env)
|
||||
os.environ.pop("ADMIN_PASSWORD", None)
|
||||
|
||||
|
||||
from routstr.core.db import ApiKey, get_session # noqa: E402
|
||||
from routstr.core.db import TemporaryCredit, get_session # noqa: E402
|
||||
from routstr.core.main import app, lifespan # noqa: E402
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ class TestmintWallet:
|
||||
self.refund_history: List[Dict[str, Any]] = []
|
||||
|
||||
async def init(self) -> None:
|
||||
"""Initialize the sixty_nuts wallet"""
|
||||
"""Initialize the cashu wallet"""
|
||||
# In mock mode, we don't actually create a real wallet
|
||||
# This is just a placeholder for the mock implementation
|
||||
self.wallet = None
|
||||
@@ -280,7 +280,7 @@ class TestmintWallet:
|
||||
return 100000 # 100k sats
|
||||
|
||||
async def credit_balance(
|
||||
self, cashu_token: str, key: ApiKey, session: AsyncSession
|
||||
self, cashu_token: str, key: TemporaryCredit, session: AsyncSession
|
||||
) -> int:
|
||||
"""Credit balance to API key - test implementation"""
|
||||
try:
|
||||
@@ -301,9 +301,9 @@ class TestmintWallet:
|
||||
|
||||
# Use atomic update to avoid lost update problem in concurrent scenarios
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(balance=ApiKey.balance + amount_msat)
|
||||
update(TemporaryCredit)
|
||||
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
|
||||
.values(balance=TemporaryCredit.balance + amount_msat)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
@@ -390,7 +390,7 @@ class DatabaseSnapshot:
|
||||
async def capture(self) -> Dict[str, List[Dict]]:
|
||||
"""Capture current database state"""
|
||||
# Get all API keys with their data
|
||||
result = await self.session.execute(select(ApiKey))
|
||||
result = await self.session.execute(select(TemporaryCredit))
|
||||
api_keys = result.scalars().all()
|
||||
|
||||
snapshot = {
|
||||
@@ -398,10 +398,8 @@ class DatabaseSnapshot:
|
||||
{
|
||||
"hashed_key": key.hashed_key,
|
||||
"balance": key.balance,
|
||||
"total_spent": key.total_spent,
|
||||
"total_requests": key.total_requests,
|
||||
"refund_address": key.refund_address,
|
||||
"key_expiry_time": key.key_expiry_time,
|
||||
"refund_expiration_time": key.refund_expiration_time,
|
||||
}
|
||||
for key in api_keys
|
||||
]
|
||||
@@ -447,10 +445,8 @@ class DatabaseSnapshot:
|
||||
|
||||
for field in [
|
||||
"balance",
|
||||
"total_spent",
|
||||
"total_requests",
|
||||
"refund_address",
|
||||
"key_expiry_time",
|
||||
"refund_expiration_time",
|
||||
]:
|
||||
if old[field] != new[field]:
|
||||
changes[field] = {
|
||||
@@ -522,18 +518,19 @@ async def integration_app(
|
||||
with (
|
||||
patch("routstr.core.db.engine", integration_engine),
|
||||
patch.object(_settings, "cashu_mints", [mint_url]),
|
||||
patch("routstr.wallet.credit_balance", testmint_wallet.credit_balance),
|
||||
patch("routstr.wallet.send_token", testmint_wallet.send_token),
|
||||
patch("routstr.wallet.send_to_lnurl", testmint_wallet.send_to_lnurl),
|
||||
patch("routstr.wallet.recieve_token", testmint_wallet.redeem_token),
|
||||
patch("routstr.wallet.get_balance", testmint_wallet.get_balance),
|
||||
patch(
|
||||
"routstr.payment.wallet.credit_balance", testmint_wallet.credit_balance
|
||||
),
|
||||
patch("routstr.payment.wallet.send_token", testmint_wallet.send_token),
|
||||
patch("routstr.payment.wallet.recieve_token", testmint_wallet.redeem_token),
|
||||
patch("routstr.payment.wallet.get_balance", testmint_wallet.get_balance),
|
||||
patch("routstr.balance.send_token", testmint_wallet.send_token),
|
||||
patch("routstr.balance.send_to_lnurl", testmint_wallet.send_to_lnurl),
|
||||
patch("websockets.connect") as mock_websockets,
|
||||
patch("routstr.payment.price.btc_usd_price", return_value=50000.0),
|
||||
patch("routstr.payment.price.sats_usd_price", return_value=0.0005),
|
||||
patch(
|
||||
"routstr.payment.helpers.calculate_discounted_max_cost",
|
||||
"routstr.payment.cost.calculate_discounted_max_cost",
|
||||
side_effect=_passthrough_discount,
|
||||
),
|
||||
):
|
||||
@@ -705,8 +702,8 @@ async def background_tasks_controller() -> AsyncGenerator[Any, None]:
|
||||
original_periodic_payout: Optional[Callable] = None
|
||||
|
||||
try:
|
||||
from routstr.payment.models import update_sats_pricing
|
||||
from routstr.wallet import periodic_payout
|
||||
from routstr.models.models import update_sats_pricing
|
||||
from routstr.payment.wallet import periodic_payout
|
||||
|
||||
async def controlled_update_pricing() -> None:
|
||||
while not controller.cancelled:
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"""
|
||||
Real Cashu mint integration for integration tests.
|
||||
|
||||
This module provides a real sixty_nuts Wallet implementation that can be used
|
||||
This module provides a real cashu Wallet implementation that can be used
|
||||
with an actual Cashu mint instance for more thorough integration testing.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sixty_nuts import Wallet
|
||||
from cashu.wallet.wallet import Wallet
|
||||
|
||||
|
||||
class RealMintWallet:
|
||||
"""Real Cashu mint wallet using sixty_nuts library"""
|
||||
"""Real Cashu mint wallet using cashu library"""
|
||||
|
||||
def __init__(self, mint_url: str, nsec: str):
|
||||
self.mint_url = mint_url
|
||||
@@ -22,7 +22,12 @@ class RealMintWallet:
|
||||
async def init(self) -> None:
|
||||
"""Initialize the wallet connection"""
|
||||
if not self._wallet:
|
||||
self._wallet = await Wallet.create(nsec=self.nsec)
|
||||
self._wallet = await Wallet.with_db(
|
||||
self.mint_url,
|
||||
db="sqlite+aiosqlite:///:memory:",
|
||||
load_all_keysets=True,
|
||||
unit="sat",
|
||||
)
|
||||
|
||||
@property
|
||||
def wallet(self) -> Wallet:
|
||||
|
||||
@@ -9,9 +9,9 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.payment.models import Model, Pricing, update_sats_pricing
|
||||
from routstr.wallet import periodic_payout
|
||||
from routstr.core.db import TemporaryCredit
|
||||
from routstr.models.models import Model, Pricing, update_sats_pricing
|
||||
from routstr.payment.wallet import periodic_payout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -201,12 +201,12 @@ class TestRefundCheckTask:
|
||||
) -> None:
|
||||
"""Test that expired keys with balance and refund address are refunded"""
|
||||
# Create an expired API key with balance
|
||||
expired_key = ApiKey(
|
||||
expired_key = TemporaryCredit(
|
||||
hashed_key="expired_test_key",
|
||||
balance=5000, # 5 sats in msats
|
||||
refund_address="lnurl1test",
|
||||
key_expiry_time=int(time.time()) - 3600, # Expired 1 hour ago
|
||||
created_at=datetime.utcnow() - timedelta(days=1),
|
||||
refund_expiration_time=int(time.time()) - 3600, # Expired 1 hour ago
|
||||
created=datetime.utcnow() - timedelta(days=1),
|
||||
)
|
||||
integration_session.add(expired_key)
|
||||
await integration_session.commit()
|
||||
@@ -214,7 +214,7 @@ class TestRefundCheckTask:
|
||||
# Mock the wallet send_to_lnurl method and get_session
|
||||
with (
|
||||
patch(
|
||||
"routstr.wallet.send_to_lnurl", AsyncMock(return_value=5)
|
||||
"routstr.payment.lnurl.send_to_lnurl", AsyncMock(return_value=5)
|
||||
) as mock_send_to_lnurl,
|
||||
patch("routstr.core.db.get_session") as mock_get_session,
|
||||
):
|
||||
@@ -233,8 +233,8 @@ class TestRefundCheckTask:
|
||||
if (
|
||||
expired_key.balance > 0
|
||||
and expired_key.refund_address
|
||||
and expired_key.key_expiry_time
|
||||
and expired_key.key_expiry_time < current_time
|
||||
and expired_key.refund_expiration_time
|
||||
and expired_key.refund_expiration_time < current_time
|
||||
):
|
||||
# Call wallet send_to_lnurl to trigger the refund
|
||||
amount_sats = expired_key.balance // 1000
|
||||
@@ -258,12 +258,12 @@ class TestRefundCheckTask:
|
||||
"""Test that refund check continues after mint errors"""
|
||||
# Create multiple expired keys
|
||||
for i in range(3):
|
||||
key = ApiKey(
|
||||
key = TemporaryCredit(
|
||||
hashed_key=f"expired_key_{i}",
|
||||
balance=1000 * (i + 1),
|
||||
refund_address=f"lnurl{i}",
|
||||
key_expiry_time=int(time.time()) - 3600,
|
||||
created_at=datetime.utcnow(),
|
||||
refund_expiration_time=int(time.time()) - 3600,
|
||||
created=datetime.utcnow(),
|
||||
)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
@@ -279,7 +279,7 @@ class TestRefundCheckTask:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"routstr.wallet.send_to_lnurl", mock_send_to_lnurl
|
||||
"routstr.payment.lnurl.send_to_lnurl", mock_send_to_lnurl
|
||||
) as mock_send_to_lnurl_patch,
|
||||
patch("routstr.core.db.get_session") as mock_get_session,
|
||||
):
|
||||
@@ -293,15 +293,15 @@ class TestRefundCheckTask:
|
||||
current_time = int(time.time())
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
result = await integration_session.execute(sa_select(ApiKey))
|
||||
result = await integration_session.execute(sa_select(TemporaryCredit))
|
||||
keys = result.scalars().all()
|
||||
|
||||
for key in keys:
|
||||
if (
|
||||
key.balance > 0
|
||||
and key.refund_address
|
||||
and key.key_expiry_time
|
||||
and key.key_expiry_time < current_time
|
||||
and key.refund_expiration_time
|
||||
and key.refund_expiration_time < current_time
|
||||
):
|
||||
amount_sats = key.balance // 1000
|
||||
try:
|
||||
@@ -352,21 +352,21 @@ class TestRefundCheckTask:
|
||||
|
||||
current_time = int(time.time())
|
||||
for data in keys_data:
|
||||
key = ApiKey(
|
||||
key = TemporaryCredit(
|
||||
hashed_key=data["hashed_key"],
|
||||
balance=data["balance"],
|
||||
refund_address=data["refund_address"],
|
||||
key_expiry_time=current_time - 3600
|
||||
refund_expiration_time=current_time - 3600
|
||||
if data["expired"]
|
||||
else current_time + 3600,
|
||||
created_at=datetime.utcnow(),
|
||||
created=datetime.utcnow(),
|
||||
)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"routstr.wallet.send_to_lnurl", AsyncMock(return_value=1)
|
||||
"routstr.payment.lnurl.send_to_lnurl", AsyncMock(return_value=1)
|
||||
) as mock_send_to_lnurl,
|
||||
patch("routstr.core.db.get_session") as mock_get_session,
|
||||
):
|
||||
@@ -382,15 +382,15 @@ class TestRefundCheckTask:
|
||||
current_time = int(time.time())
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
result = await integration_session.execute(sa_select(ApiKey))
|
||||
result = await integration_session.execute(sa_select(TemporaryCredit))
|
||||
keys = result.scalars().all()
|
||||
|
||||
for key in keys:
|
||||
if (
|
||||
key.balance > 0
|
||||
and key.refund_address
|
||||
and key.key_expiry_time
|
||||
and key.key_expiry_time < current_time
|
||||
and key.refund_expiration_time
|
||||
and key.refund_expiration_time < current_time
|
||||
):
|
||||
amount_sats = key.balance // 1000
|
||||
await mock_send_to_lnurl(key.refund_address, amount=amount_sats)
|
||||
@@ -410,7 +410,7 @@ class TestRefundCheckTask:
|
||||
# Check final state
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
result = await integration_session.execute(sa_select(ApiKey))
|
||||
result = await integration_session.execute(sa_select(TemporaryCredit))
|
||||
remaining_keys_list = result.scalars().all()
|
||||
remaining_ids = [k.hashed_key for k in remaining_keys_list]
|
||||
|
||||
@@ -454,10 +454,10 @@ class TestPeriodicPayoutTask:
|
||||
for i in range(5):
|
||||
balance = 10000 * (i + 1) # 10, 20, 30, 40, 50 sats
|
||||
total_user_balance += balance
|
||||
key = ApiKey(
|
||||
key = TemporaryCredit(
|
||||
hashed_key=f"user_key_{i}",
|
||||
balance=balance,
|
||||
created_at=datetime.utcnow(),
|
||||
created=datetime.utcnow(),
|
||||
)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
@@ -466,9 +466,12 @@ class TestPeriodicPayoutTask:
|
||||
wallet_balance = 200000 # 200 sats total
|
||||
|
||||
with (
|
||||
patch("routstr.wallet.get_balance", AsyncMock(return_value=wallet_balance)),
|
||||
patch(
|
||||
"routstr.wallet.send_to_lnurl", AsyncMock(return_value=None)
|
||||
"routstr.payment.wallet.get_balance",
|
||||
AsyncMock(return_value=wallet_balance),
|
||||
),
|
||||
patch(
|
||||
"routstr.payment.lnurl.send_to_lnurl", AsyncMock(return_value=None)
|
||||
) as mock_send_to_lnurl,
|
||||
):
|
||||
# Mock environment variables
|
||||
@@ -481,7 +484,7 @@ class TestPeriodicPayoutTask:
|
||||
},
|
||||
):
|
||||
# Call periodic_payout directly (pay_out was renamed/refactored)
|
||||
from routstr.wallet import periodic_payout
|
||||
from routstr.payment.wallet import periodic_payout
|
||||
|
||||
await periodic_payout()
|
||||
|
||||
@@ -501,7 +504,7 @@ class TestPeriodicPayoutTask:
|
||||
# key = ApiKey(
|
||||
# hashed_key="single_user",
|
||||
# balance=50000, # 50 sats
|
||||
# created_at=datetime.utcnow(),
|
||||
# created=datetime.utcnow(),
|
||||
# )
|
||||
# integration_session.add(key)
|
||||
# await integration_session.commit()
|
||||
@@ -538,7 +541,7 @@ class TestPeriodicPayoutTask:
|
||||
# key = ApiKey(
|
||||
# hashed_key="low_revenue_user",
|
||||
# balance=95000, # 95 sats
|
||||
# created_at=datetime.utcnow(),
|
||||
# created=datetime.utcnow(),
|
||||
# )
|
||||
# integration_session.add(key)
|
||||
# await integration_session.commit()
|
||||
@@ -648,14 +651,14 @@ class TestTaskInteractions:
|
||||
"""Test that database operations don't deadlock during concurrent task execution"""
|
||||
# Create test data
|
||||
for i in range(10):
|
||||
key = ApiKey(
|
||||
key = TemporaryCredit(
|
||||
hashed_key=f"concurrent_key_{i}",
|
||||
balance=1000 * i,
|
||||
refund_address=f"lnurl{i}" if i % 2 == 0 else None,
|
||||
key_expiry_time=int(time.time()) - 3600
|
||||
refund_expiration_time=int(time.time()) - 3600
|
||||
if i % 3 == 0
|
||||
else int(time.time()) + 3600,
|
||||
created_at=datetime.utcnow(),
|
||||
created=datetime.utcnow(),
|
||||
)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
@@ -664,14 +667,14 @@ class TestTaskInteractions:
|
||||
async def read_operation() -> int:
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
result = await integration_session.execute(sa_select(ApiKey))
|
||||
result = await integration_session.execute(sa_select(TemporaryCredit))
|
||||
return len(result.scalars().all())
|
||||
|
||||
async def write_operation(key_id: int) -> None:
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
stmt = sa_select(ApiKey).where(
|
||||
ApiKey.hashed_key == f"concurrent_key_{key_id}" # type: ignore[arg-type]
|
||||
stmt = sa_select(TemporaryCredit).where(
|
||||
TemporaryCredit.hashed_key == f"concurrent_key_{key_id}" # type: ignore[arg-type]
|
||||
)
|
||||
result = await integration_session.execute(stmt)
|
||||
key = result.scalar_one_or_none()
|
||||
@@ -708,14 +711,16 @@ class TestTaskInteractions:
|
||||
# Patch the actual task functions
|
||||
with (
|
||||
patch(
|
||||
"routstr.payment.models.update_sats_pricing",
|
||||
"routstr.models.models.update_sats_pricing",
|
||||
lambda: task_with_cleanup("pricing"),
|
||||
),
|
||||
patch(
|
||||
"routstr.wallet.periodic_payout", lambda: task_with_cleanup("refund")
|
||||
"routstr.payment.wallet.periodic_payout",
|
||||
lambda: task_with_cleanup("refund"),
|
||||
),
|
||||
patch(
|
||||
"routstr.wallet.periodic_payout", lambda: task_with_cleanup("payout")
|
||||
"routstr.payment.wallet.periodic_payout",
|
||||
lambda: task_with_cleanup("payout"),
|
||||
),
|
||||
):
|
||||
# Start all tasks
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.db import TemporaryCredit
|
||||
|
||||
|
||||
class TestTransactionAtomicity:
|
||||
@@ -34,7 +34,7 @@ class TestTransactionAtomicity:
|
||||
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
|
||||
)
|
||||
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
initial_balance = api_key.balance
|
||||
@@ -47,7 +47,9 @@ class TestTransactionAtomicity:
|
||||
try:
|
||||
# Get api key in new session
|
||||
result = await test_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(
|
||||
TemporaryCredit.hashed_key == api_key_hash
|
||||
) # type: ignore[arg-type]
|
||||
)
|
||||
test_api_key = result.scalar_one()
|
||||
|
||||
@@ -72,14 +74,14 @@ class TestTransactionAtomicity:
|
||||
|
||||
try:
|
||||
await integration_session.execute(
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
.values(balance=ApiKey.balance - 1000)
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
.values(balance=TemporaryCredit.balance - 1000)
|
||||
)
|
||||
# Force a constraint violation or error
|
||||
await integration_session.execute(
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == "non_existent_key") # type: ignore[arg-type]
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == "non_existent_key") # type: ignore[arg-type]
|
||||
.values(balance=-1) # This should fail
|
||||
)
|
||||
await integration_session.commit()
|
||||
@@ -108,13 +110,13 @@ class TestTransactionAtomicity:
|
||||
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
|
||||
)
|
||||
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
initial_balance = api_key.balance
|
||||
|
||||
# Mock wallet to fail after token validation
|
||||
with patch("routstr.wallet.send_token") as mock_wallet_func:
|
||||
with patch("routstr.payment.wallet.send_token") as mock_wallet_func:
|
||||
mock_proof = MagicMock()
|
||||
mock_proof.amount = 1000
|
||||
mock_wallet = AsyncMock()
|
||||
@@ -158,7 +160,7 @@ class TestTransactionAtomicity:
|
||||
)
|
||||
|
||||
# Set a known balance
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
api_key.balance = 10000
|
||||
@@ -166,12 +168,12 @@ class TestTransactionAtomicity:
|
||||
|
||||
# Simulate concurrent balance updates through direct database operations
|
||||
async def update_balance(session: AsyncSession, amount: int) -> bool:
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(TemporaryCredit).where(
|
||||
TemporaryCredit.hashed_key == api_key_hash
|
||||
) # type: ignore[arg-type]
|
||||
result = await session.execute(stmt)
|
||||
key = result.scalar_one()
|
||||
key.balance -= amount
|
||||
key.total_spent += amount
|
||||
key.total_requests += 1
|
||||
try:
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -248,7 +250,7 @@ class TestConcurrentOperations:
|
||||
)
|
||||
|
||||
# Set initial balance
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
initial_balance = 5000
|
||||
@@ -256,7 +258,7 @@ class TestConcurrentOperations:
|
||||
await integration_session.commit()
|
||||
|
||||
# Mock wallet for topup
|
||||
with patch("routstr.wallet.send_token") as mock_wallet_func:
|
||||
with patch("routstr.payment.wallet.send_token") as mock_wallet_func:
|
||||
mock_proof = MagicMock()
|
||||
mock_proof.amount = 2000
|
||||
mock_wallet = AsyncMock()
|
||||
@@ -323,12 +325,10 @@ class TestConcurrentOperations:
|
||||
)
|
||||
|
||||
# Set a specific balance
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
api_key.balance = 1000
|
||||
api_key.total_spent = 0
|
||||
api_key.total_requests = 0
|
||||
await integration_session.commit()
|
||||
|
||||
# Create a controlled race condition scenario
|
||||
@@ -336,7 +336,9 @@ class TestConcurrentOperations:
|
||||
|
||||
async def check_and_update_balance() -> bool:
|
||||
# Read current balance
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(TemporaryCredit).where(
|
||||
TemporaryCredit.hashed_key == api_key_hash
|
||||
) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
current_api_key = result.scalar_one()
|
||||
current_balance = current_api_key.balance
|
||||
@@ -347,8 +349,6 @@ class TestConcurrentOperations:
|
||||
|
||||
# Try to update based on read value
|
||||
current_api_key.balance = current_balance - 100
|
||||
current_api_key.total_spent += 100
|
||||
current_api_key.total_requests += 1
|
||||
|
||||
try:
|
||||
await integration_session.commit()
|
||||
@@ -371,8 +371,6 @@ class TestConcurrentOperations:
|
||||
# Final balance should reflect successful updates
|
||||
expected_balance = 1000 - (successful_updates * 100)
|
||||
assert api_key.balance == expected_balance
|
||||
assert api_key.total_spent == successful_updates * 100
|
||||
assert api_key.total_requests == successful_updates
|
||||
|
||||
|
||||
class TestDataIntegrity:
|
||||
@@ -396,7 +394,7 @@ class TestDataIntegrity:
|
||||
)
|
||||
|
||||
# Set low balance
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
api_key.balance = 0
|
||||
@@ -431,9 +429,7 @@ class TestDataIntegrity:
|
||||
)
|
||||
|
||||
# Try to manually insert duplicate key with same hash
|
||||
duplicate_key = ApiKey(
|
||||
hashed_key=api_key_hash, balance=5000, total_spent=0, total_requests=0
|
||||
)
|
||||
duplicate_key = TemporaryCredit(hashed_key=api_key_hash, balance=5000)
|
||||
|
||||
integration_session.add(duplicate_key)
|
||||
|
||||
@@ -481,20 +477,13 @@ class TestDataIntegrity:
|
||||
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
|
||||
)
|
||||
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
|
||||
# Test setting invalid values directly
|
||||
# These should maintain integrity
|
||||
assert api_key.balance >= 0
|
||||
assert api_key.total_spent >= 0
|
||||
assert api_key.total_requests >= 0
|
||||
|
||||
# Verify calculations are consistent
|
||||
if api_key.total_requests > 0:
|
||||
average_cost = api_key.total_spent / api_key.total_requests
|
||||
assert average_cost >= 0
|
||||
|
||||
|
||||
class TestPerformance:
|
||||
@@ -522,7 +511,7 @@ class TestPerformance:
|
||||
operation_times["select"].append((end - start) * 1000) # Convert to ms
|
||||
|
||||
# Test UPDATE performance (via topup)
|
||||
with patch("routstr.wallet.send_token") as mock_wallet_func:
|
||||
with patch("routstr.payment.wallet.send_token") as mock_wallet_func:
|
||||
mock_proof = MagicMock()
|
||||
mock_proof.amount = 100
|
||||
mock_wallet = AsyncMock()
|
||||
@@ -607,7 +596,7 @@ class TestPerformance:
|
||||
|
||||
# Primary key lookup should be fast
|
||||
start = time.time()
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
end = time.time()
|
||||
|
||||
@@ -11,7 +11,7 @@ from httpx import ASGITransport, AsyncClient, ConnectError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.db import TemporaryCredit
|
||||
|
||||
|
||||
class TestNetworkFailureScenarios:
|
||||
@@ -27,7 +27,7 @@ class TestNetworkFailureScenarios:
|
||||
# Patch the wallet send function to simulate failure across all modules
|
||||
with (
|
||||
patch(
|
||||
"routstr.wallet.send_token",
|
||||
"routstr.payment.wallet.send_token",
|
||||
AsyncMock(side_effect=ConnectError("Mint service unavailable")),
|
||||
),
|
||||
patch(
|
||||
@@ -422,7 +422,7 @@ class TestRecoveryScenarios:
|
||||
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
|
||||
)
|
||||
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
initial_key = result.scalar_one()
|
||||
initial_balance = initial_key.balance
|
||||
@@ -458,17 +458,15 @@ class TestRecoveryScenarios:
|
||||
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
|
||||
)
|
||||
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
initial_balance = api_key.balance
|
||||
initial_requests = api_key.total_requests
|
||||
|
||||
# Simulate operations that might be interrupted
|
||||
try:
|
||||
# Start a transaction
|
||||
api_key.reserved_balance += 1000
|
||||
api_key.total_requests += 1
|
||||
# Don't commit - simulate crash
|
||||
raise Exception("Simulated database crash")
|
||||
except Exception:
|
||||
@@ -478,7 +476,6 @@ class TestRecoveryScenarios:
|
||||
# Verify state is consistent after "recovery"
|
||||
await integration_session.refresh(api_key)
|
||||
assert api_key.balance == initial_balance
|
||||
assert api_key.total_requests == initial_requests
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_consistency_after_failures(
|
||||
@@ -527,10 +524,7 @@ class TestRecoveryScenarios:
|
||||
for mod in diff["api_keys"]["modified"]:
|
||||
# Only acceptable changes are request counts
|
||||
for field, change in mod["changes"].items():
|
||||
if field == "total_requests":
|
||||
# Request count might increase
|
||||
assert change["delta"] >= 0
|
||||
elif field == "balance":
|
||||
if field == "balance":
|
||||
# Balance should not decrease from failed operations
|
||||
assert change["delta"] >= 0
|
||||
else:
|
||||
@@ -641,12 +635,10 @@ class TestEdgeCaseCombinations:
|
||||
api_key_hash = test_key[3:] # Remove sk- prefix
|
||||
|
||||
# Create the API key with only 500 msats (less than one request cost)
|
||||
new_key = ApiKey(
|
||||
new_key = TemporaryCredit(
|
||||
hashed_key=api_key_hash,
|
||||
balance=500, # Less than fixed cost per request (1000 msats)
|
||||
reserved_balance=0,
|
||||
total_spent=0,
|
||||
total_requests=0,
|
||||
)
|
||||
integration_session.add(new_key)
|
||||
await integration_session.commit()
|
||||
@@ -687,7 +679,7 @@ class TestEdgeCaseCombinations:
|
||||
assert insufficient_funds_count > 0
|
||||
|
||||
# Balance should never go negative
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
final_key = result.scalar_one()
|
||||
assert final_key.balance >= 0
|
||||
|
||||
@@ -379,4 +379,4 @@ async def test_info_endpoints_response_consistency(
|
||||
# Model IDs should be the same
|
||||
first_ids = {m["id"] for m in first_models}
|
||||
response_ids = {m["id"] for m in models}
|
||||
assert first_ids == response_ids
|
||||
assert first_ids == response_ids
|
||||
|
||||
@@ -149,15 +149,13 @@ class TestPerformanceBaseline:
|
||||
"""Test database operation performance"""
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.db import TemporaryCredit
|
||||
|
||||
# Create test data
|
||||
for i in range(100):
|
||||
key = ApiKey(
|
||||
key = TemporaryCredit(
|
||||
hashed_key=f"test_key_{i}",
|
||||
balance=1000000,
|
||||
total_spent=0,
|
||||
total_requests=0,
|
||||
)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
@@ -168,7 +166,7 @@ class TestPerformanceBaseline:
|
||||
for _ in range(100):
|
||||
start = time.time()
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.balance > 0) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(TemporaryCredit.balance > 0) # type: ignore[arg-type]
|
||||
)
|
||||
_ = result.all()
|
||||
duration = (time.time() - start) * 1000
|
||||
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from routstr.discovery import _PROVIDERS_CACHE
|
||||
from routstr.nostr.discovery import _PROVIDERS_CACHE
|
||||
|
||||
from .utils import PerformanceValidator, ResponseValidator
|
||||
|
||||
@@ -62,9 +62,9 @@ async def test_providers_endpoint_default_response(
|
||||
}
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
# Configure mock to return appropriate responses
|
||||
mock_fetch.side_effect = lambda url: mock_fetch_responses.get(
|
||||
url, {"status_code": 500, "json": {"error": "Unknown provider"}}
|
||||
@@ -126,9 +126,9 @@ async def test_providers_endpoint_with_include_json(
|
||||
}
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"status_code": 200,
|
||||
"json": mock_provider_response,
|
||||
@@ -200,9 +200,9 @@ async def test_providers_data_structure_validation(
|
||||
}
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = mock_health_response
|
||||
|
||||
response = await integration_client.get("/v1/providers/?include_json=true")
|
||||
@@ -247,7 +247,7 @@ async def test_providers_endpoint_no_providers_found(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
response = await integration_client.get("/v1/providers/")
|
||||
|
||||
@@ -308,10 +308,10 @@ async def test_providers_endpoint_offline_providers(
|
||||
}
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch(
|
||||
"routstr.discovery.fetch_provider_health",
|
||||
"routstr.nostr.discovery.fetch_provider_health",
|
||||
side_effect=mock_fetch_provider_health,
|
||||
):
|
||||
response = await integration_client.get("/v1/providers/?include_json=true")
|
||||
@@ -377,9 +377,9 @@ async def test_providers_endpoint_duplicate_urls(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"status_code": 200,
|
||||
"endpoint": "root",
|
||||
@@ -416,7 +416,7 @@ async def test_providers_endpoint_nostr_relay_failures(
|
||||
raise Exception("Connection to relay failed")
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", side_effect=failing_query
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", side_effect=failing_query
|
||||
):
|
||||
response = await integration_client.get("/v1/providers/")
|
||||
|
||||
@@ -454,9 +454,9 @@ async def test_providers_endpoint_malformed_urls(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
response = await integration_client.get("/v1/providers/")
|
||||
@@ -486,9 +486,9 @@ async def test_providers_endpoint_response_format(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Test default format
|
||||
@@ -536,9 +536,9 @@ async def test_providers_endpoint_performance(integration_client: AsyncClient) -
|
||||
validator = PerformanceValidator()
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Test multiple requests
|
||||
@@ -576,9 +576,9 @@ async def test_providers_endpoint_concurrent_requests(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Create concurrent requests
|
||||
@@ -618,9 +618,9 @@ async def test_providers_endpoint_parameter_validation(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Test various parameter values
|
||||
@@ -670,9 +670,9 @@ async def test_no_database_changes_during_provider_operations(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Make multiple requests with different parameters
|
||||
|
||||
@@ -14,7 +14,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.db import TemporaryCredit
|
||||
|
||||
from .utils import (
|
||||
ConcurrencyTester,
|
||||
@@ -297,8 +297,8 @@ async def test_proxy_get_insufficient_balance(
|
||||
from sqlmodel import update
|
||||
|
||||
await integration_session.execute(
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(balance=100) # Only 0.1 sats
|
||||
)
|
||||
await integration_session.commit()
|
||||
@@ -382,7 +382,7 @@ async def test_proxy_get_database_state_verification(
|
||||
|
||||
# Get initial key state
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
initial_key = result.scalar_one()
|
||||
initial_balance = initial_key.balance
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
try:
|
||||
from .real_testmint import create_real_mint_wallet
|
||||
except ImportError:
|
||||
# sixty_nuts not available, tests will be skipped
|
||||
# cashu not available, tests will be skipped
|
||||
create_real_mint_wallet = None # type: ignore
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ async def test_real_wallet() -> None:
|
||||
"""Test basic operations with a real Cashu mint wallet"""
|
||||
print("Testing real Cashu mint wallet...")
|
||||
|
||||
# Check if sixty_nuts dependency is available
|
||||
# Check if cashu dependency is available
|
||||
if create_real_mint_wallet is None:
|
||||
print("sixty_nuts not available. Skipping real mint tests.")
|
||||
print("cashu not available. Skipping real mint tests.")
|
||||
return
|
||||
|
||||
# Check if real mint is enabled
|
||||
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.db import ApiKey, create_session
|
||||
from routstr.core.db import TemporaryCredit, create_session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -16,7 +16,7 @@ async def test_reserved_balance_never_negative(integration_client: AsyncClient)
|
||||
|
||||
# Create a test API key with limited balance
|
||||
async with create_session() as session:
|
||||
test_key = ApiKey(
|
||||
test_key = TemporaryCredit(
|
||||
hashed_key="test_reserved_balance_key",
|
||||
balance=1000, # 1 sat
|
||||
reserved_balance=0,
|
||||
@@ -40,7 +40,7 @@ async def test_reserved_balance_never_negative(integration_client: AsyncClient)
|
||||
|
||||
# Check reserved balance after failed request
|
||||
async with create_session() as session:
|
||||
key = await session.get(ApiKey, "test_reserved_balance_key")
|
||||
key = await session.get(TemporaryCredit, "test_reserved_balance_key")
|
||||
assert key is not None
|
||||
assert key.reserved_balance >= 0, (
|
||||
f"Reserved balance went negative: {key.reserved_balance}"
|
||||
@@ -69,7 +69,7 @@ async def test_reserved_balance_never_negative(integration_client: AsyncClient)
|
||||
|
||||
# Check final state
|
||||
async with create_session() as session:
|
||||
key = await session.get(ApiKey, "test_reserved_balance_key")
|
||||
key = await session.get(TemporaryCredit, "test_reserved_balance_key")
|
||||
assert key is not None
|
||||
assert key.reserved_balance >= 0, (
|
||||
f"Reserved balance went negative after concurrent requests: {key.reserved_balance}"
|
||||
@@ -86,7 +86,7 @@ async def test_reserved_balance_with_successful_requests(
|
||||
# Create a test API key with more balance
|
||||
async with create_session() as session:
|
||||
unique_key = f"test_successful_key_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
test_key = TemporaryCredit(
|
||||
hashed_key=unique_key,
|
||||
balance=100000, # 100 sats
|
||||
reserved_balance=0,
|
||||
@@ -111,24 +111,23 @@ async def test_reserved_balance_with_successful_requests(
|
||||
|
||||
# Check that reserved balance was properly adjusted
|
||||
async with create_session() as session:
|
||||
key = await session.get(ApiKey, unique_key)
|
||||
key = await session.get(TemporaryCredit, unique_key)
|
||||
assert key is not None
|
||||
assert key.reserved_balance >= 0, (
|
||||
f"Reserved balance went negative: {key.reserved_balance}"
|
||||
)
|
||||
# Check if the request was processed (might fail due to model pricing in test env)
|
||||
# The important part is that reserved_balance doesn't go negative
|
||||
if key.total_spent > 0:
|
||||
assert key.balance < 100000, (
|
||||
"Balance should decrease after successful request"
|
||||
)
|
||||
if key.balance < 100000:
|
||||
# Balance decreased
|
||||
pass
|
||||
else:
|
||||
# Request failed, but reserved balance should still be non-negative
|
||||
assert key.balance == 100000, (
|
||||
"Balance should remain unchanged if request failed"
|
||||
)
|
||||
print(
|
||||
f"After successful request - Balance: {key.balance}, Reserved: {key.reserved_balance}, Spent: {key.total_spent}"
|
||||
f"After successful request - Balance: {key.balance}, Reserved: {key.reserved_balance}"
|
||||
)
|
||||
|
||||
|
||||
@@ -137,11 +136,11 @@ async def test_insufficient_reserved_balance_for_revert(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test revert_pay_for_request behavior with insufficient reserved balance."""
|
||||
from routstr.auth import revert_pay_for_request
|
||||
from routstr.payment.helpers import revert_pay_for_request
|
||||
|
||||
# Create key with zero reserved balance
|
||||
unique_key = f"test_revert_key_{uuid.uuid4().hex[:8]}"
|
||||
test_key = ApiKey(
|
||||
test_key = TemporaryCredit(
|
||||
hashed_key=unique_key,
|
||||
balance=1000,
|
||||
reserved_balance=0,
|
||||
@@ -160,6 +159,3 @@ async def test_insufficient_reserved_balance_for_revert(
|
||||
assert test_key.reserved_balance == -100, (
|
||||
f"Expected reserved_balance to be -100, got: {test_key.reserved_balance}"
|
||||
)
|
||||
assert test_key.total_requests == -1, (
|
||||
f"Expected total_requests to be -1, got: {test_key.total_requests}"
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.db import TemporaryCredit
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
@@ -55,13 +55,11 @@ async def test_api_key_generation_valid_token(
|
||||
# Verify database state directly
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
|
||||
assert db_key.balance == amount * 1000
|
||||
assert db_key.total_spent == 0
|
||||
assert db_key.total_requests == 0
|
||||
|
||||
# Verify the API key can be used for authentication
|
||||
integration_client.headers["Authorization"] = f"Bearer {api_key}"
|
||||
@@ -93,15 +91,15 @@ async def test_api_key_generation_invalid_token(
|
||||
integration_client.headers["Authorization"] = f"Bearer {invalid_token}"
|
||||
response = await integration_client.get("/v1/wallet/info")
|
||||
|
||||
# Should fail with 401
|
||||
assert response.status_code == 401, (
|
||||
f"Token {invalid_token[:20]}... should be invalid"
|
||||
# Should fail with 401 or 400
|
||||
assert response.status_code in [400, 401], (
|
||||
f"Token {invalid_token[:20]}... should be invalid (400 or 401)"
|
||||
)
|
||||
|
||||
# Validate error response
|
||||
validator = ResponseValidator()
|
||||
error_validation = validator.validate_error_response(
|
||||
response, expected_status=401, expected_error_key="detail"
|
||||
response, expected_status=response.status_code, expected_error_key="detail"
|
||||
)
|
||||
assert error_validation["valid"]
|
||||
|
||||
@@ -169,7 +167,7 @@ async def test_authorization_header_validation(
|
||||
valid_api_key = response.json()["api_key"]
|
||||
|
||||
# Test scenarios
|
||||
test_cases = [
|
||||
test_cases: list[tuple[dict[str, str], int, str]] = [
|
||||
# (headers, expected_status, description)
|
||||
(
|
||||
{},
|
||||
@@ -193,7 +191,7 @@ async def test_authorization_header_validation(
|
||||
integration_client.headers.pop("authorization", None)
|
||||
|
||||
# Set test headers
|
||||
integration_client.headers.update(headers)
|
||||
integration_client.headers.update(headers) # type: ignore[arg-type]
|
||||
|
||||
# Make request to protected endpoint
|
||||
response = await integration_client.get("/v1/wallet/")
|
||||
@@ -256,16 +254,14 @@ async def test_database_state_api_key_creation(
|
||||
# Verify database record
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
|
||||
# Validate stored data
|
||||
assert db_key.balance == amount * 1000 # msats
|
||||
assert db_key.total_spent == 0
|
||||
assert db_key.total_requests == 0
|
||||
assert db_key.refund_address is None
|
||||
assert db_key.key_expiry_time is None
|
||||
assert db_key.refund_expiration_time is None
|
||||
|
||||
# Creation timestamp should be recent (within last minute)
|
||||
# Note: The model doesn't have a creation timestamp field,
|
||||
@@ -446,7 +442,7 @@ async def test_concurrent_token_submissions(
|
||||
for api_key in api_keys:
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
assert db_key.balance == expected_balances[hashed_key]
|
||||
@@ -568,7 +564,7 @@ async def test_database_timestamp_accuracy(
|
||||
# Verify key exists in database
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select, update
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.db import TemporaryCredit
|
||||
|
||||
from .utils import ConcurrencyTester, ResponseValidator
|
||||
|
||||
@@ -49,7 +49,7 @@ async def test_wallet_endpoint_with_valid_api_key(
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
|
||||
@@ -138,7 +138,9 @@ async def test_wallet_with_zero_balance(
|
||||
# Manually set balance to zero in database
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
await integration_session.execute(
|
||||
update(ApiKey).where(ApiKey.hashed_key == hashed_key).values(balance=0) # type: ignore[arg-type]
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key)
|
||||
.values(balance=0) # type: ignore[arg-type]
|
||||
)
|
||||
await integration_session.commit()
|
||||
|
||||
@@ -181,9 +183,11 @@ async def test_expired_api_key_behavior(
|
||||
from sqlmodel import update
|
||||
|
||||
await integration_session.execute(
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(key_expiry_time=past_expiry, refund_address="test@lightning.address")
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(
|
||||
refund_expiration_time=past_expiry, refund_address="test@lightning.address"
|
||||
)
|
||||
)
|
||||
await integration_session.commit()
|
||||
|
||||
@@ -197,10 +201,10 @@ async def test_expired_api_key_behavior(
|
||||
# Verify expiry time was stored
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
assert db_key.key_expiry_time == past_expiry
|
||||
assert db_key.refund_expiration_time == past_expiry
|
||||
assert db_key.refund_address == "test@lightning.address"
|
||||
|
||||
|
||||
@@ -270,7 +274,7 @@ async def test_wallet_info_data_consistency(
|
||||
# Verify against database
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
|
||||
@@ -360,12 +364,10 @@ async def test_wallet_after_partial_spending(
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
|
||||
await integration_session.execute(
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(
|
||||
balance=initial_balance - spent_amount,
|
||||
total_spent=spent_amount,
|
||||
total_requests=5, # Simulate 5 requests
|
||||
)
|
||||
)
|
||||
await integration_session.commit()
|
||||
|
||||
@@ -13,7 +13,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.db import TemporaryCredit
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -123,7 +123,9 @@ async def test_zero_balance_refund_handling(
|
||||
from sqlmodel import update
|
||||
|
||||
await integration_session.execute(
|
||||
update(ApiKey).where(ApiKey.hashed_key == hashed_key).values(balance=0) # type: ignore[arg-type]
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key)
|
||||
.values(balance=0) # type: ignore[arg-type]
|
||||
)
|
||||
await integration_session.commit()
|
||||
|
||||
@@ -136,7 +138,7 @@ async def test_zero_balance_refund_handling(
|
||||
|
||||
# Key should still exist
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
assert result.scalar_one_or_none() is not None
|
||||
|
||||
@@ -159,7 +161,7 @@ async def test_refund_amount_validation(
|
||||
|
||||
# Verify the key has no refund address (needed for the "too small" check)
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
key = result.scalar_one()
|
||||
assert key.refund_address is None
|
||||
@@ -192,8 +194,8 @@ async def test_refund_with_lightning_address(
|
||||
from sqlmodel import update
|
||||
|
||||
await integration_session.execute(
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(refund_address=refund_address)
|
||||
)
|
||||
await integration_session.commit()
|
||||
@@ -252,7 +254,7 @@ async def test_database_state_after_refund(
|
||||
|
||||
# Verify key exists before refund
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
key_before = result.scalar_one()
|
||||
assert key_before.balance == 10_000_000
|
||||
@@ -263,12 +265,12 @@ async def test_database_state_after_refund(
|
||||
|
||||
# Verify key is deleted after refund
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
# Count total keys to ensure only the specific one was deleted
|
||||
result = await integration_session.execute(select(ApiKey))
|
||||
result = await integration_session.execute(select(TemporaryCredit))
|
||||
remaining_keys = result.scalars().all()
|
||||
# Should have no keys left (assuming clean test environment)
|
||||
assert len(remaining_keys) == 0
|
||||
@@ -480,8 +482,8 @@ async def test_refund_error_handling(
|
||||
from sqlmodel import update
|
||||
|
||||
await integration_session.execute(
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(balance=-1000) # Invalid negative balance
|
||||
)
|
||||
await integration_session.commit()
|
||||
@@ -517,9 +519,9 @@ async def test_refund_with_expired_key(
|
||||
from sqlmodel import update
|
||||
|
||||
await integration_session.execute(
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(key_expiry_time=past_expiry, refund_address="expired@ln.address")
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(refund_expiration_time=past_expiry, refund_address="expired@ln.address")
|
||||
)
|
||||
await integration_session.commit()
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.db import TemporaryCredit
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
@@ -64,7 +64,7 @@ async def test_topup_with_valid_token( # type: ignore[no-untyped-def]
|
||||
# Get the hashed key from the API key
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.db import TemporaryCredit
|
||||
|
||||
|
||||
class CashuTokenGenerator:
|
||||
@@ -97,11 +97,11 @@ class DatabaseStateValidator:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_api_key(self, api_key: str) -> Optional[ApiKey]:
|
||||
async def get_api_key(self, api_key: str) -> Optional[TemporaryCredit]:
|
||||
"""Get API key from database"""
|
||||
hashed_key = hashlib.sha256(api_key.encode()).hexdigest()
|
||||
result = await self.session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -125,20 +125,6 @@ class DatabaseStateValidator:
|
||||
"current_balance": key_obj.balance,
|
||||
}
|
||||
|
||||
async def validate_request_count(
|
||||
self, api_key: str, expected_count: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Validate request count for an API key"""
|
||||
key_obj = await self.get_api_key(api_key)
|
||||
if not key_obj:
|
||||
return {"valid": False, "error": "API key not found"}
|
||||
|
||||
return {
|
||||
"valid": key_obj.total_requests == expected_count,
|
||||
"expected": expected_count,
|
||||
"actual": key_obj.total_requests,
|
||||
}
|
||||
|
||||
async def validate_atomic_update(
|
||||
self, api_key: str, field: str, expected_value: Any
|
||||
) -> bool:
|
||||
@@ -315,7 +301,7 @@ class ConcurrencyTester:
|
||||
)
|
||||
|
||||
tasks = [make_request(req) for req in requests]
|
||||
return await asyncio.gather(*tasks, return_exceptions=False)
|
||||
return await asyncio.gather(*tasks, return_exceptions=False) # type: ignore
|
||||
|
||||
@staticmethod
|
||||
async def test_race_condition(
|
||||
@@ -463,8 +449,6 @@ class TestDataBuilder:
|
||||
"""Create test API key data"""
|
||||
data: Dict[str, Any] = {
|
||||
"balance": balance,
|
||||
"total_spent": 0,
|
||||
"total_requests": 0,
|
||||
}
|
||||
|
||||
if refund_address:
|
||||
|
||||
@@ -44,9 +44,9 @@ def check_imports() -> bool:
|
||||
print("Conftest fixtures imported successfully")
|
||||
|
||||
# Check routstr modules - imports are for verification only
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.core.db import TemporaryCredit
|
||||
|
||||
del ApiKey
|
||||
del TemporaryCredit
|
||||
|
||||
print("Router modules imported successfully")
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ from unittest.mock import Mock
|
||||
os.environ["UPSTREAM_BASE_URL"] = "http://test"
|
||||
os.environ["UPSTREAM_API_KEY"] = "test"
|
||||
|
||||
from routstr.algorithm import ( # noqa: E402
|
||||
from routstr.models.algorithm import ( # noqa: E402
|
||||
calculate_model_cost_score,
|
||||
get_provider_penalty,
|
||||
should_prefer_model,
|
||||
)
|
||||
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
|
||||
from routstr.models.models import Architecture, Model, Pricing # noqa: E402
|
||||
|
||||
|
||||
def create_test_model(
|
||||
|
||||
@@ -21,11 +21,11 @@ import pytest
|
||||
os.environ["UPSTREAM_BASE_URL"] = "http://test"
|
||||
os.environ["UPSTREAM_API_KEY"] = "test"
|
||||
|
||||
from routstr.payment.models import ( # noqa: E402
|
||||
from routstr.models.crud import _model_to_row_payload
|
||||
from routstr.models.models import ( # noqa: E402
|
||||
Architecture,
|
||||
Model,
|
||||
Pricing,
|
||||
_model_to_row_payload,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ from io import BytesIO
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from routstr.payment.helpers import (
|
||||
from routstr.payment.cost import (
|
||||
_calculate_image_tokens,
|
||||
_estimate_image_tokens_in_messages,
|
||||
_get_image_dimensions,
|
||||
estimate_image_tokens_in_messages,
|
||||
)
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ async def test_estimate_image_tokens_base64() -> None:
|
||||
}
|
||||
]
|
||||
|
||||
tokens = await estimate_image_tokens_in_messages(messages)
|
||||
tokens = await _estimate_image_tokens_in_messages(messages)
|
||||
assert tokens > 0
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ async def test_estimate_image_tokens_multiple_images() -> None:
|
||||
}
|
||||
]
|
||||
|
||||
tokens = await estimate_image_tokens_in_messages(messages)
|
||||
tokens = await _estimate_image_tokens_in_messages(messages)
|
||||
assert tokens > 0
|
||||
|
||||
|
||||
@@ -148,8 +148,8 @@ async def test_estimate_image_tokens_with_detail() -> None:
|
||||
}
|
||||
]
|
||||
|
||||
tokens_low = await estimate_image_tokens_in_messages(messages_low)
|
||||
tokens_high = await estimate_image_tokens_in_messages(messages_high)
|
||||
tokens_low = await _estimate_image_tokens_in_messages(messages_low)
|
||||
tokens_high = await _estimate_image_tokens_in_messages(messages_high)
|
||||
|
||||
assert tokens_low == 85
|
||||
assert tokens_high > tokens_low
|
||||
@@ -163,7 +163,7 @@ async def test_estimate_image_tokens_no_images() -> None:
|
||||
{"role": "assistant", "content": "I'm doing well, thank you!"},
|
||||
]
|
||||
|
||||
tokens = await estimate_image_tokens_in_messages(messages)
|
||||
tokens = await _estimate_image_tokens_in_messages(messages)
|
||||
assert tokens == 0
|
||||
|
||||
|
||||
@@ -185,5 +185,5 @@ async def test_estimate_image_tokens_input_image_type() -> None:
|
||||
}
|
||||
]
|
||||
|
||||
tokens = await estimate_image_tokens_in_messages(messages)
|
||||
tokens = await _estimate_image_tokens_in_messages(messages)
|
||||
assert tokens > 0
|
||||
|
||||
@@ -7,11 +7,11 @@ os.environ["UPSTREAM_BASE_URL"] = "http://test"
|
||||
os.environ["UPSTREAM_API_KEY"] = "test"
|
||||
|
||||
from routstr.core.settings import settings # noqa: E402
|
||||
from routstr.payment.helpers import get_max_cost_for_model # noqa: E402
|
||||
from routstr.payment.cost import get_max_cost_for_model # noqa: E402
|
||||
|
||||
|
||||
async def test_get_max_cost_for_model_known() -> None:
|
||||
from routstr.payment.models import Pricing
|
||||
from routstr.models.models import Pricing
|
||||
|
||||
# Mock DB session behavior
|
||||
mock_session = AsyncMock()
|
||||
@@ -63,48 +63,32 @@ async def test_get_max_cost_for_model_known() -> None:
|
||||
|
||||
with patch.object(settings, "fixed_pricing", False):
|
||||
with patch.object(settings, "tolerance_percentage", 0):
|
||||
cost = await get_max_cost_for_model(
|
||||
"gpt-4", session=mock_session, model_obj=mock_model
|
||||
)
|
||||
cost = get_max_cost_for_model(model_obj=mock_model)
|
||||
assert cost == 500000 # 500 sats * 1000 = msats
|
||||
|
||||
|
||||
async def test_get_max_cost_for_model_unknown() -> None:
|
||||
mock_session = AsyncMock()
|
||||
mock_model = Mock()
|
||||
mock_model.sats_pricing = None
|
||||
mock_model.id = "unknown-model"
|
||||
|
||||
# Mock the exec results to return no model override
|
||||
async def async_mock_exec(query: Any) -> Any:
|
||||
result = Mock()
|
||||
result.first = Mock(return_value=None)
|
||||
result.all = Mock(return_value=[])
|
||||
return result
|
||||
|
||||
mock_session.exec = AsyncMock(side_effect=async_mock_exec)
|
||||
mock_session.get = AsyncMock(return_value=None)
|
||||
|
||||
# Mock get_upstreams to return empty list
|
||||
with patch("routstr.proxy.get_upstreams", return_value=[]):
|
||||
with patch.object(settings, "fixed_cost_per_request", 100):
|
||||
with patch.object(settings, "tolerance_percentage", 0):
|
||||
cost = await get_max_cost_for_model(
|
||||
"unknown-model", session=mock_session, model_obj=None
|
||||
)
|
||||
assert cost == 100000
|
||||
with patch.object(settings, "fixed_cost_per_request", 100):
|
||||
with patch.object(settings, "tolerance_percentage", 0):
|
||||
cost = get_max_cost_for_model(model_obj=mock_model)
|
||||
assert cost == 100000
|
||||
|
||||
|
||||
async def test_get_max_cost_for_model_disabled() -> None:
|
||||
mock_session = AsyncMock()
|
||||
mock_model = Mock()
|
||||
with patch.object(settings, "fixed_pricing", True):
|
||||
with patch.object(settings, "fixed_cost_per_request", 200):
|
||||
with patch.object(settings, "tolerance_percentage", 0):
|
||||
cost = await get_max_cost_for_model("any-model", session=mock_session)
|
||||
cost = get_max_cost_for_model(model_obj=mock_model)
|
||||
assert cost == 200000
|
||||
|
||||
|
||||
async def test_get_max_cost_for_model_tolerance() -> None:
|
||||
from routstr.payment.models import Pricing
|
||||
|
||||
mock_session = AsyncMock()
|
||||
from routstr.models.models import Pricing
|
||||
|
||||
# Mock the model with sats_pricing
|
||||
mock_pricing = Pricing(
|
||||
@@ -121,7 +105,5 @@ async def test_get_max_cost_for_model_tolerance() -> None:
|
||||
|
||||
with patch.object(settings, "fixed_pricing", False):
|
||||
with patch.object(settings, "tolerance_percentage", 10):
|
||||
cost = await get_max_cost_for_model(
|
||||
"gpt-4", session=mock_session, model_obj=mock_model
|
||||
)
|
||||
cost = get_max_cost_for_model(model_obj=mock_model)
|
||||
assert cost == 450000 # 500 sats * 1000 * 0.9 = 450000
|
||||
|
||||
@@ -4,8 +4,13 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.wallet import credit_balance, get_balance, recieve_token, send_token
|
||||
from routstr.core.db import TemporaryCredit
|
||||
from routstr.payment.wallet import (
|
||||
credit_balance,
|
||||
get_balance,
|
||||
recieve_token,
|
||||
send_token,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -15,7 +20,7 @@ async def test_get_balance() -> None:
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
mock_wallet.load_proofs = AsyncMock()
|
||||
|
||||
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
with patch("routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
balance = await get_balance("sat")
|
||||
assert balance == 50000
|
||||
|
||||
@@ -43,7 +48,9 @@ async def test_recieve_token_valid() -> None:
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
|
||||
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
|
||||
with patch(
|
||||
"routstr.payment.wallet.deserialize_token_from_string"
|
||||
) as mock_deserialize:
|
||||
mock_token = Mock()
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.mint = "http://mint:3338"
|
||||
@@ -54,7 +61,9 @@ async def test_recieve_token_valid() -> None:
|
||||
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
mock_wallet.load_proofs = AsyncMock()
|
||||
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
with patch(
|
||||
"routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet
|
||||
):
|
||||
amount, unit, mint = await recieve_token(token_str)
|
||||
assert amount == 1000
|
||||
assert unit == "sat"
|
||||
@@ -65,8 +74,8 @@ async def test_recieve_token_valid() -> None:
|
||||
async def test_send_token() -> None:
|
||||
mock_wallet = Mock()
|
||||
|
||||
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
with patch("routstr.wallet.send", return_value=(1000, "test_token")):
|
||||
with patch("routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
with patch("routstr.payment.wallet.send", return_value=(1000, "test_token")):
|
||||
token = await send_token(1000, "sat", "http://mint:3338")
|
||||
assert token == "test_token"
|
||||
|
||||
@@ -87,7 +96,7 @@ async def test_credit_balance() -> None:
|
||||
mock_session = AsyncMock()
|
||||
|
||||
# Mock session.refresh to update the balance (simulates DB reload)
|
||||
async def mock_refresh(key: ApiKey) -> None:
|
||||
async def mock_refresh(key: TemporaryCredit) -> None:
|
||||
key.balance = 6000000
|
||||
|
||||
mock_session.refresh.side_effect = mock_refresh
|
||||
@@ -96,7 +105,7 @@ async def test_credit_balance() -> None:
|
||||
|
||||
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
|
||||
with patch(
|
||||
"routstr.wallet.recieve_token",
|
||||
"routstr.payment.wallet.recieve_token",
|
||||
return_value=(1000, "sat", "http://mint:3338"),
|
||||
):
|
||||
amount = await credit_balance(token_str, mock_key, mock_session)
|
||||
@@ -112,7 +121,9 @@ async def test_credit_balance() -> None:
|
||||
async def test_recieve_token_untrusted_mint() -> None:
|
||||
mock_wallet = Mock()
|
||||
|
||||
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
|
||||
with patch(
|
||||
"routstr.payment.wallet.deserialize_token_from_string"
|
||||
) as mock_deserialize:
|
||||
mock_token = Mock()
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.mint = "http://untrusted:3338"
|
||||
@@ -122,9 +133,9 @@ async def test_recieve_token_untrusted_mint() -> None:
|
||||
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
mock_wallet.load_proofs = AsyncMock()
|
||||
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
with patch("routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
with patch(
|
||||
"routstr.wallet.swap_to_primary_mint",
|
||||
"routstr.payment.wallet.swap_to_primary_mint",
|
||||
return_value=(900, "sat", "http://mint:3338"),
|
||||
):
|
||||
amount, unit, mint = await recieve_token("test_token")
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Key,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { AdminService, TemporaryBalance } from '@/lib/api/services/admin';
|
||||
import {
|
||||
@@ -56,21 +55,17 @@ export function TemporaryBalances({
|
||||
|
||||
const calculateTotals = (balances: TemporaryBalance[]) => {
|
||||
let totalBalance = 0;
|
||||
let totalSpent = 0;
|
||||
let totalRequests = 0;
|
||||
|
||||
balances.forEach((balance) => {
|
||||
totalBalance += balance.balance || 0;
|
||||
totalSpent += balance.total_spent || 0;
|
||||
totalRequests += balance.total_requests || 0;
|
||||
});
|
||||
|
||||
return { totalBalance, totalSpent, totalRequests };
|
||||
return { totalBalance };
|
||||
};
|
||||
|
||||
const totals = data
|
||||
? calculateTotals(data)
|
||||
: { totalBalance: 0, totalSpent: 0, totalRequests: 0 };
|
||||
: { totalBalance: 0 };
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -142,32 +137,6 @@ export function TemporaryBalances({
|
||||
{formatBalance(totals.totalBalance)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg border border-green-200 bg-gradient-to-r from-green-50 to-emerald-50 p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Activity className='h-5 w-5 text-green-600' />
|
||||
<span className='text-sm font-medium text-green-800'>
|
||||
Total Spent
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-2 text-2xl font-bold text-green-900'>
|
||||
{formatBalance(totals.totalSpent)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg border border-purple-200 bg-gradient-to-r from-purple-50 to-pink-50 p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Key className='h-5 w-5 text-purple-600' />
|
||||
<span className='text-sm font-medium text-purple-800'>
|
||||
Total Requests
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-2 text-2xl font-bold text-purple-900'>
|
||||
{totals.totalRequests.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
@@ -176,9 +145,9 @@ export function TemporaryBalances({
|
||||
<div className='bg-muted hidden grid-cols-6 gap-2 p-3 text-sm font-semibold md:grid'>
|
||||
<div>Hashed Key</div>
|
||||
<div className='text-right'>Balance</div>
|
||||
<div className='text-right'>Total Spent</div>
|
||||
<div className='text-right'>Total Requests</div>
|
||||
<div>Refund Address</div>
|
||||
<div>Refund Mint</div>
|
||||
<div>Currency</div>
|
||||
<div className='text-right'>Expiry Time</div>
|
||||
</div>
|
||||
|
||||
@@ -199,22 +168,22 @@ export function TemporaryBalances({
|
||||
<div className='text-right font-mono'>
|
||||
{formatBalance(balance.balance)}
|
||||
</div>
|
||||
<div className='text-right font-mono'>
|
||||
{formatBalance(balance.total_spent)}
|
||||
</div>
|
||||
<div className='text-right font-mono'>
|
||||
{balance.total_requests.toLocaleString()}
|
||||
</div>
|
||||
<div className='max-w-32 truncate font-mono text-xs break-all'>
|
||||
{balance.refund_address || '-'}
|
||||
</div>
|
||||
<div className='max-w-32 truncate font-mono text-xs break-all'>
|
||||
{balance.refund_mint_url || '-'}
|
||||
</div>
|
||||
<div className='truncate font-mono text-xs'>
|
||||
{balance.refund_currency || '-'}
|
||||
</div>
|
||||
<div className='text-right font-mono text-xs'>
|
||||
{balance.key_expiry_time ? (
|
||||
{balance.refund_expiration_time ? (
|
||||
<div className='flex items-center justify-end gap-1'>
|
||||
<Clock className='h-3 w-3' />
|
||||
<span>
|
||||
{new Date(
|
||||
balance.key_expiry_time * 1000
|
||||
balance.refund_expiration_time * 1000
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -246,34 +215,26 @@ export function TemporaryBalances({
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
Spent
|
||||
Currency
|
||||
</div>
|
||||
<div className='truncate font-mono text-sm'>
|
||||
{formatBalance(balance.total_spent)}
|
||||
{balance.refund_currency || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
Requests
|
||||
</div>
|
||||
<div className='truncate font-mono text-sm'>
|
||||
{balance.total_requests.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
Expires
|
||||
</div>
|
||||
<div className='font-mono text-xs'>
|
||||
{balance.key_expiry_time ? (
|
||||
{balance.refund_expiration_time ? (
|
||||
<div className='flex items-center gap-1'>
|
||||
<Clock className='h-3 w-3 flex-shrink-0' />
|
||||
<span className='truncate'>
|
||||
{new Date(
|
||||
balance.key_expiry_time * 1000
|
||||
balance.refund_expiration_time * 1000
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -294,6 +255,17 @@ export function TemporaryBalances({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{balance.refund_mint_url && (
|
||||
<div className='space-y-1 pt-2'>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
Refund Mint
|
||||
</div>
|
||||
<div className='font-mono text-xs break-all'>
|
||||
{balance.refund_mint_url}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -861,10 +861,10 @@ export class AdminService {
|
||||
export const TemporaryBalanceSchema = z.object({
|
||||
hashed_key: z.string(),
|
||||
balance: z.number(),
|
||||
total_spent: z.number(),
|
||||
total_requests: z.number(),
|
||||
refund_address: z.string().nullable(),
|
||||
key_expiry_time: z.number().nullable(),
|
||||
refund_mint_url: z.string().nullable(),
|
||||
refund_currency: z.string().nullable(),
|
||||
refund_expiration_time: z.number().nullable(),
|
||||
});
|
||||
|
||||
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
|
||||
|
||||
Reference in New Issue
Block a user