mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-06 09:54:36 +00:00
Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
689a07f562 | ||
|
|
fa6d3c76d0 | ||
|
|
ede1804d4b | ||
|
|
3392e8d4cb | ||
|
|
b5174d9753 | ||
|
|
0c60644ba2 | ||
|
|
aca8d43a61 | ||
|
|
7fe4c1963b | ||
|
|
9a0919f149 | ||
|
|
d69ab913d4 | ||
|
|
42b8c332df | ||
|
|
aa682bf8ec | ||
|
|
c53e72e80a | ||
|
|
afd81aeca2 | ||
|
|
4e03145323 | ||
|
|
169686681f | ||
|
|
fa7d2804bb | ||
|
|
ccabc5d06d | ||
|
|
cb68227c88 | ||
|
|
c72fc7dd56 | ||
|
|
8c6d1f89dc | ||
|
|
1ab7e54bd7 | ||
|
|
42dceb0cd6 | ||
|
|
05115c3387 | ||
|
|
c0c8cafd00 | ||
|
|
16d6d66d17 | ||
|
|
3681fc8aab | ||
|
|
d95c09e00c | ||
|
|
3cdef5ec17 | ||
|
|
58e1620347 | ||
|
|
42b5a5e6c8 | ||
|
|
d84d249f2c | ||
|
|
8b6393f794 | ||
|
|
81146710ba | ||
|
|
25f39897d7 | ||
|
|
45bdfaee58 | ||
|
|
a1ae6e94e9 | ||
|
|
eebcc67c85 | ||
|
|
3f9e7f7728 | ||
|
|
a5b5549edd | ||
|
|
22eec0162c | ||
|
|
9f55da9bb8 | ||
|
|
16dce9ea81 | ||
|
|
963ee04619 | ||
|
|
b874b1f01c | ||
|
|
b6cca3d3a0 | ||
|
|
86ebc84f4c | ||
|
|
8a74c0543f | ||
|
|
3d16a9b988 | ||
|
|
40f98b99aa | ||
|
|
7fcae5b08d | ||
|
|
f50cb31749 | ||
|
|
85a1ea4b4c | ||
|
|
0f0f8c40bf | ||
|
|
a40d224ee7 | ||
|
|
a637acd8f4 | ||
|
|
58be0c7976 | ||
|
|
d934f3eead | ||
|
|
74b58e5fa3 | ||
|
|
c1497e0cfe | ||
|
|
30db582321 | ||
|
|
a1018776e9 | ||
|
|
5ef499e2ae | ||
|
|
c7c802c610 | ||
|
|
685368bb0a | ||
|
|
55e240d92a | ||
|
|
7da4ad3818 | ||
|
|
453337cb2c | ||
|
|
9e1934bfda | ||
|
|
d686e0e851 | ||
|
|
7708ed1c8b | ||
|
|
236854bfe4 | ||
|
|
a7886c528f | ||
|
|
a7b815b29f |
@@ -51,14 +51,26 @@ curl https://api.routstr.com/v1/chat/completions \
|
|||||||
|
|
||||||
## Quick Start (Docker)
|
## Quick Start (Docker)
|
||||||
|
|
||||||
If you are a node runner, start a Routstr Core instance and configure upstream access in the dashboard.
|
If you are a node runner, start a Routstr Core instance using Docker Compose:
|
||||||
|
|
||||||
```bash
|
1. **Prepare your `.env`**:
|
||||||
docker run -d \
|
```bash
|
||||||
--name routstr-proxy \
|
ADMIN_PASSWORD=mysecretpassword
|
||||||
-p 8000:8000 \
|
NAME="My AI Node"
|
||||||
ghcr.io/routstr/proxy:latest
|
DESCRIPTION="Fast access to models"
|
||||||
```
|
NSEC=yournsec
|
||||||
|
RECEIVE_LN_ADDRESS=yourname@wallet.com
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start the services**:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure**:
|
||||||
|
Open [http://localhost:8000/admin/](http://localhost:8000/admin/) to connect your AI providers and set pricing.
|
||||||
|
|
||||||
|
For full instructions, see the **[Provider Quick Start Guide](https://docs.routstr.com/provider/quickstart/)**.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,7 @@ Production deployment guide for Routstr Provider nodes.
|
|||||||
|
|
||||||
For production, use Docker Compose with persistent storage and optional Tor support.
|
For production, use Docker Compose with persistent storage and optional Tor support.
|
||||||
|
|
||||||
### Unified Setup (All-in-one)
|
Use the included `compose.yml` for a flexible setup that handles both the UI and the node execution. This is useful for development or when you want to manage Tor as a separate service.
|
||||||
To build and run the node with the UI integrated in a single container using the multi-stage build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -f Dockerfile.full -t routstr-full .
|
|
||||||
docker run -d -p 8000:8000 --env-file .env routstr-full
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advanced Setup (Separated UI & Node)
|
|
||||||
Use the included `compose.yml` for a more flexible setup that separates the UI build process from the node execution. This is useful for development or when you want to manage Tor as a separate service.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -184,20 +175,16 @@ docker compose up -d
|
|||||||
|
|
||||||
## Building from Source
|
## Building from Source
|
||||||
|
|
||||||
### Unified Image (UI + Node)
|
### Using Docker Compose
|
||||||
The easiest way to build everything from source into a single production-ready image:
|
The easiest way to build everything from source:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f Dockerfile.full -t routstr-full .
|
docker compose build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Individual Components
|
### Individual Components
|
||||||
If you prefer building them separately or using Docker Compose:
|
If you prefer building the node only (requires manual UI build first):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build using compose
|
|
||||||
docker compose build
|
|
||||||
|
|
||||||
# Or build the node only (requires manual UI build first)
|
|
||||||
docker build -t routstr-node .
|
docker build -t routstr-node .
|
||||||
```
|
```
|
||||||
|
|||||||
+12
-24
@@ -35,6 +35,7 @@ ADMIN_PASSWORD=mysecretpassword
|
|||||||
# Node Identity
|
# Node Identity
|
||||||
NAME="My AI Node"
|
NAME="My AI Node"
|
||||||
DESCRIPTION="Fast access to models"
|
DESCRIPTION="Fast access to models"
|
||||||
|
NSEC=yournsec
|
||||||
|
|
||||||
# Lightning Payouts
|
# Lightning Payouts
|
||||||
RECEIVE_LN_ADDRESS=yourname@wallet.com
|
RECEIVE_LN_ADDRESS=yourname@wallet.com
|
||||||
@@ -43,32 +44,10 @@ RECEIVE_LN_ADDRESS=yourname@wallet.com
|
|||||||
|
|
||||||
## 2. Start the Node
|
## 2. Start the Node
|
||||||
|
|
||||||
You can run the pre-built image directly:
|
The recommended way to run Routstr is using Docker Compose, which handles the node, the UI, and optional services like Tor.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker compose up -d
|
||||||
--name routstr \
|
|
||||||
-p 8000:8000 \
|
|
||||||
--env-file .env \
|
|
||||||
-v routstr-data:/app/data \
|
|
||||||
ghcr.io/routstr/proxy:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
*Note: The pre-built image does not contain the UI. For the all-in-one experience with the Admin Dashboard, use the Build from Source instructions below.*
|
|
||||||
|
|
||||||
### Build from Source (Recommended)
|
|
||||||
|
|
||||||
If you want to build the node and UI yourself from source, use the unified Dockerfile:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/routstr/routstr-core.git
|
|
||||||
cd routstr-core
|
|
||||||
# Edit your .env with ADMIN_PASSWORD and API keys
|
|
||||||
cp .env.example .env
|
|
||||||
nano .env
|
|
||||||
|
|
||||||
docker build -f Dockerfile.full -t routstr-local .
|
|
||||||
docker run -d -p 8000:8000 --env-file .env --name routstr routstr-local
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify it's running:
|
Verify it's running:
|
||||||
@@ -77,6 +56,15 @@ Verify it's running:
|
|||||||
curl http://localhost:8000/v1/info
|
curl http://localhost:8000/v1/info
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Build from Source (Optional)
|
||||||
|
|
||||||
|
If you've cloned the repository and want to build the images yourself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Configure via Dashboard
|
## 3. Configure via Dashboard
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""add routstr_fees table
|
||||||
|
|
||||||
|
Revision ID: 02650cd6f028
|
||||||
|
Revises: c3d4e5f6a7b8
|
||||||
|
Create Date: 2026-04-24 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "02650cd6f028"
|
||||||
|
down_revision = "c3d4e5f6a7b8"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"routstr_fees",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("accumulated_msats", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("total_paid_msats", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("last_paid_at", sa.Integer(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
# Seed with a single row
|
||||||
|
op.execute("INSERT INTO routstr_fees (id, accumulated_msats, total_paid_msats) VALUES (1, 0, 0)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("routstr_fees")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add forwarded_model_id to models
|
||||||
|
|
||||||
|
Revision ID: b1c2d3e4f5a6
|
||||||
|
Revises: a776ca70e5fe
|
||||||
|
Create Date: 2026-04-05 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "b1c2d3e4f5a6"
|
||||||
|
down_revision = "a776ca70e5fe"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"models",
|
||||||
|
sa.Column(
|
||||||
|
"forwarded_model_id",
|
||||||
|
sqlmodel.sql.sqltypes.AutoString(),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Backfill: set forwarded_model_id = id for all existing rows
|
||||||
|
op.execute("UPDATE models SET forwarded_model_id = id WHERE forwarded_model_id IS NULL")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("models", "forwarded_model_id")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""add source to cashu_transactions
|
||||||
|
|
||||||
|
Revision ID: c3d4e5f6a7b8
|
||||||
|
Revises: b1c2d3e4f5a6
|
||||||
|
Create Date: 2026-04-10 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "c3d4e5f6a7b8"
|
||||||
|
down_revision = "b1c2d3e4f5a6"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
columns = [col["name"] for col in inspector.get_columns("cashu_transactions")]
|
||||||
|
if "source" not in columns:
|
||||||
|
op.add_column(
|
||||||
|
"cashu_transactions",
|
||||||
|
sa.Column(
|
||||||
|
"source",
|
||||||
|
sqlmodel.sql.sqltypes.AutoString(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="x-cashu",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("cashu_transactions", "source")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""add api key link to cashu_transactions
|
||||||
|
|
||||||
|
Revision ID: d4e5f6a7b8c9
|
||||||
|
Revises: c3d4e5f6a7b8
|
||||||
|
Create Date: 2026-04-20 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "d4e5f6a7b8c9"
|
||||||
|
down_revision = "c3d4e5f6a7b8"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
columns = [col["name"] for col in inspector.get_columns("cashu_transactions")]
|
||||||
|
indexes = {index["name"] for index in inspector.get_indexes("cashu_transactions")}
|
||||||
|
|
||||||
|
if "api_key_hashed_key" not in columns:
|
||||||
|
op.add_column(
|
||||||
|
"cashu_transactions",
|
||||||
|
sa.Column(
|
||||||
|
"api_key_hashed_key",
|
||||||
|
sqlmodel.sql.sqltypes.AutoString(),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if "ix_cashu_transactions_api_key_hashed_key" not in indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_cashu_transactions_api_key_hashed_key",
|
||||||
|
"cashu_transactions",
|
||||||
|
["api_key_hashed_key"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_cashu_transactions_api_key_hashed_key", table_name="cashu_transactions")
|
||||||
|
op.drop_column("cashu_transactions", "api_key_hashed_key")
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""merge heads: routstr_fees + api_key_to_cashu_transactions
|
||||||
|
|
||||||
|
Revision ID: e8f9a0b1c2d3
|
||||||
|
Revises: 02650cd6f028, d4e5f6a7b8c9
|
||||||
|
Create Date: 2026-04-24 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "e8f9a0b1c2d3"
|
||||||
|
down_revision = ("02650cd6f028", "d4e5f6a7b8c9")
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "routstr"
|
name = "routstr"
|
||||||
version = "0.4.1"
|
version = "0.4.3"
|
||||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -217,6 +217,10 @@ def create_model_mappings(
|
|||||||
if prefixed_id not in aliases:
|
if prefixed_id not in aliases:
|
||||||
aliases.append(prefixed_id)
|
aliases.append(prefixed_id)
|
||||||
|
|
||||||
|
# Register forwarded_model_id as a routable alias
|
||||||
|
if model_to_use.forwarded_model_id and model_to_use.forwarded_model_id not in aliases:
|
||||||
|
aliases.append(model_to_use.forwarded_model_id)
|
||||||
|
|
||||||
# Try to set each alias
|
# Try to set each alias
|
||||||
for alias in aliases:
|
for alias in aliases:
|
||||||
_add_candidate(alias, model_to_use, upstream)
|
_add_candidate(alias, model_to_use, upstream)
|
||||||
@@ -305,6 +309,10 @@ def create_model_mappings(
|
|||||||
if prefixed_id not in aliases:
|
if prefixed_id not in aliases:
|
||||||
aliases.append(prefixed_id)
|
aliases.append(prefixed_id)
|
||||||
|
|
||||||
|
# Register forwarded_model_id as a routable alias
|
||||||
|
if model_to_use.forwarded_model_id and model_to_use.forwarded_model_id not in aliases:
|
||||||
|
aliases.append(model_to_use.forwarded_model_id)
|
||||||
|
|
||||||
for alias in aliases:
|
for alias in aliases:
|
||||||
_add_candidate(alias, model_to_use, upstream_for_override)
|
_add_candidate(alias, model_to_use, upstream_for_override)
|
||||||
seen_model_provider.add(dedupe_key)
|
seen_model_provider.add(dedupe_key)
|
||||||
|
|||||||
+217
-34
@@ -7,11 +7,12 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import case
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlmodel import col, select, update
|
from sqlmodel import col, select, update
|
||||||
|
|
||||||
from .core import get_logger
|
from .core import get_logger
|
||||||
from .core.db import ApiKey, AsyncSession
|
from .core.db import ApiKey, AsyncSession, accumulate_routstr_fee
|
||||||
from .core.settings import settings
|
from .core.settings import settings
|
||||||
from .payment.cost_calculation import (
|
from .payment.cost_calculation import (
|
||||||
CostData,
|
CostData,
|
||||||
@@ -22,6 +23,13 @@ from .payment.cost_calculation import (
|
|||||||
from .wallet import credit_balance, deserialize_token_from_string
|
from .wallet import credit_balance, deserialize_token_from_string
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
payments_logger = get_logger("routstr.payments")
|
||||||
|
|
||||||
|
# Routstr platform fee constants
|
||||||
|
ROUTSTR_FEE_PERCENT: float = 2.1
|
||||||
|
ROUTSTR_LN_ADDRESS: str = "npub130mznv74rxs032peqym6g3wqavh472623mt3z5w73xq9r6qqdufs7ql29s@npub.cash"
|
||||||
|
ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS: int = 900
|
||||||
|
ROUTSTR_FEE_DEFAULT_PAYOUT: int = 200
|
||||||
|
|
||||||
# TODO: implement prepaid api key (not like it was before)
|
# TODO: implement prepaid api key (not like it was before)
|
||||||
# PREPAID_API_KEY = os.environ.get("PREPAID_API_KEY", None)
|
# PREPAID_API_KEY = os.environ.get("PREPAID_API_KEY", None)
|
||||||
@@ -584,6 +592,18 @@ async def pay_for_request(
|
|||||||
"total_requests": billing_key.total_requests,
|
"total_requests": billing_key.total_requests,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
payments_logger.info(
|
||||||
|
"RESERVE",
|
||||||
|
extra={
|
||||||
|
"event": "reserve",
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
|
"cost_reserved": cost_per_request,
|
||||||
|
"balance": billing_key.balance,
|
||||||
|
"reserved_balance": billing_key.reserved_balance,
|
||||||
|
"total_spent": billing_key.total_spent,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return cost_per_request
|
return cost_per_request
|
||||||
|
|
||||||
@@ -635,6 +655,17 @@ async def revert_pay_for_request(
|
|||||||
await session.refresh(billing_key)
|
await session.refresh(billing_key)
|
||||||
if billing_key.hashed_key != key.hashed_key:
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
await session.refresh(key)
|
await session.refresh(key)
|
||||||
|
payments_logger.info(
|
||||||
|
"REVERT",
|
||||||
|
extra={
|
||||||
|
"event": "revert",
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
|
"cost_reverted": cost_per_request,
|
||||||
|
"balance": billing_key.balance,
|
||||||
|
"reserved_balance": billing_key.reserved_balance,
|
||||||
|
},
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -716,6 +747,17 @@ async def adjust_payment_for_tokens(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _accumulate_fee(total_cost_msats: int) -> None:
|
||||||
|
if total_cost_msats > 0 and ROUTSTR_FEE_PERCENT > 0:
|
||||||
|
fee_msats = math.ceil(total_cost_msats * ROUTSTR_FEE_PERCENT / 100)
|
||||||
|
try:
|
||||||
|
await accumulate_routstr_fee(session, fee_msats)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to accumulate Routstr fee",
|
||||||
|
extra={"error": str(e), "fee_msats": fee_msats},
|
||||||
|
)
|
||||||
|
|
||||||
match await calculate_cost(response_data, deducted_max_cost, session):
|
match await calculate_cost(response_data, deducted_max_cost, session):
|
||||||
case MaxCostData() as cost:
|
case MaxCostData() as cost:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -728,11 +770,32 @@ async def adjust_payment_for_tokens(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Finalize by releasing reservation and charging max cost
|
# Finalize by releasing reservation and charging max cost
|
||||||
|
if billing_key.reserved_balance < deducted_max_cost:
|
||||||
|
logger.error(
|
||||||
|
"reserved_balance below deducted_max_cost before MaxCost finalization — clamping to 0",
|
||||||
|
extra={
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
|
"reserved_balance": billing_key.reserved_balance,
|
||||||
|
"deducted_max_cost": deducted_max_cost,
|
||||||
|
"total_cost_msats": cost.total_msats,
|
||||||
|
"balance": billing_key.balance,
|
||||||
|
"total_spent": billing_key.total_spent,
|
||||||
|
"model": model,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
safe_reserved = case(
|
||||||
|
(col(ApiKey.reserved_balance) >= deducted_max_cost,
|
||||||
|
col(ApiKey.reserved_balance) - deducted_max_cost),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
|
||||||
finalize_stmt = (
|
finalize_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
reserved_balance=safe_reserved,
|
||||||
balance=col(ApiKey.balance) - cost.total_msats,
|
balance=col(ApiKey.balance) - cost.total_msats,
|
||||||
total_spent=col(ApiKey.total_spent) + cost.total_msats,
|
total_spent=col(ApiKey.total_spent) + cost.total_msats,
|
||||||
)
|
)
|
||||||
@@ -741,13 +804,17 @@ async def adjust_payment_for_tokens(
|
|||||||
|
|
||||||
# Also update total_spent and reserved_balance on the child key if it's different
|
# Also update total_spent and reserved_balance on the child key if it's different
|
||||||
if billing_key.hashed_key != key.hashed_key:
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
child_safe_reserved = case(
|
||||||
|
(col(ApiKey.reserved_balance) >= deducted_max_cost,
|
||||||
|
col(ApiKey.reserved_balance) - deducted_max_cost),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
child_stmt = (
|
child_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
total_spent=col(ApiKey.total_spent) + cost.total_msats,
|
total_spent=col(ApiKey.total_spent) + cost.total_msats,
|
||||||
reserved_balance=col(ApiKey.reserved_balance)
|
reserved_balance=child_safe_reserved,
|
||||||
- deducted_max_cost,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
@@ -782,6 +849,24 @@ async def adjust_payment_for_tokens(
|
|||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await _accumulate_fee(cost.total_msats)
|
||||||
|
payments_logger.info(
|
||||||
|
"FINALIZE",
|
||||||
|
extra={
|
||||||
|
"event": "finalize",
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
|
"model": model,
|
||||||
|
"cost_reserved": deducted_max_cost,
|
||||||
|
"cost_charged": cost.total_msats,
|
||||||
|
"input_tokens": cost.input_tokens,
|
||||||
|
"output_tokens": cost.output_tokens,
|
||||||
|
"balance": billing_key.balance,
|
||||||
|
"reserved_balance": billing_key.reserved_balance,
|
||||||
|
"total_spent": billing_key.total_spent,
|
||||||
|
"finalize_type": "max_cost",
|
||||||
|
},
|
||||||
|
)
|
||||||
return cost.dict()
|
return cost.dict()
|
||||||
|
|
||||||
case CostData() as cost:
|
case CostData() as cost:
|
||||||
@@ -815,12 +900,32 @@ async def adjust_payment_for_tokens(
|
|||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if billing_key.reserved_balance < deducted_max_cost:
|
||||||
|
logger.error(
|
||||||
|
"reserved_balance below deducted_max_cost on exact-cost finalization — clamping to 0",
|
||||||
|
extra={
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
|
"reserved_balance": billing_key.reserved_balance,
|
||||||
|
"deducted_max_cost": deducted_max_cost,
|
||||||
|
"total_cost_msats": total_cost_msats,
|
||||||
|
"balance": billing_key.balance,
|
||||||
|
"total_spent": billing_key.total_spent,
|
||||||
|
"model": model,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
exact_safe_reserved = case(
|
||||||
|
(col(ApiKey.reserved_balance) >= deducted_max_cost,
|
||||||
|
col(ApiKey.reserved_balance) - deducted_max_cost),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
|
||||||
finalize_stmt = (
|
finalize_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance)
|
reserved_balance=exact_safe_reserved,
|
||||||
- deducted_max_cost,
|
|
||||||
balance=col(ApiKey.balance) - total_cost_msats,
|
balance=col(ApiKey.balance) - total_cost_msats,
|
||||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||||
)
|
)
|
||||||
@@ -829,13 +934,17 @@ async def adjust_payment_for_tokens(
|
|||||||
|
|
||||||
# Also update total_spent and reserved_balance on the child key if it's different
|
# Also update total_spent and reserved_balance on the child key if it's different
|
||||||
if billing_key.hashed_key != key.hashed_key:
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
child_exact_safe_reserved = case(
|
||||||
|
(col(ApiKey.reserved_balance) >= deducted_max_cost,
|
||||||
|
col(ApiKey.reserved_balance) - deducted_max_cost),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
child_stmt = (
|
child_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||||
reserved_balance=col(ApiKey.reserved_balance)
|
reserved_balance=child_exact_safe_reserved,
|
||||||
- deducted_max_cost,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
@@ -844,44 +953,56 @@ async def adjust_payment_for_tokens(
|
|||||||
await session.refresh(billing_key)
|
await session.refresh(billing_key)
|
||||||
if billing_key.hashed_key != key.hashed_key:
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
await session.refresh(key)
|
await session.refresh(key)
|
||||||
return cost.dict()
|
await _accumulate_fee(total_cost_msats)
|
||||||
|
payments_logger.info(
|
||||||
# this should never happen why do we handle this???
|
"FINALIZE",
|
||||||
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={
|
extra={
|
||||||
|
"event": "finalize",
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"additional_charge": cost_difference,
|
|
||||||
"current_balance": billing_key.balance,
|
|
||||||
"sufficient_balance": billing_key.balance >= cost_difference,
|
|
||||||
"model": model,
|
"model": model,
|
||||||
|
"cost_reserved": deducted_max_cost,
|
||||||
|
"cost_charged": total_cost_msats,
|
||||||
|
"input_tokens": cost.input_tokens,
|
||||||
|
"output_tokens": cost.output_tokens,
|
||||||
|
"balance": billing_key.balance,
|
||||||
|
"reserved_balance": billing_key.reserved_balance,
|
||||||
|
"total_spent": billing_key.total_spent,
|
||||||
|
"finalize_type": "exact",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
return cost.dict()
|
||||||
|
|
||||||
|
# actual cost exceeded discounted reservation (due to tolerance_percentage)
|
||||||
|
if cost_difference > 0:
|
||||||
|
# Always release the reservation and charge min(actual_cost, balance).
|
||||||
|
# Using a CASE expression makes this a single atomic UPDATE — no
|
||||||
|
# multi-level fallback needed and balance can never go negative.
|
||||||
|
chargeable = case(
|
||||||
|
(col(ApiKey.balance) >= total_cost_msats, total_cost_msats),
|
||||||
|
else_=col(ApiKey.balance),
|
||||||
|
)
|
||||||
|
|
||||||
finalize_stmt = (
|
finalize_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
|
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance)
|
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
||||||
- deducted_max_cost,
|
balance=col(ApiKey.balance) - chargeable,
|
||||||
balance=col(ApiKey.balance) - total_cost_msats,
|
total_spent=col(ApiKey.total_spent) + chargeable,
|
||||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
# Also update total_spent and reserved_balance on the child key if it's different
|
|
||||||
if billing_key.hashed_key != key.hashed_key:
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
child_stmt = (
|
child_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||||
|
.where(col(ApiKey.reserved_balance) >= deducted_max_cost)
|
||||||
.values(
|
.values(
|
||||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
||||||
reserved_balance=col(ApiKey.reserved_balance)
|
total_spent=col(ApiKey.total_spent) + min(billing_key.balance, total_cost_msats),
|
||||||
- deducted_max_cost,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
@@ -889,11 +1010,10 @@ async def adjust_payment_for_tokens(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
if result.rowcount:
|
if result.rowcount:
|
||||||
cost.total_msats = total_cost_msats
|
|
||||||
await session.refresh(billing_key)
|
await session.refresh(billing_key)
|
||||||
if billing_key.hashed_key != key.hashed_key:
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
await session.refresh(key)
|
await session.refresh(key)
|
||||||
|
cost.total_msats = total_cost_msats
|
||||||
logger.info(
|
logger.info(
|
||||||
"Finalized payment with additional charge",
|
"Finalized payment with additional charge",
|
||||||
extra={
|
extra={
|
||||||
@@ -904,9 +1024,29 @@ async def adjust_payment_for_tokens(
|
|||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await _accumulate_fee(total_cost_msats)
|
||||||
|
payments_logger.info(
|
||||||
|
"FINALIZE",
|
||||||
|
extra={
|
||||||
|
"event": "finalize",
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
|
"model": model,
|
||||||
|
"cost_reserved": deducted_max_cost,
|
||||||
|
"cost_charged": total_cost_msats,
|
||||||
|
"input_tokens": cost.input_tokens,
|
||||||
|
"output_tokens": cost.output_tokens,
|
||||||
|
"balance": billing_key.balance,
|
||||||
|
"reserved_balance": billing_key.reserved_balance,
|
||||||
|
"total_spent": billing_key.total_spent,
|
||||||
|
"finalize_type": "overrun",
|
||||||
|
},
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
|
# Guard fired: reservation was already released by a concurrent
|
||||||
|
# finalization for this key. Nothing left to do.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to finalize additional charge - releasing reservation",
|
"Finalization skipped - reservation already released",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
@@ -914,7 +1054,6 @@ async def adjust_payment_for_tokens(
|
|||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await release_reservation_only()
|
|
||||||
else:
|
else:
|
||||||
# Refund some of the base cost
|
# Refund some of the base cost
|
||||||
refund = abs(cost_difference)
|
refund = abs(cost_difference)
|
||||||
@@ -929,12 +1068,33 @@ async def adjust_payment_for_tokens(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if billing_key.reserved_balance < deducted_max_cost:
|
||||||
|
logger.error(
|
||||||
|
"reserved_balance below deducted_max_cost on refund finalization — clamping to 0",
|
||||||
|
extra={
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
|
"reserved_balance": billing_key.reserved_balance,
|
||||||
|
"deducted_max_cost": deducted_max_cost,
|
||||||
|
"total_cost_msats": total_cost_msats,
|
||||||
|
"refund_amount": refund,
|
||||||
|
"balance": billing_key.balance,
|
||||||
|
"total_spent": billing_key.total_spent,
|
||||||
|
"model": model,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
refund_safe_reserved = case(
|
||||||
|
(col(ApiKey.reserved_balance) >= deducted_max_cost,
|
||||||
|
col(ApiKey.reserved_balance) - deducted_max_cost),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
|
||||||
refund_stmt = (
|
refund_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance)
|
reserved_balance=refund_safe_reserved,
|
||||||
- deducted_max_cost,
|
|
||||||
balance=col(ApiKey.balance) - total_cost_msats,
|
balance=col(ApiKey.balance) - total_cost_msats,
|
||||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||||
)
|
)
|
||||||
@@ -943,13 +1103,17 @@ async def adjust_payment_for_tokens(
|
|||||||
|
|
||||||
# Also update total_spent and reserved_balance on the child key if it's different
|
# Also update total_spent and reserved_balance on the child key if it's different
|
||||||
if billing_key.hashed_key != key.hashed_key:
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
child_refund_safe_reserved = case(
|
||||||
|
(col(ApiKey.reserved_balance) >= deducted_max_cost,
|
||||||
|
col(ApiKey.reserved_balance) - deducted_max_cost),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
child_stmt = (
|
child_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||||
reserved_balance=col(ApiKey.reserved_balance)
|
reserved_balance=child_refund_safe_reserved,
|
||||||
- deducted_max_cost,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.exec(child_stmt) # type: ignore[call-overload]
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
@@ -986,6 +1150,25 @@ async def adjust_payment_for_tokens(
|
|||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await _accumulate_fee(total_cost_msats)
|
||||||
|
payments_logger.info(
|
||||||
|
"FINALIZE",
|
||||||
|
extra={
|
||||||
|
"event": "finalize",
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
|
"model": model,
|
||||||
|
"cost_reserved": deducted_max_cost,
|
||||||
|
"cost_charged": total_cost_msats,
|
||||||
|
"refunded": refund,
|
||||||
|
"input_tokens": cost.input_tokens,
|
||||||
|
"output_tokens": cost.output_tokens,
|
||||||
|
"balance": billing_key.balance,
|
||||||
|
"reserved_balance": billing_key.reserved_balance,
|
||||||
|
"total_spent": billing_key.total_spent,
|
||||||
|
"finalize_type": "refund",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return cost.dict()
|
return cost.dict()
|
||||||
|
|
||||||
|
|||||||
+118
-11
@@ -7,10 +7,16 @@ from typing import Annotated, NoReturn
|
|||||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlmodel import select
|
from sqlmodel import col, select, update
|
||||||
|
|
||||||
from .auth import get_billing_key, validate_bearer_key
|
from .auth import get_billing_key, validate_bearer_key
|
||||||
from .core.db import ApiKey, AsyncSession, CashuTransaction, get_session
|
from .core.db import (
|
||||||
|
ApiKey,
|
||||||
|
AsyncSession,
|
||||||
|
CashuTransaction,
|
||||||
|
get_session,
|
||||||
|
store_cashu_transaction,
|
||||||
|
)
|
||||||
from .core.logging import get_logger
|
from .core.logging import get_logger
|
||||||
from .core.settings import settings
|
from .core.settings import settings
|
||||||
from .lightning import lightning_router
|
from .lightning import lightning_router
|
||||||
@@ -205,6 +211,26 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None:
|
|||||||
_refund_cache[key] = (expiry, value)
|
_refund_cache[key] = (expiry, value)
|
||||||
|
|
||||||
|
|
||||||
|
async def _restore_balance(
|
||||||
|
session: AsyncSession, hashed_key: str, balance: int, reserved_balance: int
|
||||||
|
) -> None:
|
||||||
|
"""Restore balance after a failed refund mint attempt."""
|
||||||
|
restore_stmt = (
|
||||||
|
update(ApiKey)
|
||||||
|
.where(col(ApiKey.hashed_key) == hashed_key)
|
||||||
|
.values(
|
||||||
|
balance=col(ApiKey.balance) + balance,
|
||||||
|
reserved_balance=col(ApiKey.reserved_balance) + reserved_balance,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.exec(restore_stmt) # type: ignore[call-overload]
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
"refund_wallet_endpoint: balance restored after mint failure",
|
||||||
|
extra={"hashed_key": hashed_key, "restored_balance": balance},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refund", response_model=None)
|
@router.post("/refund", response_model=None)
|
||||||
async def refund_wallet_endpoint(
|
async def refund_wallet_endpoint(
|
||||||
authorization: Annotated[str | None, Header()] = None,
|
authorization: Annotated[str | None, Header()] = None,
|
||||||
@@ -286,7 +312,31 @@ async def refund_wallet_endpoint(
|
|||||||
elif remaining_balance <= 0:
|
elif remaining_balance <= 0:
|
||||||
raise HTTPException(status_code=400, detail="No balance to refund")
|
raise HTTPException(status_code=400, detail="No balance to refund")
|
||||||
|
|
||||||
# Perform refund operation first, before modifying balance
|
# Capture values before debit — the session may refresh key after commit
|
||||||
|
pre_debit_balance = key.balance
|
||||||
|
pre_debit_reserved = key.reserved_balance
|
||||||
|
|
||||||
|
# --- DEBIT FIRST: atomically zero the balance before minting tokens ---
|
||||||
|
# This prevents the race where a concurrent topup/spend happens between
|
||||||
|
# reading the balance and minting the refund token (double-spend).
|
||||||
|
debit_stmt = (
|
||||||
|
update(ApiKey)
|
||||||
|
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||||
|
.where(col(ApiKey.balance) == pre_debit_balance)
|
||||||
|
.where(col(ApiKey.reserved_balance) == pre_debit_reserved)
|
||||||
|
.values(balance=0, reserved_balance=0)
|
||||||
|
)
|
||||||
|
debit_result = await session.exec(debit_stmt) # type: ignore[call-overload]
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
if debit_result.rowcount == 0:
|
||||||
|
# Balance changed between read and debit — another request is active
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Balance changed concurrently. Please retry the refund.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- MINT: balance is locked at zero, safe to create the refund token ---
|
||||||
try:
|
try:
|
||||||
if key.refund_address:
|
if key.refund_address:
|
||||||
from .core.settings import settings as global_settings
|
from .core.settings import settings as global_settings
|
||||||
@@ -310,11 +360,24 @@ async def refund_wallet_endpoint(
|
|||||||
else:
|
else:
|
||||||
result["msats"] = str(remaining_balance_msats)
|
result["msats"] = str(remaining_balance_msats)
|
||||||
|
|
||||||
|
if "token" in result:
|
||||||
|
logger.info(
|
||||||
|
"refund_wallet_endpoint: cashu token issued",
|
||||||
|
extra={
|
||||||
|
"path": "/v1/wallet/refund",
|
||||||
|
"token": result["token"],
|
||||||
|
"amount": remaining_balance,
|
||||||
|
"currency": key.refund_currency or "sat",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
# Re-raise HTTP exceptions (like 400 for balance too small)
|
# Minting failed — restore the debited balance
|
||||||
|
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved)
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If refund fails, don't modify the database
|
# Minting failed — restore the debited balance
|
||||||
|
await _restore_balance(session, key.hashed_key, pre_debit_balance, pre_debit_reserved)
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if (
|
if (
|
||||||
"mint" in error_msg.lower()
|
"mint" in error_msg.lower()
|
||||||
@@ -328,23 +391,67 @@ async def refund_wallet_endpoint(
|
|||||||
|
|
||||||
await _refund_cache_set(bearer_value, result)
|
await _refund_cache_set(bearer_value, result)
|
||||||
|
|
||||||
previous_reserved_balance = key.reserved_balance
|
if "token" in result:
|
||||||
key.balance = 0
|
try:
|
||||||
key.reserved_balance = 0
|
await store_cashu_transaction(
|
||||||
session.add(key)
|
token=result["token"],
|
||||||
await session.commit()
|
amount=remaining_balance,
|
||||||
|
unit=key.refund_currency or "sat",
|
||||||
|
mint_url=key.refund_mint_url,
|
||||||
|
typ="out",
|
||||||
|
collected=False,
|
||||||
|
source="apikey",
|
||||||
|
api_key_hashed_key=key.hashed_key,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # store_cashu_transaction already logs
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"refund_wallet_endpoint: refund successful",
|
"refund_wallet_endpoint: refund successful",
|
||||||
extra={
|
extra={
|
||||||
"refunded_msats": remaining_balance_msats,
|
"refunded_msats": remaining_balance_msats,
|
||||||
"previous_reserved_balance": previous_reserved_balance,
|
"previous_reserved_balance": key.reserved_balance,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def wallet_history(
|
||||||
|
key: ApiKey = Depends(get_key_from_header),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict[str, list[dict[str, str | int | bool | None]]]:
|
||||||
|
if key.parent_key_hash:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot view child key history. Please use the parent key instead.",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.exec(
|
||||||
|
select(CashuTransaction)
|
||||||
|
.where(CashuTransaction.api_key_hashed_key == key.hashed_key)
|
||||||
|
.order_by(col(CashuTransaction.created_at).desc())
|
||||||
|
)
|
||||||
|
transactions = result.all()
|
||||||
|
return {
|
||||||
|
"transactions": [
|
||||||
|
{
|
||||||
|
"id": tx.id,
|
||||||
|
"type": tx.type,
|
||||||
|
"source": tx.source,
|
||||||
|
"amount": tx.amount,
|
||||||
|
"unit": tx.unit,
|
||||||
|
"mint_url": tx.mint_url,
|
||||||
|
"created_at": tx.created_at,
|
||||||
|
"collected": tx.collected,
|
||||||
|
"swept": tx.swept,
|
||||||
|
}
|
||||||
|
for tx in transactions
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/donate")
|
@router.post("/donate")
|
||||||
async def donate(token: str, ref: str | None = None) -> str:
|
async def donate(token: str, ref: str | None = None) -> str:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+28
-10
@@ -295,6 +295,7 @@ class ModelCreate(BaseModel):
|
|||||||
canonical_slug: str | None = None
|
canonical_slug: str | None = None
|
||||||
alias_ids: list[str] | None = None
|
alias_ids: list[str] | None = None
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
forwarded_model_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post(
|
@admin_router.post(
|
||||||
@@ -339,6 +340,7 @@ async def upsert_provider_model(
|
|||||||
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
||||||
)
|
)
|
||||||
existing_row.enabled = payload.enabled
|
existing_row.enabled = payload.enabled
|
||||||
|
existing_row.forwarded_model_id = payload.forwarded_model_id or payload.id
|
||||||
|
|
||||||
session.add(existing_row)
|
session.add(existing_row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -371,6 +373,7 @@ async def upsert_provider_model(
|
|||||||
),
|
),
|
||||||
upstream_provider_id=provider_id,
|
upstream_provider_id=provider_id,
|
||||||
enabled=payload.enabled,
|
enabled=payload.enabled,
|
||||||
|
forwarded_model_id=payload.forwarded_model_id or payload.id,
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -1332,41 +1335,56 @@ async def get_transactions_api(
|
|||||||
type: str | None = None,
|
type: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
limit: int = 100,
|
source: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
from sqlmodel import col
|
from sqlmodel import col, func
|
||||||
|
|
||||||
stmt = select(CashuTransaction)
|
base = select(CashuTransaction)
|
||||||
if type:
|
if type:
|
||||||
stmt = stmt.where(CashuTransaction.type == type)
|
base = base.where(CashuTransaction.type == type)
|
||||||
|
if source:
|
||||||
|
if source == "x-cashu":
|
||||||
|
base = base.where(
|
||||||
|
(CashuTransaction.source == "x-cashu")
|
||||||
|
| (CashuTransaction.source == None) # noqa: E711
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
base = base.where(CashuTransaction.source == source)
|
||||||
if status:
|
if status:
|
||||||
if status == "collected":
|
if status == "collected":
|
||||||
stmt = stmt.where(CashuTransaction.collected == True) # noqa: E712
|
base = base.where(CashuTransaction.collected == True) # noqa: E712
|
||||||
elif status == "swept":
|
elif status == "swept":
|
||||||
stmt = stmt.where(CashuTransaction.swept == True) # noqa: E712
|
base = base.where(CashuTransaction.swept == True) # noqa: E712
|
||||||
elif status == "pending":
|
elif status == "pending":
|
||||||
stmt = stmt.where(
|
base = base.where(
|
||||||
CashuTransaction.collected == False, # noqa: E712
|
CashuTransaction.collected == False, # noqa: E712
|
||||||
CashuTransaction.swept == False, # noqa: E712
|
CashuTransaction.swept == False, # noqa: E712
|
||||||
)
|
)
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
search_pattern = f"%{search}%"
|
search_pattern = f"%{search}%"
|
||||||
stmt = stmt.where(
|
base = base.where(
|
||||||
(col(CashuTransaction.id).like(search_pattern))
|
(col(CashuTransaction.id).like(search_pattern))
|
||||||
| (col(CashuTransaction.token).like(search_pattern))
|
| (col(CashuTransaction.token).like(search_pattern))
|
||||||
| (col(CashuTransaction.request_id).like(search_pattern))
|
| (col(CashuTransaction.request_id).like(search_pattern))
|
||||||
|
| (col(CashuTransaction.api_key_hashed_key).like(search_pattern))
|
||||||
)
|
)
|
||||||
|
|
||||||
stmt = stmt.order_by(col(CashuTransaction.created_at).desc()).limit(limit)
|
count_result = await session.exec(
|
||||||
|
select(func.count()).select_from(base.subquery())
|
||||||
|
)
|
||||||
|
total = count_result.one()
|
||||||
|
|
||||||
|
stmt = base.order_by(col(CashuTransaction.created_at).desc()).offset(offset).limit(limit)
|
||||||
results = await session.exec(stmt)
|
results = await session.exec(stmt)
|
||||||
transactions = results.all()
|
transactions = results.all()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"transactions": [tx.dict() for tx in transactions],
|
"transactions": [tx.dict() for tx in transactions],
|
||||||
"total": len(transactions),
|
"total": total,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+75
-1
@@ -10,8 +10,9 @@ from alembic import command
|
|||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
from alembic.util.exc import CommandError
|
from alembic.util.exc import CommandError
|
||||||
from sqlalchemy import UniqueConstraint
|
from sqlalchemy import UniqueConstraint
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||||
from sqlmodel import Field, Relationship, SQLModel, func, select, update
|
from sqlmodel import Field, Relationship, SQLModel, col, func, select, update
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from .logging import get_logger
|
from .logging import get_logger
|
||||||
@@ -105,6 +106,10 @@ class ModelRow(SQLModel, table=True): # type: ignore
|
|||||||
default=None, description="JSON array of model alias IDs"
|
default=None, description="JSON array of model alias IDs"
|
||||||
)
|
)
|
||||||
enabled: bool = Field(default=True, description="Whether this model is enabled")
|
enabled: bool = Field(default=True, description="Whether this model is enabled")
|
||||||
|
forwarded_model_id: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Model ID to use when forwarding requests to upstream provider. Defaults to id if not set.",
|
||||||
|
)
|
||||||
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
|
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
|
||||||
|
|
||||||
|
|
||||||
@@ -150,6 +155,16 @@ class CashuTransaction(SQLModel, table=True): # type: ignore
|
|||||||
)
|
)
|
||||||
collected: bool = Field(default=False)
|
collected: bool = Field(default=False)
|
||||||
swept: bool = Field(default=False)
|
swept: bool = Field(default=False)
|
||||||
|
source: str = Field(
|
||||||
|
default="x-cashu",
|
||||||
|
description="Payment source: x-cashu or apikey",
|
||||||
|
)
|
||||||
|
api_key_hashed_key: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
foreign_key="api_keys.hashed_key",
|
||||||
|
index=True,
|
||||||
|
description="Associated API key hash for wallet history",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def store_cashu_transaction(
|
async def store_cashu_transaction(
|
||||||
@@ -161,6 +176,8 @@ async def store_cashu_transaction(
|
|||||||
request_id: str | None = None,
|
request_id: str | None = None,
|
||||||
collected: bool = False,
|
collected: bool = False,
|
||||||
created_at: int | None = None,
|
created_at: int | None = None,
|
||||||
|
source: str = "x-cashu",
|
||||||
|
api_key_hashed_key: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
@@ -173,6 +190,8 @@ async def store_cashu_transaction(
|
|||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
collected=collected,
|
collected=collected,
|
||||||
created_at=created_at or int(time.time()),
|
created_at=created_at or int(time.time()),
|
||||||
|
source=source,
|
||||||
|
api_key_hashed_key=api_key_hashed_key,
|
||||||
)
|
)
|
||||||
session.add(tx)
|
session.add(tx)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -212,6 +231,50 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RoutstrFee(SQLModel, table=True): # type: ignore
|
||||||
|
__tablename__ = "routstr_fees"
|
||||||
|
id: int = Field(default=1, primary_key=True)
|
||||||
|
accumulated_msats: int = Field(default=0)
|
||||||
|
total_paid_msats: int = Field(default=0)
|
||||||
|
last_paid_at: int | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
async def accumulate_routstr_fee(session: AsyncSession, amount_msats: int) -> None:
|
||||||
|
stmt = (
|
||||||
|
update(RoutstrFee)
|
||||||
|
.where(col(RoutstrFee.id) == 1)
|
||||||
|
.values(accumulated_msats=RoutstrFee.accumulated_msats + amount_msats)
|
||||||
|
)
|
||||||
|
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||||
|
if result.rowcount == 0:
|
||||||
|
session.add(RoutstrFee(id=1, accumulated_msats=amount_msats))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_routstr_fee(session: AsyncSession) -> RoutstrFee:
|
||||||
|
fee = await session.get(RoutstrFee, 1)
|
||||||
|
if fee is None:
|
||||||
|
fee = RoutstrFee(id=1, accumulated_msats=0, total_paid_msats=0)
|
||||||
|
session.add(fee)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(fee)
|
||||||
|
return fee
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_routstr_fee(session: AsyncSession, paid_msats: int) -> None:
|
||||||
|
stmt = (
|
||||||
|
update(RoutstrFee)
|
||||||
|
.where(col(RoutstrFee.id) == 1)
|
||||||
|
.values(
|
||||||
|
accumulated_msats=RoutstrFee.accumulated_msats - paid_msats,
|
||||||
|
total_paid_msats=RoutstrFee.total_paid_msats + paid_msats,
|
||||||
|
last_paid_at=int(time.time()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.exec(stmt) # type: ignore[call-overload]
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def balances_for_mint_and_unit(
|
async def balances_for_mint_and_unit(
|
||||||
db_session: AsyncSession, mint_url: str, unit: str
|
db_session: AsyncSession, mint_url: str, unit: str
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -327,6 +390,17 @@ def run_migrations() -> None:
|
|||||||
command.stamp(alembic_cfg, "head")
|
command.stamp(alembic_cfg, "head")
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
except OperationalError as e:
|
||||||
|
if "duplicate column name" in str(e).lower():
|
||||||
|
logger.warning(
|
||||||
|
"Migration hit a column that already exists (likely added via "
|
||||||
|
"create_all on another branch). Stamping to current head.",
|
||||||
|
extra={"error": str(e)},
|
||||||
|
)
|
||||||
|
_clear_alembic_version()
|
||||||
|
command.stamp(alembic_cfg, "head")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
logger.info("Database migrations completed successfully")
|
logger.info("Database migrations completed successfully")
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -22,7 +22,7 @@ from ..payment.models import models_router, update_sats_pricing
|
|||||||
from ..payment.price import update_prices_periodically
|
from ..payment.price import update_prices_periodically
|
||||||
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
||||||
from ..upstream.auto_topup import periodic_auto_topup
|
from ..upstream.auto_topup import periodic_auto_topup
|
||||||
from ..wallet import periodic_payout, periodic_refund_sweep
|
from ..wallet import periodic_payout, periodic_refund_sweep, periodic_routstr_fee_payout
|
||||||
from .admin import admin_router
|
from .admin import admin_router
|
||||||
from .db import create_session, init_db, run_migrations
|
from .db import create_session, init_db, run_migrations
|
||||||
from .exceptions import general_exception_handler, http_exception_handler
|
from .exceptions import general_exception_handler, http_exception_handler
|
||||||
@@ -36,9 +36,9 @@ setup_logging()
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
if os.getenv("VERSION_SUFFIX") is not None:
|
if os.getenv("VERSION_SUFFIX") is not None:
|
||||||
__version__ = f"0.4.1-{os.getenv('VERSION_SUFFIX')}"
|
__version__ = f"0.4.3-{os.getenv('VERSION_SUFFIX')}"
|
||||||
else:
|
else:
|
||||||
__version__ = "0.4.1"
|
__version__ = "0.4.3"
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -56,6 +56,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
key_reset_task = None
|
key_reset_task = None
|
||||||
auto_topup_task = None
|
auto_topup_task = None
|
||||||
refund_sweep_task = None
|
refund_sweep_task = None
|
||||||
|
routstr_fee_task = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run database migrations on startup
|
# Run database migrations on startup
|
||||||
@@ -102,8 +103,11 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
btc_price_task = asyncio.create_task(update_prices_periodically())
|
btc_price_task = asyncio.create_task(update_prices_periodically())
|
||||||
pricing_task = asyncio.create_task(update_sats_pricing())
|
pricing_task = asyncio.create_task(update_sats_pricing())
|
||||||
if global_settings.models_refresh_interval_seconds > 0:
|
if global_settings.models_refresh_interval_seconds > 0:
|
||||||
|
# Pass the accessor (not its current value) so the loop sees providers
|
||||||
|
# added/changed via reinitialize_upstreams() instead of staying pinned
|
||||||
|
# to the startup snapshot.
|
||||||
models_refresh_task = asyncio.create_task(
|
models_refresh_task = asyncio.create_task(
|
||||||
refresh_upstreams_models_periodically(get_upstreams())
|
refresh_upstreams_models_periodically(get_upstreams)
|
||||||
)
|
)
|
||||||
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
|
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
|
||||||
payout_task = asyncio.create_task(periodic_payout())
|
payout_task = asyncio.create_task(periodic_payout())
|
||||||
@@ -115,6 +119,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
key_reset_task = asyncio.create_task(periodic_key_reset())
|
key_reset_task = asyncio.create_task(periodic_key_reset())
|
||||||
auto_topup_task = asyncio.create_task(periodic_auto_topup())
|
auto_topup_task = asyncio.create_task(periodic_auto_topup())
|
||||||
refund_sweep_task = asyncio.create_task(periodic_refund_sweep())
|
refund_sweep_task = asyncio.create_task(periodic_refund_sweep())
|
||||||
|
routstr_fee_task = asyncio.create_task(periodic_routstr_fee_payout())
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -152,6 +157,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
auto_topup_task.cancel()
|
auto_topup_task.cancel()
|
||||||
if refund_sweep_task is not None:
|
if refund_sweep_task is not None:
|
||||||
refund_sweep_task.cancel()
|
refund_sweep_task.cancel()
|
||||||
|
if routstr_fee_task is not None:
|
||||||
|
routstr_fee_task.cancel()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tasks_to_wait = []
|
tasks_to_wait = []
|
||||||
@@ -177,6 +184,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
tasks_to_wait.append(auto_topup_task)
|
tasks_to_wait.append(auto_topup_task)
|
||||||
if refund_sweep_task is not None:
|
if refund_sweep_task is not None:
|
||||||
tasks_to_wait.append(refund_sweep_task)
|
tasks_to_wait.append(refund_sweep_task)
|
||||||
|
if routstr_fee_task is not None:
|
||||||
|
tasks_to_wait.append(routstr_fee_task)
|
||||||
|
|
||||||
if tasks_to_wait:
|
if tasks_to_wait:
|
||||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||||
|
|||||||
@@ -38,11 +38,6 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Extract request info
|
|
||||||
client_host = None
|
|
||||||
if request.client:
|
|
||||||
client_host = request.client.host
|
|
||||||
|
|
||||||
# Log incoming request
|
# Log incoming request
|
||||||
logger.info(
|
logger.info(
|
||||||
"Incoming request",
|
"Incoming request",
|
||||||
@@ -51,7 +46,6 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
"method": request.method,
|
"method": request.method,
|
||||||
"path": request.url.path,
|
"path": request.url.path,
|
||||||
"query_params": dict(request.query_params),
|
"query_params": dict(request.query_params),
|
||||||
"client_host": client_host,
|
|
||||||
"headers": {
|
"headers": {
|
||||||
k: v
|
k: v
|
||||||
for k, v in request.headers.items()
|
for k, v in request.headers.items()
|
||||||
@@ -100,7 +94,6 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
"path": request.url.path,
|
"path": request.url.path,
|
||||||
"status_code": response.status_code,
|
"status_code": response.status_code,
|
||||||
"duration_ms": round(duration * 1000, 2),
|
"duration_ms": round(duration * 1000, 2),
|
||||||
"client_host": client_host,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if hasattr(response, "headers"):
|
if hasattr(response, "headers"):
|
||||||
@@ -120,7 +113,6 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
"method": request.method,
|
"method": request.method,
|
||||||
"path": request.url.path,
|
"path": request.url.path,
|
||||||
"duration_ms": round(duration * 1000, 2),
|
"duration_ms": round(duration * 1000, 2),
|
||||||
"client_host": client_host,
|
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
"error_type": type(e).__name__,
|
"error_type": type(e).__name__,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class Settings(BaseSettings):
|
|||||||
enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH")
|
enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH")
|
||||||
enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH")
|
enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH")
|
||||||
refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS")
|
refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS")
|
||||||
refund_sweep_ttl_seconds: int = Field(default=86400, env="REFUND_SWEEP_TTL_SECONDS")
|
refund_sweep_ttl_seconds: int = Field(default=604800, env="REFUND_SWEEP_TTL_SECONDS")
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from pydantic.v1 import BaseModel
|
from pydantic.v1 import BaseModel
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from ..core.db import ModelRow, get_session
|
from ..core.db import ModelRow, UpstreamProviderRow, get_session
|
||||||
from ..core.logging import get_logger
|
from ..core.logging import get_logger
|
||||||
from ..core.settings import settings
|
from ..core.settings import settings
|
||||||
from .price import sats_usd_price
|
from .price import sats_usd_price
|
||||||
@@ -60,6 +60,7 @@ class Model(BaseModel):
|
|||||||
upstream_provider_id: int | str | None = None
|
upstream_provider_id: int | str | None = None
|
||||||
canonical_slug: str | None = None
|
canonical_slug: str | None = None
|
||||||
alias_ids: list[str] | None = None
|
alias_ids: list[str] | None = None
|
||||||
|
forwarded_model_id: str | None = None
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
return hash(self.id)
|
return hash(self.id)
|
||||||
@@ -177,6 +178,7 @@ def _row_to_model(
|
|||||||
upstream_provider_id=row.upstream_provider_id,
|
upstream_provider_id=row.upstream_provider_id,
|
||||||
canonical_slug=getattr(row, "canonical_slug", None),
|
canonical_slug=getattr(row, "canonical_slug", None),
|
||||||
alias_ids=json.loads(row.alias_ids) if row.alias_ids else None,
|
alias_ids=json.loads(row.alias_ids) if row.alias_ids else None,
|
||||||
|
forwarded_model_id=getattr(row, "forwarded_model_id", None) or row.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if apply_provider_fee:
|
if apply_provider_fee:
|
||||||
@@ -329,6 +331,7 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
|
|||||||
upstream_provider_id=model.upstream_provider_id,
|
upstream_provider_id=model.upstream_provider_id,
|
||||||
canonical_slug=model.canonical_slug,
|
canonical_slug=model.canonical_slug,
|
||||||
alias_ids=model.alias_ids,
|
alias_ids=model.alias_ids,
|
||||||
|
forwarded_model_id=model.forwarded_model_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -402,6 +405,76 @@ async def update_sats_pricing() -> None:
|
|||||||
logger.error(f"Error updating sats pricing: {e}")
|
logger.error(f"Error updating sats pricing: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class ModelTestRequest(BaseModel):
|
||||||
|
model_id: str
|
||||||
|
endpoint_type: str
|
||||||
|
request_data: dict
|
||||||
|
|
||||||
|
|
||||||
|
@models_router.post("/api/models/test")
|
||||||
|
async def test_model(
|
||||||
|
payload: ModelTestRequest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Test a model by sending a request through its configured upstream provider."""
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(ModelRow).where(ModelRow.id == payload.model_id)
|
||||||
|
)
|
||||||
|
model_row = result.scalars().first()
|
||||||
|
|
||||||
|
if not model_row:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Model '{payload.model_id}' not found in database",
|
||||||
|
"status_code": 404,
|
||||||
|
}
|
||||||
|
|
||||||
|
provider = await session.get(UpstreamProviderRow, model_row.upstream_provider_id)
|
||||||
|
if not provider:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Upstream provider not found",
|
||||||
|
"status_code": 404,
|
||||||
|
}
|
||||||
|
|
||||||
|
base_url = provider.base_url.rstrip("/")
|
||||||
|
if payload.endpoint_type == "chat-completions":
|
||||||
|
url = f"{base_url}/chat/completions"
|
||||||
|
else:
|
||||||
|
url = f"{base_url}/{payload.endpoint_type}"
|
||||||
|
|
||||||
|
actual_model_id = model_row.forwarded_model_id or model_row.id
|
||||||
|
request_data = dict(payload.request_data)
|
||||||
|
request_data["model"] = actual_model_id
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {provider.api_key}",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.post(url, json=request_data, headers=headers)
|
||||||
|
try:
|
||||||
|
response_data = response.json()
|
||||||
|
except Exception:
|
||||||
|
response_data = {"raw": response.text}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": response.status_code < 400,
|
||||||
|
"data": response_data,
|
||||||
|
"status_code": response.status_code,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"status_code": 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@models_router.get("/v1/models")
|
@models_router.get("/v1/models")
|
||||||
@models_router.get("/v1/models/", include_in_schema=False)
|
@models_router.get("/v1/models/", include_in_schema=False)
|
||||||
@models_router.get("/models")
|
@models_router.get("/models")
|
||||||
@@ -411,4 +484,10 @@ async def models(session: AsyncSession = Depends(get_session)) -> dict:
|
|||||||
from ..proxy import get_unique_models
|
from ..proxy import get_unique_models
|
||||||
|
|
||||||
items = get_unique_models()
|
items = get_unique_models()
|
||||||
return {"data": items}
|
data = []
|
||||||
|
for model in items:
|
||||||
|
m = model.dict()
|
||||||
|
if model.forwarded_model_id:
|
||||||
|
m["id"] = model.forwarded_model_id
|
||||||
|
data.append(m)
|
||||||
|
return {"data": data}
|
||||||
|
|||||||
+29
-4
@@ -69,7 +69,25 @@ def get_upstreams() -> list[BaseUpstreamProvider]:
|
|||||||
|
|
||||||
def get_model_instance(model_id: str) -> Model | None:
|
def get_model_instance(model_id: str) -> Model | None:
|
||||||
"""Get Model instance by ID from global cache."""
|
"""Get Model instance by ID from global cache."""
|
||||||
return _model_instances.get(model_id.lower())
|
if not model_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
model_id_lower = model_id.lower()
|
||||||
|
# Try exact match first
|
||||||
|
if model := _model_instances.get(model_id_lower):
|
||||||
|
return model
|
||||||
|
|
||||||
|
# Try stripping common version suffixes (e.g., -20251222)
|
||||||
|
# This handles cases where upstream returns a specific version
|
||||||
|
# but we only track the base model name.
|
||||||
|
import re
|
||||||
|
|
||||||
|
base_model_id = re.sub(r"-\d{8}$", "", model_id_lower)
|
||||||
|
if base_model_id != model_id_lower:
|
||||||
|
if model := _model_instances.get(base_model_id):
|
||||||
|
return model
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_provider_for_model(model_id: str) -> list[BaseUpstreamProvider] | None:
|
def get_provider_for_model(model_id: str) -> list[BaseUpstreamProvider] | None:
|
||||||
@@ -207,7 +225,7 @@ async def proxy(
|
|||||||
|
|
||||||
elif auth := headers.get("authorization", None):
|
elif auth := headers.get("authorization", None):
|
||||||
key = await get_bearer_token_key(
|
key = await get_bearer_token_key(
|
||||||
headers, path, session, auth, max_cost_for_model
|
headers, path, session, auth, max_cost_for_model, model_id
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -387,7 +405,12 @@ async def proxy(
|
|||||||
|
|
||||||
|
|
||||||
async def get_bearer_token_key(
|
async def get_bearer_token_key(
|
||||||
headers: dict, path: str, session: AsyncSession, auth: str, min_cost: int = 0
|
headers: dict,
|
||||||
|
path: str,
|
||||||
|
session: AsyncSession,
|
||||||
|
auth: str,
|
||||||
|
min_cost: int = 0,
|
||||||
|
model_id: str = "unknown",
|
||||||
) -> ApiKey:
|
) -> ApiKey:
|
||||||
"""Handle bearer token authentication proxy requests."""
|
"""Handle bearer token authentication proxy requests."""
|
||||||
parts = auth.split()
|
parts = auth.split()
|
||||||
@@ -457,11 +480,13 @@ async def get_bearer_token_key(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
key_preview = bearer_key[:20] + "..." if len(bearer_key) > 20 else bearer_key
|
key_preview = bearer_key[:20] + "..." if len(bearer_key) > 20 else bearer_key
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Bearer token validation failed: {type(e).__name__}: {e} path={path} key={key_preview!r}",
|
f"Bearer token validation failed: {type(e).__name__}: {e} path={path} model={model_id!r} min_cost={min_cost} key={key_preview!r}",
|
||||||
extra={
|
extra={
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
"error_type": type(e).__name__,
|
"error_type": type(e).__name__,
|
||||||
"path": path,
|
"path": path,
|
||||||
|
"model_id": model_id,
|
||||||
|
"min_cost_msat": min_cost,
|
||||||
"bearer_key_preview": key_preview,
|
"bearer_key_preview": key_preview,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+338
-111
@@ -5,6 +5,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
|
import uuid
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
|
|
||||||
@@ -118,6 +119,51 @@ class BaseUpstreamProvider:
|
|||||||
"can_show_balance": False,
|
"can_show_balance": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def inject_cost_metadata(
|
||||||
|
self,
|
||||||
|
response_json: dict,
|
||||||
|
cost_data: CostData | MaxCostData | dict,
|
||||||
|
key: ApiKey,
|
||||||
|
) -> None:
|
||||||
|
"""Unifies the injection of cost and usage metadata across all completion types."""
|
||||||
|
if isinstance(cost_data, dict):
|
||||||
|
total_msats = cost_data.get("total_msats", 0)
|
||||||
|
total_usd = cost_data.get("total_usd", 0.0)
|
||||||
|
cost_dict = cost_data
|
||||||
|
else:
|
||||||
|
total_msats = cost_data.total_msats
|
||||||
|
total_usd = cost_data.total_usd
|
||||||
|
cost_dict = cost_data.dict()
|
||||||
|
|
||||||
|
sats_cost = total_msats // 1000
|
||||||
|
|
||||||
|
# Inject into top-level usage block (OpenAI/Anthropic style)
|
||||||
|
if "usage" in response_json:
|
||||||
|
response_json["usage"]["cost"] = total_usd
|
||||||
|
response_json["usage"]["cost_sats"] = sats_cost
|
||||||
|
response_json["usage"]["remaining_balance_msats"] = key.balance
|
||||||
|
|
||||||
|
# Inject into Anthropic nested usage block if present
|
||||||
|
if (
|
||||||
|
"message" in response_json
|
||||||
|
and isinstance(response_json["message"], dict)
|
||||||
|
and "usage" in response_json["message"]
|
||||||
|
):
|
||||||
|
response_json["message"]["usage"]["sats_cost"] = sats_cost
|
||||||
|
|
||||||
|
# Unified Routstr metadata
|
||||||
|
response_json["metadata"] = response_json.get("metadata", {})
|
||||||
|
response_json["metadata"]["routstr"] = {
|
||||||
|
"cost": cost_dict,
|
||||||
|
"sats_cost": sats_cost,
|
||||||
|
"remaining_balance_msats": key.balance,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Legacy/Compatibility fields
|
||||||
|
response_json["cost"] = cost_dict.copy()
|
||||||
|
response_json["cost"]["sats_cost"] = sats_cost
|
||||||
|
response_json["cost"]["remaining_balance_msats"] = key.balance
|
||||||
|
|
||||||
def prepare_headers(self, request_headers: dict) -> dict:
|
def prepare_headers(self, request_headers: dict) -> dict:
|
||||||
"""Prepare headers for upstream request by removing proxy-specific headers and adding authentication.
|
"""Prepare headers for upstream request by removing proxy-specific headers and adding authentication.
|
||||||
|
|
||||||
@@ -375,75 +421,83 @@ class BaseUpstreamProvider:
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def map_upstream_error_response(
|
async def forward_upstream_error_response(
|
||||||
self, request: Request, path: str, upstream_response: httpx.Response
|
self, request: Request, path: str, upstream_response: httpx.Response
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Map upstream error responses to appropriate proxy error responses.
|
"""Log upstream errors and forward the upstream response unchanged."""
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Original FastAPI request
|
|
||||||
path: Request path
|
|
||||||
upstream_response: Response from upstream service
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Mapped error response with appropriate status code and error type
|
|
||||||
"""
|
|
||||||
status_code = upstream_response.status_code
|
status_code = upstream_response.status_code
|
||||||
headers = dict(upstream_response.headers)
|
headers = dict(upstream_response.headers)
|
||||||
content_type = headers.get("content-type", "")
|
content_type = headers.get("content-type") or headers.get("Content-Type", "")
|
||||||
|
upstream_request_id = (
|
||||||
|
headers.get("request-id")
|
||||||
|
or headers.get("Request-Id")
|
||||||
|
or headers.get("x-request-id")
|
||||||
|
or headers.get("X-Request-Id")
|
||||||
|
or headers.get("anthropic-request-id")
|
||||||
|
or headers.get("openai-request-id")
|
||||||
|
)
|
||||||
|
|
||||||
|
body_read_error = None
|
||||||
try:
|
try:
|
||||||
body_bytes = await upstream_response.aread()
|
body_bytes = await upstream_response.aread()
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
body_bytes = b""
|
body_bytes = b""
|
||||||
|
body_read_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
|
||||||
message, upstream_code = self._extract_upstream_error_message(body_bytes)
|
message, upstream_code = self._extract_upstream_error_message(body_bytes)
|
||||||
lowered_message = message.lower()
|
body_preview = body_bytes.decode("utf-8", errors="ignore").strip()[:500]
|
||||||
lowered_code = (upstream_code or "").lower()
|
|
||||||
|
|
||||||
error_type = "upstream_error"
|
logger.warning(
|
||||||
mapped_status = 502
|
"Forwarding upstream error response as-is",
|
||||||
|
|
||||||
if status_code in (400, 422):
|
|
||||||
error_type = "invalid_request_error"
|
|
||||||
mapped_status = 400
|
|
||||||
elif status_code in (401, 403):
|
|
||||||
error_type = "upstream_auth_error"
|
|
||||||
mapped_status = 502
|
|
||||||
elif status_code == 404:
|
|
||||||
if path.endswith("chat/completions"):
|
|
||||||
error_type = "invalid_model"
|
|
||||||
mapped_status = 400
|
|
||||||
if not message or message == "Upstream request failed":
|
|
||||||
message = "Requested model is not available upstream"
|
|
||||||
elif "model" in lowered_message or "model" in lowered_code:
|
|
||||||
error_type = "invalid_model"
|
|
||||||
mapped_status = 400
|
|
||||||
if not message or message == "Upstream request failed":
|
|
||||||
message = "Requested model is not available upstream"
|
|
||||||
else:
|
|
||||||
error_type = "upstream_error"
|
|
||||||
mapped_status = 502
|
|
||||||
elif status_code == 429:
|
|
||||||
error_type = "rate_limit_exceeded"
|
|
||||||
mapped_status = 429
|
|
||||||
elif status_code >= 500:
|
|
||||||
error_type = "upstream_error"
|
|
||||||
mapped_status = 502
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"Mapped upstream error",
|
|
||||||
extra={
|
extra={
|
||||||
"path": path,
|
"path": path,
|
||||||
|
"provider": self.provider_type,
|
||||||
"upstream_status": status_code,
|
"upstream_status": status_code,
|
||||||
"mapped_status": mapped_status,
|
"upstream_code": upstream_code,
|
||||||
"error_type": error_type,
|
|
||||||
"upstream_content_type": content_type,
|
"upstream_content_type": content_type,
|
||||||
|
"upstream_request_id": upstream_request_id,
|
||||||
"message_preview": message[:200],
|
"message_preview": message[:200],
|
||||||
|
"body_preview": body_preview,
|
||||||
|
"body_read_error": body_read_error,
|
||||||
|
"method": request.method,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return create_error_response(
|
for header_name in (
|
||||||
error_type, message, mapped_status, request=request
|
"content-length",
|
||||||
|
"Content-Length",
|
||||||
|
"transfer-encoding",
|
||||||
|
"Transfer-Encoding",
|
||||||
|
"content-encoding",
|
||||||
|
"Content-Encoding",
|
||||||
|
"connection",
|
||||||
|
"Connection",
|
||||||
|
"keep-alive",
|
||||||
|
"Keep-Alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"Proxy-Authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"Proxy-Authorization",
|
||||||
|
"te",
|
||||||
|
"TE",
|
||||||
|
"trailer",
|
||||||
|
"Trailer",
|
||||||
|
"upgrade",
|
||||||
|
"Upgrade",
|
||||||
|
):
|
||||||
|
headers.pop(header_name, None)
|
||||||
|
|
||||||
|
if not content_type:
|
||||||
|
headers.pop("content-type", None)
|
||||||
|
headers.pop("Content-Type", None)
|
||||||
|
|
||||||
|
media_type = content_type or None
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=body_bytes,
|
||||||
|
status_code=status_code,
|
||||||
|
headers=headers,
|
||||||
|
media_type=media_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def handle_streaming_chat_completion(
|
async def handle_streaming_chat_completion(
|
||||||
@@ -517,20 +571,32 @@ class BaseUpstreamProvider:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
obj = json.loads(part)
|
# Only parse if it looks like a JSON object to avoid SSE control messages or partials
|
||||||
if isinstance(obj, dict):
|
if part.strip().startswith(b"{") and part.strip().endswith(
|
||||||
if obj.get("model"):
|
b"}"
|
||||||
last_model_seen = str(obj.get("model"))
|
):
|
||||||
if requested_model:
|
obj = json.loads(part)
|
||||||
obj["model"] = requested_model
|
if isinstance(obj, dict):
|
||||||
|
if obj.get("model"):
|
||||||
if isinstance(obj.get("usage"), dict):
|
last_model_seen = str(obj.get("model"))
|
||||||
# Hold this chunk back to merge cost later
|
if requested_model:
|
||||||
usage_chunk_data = obj
|
obj["model"] = requested_model
|
||||||
|
if (
|
||||||
|
"id" not in obj
|
||||||
|
or not isinstance(obj["id"], str)
|
||||||
|
or obj["id"] == "existing-id"
|
||||||
|
):
|
||||||
|
if not hasattr(self, "_current_stream_id"):
|
||||||
|
self._current_stream_id = (
|
||||||
|
f"chatcmpl-{uuid.uuid4()}"
|
||||||
|
)
|
||||||
|
obj["id"] = self._current_stream_id
|
||||||
|
if isinstance(obj.get("usage"), dict):
|
||||||
|
usage_chunk_data = obj
|
||||||
|
continue
|
||||||
|
yield b"data: " + json.dumps(obj).encode() + b"\n\n"
|
||||||
continue
|
continue
|
||||||
yield b"data: " + json.dumps(obj).encode() + b"\n\n"
|
except Exception:
|
||||||
continue
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
prefix = (
|
prefix = (
|
||||||
@@ -661,6 +727,11 @@ class BaseUpstreamProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if requested_model:
|
||||||
|
response_json["model"] = requested_model
|
||||||
|
if "id" not in response_json or not isinstance(response_json["id"], str):
|
||||||
|
response_json["id"] = f"chatcmpl-{uuid.uuid4()}"
|
||||||
|
|
||||||
cost_data = await adjust_payment_for_tokens(
|
cost_data = await adjust_payment_for_tokens(
|
||||||
key, response_json, session, deducted_max_cost
|
key, response_json, session, deducted_max_cost
|
||||||
)
|
)
|
||||||
@@ -674,7 +745,9 @@ class BaseUpstreamProvider:
|
|||||||
response_json["usage"]["cost_sats"] = (
|
response_json["usage"]["cost_sats"] = (
|
||||||
cost_data.get("total_msats", 0) // 1000
|
cost_data.get("total_msats", 0) // 1000
|
||||||
)
|
)
|
||||||
response_json["usage"]["remaining_balance_msats"] = remaining_balance_msats
|
response_json["usage"]["remaining_balance_msats"] = (
|
||||||
|
remaining_balance_msats
|
||||||
|
)
|
||||||
|
|
||||||
# Keep detailed cost
|
# Keep detailed cost
|
||||||
response_json["metadata"] = response_json.get("metadata", {})
|
response_json["metadata"] = response_json.get("metadata", {})
|
||||||
@@ -750,7 +823,11 @@ class BaseUpstreamProvider:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
async def handle_streaming_responses_completion(
|
async def handle_streaming_responses_completion(
|
||||||
self, response: httpx.Response, key: ApiKey, max_cost_for_model: int, requested_model: str | None = None
|
self,
|
||||||
|
response: httpx.Response,
|
||||||
|
key: ApiKey,
|
||||||
|
max_cost_for_model: int,
|
||||||
|
requested_model: str | None = None,
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""Handle streaming Responses API responses with token usage tracking and cost adjustment.
|
"""Handle streaming Responses API responses with token usage tracking and cost adjustment.
|
||||||
|
|
||||||
@@ -981,6 +1058,11 @@ class BaseUpstreamProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if requested_model:
|
||||||
|
response_json["model"] = requested_model
|
||||||
|
if "id" not in response_json or not isinstance(response_json["id"], str):
|
||||||
|
response_json["id"] = f"chatcmpl-{uuid.uuid4()}"
|
||||||
|
|
||||||
cost_data = await adjust_payment_for_tokens(
|
cost_data = await adjust_payment_for_tokens(
|
||||||
key, response_json, session, deducted_max_cost
|
key, response_json, session, deducted_max_cost
|
||||||
)
|
)
|
||||||
@@ -994,7 +1076,9 @@ class BaseUpstreamProvider:
|
|||||||
response_json["usage"]["cost_sats"] = (
|
response_json["usage"]["cost_sats"] = (
|
||||||
cost_data.get("total_msats", 0) // 1000
|
cost_data.get("total_msats", 0) // 1000
|
||||||
)
|
)
|
||||||
response_json["usage"]["remaining_balance_msats"] = remaining_balance_msats
|
response_json["usage"]["remaining_balance_msats"] = (
|
||||||
|
remaining_balance_msats
|
||||||
|
)
|
||||||
|
|
||||||
# Keep detailed cost
|
# Keep detailed cost
|
||||||
response_json["metadata"] = response_json.get("metadata", {})
|
response_json["metadata"] = response_json.get("metadata", {})
|
||||||
@@ -1108,7 +1192,11 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def handle_streaming_messages_completion(
|
async def handle_streaming_messages_completion(
|
||||||
self, response: httpx.Response, key: ApiKey, max_cost_for_model: int
|
self,
|
||||||
|
response: httpx.Response,
|
||||||
|
key: ApiKey,
|
||||||
|
max_cost_for_model: int,
|
||||||
|
requested_model: str | None = None,
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
async def stream_with_cost(
|
async def stream_with_cost(
|
||||||
max_cost_for_model: int,
|
max_cost_for_model: int,
|
||||||
@@ -1147,6 +1235,8 @@ class BaseUpstreamProvider:
|
|||||||
stored_chunks.append(chunk)
|
stored_chunks.append(chunk)
|
||||||
try:
|
try:
|
||||||
decoded_chunk = chunk.decode("utf-8", errors="ignore")
|
decoded_chunk = chunk.decode("utf-8", errors="ignore")
|
||||||
|
modified_lines = []
|
||||||
|
changed = False
|
||||||
for line in decoded_chunk.split("\n"):
|
for line in decoded_chunk.split("\n"):
|
||||||
if line.startswith("data: "):
|
if line.startswith("data: "):
|
||||||
try:
|
try:
|
||||||
@@ -1156,6 +1246,20 @@ class BaseUpstreamProvider:
|
|||||||
if msg and msg.get("model"):
|
if msg and msg.get("model"):
|
||||||
last_model_seen = str(msg.get("model"))
|
last_model_seen = str(msg.get("model"))
|
||||||
|
|
||||||
|
if requested_model:
|
||||||
|
# Apply requested_model override
|
||||||
|
model_updated = False
|
||||||
|
if msg:
|
||||||
|
msg["model"] = requested_model
|
||||||
|
model_updated = True
|
||||||
|
if data.get("model"):
|
||||||
|
data["model"] = requested_model
|
||||||
|
model_updated = True
|
||||||
|
|
||||||
|
if model_updated:
|
||||||
|
line = "data: " + json.dumps(data)
|
||||||
|
changed = True
|
||||||
|
|
||||||
if usage := msg.get("usage"):
|
if usage := msg.get("usage"):
|
||||||
input_tokens += usage.get("input_tokens", 0)
|
input_tokens += usage.get("input_tokens", 0)
|
||||||
output_tokens += usage.get(
|
output_tokens += usage.get(
|
||||||
@@ -1169,10 +1273,14 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
modified_lines.append(line)
|
||||||
pass
|
|
||||||
|
|
||||||
yield chunk
|
if changed:
|
||||||
|
yield "\n".join(modified_lines).encode("utf-8")
|
||||||
|
else:
|
||||||
|
yield chunk
|
||||||
|
except Exception:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
usage_data = {
|
usage_data = {
|
||||||
"input_tokens": input_tokens,
|
"input_tokens": input_tokens,
|
||||||
@@ -1194,6 +1302,11 @@ class BaseUpstreamProvider:
|
|||||||
new_session,
|
new_session,
|
||||||
max_cost_for_model,
|
max_cost_for_model,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.inject_cost_metadata(
|
||||||
|
combined_data, cost_data, fresh_key
|
||||||
|
)
|
||||||
|
|
||||||
usage_finalized = True
|
usage_finalized = True
|
||||||
yield f"event: cost\ndata: {json.dumps({'cost': cost_data})}\n\n".encode()
|
yield f"event: cost\ndata: {json.dumps({'cost': cost_data})}\n\n".encode()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1233,11 +1346,22 @@ class BaseUpstreamProvider:
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
deducted_max_cost: int,
|
deducted_max_cost: int,
|
||||||
path: str,
|
path: str,
|
||||||
|
requested_model: str | None = None,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
try:
|
try:
|
||||||
content = await response.aread()
|
content = await response.aread()
|
||||||
response_json = json.loads(content)
|
response_json = json.loads(content)
|
||||||
|
|
||||||
|
if requested_model:
|
||||||
|
if "model" in response_json:
|
||||||
|
response_json["model"] = requested_model
|
||||||
|
if (
|
||||||
|
"message" in response_json
|
||||||
|
and isinstance(response_json["message"], dict)
|
||||||
|
and "model" in response_json["message"]
|
||||||
|
):
|
||||||
|
response_json["message"]["model"] = requested_model
|
||||||
|
|
||||||
if path.endswith("count_tokens") and "usage" not in response_json:
|
if path.endswith("count_tokens") and "usage" not in response_json:
|
||||||
input_tokens = response_json.get("input_tokens", 0)
|
input_tokens = response_json.get("input_tokens", 0)
|
||||||
response_json["usage"] = {"input_tokens": input_tokens}
|
response_json["usage"] = {"input_tokens": input_tokens}
|
||||||
@@ -1245,7 +1369,8 @@ class BaseUpstreamProvider:
|
|||||||
cost_data = await adjust_payment_for_tokens(
|
cost_data = await adjust_payment_for_tokens(
|
||||||
key, response_json, session, deducted_max_cost
|
key, response_json, session, deducted_max_cost
|
||||||
)
|
)
|
||||||
response_json["cost"] = cost_data
|
|
||||||
|
self.inject_cost_metadata(response_json, cost_data, key)
|
||||||
|
|
||||||
allowed_headers = {
|
allowed_headers = {
|
||||||
"content-type",
|
"content-type",
|
||||||
@@ -1303,7 +1428,9 @@ class BaseUpstreamProvider:
|
|||||||
path = self.normalize_request_path(path, model_obj)
|
path = self.normalize_request_path(path, model_obj)
|
||||||
url = self.build_request_url(path, model_obj)
|
url = self.build_request_url(path, model_obj)
|
||||||
|
|
||||||
original_model_id = model_obj.id if model_obj else None
|
original_model_id = (
|
||||||
|
(model_obj.forwarded_model_id or model_obj.id) if model_obj else None
|
||||||
|
)
|
||||||
|
|
||||||
transformed_body = self.prepare_request_body(request_body, model_obj)
|
transformed_body = self.prepare_request_body(request_body, model_obj)
|
||||||
|
|
||||||
@@ -1348,15 +1475,28 @@ class BaseUpstreamProvider:
|
|||||||
stream=True,
|
stream=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
if response.status_code != 200:
|
||||||
"Received upstream response",
|
logger.error(
|
||||||
extra={
|
"Received upstream response",
|
||||||
"status_code": response.status_code,
|
extra={
|
||||||
"path": path,
|
"reason_phrase": response.reason_phrase,
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"status_code": response.status_code,
|
||||||
"content_type": response.headers.get("content-type", "unknown"),
|
"path": path,
|
||||||
},
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
)
|
"content_type": response.headers.get("content-type", "unknown"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Received upstream response",
|
||||||
|
extra={
|
||||||
|
"reason_phrase": response.reason_phrase,
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"path": path,
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"content_type": response.headers.get("content-type", "unknown"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
if response.status_code >= 500:
|
if response.status_code >= 500:
|
||||||
@@ -1368,7 +1508,7 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mapped_error = await self.map_upstream_error_response(
|
mapped_error = await self.forward_upstream_error_response(
|
||||||
request, path, response
|
request, path, response
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
@@ -1397,7 +1537,10 @@ class BaseUpstreamProvider:
|
|||||||
|
|
||||||
if is_streaming and response.status_code == 200:
|
if is_streaming and response.status_code == 200:
|
||||||
result = await self.handle_streaming_messages_completion(
|
result = await self.handle_streaming_messages_completion(
|
||||||
response, key, max_cost_for_model
|
response,
|
||||||
|
key,
|
||||||
|
max_cost_for_model,
|
||||||
|
requested_model=original_model_id,
|
||||||
)
|
)
|
||||||
background_tasks = BackgroundTasks()
|
background_tasks = BackgroundTasks()
|
||||||
background_tasks.add_task(response.aclose)
|
background_tasks.add_task(response.aclose)
|
||||||
@@ -1408,7 +1551,12 @@ class BaseUpstreamProvider:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
try:
|
try:
|
||||||
return await self.handle_non_streaming_messages_completion(
|
return await self.handle_non_streaming_messages_completion(
|
||||||
response, key, session, max_cost_for_model, path
|
response,
|
||||||
|
key,
|
||||||
|
session,
|
||||||
|
max_cost_for_model,
|
||||||
|
path,
|
||||||
|
requested_model=original_model_id,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await response.aclose()
|
await response.aclose()
|
||||||
@@ -1418,7 +1566,12 @@ class BaseUpstreamProvider:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
try:
|
try:
|
||||||
return await self.handle_non_streaming_messages_completion(
|
return await self.handle_non_streaming_messages_completion(
|
||||||
response, key, session, max_cost_for_model, path
|
response,
|
||||||
|
key,
|
||||||
|
session,
|
||||||
|
max_cost_for_model,
|
||||||
|
path,
|
||||||
|
requested_model=original_model_id,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await response.aclose()
|
await response.aclose()
|
||||||
@@ -1463,7 +1616,10 @@ class BaseUpstreamProvider:
|
|||||||
background_tasks.add_task(response.aclose)
|
background_tasks.add_task(response.aclose)
|
||||||
background_tasks.add_task(client.aclose)
|
background_tasks.add_task(client.aclose)
|
||||||
result = await self.handle_streaming_chat_completion(
|
result = await self.handle_streaming_chat_completion(
|
||||||
response, key, max_cost_for_model, background_tasks,
|
response,
|
||||||
|
key,
|
||||||
|
max_cost_for_model,
|
||||||
|
background_tasks,
|
||||||
requested_model=original_model_id,
|
requested_model=original_model_id,
|
||||||
)
|
)
|
||||||
result.background = background_tasks
|
result.background = background_tasks
|
||||||
@@ -1473,7 +1629,10 @@ class BaseUpstreamProvider:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
try:
|
try:
|
||||||
return await self.handle_non_streaming_chat_completion(
|
return await self.handle_non_streaming_chat_completion(
|
||||||
response, key, session, max_cost_for_model,
|
response,
|
||||||
|
key,
|
||||||
|
session,
|
||||||
|
max_cost_for_model,
|
||||||
requested_model=original_model_id,
|
requested_model=original_model_id,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
@@ -1589,7 +1748,9 @@ class BaseUpstreamProvider:
|
|||||||
path = self.normalize_request_path(path, model_obj)
|
path = self.normalize_request_path(path, model_obj)
|
||||||
url = self.build_request_url(path, model_obj)
|
url = self.build_request_url(path, model_obj)
|
||||||
|
|
||||||
original_model_id = model_obj.id if model_obj else None
|
original_model_id = (
|
||||||
|
(model_obj.forwarded_model_id or model_obj.id) if model_obj else None
|
||||||
|
)
|
||||||
|
|
||||||
transformed_body = self.prepare_responses_request_body(request_body, model_obj)
|
transformed_body = self.prepare_responses_request_body(request_body, model_obj)
|
||||||
|
|
||||||
@@ -1654,7 +1815,7 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mapped_error = await self.map_upstream_error_response(
|
mapped_error = await self.forward_upstream_error_response(
|
||||||
request, path, response
|
request, path, response
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
@@ -1677,7 +1838,9 @@ class BaseUpstreamProvider:
|
|||||||
|
|
||||||
if is_streaming and response.status_code == 200:
|
if is_streaming and response.status_code == 200:
|
||||||
result = await self.handle_streaming_responses_completion(
|
result = await self.handle_streaming_responses_completion(
|
||||||
response, key, max_cost_for_model,
|
response,
|
||||||
|
key,
|
||||||
|
max_cost_for_model,
|
||||||
requested_model=original_model_id,
|
requested_model=original_model_id,
|
||||||
)
|
)
|
||||||
background_tasks = BackgroundTasks()
|
background_tasks = BackgroundTasks()
|
||||||
@@ -1689,7 +1852,10 @@ class BaseUpstreamProvider:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
try:
|
try:
|
||||||
return await self.handle_non_streaming_responses_completion(
|
return await self.handle_non_streaming_responses_completion(
|
||||||
response, key, session, max_cost_for_model,
|
response,
|
||||||
|
key,
|
||||||
|
session,
|
||||||
|
max_cost_for_model,
|
||||||
requested_model=original_model_id,
|
requested_model=original_model_id,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
@@ -1821,7 +1987,7 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
try:
|
try:
|
||||||
mapped = await self.map_upstream_error_response(
|
mapped = await self.forward_upstream_error_response(
|
||||||
request, path, response
|
request, path, response
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
@@ -2116,7 +2282,10 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
refund_token = await self.send_refund(
|
refund_token = await self.send_refund(
|
||||||
refund_amount, unit, mint, payment_token_hash,
|
refund_amount,
|
||||||
|
unit,
|
||||||
|
mint,
|
||||||
|
payment_token_hash,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
)
|
)
|
||||||
response_headers["X-Cashu"] = refund_token
|
response_headers["X-Cashu"] = refund_token
|
||||||
@@ -2152,6 +2321,19 @@ class BaseUpstreamProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if cost_data:
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.startswith("data: "):
|
||||||
|
try:
|
||||||
|
data_json = json.loads(line[6:])
|
||||||
|
if "usage" in data_json and data_json["usage"]:
|
||||||
|
data_json["usage"]["cost_sats"] = (
|
||||||
|
cost_data.total_msats // 1000
|
||||||
|
)
|
||||||
|
lines[i] = "data: " + json.dumps(data_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
async def generate() -> AsyncGenerator[bytes, None]:
|
async def generate() -> AsyncGenerator[bytes, None]:
|
||||||
for line in lines:
|
for line in lines:
|
||||||
yield (line + "\n").encode("utf-8")
|
yield (line + "\n").encode("utf-8")
|
||||||
@@ -2196,6 +2378,9 @@ class BaseUpstreamProvider:
|
|||||||
response_json = json.loads(content_str)
|
response_json = json.loads(content_str)
|
||||||
cost_data = await self.get_x_cashu_cost(response_json, max_cost_for_model)
|
cost_data = await self.get_x_cashu_cost(response_json, max_cost_for_model)
|
||||||
|
|
||||||
|
if cost_data and "usage" in response_json:
|
||||||
|
response_json["usage"]["cost_sats"] = cost_data.total_msats // 1000
|
||||||
|
|
||||||
if not cost_data:
|
if not cost_data:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to calculate cost for response",
|
"Failed to calculate cost for response",
|
||||||
@@ -2245,7 +2430,10 @@ class BaseUpstreamProvider:
|
|||||||
|
|
||||||
if refund_amount > 0:
|
if refund_amount > 0:
|
||||||
refund_token = await self.send_refund(
|
refund_token = await self.send_refund(
|
||||||
refund_amount, unit, mint, payment_token_hash,
|
refund_amount,
|
||||||
|
unit,
|
||||||
|
mint,
|
||||||
|
payment_token_hash,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
)
|
)
|
||||||
response_headers["X-Cashu"] = refund_token
|
response_headers["X-Cashu"] = refund_token
|
||||||
@@ -2262,7 +2450,7 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
content=content_str,
|
content=json.dumps(response_json),
|
||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
headers=response_headers,
|
headers=response_headers,
|
||||||
media_type="application/json",
|
media_type="application/json",
|
||||||
@@ -2300,7 +2488,6 @@ class BaseUpstreamProvider:
|
|||||||
extra={
|
extra={
|
||||||
"original_amount": amount,
|
"original_amount": amount,
|
||||||
"refund_amount": emergency_refund,
|
"refund_amount": emergency_refund,
|
||||||
"deduction": 60,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2454,14 +2641,25 @@ class BaseUpstreamProvider:
|
|||||||
stream=True,
|
stream=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(
|
if response.status_code != 200:
|
||||||
"Received upstream response",
|
logger.error(
|
||||||
extra={
|
"Received upstream response",
|
||||||
"status_code": response.status_code,
|
extra={
|
||||||
"path": path,
|
"reason_phrase": response.reason_phrase,
|
||||||
"response_headers": dict(response.headers),
|
"status_code": response.status_code,
|
||||||
},
|
"path": path,
|
||||||
)
|
"response_headers": dict(response.headers),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Received upstream response",
|
||||||
|
extra={
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"path": path,
|
||||||
|
"response_headers": dict(response.headers),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -2475,7 +2673,10 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
refund_token = await self.send_refund(
|
refund_token = await self.send_refund(
|
||||||
amount - 60, unit, mint, payment_token_hash,
|
amount,
|
||||||
|
unit,
|
||||||
|
mint,
|
||||||
|
payment_token_hash,
|
||||||
request_id=getattr(request.state, "request_id", None),
|
request_id=getattr(request.state, "request_id", None),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2767,7 +2968,10 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
refund_token = await self.send_refund(
|
refund_token = await self.send_refund(
|
||||||
amount - 60, unit, mint, payment_token_hash,
|
amount,
|
||||||
|
unit,
|
||||||
|
mint,
|
||||||
|
payment_token_hash,
|
||||||
request_id=getattr(request.state, "request_id", None),
|
request_id=getattr(request.state, "request_id", None),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3031,7 +3235,10 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
refund_token = await self.send_refund(
|
refund_token = await self.send_refund(
|
||||||
refund_amount, unit, mint, payment_token_hash,
|
refund_amount,
|
||||||
|
unit,
|
||||||
|
mint,
|
||||||
|
payment_token_hash,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
)
|
)
|
||||||
response_headers["X-Cashu"] = refund_token
|
response_headers["X-Cashu"] = refund_token
|
||||||
@@ -3067,6 +3274,19 @@ class BaseUpstreamProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if cost_data:
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.startswith("data: "):
|
||||||
|
try:
|
||||||
|
data_json = json.loads(line[6:])
|
||||||
|
if "usage" in data_json and data_json["usage"]:
|
||||||
|
data_json["usage"]["cost_sats"] = (
|
||||||
|
cost_data.total_msats // 1000
|
||||||
|
)
|
||||||
|
lines[i] = "data: " + json.dumps(data_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
async def generate() -> AsyncGenerator[bytes, None]:
|
async def generate() -> AsyncGenerator[bytes, None]:
|
||||||
for line in lines:
|
for line in lines:
|
||||||
yield (line + "\\n").encode("utf-8")
|
yield (line + "\\n").encode("utf-8")
|
||||||
@@ -3099,6 +3319,9 @@ class BaseUpstreamProvider:
|
|||||||
response_json = json.loads(content_str)
|
response_json = json.loads(content_str)
|
||||||
cost_data = await self.get_x_cashu_cost(response_json, max_cost_for_model)
|
cost_data = await self.get_x_cashu_cost(response_json, max_cost_for_model)
|
||||||
|
|
||||||
|
if cost_data and "usage" in response_json:
|
||||||
|
response_json["usage"]["cost_sats"] = cost_data.total_msats // 1000
|
||||||
|
|
||||||
if not cost_data:
|
if not cost_data:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to calculate cost for Responses API response",
|
"Failed to calculate cost for Responses API response",
|
||||||
@@ -3148,7 +3371,10 @@ class BaseUpstreamProvider:
|
|||||||
|
|
||||||
if refund_amount > 0:
|
if refund_amount > 0:
|
||||||
refund_token = await self.send_refund(
|
refund_token = await self.send_refund(
|
||||||
refund_amount, unit, mint, payment_token_hash,
|
refund_amount,
|
||||||
|
unit,
|
||||||
|
mint,
|
||||||
|
payment_token_hash,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
)
|
)
|
||||||
response_headers["X-Cashu"] = refund_token
|
response_headers["X-Cashu"] = refund_token
|
||||||
@@ -3165,7 +3391,7 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
content=content_str,
|
content=json.dumps(response_json),
|
||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
headers=response_headers,
|
headers=response_headers,
|
||||||
media_type="application/json",
|
media_type="application/json",
|
||||||
@@ -3203,7 +3429,6 @@ class BaseUpstreamProvider:
|
|||||||
extra={
|
extra={
|
||||||
"original_amount": amount,
|
"original_amount": amount,
|
||||||
"refund_amount": emergency_refund,
|
"refund_amount": emergency_refund,
|
||||||
"deduction": 60,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3356,6 +3581,7 @@ class BaseUpstreamProvider:
|
|||||||
upstream_provider_id=model.upstream_provider_id,
|
upstream_provider_id=model.upstream_provider_id,
|
||||||
canonical_slug=model.canonical_slug,
|
canonical_slug=model.canonical_slug,
|
||||||
alias_ids=model.alias_ids,
|
alias_ids=model.alias_ids,
|
||||||
|
forwarded_model_id=model.forwarded_model_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
(
|
(
|
||||||
@@ -3379,6 +3605,7 @@ class BaseUpstreamProvider:
|
|||||||
upstream_provider_id=model.upstream_provider_id,
|
upstream_provider_id=model.upstream_provider_id,
|
||||||
canonical_slug=model.canonical_slug,
|
canonical_slug=model.canonical_slug,
|
||||||
alias_ids=model.alias_ids,
|
alias_ids=model.alias_ids,
|
||||||
|
forwarded_model_id=model.forwarded_model_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def fetch_models(self) -> list[Model]:
|
async def fetch_models(self) -> list[Model]:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Callable
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..core.settings import Settings
|
from ..core.settings import Settings
|
||||||
@@ -122,12 +122,16 @@ async def get_all_models_with_overrides(
|
|||||||
|
|
||||||
|
|
||||||
async def refresh_upstreams_models_periodically(
|
async def refresh_upstreams_models_periodically(
|
||||||
upstreams: list[BaseUpstreamProvider],
|
upstreams_provider: (
|
||||||
|
Callable[[], list[BaseUpstreamProvider]] | list[BaseUpstreamProvider]
|
||||||
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Background task to periodically refresh models cache for all providers.
|
"""Background task to periodically refresh models cache for all providers.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
upstreams: List of upstream provider instances
|
upstreams_provider: Either a callable returning the live upstream list
|
||||||
|
(preferred — picks up providers added/changed via reinitialize_upstreams),
|
||||||
|
or a static list (legacy, will go stale after reinitialize_upstreams).
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
@@ -139,9 +143,14 @@ async def refresh_upstreams_models_periodically(
|
|||||||
logger.info("Provider models refresh disabled (interval <= 0)")
|
logger.info("Provider models refresh disabled (interval <= 0)")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _resolve_upstreams() -> list[BaseUpstreamProvider]:
|
||||||
|
if callable(upstreams_provider):
|
||||||
|
return upstreams_provider()
|
||||||
|
return upstreams_provider
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
for upstream in upstreams:
|
for upstream in _resolve_upstreams():
|
||||||
try:
|
try:
|
||||||
await upstream.refresh_models_cache()
|
await upstream.refresh_models_cache()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+44
-19
@@ -1,9 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..core.logging import get_logger
|
from ..core.logging import get_logger
|
||||||
from ..payment.models import Architecture, Model, Pricing, async_fetch_openrouter_models
|
from ..payment.models import Architecture, Model, Pricing, async_fetch_openrouter_models
|
||||||
@@ -16,18 +16,20 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class PPQAIModelPricing(BaseModel):
|
class PPQAIModelPricing(BaseModel):
|
||||||
ui: dict[str, float]
|
ui: Optional[dict[str, float]] = None
|
||||||
api: dict[str, float]
|
api: Optional[dict[str, float]] = None
|
||||||
|
input_per_1M_tokens: Optional[float] = Field(None, alias="input_per_1M_tokens")
|
||||||
|
output_per_1M_tokens: Optional[float] = Field(None, alias="output_per_1M_tokens")
|
||||||
|
|
||||||
|
|
||||||
class PPQAIModel(BaseModel):
|
class PPQAIModel(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
provider: str
|
provider: Optional[str] = None
|
||||||
name: str
|
name: str
|
||||||
created_at: int
|
created_at: int
|
||||||
context_length: int
|
context_length: int
|
||||||
pricing: PPQAIModelPricing
|
pricing: PPQAIModelPricing
|
||||||
popular: bool
|
popular: bool = False
|
||||||
|
|
||||||
|
|
||||||
class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||||
@@ -134,31 +136,54 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if or_model:
|
if or_model:
|
||||||
if input_price := ppqai_model.pricing.api.get(
|
input_price = None
|
||||||
"input_per_1M"
|
if ppqai_model.pricing.api:
|
||||||
):
|
input_price = ppqai_model.pricing.api.get(
|
||||||
|
"input_per_1M"
|
||||||
|
)
|
||||||
|
elif ppqai_model.pricing.input_per_1M_tokens:
|
||||||
|
input_price = ppqai_model.pricing.input_per_1M_tokens
|
||||||
|
|
||||||
|
if input_price is not None:
|
||||||
or_model.pricing.prompt = input_price / 1_000_000
|
or_model.pricing.prompt = input_price / 1_000_000
|
||||||
if output_price := ppqai_model.pricing.api.get(
|
|
||||||
"output_per_1M"
|
output_price = None
|
||||||
):
|
if ppqai_model.pricing.api:
|
||||||
|
output_price = ppqai_model.pricing.api.get(
|
||||||
|
"output_per_1M"
|
||||||
|
)
|
||||||
|
elif ppqai_model.pricing.output_per_1M_tokens:
|
||||||
|
output_price = ppqai_model.pricing.output_per_1M_tokens
|
||||||
|
|
||||||
|
if output_price is not None:
|
||||||
or_model.pricing.completion = output_price / 1_000_000
|
or_model.pricing.completion = output_price / 1_000_000
|
||||||
|
|
||||||
if cl := ppqai_model.context_length:
|
if cl := ppqai_model.context_length:
|
||||||
or_model.context_length = cl
|
or_model.context_length = cl
|
||||||
models.append(or_model)
|
models.append(or_model)
|
||||||
else:
|
else:
|
||||||
input_price = ppqai_model.pricing.api.get(
|
input_price = 0.0
|
||||||
"input_per_1M", 0.0
|
if ppqai_model.pricing.api:
|
||||||
)
|
input_price = ppqai_model.pricing.api.get(
|
||||||
output_price = ppqai_model.pricing.api.get(
|
"input_per_1M", 0.0
|
||||||
"output_per_1M", 0.0
|
)
|
||||||
)
|
elif ppqai_model.pricing.input_per_1M_tokens:
|
||||||
|
input_price = ppqai_model.pricing.input_per_1M_tokens
|
||||||
|
|
||||||
|
output_price = 0.0
|
||||||
|
if ppqai_model.pricing.api:
|
||||||
|
output_price = ppqai_model.pricing.api.get(
|
||||||
|
"output_per_1M", 0.0
|
||||||
|
)
|
||||||
|
elif ppqai_model.pricing.output_per_1M_tokens:
|
||||||
|
output_price = ppqai_model.pricing.output_per_1M_tokens
|
||||||
|
|
||||||
models.append(
|
models.append(
|
||||||
Model(
|
Model(
|
||||||
id=ppqai_model.id,
|
id=ppqai_model.id,
|
||||||
name=ppqai_model.name,
|
name=ppqai_model.name,
|
||||||
created=ppqai_model.created_at // 1000,
|
created=ppqai_model.created_at // 1000,
|
||||||
description=f"{ppqai_model.provider} model",
|
description=f"{ppqai_model.provider or 'PPQ.AI'} model",
|
||||||
context_length=ppqai_model.context_length,
|
context_length=ppqai_model.context_length,
|
||||||
architecture=Architecture(
|
architecture=Architecture(
|
||||||
modality="text->text",
|
modality="text->text",
|
||||||
|
|||||||
+71
-1
@@ -8,6 +8,7 @@ from cashu.wallet.wallet import Wallet
|
|||||||
from sqlmodel import col, select, update
|
from sqlmodel import col, select, update
|
||||||
|
|
||||||
from .core import db, get_logger
|
from .core import db, get_logger
|
||||||
|
from .core.db import store_cashu_transaction
|
||||||
from .core.settings import settings
|
from .core.settings import settings
|
||||||
from .payment.lnurl import raw_send_to_lnurl
|
from .payment.lnurl import raw_send_to_lnurl
|
||||||
|
|
||||||
@@ -155,6 +156,20 @@ async def swap_to_primary_mint(
|
|||||||
raise ValueError("Invalid unit")
|
raise ValueError("Invalid unit")
|
||||||
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
|
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
|
||||||
|
|
||||||
|
# If the token is already from the primary mint, we don't need to swap
|
||||||
|
# and we definitely don't want to calculate or pay fees.
|
||||||
|
if token_obj.mint == settings.primary_mint:
|
||||||
|
logger.info(
|
||||||
|
"swap_to_primary_mint: token already on primary mint, skipping swap",
|
||||||
|
extra={
|
||||||
|
"mint": token_obj.mint,
|
||||||
|
"amount": token_amount,
|
||||||
|
"unit": token_obj.unit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await token_wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
|
||||||
|
return token_amount, token_obj.unit, token_obj.mint
|
||||||
|
|
||||||
minted_amount = await _calculate_swap_amount(
|
minted_amount = await _calculate_swap_amount(
|
||||||
amount_msat,
|
amount_msat,
|
||||||
token_obj.unit,
|
token_obj.unit,
|
||||||
@@ -265,6 +280,8 @@ async def credit_balance(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
amount, unit, mint_url = await recieve_token(cashu_token)
|
amount, unit, mint_url = await recieve_token(cashu_token)
|
||||||
|
original_amount = amount
|
||||||
|
original_unit = unit
|
||||||
logger.info(
|
logger.info(
|
||||||
"credit_balance: Token redeemed successfully",
|
"credit_balance: Token redeemed successfully",
|
||||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||||
@@ -296,6 +313,19 @@ async def credit_balance(
|
|||||||
extra={"new_balance": key.balance},
|
extra={"new_balance": key.balance},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await store_cashu_transaction(
|
||||||
|
token=cashu_token,
|
||||||
|
amount=original_amount,
|
||||||
|
unit=original_unit,
|
||||||
|
mint_url=mint_url,
|
||||||
|
typ="in",
|
||||||
|
source="apikey",
|
||||||
|
api_key_hashed_key=key.hashed_key,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Cashu token successfully redeemed and stored",
|
"Cashu token successfully redeemed and stored",
|
||||||
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
|
||||||
@@ -535,7 +565,7 @@ async def periodic_refund_sweep() -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e).lower()
|
error_msg = str(e).lower()
|
||||||
if "already spent" in error_msg:
|
if "already spent" in error_msg:
|
||||||
refund.swept = True
|
refund.collected = True
|
||||||
session.add(refund)
|
session.add(refund)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Refund already spent (client collected), marking swept",
|
"Refund already spent (client collected), marking swept",
|
||||||
@@ -559,6 +589,46 @@ async def periodic_refund_sweep() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def periodic_routstr_fee_payout() -> None:
|
||||||
|
from .auth import (
|
||||||
|
ROUTSTR_FEE_DEFAULT_PAYOUT,
|
||||||
|
ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS,
|
||||||
|
ROUTSTR_LN_ADDRESS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ROUTSTR_LN_ADDRESS:
|
||||||
|
logger.info("ROUTSTR_LN_ADDRESS not set, skipping fee payout")
|
||||||
|
return
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS)
|
||||||
|
try:
|
||||||
|
async with db.create_session() as session:
|
||||||
|
fee = await db.get_routstr_fee(session)
|
||||||
|
accumulated_sats = fee.accumulated_msats // 1000
|
||||||
|
if accumulated_sats >= ROUTSTR_FEE_DEFAULT_PAYOUT:
|
||||||
|
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||||
|
proofs = get_proofs_per_mint_and_unit(
|
||||||
|
wallet, settings.primary_mint, "sat", not_reserved=True
|
||||||
|
)
|
||||||
|
amount_received = await raw_send_to_lnurl(
|
||||||
|
wallet, proofs, ROUTSTR_LN_ADDRESS, "sat", amount=accumulated_sats
|
||||||
|
)
|
||||||
|
paid_msats = accumulated_sats * 1000
|
||||||
|
await db.reset_routstr_fee(session, paid_msats)
|
||||||
|
logger.info(
|
||||||
|
"Routstr fee payout sent",
|
||||||
|
extra={
|
||||||
|
"accumulated_sats": accumulated_sats,
|
||||||
|
"amount_received": amount_received,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error in Routstr fee payout: {type(e).__name__}",
|
||||||
|
extra={"error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
|
async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
|
||||||
wallet = await get_wallet(mint, unit)
|
wallet = await get_wallet(mint, unit)
|
||||||
proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id]
|
proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id]
|
||||||
|
|||||||
@@ -380,6 +380,13 @@ async def integration_session(
|
|||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def patched_db_engine(integration_engine: Any) -> AsyncGenerator[None, None]:
|
||||||
|
"""Patch the global db engine so create_session() uses the test engine."""
|
||||||
|
with patch("routstr.core.db.engine", integration_engine):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
class DatabaseSnapshot:
|
class DatabaseSnapshot:
|
||||||
"""Utility to capture and compare database states"""
|
"""Utility to capture and compare database states"""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for the balance-goes-negative bug in adjust_payment_for_tokens.
|
||||||
|
|
||||||
|
Root cause: when actual token cost exceeds the discounted reservation
|
||||||
|
(cost_difference > 0, caused by tolerance_percentage discounting the reservation),
|
||||||
|
the finalization UPDATE had no WHERE guard on balance, allowing balance to go negative.
|
||||||
|
|
||||||
|
Fix: added `.where(col(ApiKey.balance) >= total_cost_msats)` so the UPDATE is a no-op
|
||||||
|
when balance is insufficient, then falls back to charging only deducted_max_cost.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from routstr.core.db import ApiKey
|
||||||
|
from routstr.payment.cost_calculation import CostData
|
||||||
|
|
||||||
|
|
||||||
|
def _make_key(balance: int, reserved: int) -> ApiKey:
|
||||||
|
return ApiKey(
|
||||||
|
hashed_key=f"test_{uuid.uuid4().hex}",
|
||||||
|
balance=balance,
|
||||||
|
reserved_balance=reserved,
|
||||||
|
total_spent=0,
|
||||||
|
total_requests=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh(session: AsyncSession, key: ApiKey) -> ApiKey:
|
||||||
|
await session.refresh(key)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: build a CostData where token cost > deducted_max_cost
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _cost_data(total_msats: int) -> CostData:
|
||||||
|
return CostData(
|
||||||
|
base_msats=0,
|
||||||
|
input_msats=total_msats // 2,
|
||||||
|
output_msats=total_msats - total_msats // 2,
|
||||||
|
total_msats=total_msats,
|
||||||
|
total_usd=0.0,
|
||||||
|
input_tokens=100,
|
||||||
|
output_tokens=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 1 — exact reproduction of the bug
|
||||||
|
#
|
||||||
|
# Setup: balance == deducted_max_cost (user has just enough for the reservation,
|
||||||
|
# nothing extra). Actual token cost is 1% higher (tolerance_percentage).
|
||||||
|
#
|
||||||
|
# Before fix: balance -= total_cost_msats → goes negative.
|
||||||
|
# After fix: WHERE balance >= total_cost_msats fails → fallback charges
|
||||||
|
# deducted_max_cost → balance reaches 0, never negative.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_balance_never_negative_when_cost_exceeds_reservation(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""Balance must not go negative when actual token cost > discounted reservation."""
|
||||||
|
from routstr.auth import adjust_payment_for_tokens
|
||||||
|
|
||||||
|
deducted_max_cost = 990 # reserved (1% below true max of 1000)
|
||||||
|
actual_token_cost = 1000 # actual cost at true max
|
||||||
|
|
||||||
|
# User has balance exactly equal to the reservation — tight budget
|
||||||
|
key = _make_key(balance=deducted_max_cost, reserved=deducted_max_cost)
|
||||||
|
integration_session.add(key)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
response_data = {"model": "test-model", "usage": {"prompt_tokens": 100, "completion_tokens": 100}}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.auth.calculate_cost",
|
||||||
|
return_value=_cost_data(actual_token_cost),
|
||||||
|
):
|
||||||
|
await adjust_payment_for_tokens(key, response_data, integration_session, deducted_max_cost)
|
||||||
|
|
||||||
|
await _refresh(integration_session, key)
|
||||||
|
|
||||||
|
assert key.balance >= 0, f"Balance went negative: {key.balance}"
|
||||||
|
assert key.reserved_balance >= 0, f"Reserved balance went negative: {key.reserved_balance}"
|
||||||
|
assert key.reserved_balance == 0, "Reservation must be fully released after finalization"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 2 — balance is ZERO after the reservation is accounted for
|
||||||
|
# (absolute floor case)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_balance_floor_at_zero_on_overrun(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""When balance exactly covers deducted_max_cost and cost overruns, balance reaches 0 not negative."""
|
||||||
|
from routstr.auth import adjust_payment_for_tokens
|
||||||
|
|
||||||
|
deducted_max_cost = 500
|
||||||
|
actual_token_cost = 550 # 10% overrun
|
||||||
|
|
||||||
|
key = _make_key(balance=500, reserved=500)
|
||||||
|
integration_session.add(key)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
response_data = {"model": "test-model", "usage": {"prompt_tokens": 50, "completion_tokens": 50}}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.auth.calculate_cost",
|
||||||
|
return_value=_cost_data(actual_token_cost),
|
||||||
|
):
|
||||||
|
await adjust_payment_for_tokens(key, response_data, integration_session, deducted_max_cost)
|
||||||
|
|
||||||
|
await _refresh(integration_session, key)
|
||||||
|
|
||||||
|
assert key.balance == 0, (
|
||||||
|
f"Expected balance=0 (charged deducted_max_cost fallback), got {key.balance}"
|
||||||
|
)
|
||||||
|
assert key.reserved_balance == 0, f"Reserved balance should be 0, got {key.reserved_balance}"
|
||||||
|
# Fallback charges deducted_max_cost
|
||||||
|
assert key.total_spent == deducted_max_cost, (
|
||||||
|
f"Expected total_spent={deducted_max_cost}, got {key.total_spent}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 3 — balance has enough room: full token cost should be charged
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_cost_charged_when_balance_sufficient_for_overrun(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""When balance covers total_cost_msats, the full amount is charged (not just deducted_max_cost)."""
|
||||||
|
from routstr.auth import adjust_payment_for_tokens
|
||||||
|
|
||||||
|
deducted_max_cost = 990
|
||||||
|
actual_token_cost = 1000
|
||||||
|
|
||||||
|
# User has extra balance beyond the reservation
|
||||||
|
key = _make_key(balance=2000, reserved=990)
|
||||||
|
integration_session.add(key)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
response_data = {"model": "test-model", "usage": {"prompt_tokens": 100, "completion_tokens": 100}}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.auth.calculate_cost",
|
||||||
|
return_value=_cost_data(actual_token_cost),
|
||||||
|
):
|
||||||
|
await adjust_payment_for_tokens(key, response_data, integration_session, deducted_max_cost)
|
||||||
|
|
||||||
|
await _refresh(integration_session, key)
|
||||||
|
|
||||||
|
assert key.balance >= 0, f"Balance went negative: {key.balance}"
|
||||||
|
assert key.reserved_balance == 0, f"Reservation not released: {key.reserved_balance}"
|
||||||
|
assert key.total_spent == actual_token_cost, (
|
||||||
|
f"Expected full charge of {actual_token_cost}, got {key.total_spent}"
|
||||||
|
)
|
||||||
|
assert key.balance == 2000 - actual_token_cost, (
|
||||||
|
f"Expected balance={2000 - actual_token_cost}, got {key.balance}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 4 — concurrent finalizations with cost overrun
|
||||||
|
#
|
||||||
|
# Multiple requests finish concurrently. Each has a small overrun.
|
||||||
|
# None should drive balance negative.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_concurrent_cost_overruns_never_negative(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
patched_db_engine: None,
|
||||||
|
) -> None:
|
||||||
|
"""Concurrent finalization with cost overruns must never produce negative balance."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from routstr.auth import adjust_payment_for_tokens, pay_for_request
|
||||||
|
from routstr.core.db import create_session
|
||||||
|
|
||||||
|
deducted_max_cost = 990
|
||||||
|
actual_token_cost = 1000
|
||||||
|
n_requests = 5
|
||||||
|
|
||||||
|
# Fund the key with exactly enough for n_requests reservations + a tiny buffer
|
||||||
|
starting_balance = deducted_max_cost * n_requests
|
||||||
|
key_hash = f"test_concurrent_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
async with create_session() as session:
|
||||||
|
key = ApiKey(
|
||||||
|
hashed_key=key_hash,
|
||||||
|
balance=starting_balance,
|
||||||
|
reserved_balance=0,
|
||||||
|
total_spent=0,
|
||||||
|
total_requests=0,
|
||||||
|
)
|
||||||
|
session.add(key)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Reserve n_requests slots (sequentially, as pay_for_request is atomic)
|
||||||
|
async with create_session() as session:
|
||||||
|
key_to_reserve = await session.get(ApiKey, key_hash)
|
||||||
|
assert key_to_reserve is not None
|
||||||
|
for _ in range(n_requests):
|
||||||
|
await pay_for_request(key_to_reserve, deducted_max_cost, session)
|
||||||
|
await session.refresh(key_to_reserve)
|
||||||
|
|
||||||
|
# Now finalize all concurrently with cost overrun
|
||||||
|
async def finalize() -> None:
|
||||||
|
response_data = {
|
||||||
|
"model": "test-model",
|
||||||
|
"usage": {"prompt_tokens": 100, "completion_tokens": 100},
|
||||||
|
}
|
||||||
|
async with create_session() as session:
|
||||||
|
fresh_key = await session.get(ApiKey, key_hash)
|
||||||
|
assert fresh_key is not None
|
||||||
|
with patch(
|
||||||
|
"routstr.auth.calculate_cost",
|
||||||
|
return_value=_cost_data(actual_token_cost),
|
||||||
|
):
|
||||||
|
await adjust_payment_for_tokens(
|
||||||
|
fresh_key, response_data, session, deducted_max_cost
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.gather(*[finalize() for _ in range(n_requests)])
|
||||||
|
|
||||||
|
async with create_session() as session:
|
||||||
|
final_key = await session.get(ApiKey, key_hash)
|
||||||
|
assert final_key is not None
|
||||||
|
|
||||||
|
assert final_key.balance >= 0, (
|
||||||
|
f"Balance went negative after concurrent overruns: {final_key.balance}"
|
||||||
|
)
|
||||||
|
assert final_key.reserved_balance == 0, (
|
||||||
|
f"Reserved balance not fully released: {final_key.reserved_balance}"
|
||||||
|
)
|
||||||
|
assert final_key.total_spent <= starting_balance, (
|
||||||
|
f"Total spent ({final_key.total_spent}) exceeds starting balance ({starting_balance})"
|
||||||
|
)
|
||||||
|
# Every request must have been charged at least deducted_max_cost — no free inference.
|
||||||
|
assert final_key.total_spent == starting_balance, (
|
||||||
|
f"Expected total_spent={starting_balance} (all {n_requests} reservations charged), "
|
||||||
|
f"got {final_key.total_spent} — at least one request got free inference"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 5 — overrun with no balance at all (reserved_balance == balance)
|
||||||
|
# simulates a user who topped up to exactly the reservation floor
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_zero_free_balance_overrun_is_safe(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""User with zero free balance (all reserved) should never go negative on overrun."""
|
||||||
|
from routstr.auth import adjust_payment_for_tokens
|
||||||
|
|
||||||
|
deducted_max_cost = 1000
|
||||||
|
actual_token_cost = 1050
|
||||||
|
|
||||||
|
# balance == reserved_balance: zero free balance
|
||||||
|
key = _make_key(balance=1000, reserved=1000)
|
||||||
|
integration_session.add(key)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
response_data = {"model": "test-model", "usage": {"prompt_tokens": 50, "completion_tokens": 100}}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.auth.calculate_cost",
|
||||||
|
return_value=_cost_data(actual_token_cost),
|
||||||
|
):
|
||||||
|
await adjust_payment_for_tokens(key, response_data, integration_session, deducted_max_cost)
|
||||||
|
|
||||||
|
await _refresh(integration_session, key)
|
||||||
|
|
||||||
|
assert key.balance >= 0, f"Balance went negative: {key.balance}"
|
||||||
|
assert key.reserved_balance >= 0, f"Reserved balance went negative: {key.reserved_balance}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 6 — parallel requests: second finalization must not get free inference
|
||||||
|
#
|
||||||
|
# Root cause of the bug fixed in auth.py:
|
||||||
|
# `.where(col(ApiKey.balance) >= total_cost_msats)` ignores other requests'
|
||||||
|
# reservations, so after Request A charges total_cost_msats, balance can drop
|
||||||
|
# below deducted_max_cost, causing Request B's fallback to release for free.
|
||||||
|
#
|
||||||
|
# Fix: use `balance - reserved_balance + deducted_max_cost >= total_cost_msats`
|
||||||
|
# so the check accounts for concurrent reservations.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parallel_requests_no_free_inference(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
patched_db_engine: None,
|
||||||
|
) -> None:
|
||||||
|
"""Second parallel finalization must be charged even when first depleted free balance."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from routstr.auth import adjust_payment_for_tokens
|
||||||
|
from routstr.core.db import create_session
|
||||||
|
|
||||||
|
deducted_max_cost = 100
|
||||||
|
actual_token_cost = 150 # overrun: 50 more than reserved
|
||||||
|
|
||||||
|
# Fund the key with exactly 2 * deducted_max_cost.
|
||||||
|
# Both requests pre-reserved 100 each → balance=200, reserved=200, free=0.
|
||||||
|
# Old check (balance >= total_cost_msats):
|
||||||
|
# Request A: 200 >= 150 ✓ → charges 150 → balance=50, reserved=100
|
||||||
|
# Request B: 50 >= 150 ✗ → fallback: 50 >= 100 ✗ → releases FREE
|
||||||
|
# New check (balance - reserved + deducted >= total_cost_msats):
|
||||||
|
# Both fall to fallback (0 free balance).
|
||||||
|
# Both charge deducted_max_cost=100 → total_spent=200, balance=0.
|
||||||
|
starting_balance = deducted_max_cost * 2
|
||||||
|
key_hash = f"test_parallel_no_free_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
async with create_session() as session:
|
||||||
|
key = ApiKey(
|
||||||
|
hashed_key=key_hash,
|
||||||
|
balance=starting_balance,
|
||||||
|
reserved_balance=deducted_max_cost * 2, # both slots pre-reserved
|
||||||
|
total_spent=0,
|
||||||
|
total_requests=2,
|
||||||
|
)
|
||||||
|
session.add(key)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def finalize() -> None:
|
||||||
|
response_data = {
|
||||||
|
"model": "test-model",
|
||||||
|
"usage": {"prompt_tokens": 50, "completion_tokens": 100},
|
||||||
|
}
|
||||||
|
async with create_session() as session:
|
||||||
|
fresh_key = await session.get(ApiKey, key_hash)
|
||||||
|
assert fresh_key is not None
|
||||||
|
with patch(
|
||||||
|
"routstr.auth.calculate_cost",
|
||||||
|
return_value=_cost_data(actual_token_cost),
|
||||||
|
):
|
||||||
|
await adjust_payment_for_tokens(
|
||||||
|
fresh_key, response_data, session, deducted_max_cost
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.gather(finalize(), finalize())
|
||||||
|
|
||||||
|
async with create_session() as session:
|
||||||
|
final_key = await session.get(ApiKey, key_hash)
|
||||||
|
assert final_key is not None
|
||||||
|
|
||||||
|
assert final_key.balance >= 0, f"Balance went negative: {final_key.balance}"
|
||||||
|
assert final_key.reserved_balance == 0, (
|
||||||
|
f"Reserved balance not released: {final_key.reserved_balance}"
|
||||||
|
)
|
||||||
|
# Both requests must have been charged — no free inference.
|
||||||
|
assert final_key.total_spent == starting_balance, (
|
||||||
|
f"Expected total_spent={starting_balance} (both reservations charged), "
|
||||||
|
f"got {final_key.total_spent} — one request got free inference"
|
||||||
|
)
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
Tests showing how a user hits "Insufficient balance: X mSats required for this model"
|
||||||
|
when their balance is too low for the model's cost.
|
||||||
|
|
||||||
|
The log line that triggered this:
|
||||||
|
WARNING Insufficient billing balance during validation
|
||||||
|
ERROR Bearer token validation failed: HTTPException: 402:
|
||||||
|
{'error': {'message': 'Insufficient balance: 622888 mSats required
|
||||||
|
for this model. 20320 available.', ...}}
|
||||||
|
|
||||||
|
This happens in validate_bearer_key (auth.py) when:
|
||||||
|
billing_key.total_balance < min_cost (model's max cost)
|
||||||
|
|
||||||
|
and also in pay_for_request when the atomic UPDATE finds no available balance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from routstr.core.db import ApiKey
|
||||||
|
|
||||||
|
|
||||||
|
def _key(balance: int, reserved: int = 0) -> ApiKey:
|
||||||
|
return ApiKey(
|
||||||
|
hashed_key=f"test_{uuid.uuid4().hex}",
|
||||||
|
balance=balance,
|
||||||
|
reserved_balance=reserved,
|
||||||
|
total_spent=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 1 — simplest case: balance < model cost → pay_for_request raises 402
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pay_for_request_raises_402_when_balance_too_low(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
User has 20_000 msats. Model costs 622_888 msats.
|
||||||
|
pay_for_request must raise HTTP 402 with a clear message.
|
||||||
|
"""
|
||||||
|
from routstr.auth import pay_for_request
|
||||||
|
|
||||||
|
model_cost = 622_888
|
||||||
|
user_balance = 20_000
|
||||||
|
|
||||||
|
key = _key(balance=user_balance)
|
||||||
|
integration_session.add(key)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await pay_for_request(key, model_cost, integration_session)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 402
|
||||||
|
detail = exc_info.value.detail
|
||||||
|
assert isinstance(detail, dict)
|
||||||
|
error = detail["error"]
|
||||||
|
assert error["code"] == "insufficient_balance"
|
||||||
|
assert str(model_cost) in error["message"]
|
||||||
|
assert str(user_balance) in error["message"]
|
||||||
|
|
||||||
|
# Balance must be untouched
|
||||||
|
await integration_session.refresh(key)
|
||||||
|
assert key.balance == user_balance
|
||||||
|
assert key.reserved_balance == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 2 — balance is zero
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pay_for_request_raises_402_on_zero_balance(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""User with zero balance cannot make any request."""
|
||||||
|
from routstr.auth import pay_for_request
|
||||||
|
|
||||||
|
key = _key(balance=0)
|
||||||
|
integration_session.add(key)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await pay_for_request(key, 1_000, integration_session)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 402
|
||||||
|
detail = exc_info.value.detail
|
||||||
|
assert isinstance(detail, dict)
|
||||||
|
assert detail["error"]["code"] == "insufficient_balance"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 3 — all balance is reserved (total_balance = balance - reserved = 0)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pay_for_request_raises_402_when_all_balance_reserved(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
User has 50_000 msats balance but 50_000 is already reserved for in-flight
|
||||||
|
requests. Free balance (total_balance) = 0. Should get 402.
|
||||||
|
"""
|
||||||
|
from routstr.auth import pay_for_request
|
||||||
|
|
||||||
|
key = _key(balance=50_000, reserved=50_000)
|
||||||
|
integration_session.add(key)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await pay_for_request(key, 1_000, integration_session)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 402
|
||||||
|
# Balance and reserved must be untouched
|
||||||
|
await integration_session.refresh(key)
|
||||||
|
assert key.balance == 50_000
|
||||||
|
assert key.reserved_balance == 50_000
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 4 — balance just one msat below model cost
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pay_for_request_raises_402_one_msat_short(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""Off-by-one: balance is exactly model_cost - 1."""
|
||||||
|
from routstr.auth import pay_for_request
|
||||||
|
|
||||||
|
model_cost = 10_000
|
||||||
|
key = _key(balance=model_cost - 1)
|
||||||
|
integration_session.add(key)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await pay_for_request(key, model_cost, integration_session)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 402
|
||||||
|
await integration_session.refresh(key)
|
||||||
|
assert key.balance == model_cost - 1 # untouched
|
||||||
|
assert key.reserved_balance == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 5 — balance exactly equal to model cost → succeeds
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pay_for_request_succeeds_when_balance_equals_cost(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""Balance == model cost: the request should be reserved successfully."""
|
||||||
|
from routstr.auth import pay_for_request
|
||||||
|
|
||||||
|
model_cost = 10_000
|
||||||
|
key = _key(balance=model_cost)
|
||||||
|
integration_session.add(key)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
await pay_for_request(key, model_cost, integration_session)
|
||||||
|
|
||||||
|
await integration_session.refresh(key)
|
||||||
|
assert key.reserved_balance == model_cost
|
||||||
|
assert key.balance == model_cost # balance unchanged, only reserved goes up
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 6 — HTTP layer returns 402 JSON with the right shape
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_402_response_shape_on_insufficient_balance(
|
||||||
|
integration_client: AsyncClient,
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
End-to-end: POST /v1/chat/completions with a key whose balance is far below
|
||||||
|
the mocked model cost returns HTTP 402 with the expected JSON error body.
|
||||||
|
|
||||||
|
Matches exactly the log snippet in the bug report:
|
||||||
|
'Insufficient balance: X mSats required for this model. Y available.'
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
model_cost = 622_888
|
||||||
|
user_balance = 20_320
|
||||||
|
|
||||||
|
key = _key(balance=user_balance)
|
||||||
|
integration_session.add(key)
|
||||||
|
await integration_session.commit()
|
||||||
|
|
||||||
|
# Minimal model stub so proxy routing doesn't 400 before reaching balance check
|
||||||
|
mock_model = MagicMock()
|
||||||
|
mock_model.sats_pricing = None
|
||||||
|
|
||||||
|
# Upstream stub — never reached because balance check fires first
|
||||||
|
mock_upstream = MagicMock()
|
||||||
|
mock_upstream.prepare_headers = MagicMock(return_value={})
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("routstr.proxy.get_model_instance", return_value=mock_model),
|
||||||
|
patch("routstr.proxy.get_provider_for_model", return_value=[mock_upstream]),
|
||||||
|
# Patch where it is used (proxy imports it at module level)
|
||||||
|
patch(
|
||||||
|
"routstr.proxy.get_max_cost_for_model",
|
||||||
|
new=AsyncMock(return_value=model_cost),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = await integration_client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer sk-{key.hashed_key}"},
|
||||||
|
json={
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"messages": [{"role": "user", "content": "hello"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 402
|
||||||
|
body = response.json()
|
||||||
|
# FastAPI wraps HTTPException detail under "detail"
|
||||||
|
error = body["detail"]["error"]
|
||||||
|
assert error["code"] == "insufficient_balance"
|
||||||
|
assert error["type"] == "insufficient_quota"
|
||||||
|
assert str(model_cost) in error["message"]
|
||||||
|
assert str(user_balance) in error["message"]
|
||||||
|
|
||||||
|
# Balance must be completely untouched
|
||||||
|
await integration_session.refresh(key)
|
||||||
|
assert key.balance == user_balance
|
||||||
|
assert key.reserved_balance == 0
|
||||||
|
assert key.total_spent == 0
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"""
|
||||||
|
Tests for the reservation lifecycle:
|
||||||
|
|
||||||
|
1. Reserve → reserved_balance increases, available (total_balance) decreases.
|
||||||
|
2. Reserve → revert → reserved_balance restored, balance untouched.
|
||||||
|
3. Reserve → finalise → reserved_balance released, balance charged.
|
||||||
|
4. Two parallel reserves, only one fits → second blocked with 402.
|
||||||
|
5. Three parallel reserves, two fit, third blocked with 402.
|
||||||
|
6. Sequential reserves until balance exhausted → next request blocked.
|
||||||
|
|
||||||
|
Reservation invariant enforced by the atomic WHERE clause in pay_for_request:
|
||||||
|
balance - reserved_balance >= cost_per_request
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from routstr.auth import pay_for_request, revert_pay_for_request
|
||||||
|
from routstr.core.db import ApiKey, create_session
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_key(balance: int, reserved: int = 0) -> ApiKey:
|
||||||
|
return ApiKey(
|
||||||
|
hashed_key=f"test_{uuid.uuid4().hex}",
|
||||||
|
balance=balance,
|
||||||
|
reserved_balance=reserved,
|
||||||
|
total_spent=0,
|
||||||
|
total_requests=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist(session: AsyncSession, key: ApiKey) -> ApiKey:
|
||||||
|
session.add(key)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(key)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 1 — Reserve: reserved_balance increases, available balance decreases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reserve_increases_reserved_balance(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""pay_for_request must increment reserved_balance by cost_per_request."""
|
||||||
|
cost = 100
|
||||||
|
key = await _persist(integration_session, _make_key(balance=500))
|
||||||
|
|
||||||
|
await pay_for_request(key, cost, integration_session)
|
||||||
|
await integration_session.refresh(key)
|
||||||
|
|
||||||
|
assert key.reserved_balance == cost
|
||||||
|
assert key.balance == 500 # balance column is NOT decremented on reserve
|
||||||
|
assert key.total_balance == 500 - cost # available = balance - reserved
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 2 — Revert: reserved_balance restored, balance untouched
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_revert_releases_reservation(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""revert_pay_for_request must release the reservation without touching balance."""
|
||||||
|
cost = 150
|
||||||
|
key = await _persist(integration_session, _make_key(balance=300))
|
||||||
|
|
||||||
|
await pay_for_request(key, cost, integration_session)
|
||||||
|
await integration_session.refresh(key)
|
||||||
|
assert key.reserved_balance == cost
|
||||||
|
|
||||||
|
await revert_pay_for_request(key, integration_session, cost)
|
||||||
|
await integration_session.refresh(key)
|
||||||
|
|
||||||
|
assert key.reserved_balance == 0
|
||||||
|
assert key.balance == 300 # balance unchanged after revert
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 3 — Finalise: reservation released + balance charged
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_finalise_releases_reservation_and_charges_balance(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""adjust_payment_for_tokens must zero reserved_balance and deduct actual cost."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from routstr.auth import adjust_payment_for_tokens
|
||||||
|
from routstr.payment.cost_calculation import CostData
|
||||||
|
|
||||||
|
cost = 100
|
||||||
|
actual = 80 # actual < reserved → refund path
|
||||||
|
key = await _persist(integration_session, _make_key(balance=500))
|
||||||
|
|
||||||
|
await pay_for_request(key, cost, integration_session)
|
||||||
|
await integration_session.refresh(key)
|
||||||
|
assert key.reserved_balance == cost
|
||||||
|
|
||||||
|
cost_data = CostData(
|
||||||
|
base_msats=0,
|
||||||
|
input_msats=40,
|
||||||
|
output_msats=40,
|
||||||
|
total_msats=actual,
|
||||||
|
total_usd=0.0,
|
||||||
|
input_tokens=50,
|
||||||
|
output_tokens=50,
|
||||||
|
)
|
||||||
|
response_data = {"model": "test-model", "usage": {"prompt_tokens": 50, "completion_tokens": 50}}
|
||||||
|
|
||||||
|
with patch("routstr.auth.calculate_cost", return_value=cost_data):
|
||||||
|
await adjust_payment_for_tokens(key, response_data, integration_session, cost)
|
||||||
|
|
||||||
|
await integration_session.refresh(key)
|
||||||
|
|
||||||
|
assert key.reserved_balance == 0
|
||||||
|
assert key.balance == 500 - actual
|
||||||
|
assert key.total_spent == actual
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 4 — Concurrent: second parallel reserve blocked when balance exhausted
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_concurrent_second_reserve_blocked_when_balance_exhausted(
|
||||||
|
patched_db_engine: None,
|
||||||
|
) -> None:
|
||||||
|
"""When two requests race for the same balance, only one succeeds; the other gets 402."""
|
||||||
|
cost = 300
|
||||||
|
key_hash = f"test_concurrent_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
async with create_session() as session:
|
||||||
|
key = ApiKey(
|
||||||
|
hashed_key=key_hash,
|
||||||
|
balance=300, # exactly enough for ONE reservation
|
||||||
|
reserved_balance=0,
|
||||||
|
total_spent=0,
|
||||||
|
total_requests=0,
|
||||||
|
)
|
||||||
|
session.add(key)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
results: list[str] = []
|
||||||
|
|
||||||
|
async def attempt_reserve() -> None:
|
||||||
|
async with create_session() as session:
|
||||||
|
fresh_key = await session.get(ApiKey, key_hash)
|
||||||
|
assert fresh_key is not None
|
||||||
|
try:
|
||||||
|
await pay_for_request(fresh_key, cost, session)
|
||||||
|
results.append("success")
|
||||||
|
except HTTPException as exc:
|
||||||
|
assert exc.status_code == 402
|
||||||
|
results.append("blocked")
|
||||||
|
|
||||||
|
await asyncio.gather(attempt_reserve(), attempt_reserve())
|
||||||
|
|
||||||
|
assert sorted(results) == ["blocked", "success"], (
|
||||||
|
f"Expected exactly one success and one 402, got: {results}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async with create_session() as session:
|
||||||
|
final = await session.get(ApiKey, key_hash)
|
||||||
|
assert final is not None
|
||||||
|
|
||||||
|
# reserved_balance must equal exactly one reservation (not two)
|
||||||
|
assert final.reserved_balance == cost, (
|
||||||
|
f"Expected reserved_balance={cost}, got {final.reserved_balance}"
|
||||||
|
)
|
||||||
|
assert final.balance == 300, "Balance column must not be modified by reservation"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 5 — Concurrent: three requests, two fit, third blocked
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_three_parallel_reserves_third_blocked(
|
||||||
|
patched_db_engine: None,
|
||||||
|
) -> None:
|
||||||
|
"""Balance covers two reservations exactly; the third concurrent request must be blocked."""
|
||||||
|
cost = 100
|
||||||
|
key_hash = f"test_three_parallel_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
async with create_session() as session:
|
||||||
|
key = ApiKey(
|
||||||
|
hashed_key=key_hash,
|
||||||
|
balance=200, # fits exactly 2 reservations of 100
|
||||||
|
reserved_balance=0,
|
||||||
|
total_spent=0,
|
||||||
|
total_requests=0,
|
||||||
|
)
|
||||||
|
session.add(key)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
results: list[str] = []
|
||||||
|
|
||||||
|
async def attempt_reserve() -> None:
|
||||||
|
async with create_session() as session:
|
||||||
|
fresh_key = await session.get(ApiKey, key_hash)
|
||||||
|
assert fresh_key is not None
|
||||||
|
try:
|
||||||
|
await pay_for_request(fresh_key, cost, session)
|
||||||
|
results.append("success")
|
||||||
|
except HTTPException as exc:
|
||||||
|
assert exc.status_code == 402
|
||||||
|
results.append("blocked")
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
attempt_reserve(),
|
||||||
|
attempt_reserve(),
|
||||||
|
attempt_reserve(),
|
||||||
|
)
|
||||||
|
|
||||||
|
successes = results.count("success")
|
||||||
|
blocked = results.count("blocked")
|
||||||
|
|
||||||
|
assert successes == 2, f"Expected 2 successes, got {successes}: {results}"
|
||||||
|
assert blocked == 1, f"Expected 1 blocked, got {blocked}: {results}"
|
||||||
|
|
||||||
|
async with create_session() as session:
|
||||||
|
final = await session.get(ApiKey, key_hash)
|
||||||
|
assert final is not None
|
||||||
|
|
||||||
|
assert final.reserved_balance == cost * 2, (
|
||||||
|
f"Expected reserved_balance={cost * 2}, got {final.reserved_balance}"
|
||||||
|
)
|
||||||
|
assert final.balance == 200, "Balance column must not be modified by reservation"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test 6 — Sequential exhaustion: reserve until empty, next request blocked
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sequential_reserves_block_when_balance_exhausted(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""Repeated reservations should block as soon as available balance drops below cost."""
|
||||||
|
cost = 100
|
||||||
|
key = await _persist(integration_session, _make_key(balance=250))
|
||||||
|
|
||||||
|
# First two succeed (100 + 100 = 200 ≤ 250)
|
||||||
|
await pay_for_request(key, cost, integration_session)
|
||||||
|
await pay_for_request(key, cost, integration_session)
|
||||||
|
await integration_session.refresh(key)
|
||||||
|
assert key.reserved_balance == 200
|
||||||
|
assert key.total_balance == 50 # 250 - 200
|
||||||
|
|
||||||
|
# Third: only 50 available, need 100 → blocked
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await pay_for_request(key, cost, integration_session)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 402
|
||||||
|
await integration_session.refresh(key)
|
||||||
|
assert key.reserved_balance == 200 # unchanged after failed reserve
|
||||||
@@ -13,7 +13,7 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from routstr.core.db import ApiKey
|
from routstr.core.db import ApiKey, CashuTransaction
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -356,6 +356,70 @@ async def test_concurrent_refund_requests(
|
|||||||
assert len(successful) + len(failed) == 5
|
assert len(successful) + len(failed) == 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refund_rejects_concurrent_topup_on_same_key(
|
||||||
|
authenticated_client: AsyncClient,
|
||||||
|
testmint_wallet: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Test refund returns 409 when a concurrent topup changes the balance first."""
|
||||||
|
from routstr import balance as balance_module
|
||||||
|
|
||||||
|
wallet_response = await authenticated_client.get("/v1/wallet/")
|
||||||
|
assert wallet_response.status_code == 200
|
||||||
|
initial_balance = wallet_response.json()["balance"]
|
||||||
|
|
||||||
|
topup_amount_sat = 500
|
||||||
|
topup_token = await testmint_wallet.mint_tokens(topup_amount_sat)
|
||||||
|
|
||||||
|
validate_called = asyncio.Event()
|
||||||
|
allow_refund_to_continue = asyncio.Event()
|
||||||
|
original_validate_bearer_key = balance_module.validate_bearer_key
|
||||||
|
delayed_once = False
|
||||||
|
|
||||||
|
async def delayed_validate_bearer_key(*args: Any, **kwargs: Any) -> ApiKey:
|
||||||
|
nonlocal delayed_once
|
||||||
|
key = await original_validate_bearer_key(*args, **kwargs)
|
||||||
|
if not delayed_once:
|
||||||
|
delayed_once = True
|
||||||
|
validate_called.set()
|
||||||
|
await allow_refund_to_continue.wait()
|
||||||
|
return key
|
||||||
|
|
||||||
|
async def issue_refund() -> Any:
|
||||||
|
return await authenticated_client.post("/v1/wallet/refund")
|
||||||
|
|
||||||
|
async def issue_topup() -> Any:
|
||||||
|
await validate_called.wait()
|
||||||
|
try:
|
||||||
|
return await authenticated_client.post(
|
||||||
|
"/v1/wallet/topup", params={"cashu_token": topup_token}
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
allow_refund_to_continue.set()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.balance.validate_bearer_key", new=delayed_validate_bearer_key
|
||||||
|
):
|
||||||
|
refund_response, topup_response = await asyncio.gather(
|
||||||
|
issue_refund(), issue_topup()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert topup_response.status_code == 200
|
||||||
|
assert topup_response.json()["msats"] == topup_amount_sat * 1000
|
||||||
|
assert refund_response.status_code == 409
|
||||||
|
assert (
|
||||||
|
refund_response.json()["detail"]
|
||||||
|
== "Balance changed concurrently. Please retry the refund."
|
||||||
|
)
|
||||||
|
|
||||||
|
final_balance_response = await authenticated_client.get("/v1/wallet/")
|
||||||
|
assert final_balance_response.status_code == 200
|
||||||
|
assert final_balance_response.json()["balance"] == (
|
||||||
|
initial_balance + topup_amount_sat * 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_refund_during_active_usage(
|
async def test_refund_during_active_usage(
|
||||||
@@ -394,6 +458,42 @@ async def test_refund_during_active_usage(
|
|||||||
assert response.json()["balance"] == 0
|
assert response.json()["balance"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wallet_history_returns_apikey_transactions(
|
||||||
|
authenticated_client: AsyncClient,
|
||||||
|
testmint_wallet: Any,
|
||||||
|
integration_session: Any,
|
||||||
|
) -> None:
|
||||||
|
wallet_response = await authenticated_client.get("/v1/wallet/")
|
||||||
|
api_key = wallet_response.json()["api_key"]
|
||||||
|
hashed_key = api_key[3:] if api_key.startswith("sk-") else api_key
|
||||||
|
|
||||||
|
topup_token = await testmint_wallet.mint_tokens(250)
|
||||||
|
topup_response = await authenticated_client.post(
|
||||||
|
"/v1/wallet/topup", params={"cashu_token": topup_token}
|
||||||
|
)
|
||||||
|
assert topup_response.status_code == 200
|
||||||
|
|
||||||
|
refund_response = await authenticated_client.post("/v1/wallet/refund")
|
||||||
|
assert refund_response.status_code == 200
|
||||||
|
|
||||||
|
history_response = await authenticated_client.get("/v1/wallet/history")
|
||||||
|
assert history_response.status_code == 200
|
||||||
|
transactions = history_response.json()["transactions"]
|
||||||
|
assert len(transactions) >= 2
|
||||||
|
assert all("api_key_hashed_key" not in tx for tx in transactions)
|
||||||
|
assert {tx["type"] for tx in transactions} >= {"in", "out"}
|
||||||
|
|
||||||
|
db_result = await integration_session.execute(
|
||||||
|
select(CashuTransaction).where(
|
||||||
|
CashuTransaction.api_key_hashed_key == hashed_key
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_transactions = db_result.scalars().all()
|
||||||
|
assert len(db_transactions) >= 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mint_unavailability_handling(
|
async def test_mint_unavailability_handling(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from routstr.core.db import ApiKey
|
from routstr.core.db import ApiKey, CashuTransaction
|
||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
CashuTokenGenerator,
|
CashuTokenGenerator,
|
||||||
@@ -71,6 +71,16 @@ async def test_topup_with_valid_token( # type: ignore[no-untyped-def]
|
|||||||
assert db_key.balance == new_balance
|
assert db_key.balance == new_balance
|
||||||
assert db_key.balance == initial_balance + (topup_amount * 1000)
|
assert db_key.balance == initial_balance + (topup_amount * 1000)
|
||||||
|
|
||||||
|
tx_result = await integration_session.execute(
|
||||||
|
select(CashuTransaction).where(
|
||||||
|
CashuTransaction.token == token,
|
||||||
|
CashuTransaction.type == "in",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tx = tx_result.scalar_one()
|
||||||
|
assert tx.api_key_hashed_key == hashed_key
|
||||||
|
assert tx.source == "apikey"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
+243
-2
@@ -1,11 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from routstr.balance import refund_wallet_endpoint
|
from routstr.balance import refund_wallet_endpoint
|
||||||
from routstr.core.db import CashuTransaction
|
from routstr.core.db import ApiKey, CashuTransaction
|
||||||
|
from routstr.wallet import credit_balance
|
||||||
|
|
||||||
|
|
||||||
def _make_cashu_tx(
|
def _make_cashu_tx(
|
||||||
@@ -29,6 +30,12 @@ def _exec_result(tx: CashuTransaction | None) -> MagicMock:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _update_result(rowcount: int) -> MagicMock:
|
||||||
|
result = MagicMock()
|
||||||
|
result.rowcount = rowcount
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_refund_x_cashu_returns_token() -> None:
|
async def test_refund_x_cashu_returns_token() -> None:
|
||||||
x_cashu_token = "cashuAtest_token_value"
|
x_cashu_token = "cashuAtest_token_value"
|
||||||
@@ -114,3 +121,237 @@ async def test_refund_x_cashu_swept_raises_410() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert exc_info.value.status_code == 410
|
assert exc_info.value.status_code == 410
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# source field defaults
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cashu_transaction_source_defaults_to_x_cashu() -> None:
|
||||||
|
tx = CashuTransaction(token="cashuAtest", amount=100, unit="msat")
|
||||||
|
assert tx.source == "x-cashu"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cashu_transaction_source_can_be_apikey() -> None:
|
||||||
|
tx = CashuTransaction(token="cashuAtest", amount=100, unit="msat", source="apikey")
|
||||||
|
assert tx.source == "apikey"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# apikey-based refund: token logging and CashuTransaction storage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_api_key(
|
||||||
|
balance: int = 5000,
|
||||||
|
refund_currency: str | None = "sat",
|
||||||
|
refund_mint_url: str | None = "https://mint.example.com",
|
||||||
|
refund_address: str | None = None,
|
||||||
|
parent_key_hash: str | None = None,
|
||||||
|
) -> ApiKey:
|
||||||
|
key = ApiKey(hashed_key="testhash")
|
||||||
|
key.balance = balance
|
||||||
|
key.reserved_balance = 0
|
||||||
|
key.refund_currency = refund_currency
|
||||||
|
key.refund_mint_url = refund_mint_url
|
||||||
|
key.refund_address = refund_address
|
||||||
|
key.parent_key_hash = parent_key_hash
|
||||||
|
key.total_spent = 0
|
||||||
|
key.total_requests = 0
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> None:
|
||||||
|
key = _make_api_key(balance=5000, refund_currency="sat")
|
||||||
|
refund_token = "cashuArefund_apikey_token"
|
||||||
|
|
||||||
|
session = MagicMock()
|
||||||
|
session.exec = AsyncMock(return_value=_update_result(1))
|
||||||
|
session.add = MagicMock()
|
||||||
|
session.commit = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
|
||||||
|
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||||
|
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
|
||||||
|
patch("routstr.balance.store_cashu_transaction", AsyncMock()) as mock_store,
|
||||||
|
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
|
||||||
|
patch("routstr.balance._refund_cache_set", AsyncMock()),
|
||||||
|
):
|
||||||
|
result = await refund_wallet_endpoint(
|
||||||
|
authorization="Bearer sk-testhash",
|
||||||
|
x_cashu=None,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert result["token"] == refund_token
|
||||||
|
|
||||||
|
mock_store.assert_awaited_once()
|
||||||
|
call_kwargs = mock_store.call_args.kwargs
|
||||||
|
assert call_kwargs["source"] == "apikey"
|
||||||
|
assert call_kwargs["token"] == refund_token
|
||||||
|
assert call_kwargs["typ"] == "out"
|
||||||
|
assert call_kwargs["api_key_hashed_key"] == key.hashed_key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apikey_refund_logs_token() -> None:
|
||||||
|
key = _make_api_key(balance=5000, refund_currency="sat")
|
||||||
|
refund_token = "cashuAlogged_token"
|
||||||
|
|
||||||
|
session = MagicMock()
|
||||||
|
session.exec = AsyncMock(return_value=_update_result(1))
|
||||||
|
session.add = MagicMock()
|
||||||
|
session.commit = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
|
||||||
|
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||||
|
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
|
||||||
|
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||||
|
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
|
||||||
|
patch("routstr.balance._refund_cache_set", AsyncMock()),
|
||||||
|
patch("routstr.balance.logger") as mock_logger,
|
||||||
|
):
|
||||||
|
await refund_wallet_endpoint(
|
||||||
|
authorization="Bearer sk-testhash",
|
||||||
|
x_cashu=None,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = [str(c) for c in mock_logger.info.call_args_list]
|
||||||
|
assert any("cashu token issued" in c for c in calls)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apikey_refund_log_includes_path() -> None:
|
||||||
|
key = _make_api_key(balance=5000, refund_currency="sat")
|
||||||
|
refund_token = "cashuApath_token"
|
||||||
|
|
||||||
|
session = MagicMock()
|
||||||
|
session.exec = AsyncMock(return_value=_update_result(1))
|
||||||
|
session.add = MagicMock()
|
||||||
|
session.commit = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
|
||||||
|
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||||
|
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
|
||||||
|
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||||
|
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
|
||||||
|
patch("routstr.balance._refund_cache_set", AsyncMock()),
|
||||||
|
patch("routstr.balance.logger") as mock_logger,
|
||||||
|
):
|
||||||
|
await refund_wallet_endpoint(
|
||||||
|
authorization="Bearer sk-testhash",
|
||||||
|
x_cashu=None,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find the "cashu token issued" call and verify extra contains the path
|
||||||
|
token_issued_calls = [
|
||||||
|
c for c in mock_logger.info.call_args_list
|
||||||
|
if c.args and "cashu token issued" in c.args[0]
|
||||||
|
]
|
||||||
|
assert len(token_issued_calls) == 1
|
||||||
|
extra = token_issued_calls[0].kwargs.get("extra", {})
|
||||||
|
assert extra.get("path") == "/v1/wallet/refund"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apikey_refund_rejects_on_concurrent_balance_change() -> None:
|
||||||
|
"""When the debit CAS fails (rowcount=0), no token is minted and 409 is returned."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
key = _make_api_key(balance=5000, refund_currency="sat")
|
||||||
|
|
||||||
|
session = MagicMock()
|
||||||
|
# Debit returns rowcount=0 → balance changed concurrently
|
||||||
|
session.exec = AsyncMock(return_value=_update_result(0))
|
||||||
|
session.commit = AsyncMock()
|
||||||
|
|
||||||
|
mock_send_token = AsyncMock(return_value="cashuAshould_not_be_minted")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
|
||||||
|
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||||
|
patch("routstr.balance.send_token", mock_send_token),
|
||||||
|
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||||
|
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
|
||||||
|
patch("routstr.balance._refund_cache_set", AsyncMock()),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await refund_wallet_endpoint(
|
||||||
|
authorization="Bearer sk-testhash",
|
||||||
|
x_cashu=None,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 409
|
||||||
|
# Crucially: send_token must NOT have been called
|
||||||
|
mock_send_token.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_credit_balance_stores_apikey_transaction_history() -> None:
|
||||||
|
key = _make_api_key(balance=1000)
|
||||||
|
session = MagicMock()
|
||||||
|
session.exec = AsyncMock(return_value=_update_result(1))
|
||||||
|
session.commit = AsyncMock()
|
||||||
|
session.refresh = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.recieve_token",
|
||||||
|
AsyncMock(return_value=(100, "sat", "https://mint.example")),
|
||||||
|
),
|
||||||
|
patch("routstr.wallet.store_cashu_transaction", AsyncMock()) as mock_store,
|
||||||
|
):
|
||||||
|
amount = await credit_balance("cashuAtopup_token", key, session)
|
||||||
|
|
||||||
|
assert amount == 100_000
|
||||||
|
mock_store.assert_awaited_once()
|
||||||
|
call_kwargs = mock_store.call_args.kwargs
|
||||||
|
assert call_kwargs["typ"] == "in"
|
||||||
|
assert call_kwargs["source"] == "apikey"
|
||||||
|
assert call_kwargs["api_key_hashed_key"] == key.hashed_key
|
||||||
|
assert call_kwargs["amount"] == 100
|
||||||
|
assert call_kwargs["unit"] == "sat"
|
||||||
|
assert call_kwargs["token"] == "cashuAtopup_token"
|
||||||
|
assert call_kwargs["mint_url"] == "https://mint.example"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
|
||||||
|
"""When debit succeeds but minting fails, balance must be restored."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
key = _make_api_key(balance=5000, refund_currency="sat")
|
||||||
|
|
||||||
|
# First exec call = debit (succeeds), second = restore
|
||||||
|
session = MagicMock()
|
||||||
|
session.exec = AsyncMock(side_effect=[_update_result(1), _update_result(1)])
|
||||||
|
session.commit = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
|
||||||
|
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||||
|
patch("routstr.balance.send_token", AsyncMock(side_effect=Exception("mint down"))),
|
||||||
|
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||||
|
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
|
||||||
|
patch("routstr.balance._refund_cache_set", AsyncMock()),
|
||||||
|
patch("routstr.balance.logger"),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await refund_wallet_endpoint(
|
||||||
|
authorization="Bearer sk-testhash",
|
||||||
|
x_cashu=None,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 503
|
||||||
|
# Verify two exec calls: debit + restore
|
||||||
|
assert session.exec.await_count == 2
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Regression tests for the periodic upstream models refresh loop."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from typing import cast
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
|
||||||
|
os.environ.setdefault("UPSTREAM_API_KEY", "test")
|
||||||
|
|
||||||
|
from routstr.upstream.base import BaseUpstreamProvider # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeUpstream:
|
||||||
|
"""Minimal stand-in for BaseUpstreamProvider used by the refresh loop.
|
||||||
|
|
||||||
|
Only ``base_url`` (for error logging) and ``refresh_models_cache`` (the call
|
||||||
|
under test) are exercised; everything else stays unused.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
self.base_url = f"http://{name}"
|
||||||
|
self.refresh_models_cache = AsyncMock()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fake_upstream(name: str) -> BaseUpstreamProvider:
|
||||||
|
# The loop only uses duck-typed attributes — cast keeps the test type-clean
|
||||||
|
# without dragging in BaseUpstreamProvider's full constructor.
|
||||||
|
return cast(BaseUpstreamProvider, _FakeUpstream(name))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_loop_picks_up_providers_added_after_startup(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""If a provider is added after the loop starts (e.g. via reinitialize_upstreams),
|
||||||
|
the next loop iteration must refresh it. Previously the loop captured the upstream
|
||||||
|
list at startup and missed any later additions."""
|
||||||
|
from routstr.core.settings import settings as global_settings
|
||||||
|
from routstr.upstream.helpers import refresh_upstreams_models_periodically
|
||||||
|
|
||||||
|
# Tight interval so the test finishes quickly.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
global_settings, "models_refresh_interval_seconds", 1, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
initial_upstream = _make_fake_upstream("initial")
|
||||||
|
live_list: list[BaseUpstreamProvider] = [initial_upstream]
|
||||||
|
|
||||||
|
# Stub out the post-iteration sats-pricing refresh so the loop body has no DB deps.
|
||||||
|
async def _noop_pricing_refresh() -> None: # pragma: no cover - trivial stub
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routstr.payment.models._update_sats_pricing_once",
|
||||||
|
_noop_pricing_refresh,
|
||||||
|
)
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
refresh_upstreams_models_periodically(lambda: live_list)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Wait for the first iteration to refresh the initial upstream.
|
||||||
|
for _ in range(40):
|
||||||
|
if initial_upstream.refresh_models_cache.await_count >= 1: # type: ignore[attr-defined]
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert initial_upstream.refresh_models_cache.await_count >= 1, ( # type: ignore[attr-defined]
|
||||||
|
"loop did not refresh the initial upstream within the timeout"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate reinitialize_upstreams: replace the live list contents with new
|
||||||
|
# provider instances. The loop must observe the swap on its next tick.
|
||||||
|
new_upstream = _make_fake_upstream("added-after-startup")
|
||||||
|
live_list[:] = [new_upstream]
|
||||||
|
|
||||||
|
for _ in range(60):
|
||||||
|
if new_upstream.refresh_models_cache.await_count >= 1: # type: ignore[attr-defined]
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
assert new_upstream.refresh_models_cache.await_count >= 1, ( # type: ignore[attr-defined]
|
||||||
|
"loop did not refresh the upstream added after startup — "
|
||||||
|
"regression: list snapshot captured at startup"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_loop_disabled_when_interval_non_positive(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
from routstr.core.settings import settings as global_settings
|
||||||
|
from routstr.upstream.helpers import refresh_upstreams_models_periodically
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
global_settings, "models_refresh_interval_seconds", 0, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
upstream = _make_fake_upstream("never-refreshed")
|
||||||
|
|
||||||
|
# Loop must return immediately without ever touching the upstream.
|
||||||
|
await asyncio.wait_for(
|
||||||
|
refresh_upstreams_models_periodically(lambda: [upstream]),
|
||||||
|
timeout=1.0,
|
||||||
|
)
|
||||||
|
upstream.refresh_models_cache.assert_not_awaited() # type: ignore[attr-defined]
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import json
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from routstr.core.db import ApiKey
|
||||||
|
from routstr.upstream.base import BaseUpstreamProvider
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_with_id_injection() -> None:
|
||||||
|
"""Test that stream_with_cost correctly injects IDs into complete JSON chunks but skips partials."""
|
||||||
|
provider = BaseUpstreamProvider(
|
||||||
|
base_url="https://api.example.com", api_key="test_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock response with mixed chunks:
|
||||||
|
# 1. Complete JSON without ID
|
||||||
|
# 2. Partial JSON (should be passed through)
|
||||||
|
# 3. Complete JSON with ID (should be preserved or updated if requested_model is set)
|
||||||
|
# 4. [DONE] message
|
||||||
|
chunks = [
|
||||||
|
b'data: {"choices": [{"delta": {"content": "Hello"}}]}\n\n',
|
||||||
|
b'data: {"choices": [{"delta": {"content": "', # Partial
|
||||||
|
b'world"}}]}\n\n',
|
||||||
|
b'data: {"id": "existing-id", "choices": [{"delta": {"content": "!"}}]}\n\n',
|
||||||
|
b"data: [DONE]\n\n",
|
||||||
|
]
|
||||||
|
|
||||||
|
async def aiter_bytes() -> AsyncGenerator[bytes, None]:
|
||||||
|
for chunk in chunks:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.headers = {"content-type": "text/event-stream"}
|
||||||
|
mock_response.aiter_bytes = aiter_bytes
|
||||||
|
|
||||||
|
key = MagicMock(spec=ApiKey)
|
||||||
|
key.hashed_key = "test_hash"
|
||||||
|
key.balance = 1000
|
||||||
|
|
||||||
|
background_tasks = MagicMock()
|
||||||
|
|
||||||
|
# We need to mock adjust_payment_for_tokens since it's called at the end
|
||||||
|
with MagicMock():
|
||||||
|
from routstr.upstream import base
|
||||||
|
|
||||||
|
# Mocking the module-level function used in the generator
|
||||||
|
base.adjust_payment_for_tokens = AsyncMock(
|
||||||
|
return_value={"total_usd": 0.1, "total_msats": 100}
|
||||||
|
)
|
||||||
|
base.create_session = MagicMock()
|
||||||
|
|
||||||
|
streaming_response = await provider.handle_streaming_chat_completion(
|
||||||
|
response=mock_response,
|
||||||
|
key=key,
|
||||||
|
max_cost_for_model=100,
|
||||||
|
background_tasks=background_tasks,
|
||||||
|
requested_model="test-model",
|
||||||
|
)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
async for chunk in streaming_response.body_iterator:
|
||||||
|
results.append(chunk)
|
||||||
|
|
||||||
|
# Parse results
|
||||||
|
parsed_results = []
|
||||||
|
for r in results:
|
||||||
|
if isinstance(r, bytes) and r.startswith(b"data: "):
|
||||||
|
data = r[6:].decode().strip()
|
||||||
|
if data == "[DONE]":
|
||||||
|
parsed_results.append(data)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
parsed_results.append(json.loads(data))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
parsed_results.append(
|
||||||
|
data
|
||||||
|
) # Keep as string if it failed to parse
|
||||||
|
|
||||||
|
# Verifications
|
||||||
|
# 1. First chunk should have an injected ID and the requested model
|
||||||
|
assert isinstance(parsed_results[0], dict)
|
||||||
|
assert "id" in parsed_results[0]
|
||||||
|
assert parsed_results[0]["id"].startswith("chatcmpl-")
|
||||||
|
assert parsed_results[0]["model"] == "test-model"
|
||||||
|
|
||||||
|
# 2. Second chunk was partial, should be passed as-is
|
||||||
|
# In current implementation, re.split(b"data: ", b'data: {...') gives ['', '{...']
|
||||||
|
# The first empty part is skipped. The second part is processed.
|
||||||
|
|
||||||
|
# Check that we have results
|
||||||
|
assert len(parsed_results) >= 4
|
||||||
|
|
||||||
|
# Find the chunk that was "existing-id"
|
||||||
|
id_chunk = next(
|
||||||
|
r
|
||||||
|
for r in parsed_results
|
||||||
|
if isinstance(r, dict)
|
||||||
|
and "choices" in r
|
||||||
|
and r["choices"][0]["delta"].get("content") == "!"
|
||||||
|
)
|
||||||
|
assert id_chunk["id"] == parsed_results[0]["id"]
|
||||||
|
assert id_chunk["model"] == "test-model"
|
||||||
|
|
||||||
|
# 4. [DONE] should be there
|
||||||
|
assert "[DONE]" in parsed_results
|
||||||
@@ -220,6 +220,33 @@ async def test_recieve_token_untrusted_mint() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_swap_to_primary_mint_already_on_primary() -> None:
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
from routstr.wallet import swap_to_primary_mint
|
||||||
|
|
||||||
|
mock_token = Mock()
|
||||||
|
mock_token.mint = settings.primary_mint
|
||||||
|
mock_token.amount = 1000
|
||||||
|
mock_token.unit = "sat"
|
||||||
|
mock_token.proofs = []
|
||||||
|
|
||||||
|
mock_token_wallet = Mock()
|
||||||
|
mock_token_wallet.split = AsyncMock(return_value=None)
|
||||||
|
mock_token_wallet.request_mint = AsyncMock()
|
||||||
|
mock_token_wallet.melt_quote = AsyncMock()
|
||||||
|
|
||||||
|
with patch("routstr.wallet.get_wallet", AsyncMock(return_value=mock_token_wallet)):
|
||||||
|
amount, unit, mint = await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||||
|
|
||||||
|
assert amount == 1000
|
||||||
|
assert unit == "sat"
|
||||||
|
assert mint == settings.primary_mint
|
||||||
|
mock_token_wallet.split.assert_called_once()
|
||||||
|
mock_token_wallet.request_mint.assert_not_called()
|
||||||
|
mock_token_wallet.melt_quote.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
async def test_swap_to_primary_mint_success() -> None:
|
async def test_swap_to_primary_mint_success() -> None:
|
||||||
"""Test successful swap with dynamic fee calculation."""
|
"""Test successful swap with dynamic fee calculation."""
|
||||||
from routstr.wallet import swap_to_primary_mint
|
from routstr.wallet import swap_to_primary_mint
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
|
||||||
|
os.environ.setdefault("UPSTREAM_API_KEY", "test")
|
||||||
|
|
||||||
|
from routstr.payment.cost_calculation import CostData # noqa: E402
|
||||||
|
from routstr.upstream.base import BaseUpstreamProvider # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _make_provider() -> BaseUpstreamProvider:
|
||||||
|
return BaseUpstreamProvider(base_url="http://test", api_key="test-key")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_httpx_response(status_code: int = 200) -> httpx.Response:
|
||||||
|
return httpx.Response(status_code, headers={})
|
||||||
|
|
||||||
|
|
||||||
|
def _make_cost_data(total_msats: int = 5000) -> CostData:
|
||||||
|
return CostData(
|
||||||
|
base_msats=0,
|
||||||
|
input_msats=3000,
|
||||||
|
output_msats=2000,
|
||||||
|
total_msats=total_msats,
|
||||||
|
total_usd=0.00025,
|
||||||
|
input_tokens=100,
|
||||||
|
output_tokens=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Non-streaming (chat completions)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_streaming_includes_cost_sats() -> None:
|
||||||
|
provider = _make_provider()
|
||||||
|
cost_data = _make_cost_data(total_msats=5000)
|
||||||
|
|
||||||
|
response_body = {
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 100,
|
||||||
|
"completion_tokens": 50,
|
||||||
|
"total_tokens": 150,
|
||||||
|
"cost": 0.00025,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
content_str = json.dumps(response_body)
|
||||||
|
httpx_response = _make_httpx_response()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(provider, "get_x_cashu_cost", new=AsyncMock(return_value=cost_data)),
|
||||||
|
patch.object(provider, "send_refund", new=AsyncMock(return_value="cashuA_refund_token")),
|
||||||
|
):
|
||||||
|
response = await provider.handle_x_cashu_non_streaming_response(
|
||||||
|
content_str=content_str,
|
||||||
|
response=httpx_response,
|
||||||
|
amount=10000,
|
||||||
|
unit="msat",
|
||||||
|
max_cost_for_model=10000,
|
||||||
|
mint=None,
|
||||||
|
payment_token_hash=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
body = json.loads(response.body)
|
||||||
|
assert "cost_sats" in body["usage"]
|
||||||
|
assert body["usage"]["cost_sats"] == 5 # 5000 msats // 1000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_streaming_cost_sats_value_rounds_down() -> None:
|
||||||
|
provider = _make_provider()
|
||||||
|
cost_data = _make_cost_data(total_msats=1999)
|
||||||
|
|
||||||
|
response_body = {"model": "gpt-4o", "usage": {"prompt_tokens": 10}}
|
||||||
|
content_str = json.dumps(response_body)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(provider, "get_x_cashu_cost", new=AsyncMock(return_value=cost_data)),
|
||||||
|
patch.object(provider, "send_refund", new=AsyncMock(return_value="cashuA_refund_token")),
|
||||||
|
):
|
||||||
|
response = await provider.handle_x_cashu_non_streaming_response(
|
||||||
|
content_str=content_str,
|
||||||
|
response=_make_httpx_response(),
|
||||||
|
amount=10000,
|
||||||
|
unit="msat",
|
||||||
|
max_cost_for_model=10000,
|
||||||
|
)
|
||||||
|
|
||||||
|
body = json.loads(response.body)
|
||||||
|
assert body["usage"]["cost_sats"] == 1 # 1999 // 1000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_streaming_preserves_existing_usage_fields() -> None:
|
||||||
|
provider = _make_provider()
|
||||||
|
cost_data = _make_cost_data(total_msats=3000)
|
||||||
|
|
||||||
|
response_body = {
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 100,
|
||||||
|
"completion_tokens": 50,
|
||||||
|
"total_tokens": 150,
|
||||||
|
"cost": 0.00015,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(provider, "get_x_cashu_cost", new=AsyncMock(return_value=cost_data)),
|
||||||
|
patch.object(provider, "send_refund", new=AsyncMock(return_value="cashuA_refund_token")),
|
||||||
|
):
|
||||||
|
response = await provider.handle_x_cashu_non_streaming_response(
|
||||||
|
content_str=json.dumps(response_body),
|
||||||
|
response=_make_httpx_response(),
|
||||||
|
amount=10000,
|
||||||
|
unit="msat",
|
||||||
|
max_cost_for_model=10000,
|
||||||
|
)
|
||||||
|
|
||||||
|
body = json.loads(response.body)
|
||||||
|
usage = body["usage"]
|
||||||
|
assert usage["prompt_tokens"] == 100
|
||||||
|
assert usage["completion_tokens"] == 50
|
||||||
|
assert usage["total_tokens"] == 150
|
||||||
|
assert usage["cost"] == 0.00015
|
||||||
|
assert usage["cost_sats"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Streaming (chat completions)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _collect_streaming(response: object) -> list[str]:
|
||||||
|
chunks: list[str] = []
|
||||||
|
async for chunk in response.body_iterator: # type: ignore[attr-defined]
|
||||||
|
if isinstance(chunk, bytes):
|
||||||
|
chunks.append(chunk.decode("utf-8"))
|
||||||
|
else:
|
||||||
|
chunks.append(str(chunk))
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_streaming_includes_cost_sats_in_usage_chunk() -> None:
|
||||||
|
provider = _make_provider()
|
||||||
|
cost_data = _make_cost_data(total_msats=7000)
|
||||||
|
|
||||||
|
usage_chunk = {
|
||||||
|
"id": "chatcmpl-123",
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
|
||||||
|
}
|
||||||
|
content_str = "\n".join([
|
||||||
|
'data: {"id":"chatcmpl-123","model":"gpt-4o","choices":[]}',
|
||||||
|
f"data: {json.dumps(usage_chunk)}",
|
||||||
|
"data: [DONE]",
|
||||||
|
])
|
||||||
|
|
||||||
|
with patch.object(provider, "get_x_cashu_cost", new=AsyncMock(return_value=cost_data)):
|
||||||
|
response = await provider.handle_x_cashu_streaming_response(
|
||||||
|
content_str=content_str,
|
||||||
|
response=_make_httpx_response(),
|
||||||
|
amount=10000,
|
||||||
|
unit="msat",
|
||||||
|
max_cost_for_model=10000,
|
||||||
|
mint=None,
|
||||||
|
payment_token_hash=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks = await _collect_streaming(response)
|
||||||
|
|
||||||
|
full_output = "".join(chunks)
|
||||||
|
usage_line = next(
|
||||||
|
line for line in full_output.split("\n") if '"usage"' in line and "cost_sats" in line
|
||||||
|
)
|
||||||
|
data_json = json.loads(usage_line.lstrip("data: ").strip())
|
||||||
|
assert data_json["usage"]["cost_sats"] == 7 # 7000 // 1000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_streaming_non_usage_chunks_unmodified() -> None:
|
||||||
|
provider = _make_provider()
|
||||||
|
cost_data = _make_cost_data(total_msats=2000)
|
||||||
|
|
||||||
|
regular_chunk = {"id": "chatcmpl-123", "model": "gpt-4o", "choices": [{"delta": {"content": "hi"}}]}
|
||||||
|
usage_chunk = {"id": "chatcmpl-123", "model": "gpt-4o", "usage": {"prompt_tokens": 10}}
|
||||||
|
content_str = "\n".join([
|
||||||
|
f"data: {json.dumps(regular_chunk)}",
|
||||||
|
f"data: {json.dumps(usage_chunk)}",
|
||||||
|
"data: [DONE]",
|
||||||
|
])
|
||||||
|
|
||||||
|
with patch.object(provider, "get_x_cashu_cost", new=AsyncMock(return_value=cost_data)):
|
||||||
|
response = await provider.handle_x_cashu_streaming_response(
|
||||||
|
content_str=content_str,
|
||||||
|
response=_make_httpx_response(),
|
||||||
|
amount=10000,
|
||||||
|
unit="msat",
|
||||||
|
max_cost_for_model=10000,
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks = await _collect_streaming(response)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
line for line in "".join(chunks).split("\n")
|
||||||
|
if line.startswith("data: ") and line != "data: [DONE]"
|
||||||
|
]
|
||||||
|
regular_line_data = json.loads(lines[0][6:])
|
||||||
|
# regular chunk should not have cost_sats injected
|
||||||
|
assert "cost_sats" not in regular_line_data.get("usage", {})
|
||||||
+336
-142
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { AppPageShell } from '@/components/app-page-shell';
|
import { AppPageShell } from '@/components/app-page-shell';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -30,7 +31,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
@@ -47,6 +48,10 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
Key,
|
||||||
|
Zap,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AdminService, type Transaction } from '@/lib/api/services/admin';
|
import { AdminService, type Transaction } from '@/lib/api/services/admin';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@@ -54,6 +59,147 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
const STORAGE_KEY = 'routstr-transaction-filters';
|
const STORAGE_KEY = 'routstr-transaction-filters';
|
||||||
|
|
||||||
|
function TransactionTable({
|
||||||
|
transactions,
|
||||||
|
copiedId,
|
||||||
|
onCopy,
|
||||||
|
getStatusBadge,
|
||||||
|
}: {
|
||||||
|
transactions: Transaction[];
|
||||||
|
copiedId: string | null;
|
||||||
|
onCopy: (text: string, id: string) => void;
|
||||||
|
getStatusBadge: (tx: Transaction) => React.ReactNode;
|
||||||
|
}) {
|
||||||
|
if (transactions.length === 0) {
|
||||||
|
return (
|
||||||
|
<Empty className='py-8'>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant='icon'>
|
||||||
|
<Receipt className='h-4 w-4' />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>No transactions found</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
Try adjusting your filters or check back later.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
|
||||||
|
<div className='min-w-[800px]'>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Amount</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>API Key</TableHead>
|
||||||
|
<TableHead>Request ID</TableHead>
|
||||||
|
<TableHead>Mint</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead className='text-right'>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{transactions.map((tx) => (
|
||||||
|
<TableRow key={tx.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
{tx.type === 'in' ? (
|
||||||
|
<ArrowDownLeft className='h-4 w-4 text-green-500' />
|
||||||
|
) : (
|
||||||
|
<ArrowUpRight className='h-4 w-4 text-blue-500' />
|
||||||
|
)}
|
||||||
|
<span className='capitalize'>{tx.type}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='font-mono'>
|
||||||
|
{tx.amount} {tx.unit}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(tx)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{tx.api_key_hashed_key ? (
|
||||||
|
<div className='flex items-center gap-1 text-xs'>
|
||||||
|
<span className='max-w-[120px] truncate font-mono'>
|
||||||
|
{tx.api_key_hashed_key.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='h-4 w-4'
|
||||||
|
onClick={() =>
|
||||||
|
onCopy(tx.api_key_hashed_key!, tx.id + '-apikey')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copiedId === tx.id + '-apikey' ? (
|
||||||
|
<Check className='h-3 w-3' />
|
||||||
|
) : (
|
||||||
|
<Copy className='h-3 w-3' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className='text-muted-foreground text-xs'>—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{tx.request_id ? (
|
||||||
|
<div className='flex items-center gap-1 text-xs'>
|
||||||
|
<span className='max-w-[150px] truncate font-mono'>
|
||||||
|
{tx.request_id}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='h-4 w-4'
|
||||||
|
onClick={() => onCopy(tx.request_id!, tx.id + '-req')}
|
||||||
|
>
|
||||||
|
{copiedId === tx.id + '-req' ? (
|
||||||
|
<Check className='h-3 w-3' />
|
||||||
|
) : (
|
||||||
|
<Copy className='h-3 w-3' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className='text-muted-foreground text-xs'>—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex max-w-[150px] items-center gap-1 truncate text-xs'>
|
||||||
|
<span className='truncate'>{tx.mint_url}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='text-xs whitespace-nowrap'>
|
||||||
|
{format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='text-right'>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='h-8 w-8'
|
||||||
|
onClick={() => onCopy(tx.token, tx.id + '-token')}
|
||||||
|
title='Copy Token'
|
||||||
|
>
|
||||||
|
{copiedId === tx.id + '-token' ? (
|
||||||
|
<Check className='h-4 w-4' />
|
||||||
|
) : (
|
||||||
|
<Copy className='h-4 w-4' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<ScrollBar orientation='horizontal' />
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TransactionsPage() {
|
export default function TransactionsPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [type, setType] = useState<string>('all');
|
const [type, setType] = useState<string>('all');
|
||||||
@@ -81,21 +227,63 @@ export default function TransactionsPage() {
|
|||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
|
||||||
}, [search, type, status]);
|
}, [search, type, status]);
|
||||||
|
|
||||||
const { data, isLoading, refetch, isRefetching } = useQuery({
|
const PAGE_SIZE = 50;
|
||||||
queryKey: ['transactions', type, status, search],
|
const [activeTab, setActiveTab] = useState<string>('x-cashu');
|
||||||
|
const [xcashuPage, setXcashuPage] = useState(0);
|
||||||
|
const [apikeyPage, setApikeyPage] = useState(0);
|
||||||
|
|
||||||
|
const typeParam = type === 'all' ? undefined : type;
|
||||||
|
const statusParam = status === 'all' ? undefined : status;
|
||||||
|
const searchParam = search || undefined;
|
||||||
|
|
||||||
|
const xcashuQuery = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
'transactions',
|
||||||
|
'x-cashu',
|
||||||
|
typeParam,
|
||||||
|
statusParam,
|
||||||
|
searchParam,
|
||||||
|
xcashuPage,
|
||||||
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
AdminService.getTransactions(
|
AdminService.getTransactions(
|
||||||
type === 'all' ? undefined : type,
|
typeParam,
|
||||||
status === 'all' ? undefined : status,
|
statusParam,
|
||||||
search || undefined,
|
searchParam,
|
||||||
100
|
'x-cashu',
|
||||||
|
PAGE_SIZE,
|
||||||
|
xcashuPage * PAGE_SIZE
|
||||||
),
|
),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const apikeyQuery = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
'transactions',
|
||||||
|
'apikey',
|
||||||
|
typeParam,
|
||||||
|
statusParam,
|
||||||
|
searchParam,
|
||||||
|
apikeyPage,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
AdminService.getTransactions(
|
||||||
|
typeParam,
|
||||||
|
statusParam,
|
||||||
|
searchParam,
|
||||||
|
'apikey',
|
||||||
|
PAGE_SIZE,
|
||||||
|
apikeyPage * PAGE_SIZE
|
||||||
|
),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleClearFilters = () => {
|
const handleClearFilters = () => {
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setType('all');
|
setType('all');
|
||||||
setStatus('all');
|
setStatus('all');
|
||||||
|
setXcashuPage(0);
|
||||||
|
setApikeyPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyToClipboard = (text: string, id: string) => {
|
const copyToClipboard = (text: string, id: string) => {
|
||||||
@@ -145,15 +333,91 @@ export default function TransactionsPage() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' • ');
|
.join(' • ');
|
||||||
|
|
||||||
|
// Reset pages when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
setXcashuPage(0);
|
||||||
|
setApikeyPage(0);
|
||||||
|
}, [type, status, search]);
|
||||||
|
|
||||||
|
const isRefetching = xcashuQuery.isRefetching || apikeyQuery.isRefetching;
|
||||||
|
|
||||||
|
const renderCardContent = (
|
||||||
|
query: typeof xcashuQuery,
|
||||||
|
page: number,
|
||||||
|
setPage: (p: number) => void
|
||||||
|
) => {
|
||||||
|
if (query.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<Skeleton
|
||||||
|
key={`tx-loading-${index}`}
|
||||||
|
className='h-16 w-full rounded-lg'
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactions = query.data?.transactions ?? [];
|
||||||
|
const total = query.data?.total ?? 0;
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className='flex flex-col gap-2 border-b pb-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||||
|
<span className='text-muted-foreground text-xs sm:text-sm'>
|
||||||
|
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)}{' '}
|
||||||
|
of {total}
|
||||||
|
</span>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className='h-4 w-4' />
|
||||||
|
<span className='hidden sm:inline'>Previous</span>
|
||||||
|
</Button>
|
||||||
|
<span className='text-xs sm:text-sm'>
|
||||||
|
{page + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
>
|
||||||
|
<span className='hidden sm:inline'>Next</span>
|
||||||
|
<ChevronRight className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<TransactionTable
|
||||||
|
transactions={transactions}
|
||||||
|
copiedId={copiedId}
|
||||||
|
onCopy={copyToClipboard}
|
||||||
|
getStatusBadge={getStatusBadge}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl overflow-x-hidden'>
|
<AppPageShell contentClassName='mx-auto w-full max-w-5xl overflow-x-hidden'>
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title='X-Cashu Transactions'
|
title='Cashu Transactions'
|
||||||
description='View all incoming and outgoing X-Cashu token transactions.'
|
description='View all incoming and outgoing Cashu token transactions.'
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
onClick={() => refetch()}
|
onClick={() => {
|
||||||
|
xcashuQuery.refetch();
|
||||||
|
apikeyQuery.refetch();
|
||||||
|
}}
|
||||||
variant='outline'
|
variant='outline'
|
||||||
size='sm'
|
size='sm'
|
||||||
disabled={isRefetching}
|
disabled={isRefetching}
|
||||||
@@ -181,7 +445,7 @@ export default function TransactionsPage() {
|
|||||||
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
|
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
|
||||||
<Input
|
<Input
|
||||||
id='search'
|
id='search'
|
||||||
placeholder='Search by ID, token or request ID...'
|
placeholder='Search by ID, token, request ID or key hash...'
|
||||||
className='pl-8'
|
className='pl-8'
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
@@ -228,138 +492,68 @@ export default function TransactionsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Tabs
|
||||||
<CardHeader>
|
defaultValue='x-cashu'
|
||||||
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
value={activeTab}
|
||||||
<CardTitle>Transaction History</CardTitle>
|
onValueChange={setActiveTab}
|
||||||
{data && (
|
>
|
||||||
<Badge variant='secondary'>
|
<TabsList className='mb-4'>
|
||||||
{data.transactions.length} entries
|
<TabsTrigger value='x-cashu' className='flex items-center gap-2'>
|
||||||
|
<Zap className='h-4 w-4' />
|
||||||
|
X-Cashu
|
||||||
|
{xcashuQuery.data && (
|
||||||
|
<Badge variant='secondary' className='ml-1'>
|
||||||
|
{xcashuQuery.data.total}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TabsTrigger>
|
||||||
{hasActiveFilters && (
|
<TabsTrigger value='apikey' className='flex items-center gap-2'>
|
||||||
<CardDescription>
|
<Key className='h-4 w-4' />
|
||||||
Showing transactions filtered by {activeFilterDescription}
|
API Key
|
||||||
</CardDescription>
|
{apikeyQuery.data && (
|
||||||
)}
|
<Badge variant='secondary' className='ml-1'>
|
||||||
</CardHeader>
|
{apikeyQuery.data.total}
|
||||||
<CardContent className='overflow-hidden'>
|
</Badge>
|
||||||
{isLoading ? (
|
)}
|
||||||
<div className='space-y-2'>
|
</TabsTrigger>
|
||||||
{Array.from({ length: 8 }).map((_, index) => (
|
</TabsList>
|
||||||
<Skeleton
|
|
||||||
key={`tx-loading-${index}`}
|
<TabsContent value='x-cashu'>
|
||||||
className='h-16 w-full rounded-lg'
|
<Card>
|
||||||
/>
|
<CardHeader>
|
||||||
))}
|
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||||
</div>
|
<CardTitle>X-Cashu Transaction History</CardTitle>
|
||||||
) : data?.transactions && data.transactions.length > 0 ? (
|
{hasActiveFilters && (
|
||||||
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
|
<CardDescription>
|
||||||
<Table>
|
Filtered by {activeFilterDescription}
|
||||||
<TableHeader>
|
</CardDescription>
|
||||||
<TableRow>
|
)}
|
||||||
<TableHead>Type</TableHead>
|
</div>
|
||||||
<TableHead>Amount</TableHead>
|
</CardHeader>
|
||||||
<TableHead>Status</TableHead>
|
<CardContent className='overflow-hidden'>
|
||||||
<TableHead>Request ID</TableHead>
|
{renderCardContent(xcashuQuery, xcashuPage, setXcashuPage)}
|
||||||
<TableHead>Mint</TableHead>
|
</CardContent>
|
||||||
<TableHead>Date</TableHead>
|
</Card>
|
||||||
<TableHead className='text-right'>Actions</TableHead>
|
</TabsContent>
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
<TabsContent value='apikey'>
|
||||||
<TableBody>
|
<Card>
|
||||||
{data.transactions.map((tx) => (
|
<CardHeader>
|
||||||
<TableRow key={tx.id}>
|
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||||
<TableCell>
|
<CardTitle>API Key Transaction History</CardTitle>
|
||||||
<div className='flex items-center gap-2'>
|
{hasActiveFilters && (
|
||||||
{tx.type === 'in' ? (
|
<CardDescription>
|
||||||
<ArrowDownLeft className='h-4 w-4 text-green-500' />
|
Filtered by {activeFilterDescription}
|
||||||
) : (
|
</CardDescription>
|
||||||
<ArrowUpRight className='h-4 w-4 text-blue-500' />
|
)}
|
||||||
)}
|
</div>
|
||||||
<span className='capitalize'>{tx.type}</span>
|
</CardHeader>
|
||||||
</div>
|
<CardContent className='overflow-hidden'>
|
||||||
</TableCell>
|
{renderCardContent(apikeyQuery, apikeyPage, setApikeyPage)}
|
||||||
<TableCell className='font-mono'>
|
</CardContent>
|
||||||
{tx.amount} {tx.unit}
|
</Card>
|
||||||
</TableCell>
|
</TabsContent>
|
||||||
<TableCell>{getStatusBadge(tx)}</TableCell>
|
</Tabs>
|
||||||
<TableCell>
|
|
||||||
{tx.request_id ? (
|
|
||||||
<div className='flex items-center gap-1 text-xs'>
|
|
||||||
<span className='max-w-[150px] truncate font-mono'>
|
|
||||||
{tx.request_id}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant='ghost'
|
|
||||||
size='icon'
|
|
||||||
className='h-4 w-4'
|
|
||||||
onClick={() =>
|
|
||||||
copyToClipboard(
|
|
||||||
tx.request_id!,
|
|
||||||
tx.id + '-req'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{copiedId === tx.id + '-req' ? (
|
|
||||||
<Check className='h-3 w-3' />
|
|
||||||
) : (
|
|
||||||
<Copy className='h-3 w-3' />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className='text-muted-foreground text-xs'>
|
|
||||||
—
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className='flex max-w-[150px] items-center gap-1 truncate text-xs'>
|
|
||||||
<span className='truncate'>{tx.mint_url}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-xs whitespace-nowrap'>
|
|
||||||
{format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-right'>
|
|
||||||
<Button
|
|
||||||
variant='ghost'
|
|
||||||
size='icon'
|
|
||||||
className='h-8 w-8'
|
|
||||||
onClick={() =>
|
|
||||||
copyToClipboard(tx.token, tx.id + '-token')
|
|
||||||
}
|
|
||||||
title='Copy Token'
|
|
||||||
>
|
|
||||||
{copiedId === tx.id + '-token' ? (
|
|
||||||
<Check className='h-4 w-4' />
|
|
||||||
) : (
|
|
||||||
<Copy className='h-4 w-4' />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</ScrollArea>
|
|
||||||
) : (
|
|
||||||
<Empty className='py-8'>
|
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyMedia variant='icon'>
|
|
||||||
<Receipt className='h-4 w-4' />
|
|
||||||
</EmptyMedia>
|
|
||||||
<EmptyTitle>No transactions found</EmptyTitle>
|
|
||||||
<EmptyDescription>
|
|
||||||
Try adjusting your filters or check back later.
|
|
||||||
</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</AppPageShell>
|
</AppPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Loader2, Plus } from 'lucide-react';
|
import { Check, Copy, Loader2, Plus } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
|
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ const FormSchema = z.object({
|
|||||||
instruct_type: z.string().default(''),
|
instruct_type: z.string().default(''),
|
||||||
canonical_slug: z.string().default(''),
|
canonical_slug: z.string().default(''),
|
||||||
alias_ids_raw: z.string().default(''),
|
alias_ids_raw: z.string().default(''),
|
||||||
|
forwarded_model_id: z.string().default(''),
|
||||||
upstream_provider_id: z.string().default(''),
|
upstream_provider_id: z.string().default(''),
|
||||||
input_cost: z.coerce.number().min(0).default(0),
|
input_cost: z.coerce.number().min(0).default(0),
|
||||||
output_cost: z.coerce.number().min(0).default(0),
|
output_cost: z.coerce.number().min(0).default(0),
|
||||||
@@ -104,6 +105,7 @@ export function AddProviderModelDialog({
|
|||||||
const [isPresetOpen, setIsPresetOpen] = useState(false);
|
const [isPresetOpen, setIsPresetOpen] = useState(false);
|
||||||
const [selectedPresetLabel, setSelectedPresetLabel] =
|
const [selectedPresetLabel, setSelectedPresetLabel] =
|
||||||
useState('Select a preset');
|
useState('Select a preset');
|
||||||
|
const [forwardedModelIdCopied, setForwardedModelIdCopied] = useState(false);
|
||||||
|
|
||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
resolver: zodResolver(FormSchema) as never,
|
resolver: zodResolver(FormSchema) as never,
|
||||||
@@ -119,6 +121,7 @@ export function AddProviderModelDialog({
|
|||||||
instruct_type: '',
|
instruct_type: '',
|
||||||
canonical_slug: '',
|
canonical_slug: '',
|
||||||
alias_ids_raw: '',
|
alias_ids_raw: '',
|
||||||
|
forwarded_model_id: '',
|
||||||
upstream_provider_id: '',
|
upstream_provider_id: '',
|
||||||
input_cost: 0,
|
input_cost: 0,
|
||||||
output_cost: 0,
|
output_cost: 0,
|
||||||
@@ -180,6 +183,7 @@ export function AddProviderModelDialog({
|
|||||||
: '',
|
: '',
|
||||||
canonical_slug: initialData.canonical_slug || '',
|
canonical_slug: initialData.canonical_slug || '',
|
||||||
alias_ids_raw: listToString(initialData.alias_ids),
|
alias_ids_raw: listToString(initialData.alias_ids),
|
||||||
|
forwarded_model_id: initialData.forwarded_model_id || initialData.id,
|
||||||
upstream_provider_id:
|
upstream_provider_id:
|
||||||
typeof initialData.upstream_provider_id === 'string'
|
typeof initialData.upstream_provider_id === 'string'
|
||||||
? initialData.upstream_provider_id
|
? initialData.upstream_provider_id
|
||||||
@@ -223,6 +227,7 @@ export function AddProviderModelDialog({
|
|||||||
instruct_type: '',
|
instruct_type: '',
|
||||||
canonical_slug: '',
|
canonical_slug: '',
|
||||||
alias_ids_raw: '',
|
alias_ids_raw: '',
|
||||||
|
forwarded_model_id: '',
|
||||||
upstream_provider_id: '',
|
upstream_provider_id: '',
|
||||||
input_cost: 0,
|
input_cost: 0,
|
||||||
output_cost: 0,
|
output_cost: 0,
|
||||||
@@ -280,6 +285,7 @@ export function AddProviderModelDialog({
|
|||||||
);
|
);
|
||||||
form.setValue('canonical_slug', model.canonical_slug || '');
|
form.setValue('canonical_slug', model.canonical_slug || '');
|
||||||
form.setValue('alias_ids_raw', listToString(model.alias_ids));
|
form.setValue('alias_ids_raw', listToString(model.alias_ids));
|
||||||
|
form.setValue('forwarded_model_id', model.forwarded_model_id || model.id);
|
||||||
form.setValue(
|
form.setValue(
|
||||||
'upstream_provider_id',
|
'upstream_provider_id',
|
||||||
typeof model.upstream_provider_id === 'string'
|
typeof model.upstream_provider_id === 'string'
|
||||||
@@ -385,6 +391,7 @@ export function AddProviderModelDialog({
|
|||||||
canonical_slug: data.canonical_slug?.trim() || null,
|
canonical_slug: data.canonical_slug?.trim() || null,
|
||||||
alias_ids: listFromString(data.alias_ids_raw || ''),
|
alias_ids: listFromString(data.alias_ids_raw || ''),
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
|
forwarded_model_id: data.forwarded_model_id?.trim() || data.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
@@ -520,6 +527,53 @@ export function AddProviderModelDialog({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='forwarded_model_id'
|
||||||
|
render={({ field }) => {
|
||||||
|
const handleCopy = () => {
|
||||||
|
const value = field.value || form.getValues('id');
|
||||||
|
if (!value) return;
|
||||||
|
navigator.clipboard.writeText(value);
|
||||||
|
setForwardedModelIdCopied(true);
|
||||||
|
setTimeout(() => setForwardedModelIdCopied(false), 1500);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Client Alias ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Input
|
||||||
|
placeholder={
|
||||||
|
form.watch('id') || 'e.g., openai/gpt-4o'
|
||||||
|
}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='icon'
|
||||||
|
onClick={handleCopy}
|
||||||
|
title='Copy model ID'
|
||||||
|
>
|
||||||
|
{forwardedModelIdCopied ? (
|
||||||
|
<Check className='h-4 w-4 text-green-500' />
|
||||||
|
) : (
|
||||||
|
<Copy className='h-4 w-4' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Alternate ID that clients can use to reference this
|
||||||
|
model. Defaults to the model's own ID.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='name'
|
name='name'
|
||||||
|
|||||||
@@ -471,7 +471,11 @@ export function ApiEndpointTester({ models }: ApiEndpointTesterProps) {
|
|||||||
testEndpointMutation.mutate(requestData);
|
testEndpointMutation.mutate(requestData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const enabledModels = models.filter((model) => model.isEnabled);
|
const enabledModels = Array.from(
|
||||||
|
new Map(
|
||||||
|
models.filter((model) => model.isEnabled).map((m) => [m.id, m])
|
||||||
|
).values()
|
||||||
|
);
|
||||||
const credentials = selectedModel ? getModelCredentials(selectedModel) : null;
|
const credentials = selectedModel ? getModelCredentials(selectedModel) : null;
|
||||||
const endpointUrl = credentials
|
const endpointUrl = credentials
|
||||||
? buildEndpointUrl(
|
? buildEndpointUrl(
|
||||||
|
|||||||
@@ -1,577 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { type Model } from '@/lib/api/schemas/models';
|
|
||||||
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@/components/ui/form';
|
|
||||||
import { Edit3, Loader2 } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
|
|
||||||
const EditModelFormSchema = z.object({
|
|
||||||
name: z.string().min(1, 'Name is required'),
|
|
||||||
description: z.string().optional(),
|
|
||||||
context_length: z.number().min(0),
|
|
||||||
prompt: z.number().min(0),
|
|
||||||
completion: z.number().min(0),
|
|
||||||
enabled: z.boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type EditModelFormData = z.infer<typeof EditModelFormSchema>;
|
|
||||||
|
|
||||||
const roundToFiveDecimals = (value: number | undefined | null): number => {
|
|
||||||
if (value === undefined || value === null || isNaN(value)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.round(value * 100000) / 100000;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toNumber = (value: unknown, fallback = 0): number => {
|
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (Number.isFinite(parsed)) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toStringArray = (value: unknown, fallback: string[]): string[] => {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
const filtered = value.filter(
|
|
||||||
(item): item is string => typeof item === 'string'
|
|
||||||
);
|
|
||||||
return filtered.length > 0 ? filtered : fallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface EditModelFormProps {
|
|
||||||
model: Model;
|
|
||||||
providerId?: number;
|
|
||||||
onModelUpdate?: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
isOpen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdminModelData {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
created: number;
|
|
||||||
context_length: number;
|
|
||||||
architecture: {
|
|
||||||
modality: string;
|
|
||||||
input_modalities: string[];
|
|
||||||
output_modalities: string[];
|
|
||||||
tokenizer: string;
|
|
||||||
instruct_type: string | null;
|
|
||||||
};
|
|
||||||
pricing: {
|
|
||||||
prompt: number;
|
|
||||||
completion: number;
|
|
||||||
request: number;
|
|
||||||
image: number;
|
|
||||||
web_search: number;
|
|
||||||
internal_reasoning: number;
|
|
||||||
};
|
|
||||||
per_request_limits: null | undefined;
|
|
||||||
top_provider: null | undefined;
|
|
||||||
upstream_provider_id: number;
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeAdminModelData = (
|
|
||||||
adminModel: AdminModel,
|
|
||||||
fallbackModel: Model,
|
|
||||||
providerId: number
|
|
||||||
): AdminModelData => {
|
|
||||||
const pricingRecord =
|
|
||||||
adminModel.pricing && typeof adminModel.pricing === 'object'
|
|
||||||
? (adminModel.pricing as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const architectureRecord =
|
|
||||||
adminModel.architecture && typeof adminModel.architecture === 'object'
|
|
||||||
? (adminModel.architecture as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: adminModel.id,
|
|
||||||
name: adminModel.name,
|
|
||||||
description: adminModel.description || '',
|
|
||||||
created: toNumber(adminModel.created, Math.floor(Date.now() / 1000)),
|
|
||||||
context_length: Math.max(
|
|
||||||
0,
|
|
||||||
Math.trunc(
|
|
||||||
toNumber(adminModel.context_length, fallbackModel.contextLength || 4096)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
architecture: {
|
|
||||||
modality:
|
|
||||||
typeof architectureRecord.modality === 'string'
|
|
||||||
? architectureRecord.modality
|
|
||||||
: fallbackModel.modelType || 'text',
|
|
||||||
input_modalities: toStringArray(architectureRecord.input_modalities, [
|
|
||||||
fallbackModel.modelType || 'text',
|
|
||||||
]),
|
|
||||||
output_modalities: toStringArray(architectureRecord.output_modalities, [
|
|
||||||
fallbackModel.modelType || 'text',
|
|
||||||
]),
|
|
||||||
tokenizer:
|
|
||||||
typeof architectureRecord.tokenizer === 'string'
|
|
||||||
? architectureRecord.tokenizer
|
|
||||||
: '',
|
|
||||||
instruct_type:
|
|
||||||
typeof architectureRecord.instruct_type === 'string'
|
|
||||||
? architectureRecord.instruct_type
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
pricing: {
|
|
||||||
prompt: roundToFiveDecimals(
|
|
||||||
toNumber(pricingRecord.prompt, fallbackModel.input_cost)
|
|
||||||
),
|
|
||||||
completion: roundToFiveDecimals(
|
|
||||||
toNumber(pricingRecord.completion, fallbackModel.output_cost)
|
|
||||||
),
|
|
||||||
request: toNumber(pricingRecord.request, 0),
|
|
||||||
image: toNumber(pricingRecord.image, 0),
|
|
||||||
web_search: toNumber(pricingRecord.web_search, 0),
|
|
||||||
internal_reasoning: toNumber(pricingRecord.internal_reasoning, 0),
|
|
||||||
},
|
|
||||||
per_request_limits:
|
|
||||||
adminModel.per_request_limits === null ||
|
|
||||||
adminModel.per_request_limits === undefined
|
|
||||||
? adminModel.per_request_limits
|
|
||||||
: null,
|
|
||||||
top_provider:
|
|
||||||
adminModel.top_provider === null || adminModel.top_provider === undefined
|
|
||||||
? adminModel.top_provider
|
|
||||||
: null,
|
|
||||||
upstream_provider_id:
|
|
||||||
typeof adminModel.upstream_provider_id === 'number'
|
|
||||||
? adminModel.upstream_provider_id
|
|
||||||
: providerId,
|
|
||||||
enabled: adminModel.enabled !== false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EditModelForm({
|
|
||||||
model,
|
|
||||||
providerId,
|
|
||||||
onModelUpdate,
|
|
||||||
onCancel,
|
|
||||||
isOpen,
|
|
||||||
}: EditModelFormProps) {
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [adminModelData, setAdminModelData] = useState<AdminModelData | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [isNewOverride, setIsNewOverride] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<EditModelFormData>({
|
|
||||||
resolver: zodResolver(EditModelFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: model.name,
|
|
||||||
description: model.description || '',
|
|
||||||
context_length: model.contextLength || 4096,
|
|
||||||
prompt: roundToFiveDecimals(model.input_cost),
|
|
||||||
completion: roundToFiveDecimals(model.output_cost),
|
|
||||||
enabled: model.isEnabled !== false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadAdminModel = useCallback(async () => {
|
|
||||||
if (!providerId) {
|
|
||||||
console.error('loadAdminModel called without providerId');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const adminModel = await AdminService.getProviderModel(
|
|
||||||
providerId,
|
|
||||||
model.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const normalizedAdminModel = normalizeAdminModelData(
|
|
||||||
adminModel,
|
|
||||||
model,
|
|
||||||
providerId
|
|
||||||
);
|
|
||||||
setAdminModelData(normalizedAdminModel);
|
|
||||||
setIsNewOverride(false);
|
|
||||||
|
|
||||||
form.reset({
|
|
||||||
name: normalizedAdminModel.name,
|
|
||||||
description: normalizedAdminModel.description || '',
|
|
||||||
context_length: normalizedAdminModel.context_length,
|
|
||||||
prompt: normalizedAdminModel.pricing.prompt,
|
|
||||||
completion: normalizedAdminModel.pricing.completion,
|
|
||||||
enabled: normalizedAdminModel.enabled !== false,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
setIsNewOverride(true);
|
|
||||||
setAdminModelData({
|
|
||||||
id: model.full_name,
|
|
||||||
name: model.name,
|
|
||||||
description: model.description || '',
|
|
||||||
created: Math.floor(Date.now() / 1000),
|
|
||||||
context_length: model.contextLength || 4096,
|
|
||||||
architecture: {
|
|
||||||
modality: model.modelType || 'text',
|
|
||||||
input_modalities: [model.modelType || 'text'],
|
|
||||||
output_modalities: [model.modelType || 'text'],
|
|
||||||
tokenizer: '',
|
|
||||||
instruct_type: null,
|
|
||||||
},
|
|
||||||
pricing: {
|
|
||||||
prompt: roundToFiveDecimals(model.input_cost),
|
|
||||||
completion: roundToFiveDecimals(model.output_cost),
|
|
||||||
request: 0,
|
|
||||||
image: 0,
|
|
||||||
web_search: 0,
|
|
||||||
internal_reasoning: 0,
|
|
||||||
},
|
|
||||||
per_request_limits: null,
|
|
||||||
top_provider: null,
|
|
||||||
upstream_provider_id: providerId,
|
|
||||||
enabled: model.isEnabled !== false,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.reset({
|
|
||||||
name: model.name,
|
|
||||||
description: model.description || '',
|
|
||||||
context_length: model.contextLength || 4096,
|
|
||||||
prompt: roundToFiveDecimals(model.input_cost),
|
|
||||||
completion: roundToFiveDecimals(model.output_cost),
|
|
||||||
enabled: model.isEnabled !== false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [providerId, model, form]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && providerId) {
|
|
||||||
loadAdminModel();
|
|
||||||
} else if (isOpen && !providerId) {
|
|
||||||
console.error('EditModelForm opened without providerId', {
|
|
||||||
model,
|
|
||||||
providerId,
|
|
||||||
});
|
|
||||||
toast.error('Missing provider information for this model');
|
|
||||||
}
|
|
||||||
}, [isOpen, providerId, model, loadAdminModel]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: EditModelFormData) => {
|
|
||||||
if (!providerId) {
|
|
||||||
console.error('onSubmit called without providerId', {
|
|
||||||
model,
|
|
||||||
providerId,
|
|
||||||
});
|
|
||||||
toast.error('Missing provider ID - cannot update model');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!adminModelData) {
|
|
||||||
console.error('onSubmit called without adminModelData', {
|
|
||||||
model,
|
|
||||||
providerId,
|
|
||||||
adminModelData,
|
|
||||||
});
|
|
||||||
toast.error('Model data not loaded - please try reopening the form');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
id: adminModelData.id,
|
|
||||||
name: data.name,
|
|
||||||
description: data.description || '',
|
|
||||||
created: adminModelData.created || Math.floor(Date.now() / 1000),
|
|
||||||
context_length: data.context_length,
|
|
||||||
architecture: adminModelData.architecture || {
|
|
||||||
modality: 'text',
|
|
||||||
input_modalities: ['text'],
|
|
||||||
output_modalities: ['text'],
|
|
||||||
tokenizer: '',
|
|
||||||
instruct_type: null,
|
|
||||||
},
|
|
||||||
pricing: {
|
|
||||||
prompt: roundToFiveDecimals(data.prompt),
|
|
||||||
completion: roundToFiveDecimals(data.completion),
|
|
||||||
request: 0,
|
|
||||||
image: 0,
|
|
||||||
web_search: 0,
|
|
||||||
internal_reasoning: 0,
|
|
||||||
},
|
|
||||||
per_request_limits: adminModelData.per_request_limits,
|
|
||||||
top_provider: adminModelData.top_provider,
|
|
||||||
upstream_provider_id: providerId,
|
|
||||||
enabled: data.enabled,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isNewOverride) {
|
|
||||||
await AdminService.createProviderModel(providerId, payload);
|
|
||||||
toast.success('Model override created successfully!');
|
|
||||||
} else {
|
|
||||||
await AdminService.updateProviderModel(
|
|
||||||
providerId,
|
|
||||||
adminModelData.id,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
toast.success('Model updated successfully!');
|
|
||||||
}
|
|
||||||
|
|
||||||
onModelUpdate?.();
|
|
||||||
onCancel?.();
|
|
||||||
} catch (error) {
|
|
||||||
const action = isNewOverride ? 'create' : 'update';
|
|
||||||
toast.error(`Failed to ${action} model. Please try again.`);
|
|
||||||
console.error(`Error ${action}ing model:`, error);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!isSubmitting) {
|
|
||||||
onCancel?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[600px]'>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className='flex items-center gap-2'>
|
|
||||||
<Edit3 className='h-5 w-5' />
|
|
||||||
{isNewOverride ? 'Create Model Override' : 'Edit Model Override'}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{isNewOverride
|
|
||||||
? `Create an override for "${model.name}"`
|
|
||||||
: `Update the model override for "${model.name}"`}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
|
|
||||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='name'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Display Name *</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder='e.g., GPT-4'
|
|
||||||
{...field}
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Custom display name for the model
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='context_length'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Context Length *</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type='number'
|
|
||||||
min='0'
|
|
||||||
placeholder='4096'
|
|
||||||
value={field.value ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
field.onChange(
|
|
||||||
value === '' ? 0 : parseInt(value, 10) || 0
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const value = parseInt(e.target.value, 10);
|
|
||||||
field.onChange(
|
|
||||||
Number.isNaN(value) ? 0 : Math.max(0, value)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Maximum context window size
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='description'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Description</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder='Brief description of the model...'
|
|
||||||
{...field}
|
|
||||||
rows={3}
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Optional description or notes about the model
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='prompt'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Input Cost (per 1M tokens) *</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type='number'
|
|
||||||
step='0.00001'
|
|
||||||
min='0'
|
|
||||||
placeholder='5.00000'
|
|
||||||
value={field.value ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
field.onChange(value === '' ? 0 : parseFloat(value));
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const value = parseFloat(e.target.value);
|
|
||||||
field.onChange(roundToFiveDecimals(value));
|
|
||||||
}}
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Cost in USD per 1,000,000 input tokens (max 5 decimals)
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='completion'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Output Cost (per 1M tokens) *</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type='number'
|
|
||||||
step='0.00001'
|
|
||||||
min='0'
|
|
||||||
placeholder='15.00000'
|
|
||||||
value={field.value ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
field.onChange(value === '' ? 0 : parseFloat(value));
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const value = parseFloat(e.target.value);
|
|
||||||
field.onChange(roundToFiveDecimals(value));
|
|
||||||
}}
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Cost in USD per 1,000,000 output tokens (max 5 decimals)
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='enabled'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
|
||||||
<div className='space-y-0.5'>
|
|
||||||
<FormLabel className='text-base'>Model Enabled</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Enable or disable this model override
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='flex justify-end gap-2 pt-4'>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='outline'
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type='submit' disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
|
||||||
{isNewOverride ? 'Creating...' : 'Updating...'}
|
|
||||||
</>
|
|
||||||
) : isNewOverride ? (
|
|
||||||
'Create Override'
|
|
||||||
) : (
|
|
||||||
'Update Model'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -197,7 +197,11 @@ export function ModelTester({ models }: ModelTesterProps) {
|
|||||||
testModelMutation.mutate(request);
|
testModelMutation.mutate(request);
|
||||||
};
|
};
|
||||||
|
|
||||||
const enabledModels = models.filter((model) => model.isEnabled);
|
const enabledModels = Array.from(
|
||||||
|
new Map(
|
||||||
|
models.filter((model) => model.isEnabled).map((m) => [m.id, m])
|
||||||
|
).values()
|
||||||
|
);
|
||||||
const credentials = selectedModel ? getModelCredentials(selectedModel) : null;
|
const credentials = selectedModel ? getModelCredentials(selectedModel) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -124,12 +124,14 @@ export function ModelsPage() {
|
|||||||
>
|
>
|
||||||
Basic Testing
|
Basic Testing
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{/*
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value='test-api'
|
value='test-api'
|
||||||
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
||||||
>
|
>
|
||||||
API Endpoints
|
API Endpoints
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
*/}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value='manage' className='mt-0'>
|
<TabsContent value='manage' className='mt-0'>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
|
import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -10,7 +10,6 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|
||||||
interface RoutstrCreateKeySectionProps {
|
interface RoutstrCreateKeySectionProps {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export const AdminModelSchema = z.object({
|
|||||||
canonical_slug: z.string().nullable().optional(),
|
canonical_slug: z.string().nullable().optional(),
|
||||||
alias_ids: z.array(z.string()).nullable().optional(),
|
alias_ids: z.array(z.string()).nullable().optional(),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
|
forwarded_model_id: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ProviderModelsSchema = z.object({
|
export const ProviderModelsSchema = z.object({
|
||||||
@@ -890,13 +891,17 @@ export class AdminService {
|
|||||||
type?: string,
|
type?: string,
|
||||||
status?: string,
|
status?: string,
|
||||||
search?: string,
|
search?: string,
|
||||||
limit: number = 100
|
source?: string,
|
||||||
|
limit: number = 50,
|
||||||
|
offset: number = 0
|
||||||
): Promise<TransactionsResponse> {
|
): Promise<TransactionsResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (type) params.append('type', type);
|
if (type) params.append('type', type);
|
||||||
if (status) params.append('status', status);
|
if (status) params.append('status', status);
|
||||||
if (search) params.append('search', search);
|
if (search) params.append('search', search);
|
||||||
|
if (source) params.append('source', source);
|
||||||
params.append('limit', limit.toString());
|
params.append('limit', limit.toString());
|
||||||
|
params.append('offset', offset.toString());
|
||||||
|
|
||||||
return await apiClient.get<TransactionsResponse>(
|
return await apiClient.get<TransactionsResponse>(
|
||||||
`/admin/api/transactions?${params.toString()}`
|
`/admin/api/transactions?${params.toString()}`
|
||||||
@@ -1134,6 +1139,8 @@ export interface Transaction {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
collected: boolean;
|
collected: boolean;
|
||||||
swept: boolean;
|
swept: boolean;
|
||||||
|
source: 'x-cashu' | 'apikey';
|
||||||
|
api_key_hashed_key?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransactionsResponse {
|
export interface TransactionsResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user