mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-02 16:26:14 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46bbba7027 | ||
|
|
e2aa02307b | ||
|
|
66bc1260a4 | ||
|
|
9dc4c979b8 | ||
|
|
7f49ba1771 | ||
|
|
949dc433f1 | ||
|
|
b7fcf000af | ||
|
|
a1af1383e1 | ||
|
|
5bedbd129f | ||
|
|
f588147b41 | ||
|
|
17bc949597 | ||
|
|
7705656016 | ||
|
|
434283c58d | ||
|
|
9fcf870e3f | ||
|
|
782b233d40 | ||
|
|
0c7373675f | ||
|
|
a33ea5da07 |
+87
-20
@@ -113,42 +113,109 @@ All errors follow a consistent JSON structure:
|
|||||||
**Status:** 402
|
**Status:** 402
|
||||||
**Resolution:** Top up API key balance
|
**Resolution:** Top up API key balance
|
||||||
|
|
||||||
#### Invalid Token
|
### Cashu Token Redemption Errors
|
||||||
|
|
||||||
|
These errors are returned when a Cashu token you pay with cannot be redeemed.
|
||||||
|
They apply to every endpoint that accepts a token:
|
||||||
|
|
||||||
|
- **Per-request payment** via the `X-Cashu` header (chat completions + Responses API).
|
||||||
|
- **API key top-up** via `POST /v1/wallet/topup`.
|
||||||
|
- **Minting an API key** from a token sent in `Authorization: Bearer <cashu-token>`.
|
||||||
|
|
||||||
|
All three share one classifier, so the same failure yields the same HTTP status
|
||||||
|
and sanitized message everywhere. Structured error envelopes (`X-Cashu` and
|
||||||
|
`Authorization: Bearer <cashu-token>`) also expose the same `type` and `code` —
|
||||||
|
branch on `type` (or `code` for finer granularity). `POST /v1/wallet/topup`
|
||||||
|
keeps its existing plain-string `detail` envelope, so branch on status there.
|
||||||
|
|
||||||
|
| `type` | Status | `code` | Retryable | Meaning |
|
||||||
|
|--------|--------|--------|-----------|---------|
|
||||||
|
| `token_already_spent` | 400 | `cashu_token_already_spent` | No | The token was already redeemed. |
|
||||||
|
| `invalid_token` | 400 | `invalid_cashu_token` | No | The token is malformed or cannot be decoded. |
|
||||||
|
| `mint_error` | 422 | `cashu_token_swap_fees_exceed_amount` | No | Token value is too small to cover the mint's swap/melt fees. |
|
||||||
|
| `mint_error` | 422 | `cashu_foreign_mint_swap_failed` | No | Swapping the token from a foreign mint to the primary mint failed. |
|
||||||
|
| `mint_unreachable` | 503 | `cashu_mint_unreachable` | **Yes** | The mint could not be reached (DNS failure, refused/reset connection, timeout). The token is fine — retry once the mint recovers. |
|
||||||
|
| `cashu_error` | 400 | `cashu_token_redemption_failed` | No | The token could not be redeemed for another expected reason. |
|
||||||
|
| `cashu_error` | 400 | `cashu_token_zero_value` | No | The token redeemed to zero (empty/dust token, or value fully consumed by fees). |
|
||||||
|
| `token_consumed` | 500 | `cashu_token_consumed` | No | The token was **spent** (melted/redeemed) but crediting it then failed. Do not retry — the token is gone; contact support to reconcile. |
|
||||||
|
| `api_error` | 500 | `internal_error` | Maybe | Unexpected server-side fault during redemption. |
|
||||||
|
|
||||||
|
!!! important "Retry only `mint_unreachable`"
|
||||||
|
Only `mint_unreachable` (503) means the same token will work again later —
|
||||||
|
everything else is a permanent property of the token and must not be
|
||||||
|
blindly retried. Use exponential backoff for the 503. In particular, a
|
||||||
|
`token_consumed` 500 means the mint already spent the token, so a retry
|
||||||
|
would fail as `token_already_spent`.
|
||||||
|
|
||||||
|
#### Mint Unreachable (retryable)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"error": {
|
"error": {
|
||||||
"type": "payment_error",
|
"type": "mint_unreachable",
|
||||||
"message": "Invalid Cashu token",
|
"message": "Cashu mint is unreachable",
|
||||||
"code": "invalid_token",
|
"code": "cashu_mint_unreachable"
|
||||||
"details": {
|
|
||||||
"reason": "Token already spent"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Status:** 400
|
**Status:** 503
|
||||||
**Resolution:** Use a valid, unspent token
|
|
||||||
|
|
||||||
#### Mint Unavailable
|
**Resolution:** The token is valid — the mint is temporarily down. Retry with
|
||||||
|
backoff, or pay with a token from a different mint.
|
||||||
|
|
||||||
|
#### Token Already Spent
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"error": {
|
"error": {
|
||||||
"type": "payment_error",
|
"type": "token_already_spent",
|
||||||
"message": "Cannot connect to Cashu mint",
|
"message": "Cashu token already spent",
|
||||||
"code": "mint_unavailable",
|
"code": "cashu_token_already_spent"
|
||||||
"details": {
|
|
||||||
"mint_url": "https://mint.example.com",
|
|
||||||
"retry_after": 60
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Status:** 503
|
**Status:** 400
|
||||||
**Resolution:** Try again later or use different mint
|
|
||||||
|
**Resolution:** Use a fresh, unspent token. Do not retry with the same token.
|
||||||
|
|
||||||
|
#### Response envelope differs by endpoint
|
||||||
|
|
||||||
|
The `error` object above is identical everywhere, but the surrounding envelope
|
||||||
|
depends on how you paid:
|
||||||
|
|
||||||
|
- **`X-Cashu` header payments** (chat + Responses API) return the object at the
|
||||||
|
top level, alongside a `request_id`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": { "type": "mint_unreachable", "message": "Cashu mint is unreachable", "code": "cashu_mint_unreachable" },
|
||||||
|
"request_id": "req-abc123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The original token is echoed back in the `X-Cashu` **response header only when
|
||||||
|
it is still spendable** (e.g. `mint_unreachable`, `invalid_cashu_token`, fee
|
||||||
|
errors) so you can recover/retry it. It is **not** echoed for spent/consumed
|
||||||
|
tokens (`cashu_token_already_spent`, `cashu_token_consumed`,
|
||||||
|
`cashu_token_zero_value`, `internal_error`) — retrying those can never succeed.
|
||||||
|
|
||||||
|
- **`Authorization: Bearer <cashu-token>`** (API key minting) wraps it in
|
||||||
|
FastAPI's `detail` field:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "detail": { "error": { "type": "mint_unreachable", "message": "Cashu mint is unreachable", "code": "cashu_mint_unreachable" } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`POST /v1/wallet/topup`** returns a plain string message under `detail` —
|
||||||
|
it carries the shared HTTP **status** and **message** (e.g. `503` for an
|
||||||
|
unreachable mint) but not the structured `type`/`code`, so branch on the
|
||||||
|
status code here:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "detail": "Cashu mint is unreachable" }
|
||||||
|
```
|
||||||
|
|
||||||
### Validation Errors
|
### Validation Errors
|
||||||
|
|
||||||
@@ -347,7 +414,7 @@ class ErrorHandler:
|
|||||||
'rate_limit',
|
'rate_limit',
|
||||||
'upstream_timeout',
|
'upstream_timeout',
|
||||||
'model_overloaded',
|
'model_overloaded',
|
||||||
'mint_unavailable'
|
'cashu_mint_unreachable'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Errors requiring user action
|
# Errors requiring user action
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""add slug to upstream_providers
|
||||||
|
|
||||||
|
Revision ID: c6d7e8f9a0b1
|
||||||
|
Revises: b5e7c9d1f3a2
|
||||||
|
Create Date: 2026-06-29 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from routstr.core.provider_slugs import provider_slug_base, provider_slug_candidate
|
||||||
|
|
||||||
|
revision = "c6d7e8f9a0b1"
|
||||||
|
down_revision = "b5e7c9d1f3a2"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _allocate_backfill_slug(provider_type: str, reserved_slugs: set[str]) -> str:
|
||||||
|
base = provider_slug_base(provider_type)
|
||||||
|
suffix_number = 1
|
||||||
|
while True:
|
||||||
|
candidate = provider_slug_candidate(base, suffix_number)
|
||||||
|
if candidate not in reserved_slugs:
|
||||||
|
reserved_slugs.add(candidate)
|
||||||
|
return candidate
|
||||||
|
suffix_number += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_provider_slugs(conn: sa.Connection) -> None:
|
||||||
|
existing_rows = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT slug FROM upstream_providers "
|
||||||
|
"WHERE slug IS NOT NULL AND slug != ''"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reserved_slugs = {str(row.slug).lower() for row in existing_rows}
|
||||||
|
|
||||||
|
rows_to_backfill = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT id, provider_type FROM upstream_providers "
|
||||||
|
"WHERE slug IS NULL OR slug = '' "
|
||||||
|
"ORDER BY id"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for row in rows_to_backfill:
|
||||||
|
slug = _allocate_backfill_slug(str(row.provider_type), reserved_slugs)
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE upstream_providers SET slug = :slug WHERE id = :id"),
|
||||||
|
{"slug": slug, "id": row.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
columns = {c["name"] for c in inspector.get_columns("upstream_providers")}
|
||||||
|
|
||||||
|
if "slug" not in columns:
|
||||||
|
op.add_column(
|
||||||
|
"upstream_providers",
|
||||||
|
sa.Column("slug", sa.String(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
_backfill_provider_slugs(conn)
|
||||||
|
|
||||||
|
existing_indexes = {idx["name"] for idx in inspector.get_indexes("upstream_providers")}
|
||||||
|
if "ix_upstream_providers_slug" not in existing_indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_upstream_providers_slug",
|
||||||
|
"upstream_providers",
|
||||||
|
["slug"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
existing_indexes = {idx["name"] for idx in inspector.get_indexes("upstream_providers")}
|
||||||
|
if "ix_upstream_providers_slug" in existing_indexes:
|
||||||
|
op.drop_index("ix_upstream_providers_slug", table_name="upstream_providers")
|
||||||
|
|
||||||
|
columns = {c["name"] for c in inspector.get_columns("upstream_providers")}
|
||||||
|
if "slug" in columns:
|
||||||
|
op.drop_column("upstream_providers", "slug")
|
||||||
+66
-8
@@ -20,7 +20,11 @@ from .payment.cost_calculation import (
|
|||||||
MaxCostData,
|
MaxCostData,
|
||||||
calculate_cost,
|
calculate_cost,
|
||||||
)
|
)
|
||||||
from .wallet import credit_balance, deserialize_token_from_string
|
from .wallet import (
|
||||||
|
classify_redemption_error,
|
||||||
|
credit_balance,
|
||||||
|
deserialize_token_from_string,
|
||||||
|
)
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
payments_logger = get_logger("routstr.payments")
|
payments_logger = get_logger("routstr.payments")
|
||||||
@@ -78,6 +82,37 @@ async def check_and_reset_limit(key: ApiKey, session: AsyncSession) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def redemption_error_to_http_exception(error: Exception) -> HTTPException:
|
||||||
|
"""Map a Cashu token redemption failure to a sanitized client-facing error.
|
||||||
|
|
||||||
|
Thin wrapper over the shared :func:`classify_redemption_error` so the bearer
|
||||||
|
path stays identical to the X-Cashu and top-up paths.
|
||||||
|
"""
|
||||||
|
classified = classify_redemption_error(error)
|
||||||
|
if classified is None:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"message": "Internal error during token redemption",
|
||||||
|
"type": "api_error",
|
||||||
|
"code": "internal_error",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
error_type, status_code, message, error_code = classified
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status_code,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"message": message,
|
||||||
|
"type": error_type,
|
||||||
|
"code": error_code,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def validate_bearer_key(
|
async def validate_bearer_key(
|
||||||
bearer_key: str,
|
bearer_key: str,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -216,7 +251,17 @@ async def validate_bearer_key(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
|
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
|
||||||
token_obj = deserialize_token_from_string(bearer_key)
|
try:
|
||||||
|
token_obj = deserialize_token_from_string(bearer_key)
|
||||||
|
except Exception as decode_error:
|
||||||
|
# A malformed token is a bad token (400 invalid_cashu_token via
|
||||||
|
# the shared taxonomy), not an auth failure (401) — otherwise it
|
||||||
|
# would fall through to the generic "Invalid API key" handler.
|
||||||
|
raise redemption_error_to_http_exception(
|
||||||
|
ValueError(
|
||||||
|
f"Invalid Cashu token: could not decode token ({decode_error})"
|
||||||
|
)
|
||||||
|
) from decode_error
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."}
|
"Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."}
|
||||||
)
|
)
|
||||||
@@ -334,19 +379,32 @@ async def validate_bearer_key(
|
|||||||
"error_type": type(credit_error).__name__,
|
"error_type": type(credit_error).__name__,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
raise credit_error
|
await session.rollback()
|
||||||
|
raise redemption_error_to_http_exception(credit_error) from credit_error
|
||||||
|
|
||||||
if msats <= 0:
|
if msats <= 0:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Token redemption returned zero or negative amount",
|
"Token redemption returned zero or negative amount",
|
||||||
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
|
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
|
||||||
)
|
)
|
||||||
# Defense-in-depth: credit_balance now refuses to commit on a
|
# Defense-in-depth: credit_balance already raises
|
||||||
# zero/negative redemption, but if a row was nonetheless
|
# ValueError("Redeemed token amount must be positive…") before
|
||||||
# persisted, drop it so we never leave an orphan zero-balance key.
|
# returning (wallet.py), so this branch is only reachable if a
|
||||||
|
# zero/negative row was somehow persisted; drop it so we never
|
||||||
|
# leave an orphan zero-balance key. Reuse the shared taxonomy
|
||||||
|
# (cashu_error) so the envelope matches the mapper above.
|
||||||
await session.delete(new_key)
|
await session.delete(new_key)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
raise Exception("Token redemption failed")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"message": "Failed to redeem Cashu token: token yielded no value",
|
||||||
|
"type": "cashu_error",
|
||||||
|
"code": "cashu_token_zero_value",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
await session.refresh(new_key)
|
await session.refresh(new_key)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -379,7 +437,7 @@ async def validate_bearer_key(
|
|||||||
status_code=401,
|
status_code=401,
|
||||||
detail={
|
detail={
|
||||||
"error": {
|
"error": {
|
||||||
"message": f"Invalid or expired Cashu key: {str(e)}",
|
"message": "Invalid or expired Cashu key",
|
||||||
"type": "invalid_request_error",
|
"type": "invalid_request_error",
|
||||||
"code": "invalid_api_key",
|
"code": "invalid_api_key",
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-31
@@ -20,7 +20,14 @@ from .core.db import (
|
|||||||
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
|
||||||
from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
|
from .wallet import (
|
||||||
|
classify_redemption_error,
|
||||||
|
credit_balance,
|
||||||
|
is_mint_connection_error,
|
||||||
|
recieve_token,
|
||||||
|
send_to_lnurl,
|
||||||
|
send_token,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
balance_router = APIRouter(prefix="/v1/balance")
|
balance_router = APIRouter(prefix="/v1/balance")
|
||||||
@@ -156,30 +163,18 @@ async def topup_wallet_endpoint(
|
|||||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||||
try:
|
try:
|
||||||
amount_msats = await credit_balance(cashu_token, billing_key, session)
|
amount_msats = await credit_balance(cashu_token, billing_key, session)
|
||||||
except ValueError as e:
|
|
||||||
error_msg = str(e)
|
|
||||||
if "already spent" in error_msg.lower():
|
|
||||||
raise HTTPException(status_code=400, detail="Token already spent")
|
|
||||||
elif "invalid" in error_msg.lower() or "decode" in error_msg.lower():
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
|
||||||
elif "insufficient" in error_msg.lower() or "melt fee" in error_msg.lower():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Token value is too small to cover swap fees. {error_msg}",
|
|
||||||
)
|
|
||||||
elif "failed to melt" in error_msg.lower():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Failed to swap foreign mint token. {error_msg}",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Failed to redeem token: {error_msg}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
# Shared taxonomy so top-up matches the bearer/X-Cashu paths (503 for an
|
||||||
"topup_wallet_endpoint: unhandled error",
|
# unreachable mint, 422 for fee/swap failures, 400 for token faults).
|
||||||
extra={"error": str(e), "error_type": type(e).__name__},
|
classified = classify_redemption_error(e)
|
||||||
)
|
if classified is None:
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
logger.error(
|
||||||
|
"topup_wallet_endpoint: unhandled error",
|
||||||
|
extra={"error": str(e), "error_type": type(e).__name__},
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
_type, status_code, message, _code = classified
|
||||||
|
raise HTTPException(status_code=status_code, detail=message)
|
||||||
return {"msats": amount_msats}
|
return {"msats": amount_msats}
|
||||||
|
|
||||||
|
|
||||||
@@ -441,14 +436,10 @@ async def refund_wallet_endpoint(
|
|||||||
"has_refund_address": bool(key.refund_address),
|
"has_refund_address": bool(key.refund_address),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (
|
if is_mint_connection_error(e):
|
||||||
"mint" in error_msg.lower()
|
raise HTTPException(status_code=503, detail="Mint service unavailable")
|
||||||
or "connection" in error_msg.lower()
|
|
||||||
or "ConnectError" in str(type(e))
|
|
||||||
):
|
|
||||||
raise HTTPException(status_code=503, detail=f"Mint service unavailable: {error_msg}")
|
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=500, detail=f"Refund failed: {error_msg}")
|
raise HTTPException(status_code=500, detail="Refund failed")
|
||||||
|
|
||||||
await _refund_cache_set(bearer_value, result)
|
await _refund_cache_set(bearer_value, result)
|
||||||
|
|
||||||
|
|||||||
+219
-127
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -8,6 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|||||||
from pydantic import BaseModel, RootModel
|
from pydantic import BaseModel, RootModel
|
||||||
from pydantic.v1 import ValidationError as PydanticValidationError
|
from pydantic.v1 import ValidationError as PydanticValidationError
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from ..payment.models import _row_to_model, list_models
|
from ..payment.models import _row_to_model, list_models
|
||||||
from ..proxy import refresh_model_maps, reinitialize_upstreams
|
from ..proxy import refresh_model_maps, reinitialize_upstreams
|
||||||
@@ -29,6 +31,7 @@ from .db import (
|
|||||||
)
|
)
|
||||||
from .log_manager import log_manager
|
from .log_manager import log_manager
|
||||||
from .logging import get_logger
|
from .logging import get_logger
|
||||||
|
from .provider_slugs import allocate_unique_provider_slug
|
||||||
from .settings import SettingsService, settings
|
from .settings import SettingsService, settings
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -456,19 +459,18 @@ class ModelCreate(BaseModel):
|
|||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def upsert_provider_model(
|
async def upsert_provider_model(
|
||||||
provider_id: int, payload: ModelCreate
|
provider_id: str, payload: ModelCreate
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
print(payload)
|
print(payload)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"UPSERT_PROVIDER_MODEL called: provider_id={provider_id}, model_id={payload.id}"
|
f"UPSERT_PROVIDER_MODEL called: provider_id={provider_id}, model_id={payload.id}"
|
||||||
)
|
)
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
provider_pk = _provider_pk(provider)
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
# Try to get existing model
|
# Try to get existing model
|
||||||
existing_row = await session.get(ModelRow, (payload.id, provider_id))
|
existing_row = await session.get(ModelRow, (payload.id, provider_pk))
|
||||||
|
|
||||||
if existing_row:
|
if existing_row:
|
||||||
# Update existing model
|
# Update existing model
|
||||||
@@ -524,7 +526,7 @@ async def upsert_provider_model(
|
|||||||
alias_ids=(
|
alias_ids=(
|
||||||
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
||||||
),
|
),
|
||||||
upstream_provider_id=provider_id,
|
upstream_provider_id=provider_pk,
|
||||||
enabled=payload.enabled,
|
enabled=payload.enabled,
|
||||||
forwarded_model_id=payload.forwarded_model_id or payload.id,
|
forwarded_model_id=payload.forwarded_model_id or payload.id,
|
||||||
)
|
)
|
||||||
@@ -543,7 +545,7 @@ async def upsert_provider_model(
|
|||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def update_provider_model_legacy(
|
async def update_provider_model_legacy(
|
||||||
provider_id: int, model_id: str, payload: ModelCreate
|
provider_id: str, model_id: str, payload: ModelCreate
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Legacy PATCH endpoint - redirects to upsert POST endpoint for backward compatibility."""
|
"""Legacy PATCH endpoint - redirects to upsert POST endpoint for backward compatibility."""
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -556,13 +558,12 @@ async def update_provider_model_legacy(
|
|||||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def get_provider_model(provider_id: int, model_id: str) -> dict[str, object]:
|
async def get_provider_model(provider_id: str, model_id: str) -> dict[str, object]:
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
provider_pk = _provider_pk(provider)
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
row = await session.get(ModelRow, (model_id, provider_id))
|
row = await session.get(ModelRow, (model_id, provider_pk))
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404, detail="Model not found for this provider"
|
status_code=404, detail="Model not found for this provider"
|
||||||
@@ -576,9 +577,11 @@ async def get_provider_model(provider_id: int, model_id: str) -> dict[str, objec
|
|||||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def delete_provider_model(provider_id: int, model_id: str) -> dict[str, object]:
|
async def delete_provider_model(provider_id: str, model_id: str) -> dict[str, object]:
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
row = await session.get(ModelRow, (model_id, provider_id))
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
|
provider_pk = _provider_pk(provider)
|
||||||
|
row = await session.get(ModelRow, (model_id, provider_pk))
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404, detail="Model not found for this provider"
|
status_code=404, detail="Model not found for this provider"
|
||||||
@@ -593,10 +596,12 @@ async def delete_provider_model(provider_id: int, model_id: str) -> dict[str, ob
|
|||||||
"/api/upstream-providers/{provider_id}/models",
|
"/api/upstream-providers/{provider_id}/models",
|
||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def delete_all_provider_models(provider_id: int) -> dict[str, object]:
|
async def delete_all_provider_models(provider_id: str) -> dict[str, object]:
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
|
provider_pk = _provider_pk(provider)
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(ModelRow).where(ModelRow.upstream_provider_id == provider_id)
|
select(ModelRow).where(ModelRow.upstream_provider_id == provider_pk)
|
||||||
) # type: ignore
|
) # type: ignore
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@@ -615,7 +620,7 @@ class BatchOverrideRequest(BaseModel):
|
|||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def batch_override_provider_models(
|
async def batch_override_provider_models(
|
||||||
provider_id: int, payload: BatchOverrideRequest
|
provider_id: str, payload: BatchOverrideRequest
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Batch override models for a specific provider."""
|
"""Batch override models for a specific provider."""
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -623,15 +628,14 @@ async def batch_override_provider_models(
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
provider_pk = _provider_pk(provider)
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
overridden_count = 0
|
overridden_count = 0
|
||||||
|
|
||||||
for model_data in payload.models:
|
for model_data in payload.models:
|
||||||
# Try to get existing model regardless of whether it's enabled or not
|
# Try to get existing model regardless of whether it's enabled or not
|
||||||
existing_row = await session.get(ModelRow, (model_data.id, provider_id))
|
existing_row = await session.get(ModelRow, (model_data.id, provider_pk))
|
||||||
|
|
||||||
if existing_row:
|
if existing_row:
|
||||||
# Update existing
|
# Update existing
|
||||||
@@ -685,7 +689,7 @@ async def batch_override_provider_models(
|
|||||||
if model_data.alias_ids
|
if model_data.alias_ids
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
upstream_provider_id=provider_id,
|
upstream_provider_id=provider_pk,
|
||||||
enabled=model_data.enabled,
|
enabled=model_data.enabled,
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
@@ -702,6 +706,85 @@ async def batch_override_provider_models(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_slug(value: str) -> str:
|
||||||
|
candidate = value.strip().lower()
|
||||||
|
if not _SLUG_PATTERN.fullmatch(candidate):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
"slug must be 3-64 chars, lowercase letters/digits/hyphens, "
|
||||||
|
"and may not start or end with a hyphen"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if candidate.isdigit():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="slug must not be all digits",
|
||||||
|
)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_unique_slug(
|
||||||
|
session: AsyncSession, slug: str, exclude_id: int | None = None
|
||||||
|
) -> None:
|
||||||
|
stmt = select(UpstreamProviderRow).where(UpstreamProviderRow.slug == slug)
|
||||||
|
result = await session.exec(stmt)
|
||||||
|
existing = result.first()
|
||||||
|
if existing and existing.id != exclude_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Provider with this slug already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_upstream_provider_by_ref(
|
||||||
|
session: AsyncSession, provider_ref: str
|
||||||
|
) -> UpstreamProviderRow:
|
||||||
|
if provider_ref.isdigit():
|
||||||
|
provider = await session.get(UpstreamProviderRow, int(provider_ref))
|
||||||
|
else:
|
||||||
|
slug = _validate_slug(provider_ref)
|
||||||
|
result = await session.exec(
|
||||||
|
select(UpstreamProviderRow).where(UpstreamProviderRow.slug == slug)
|
||||||
|
)
|
||||||
|
provider = result.first()
|
||||||
|
|
||||||
|
if not provider:
|
||||||
|
raise HTTPException(status_code=404, detail="Provider not found")
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_pk(provider: UpstreamProviderRow) -> int:
|
||||||
|
if provider.id is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Provider has no database id")
|
||||||
|
return provider.id
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_provider(
|
||||||
|
provider: UpstreamProviderRow, redact_api_key: bool = True
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": provider.id,
|
||||||
|
"slug": provider.slug,
|
||||||
|
"provider_type": provider.provider_type,
|
||||||
|
"base_url": provider.base_url,
|
||||||
|
"api_key": "[REDACTED]"
|
||||||
|
if (redact_api_key and provider.api_key)
|
||||||
|
else provider.api_key
|
||||||
|
if not redact_api_key
|
||||||
|
else "",
|
||||||
|
"api_version": provider.api_version,
|
||||||
|
"enabled": provider.enabled,
|
||||||
|
"provider_fee": provider.provider_fee,
|
||||||
|
"provider_settings": json.loads(provider.provider_settings)
|
||||||
|
if provider.provider_settings
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class UpstreamProviderCreate(BaseModel):
|
class UpstreamProviderCreate(BaseModel):
|
||||||
provider_type: str
|
provider_type: str
|
||||||
base_url: str
|
base_url: str
|
||||||
@@ -710,6 +793,7 @@ class UpstreamProviderCreate(BaseModel):
|
|||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
provider_fee: float = 1.01
|
provider_fee: float = 1.01
|
||||||
provider_settings: dict | None = None
|
provider_settings: dict | None = None
|
||||||
|
slug: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UpstreamProviderUpdate(BaseModel):
|
class UpstreamProviderUpdate(BaseModel):
|
||||||
@@ -720,6 +804,50 @@ class UpstreamProviderUpdate(BaseModel):
|
|||||||
enabled: bool | None = None
|
enabled: bool | None = None
|
||||||
provider_fee: float | None = None
|
provider_fee: float | None = None
|
||||||
provider_settings: dict | None = None
|
provider_settings: dict | None = None
|
||||||
|
slug: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UpstreamProviderUpdateBySlug(BaseModel):
|
||||||
|
slug: str
|
||||||
|
new_slug: str | None = None
|
||||||
|
provider_type: str | None = None
|
||||||
|
base_url: str | None = None
|
||||||
|
api_key: str | None = None
|
||||||
|
api_version: str | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
provider_fee: float | None = None
|
||||||
|
provider_settings: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_provider_update(
|
||||||
|
session: AsyncSession,
|
||||||
|
provider: UpstreamProviderRow,
|
||||||
|
payload: UpstreamProviderUpdate,
|
||||||
|
new_slug: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
if new_slug is not None:
|
||||||
|
validated = _validate_slug(new_slug)
|
||||||
|
await _ensure_unique_slug(session, validated, exclude_id=provider.id)
|
||||||
|
provider.slug = validated
|
||||||
|
|
||||||
|
if payload.provider_type is not None:
|
||||||
|
provider.provider_type = payload.provider_type
|
||||||
|
if payload.base_url is not None:
|
||||||
|
provider.base_url = payload.base_url
|
||||||
|
if payload.api_key is not None:
|
||||||
|
provider.api_key = payload.api_key
|
||||||
|
if payload.api_version is not None:
|
||||||
|
provider.api_version = payload.api_version
|
||||||
|
if payload.enabled is not None:
|
||||||
|
provider.enabled = payload.enabled
|
||||||
|
if payload.provider_fee is not None:
|
||||||
|
provider.provider_fee = payload.provider_fee
|
||||||
|
if payload.provider_settings is not None:
|
||||||
|
provider.provider_settings = json.dumps(payload.provider_settings)
|
||||||
|
|
||||||
|
session.add(provider)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(provider)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
|
@admin_router.get("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
|
||||||
@@ -727,21 +855,7 @@ async def get_upstream_providers() -> list[dict[str, object]]:
|
|||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
result = await session.exec(select(UpstreamProviderRow))
|
result = await session.exec(select(UpstreamProviderRow))
|
||||||
providers = result.all()
|
providers = result.all()
|
||||||
return [
|
return [_serialize_provider(p) for p in providers]
|
||||||
{
|
|
||||||
"id": p.id,
|
|
||||||
"provider_type": p.provider_type,
|
|
||||||
"base_url": p.base_url,
|
|
||||||
"api_key": "[REDACTED]" if p.api_key else "",
|
|
||||||
"api_version": p.api_version,
|
|
||||||
"enabled": p.enabled,
|
|
||||||
"provider_fee": p.provider_fee,
|
|
||||||
"provider_settings": json.loads(p.provider_settings)
|
|
||||||
if p.provider_settings
|
|
||||||
else None,
|
|
||||||
}
|
|
||||||
for p in providers
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
|
@admin_router.post("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
|
||||||
@@ -761,7 +875,14 @@ async def create_upstream_provider(
|
|||||||
detail="Provider with this base URL and API key already exists",
|
detail="Provider with this base URL and API key already exists",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if payload.slug:
|
||||||
|
slug = _validate_slug(payload.slug)
|
||||||
|
await _ensure_unique_slug(session, slug)
|
||||||
|
else:
|
||||||
|
slug = await allocate_unique_provider_slug(session, payload.provider_type)
|
||||||
|
|
||||||
provider = UpstreamProviderRow(
|
provider = UpstreamProviderRow(
|
||||||
|
slug=slug,
|
||||||
provider_type=payload.provider_type,
|
provider_type=payload.provider_type,
|
||||||
base_url=payload.base_url,
|
base_url=payload.base_url,
|
||||||
api_key=payload.api_key,
|
api_key=payload.api_key,
|
||||||
@@ -778,99 +899,81 @@ async def create_upstream_provider(
|
|||||||
|
|
||||||
await reinitialize_upstreams()
|
await reinitialize_upstreams()
|
||||||
await refresh_model_maps()
|
await refresh_model_maps()
|
||||||
return {
|
return _serialize_provider(provider)
|
||||||
"id": provider.id,
|
|
||||||
"provider_type": provider.provider_type,
|
|
||||||
"base_url": provider.base_url,
|
|
||||||
"api_key": "[REDACTED]",
|
|
||||||
"api_version": provider.api_version,
|
|
||||||
"enabled": provider.enabled,
|
|
||||||
"provider_fee": provider.provider_fee,
|
|
||||||
"provider_settings": payload.provider_settings,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get(
|
@admin_router.get(
|
||||||
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
||||||
)
|
)
|
||||||
async def get_upstream_provider(provider_id: int) -> dict[str, object]:
|
async def get_upstream_provider(provider_id: str) -> dict[str, object]:
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
return _serialize_provider(provider)
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
return {
|
|
||||||
"id": provider.id,
|
|
||||||
"provider_type": provider.provider_type,
|
|
||||||
"base_url": provider.base_url,
|
|
||||||
"api_key": "[REDACTED]" if provider.api_key else "",
|
|
||||||
"api_version": provider.api_version,
|
|
||||||
"enabled": provider.enabled,
|
|
||||||
"provider_fee": provider.provider_fee,
|
|
||||||
"provider_settings": json.loads(provider.provider_settings)
|
|
||||||
if provider.provider_settings
|
|
||||||
else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.patch(
|
@admin_router.patch(
|
||||||
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
||||||
)
|
)
|
||||||
async def update_upstream_provider(
|
async def update_upstream_provider(
|
||||||
provider_id: int, payload: UpstreamProviderUpdate
|
provider_id: str, payload: UpstreamProviderUpdate
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
if payload.provider_type is not None:
|
await _apply_provider_update(session, provider, payload, new_slug=payload.slug)
|
||||||
provider.provider_type = payload.provider_type
|
|
||||||
if payload.base_url is not None:
|
|
||||||
provider.base_url = payload.base_url
|
|
||||||
if payload.api_key is not None:
|
|
||||||
provider.api_key = payload.api_key
|
|
||||||
if payload.api_version is not None:
|
|
||||||
provider.api_version = payload.api_version
|
|
||||||
if payload.enabled is not None:
|
|
||||||
provider.enabled = payload.enabled
|
|
||||||
if payload.provider_fee is not None:
|
|
||||||
provider.provider_fee = payload.provider_fee
|
|
||||||
if payload.provider_settings is not None:
|
|
||||||
provider.provider_settings = json.dumps(payload.provider_settings)
|
|
||||||
|
|
||||||
session.add(provider)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(provider)
|
|
||||||
|
|
||||||
await reinitialize_upstreams()
|
await reinitialize_upstreams()
|
||||||
await refresh_model_maps()
|
await refresh_model_maps()
|
||||||
return {
|
return _serialize_provider(provider)
|
||||||
"id": provider.id,
|
|
||||||
"provider_type": provider.provider_type,
|
|
||||||
"base_url": provider.base_url,
|
@admin_router.patch(
|
||||||
"api_key": "[REDACTED]",
|
"/api/upstream-providers", dependencies=[Depends(require_admin_api)]
|
||||||
"api_version": provider.api_version,
|
)
|
||||||
"enabled": provider.enabled,
|
async def update_upstream_provider_by_slug(
|
||||||
"provider_fee": provider.provider_fee,
|
payload: UpstreamProviderUpdateBySlug,
|
||||||
"provider_settings": json.loads(provider.provider_settings)
|
) -> dict[str, object]:
|
||||||
if provider.provider_settings
|
lookup = _validate_slug(payload.slug)
|
||||||
else None,
|
async with create_session() as session:
|
||||||
}
|
result = await session.exec(
|
||||||
|
select(UpstreamProviderRow).where(
|
||||||
|
UpstreamProviderRow.slug == lookup
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider = result.first()
|
||||||
|
if not provider:
|
||||||
|
raise HTTPException(status_code=404, detail="Provider not found")
|
||||||
|
|
||||||
|
update_payload = UpstreamProviderUpdate(
|
||||||
|
provider_type=payload.provider_type,
|
||||||
|
base_url=payload.base_url,
|
||||||
|
api_key=payload.api_key,
|
||||||
|
api_version=payload.api_version,
|
||||||
|
enabled=payload.enabled,
|
||||||
|
provider_fee=payload.provider_fee,
|
||||||
|
provider_settings=payload.provider_settings,
|
||||||
|
)
|
||||||
|
await _apply_provider_update(
|
||||||
|
session, provider, update_payload, new_slug=payload.new_slug
|
||||||
|
)
|
||||||
|
|
||||||
|
await reinitialize_upstreams()
|
||||||
|
await refresh_model_maps()
|
||||||
|
return _serialize_provider(provider)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.delete(
|
@admin_router.delete(
|
||||||
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
"/api/upstream-providers/{provider_id}", dependencies=[Depends(require_admin_api)]
|
||||||
)
|
)
|
||||||
async def delete_upstream_provider(provider_id: int) -> dict[str, object]:
|
async def delete_upstream_provider(provider_id: str) -> dict[str, object]:
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
deleted_id = _provider_pk(provider)
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
await session.delete(provider)
|
await session.delete(provider)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await reinitialize_upstreams()
|
await reinitialize_upstreams()
|
||||||
await refresh_model_maps()
|
await refresh_model_maps()
|
||||||
return {"ok": True, "deleted_id": provider_id}
|
return {"ok": True, "deleted_id": deleted_id}
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/api/provider-types", dependencies=[Depends(require_admin_api)])
|
@admin_router.get("/api/provider-types", dependencies=[Depends(require_admin_api)])
|
||||||
@@ -885,17 +988,16 @@ async def get_provider_types() -> list[dict[str, object]]:
|
|||||||
"/api/upstream-providers/{provider_id}/models",
|
"/api/upstream-providers/{provider_id}/models",
|
||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def get_provider_models(provider_id: int) -> dict[str, object]:
|
async def get_provider_models(provider_id: str) -> dict[str, object]:
|
||||||
from ..upstream.helpers import _instantiate_provider
|
from ..upstream.helpers import _instantiate_provider
|
||||||
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
provider_pk = _provider_pk(provider)
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
db_models = await list_models(
|
db_models = await list_models(
|
||||||
session=session,
|
session=session,
|
||||||
upstream_id=provider_id,
|
upstream_id=provider_pk,
|
||||||
include_disabled=True,
|
include_disabled=True,
|
||||||
apply_fees=False,
|
apply_fees=False,
|
||||||
)
|
)
|
||||||
@@ -985,13 +1087,11 @@ class TopupTokenRequest(BaseModel):
|
|||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def topup_provider_with_token(
|
async def topup_provider_with_token(
|
||||||
provider_id: int, payload: TopupTokenRequest
|
provider_id: str, payload: TopupTokenRequest
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Redeem a Cashu token for an upstream provider."""
|
"""Redeem a Cashu token for an upstream provider."""
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -1022,15 +1122,13 @@ async def topup_provider_with_token(
|
|||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def initiate_provider_topup(
|
async def initiate_provider_topup(
|
||||||
provider_id: int, payload: TopupRequest
|
provider_id: str, payload: TopupRequest
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Initiate a Lightning Network top-up for the upstream provider account."""
|
"""Initiate a Lightning Network top-up for the upstream provider account."""
|
||||||
from ..upstream.helpers import _instantiate_provider
|
from ..upstream.helpers import _instantiate_provider
|
||||||
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1150,15 +1248,13 @@ async def initiate_provider_topup(
|
|||||||
"/api/upstream-providers/{provider_id}/topup/{invoice_id}/status",
|
"/api/upstream-providers/{provider_id}/topup/{invoice_id}/status",
|
||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, object]:
|
async def check_topup_status(provider_id: str, invoice_id: str) -> dict[str, object]:
|
||||||
"""Check the status of a Lightning Network top-up invoice."""
|
"""Check the status of a Lightning Network top-up invoice."""
|
||||||
from ..upstream.helpers import _instantiate_provider
|
from ..upstream.helpers import _instantiate_provider
|
||||||
from ..upstream.ppqai import PPQAIUpstreamProvider
|
from ..upstream.ppqai import PPQAIUpstreamProvider
|
||||||
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
# For Routstr providers, proxy the status check
|
# For Routstr providers, proxy the status check
|
||||||
if provider.provider_type == "routstr":
|
if provider.provider_type == "routstr":
|
||||||
@@ -1205,14 +1301,12 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
|
|||||||
"/api/upstream-providers/{provider_id}/balance",
|
"/api/upstream-providers/{provider_id}/balance",
|
||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def get_provider_balance(provider_id: int) -> dict[str, object]:
|
async def get_provider_balance(provider_id: str) -> dict[str, object]:
|
||||||
"""Get the current balance for an upstream provider account."""
|
"""Get the current balance for an upstream provider account."""
|
||||||
from ..upstream.helpers import _instantiate_provider
|
from ..upstream.helpers import _instantiate_provider
|
||||||
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider:
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
# For Routstr providers, proxy the balance check
|
# For Routstr providers, proxy the balance check
|
||||||
if provider.provider_type == "routstr":
|
if provider.provider_type == "routstr":
|
||||||
@@ -1591,15 +1685,13 @@ async def get_lightning_invoices_api(
|
|||||||
"/api/upstream-providers/{provider_id}/routstr/refund",
|
"/api/upstream-providers/{provider_id}/routstr/refund",
|
||||||
dependencies=[Depends(require_admin_api)],
|
dependencies=[Depends(require_admin_api)],
|
||||||
)
|
)
|
||||||
async def refund_routstr_provider_balance(provider_id: int) -> dict[str, object]:
|
async def refund_routstr_provider_balance(provider_id: str) -> dict[str, object]:
|
||||||
"""Refund balance from an upstream Routstr provider back to the local wallet."""
|
"""Refund balance from an upstream Routstr provider back to the local wallet."""
|
||||||
from ..upstream.helpers import _instantiate_provider
|
from ..upstream.helpers import _instantiate_provider
|
||||||
from ..upstream.routstr import RoutstrUpstreamProvider
|
from ..upstream.routstr import RoutstrUpstreamProvider
|
||||||
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider_row = await session.get(UpstreamProviderRow, provider_id)
|
provider_row = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
if not provider_row:
|
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
|
||||||
|
|
||||||
if provider_row.provider_type != "routstr":
|
if provider_row.provider_type != "routstr":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -319,6 +319,12 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
slug: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
unique=True,
|
||||||
|
index=True,
|
||||||
|
description="Stable external slug used for updates via API key.",
|
||||||
|
)
|
||||||
provider_type: str = Field(
|
provider_type: str = Field(
|
||||||
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
|
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from itertools import count
|
||||||
|
from typing import Collection
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from .db import UpstreamProviderRow
|
||||||
|
|
||||||
|
_SLUG_BASE_PATTERN = re.compile(r"[^a-z0-9]+")
|
||||||
|
_MAX_SLUG_LENGTH = 64
|
||||||
|
|
||||||
|
|
||||||
|
def provider_slug_base(provider_type: str) -> str:
|
||||||
|
"""Return a deterministic slug base for a provider type."""
|
||||||
|
base = _SLUG_BASE_PATTERN.sub("-", provider_type.lower()).strip("-")
|
||||||
|
if not base:
|
||||||
|
base = "provider"
|
||||||
|
elif base.isdigit():
|
||||||
|
base = f"provider-{base}"
|
||||||
|
elif len(base) < 3:
|
||||||
|
base = f"{base}-provider"
|
||||||
|
|
||||||
|
if len(base) > _MAX_SLUG_LENGTH:
|
||||||
|
base = base[:_MAX_SLUG_LENGTH].rstrip("-") or "provider"
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def provider_slug_candidate(base: str, suffix_number: int) -> str:
|
||||||
|
if suffix_number == 1:
|
||||||
|
return base
|
||||||
|
|
||||||
|
suffix = f"-{suffix_number}"
|
||||||
|
max_base_length = _MAX_SLUG_LENGTH - len(suffix)
|
||||||
|
return f"{base[:max_base_length].rstrip('-')}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
async def allocate_unique_provider_slug(
|
||||||
|
session: AsyncSession,
|
||||||
|
provider_type: str,
|
||||||
|
reserved_slugs: Collection[str] = (),
|
||||||
|
) -> str:
|
||||||
|
"""Allocate a stable, deterministic provider slug.
|
||||||
|
|
||||||
|
The first provider of a type gets ``openai``; later collisions get
|
||||||
|
``openai-2``, ``openai-3``, etc. ``reserved_slugs`` covers rows staged in
|
||||||
|
memory but not flushed yet, such as settings/env seeding.
|
||||||
|
"""
|
||||||
|
base = provider_slug_base(provider_type)
|
||||||
|
reserved = {slug.lower() for slug in reserved_slugs}
|
||||||
|
|
||||||
|
for suffix_number in count(1):
|
||||||
|
candidate = provider_slug_candidate(base, suffix_number)
|
||||||
|
if candidate in reserved:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = await session.exec(
|
||||||
|
select(UpstreamProviderRow).where(UpstreamProviderRow.slug == candidate)
|
||||||
|
)
|
||||||
|
if result.first() is None:
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
raise RuntimeError("unreachable")
|
||||||
@@ -94,7 +94,10 @@ def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
|
|||||||
cache reads (DeepSeek hits are 10x cheaper) and undercharges Anthropic
|
cache reads (DeepSeek hits are 10x cheaper) and undercharges Anthropic
|
||||||
cache writes (1.25x). litellm ships per-model USD rates keyed by the exact
|
cache writes (1.25x). litellm ships per-model USD rates keyed by the exact
|
||||||
OpenRouter id (deepseek/deepseek-chat) or by the bare model name
|
OpenRouter id (deepseek/deepseek-chat) or by the bare model name
|
||||||
(gpt-4o, claude-sonnet-4-5), so both spellings are tried.
|
(gpt-4o, claude-sonnet-4-5), so both spellings are tried. litellm keys are
|
||||||
|
lowercase, but a generic upstream may report a mixed-case id
|
||||||
|
(``deepseek-ai/DeepSeek-V4-Flash``); an exact match is attempted first, then
|
||||||
|
a case-insensitive fallback so such ids still resolve.
|
||||||
|
|
||||||
Rates already present (e.g. provided by OpenRouter) are authoritative and
|
Rates already present (e.g. provided by OpenRouter) are authoritative and
|
||||||
never overwritten. Unknown models are returned unchanged.
|
never overwritten. Unknown models are returned unchanged.
|
||||||
@@ -106,12 +109,26 @@ def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
|
|||||||
|
|
||||||
import litellm
|
import litellm
|
||||||
|
|
||||||
|
candidates = (model_id, model_id.split("/", 1)[-1])
|
||||||
info: dict | None = None
|
info: dict | None = None
|
||||||
for key in (model_id, model_id.split("/", 1)[-1]):
|
for key in candidates:
|
||||||
candidate = litellm.model_cost.get(key)
|
candidate = litellm.model_cost.get(key)
|
||||||
if isinstance(candidate, dict):
|
if isinstance(candidate, dict):
|
||||||
info = candidate
|
info = candidate
|
||||||
break
|
break
|
||||||
|
if info is None:
|
||||||
|
# Case-insensitive fallback: a mixed-case upstream id (e.g.
|
||||||
|
# ``deepseek-ai/DeepSeek-V4-Flash``) won't match litellm's lowercase
|
||||||
|
# keys exactly. Build a lowercased index once and retry.
|
||||||
|
lowered = {c.lower() for c in candidates}
|
||||||
|
for key, candidate in litellm.model_cost.items():
|
||||||
|
if (
|
||||||
|
isinstance(key, str)
|
||||||
|
and key.lower() in lowered
|
||||||
|
and isinstance(candidate, dict)
|
||||||
|
):
|
||||||
|
info = candidate
|
||||||
|
break
|
||||||
if info is None:
|
if info is None:
|
||||||
return pricing
|
return pricing
|
||||||
|
|
||||||
@@ -215,13 +232,29 @@ def _row_to_model(
|
|||||||
)
|
)
|
||||||
top_provider_dict = json.loads(row.top_provider) if row.top_provider else None
|
top_provider_dict = json.loads(row.top_provider) if row.top_provider else None
|
||||||
|
|
||||||
if apply_provider_fee and isinstance(pricing, dict):
|
|
||||||
pricing = {k: float(v) * provider_fee for k, v in pricing.items()}
|
|
||||||
|
|
||||||
if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0:
|
if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0:
|
||||||
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
|
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
|
||||||
|
|
||||||
parsed_pricing = Pricing.parse_obj(pricing)
|
parsed_pricing = Pricing.parse_obj(pricing)
|
||||||
|
|
||||||
|
# Fill missing cache-read/write rates from litellm's cost map BEFORE applying
|
||||||
|
# the provider fee, so they carry the same markup as every other component.
|
||||||
|
# DB-stored override pricing (e.g. generic providers) omits cache rates;
|
||||||
|
# without this, ``_row_to_model`` bills cache reads at the full input rate —
|
||||||
|
# the ``_apply_provider_fee_to_model`` path backfills, but the override path
|
||||||
|
# used for admin-configured providers did not.
|
||||||
|
#
|
||||||
|
# Key on ``forwarded_model_id`` (the actual upstream model name litellm
|
||||||
|
# prices) when set: an alias row (id="local-alias",
|
||||||
|
# forwarded_model_id="deepseek-v4-flash") would otherwise look up the alias
|
||||||
|
# and miss the cache rate.
|
||||||
|
pricing_model_id = getattr(row, "forwarded_model_id", None) or row.id
|
||||||
|
parsed_pricing = backfill_cache_pricing(pricing_model_id, parsed_pricing)
|
||||||
|
|
||||||
|
if apply_provider_fee:
|
||||||
|
parsed_pricing = Pricing.parse_obj(
|
||||||
|
{k: float(v) * provider_fee for k, v in parsed_pricing.dict().items()}
|
||||||
|
)
|
||||||
model = Model(
|
model = Model(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
name=row.name,
|
name=row.name,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class AnthropicUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "AnthropicUpstreamProvider":
|
) -> "AnthropicUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
|
|||||||
|
|
||||||
# Instantiate provider and check balance
|
# Instantiate provider and check balance
|
||||||
provider = RoutstrUpstreamProvider.from_db_row(row)
|
provider = RoutstrUpstreamProvider.from_db_row(row)
|
||||||
|
if provider is None:
|
||||||
|
return
|
||||||
balance = await provider.get_balance()
|
balance = await provider.get_balance()
|
||||||
|
|
||||||
if balance is None:
|
if balance is None:
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class AzureUpstreamProvider(BaseUpstreamProvider):
|
|||||||
self.api_version = api_version
|
self.api_version = api_version
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "AzureUpstreamProvider | None":
|
) -> "AzureUpstreamProvider | None":
|
||||||
if not provider_row.api_version:
|
if not provider_row.api_version:
|
||||||
|
|||||||
+102
-63
@@ -6,13 +6,12 @@ import math
|
|||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncGenerator, AsyncIterator, Iterator
|
from collections.abc import AsyncGenerator, AsyncIterator, Iterator
|
||||||
from typing import Any, Mapping, cast
|
from typing import Any, Mapping, Self, cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import BackgroundTasks, HTTPException, Request
|
from fastapi import BackgroundTasks, HTTPException, Request
|
||||||
from fastapi.responses import Response, StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
from pydantic.v1 import BaseModel
|
from pydantic.v1 import BaseModel
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from ..auth import adjust_payment_for_tokens
|
from ..auth import adjust_payment_for_tokens
|
||||||
from ..core import get_logger
|
from ..core import get_logger
|
||||||
@@ -41,7 +40,12 @@ from ..payment.models import (
|
|||||||
list_models,
|
list_models,
|
||||||
)
|
)
|
||||||
from ..payment.price import sats_usd_price
|
from ..payment.price import sats_usd_price
|
||||||
from ..wallet import recieve_token, send_token
|
from ..wallet import (
|
||||||
|
SPENT_TOKEN_CODES,
|
||||||
|
classify_redemption_error,
|
||||||
|
recieve_token,
|
||||||
|
send_token,
|
||||||
|
)
|
||||||
from . import messages_dispatch
|
from . import messages_dispatch
|
||||||
from .cache_breakpoints import (
|
from .cache_breakpoints import (
|
||||||
inject_anthropic_cache_breakpoints,
|
inject_anthropic_cache_breakpoints,
|
||||||
@@ -92,6 +96,11 @@ class BaseUpstreamProvider:
|
|||||||
base_url: str
|
base_url: str
|
||||||
api_key: str
|
api_key: str
|
||||||
provider_fee: float = 1.05
|
provider_fee: float = 1.05
|
||||||
|
# Primary key of the ``upstream_providers`` row this instance was built
|
||||||
|
# from. Set by ``from_db_row`` so a live provider can re-find its own row by
|
||||||
|
# stable identity instead of its rotatable ``api_key``. ``None`` for
|
||||||
|
# instances not sourced from a row.
|
||||||
|
db_id: int | None = None
|
||||||
_models_cache: list[Model] = []
|
_models_cache: list[Model] = []
|
||||||
_models_by_id: dict[str, Model] = {}
|
_models_by_id: dict[str, Model] = {}
|
||||||
|
|
||||||
@@ -106,6 +115,7 @@ class BaseUpstreamProvider:
|
|||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.provider_fee = provider_fee
|
self.provider_fee = provider_fee
|
||||||
|
self.db_id = None
|
||||||
self._models_cache = []
|
self._models_cache = []
|
||||||
self._models_by_id = {}
|
self._models_by_id = {}
|
||||||
|
|
||||||
@@ -123,10 +133,13 @@ class BaseUpstreamProvider:
|
|||||||
return detect_litellm_prefix(self.base_url)
|
return detect_litellm_prefix(self.base_url)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "Self | None":
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
"""Instantiate a provider from a database row, carrying its identity.
|
||||||
) -> "BaseUpstreamProvider | None":
|
|
||||||
"""Factory method to instantiate provider from database row.
|
Construction itself is delegated to the ``_build_from_row`` hook (which
|
||||||
|
subclasses override to match their constructor); this wrapper stamps the
|
||||||
|
row's primary key onto the instance as ``db_id`` so the provider can
|
||||||
|
later re-find its own row by identity rather than by its ``api_key``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
provider_row: Database row containing provider configuration
|
provider_row: Database row containing provider configuration
|
||||||
@@ -134,6 +147,19 @@ class BaseUpstreamProvider:
|
|||||||
Returns:
|
Returns:
|
||||||
Instantiated provider or None if instantiation fails
|
Instantiated provider or None if instantiation fails
|
||||||
"""
|
"""
|
||||||
|
provider = cls._build_from_row(provider_row)
|
||||||
|
if provider is not None:
|
||||||
|
provider.db_id = provider_row.id
|
||||||
|
return provider
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_from_row(cls, provider_row: "UpstreamProviderRow") -> "Self | None":
|
||||||
|
"""Construct the provider instance from a row (no identity stamping).
|
||||||
|
|
||||||
|
Overridden by subclasses whose constructors differ from the base
|
||||||
|
``(base_url, api_key, provider_fee)`` shape. Callers should use
|
||||||
|
``from_db_row`` instead, which also attaches ``db_id``.
|
||||||
|
"""
|
||||||
return cls(
|
return cls(
|
||||||
base_url=provider_row.base_url,
|
base_url=provider_row.base_url,
|
||||||
api_key=provider_row.api_key,
|
api_key=provider_row.api_key,
|
||||||
@@ -3960,9 +3986,19 @@ class BaseUpstreamProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
redeemed = False
|
||||||
try:
|
try:
|
||||||
headers = dict(request.headers)
|
headers = dict(request.headers)
|
||||||
amount, unit, mint = await recieve_token(x_cashu_token)
|
amount, unit, mint = await recieve_token(x_cashu_token)
|
||||||
|
# Reject a zero/negative redemption (empty/dust token, or a value
|
||||||
|
# fully consumed by fees) before marking the token redeemed, so it
|
||||||
|
# classifies as cashu_token_zero_value like the bearer/top-up paths
|
||||||
|
# rather than being forwarded as a free request.
|
||||||
|
if amount <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"Redeemed token amount must be positive, got {amount} {unit}"
|
||||||
|
)
|
||||||
|
redeemed = True
|
||||||
headers = self.prepare_headers(dict(request.headers))
|
headers = self.prepare_headers(dict(request.headers))
|
||||||
|
|
||||||
request_id = getattr(request.state, "request_id", None)
|
request_id = getattr(request.state, "request_id", None)
|
||||||
@@ -4006,40 +4042,37 @@ class BaseUpstreamProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use same error handling as regular X-Cashu
|
# Post-redemption the token is spent; a forwarding failure must not
|
||||||
if "already spent" in error_message.lower():
|
# be reported as a retryable redemption error (see handle_x_cashu).
|
||||||
|
if redeemed:
|
||||||
return create_error_response(
|
return create_error_response(
|
||||||
"token_already_spent",
|
"upstream_error",
|
||||||
"The provided CASHU token has already been spent",
|
"Payment succeeded but the upstream request failed",
|
||||||
400,
|
502,
|
||||||
request=request,
|
request=request,
|
||||||
token=x_cashu_token,
|
code="upstream_request_failed",
|
||||||
)
|
)
|
||||||
|
|
||||||
if "invalid token" in error_message.lower():
|
classified = classify_redemption_error(e)
|
||||||
|
if classified is None:
|
||||||
return create_error_response(
|
return create_error_response(
|
||||||
"invalid_token",
|
"api_error",
|
||||||
"The provided CASHU token is invalid",
|
"Internal error during token redemption",
|
||||||
400,
|
500,
|
||||||
request=request,
|
request=request,
|
||||||
token=x_cashu_token,
|
code="internal_error",
|
||||||
)
|
)
|
||||||
|
error_type, status_code, message, error_code = classified
|
||||||
if "mint error" in error_message.lower():
|
# Echo the token back only when it is still spendable, so clients
|
||||||
return create_error_response(
|
# can recover it; a spent/consumed token is never re-offered.
|
||||||
"mint_error",
|
echo_token = None if error_code in SPENT_TOKEN_CODES else x_cashu_token
|
||||||
f"CASHU mint error: {error_message}",
|
|
||||||
422,
|
|
||||||
request=request,
|
|
||||||
token=x_cashu_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
return create_error_response(
|
return create_error_response(
|
||||||
"cashu_error",
|
error_type,
|
||||||
f"CASHU token processing failed: {error_message}",
|
message,
|
||||||
400,
|
status_code,
|
||||||
request=request,
|
request=request,
|
||||||
token=x_cashu_token,
|
token=echo_token,
|
||||||
|
code=error_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def forward_x_cashu_responses_request(
|
async def forward_x_cashu_responses_request(
|
||||||
@@ -4629,9 +4662,19 @@ class BaseUpstreamProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
redeemed = False
|
||||||
try:
|
try:
|
||||||
headers = dict(request.headers)
|
headers = dict(request.headers)
|
||||||
amount, unit, mint = await recieve_token(x_cashu_token)
|
amount, unit, mint = await recieve_token(x_cashu_token)
|
||||||
|
# Reject a zero/negative redemption (empty/dust token, or a value
|
||||||
|
# fully consumed by fees) before marking the token redeemed, so it
|
||||||
|
# classifies as cashu_token_zero_value like the bearer/top-up paths
|
||||||
|
# rather than being forwarded as a free request.
|
||||||
|
if amount <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"Redeemed token amount must be positive, got {amount} {unit}"
|
||||||
|
)
|
||||||
|
redeemed = True
|
||||||
headers = self.prepare_headers(dict(request.headers))
|
headers = self.prepare_headers(dict(request.headers))
|
||||||
|
|
||||||
request_id = getattr(request.state, "request_id", None)
|
request_id = getattr(request.state, "request_id", None)
|
||||||
@@ -4675,39 +4718,38 @@ class BaseUpstreamProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if "already spent" in error_message.lower():
|
# Once redeemed the token is spent, so a later forwarding failure
|
||||||
|
# must not surface as a retryable mint_unreachable (spent-token retry
|
||||||
|
# bait). Redemption classification only applies while not redeemed.
|
||||||
|
if redeemed:
|
||||||
return create_error_response(
|
return create_error_response(
|
||||||
"token_already_spent",
|
"upstream_error",
|
||||||
"The provided CASHU token has already been spent",
|
"Payment succeeded but the upstream request failed",
|
||||||
400,
|
502,
|
||||||
request=request,
|
request=request,
|
||||||
token=x_cashu_token,
|
code="upstream_request_failed",
|
||||||
)
|
)
|
||||||
|
|
||||||
if "invalid token" in error_message.lower():
|
classified = classify_redemption_error(e)
|
||||||
|
if classified is None:
|
||||||
return create_error_response(
|
return create_error_response(
|
||||||
"invalid_token",
|
"api_error",
|
||||||
"The provided CASHU token is invalid",
|
"Internal error during token redemption",
|
||||||
400,
|
500,
|
||||||
request=request,
|
request=request,
|
||||||
token=x_cashu_token,
|
code="internal_error",
|
||||||
)
|
)
|
||||||
|
error_type, status_code, message, error_code = classified
|
||||||
if "mint error" in error_message.lower():
|
# Echo the token back only when it is still spendable, so clients
|
||||||
return create_error_response(
|
# can recover it; a spent/consumed token is never re-offered.
|
||||||
"mint_error",
|
echo_token = None if error_code in SPENT_TOKEN_CODES else x_cashu_token
|
||||||
f"CASHU mint error: {error_message}",
|
|
||||||
422,
|
|
||||||
request=request,
|
|
||||||
token=x_cashu_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
return create_error_response(
|
return create_error_response(
|
||||||
"cashu_error",
|
error_type,
|
||||||
f"CASHU token processing failed: {error_message}",
|
message,
|
||||||
400,
|
status_code,
|
||||||
request=request,
|
request=request,
|
||||||
token=x_cashu_token,
|
token=echo_token,
|
||||||
|
code=error_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _apply_provider_fee_to_model(self, model: Model) -> Model:
|
def _apply_provider_fee_to_model(self, model: Model) -> Model:
|
||||||
@@ -4878,14 +4920,11 @@ class BaseUpstreamProvider:
|
|||||||
"""Refresh the in-memory models cache from upstream API."""
|
"""Refresh the in-memory models cache from upstream API."""
|
||||||
try:
|
try:
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
stmt = select(UpstreamProviderRow).where(
|
provider = (
|
||||||
UpstreamProviderRow.base_url == self.base_url,
|
await session.get(UpstreamProviderRow, self.db_id)
|
||||||
UpstreamProviderRow.api_key == self.api_key,
|
if self.db_id is not None
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
result = await session.exec(stmt)
|
|
||||||
|
|
||||||
# .first() returns the object or None if not found
|
|
||||||
provider = result.first()
|
|
||||||
if not provider or not provider.id:
|
if not provider or not provider.id:
|
||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
raise HTTPException(status_code=404, detail="Provider not found")
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,15 @@
|
|||||||
litellm's bundled cost map does not yet ship ``deepseek-v4-flash`` /
|
litellm's bundled cost map does not yet ship ``deepseek-v4-flash`` /
|
||||||
``deepseek-v4-pro``. Without an entry, ``backfill_cache_pricing`` cannot find a
|
``deepseek-v4-pro``. Without an entry, ``backfill_cache_pricing`` cannot find a
|
||||||
``cache_read_input_token_cost`` and cache reads fall back to the full input
|
``cache_read_input_token_cost`` and cache reads fall back to the full input
|
||||||
rate — a ~60% overcharge on cache hits (DeepSeek hits are ~0.2x input).
|
rate — a large overcharge on cache hits (DeepSeek V4 hits are ~0.008-0.02x
|
||||||
|
input, i.e. cached tokens cost 50-120x less than regular input).
|
||||||
|
|
||||||
This module injects the missing entries into ``litellm.model_cost`` at startup
|
This module injects the missing entries into ``litellm.model_cost`` at startup
|
||||||
so the existing backfill path resolves them. Rates mirror the open upstream PR
|
so the existing backfill path resolves them. Rates mirror the canonical
|
||||||
|
``deepseek`` provider entries now in litellm's ``model_prices`` map
|
||||||
|
(``input_cost_per_token`` is the cache-*miss* rate;
|
||||||
|
``cache_read_input_token_cost`` is the cache-*hit* rate), sourced from
|
||||||
|
https://api-docs.deepseek.com/quick_start/pricing via
|
||||||
https://github.com/BerriAI/litellm/pull/26380 (issue
|
https://github.com/BerriAI/litellm/pull/26380 (issue
|
||||||
https://github.com/BerriAI/litellm/issues/30430).
|
https://github.com/BerriAI/litellm/issues/30430).
|
||||||
|
|
||||||
@@ -22,21 +27,23 @@ from ..core import get_logger
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
# USD per token. Source: BerriAI/litellm PR #26380.
|
# USD per token. Mirrors the canonical ``deepseek`` provider entries in
|
||||||
|
# litellm's model_prices map (source: DeepSeek API pricing docs). Keep these in
|
||||||
|
# sync with ``litellm.model_cost["deepseek/deepseek-v4-*"]``.
|
||||||
_DEEPSEEK_V4_RATES: dict[str, dict[str, float]] = {
|
_DEEPSEEK_V4_RATES: dict[str, dict[str, float]] = {
|
||||||
"deepseek-v4-flash": {
|
"deepseek-v4-flash": {
|
||||||
"input_cost_per_token": 1.4e-07,
|
"input_cost_per_token": 1.4e-07,
|
||||||
"output_cost_per_token": 2.8e-07,
|
"output_cost_per_token": 2.8e-07,
|
||||||
"cache_read_input_token_cost": 2.8e-08,
|
"cache_read_input_token_cost": 2.8e-09,
|
||||||
"cache_creation_input_token_cost": 0.0,
|
"cache_creation_input_token_cost": 0.0,
|
||||||
"input_cost_per_token_cache_hit": 2.8e-08,
|
"input_cost_per_token_cache_hit": 2.8e-09,
|
||||||
},
|
},
|
||||||
"deepseek-v4-pro": {
|
"deepseek-v4-pro": {
|
||||||
"input_cost_per_token": 1.74e-06,
|
"input_cost_per_token": 4.35e-07,
|
||||||
"output_cost_per_token": 3.48e-06,
|
"output_cost_per_token": 8.7e-07,
|
||||||
"cache_read_input_token_cost": 1.4e-07,
|
"cache_read_input_token_cost": 3.625e-09,
|
||||||
"cache_creation_input_token_cost": 0.0,
|
"cache_creation_input_token_cost": 0.0,
|
||||||
"input_cost_per_token_cache_hit": 1.4e-07,
|
"input_cost_per_token_cache_hit": 3.625e-09,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class FireworksUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "FireworksUpstreamProvider":
|
) -> "FireworksUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
|
|||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "GeminiUpstreamProvider":
|
) -> "GeminiUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "GenericUpstreamProvider":
|
) -> "GenericUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class GroqUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "GroqUpstreamProvider":
|
def _build_from_row(cls, provider_row: "UpstreamProviderRow") -> "GroqUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
api_key=provider_row.api_key,
|
api_key=provider_row.api_key,
|
||||||
provider_fee=provider_row.provider_fee,
|
provider_fee=provider_row.provider_fee,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from sqlmodel import select
|
|||||||
|
|
||||||
from ..core import get_logger
|
from ..core import get_logger
|
||||||
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
|
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
|
||||||
|
from ..core.provider_slugs import allocate_unique_provider_slug
|
||||||
from ..payment.models import Model
|
from ..payment.models import Model
|
||||||
from .base import BaseUpstreamProvider
|
from .base import BaseUpstreamProvider
|
||||||
|
|
||||||
@@ -215,9 +216,6 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
|||||||
|
|
||||||
provider = _instantiate_provider(provider_row)
|
provider = _instantiate_provider(provider_row)
|
||||||
if provider:
|
if provider:
|
||||||
# Keep provider DB id on runtime instance so model mapping can
|
|
||||||
# bind DB overrides to the correct upstream.
|
|
||||||
setattr(provider, "db_id", provider_row.id)
|
|
||||||
await provider.refresh_models_cache()
|
await provider.refresh_models_cache()
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Initialized {provider_row.provider_type} provider",
|
f"Initialized {provider_row.provider_type} provider",
|
||||||
@@ -250,6 +248,7 @@ async def _seed_providers_from_settings(
|
|||||||
|
|
||||||
providers_to_add: list[UpstreamProviderRow] = []
|
providers_to_add: list[UpstreamProviderRow] = []
|
||||||
seeded_provider_keys: set[tuple[str, str]] = set()
|
seeded_provider_keys: set[tuple[str, str]] = set()
|
||||||
|
reserved_slugs: set[str] = set()
|
||||||
|
|
||||||
provider_classes_by_type = {
|
provider_classes_by_type = {
|
||||||
cls.provider_type: cls
|
cls.provider_type: cls
|
||||||
@@ -279,8 +278,13 @@ async def _seed_providers_from_settings(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
|
slug = await allocate_unique_provider_slug(
|
||||||
|
session, provider_type, reserved_slugs
|
||||||
|
)
|
||||||
|
reserved_slugs.add(slug)
|
||||||
providers_to_add.append(
|
providers_to_add.append(
|
||||||
UpstreamProviderRow(
|
UpstreamProviderRow(
|
||||||
|
slug=slug,
|
||||||
provider_type=provider_type,
|
provider_type=provider_type,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
@@ -299,8 +303,13 @@ async def _seed_providers_from_settings(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
|
slug = await allocate_unique_provider_slug(
|
||||||
|
session, "ollama", reserved_slugs
|
||||||
|
)
|
||||||
|
reserved_slugs.add(slug)
|
||||||
providers_to_add.append(
|
providers_to_add.append(
|
||||||
UpstreamProviderRow(
|
UpstreamProviderRow(
|
||||||
|
slug=slug,
|
||||||
provider_type="ollama",
|
provider_type="ollama",
|
||||||
base_url=ollama_base_url,
|
base_url=ollama_base_url,
|
||||||
api_key=ollama_api_key,
|
api_key=ollama_api_key,
|
||||||
@@ -320,8 +329,13 @@ async def _seed_providers_from_settings(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
|
slug = await allocate_unique_provider_slug(
|
||||||
|
session, "azure", reserved_slugs
|
||||||
|
)
|
||||||
|
reserved_slugs.add(slug)
|
||||||
providers_to_add.append(
|
providers_to_add.append(
|
||||||
UpstreamProviderRow(
|
UpstreamProviderRow(
|
||||||
|
slug=slug,
|
||||||
provider_type="azure",
|
provider_type="azure",
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
@@ -342,8 +356,13 @@ async def _seed_providers_from_settings(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
|
slug = await allocate_unique_provider_slug(
|
||||||
|
session, "custom", reserved_slugs
|
||||||
|
)
|
||||||
|
reserved_slugs.add(slug)
|
||||||
providers_to_add.append(
|
providers_to_add.append(
|
||||||
UpstreamProviderRow(
|
UpstreamProviderRow(
|
||||||
|
slug=slug,
|
||||||
provider_type="custom",
|
provider_type="custom",
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
@@ -356,7 +375,7 @@ async def _seed_providers_from_settings(
|
|||||||
session.add(provider)
|
session.add(provider)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Seeding {provider.provider_type} provider", # type: ignore[str-format]
|
f"Seeding {provider.provider_type} provider", # type: ignore[str-format]
|
||||||
extra={"base_url": provider.base_url},
|
extra={"base_url": provider.base_url, "slug": provider.slug},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -391,9 +410,7 @@ def _instantiate_provider(
|
|||||||
return provider
|
return provider
|
||||||
|
|
||||||
if provider_row.provider_type == "custom":
|
if provider_row.provider_type == "custom":
|
||||||
return BaseUpstreamProvider(
|
return BaseUpstreamProvider.from_db_row(provider_row)
|
||||||
provider_row.base_url, provider_row.api_key, provider_row.provider_fee
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Unknown provider type: {provider_row.provider_type}",
|
f"Unknown provider type: {provider_row.provider_type}",
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "OllamaUpstreamProvider":
|
) -> "OllamaUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class OpenAIUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "OpenAIUpstreamProvider":
|
) -> "OpenAIUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "OpenRouterUpstreamProvider":
|
) -> "OpenRouterUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class PerplexityUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "PerplexityUpstreamProvider":
|
) -> "PerplexityUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "PPQAIUpstreamProvider":
|
) -> "PPQAIUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
@@ -229,17 +229,14 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
|||||||
f"Disabling PPQ.AI provider ({self.base_url}) due to insufficient balance",
|
f"Disabling PPQ.AI provider ({self.base_url}) due to insufficient balance",
|
||||||
extra={"error": error_message},
|
extra={"error": error_message},
|
||||||
)
|
)
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from ..core.db import UpstreamProviderRow, create_session
|
from ..core.db import UpstreamProviderRow, create_session
|
||||||
|
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
statement = select(UpstreamProviderRow).where(
|
provider = (
|
||||||
UpstreamProviderRow.base_url == self.base_url,
|
await session.get(UpstreamProviderRow, self.db_id)
|
||||||
UpstreamProviderRow.api_key == self.api_key,
|
if self.db_id is not None
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
result = await session.exec(statement)
|
|
||||||
provider = result.first()
|
|
||||||
|
|
||||||
if provider:
|
if provider:
|
||||||
provider.enabled = False
|
provider.enabled = False
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class RoutstrUpstreamProvider(BaseUpstreamProvider):
|
|||||||
return path.lstrip("/")
|
return path.lstrip("/")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(
|
def _build_from_row(
|
||||||
cls, provider_row: "UpstreamProviderRow"
|
cls, provider_row: "UpstreamProviderRow"
|
||||||
) -> "RoutstrUpstreamProvider":
|
) -> "RoutstrUpstreamProvider":
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class XAIUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "XAIUpstreamProvider":
|
def _build_from_row(cls, provider_row: "UpstreamProviderRow") -> "XAIUpstreamProvider":
|
||||||
return cls(
|
return cls(
|
||||||
api_key=provider_row.api_key,
|
api_key=provider_row.api_key,
|
||||||
provider_fee=provider_row.provider_fee,
|
provider_fee=provider_row.provider_fee,
|
||||||
|
|||||||
+188
-17
@@ -1,9 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
|
import httpx
|
||||||
from cashu.core.base import Proof, Token
|
from cashu.core.base import Proof, Token
|
||||||
from cashu.core.mint_info import MintInfo as _CashuMintInfo
|
from cashu.core.mint_info import MintInfo as _CashuMintInfo
|
||||||
from cashu.wallet.helpers import deserialize_token_from_string
|
from cashu.wallet.helpers import deserialize_token_from_string
|
||||||
@@ -31,6 +33,149 @@ _CashuMintInfo.model_rebuild(force=True)
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MintConnectionError(Exception):
|
||||||
|
"""The mint could not be reached (network transport failure).
|
||||||
|
|
||||||
|
Maps to a 503, not a 4xx: the token is fine, the mint is just unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TokenConsumedError(Exception):
|
||||||
|
"""A failure that happened AFTER the token's proofs were spent (melt
|
||||||
|
succeeded, or redemption already returned) — e.g. minting on the primary
|
||||||
|
mint or the DB credit then failed.
|
||||||
|
|
||||||
|
Non-retryable: the same token will not work again. Seals the cause chain so
|
||||||
|
a transport error underneath is never re-surfaced as a retryable
|
||||||
|
mint_unreachable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# httpx base classes cover their subclasses. HTTPStatusError is excluded on
|
||||||
|
# purpose — that means the mint answered, just with an error status.
|
||||||
|
_TRANSPORT_EXC_TYPES: tuple[type[BaseException], ...] = (
|
||||||
|
httpx.NetworkError,
|
||||||
|
httpx.TimeoutException,
|
||||||
|
ConnectionError, # refused/reset/aborted
|
||||||
|
socket.gaierror, # DNS failure
|
||||||
|
asyncio.TimeoutError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_mint_connection_error(error: BaseException) -> bool:
|
||||||
|
"""True if ``error`` (or anything in its cause/context chain) is a mint
|
||||||
|
transport failure. Walks the chain because some sites re-raise transport
|
||||||
|
errors wrapped in ValueError/MintConnectionError; matches on TYPE, not text.
|
||||||
|
"""
|
||||||
|
seen: set[int] = set()
|
||||||
|
current: BaseException | None = error
|
||||||
|
while current is not None and id(current) not in seen:
|
||||||
|
seen.add(id(current))
|
||||||
|
if isinstance(current, TokenConsumedError):
|
||||||
|
# Sealed: the token was already spent, so whatever transport error
|
||||||
|
# sits underneath must not make this look retryable.
|
||||||
|
return False
|
||||||
|
if isinstance(current, MintConnectionError):
|
||||||
|
return True
|
||||||
|
if isinstance(current, _TRANSPORT_EXC_TYPES):
|
||||||
|
return True
|
||||||
|
current = current.__cause__ or current.__context__
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Redemption ``code`` values whose token is spent/consumed/unusable — the
|
||||||
|
# X-Cashu path must NOT echo the original token for these (echoing invites a
|
||||||
|
# retry with a token that can never succeed again).
|
||||||
|
SPENT_TOKEN_CODES: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"cashu_token_already_spent",
|
||||||
|
"cashu_token_consumed",
|
||||||
|
"cashu_token_zero_value",
|
||||||
|
"internal_error",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_redemption_error(
|
||||||
|
error: Exception,
|
||||||
|
) -> tuple[str, int, str, str] | None:
|
||||||
|
"""Map a token-redemption failure to ``(type, status, message, code)``.
|
||||||
|
|
||||||
|
Single source of truth for every endpoint that redeems a token (bearer,
|
||||||
|
X-Cashu, top-up) so the same failure yields the same taxonomy everywhere.
|
||||||
|
``type`` and ``code`` are stable client contract; ``message`` is sanitized
|
||||||
|
(raw error text stays in logs). Returns None for an unclassified internal
|
||||||
|
fault — the caller emits a generic 500.
|
||||||
|
"""
|
||||||
|
if isinstance(error, TokenConsumedError):
|
||||||
|
return (
|
||||||
|
"token_consumed",
|
||||||
|
500,
|
||||||
|
"Token was redeemed but could not be credited; do not retry",
|
||||||
|
"cashu_token_consumed",
|
||||||
|
)
|
||||||
|
if is_mint_connection_error(error):
|
||||||
|
return (
|
||||||
|
"mint_unreachable",
|
||||||
|
503,
|
||||||
|
"Cashu mint is unreachable",
|
||||||
|
"cashu_mint_unreachable",
|
||||||
|
)
|
||||||
|
lowered = str(error).lower()
|
||||||
|
if "already spent" in lowered:
|
||||||
|
return (
|
||||||
|
"token_already_spent",
|
||||||
|
400,
|
||||||
|
"Cashu token already spent",
|
||||||
|
"cashu_token_already_spent",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
"insufficient" in lowered
|
||||||
|
or "melt fee" in lowered
|
||||||
|
or "exceed token amount" in lowered
|
||||||
|
or "estimate fees" in lowered
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
"mint_error",
|
||||||
|
422,
|
||||||
|
"Token value is too small to cover swap fees",
|
||||||
|
"cashu_token_swap_fees_exceed_amount",
|
||||||
|
)
|
||||||
|
if "failed to melt" in lowered:
|
||||||
|
return (
|
||||||
|
"mint_error",
|
||||||
|
422,
|
||||||
|
"Failed to swap token from foreign mint",
|
||||||
|
"cashu_foreign_mint_swap_failed",
|
||||||
|
)
|
||||||
|
if ("invalid" in lowered or "decode" in lowered) and "token" in lowered:
|
||||||
|
# Anchored to "token" so internal faults whose text merely contains
|
||||||
|
# "invalid"/"decode" fall through to the 500 branch, not a token error.
|
||||||
|
return (
|
||||||
|
"invalid_token",
|
||||||
|
400,
|
||||||
|
"Invalid Cashu token",
|
||||||
|
"invalid_cashu_token",
|
||||||
|
)
|
||||||
|
if "must be positive" in lowered or "yielded no value" in lowered:
|
||||||
|
# Redeemed to <= 0 (empty/dust token, or value fully consumed by fees).
|
||||||
|
# Consumed, so non-retryable, but its own code — not the generic bucket.
|
||||||
|
return (
|
||||||
|
"cashu_error",
|
||||||
|
400,
|
||||||
|
"Failed to redeem Cashu token: token yielded no value",
|
||||||
|
"cashu_token_zero_value",
|
||||||
|
)
|
||||||
|
if isinstance(error, ValueError):
|
||||||
|
return (
|
||||||
|
"cashu_error",
|
||||||
|
400,
|
||||||
|
"Failed to redeem Cashu token",
|
||||||
|
"cashu_token_redemption_failed",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def get_balance(unit: str) -> int:
|
async def get_balance(unit: str) -> int:
|
||||||
wallet = await get_wallet(settings.primary_mint, unit)
|
wallet = await get_wallet(settings.primary_mint, unit)
|
||||||
return wallet.available_balance.amount
|
return wallet.available_balance.amount
|
||||||
@@ -258,6 +403,8 @@ async def _calculate_swap_amount(
|
|||||||
"swap_to_primary_mint: fee estimation failed",
|
"swap_to_primary_mint: fee estimation failed",
|
||||||
extra={"error": str(e)},
|
extra={"error": str(e)},
|
||||||
)
|
)
|
||||||
|
if is_mint_connection_error(e):
|
||||||
|
raise MintConnectionError("Cashu mint is unreachable") from e
|
||||||
raise ValueError(f"Failed to estimate fees: {e}") from e
|
raise ValueError(f"Failed to estimate fees: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
@@ -383,6 +530,13 @@ async def swap_to_primary_mint(
|
|||||||
quote_id=melt_quote.quote,
|
quote_id=melt_quote.quote,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# A down mint won't fix itself by retrying with a smaller amount.
|
||||||
|
if is_mint_connection_error(e):
|
||||||
|
logger.error(
|
||||||
|
"swap_to_primary_mint: melt failed — mint unreachable",
|
||||||
|
extra={"error": str(e), "foreign_mint": token_obj.mint},
|
||||||
|
)
|
||||||
|
raise MintConnectionError("Cashu mint is unreachable") from e
|
||||||
shortfall = _melt_insufficient_shortfall(e)
|
shortfall = _melt_insufficient_shortfall(e)
|
||||||
recomputed = 0
|
recomputed = 0
|
||||||
if shortfall is not None:
|
if shortfall is not None:
|
||||||
@@ -458,21 +612,21 @@ async def swap_to_primary_mint(
|
|||||||
# Recovery scan ran but did NOT restore the orphaned proofs
|
# Recovery scan ran but did NOT restore the orphaned proofs
|
||||||
# (mint reports them as spent — they're stuck). Refuse to
|
# (mint reports them as spent — they're stuck). Refuse to
|
||||||
# credit the API key balance for proofs we don't actually hold.
|
# credit the API key balance for proofs we don't actually hold.
|
||||||
raise ValueError(
|
raise TokenConsumedError(
|
||||||
f"Swap recovery failed: mint signed outputs but proofs are "
|
f"Swap recovery failed: mint signed outputs but proofs are "
|
||||||
f"unrecoverable (mint reports them spent). "
|
f"unrecoverable (mint reports them spent). "
|
||||||
f"Expected {minted_amount}, recovered {balance_gained}. "
|
f"Expected {minted_amount}, recovered {balance_gained}. "
|
||||||
f"Local wallet DB ('.wallet/') state is corrupted — "
|
f"Local wallet DB ('.wallet/') state is corrupted — "
|
||||||
f"the counter for keyset is stuck at a bad index range."
|
f"the counter for keyset is stuck at a bad index range."
|
||||||
)
|
)
|
||||||
except ValueError:
|
except TokenConsumedError:
|
||||||
raise
|
raise
|
||||||
except Exception as recovery_err:
|
except Exception as recovery_err:
|
||||||
logger.error(
|
logger.error(
|
||||||
"swap_to_primary_mint: recovery failed",
|
"swap_to_primary_mint: recovery failed",
|
||||||
extra={"error": str(recovery_err)},
|
extra={"error": str(recovery_err)},
|
||||||
)
|
)
|
||||||
raise ValueError(
|
raise TokenConsumedError(
|
||||||
f"Mint on primary failed and recovery unsuccessful: {e}"
|
f"Mint on primary failed and recovery unsuccessful: {e}"
|
||||||
) from e
|
) from e
|
||||||
else:
|
else:
|
||||||
@@ -485,7 +639,10 @@ async def swap_to_primary_mint(
|
|||||||
"mint_quote_id": mint_quote.quote,
|
"mint_quote_id": mint_quote.quote,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
raise
|
# Foreign proofs already melted (spent) — non-retryable.
|
||||||
|
raise TokenConsumedError(
|
||||||
|
"Mint on primary failed after successful melt"
|
||||||
|
) from e
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"swap_to_primary_mint: completed successfully",
|
"swap_to_primary_mint: completed successfully",
|
||||||
@@ -543,19 +700,33 @@ async def credit_balance(
|
|||||||
extra={"old_balance": key.balance, "credit_amount": amount},
|
extra={"old_balance": key.balance, "credit_amount": amount},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use atomic SQL UPDATE to prevent race conditions during concurrent topups
|
# The token is already redeemed (spent) here, so any crediting failure
|
||||||
stmt = (
|
# is post-redemption and non-retryable — surface it as TokenConsumedError
|
||||||
update(db.ApiKey)
|
# (a key that vanished mid-flight, or an unexpected DB fault), never a
|
||||||
.where(col(db.ApiKey.hashed_key) == key.hashed_key)
|
# retryable/token-error taxonomy.
|
||||||
.values(balance=(db.ApiKey.balance) + amount)
|
try:
|
||||||
)
|
# Atomic UPDATE to prevent race conditions during concurrent topups.
|
||||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
stmt = (
|
||||||
# If pruning removed this key after redemption, do not commit a no-op
|
update(db.ApiKey)
|
||||||
# balance update and pretend the top-up succeeded.
|
.where(col(db.ApiKey.hashed_key) == key.hashed_key)
|
||||||
if (getattr(result, "rowcount", 0) or 0) == 0:
|
.values(balance=(db.ApiKey.balance) + amount)
|
||||||
raise ValueError("API key disappeared before credit could be recorded")
|
)
|
||||||
await session.commit()
|
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||||
await session.refresh(key)
|
# If pruning removed this key after redemption, do not commit a no-op
|
||||||
|
# balance update and pretend the top-up succeeded.
|
||||||
|
if (getattr(result, "rowcount", 0) or 0) == 0:
|
||||||
|
raise TokenConsumedError(
|
||||||
|
"Token redeemed but the API key disappeared before the "
|
||||||
|
"credit could be recorded"
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(key)
|
||||||
|
except TokenConsumedError:
|
||||||
|
raise
|
||||||
|
except Exception as db_error:
|
||||||
|
raise TokenConsumedError(
|
||||||
|
"Token redeemed but crediting the balance failed"
|
||||||
|
) from db_error
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"credit_balance: Balance updated successfully",
|
"credit_balance: Balance updated successfully",
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""A live upstream provider resolves its OWN database row by stable identity
|
||||||
|
(its primary key), not by its mutable/secret ``api_key``.
|
||||||
|
|
||||||
|
Today ``from_db_row`` drops ``provider_row.id`` and the two self-referential
|
||||||
|
paths — PPQ.AI's insufficient-balance self-disable and the base
|
||||||
|
``refresh_models_cache`` — re-find their own row with
|
||||||
|
``WHERE base_url == self.base_url AND api_key == self.api_key``. That uses a
|
||||||
|
rotatable secret as a self-handle: the moment the row's key changes underneath a
|
||||||
|
live object (a rotation racing an in-flight request), the object can no longer
|
||||||
|
find itself. These tests pin the invariant that a provider looks itself up by
|
||||||
|
identity, so the lookup survives a key change (and, later, key encryption).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from routstr.core.db import UpstreamProviderRow
|
||||||
|
from routstr.upstream.ppqai import PPQAIUpstreamProvider
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_provider_object_carries_its_persistent_identity(
|
||||||
|
integration_session: object,
|
||||||
|
patched_db_engine: None,
|
||||||
|
) -> None:
|
||||||
|
"""``from_db_row`` gives the in-memory object its row's identity (``db_id``)."""
|
||||||
|
row = UpstreamProviderRow(
|
||||||
|
provider_type="ppqai",
|
||||||
|
base_url="https://api.ppq.ai",
|
||||||
|
api_key="sk-original",
|
||||||
|
enabled=True,
|
||||||
|
provider_fee=1.0,
|
||||||
|
)
|
||||||
|
integration_session.add(row) # type: ignore[attr-defined]
|
||||||
|
await integration_session.commit() # type: ignore[attr-defined]
|
||||||
|
await integration_session.refresh(row) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
provider = PPQAIUpstreamProvider.from_db_row(row)
|
||||||
|
assert provider is not None
|
||||||
|
|
||||||
|
assert provider.db_id == row.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_self_disable_targets_own_row_after_key_rotation(
|
||||||
|
integration_session: object,
|
||||||
|
patched_db_engine: None,
|
||||||
|
) -> None:
|
||||||
|
"""PPQ.AI self-disable must disable *its* row even after the key rotated.
|
||||||
|
|
||||||
|
RED (current): the object holds the pre-rotation key, so the
|
||||||
|
``(base_url, api_key)`` lookup misses the row → the provider is never
|
||||||
|
disabled. GREEN: lookup by ``id`` finds it and disables it.
|
||||||
|
"""
|
||||||
|
row = UpstreamProviderRow(
|
||||||
|
provider_type="ppqai",
|
||||||
|
base_url="https://api.ppq.ai",
|
||||||
|
api_key="sk-original",
|
||||||
|
enabled=True,
|
||||||
|
provider_fee=1.0,
|
||||||
|
)
|
||||||
|
integration_session.add(row) # type: ignore[attr-defined]
|
||||||
|
await integration_session.commit() # type: ignore[attr-defined]
|
||||||
|
await integration_session.refresh(row) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
provider = PPQAIUpstreamProvider.from_db_row(row) # captures sk-original
|
||||||
|
assert provider is not None
|
||||||
|
|
||||||
|
# Key is rotated in the DB while `provider` is still live.
|
||||||
|
row.api_key = "sk-rotated"
|
||||||
|
integration_session.add(row) # type: ignore[attr-defined]
|
||||||
|
await integration_session.commit() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
with patch("routstr.proxy.reinitialize_upstreams", new=AsyncMock()):
|
||||||
|
await provider.on_upstream_error_redirect(402, "Insufficient balance")
|
||||||
|
|
||||||
|
await integration_session.refresh(row) # type: ignore[attr-defined]
|
||||||
|
assert row.enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_models_cache_finds_own_row_after_key_rotation(
|
||||||
|
integration_session: object,
|
||||||
|
patched_db_engine: None,
|
||||||
|
) -> None:
|
||||||
|
"""``refresh_models_cache`` must resolve its own row after a key rotation.
|
||||||
|
|
||||||
|
``refresh_models_cache`` swallows every exception (it only logs), so the
|
||||||
|
observable proof it found its row is that it reaches ``list_models`` — which
|
||||||
|
is called with the row's ``id`` only *after* the row is resolved. RED
|
||||||
|
(current): the stale-key ``(base_url, api_key)`` lookup returns nothing, the
|
||||||
|
method raises ``404`` internally and returns before ``list_models`` is ever
|
||||||
|
called. GREEN: lookup by ``id`` finds the row and ``list_models`` runs for
|
||||||
|
that ``id``.
|
||||||
|
"""
|
||||||
|
row = UpstreamProviderRow(
|
||||||
|
provider_type="ppqai",
|
||||||
|
base_url="https://api.ppq.ai",
|
||||||
|
api_key="sk-original",
|
||||||
|
enabled=True,
|
||||||
|
provider_fee=1.0,
|
||||||
|
)
|
||||||
|
integration_session.add(row) # type: ignore[attr-defined]
|
||||||
|
await integration_session.commit() # type: ignore[attr-defined]
|
||||||
|
await integration_session.refresh(row) # type: ignore[attr-defined]
|
||||||
|
row_id = row.id
|
||||||
|
|
||||||
|
provider = PPQAIUpstreamProvider.from_db_row(row) # captures sk-original
|
||||||
|
assert provider is not None
|
||||||
|
|
||||||
|
row.api_key = "sk-rotated"
|
||||||
|
integration_session.add(row) # type: ignore[attr-defined]
|
||||||
|
await integration_session.commit() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
list_models_mock = AsyncMock(return_value=[])
|
||||||
|
with (
|
||||||
|
patch.object(provider, "fetch_models", new=AsyncMock(return_value=[])),
|
||||||
|
patch("routstr.upstream.base.list_models", new=list_models_mock),
|
||||||
|
):
|
||||||
|
await provider.refresh_models_cache()
|
||||||
|
|
||||||
|
list_models_mock.assert_awaited_once()
|
||||||
|
assert list_models_mock.await_args is not None
|
||||||
|
assert list_models_mock.await_args.kwargs["upstream_id"] == row_id
|
||||||
@@ -174,12 +174,12 @@ async def test_topup_retries_when_quote_fee_exceeds_estimate(
|
|||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_topup_returns_400_when_retries_exhausted(
|
async def test_topup_returns_422_when_retries_exhausted(
|
||||||
authenticated_client: AsyncClient,
|
authenticated_client: AsyncClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A mint that escalates fee_reserve on every re-quote (1 → 10 → 25 → 50)
|
"""A mint that escalates fee_reserve on every re-quote (1 → 10 → 25 → 50)
|
||||||
exhausts the retry budget: clean 400 with an actionable message, melt never
|
exhausts the retry budget: clean 422 mint_error/too-small taxonomy, melt
|
||||||
executed."""
|
never executed."""
|
||||||
mock_token, token_wallet, primary_wallet = _make_swap_mocks(
|
mock_token, token_wallet, primary_wallet = _make_swap_mocks(
|
||||||
1000, fee_reserves=[1, 10, 25, 50]
|
1000, fee_reserves=[1, 10, 25, 50]
|
||||||
)
|
)
|
||||||
@@ -188,7 +188,7 @@ async def test_topup_returns_400_when_retries_exhausted(
|
|||||||
authenticated_client, mock_token, token_wallet, primary_wallet
|
authenticated_client, mock_token, token_wallet, primary_wallet
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 422
|
||||||
assert "too small to cover swap fees" in response.json()["detail"]
|
assert "too small to cover swap fees" in response.json()["detail"]
|
||||||
assert token_wallet.melt_quote.call_count == 4 # estimation + 3 attempts
|
assert token_wallet.melt_quote.call_count == 4 # estimation + 3 attempts
|
||||||
token_wallet.melt.assert_not_called()
|
token_wallet.melt.assert_not_called()
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ from sqlmodel import select
|
|||||||
|
|
||||||
from routstr.core.db import ApiKey
|
from routstr.core.db import ApiKey
|
||||||
|
|
||||||
from .utils import (
|
from .utils import ResponseValidator
|
||||||
CashuTokenGenerator,
|
|
||||||
ResponseValidator,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -79,29 +76,31 @@ async def test_api_key_generation_invalid_token(
|
|||||||
# Capture initial state
|
# Capture initial state
|
||||||
await db_snapshot.capture()
|
await db_snapshot.capture()
|
||||||
|
|
||||||
# Test various invalid tokens
|
# Non-Cashu bearer values are invalid API keys (401). Malformed values that
|
||||||
invalid_tokens = [
|
# look like Cashu tokens use the shared Cashu taxonomy (400 invalid_token).
|
||||||
CashuTokenGenerator.generate_invalid_token(), # Malformed token
|
invalid_tokens: list[tuple[str, int, str | None]] = [
|
||||||
"not-a-cashu-token", # Wrong format
|
("not-a-cashu-token", 401, None),
|
||||||
"cashuA", # Empty token
|
("sk-not-a-real-api-key", 401, None),
|
||||||
"cashuA" + "x" * 1000, # Invalid base64
|
("cashuA", 400, "invalid_cashu_token"),
|
||||||
|
("cashuA" + "x" * 1000, 400, "invalid_cashu_token"),
|
||||||
]
|
]
|
||||||
|
|
||||||
for invalid_token in invalid_tokens:
|
for invalid_token, expected_status, expected_code in invalid_tokens:
|
||||||
integration_client.headers["Authorization"] = f"Bearer {invalid_token}"
|
integration_client.headers["Authorization"] = f"Bearer {invalid_token}"
|
||||||
response = await integration_client.get("/v1/wallet/info")
|
response = await integration_client.get("/v1/wallet/info")
|
||||||
|
|
||||||
# Should fail with 401
|
assert response.status_code == expected_status, (
|
||||||
assert response.status_code == 401, (
|
|
||||||
f"Token {invalid_token[:20]}... should be invalid"
|
f"Token {invalid_token[:20]}... should be invalid"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate error response
|
# Validate error response
|
||||||
validator = ResponseValidator()
|
validator = ResponseValidator()
|
||||||
error_validation = validator.validate_error_response(
|
error_validation = validator.validate_error_response(
|
||||||
response, expected_status=401, expected_error_key="detail"
|
response, expected_status=expected_status, expected_error_key="detail"
|
||||||
)
|
)
|
||||||
assert error_validation["valid"]
|
assert error_validation["valid"]
|
||||||
|
if expected_code is not None:
|
||||||
|
assert response.json()["detail"]["error"]["code"] == expected_code
|
||||||
|
|
||||||
# Verify no database changes
|
# Verify no database changes
|
||||||
diff = await db_snapshot.diff()
|
diff = await db_snapshot.diff()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from httpx import AsyncClient
|
|||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from routstr.core.db import ApiKey, CashuTransaction
|
from routstr.core.db import ApiKey, CashuTransaction
|
||||||
|
from routstr.wallet import MintConnectionError
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -503,26 +504,19 @@ async def test_mint_unavailability_handling(
|
|||||||
|
|
||||||
# The global mock in conftest.py is already in place,
|
# The global mock in conftest.py is already in place,
|
||||||
# so we need to temporarily modify it
|
# so we need to temporarily modify it
|
||||||
from unittest.mock import patch
|
raw_error = "Mint unavailable: Connection refused"
|
||||||
|
|
||||||
# Make the send_token method raise an exception
|
# Make the send_token method raise a typed mint connection exception.
|
||||||
with patch(
|
with patch(
|
||||||
"routstr.balance.send_token",
|
"routstr.balance.send_token",
|
||||||
side_effect=Exception("Mint unavailable: Connection refused"),
|
side_effect=MintConnectionError(raw_error),
|
||||||
):
|
):
|
||||||
# The exception should propagate as a 503 error (Service Unavailable)
|
response = await authenticated_client.post("/v1/wallet/refund")
|
||||||
# But we need to handle it properly
|
assert response.status_code == 503
|
||||||
try:
|
assert response.json()["detail"] == "Mint service unavailable"
|
||||||
response = await authenticated_client.post("/v1/wallet/refund")
|
assert raw_error not in response.text
|
||||||
# If we get here, check the status code
|
|
||||||
assert response.status_code == 503
|
|
||||||
assert "Mint service unavailable" in response.json()["detail"]
|
|
||||||
except Exception as e:
|
|
||||||
# If the exception propagates, that's also a failure scenario
|
|
||||||
assert "Mint unavailable" in str(e)
|
|
||||||
|
|
||||||
# Balance should remain unchanged (transaction should roll back)
|
# Balance should remain unchanged (transaction should roll back)
|
||||||
# Note: Current implementation might not handle this perfectly
|
|
||||||
wallet_response = await authenticated_client.get("/v1/wallet/")
|
wallet_response = await authenticated_client.get("/v1/wallet/")
|
||||||
assert wallet_response.status_code == 200
|
assert wallet_response.status_code == 200
|
||||||
assert wallet_response.json()["balance"] == 10_000_000
|
assert wallet_response.json()["balance"] == 10_000_000
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator, cast
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||||
@@ -12,6 +13,19 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||||||
|
|
||||||
from routstr.auth import validate_bearer_key
|
from routstr.auth import validate_bearer_key
|
||||||
from routstr.core.db import ApiKey
|
from routstr.core.db import ApiKey
|
||||||
|
from routstr.wallet import MintConnectionError
|
||||||
|
|
||||||
|
|
||||||
|
def _value_error_wrapping_transport() -> ValueError:
|
||||||
|
"""A ValueError re-raised ``from`` a real httpx transport error, mirroring
|
||||||
|
``wallet.py`` wrapping a connection failure. The sanitized classifier must
|
||||||
|
still see the mint-unreachable signal through the ``__cause__`` chain."""
|
||||||
|
try:
|
||||||
|
raise httpx.ConnectError("All connection attempts failed")
|
||||||
|
except httpx.ConnectError as exc:
|
||||||
|
err = ValueError("Failed to estimate fees: connection failed")
|
||||||
|
err.__cause__ = exc
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
def _make_engine() -> AsyncEngine:
|
def _make_engine() -> AsyncEngine:
|
||||||
@@ -57,3 +71,223 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
|
|||||||
await validate_bearer_key(token, session)
|
await validate_bearer_key(token, session)
|
||||||
|
|
||||||
assert await session.get(ApiKey, hashed_key) is None
|
assert await session.get(ApiKey, hashed_key) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("error", "expected_status", "expected_type", "expected_message", "expected_code"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
ValueError("Mint Error: Token already spent. (Code: 11001)"),
|
||||||
|
400,
|
||||||
|
"token_already_spent",
|
||||||
|
"Cashu token already spent",
|
||||||
|
"cashu_token_already_spent",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# Raw httpx transport error propagated unwrapped from cashu.
|
||||||
|
httpx.ConnectError("All connection attempts failed"),
|
||||||
|
503,
|
||||||
|
"mint_unreachable",
|
||||||
|
"Cashu mint is unreachable",
|
||||||
|
"cashu_mint_unreachable",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# Typed error raised by wallet.py at a wrap site.
|
||||||
|
MintConnectionError("connect to http://mint:3338 refused"),
|
||||||
|
503,
|
||||||
|
"mint_unreachable",
|
||||||
|
"Cashu mint is unreachable",
|
||||||
|
"cashu_mint_unreachable",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# ValueError wrapping the httpx error in its __cause__ chain.
|
||||||
|
_value_error_wrapping_transport(),
|
||||||
|
503,
|
||||||
|
"mint_unreachable",
|
||||||
|
"Cashu mint is unreachable",
|
||||||
|
"cashu_mint_unreachable",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# asyncio.TimeoutError is builtin TimeoutError on 3.11+.
|
||||||
|
TimeoutError("Timed out connecting to Cashu mint http://mint:3338"),
|
||||||
|
503,
|
||||||
|
"mint_unreachable",
|
||||||
|
"Cashu mint is unreachable",
|
||||||
|
"cashu_mint_unreachable",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ValueError(
|
||||||
|
"Token amount (5 sat) is insufficient to cover melt fees. "
|
||||||
|
"Needed: 7 sat (amount: 5 + fee: 1 + input_fees: 1)"
|
||||||
|
),
|
||||||
|
422,
|
||||||
|
"mint_error",
|
||||||
|
"Token value is too small to cover swap fees",
|
||||||
|
"cashu_token_swap_fees_exceed_amount",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ValueError(
|
||||||
|
"Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)"
|
||||||
|
),
|
||||||
|
422,
|
||||||
|
"mint_error",
|
||||||
|
"Token value is too small to cover swap fees",
|
||||||
|
"cashu_token_swap_fees_exceed_amount",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ValueError(
|
||||||
|
"Failed to melt token from foreign mint http://foreign:3338: boom"
|
||||||
|
),
|
||||||
|
422,
|
||||||
|
"mint_error",
|
||||||
|
"Failed to swap token from foreign mint",
|
||||||
|
"cashu_foreign_mint_swap_failed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ValueError("could not decode token"),
|
||||||
|
400,
|
||||||
|
"invalid_token",
|
||||||
|
"Invalid Cashu token",
|
||||||
|
"invalid_cashu_token",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ValueError("some unexpected wallet condition"),
|
||||||
|
400,
|
||||||
|
"cashu_error",
|
||||||
|
"Failed to redeem Cashu token",
|
||||||
|
"cashu_token_redemption_failed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ValueError("Redeemed token amount must be positive, got 0 msats"),
|
||||||
|
400,
|
||||||
|
"cashu_error",
|
||||||
|
"Failed to redeem Cashu token: token yielded no value",
|
||||||
|
"cashu_token_zero_value",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redemption_failure_returns_sanitized_error(
|
||||||
|
session: AsyncSession,
|
||||||
|
error: Exception,
|
||||||
|
expected_status: int,
|
||||||
|
expected_type: str,
|
||||||
|
expected_message: str,
|
||||||
|
expected_code: str,
|
||||||
|
) -> None:
|
||||||
|
"""Redemption failures reuse the shared X-Cashu taxonomy (carried in
|
||||||
|
``type``), expose stable sanitized messages and granular machine-readable
|
||||||
|
``code`` values, and leave no orphan ApiKey row."""
|
||||||
|
token = "cashuAredemption_fails_with_specific_error"
|
||||||
|
hashed_key = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat")
|
||||||
|
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
|
||||||
|
patch("routstr.auth.deserialize_token_from_string", return_value=token_obj),
|
||||||
|
patch(
|
||||||
|
"routstr.auth.credit_balance",
|
||||||
|
new=AsyncMock(side_effect=error),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await validate_bearer_key(token, session)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == expected_status
|
||||||
|
detail = cast(dict[str, dict[str, object]], exc_info.value.detail)
|
||||||
|
error_detail = detail["error"]
|
||||||
|
assert error_detail["type"] == expected_type
|
||||||
|
assert error_detail["code"] == expected_code
|
||||||
|
assert error_detail["message"] == expected_message
|
||||||
|
assert str(error) not in cast(str, error_detail["message"])
|
||||||
|
assert await session.get(ApiKey, hashed_key) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unexpected_redemption_error_returns_internal_error(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""Unexpected (non-wallet) failures surface as generic 500s without
|
||||||
|
leaking internal details, instead of masquerading as token errors."""
|
||||||
|
token = "cashuAredemption_fails_with_internal_error"
|
||||||
|
hashed_key = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat")
|
||||||
|
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
|
||||||
|
patch("routstr.auth.deserialize_token_from_string", return_value=token_obj),
|
||||||
|
patch(
|
||||||
|
"routstr.auth.credit_balance",
|
||||||
|
new=AsyncMock(side_effect=RuntimeError("db exploded at /var/lib/secret")),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await validate_bearer_key(token, session)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
detail = cast(dict[str, dict[str, str]], exc_info.value.detail)
|
||||||
|
error_detail = detail["error"]
|
||||||
|
assert error_detail["code"] == "internal_error"
|
||||||
|
assert "/var/lib/secret" not in error_detail["message"]
|
||||||
|
assert await session.get(ApiKey, hashed_key) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_internal_error_with_invalid_keyword_does_not_masquerade(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""A non-wallet fault whose text merely contains "invalid" (but not
|
||||||
|
"token") must fall through to a generic 500, not a 401 token error.
|
||||||
|
|
||||||
|
Guards the anchored `"invalid"/"decode"` + `"token"` gate against stdlib/
|
||||||
|
driver strings like "Invalid isoformat string" leaking as token errors."""
|
||||||
|
token = "cashuAinternal_fault_mentions_invalid"
|
||||||
|
hashed_key = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat")
|
||||||
|
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
|
||||||
|
patch("routstr.auth.deserialize_token_from_string", return_value=token_obj),
|
||||||
|
patch(
|
||||||
|
"routstr.auth.credit_balance",
|
||||||
|
new=AsyncMock(
|
||||||
|
side_effect=RuntimeError("Invalid isoformat string: '2020-13-99'")
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await validate_bearer_key(token, session)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
detail = cast(dict[str, dict[str, str]], exc_info.value.detail)
|
||||||
|
assert detail["error"]["code"] == "internal_error"
|
||||||
|
assert await session.get(ApiKey, hashed_key) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_malformed_cashu_token_returns_400_invalid_token(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""A malformed 'cashu...' token that fails to decode maps to 400
|
||||||
|
invalid_cashu_token (shared taxonomy), not the generic 401 invalid_api_key."""
|
||||||
|
token = "cashuAthis_is_not_a_valid_token"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.auth.deserialize_token_from_string",
|
||||||
|
side_effect=ValueError("unable to decode token: bad base64"),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await validate_bearer_key(token, session)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
detail = cast(dict[str, dict[str, str]], exc_info.value.detail)
|
||||||
|
assert detail["error"]["type"] == "invalid_token"
|
||||||
|
assert detail["error"]["code"] == "invalid_cashu_token"
|
||||||
|
# Raw decoder text must not leak to the client.
|
||||||
|
assert "base64" not in detail["error"]["message"]
|
||||||
|
|||||||
+230
-3
@@ -1,12 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
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, topup_wallet_endpoint
|
||||||
from routstr.core.db import ApiKey, CashuTransaction
|
from routstr.core.db import ApiKey, CashuTransaction
|
||||||
from routstr.wallet import credit_balance
|
from routstr.wallet import MintConnectionError, credit_balance
|
||||||
|
|
||||||
|
|
||||||
def _make_cashu_tx(
|
def _make_cashu_tx(
|
||||||
@@ -339,7 +340,10 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch("routstr.balance.get_billing_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.send_token",
|
||||||
|
AsyncMock(side_effect=MintConnectionError("raw mint outage detail")),
|
||||||
|
),
|
||||||
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||||
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
|
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
|
||||||
patch("routstr.balance._refund_cache_set", AsyncMock()),
|
patch("routstr.balance._refund_cache_set", AsyncMock()),
|
||||||
@@ -353,10 +357,46 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert exc_info.value.status_code == 503
|
assert exc_info.value.status_code == 503
|
||||||
|
assert exc_info.value.detail == "Mint service unavailable"
|
||||||
|
assert "raw mint outage detail" not in exc_info.value.detail
|
||||||
# Verify two exec calls: debit + restore
|
# Verify two exec calls: debit + restore
|
||||||
assert session.exec.await_count == 2
|
assert session.exec.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apikey_refund_generic_failure_is_sanitized_500() -> None:
|
||||||
|
"""Unexpected send-side failures restore balance without leaking exception text."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
key = _make_api_key(balance=5000, refund_currency="sat")
|
||||||
|
raw_error = "database secret token raw-mint-response"
|
||||||
|
|
||||||
|
session = MagicMock()
|
||||||
|
session.get = AsyncMock(return_value=key)
|
||||||
|
session.exec = AsyncMock(side_effect=[_update_result(1), _update_result(1)])
|
||||||
|
session.commit = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||||
|
patch("routstr.balance.send_token", AsyncMock(side_effect=RuntimeError(raw_error))),
|
||||||
|
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 == 500
|
||||||
|
assert exc_info.value.detail == "Refund failed"
|
||||||
|
assert raw_error not in exc_info.value.detail
|
||||||
|
assert session.exec.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# no-create guarantee: fresh Cashu/unknown sk- tokens must not create API keys
|
# no-create guarantee: fresh Cashu/unknown sk- tokens must not create API keys
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -401,3 +441,190 @@ async def test_refund_unknown_sk_bearer_returns_401() -> None:
|
|||||||
|
|
||||||
assert exc_info.value.status_code == 401
|
assert exc_info.value.status_code == 401
|
||||||
session.get.assert_awaited_once()
|
session.get.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Topup redemption error taxonomy (POST /v1/wallet/topup) ------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error",
|
||||||
|
[
|
||||||
|
httpx.ConnectError("All connection attempts failed"),
|
||||||
|
MintConnectionError("connect to mint refused"),
|
||||||
|
TimeoutError("timed out connecting to mint"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_topup_mint_unreachable_returns_503(error: Exception) -> None:
|
||||||
|
"""A down mint must surface 503 (retryable), not 400 or 500 — the token is
|
||||||
|
fine, so the client should retry once the mint recovers."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
key = _make_api_key(balance=1000)
|
||||||
|
session = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||||
|
patch("routstr.balance.credit_balance", AsyncMock(side_effect=error)),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await topup_wallet_endpoint(
|
||||||
|
cashu_token="cashuAtoken", key=key, session=session
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 503
|
||||||
|
assert exc_info.value.detail == "Cashu mint is unreachable"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_topup_already_spent_still_returns_400() -> None:
|
||||||
|
"""Regression: the mint-unreachable short-circuit must not swallow the
|
||||||
|
existing ValueError substring buckets."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
key = _make_api_key(balance=1000)
|
||||||
|
session = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||||
|
patch(
|
||||||
|
"routstr.balance.credit_balance",
|
||||||
|
AsyncMock(side_effect=ValueError("Token already spent")),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await topup_wallet_endpoint(
|
||||||
|
cashu_token="cashuAtoken", key=key, session=session
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert exc_info.value.detail == "Cashu token already spent"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_topup_zero_value_returns_400_zero_value_message() -> None:
|
||||||
|
"""A dust/zero redemption maps to the documented zero-value message, not the
|
||||||
|
generic redemption-failed one."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
key = _make_api_key(balance=1000)
|
||||||
|
session = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||||
|
patch(
|
||||||
|
"routstr.balance.credit_balance",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=ValueError("Redeemed token amount must be positive, got 0 msats")
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await topup_wallet_endpoint(
|
||||||
|
cashu_token="cashuAtoken", key=key, session=session
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert exc_info.value.detail == "Failed to redeem Cashu token: token yielded no value"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_topup_token_consumed_returns_500() -> None:
|
||||||
|
"""A post-redemption crediting failure (token spent) is a non-retryable 500,
|
||||||
|
not a 4xx that invites a retry."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from routstr.wallet import TokenConsumedError
|
||||||
|
|
||||||
|
key = _make_api_key(balance=1000)
|
||||||
|
session = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||||
|
patch(
|
||||||
|
"routstr.balance.credit_balance",
|
||||||
|
AsyncMock(side_effect=TokenConsumedError("credit failed")),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await topup_wallet_endpoint(
|
||||||
|
cashu_token="cashuAtoken", key=key, session=session
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
assert exc_info.value.detail == (
|
||||||
|
"Token was redeemed but could not be credited; do not retry"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("error", "expected_status", "expected_detail"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
ValueError(
|
||||||
|
"Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)"
|
||||||
|
),
|
||||||
|
422,
|
||||||
|
"Token value is too small to cover swap fees",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ValueError(
|
||||||
|
"Token amount (5 sat) is insufficient to cover melt fees."
|
||||||
|
),
|
||||||
|
422,
|
||||||
|
"Token value is too small to cover swap fees",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ValueError("Failed to melt token from foreign mint http://m: boom"),
|
||||||
|
422,
|
||||||
|
"Failed to swap token from foreign mint",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_topup_fee_and_swap_failures_return_422(
|
||||||
|
error: Exception, expected_status: int, expected_detail: str
|
||||||
|
) -> None:
|
||||||
|
"""Fee/swap failures map to 422 (shared taxonomy), matching the bearer and
|
||||||
|
X-Cashu paths — previously top-up flattened these to 400."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
key = _make_api_key(balance=1000)
|
||||||
|
session = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||||
|
patch("routstr.balance.credit_balance", AsyncMock(side_effect=error)),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await topup_wallet_endpoint(
|
||||||
|
cashu_token="cashuAtoken", key=key, session=session
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == expected_status
|
||||||
|
assert exc_info.value.detail == expected_detail
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_topup_unexpected_non_valueerror_returns_500() -> None:
|
||||||
|
"""A non-ValueError, non-transport fault is an internal error (500), not a
|
||||||
|
sanitized 400 — the merged except must preserve this."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
key = _make_api_key(balance=1000)
|
||||||
|
session = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
|
||||||
|
patch(
|
||||||
|
"routstr.balance.credit_balance",
|
||||||
|
AsyncMock(side_effect=RuntimeError("db exploded")),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await topup_wallet_endpoint(
|
||||||
|
cashu_token="cashuAtoken", key=key, session=session
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 500
|
||||||
|
assert exc_info.value.detail == "Internal server error"
|
||||||
|
|||||||
@@ -81,6 +81,21 @@ def test_backfill_strips_vendor_prefix_for_litellm_lookup() -> None:
|
|||||||
assert result.input_cache_read == expected
|
assert result.input_cache_read == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_case_insensitive_lookup() -> None:
|
||||||
|
"""A generic upstream may report a mixed-case id
|
||||||
|
(deepseek-ai/DeepSeek-V4-Flash); litellm keys are lowercase. The
|
||||||
|
case-insensitive fallback still resolves the cache rate."""
|
||||||
|
pricing = Pricing(prompt=1.4e-07, completion=2.8e-07)
|
||||||
|
|
||||||
|
result = backfill_cache_pricing("deepseek-ai/DeepSeek-V4-Flash", pricing)
|
||||||
|
|
||||||
|
expected = litellm.model_cost["deepseek-v4-flash"][
|
||||||
|
"cache_read_input_token_cost"
|
||||||
|
]
|
||||||
|
assert result.input_cache_read == expected
|
||||||
|
assert result.input_cache_read < pricing.prompt # sanity: it's a discount
|
||||||
|
|
||||||
|
|
||||||
def test_backfill_fills_cache_write_rate() -> None:
|
def test_backfill_fills_cache_write_rate() -> None:
|
||||||
"""Anthropic cache writes cost more than input (1.25x); billing them at
|
"""Anthropic cache writes cost more than input (1.25x); billing them at
|
||||||
the input rate undercharges. litellm carries the write rate."""
|
the input rate undercharges. litellm carries the write rate."""
|
||||||
@@ -136,6 +151,93 @@ def test_provider_fee_applies_to_backfilled_cache_rates() -> None:
|
|||||||
assert adjusted.pricing.prompt == pytest.approx(2.8e-07 * 2.0)
|
assert adjusted.pricing.prompt == pytest.approx(2.8e-07 * 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_row_to_model_backfills_cache_rate() -> None:
|
||||||
|
"""The DB-override path (admin-configured providers, e.g. a generic
|
||||||
|
upstream) stores pricing without cache rates. ``_row_to_model`` must
|
||||||
|
backfill them from litellm just like ``_apply_provider_fee_to_model``,
|
||||||
|
otherwise cache reads bill at the full input rate."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from routstr.core.db import ModelRow
|
||||||
|
from routstr.payment.models import _row_to_model
|
||||||
|
|
||||||
|
row = ModelRow(
|
||||||
|
id="deepseek-v4-flash",
|
||||||
|
name="deepseek-v4-flash",
|
||||||
|
created=0,
|
||||||
|
description="",
|
||||||
|
context_length=1000000,
|
||||||
|
architecture=json.dumps(
|
||||||
|
{
|
||||||
|
"modality": "text",
|
||||||
|
"input_modalities": ["text"],
|
||||||
|
"output_modalities": ["text"],
|
||||||
|
"tokenizer": "unknown",
|
||||||
|
"instruct_type": None,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
# Stored pricing omits input_cache_read (generic provider never sets it).
|
||||||
|
pricing=json.dumps({"prompt": 1.4e-07, "completion": 2.8e-07}),
|
||||||
|
enabled=True,
|
||||||
|
upstream_provider_id=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.payment.models.sats_usd_price", return_value=5.0e-5
|
||||||
|
):
|
||||||
|
model = _row_to_model(row, apply_provider_fee=True, provider_fee=1.0)
|
||||||
|
|
||||||
|
litellm_read = litellm.model_cost["deepseek-v4-flash"][
|
||||||
|
"cache_read_input_token_cost"
|
||||||
|
]
|
||||||
|
assert model.pricing.input_cache_read == pytest.approx(litellm_read)
|
||||||
|
assert model.pricing.input_cache_read < model.pricing.prompt # a discount
|
||||||
|
assert model.sats_pricing is not None
|
||||||
|
assert model.sats_pricing.input_cache_read > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_row_to_model_backfills_via_forwarded_model_id() -> None:
|
||||||
|
"""An alias row (id != forwarded_model_id) must backfill cache rates from
|
||||||
|
the *forwarded* model name — the real upstream model litellm prices —
|
||||||
|
not the alias id, which litellm doesn't know."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from routstr.core.db import ModelRow
|
||||||
|
from routstr.payment.models import _row_to_model
|
||||||
|
|
||||||
|
row = ModelRow(
|
||||||
|
id="local-alias", # litellm has no such key
|
||||||
|
name="local-alias",
|
||||||
|
created=0,
|
||||||
|
description="",
|
||||||
|
context_length=1000000,
|
||||||
|
architecture=json.dumps(
|
||||||
|
{
|
||||||
|
"modality": "text",
|
||||||
|
"input_modalities": ["text"],
|
||||||
|
"output_modalities": ["text"],
|
||||||
|
"tokenizer": "unknown",
|
||||||
|
"instruct_type": None,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
pricing=json.dumps({"prompt": 1.4e-07, "completion": 2.8e-07}),
|
||||||
|
enabled=True,
|
||||||
|
upstream_provider_id=1,
|
||||||
|
forwarded_model_id="deepseek-v4-flash",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.payment.models.sats_usd_price", return_value=5.0e-5
|
||||||
|
):
|
||||||
|
model = _row_to_model(row, apply_provider_fee=True, provider_fee=1.0)
|
||||||
|
|
||||||
|
litellm_read = litellm.model_cost["deepseek-v4-flash"][
|
||||||
|
"cache_read_input_token_cost"
|
||||||
|
]
|
||||||
|
assert model.pricing.input_cache_read == pytest.approx(litellm_read)
|
||||||
|
assert model.pricing.input_cache_read < model.pricing.prompt
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# calculate_cost — cached tokens billed at cache rates
|
# calculate_cost — cached tokens billed at cache rates
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import os
|
|||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.responses import Response, StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ from routstr.core.db import ApiKey # noqa: E402
|
|||||||
from routstr.payment.cost_calculation import CostData # noqa: E402
|
from routstr.payment.cost_calculation import CostData # noqa: E402
|
||||||
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
|
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
|
||||||
from routstr.upstream.base import BaseUpstreamProvider # noqa: E402
|
from routstr.upstream.base import BaseUpstreamProvider # noqa: E402
|
||||||
|
from routstr.wallet import MintConnectionError, TokenConsumedError # noqa: E402
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fixtures
|
# Fixtures
|
||||||
@@ -1308,3 +1310,320 @@ async def test_dispatch_uses_url_detected_prefix_for_fireworks_custom_row() -> N
|
|||||||
"fireworks_ai/accounts/fireworks/models/glm-5"
|
"fireworks_ai/accounts/fireworks/models/glm-5"
|
||||||
)
|
)
|
||||||
assert captured_kwargs["api_base"] == "https://api.fireworks.ai/inference/v1"
|
assert captured_kwargs["api_base"] == "https://api.fireworks.ai/inference/v1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# X-Cashu redemption error taxonomy (unreachable mint + string codes)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"handler_name",
|
||||||
|
["handle_x_cashu", "handle_x_cashu_responses"],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error",
|
||||||
|
[
|
||||||
|
httpx.ConnectError("All connection attempts failed"),
|
||||||
|
MintConnectionError("Cashu mint is unreachable"),
|
||||||
|
TimeoutError("timed out connecting to mint"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_x_cashu_mint_unreachable_returns_503(
|
||||||
|
handler_name: str, error: Exception
|
||||||
|
) -> None:
|
||||||
|
"""Both X-Cashu entrypoints classify a down mint as 503 mint_unreachable,
|
||||||
|
not a generic 400 cashu_error."""
|
||||||
|
provider = _make_provider()
|
||||||
|
model = _make_model()
|
||||||
|
request = _make_request()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.upstream.base.recieve_token", new=AsyncMock(side_effect=error)
|
||||||
|
):
|
||||||
|
handler = getattr(provider, handler_name)
|
||||||
|
response = await handler(
|
||||||
|
request=request,
|
||||||
|
x_cashu_token="cashuAtoken",
|
||||||
|
path="v1/chat/completions",
|
||||||
|
max_cost_for_model=10_000,
|
||||||
|
model_obj=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
body = json.loads(bytes(response.body))
|
||||||
|
assert body["error"]["type"] == "mint_unreachable"
|
||||||
|
assert body["error"]["message"] == "Cashu mint is unreachable"
|
||||||
|
assert body["error"]["code"] == "cashu_mint_unreachable"
|
||||||
|
if str(error) != body["error"]["message"]:
|
||||||
|
assert str(error) not in body["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"handler_name",
|
||||||
|
["handle_x_cashu", "handle_x_cashu_responses"],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
(
|
||||||
|
"error",
|
||||||
|
"expected_status",
|
||||||
|
"expected_type",
|
||||||
|
"expected_message",
|
||||||
|
"expected_code",
|
||||||
|
),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
ValueError("Mint Error: Token already spent"),
|
||||||
|
400,
|
||||||
|
"token_already_spent",
|
||||||
|
"Cashu token already spent",
|
||||||
|
"cashu_token_already_spent",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ValueError("invalid token: could not decode"),
|
||||||
|
400,
|
||||||
|
"invalid_token",
|
||||||
|
"Invalid Cashu token",
|
||||||
|
"invalid_cashu_token",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# Fee/swap failures now map to a granular 422 on the X-Cashu path,
|
||||||
|
# matching the bearer path (previously flattened to 400).
|
||||||
|
ValueError(
|
||||||
|
"Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)"
|
||||||
|
),
|
||||||
|
422,
|
||||||
|
"mint_error",
|
||||||
|
"Token value is too small to cover swap fees",
|
||||||
|
"cashu_token_swap_fees_exceed_amount",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ValueError("Failed to melt token from foreign mint http://m: boom"),
|
||||||
|
422,
|
||||||
|
"mint_error",
|
||||||
|
"Failed to swap token from foreign mint",
|
||||||
|
"cashu_foreign_mint_swap_failed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ValueError("some unexpected wallet condition"),
|
||||||
|
400,
|
||||||
|
"cashu_error",
|
||||||
|
"Failed to redeem Cashu token",
|
||||||
|
"cashu_token_redemption_failed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# Non-ValueError faults are internal errors (500), not token errors.
|
||||||
|
RuntimeError("db exploded"),
|
||||||
|
500,
|
||||||
|
"api_error",
|
||||||
|
"Internal error during token redemption",
|
||||||
|
"internal_error",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_x_cashu_error_code_is_stable_string(
|
||||||
|
handler_name: str,
|
||||||
|
error: Exception,
|
||||||
|
expected_status: int,
|
||||||
|
expected_type: str,
|
||||||
|
expected_message: str,
|
||||||
|
expected_code: str,
|
||||||
|
) -> None:
|
||||||
|
"""X-Cashu emits a stable string ``code`` on every branch, matching the
|
||||||
|
bearer path's taxonomy instead of an int HTTP status."""
|
||||||
|
provider = _make_provider()
|
||||||
|
model = _make_model()
|
||||||
|
request = _make_request()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.upstream.base.recieve_token", new=AsyncMock(side_effect=error)
|
||||||
|
):
|
||||||
|
handler = getattr(provider, handler_name)
|
||||||
|
response = await handler(
|
||||||
|
request=request,
|
||||||
|
x_cashu_token="cashuAtoken",
|
||||||
|
path="v1/chat/completions",
|
||||||
|
max_cost_for_model=10_000,
|
||||||
|
model_obj=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == expected_status
|
||||||
|
body = json.loads(bytes(response.body))
|
||||||
|
assert body["error"]["type"] == expected_type
|
||||||
|
assert body["error"]["message"] == expected_message
|
||||||
|
assert body["error"]["code"] == expected_code
|
||||||
|
assert isinstance(body["error"]["code"], str)
|
||||||
|
if str(error) != expected_message:
|
||||||
|
assert str(error) not in body["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("handler_name", "forward_attr"),
|
||||||
|
[
|
||||||
|
("handle_x_cashu", "forward_x_cashu_request"),
|
||||||
|
("handle_x_cashu_responses", "forward_x_cashu_responses_request"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_x_cashu_transport_error_after_redemption_is_not_retryable(
|
||||||
|
handler_name: str, forward_attr: str
|
||||||
|
) -> None:
|
||||||
|
"""A transport failure while forwarding (after the token is spent) maps to
|
||||||
|
502 upstream_error, never a retryable cashu_mint_unreachable."""
|
||||||
|
provider = _make_provider()
|
||||||
|
model = _make_model()
|
||||||
|
request = _make_request()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.base.recieve_token",
|
||||||
|
new=AsyncMock(return_value=(5_000, "sat", "https://mint")),
|
||||||
|
),
|
||||||
|
patch("routstr.upstream.base.store_cashu_transaction", new=AsyncMock()),
|
||||||
|
patch.object(
|
||||||
|
provider,
|
||||||
|
forward_attr,
|
||||||
|
new=AsyncMock(side_effect=httpx.ConnectError("upstream down")),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
handler = getattr(provider, handler_name)
|
||||||
|
response = await handler(
|
||||||
|
request=request,
|
||||||
|
x_cashu_token="cashuAtoken",
|
||||||
|
path="v1/chat/completions",
|
||||||
|
max_cost_for_model=10_000,
|
||||||
|
model_obj=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 502
|
||||||
|
body = json.loads(bytes(response.body))
|
||||||
|
assert body["error"]["type"] == "upstream_error"
|
||||||
|
assert body["error"]["code"] != "cashu_mint_unreachable"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"handler_name",
|
||||||
|
["handle_x_cashu", "handle_x_cashu_responses"],
|
||||||
|
)
|
||||||
|
async def test_x_cashu_token_consumed_returns_500_and_no_echo(
|
||||||
|
handler_name: str,
|
||||||
|
) -> None:
|
||||||
|
"""A post-redemption failure (token spent, crediting/minting failed) is a
|
||||||
|
non-retryable 500 token_consumed and must NOT echo the spent token back."""
|
||||||
|
provider = _make_provider()
|
||||||
|
model = _make_model()
|
||||||
|
request = _make_request()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.upstream.base.recieve_token",
|
||||||
|
new=AsyncMock(side_effect=TokenConsumedError("credit failed")),
|
||||||
|
):
|
||||||
|
handler = getattr(provider, handler_name)
|
||||||
|
response = await handler(
|
||||||
|
request=request,
|
||||||
|
x_cashu_token="cashuAtoken",
|
||||||
|
path="v1/chat/completions",
|
||||||
|
max_cost_for_model=10_000,
|
||||||
|
model_obj=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
body = json.loads(bytes(response.body))
|
||||||
|
assert body["error"]["type"] == "token_consumed"
|
||||||
|
assert body["error"]["code"] == "cashu_token_consumed"
|
||||||
|
assert "X-Cashu" not in response.headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("handler_name", "error", "echoed"),
|
||||||
|
[
|
||||||
|
# Spent token: must NOT be echoed.
|
||||||
|
("handle_x_cashu", ValueError("Token already spent"), False),
|
||||||
|
("handle_x_cashu_responses", ValueError("Token already spent"), False),
|
||||||
|
# Unspent but unreachable mint: echo so the client can retry the token.
|
||||||
|
("handle_x_cashu", MintConnectionError("mint down"), True),
|
||||||
|
("handle_x_cashu_responses", MintConnectionError("mint down"), True),
|
||||||
|
# Consumed token (post-redemption): must NOT be echoed.
|
||||||
|
("handle_x_cashu", TokenConsumedError("credit failed"), False),
|
||||||
|
("handle_x_cashu_responses", TokenConsumedError("credit failed"), False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_x_cashu_echoes_token_only_when_recoverable(
|
||||||
|
handler_name: str, error: Exception, echoed: bool
|
||||||
|
) -> None:
|
||||||
|
provider = _make_provider()
|
||||||
|
model = _make_model()
|
||||||
|
request = _make_request()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.upstream.base.recieve_token", new=AsyncMock(side_effect=error)
|
||||||
|
):
|
||||||
|
handler = getattr(provider, handler_name)
|
||||||
|
response = await handler(
|
||||||
|
request=request,
|
||||||
|
x_cashu_token="cashuAtoken",
|
||||||
|
path="v1/chat/completions",
|
||||||
|
max_cost_for_model=10_000,
|
||||||
|
model_obj=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
if echoed:
|
||||||
|
assert response.headers.get("X-Cashu") == "cashuAtoken"
|
||||||
|
else:
|
||||||
|
assert "X-Cashu" not in response.headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("handler_name", "forward_attr"),
|
||||||
|
[
|
||||||
|
("handle_x_cashu", "forward_x_cashu_request"),
|
||||||
|
("handle_x_cashu_responses", "forward_x_cashu_responses_request"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("amount", [0, -5])
|
||||||
|
async def test_x_cashu_zero_value_rejected_not_forwarded(
|
||||||
|
handler_name: str, forward_attr: str, amount: int
|
||||||
|
) -> None:
|
||||||
|
"""A token that redeems to <= 0 must be rejected as cashu_token_zero_value
|
||||||
|
(400) and NEVER forwarded as a free request — the X-Cashu path lacked the
|
||||||
|
guard that credit_balance has."""
|
||||||
|
provider = _make_provider()
|
||||||
|
model = _make_model()
|
||||||
|
request = _make_request()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.base.recieve_token",
|
||||||
|
new=AsyncMock(return_value=(amount, "sat", "https://mint")),
|
||||||
|
),
|
||||||
|
patch("routstr.upstream.base.store_cashu_transaction", new=AsyncMock()),
|
||||||
|
patch.object(
|
||||||
|
provider,
|
||||||
|
forward_attr,
|
||||||
|
new=AsyncMock(side_effect=AssertionError("must not forward a zero-value token")),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
handler = getattr(provider, handler_name)
|
||||||
|
response = await handler(
|
||||||
|
request=request,
|
||||||
|
x_cashu_token="cashuAtoken",
|
||||||
|
path="v1/chat/completions",
|
||||||
|
max_cost_for_model=10_000,
|
||||||
|
model_obj=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
body = json.loads(bytes(response.body))
|
||||||
|
assert body["error"]["type"] == "cashu_error"
|
||||||
|
assert (
|
||||||
|
body["error"]["message"]
|
||||||
|
== "Failed to redeem Cashu token: token yielded no value"
|
||||||
|
)
|
||||||
|
assert body["error"]["code"] == "cashu_token_zero_value"
|
||||||
|
# Spent-to-zero token must not be echoed back for retry.
|
||||||
|
assert "X-Cashu" not in response.headers
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
_MIGRATION_PATH = (
|
||||||
|
Path(__file__).resolve().parents[2]
|
||||||
|
/ "migrations"
|
||||||
|
/ "versions"
|
||||||
|
/ "c6d7e8f9a0b1_add_slug_to_upstream_providers.py"
|
||||||
|
)
|
||||||
|
_spec = importlib.util.spec_from_file_location("provider_slug_migration", _MIGRATION_PATH)
|
||||||
|
assert _spec is not None and _spec.loader is not None
|
||||||
|
migration = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(migration)
|
||||||
|
|
||||||
|
|
||||||
|
def test_slug_migration_backfill_uses_api_safe_deterministic_slugs() -> None:
|
||||||
|
engine = sa.create_engine("sqlite:///:memory:")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"CREATE TABLE upstream_providers ("
|
||||||
|
"id INTEGER PRIMARY KEY, "
|
||||||
|
"provider_type VARCHAR NOT NULL, "
|
||||||
|
"slug VARCHAR NULL"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO upstream_providers (id, provider_type, slug) VALUES "
|
||||||
|
"(1, 'OpenAI Compatible', NULL), "
|
||||||
|
"(2, 'OpenAI Compatible', ''), "
|
||||||
|
"(3, '123', NULL), "
|
||||||
|
"(4, 'x', NULL), "
|
||||||
|
"(5, 'anthropic', 'anthropic')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
migration._backfill_provider_slugs(conn)
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.text("SELECT id, slug FROM upstream_providers ORDER BY id")
|
||||||
|
).all()
|
||||||
|
|
||||||
|
assert rows == [
|
||||||
|
(1, "openai-compatible"),
|
||||||
|
(2, "openai-compatible-2"),
|
||||||
|
(3, "provider-123"),
|
||||||
|
(4, "x-provider"),
|
||||||
|
(5, "anthropic"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlmodel import SQLModel, select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from routstr.core.admin import _get_upstream_provider_by_ref
|
||||||
|
from routstr.core.db import UpstreamProviderRow
|
||||||
|
from routstr.core.provider_slugs import (
|
||||||
|
allocate_unique_provider_slug,
|
||||||
|
provider_slug_base,
|
||||||
|
)
|
||||||
|
from routstr.upstream.helpers import _seed_providers_from_settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_allocate_unique_provider_slug_is_deterministic_with_suffixes() -> None:
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
session.add(
|
||||||
|
UpstreamProviderRow(
|
||||||
|
slug="openai",
|
||||||
|
provider_type="openai",
|
||||||
|
base_url="https://api.openai.com/v1",
|
||||||
|
api_key="key-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
assert await allocate_unique_provider_slug(session, "openai") == "openai-2"
|
||||||
|
assert (
|
||||||
|
await allocate_unique_provider_slug(session, "openai", {"openai-2"})
|
||||||
|
== "openai-3"
|
||||||
|
)
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_slug_base_sanitizes_provider_type() -> None:
|
||||||
|
assert provider_slug_base("OpenAI Compatible") == "openai-compatible"
|
||||||
|
assert provider_slug_base("!!!") == "provider"
|
||||||
|
assert provider_slug_base("AI") == "ai-provider"
|
||||||
|
assert provider_slug_base("123") == "provider-123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_provider_ref_lookup_accepts_existing_numeric_ids_and_slugs() -> None:
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
provider = UpstreamProviderRow(
|
||||||
|
slug="openai",
|
||||||
|
provider_type="openai",
|
||||||
|
base_url="https://api.openai.com/v1",
|
||||||
|
api_key="key-1",
|
||||||
|
)
|
||||||
|
session.add(provider)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(provider)
|
||||||
|
|
||||||
|
by_id = await _get_upstream_provider_by_ref(session, str(provider.id))
|
||||||
|
by_slug = await _get_upstream_provider_by_ref(session, "openai")
|
||||||
|
|
||||||
|
assert by_id.id == provider.id
|
||||||
|
assert by_slug.id == provider.id
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seed_providers_from_settings_sets_deterministic_slug(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "seeded-openai-key")
|
||||||
|
|
||||||
|
class SettingsStub:
|
||||||
|
chat_completions_api_version: str | None = None
|
||||||
|
upstream_base_url: str | None = None
|
||||||
|
upstream_api_key: str = ""
|
||||||
|
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
await _seed_providers_from_settings(session, SettingsStub()) # type: ignore[arg-type]
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
result = await session.exec(select(UpstreamProviderRow))
|
||||||
|
providers: list[UpstreamProviderRow] = list(result.all())
|
||||||
|
|
||||||
|
assert [(p.provider_type, p.slug) for p in providers] == [("openai", "openai")]
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seed_providers_from_settings_keeps_slug_stable_on_reseed(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "seeded-openai-key")
|
||||||
|
|
||||||
|
class SettingsStub:
|
||||||
|
chat_completions_api_version: str | None = None
|
||||||
|
upstream_base_url: str | None = None
|
||||||
|
upstream_api_key: str = ""
|
||||||
|
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
session.add(
|
||||||
|
UpstreamProviderRow(
|
||||||
|
slug="openai",
|
||||||
|
provider_type="openai",
|
||||||
|
base_url="https://example.invalid/v1",
|
||||||
|
api_key="other-key",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await _seed_providers_from_settings(session, SettingsStub()) # type: ignore[arg-type]
|
||||||
|
await session.commit()
|
||||||
|
await _seed_providers_from_settings(session, SettingsStub()) # type: ignore[arg-type]
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
result = await session.exec(
|
||||||
|
select(UpstreamProviderRow).order_by(UpstreamProviderRow.slug)
|
||||||
|
)
|
||||||
|
providers: list[UpstreamProviderRow] = list(result.all())
|
||||||
|
|
||||||
|
assert [(p.provider_type, p.slug) for p in providers] == [
|
||||||
|
("openai", "openai"),
|
||||||
|
("openai", "openai-2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
+238
-9
@@ -1,11 +1,22 @@
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import socket
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from routstr.core.db import ApiKey
|
from routstr.core.db import ApiKey
|
||||||
from routstr.wallet import credit_balance, get_balance, recieve_token, send_token
|
from routstr.wallet import (
|
||||||
|
MintConnectionError,
|
||||||
|
TokenConsumedError,
|
||||||
|
classify_redemption_error,
|
||||||
|
credit_balance,
|
||||||
|
get_balance,
|
||||||
|
is_mint_connection_error,
|
||||||
|
recieve_token,
|
||||||
|
send_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -231,9 +242,15 @@ async def test_credit_balance_rejects_missing_key() -> None:
|
|||||||
"routstr.wallet.recieve_token",
|
"routstr.wallet.recieve_token",
|
||||||
return_value=(1000, "sat", "http://mint:3338"),
|
return_value=(1000, "sat", "http://mint:3338"),
|
||||||
):
|
):
|
||||||
with pytest.raises(ValueError, match="disappeared"):
|
# Post-redemption: token already spent, so a vanished key is a
|
||||||
|
# non-retryable TokenConsumedError, not a generic token error.
|
||||||
|
with pytest.raises(TokenConsumedError, match="disappeared") as exc_info:
|
||||||
await credit_balance(token_str, mock_key, mock_session)
|
await credit_balance(token_str, mock_key, mock_session)
|
||||||
|
|
||||||
|
classified = classify_redemption_error(exc_info.value)
|
||||||
|
assert classified is not None
|
||||||
|
_type, status, _msg, code = classified
|
||||||
|
assert (status, code) == (500, "cashu_token_consumed")
|
||||||
# UPDATE matched nothing; committing would hide the failed credit.
|
# UPDATE matched nothing; committing would hide the failed credit.
|
||||||
assert mock_session.exec.called
|
assert mock_session.exec.called
|
||||||
assert not mock_session.commit.called
|
assert not mock_session.commit.called
|
||||||
@@ -902,9 +919,10 @@ def _with_recovery_mocks(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_swap_mint_failure_propagates_unwrapped() -> None:
|
async def test_swap_mint_failure_after_melt_is_token_consumed() -> None:
|
||||||
"""A non-recoverable mint failure after melt propagates as-is (a 500, not a
|
"""A non-recoverable mint failure after melt is a non-retryable
|
||||||
client error): the melt already spent the foreign proofs."""
|
TokenConsumedError (the melt already spent the foreign proofs), with the
|
||||||
|
original error preserved in the cause chain."""
|
||||||
from routstr.wallet import swap_to_primary_mint
|
from routstr.wallet import swap_to_primary_mint
|
||||||
|
|
||||||
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
||||||
@@ -919,10 +937,10 @@ async def test_swap_mint_failure_propagates_unwrapped() -> None:
|
|||||||
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||||
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||||
with pytest.raises(Exception, match="Quote is expired") as exc_info:
|
with pytest.raises(TokenConsumedError) as exc_info:
|
||||||
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||||
|
|
||||||
assert type(exc_info.value) is Exception # original error, not wrapped
|
assert "Quote is expired" in str(exc_info.value.__cause__)
|
||||||
assert mock_token_wallet.melt.call_count == 1
|
assert mock_token_wallet.melt.call_count == 1
|
||||||
mock_primary_wallet.restore_tokens_for_keyset.assert_not_called()
|
mock_primary_wallet.restore_tokens_for_keyset.assert_not_called()
|
||||||
|
|
||||||
@@ -977,7 +995,7 @@ async def test_swap_recovery_shortfall_refuses_credit() -> None:
|
|||||||
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||||
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||||
with pytest.raises(ValueError, match="Swap recovery failed"):
|
with pytest.raises(TokenConsumedError, match="Swap recovery failed"):
|
||||||
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||||
|
|
||||||
|
|
||||||
@@ -1004,7 +1022,7 @@ async def test_swap_recovery_failure_wrapped() -> None:
|
|||||||
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||||
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||||
with pytest.raises(ValueError, match="recovery unsuccessful"):
|
with pytest.raises(TokenConsumedError, match="recovery unsuccessful"):
|
||||||
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||||
|
|
||||||
|
|
||||||
@@ -1092,3 +1110,214 @@ async def test_swap_does_not_retry_on_payment_failure() -> None:
|
|||||||
|
|
||||||
assert mock_token_wallet.melt.call_count == 1
|
assert mock_token_wallet.melt.call_count == 1
|
||||||
assert mock_primary_wallet.request_mint.call_count == 2
|
assert mock_primary_wallet.request_mint.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
# --- Mint-unreachable classification (is_mint_connection_error) ---------------
|
||||||
|
|
||||||
|
|
||||||
|
def _chain(outer: BaseException, cause: BaseException) -> BaseException:
|
||||||
|
"""Attach ``cause`` as the ``__cause__`` of ``outer`` (as ``raise X from Y``
|
||||||
|
would) and return ``outer``."""
|
||||||
|
outer.__cause__ = cause
|
||||||
|
return outer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error",
|
||||||
|
[
|
||||||
|
httpx.ConnectError("connection refused"),
|
||||||
|
httpx.ConnectTimeout("timed out"),
|
||||||
|
httpx.ReadTimeout("read timed out"), # subclass of TimeoutException
|
||||||
|
httpx.PoolTimeout("pool timed out"),
|
||||||
|
httpx.WriteError("write failed"), # subclass of NetworkError
|
||||||
|
ConnectionRefusedError("refused"), # subclass of ConnectionError
|
||||||
|
ConnectionResetError("reset"),
|
||||||
|
socket.gaierror("Name or service not known"),
|
||||||
|
TimeoutError("timed out"), # asyncio.TimeoutError alias on 3.11+
|
||||||
|
MintConnectionError("mint down"),
|
||||||
|
# Wrapped: the real transport error survives in the __cause__ chain.
|
||||||
|
_chain(ValueError("Failed to estimate fees: boom"), httpx.ConnectError("x")),
|
||||||
|
# Two levels deep.
|
||||||
|
_chain(
|
||||||
|
RuntimeError("outer"),
|
||||||
|
_chain(ValueError("mid"), httpx.ConnectTimeout("deep")),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_is_mint_connection_error_detects_transport_failures(
|
||||||
|
error: BaseException,
|
||||||
|
) -> None:
|
||||||
|
assert is_mint_connection_error(error) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error",
|
||||||
|
[
|
||||||
|
ValueError("token already spent"),
|
||||||
|
ValueError("Mint unreachable: all connection attempts failed"), # text only
|
||||||
|
ValueError("Invalid Cashu token"),
|
||||||
|
# Mint answered with an error status — reachable, so NOT a connection error.
|
||||||
|
httpx.HTTPStatusError(
|
||||||
|
"500", request=httpx.Request("POST", "http://m"), response=httpx.Response(500)
|
||||||
|
),
|
||||||
|
RuntimeError("some internal fault"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_is_mint_connection_error_ignores_non_transport(error: BaseException) -> None:
|
||||||
|
assert is_mint_connection_error(error) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_mint_connection_error_survives_reference_cycle() -> None:
|
||||||
|
"""A pathological cause/context cycle must not hang the classifier."""
|
||||||
|
a = ValueError("a")
|
||||||
|
b = ValueError("b")
|
||||||
|
a.__cause__ = b
|
||||||
|
b.__context__ = a
|
||||||
|
assert is_mint_connection_error(a) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_consumed_seals_transport_cause() -> None:
|
||||||
|
"""A transport error wrapped in TokenConsumedError is NOT retryable — the
|
||||||
|
token is spent, so the seal wins over the httpx cause underneath."""
|
||||||
|
try:
|
||||||
|
raise httpx.ConnectError("mint down")
|
||||||
|
except httpx.ConnectError as exc:
|
||||||
|
consumed = TokenConsumedError("credit failed")
|
||||||
|
consumed.__cause__ = exc
|
||||||
|
|
||||||
|
assert is_mint_connection_error(consumed) is False
|
||||||
|
classified = classify_redemption_error(consumed)
|
||||||
|
assert classified is not None
|
||||||
|
type_, status, _msg, code = classified
|
||||||
|
assert (type_, status, code) == ("token_consumed", 500, "cashu_token_consumed")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error",
|
||||||
|
[
|
||||||
|
# The message credit_balance raises for a dust/zero redemption.
|
||||||
|
ValueError("Redeemed token amount must be positive, got 0 msats"),
|
||||||
|
ValueError("Redeemed token amount must be positive, got -5 msats"),
|
||||||
|
ValueError("Failed to redeem Cashu token: token yielded no value"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_classify_zero_value(error: ValueError) -> None:
|
||||||
|
"""A zero/negative redemption gets its own documented code, not the generic
|
||||||
|
cashu_token_redemption_failed bucket."""
|
||||||
|
classified = classify_redemption_error(error)
|
||||||
|
assert classified is not None
|
||||||
|
type_, status, _msg, code = classified
|
||||||
|
assert (type_, status, code) == ("cashu_error", 400, "cashu_token_zero_value")
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_generic_valueerror_is_not_zero_value() -> None:
|
||||||
|
"""A generic wallet ValueError still falls to the generic bucket — the
|
||||||
|
zero-value match must not over-trigger."""
|
||||||
|
classified = classify_redemption_error(ValueError("some unexpected wallet condition"))
|
||||||
|
assert classified is not None
|
||||||
|
type_, status, _msg, code = classified
|
||||||
|
assert (type_, status, code) == (
|
||||||
|
"cashu_error",
|
||||||
|
400,
|
||||||
|
"cashu_token_redemption_failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_swap_mint_transport_error_after_melt_is_not_retryable() -> None:
|
||||||
|
"""A transport error minting on the primary mint (after the foreign melt
|
||||||
|
already spent the proofs) classifies as a non-retryable token_consumed 500,
|
||||||
|
never a retryable mint_unreachable 503."""
|
||||||
|
from routstr.wallet import swap_to_primary_mint
|
||||||
|
|
||||||
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
||||||
|
1000, fee_reserves=[10, 10]
|
||||||
|
)
|
||||||
|
# Melt succeeds (proofs spent); minting on primary hits a transport error.
|
||||||
|
mock_primary_wallet.mint = AsyncMock(
|
||||||
|
side_effect=httpx.ConnectError("primary mint down")
|
||||||
|
)
|
||||||
|
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||||
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||||
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||||
|
with pytest.raises(TokenConsumedError) as exc_info:
|
||||||
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||||
|
|
||||||
|
classified = classify_redemption_error(exc_info.value)
|
||||||
|
assert classified is not None
|
||||||
|
_type, status, _msg, code = classified
|
||||||
|
assert status == 500
|
||||||
|
assert code == "cashu_token_consumed"
|
||||||
|
assert is_mint_connection_error(exc_info.value) is False
|
||||||
|
assert mock_token_wallet.melt.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_credit_balance_db_transport_error_is_token_consumed() -> None:
|
||||||
|
"""A transport-like DB failure after the token is redeemed must be
|
||||||
|
non-retryable (token_consumed), not a retryable mint_unreachable."""
|
||||||
|
mock_key = Mock()
|
||||||
|
mock_key.balance = 1000
|
||||||
|
mock_key.hashed_key = "test_hash"
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.exec = AsyncMock(side_effect=ConnectionError("db connection reset"))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.wallet.recieve_token",
|
||||||
|
return_value=(100, "sat", "https://mint.example"),
|
||||||
|
):
|
||||||
|
with pytest.raises(TokenConsumedError) as exc_info:
|
||||||
|
await credit_balance("cashuAtoken", mock_key, mock_session)
|
||||||
|
|
||||||
|
assert is_mint_connection_error(exc_info.value) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_swap_fee_estimation_transport_error_raises_mint_connection_error() -> None:
|
||||||
|
"""A transport failure while estimating fees is surfaced as
|
||||||
|
MintConnectionError (→ 503), not a generic fee ValueError (→ 422)."""
|
||||||
|
from routstr.wallet import swap_to_primary_mint
|
||||||
|
|
||||||
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
||||||
|
1000, fee_reserves=[10]
|
||||||
|
)
|
||||||
|
mock_primary_wallet.request_mint = AsyncMock(
|
||||||
|
side_effect=httpx.ConnectError("All connection attempts failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||||
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||||
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||||
|
with pytest.raises(MintConnectionError):
|
||||||
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||||
|
|
||||||
|
mock_token_wallet.melt.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_swap_melt_transport_error_raises_mint_connection_error() -> None:
|
||||||
|
"""A transport failure during melt is surfaced as MintConnectionError and
|
||||||
|
is NOT retried — the mint is down, not demanding higher fees."""
|
||||||
|
from routstr.wallet import swap_to_primary_mint
|
||||||
|
|
||||||
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
||||||
|
1000, fee_reserves=[10, 10]
|
||||||
|
)
|
||||||
|
mock_token_wallet.melt = AsyncMock(
|
||||||
|
side_effect=httpx.ConnectTimeout("timed out")
|
||||||
|
)
|
||||||
|
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||||
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||||
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||||
|
with pytest.raises(MintConnectionError):
|
||||||
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||||
|
|
||||||
|
assert mock_token_wallet.melt.call_count == 1
|
||||||
|
|||||||
@@ -118,6 +118,26 @@ export function ProviderFormFields({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor={`${idPrefix}slug`}>
|
||||||
|
Slug {mode === 'create' ? '(optional, auto-generated)' : ''}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`${idPrefix}slug`}
|
||||||
|
value={formData.slug || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
slug: e.target.value || undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder='e.g. openai-prod'
|
||||||
|
/>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Stable external key used to update this provider via the admin API.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
<Label htmlFor={`${idPrefix}base_url`}>Base URL</Label>
|
<Label htmlFor={`${idPrefix}base_url`}>Base URL</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const ProviderTypeSchema = z.object({
|
|||||||
|
|
||||||
export const UpstreamProviderSchema = z.object({
|
export const UpstreamProviderSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
|
slug: z.string().nullable().optional(),
|
||||||
provider_type: z.string(),
|
provider_type: z.string(),
|
||||||
base_url: z.string(),
|
base_url: z.string(),
|
||||||
api_key: z.string().optional(),
|
api_key: z.string().optional(),
|
||||||
@@ -31,6 +32,7 @@ export const CreateUpstreamProviderSchema = z.object({
|
|||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
provider_fee: z.number().optional(),
|
provider_fee: z.number().optional(),
|
||||||
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
||||||
|
slug: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateUpstreamProviderSchema = z.object({
|
export const UpdateUpstreamProviderSchema = z.object({
|
||||||
@@ -41,6 +43,7 @@ export const UpdateUpstreamProviderSchema = z.object({
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
provider_fee: z.number().optional(),
|
provider_fee: z.number().optional(),
|
||||||
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
|
||||||
|
slug: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AdminModelPricingSchema = z.object({
|
export const AdminModelPricingSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user