mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 23:36:15 +00:00
Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50eabafa57 | ||
|
|
f1fa7d094f | ||
|
|
eed5bc5b04 | ||
|
|
f4b014cb05 | ||
|
|
4418d87664 | ||
|
|
634a473f50 | ||
|
|
ea655b748b | ||
|
|
2d247ddc8b | ||
|
|
c064452aea | ||
|
|
1c6a603042 | ||
|
|
84b0007b05 | ||
|
|
525476ccfa | ||
|
|
b54812cb04 | ||
|
|
ee508cbb3a | ||
|
|
0c61fdee07 | ||
|
|
b9418db31f | ||
|
|
71e7c2171b | ||
|
|
a3e8d5fd38 | ||
|
|
41fd2e2dfc | ||
|
|
2c404c66d6 | ||
|
|
0fa3e77f9a | ||
|
|
5b8e56f590 | ||
|
|
a6d0bd1a19 | ||
|
|
9594e9fb52 | ||
|
|
b01c7b2e56 | ||
|
|
dd4ed7541f | ||
|
|
cc42534a97 | ||
|
|
d203370f01 | ||
|
|
e39742c429 | ||
|
|
301dd81215 | ||
|
|
5416cefd87 | ||
|
|
8edc3512c1 | ||
|
|
82d2627c60 | ||
|
|
43e97326e0 | ||
|
|
06770a0702 | ||
|
|
590fb4bc2c | ||
|
|
5db9abc3ce | ||
|
|
c11cc107c8 | ||
|
|
547365894d | ||
|
|
19b5f2889a | ||
|
|
52601f89bd | ||
|
|
72b281b815 | ||
|
|
c0176a5274 | ||
|
|
329d22363f | ||
|
|
87b1443c23 | ||
|
|
29129f8953 | ||
|
|
c97c74a2ee | ||
|
|
1e37c42ea0 | ||
|
|
4c7887fa4e | ||
|
|
2cc5063dee | ||
|
|
e4eda59e6a | ||
|
|
7f918eab6a | ||
|
|
b8c34afef8 | ||
|
|
5738b1bd99 | ||
|
|
2aee75e7d5 | ||
|
|
4d67af51ab | ||
|
|
2f841dfcbc | ||
|
|
7ca69063bd | ||
|
|
085ff75d1d | ||
|
|
21340b2de1 | ||
|
|
a150d38df7 | ||
|
|
02ca36141e | ||
|
|
20bf5e35a4 | ||
|
|
5a67e6b4d6 | ||
|
|
34d1e4b041 | ||
|
|
b88ef858ee | ||
|
|
a363c7a0b1 | ||
|
|
4ac257db0d | ||
|
|
fd5ec01a99 | ||
|
|
bc8c08c468 | ||
|
|
d6648d3337 | ||
|
|
9438bc957f | ||
|
|
5d2219880d | ||
|
|
195da0c9da | ||
|
|
8df0c17bc3 | ||
|
|
7bc9ee0653 | ||
|
|
355f8601c1 | ||
|
|
e7f677b315 | ||
|
|
d7611e74c3 | ||
|
|
d0790dcb22 | ||
|
|
6b4b3924a1 | ||
|
|
2b4f71cc4f | ||
|
|
80359d0854 | ||
|
|
a226a78222 | ||
|
|
42c2ddb355 | ||
|
|
65e7702f90 | ||
|
|
253f419ada | ||
|
|
83818e1097 | ||
|
|
f116a5ed38 | ||
|
|
ee185f56e5 | ||
|
|
b76598ea41 | ||
|
|
432fe48f36 | ||
|
|
7b6e00edd8 | ||
|
|
e59335db4d | ||
|
|
6f00478580 | ||
|
|
4ed4f610f4 | ||
|
|
30fd369bfa | ||
|
|
8208a870a2 | ||
|
|
5eb4a40395 | ||
|
|
95ffc612ca | ||
|
|
afa4a59bb6 | ||
|
|
12515cb0e3 | ||
|
|
0f23335de9 | ||
|
|
bd0764ee0d | ||
|
|
c5c032bd2c | ||
|
|
b717c9739a |
@@ -62,14 +62,18 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
node-version: "18"
|
||||
cache: "npm"
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
|
||||
- name: Install UI dependencies
|
||||
working-directory: ./ui
|
||||
run: npm ci
|
||||
|
||||
- name: Run UI format check
|
||||
working-directory: ./ui
|
||||
run: npm run format-check
|
||||
|
||||
- name: Run UI linting
|
||||
working-directory: ./ui
|
||||
run: npm run lint
|
||||
|
||||
@@ -34,7 +34,7 @@ sequenceDiagram
|
||||
- **Model-Based Pricing** – Convert USD prices in `models.json` to sats using live BTC/USD rates
|
||||
- **Admin Dashboard** – Simple HTML interface at `/admin/` to view balances and API keys
|
||||
- **Discovery** – Fetch available providers from Nostr relays using NIP-91 protocol
|
||||
- **Auto-Listing** – Automatically announce this provider to Nostr relays when NSEC is provided
|
||||
- **NIP-91 Auto-Announcement** – Automatically announce this provider to Nostr relays when NSEC is provided
|
||||
- **Docker Support** – Provided `Dockerfile` and `compose.yml` for running with an optional Tor hidden service
|
||||
|
||||
## Getting Started
|
||||
@@ -99,7 +99,7 @@ The most common settings are shown below. See `.env.example` for the full list.
|
||||
- `NPUB` – Nostr public key of the proxy
|
||||
- `HTTP_URL` – Public-facing URL of the proxy
|
||||
- `ONION_URL` – Tor hidden service URL of the proxy
|
||||
- `NEXT_PUBLIC_API_URL` - UI Configuration for Next.js frontend (proxy URL, default: '<http://127.0.0.1:8000>' )
|
||||
- `NEXT_PUBLIC_API_URL` - UI Configuration for Next.js frontend (proxy URL, default: 'http://127.0.0.1:8000' )
|
||||
|
||||
## Database Migrations
|
||||
|
||||
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
import os
|
||||
|
||||
import openai
|
||||
|
||||
client = openai.OpenAI(
|
||||
api_key=os.environ["CASHU_TOKEN"],
|
||||
base_url=os.environ.get("ROUTSTR_API_URL", "https://api.routstr.com/v1"),
|
||||
# base_url="http://roustrjfsdgfiueghsklchg.onion/v1",
|
||||
# client=httpx.AsyncClient(
|
||||
# proxies={"http": "socks5://localhost:9050"},
|
||||
# ), # to use onion proxy (tor)
|
||||
)
|
||||
history: list = []
|
||||
|
||||
|
||||
def chat() -> None:
|
||||
while True:
|
||||
user_msg = {"role": "user", "content": input("\nYou: ")}
|
||||
history.append(user_msg)
|
||||
ai_msg = {"role": "assistant", "content": ""}
|
||||
|
||||
for chunk in client.chat.completions.create(
|
||||
model=os.environ.get("MODEL", "openai/gpt-4o-mini"),
|
||||
messages=history,
|
||||
stream=True,
|
||||
):
|
||||
if len(chunk.choices) > 0:
|
||||
content = chunk.choices[0].delta.content
|
||||
if content is not None:
|
||||
ai_msg["content"] += content
|
||||
print(content, end="", flush=True)
|
||||
print()
|
||||
history.append(ai_msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
chat()
|
||||
@@ -0,0 +1,11 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Use your Cashu token or API key as the Bearer token,
|
||||
# cashu token is hashed on the server and acts as an Temporary API key
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
resp = httpx.get(f"{base_url}/balance/info", headers=headers)
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Send a Cashu token to the /create endpoint to get a persistent API key
|
||||
token = os.environ.get("TOKEN")
|
||||
if not token:
|
||||
print("Please set TOKEN environment variable with a Cashu token")
|
||||
exit(1)
|
||||
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
resp = httpx.get(f"{base_url}/balance/create", params={"initial_balance_token": token})
|
||||
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Use your Cashu token or API key as the Bearer token
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
resp = httpx.post(f"{base_url}/balance/refund", headers=headers)
|
||||
|
||||
print("Refund successful!")
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Use your Cashu token or API key as the Bearer token
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
# The Cashu token to top up with
|
||||
cashu_token = input("Enter Cashu token to top up: ")
|
||||
|
||||
resp = httpx.post(
|
||||
f"{base_url}/balance/topup", headers=headers, json={"cashu_token": cashu_token}
|
||||
)
|
||||
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=os.environ.get("MODEL", "gpt-5-nano"),
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN", ""),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
for model in client.models.list():
|
||||
print(model.id)
|
||||
|
||||
# OR
|
||||
|
||||
models = httpx.get(
|
||||
f"{client.base_url}/v1/models",
|
||||
headers={"Authorization": f"Bearer {client.api_key}"},
|
||||
).json()
|
||||
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
conversation = [] # type: ignore
|
||||
|
||||
# First turn
|
||||
response1 = client.responses.create( # type: ignore
|
||||
model="o4-mini",
|
||||
input="Hi, my name is Alice.",
|
||||
conversation=conversation,
|
||||
)
|
||||
print("Response 1:", response1.output)
|
||||
|
||||
# Note: The 'conversation' parameter might need to be constructed differently
|
||||
# depending on exact SDK/API spec. Typically, you pass back the previous turn's data.
|
||||
# Assuming the SDK manages or returns a conversation object/ID:
|
||||
# conversation.append(response1)
|
||||
|
||||
# Second turn - demonstrating intent, actual implementation depends on strict API spec
|
||||
# response2 = client.responses.create(
|
||||
# model="openai/gpt-4o-mini",
|
||||
# input="What is my name?",
|
||||
# conversation=conversation,
|
||||
# )
|
||||
# print("Response 2:", response2.output)
|
||||
@@ -0,0 +1,17 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
# The OpenAI SDK handles the 'responses' endpoint if it's updated to the latest version
|
||||
# and the base_url points to a compatible proxy like Routstr.
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
response = client.responses.create(
|
||||
model="gpt-5-mini",
|
||||
input="Tell me a three sentence bedtime story about a unicorn.",
|
||||
)
|
||||
|
||||
print(response.output)
|
||||
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
stream = client.responses.create(
|
||||
model="claude-4.5-sonnet",
|
||||
input="Write a short poem about rust.",
|
||||
stream=True,
|
||||
)
|
||||
|
||||
for event in stream:
|
||||
# Note: Depending on the SDK version and response structure,
|
||||
# you might access event.output_delta or similar fields
|
||||
print(event, end="", flush=True)
|
||||
print()
|
||||
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
response = client.responses.create(
|
||||
model="gpt-5-mini",
|
||||
input="What is the latest news about AI?",
|
||||
tools=[{"type": "web_search"}], # type: ignore
|
||||
)
|
||||
|
||||
print(response.output)
|
||||
@@ -0,0 +1,28 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
messages = []
|
||||
while True:
|
||||
messages.append({"role": "user", "content": input("\nYou: ")})
|
||||
|
||||
stream = client.chat.completions.create(
|
||||
model=os.environ.get("MODEL", "gpt-5.1-mini"),
|
||||
messages=messages, # type: ignore
|
||||
stream=True,
|
||||
)
|
||||
|
||||
print("AI: ", end="")
|
||||
response_content = ""
|
||||
for chunk in stream:
|
||||
if content := chunk.choices[0].delta.content: # type: ignore
|
||||
print(content, end="", flush=True)
|
||||
response_content += content
|
||||
print()
|
||||
|
||||
messages.append({"role": "assistant", "content": response_content})
|
||||
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
# Requires `pip install "httpx[socks]"` and a running Tor proxy on port 9050
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("ONION_URL", "http://roustrjfsdgfiueghsklchg.onion/v1"),
|
||||
http_client=httpx.Client(proxies="socks5://localhost:9050"),
|
||||
)
|
||||
|
||||
print(
|
||||
client.chat.completions.create(
|
||||
model="openai/gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "Hello from Tor!"}],
|
||||
)
|
||||
.choices[0]
|
||||
.message.content
|
||||
)
|
||||
@@ -1,60 +0,0 @@
|
||||
"""the refactor
|
||||
|
||||
Revision ID: 5f6ac1e4fa9f
|
||||
Revises: a1a1a1a1a1a1
|
||||
Create Date: 2025-11-28 13:31:49.796461
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "5f6ac1e4fa9f"
|
||||
down_revision = "a1a1a1a1a1a1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Rename the table
|
||||
op.rename_table("api_keys", "temporary_credit")
|
||||
|
||||
op.add_column(
|
||||
"temporary_credit", sa.Column("created", sa.DateTime(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"temporary_credit",
|
||||
sa.Column("refund_expiration_time", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.drop_column("temporary_credit", "key_expiry_time")
|
||||
op.drop_column("temporary_credit", "total_spent")
|
||||
op.drop_column("temporary_credit", "total_requests")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"temporary_credit",
|
||||
sa.Column(
|
||||
"total_requests",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"temporary_credit",
|
||||
sa.Column(
|
||||
"total_spent",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"temporary_credit", sa.Column("key_expiry_time", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.drop_column("temporary_credit", "refund_expiration_time")
|
||||
op.drop_column("temporary_credit", "created")
|
||||
|
||||
# Revert table rename
|
||||
op.rename_table("temporary_credit", "api_keys")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""alias-ids
|
||||
|
||||
Revision ID: b9667ffc5701
|
||||
Revises: lightning_invoices
|
||||
Create Date: 2025-12-25 19:30:44.673350
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b9667ffc5701"
|
||||
down_revision = "lightning_invoices"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic ###
|
||||
op.add_column(
|
||||
"models",
|
||||
sa.Column("canonical_slug", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"models",
|
||||
sa.Column("alias_ids", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("models", "alias_ids")
|
||||
op.drop_column("models", "canonical_slug")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Add lightning_invoices table
|
||||
|
||||
Revision ID: lightning_invoices
|
||||
Revises: a1a1a1a1a1a1
|
||||
Create Date: 2025-12-10 21:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
revision = "lightning_invoices"
|
||||
down_revision = "a1a1a1a1a1a1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"lightning_invoices",
|
||||
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("bolt11", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("amount_sats", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("payment_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("api_key_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("purpose", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||
sa.Column("expires_at", sa.Integer(), nullable=False),
|
||||
sa.Column("paid_at", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("bolt11"),
|
||||
sa.UniqueConstraint("payment_hash"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("lightning_invoices")
|
||||
@@ -20,6 +20,7 @@ dependencies = [
|
||||
"nostr>=0.0.2",
|
||||
"mdurl==0.1.2",
|
||||
"pillow>=10",
|
||||
"openai>=1.98.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -72,6 +73,7 @@ packages = ["routstr"]
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
ignore = ["E501"]
|
||||
exclude = ["examples"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..core import get_logger
|
||||
from ..models.crud import row_to_model
|
||||
from ..upstream.helpers import resolve_model_alias
|
||||
from .core.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..upstream import BaseUpstreamProvider
|
||||
from .models import Model
|
||||
from .payment.models import Model
|
||||
from .upstream import BaseUpstreamProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -152,18 +150,6 @@ def should_prefer_model(
|
||||
# Prefer lower adjusted cost
|
||||
should_replace = candidate_adjusted < current_adjusted
|
||||
|
||||
# Log provider changes when candidate wins
|
||||
if should_replace:
|
||||
candidate_provider_name = getattr(
|
||||
candidate_provider, "upstream_name", "unknown"
|
||||
)
|
||||
current_provider_name = getattr(current_provider, "upstream_name", "unknown")
|
||||
logger.debug(
|
||||
f"Model selection for alias '{alias}': choosing {candidate_provider_name} "
|
||||
f"(cost: ${candidate_adjusted:.6f}) over {current_provider_name} "
|
||||
f"(cost: ${current_adjusted:.6f})"
|
||||
)
|
||||
|
||||
return should_replace
|
||||
|
||||
|
||||
@@ -194,6 +180,8 @@ def create_model_mappings(
|
||||
Returns:
|
||||
Tuple of (model_instances, provider_map, unique_models)
|
||||
"""
|
||||
from .payment.models import _row_to_model
|
||||
from .upstream.helpers import resolve_model_alias
|
||||
|
||||
model_instances: dict[str, "Model"] = {}
|
||||
provider_map: dict[str, "BaseUpstreamProvider"] = {}
|
||||
@@ -218,19 +206,20 @@ def create_model_mappings(
|
||||
alias: str, model: "Model", provider: "BaseUpstreamProvider"
|
||||
) -> None:
|
||||
"""Set alias to model/provider if not set or if new model is preferred."""
|
||||
existing_model = model_instances.get(alias)
|
||||
alias_lower = alias.lower()
|
||||
existing_model = model_instances.get(alias_lower)
|
||||
if not existing_model:
|
||||
# No existing mapping, set it
|
||||
model_instances[alias] = model
|
||||
provider_map[alias] = provider
|
||||
model_instances[alias_lower] = model
|
||||
provider_map[alias_lower] = provider
|
||||
else:
|
||||
# Check if candidate should replace existing
|
||||
existing_provider = provider_map[alias]
|
||||
existing_provider = provider_map[alias_lower]
|
||||
if should_prefer_model(
|
||||
model, provider, existing_model, existing_provider, alias
|
||||
):
|
||||
model_instances[alias] = model
|
||||
provider_map[alias] = provider
|
||||
model_instances[alias_lower] = model
|
||||
provider_map[alias_lower] = provider
|
||||
|
||||
def process_provider_models(
|
||||
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
|
||||
@@ -245,7 +234,7 @@ def create_model_mappings(
|
||||
# Apply overrides if present
|
||||
if model.id in overrides_by_id:
|
||||
override_row, provider_fee = overrides_by_id[model.id]
|
||||
model_to_use = row_to_model(
|
||||
model_to_use = _row_to_model(
|
||||
override_row, apply_provider_fee=True, provider_fee=provider_fee
|
||||
)
|
||||
else:
|
||||
@@ -254,7 +243,12 @@ def create_model_mappings(
|
||||
# Add to unique models
|
||||
base_id = get_base_model_id(model_to_use.id)
|
||||
if not is_openrouter or base_id not in unique_models:
|
||||
unique_model = model_to_use.copy(update={"id": base_id})
|
||||
unique_model = model_to_use.copy(
|
||||
update={
|
||||
"id": base_id,
|
||||
"upstream_provider_id": upstream.provider_type,
|
||||
}
|
||||
)
|
||||
unique_models[base_id] = unique_model
|
||||
|
||||
# Get all aliases for this model
|
||||
@@ -289,12 +283,8 @@ def create_model_mappings(
|
||||
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
|
||||
|
||||
logger.debug(
|
||||
"Created model mappings",
|
||||
extra={
|
||||
"unique_model_count": len(unique_models),
|
||||
"total_alias_count": len(model_instances),
|
||||
"provider_distribution": provider_counts,
|
||||
},
|
||||
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",
|
||||
extra={"provider_distribution": provider_counts},
|
||||
)
|
||||
|
||||
return model_instances, provider_map, unique_models
|
||||
+638
-75
@@ -1,98 +1,661 @@
|
||||
import hashlib
|
||||
from typing import Annotated
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import col, update
|
||||
|
||||
from .core import get_logger
|
||||
from .core.db import AsyncSession, TemporaryCredit, get_session
|
||||
from .core.db import ApiKey, AsyncSession
|
||||
from .core.settings import settings
|
||||
from .payment.wallet import credit_balance, deserialize_token_from_string
|
||||
from .payment.cost_calculation import (
|
||||
CostData,
|
||||
CostDataError,
|
||||
MaxCostData,
|
||||
calculate_cost,
|
||||
)
|
||||
from .wallet import credit_balance, deserialize_token_from_string
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def api_key_to_credit(key: str, db_session: AsyncSession) -> TemporaryCredit:
|
||||
key = key[3:] if key.startswith("sk-") else key
|
||||
if existing_credit := await db_session.get(TemporaryCredit, key):
|
||||
return existing_credit
|
||||
|
||||
raise HTTPException(status_code=401, detail="Invalid API-KEY")
|
||||
# TODO: implement prepaid api key (not like it was before)
|
||||
# PREPAID_API_KEY = os.environ.get("PREPAID_API_KEY", None)
|
||||
# PREPAID_BALANCE = int(os.environ.get("PREPAID_BALANCE", "0")) * 1000 # Convert to msats
|
||||
|
||||
|
||||
async def cashu_token_to_credit(
|
||||
cashu_token: str, db_session: AsyncSession
|
||||
) -> TemporaryCredit:
|
||||
try:
|
||||
token_hash = hashlib.sha256(cashu_token.encode()).hexdigest()
|
||||
token_obj = deserialize_token_from_string(cashu_token)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
|
||||
if existing_credit := await db_session.get(TemporaryCredit, token_hash):
|
||||
return existing_credit
|
||||
|
||||
# create new TemporaryCredit
|
||||
if token_obj.mint in settings.cashu_mints:
|
||||
refund_currency = token_obj.unit
|
||||
refund_mint_url = token_obj.mint
|
||||
else:
|
||||
refund_mint_url = settings.primary_mint
|
||||
refund_currency = "sat"
|
||||
|
||||
new_credit = TemporaryCredit(
|
||||
hashed_key=token_hash,
|
||||
refund_currency=refund_currency,
|
||||
refund_mint_url=refund_mint_url,
|
||||
async def validate_bearer_key(
|
||||
bearer_key: str,
|
||||
session: AsyncSession,
|
||||
refund_address: Optional[str] = None,
|
||||
key_expiry_time: Optional[int] = None,
|
||||
) -> ApiKey:
|
||||
"""
|
||||
Validates the provided API key using SQLModel.
|
||||
If it's a cashu key, it redeems it and stores its hash and balance.
|
||||
Otherwise checks if the hash of the key exists.
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting bearer key validation",
|
||||
extra={
|
||||
"key_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
"has_refund_address": bool(refund_address),
|
||||
"has_expiry_time": bool(key_expiry_time),
|
||||
},
|
||||
)
|
||||
db_session.add(new_credit)
|
||||
|
||||
try:
|
||||
await db_session.flush()
|
||||
except IntegrityError: # fallback to api key in case of race condition
|
||||
await db_session.rollback()
|
||||
return await api_key_to_credit(f"sk-{token_hash}", db_session)
|
||||
|
||||
try:
|
||||
msats = await credit_balance(cashu_token, new_credit, db_session)
|
||||
except Exception as e:
|
||||
await db_session.rollback()
|
||||
logger.error(
|
||||
"Token redemption failed",
|
||||
extra={"error": str(e)},
|
||||
if not bearer_key:
|
||||
logger.error("Empty bearer key provided")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "API key or Cashu token required",
|
||||
"type": "invalid_request_error",
|
||||
"code": "missing_api_key",
|
||||
}
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Token redemption failed")
|
||||
|
||||
await db_session.refresh(new_credit)
|
||||
await db_session.commit()
|
||||
if bearer_key.startswith("sk-"):
|
||||
logger.debug(
|
||||
"Processing sk- prefixed API key",
|
||||
extra={"key_preview": bearer_key[:10] + "..."},
|
||||
)
|
||||
|
||||
if existing_key := await session.get(ApiKey, bearer_key[3:]):
|
||||
logger.info(
|
||||
"Existing sk- API key found",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"balance": existing_key.balance,
|
||||
"total_requests": existing_key.total_requests,
|
||||
},
|
||||
)
|
||||
|
||||
if key_expiry_time is not None:
|
||||
existing_key.key_expiry_time = key_expiry_time
|
||||
logger.debug(
|
||||
"Updated key expiry time",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"expiry_time": key_expiry_time,
|
||||
},
|
||||
)
|
||||
|
||||
if refund_address is not None:
|
||||
existing_key.refund_address = refund_address
|
||||
logger.debug(
|
||||
"Updated refund address",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"refund_address_preview": refund_address[:20] + "..."
|
||||
if len(refund_address) > 20
|
||||
else refund_address,
|
||||
},
|
||||
)
|
||||
|
||||
return existing_key
|
||||
else:
|
||||
logger.warning(
|
||||
"sk- API key not found in database",
|
||||
extra={"key_preview": bearer_key[:10] + "..."},
|
||||
)
|
||||
|
||||
if bearer_key.startswith("cashu"):
|
||||
logger.debug(
|
||||
"Processing Cashu token",
|
||||
extra={
|
||||
"token_preview": bearer_key[:20] + "...",
|
||||
"token_type": bearer_key[:6] if len(bearer_key) >= 6 else bearer_key,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
|
||||
token_obj = deserialize_token_from_string(bearer_key)
|
||||
logger.debug(
|
||||
"Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."}
|
||||
)
|
||||
|
||||
if existing_key := await session.get(ApiKey, hashed_key):
|
||||
logger.info(
|
||||
"Existing Cashu token found",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"balance": existing_key.balance,
|
||||
"total_requests": existing_key.total_requests,
|
||||
},
|
||||
)
|
||||
|
||||
if key_expiry_time is not None:
|
||||
existing_key.key_expiry_time = key_expiry_time
|
||||
logger.debug(
|
||||
"Updated key expiry time for existing Cashu key",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"expiry_time": key_expiry_time,
|
||||
},
|
||||
)
|
||||
|
||||
if refund_address is not None:
|
||||
existing_key.refund_address = refund_address
|
||||
logger.debug(
|
||||
"Updated refund address for existing Cashu key",
|
||||
extra={
|
||||
"key_hash": existing_key.hashed_key[:8] + "...",
|
||||
"refund_address_preview": refund_address[:20] + "..."
|
||||
if len(refund_address) > 20
|
||||
else refund_address,
|
||||
},
|
||||
)
|
||||
|
||||
return existing_key
|
||||
|
||||
logger.info(
|
||||
"Creating new Cashu token entry",
|
||||
extra={
|
||||
"hash_preview": hashed_key[:16] + "...",
|
||||
"has_refund_address": bool(refund_address),
|
||||
"has_expiry_time": bool(key_expiry_time),
|
||||
},
|
||||
)
|
||||
if token_obj.mint in settings.cashu_mints:
|
||||
refund_currency = token_obj.unit
|
||||
refund_mint_url = token_obj.mint
|
||||
else:
|
||||
refund_currency = "sat"
|
||||
refund_mint_url = settings.primary_mint
|
||||
|
||||
new_key = ApiKey(
|
||||
hashed_key=hashed_key,
|
||||
balance=0,
|
||||
refund_address=refund_address,
|
||||
key_expiry_time=key_expiry_time,
|
||||
refund_currency=refund_currency,
|
||||
refund_mint_url=refund_mint_url,
|
||||
)
|
||||
session.add(new_key)
|
||||
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
logger.info(
|
||||
"Concurrent key creation detected, fetching existing key",
|
||||
extra={"key_hash": hashed_key[:8] + "..."},
|
||||
)
|
||||
existing_key = await session.get(ApiKey, hashed_key)
|
||||
if not existing_key:
|
||||
raise Exception("Failed to fetch existing key after IntegrityError")
|
||||
|
||||
if key_expiry_time is not None:
|
||||
existing_key.key_expiry_time = key_expiry_time
|
||||
if refund_address is not None:
|
||||
existing_key.refund_address = refund_address
|
||||
|
||||
return existing_key
|
||||
|
||||
logger.debug(
|
||||
"New key created, starting token redemption",
|
||||
extra={"key_hash": hashed_key[:8] + "..."},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"AUTH: About to call credit_balance",
|
||||
extra={"token_preview": bearer_key[:50]},
|
||||
)
|
||||
try:
|
||||
msats = await credit_balance(bearer_key, new_key, session)
|
||||
logger.info(
|
||||
"AUTH: credit_balance returned successfully", extra={"msats": msats}
|
||||
)
|
||||
except Exception as credit_error:
|
||||
logger.error(
|
||||
"AUTH: credit_balance failed",
|
||||
extra={
|
||||
"error": str(credit_error),
|
||||
"error_type": type(credit_error).__name__,
|
||||
},
|
||||
)
|
||||
raise credit_error
|
||||
|
||||
if msats <= 0:
|
||||
logger.error(
|
||||
"Token redemption returned zero or negative amount",
|
||||
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
|
||||
)
|
||||
raise Exception("Token redemption failed")
|
||||
|
||||
await session.refresh(new_key)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"New Cashu token successfully redeemed and stored",
|
||||
extra={
|
||||
"key_hash": hashed_key[:8] + "...",
|
||||
"redeemed_msats": msats,
|
||||
"final_balance": new_key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
return new_key
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Cashu token redemption failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"token_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Invalid or expired Cashu key: {str(e)}",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
logger.error(
|
||||
"Invalid API key format",
|
||||
extra={
|
||||
"key_preview": bearer_key[:10] + "..."
|
||||
if len(bearer_key) > 10
|
||||
else bearer_key,
|
||||
"key_length": len(bearer_key),
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "Invalid API key",
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def pay_for_request(
|
||||
key: ApiKey, cost_per_request: int, session: AsyncSession
|
||||
) -> int:
|
||||
"""Process payment for a request."""
|
||||
|
||||
logger.info(
|
||||
"New TemporaryCredit created from CashuToken",
|
||||
extra={"key_hash": token_hash[:8] + "...", "amount_msats": msats},
|
||||
"Processing payment for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"current_balance": key.balance,
|
||||
"required_cost": cost_per_request,
|
||||
"sufficient_balance": key.balance >= cost_per_request,
|
||||
},
|
||||
)
|
||||
|
||||
return new_credit
|
||||
if key.total_balance < cost_per_request:
|
||||
logger.warning(
|
||||
"Insufficient balance for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"balance": key.balance,
|
||||
"reserved_balance": key.reserved_balance,
|
||||
"required": cost_per_request,
|
||||
"shortfall": cost_per_request - key.total_balance,
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.total_balance} available. (reserved: {key.reserved_balance})",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def nwc_to_credit(
|
||||
connection_string: str, db_session: AsyncSession
|
||||
) -> TemporaryCredit:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
async def get_credit(
|
||||
authorization: Annotated[str, Header(...)],
|
||||
db_session: AsyncSession = Depends(get_session),
|
||||
) -> TemporaryCredit:
|
||||
authorization = (
|
||||
authorization[7:] if authorization.startswith("Bearer ") else authorization
|
||||
logger.debug(
|
||||
"Charging base cost for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"cost": cost_per_request,
|
||||
"balance_before": key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
if authorization.startswith("cashu"):
|
||||
return await cashu_token_to_credit(authorization, db_session)
|
||||
elif authorization.startswith("sk-"):
|
||||
return await api_key_to_credit(authorization, db_session)
|
||||
elif authorization.startswith("nwc"):
|
||||
return await nwc_to_credit(authorization, db_session)
|
||||
else:
|
||||
raise HTTPException(status_code=401, detail="Unable to parse bearer token")
|
||||
# Charge the base cost for the request atomically to avoid race conditions
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) + 1,
|
||||
)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Concurrent request depleted balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"required_cost": cost_per_request,
|
||||
"current_balance": key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
# Another concurrent request spent the balance first
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Payment processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost_per_request,
|
||||
"new_balance": key.balance,
|
||||
"total_spent": key.total_spent,
|
||||
"total_requests": key.total_requests,
|
||||
},
|
||||
)
|
||||
|
||||
return cost_per_request
|
||||
|
||||
|
||||
async def revert_pay_for_request(
|
||||
key: ApiKey, session: AsyncSession, cost_per_request: int
|
||||
) -> None:
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||
total_requests=col(ApiKey.total_requests) - 1,
|
||||
)
|
||||
)
|
||||
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to revert payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"cost_to_revert": cost_per_request,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"failed to revert request payment: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"type": "payment_error",
|
||||
"code": "payment_error",
|
||||
}
|
||||
},
|
||||
)
|
||||
await session.refresh(key)
|
||||
|
||||
|
||||
async def adjust_payment_for_tokens(
|
||||
key: ApiKey, response_data: dict, session: AsyncSession, deducted_max_cost: int
|
||||
) -> dict:
|
||||
"""
|
||||
Adjusts the payment based on token usage in the response.
|
||||
This is called after the initial payment and the upstream request is complete.
|
||||
Returns cost data to be included in the response.
|
||||
"""
|
||||
model = response_data.get("model", "unknown")
|
||||
|
||||
logger.debug(
|
||||
"Starting payment adjustment for tokens",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_balance": key.balance,
|
||||
"has_usage": "usage" in response_data,
|
||||
},
|
||||
)
|
||||
|
||||
match await calculate_cost(response_data, deducted_max_cost, session):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost data (no token adjustment)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"max_cost": cost.total_msats,
|
||||
},
|
||||
)
|
||||
# Finalize by releasing reservation and charging max cost
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - cost.total_msats,
|
||||
total_spent=col(ApiKey.total_spent) + cost.total_msats,
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize max-cost payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"total_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
await session.refresh(key)
|
||||
logger.info(
|
||||
"Max cost payment finalized",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost.total_msats,
|
||||
"new_balance": key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
return cost.dict()
|
||||
|
||||
case CostData() as cost:
|
||||
# If token-based pricing is enabled and base cost is 0, use token-based cost
|
||||
# Otherwise, token cost is additional to the base cost
|
||||
cost_difference = cost.total_msats - deducted_max_cost
|
||||
total_cost_msats: int = math.ceil(cost.total_msats)
|
||||
|
||||
logger.info(
|
||||
"Calculated token-based cost",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"token_cost": cost.total_msats,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"cost_difference": cost_difference,
|
||||
"input_msats": cost.input_msats,
|
||||
"output_msats": cost.output_msats,
|
||||
},
|
||||
)
|
||||
|
||||
if cost_difference == 0:
|
||||
logger.debug(
|
||||
"Finalizing with exact reserved cost",
|
||||
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
||||
)
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - total_cost_msats,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
)
|
||||
)
|
||||
await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
return cost.dict()
|
||||
|
||||
# this should never happen why do we handle this???
|
||||
if cost_difference > 0:
|
||||
# Need to charge more than reserved, finalize by releasing reservation and charging total
|
||||
logger.info(
|
||||
"Additional charge required for token usage",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"additional_charge": cost_difference,
|
||||
"current_balance": key.balance,
|
||||
"sufficient_balance": key.balance >= cost_difference,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
finalize_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - total_cost_msats,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount:
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Finalized payment with additional charge",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"charged_amount": total_cost_msats,
|
||||
"new_balance": key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to finalize additional charge (concurrent operation)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"attempted_charge": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Refund some of the base cost
|
||||
refund = abs(cost_difference)
|
||||
logger.info(
|
||||
"Refunding excess payment",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"refund_amount": refund,
|
||||
"current_balance": key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
refund_stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(ApiKey.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(ApiKey.balance) - total_cost_msats,
|
||||
total_spent=col(ApiKey.total_spent) + total_cost_msats,
|
||||
)
|
||||
)
|
||||
result = await session.exec(refund_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"total_cost": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
# Still return the cost data even if we couldn't properly finalize
|
||||
# The reservation was already made, so the user has paid
|
||||
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Refund processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"refunded_amount": refund,
|
||||
"new_balance": key.balance,
|
||||
"final_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
return cost.dict()
|
||||
|
||||
case CostDataError() as error:
|
||||
logger.error(
|
||||
"Cost calculation error during payment adjustment",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"error_message": error.message,
|
||||
"error_code": error.code,
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"message": error.message,
|
||||
"type": "invalid_request_error",
|
||||
"code": error.code,
|
||||
}
|
||||
},
|
||||
)
|
||||
# Fallback return to satisfy type checker; execution should not reach here
|
||||
return {
|
||||
"base_msats": deducted_max_cost,
|
||||
"input_msats": 0,
|
||||
"output_msats": 0,
|
||||
"total_msats": deducted_max_cost,
|
||||
}
|
||||
|
||||
+39
-25
@@ -6,12 +6,12 @@ from typing import Annotated, NoReturn
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .auth import get_credit
|
||||
from .core.db import AsyncSession, TemporaryCredit, get_session
|
||||
from .auth import validate_bearer_key
|
||||
from .core.db import ApiKey, AsyncSession, get_session
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from .payment.lnurl import send_to_lnurl
|
||||
from .payment.wallet import credit_balance, recieve_token, send_token
|
||||
from .lightning import lightning_router
|
||||
from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
|
||||
|
||||
router = APIRouter()
|
||||
balance_router = APIRouter(prefix="/v1/balance")
|
||||
@@ -19,15 +19,26 @@ balance_router = APIRouter(prefix="/v1/balance")
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def get_key_from_header(
|
||||
authorization: Annotated[str, Header(...)],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ApiKey:
|
||||
if authorization.startswith("Bearer "):
|
||||
return await validate_bearer_key(authorization[7:], session)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid authorization. Use 'Bearer <cashu-token>' or 'Bearer <api-key>'",
|
||||
)
|
||||
|
||||
|
||||
# TODO: remove this endpoint when frontend is updated
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def account_info(
|
||||
credit: TemporaryCredit = Depends(get_credit),
|
||||
) -> dict:
|
||||
async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
return {
|
||||
"api_key": credit.api_key,
|
||||
"balance": credit.balance,
|
||||
"reserved": credit.reserved_balance,
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
"reserved": key.reserved_balance,
|
||||
}
|
||||
|
||||
|
||||
@@ -47,18 +58,19 @@ async def account_info(
|
||||
async def create_balance(
|
||||
initial_balance_token: str, session: AsyncSession = Depends(get_session)
|
||||
) -> dict:
|
||||
credit = await get_credit(initial_balance_token, session)
|
||||
return {"api_key": credit.api_key, "balance": credit.balance}
|
||||
key = await validate_bearer_key(initial_balance_token, session)
|
||||
return {
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def wallet_info(
|
||||
credit: TemporaryCredit = Depends(get_credit),
|
||||
) -> dict:
|
||||
async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
||||
return {
|
||||
"api_key": credit.api_key,
|
||||
"balance": credit.balance,
|
||||
"reserved": credit.reserved_balance,
|
||||
"api_key": "sk-" + key.hashed_key,
|
||||
"balance": key.balance,
|
||||
"reserved": key.reserved_balance,
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +82,7 @@ class TopupRequest(BaseModel):
|
||||
async def topup_wallet_endpoint(
|
||||
cashu_token: str | None = None,
|
||||
topup_request: TopupRequest | None = None,
|
||||
credit: TemporaryCredit = Depends(get_credit),
|
||||
key: ApiKey = Depends(get_key_from_header),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, int]:
|
||||
if topup_request is not None:
|
||||
@@ -82,7 +94,7 @@ async def topup_wallet_endpoint(
|
||||
if len(cashu_token) < 10 or "cashu" not in cashu_token:
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
try:
|
||||
amount_msats = await credit_balance(cashu_token, credit, session)
|
||||
amount_msats = await credit_balance(cashu_token, key, session)
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
if "already spent" in error_msg.lower():
|
||||
@@ -141,11 +153,10 @@ async def refund_wallet_endpoint(
|
||||
if cached := await _refund_cache_get(bearer_value):
|
||||
return cached
|
||||
|
||||
key: TemporaryCredit = await get_credit(bearer_value, session)
|
||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||
remaining_balance_msats: int = key.balance
|
||||
refund_currency = key.refund_currency or "sat"
|
||||
|
||||
if refund_currency == "sat":
|
||||
if key.refund_currency == "sat":
|
||||
remaining_balance = remaining_balance_msats // 1000
|
||||
else:
|
||||
remaining_balance = remaining_balance_msats
|
||||
@@ -162,18 +173,19 @@ async def refund_wallet_endpoint(
|
||||
|
||||
await send_to_lnurl(
|
||||
remaining_balance,
|
||||
refund_currency,
|
||||
key.refund_currency or "sat",
|
||||
key.refund_mint_url or global_settings.primary_mint,
|
||||
key.refund_address,
|
||||
)
|
||||
result = {"recipient": key.refund_address}
|
||||
else:
|
||||
refund_currency = key.refund_currency or "sat"
|
||||
token = await send_token(
|
||||
remaining_balance, refund_currency, key.refund_mint_url
|
||||
)
|
||||
result = {"token": token}
|
||||
|
||||
if refund_currency == "sat":
|
||||
if key.refund_currency == "sat":
|
||||
result["sats"] = str(remaining_balance_msats // 1000)
|
||||
else:
|
||||
result["msats"] = str(remaining_balance_msats)
|
||||
@@ -227,6 +239,8 @@ async def wallet_catch_all(path: str) -> NoReturn:
|
||||
)
|
||||
|
||||
|
||||
balance_router.include_router(lightning_router)
|
||||
balance_router.include_router(router)
|
||||
|
||||
deprecated_wallet_router = APIRouter(prefix="/v1/wallet", include_in_schema=False)
|
||||
deprecated_wallet_router.include_router(router)
|
||||
|
||||
+281
-133
@@ -8,16 +8,16 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
|
||||
from ..models.crud import row_to_model, list_models
|
||||
from ..payment.wallet import (
|
||||
from ..payment.models import _row_to_model, list_models
|
||||
from ..proxy import refresh_model_maps, reinitialize_upstreams
|
||||
from ..wallet import (
|
||||
fetch_all_balances,
|
||||
get_proofs_per_mint_and_unit,
|
||||
get_wallet,
|
||||
send_token,
|
||||
slow_filter_spend_proofs,
|
||||
)
|
||||
from ..proxy import refresh_model_maps, reinitialize_upstreams
|
||||
from .db import ModelRow, TemporaryCredit, UpstreamProviderRow, create_session
|
||||
from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session
|
||||
from .log_manager import log_manager
|
||||
from .logging import get_logger
|
||||
from .settings import SettingsService, settings
|
||||
@@ -107,13 +107,13 @@ async def partial_balances(request: Request) -> str:
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/partials/TemporaryCredits",
|
||||
"/partials/apikeys",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
async def partial_TemporaryCredits(request: Request) -> str:
|
||||
async def partial_apikeys(request: Request) -> str:
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(TemporaryCredit))
|
||||
result = await session.exec(select(ApiKey))
|
||||
api_keys = result.all()
|
||||
|
||||
def fmt_time(ts: int | None) -> str:
|
||||
@@ -124,7 +124,7 @@ async def partial_TemporaryCredits(request: Request) -> str:
|
||||
|
||||
rows = "".join(
|
||||
[
|
||||
f"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.refund_address}</td><td>{key.refund_mint_url}</td><td>{key.refund_currency}</td><td>{fmt_time(key.refund_expiration_time)}</td></tr>"
|
||||
f"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{fmt_time(key.key_expiry_time)}</td></tr>"
|
||||
for key in api_keys
|
||||
]
|
||||
)
|
||||
@@ -134,9 +134,9 @@ async def partial_TemporaryCredits(request: Request) -> str:
|
||||
<tr>
|
||||
<th>Hashed Key</th>
|
||||
<th>Balance (mSats)</th>
|
||||
<th>Total Spent (mSats)</th>
|
||||
<th>Total Requests</th>
|
||||
<th>Refund Address</th>
|
||||
<th>Refund Mint</th>
|
||||
<th>Currency</th>
|
||||
<th>Refund Time</th>
|
||||
</tr>
|
||||
{rows}
|
||||
@@ -147,17 +147,17 @@ async def partial_TemporaryCredits(request: Request) -> str:
|
||||
@admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)])
|
||||
async def get_temporary_balances_api(request: Request) -> list[dict[str, object]]:
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(TemporaryCredit))
|
||||
result = await session.exec(select(ApiKey))
|
||||
api_keys = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"hashed_key": key.hashed_key,
|
||||
"balance": key.balance,
|
||||
"total_spent": key.total_spent,
|
||||
"total_requests": key.total_requests,
|
||||
"refund_address": key.refund_address,
|
||||
"refund_mint_url": key.refund_mint_url,
|
||||
"refund_currency": key.refund_currency,
|
||||
"refund_expiration_time": key.refund_expiration_time,
|
||||
"key_expiry_time": key.key_expiry_time,
|
||||
}
|
||||
for key in api_keys
|
||||
]
|
||||
@@ -777,8 +777,8 @@ async def dashboard(request: Request) -> str:
|
||||
<p><em>Save this token! It represents your withdrawn balance.</em></p>
|
||||
</div>
|
||||
|
||||
<div id="TemporaryCredits-table"
|
||||
hx-get="/admin/partials/TemporaryCredits"
|
||||
<div id="apikeys-table"
|
||||
hx-get="/admin/partials/apikeys"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML">
|
||||
<h2>Temporary Balances</h2>
|
||||
@@ -1493,20 +1493,8 @@ class ModelCreate(BaseModel):
|
||||
per_request_limits: dict[str, object] | None = None
|
||||
top_provider: dict[str, object] | None = None
|
||||
upstream_provider_id: int | None = None
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ModelUpdate(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
created: int
|
||||
context_length: int
|
||||
architecture: dict[str, object]
|
||||
pricing: dict[str, object]
|
||||
per_request_limits: dict[str, object] | None = None
|
||||
top_provider: dict[str, object] | None = None
|
||||
upstream_provider_id: int | None = None
|
||||
canonical_slug: str | None = None
|
||||
alias_ids: list[str] | None = None
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@@ -1763,9 +1751,9 @@ UPSTREAM_PROVIDERS_JS: str = """<!--html-->
|
||||
provider_fee: feeValue ? parseFloat(feeValue) : defaultFee,
|
||||
};
|
||||
|
||||
const TemporaryCredit = document.getElementById('provider-api-key').value;
|
||||
if (TemporaryCredit) {
|
||||
payload.api_key = TemporaryCredit;
|
||||
const apiKey = document.getElementById('provider-api-key').value;
|
||||
if (apiKey) {
|
||||
payload.api_key = apiKey;
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById('provider-save-btn');
|
||||
@@ -1783,7 +1771,7 @@ UPSTREAM_PROVIDERS_JS: str = """<!--html-->
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} else {
|
||||
if (!TemporaryCredit) {
|
||||
if (!apiKey) {
|
||||
throw new Error('API Key is required for new providers');
|
||||
}
|
||||
resp = await fetch('/admin/api/upstream-providers', {
|
||||
@@ -2416,51 +2404,101 @@ async def admin_upstream_providers(request: Request) -> str:
|
||||
"/api/upstream-providers/{provider_id}/models",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def create_provider_model(
|
||||
async def upsert_provider_model(
|
||||
provider_id: int, payload: ModelCreate
|
||||
) -> dict[str, object]:
|
||||
print(payload)
|
||||
logger.info(
|
||||
f"UPSERT_PROVIDER_MODEL called: provider_id={provider_id}, model_id={payload.id}"
|
||||
)
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
exists = await session.get(ModelRow, (payload.id, provider_id))
|
||||
if exists:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Model with this ID already exists for this provider",
|
||||
)
|
||||
# Try to get existing model
|
||||
existing_row = await session.get(ModelRow, (payload.id, provider_id))
|
||||
|
||||
row = ModelRow(
|
||||
id=payload.id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
created=int(payload.created),
|
||||
context_length=int(payload.context_length),
|
||||
architecture=json.dumps(payload.architecture),
|
||||
pricing=json.dumps(payload.pricing),
|
||||
sats_pricing=None,
|
||||
per_request_limits=(
|
||||
if existing_row:
|
||||
# Update existing model
|
||||
logger.info(f"Updating existing model: {payload.id}")
|
||||
existing_row.name = payload.name
|
||||
existing_row.description = payload.description
|
||||
existing_row.created = int(payload.created)
|
||||
existing_row.context_length = int(payload.context_length)
|
||||
existing_row.architecture = json.dumps(payload.architecture)
|
||||
existing_row.pricing = json.dumps(payload.pricing)
|
||||
existing_row.sats_pricing = None
|
||||
existing_row.per_request_limits = (
|
||||
json.dumps(payload.per_request_limits)
|
||||
if payload.per_request_limits is not None
|
||||
else None
|
||||
),
|
||||
top_provider=(
|
||||
)
|
||||
existing_row.top_provider = (
|
||||
json.dumps(payload.top_provider) if payload.top_provider else None
|
||||
),
|
||||
upstream_provider_id=provider_id,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
)
|
||||
existing_row.canonical_slug = payload.canonical_slug
|
||||
existing_row.alias_ids = (
|
||||
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
||||
)
|
||||
existing_row.enabled = payload.enabled
|
||||
|
||||
session.add(existing_row)
|
||||
await session.commit()
|
||||
await session.refresh(existing_row)
|
||||
row = existing_row
|
||||
|
||||
else:
|
||||
# Create new model
|
||||
logger.info(f"Creating new model: {payload.id}")
|
||||
row = ModelRow(
|
||||
id=payload.id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
created=int(payload.created),
|
||||
context_length=int(payload.context_length),
|
||||
architecture=json.dumps(payload.architecture),
|
||||
pricing=json.dumps(payload.pricing),
|
||||
sats_pricing=None,
|
||||
per_request_limits=(
|
||||
json.dumps(payload.per_request_limits)
|
||||
if payload.per_request_limits is not None
|
||||
else None
|
||||
),
|
||||
top_provider=(
|
||||
json.dumps(payload.top_provider) if payload.top_provider else None
|
||||
),
|
||||
canonical_slug=payload.canonical_slug,
|
||||
alias_ids=(
|
||||
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
||||
),
|
||||
upstream_provider_id=provider_id,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
|
||||
await refresh_model_maps()
|
||||
return row_to_model(
|
||||
return _row_to_model(
|
||||
row, apply_provider_fee=True, provider_fee=provider.provider_fee
|
||||
).dict() # type: ignore
|
||||
|
||||
|
||||
@admin_router.patch(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def update_provider_model_legacy(
|
||||
provider_id: int, model_id: str, payload: ModelCreate
|
||||
) -> dict[str, object]:
|
||||
"""Legacy PATCH endpoint - redirects to upsert POST endpoint for backward compatibility."""
|
||||
logger.info(
|
||||
f"LEGACY_PATCH_UPDATE called: provider_id={provider_id}, model_id={model_id}"
|
||||
)
|
||||
return await upsert_provider_model(provider_id, payload)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
@@ -2476,81 +2514,11 @@ async def get_provider_model(provider_id: int, model_id: str) -> dict[str, objec
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Model not found for this provider"
|
||||
)
|
||||
return row_to_model(
|
||||
return _row_to_model(
|
||||
row, apply_provider_fee=True, provider_fee=provider.provider_fee
|
||||
).dict() # type: ignore
|
||||
|
||||
|
||||
@admin_router.patch(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def update_provider_model(
|
||||
provider_id: int, model_id: str, payload: ModelUpdate
|
||||
) -> dict[str, object]:
|
||||
if payload.id != model_id:
|
||||
raise HTTPException(status_code=400, detail="Path id does not match payload id")
|
||||
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
row = await session.get(ModelRow, (model_id, provider_id))
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Model not found for this provider"
|
||||
)
|
||||
|
||||
row.name = payload.name
|
||||
row.description = payload.description
|
||||
row.created = int(payload.created)
|
||||
row.context_length = int(payload.context_length)
|
||||
row.architecture = json.dumps(payload.architecture)
|
||||
row.pricing = json.dumps(payload.pricing)
|
||||
row.sats_pricing = None
|
||||
row.per_request_limits = (
|
||||
json.dumps(payload.per_request_limits)
|
||||
if payload.per_request_limits is not None
|
||||
else None
|
||||
)
|
||||
row.top_provider = (
|
||||
json.dumps(payload.top_provider) if payload.top_provider else None
|
||||
)
|
||||
was_disabled = not row.enabled
|
||||
row.enabled = payload.enabled
|
||||
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
|
||||
if was_disabled and payload.enabled:
|
||||
from ..models.models import _cleanup_enabled_models_once
|
||||
|
||||
try:
|
||||
await _cleanup_enabled_models_once()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to run model cleanup after enabling: {e}",
|
||||
extra={"model_id": model_id, "error": str(e)},
|
||||
)
|
||||
|
||||
await refresh_model_maps()
|
||||
return row_to_model(
|
||||
row, apply_provider_fee=True, provider_fee=provider.provider_fee
|
||||
).dict() # type: ignore
|
||||
|
||||
|
||||
@admin_router.put(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def update_provider_model_put(
|
||||
provider_id: int, model_id: str, payload: ModelUpdate
|
||||
) -> dict[str, object]:
|
||||
return await update_provider_model(provider_id, model_id, payload)
|
||||
|
||||
|
||||
@admin_router.delete(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
@@ -2791,12 +2759,192 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
class CreateAccountRequest(BaseModel):
|
||||
provider_type: str
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/create-account",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def create_provider_account_by_type(
|
||||
payload: CreateAccountRequest,
|
||||
) -> dict[str, object]:
|
||||
"""Create a new account with a provider by provider type (before provider exists in DB)."""
|
||||
from ..upstream import upstream_provider_classes
|
||||
|
||||
provider_class = next(
|
||||
(
|
||||
cls
|
||||
for cls in upstream_provider_classes
|
||||
if cls.provider_type == payload.provider_type
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not provider_class:
|
||||
raise HTTPException(status_code=404, detail="Provider type not found")
|
||||
|
||||
try:
|
||||
account_data = await provider_class.create_account_static()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"account_data": account_data,
|
||||
"message": "Account created successfully",
|
||||
}
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider does not support account creation: {str(e)}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to create account for provider type {payload.provider_type}: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
class TopupRequest(BaseModel):
|
||||
amount: int
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/{provider_id}/topup",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def initiate_provider_topup(
|
||||
provider_id: int, payload: TopupRequest
|
||||
) -> dict[str, object]:
|
||||
"""Initiate a Lightning Network top-up for the upstream provider account."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Initiating top-up for provider {provider_id}",
|
||||
extra={"amount": payload.amount},
|
||||
)
|
||||
topup_data = await upstream_instance.initiate_topup(payload.amount)
|
||||
logger.info(
|
||||
"Top-up initiated successfully",
|
||||
extra={
|
||||
"provider_id": provider_id,
|
||||
"invoice_id": topup_data.invoice_id,
|
||||
"amount": topup_data.amount,
|
||||
},
|
||||
)
|
||||
|
||||
response_data = {
|
||||
"ok": True,
|
||||
"topup_data": {
|
||||
"invoice_id": topup_data.invoice_id,
|
||||
"payment_request": topup_data.payment_request,
|
||||
"amount": topup_data.amount,
|
||||
"currency": topup_data.currency,
|
||||
"expires_at": topup_data.expires_at,
|
||||
"checkout_url": topup_data.checkout_url,
|
||||
},
|
||||
"message": "Top-up initiated successfully",
|
||||
}
|
||||
logger.info("Returning response", extra={"response": response_data})
|
||||
return response_data
|
||||
except NotImplementedError as e:
|
||||
logger.error(f"Provider does not support top-up: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Provider does not support top-up: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to initiate top-up for provider {provider_id}: {e}",
|
||||
extra={"error_type": type(e).__name__, "error": str(e)},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/upstream-providers/{provider_id}/topup/{invoice_id}/status",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, object]:
|
||||
"""Check the status of a Lightning Network top-up invoice."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
from ..upstream.ppqai import PPQAIUpstreamProvider
|
||||
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
if not isinstance(upstream_instance, PPQAIUpstreamProvider):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Provider does not support top-up status checking",
|
||||
)
|
||||
|
||||
try:
|
||||
paid = await upstream_instance.check_topup_status(invoice_id)
|
||||
return {"ok": True, "paid": paid}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to check top-up status for provider {provider_id}: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/upstream-providers/{provider_id}/balance",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def get_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
"""Get the current account balance for the upstream provider."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
try:
|
||||
balance_data = await upstream_instance.get_balance()
|
||||
return {"ok": True, "balance_data": balance_data}
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider does not support balance checking: {str(e)}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch balance for provider {provider_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/openrouter-presets",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def get_openrouter_presets() -> list[dict[str, object]]:
|
||||
from ..models import async_fetch_openrouter_models
|
||||
from ..payment.models import async_fetch_openrouter_models
|
||||
|
||||
models_data = await async_fetch_openrouter_models()
|
||||
return models_data
|
||||
|
||||
+51
-30
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from alembic import command
|
||||
@@ -19,39 +19,38 @@ DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///keys.db")
|
||||
engine = create_async_engine(DATABASE_URL, echo=False) # echo=True for debugging SQL
|
||||
|
||||
|
||||
class TemporaryCredit(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "temporary_credit"
|
||||
class ApiKey(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
hashed_key: str = Field(primary_key=True)
|
||||
balance: int = Field(default=0, description="Balance in msats")
|
||||
reserved_balance: int = Field(default=0, description="Blocked balance in msats")
|
||||
created: datetime | None = Field(None, description="Timestamp of creation")
|
||||
balance: int = Field(default=0, description="Balance in millisatoshis (msats)")
|
||||
reserved_balance: int = Field(
|
||||
default=0, description="Reserved balance in millisatoshis (msats)"
|
||||
)
|
||||
refund_address: str | None = Field(
|
||||
None, description="Address to refund on expiration"
|
||||
default=None,
|
||||
description="Lightning address to refund remaining balance after key expires",
|
||||
)
|
||||
key_expiry_time: int | None = Field(
|
||||
default=None,
|
||||
description="Unix-timestamp after which the cashu-token's balance gets refunded to the refund_address",
|
||||
)
|
||||
total_spent: int = Field(
|
||||
default=0, description="Total spent in millisatoshis (msats)"
|
||||
)
|
||||
total_requests: int = Field(default=0)
|
||||
refund_mint_url: str | None = Field(
|
||||
default=None, description="Mint used to issue the refund token"
|
||||
default=None,
|
||||
description="URL of the mint used to create the cashu-token",
|
||||
)
|
||||
refund_currency: str | None = Field(None, description="Currency of the cashu-token")
|
||||
refund_expiration_time: int | None = Field(
|
||||
None, description="Refund not allowed after timeout"
|
||||
refund_currency: str | None = Field(
|
||||
default=None,
|
||||
description="Currency of the cashu-token",
|
||||
)
|
||||
|
||||
@property
|
||||
def total_balance_msat(self) -> int:
|
||||
return self.balance - self.reserved_balance
|
||||
|
||||
@property
|
||||
def total_balance_sat(self) -> int:
|
||||
return self.total_balance_msat // 1000
|
||||
|
||||
@property
|
||||
def total_balance(self) -> int:
|
||||
return self.total_balance_msat
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
return "sk-" + self.hashed_key
|
||||
return self.balance - self.reserved_balance
|
||||
|
||||
|
||||
class ModelRow(SQLModel, table=True): # type: ignore
|
||||
@@ -69,10 +68,36 @@ class ModelRow(SQLModel, table=True): # type: ignore
|
||||
sats_pricing: str | None = Field(default=None)
|
||||
per_request_limits: str | None = Field(default=None)
|
||||
top_provider: str | None = Field(default=None)
|
||||
canonical_slug: str | None = Field(default=None, description="Canonical model slug")
|
||||
alias_ids: str | None = Field(
|
||||
default=None, description="JSON array of model alias IDs"
|
||||
)
|
||||
enabled: bool = Field(default=True, description="Whether this model is enabled")
|
||||
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
|
||||
|
||||
|
||||
class LightningInvoice(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "lightning_invoices"
|
||||
|
||||
id: str = Field(primary_key=True, description="Unique invoice identifier")
|
||||
bolt11: str = Field(description="BOLT11 invoice string", unique=True)
|
||||
amount_sats: int = Field(description="Amount in satoshis")
|
||||
description: str = Field(description="Invoice description")
|
||||
payment_hash: str = Field(description="Payment hash for tracking", unique=True)
|
||||
status: str = Field(
|
||||
default="pending", description="pending, paid, expired, cancelled"
|
||||
)
|
||||
api_key_hash: str | None = Field(
|
||||
default=None, description="Associated API key hash for topup operations"
|
||||
)
|
||||
purpose: str = Field(description="create or topup")
|
||||
created_at: int = Field(
|
||||
default_factory=lambda: int(time.time()), description="Unix timestamp"
|
||||
)
|
||||
expires_at: int = Field(description="Unix timestamp when invoice expires")
|
||||
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
|
||||
|
||||
|
||||
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
__tablename__ = "upstream_providers"
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
@@ -97,9 +122,8 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||
async def balances_for_mint_and_unit(
|
||||
db_session: AsyncSession, mint_url: str, unit: str
|
||||
) -> int:
|
||||
query = select(func.sum(TemporaryCredit.balance)).where(
|
||||
TemporaryCredit.refund_mint_url == mint_url,
|
||||
TemporaryCredit.refund_currency == unit,
|
||||
query = select(func.sum(ApiKey.balance)).where(
|
||||
ApiKey.refund_mint_url == mint_url, ApiKey.refund_currency == unit
|
||||
)
|
||||
result = await db_session.exec(query)
|
||||
return result.one() or 0
|
||||
@@ -129,8 +153,6 @@ def run_migrations() -> None:
|
||||
import pathlib
|
||||
|
||||
try:
|
||||
logger.info("Starting database migrations")
|
||||
|
||||
# Get the path to the alembic.ini file
|
||||
project_root = pathlib.Path(__file__).resolve().parents[2]
|
||||
alembic_ini_path = project_root / "alembic.ini"
|
||||
@@ -147,7 +169,6 @@ def run_migrations() -> None:
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
# Run migrations to the latest revision
|
||||
logger.info("Running migrations to latest revision")
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
logger.info("Database migrations completed successfully")
|
||||
|
||||
+21
-8
@@ -192,21 +192,24 @@ class SecurityFilter(logging.Filter):
|
||||
"""Filter out sensitive information from log records."""
|
||||
try:
|
||||
message = record.getMessage()
|
||||
standalone_patterns = [
|
||||
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
|
||||
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
|
||||
r"nsec[a-z0-9]+", # Nostr Public / Private Key
|
||||
]
|
||||
for pattern in standalone_patterns:
|
||||
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
|
||||
|
||||
for key in self.SENSITIVE_KEYS:
|
||||
if key in message.lower():
|
||||
patterns = [
|
||||
rf"{key}[:\s=]+([a-zA-Z0-9_\-\.]+)", # key: value or key=value
|
||||
rf'{key}[:\s=]+["\']([^"\']+)["\']', # key: "value" or key='value'
|
||||
r"Bearer\s+([a-zA-Z0-9_\-\.]+)", # Bearer token
|
||||
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
|
||||
key_patterns = [
|
||||
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
|
||||
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
for pattern in key_patterns:
|
||||
message = re.sub(
|
||||
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
record.msg = message
|
||||
record.args = ()
|
||||
|
||||
@@ -335,6 +338,11 @@ def setup_logging() -> None:
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
"openai": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
"httpcore": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
@@ -357,6 +365,11 @@ def setup_logging() -> None:
|
||||
},
|
||||
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
|
||||
"aiosqlite": {"level": "ERROR", "handlers": [], "propagate": False},
|
||||
"alembic": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"level": log_level,
|
||||
|
||||
+269
-7
@@ -1,21 +1,32 @@
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from ..balance import balance_router, deprecated_wallet_router
|
||||
from ..models.models import models_router
|
||||
from ..nostr.discovery import providers_router
|
||||
from ..proxy import proxy_router
|
||||
from ..discovery import providers_cache_refresher, providers_router
|
||||
from ..nip91 import announce_provider
|
||||
from ..payment.models import (
|
||||
models_router,
|
||||
update_sats_pricing,
|
||||
)
|
||||
from ..payment.price import update_prices_periodically
|
||||
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
||||
from ..wallet import periodic_payout
|
||||
from .admin import admin_router
|
||||
from .db import create_session, init_db, run_migrations
|
||||
from .exceptions import general_exception_handler, http_exception_handler
|
||||
from .logging import get_logger, setup_logging
|
||||
from .middleware import LoggingMiddleware
|
||||
from .settings import SettingsService
|
||||
from .settings import settings as global_settings
|
||||
from .tasks import lifespan
|
||||
from .ui import setup_ui_routes
|
||||
|
||||
# Initialize logging first
|
||||
setup_logging()
|
||||
@@ -27,6 +38,120 @@ else:
|
||||
__version__ = "0.2.1"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logger.info("Application startup initiated", extra={"version": __version__})
|
||||
|
||||
btc_price_task = None
|
||||
pricing_task = None
|
||||
payout_task = None
|
||||
nip91_task = None
|
||||
providers_task = None
|
||||
models_refresh_task = None
|
||||
model_maps_refresh_task = None
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
run_migrations()
|
||||
|
||||
# Initialize database connection pools
|
||||
# This creates any tables that might not be tracked by migrations yet
|
||||
await init_db()
|
||||
|
||||
# Initialize application settings (env -> computed -> DB precedence)
|
||||
async with create_session() as session:
|
||||
s = await SettingsService.initialize(session)
|
||||
|
||||
# Apply app metadata from settings
|
||||
try:
|
||||
app.title = s.name
|
||||
app.description = s.description
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# await ensure_models_bootstrapped()
|
||||
|
||||
from ..payment.price import _update_prices
|
||||
from ..proxy import get_upstreams
|
||||
from ..upstream.helpers import refresh_upstreams_models_periodically
|
||||
|
||||
_update_prices_task = asyncio.create_task(_update_prices())
|
||||
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
|
||||
|
||||
# ensure both setup tasks complete
|
||||
await asyncio.gather(
|
||||
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
|
||||
)
|
||||
|
||||
btc_price_task = asyncio.create_task(update_prices_periodically())
|
||||
pricing_task = asyncio.create_task(update_sats_pricing())
|
||||
if global_settings.models_refresh_interval_seconds > 0:
|
||||
models_refresh_task = asyncio.create_task(
|
||||
refresh_upstreams_models_periodically(get_upstreams())
|
||||
)
|
||||
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
|
||||
payout_task = asyncio.create_task(periodic_payout())
|
||||
if global_settings.nsec:
|
||||
nip91_task = asyncio.create_task(announce_provider())
|
||||
if global_settings.providers_refresh_interval_seconds > 0:
|
||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||
|
||||
yield
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Expected during shutdown
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Application startup failed",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
logger.info("Application shutdown initiated")
|
||||
|
||||
if btc_price_task is not None:
|
||||
btc_price_task.cancel()
|
||||
if pricing_task is not None:
|
||||
pricing_task.cancel()
|
||||
if payout_task is not None:
|
||||
payout_task.cancel()
|
||||
if nip91_task is not None:
|
||||
nip91_task.cancel()
|
||||
if providers_task is not None:
|
||||
providers_task.cancel()
|
||||
if models_refresh_task is not None:
|
||||
models_refresh_task.cancel()
|
||||
if model_maps_refresh_task is not None:
|
||||
model_maps_refresh_task.cancel()
|
||||
|
||||
try:
|
||||
tasks_to_wait = []
|
||||
if btc_price_task is not None:
|
||||
tasks_to_wait.append(btc_price_task)
|
||||
if pricing_task is not None:
|
||||
tasks_to_wait.append(pricing_task)
|
||||
if payout_task is not None:
|
||||
tasks_to_wait.append(payout_task)
|
||||
if nip91_task is not None:
|
||||
tasks_to_wait.append(nip91_task)
|
||||
if providers_task is not None:
|
||||
tasks_to_wait.append(providers_task)
|
||||
if models_refresh_task is not None:
|
||||
tasks_to_wait.append(models_refresh_task)
|
||||
if model_maps_refresh_task is not None:
|
||||
tasks_to_wait.append(model_maps_refresh_task)
|
||||
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
logger.info("Background tasks stopped successfully")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error stopping background tasks",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
|
||||
app = FastAPI(version=__version__, lifespan=lifespan)
|
||||
|
||||
|
||||
@@ -65,7 +190,144 @@ async def providers() -> RedirectResponse:
|
||||
return RedirectResponse("/v1/providers/")
|
||||
|
||||
|
||||
setup_ui_routes(app)
|
||||
UI_DIST_PATH = Path(__file__).parent.parent.parent / "ui_out"
|
||||
|
||||
if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
|
||||
logger.info(f"Serving static UI from {UI_DIST_PATH}")
|
||||
|
||||
app.mount(
|
||||
"/_next",
|
||||
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
|
||||
name="next-static",
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def serve_root_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
# Add explicit route for /index.txt to redirect to /
|
||||
@app.get("/index.txt", include_in_schema=False)
|
||||
async def redirect_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/")
|
||||
|
||||
@app.get("/admin")
|
||||
async def admin_redirect() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@app.get("/dashboard", include_in_schema=False)
|
||||
async def serve_dashboard_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@app.get("/login", include_in_schema=False)
|
||||
async def serve_login_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "login" / "index.html")
|
||||
|
||||
# Add explicit route for /login/index.txt to redirect to /login
|
||||
@app.get("/login/index.txt", include_in_schema=False)
|
||||
async def redirect_login_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/login")
|
||||
|
||||
@app.get("/model", include_in_schema=False)
|
||||
async def serve_models_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "model" / "index.html")
|
||||
|
||||
# Add explicit route for /model/index.txt to redirect to /model
|
||||
@app.get("/model/index.txt", include_in_schema=False)
|
||||
async def redirect_model_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/model")
|
||||
|
||||
@app.get("/providers", include_in_schema=False)
|
||||
async def serve_providers_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
|
||||
|
||||
# Add explicit route for /providers/index.txt to redirect to /providers
|
||||
@app.get("/providers/index.txt", include_in_schema=False)
|
||||
async def redirect_providers_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/providers")
|
||||
|
||||
@app.get("/settings", include_in_schema=False)
|
||||
async def serve_settings_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
|
||||
|
||||
# Add explicit route for /settings/index.txt to redirect to /settings
|
||||
@app.get("/settings/index.txt", include_in_schema=False)
|
||||
async def redirect_settings_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/settings")
|
||||
|
||||
@app.get("/transactions", include_in_schema=False)
|
||||
async def serve_transactions_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
|
||||
|
||||
# Add explicit route for /transactions/index.txt to redirect to /transactions
|
||||
@app.get("/transactions/index.txt", include_in_schema=False)
|
||||
async def redirect_transactions_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/transactions")
|
||||
|
||||
@app.get("/balances", include_in_schema=False)
|
||||
async def serve_balances_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "balances" / "index.html")
|
||||
|
||||
# Add explicit route for /balances/index.txt to redirect to /balances
|
||||
@app.get("/balances/index.txt", include_in_schema=False)
|
||||
async def redirect_balances_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/balances")
|
||||
|
||||
@app.get("/logs", include_in_schema=False)
|
||||
async def serve_logs_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "logs" / "index.html")
|
||||
|
||||
# Add explicit route for /logs/index.txt to redirect to /logs
|
||||
@app.get("/logs/index.txt", include_in_schema=False)
|
||||
async def redirect_logs_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/logs")
|
||||
|
||||
@app.get("/usage", include_in_schema=False)
|
||||
async def serve_usage_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
|
||||
|
||||
# Add explicit route for /usage/index.txt to redirect to /usage
|
||||
@app.get("/usage/index.txt", include_in_schema=False)
|
||||
async def redirect_usage_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/usage")
|
||||
|
||||
@app.get("/unauthorized", include_in_schema=False)
|
||||
async def serve_unauthorized_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
|
||||
|
||||
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
|
||||
@app.get("/unauthorized/index.txt", include_in_schema=False)
|
||||
async def redirect_unauthorized_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/unauthorized")
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def serve_favicon() -> FileResponse:
|
||||
icon_path = UI_DIST_PATH / "icon.ico"
|
||||
if icon_path.exists():
|
||||
return FileResponse(icon_path)
|
||||
return FileResponse(UI_DIST_PATH / "favicon.ico")
|
||||
|
||||
@app.get("/icon.ico", include_in_schema=False)
|
||||
async def serve_icon() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "icon.ico")
|
||||
|
||||
app.mount(
|
||||
"/static", StaticFiles(directory=UI_DIST_PATH, check_dir=True), name="ui-static"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root_fallback() -> dict:
|
||||
return {
|
||||
"name": global_settings.name,
|
||||
"description": global_settings.description,
|
||||
"version": __version__,
|
||||
"status": "running",
|
||||
"ui": "not available",
|
||||
}
|
||||
|
||||
|
||||
app.include_router(models_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
@@ -55,7 +55,16 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
"headers": {
|
||||
k: v
|
||||
for k, v in request.headers.items()
|
||||
if k.lower() not in ["authorization", "x-cashu", "cookie"]
|
||||
if k.lower()
|
||||
not in [
|
||||
"authorization",
|
||||
"x-cashu",
|
||||
"cookie",
|
||||
"cf-connecting-ip",
|
||||
"cf-ipcountry",
|
||||
"x-forwarded-for",
|
||||
"x-real-ip",
|
||||
]
|
||||
},
|
||||
"body_size": len(request_body) if request_body else 0,
|
||||
},
|
||||
|
||||
@@ -139,7 +139,7 @@ def resolve_bootstrap() -> Settings:
|
||||
pass
|
||||
if not base.onion_url:
|
||||
try:
|
||||
from ..nostr.listing import discover_onion_url_from_tor
|
||||
from ..nip91 import discover_onion_url_from_tor # type: ignore
|
||||
|
||||
discovered = discover_onion_url_from_tor()
|
||||
if discovered:
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from ..models.models import (
|
||||
cleanup_enabled_models_periodically,
|
||||
update_sats_pricing,
|
||||
)
|
||||
from ..nostr import announce_provider, providers_cache_refresher
|
||||
from ..payment.price import update_prices_periodically
|
||||
from ..payment.wallet import periodic_payout
|
||||
from ..proxy import get_upstreams, initialize_upstreams, refresh_model_maps_periodically
|
||||
from ..upstream.helpers import refresh_upstreams_models_periodically
|
||||
from .db import create_session, init_db, run_migrations
|
||||
from .logging import get_logger
|
||||
from .settings import SettingsService
|
||||
from .settings import settings as global_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# Extract version from app if available, or use default/log without it
|
||||
version = getattr(app, "version", "unknown")
|
||||
|
||||
logger.info("Application startup initiated", extra={"version": version})
|
||||
|
||||
btc_price_task = None
|
||||
pricing_task = None
|
||||
payout_task = None
|
||||
listing_task = None
|
||||
providers_task = None
|
||||
models_refresh_task = None
|
||||
models_cleanup_task = None
|
||||
model_maps_refresh_task = None
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
# This ensures the database schema is always up-to-date in production
|
||||
# Migrations are idempotent - running them multiple times is safe
|
||||
logger.info("Running database migrations")
|
||||
run_migrations()
|
||||
|
||||
# Initialize database connection pools
|
||||
# This creates any tables that might not be tracked by migrations yet
|
||||
await init_db()
|
||||
|
||||
# Initialize application settings (env -> computed -> DB precedence)
|
||||
async with create_session() as session:
|
||||
s = await SettingsService.initialize(session)
|
||||
|
||||
# Apply app metadata from settings
|
||||
try:
|
||||
app.title = s.name
|
||||
app.description = s.description
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
|
||||
|
||||
btc_price_task = asyncio.create_task(update_prices_periodically())
|
||||
pricing_task = asyncio.create_task(update_sats_pricing())
|
||||
if global_settings.models_refresh_interval_seconds > 0:
|
||||
models_refresh_task = asyncio.create_task(
|
||||
refresh_upstreams_models_periodically(get_upstreams())
|
||||
)
|
||||
models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically())
|
||||
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
|
||||
payout_task = asyncio.create_task(periodic_payout())
|
||||
listing_task = asyncio.create_task(announce_provider())
|
||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||
|
||||
await _initialize_upstreams_task
|
||||
|
||||
yield
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Application startup failed",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
logger.info("Application shutdown initiated")
|
||||
|
||||
if btc_price_task is not None:
|
||||
btc_price_task.cancel()
|
||||
if pricing_task is not None:
|
||||
pricing_task.cancel()
|
||||
if payout_task is not None:
|
||||
payout_task.cancel()
|
||||
if listing_task is not None:
|
||||
listing_task.cancel()
|
||||
if providers_task is not None:
|
||||
providers_task.cancel()
|
||||
if models_refresh_task is not None:
|
||||
models_refresh_task.cancel()
|
||||
if models_cleanup_task is not None:
|
||||
models_cleanup_task.cancel()
|
||||
if model_maps_refresh_task is not None:
|
||||
model_maps_refresh_task.cancel()
|
||||
|
||||
try:
|
||||
tasks_to_wait = []
|
||||
if btc_price_task is not None:
|
||||
tasks_to_wait.append(btc_price_task)
|
||||
if pricing_task is not None:
|
||||
tasks_to_wait.append(pricing_task)
|
||||
if payout_task is not None:
|
||||
tasks_to_wait.append(payout_task)
|
||||
if listing_task is not None:
|
||||
tasks_to_wait.append(listing_task)
|
||||
if providers_task is not None:
|
||||
tasks_to_wait.append(providers_task)
|
||||
if models_refresh_task is not None:
|
||||
tasks_to_wait.append(models_refresh_task)
|
||||
if models_cleanup_task is not None:
|
||||
tasks_to_wait.append(models_cleanup_task)
|
||||
if model_maps_refresh_task is not None:
|
||||
tasks_to_wait.append(model_maps_refresh_task)
|
||||
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
logger.info("Background tasks stopped successfully")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error stopping background tasks",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
@@ -1,160 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .logging import get_logger
|
||||
from .settings import settings as global_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def setup_ui_routes(app: FastAPI) -> None:
|
||||
UI_DIST_PATH = Path(__file__).parent.parent.parent / "ui_out"
|
||||
|
||||
if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
|
||||
logger.info(f"Serving static UI from {UI_DIST_PATH}")
|
||||
|
||||
app.mount(
|
||||
"/_next",
|
||||
StaticFiles(directory=UI_DIST_PATH / "_next", check_dir=True),
|
||||
name="next-static",
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def serve_root_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
# Add explicit route for /index.txt to redirect to /
|
||||
@router.get("/index.txt", include_in_schema=False)
|
||||
async def redirect_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/")
|
||||
|
||||
@router.get("/admin")
|
||||
async def admin_redirect() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@router.get("/dashboard", include_in_schema=False)
|
||||
async def serve_dashboard_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "index.html")
|
||||
|
||||
@router.get("/login", include_in_schema=False)
|
||||
async def serve_login_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "login" / "index.html")
|
||||
|
||||
# Add explicit route for /login/index.txt to redirect to /login
|
||||
@router.get("/login/index.txt", include_in_schema=False)
|
||||
async def redirect_login_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/login")
|
||||
|
||||
@router.get("/model", include_in_schema=False)
|
||||
async def serve_models_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "model" / "index.html")
|
||||
|
||||
# Add explicit route for /model/index.txt to redirect to /model
|
||||
@router.get("/model/index.txt", include_in_schema=False)
|
||||
async def redirect_model_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/model")
|
||||
|
||||
@router.get("/providers", include_in_schema=False)
|
||||
async def serve_providers_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "providers" / "index.html")
|
||||
|
||||
# Add explicit route for /providers/index.txt to redirect to /providers
|
||||
@router.get("/providers/index.txt", include_in_schema=False)
|
||||
async def redirect_providers_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/providers")
|
||||
|
||||
@router.get("/settings", include_in_schema=False)
|
||||
async def serve_settings_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "settings" / "index.html")
|
||||
|
||||
# Add explicit route for /settings/index.txt to redirect to /settings
|
||||
@router.get("/settings/index.txt", include_in_schema=False)
|
||||
async def redirect_settings_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/settings")
|
||||
|
||||
@router.get("/transactions", include_in_schema=False)
|
||||
async def serve_transactions_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "transactions" / "index.html")
|
||||
|
||||
# Add explicit route for /transactions/index.txt to redirect to /transactions
|
||||
@router.get("/transactions/index.txt", include_in_schema=False)
|
||||
async def redirect_transactions_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/transactions")
|
||||
|
||||
@router.get("/balances", include_in_schema=False)
|
||||
async def serve_balances_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "balances" / "index.html")
|
||||
|
||||
# Add explicit route for /balances/index.txt to redirect to /balances
|
||||
@router.get("/balances/index.txt", include_in_schema=False)
|
||||
async def redirect_balances_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/balances")
|
||||
|
||||
@router.get("/logs", include_in_schema=False)
|
||||
async def serve_logs_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "logs" / "index.html")
|
||||
|
||||
# Add explicit route for /logs/index.txt to redirect to /logs
|
||||
@router.get("/logs/index.txt", include_in_schema=False)
|
||||
async def redirect_logs_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/logs")
|
||||
|
||||
@router.get("/usage", include_in_schema=False)
|
||||
async def serve_usage_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
|
||||
|
||||
# Add explicit route for /usage/index.txt to redirect to /usage
|
||||
@router.get("/usage/index.txt", include_in_schema=False)
|
||||
async def redirect_usage_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/usage")
|
||||
|
||||
@router.get("/unauthorized", include_in_schema=False)
|
||||
async def serve_unauthorized_ui() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
|
||||
|
||||
# Add explicit route for /unauthorized/index.txt to redirect to /unauthorized
|
||||
@router.get("/unauthorized/index.txt", include_in_schema=False)
|
||||
async def redirect_unauthorized_index_txt() -> RedirectResponse:
|
||||
return RedirectResponse("/unauthorized")
|
||||
|
||||
@router.get("/favicon.ico", include_in_schema=False)
|
||||
async def serve_favicon() -> FileResponse:
|
||||
icon_path = UI_DIST_PATH / "icon.ico"
|
||||
if icon_path.exists():
|
||||
return FileResponse(icon_path)
|
||||
return FileResponse(UI_DIST_PATH / "favicon.ico")
|
||||
|
||||
@router.get("/icon.ico", include_in_schema=False)
|
||||
async def serve_icon() -> FileResponse:
|
||||
return FileResponse(UI_DIST_PATH / "icon.ico")
|
||||
|
||||
app.include_router(router)
|
||||
|
||||
app.mount(
|
||||
"/static",
|
||||
StaticFiles(directory=UI_DIST_PATH, check_dir=True),
|
||||
name="ui-static",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"UI dist directory not found at {UI_DIST_PATH}, skipping static file serving"
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def root_fallback() -> dict:
|
||||
return {
|
||||
"name": global_settings.name,
|
||||
"description": global_settings.description,
|
||||
"version": app.version,
|
||||
"status": "running",
|
||||
"ui": "not available",
|
||||
}
|
||||
|
||||
app.include_router(router)
|
||||
@@ -8,8 +8,8 @@ import httpx
|
||||
import websockets
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..core.logging import get_logger
|
||||
from ..core.settings import settings
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -62,7 +62,9 @@ async def query_nostr_relay_for_providers(
|
||||
|
||||
if data[0] == "EVENT" and data[1] == sub_id:
|
||||
event = data[2]
|
||||
logger.debug(f"Found provider announcement: {event['id']}")
|
||||
logger.debug(
|
||||
f"Found provider announcement: {event['id'][:6]}...{event['id'][-6:]}"
|
||||
)
|
||||
events.append(event)
|
||||
elif data[0] == "EOSE" and data[1] == sub_id:
|
||||
logger.debug("Received EOSE message")
|
||||
@@ -0,0 +1,270 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .core.db import ApiKey, LightningInvoice, get_session
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from .wallet import get_wallet
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
lightning_router = APIRouter(prefix="/lightning")
|
||||
|
||||
|
||||
class InvoiceCreateRequest(BaseModel):
|
||||
amount_sats: int = Field(gt=0, le=1_000_000, description="Amount in satoshis")
|
||||
purpose: str = Field(description="create or topup", pattern="^(create|topup)$")
|
||||
api_key: str | None = Field(
|
||||
default=None, description="Required for topup operations"
|
||||
)
|
||||
|
||||
|
||||
class InvoiceCreateResponse(BaseModel):
|
||||
invoice_id: str
|
||||
bolt11: str
|
||||
amount_sats: int
|
||||
expires_at: int
|
||||
payment_hash: str
|
||||
|
||||
|
||||
class InvoiceStatusResponse(BaseModel):
|
||||
status: str
|
||||
api_key: str | None = None
|
||||
amount_sats: int
|
||||
paid_at: int | None = None
|
||||
created_at: int
|
||||
expires_at: int
|
||||
|
||||
|
||||
class InvoiceRecoverRequest(BaseModel):
|
||||
bolt11: str = Field(description="BOLT11 invoice string")
|
||||
|
||||
|
||||
async def generate_lightning_invoice(
|
||||
amount_sats: int, description: str
|
||||
) -> tuple[str, str]:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
quote = await wallet.request_mint(amount_sats)
|
||||
return quote.request, quote.quote
|
||||
|
||||
|
||||
def generate_invoice_id() -> str:
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
@lightning_router.post("/invoice", response_model=InvoiceCreateResponse)
|
||||
async def create_invoice(
|
||||
request: InvoiceCreateRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvoiceCreateResponse:
|
||||
if request.purpose == "topup" and not request.api_key:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="api_key is required for topup operations"
|
||||
)
|
||||
|
||||
if request.purpose == "topup" and request.api_key:
|
||||
if not request.api_key.startswith("sk-"):
|
||||
raise HTTPException(status_code=400, detail="Invalid API key format")
|
||||
|
||||
api_key = await session.get(ApiKey, request.api_key[3:])
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
|
||||
try:
|
||||
description = f"Routstr {request.purpose} {request.amount_sats} sats"
|
||||
bolt11, payment_hash = await generate_lightning_invoice(
|
||||
request.amount_sats, description
|
||||
)
|
||||
|
||||
invoice_id = generate_invoice_id()
|
||||
expires_at = int(time.time()) + 3600 # 1 hour expiry
|
||||
|
||||
invoice = LightningInvoice(
|
||||
id=invoice_id,
|
||||
bolt11=bolt11,
|
||||
amount_sats=request.amount_sats,
|
||||
description=description,
|
||||
payment_hash=payment_hash,
|
||||
status="pending",
|
||||
api_key_hash=request.api_key[3:] if request.api_key else None,
|
||||
purpose=request.purpose,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
session.add(invoice)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Lightning invoice created",
|
||||
extra={
|
||||
"invoice_id": invoice_id,
|
||||
"amount_sats": request.amount_sats,
|
||||
"purpose": request.purpose,
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
)
|
||||
|
||||
return InvoiceCreateResponse(
|
||||
invoice_id=invoice_id,
|
||||
bolt11=bolt11,
|
||||
amount_sats=request.amount_sats,
|
||||
expires_at=expires_at,
|
||||
payment_hash=payment_hash,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create Lightning invoice: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to create Lightning invoice"
|
||||
)
|
||||
|
||||
|
||||
@lightning_router.get(
|
||||
"/invoice/{invoice_id}/status", response_model=InvoiceStatusResponse
|
||||
)
|
||||
async def get_invoice_status(
|
||||
invoice_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvoiceStatusResponse:
|
||||
invoice = await session.get(LightningInvoice, invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
if invoice.status == "pending" and int(time.time()) > invoice.expires_at:
|
||||
invoice.status = "expired"
|
||||
await session.commit()
|
||||
|
||||
if invoice.status == "pending":
|
||||
await check_invoice_payment(invoice, session)
|
||||
|
||||
api_key = None
|
||||
if invoice.status == "paid" and invoice.purpose == "create":
|
||||
if invoice.api_key_hash:
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
elif (
|
||||
invoice.status == "paid" and invoice.purpose == "topup" and invoice.api_key_hash
|
||||
):
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
|
||||
return InvoiceStatusResponse(
|
||||
status=invoice.status,
|
||||
api_key=api_key,
|
||||
amount_sats=invoice.amount_sats,
|
||||
paid_at=invoice.paid_at,
|
||||
created_at=invoice.created_at,
|
||||
expires_at=invoice.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@lightning_router.post("/recover", response_model=InvoiceStatusResponse)
|
||||
async def recover_invoice(
|
||||
request: InvoiceRecoverRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvoiceStatusResponse:
|
||||
result = await session.exec(
|
||||
select(LightningInvoice).where(LightningInvoice.bolt11 == request.bolt11)
|
||||
)
|
||||
invoice = result.first()
|
||||
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
if invoice.status == "pending":
|
||||
await check_invoice_payment(invoice, session)
|
||||
|
||||
api_key = None
|
||||
if invoice.status == "paid":
|
||||
if invoice.purpose == "create" and invoice.api_key_hash:
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
elif invoice.purpose == "topup" and invoice.api_key_hash:
|
||||
api_key = f"sk-{invoice.api_key_hash}"
|
||||
|
||||
return InvoiceStatusResponse(
|
||||
status=invoice.status,
|
||||
api_key=api_key,
|
||||
amount_sats=invoice.amount_sats,
|
||||
paid_at=invoice.paid_at,
|
||||
created_at=invoice.created_at,
|
||||
expires_at=invoice.expires_at,
|
||||
)
|
||||
|
||||
|
||||
async def check_invoice_payment(
|
||||
invoice: LightningInvoice, session: AsyncSession
|
||||
) -> None:
|
||||
try:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
|
||||
mint_status = await wallet.get_mint_quote(invoice.payment_hash)
|
||||
|
||||
if mint_status.paid:
|
||||
invoice.status = "paid"
|
||||
invoice.paid_at = int(time.time())
|
||||
|
||||
if invoice.purpose == "create":
|
||||
api_key = await create_api_key_from_invoice(invoice, session)
|
||||
invoice.api_key_hash = api_key.hashed_key
|
||||
elif invoice.purpose == "topup" and invoice.api_key_hash:
|
||||
await topup_api_key_from_invoice(invoice, session)
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Lightning invoice paid",
|
||||
extra={
|
||||
"invoice_id": invoice.id,
|
||||
"amount_sats": invoice.amount_sats,
|
||||
"purpose": invoice.purpose,
|
||||
"api_key_hash": invoice.api_key_hash[:8] + "..."
|
||||
if invoice.api_key_hash
|
||||
else None,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check invoice payment: {e}")
|
||||
|
||||
|
||||
async def create_api_key_from_invoice(
|
||||
invoice: LightningInvoice, session: AsyncSession
|
||||
) -> ApiKey:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash)
|
||||
|
||||
dummy_token = f"invoice-{invoice.id}-{invoice.payment_hash}"
|
||||
hashed_key = hashlib.sha256(dummy_token.encode()).hexdigest()
|
||||
|
||||
api_key = ApiKey(
|
||||
hashed_key=hashed_key,
|
||||
balance=invoice.amount_sats * 1000, # Convert to msats
|
||||
refund_currency="sat",
|
||||
refund_mint_url=settings.primary_mint,
|
||||
)
|
||||
|
||||
session.add(api_key)
|
||||
await session.flush()
|
||||
|
||||
return api_key
|
||||
|
||||
|
||||
async def topup_api_key_from_invoice(
|
||||
invoice: LightningInvoice, session: AsyncSession
|
||||
) -> None:
|
||||
wallet = await get_wallet(settings.primary_mint, "sat")
|
||||
await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash)
|
||||
|
||||
if not invoice.api_key_hash:
|
||||
raise ValueError("No API key associated with topup invoice")
|
||||
|
||||
api_key = await session.get(ApiKey, invoice.api_key_hash)
|
||||
if not api_key:
|
||||
raise ValueError("Associated API key not found")
|
||||
|
||||
api_key.balance += invoice.amount_sats * 1000 # Convert to msats
|
||||
await session.flush()
|
||||
@@ -1,9 +0,0 @@
|
||||
from .metadata import async_fetch_openrouter_models
|
||||
from .models import Model
|
||||
|
||||
__all__ = [
|
||||
"Model",
|
||||
"async_fetch_openrouter_models",
|
||||
]
|
||||
|
||||
# specifically ai models that the node sells and not meaning database models
|
||||
@@ -1,105 +0,0 @@
|
||||
import json
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow
|
||||
from ..payment.price import sats_usd_price
|
||||
from .models import (
|
||||
Architecture,
|
||||
Model,
|
||||
Pricing,
|
||||
TopProvider,
|
||||
_update_model_sats_pricing,
|
||||
calculate_usd_max_costs,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def row_to_model(
|
||||
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
|
||||
) -> Model:
|
||||
architecture = json.loads(row.architecture)
|
||||
pricing = json.loads(row.pricing)
|
||||
per_request_limits = (
|
||||
json.loads(row.per_request_limits) if row.per_request_limits else None
|
||||
)
|
||||
top_provider_dict = json.loads(row.top_provider) if row.top_provider else None
|
||||
|
||||
if apply_provider_fee and isinstance(pricing, dict):
|
||||
pricing = {k: float(v) * provider_fee for k, v in pricing.items()}
|
||||
|
||||
if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0:
|
||||
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
|
||||
|
||||
parsed_pricing = Pricing.parse_obj(pricing)
|
||||
model = Model(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
created=row.created,
|
||||
description=row.description,
|
||||
context_length=row.context_length,
|
||||
architecture=Architecture.parse_obj(architecture),
|
||||
pricing=parsed_pricing,
|
||||
sats_pricing=None,
|
||||
per_request_limits=per_request_limits,
|
||||
top_provider=TopProvider.parse_obj(top_provider_dict)
|
||||
if top_provider_dict
|
||||
else None,
|
||||
enabled=row.enabled,
|
||||
upstream_provider_id=row.upstream_provider_id,
|
||||
canonical_slug=getattr(row, "canonical_slug", None),
|
||||
)
|
||||
|
||||
if apply_provider_fee:
|
||||
(
|
||||
parsed_pricing.max_prompt_cost,
|
||||
parsed_pricing.max_completion_cost,
|
||||
parsed_pricing.max_cost,
|
||||
) = calculate_usd_max_costs(model)
|
||||
|
||||
try:
|
||||
sats_to_usd = sats_usd_price()
|
||||
model = _update_model_sats_pricing(model, sats_to_usd)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not calculate sats pricing: {e}")
|
||||
|
||||
return model
|
||||
|
||||
|
||||
async def list_models(
|
||||
session: AsyncSession,
|
||||
upstream_id: int,
|
||||
include_disabled: bool = False,
|
||||
) -> list[Model]:
|
||||
from sqlmodel import select
|
||||
|
||||
query = select(ModelRow)
|
||||
if upstream_id is not None:
|
||||
query = query.where(ModelRow.upstream_provider_id == upstream_id)
|
||||
if not include_disabled:
|
||||
query = query.where(ModelRow.enabled)
|
||||
|
||||
rows = (await session.exec(query)).all() # type: ignore
|
||||
provider_result = await session.exec(select(UpstreamProviderRow))
|
||||
providers_by_id = {p.id: p for p in provider_result.all()}
|
||||
return [
|
||||
row_to_model(
|
||||
r,
|
||||
apply_provider_fee=True,
|
||||
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
|
||||
if r.upstream_provider_id in providers_by_id
|
||||
else 1.01,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def get_model_by_id(
|
||||
model_id: str, provider_id: int, session: AsyncSession
|
||||
) -> Model | None:
|
||||
row = await session.get(ModelRow, (model_id, provider_id))
|
||||
if not row or not row.enabled:
|
||||
return None
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
provider_fee = provider.provider_fee if provider else 1.01
|
||||
return row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
|
||||
@@ -1,52 +0,0 @@
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
||||
from ..core import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DEFAULT_EXCLUDED_MODEL_IDS: Final[set[str]] = {
|
||||
"openrouter/auto",
|
||||
"google/gemini-2.5-pro-exp-03-25",
|
||||
"opengvlab/internvl3-78b",
|
||||
"openrouter/sonoma-dusk-alpha",
|
||||
"openrouter/sonoma-sky-alpha",
|
||||
}
|
||||
|
||||
|
||||
async def async_fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
"""Asynchronously fetch model information from OpenRouter API."""
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(f"{base_url}/models", timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
models_data: list[dict] = []
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
|
||||
if source_filter:
|
||||
source_prefix = f"{source_filter}/"
|
||||
if not model_id.startswith(source_prefix):
|
||||
continue
|
||||
|
||||
model = dict(model)
|
||||
model["id"] = model_id[len(source_prefix) :]
|
||||
model_id = model["id"]
|
||||
|
||||
if (
|
||||
"(free)" in model.get("name", "")
|
||||
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
|
||||
):
|
||||
continue
|
||||
|
||||
models_data.append(model)
|
||||
|
||||
return models_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error (async) fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
@@ -18,15 +18,15 @@ from nostr.key import PrivateKey
|
||||
from nostr.message_type import ClientMessageType
|
||||
from nostr.relay_manager import RelayManager
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.settings import settings
|
||||
from .core import get_logger
|
||||
from .core.settings import settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_app_version() -> str | None:
|
||||
try:
|
||||
from ..core.main import __version__ as imported_version
|
||||
from .core.main import __version__ as imported_version
|
||||
|
||||
return imported_version
|
||||
except Exception:
|
||||
@@ -71,7 +71,7 @@ def nsec_to_keypair(nsec: str) -> tuple[str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def create_listing_event(
|
||||
def create_nip91_event(
|
||||
private_key_hex: str,
|
||||
provider_id: str,
|
||||
endpoint_urls: list[str],
|
||||
@@ -164,7 +164,7 @@ def events_semantically_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def query_listing_events(
|
||||
async def query_nip91_events(
|
||||
relay_url: str,
|
||||
pubkey: str,
|
||||
provider_id: str | None = None,
|
||||
@@ -188,7 +188,7 @@ async def query_listing_events(
|
||||
|
||||
flt = Filter(kinds=[38421], authors=[pubkey], limit=10)
|
||||
filters = Filters([flt])
|
||||
sub_id = f"routstr_listing_{int(time.time())}"
|
||||
sub_id = f"nip91_{int(time.time())}"
|
||||
rm.add_subscription(sub_id, filters)
|
||||
req: list[Any] = [ClientMessageType.REQUEST, sub_id]
|
||||
req.extend(filters.to_json_array())
|
||||
@@ -215,7 +215,7 @@ async def query_listing_events(
|
||||
continue
|
||||
events_out.append(ev_dict)
|
||||
logger.debug(
|
||||
f"Found existing NIP-91 event: {ev_dict.get('id', '')}"
|
||||
f"Found listing event: {ev_dict.get('id', '')[:6]}...{ev_dict.get('id', '')[-6:]}"
|
||||
)
|
||||
if drained:
|
||||
last_event_ts = time.time()
|
||||
@@ -294,7 +294,7 @@ async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) ->
|
||||
|
||||
async def query_single_relay(relay_url: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
events, _ok = await query_listing_events(relay_url, public_key_hex, None)
|
||||
events, _ok = await query_nip91_events(relay_url, public_key_hex, None)
|
||||
return events
|
||||
except Exception:
|
||||
return []
|
||||
@@ -434,7 +434,7 @@ async def announce_provider() -> None:
|
||||
|
||||
# Create the candidate event that we would publish
|
||||
version_str = get_app_version()
|
||||
candidate_event = create_listing_event(
|
||||
candidate_event = create_nip91_event(
|
||||
private_key_hex=private_key_hex,
|
||||
provider_id=provider_id,
|
||||
endpoint_urls=endpoint_urls,
|
||||
@@ -474,7 +474,7 @@ async def announce_provider() -> None:
|
||||
if _should_skip(relay_url):
|
||||
logger.debug(f"Skipping {relay_url} due to backoff")
|
||||
continue
|
||||
events, ok = await query_listing_events(relay_url, public_key_hex, provider_id)
|
||||
events, ok = await query_nip91_events(relay_url, public_key_hex, provider_id)
|
||||
if ok:
|
||||
_register_success(relay_url)
|
||||
existing_events.extend(events)
|
||||
@@ -518,7 +518,7 @@ async def announce_provider() -> None:
|
||||
|
||||
# Build fresh candidate event for comparison
|
||||
version_str = get_app_version()
|
||||
candidate_event = create_listing_event(
|
||||
candidate_event = create_nip91_event(
|
||||
private_key_hex=private_key_hex,
|
||||
provider_id=provider_id,
|
||||
endpoint_urls=endpoint_urls,
|
||||
@@ -533,7 +533,7 @@ async def announce_provider() -> None:
|
||||
if _should_skip(relay_url):
|
||||
logger.debug(f"Skipping {relay_url} due to backoff")
|
||||
continue
|
||||
events, ok = await query_listing_events(
|
||||
events, ok = await query_nip91_events(
|
||||
relay_url, public_key_hex, provider_id
|
||||
)
|
||||
if ok:
|
||||
@@ -1,7 +0,0 @@
|
||||
from .discovery import providers_cache_refresher
|
||||
from .listing import announce_provider
|
||||
|
||||
__all__ = [
|
||||
"providers_cache_refresher",
|
||||
"announce_provider",
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
from .cost import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
from .cost_calculation import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
|
||||
__all__ = [
|
||||
"CostData",
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
from cashu.wallet.helpers import deserialize_token_from_string
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..core import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_token_balance_msat(cashu_token: str) -> int:
|
||||
try:
|
||||
token = deserialize_token_from_string(cashu_token)
|
||||
except Exception:
|
||||
raise HTTPException(401, "Invalid authentication token format")
|
||||
|
||||
if token.unit == "sat":
|
||||
return token.amount * 1000
|
||||
if token.unit == "msat":
|
||||
return token.amount
|
||||
|
||||
raise HTTPException(401, f"Unit {token.unit} not supported yet")
|
||||
|
||||
|
||||
def pre_check_header_token_balance(
|
||||
headers: dict, body: dict, max_cost_for_model: int
|
||||
) -> None:
|
||||
if x_cashu := headers.get("x-cashu", None):
|
||||
cashu_token = x_cashu
|
||||
elif auth := headers.get("authorization", None):
|
||||
cashu_token = auth.split(" ")[1] if len(auth.split(" ")) > 1 else ""
|
||||
else:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# Handle regular API keys (sk-*)
|
||||
if cashu_token.startswith("sk-"):
|
||||
return
|
||||
|
||||
amount_msat = get_token_balance_msat(cashu_token)
|
||||
|
||||
if max_cost_for_model > amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail={
|
||||
"reason": "Insufficient balance",
|
||||
"amount_required_msat": max_cost_for_model,
|
||||
"model": body.get("model", "unknown"),
|
||||
"type": "minimum_balance_required",
|
||||
},
|
||||
)
|
||||
@@ -1,425 +0,0 @@
|
||||
import base64
|
||||
import math
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from PIL import Image
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.settings import settings
|
||||
from ..models.models import Model
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CostData(BaseModel):
|
||||
base_msats: int
|
||||
input_msats: int
|
||||
output_msats: int
|
||||
total_msats: int
|
||||
|
||||
|
||||
class MaxCostData(CostData):
|
||||
pass
|
||||
|
||||
|
||||
class CostDataError(BaseModel):
|
||||
message: str
|
||||
code: str
|
||||
|
||||
|
||||
def calculate_cost(
|
||||
response_data: dict, max_cost: int
|
||||
) -> CostData | MaxCostData | CostDataError:
|
||||
"""
|
||||
Calculate the cost of an API request based on token usage.
|
||||
|
||||
Args:
|
||||
response_data: Response data containing usage information
|
||||
max_cost: Maximum cost in millisats
|
||||
|
||||
Returns:
|
||||
Cost data or error information
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting cost calculation",
|
||||
extra={
|
||||
"max_cost_msats": max_cost,
|
||||
"has_usage_data": "usage" in response_data,
|
||||
"response_model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
cost_data = MaxCostData(
|
||||
base_msats=max_cost,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=max_cost,
|
||||
)
|
||||
|
||||
if "usage" not in response_data or response_data["usage"] is None:
|
||||
logger.warning(
|
||||
"No usage data in response, using base cost only",
|
||||
extra={
|
||||
"max_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS: float = (
|
||||
float(settings.fixed_per_1k_input_tokens) * 1000.0
|
||||
)
|
||||
MSATS_PER_1K_OUTPUT_TOKENS: float = (
|
||||
float(settings.fixed_per_1k_output_tokens) * 1000.0
|
||||
)
|
||||
|
||||
if not settings.fixed_pricing:
|
||||
response_model = response_data.get("model", "")
|
||||
logger.debug(
|
||||
"Using model-based pricing",
|
||||
extra={"model": response_model},
|
||||
)
|
||||
|
||||
from ..proxy import get_model_instance
|
||||
|
||||
model_obj = get_model_instance(response_model)
|
||||
|
||||
if not model_obj:
|
||||
logger.error(
|
||||
"Invalid model in response",
|
||||
extra={"response_model": response_model},
|
||||
)
|
||||
return CostDataError(
|
||||
message=f"Invalid model in response: {response_model}",
|
||||
code="model_not_found",
|
||||
)
|
||||
|
||||
if not model_obj.sats_pricing:
|
||||
logger.error(
|
||||
"Model pricing not defined",
|
||||
extra={"model": response_model, "model_id": response_model},
|
||||
)
|
||||
return CostDataError(
|
||||
message="Model pricing not defined", code="pricing_not_found"
|
||||
)
|
||||
|
||||
try:
|
||||
mspp = float(model_obj.sats_pricing.prompt)
|
||||
mspc = float(model_obj.sats_pricing.completion)
|
||||
except Exception:
|
||||
return CostDataError(message="Invalid pricing data", code="pricing_invalid")
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS = mspp * 1_000_000.0
|
||||
MSATS_PER_1K_OUTPUT_TOKENS = mspc * 1_000_000.0
|
||||
|
||||
logger.info(
|
||||
"Applied model-specific pricing",
|
||||
extra={
|
||||
"model": response_model,
|
||||
"input_price_msats_per_1k": MSATS_PER_1K_INPUT_TOKENS,
|
||||
"output_price_msats_per_1k": MSATS_PER_1K_OUTPUT_TOKENS,
|
||||
},
|
||||
)
|
||||
|
||||
if not (MSATS_PER_1K_OUTPUT_TOKENS and MSATS_PER_1K_INPUT_TOKENS):
|
||||
logger.warning(
|
||||
"No token pricing configured, using base cost",
|
||||
extra={
|
||||
"base_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
|
||||
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
|
||||
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
|
||||
|
||||
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||
token_based_cost = math.ceil(input_msats + output_msats)
|
||||
|
||||
logger.info(
|
||||
"Calculated token-based cost",
|
||||
extra={
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"input_cost_msats": input_msats,
|
||||
"output_cost_msats": output_msats,
|
||||
"total_cost_msats": token_based_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return CostData(
|
||||
base_msats=0,
|
||||
input_msats=int(input_msats),
|
||||
output_msats=int(output_msats),
|
||||
total_msats=token_based_cost,
|
||||
)
|
||||
|
||||
|
||||
def get_max_cost_for_model(model_obj: Model) -> int:
|
||||
"""Get the maximum cost for a specific model from providers with overrides."""
|
||||
if settings.fixed_pricing:
|
||||
default_cost_msats = settings.fixed_cost_per_request * 1000
|
||||
return max(settings.min_request_msat, default_cost_msats)
|
||||
|
||||
if model_obj.sats_pricing:
|
||||
max_cost = (
|
||||
model_obj.sats_pricing.max_cost
|
||||
* 1000
|
||||
* (1 - settings.tolerance_percentage / 100)
|
||||
)
|
||||
calculated_msats = int(max_cost)
|
||||
return max(settings.min_request_msat, calculated_msats)
|
||||
|
||||
logger.warning(
|
||||
"Model pricing not found, using fixed cost",
|
||||
extra={
|
||||
"model": model_obj.id,
|
||||
"default_cost_msats": settings.fixed_cost_per_request * 1000,
|
||||
},
|
||||
)
|
||||
return max(settings.min_request_msat, settings.fixed_cost_per_request * 1000)
|
||||
|
||||
|
||||
async def calculate_discounted_max_cost(
|
||||
max_cost_for_model: int,
|
||||
body: dict,
|
||||
model_obj: Any | None = None,
|
||||
) -> int:
|
||||
"""Calculate the discounted max cost for a request using model pricing when available."""
|
||||
if settings.fixed_pricing:
|
||||
return max_cost_for_model
|
||||
|
||||
model = body.get("model", "unknown")
|
||||
|
||||
model_pricing = model_obj.sats_pricing if model_obj else None
|
||||
if not model_pricing:
|
||||
return max_cost_for_model
|
||||
|
||||
tol = settings.tolerance_percentage
|
||||
tol_factor = max(0.0, 1 - float(tol) / 100.0)
|
||||
max_prompt_allowed_sats = model_pricing.max_prompt_cost * tol_factor
|
||||
max_completion_allowed_sats = model_pricing.max_completion_cost * tol_factor
|
||||
|
||||
adjusted = max_cost_for_model
|
||||
|
||||
if messages := body.get("messages"):
|
||||
prompt_tokens = _estimate_tokens(messages)
|
||||
|
||||
image_tokens = await _estimate_image_tokens_in_messages(messages)
|
||||
if image_tokens > 0:
|
||||
logger.debug(
|
||||
"Found images in request",
|
||||
extra={
|
||||
"model": model,
|
||||
"image_tokens": image_tokens,
|
||||
},
|
||||
)
|
||||
prompt_tokens += image_tokens
|
||||
|
||||
estimated_prompt_delta_sats = (
|
||||
max_prompt_allowed_sats - prompt_tokens * model_pricing.prompt
|
||||
)
|
||||
if estimated_prompt_delta_sats > 0:
|
||||
adjusted = adjusted - math.floor(estimated_prompt_delta_sats * 1000)
|
||||
|
||||
max_tokens_raw = body.get("max_tokens", None)
|
||||
if max_tokens_raw is not None:
|
||||
try:
|
||||
max_tokens_int = int(max_tokens_raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Invalid max_tokens; ignoring in cost adjustment",
|
||||
extra={"max_tokens": str(max_tokens_raw)[:64], "model": model},
|
||||
)
|
||||
else:
|
||||
estimated_completion_delta_sats = (
|
||||
max_completion_allowed_sats - max_tokens_int * model_pricing.completion
|
||||
)
|
||||
if estimated_completion_delta_sats > 0:
|
||||
adjusted = adjusted - math.floor(estimated_completion_delta_sats * 1000)
|
||||
|
||||
logger.debug(
|
||||
"Discounted max cost computed",
|
||||
extra={
|
||||
"model": model,
|
||||
"original_msats": max_cost_for_model,
|
||||
"adjusted_msats": adjusted,
|
||||
"tolerance_pct": tol,
|
||||
},
|
||||
)
|
||||
|
||||
return max(0, adjusted)
|
||||
|
||||
|
||||
def _estimate_tokens(messages: list) -> int:
|
||||
"""Estimate tokens for text content, excluding image_url fields."""
|
||||
total = 0
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict):
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
total += len(content)
|
||||
elif isinstance(content, list):
|
||||
total += sum(
|
||||
len(item.get("text", ""))
|
||||
for item in content
|
||||
if isinstance(item, dict) and item.get("type") == "text"
|
||||
)
|
||||
return total // 3
|
||||
|
||||
|
||||
def _get_image_dimensions(image_data: bytes) -> tuple[int, int]:
|
||||
"""Extract image dimensions from image bytes."""
|
||||
try:
|
||||
img = Image.open(BytesIO(image_data))
|
||||
return img.size
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to get image dimensions, using default",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
return (512, 512)
|
||||
|
||||
|
||||
async def _fetch_image_from_url(url: str) -> bytes | None:
|
||||
"""Fetch image from URL."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to fetch image from URL",
|
||||
extra={"error": str(e), "url": url[:100]},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _calculate_image_tokens(width: int, height: int, detail: str = "auto") -> int:
|
||||
"""Calculate image tokens based on OpenAI's vision pricing.
|
||||
|
||||
For low detail: 85 tokens
|
||||
For high detail/auto: 85 base tokens + 170 tokens per 512px tile
|
||||
"""
|
||||
if detail == "low":
|
||||
return 85
|
||||
|
||||
if width > 2048 or height > 2048:
|
||||
aspect_ratio = width / height
|
||||
if width > height:
|
||||
width = 2048
|
||||
height = int(width / aspect_ratio)
|
||||
else:
|
||||
height = 2048
|
||||
width = int(height * aspect_ratio)
|
||||
|
||||
if width > 768 or height > 768:
|
||||
aspect_ratio = width / height
|
||||
if width > height:
|
||||
width = 768
|
||||
height = int(width / aspect_ratio)
|
||||
else:
|
||||
height = 768
|
||||
width = int(height * aspect_ratio)
|
||||
|
||||
tiles_width = (width + 511) // 512
|
||||
tiles_height = (height + 511) // 512
|
||||
num_tiles = tiles_width * tiles_height
|
||||
|
||||
return 85 + (170 * num_tiles)
|
||||
|
||||
|
||||
async def _estimate_image_tokens_in_messages(messages: list) -> int:
|
||||
"""Estimate total tokens for all images in messages.
|
||||
|
||||
Supports both base64 encoded images and image URLs.
|
||||
"""
|
||||
total_image_tokens = 0
|
||||
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
|
||||
content = message.get("content")
|
||||
if not content:
|
||||
continue
|
||||
|
||||
if isinstance(content, str):
|
||||
continue
|
||||
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
|
||||
for content_item in content:
|
||||
if not isinstance(content_item, dict):
|
||||
continue
|
||||
|
||||
content_type = content_item.get("type")
|
||||
if content_type not in ("image_url", "input_image"):
|
||||
continue
|
||||
|
||||
image_url_data = content_item.get("image_url")
|
||||
if not image_url_data:
|
||||
continue
|
||||
|
||||
if isinstance(image_url_data, str):
|
||||
url = image_url_data
|
||||
detail = "auto"
|
||||
elif isinstance(image_url_data, dict):
|
||||
url = image_url_data.get("url", "")
|
||||
detail = image_url_data.get("detail", "auto")
|
||||
else:
|
||||
continue
|
||||
|
||||
if not url:
|
||||
continue
|
||||
|
||||
if url.startswith("data:image/"):
|
||||
try:
|
||||
header, base64_data = url.split(",", 1)
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
width, height = _get_image_dimensions(image_bytes)
|
||||
tokens = _calculate_image_tokens(width, height, detail)
|
||||
total_image_tokens += tokens
|
||||
logger.debug(
|
||||
"Calculated tokens for base64 image",
|
||||
extra={
|
||||
"width": width,
|
||||
"height": height,
|
||||
"detail": detail,
|
||||
"tokens": tokens,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to process base64 image",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
total_image_tokens += 85
|
||||
else:
|
||||
image_bytes_or_none = await _fetch_image_from_url(url)
|
||||
if image_bytes_or_none:
|
||||
width, height = _get_image_dimensions(image_bytes_or_none)
|
||||
tokens = _calculate_image_tokens(width, height, detail)
|
||||
total_image_tokens += tokens
|
||||
logger.debug(
|
||||
"Calculated tokens for URL image",
|
||||
extra={
|
||||
"url": url[:100],
|
||||
"width": width,
|
||||
"height": height,
|
||||
"detail": detail,
|
||||
"tokens": tokens,
|
||||
},
|
||||
)
|
||||
else:
|
||||
total_image_tokens += 85
|
||||
|
||||
return total_image_tokens
|
||||
@@ -0,0 +1,220 @@
|
||||
import math
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession
|
||||
from ..core.settings import settings
|
||||
from .price import sats_usd_price
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CostData(BaseModel):
|
||||
base_msats: int
|
||||
input_msats: int
|
||||
output_msats: int
|
||||
total_msats: int
|
||||
|
||||
|
||||
class MaxCostData(CostData):
|
||||
pass
|
||||
|
||||
|
||||
class CostDataError(BaseModel):
|
||||
message: str
|
||||
code: str
|
||||
|
||||
|
||||
async def calculate_cost( # todo: can be sync
|
||||
response_data: dict, max_cost: int, session: AsyncSession
|
||||
) -> CostData | MaxCostData | CostDataError:
|
||||
"""
|
||||
Calculate the cost of an API request based on token usage.
|
||||
|
||||
Args:
|
||||
response_data: Response data containing usage information
|
||||
max_cost: Maximum cost in millisats
|
||||
|
||||
Returns:
|
||||
Cost data or error information
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting cost calculation",
|
||||
extra={
|
||||
"max_cost_msats": max_cost,
|
||||
"has_usage_data": "usage" in response_data,
|
||||
"response_model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
cost_data = MaxCostData(
|
||||
base_msats=max_cost,
|
||||
input_msats=0,
|
||||
output_msats=0,
|
||||
total_msats=max_cost,
|
||||
)
|
||||
|
||||
if "usage" not in response_data or response_data["usage"] is None:
|
||||
logger.warning(
|
||||
"No usage data in response, using base cost only",
|
||||
extra={
|
||||
"max_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
|
||||
usage_data = response_data["usage"]
|
||||
|
||||
usd_cost = 0.0
|
||||
|
||||
# Prioritize cost_details.upstream_inference_cost
|
||||
if "cost_details" in usage_data:
|
||||
usd_cost = float(
|
||||
usage_data["cost_details"].get("upstream_inference_cost", 0) or 0
|
||||
)
|
||||
|
||||
# Fallback to cost field if upstream_inference_cost is 0
|
||||
if usd_cost == 0 and "cost" in usage_data:
|
||||
try:
|
||||
usd_cost = float(usage_data.get("cost", 0) or 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if usd_cost > 0:
|
||||
try:
|
||||
sats_per_usd = 1.0 / sats_usd_price()
|
||||
cost_in_sats = usd_cost * sats_per_usd
|
||||
cost_in_msats = math.ceil(cost_in_sats * 1000)
|
||||
|
||||
logger.info(
|
||||
"Using cost from usage data/details",
|
||||
extra={
|
||||
"usd_cost": usd_cost,
|
||||
"cost_in_sats": cost_in_sats,
|
||||
"cost_in_msats": cost_in_msats,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return CostData(
|
||||
base_msats=-1,
|
||||
input_msats=-1, # Cost field doesn't break down by token type
|
||||
output_msats=-1,
|
||||
total_msats=cost_in_msats,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Error calculating cost from usage data",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"usd_cost": usd_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
# Fall through to token-based calculation
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS: float = (
|
||||
float(settings.fixed_per_1k_input_tokens) * 1000.0
|
||||
)
|
||||
MSATS_PER_1K_OUTPUT_TOKENS: float = (
|
||||
float(settings.fixed_per_1k_output_tokens) * 1000.0
|
||||
)
|
||||
|
||||
if not settings.fixed_pricing:
|
||||
response_model = response_data.get("model", "")
|
||||
logger.debug(
|
||||
"Using model-based pricing",
|
||||
extra={"model": response_model},
|
||||
)
|
||||
|
||||
from ..proxy import get_model_instance
|
||||
|
||||
model_obj = get_model_instance(response_model)
|
||||
|
||||
if not model_obj:
|
||||
logger.error(
|
||||
"Invalid model in response",
|
||||
extra={"response_model": response_model},
|
||||
)
|
||||
return CostDataError(
|
||||
message=f"Invalid model in response: {response_model}",
|
||||
code="model_not_found",
|
||||
)
|
||||
|
||||
if not model_obj.sats_pricing:
|
||||
logger.error(
|
||||
"Model pricing not defined",
|
||||
extra={"model": response_model, "model_id": response_model},
|
||||
)
|
||||
return CostDataError(
|
||||
message="Model pricing not defined", code="pricing_not_found"
|
||||
)
|
||||
|
||||
try:
|
||||
mspp = float(model_obj.sats_pricing.prompt)
|
||||
mspc = float(model_obj.sats_pricing.completion)
|
||||
except Exception:
|
||||
return CostDataError(message="Invalid pricing data", code="pricing_invalid")
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS = mspp * 1_000_000.0
|
||||
MSATS_PER_1K_OUTPUT_TOKENS = mspc * 1_000_000.0
|
||||
|
||||
logger.info(
|
||||
"Applied model-specific pricing",
|
||||
extra={
|
||||
"model": response_model,
|
||||
"input_price_msats_per_1k": MSATS_PER_1K_INPUT_TOKENS,
|
||||
"output_price_msats_per_1k": MSATS_PER_1K_OUTPUT_TOKENS,
|
||||
},
|
||||
)
|
||||
|
||||
if not (MSATS_PER_1K_OUTPUT_TOKENS and MSATS_PER_1K_INPUT_TOKENS):
|
||||
logger.warning(
|
||||
"No token pricing configured, using base cost",
|
||||
extra={
|
||||
"base_cost_msats": max_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
return cost_data
|
||||
|
||||
input_tokens = usage_data.get("prompt_tokens", 0)
|
||||
output_tokens = usage_data.get("completion_tokens", 0)
|
||||
|
||||
# added for response api
|
||||
input_tokens = (
|
||||
input_tokens if input_tokens != 0 else usage_data.get("input_tokens", 0)
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens if output_tokens != 0 else usage_data.get("output_tokens", 0)
|
||||
)
|
||||
|
||||
# added for response api
|
||||
input_tokens = input_tokens if input_tokens != 0 else response_data.get("usage", {}).get("input_tokens", 0)
|
||||
output_tokens = output_tokens if output_tokens != 0 else response_data.get("usage", {}).get("output_tokens", 0)
|
||||
|
||||
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||
|
||||
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||
token_based_cost = math.ceil(input_msats + output_msats)
|
||||
|
||||
logger.info(
|
||||
"Calculated token-based cost",
|
||||
extra={
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"input_cost_msats": input_msats,
|
||||
"output_cost_msats": output_msats,
|
||||
"total_cost_msats": token_based_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return CostData(
|
||||
base_msats=0,
|
||||
input_msats=int(input_msats),
|
||||
output_msats=int(output_msats),
|
||||
total_msats=token_based_cost,
|
||||
)
|
||||
+382
-381
@@ -1,18 +1,396 @@
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, Response
|
||||
from fastapi.requests import Request
|
||||
from sqlmodel import col, update
|
||||
from PIL import Image
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession, TemporaryCredit
|
||||
from ..payment.cost import CostData, CostDataError, MaxCostData, calculate_cost
|
||||
from ..core.settings import settings
|
||||
from ..wallet import deserialize_token_from_string
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# TODO: remove and replace with custom HTTPException
|
||||
def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> None:
|
||||
if x_cashu := headers.get("x-cashu", None):
|
||||
cashu_token = x_cashu
|
||||
logger.debug(
|
||||
"Using X-Cashu token",
|
||||
extra={
|
||||
"token_preview": cashu_token[:20] + "..."
|
||||
if len(cashu_token) > 20
|
||||
else cashu_token
|
||||
},
|
||||
)
|
||||
elif auth := headers.get("authorization", None):
|
||||
cashu_token = auth.split(" ")[1] if len(auth.split(" ")) > 1 else ""
|
||||
logger.debug(
|
||||
"Using Authorization header token",
|
||||
extra={
|
||||
"token_preview": cashu_token[:20] + "..."
|
||||
if len(cashu_token) > 20
|
||||
else cashu_token
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.error("No authentication token provided")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# Handle empty token
|
||||
if not cashu_token:
|
||||
logger.error("Empty token provided")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {
|
||||
"message": "API key or Cashu token required",
|
||||
"type": "invalid_request_error",
|
||||
"code": "missing_api_key",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Handle regular API keys (sk-*)
|
||||
if cashu_token.startswith("sk-"):
|
||||
return
|
||||
|
||||
try:
|
||||
token_obj = deserialize_token_from_string(cashu_token)
|
||||
except Exception:
|
||||
# Invalid token format - let the auth system handle it
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid authentication token format",
|
||||
)
|
||||
|
||||
amount_msat = (
|
||||
token_obj.amount if token_obj.unit == "msat" else token_obj.amount * 1000
|
||||
)
|
||||
|
||||
if max_cost_for_model > amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail={
|
||||
"reason": "Insufficient balance",
|
||||
"amount_required_msat": max_cost_for_model,
|
||||
"model": body.get("model", "unknown"),
|
||||
"type": "minimum_balance_required",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def get_max_cost_for_model(
|
||||
model: str,
|
||||
session: AsyncSession,
|
||||
model_obj: Any | None = None,
|
||||
) -> int:
|
||||
"""Get the maximum cost for a specific model from providers with overrides."""
|
||||
logger.debug(
|
||||
"Getting max cost for model",
|
||||
extra={
|
||||
"model": model,
|
||||
"fixed_pricing": settings.fixed_pricing,
|
||||
},
|
||||
)
|
||||
|
||||
if settings.fixed_pricing:
|
||||
default_cost_msats = settings.fixed_cost_per_request * 1000
|
||||
logger.debug(
|
||||
"Using fixed cost pricing",
|
||||
extra={"cost_msats": default_cost_msats, "model": model},
|
||||
)
|
||||
return max(settings.min_request_msat, default_cost_msats)
|
||||
|
||||
if not model_obj:
|
||||
from ..proxy import get_model_instance
|
||||
|
||||
model_obj = get_model_instance(model)
|
||||
|
||||
if not model_obj:
|
||||
fallback_msats = settings.fixed_cost_per_request * 1000
|
||||
logger.warning(
|
||||
"Model not found in providers or overrides",
|
||||
extra={
|
||||
"requested_model": model,
|
||||
"using_default_cost": fallback_msats,
|
||||
},
|
||||
)
|
||||
return max(settings.min_request_msat, fallback_msats)
|
||||
|
||||
if model_obj.sats_pricing:
|
||||
try:
|
||||
max_cost = (
|
||||
model_obj.sats_pricing.max_cost
|
||||
* 1000
|
||||
* (1 - settings.tolerance_percentage / 100)
|
||||
)
|
||||
logger.debug(
|
||||
"Found model-specific max cost",
|
||||
extra={"model": model, "max_cost_msats": max_cost},
|
||||
)
|
||||
calculated_msats = int(max_cost)
|
||||
return max(settings.min_request_msat, calculated_msats)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error calculating max cost from model pricing",
|
||||
extra={"model": model, "error": str(e)},
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"Model pricing not found, using fixed cost",
|
||||
extra={
|
||||
"model": model,
|
||||
"default_cost_msats": settings.fixed_cost_per_request * 1000,
|
||||
},
|
||||
)
|
||||
return max(settings.min_request_msat, settings.fixed_cost_per_request * 1000)
|
||||
|
||||
|
||||
async def calculate_discounted_max_cost(
|
||||
max_cost_for_model: int,
|
||||
body: dict,
|
||||
model_obj: Any | None = None,
|
||||
) -> int:
|
||||
"""Calculate the discounted max cost for a request using model pricing when available."""
|
||||
if settings.fixed_pricing:
|
||||
return max_cost_for_model
|
||||
|
||||
model = body.get("model", "unknown")
|
||||
|
||||
model_pricing = model_obj.sats_pricing if model_obj else None
|
||||
if not model_pricing:
|
||||
return max_cost_for_model
|
||||
|
||||
tol = settings.tolerance_percentage
|
||||
tol_factor = max(0.0, 1 - float(tol) / 100.0)
|
||||
max_prompt_allowed_sats = model_pricing.max_prompt_cost * tol_factor
|
||||
max_completion_allowed_sats = model_pricing.max_completion_cost * tol_factor
|
||||
|
||||
adjusted = max_cost_for_model
|
||||
|
||||
if messages := body.get("messages"):
|
||||
prompt_tokens = estimate_tokens(messages)
|
||||
|
||||
image_tokens = await estimate_image_tokens_in_messages(messages)
|
||||
if image_tokens > 0:
|
||||
logger.debug(
|
||||
"Found images in request",
|
||||
extra={
|
||||
"model": model,
|
||||
"image_tokens": image_tokens,
|
||||
},
|
||||
)
|
||||
prompt_tokens += image_tokens
|
||||
|
||||
estimated_prompt_delta_sats = (
|
||||
max_prompt_allowed_sats - prompt_tokens * model_pricing.prompt
|
||||
)
|
||||
if estimated_prompt_delta_sats > 0:
|
||||
adjusted = adjusted - math.floor(estimated_prompt_delta_sats * 1000)
|
||||
|
||||
max_tokens_raw = body.get("max_tokens", None)
|
||||
if max_tokens_raw is not None:
|
||||
try:
|
||||
max_tokens_int = int(max_tokens_raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Invalid max_tokens; ignoring in cost adjustment",
|
||||
extra={"max_tokens": str(max_tokens_raw)[:64], "model": model},
|
||||
)
|
||||
else:
|
||||
estimated_completion_delta_sats = (
|
||||
max_completion_allowed_sats - max_tokens_int * model_pricing.completion
|
||||
)
|
||||
if estimated_completion_delta_sats > 0:
|
||||
adjusted = adjusted - math.floor(estimated_completion_delta_sats * 1000)
|
||||
|
||||
logger.debug(
|
||||
"Discounted max cost computed",
|
||||
extra={
|
||||
"model": model,
|
||||
"original_msats": max_cost_for_model,
|
||||
"adjusted_msats": adjusted,
|
||||
"tolerance_pct": tol,
|
||||
},
|
||||
)
|
||||
|
||||
return max(0, adjusted)
|
||||
|
||||
|
||||
def estimate_tokens(messages: list) -> int:
|
||||
"""Estimate tokens for text content, excluding image_url fields."""
|
||||
total = 0
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict):
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
total += len(content)
|
||||
elif isinstance(content, list):
|
||||
total += sum(
|
||||
len(item.get("text", ""))
|
||||
for item in content
|
||||
if isinstance(item, dict) and item.get("type") == "text"
|
||||
)
|
||||
return total // 3
|
||||
|
||||
|
||||
def _get_image_dimensions(image_data: bytes) -> tuple[int, int]:
|
||||
"""Extract image dimensions from image bytes."""
|
||||
try:
|
||||
img = Image.open(BytesIO(image_data))
|
||||
return img.size
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to get image dimensions, using default",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
return (512, 512)
|
||||
|
||||
|
||||
async def _fetch_image_from_url(url: str) -> bytes | None:
|
||||
"""Fetch image from URL."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to fetch image from URL",
|
||||
extra={"error": str(e), "url": url[:100]},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _calculate_image_tokens(width: int, height: int, detail: str = "auto") -> int:
|
||||
"""Calculate image tokens based on OpenAI's vision pricing.
|
||||
|
||||
For low detail: 85 tokens
|
||||
For high detail/auto: 85 base tokens + 170 tokens per 512px tile
|
||||
"""
|
||||
if detail == "low":
|
||||
return 85
|
||||
|
||||
if width > 2048 or height > 2048:
|
||||
aspect_ratio = width / height
|
||||
if width > height:
|
||||
width = 2048
|
||||
height = int(width / aspect_ratio)
|
||||
else:
|
||||
height = 2048
|
||||
width = int(height * aspect_ratio)
|
||||
|
||||
if width > 768 or height > 768:
|
||||
aspect_ratio = width / height
|
||||
if width > height:
|
||||
width = 768
|
||||
height = int(width / aspect_ratio)
|
||||
else:
|
||||
height = 768
|
||||
width = int(height * aspect_ratio)
|
||||
|
||||
tiles_width = (width + 511) // 512
|
||||
tiles_height = (height + 511) // 512
|
||||
num_tiles = tiles_width * tiles_height
|
||||
|
||||
return 85 + (170 * num_tiles)
|
||||
|
||||
|
||||
async def estimate_image_tokens_in_messages(messages: list) -> int:
|
||||
"""Estimate total tokens for all images in messages.
|
||||
|
||||
Supports both base64 encoded images and image URLs.
|
||||
"""
|
||||
total_image_tokens = 0
|
||||
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
|
||||
content = message.get("content")
|
||||
if not content:
|
||||
continue
|
||||
|
||||
if isinstance(content, str):
|
||||
continue
|
||||
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
|
||||
for content_item in content:
|
||||
if not isinstance(content_item, dict):
|
||||
continue
|
||||
|
||||
content_type = content_item.get("type")
|
||||
if content_type not in ("image_url", "input_image"):
|
||||
continue
|
||||
|
||||
image_url_data = content_item.get("image_url")
|
||||
if not image_url_data:
|
||||
continue
|
||||
|
||||
if isinstance(image_url_data, str):
|
||||
url = image_url_data
|
||||
detail = "auto"
|
||||
elif isinstance(image_url_data, dict):
|
||||
url = image_url_data.get("url", "")
|
||||
detail = image_url_data.get("detail", "auto")
|
||||
else:
|
||||
continue
|
||||
|
||||
if not url:
|
||||
continue
|
||||
|
||||
if url.startswith("data:image/"):
|
||||
try:
|
||||
header, base64_data = url.split(",", 1)
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
width, height = _get_image_dimensions(image_bytes)
|
||||
tokens = _calculate_image_tokens(width, height, detail)
|
||||
total_image_tokens += tokens
|
||||
logger.debug(
|
||||
"Calculated tokens for base64 image",
|
||||
extra={
|
||||
"width": width,
|
||||
"height": height,
|
||||
"detail": detail,
|
||||
"tokens": tokens,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to process base64 image",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
total_image_tokens += 85
|
||||
else:
|
||||
image_bytes_or_none = await _fetch_image_from_url(url)
|
||||
if image_bytes_or_none:
|
||||
width, height = _get_image_dimensions(image_bytes_or_none)
|
||||
tokens = _calculate_image_tokens(width, height, detail)
|
||||
total_image_tokens += tokens
|
||||
logger.debug(
|
||||
"Calculated tokens for URL image",
|
||||
extra={
|
||||
"url": url[:100],
|
||||
"width": width,
|
||||
"height": height,
|
||||
"detail": detail,
|
||||
"tokens": tokens,
|
||||
},
|
||||
)
|
||||
else:
|
||||
total_image_tokens += 85
|
||||
|
||||
return total_image_tokens
|
||||
|
||||
|
||||
def create_error_response(
|
||||
error_type: str,
|
||||
message: str,
|
||||
@@ -36,380 +414,3 @@ def create_error_response(
|
||||
media_type="application/json",
|
||||
headers={"X-Cashu": token} if token else {},
|
||||
)
|
||||
|
||||
|
||||
# Request payment handlers, todo: maybe mode somewhere else...
|
||||
|
||||
|
||||
async def pay_for_request(
|
||||
key: TemporaryCredit, cost_per_request: int, session: AsyncSession
|
||||
) -> int:
|
||||
"""Process payment for a request."""
|
||||
|
||||
logger.info(
|
||||
"Processing payment for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"current_balance": key.balance,
|
||||
"required_cost": cost_per_request,
|
||||
"sufficient_balance": key.balance >= cost_per_request,
|
||||
},
|
||||
)
|
||||
|
||||
if key.total_balance_msat < cost_per_request:
|
||||
logger.warning(
|
||||
"Insufficient balance for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"balance": key.balance,
|
||||
"reserved_balance": key.reserved_balance,
|
||||
"required": cost_per_request,
|
||||
"shortfall": cost_per_request - key.total_balance_msat,
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.total_balance} available. (reserved: {key.reserved_balance})",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Charging base cost for request",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"cost": cost_per_request,
|
||||
"balance_before": key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
# Charge the base cost for the request atomically to avoid race conditions
|
||||
stmt = (
|
||||
update(TemporaryCredit)
|
||||
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
|
||||
.where(
|
||||
col(TemporaryCredit.balance) - col(TemporaryCredit.reserved_balance)
|
||||
>= cost_per_request
|
||||
)
|
||||
.values(
|
||||
reserved_balance=col(TemporaryCredit.reserved_balance) + cost_per_request
|
||||
)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Concurrent request depleted balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"required_cost": cost_per_request,
|
||||
"current_balance": key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
# Another concurrent request spent the balance first
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"type": "insufficient_quota",
|
||||
"code": "insufficient_balance",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Payment processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost_per_request,
|
||||
"new_balance": key.balance,
|
||||
},
|
||||
)
|
||||
|
||||
return cost_per_request
|
||||
|
||||
|
||||
async def revert_pay_for_request(
|
||||
key: TemporaryCredit, session: AsyncSession, cost_per_request: int
|
||||
) -> None:
|
||||
stmt = (
|
||||
update(TemporaryCredit)
|
||||
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(TemporaryCredit.reserved_balance) - cost_per_request
|
||||
)
|
||||
)
|
||||
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to revert payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"cost_to_revert": cost_per_request,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": {
|
||||
"message": f"failed to revert request payment: {cost_per_request} mSats required. {key.balance} available.",
|
||||
"type": "payment_error",
|
||||
"code": "payment_error",
|
||||
}
|
||||
},
|
||||
)
|
||||
await session.refresh(key)
|
||||
|
||||
|
||||
async def adjust_payment_for_tokens(
|
||||
key: TemporaryCredit,
|
||||
response_data: dict,
|
||||
session: AsyncSession,
|
||||
deducted_max_cost: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Adjusts the payment based on token usage in the response.
|
||||
This is called after the initial payment and the upstream request is complete.
|
||||
Returns cost data to be included in the response.
|
||||
"""
|
||||
model = response_data.get("model", "unknown")
|
||||
|
||||
logger.debug(
|
||||
"Starting payment adjustment for tokens",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_balance": key.balance,
|
||||
"has_usage": "usage" in response_data,
|
||||
},
|
||||
)
|
||||
|
||||
match calculate_cost(response_data, deducted_max_cost):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost data (no token adjustment)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"max_cost": cost.total_msats,
|
||||
},
|
||||
)
|
||||
# Finalize by releasing reservation and charging max cost
|
||||
finalize_stmt = (
|
||||
update(TemporaryCredit)
|
||||
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(TemporaryCredit.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(TemporaryCredit.balance) - cost.total_msats,
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize max-cost payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"total_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
await session.refresh(key)
|
||||
logger.info(
|
||||
"Max cost payment finalized",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"charged_amount": cost.total_msats,
|
||||
"new_balance": key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
return cost.dict()
|
||||
|
||||
case CostData() as cost:
|
||||
# If token-based pricing is enabled and base cost is 0, use token-based cost
|
||||
# Otherwise, token cost is additional to the base cost
|
||||
cost_difference = cost.total_msats - deducted_max_cost
|
||||
total_cost_msats: int = math.ceil(cost.total_msats)
|
||||
|
||||
logger.info(
|
||||
"Calculated token-based cost",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"token_cost": cost.total_msats,
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"cost_difference": cost_difference,
|
||||
"input_msats": cost.input_msats,
|
||||
"output_msats": cost.output_msats,
|
||||
},
|
||||
)
|
||||
|
||||
if cost_difference == 0:
|
||||
logger.debug(
|
||||
"Finalizing with exact reserved cost",
|
||||
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
||||
)
|
||||
finalize_stmt = (
|
||||
update(TemporaryCredit)
|
||||
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(TemporaryCredit.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(TemporaryCredit.balance) - total_cost_msats,
|
||||
)
|
||||
)
|
||||
await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
return cost.dict()
|
||||
|
||||
# this should never happen why do we handle this???
|
||||
if cost_difference > 0:
|
||||
# Need to charge more than reserved, finalize by releasing reservation and charging total
|
||||
logger.info(
|
||||
"Additional charge required for token usage",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"additional_charge": cost_difference,
|
||||
"current_balance": key.balance,
|
||||
"sufficient_balance": key.balance >= cost_difference,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
finalize_stmt = (
|
||||
update(TemporaryCredit)
|
||||
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(TemporaryCredit.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(TemporaryCredit.balance) - total_cost_msats,
|
||||
)
|
||||
)
|
||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount:
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Finalized payment with additional charge",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"charged_amount": total_cost_msats,
|
||||
"new_balance": key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to finalize additional charge (concurrent operation)",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"attempted_charge": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Refund some of the base cost
|
||||
refund = abs(cost_difference)
|
||||
logger.info(
|
||||
"Refunding excess payment",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"refund_amount": refund,
|
||||
"current_balance": key.balance,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
refund_stmt = (
|
||||
update(TemporaryCredit)
|
||||
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
|
||||
.values(
|
||||
reserved_balance=col(TemporaryCredit.reserved_balance)
|
||||
- deducted_max_cost,
|
||||
balance=col(TemporaryCredit.balance) - total_cost_msats,
|
||||
)
|
||||
)
|
||||
result = await session.exec(refund_stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
logger.error(
|
||||
"Failed to finalize payment - insufficient reserved balance",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"deducted_max_cost": deducted_max_cost,
|
||||
"current_reserved_balance": key.reserved_balance,
|
||||
"total_cost": total_cost_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
# Still return the cost data even if we couldn't properly finalize
|
||||
# The reservation was already made, so the user has paid
|
||||
|
||||
cost.total_msats = total_cost_msats
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"Refund processed successfully",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"refunded_amount": refund,
|
||||
"new_balance": key.balance,
|
||||
"final_cost": cost.total_msats,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
|
||||
return cost.dict()
|
||||
|
||||
case CostDataError() as error:
|
||||
logger.error(
|
||||
"Cost calculation error during payment adjustment",
|
||||
extra={
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"model": model,
|
||||
"error_message": error.message,
|
||||
"error_code": error.code,
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"message": error.message,
|
||||
"type": "invalid_request_error",
|
||||
"code": error.code,
|
||||
}
|
||||
},
|
||||
)
|
||||
# Fallback return to satisfy type checker; execution should not reach here
|
||||
return {
|
||||
"base_msats": deducted_max_cost,
|
||||
"input_msats": 0,
|
||||
"output_msats": 0,
|
||||
"total_msats": deducted_max_cost,
|
||||
}
|
||||
|
||||
@@ -305,12 +305,3 @@ async def raw_send_to_lnurl(
|
||||
quote_id=melt_quote_resp.quote,
|
||||
)
|
||||
return final_amount
|
||||
|
||||
|
||||
async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
|
||||
from .wallet import get_wallet
|
||||
|
||||
wallet = await get_wallet(mint, unit)
|
||||
proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id]
|
||||
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
|
||||
return await raw_send_to_lnurl(wallet, proofs, address, unit)
|
||||
|
||||
@@ -2,15 +2,15 @@ import asyncio
|
||||
import json
|
||||
import random
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic.v1 import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..core.db import ModelRow, create_session, get_session
|
||||
from ..core.db import ModelRow, get_session
|
||||
from ..core.logging import get_logger
|
||||
from ..core.settings import settings
|
||||
from ..payment.price import sats_usd_price
|
||||
from .price import sats_usd_price
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -28,10 +28,12 @@ class Architecture(BaseModel):
|
||||
class Pricing(BaseModel):
|
||||
prompt: float
|
||||
completion: float
|
||||
request: float
|
||||
image: float
|
||||
web_search: float
|
||||
internal_reasoning: float
|
||||
request: float = 0.0
|
||||
image: float = 0.0
|
||||
web_search: float = 0.0
|
||||
internal_reasoning: float = 0.0
|
||||
input_cache_read: float = 0.0
|
||||
input_cache_write: float = 0.0
|
||||
max_prompt_cost: float = 0.0 # in sats not msats
|
||||
max_completion_cost: float = 0.0 # in sats not msats
|
||||
max_cost: float = 0.0 # in sats not msats
|
||||
@@ -55,7 +57,7 @@ class Model(BaseModel):
|
||||
per_request_limits: dict | None = None
|
||||
top_provider: TopProvider | None = None
|
||||
enabled: bool = True
|
||||
upstream_provider_id: int | None = None
|
||||
upstream_provider_id: int | str | None = None
|
||||
canonical_slug: str | None = None
|
||||
alias_ids: list[str] | None = None
|
||||
|
||||
@@ -63,6 +65,84 @@ class Model(BaseModel):
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
def _has_valid_pricing(model: dict) -> bool:
|
||||
"""Check if model has valid pricing (not free, no negative values)."""
|
||||
pricing = model.get("pricing", {})
|
||||
if not pricing:
|
||||
return False
|
||||
|
||||
try:
|
||||
prompt = float(pricing.get("prompt", 0))
|
||||
completion = float(pricing.get("completion", 0))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
if prompt < 0 or completion < 0:
|
||||
return False
|
||||
|
||||
if prompt == 0 and completion == 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
|
||||
"""Asynchronously fetch model information from OpenRouter API."""
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
models_response, embeddings_response = await asyncio.gather(
|
||||
client.get(f"{base_url}/models", timeout=30),
|
||||
client.get(f"{base_url}/embeddings/models", timeout=30),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
def process_models_response(
|
||||
response: httpx.Response | BaseException,
|
||||
) -> list[dict]:
|
||||
if not isinstance(response, BaseException):
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return [
|
||||
model
|
||||
for model in data.get("data", [])
|
||||
if ":free" not in model.get("id", "").lower()
|
||||
]
|
||||
return []
|
||||
|
||||
models_data: list[dict] = []
|
||||
models_data.extend(process_models_response(models_response))
|
||||
models_data.extend(process_models_response(embeddings_response))
|
||||
|
||||
# Apply source filter and exclusions
|
||||
filtered_models = []
|
||||
for model in models_data:
|
||||
model_id = model.get("id", "")
|
||||
|
||||
if source_filter:
|
||||
source_prefix = f"{source_filter}/"
|
||||
if not model_id.startswith(source_prefix):
|
||||
continue
|
||||
|
||||
model = dict(model)
|
||||
model["id"] = model_id[len(source_prefix) :]
|
||||
model_id = model["id"]
|
||||
|
||||
if "(free)" in model.get("name", ""):
|
||||
continue
|
||||
|
||||
if not _has_valid_pricing(model):
|
||||
continue
|
||||
|
||||
filtered_models.append(model)
|
||||
|
||||
return filtered_models
|
||||
except Exception as e:
|
||||
logger.error(f"Error (async) fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def is_openrouter_upstream() -> bool:
|
||||
try:
|
||||
base = (settings.upstream_base_url or "").strip().rstrip("/")
|
||||
@@ -71,7 +151,125 @@ def is_openrouter_upstream() -> bool:
|
||||
return base.lower() == "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
|
||||
def _row_to_model(
|
||||
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
|
||||
) -> Model:
|
||||
architecture = json.loads(row.architecture)
|
||||
pricing = json.loads(row.pricing)
|
||||
per_request_limits = (
|
||||
json.loads(row.per_request_limits) if row.per_request_limits else None
|
||||
)
|
||||
top_provider_dict = json.loads(row.top_provider) if row.top_provider else None
|
||||
|
||||
if apply_provider_fee and isinstance(pricing, dict):
|
||||
pricing = {k: float(v) * provider_fee for k, v in pricing.items()}
|
||||
|
||||
if isinstance(pricing, dict) and float(pricing.get("request", 0.0)) <= 0.0:
|
||||
pricing["request"] = max(pricing.get("request", 0.0), 0.0)
|
||||
|
||||
parsed_pricing = Pricing.parse_obj(pricing)
|
||||
model = Model(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
created=row.created,
|
||||
description=row.description,
|
||||
context_length=row.context_length,
|
||||
architecture=Architecture.parse_obj(architecture),
|
||||
pricing=parsed_pricing,
|
||||
sats_pricing=None,
|
||||
per_request_limits=per_request_limits,
|
||||
top_provider=TopProvider.parse_obj(top_provider_dict)
|
||||
if top_provider_dict
|
||||
else None,
|
||||
enabled=row.enabled,
|
||||
upstream_provider_id=row.upstream_provider_id,
|
||||
canonical_slug=getattr(row, "canonical_slug", None),
|
||||
alias_ids=json.loads(row.alias_ids) if row.alias_ids else None,
|
||||
)
|
||||
|
||||
if apply_provider_fee:
|
||||
(
|
||||
parsed_pricing.max_prompt_cost,
|
||||
parsed_pricing.max_completion_cost,
|
||||
parsed_pricing.max_cost,
|
||||
) = _calculate_usd_max_costs(model)
|
||||
|
||||
try:
|
||||
sats_to_usd = sats_usd_price()
|
||||
model = _update_model_sats_pricing(model, sats_to_usd)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not calculate sats pricing: {e}")
|
||||
|
||||
return model
|
||||
|
||||
|
||||
def _model_to_row_payload(model: Model) -> dict[str, str | int | bool | None]:
|
||||
return {
|
||||
"id": model.id,
|
||||
"name": model.name,
|
||||
"created": model.created,
|
||||
"description": model.description,
|
||||
"context_length": model.context_length,
|
||||
"architecture": json.dumps(model.architecture.dict()),
|
||||
"pricing": json.dumps(model.pricing.dict()),
|
||||
"sats_pricing": json.dumps(model.sats_pricing.dict())
|
||||
if model.sats_pricing
|
||||
else None,
|
||||
"per_request_limits": json.dumps(model.per_request_limits)
|
||||
if model.per_request_limits is not None
|
||||
else None,
|
||||
"top_provider": json.dumps(model.top_provider.dict())
|
||||
if model.top_provider is not None
|
||||
else None,
|
||||
"enabled": model.enabled,
|
||||
"upstream_provider_id": model.upstream_provider_id,
|
||||
}
|
||||
|
||||
|
||||
async def list_models(
|
||||
session: AsyncSession,
|
||||
upstream_id: int,
|
||||
include_disabled: bool = False,
|
||||
) -> list[Model]:
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
query = select(ModelRow)
|
||||
if upstream_id is not None:
|
||||
query = query.where(ModelRow.upstream_provider_id == upstream_id)
|
||||
if not include_disabled:
|
||||
query = query.where(ModelRow.enabled)
|
||||
|
||||
rows = (await session.exec(query)).all() # type: ignore
|
||||
provider_result = await session.exec(select(UpstreamProviderRow))
|
||||
providers_by_id = {p.id: p for p in provider_result.all()}
|
||||
return [
|
||||
_row_to_model(
|
||||
r,
|
||||
apply_provider_fee=True,
|
||||
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
|
||||
if r.upstream_provider_id in providers_by_id
|
||||
else 1.01,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def get_model_by_id(
|
||||
model_id: str, provider_id: int, session: AsyncSession
|
||||
) -> Model | None:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
row = await session.get(ModelRow, (model_id, provider_id))
|
||||
if not row or not row.enabled:
|
||||
return None
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
provider_fee = provider.provider_fee if provider else 1.01
|
||||
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
|
||||
|
||||
|
||||
def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
|
||||
"""Calculate max costs in USD based on model context/token limits.
|
||||
|
||||
Args:
|
||||
@@ -184,7 +382,7 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
|
||||
|
||||
async def _update_sats_pricing_once() -> None:
|
||||
"""Update sats pricing once for all provider models (in-memory only)."""
|
||||
from ..proxy import get_upstreams
|
||||
from ..proxy import get_upstreams, refresh_model_maps
|
||||
|
||||
upstreams = get_upstreams()
|
||||
sats_to_usd = sats_usd_price()
|
||||
@@ -201,6 +399,7 @@ async def _update_sats_pricing_once() -> None:
|
||||
|
||||
if updated_count > 0:
|
||||
logger.info("Updated sats pricing", extra={"models_updated": updated_count})
|
||||
await refresh_model_maps()
|
||||
|
||||
|
||||
async def update_sats_pricing() -> None:
|
||||
@@ -211,7 +410,13 @@ async def update_sats_pricing() -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await _update_sats_pricing_once()
|
||||
try:
|
||||
await _update_sats_pricing_once()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Initial sats pricing update failed (will retry in loop)",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -235,137 +440,6 @@ async def update_sats_pricing() -> None:
|
||||
logger.error(f"Error updating sats pricing: {e}")
|
||||
|
||||
|
||||
async def cleanup_enabled_models_periodically() -> None:
|
||||
"""Background task to clean up enabled models that match upstream pricing.
|
||||
|
||||
When model is enabled (enabled=True), remove it from DB if it matches upstream pricing.
|
||||
Keep it in DB only if pricing differs from upstream or if it's disabled.
|
||||
"""
|
||||
interval = getattr(
|
||||
settings, "models_cleanup_interval_seconds", 300
|
||||
) # 5 minutes default
|
||||
if not interval or interval <= 0:
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
await _cleanup_enabled_models_once()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error during enabled models cleanup",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
try:
|
||||
jitter = max(0.0, float(interval) * 0.1)
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
|
||||
async def _cleanup_enabled_models_once() -> None:
|
||||
"""Clean up enabled models that match upstream pricing."""
|
||||
from ..proxy import get_upstreams
|
||||
|
||||
async with create_session() as session:
|
||||
# Get all enabled models from DB
|
||||
result = await session.exec(
|
||||
select(ModelRow).where(
|
||||
ModelRow.enabled, # Only enabled models
|
||||
)
|
||||
)
|
||||
db_models = result.all()
|
||||
|
||||
if not db_models:
|
||||
return
|
||||
|
||||
upstreams = get_upstreams()
|
||||
models_to_remove = []
|
||||
|
||||
for db_model in db_models:
|
||||
# Find corresponding upstream model
|
||||
upstream_model = None
|
||||
for upstream in upstreams:
|
||||
upstream_model = upstream.get_cached_model_by_id(db_model.id)
|
||||
if upstream_model:
|
||||
break
|
||||
|
||||
if not upstream_model:
|
||||
continue
|
||||
|
||||
# Compare pricing to see if they match
|
||||
db_pricing = json.loads(db_model.pricing)
|
||||
upstream_pricing = upstream_model.pricing.dict()
|
||||
|
||||
# Check if pricing matches (with small tolerance for float comparison)
|
||||
pricing_matches = _pricing_matches(db_pricing, upstream_pricing)
|
||||
|
||||
if pricing_matches:
|
||||
models_to_remove.append(db_model)
|
||||
logger.info(
|
||||
f"Removing enabled model {db_model.id} - matches upstream pricing",
|
||||
extra={"model_id": db_model.id},
|
||||
)
|
||||
|
||||
# Remove models that match upstream pricing
|
||||
for model in models_to_remove:
|
||||
await session.delete(model)
|
||||
|
||||
if models_to_remove:
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Cleaned up {len(models_to_remove)} enabled models that match upstream pricing"
|
||||
)
|
||||
|
||||
|
||||
def _pricing_matches(
|
||||
db_pricing: dict, upstream_pricing: dict, tolerance: float = 0.0
|
||||
) -> bool:
|
||||
"""Check if pricing dictionaries match within tolerance."""
|
||||
keys_to_compare = [
|
||||
"prompt",
|
||||
"completion",
|
||||
"request",
|
||||
"image",
|
||||
"web_search",
|
||||
"internal_reasoning",
|
||||
]
|
||||
|
||||
for key in keys_to_compare:
|
||||
db_val = int(float(db_pricing.get(key, 0.0)) * 1000000)
|
||||
upstream_val = int(float(upstream_pricing.get(key, 0.0)) * 1000000)
|
||||
|
||||
if abs(db_val - upstream_val) > tolerance:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _model_to_row_payload(model: Model) -> dict[str, str | int | bool | None]:
|
||||
return {
|
||||
"id": model.id,
|
||||
"name": model.name,
|
||||
"created": model.created,
|
||||
"description": model.description,
|
||||
"context_length": model.context_length,
|
||||
"architecture": json.dumps(model.architecture.dict()),
|
||||
"pricing": json.dumps(model.pricing.dict()),
|
||||
"sats_pricing": json.dumps(model.sats_pricing.dict())
|
||||
if model.sats_pricing
|
||||
else None,
|
||||
"per_request_limits": json.dumps(model.per_request_limits)
|
||||
if model.per_request_limits is not None
|
||||
else None,
|
||||
"top_provider": json.dumps(model.top_provider.dict())
|
||||
if model.top_provider is not None
|
||||
else None,
|
||||
"enabled": model.enabled,
|
||||
"upstream_provider_id": model.upstream_provider_id,
|
||||
}
|
||||
|
||||
|
||||
@models_router.get("/v1/models")
|
||||
@models_router.get("/models", include_in_schema=False)
|
||||
async def models(session: AsyncSession = Depends(get_session)) -> dict:
|
||||
@@ -110,10 +110,6 @@ async def _update_prices() -> None:
|
||||
return
|
||||
BTC_USD_PRICE = btc_price
|
||||
SATS_USD_PRICE = btc_price / 100_000_000
|
||||
logger.info(
|
||||
"Updated BTC/USD price",
|
||||
extra={"btc_usd": btc_price, "sats_usd": SATS_USD_PRICE},
|
||||
)
|
||||
|
||||
|
||||
def btc_usd_price() -> float:
|
||||
|
||||
+215
-102
@@ -5,20 +5,24 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from sqlmodel import select
|
||||
|
||||
from .auth import get_credit
|
||||
from .algorithm import create_model_mappings
|
||||
from .auth import pay_for_request, revert_pay_for_request, validate_bearer_key
|
||||
from .core import get_logger
|
||||
from .core.db import (
|
||||
ApiKey,
|
||||
AsyncSession,
|
||||
ModelRow,
|
||||
UpstreamProviderRow,
|
||||
create_session,
|
||||
get_session,
|
||||
)
|
||||
from .models.algorithm import create_model_mappings
|
||||
from .models.models import Model
|
||||
from .payment.cashu import pre_check_header_token_balance
|
||||
from .payment.cost import calculate_discounted_max_cost, get_max_cost_for_model
|
||||
from .payment.helpers import pay_for_request, revert_pay_for_request
|
||||
from .payment.helpers import (
|
||||
calculate_discounted_max_cost,
|
||||
check_token_balance,
|
||||
create_error_response,
|
||||
get_max_cost_for_model,
|
||||
)
|
||||
from .payment.models import Model
|
||||
from .upstream import BaseUpstreamProvider
|
||||
from .upstream.helpers import init_upstreams
|
||||
|
||||
@@ -61,12 +65,12 @@ def get_upstreams() -> list[BaseUpstreamProvider]:
|
||||
|
||||
def get_model_instance(model_id: str) -> Model | None:
|
||||
"""Get Model instance by ID from global cache."""
|
||||
return _model_instances.get(model_id)
|
||||
return _model_instances.get(model_id.lower())
|
||||
|
||||
|
||||
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
|
||||
"""Get UpstreamProvider for model ID from global cache."""
|
||||
return _provider_map.get(model_id)
|
||||
return _provider_map.get(model_id.lower())
|
||||
|
||||
|
||||
def get_unique_models() -> list[Model]:
|
||||
@@ -76,31 +80,27 @@ def get_unique_models() -> list[Model]:
|
||||
|
||||
async def refresh_model_maps() -> None:
|
||||
"""Refresh global model and provider maps using the cost-based algorithm."""
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
global _model_instances, _provider_map, _unique_models
|
||||
|
||||
# Gather database overrides and disabled models
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(ModelRow).where(ModelRow.enabled))
|
||||
override_rows = result.all()
|
||||
|
||||
provider_result = await session.exec(select(UpstreamProviderRow))
|
||||
providers_by_id = {p.id: p for p in provider_result.all()}
|
||||
|
||||
overrides_by_id: dict[str, tuple[ModelRow, float]] = {
|
||||
row.id: (
|
||||
row,
|
||||
providers_by_id[row.upstream_provider_id].provider_fee
|
||||
if row.upstream_provider_id in providers_by_id
|
||||
else 1.01,
|
||||
)
|
||||
for row in override_rows
|
||||
if row.upstream_provider_id is not None
|
||||
}
|
||||
|
||||
disabled_result = await session.exec(
|
||||
select(ModelRow.id).where(ModelRow.enabled == False) # noqa: E712
|
||||
# Fetch all providers with their models in a single logical operation
|
||||
query = select(UpstreamProviderRow).options(
|
||||
selectinload(UpstreamProviderRow.models) # type: ignore
|
||||
)
|
||||
disabled_model_ids = {row for row in disabled_result.all()}
|
||||
result = await session.exec(query)
|
||||
provider_rows = result.all()
|
||||
|
||||
overrides_by_id: dict[str, tuple[ModelRow, float]] = {}
|
||||
disabled_model_ids: set[str] = set()
|
||||
|
||||
for provider in provider_rows:
|
||||
for model in provider.models:
|
||||
if model.enabled:
|
||||
overrides_by_id[model.id] = (model, provider.provider_fee)
|
||||
else:
|
||||
disabled_model_ids.add(model.id)
|
||||
|
||||
_model_instances, _provider_map, _unique_models = create_model_mappings(
|
||||
upstreams=_upstreams,
|
||||
@@ -126,89 +126,102 @@ async def refresh_model_maps_periodically() -> None:
|
||||
)
|
||||
|
||||
|
||||
async def parse_request(request: Request) -> tuple[dict, dict, bytes]:
|
||||
headers = dict(request.headers)
|
||||
body_bytes = await request.body()
|
||||
body_dict = parse_request_body_json(body_bytes)
|
||||
return headers, body_dict, body_bytes
|
||||
|
||||
|
||||
def ensure_authorization(headers: dict) -> None:
|
||||
if "x-cashu" not in headers and "authorization" not in headers.keys():
|
||||
# return create_error_response("unauthorized", "Unauthorized", 401, request=request)
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
def log_request(request: Request, path: str) -> None:
|
||||
logger.info(
|
||||
"Received proxy request",
|
||||
extra={
|
||||
"method": request.method,
|
||||
"path": path,
|
||||
"client_host": request.client.host if request.client else "unknown",
|
||||
"user_agent": request.headers.get("user-agent", "unknown")[:100],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_model_and_upstream(request_body: dict) -> tuple[Model, BaseUpstreamProvider]:
|
||||
model_id = request_body.get("model", "unknown")
|
||||
|
||||
if not (model := get_model_instance(model_id)):
|
||||
raise HTTPException(status_code=400, detail=f"Model '{model_id}' not found")
|
||||
|
||||
if not (upstream := get_provider_for_model(model_id)):
|
||||
raise HTTPException(status_code=400, detail=f"No provider with '{model_id}'")
|
||||
|
||||
return model, upstream
|
||||
|
||||
|
||||
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
|
||||
async def proxy(
|
||||
request: Request,
|
||||
path: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
request: Request, path: str, session: AsyncSession = Depends(get_session)
|
||||
) -> Response | StreamingResponse:
|
||||
headers, body_dict, body_bytes = await parse_request(request)
|
||||
headers = dict(request.headers)
|
||||
|
||||
ensure_authorization(headers)
|
||||
|
||||
log_request(request, path)
|
||||
|
||||
model, upstream = get_model_and_upstream(body_dict)
|
||||
|
||||
_max_cost_for_model = get_max_cost_for_model(model_obj=model)
|
||||
max_cost_for_model = await calculate_discounted_max_cost(
|
||||
_max_cost_for_model, body_dict, model_obj=model
|
||||
)
|
||||
pre_check_header_token_balance(headers, body_dict, max_cost_for_model)
|
||||
|
||||
if x_cashu := headers.get("x-cashu", None):
|
||||
return await upstream.handle_x_cashu(
|
||||
request, x_cashu, path, max_cost_for_model, model
|
||||
if "x-cashu" not in headers and "authorization" not in headers.keys():
|
||||
return create_error_response(
|
||||
"unauthorized", "Unauthorized", 401, request=request
|
||||
)
|
||||
|
||||
elif auth := headers.get("authorization", None):
|
||||
key = await get_credit(auth, session)
|
||||
is_responses_api = path.startswith("v1/responses") or path.startswith("responses")
|
||||
request_body = await request.body()
|
||||
request_body_dict = parse_request_body_json(request_body, path)
|
||||
|
||||
# Only pay for request if we have request body data (for completions endpoints)
|
||||
if body_dict:
|
||||
if is_responses_api:
|
||||
model_id = extract_model_from_responses_request(request_body_dict)
|
||||
else:
|
||||
model_id = request_body_dict.get("model", "unknown")
|
||||
|
||||
model_obj = get_model_instance(model_id)
|
||||
if not model_obj:
|
||||
return create_error_response(
|
||||
"invalid_model", f"Model '{model_id}' not found", 400, request=request
|
||||
)
|
||||
|
||||
upstream = get_provider_for_model(model_id)
|
||||
if not upstream:
|
||||
return create_error_response(
|
||||
"invalid_model",
|
||||
f"No provider found for model '{model_id}'",
|
||||
400,
|
||||
request=request,
|
||||
)
|
||||
|
||||
_max_cost_for_model = await get_max_cost_for_model(
|
||||
model=model_id, session=session, model_obj=model_obj
|
||||
)
|
||||
max_cost_for_model = await calculate_discounted_max_cost(
|
||||
_max_cost_for_model, request_body_dict, model_obj=model_obj
|
||||
)
|
||||
check_token_balance(headers, request_body_dict, max_cost_for_model)
|
||||
|
||||
if x_cashu := headers.get("x-cashu", None):
|
||||
if is_responses_api:
|
||||
return await upstream.handle_x_cashu_responses(
|
||||
request, x_cashu, path, max_cost_for_model, model_obj
|
||||
)
|
||||
else:
|
||||
return await upstream.handle_x_cashu(
|
||||
request, x_cashu, path, max_cost_for_model, model_obj
|
||||
)
|
||||
|
||||
elif auth := headers.get("authorization", None):
|
||||
key = await get_bearer_token_key(headers, path, session, auth)
|
||||
|
||||
else:
|
||||
if request.method not in ["GET"]:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {"type": "invalid_request_error", "code": "unauthorized"}
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug("Processing unauthenticated GET request", extra={"path": path})
|
||||
headers = upstream.prepare_headers(dict(request.headers))
|
||||
return await upstream.forward_get_request(request, path, headers)
|
||||
|
||||
if request_body_dict:
|
||||
await pay_for_request(key, max_cost_for_model, session)
|
||||
|
||||
# Prepare headers for upstream
|
||||
headers = upstream.prepare_headers(dict(request.headers))
|
||||
|
||||
# Forward to upstream and handle response
|
||||
response = await upstream.forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
body_bytes,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model,
|
||||
)
|
||||
if is_responses_api:
|
||||
response = await upstream.forward_responses_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
else:
|
||||
response = await upstream.forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||
@@ -231,7 +244,105 @@ async def proxy(
|
||||
return response
|
||||
|
||||
|
||||
def parse_request_body_json(request_body: bytes) -> dict[str, Any]:
|
||||
async def get_bearer_token_key(
|
||||
headers: dict, path: str, session: AsyncSession, auth: str
|
||||
) -> ApiKey:
|
||||
"""Handle bearer token authentication proxy requests."""
|
||||
bearer_key = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
|
||||
refund_address = headers.get("Refund-LNURL", None)
|
||||
key_expiry_time = headers.get("Key-Expiry-Time", None)
|
||||
|
||||
logger.debug(
|
||||
"Processing bearer token",
|
||||
extra={
|
||||
"path": path,
|
||||
"has_refund_address": bool(refund_address),
|
||||
"has_expiry_time": bool(key_expiry_time),
|
||||
"bearer_key_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
},
|
||||
)
|
||||
|
||||
# Validate key_expiry_time header
|
||||
if key_expiry_time:
|
||||
try:
|
||||
key_expiry_time = int(key_expiry_time) # type: ignore
|
||||
logger.debug(
|
||||
"Key expiry time validated",
|
||||
extra={"expiry_time": key_expiry_time, "path": path},
|
||||
)
|
||||
except ValueError:
|
||||
logger.error(
|
||||
"Invalid Key-Expiry-Time header",
|
||||
extra={"key_expiry_time": key_expiry_time, "path": path},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid Key-Expiry-Time: must be a valid Unix timestamp",
|
||||
)
|
||||
if not refund_address:
|
||||
logger.error(
|
||||
"Missing Refund-LNURL header with Key-Expiry-Time",
|
||||
extra={"path": path, "expiry_time": key_expiry_time},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Error: Refund-LNURL header required when using Key-Expiry-Time",
|
||||
)
|
||||
else:
|
||||
key_expiry_time = None
|
||||
|
||||
try:
|
||||
key = await validate_bearer_key(
|
||||
bearer_key,
|
||||
session,
|
||||
refund_address,
|
||||
key_expiry_time, # type: ignore
|
||||
)
|
||||
logger.info(
|
||||
"Bearer token validated successfully",
|
||||
extra={
|
||||
"path": path,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
"key_balance": key.balance,
|
||||
},
|
||||
)
|
||||
return key
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Bearer token validation failed",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"path": path,
|
||||
"bearer_key_preview": bearer_key[:20] + "..."
|
||||
if len(bearer_key) > 20
|
||||
else bearer_key,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def extract_model_from_responses_request(request_body_dict: dict[str, Any]) -> str:
|
||||
if model := request_body_dict.get("model"):
|
||||
return model
|
||||
|
||||
if input_data := request_body_dict.get("input"):
|
||||
if isinstance(input_data, dict) and (model := input_data.get("model")):
|
||||
return model
|
||||
|
||||
if request_body_dict.get("messages"):
|
||||
return "unknown"
|
||||
|
||||
logger.warning(
|
||||
"No model found in Responses API request",
|
||||
extra={"body_keys": list(request_body_dict.keys())}
|
||||
)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def parse_request_body_json(request_body: bytes, path: str) -> dict[str, Any]:
|
||||
request_body_dict = {}
|
||||
if request_body:
|
||||
try:
|
||||
@@ -251,6 +362,7 @@ def parse_request_body_json(request_body: bytes) -> dict[str, Any]:
|
||||
logger.debug(
|
||||
"Request body parsed",
|
||||
extra={
|
||||
"path": path,
|
||||
"body_keys": list(request_body_dict.keys()),
|
||||
"model": request_body_dict.get("model", "not_specified"),
|
||||
},
|
||||
@@ -260,6 +372,7 @@ def parse_request_body_json(request_body: bytes) -> dict[str, Any]:
|
||||
"Invalid JSON in request body",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"path": path,
|
||||
"body_preview": request_body[:200].decode(errors="ignore")
|
||||
if request_body
|
||||
else "empty",
|
||||
|
||||
@@ -2,24 +2,28 @@ from .anthropic import AnthropicUpstreamProvider
|
||||
from .azure import AzureUpstreamProvider
|
||||
from .base import BaseUpstreamProvider
|
||||
from .fireworks import FireworksUpstreamProvider
|
||||
from .gemini import GeminiUpstreamProvider
|
||||
from .generic import GenericUpstreamProvider
|
||||
from .groq import GroqUpstreamProvider
|
||||
from .ollama import OllamaUpstreamProvider
|
||||
from .openai import OpenAIUpstreamProvider
|
||||
from .openrouter import OpenRouterUpstreamProvider
|
||||
from .perplexity import PerplexityUpstreamProvider
|
||||
from .ppqai import PPQAIUpstreamProvider
|
||||
from .xai import XAIUpstreamProvider
|
||||
|
||||
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
AnthropicUpstreamProvider,
|
||||
AzureUpstreamProvider,
|
||||
FireworksUpstreamProvider,
|
||||
GeminiUpstreamProvider,
|
||||
GenericUpstreamProvider,
|
||||
GroqUpstreamProvider,
|
||||
OllamaUpstreamProvider,
|
||||
OpenAIUpstreamProvider,
|
||||
OpenRouterUpstreamProvider,
|
||||
PerplexityUpstreamProvider,
|
||||
PPQAIUpstreamProvider,
|
||||
XAIUpstreamProvider,
|
||||
]
|
||||
"""List of all upstream classes"""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..models import Model, async_fetch_openrouter_models
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
+1342
-115
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
from .gemini import GeminiClient
|
||||
|
||||
__all__ = ["GeminiClient"]
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
|
||||
class BaseAPIClient(ABC):
|
||||
"""Base class for AI provider API clients."""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str | None = None):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
||||
@abstractmethod
|
||||
async def generate_content(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate content non-streaming."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def generate_content_stream(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_models(self) -> list[dict[str, Any]]:
|
||||
"""List available models."""
|
||||
pass
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from .base import BaseAPIClient
|
||||
|
||||
|
||||
class GeminiClient(BaseAPIClient):
|
||||
"""Gemini API client using OpenAI compatibility layer."""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str | None = None):
|
||||
super().__init__(api_key, base_url)
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url
|
||||
or "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
)
|
||||
|
||||
async def generate_content(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
from openai import NOT_GIVEN
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages, # type: ignore
|
||||
temperature=temperature if temperature is not None else NOT_GIVEN,
|
||||
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
|
||||
top_p=kwargs.get("top_p", NOT_GIVEN),
|
||||
)
|
||||
return response.model_dump()
|
||||
|
||||
async def generate_content_stream(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
from openai import NOT_GIVEN
|
||||
|
||||
usage_callback = kwargs.get("usage_callback")
|
||||
completion_callback = kwargs.get("completion_callback")
|
||||
|
||||
stream = await self.client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages, # type: ignore
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
temperature=temperature if temperature is not None else NOT_GIVEN,
|
||||
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
|
||||
top_p=kwargs.get("top_p", NOT_GIVEN),
|
||||
)
|
||||
|
||||
final_usage = None
|
||||
|
||||
async for chunk in stream:
|
||||
chunk_data = chunk.model_dump()
|
||||
|
||||
if chunk.usage:
|
||||
final_usage = chunk.usage.model_dump()
|
||||
if usage_callback:
|
||||
usage_callback(final_usage)
|
||||
|
||||
yield chunk_data
|
||||
|
||||
if completion_callback:
|
||||
await completion_callback(model, final_usage)
|
||||
|
||||
async def list_models(self) -> list[dict[str, Any]]:
|
||||
"""List available Gemini models."""
|
||||
try:
|
||||
response = await self.client.models.list()
|
||||
return [model.model_dump() for model in response.data]
|
||||
except Exception as e:
|
||||
from ...core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"Failed to list Gemini models: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,283 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from .base import BaseUpstreamProvider
|
||||
from .clients.gemini import GeminiClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GeminiUpstreamProvider(BaseUpstreamProvider):
|
||||
provider_type = "gemini"
|
||||
default_base_url = "https://generativelanguage.googleapis.com/v1beta"
|
||||
platform_url = "https://aistudio.google.com/app/apikey"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://generativelanguage.googleapis.com/v1beta",
|
||||
api_key: str = "",
|
||||
provider_fee: float = 1.01,
|
||||
):
|
||||
super().__init__(
|
||||
api_key=api_key,
|
||||
provider_fee=provider_fee,
|
||||
base_url=base_url,
|
||||
)
|
||||
self._client: GeminiClient | None = None
|
||||
|
||||
@property
|
||||
def client(self) -> GeminiClient:
|
||||
"""Get or create the Gemini API client."""
|
||||
if self._client is None:
|
||||
self._client = GeminiClient(api_key=self.api_key)
|
||||
return self._client
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "GeminiUpstreamProvider":
|
||||
return cls(
|
||||
base_url=provider_row.base_url,
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "Google Gemini",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
return model_id.removeprefix("gemini/")
|
||||
|
||||
async def forward_request(
|
||||
self,
|
||||
request: Request,
|
||||
path: str,
|
||||
headers: dict,
|
||||
request_body: bytes | None,
|
||||
key: ApiKey,
|
||||
max_cost_for_model: int,
|
||||
session: AsyncSession,
|
||||
model_obj: Model,
|
||||
) -> Response | StreamingResponse:
|
||||
# Remove provider prefix from model ID for Gemini API
|
||||
if "/" in model_obj.id:
|
||||
model_obj.id = model_obj.id.split("/", 1)[1]
|
||||
|
||||
if not path.startswith("chat/completions"):
|
||||
return await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
if not request_body:
|
||||
return await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
try:
|
||||
openai_data = json.loads(request_body)
|
||||
messages = openai_data.get("messages", [])
|
||||
temperature = openai_data.get("temperature")
|
||||
max_tokens = openai_data.get("max_tokens")
|
||||
top_p = openai_data.get("top_p")
|
||||
is_streaming = openai_data.get("stream", False)
|
||||
|
||||
logger.info(
|
||||
"Processing Gemini request with client abstraction",
|
||||
extra={
|
||||
"model": model_obj.id,
|
||||
"is_streaming": is_streaming,
|
||||
"message_count": len(messages),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
if is_streaming:
|
||||
final_usage_data: dict | None = None
|
||||
|
||||
def usage_callback(usage_data: dict[str, Any]) -> None:
|
||||
"""Callback to capture usage data during streaming"""
|
||||
nonlocal final_usage_data
|
||||
final_usage_data = usage_data
|
||||
|
||||
async def completion_callback(
|
||||
model: str, usage_data: dict[str, Any] | None
|
||||
) -> None:
|
||||
"""Callback to handle payment when streaming completes"""
|
||||
nonlocal final_usage_data
|
||||
if usage_data:
|
||||
final_usage_data = usage_data
|
||||
|
||||
payment_data = {
|
||||
"model": model,
|
||||
"usage": final_usage_data,
|
||||
}
|
||||
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
from ..core.db import create_session
|
||||
|
||||
async with create_session() as new_session:
|
||||
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
||||
if fresh_key:
|
||||
try:
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
fresh_key,
|
||||
payment_data,
|
||||
new_session,
|
||||
max_cost_for_model,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Gemini streaming payment finalized",
|
||||
extra={
|
||||
"cost_data": cost_data,
|
||||
"usage_data": final_usage_data,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
except Exception as cost_error:
|
||||
logger.error(
|
||||
"Error finalizing Gemini streaming payment",
|
||||
extra={
|
||||
"error": str(cost_error),
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
response_generator = self.client.generate_content_stream(
|
||||
model=model_obj.id,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
usage_callback=usage_callback,
|
||||
completion_callback=completion_callback,
|
||||
)
|
||||
|
||||
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
|
||||
try:
|
||||
async for chunk in response_generator:
|
||||
sse_data = f"data: {json.dumps(chunk)}\n\n"
|
||||
yield sse_data.encode()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in Gemini streaming response",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
return StreamingResponse(
|
||||
stream_with_cost(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||
)
|
||||
|
||||
else:
|
||||
openai_format_response = await self.client.generate_content(
|
||||
model=model_obj.id,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
)
|
||||
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
key, openai_format_response, session, max_cost_for_model
|
||||
)
|
||||
openai_format_response["cost"] = cost_data
|
||||
|
||||
logger.info(
|
||||
"Gemini non-streaming payment completed",
|
||||
extra={
|
||||
"cost_data": cost_data,
|
||||
"model": model_obj.id,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=json.dumps(openai_format_response),
|
||||
media_type="application/json",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in Gemini forward_request",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"path": path,
|
||||
"key_hash": key.hashed_key[:8] + "...",
|
||||
},
|
||||
)
|
||||
return await super().forward_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
request_body,
|
||||
key,
|
||||
max_cost_for_model,
|
||||
session,
|
||||
model_obj,
|
||||
)
|
||||
|
||||
async def _fetch_provider_models(self) -> dict:
|
||||
"""Fetch models from Gemini API."""
|
||||
try:
|
||||
models_data = await self.client.list_models()
|
||||
|
||||
for model in models_data:
|
||||
if "id" in model and model["id"].startswith("models/"):
|
||||
model["id"] = model["id"].removeprefix("models/")
|
||||
|
||||
return {"data": models_data}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to fetch models from Gemini API: {e}",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"base_url": self.base_url,
|
||||
},
|
||||
)
|
||||
return {"data": []}
|
||||
+27
-10
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -7,10 +8,11 @@ from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from ..core.settings import Settings
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
|
||||
from ..models import Model
|
||||
from ..payment.models import Model
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -82,7 +84,7 @@ async def get_all_models_with_overrides(
|
||||
"""
|
||||
from sqlmodel import select
|
||||
|
||||
from ..models.crud import row_to_model
|
||||
from ..payment.models import _row_to_model
|
||||
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(ModelRow).where(ModelRow.enabled))
|
||||
@@ -108,7 +110,7 @@ async def get_all_models_with_overrides(
|
||||
for model in upstream.get_cached_models():
|
||||
if model.id in overrides_by_id:
|
||||
override_row, provider_fee = overrides_by_id[model.id]
|
||||
all_models[model.id] = row_to_model(
|
||||
all_models[model.id] = _row_to_model(
|
||||
override_row, apply_provider_fee=True, provider_fee=provider_fee
|
||||
)
|
||||
elif model.enabled:
|
||||
@@ -145,6 +147,17 @@ async def refresh_upstreams_models_periodically(
|
||||
f"Error refreshing models for {upstream.base_url}",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
try:
|
||||
from ..payment.models import _update_sats_pricing_once
|
||||
|
||||
await _update_sats_pricing_once()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update pricing after model refresh: {e}")
|
||||
from ..proxy import refresh_model_maps
|
||||
|
||||
await refresh_model_maps()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -166,8 +179,6 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
Seeds database with providers from settings if empty, then loads and instantiates
|
||||
provider instances from database records, and refreshes their models cache.
|
||||
"""
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core.settings import settings
|
||||
|
||||
async with create_session() as session:
|
||||
@@ -183,23 +194,29 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
result = await session.exec(select(UpstreamProviderRow))
|
||||
existing_providers = result.all()
|
||||
|
||||
upstreams: list[BaseUpstreamProvider] = []
|
||||
for provider_row in existing_providers:
|
||||
async def _init_single_provider(
|
||||
provider_row: UpstreamProviderRow,
|
||||
) -> BaseUpstreamProvider | None:
|
||||
if not provider_row.enabled:
|
||||
logger.debug(f"Skipping disabled provider: {provider_row.base_url}")
|
||||
continue
|
||||
return None
|
||||
|
||||
provider = _instantiate_provider(provider_row)
|
||||
if provider:
|
||||
await provider.refresh_models_cache()
|
||||
upstreams.append(provider)
|
||||
logger.info(
|
||||
logger.debug(
|
||||
f"Initialized {provider_row.provider_type} provider",
|
||||
extra={
|
||||
"base_url": provider_row.base_url,
|
||||
"models_cached": len(provider.get_cached_models()),
|
||||
},
|
||||
)
|
||||
return provider
|
||||
return None
|
||||
|
||||
tasks = [_init_single_provider(row) for row in existing_providers]
|
||||
results = await asyncio.gather(*tasks)
|
||||
upstreams = [p for p in results if p is not None]
|
||||
|
||||
return upstreams
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ from fastapi.responses import Response, StreamingResponse
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import AsyncSession, TemporaryCredit, UpstreamProviderRow
|
||||
from ..models import Model
|
||||
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
|
||||
from ..payment.models import Model
|
||||
|
||||
from ..core.logging import get_logger
|
||||
|
||||
@@ -73,7 +73,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
path: str,
|
||||
headers: dict,
|
||||
request_body: bytes | None,
|
||||
key: TemporaryCredit,
|
||||
key: ApiKey,
|
||||
max_cost_for_model: int,
|
||||
session: AsyncSession,
|
||||
model_obj: Model,
|
||||
@@ -102,7 +102,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from Ollama API using /api/tags endpoint."""
|
||||
from ..models.models import Architecture, Model, Pricing, TopProvider
|
||||
from ..payment.models import Architecture, Model, Pricing, TopProvider
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
@@ -199,7 +199,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
async def refresh_models_cache(self) -> None:
|
||||
"""Refresh the in-memory models cache from upstream API."""
|
||||
try:
|
||||
from ..models.models import _update_model_sats_pricing
|
||||
from ..payment.models import _update_model_sats_pricing
|
||||
from ..payment.price import sats_usd_price
|
||||
|
||||
models = await self.fetch_models()
|
||||
@@ -252,7 +252,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
Returns:
|
||||
Model with provider fee applied to pricing and max costs calculated
|
||||
"""
|
||||
from ..models.models import Model, Pricing, calculate_usd_max_costs
|
||||
from ..payment.models import Model, Pricing, _calculate_usd_max_costs
|
||||
|
||||
adjusted_pricing = Pricing.parse_obj(
|
||||
{k: v * self.provider_fee for k, v in model.pricing.dict().items()}
|
||||
@@ -278,7 +278,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
|
||||
adjusted_pricing.max_prompt_cost,
|
||||
adjusted_pricing.max_completion_cost,
|
||||
adjusted_pricing.max_cost,
|
||||
) = calculate_usd_max_costs(temp_model)
|
||||
) = _calculate_usd_max_costs(temp_model)
|
||||
|
||||
return Model(
|
||||
id=model.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..models import Model, async_fetch_openrouter_models
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..models import Model, async_fetch_openrouter_models
|
||||
import httpx
|
||||
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -42,9 +44,39 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
"can_show_balance": True,
|
||||
}
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch all OpenRouter models."""
|
||||
models_data = await async_fetch_openrouter_models()
|
||||
return [Model(**model) for model in models_data] # type: ignore
|
||||
models = [Model(**model) for model in models_data] # type: ignore
|
||||
# manual alias for openai/text-embedding-ada-002 due to openrouter api bug
|
||||
for model in models:
|
||||
if model.id == "openai/text-embedding-ada-002":
|
||||
model.alias_ids = ["text-embedding-ada-002-v2"]
|
||||
break
|
||||
return models
|
||||
|
||||
async def get_balance(self) -> float | None:
|
||||
"""Get the current account balance from OpenRouter.
|
||||
|
||||
Returns:
|
||||
Float representing the balance amount (in credits/USD), or None if unavailable.
|
||||
"""
|
||||
url = f"{self.base_url}/credits"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
credits_data = data.get("data", {})
|
||||
total_credits = float(credits_data.get("total_credits", 0.0))
|
||||
total_usage = float(credits_data.get("total_usage", 0.0))
|
||||
|
||||
return total_credits - total_usage
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..models import Model, async_fetch_openrouter_models
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.logging import get_logger
|
||||
from ..payment.models import Architecture, Model, Pricing, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider, TopupData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PPQAIModelPricing(BaseModel):
|
||||
ui: dict[str, float]
|
||||
api: dict[str, float]
|
||||
|
||||
|
||||
class PPQAIModel(BaseModel):
|
||||
id: str
|
||||
provider: str
|
||||
name: str
|
||||
created_at: int
|
||||
context_length: int
|
||||
pricing: PPQAIModelPricing
|
||||
popular: bool
|
||||
|
||||
|
||||
class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider for PPQ.AI API with Lightning Network top-up support."""
|
||||
|
||||
provider_type = "ppqai"
|
||||
default_base_url = "https://api.ppq.ai"
|
||||
platform_url = "https://ppq.ai/api-docs"
|
||||
IGNORED_MODEL_IDS: list[str] = ["auto"]
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.0):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "PPQAIUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "PPQ.AI",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
"can_create_account": True,
|
||||
"can_topup": True,
|
||||
"can_show_balance": True,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
return model_id
|
||||
|
||||
@classmethod
|
||||
async def create_account_static(cls) -> dict[str, object]:
|
||||
"""Create a new PPQ.AI account without requiring an instance.
|
||||
|
||||
Returns:
|
||||
Dict containing 'credit_id' and 'api_key' for the new account.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{cls.default_base_url}/accounts/create"
|
||||
|
||||
logger.info("Creating new PPQ.AI account", extra={"url": url})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url)
|
||||
response.raise_for_status()
|
||||
account_data = response.json()
|
||||
|
||||
logger.info(
|
||||
"Successfully created PPQ.AI account",
|
||||
extra={
|
||||
"credit_id": account_data.get("credit_id"),
|
||||
"has_api_key": bool(account_data.get("api_key")),
|
||||
},
|
||||
)
|
||||
|
||||
return account_data
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from PPQ.AI API."""
|
||||
url = f"{self.base_url}/models"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
models_data = data.get("data", [])
|
||||
|
||||
or_models = [
|
||||
Model(**model) # type: ignore
|
||||
for model in await async_fetch_openrouter_models()
|
||||
]
|
||||
|
||||
models = []
|
||||
for model_data in models_data:
|
||||
try:
|
||||
ppqai_model = PPQAIModel.parse_obj(model_data)
|
||||
if ppqai_model.id in self.IGNORED_MODEL_IDS:
|
||||
continue
|
||||
|
||||
or_model = next(
|
||||
(
|
||||
model
|
||||
for model in or_models
|
||||
if (model.id == ppqai_model.id)
|
||||
or (model.id.split("/")[-1] == ppqai_model.id)
|
||||
or (model.id == ppqai_model.id.split("/")[-1])
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if or_model:
|
||||
if input_price := ppqai_model.pricing.api.get(
|
||||
"input_per_1M"
|
||||
):
|
||||
or_model.pricing.prompt = input_price / 1_000_000
|
||||
if output_price := ppqai_model.pricing.api.get(
|
||||
"output_per_1M"
|
||||
):
|
||||
or_model.pricing.completion = output_price / 1_000_000
|
||||
if cl := ppqai_model.context_length:
|
||||
or_model.context_length = cl
|
||||
models.append(or_model)
|
||||
else:
|
||||
input_price = ppqai_model.pricing.api.get(
|
||||
"input_per_1M", 0.0
|
||||
)
|
||||
output_price = ppqai_model.pricing.api.get(
|
||||
"output_per_1M", 0.0
|
||||
)
|
||||
|
||||
models.append(
|
||||
Model(
|
||||
id=ppqai_model.id,
|
||||
name=ppqai_model.name,
|
||||
created=ppqai_model.created_at // 1000,
|
||||
description=f"{ppqai_model.provider} model",
|
||||
context_length=ppqai_model.context_length,
|
||||
architecture=Architecture(
|
||||
modality="text->text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="Unknown",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(
|
||||
prompt=input_price / 1_000_000,
|
||||
completion=output_price / 1_000_000,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to parse PPQ.AI model",
|
||||
extra={
|
||||
"model_id": model_data.get("id", "unknown"),
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
return models
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error fetching models from PPQ.AI",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
return []
|
||||
|
||||
async def create_account(self) -> dict[str, object]:
|
||||
"""Create a new PPQ.AI account.
|
||||
|
||||
Returns:
|
||||
Dict containing 'credit_id' and 'api_key' for the new account.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/accounts/create"
|
||||
|
||||
logger.info("Creating new PPQ.AI account", extra={"url": url})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url)
|
||||
response.raise_for_status()
|
||||
account_data = response.json()
|
||||
|
||||
logger.info(
|
||||
"Successfully created PPQ.AI account",
|
||||
extra={
|
||||
"credit_id": account_data.get("credit_id"),
|
||||
"has_api_key": bool(account_data.get("api_key")),
|
||||
},
|
||||
)
|
||||
|
||||
return account_data
|
||||
|
||||
async def create_lightning_topup(
|
||||
self, amount: int, currency: str
|
||||
) -> dict[str, object]:
|
||||
"""Create a Lightning Network top-up invoice for this account.
|
||||
|
||||
Args:
|
||||
amount: Amount to top up (in the specified currency)
|
||||
currency: Currency for the top-up (default: "USD")
|
||||
|
||||
Returns:
|
||||
Dict containing invoice details including 'invoice_id', 'payment_request', etc.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/topup/create/btc-lightning"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {"amount": amount, "currency": currency}
|
||||
|
||||
logger.info(
|
||||
"Creating Lightning top-up invoice",
|
||||
extra={"url": url, "amount": amount, "currency": currency},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
print(f"Payload: {payload}", "sending to", url)
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
invoice_data = response.json()
|
||||
|
||||
logger.info(
|
||||
"Successfully created Lightning top-up invoice",
|
||||
extra={
|
||||
"invoice_id": invoice_data.get("invoice_id"),
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
},
|
||||
)
|
||||
|
||||
return invoice_data
|
||||
|
||||
async def check_topup_status(self, invoice_id: str) -> bool:
|
||||
"""Check the status of a Lightning top-up invoice.
|
||||
|
||||
Args:
|
||||
invoice_id: The invoice ID to check
|
||||
|
||||
Returns:
|
||||
True if the invoice is paid (status == "Settled"), False otherwise
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/topup/status/{invoice_id}"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
logger.debug(
|
||||
"Checking Lightning top-up status",
|
||||
extra={"url": url, "invoice_id": invoice_id},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
status_data = response.json()
|
||||
|
||||
is_paid = status_data.get("status") == "Settled"
|
||||
|
||||
logger.debug(
|
||||
"Retrieved Lightning top-up status",
|
||||
extra={
|
||||
"invoice_id": invoice_id,
|
||||
"status": status_data.get("status"),
|
||||
"is_paid": is_paid,
|
||||
},
|
||||
)
|
||||
|
||||
return is_paid
|
||||
|
||||
async def initiate_topup(self, amount: int) -> TopupData:
|
||||
"""Initiate a Lightning Network top-up for the PPQ.AI account.
|
||||
|
||||
Args:
|
||||
amount: Amount in currency units to top up (will be sent to PPQ.AI API)
|
||||
|
||||
Returns:
|
||||
TopupData with standardized invoice information
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails
|
||||
"""
|
||||
ppq_response = await self.create_lightning_topup(amount, "USD")
|
||||
|
||||
logger.info(
|
||||
"PPQ.AI top-up response",
|
||||
extra={
|
||||
"ppq_response": ppq_response,
|
||||
"invoice_id": ppq_response.get("invoice_id"),
|
||||
"has_lightning_invoice": "lightning_invoice" in ppq_response,
|
||||
},
|
||||
)
|
||||
|
||||
expires_at_value = ppq_response.get("expires_at")
|
||||
checkout_url_value = ppq_response.get("checkout_url")
|
||||
|
||||
topup_data = TopupData(
|
||||
invoice_id=str(ppq_response["invoice_id"]),
|
||||
payment_request=str(ppq_response["lightning_invoice"]),
|
||||
amount=int(ppq_response["amount"])
|
||||
if isinstance(ppq_response["amount"], (int, float, str))
|
||||
else 0,
|
||||
currency=str(ppq_response["currency"]),
|
||||
expires_at=int(expires_at_value)
|
||||
if isinstance(expires_at_value, (int, float, str))
|
||||
and expires_at_value is not None
|
||||
else None,
|
||||
checkout_url=str(checkout_url_value)
|
||||
if checkout_url_value is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Created TopupData",
|
||||
extra={
|
||||
"invoice_id": topup_data.invoice_id,
|
||||
"payment_request_length": len(topup_data.payment_request),
|
||||
"amount": topup_data.amount,
|
||||
},
|
||||
)
|
||||
|
||||
return topup_data
|
||||
|
||||
async def get_balance(self) -> float | None:
|
||||
"""Get the current account balance from PPQ.AI.
|
||||
|
||||
Returns:
|
||||
Float representing the balance amount (in USD), or None if unavailable.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails
|
||||
"""
|
||||
data = await self.check_balance()
|
||||
balance = data.get("balance")
|
||||
if isinstance(balance, (int, float)):
|
||||
return float(balance)
|
||||
return None
|
||||
|
||||
async def check_balance(self) -> dict[str, object]:
|
||||
"""Check the account balance for this PPQ.AI account.
|
||||
|
||||
Returns:
|
||||
Dict containing balance information
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/credits/balance"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
logger.debug("Checking PPQ.AI account balance", extra={"url": url})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, headers=headers, json={})
|
||||
response.raise_for_status()
|
||||
balance_data = response.json()
|
||||
|
||||
logger.debug(
|
||||
"Retrieved PPQ.AI account balance",
|
||||
extra={"balance": balance_data.get("balance")},
|
||||
)
|
||||
|
||||
return balance_data
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..models import Model, async_fetch_openrouter_models
|
||||
from ..payment.models import Model, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -7,9 +7,9 @@ from cashu.wallet.helpers import deserialize_token_from_string
|
||||
from cashu.wallet.wallet import Wallet
|
||||
from sqlmodel import col, update
|
||||
|
||||
from ..core import db, get_logger
|
||||
from ..core.settings import settings
|
||||
from ..payment.lnurl import raw_send_to_lnurl
|
||||
from .core import db, get_logger
|
||||
from .core.settings import settings
|
||||
from .payment.lnurl import raw_send_to_lnurl
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -19,7 +19,9 @@ async def get_balance(unit: str) -> int:
|
||||
return wallet.available_balance.amount
|
||||
|
||||
|
||||
async def recieve_token(token: str) -> tuple[int, str, str]: # amount, unit, mint_url
|
||||
async def recieve_token(
|
||||
token: str,
|
||||
) -> tuple[int, str, str]: # amount, unit, mint_url
|
||||
token_obj = deserialize_token_from_string(token)
|
||||
if len(token_obj.keysets) > 1:
|
||||
raise ValueError("Multiple keysets per token currently not supported")
|
||||
@@ -102,7 +104,7 @@ async def swap_to_primary_mint(
|
||||
|
||||
|
||||
async def credit_balance(
|
||||
cashu_token: str, credit: db.TemporaryCredit, session: db.AsyncSession
|
||||
cashu_token: str, key: db.ApiKey, session: db.AsyncSession
|
||||
) -> int:
|
||||
logger.info(
|
||||
"credit_balance: Starting token redemption",
|
||||
@@ -124,22 +126,22 @@ async def credit_balance(
|
||||
|
||||
logger.info(
|
||||
"credit_balance: Updating balance",
|
||||
extra={"old_balance": credit.balance, "credit_amount": amount},
|
||||
extra={"old_balance": key.balance, "credit_amount": amount},
|
||||
)
|
||||
|
||||
# Use atomic SQL UPDATE to prevent race conditions during concurrent topups
|
||||
stmt = (
|
||||
update(db.TemporaryCredit)
|
||||
.where(col(db.TemporaryCredit.hashed_key) == credit.hashed_key)
|
||||
.values(balance=(db.TemporaryCredit.balance) + amount)
|
||||
update(db.ApiKey)
|
||||
.where(col(db.ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(balance=(db.ApiKey.balance) + amount)
|
||||
)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
await session.refresh(credit)
|
||||
await session.refresh(key)
|
||||
|
||||
logger.info(
|
||||
"credit_balance: Balance updated successfully",
|
||||
extra={"new_balance": credit.balance},
|
||||
extra={"new_balance": key.balance},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -349,3 +351,34 @@ async def periodic_payout() -> None:
|
||||
f"Error sending payout: {type(e).__name__}",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
|
||||
|
||||
async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
|
||||
wallet = await get_wallet(mint, unit)
|
||||
proofs = wallet._get_proofs_per_keyset(wallet.proofs)[wallet.keyset_id]
|
||||
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
|
||||
return await raw_send_to_lnurl(wallet, proofs, address, unit)
|
||||
|
||||
|
||||
# class Payment:
|
||||
# """
|
||||
# Stores all cashu payment related data
|
||||
# """
|
||||
|
||||
# def __init__(self, token: str) -> None:
|
||||
# self.initial_token = token
|
||||
# amount, unit, mint_url = self.parse_token(token)
|
||||
# self.amount = amount
|
||||
# self.unit = unit
|
||||
# self.mint_url = mint_url
|
||||
|
||||
# self.claimed_proofs = redeem_to_proofs(token)
|
||||
|
||||
# def parse_token(self, token: str) -> tuple[int, CurrencyUnit, str]:
|
||||
# raise NotImplementedError
|
||||
|
||||
# def refund_full(self) -> None:
|
||||
# raise NotImplementedError
|
||||
|
||||
# def refund_partial(self, amount: int) -> None:
|
||||
# raise NotImplementedError
|
||||
@@ -0,0 +1,5 @@
|
||||
# Example crontab entries for Routstr tasks
|
||||
|
||||
# Update models.json daily at 03:15 (optional)
|
||||
# OUTPUT_FILE=/app/models.json SOURCE=openrouter
|
||||
15 3 * * * /usr/local/bin/python /app/scripts/models_meta.py >> /var/log/cron.log 2>&1
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import TypedDict
|
||||
from urllib.request import urlopen
|
||||
|
||||
|
||||
class ModelArchitecture(TypedDict):
|
||||
modality: str
|
||||
input_modalities: list[str]
|
||||
output_modalities: list[str]
|
||||
tokenizer: str
|
||||
instruct_type: str | None
|
||||
|
||||
|
||||
class ModelPricing(TypedDict):
|
||||
prompt: str
|
||||
completion: str
|
||||
request: str
|
||||
image: str
|
||||
web_search: str
|
||||
internal_reasoning: str
|
||||
|
||||
|
||||
class ModelProvider(TypedDict):
|
||||
context_length: int
|
||||
max_completion_tokens: int | None
|
||||
is_moderated: bool
|
||||
|
||||
|
||||
class Model(TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
created: int
|
||||
description: str
|
||||
context_length: int
|
||||
architecture: ModelArchitecture
|
||||
pricing: ModelPricing
|
||||
top_provider: ModelProvider
|
||||
per_request_limits: dict | None
|
||||
|
||||
|
||||
OUTPUT_FILE = os.getenv("OUTPUT_FILE", "models.json")
|
||||
SOURCE = os.getenv("SOURCE")
|
||||
|
||||
|
||||
def fetch_openrouter_models(source_filter: str | None = None) -> list[Model]:
|
||||
"""Fetches model information from OpenRouter API."""
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
with urlopen(f"{base_url}/models") as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
models_data: list[Model] = []
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
|
||||
if source_filter:
|
||||
source_prefix = f"{source_filter}/"
|
||||
if not model_id.startswith(source_prefix):
|
||||
continue
|
||||
|
||||
model = dict(model)
|
||||
model["id"] = model_id[len(source_prefix) :]
|
||||
model_id = model["id"]
|
||||
|
||||
if (
|
||||
"(free)" in model.get("name", "")
|
||||
or model_id == "openrouter/auto"
|
||||
or model_id == "google/gemini-2.5-pro-exp-03-25"
|
||||
):
|
||||
continue
|
||||
|
||||
models_data.append(model)
|
||||
|
||||
return models_data
|
||||
|
||||
|
||||
def main() -> None:
|
||||
source_filter = SOURCE if SOURCE and SOURCE.strip() else None
|
||||
models = fetch_openrouter_models(source_filter=source_filter)
|
||||
|
||||
print(f"Writing {len(models)} models to {OUTPUT_FILE}")
|
||||
|
||||
with open(OUTPUT_FILE, "w") as f:
|
||||
json.dump({"models": models}, f, indent=4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -66,7 +66,7 @@ os.environ.update(test_env)
|
||||
os.environ.pop("ADMIN_PASSWORD", None)
|
||||
|
||||
|
||||
from routstr.core.db import TemporaryCredit, get_session # noqa: E402
|
||||
from routstr.core.db import ApiKey, get_session # noqa: E402
|
||||
from routstr.core.main import app, lifespan # noqa: E402
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ class TestmintWallet:
|
||||
self.refund_history: List[Dict[str, Any]] = []
|
||||
|
||||
async def init(self) -> None:
|
||||
"""Initialize the cashu wallet"""
|
||||
"""Initialize the sixty_nuts wallet"""
|
||||
# In mock mode, we don't actually create a real wallet
|
||||
# This is just a placeholder for the mock implementation
|
||||
self.wallet = None
|
||||
@@ -280,7 +280,7 @@ class TestmintWallet:
|
||||
return 100000 # 100k sats
|
||||
|
||||
async def credit_balance(
|
||||
self, cashu_token: str, key: TemporaryCredit, session: AsyncSession
|
||||
self, cashu_token: str, key: ApiKey, session: AsyncSession
|
||||
) -> int:
|
||||
"""Credit balance to API key - test implementation"""
|
||||
try:
|
||||
@@ -301,9 +301,9 @@ class TestmintWallet:
|
||||
|
||||
# Use atomic update to avoid lost update problem in concurrent scenarios
|
||||
stmt = (
|
||||
update(TemporaryCredit)
|
||||
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
|
||||
.values(balance=TemporaryCredit.balance + amount_msat)
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(balance=ApiKey.balance + amount_msat)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
@@ -390,7 +390,7 @@ class DatabaseSnapshot:
|
||||
async def capture(self) -> Dict[str, List[Dict]]:
|
||||
"""Capture current database state"""
|
||||
# Get all API keys with their data
|
||||
result = await self.session.execute(select(TemporaryCredit))
|
||||
result = await self.session.execute(select(ApiKey))
|
||||
api_keys = result.scalars().all()
|
||||
|
||||
snapshot = {
|
||||
@@ -398,8 +398,10 @@ class DatabaseSnapshot:
|
||||
{
|
||||
"hashed_key": key.hashed_key,
|
||||
"balance": key.balance,
|
||||
"total_spent": key.total_spent,
|
||||
"total_requests": key.total_requests,
|
||||
"refund_address": key.refund_address,
|
||||
"refund_expiration_time": key.refund_expiration_time,
|
||||
"key_expiry_time": key.key_expiry_time,
|
||||
}
|
||||
for key in api_keys
|
||||
]
|
||||
@@ -445,8 +447,10 @@ class DatabaseSnapshot:
|
||||
|
||||
for field in [
|
||||
"balance",
|
||||
"total_spent",
|
||||
"total_requests",
|
||||
"refund_address",
|
||||
"refund_expiration_time",
|
||||
"key_expiry_time",
|
||||
]:
|
||||
if old[field] != new[field]:
|
||||
changes[field] = {
|
||||
@@ -518,19 +522,18 @@ async def integration_app(
|
||||
with (
|
||||
patch("routstr.core.db.engine", integration_engine),
|
||||
patch.object(_settings, "cashu_mints", [mint_url]),
|
||||
patch(
|
||||
"routstr.payment.wallet.credit_balance", testmint_wallet.credit_balance
|
||||
),
|
||||
patch("routstr.payment.wallet.send_token", testmint_wallet.send_token),
|
||||
patch("routstr.payment.wallet.recieve_token", testmint_wallet.redeem_token),
|
||||
patch("routstr.payment.wallet.get_balance", testmint_wallet.get_balance),
|
||||
patch("routstr.wallet.credit_balance", testmint_wallet.credit_balance),
|
||||
patch("routstr.wallet.send_token", testmint_wallet.send_token),
|
||||
patch("routstr.wallet.send_to_lnurl", testmint_wallet.send_to_lnurl),
|
||||
patch("routstr.wallet.recieve_token", testmint_wallet.redeem_token),
|
||||
patch("routstr.wallet.get_balance", testmint_wallet.get_balance),
|
||||
patch("routstr.balance.send_token", testmint_wallet.send_token),
|
||||
patch("routstr.balance.send_to_lnurl", testmint_wallet.send_to_lnurl),
|
||||
patch("websockets.connect") as mock_websockets,
|
||||
patch("routstr.payment.price.btc_usd_price", return_value=50000.0),
|
||||
patch("routstr.payment.price.sats_usd_price", return_value=0.0005),
|
||||
patch(
|
||||
"routstr.payment.cost.calculate_discounted_max_cost",
|
||||
"routstr.payment.helpers.calculate_discounted_max_cost",
|
||||
side_effect=_passthrough_discount,
|
||||
),
|
||||
):
|
||||
@@ -702,8 +705,8 @@ async def background_tasks_controller() -> AsyncGenerator[Any, None]:
|
||||
original_periodic_payout: Optional[Callable] = None
|
||||
|
||||
try:
|
||||
from routstr.models.models import update_sats_pricing
|
||||
from routstr.payment.wallet import periodic_payout
|
||||
from routstr.payment.models import update_sats_pricing
|
||||
from routstr.wallet import periodic_payout
|
||||
|
||||
async def controlled_update_pricing() -> None:
|
||||
while not controller.cancelled:
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"""
|
||||
Real Cashu mint integration for integration tests.
|
||||
|
||||
This module provides a real cashu Wallet implementation that can be used
|
||||
This module provides a real sixty_nuts Wallet implementation that can be used
|
||||
with an actual Cashu mint instance for more thorough integration testing.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from cashu.wallet.wallet import Wallet
|
||||
from sixty_nuts import Wallet
|
||||
|
||||
|
||||
class RealMintWallet:
|
||||
"""Real Cashu mint wallet using cashu library"""
|
||||
"""Real Cashu mint wallet using sixty_nuts library"""
|
||||
|
||||
def __init__(self, mint_url: str, nsec: str):
|
||||
self.mint_url = mint_url
|
||||
@@ -22,12 +22,7 @@ class RealMintWallet:
|
||||
async def init(self) -> None:
|
||||
"""Initialize the wallet connection"""
|
||||
if not self._wallet:
|
||||
self._wallet = await Wallet.with_db(
|
||||
self.mint_url,
|
||||
db="sqlite+aiosqlite:///:memory:",
|
||||
load_all_keysets=True,
|
||||
unit="sat",
|
||||
)
|
||||
self._wallet = await Wallet.create(nsec=self.nsec)
|
||||
|
||||
@property
|
||||
def wallet(self) -> Wallet:
|
||||
|
||||
@@ -9,9 +9,9 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core.db import TemporaryCredit
|
||||
from routstr.models.models import Model, Pricing, update_sats_pricing
|
||||
from routstr.payment.wallet import periodic_payout
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.payment.models import Model, Pricing, update_sats_pricing
|
||||
from routstr.wallet import periodic_payout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -201,12 +201,12 @@ class TestRefundCheckTask:
|
||||
) -> None:
|
||||
"""Test that expired keys with balance and refund address are refunded"""
|
||||
# Create an expired API key with balance
|
||||
expired_key = TemporaryCredit(
|
||||
expired_key = ApiKey(
|
||||
hashed_key="expired_test_key",
|
||||
balance=5000, # 5 sats in msats
|
||||
refund_address="lnurl1test",
|
||||
refund_expiration_time=int(time.time()) - 3600, # Expired 1 hour ago
|
||||
created=datetime.utcnow() - timedelta(days=1),
|
||||
key_expiry_time=int(time.time()) - 3600, # Expired 1 hour ago
|
||||
created_at=datetime.utcnow() - timedelta(days=1),
|
||||
)
|
||||
integration_session.add(expired_key)
|
||||
await integration_session.commit()
|
||||
@@ -214,7 +214,7 @@ class TestRefundCheckTask:
|
||||
# Mock the wallet send_to_lnurl method and get_session
|
||||
with (
|
||||
patch(
|
||||
"routstr.payment.lnurl.send_to_lnurl", AsyncMock(return_value=5)
|
||||
"routstr.wallet.send_to_lnurl", AsyncMock(return_value=5)
|
||||
) as mock_send_to_lnurl,
|
||||
patch("routstr.core.db.get_session") as mock_get_session,
|
||||
):
|
||||
@@ -233,8 +233,8 @@ class TestRefundCheckTask:
|
||||
if (
|
||||
expired_key.balance > 0
|
||||
and expired_key.refund_address
|
||||
and expired_key.refund_expiration_time
|
||||
and expired_key.refund_expiration_time < current_time
|
||||
and expired_key.key_expiry_time
|
||||
and expired_key.key_expiry_time < current_time
|
||||
):
|
||||
# Call wallet send_to_lnurl to trigger the refund
|
||||
amount_sats = expired_key.balance // 1000
|
||||
@@ -258,12 +258,12 @@ class TestRefundCheckTask:
|
||||
"""Test that refund check continues after mint errors"""
|
||||
# Create multiple expired keys
|
||||
for i in range(3):
|
||||
key = TemporaryCredit(
|
||||
key = ApiKey(
|
||||
hashed_key=f"expired_key_{i}",
|
||||
balance=1000 * (i + 1),
|
||||
refund_address=f"lnurl{i}",
|
||||
refund_expiration_time=int(time.time()) - 3600,
|
||||
created=datetime.utcnow(),
|
||||
key_expiry_time=int(time.time()) - 3600,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
@@ -279,7 +279,7 @@ class TestRefundCheckTask:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"routstr.payment.lnurl.send_to_lnurl", mock_send_to_lnurl
|
||||
"routstr.wallet.send_to_lnurl", mock_send_to_lnurl
|
||||
) as mock_send_to_lnurl_patch,
|
||||
patch("routstr.core.db.get_session") as mock_get_session,
|
||||
):
|
||||
@@ -293,15 +293,15 @@ class TestRefundCheckTask:
|
||||
current_time = int(time.time())
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
result = await integration_session.execute(sa_select(TemporaryCredit))
|
||||
result = await integration_session.execute(sa_select(ApiKey))
|
||||
keys = result.scalars().all()
|
||||
|
||||
for key in keys:
|
||||
if (
|
||||
key.balance > 0
|
||||
and key.refund_address
|
||||
and key.refund_expiration_time
|
||||
and key.refund_expiration_time < current_time
|
||||
and key.key_expiry_time
|
||||
and key.key_expiry_time < current_time
|
||||
):
|
||||
amount_sats = key.balance // 1000
|
||||
try:
|
||||
@@ -352,21 +352,21 @@ class TestRefundCheckTask:
|
||||
|
||||
current_time = int(time.time())
|
||||
for data in keys_data:
|
||||
key = TemporaryCredit(
|
||||
key = ApiKey(
|
||||
hashed_key=data["hashed_key"],
|
||||
balance=data["balance"],
|
||||
refund_address=data["refund_address"],
|
||||
refund_expiration_time=current_time - 3600
|
||||
key_expiry_time=current_time - 3600
|
||||
if data["expired"]
|
||||
else current_time + 3600,
|
||||
created=datetime.utcnow(),
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"routstr.payment.lnurl.send_to_lnurl", AsyncMock(return_value=1)
|
||||
"routstr.wallet.send_to_lnurl", AsyncMock(return_value=1)
|
||||
) as mock_send_to_lnurl,
|
||||
patch("routstr.core.db.get_session") as mock_get_session,
|
||||
):
|
||||
@@ -382,15 +382,15 @@ class TestRefundCheckTask:
|
||||
current_time = int(time.time())
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
result = await integration_session.execute(sa_select(TemporaryCredit))
|
||||
result = await integration_session.execute(sa_select(ApiKey))
|
||||
keys = result.scalars().all()
|
||||
|
||||
for key in keys:
|
||||
if (
|
||||
key.balance > 0
|
||||
and key.refund_address
|
||||
and key.refund_expiration_time
|
||||
and key.refund_expiration_time < current_time
|
||||
and key.key_expiry_time
|
||||
and key.key_expiry_time < current_time
|
||||
):
|
||||
amount_sats = key.balance // 1000
|
||||
await mock_send_to_lnurl(key.refund_address, amount=amount_sats)
|
||||
@@ -410,7 +410,7 @@ class TestRefundCheckTask:
|
||||
# Check final state
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
result = await integration_session.execute(sa_select(TemporaryCredit))
|
||||
result = await integration_session.execute(sa_select(ApiKey))
|
||||
remaining_keys_list = result.scalars().all()
|
||||
remaining_ids = [k.hashed_key for k in remaining_keys_list]
|
||||
|
||||
@@ -454,10 +454,10 @@ class TestPeriodicPayoutTask:
|
||||
for i in range(5):
|
||||
balance = 10000 * (i + 1) # 10, 20, 30, 40, 50 sats
|
||||
total_user_balance += balance
|
||||
key = TemporaryCredit(
|
||||
key = ApiKey(
|
||||
hashed_key=f"user_key_{i}",
|
||||
balance=balance,
|
||||
created=datetime.utcnow(),
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
@@ -466,12 +466,9 @@ class TestPeriodicPayoutTask:
|
||||
wallet_balance = 200000 # 200 sats total
|
||||
|
||||
with (
|
||||
patch("routstr.wallet.get_balance", AsyncMock(return_value=wallet_balance)),
|
||||
patch(
|
||||
"routstr.payment.wallet.get_balance",
|
||||
AsyncMock(return_value=wallet_balance),
|
||||
),
|
||||
patch(
|
||||
"routstr.payment.lnurl.send_to_lnurl", AsyncMock(return_value=None)
|
||||
"routstr.wallet.send_to_lnurl", AsyncMock(return_value=None)
|
||||
) as mock_send_to_lnurl,
|
||||
):
|
||||
# Mock environment variables
|
||||
@@ -484,7 +481,7 @@ class TestPeriodicPayoutTask:
|
||||
},
|
||||
):
|
||||
# Call periodic_payout directly (pay_out was renamed/refactored)
|
||||
from routstr.payment.wallet import periodic_payout
|
||||
from routstr.wallet import periodic_payout
|
||||
|
||||
await periodic_payout()
|
||||
|
||||
@@ -504,7 +501,7 @@ class TestPeriodicPayoutTask:
|
||||
# key = ApiKey(
|
||||
# hashed_key="single_user",
|
||||
# balance=50000, # 50 sats
|
||||
# created=datetime.utcnow(),
|
||||
# created_at=datetime.utcnow(),
|
||||
# )
|
||||
# integration_session.add(key)
|
||||
# await integration_session.commit()
|
||||
@@ -541,7 +538,7 @@ class TestPeriodicPayoutTask:
|
||||
# key = ApiKey(
|
||||
# hashed_key="low_revenue_user",
|
||||
# balance=95000, # 95 sats
|
||||
# created=datetime.utcnow(),
|
||||
# created_at=datetime.utcnow(),
|
||||
# )
|
||||
# integration_session.add(key)
|
||||
# await integration_session.commit()
|
||||
@@ -651,14 +648,14 @@ class TestTaskInteractions:
|
||||
"""Test that database operations don't deadlock during concurrent task execution"""
|
||||
# Create test data
|
||||
for i in range(10):
|
||||
key = TemporaryCredit(
|
||||
key = ApiKey(
|
||||
hashed_key=f"concurrent_key_{i}",
|
||||
balance=1000 * i,
|
||||
refund_address=f"lnurl{i}" if i % 2 == 0 else None,
|
||||
refund_expiration_time=int(time.time()) - 3600
|
||||
key_expiry_time=int(time.time()) - 3600
|
||||
if i % 3 == 0
|
||||
else int(time.time()) + 3600,
|
||||
created=datetime.utcnow(),
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
@@ -667,14 +664,14 @@ class TestTaskInteractions:
|
||||
async def read_operation() -> int:
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
result = await integration_session.execute(sa_select(TemporaryCredit))
|
||||
result = await integration_session.execute(sa_select(ApiKey))
|
||||
return len(result.scalars().all())
|
||||
|
||||
async def write_operation(key_id: int) -> None:
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
stmt = sa_select(TemporaryCredit).where(
|
||||
TemporaryCredit.hashed_key == f"concurrent_key_{key_id}" # type: ignore[arg-type]
|
||||
stmt = sa_select(ApiKey).where(
|
||||
ApiKey.hashed_key == f"concurrent_key_{key_id}" # type: ignore[arg-type]
|
||||
)
|
||||
result = await integration_session.execute(stmt)
|
||||
key = result.scalar_one_or_none()
|
||||
@@ -711,16 +708,14 @@ class TestTaskInteractions:
|
||||
# Patch the actual task functions
|
||||
with (
|
||||
patch(
|
||||
"routstr.models.models.update_sats_pricing",
|
||||
"routstr.payment.models.update_sats_pricing",
|
||||
lambda: task_with_cleanup("pricing"),
|
||||
),
|
||||
patch(
|
||||
"routstr.payment.wallet.periodic_payout",
|
||||
lambda: task_with_cleanup("refund"),
|
||||
"routstr.wallet.periodic_payout", lambda: task_with_cleanup("refund")
|
||||
),
|
||||
patch(
|
||||
"routstr.payment.wallet.periodic_payout",
|
||||
lambda: task_with_cleanup("payout"),
|
||||
"routstr.wallet.periodic_payout", lambda: task_with_cleanup("payout")
|
||||
),
|
||||
):
|
||||
# Start all tasks
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import TemporaryCredit
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
|
||||
class TestTransactionAtomicity:
|
||||
@@ -34,7 +34,7 @@ class TestTransactionAtomicity:
|
||||
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
|
||||
)
|
||||
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
initial_balance = api_key.balance
|
||||
@@ -47,9 +47,7 @@ class TestTransactionAtomicity:
|
||||
try:
|
||||
# Get api key in new session
|
||||
result = await test_session.execute(
|
||||
select(TemporaryCredit).where(
|
||||
TemporaryCredit.hashed_key == api_key_hash
|
||||
) # type: ignore[arg-type]
|
||||
select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
)
|
||||
test_api_key = result.scalar_one()
|
||||
|
||||
@@ -74,14 +72,14 @@ class TestTransactionAtomicity:
|
||||
|
||||
try:
|
||||
await integration_session.execute(
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
.values(balance=TemporaryCredit.balance - 1000)
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
.values(balance=ApiKey.balance - 1000)
|
||||
)
|
||||
# Force a constraint violation or error
|
||||
await integration_session.execute(
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == "non_existent_key") # type: ignore[arg-type]
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == "non_existent_key") # type: ignore[arg-type]
|
||||
.values(balance=-1) # This should fail
|
||||
)
|
||||
await integration_session.commit()
|
||||
@@ -110,13 +108,13 @@ class TestTransactionAtomicity:
|
||||
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
|
||||
)
|
||||
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
initial_balance = api_key.balance
|
||||
|
||||
# Mock wallet to fail after token validation
|
||||
with patch("routstr.payment.wallet.send_token") as mock_wallet_func:
|
||||
with patch("routstr.wallet.send_token") as mock_wallet_func:
|
||||
mock_proof = MagicMock()
|
||||
mock_proof.amount = 1000
|
||||
mock_wallet = AsyncMock()
|
||||
@@ -160,7 +158,7 @@ class TestTransactionAtomicity:
|
||||
)
|
||||
|
||||
# Set a known balance
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
api_key.balance = 10000
|
||||
@@ -168,12 +166,12 @@ class TestTransactionAtomicity:
|
||||
|
||||
# Simulate concurrent balance updates through direct database operations
|
||||
async def update_balance(session: AsyncSession, amount: int) -> bool:
|
||||
stmt = select(TemporaryCredit).where(
|
||||
TemporaryCredit.hashed_key == api_key_hash
|
||||
) # type: ignore[arg-type]
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await session.execute(stmt)
|
||||
key = result.scalar_one()
|
||||
key.balance -= amount
|
||||
key.total_spent += amount
|
||||
key.total_requests += 1
|
||||
try:
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -250,7 +248,7 @@ class TestConcurrentOperations:
|
||||
)
|
||||
|
||||
# Set initial balance
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
initial_balance = 5000
|
||||
@@ -258,7 +256,7 @@ class TestConcurrentOperations:
|
||||
await integration_session.commit()
|
||||
|
||||
# Mock wallet for topup
|
||||
with patch("routstr.payment.wallet.send_token") as mock_wallet_func:
|
||||
with patch("routstr.wallet.send_token") as mock_wallet_func:
|
||||
mock_proof = MagicMock()
|
||||
mock_proof.amount = 2000
|
||||
mock_wallet = AsyncMock()
|
||||
@@ -325,10 +323,12 @@ class TestConcurrentOperations:
|
||||
)
|
||||
|
||||
# Set a specific balance
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
api_key.balance = 1000
|
||||
api_key.total_spent = 0
|
||||
api_key.total_requests = 0
|
||||
await integration_session.commit()
|
||||
|
||||
# Create a controlled race condition scenario
|
||||
@@ -336,9 +336,7 @@ class TestConcurrentOperations:
|
||||
|
||||
async def check_and_update_balance() -> bool:
|
||||
# Read current balance
|
||||
stmt = select(TemporaryCredit).where(
|
||||
TemporaryCredit.hashed_key == api_key_hash
|
||||
) # type: ignore[arg-type]
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
current_api_key = result.scalar_one()
|
||||
current_balance = current_api_key.balance
|
||||
@@ -349,6 +347,8 @@ class TestConcurrentOperations:
|
||||
|
||||
# Try to update based on read value
|
||||
current_api_key.balance = current_balance - 100
|
||||
current_api_key.total_spent += 100
|
||||
current_api_key.total_requests += 1
|
||||
|
||||
try:
|
||||
await integration_session.commit()
|
||||
@@ -371,6 +371,8 @@ class TestConcurrentOperations:
|
||||
# Final balance should reflect successful updates
|
||||
expected_balance = 1000 - (successful_updates * 100)
|
||||
assert api_key.balance == expected_balance
|
||||
assert api_key.total_spent == successful_updates * 100
|
||||
assert api_key.total_requests == successful_updates
|
||||
|
||||
|
||||
class TestDataIntegrity:
|
||||
@@ -394,7 +396,7 @@ class TestDataIntegrity:
|
||||
)
|
||||
|
||||
# Set low balance
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
api_key.balance = 0
|
||||
@@ -429,7 +431,9 @@ class TestDataIntegrity:
|
||||
)
|
||||
|
||||
# Try to manually insert duplicate key with same hash
|
||||
duplicate_key = TemporaryCredit(hashed_key=api_key_hash, balance=5000)
|
||||
duplicate_key = ApiKey(
|
||||
hashed_key=api_key_hash, balance=5000, total_spent=0, total_requests=0
|
||||
)
|
||||
|
||||
integration_session.add(duplicate_key)
|
||||
|
||||
@@ -477,13 +481,20 @@ class TestDataIntegrity:
|
||||
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
|
||||
)
|
||||
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
|
||||
# Test setting invalid values directly
|
||||
# These should maintain integrity
|
||||
assert api_key.balance >= 0
|
||||
assert api_key.total_spent >= 0
|
||||
assert api_key.total_requests >= 0
|
||||
|
||||
# Verify calculations are consistent
|
||||
if api_key.total_requests > 0:
|
||||
average_cost = api_key.total_spent / api_key.total_requests
|
||||
assert average_cost >= 0
|
||||
|
||||
|
||||
class TestPerformance:
|
||||
@@ -511,7 +522,7 @@ class TestPerformance:
|
||||
operation_times["select"].append((end - start) * 1000) # Convert to ms
|
||||
|
||||
# Test UPDATE performance (via topup)
|
||||
with patch("routstr.payment.wallet.send_token") as mock_wallet_func:
|
||||
with patch("routstr.wallet.send_token") as mock_wallet_func:
|
||||
mock_proof = MagicMock()
|
||||
mock_proof.amount = 100
|
||||
mock_wallet = AsyncMock()
|
||||
@@ -596,7 +607,7 @@ class TestPerformance:
|
||||
|
||||
# Primary key lookup should be fast
|
||||
start = time.time()
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
end = time.time()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_embeddings_endpoint(authenticated_client: AsyncClient) -> None:
|
||||
"""Test the embeddings endpoint proxy functionality"""
|
||||
|
||||
test_payload = {
|
||||
"model": "text-embedding-ada-002",
|
||||
"input": "The quick brown fox",
|
||||
}
|
||||
|
||||
mock_response_data = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"object": "embedding", "embedding": [0.0023, -0.0012, 0.0045], "index": 0}
|
||||
],
|
||||
"model": "text-embedding-ada-002",
|
||||
"usage": {"prompt_tokens": 5, "total_tokens": 5},
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient.send") as mock_send:
|
||||
# Create a proper async generator for iter_bytes
|
||||
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
|
||||
yield json.dumps(mock_response_data).encode()
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "application/json"}
|
||||
mock_response.text = json.dumps(mock_response_data)
|
||||
# Use MagicMock for synchronous .json() method
|
||||
mock_response.json = MagicMock(return_value=mock_response_data)
|
||||
mock_response.iter_bytes = mock_iter_bytes
|
||||
mock_response.aiter_bytes = mock_iter_bytes
|
||||
mock_send.return_value = mock_response
|
||||
|
||||
# Make POST request to embeddings endpoint
|
||||
response = await authenticated_client.post("/v1/embeddings", json=test_payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
response_data = response.json()
|
||||
assert response_data["object"] == "list"
|
||||
assert len(response_data["data"]) == 1
|
||||
assert response_data["data"][0]["object"] == "embedding"
|
||||
|
||||
# Verify request was forwarded
|
||||
mock_send.assert_called_once()
|
||||
forwarded_request = mock_send.call_args[0][0]
|
||||
# Verify the path ends with embeddings
|
||||
# Note: forwarded path might be full URL
|
||||
assert str(forwarded_request.url).endswith("embeddings")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_case_insensitivity(authenticated_client: AsyncClient) -> None:
|
||||
"""Test that model lookups are case insensitive"""
|
||||
|
||||
# We'll use a mixed-case model ID that should match the lowercase one in the system
|
||||
# We assume 'gpt-3.5-turbo' is available in the mock env/database
|
||||
|
||||
test_payload = {
|
||||
"model": "GPT-3.5-TURBO",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient.send") as mock_send:
|
||||
mock_response_data = {
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"choices": [{"message": {"content": "Hi"}}],
|
||||
"usage": {"total_tokens": 10},
|
||||
}
|
||||
|
||||
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
|
||||
yield json.dumps(mock_response_data).encode()
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "application/json"}
|
||||
mock_response.text = json.dumps(mock_response_data)
|
||||
mock_response.json = MagicMock(return_value=mock_response_data)
|
||||
mock_response.iter_bytes = mock_iter_bytes
|
||||
mock_response.aiter_bytes = mock_iter_bytes
|
||||
mock_send.return_value = mock_response
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/v1/chat/completions", json=test_payload
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -11,7 +11,7 @@ from httpx import ASGITransport, AsyncClient, ConnectError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import TemporaryCredit
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
|
||||
class TestNetworkFailureScenarios:
|
||||
@@ -27,7 +27,7 @@ class TestNetworkFailureScenarios:
|
||||
# Patch the wallet send function to simulate failure across all modules
|
||||
with (
|
||||
patch(
|
||||
"routstr.payment.wallet.send_token",
|
||||
"routstr.wallet.send_token",
|
||||
AsyncMock(side_effect=ConnectError("Mint service unavailable")),
|
||||
),
|
||||
patch(
|
||||
@@ -422,7 +422,7 @@ class TestRecoveryScenarios:
|
||||
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
|
||||
)
|
||||
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
initial_key = result.scalar_one()
|
||||
initial_balance = initial_key.balance
|
||||
@@ -458,15 +458,17 @@ class TestRecoveryScenarios:
|
||||
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
|
||||
)
|
||||
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
api_key = result.scalar_one()
|
||||
initial_balance = api_key.balance
|
||||
initial_requests = api_key.total_requests
|
||||
|
||||
# Simulate operations that might be interrupted
|
||||
try:
|
||||
# Start a transaction
|
||||
api_key.reserved_balance += 1000
|
||||
api_key.total_requests += 1
|
||||
# Don't commit - simulate crash
|
||||
raise Exception("Simulated database crash")
|
||||
except Exception:
|
||||
@@ -476,6 +478,7 @@ class TestRecoveryScenarios:
|
||||
# Verify state is consistent after "recovery"
|
||||
await integration_session.refresh(api_key)
|
||||
assert api_key.balance == initial_balance
|
||||
assert api_key.total_requests == initial_requests
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_consistency_after_failures(
|
||||
@@ -524,7 +527,10 @@ class TestRecoveryScenarios:
|
||||
for mod in diff["api_keys"]["modified"]:
|
||||
# Only acceptable changes are request counts
|
||||
for field, change in mod["changes"].items():
|
||||
if field == "balance":
|
||||
if field == "total_requests":
|
||||
# Request count might increase
|
||||
assert change["delta"] >= 0
|
||||
elif field == "balance":
|
||||
# Balance should not decrease from failed operations
|
||||
assert change["delta"] >= 0
|
||||
else:
|
||||
@@ -635,10 +641,12 @@ class TestEdgeCaseCombinations:
|
||||
api_key_hash = test_key[3:] # Remove sk- prefix
|
||||
|
||||
# Create the API key with only 500 msats (less than one request cost)
|
||||
new_key = TemporaryCredit(
|
||||
new_key = ApiKey(
|
||||
hashed_key=api_key_hash,
|
||||
balance=500, # Less than fixed cost per request (1000 msats)
|
||||
reserved_balance=0,
|
||||
total_spent=0,
|
||||
total_requests=0,
|
||||
)
|
||||
integration_session.add(new_key)
|
||||
await integration_session.commit()
|
||||
@@ -679,7 +687,7 @@ class TestEdgeCaseCombinations:
|
||||
assert insufficient_funds_count > 0
|
||||
|
||||
# Balance should never go negative
|
||||
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
|
||||
result = await integration_session.execute(stmt)
|
||||
final_key = result.scalar_one()
|
||||
assert final_key.balance >= 0
|
||||
|
||||
@@ -10,7 +10,6 @@ from httpx import AsyncClient
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
PerformanceValidator,
|
||||
ResponseValidator,
|
||||
)
|
||||
|
||||
@@ -159,30 +158,7 @@ async def test_error_handling(
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_performance_requirements(integration_client: AsyncClient) -> None:
|
||||
"""Test that endpoints meet performance requirements"""
|
||||
|
||||
validator = PerformanceValidator()
|
||||
|
||||
# Test info endpoint performance
|
||||
for i in range(50):
|
||||
start = validator.start_timing("info_endpoint")
|
||||
response = await integration_client.get("/")
|
||||
validator.end_timing("info_endpoint", start)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Validate 95th percentile is under 500ms
|
||||
result = validator.validate_response_time(
|
||||
"info_endpoint", max_duration=0.5, percentile=0.95
|
||||
)
|
||||
|
||||
assert result["valid"], (
|
||||
f"Performance requirement failed: "
|
||||
f"95th percentile was {result['percentile_time']:.3f}s "
|
||||
f"(required < {result['max_allowed']}s)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -142,44 +142,6 @@ class TestPerformanceBaseline:
|
||||
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_query_performance(
|
||||
self, integration_session: Any, db_snapshot: Any
|
||||
) -> None:
|
||||
"""Test database operation performance"""
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import TemporaryCredit
|
||||
|
||||
# Create test data
|
||||
for i in range(100):
|
||||
key = TemporaryCredit(
|
||||
hashed_key=f"test_key_{i}",
|
||||
balance=1000000,
|
||||
)
|
||||
integration_session.add(key)
|
||||
await integration_session.commit()
|
||||
|
||||
# Test query performance
|
||||
query_times = []
|
||||
|
||||
for _ in range(100):
|
||||
start = time.time()
|
||||
result = await integration_session.execute(
|
||||
select(TemporaryCredit).where(TemporaryCredit.balance > 0) # type: ignore[arg-type]
|
||||
)
|
||||
_ = result.all()
|
||||
duration = (time.time() - start) * 1000
|
||||
query_times.append(duration)
|
||||
|
||||
# All queries should complete < 100ms
|
||||
assert max(query_times) < 100, (
|
||||
f"Max query time {max(query_times)}ms exceeds 100ms limit"
|
||||
)
|
||||
print("\nDatabase query performance:")
|
||||
print(f" Mean: {statistics.mean(query_times):.2f}ms")
|
||||
print(f" Max: {max(query_times):.2f}ms")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.slow
|
||||
|
||||
@@ -9,9 +9,9 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from routstr.nostr.discovery import _PROVIDERS_CACHE
|
||||
from routstr.discovery import _PROVIDERS_CACHE
|
||||
|
||||
from .utils import PerformanceValidator, ResponseValidator
|
||||
from .utils import ResponseValidator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -62,9 +62,9 @@ async def test_providers_endpoint_default_response(
|
||||
}
|
||||
|
||||
with patch(
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
# Configure mock to return appropriate responses
|
||||
mock_fetch.side_effect = lambda url: mock_fetch_responses.get(
|
||||
url, {"status_code": 500, "json": {"error": "Unknown provider"}}
|
||||
@@ -126,9 +126,9 @@ async def test_providers_endpoint_with_include_json(
|
||||
}
|
||||
|
||||
with patch(
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"status_code": 200,
|
||||
"json": mock_provider_response,
|
||||
@@ -200,9 +200,9 @@ async def test_providers_data_structure_validation(
|
||||
}
|
||||
|
||||
with patch(
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = mock_health_response
|
||||
|
||||
response = await integration_client.get("/v1/providers/?include_json=true")
|
||||
@@ -247,7 +247,7 @@ async def test_providers_endpoint_no_providers_found(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
response = await integration_client.get("/v1/providers/")
|
||||
|
||||
@@ -308,10 +308,10 @@ async def test_providers_endpoint_offline_providers(
|
||||
}
|
||||
|
||||
with patch(
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch(
|
||||
"routstr.nostr.discovery.fetch_provider_health",
|
||||
"routstr.discovery.fetch_provider_health",
|
||||
side_effect=mock_fetch_provider_health,
|
||||
):
|
||||
response = await integration_client.get("/v1/providers/?include_json=true")
|
||||
@@ -377,9 +377,9 @@ async def test_providers_endpoint_duplicate_urls(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"status_code": 200,
|
||||
"endpoint": "root",
|
||||
@@ -416,7 +416,7 @@ async def test_providers_endpoint_nostr_relay_failures(
|
||||
raise Exception("Connection to relay failed")
|
||||
|
||||
with patch(
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", side_effect=failing_query
|
||||
"routstr.discovery.query_nostr_relay_for_providers", side_effect=failing_query
|
||||
):
|
||||
response = await integration_client.get("/v1/providers/")
|
||||
|
||||
@@ -454,9 +454,9 @@ async def test_providers_endpoint_malformed_urls(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
response = await integration_client.get("/v1/providers/")
|
||||
@@ -486,9 +486,9 @@ async def test_providers_endpoint_response_format(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Test default format
|
||||
@@ -518,46 +518,6 @@ async def test_providers_endpoint_response_format(
|
||||
assert isinstance(data_json["providers"], list)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_providers_endpoint_performance(integration_client: AsyncClient) -> None:
|
||||
"""Test providers endpoint meets performance requirements"""
|
||||
|
||||
# Mock quick responses to avoid network delays
|
||||
mock_events: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": f"event{i}",
|
||||
"content": f"Provider: http://provider{i}.onion",
|
||||
"created_at": 1234567890 + i,
|
||||
}
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
validator = PerformanceValidator()
|
||||
|
||||
with patch(
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Test multiple requests
|
||||
for i in range(10):
|
||||
start = validator.start_timing("providers_endpoint")
|
||||
response = await integration_client.get("/v1/providers/")
|
||||
validator.end_timing("providers_endpoint", start)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Validate performance (should be fast with mocked dependencies)
|
||||
perf_result = validator.validate_response_time(
|
||||
"providers_endpoint",
|
||||
max_duration=2.0, # Allow more time since it involves multiple operations
|
||||
percentile=0.95,
|
||||
)
|
||||
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_providers_endpoint_concurrent_requests(
|
||||
@@ -576,9 +536,9 @@ async def test_providers_endpoint_concurrent_requests(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Create concurrent requests
|
||||
@@ -618,9 +578,9 @@ async def test_providers_endpoint_parameter_validation(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Test various parameter values
|
||||
@@ -670,9 +630,9 @@ async def test_no_database_changes_during_provider_operations(
|
||||
]
|
||||
|
||||
with patch(
|
||||
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
||||
):
|
||||
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
|
||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
||||
|
||||
# Make multiple requests with different parameters
|
||||
|
||||
@@ -14,11 +14,10 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import TemporaryCredit
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
ConcurrencyTester,
|
||||
PerformanceValidator,
|
||||
)
|
||||
|
||||
|
||||
@@ -297,8 +296,8 @@ async def test_proxy_get_insufficient_balance(
|
||||
from sqlmodel import update
|
||||
|
||||
await integration_session.execute(
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(balance=100) # Only 0.1 sats
|
||||
)
|
||||
await integration_session.commit()
|
||||
@@ -382,7 +381,7 @@ async def test_proxy_get_database_state_verification(
|
||||
|
||||
# Get initial key state
|
||||
result = await integration_session.execute(
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
initial_key = result.scalar_one()
|
||||
initial_balance = initial_key.balance
|
||||
@@ -551,39 +550,7 @@ async def test_proxy_get_concurrent_requests(
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_get_performance_requirements(
|
||||
integration_client: AsyncClient, authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test that GET proxy requests meet performance requirements"""
|
||||
|
||||
validator = PerformanceValidator()
|
||||
|
||||
with patch("httpx.AsyncClient.request") as mock_request:
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "application/json"}
|
||||
mock_response.json = MagicMock(return_value={"performance": "test"})
|
||||
mock_response.text = '{"performance": "test"}'
|
||||
mock_response.iter_bytes = AsyncMock(return_value=[b'{"performance": "test"}'])
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
# Test multiple requests for performance measurement
|
||||
for i in range(20):
|
||||
start = validator.start_timing("proxy_get")
|
||||
response = await authenticated_client.get(f"/v1/perf-test-{i}")
|
||||
validator.end_timing("proxy_get", start)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Validate performance requirements
|
||||
perf_result = validator.validate_response_time(
|
||||
"proxy_get",
|
||||
max_duration=1.0, # Should complete within 1 second
|
||||
percentile=0.95,
|
||||
)
|
||||
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -15,7 +15,6 @@ from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from .utils import (
|
||||
ConcurrencyTester,
|
||||
PerformanceValidator,
|
||||
)
|
||||
|
||||
|
||||
@@ -290,55 +289,7 @@ async def test_proxy_post_unauthorized_access(integration_client: AsyncClient) -
|
||||
assert response.status_code in [400, 401]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_post_performance(
|
||||
integration_client: AsyncClient, authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test POST endpoint performance requirements"""
|
||||
|
||||
test_payload = {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "Performance test"}],
|
||||
}
|
||||
|
||||
validator = PerformanceValidator()
|
||||
|
||||
with patch("httpx.AsyncClient.send") as mock_send:
|
||||
# Mock fast responses
|
||||
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
|
||||
yield b'{"choices": [{"message": {"content": "Fast"}}], "usage": {"total_tokens": 5}}'
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "application/json"}
|
||||
response_data = {
|
||||
"choices": [{"message": {"content": "Fast"}}],
|
||||
"usage": {"total_tokens": 5},
|
||||
}
|
||||
mock_response.text = json.dumps(response_data)
|
||||
mock_response.json = AsyncMock(return_value=response_data)
|
||||
mock_response.iter_bytes = mock_iter_bytes
|
||||
mock_response.aiter_bytes = mock_iter_bytes
|
||||
mock_send.return_value = mock_response
|
||||
|
||||
# Run multiple requests for performance measurement
|
||||
for i in range(20):
|
||||
start = validator.start_timing("proxy_post")
|
||||
response = await authenticated_client.post(
|
||||
"/v1/chat/completions", json=test_payload
|
||||
)
|
||||
validator.end_timing("proxy_post", start)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Validate performance
|
||||
perf_result = validator.validate_response_time(
|
||||
"proxy_post",
|
||||
max_duration=1.5, # Allow slightly more time for POST
|
||||
percentile=0.95,
|
||||
)
|
||||
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
try:
|
||||
from .real_testmint import create_real_mint_wallet
|
||||
except ImportError:
|
||||
# cashu not available, tests will be skipped
|
||||
# sixty_nuts not available, tests will be skipped
|
||||
create_real_mint_wallet = None # type: ignore
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ async def test_real_wallet() -> None:
|
||||
"""Test basic operations with a real Cashu mint wallet"""
|
||||
print("Testing real Cashu mint wallet...")
|
||||
|
||||
# Check if cashu dependency is available
|
||||
# Check if sixty_nuts dependency is available
|
||||
if create_real_mint_wallet is None:
|
||||
print("cashu not available. Skipping real mint tests.")
|
||||
print("sixty_nuts not available. Skipping real mint tests.")
|
||||
return
|
||||
|
||||
# Check if real mint is enabled
|
||||
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.db import TemporaryCredit, create_session
|
||||
from routstr.core.db import ApiKey, create_session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -16,7 +16,7 @@ async def test_reserved_balance_never_negative(integration_client: AsyncClient)
|
||||
|
||||
# Create a test API key with limited balance
|
||||
async with create_session() as session:
|
||||
test_key = TemporaryCredit(
|
||||
test_key = ApiKey(
|
||||
hashed_key="test_reserved_balance_key",
|
||||
balance=1000, # 1 sat
|
||||
reserved_balance=0,
|
||||
@@ -40,7 +40,7 @@ async def test_reserved_balance_never_negative(integration_client: AsyncClient)
|
||||
|
||||
# Check reserved balance after failed request
|
||||
async with create_session() as session:
|
||||
key = await session.get(TemporaryCredit, "test_reserved_balance_key")
|
||||
key = await session.get(ApiKey, "test_reserved_balance_key")
|
||||
assert key is not None
|
||||
assert key.reserved_balance >= 0, (
|
||||
f"Reserved balance went negative: {key.reserved_balance}"
|
||||
@@ -69,7 +69,7 @@ async def test_reserved_balance_never_negative(integration_client: AsyncClient)
|
||||
|
||||
# Check final state
|
||||
async with create_session() as session:
|
||||
key = await session.get(TemporaryCredit, "test_reserved_balance_key")
|
||||
key = await session.get(ApiKey, "test_reserved_balance_key")
|
||||
assert key is not None
|
||||
assert key.reserved_balance >= 0, (
|
||||
f"Reserved balance went negative after concurrent requests: {key.reserved_balance}"
|
||||
@@ -86,7 +86,7 @@ async def test_reserved_balance_with_successful_requests(
|
||||
# Create a test API key with more balance
|
||||
async with create_session() as session:
|
||||
unique_key = f"test_successful_key_{uuid.uuid4().hex[:8]}"
|
||||
test_key = TemporaryCredit(
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
balance=100000, # 100 sats
|
||||
reserved_balance=0,
|
||||
@@ -111,23 +111,24 @@ async def test_reserved_balance_with_successful_requests(
|
||||
|
||||
# Check that reserved balance was properly adjusted
|
||||
async with create_session() as session:
|
||||
key = await session.get(TemporaryCredit, unique_key)
|
||||
key = await session.get(ApiKey, unique_key)
|
||||
assert key is not None
|
||||
assert key.reserved_balance >= 0, (
|
||||
f"Reserved balance went negative: {key.reserved_balance}"
|
||||
)
|
||||
# Check if the request was processed (might fail due to model pricing in test env)
|
||||
# The important part is that reserved_balance doesn't go negative
|
||||
if key.balance < 100000:
|
||||
# Balance decreased
|
||||
pass
|
||||
if key.total_spent > 0:
|
||||
assert key.balance < 100000, (
|
||||
"Balance should decrease after successful request"
|
||||
)
|
||||
else:
|
||||
# Request failed, but reserved balance should still be non-negative
|
||||
assert key.balance == 100000, (
|
||||
"Balance should remain unchanged if request failed"
|
||||
)
|
||||
print(
|
||||
f"After successful request - Balance: {key.balance}, Reserved: {key.reserved_balance}"
|
||||
f"After successful request - Balance: {key.balance}, Reserved: {key.reserved_balance}, Spent: {key.total_spent}"
|
||||
)
|
||||
|
||||
|
||||
@@ -136,11 +137,11 @@ async def test_insufficient_reserved_balance_for_revert(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Test revert_pay_for_request behavior with insufficient reserved balance."""
|
||||
from routstr.payment.helpers import revert_pay_for_request
|
||||
from routstr.auth import revert_pay_for_request
|
||||
|
||||
# Create key with zero reserved balance
|
||||
unique_key = f"test_revert_key_{uuid.uuid4().hex[:8]}"
|
||||
test_key = TemporaryCredit(
|
||||
test_key = ApiKey(
|
||||
hashed_key=unique_key,
|
||||
balance=1000,
|
||||
reserved_balance=0,
|
||||
@@ -159,3 +160,6 @@ async def test_insufficient_reserved_balance_for_revert(
|
||||
assert test_key.reserved_balance == -100, (
|
||||
f"Expected reserved_balance to be -100, got: {test_key.reserved_balance}"
|
||||
)
|
||||
assert test_key.total_requests == -1, (
|
||||
f"Expected total_requests to be -1, got: {test_key.total_requests}"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ Integration tests for wallet authentication system including API key generation
|
||||
Tests POST /v1/wallet/topup endpoint and authorization header validation.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -11,11 +10,10 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import TemporaryCredit
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
ConcurrencyTester,
|
||||
ResponseValidator,
|
||||
)
|
||||
|
||||
@@ -55,11 +53,13 @@ async def test_api_key_generation_valid_token(
|
||||
# Verify database state directly
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
|
||||
assert db_key.balance == amount * 1000
|
||||
assert db_key.total_spent == 0
|
||||
assert db_key.total_requests == 0
|
||||
|
||||
# Verify the API key can be used for authentication
|
||||
integration_client.headers["Authorization"] = f"Bearer {api_key}"
|
||||
@@ -91,15 +91,15 @@ async def test_api_key_generation_invalid_token(
|
||||
integration_client.headers["Authorization"] = f"Bearer {invalid_token}"
|
||||
response = await integration_client.get("/v1/wallet/info")
|
||||
|
||||
# Should fail with 401 or 400
|
||||
assert response.status_code in [400, 401], (
|
||||
f"Token {invalid_token[:20]}... should be invalid (400 or 401)"
|
||||
# Should fail with 401
|
||||
assert response.status_code == 401, (
|
||||
f"Token {invalid_token[:20]}... should be invalid"
|
||||
)
|
||||
|
||||
# Validate error response
|
||||
validator = ResponseValidator()
|
||||
error_validation = validator.validate_error_response(
|
||||
response, expected_status=response.status_code, expected_error_key="detail"
|
||||
response, expected_status=401, expected_error_key="detail"
|
||||
)
|
||||
assert error_validation["valid"]
|
||||
|
||||
@@ -167,7 +167,7 @@ async def test_authorization_header_validation(
|
||||
valid_api_key = response.json()["api_key"]
|
||||
|
||||
# Test scenarios
|
||||
test_cases: list[tuple[dict[str, str], int, str]] = [
|
||||
test_cases = [
|
||||
# (headers, expected_status, description)
|
||||
(
|
||||
{},
|
||||
@@ -191,7 +191,7 @@ async def test_authorization_header_validation(
|
||||
integration_client.headers.pop("authorization", None)
|
||||
|
||||
# Set test headers
|
||||
integration_client.headers.update(headers) # type: ignore[arg-type]
|
||||
integration_client.headers.update(headers)
|
||||
|
||||
# Make request to protected endpoint
|
||||
response = await integration_client.get("/v1/wallet/")
|
||||
@@ -254,14 +254,16 @@ async def test_database_state_api_key_creation(
|
||||
# Verify database record
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
|
||||
# Validate stored data
|
||||
assert db_key.balance == amount * 1000 # msats
|
||||
assert db_key.total_spent == 0
|
||||
assert db_key.total_requests == 0
|
||||
assert db_key.refund_address is None
|
||||
assert db_key.refund_expiration_time is None
|
||||
assert db_key.key_expiry_time is None
|
||||
|
||||
# Creation timestamp should be recent (within last minute)
|
||||
# Note: The model doesn't have a creation timestamp field,
|
||||
@@ -385,69 +387,6 @@ async def test_api_key_with_expiry_time(
|
||||
# The expiry time and refund address functionality is tested elsewhere
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_token_submissions(
|
||||
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
|
||||
) -> None:
|
||||
"""Test concurrent submissions of different tokens"""
|
||||
|
||||
# Generate multiple unique tokens with known amounts
|
||||
num_tokens = 10
|
||||
tokens = []
|
||||
expected_balances = {}
|
||||
|
||||
for i in range(num_tokens):
|
||||
amount = 100 + i * 10
|
||||
token = await testmint_wallet.mint_tokens(amount)
|
||||
tokens.append(token)
|
||||
# Store expected balance by token hash
|
||||
hashed_key = hashlib.sha256(token.encode()).hexdigest()
|
||||
expected_balances[hashed_key] = amount * 1000 # msats
|
||||
|
||||
# Create concurrent requests
|
||||
requests = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/v1/wallet/info",
|
||||
"headers": {"Authorization": f"Bearer {token}"},
|
||||
}
|
||||
for token in tokens
|
||||
]
|
||||
|
||||
# Execute concurrently
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=5
|
||||
)
|
||||
|
||||
# All should succeed
|
||||
assert len(responses) == num_tokens
|
||||
api_keys = set()
|
||||
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
api_key = data["api_key"]
|
||||
api_keys.add(api_key)
|
||||
|
||||
# Verify balance matches the expected amount
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
assert data["balance"] == expected_balances[hashed_key]
|
||||
|
||||
# Should have created unique API keys
|
||||
assert len(api_keys) == num_tokens
|
||||
|
||||
# Verify all keys exist in database
|
||||
for api_key in api_keys:
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
assert db_key.balance == expected_balances[hashed_key]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorization_with_cashu_token_directly(
|
||||
@@ -500,48 +439,6 @@ async def test_x_cashu_header_support(
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.slow
|
||||
async def test_api_key_consistency_under_load(
|
||||
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
|
||||
) -> None:
|
||||
"""Test API key generation consistency under concurrent load"""
|
||||
|
||||
# Generate a single token
|
||||
token = await testmint_wallet.mint_tokens(1000)
|
||||
|
||||
# First request to create the API key
|
||||
integration_client.headers["Authorization"] = f"Bearer {token}"
|
||||
initial_response = await integration_client.get("/v1/wallet/info")
|
||||
assert initial_response.status_code == 200
|
||||
expected_api_key = initial_response.json()["api_key"]
|
||||
expected_balance = initial_response.json()["balance"]
|
||||
|
||||
# Try to use the same token concurrently multiple times
|
||||
# All should return the same API key since it's already created
|
||||
requests = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/v1/wallet/info",
|
||||
"headers": {"Authorization": f"Bearer {token}"},
|
||||
}
|
||||
for _ in range(20) # 20 concurrent attempts
|
||||
]
|
||||
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=10
|
||||
)
|
||||
|
||||
# All should succeed and return the same API key
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["api_key"] == expected_api_key
|
||||
assert data["balance"] == expected_balance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_timestamp_accuracy(
|
||||
@@ -564,7 +461,7 @@ async def test_database_timestamp_accuracy(
|
||||
# Verify key exists in database
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ Integration tests for wallet information retrieval endpoints.
|
||||
Tests GET /v1/wallet/ and GET /v1/wallet/info endpoints with various scenarios.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -11,9 +11,9 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select, update
|
||||
|
||||
from routstr.core.db import TemporaryCredit
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import ConcurrencyTester, ResponseValidator
|
||||
from .utils import ResponseValidator
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -49,7 +49,7 @@ async def test_wallet_endpoint_with_valid_api_key(
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
|
||||
result = await integration_session.execute(
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
|
||||
@@ -138,9 +138,7 @@ async def test_wallet_with_zero_balance(
|
||||
# Manually set balance to zero in database
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
await integration_session.execute(
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key)
|
||||
.values(balance=0) # type: ignore[arg-type]
|
||||
update(ApiKey).where(ApiKey.hashed_key == hashed_key).values(balance=0) # type: ignore[arg-type]
|
||||
)
|
||||
await integration_session.commit()
|
||||
|
||||
@@ -183,11 +181,9 @@ async def test_expired_api_key_behavior(
|
||||
from sqlmodel import update
|
||||
|
||||
await integration_session.execute(
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(
|
||||
refund_expiration_time=past_expiry, refund_address="test@lightning.address"
|
||||
)
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(key_expiry_time=past_expiry, refund_address="test@lightning.address")
|
||||
)
|
||||
await integration_session.commit()
|
||||
|
||||
@@ -201,52 +197,13 @@ async def test_expired_api_key_behavior(
|
||||
# Verify expiry time was stored
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
assert db_key.refund_expiration_time == past_expiry
|
||||
assert db_key.key_expiry_time == past_expiry
|
||||
assert db_key.refund_address == "test@lightning.address"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_access_same_api_key(
|
||||
integration_client: AsyncClient, authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test concurrent access with the same API key"""
|
||||
|
||||
# Get the API key from authenticated client
|
||||
response = await authenticated_client.get("/v1/wallet/")
|
||||
api_key = response.json()["api_key"]
|
||||
initial_balance = response.json()["balance"]
|
||||
|
||||
# Create multiple concurrent requests
|
||||
requests = []
|
||||
for i in range(20):
|
||||
# Alternate between both endpoints
|
||||
endpoint = "/v1/wallet/" if i % 2 == 0 else "/v1/wallet/info"
|
||||
requests.append(
|
||||
{
|
||||
"method": "GET",
|
||||
"url": endpoint,
|
||||
"headers": {"Authorization": f"Bearer {api_key}"},
|
||||
}
|
||||
)
|
||||
|
||||
# Execute concurrently
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=10
|
||||
)
|
||||
|
||||
# All should succeed with consistent data
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["api_key"] == api_key
|
||||
assert data["balance"] == initial_balance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_info_data_consistency(
|
||||
@@ -274,7 +231,7 @@ async def test_wallet_info_data_consistency(
|
||||
# Verify against database
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
|
||||
@@ -364,10 +321,12 @@ async def test_wallet_after_partial_spending(
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
|
||||
await integration_session.execute(
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(
|
||||
balance=initial_balance - spent_amount,
|
||||
total_spent=spent_amount,
|
||||
total_requests=5, # Simulate 5 requests
|
||||
)
|
||||
)
|
||||
await integration_session.commit()
|
||||
@@ -408,30 +367,4 @@ async def test_wallet_info_with_special_characters_in_headers(
|
||||
# Note: Current implementation doesn't return refund_address in response
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.slow
|
||||
async def test_wallet_endpoints_performance(authenticated_client: AsyncClient) -> None:
|
||||
"""Test wallet endpoints meet performance requirements"""
|
||||
|
||||
# Warm up
|
||||
await authenticated_client.get("/v1/wallet/")
|
||||
|
||||
# Measure response times
|
||||
response_times = []
|
||||
|
||||
for _ in range(50):
|
||||
start_time = time.time()
|
||||
response = await authenticated_client.get("/v1/wallet/")
|
||||
end_time = time.time()
|
||||
|
||||
assert response.status_code == 200
|
||||
response_times.append(end_time - start_time)
|
||||
|
||||
# Calculate statistics
|
||||
avg_time = sum(response_times) / len(response_times)
|
||||
max_time = max(response_times)
|
||||
|
||||
# Performance assertions
|
||||
assert avg_time < 0.1 # Average should be under 100ms
|
||||
assert max_time < 0.5 # No request should take more than 500ms
|
||||
|
||||
@@ -13,7 +13,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import TemporaryCredit
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -123,9 +123,7 @@ async def test_zero_balance_refund_handling(
|
||||
from sqlmodel import update
|
||||
|
||||
await integration_session.execute(
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key)
|
||||
.values(balance=0) # type: ignore[arg-type]
|
||||
update(ApiKey).where(ApiKey.hashed_key == hashed_key).values(balance=0) # type: ignore[arg-type]
|
||||
)
|
||||
await integration_session.commit()
|
||||
|
||||
@@ -138,7 +136,7 @@ async def test_zero_balance_refund_handling(
|
||||
|
||||
# Key should still exist
|
||||
result = await integration_session.execute(
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
assert result.scalar_one_or_none() is not None
|
||||
|
||||
@@ -161,7 +159,7 @@ async def test_refund_amount_validation(
|
||||
|
||||
# Verify the key has no refund address (needed for the "too small" check)
|
||||
result = await integration_session.execute(
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
key = result.scalar_one()
|
||||
assert key.refund_address is None
|
||||
@@ -194,8 +192,8 @@ async def test_refund_with_lightning_address(
|
||||
from sqlmodel import update
|
||||
|
||||
await integration_session.execute(
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(refund_address=refund_address)
|
||||
)
|
||||
await integration_session.commit()
|
||||
@@ -254,7 +252,7 @@ async def test_database_state_after_refund(
|
||||
|
||||
# Verify key exists before refund
|
||||
result = await integration_session.execute(
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
key_before = result.scalar_one()
|
||||
assert key_before.balance == 10_000_000
|
||||
@@ -265,12 +263,12 @@ async def test_database_state_after_refund(
|
||||
|
||||
# Verify key is deleted after refund
|
||||
result = await integration_session.execute(
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
# Count total keys to ensure only the specific one was deleted
|
||||
result = await integration_session.execute(select(TemporaryCredit))
|
||||
result = await integration_session.execute(select(ApiKey))
|
||||
remaining_keys = result.scalars().all()
|
||||
# Should have no keys left (assuming clean test environment)
|
||||
assert len(remaining_keys) == 0
|
||||
@@ -482,8 +480,8 @@ async def test_refund_error_handling(
|
||||
from sqlmodel import update
|
||||
|
||||
await integration_session.execute(
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(balance=-1000) # Invalid negative balance
|
||||
)
|
||||
await integration_session.commit()
|
||||
@@ -519,9 +517,9 @@ async def test_refund_with_expired_key(
|
||||
from sqlmodel import update
|
||||
|
||||
await integration_session.execute(
|
||||
update(TemporaryCredit)
|
||||
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(refund_expiration_time=past_expiry, refund_address="expired@ln.address")
|
||||
update(ApiKey)
|
||||
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
.values(key_expiry_time=past_expiry, refund_address="expired@ln.address")
|
||||
)
|
||||
await integration_session.commit()
|
||||
|
||||
@@ -539,42 +537,4 @@ async def test_refund_with_expired_key(
|
||||
assert response.json()["recipient"] == "expired@ln.address"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.slow
|
||||
async def test_refund_performance(
|
||||
integration_client: AsyncClient, testmint_wallet: Any
|
||||
) -> None:
|
||||
"""Test refund endpoint performance"""
|
||||
|
||||
import time
|
||||
|
||||
# Create multiple API keys
|
||||
api_keys = []
|
||||
for i in range(10):
|
||||
token = await testmint_wallet.mint_tokens(100 + i)
|
||||
# Use cashu token as Bearer auth to create API key
|
||||
integration_client.headers["Authorization"] = f"Bearer {token}"
|
||||
response = await integration_client.get("/v1/wallet/info")
|
||||
assert response.status_code == 200
|
||||
api_keys.append(response.json()["api_key"])
|
||||
|
||||
# Measure refund times
|
||||
refund_times = []
|
||||
|
||||
for api_key in api_keys:
|
||||
integration_client.headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
start_time = time.time()
|
||||
response = await integration_client.post("/v1/wallet/refund")
|
||||
end_time = time.time()
|
||||
|
||||
assert response.status_code == 200
|
||||
refund_times.append(end_time - start_time)
|
||||
|
||||
# Performance assertions
|
||||
avg_time = sum(refund_times) / len(refund_times)
|
||||
max_time = max(refund_times)
|
||||
|
||||
assert avg_time < 0.5 # Average under 500ms
|
||||
assert max_time < 1.0 # No refund takes more than 1 second
|
||||
|
||||
@@ -11,11 +11,10 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import TemporaryCredit
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
ConcurrencyTester,
|
||||
ResponseValidator,
|
||||
)
|
||||
|
||||
@@ -64,7 +63,7 @@ async def test_topup_with_valid_token( # type: ignore[no-untyped-def]
|
||||
# Get the hashed key from the API key
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
|
||||
@@ -284,60 +283,6 @@ async def test_transaction_history_tracking( # type: ignore[no-untyped-def]
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_topups_same_api_key( # type: ignore[no-untyped-def]
|
||||
integration_client: AsyncClient,
|
||||
authenticated_client: AsyncClient,
|
||||
testmint_wallet: Any,
|
||||
) -> None:
|
||||
"""Test concurrent top-ups to the same API key"""
|
||||
|
||||
# Get API key
|
||||
response = await authenticated_client.get("/v1/wallet/")
|
||||
api_key = response.json()["api_key"]
|
||||
initial_balance = response.json()["balance"]
|
||||
|
||||
# Generate multiple unique tokens
|
||||
num_tokens = 10
|
||||
tokens = []
|
||||
total_amount = 0
|
||||
|
||||
for i in range(num_tokens):
|
||||
amount = 100 + i * 10 # Different amounts
|
||||
token = await testmint_wallet.mint_tokens(amount)
|
||||
tokens.append(token)
|
||||
total_amount += amount
|
||||
|
||||
# Create concurrent top-up requests
|
||||
requests = [
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "/v1/wallet/topup",
|
||||
"params": {"cashu_token": token},
|
||||
"headers": {"Authorization": f"Bearer {api_key}"},
|
||||
}
|
||||
for token in tokens
|
||||
]
|
||||
|
||||
# Execute concurrently
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=5
|
||||
)
|
||||
|
||||
# All should succeed
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
assert "msats" in response.json()
|
||||
|
||||
# Verify final balance is correct
|
||||
final_response = await authenticated_client.get("/v1/wallet/")
|
||||
final_balance = final_response.json()["balance"]
|
||||
expected_balance = initial_balance + (total_amount * 1000)
|
||||
assert final_balance == expected_balance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_during_active_proxy_request( # type: ignore[no-untyped-def]
|
||||
|
||||
@@ -9,7 +9,7 @@ import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
|
||||
from routstr.core.db import TemporaryCredit
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
|
||||
class CashuTokenGenerator:
|
||||
@@ -97,11 +97,11 @@ class DatabaseStateValidator:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_api_key(self, api_key: str) -> Optional[TemporaryCredit]:
|
||||
async def get_api_key(self, api_key: str) -> Optional[ApiKey]:
|
||||
"""Get API key from database"""
|
||||
hashed_key = hashlib.sha256(api_key.encode()).hexdigest()
|
||||
result = await self.session.execute(
|
||||
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -125,6 +125,20 @@ class DatabaseStateValidator:
|
||||
"current_balance": key_obj.balance,
|
||||
}
|
||||
|
||||
async def validate_request_count(
|
||||
self, api_key: str, expected_count: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Validate request count for an API key"""
|
||||
key_obj = await self.get_api_key(api_key)
|
||||
if not key_obj:
|
||||
return {"valid": False, "error": "API key not found"}
|
||||
|
||||
return {
|
||||
"valid": key_obj.total_requests == expected_count,
|
||||
"expected": expected_count,
|
||||
"actual": key_obj.total_requests,
|
||||
}
|
||||
|
||||
async def validate_atomic_update(
|
||||
self, api_key: str, field: str, expected_value: Any
|
||||
) -> bool:
|
||||
@@ -301,7 +315,7 @@ class ConcurrencyTester:
|
||||
)
|
||||
|
||||
tasks = [make_request(req) for req in requests]
|
||||
return await asyncio.gather(*tasks, return_exceptions=False) # type: ignore
|
||||
return await asyncio.gather(*tasks, return_exceptions=False)
|
||||
|
||||
@staticmethod
|
||||
async def test_race_condition(
|
||||
@@ -449,6 +463,8 @@ class TestDataBuilder:
|
||||
"""Create test API key data"""
|
||||
data: Dict[str, Any] = {
|
||||
"balance": balance,
|
||||
"total_spent": 0,
|
||||
"total_requests": 0,
|
||||
}
|
||||
|
||||
if refund_address:
|
||||
|
||||
@@ -44,9 +44,9 @@ def check_imports() -> bool:
|
||||
print("Conftest fixtures imported successfully")
|
||||
|
||||
# Check routstr modules - imports are for verification only
|
||||
from routstr.core.db import TemporaryCredit
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
del TemporaryCredit
|
||||
del ApiKey
|
||||
|
||||
print("Router modules imported successfully")
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ from unittest.mock import Mock
|
||||
os.environ["UPSTREAM_BASE_URL"] = "http://test"
|
||||
os.environ["UPSTREAM_API_KEY"] = "test"
|
||||
|
||||
from routstr.models.algorithm import ( # noqa: E402
|
||||
from routstr.algorithm import ( # noqa: E402
|
||||
calculate_model_cost_score,
|
||||
get_provider_penalty,
|
||||
should_prefer_model,
|
||||
)
|
||||
from routstr.models.models import Architecture, Model, Pricing # noqa: E402
|
||||
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
|
||||
|
||||
|
||||
def create_test_model(
|
||||
|
||||
@@ -21,11 +21,11 @@ import pytest
|
||||
os.environ["UPSTREAM_BASE_URL"] = "http://test"
|
||||
os.environ["UPSTREAM_API_KEY"] = "test"
|
||||
|
||||
from routstr.models.crud import _model_to_row_payload
|
||||
from routstr.models.models import ( # noqa: E402
|
||||
from routstr.payment.models import ( # noqa: E402
|
||||
Architecture,
|
||||
Model,
|
||||
Pricing,
|
||||
_model_to_row_payload,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ from io import BytesIO
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from routstr.payment.cost import (
|
||||
from routstr.payment.helpers import (
|
||||
_calculate_image_tokens,
|
||||
_estimate_image_tokens_in_messages,
|
||||
_get_image_dimensions,
|
||||
estimate_image_tokens_in_messages,
|
||||
)
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ async def test_estimate_image_tokens_base64() -> None:
|
||||
}
|
||||
]
|
||||
|
||||
tokens = await _estimate_image_tokens_in_messages(messages)
|
||||
tokens = await estimate_image_tokens_in_messages(messages)
|
||||
assert tokens > 0
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ async def test_estimate_image_tokens_multiple_images() -> None:
|
||||
}
|
||||
]
|
||||
|
||||
tokens = await _estimate_image_tokens_in_messages(messages)
|
||||
tokens = await estimate_image_tokens_in_messages(messages)
|
||||
assert tokens > 0
|
||||
|
||||
|
||||
@@ -148,8 +148,8 @@ async def test_estimate_image_tokens_with_detail() -> None:
|
||||
}
|
||||
]
|
||||
|
||||
tokens_low = await _estimate_image_tokens_in_messages(messages_low)
|
||||
tokens_high = await _estimate_image_tokens_in_messages(messages_high)
|
||||
tokens_low = await estimate_image_tokens_in_messages(messages_low)
|
||||
tokens_high = await estimate_image_tokens_in_messages(messages_high)
|
||||
|
||||
assert tokens_low == 85
|
||||
assert tokens_high > tokens_low
|
||||
@@ -163,7 +163,7 @@ async def test_estimate_image_tokens_no_images() -> None:
|
||||
{"role": "assistant", "content": "I'm doing well, thank you!"},
|
||||
]
|
||||
|
||||
tokens = await _estimate_image_tokens_in_messages(messages)
|
||||
tokens = await estimate_image_tokens_in_messages(messages)
|
||||
assert tokens == 0
|
||||
|
||||
|
||||
@@ -185,5 +185,5 @@ async def test_estimate_image_tokens_input_image_type() -> None:
|
||||
}
|
||||
]
|
||||
|
||||
tokens = await _estimate_image_tokens_in_messages(messages)
|
||||
tokens = await estimate_image_tokens_in_messages(messages)
|
||||
assert tokens > 0
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Unit tests for the logging SecurityFilter.
|
||||
|
||||
This module tests that the SecurityFilter correctly identifies and redacts
|
||||
sensitive information from log messages without causing false positives.
|
||||
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core.logging import SecurityFilter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def security_filter() -> SecurityFilter:
|
||||
"""Provide an instance of the SecurityFilter for testing."""
|
||||
return SecurityFilter()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def filter_message(security_filter: SecurityFilter) -> Callable[[str], str]:
|
||||
"""A helper fixture to apply the filter to a message string."""
|
||||
|
||||
def _filter(msg: str) -> str:
|
||||
record = logging.LogRecord(
|
||||
name="test_logger",
|
||||
level=logging.INFO,
|
||||
pathname="",
|
||||
lineno=0,
|
||||
msg=msg,
|
||||
args=(),
|
||||
exc_info=None,
|
||||
)
|
||||
security_filter.filter(record)
|
||||
return record.getMessage()
|
||||
|
||||
return _filter
|
||||
|
||||
|
||||
def test_redacts_unquoted_key_value_pairs(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that an unquoted key-value pair is correctly redacted."""
|
||||
original = "Processing request with api_key=sk-12345abcdef"
|
||||
expected = "Processing request with api_key: [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_redacts_quoted_key_value_pairs(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that a quoted token is correctly redacted."""
|
||||
original = 'User authenticated with token="cashuA123abc"'
|
||||
expected = "User authenticated with token: [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_redacts_bearer_token(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that a Bearer token of sufficient length is redacted."""
|
||||
original = "Authorization: Bearer abc1234567890xyzabcdefg"
|
||||
expected = "Authorization: [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_redacts_cashu_token(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that a Cashu token is redacted."""
|
||||
original = "Received cashuTOKENeyJ0b2tlbiI6W3siaWQiOiI"
|
||||
expected = "Received [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_redacts_nsec_key(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that a full-length Nostr private key is redacted."""
|
||||
original = "Private key is nsec1a8d9f8s7d9f8a7s6d5f4a3s2d1f9a8s7d6f5a4s3d2f1a9s8d7f6a5s4d3f"
|
||||
expected = "Private key is [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_ignores_non_sensitive_message(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that a message with no sensitive data is left untouched."""
|
||||
original = "No token pricing configured, using base cost"
|
||||
expected = "No token pricing configured, using base cost"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_multiple_secrets_in_one_message(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that multiple different secrets in one message are all redacted."""
|
||||
original = 'Auth with Bearer abcdefghijklmnopqrstuvwxyz and api_key="sk-12345"'
|
||||
expected = "Auth with [REDACTED] and api_key: [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_redacts_key_with_no_value(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that a key with no value is not redacted."""
|
||||
original = "Request contains api_key and secret."
|
||||
expected = "Request contains api_key and secret."
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_redacts_key_value_with_spaces(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that key-value pairs with extra spaces are correctly redacted."""
|
||||
original = "Auth info: api_key = 'sk-12345'"
|
||||
expected = "Auth info: api_key: [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_is_case_insensitive_for_keys(filter_message: Callable[[str], str]) -> None:
|
||||
"""Test that key matching is case-insensitive."""
|
||||
original = "TOKEN=sk-abcdef12345"
|
||||
expected = "token: [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
|
||||
|
||||
def test_is_case_insensitive_for_standalone(
|
||||
filter_message: Callable[[str], str],
|
||||
) -> None:
|
||||
"""Test that standalone matching is case-insensitive."""
|
||||
original = "Using NSEC1a8d9f8s7d9f8a7s6d5f4a3s2d1f9a8s7d6f5a4s3d2f1a9s8d7f6a5s4d3f and CaShuA123abc"
|
||||
expected = "Using [REDACTED] and [REDACTED]"
|
||||
assert filter_message(original) == expected
|
||||
@@ -7,11 +7,11 @@ os.environ["UPSTREAM_BASE_URL"] = "http://test"
|
||||
os.environ["UPSTREAM_API_KEY"] = "test"
|
||||
|
||||
from routstr.core.settings import settings # noqa: E402
|
||||
from routstr.payment.cost import get_max_cost_for_model # noqa: E402
|
||||
from routstr.payment.helpers import get_max_cost_for_model # noqa: E402
|
||||
|
||||
|
||||
async def test_get_max_cost_for_model_known() -> None:
|
||||
from routstr.models.models import Pricing
|
||||
from routstr.payment.models import Pricing
|
||||
|
||||
# Mock DB session behavior
|
||||
mock_session = AsyncMock()
|
||||
@@ -63,32 +63,48 @@ async def test_get_max_cost_for_model_known() -> None:
|
||||
|
||||
with patch.object(settings, "fixed_pricing", False):
|
||||
with patch.object(settings, "tolerance_percentage", 0):
|
||||
cost = get_max_cost_for_model(model_obj=mock_model)
|
||||
cost = await get_max_cost_for_model(
|
||||
"gpt-4", session=mock_session, model_obj=mock_model
|
||||
)
|
||||
assert cost == 500000 # 500 sats * 1000 = msats
|
||||
|
||||
|
||||
async def test_get_max_cost_for_model_unknown() -> None:
|
||||
mock_model = Mock()
|
||||
mock_model.sats_pricing = None
|
||||
mock_model.id = "unknown-model"
|
||||
mock_session = AsyncMock()
|
||||
|
||||
with patch.object(settings, "fixed_cost_per_request", 100):
|
||||
with patch.object(settings, "tolerance_percentage", 0):
|
||||
cost = get_max_cost_for_model(model_obj=mock_model)
|
||||
assert cost == 100000
|
||||
# Mock the exec results to return no model override
|
||||
async def async_mock_exec(query: Any) -> Any:
|
||||
result = Mock()
|
||||
result.first = Mock(return_value=None)
|
||||
result.all = Mock(return_value=[])
|
||||
return result
|
||||
|
||||
mock_session.exec = AsyncMock(side_effect=async_mock_exec)
|
||||
mock_session.get = AsyncMock(return_value=None)
|
||||
|
||||
# Mock get_upstreams to return empty list
|
||||
with patch("routstr.proxy.get_upstreams", return_value=[]):
|
||||
with patch.object(settings, "fixed_cost_per_request", 100):
|
||||
with patch.object(settings, "tolerance_percentage", 0):
|
||||
cost = await get_max_cost_for_model(
|
||||
"unknown-model", session=mock_session, model_obj=None
|
||||
)
|
||||
assert cost == 100000
|
||||
|
||||
|
||||
async def test_get_max_cost_for_model_disabled() -> None:
|
||||
mock_model = Mock()
|
||||
mock_session = AsyncMock()
|
||||
with patch.object(settings, "fixed_pricing", True):
|
||||
with patch.object(settings, "fixed_cost_per_request", 200):
|
||||
with patch.object(settings, "tolerance_percentage", 0):
|
||||
cost = get_max_cost_for_model(model_obj=mock_model)
|
||||
cost = await get_max_cost_for_model("any-model", session=mock_session)
|
||||
assert cost == 200000
|
||||
|
||||
|
||||
async def test_get_max_cost_for_model_tolerance() -> None:
|
||||
from routstr.models.models import Pricing
|
||||
from routstr.payment.models import Pricing
|
||||
|
||||
mock_session = AsyncMock()
|
||||
|
||||
# Mock the model with sats_pricing
|
||||
mock_pricing = Pricing(
|
||||
@@ -105,5 +121,7 @@ async def test_get_max_cost_for_model_tolerance() -> None:
|
||||
|
||||
with patch.object(settings, "fixed_pricing", False):
|
||||
with patch.object(settings, "tolerance_percentage", 10):
|
||||
cost = get_max_cost_for_model(model_obj=mock_model)
|
||||
cost = await get_max_cost_for_model(
|
||||
"gpt-4", session=mock_session, model_obj=mock_model
|
||||
)
|
||||
assert cost == 450000 # 500 sats * 1000 * 0.9 = 450000
|
||||
|
||||
+12
-23
@@ -4,13 +4,8 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.core.db import TemporaryCredit
|
||||
from routstr.payment.wallet import (
|
||||
credit_balance,
|
||||
get_balance,
|
||||
recieve_token,
|
||||
send_token,
|
||||
)
|
||||
from routstr.core.db import ApiKey
|
||||
from routstr.wallet import credit_balance, get_balance, recieve_token, send_token
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -20,7 +15,7 @@ async def test_get_balance() -> None:
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
mock_wallet.load_proofs = AsyncMock()
|
||||
|
||||
with patch("routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
balance = await get_balance("sat")
|
||||
assert balance == 50000
|
||||
|
||||
@@ -48,9 +43,7 @@ async def test_recieve_token_valid() -> None:
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
|
||||
with patch(
|
||||
"routstr.payment.wallet.deserialize_token_from_string"
|
||||
) as mock_deserialize:
|
||||
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
|
||||
mock_token = Mock()
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.mint = "http://mint:3338"
|
||||
@@ -61,9 +54,7 @@ async def test_recieve_token_valid() -> None:
|
||||
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
mock_wallet.load_proofs = AsyncMock()
|
||||
with patch(
|
||||
"routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet
|
||||
):
|
||||
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
amount, unit, mint = await recieve_token(token_str)
|
||||
assert amount == 1000
|
||||
assert unit == "sat"
|
||||
@@ -74,8 +65,8 @@ async def test_recieve_token_valid() -> None:
|
||||
async def test_send_token() -> None:
|
||||
mock_wallet = Mock()
|
||||
|
||||
with patch("routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
with patch("routstr.payment.wallet.send", return_value=(1000, "test_token")):
|
||||
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
with patch("routstr.wallet.send", return_value=(1000, "test_token")):
|
||||
token = await send_token(1000, "sat", "http://mint:3338")
|
||||
assert token == "test_token"
|
||||
|
||||
@@ -96,7 +87,7 @@ async def test_credit_balance() -> None:
|
||||
mock_session = AsyncMock()
|
||||
|
||||
# Mock session.refresh to update the balance (simulates DB reload)
|
||||
async def mock_refresh(key: TemporaryCredit) -> None:
|
||||
async def mock_refresh(key: ApiKey) -> None:
|
||||
key.balance = 6000000
|
||||
|
||||
mock_session.refresh.side_effect = mock_refresh
|
||||
@@ -105,7 +96,7 @@ async def test_credit_balance() -> None:
|
||||
|
||||
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
|
||||
with patch(
|
||||
"routstr.payment.wallet.recieve_token",
|
||||
"routstr.wallet.recieve_token",
|
||||
return_value=(1000, "sat", "http://mint:3338"),
|
||||
):
|
||||
amount = await credit_balance(token_str, mock_key, mock_session)
|
||||
@@ -121,9 +112,7 @@ async def test_credit_balance() -> None:
|
||||
async def test_recieve_token_untrusted_mint() -> None:
|
||||
mock_wallet = Mock()
|
||||
|
||||
with patch(
|
||||
"routstr.payment.wallet.deserialize_token_from_string"
|
||||
) as mock_deserialize:
|
||||
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
|
||||
mock_token = Mock()
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.mint = "http://untrusted:3338"
|
||||
@@ -133,9 +122,9 @@ async def test_recieve_token_untrusted_mint() -> None:
|
||||
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
mock_wallet.load_proofs = AsyncMock()
|
||||
with patch("routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
||||
with patch(
|
||||
"routstr.payment.wallet.swap_to_primary_mint",
|
||||
"routstr.wallet.swap_to_primary_mint",
|
||||
return_value=(900, "sat", "http://mint:3338"),
|
||||
):
|
||||
amount, unit, mint = await recieve_token("test_token")
|
||||
|
||||
@@ -29,9 +29,7 @@ export default function BalancesPage() {
|
||||
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold tracking-tight'>
|
||||
Balances
|
||||
</h1>
|
||||
<h1 className='text-3xl font-bold tracking-tight'>Balances</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Monitor and manage wallet balances
|
||||
</p>
|
||||
|
||||
@@ -78,8 +78,8 @@ export default function AdminLoginPage(): ReactElement {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background px-4 py-12 text-foreground'>
|
||||
<Card className='w-full max-w-md border border-border/60 bg-card/90 shadow-2xl shadow-black/30 backdrop-blur'>
|
||||
<div className='bg-background text-foreground flex min-h-screen items-center justify-center px-4 py-12'>
|
||||
<Card className='border-border/60 bg-card/90 w-full max-w-md border shadow-2xl shadow-black/30 backdrop-blur'>
|
||||
<CardHeader className='space-y-1'>
|
||||
<CardTitle className='text-center text-2xl font-bold'>
|
||||
Admin Login
|
||||
|
||||
@@ -97,7 +97,7 @@ export function LogDetailsDialog({
|
||||
<div>
|
||||
<h4 className='mb-2 text-sm font-medium'>Message</h4>
|
||||
<div className='bg-muted max-h-48 overflow-auto rounded-md p-3'>
|
||||
<pre className='font-mono text-sm whitespace-pre break-all'>
|
||||
<pre className='font-mono text-sm break-all whitespace-pre'>
|
||||
{log.message}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -116,7 +116,12 @@ export function LogDetailsDialog({
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => copyToClipboard(String(log[field as keyof LogEntry] || ''), field)}
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
String(log[field as keyof LogEntry] || ''),
|
||||
field
|
||||
)
|
||||
}
|
||||
className='h-6 flex-shrink-0 px-2'
|
||||
>
|
||||
{copiedField === field ? (
|
||||
@@ -175,7 +180,9 @@ export function LogDetailsDialog({
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => copyToClipboard(JSON.stringify(log, null, 2), 'json')}
|
||||
onClick={() =>
|
||||
copyToClipboard(JSON.stringify(log, null, 2), 'json')
|
||||
}
|
||||
className='h-6 px-2'
|
||||
>
|
||||
{copiedField === 'json' ? (
|
||||
|
||||
+30
-18
@@ -204,47 +204,59 @@ export default function ModelsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{totalModels === 0 ? (
|
||||
<Alert>
|
||||
{totalModels === 0 && (
|
||||
<Alert className='mb-4'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
<div className='space-y-2'>
|
||||
<p className='font-medium'>
|
||||
No models found for this provider
|
||||
</p>
|
||||
<div className='text-sm space-y-1'>
|
||||
<p className='font-medium'>Common issues:</p>
|
||||
<ul className='list-disc list-inside space-y-1 ml-2'>
|
||||
<div className='space-y-1 text-sm'>
|
||||
<p className='font-medium'>
|
||||
Common issues:
|
||||
</p>
|
||||
<ul className='ml-2 list-inside list-disc space-y-1'>
|
||||
<li>
|
||||
<strong>API credentials:</strong> Check if the API key is correct and has the right permissions
|
||||
<strong>API credentials:</strong>{' '}
|
||||
Check if the API key is correct
|
||||
and has the right permissions
|
||||
</li>
|
||||
<li>
|
||||
<strong>Base URL:</strong> Verify the base URL is correct for your provider
|
||||
<strong>Base URL:</strong> Verify
|
||||
the base URL is correct for your
|
||||
provider
|
||||
</li>
|
||||
<li>
|
||||
<strong>Network access:</strong> Ensure the server can reach the provider's API endpoint
|
||||
<strong>Network access:</strong>{' '}
|
||||
Ensure the server can reach the
|
||||
provider's API endpoint
|
||||
</li>
|
||||
<li>
|
||||
<strong>Provider status:</strong> The upstream provider might be temporarily unavailable
|
||||
<strong>Provider status:</strong>{' '}
|
||||
The upstream provider might be
|
||||
temporarily unavailable
|
||||
</li>
|
||||
</ul>
|
||||
{groupData?.group_url && (
|
||||
<p className='mt-2 text-xs text-muted-foreground'>
|
||||
Current endpoint: <code className='bg-muted px-1 rounded'>{groupData.group_url}</code>
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
Current endpoint:{' '}
|
||||
<code className='bg-muted rounded px-1'>
|
||||
{groupData.group_url}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ModelSelector
|
||||
filterProvider={provider}
|
||||
groupData={groupData}
|
||||
showProviderActions={true}
|
||||
showDeleteAllButton={false}
|
||||
/>
|
||||
)}
|
||||
<ModelSelector
|
||||
filterProvider={provider}
|
||||
groupData={groupData}
|
||||
showProviderActions={true}
|
||||
showDeleteAllButton={false}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
|
||||
+49
-23
@@ -53,7 +53,7 @@ export default function DashboardPage() {
|
||||
window.removeEventListener('storage', syncAuthState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
const { data: btcUsdPrice } = useQuery({
|
||||
queryKey: ['btc-usd-price'],
|
||||
queryFn: fetchBtcUsdPrice,
|
||||
@@ -131,8 +131,13 @@ export default function DashboardPage() {
|
||||
<SiteHeader />
|
||||
<div className='container max-w-7xl px-4 py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-8'>
|
||||
<h1 className='text-3xl font-bold tracking-tight mb-6'>Dashboard</h1>
|
||||
<DashboardBalanceSummary displayUnit={displayUnit} usdPerSat={usdPerSat} />
|
||||
<h1 className='mb-6 text-3xl font-bold tracking-tight'>
|
||||
Dashboard
|
||||
</h1>
|
||||
<DashboardBalanceSummary
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between'>
|
||||
@@ -140,8 +145,9 @@ export default function DashboardPage() {
|
||||
<h2 className='text-xl font-semibold tracking-tight'>
|
||||
Usage Analytics
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-sm mt-1'>
|
||||
Monitor requests, errors, and revenue over the last {timeRange} hours
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Monitor requests, errors, and revenue over the last {timeRange}{' '}
|
||||
hours
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-4'>
|
||||
@@ -176,27 +182,31 @@ export default function DashboardPage() {
|
||||
|
||||
<div className='space-y-6'>
|
||||
{summaryLoading ? (
|
||||
<div className='text-center py-8'>Loading summary...</div>
|
||||
<div className='py-8 text-center'>Loading summary...</div>
|
||||
) : summaryData ? (
|
||||
<UsageSummaryCards summary={summaryData} />
|
||||
) : null}
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-2'>
|
||||
{metricsLoading ? (
|
||||
<div className='text-center py-8 col-span-2'>
|
||||
<div className='col-span-2 py-8 text-center'>
|
||||
Loading metrics...
|
||||
</div>
|
||||
) : metricsData && metricsData.metrics.length > 0 ? (
|
||||
<>
|
||||
<div className="col-span-full">
|
||||
<div className='col-span-full'>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics.map((m) => ({
|
||||
...m,
|
||||
revenue_sats: m.revenue_msats / 1000,
|
||||
refunds_sats: m.refunds_msats / 1000,
|
||||
net_revenue_sats:
|
||||
(m.revenue_msats - m.refunds_msats) / 1000,
|
||||
})) as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
data={
|
||||
metricsData.metrics.map((m) => ({
|
||||
...m,
|
||||
revenue_sats: m.revenue_msats / 1000,
|
||||
refunds_sats: m.refunds_msats / 1000,
|
||||
net_revenue_sats:
|
||||
(m.revenue_msats - m.refunds_msats) / 1000,
|
||||
})) as Array<
|
||||
Record<string, unknown> & { timestamp: string }
|
||||
>
|
||||
}
|
||||
title='Revenue Over Time (sats)'
|
||||
dataKeys={[
|
||||
{
|
||||
@@ -218,7 +228,11 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
data={
|
||||
metricsData.metrics as Array<
|
||||
Record<string, unknown> & { timestamp: string }
|
||||
>
|
||||
}
|
||||
title='Request Volume'
|
||||
dataKeys={[
|
||||
{
|
||||
@@ -239,7 +253,11 @@ export default function DashboardPage() {
|
||||
]}
|
||||
/>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
data={
|
||||
metricsData.metrics as Array<
|
||||
Record<string, unknown> & { timestamp: string }
|
||||
>
|
||||
}
|
||||
title='Error Tracking'
|
||||
dataKeys={[
|
||||
{
|
||||
@@ -260,7 +278,11 @@ export default function DashboardPage() {
|
||||
]}
|
||||
/>
|
||||
<UsageMetricsChart
|
||||
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
|
||||
data={
|
||||
metricsData.metrics as Array<
|
||||
Record<string, unknown> & { timestamp: string }
|
||||
>
|
||||
}
|
||||
title='Payment Activity'
|
||||
dataKeys={[
|
||||
{
|
||||
@@ -270,7 +292,7 @@ export default function DashboardPage() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
{summaryData && summaryData.unique_models.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -306,7 +328,9 @@ export default function DashboardPage() {
|
||||
key={type}
|
||||
className='flex items-center justify-between'
|
||||
>
|
||||
<span className='text-sm font-medium'>{type}</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{type}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{count}
|
||||
</span>
|
||||
@@ -335,16 +359,18 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{revenueByModelLoading ? (
|
||||
<div className='text-center py-8'>Loading revenue by model...</div>
|
||||
<div className='py-8 text-center'>
|
||||
Loading revenue by model...
|
||||
</div>
|
||||
) : revenueByModelData && revenueByModelData.models.length > 0 ? (
|
||||
<RevenueByModelTable
|
||||
<RevenueByModelTable
|
||||
models={revenueByModelData.models}
|
||||
totalRevenue={revenueByModelData.total_revenue_sats}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{errorLoading ? (
|
||||
<div className='text-center py-8'>Loading errors...</div>
|
||||
<div className='py-8 text-center'>Loading errors...</div>
|
||||
) : errorData ? (
|
||||
<ErrorDetailsTable errors={errorData.errors} />
|
||||
) : null}
|
||||
|
||||
+608
-35
@@ -18,7 +18,9 @@ import {
|
||||
UpstreamProvider,
|
||||
CreateUpstreamProvider,
|
||||
UpdateUpstreamProvider,
|
||||
AdminModel,
|
||||
} from '@/lib/api/services/admin';
|
||||
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -51,9 +53,329 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
function ProviderBalance({
|
||||
providerId,
|
||||
platformUrl,
|
||||
}: {
|
||||
providerId: number;
|
||||
platformUrl?: string | null;
|
||||
}) {
|
||||
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
|
||||
const [topupAmount, setTopupAmount] = useState('');
|
||||
const [topupError, setTopupError] = useState('');
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [invoiceData, setInvoiceData] = useState<{
|
||||
payment_request: string;
|
||||
invoice_id: string;
|
||||
} | null>(null);
|
||||
const [paymentStatus, setPaymentStatus] = useState<'pending' | 'paid' | null>(
|
||||
null
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: balanceData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
queryFn: () => AdminService.getProviderBalance(providerId),
|
||||
refetchInterval: 30000,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const { data: statusData } = useQuery({
|
||||
queryKey: ['topup-status', providerId, invoiceData?.invoice_id],
|
||||
queryFn: () =>
|
||||
AdminService.checkTopupStatus(providerId, invoiceData!.invoice_id),
|
||||
enabled: !!invoiceData && paymentStatus === 'pending',
|
||||
refetchInterval: 2000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (statusData?.paid === true) {
|
||||
setPaymentStatus('paid');
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
});
|
||||
toast.success('Payment received!', {
|
||||
description: 'Your balance has been updated.',
|
||||
});
|
||||
}
|
||||
}, [statusData, queryClient, providerId]);
|
||||
|
||||
const topupMutation = useMutation({
|
||||
mutationFn: async (amount: number) => {
|
||||
console.log('Calling top-up API with:', { providerId, amount });
|
||||
try {
|
||||
const result = await AdminService.initiateProviderTopup(
|
||||
providerId,
|
||||
amount
|
||||
);
|
||||
console.log('API returned:', result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('API call failed:', err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
console.log('Top-up response:', data);
|
||||
console.log('Type of data:', typeof data);
|
||||
console.log('Keys in data:', Object.keys(data || {}));
|
||||
|
||||
if (data?.topup_data?.payment_request && data?.topup_data?.invoice_id) {
|
||||
setInvoiceData({
|
||||
payment_request: data.topup_data.payment_request as string,
|
||||
invoice_id: data.topup_data.invoice_id as string,
|
||||
});
|
||||
setPaymentStatus('pending');
|
||||
} else {
|
||||
console.error('Missing invoice data:', data);
|
||||
console.error('topup_data:', data?.topup_data);
|
||||
toast.error('No invoice returned from provider');
|
||||
setIsTopupDialogOpen(false);
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('Top-up mutation error:', error);
|
||||
toast.error(`Failed to initiate top-up: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleTopup = () => {
|
||||
// If no dialog open logic (which depends on API implementation),
|
||||
// we check if we should redirect or open dialog based on available info
|
||||
// But since this function is called inside the dialog, we might want to change
|
||||
// how the "Top Up" button behaves instead.
|
||||
const amount = parseFloat(topupAmount);
|
||||
|
||||
if (isNaN(amount)) {
|
||||
setTopupError('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount < 1 || amount > 500) {
|
||||
setTopupError('Amount must be between $1 and $500');
|
||||
return;
|
||||
}
|
||||
|
||||
topupMutation.mutate(amount);
|
||||
};
|
||||
|
||||
const handleTopUpClick = () => {
|
||||
// Check if the provider supports direct topup (currently only PPQ.AI effectively)
|
||||
// We can infer this if it's NOT OpenRouter or OpenAI, or strictly checking provider capability
|
||||
// For now, we'll try to initiate topup for anyone, but if we know it fails (or isn't implemented),
|
||||
// we should redirect.
|
||||
// However, the prompt asks to redirect if topup is not implemented.
|
||||
// The backend throws 500/400 if not implemented.
|
||||
// A better approach is to check if we have a platform URL and maybe redirect there
|
||||
// if we know it's not supported.
|
||||
|
||||
// BUT, we don't know for sure if it's supported without checking metadata or trying.
|
||||
// Let's rely on the "can_topup" metadata if available, but currently we only have "can_show_balance".
|
||||
|
||||
// Simple heuristic: If platformUrl exists and we suspect no direct topup, redirect?
|
||||
// Actually, let's try to open the dialog, but if it's OpenRouter/OpenAI, maybe we just redirect?
|
||||
// The user specifically mentioned "like in openrouter".
|
||||
|
||||
if (
|
||||
platformUrl &&
|
||||
(platformUrl.includes('openrouter.ai') ||
|
||||
platformUrl.includes('openai.com'))
|
||||
) {
|
||||
window.open(platformUrl, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTopupDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setIsTopupDialogOpen(false);
|
||||
setTopupAmount('');
|
||||
setTopupError('');
|
||||
setInvoiceData(null);
|
||||
setPaymentStatus(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className='h-9 w-24' />;
|
||||
}
|
||||
|
||||
if (error || !balanceData?.ok || !balanceData.balance_data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const balance = balanceData.balance_data;
|
||||
let displayValue = 'N/A';
|
||||
|
||||
if (typeof balance === 'number') {
|
||||
displayValue = `$${balance.toFixed(2)}`;
|
||||
} else if (balance && typeof balance === 'object') {
|
||||
// Legacy support for object response
|
||||
const b = balance as Record<string, unknown>;
|
||||
if (typeof b.balance === 'number') {
|
||||
displayValue = `$${b.balance.toFixed(2)}`;
|
||||
} else if (typeof b.balance === 'string') {
|
||||
displayValue = b.balance;
|
||||
} else if (b.amount !== undefined) {
|
||||
displayValue = `$${Number(b.amount).toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleTopUpClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className='w-full font-mono sm:w-auto'
|
||||
>
|
||||
{isHovered ? 'Top Up' : displayValue}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isTopupDialogOpen} onOpenChange={handleCloseDialog}>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{paymentStatus === 'paid'
|
||||
? 'Payment Confirmed!'
|
||||
: 'Top Up Balance'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{paymentStatus === 'paid'
|
||||
? 'Your account balance has been updated.'
|
||||
: invoiceData
|
||||
? 'Scan the QR code or copy the Lightning invoice to pay.'
|
||||
: 'Enter the amount you want to add to your account balance.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{paymentStatus === 'paid' ? (
|
||||
<div className='flex flex-col items-center gap-4 py-6'>
|
||||
<div className='rounded-full bg-green-100 p-3 dark:bg-green-900'>
|
||||
<svg
|
||||
className='h-12 w-12 text-green-600 dark:text-green-400'
|
||||
fill='none'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path d='M5 13l4 4L19 7'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='text-center font-semibold'>Top-up successful!</p>
|
||||
</div>
|
||||
) : invoiceData ? (
|
||||
<div className='flex flex-col items-center gap-4 py-4'>
|
||||
<div className='rounded-lg border-2 border-gray-200 p-2 dark:border-gray-800'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(invoiceData.payment_request)}`}
|
||||
alt='Lightning Invoice QR Code'
|
||||
className='h-64 w-64'
|
||||
/>
|
||||
</div>
|
||||
<div className='w-full space-y-2'>
|
||||
<Label htmlFor='invoice'>Lightning Invoice</Label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='invoice'
|
||||
value={invoiceData.payment_request}
|
||||
readOnly
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
invoiceData.payment_request
|
||||
);
|
||||
toast.success('Invoice copied to clipboard!');
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{paymentStatus === 'pending' && (
|
||||
<p className='text-muted-foreground text-center text-sm'>
|
||||
Waiting for payment...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid gap-4 py-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='topup_amount'>Amount (USD)</Label>
|
||||
<Input
|
||||
id='topup_amount'
|
||||
type='number'
|
||||
placeholder='Enter amount (1-500)'
|
||||
value={topupAmount}
|
||||
onChange={(e) => {
|
||||
setTopupAmount(e.target.value);
|
||||
setTopupError('');
|
||||
}}
|
||||
min='1'
|
||||
max='500'
|
||||
step='0.01'
|
||||
/>
|
||||
{topupError && (
|
||||
<p className='text-sm text-red-600 dark:text-red-400'>
|
||||
{topupError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{paymentStatus === 'paid' ? (
|
||||
<Button onClick={handleCloseDialog} className='w-full'>
|
||||
Done
|
||||
</Button>
|
||||
) : invoiceData ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleCloseDialog}
|
||||
className='w-full'
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant='outline' onClick={handleCloseDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTopup}
|
||||
disabled={topupMutation.isPending || !topupAmount}
|
||||
>
|
||||
{topupMutation.isPending
|
||||
? 'Processing...'
|
||||
: 'Generate Invoice'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProvidersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [editingProvider, setEditingProvider] =
|
||||
@@ -64,6 +386,18 @@ export default function ProvidersPage() {
|
||||
new Set()
|
||||
);
|
||||
const [viewingModels, setViewingModels] = useState<number | null>(null);
|
||||
const [isCreatingAccount, setIsCreatingAccount] = useState(false);
|
||||
const [modelDialogState, setModelDialogState] = useState<{
|
||||
isOpen: boolean;
|
||||
providerId: number | null;
|
||||
mode: 'create' | 'edit' | 'override';
|
||||
initialData?: AdminModel | null;
|
||||
}>({
|
||||
isOpen: false,
|
||||
providerId: null,
|
||||
mode: 'create',
|
||||
initialData: null,
|
||||
});
|
||||
|
||||
const [formData, setFormData] = useState<CreateUpstreamProvider>({
|
||||
provider_type: 'openrouter',
|
||||
@@ -71,8 +405,13 @@ export default function ProvidersPage() {
|
||||
api_key: '',
|
||||
api_version: null,
|
||||
enabled: true,
|
||||
provider_fee: 1.06,
|
||||
});
|
||||
|
||||
const getProviderFeePlaceholder = (type: string) => {
|
||||
return type === 'openrouter' ? 'Default: 1.06 (6%)' : 'Default: 1.01 (1%)';
|
||||
};
|
||||
|
||||
const { data: providerTypes = [] } = useQuery({
|
||||
queryKey: ['provider-types'],
|
||||
queryFn: () => AdminService.getProviderTypes(),
|
||||
@@ -138,6 +477,32 @@ export default function ProvidersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateAccount = async () => {
|
||||
setIsCreatingAccount(true);
|
||||
try {
|
||||
const response = await AdminService.createProviderAccountByType(
|
||||
formData.provider_type
|
||||
);
|
||||
if (response.ok && response.account_data.api_key) {
|
||||
setFormData({
|
||||
...formData,
|
||||
api_key: String(response.account_data.api_key),
|
||||
});
|
||||
toast.success(
|
||||
'Account created successfully! API key has been filled in.'
|
||||
);
|
||||
} else {
|
||||
toast.success('Account created, but no API key returned.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
toast.error(`Failed to create account: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsCreatingAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
provider_type: 'openrouter',
|
||||
@@ -149,7 +514,11 @@ export default function ProvidersPage() {
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
createMutation.mutate(formData);
|
||||
const data = { ...formData };
|
||||
if (data.provider_fee === undefined || data.provider_fee === null) {
|
||||
data.provider_fee = data.provider_type === 'openrouter' ? 1.06 : 1.01;
|
||||
}
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleEdit = (provider: UpstreamProvider) => {
|
||||
@@ -160,6 +529,7 @@ export default function ProvidersPage() {
|
||||
api_key: '',
|
||||
api_version: provider.api_version || null,
|
||||
enabled: provider.enabled,
|
||||
provider_fee: provider.provider_fee,
|
||||
});
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
@@ -171,6 +541,7 @@ export default function ProvidersPage() {
|
||||
base_url: formData.base_url,
|
||||
api_version: formData.api_version,
|
||||
enabled: formData.enabled,
|
||||
provider_fee: formData.provider_fee,
|
||||
};
|
||||
if (formData.api_key) {
|
||||
updateData.api_key = formData.api_key;
|
||||
@@ -199,6 +570,16 @@ export default function ProvidersPage() {
|
||||
return providerType?.platform_url || null;
|
||||
};
|
||||
|
||||
const canCreateAccount = (type: string) => {
|
||||
const providerType = providerTypes.find((pt) => pt.id === type);
|
||||
return providerType?.can_create_account || false;
|
||||
};
|
||||
|
||||
const canShowBalance = (type: string) => {
|
||||
const providerType = providerTypes.find((pt) => pt.id === type);
|
||||
return providerType?.can_show_balance || false;
|
||||
};
|
||||
|
||||
const toggleProviderExpansion = (providerId: number) => {
|
||||
const newExpanded = new Set(expandedProviders);
|
||||
if (newExpanded.has(providerId)) {
|
||||
@@ -214,6 +595,33 @@ export default function ProvidersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddModel = (providerId: number) => {
|
||||
setModelDialogState({
|
||||
isOpen: true,
|
||||
providerId,
|
||||
mode: 'create',
|
||||
initialData: null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditModel = (providerId: number, model: AdminModel) => {
|
||||
setModelDialogState({
|
||||
isOpen: true,
|
||||
providerId,
|
||||
mode: 'edit',
|
||||
initialData: model,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOverrideModel = (providerId: number, model: AdminModel) => {
|
||||
setModelDialogState({
|
||||
isOpen: true,
|
||||
providerId,
|
||||
mode: 'override',
|
||||
initialData: model,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
@@ -253,11 +661,12 @@ export default function ProvidersPage() {
|
||||
<Select
|
||||
value={formData.provider_type}
|
||||
onValueChange={(value) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
provider_type: value,
|
||||
base_url: getDefaultBaseUrl(value),
|
||||
});
|
||||
provider_fee: value === 'openrouter' ? 1.06 : 1.01,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -292,15 +701,30 @@ export default function ProvidersPage() {
|
||||
<div className='grid gap-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='api_key'>API Key</Label>
|
||||
{getPlatformUrl(formData.provider_type) && (
|
||||
<a
|
||||
href={getPlatformUrl(formData.provider_type)!}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
|
||||
{canCreateAccount(formData.provider_type) ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleCreateAccount}
|
||||
disabled={isCreatingAccount}
|
||||
className='h-6 text-xs'
|
||||
>
|
||||
Get Your API Key Here →
|
||||
</a>
|
||||
{isCreatingAccount
|
||||
? 'Creating...'
|
||||
: 'Create Account'}
|
||||
</Button>
|
||||
) : (
|
||||
getPlatformUrl(formData.provider_type) && (
|
||||
<a
|
||||
href={getPlatformUrl(formData.provider_type)!}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
|
||||
>
|
||||
Get Your API Key Here →
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
@@ -339,6 +763,32 @@ export default function ProvidersPage() {
|
||||
/>
|
||||
<Label htmlFor='enabled'>Enabled</Label>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='provider_fee'>
|
||||
Provider Fee (Multiplier)
|
||||
</Label>
|
||||
<Input
|
||||
id='provider_fee'
|
||||
type='number'
|
||||
step='0.001'
|
||||
min='1.0'
|
||||
value={formData.provider_fee || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
provider_fee: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder={getProviderFeePlaceholder(
|
||||
formData.provider_type
|
||||
)}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
1.01 means +1% e.g. currency exchange, card fees, etc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -410,7 +860,16 @@ export default function ProvidersPage() {
|
||||
{provider.base_url}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='grid grid-cols-3 gap-2 sm:flex sm:flex-nowrap'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{canShowBalance(provider.provider_type) &&
|
||||
provider.api_key && (
|
||||
<ProviderBalance
|
||||
providerId={provider.id}
|
||||
platformUrl={getPlatformUrl(
|
||||
provider.provider_type
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
@@ -473,9 +932,21 @@ export default function ProvidersPage() {
|
||||
// No provided models - show custom models directly without tabs
|
||||
<div className='space-y-2'>
|
||||
{providerModels.db_models.length === 0 ? (
|
||||
<div className='text-muted-foreground py-4 text-center text-sm'>
|
||||
No models configured. Add custom models to
|
||||
use this provider.
|
||||
<div className='flex flex-col items-center justify-center gap-2 py-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
No models configured. Add custom models
|
||||
to use this provider.
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleAddModel(provider.id)
|
||||
}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add Custom Model
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
@@ -506,9 +977,24 @@ export default function ProvidersPage() {
|
||||
{model.description || model.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs whitespace-nowrap'>
|
||||
{model.context_length?.toLocaleString()}{' '}
|
||||
tokens
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='text-muted-foreground text-xs whitespace-nowrap'>
|
||||
{model.context_length?.toLocaleString()}{' '}
|
||||
tokens
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() =>
|
||||
handleEditModel(
|
||||
provider.id,
|
||||
model
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -559,12 +1045,24 @@ export default function ProvidersPage() {
|
||||
value='custom'
|
||||
className='mt-4 space-y-2'
|
||||
>
|
||||
{providerModels.db_models.length > 0 && (
|
||||
<div className='text-muted-foreground mb-3 text-sm'>
|
||||
Custom models override or extend the
|
||||
provider's catalog.
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center justify-between'>
|
||||
{providerModels.db_models.length > 0 && (
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Custom models override or extend the
|
||||
provider's catalog.
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleAddModel(provider.id)
|
||||
}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{providerModels.db_models.length === 0 ? (
|
||||
<div className='text-muted-foreground py-4 text-center text-sm'>
|
||||
No custom models configured
|
||||
@@ -600,9 +1098,24 @@ export default function ProvidersPage() {
|
||||
model.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs whitespace-nowrap'>
|
||||
{model.context_length?.toLocaleString()}{' '}
|
||||
tokens
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='text-muted-foreground text-xs whitespace-nowrap'>
|
||||
{model.context_length?.toLocaleString()}{' '}
|
||||
tokens
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() =>
|
||||
handleEditModel(
|
||||
provider.id,
|
||||
model
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -637,9 +1150,25 @@ export default function ProvidersPage() {
|
||||
model.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs whitespace-nowrap'>
|
||||
{model.context_length?.toLocaleString()}{' '}
|
||||
tokens
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='text-muted-foreground text-xs whitespace-nowrap'>
|
||||
{model.context_length?.toLocaleString()}{' '}
|
||||
tokens
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
handleOverrideModel(
|
||||
provider.id,
|
||||
model
|
||||
)
|
||||
}
|
||||
>
|
||||
<Plus className='mr-1 h-3 w-3' />
|
||||
Override
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -674,11 +1203,12 @@ export default function ProvidersPage() {
|
||||
<Select
|
||||
value={formData.provider_type}
|
||||
onValueChange={(value) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
provider_type: value,
|
||||
base_url: getDefaultBaseUrl(value),
|
||||
});
|
||||
provider_fee: value === 'openrouter' ? 1.06 : 1.01,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -762,6 +1292,32 @@ export default function ProvidersPage() {
|
||||
/>
|
||||
<Label htmlFor='edit_enabled'>Enabled</Label>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='edit_provider_fee'>
|
||||
Provider Fee (Multiplier)
|
||||
</Label>
|
||||
<Input
|
||||
id='edit_provider_fee'
|
||||
type='number'
|
||||
step='0.001'
|
||||
min='1.0'
|
||||
value={formData.provider_fee || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
provider_fee: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder={getProviderFeePlaceholder(
|
||||
formData.provider_type
|
||||
)}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
1.01 means +1% e.g. currency exchange, card fees, etc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -779,6 +1335,23 @@ export default function ProvidersPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{modelDialogState.providerId && (
|
||||
<AddProviderModelDialog
|
||||
providerId={modelDialogState.providerId}
|
||||
isOpen={modelDialogState.isOpen}
|
||||
onClose={() =>
|
||||
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
|
||||
}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-models', modelDialogState.providerId],
|
||||
});
|
||||
}}
|
||||
initialData={modelDialogState.initialData}
|
||||
mode={modelDialogState.mode}
|
||||
/>
|
||||
)}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,956 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Loader2, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AdminService, type AdminModel } from '@/lib/api/services/admin';
|
||||
|
||||
const listFromString = (value: string): string[] =>
|
||||
value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
|
||||
const listToString = (value: string[] | undefined | null): string =>
|
||||
value && value.length > 0 ? value.join(', ') : '';
|
||||
|
||||
const FormSchema = z.object({
|
||||
id: z.string().min(1, 'Model ID is required'),
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
description: z.string().default(''),
|
||||
context_length: z.coerce.number().min(0).default(8192),
|
||||
modality: z.string().min(1, 'Modality is required'),
|
||||
input_modalities_raw: z.string().default(''),
|
||||
output_modalities_raw: z.string().default(''),
|
||||
tokenizer: z.string().default(''),
|
||||
instruct_type: z.string().default(''),
|
||||
canonical_slug: z.string().default(''),
|
||||
alias_ids_raw: z.string().default(''),
|
||||
upstream_provider_id: z.string().default(''),
|
||||
input_cost: z.coerce.number().min(0).default(0),
|
||||
output_cost: z.coerce.number().min(0).default(0),
|
||||
request_cost: z.coerce.number().min(0).default(0),
|
||||
image_cost: z.coerce.number().min(0).default(0),
|
||||
web_search_cost: z.coerce.number().min(0).default(0),
|
||||
internal_reasoning_cost: z.coerce.number().min(0).default(0),
|
||||
max_prompt_cost: z.coerce.number().min(0).default(0),
|
||||
max_completion_cost: z.coerce.number().min(0).default(0),
|
||||
max_cost: z.coerce.number().min(0).default(0),
|
||||
per_request_limits_raw: z.string().default(''),
|
||||
top_provider_context_length: z.coerce.number().min(0).optional(),
|
||||
top_provider_max_completion_tokens: z.coerce.number().min(0).optional(),
|
||||
top_provider_is_moderated: z.boolean().default(false),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type FormData = z.output<typeof FormSchema>;
|
||||
|
||||
export interface AddProviderModelDialogProps {
|
||||
providerId: number;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
initialData?: AdminModel | null;
|
||||
mode?: 'create' | 'edit' | 'override';
|
||||
}
|
||||
|
||||
export function AddProviderModelDialog({
|
||||
providerId,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
initialData,
|
||||
mode = 'create',
|
||||
}: AddProviderModelDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isPresetOpen, setIsPresetOpen] = useState(false);
|
||||
const [selectedPresetLabel, setSelectedPresetLabel] =
|
||||
useState('Select a preset');
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(FormSchema) as never,
|
||||
defaultValues: {
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
context_length: 8192,
|
||||
modality: 'text',
|
||||
input_modalities_raw: 'text',
|
||||
output_modalities_raw: 'text',
|
||||
tokenizer: '',
|
||||
instruct_type: '',
|
||||
canonical_slug: '',
|
||||
alias_ids_raw: '',
|
||||
upstream_provider_id: '',
|
||||
input_cost: 0,
|
||||
output_cost: 0,
|
||||
request_cost: 0,
|
||||
image_cost: 0,
|
||||
web_search_cost: 0,
|
||||
internal_reasoning_cost: 0,
|
||||
max_prompt_cost: 0,
|
||||
max_completion_cost: 0,
|
||||
max_cost: 0,
|
||||
per_request_limits_raw: '',
|
||||
top_provider_context_length: undefined,
|
||||
top_provider_max_completion_tokens: undefined,
|
||||
top_provider_is_moderated: false,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isOverride = useMemo(() => mode === 'override', [mode]);
|
||||
const isEdit = useMemo(() => mode === 'edit', [mode]);
|
||||
const { data: presets = [], isLoading: isLoadingPresets } = useQuery({
|
||||
queryKey: ['openrouter-presets'],
|
||||
queryFn: () => AdminService.getOpenRouterPresets(),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
const architecture = initialData.architecture as Record<string, unknown>;
|
||||
const pricing = initialData.pricing as Record<string, number>;
|
||||
const topProvider = initialData.top_provider as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
form.reset({
|
||||
id: initialData.id,
|
||||
name: initialData.name,
|
||||
description: initialData.description,
|
||||
context_length: initialData.context_length,
|
||||
modality:
|
||||
typeof architecture?.modality === 'string'
|
||||
? architecture.modality
|
||||
: 'text',
|
||||
input_modalities_raw: listToString(
|
||||
(architecture?.input_modalities as string[]) || []
|
||||
),
|
||||
output_modalities_raw: listToString(
|
||||
(architecture?.output_modalities as string[]) || []
|
||||
),
|
||||
tokenizer:
|
||||
typeof architecture?.tokenizer === 'string'
|
||||
? architecture.tokenizer
|
||||
: '',
|
||||
instruct_type:
|
||||
typeof architecture?.instruct_type === 'string'
|
||||
? architecture.instruct_type
|
||||
: '',
|
||||
canonical_slug: initialData.canonical_slug || '',
|
||||
alias_ids_raw: listToString(initialData.alias_ids),
|
||||
upstream_provider_id:
|
||||
typeof initialData.upstream_provider_id === 'string'
|
||||
? initialData.upstream_provider_id
|
||||
: initialData.upstream_provider_id?.toString() || '',
|
||||
input_cost: pricing?.prompt ?? 0,
|
||||
output_cost: pricing?.completion ?? 0,
|
||||
request_cost: pricing?.request ?? 0,
|
||||
image_cost: pricing?.image ?? 0,
|
||||
web_search_cost: pricing?.web_search ?? 0,
|
||||
internal_reasoning_cost: pricing?.internal_reasoning ?? 0,
|
||||
max_prompt_cost: pricing?.max_prompt_cost ?? 0,
|
||||
max_completion_cost: pricing?.max_completion_cost ?? 0,
|
||||
max_cost: pricing?.max_cost ?? 0,
|
||||
per_request_limits_raw: initialData.per_request_limits
|
||||
? JSON.stringify(initialData.per_request_limits, null, 2)
|
||||
: '',
|
||||
top_provider_context_length:
|
||||
typeof topProvider?.context_length === 'number'
|
||||
? topProvider.context_length
|
||||
: undefined,
|
||||
top_provider_max_completion_tokens:
|
||||
typeof topProvider?.max_completion_tokens === 'number'
|
||||
? topProvider.max_completion_tokens
|
||||
: undefined,
|
||||
top_provider_is_moderated:
|
||||
typeof topProvider?.is_moderated === 'boolean'
|
||||
? topProvider.is_moderated
|
||||
: false,
|
||||
enabled: initialData.enabled,
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
context_length: 8192,
|
||||
modality: 'text',
|
||||
input_modalities_raw: 'text',
|
||||
output_modalities_raw: 'text',
|
||||
tokenizer: '',
|
||||
instruct_type: '',
|
||||
canonical_slug: '',
|
||||
alias_ids_raw: '',
|
||||
upstream_provider_id: '',
|
||||
input_cost: 0,
|
||||
output_cost: 0,
|
||||
request_cost: 0,
|
||||
image_cost: 0,
|
||||
web_search_cost: 0,
|
||||
internal_reasoning_cost: 0,
|
||||
max_prompt_cost: 0,
|
||||
max_completion_cost: 0,
|
||||
max_cost: 0,
|
||||
per_request_limits_raw: '',
|
||||
top_provider_context_length: undefined,
|
||||
top_provider_max_completion_tokens: undefined,
|
||||
top_provider_is_moderated: false,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}, [initialData, form, isOpen]);
|
||||
|
||||
const applyModelToForm = (model: AdminModel) => {
|
||||
setSelectedPresetLabel(`${model.id} — ${model.name}`);
|
||||
const architecture = model.architecture as Record<string, unknown>;
|
||||
const pricing = model.pricing as Record<string, number>;
|
||||
const topProvider = model.top_provider as Record<string, unknown> | null;
|
||||
|
||||
form.setValue('id', model.id);
|
||||
form.setValue('name', model.name);
|
||||
form.setValue('description', model.description || '');
|
||||
form.setValue('context_length', model.context_length);
|
||||
form.setValue(
|
||||
'modality',
|
||||
typeof architecture?.modality === 'string'
|
||||
? architecture.modality
|
||||
: 'text'
|
||||
);
|
||||
form.setValue(
|
||||
'input_modalities_raw',
|
||||
listToString((architecture?.input_modalities as string[]) || [])
|
||||
);
|
||||
form.setValue(
|
||||
'output_modalities_raw',
|
||||
listToString((architecture?.output_modalities as string[]) || [])
|
||||
);
|
||||
form.setValue(
|
||||
'tokenizer',
|
||||
typeof architecture?.tokenizer === 'string' ? architecture.tokenizer : ''
|
||||
);
|
||||
form.setValue(
|
||||
'instruct_type',
|
||||
typeof architecture?.instruct_type === 'string'
|
||||
? architecture.instruct_type
|
||||
: ''
|
||||
);
|
||||
form.setValue('canonical_slug', model.canonical_slug || '');
|
||||
form.setValue('alias_ids_raw', listToString(model.alias_ids));
|
||||
form.setValue(
|
||||
'upstream_provider_id',
|
||||
typeof model.upstream_provider_id === 'string'
|
||||
? model.upstream_provider_id
|
||||
: model.upstream_provider_id?.toString() || ''
|
||||
);
|
||||
form.setValue('input_cost', pricing?.prompt ?? 0);
|
||||
form.setValue('output_cost', pricing?.completion ?? 0);
|
||||
form.setValue('request_cost', pricing?.request ?? 0);
|
||||
form.setValue('image_cost', pricing?.image ?? 0);
|
||||
form.setValue('web_search_cost', pricing?.web_search ?? 0);
|
||||
form.setValue('internal_reasoning_cost', pricing?.internal_reasoning ?? 0);
|
||||
form.setValue('max_prompt_cost', pricing?.max_prompt_cost ?? 0);
|
||||
form.setValue('max_completion_cost', pricing?.max_completion_cost ?? 0);
|
||||
form.setValue('max_cost', pricing?.max_cost ?? 0);
|
||||
form.setValue(
|
||||
'per_request_limits_raw',
|
||||
model.per_request_limits
|
||||
? JSON.stringify(model.per_request_limits, null, 2)
|
||||
: ''
|
||||
);
|
||||
form.setValue(
|
||||
'top_provider_context_length',
|
||||
typeof topProvider?.context_length === 'number'
|
||||
? topProvider.context_length
|
||||
: undefined
|
||||
);
|
||||
form.setValue(
|
||||
'top_provider_max_completion_tokens',
|
||||
typeof topProvider?.max_completion_tokens === 'number'
|
||||
? topProvider.max_completion_tokens
|
||||
: undefined
|
||||
);
|
||||
form.setValue(
|
||||
'top_provider_is_moderated',
|
||||
typeof topProvider?.is_moderated === 'boolean'
|
||||
? topProvider.is_moderated
|
||||
: false
|
||||
);
|
||||
form.setValue('enabled', model.enabled);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let perRequestLimits: Record<string, unknown> | null = null;
|
||||
if (
|
||||
data.per_request_limits_raw &&
|
||||
data.per_request_limits_raw.trim().length
|
||||
) {
|
||||
try {
|
||||
perRequestLimits = JSON.parse(data.per_request_limits_raw);
|
||||
} catch {
|
||||
toast.error('Per-request limits must be valid JSON');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const adminModel: AdminModel = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
description: data.description || '',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
context_length: data.context_length,
|
||||
architecture: {
|
||||
modality: data.modality,
|
||||
input_modalities: listFromString(
|
||||
data.input_modalities_raw || data.modality
|
||||
),
|
||||
output_modalities: listFromString(
|
||||
data.output_modalities_raw || data.modality
|
||||
),
|
||||
tokenizer: data.tokenizer || '',
|
||||
instruct_type: data.instruct_type?.trim() || null,
|
||||
},
|
||||
pricing: {
|
||||
prompt: data.input_cost,
|
||||
completion: data.output_cost,
|
||||
request: data.request_cost,
|
||||
image: data.image_cost,
|
||||
web_search: data.web_search_cost,
|
||||
internal_reasoning: data.internal_reasoning_cost,
|
||||
max_prompt_cost: data.max_prompt_cost,
|
||||
max_completion_cost: data.max_completion_cost,
|
||||
max_cost: data.max_cost,
|
||||
},
|
||||
per_request_limits: perRequestLimits,
|
||||
top_provider:
|
||||
data.top_provider_context_length ||
|
||||
data.top_provider_max_completion_tokens ||
|
||||
data.top_provider_is_moderated
|
||||
? {
|
||||
context_length: data.top_provider_context_length ?? null,
|
||||
max_completion_tokens:
|
||||
data.top_provider_max_completion_tokens ?? null,
|
||||
is_moderated: data.top_provider_is_moderated,
|
||||
}
|
||||
: null,
|
||||
upstream_provider_id: data.upstream_provider_id?.trim().length
|
||||
? data.upstream_provider_id.trim()
|
||||
: providerId,
|
||||
canonical_slug: data.canonical_slug?.trim() || null,
|
||||
alias_ids: listFromString(data.alias_ids_raw || ''),
|
||||
enabled: data.enabled,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
await AdminService.updateProviderModel(providerId, data.id, adminModel);
|
||||
toast.success('Model updated successfully');
|
||||
} else {
|
||||
await AdminService.createProviderModel(providerId, adminModel);
|
||||
toast.success(
|
||||
isOverride ? 'Model override created' : 'Model created successfully'
|
||||
);
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Unknown error saving model';
|
||||
toast.error(`Failed to save model: ${message}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title = isEdit
|
||||
? 'Edit Model'
|
||||
: isOverride
|
||||
? 'Override Model'
|
||||
: 'Add Custom Model';
|
||||
const description = isOverride
|
||||
? 'Create a custom override for this upstream model'
|
||||
: 'Add a new model configuration for this provider';
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[720px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Plus className='h-4 w-4' />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!isEdit && !isOverride && (
|
||||
<div className='bg-muted/30 rounded-md border p-3'>
|
||||
<div className='mb-2 text-sm font-medium'>Presets</div>
|
||||
<div className='grid gap-2 sm:grid-cols-3 sm:items-start'>
|
||||
<Popover open={isPresetOpen} onOpenChange={setIsPresetOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={isPresetOpen}
|
||||
className='w-full justify-between overflow-hidden text-left text-sm'
|
||||
>
|
||||
<span className='truncate'>
|
||||
{isLoadingPresets
|
||||
? 'Loading presets...'
|
||||
: selectedPresetLabel}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-80 max-w-sm p-0'
|
||||
align='start'
|
||||
sideOffset={4}
|
||||
collisionPadding={12}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Command shouldFilter={true}>
|
||||
<CommandInput placeholder='Search presets...' />
|
||||
<CommandList
|
||||
className='max-h-64 overflow-y-auto overscroll-contain'
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isLoadingPresets ? (
|
||||
<CommandEmpty>Loading presets...</CommandEmpty>
|
||||
) : presets.length === 0 ? (
|
||||
<CommandEmpty>No presets available.</CommandEmpty>
|
||||
) : (
|
||||
<CommandGroup heading='Presets'>
|
||||
{presets.map((preset) => (
|
||||
<CommandItem
|
||||
key={preset.id}
|
||||
value={preset.id}
|
||||
onSelect={() => {
|
||||
applyModelToForm(preset);
|
||||
setIsPresetOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className='flex flex-col text-sm'>
|
||||
<span className='font-medium'>{preset.id}</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{preset.name}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
Prefill fields from a preset model definition, then adjust as
|
||||
needed.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Model ID *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='e.g., gpt-5.1'
|
||||
{...field}
|
||||
disabled={isOverride || isEdit}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Unique identifier for the model
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Display Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='e.g., GPT-5.1' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder='Brief description...'
|
||||
{...field}
|
||||
rows={2}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='modality'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Modality *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='text, text+image->text, image->text, etc.'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Composite modality label (e.g., text+image->text)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='context_length'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Context Length</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='input_modalities_raw'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Input Modalities</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='text, image, file' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated list</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='output_modalities_raw'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Output Modalities</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='text, image' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated list</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='tokenizer'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tokenizer</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='e.g., GPT, tiktoken' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='instruct_type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Instruct Type</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='e.g., chat, completion' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='canonical_slug'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Canonical Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='google/gemini-3-pro-preview-20251117'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='alias_ids_raw'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Alias IDs</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='alias-1, alias-2' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated list</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='upstream_provider_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Upstream Provider ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='gemini' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Defaults to current provider if left blank
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='per_request_limits_raw'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Per-request Limits (JSON)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder='{"requests_per_min": 60}'
|
||||
{...field}
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
JSON object; leave empty for none
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/20 space-y-4 rounded-md border p-4'>
|
||||
<h4 className='text-sm font-medium'>
|
||||
Pricing (USD per 1M tokens)
|
||||
</h4>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='input_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Input Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.01' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='output_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Output Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.01' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='request_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Per Request Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.0001' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='image_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Image Cost (per image)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.0001' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='web_search_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Web Search Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.0001' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='internal_reasoning_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal Reasoning Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.0001' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='max_prompt_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Prompt Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.0001' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='max_completion_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Completion Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.0001' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='max_cost'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Total Cost</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='number' step='0.0001' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/20 space-y-4 rounded-md border p-4'>
|
||||
<h4 className='text-sm font-medium'>Top Provider (optional)</h4>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='top_provider_context_length'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Context Length</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='top_provider_max_completion_tokens'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Completion Tokens</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='top_provider_is_moderated'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
Is Moderated
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Whether provider enforces moderation
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>Enabled</FormLabel>
|
||||
<FormDescription>Enable this model for use</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
)}
|
||||
{isEdit
|
||||
? 'Save Changes'
|
||||
: isOverride
|
||||
? 'Create Override'
|
||||
: 'Create Model'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,10 @@ export function ModelSearchFilter({
|
||||
model.name.toLowerCase().includes(query) ||
|
||||
model.full_name.toLowerCase().includes(query) ||
|
||||
model.provider.toLowerCase().includes(query) ||
|
||||
(model.alias_ids &&
|
||||
model.alias_ids.some((alias) =>
|
||||
alias.toLowerCase().includes(query)
|
||||
)) ||
|
||||
(model.description &&
|
||||
model.description.toLowerCase().includes(query)) ||
|
||||
model.modelType.toLowerCase().includes(query)
|
||||
|
||||
+153
-70
@@ -2,15 +2,14 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { type Model, type GroupSettings } from '@/lib/api/schemas/models';
|
||||
import {
|
||||
type Model,
|
||||
type ManualModel,
|
||||
type GroupSettings,
|
||||
} from '@/lib/api/schemas/models';
|
||||
import { AdminService, type AdminModelGroup } from '@/lib/api/services/admin';
|
||||
AdminService,
|
||||
type AdminModelGroup,
|
||||
type AdminModel,
|
||||
} from '@/lib/api/services/admin';
|
||||
type ModelGroup = AdminModelGroup;
|
||||
import { AddModelForm } from '@/components/AddModelForm';
|
||||
import { EditModelForm } from '@/components/EditModelForm';
|
||||
import { AddProviderModelDialog } from '@/components/AddProviderModelDialog';
|
||||
import { EditGroupForm } from '@/components/EditGroupForm';
|
||||
import { CollectModelsDialog } from '@/components/CollectModelsDialog';
|
||||
import { formatCost } from '@/lib/services/costValidation';
|
||||
@@ -80,14 +79,23 @@ export function ModelSelector({
|
||||
}: ModelSelectorProps) {
|
||||
const [selectedModelId, setSelectedModelId] = useState<string>('');
|
||||
const [, setHoveredModelId] = useState<string | null>(null);
|
||||
const [isAddFormOpen, setIsAddFormOpen] = useState(false);
|
||||
const [editingModel, setEditingModel] = useState<Model | null>(null);
|
||||
const [editingGroup, setEditingGroup] = useState<{
|
||||
provider: string;
|
||||
models: Model[];
|
||||
groupData: ModelGroup;
|
||||
} | null>(null);
|
||||
const [isCollectDialogOpen, setIsCollectDialogOpen] = useState(false);
|
||||
const [modelDialogState, setModelDialogState] = useState<{
|
||||
isOpen: boolean;
|
||||
providerId: number | null;
|
||||
mode: 'create' | 'edit' | 'override';
|
||||
initialData?: AdminModel | null;
|
||||
}>({
|
||||
isOpen: false,
|
||||
providerId: null,
|
||||
mode: 'create',
|
||||
initialData: null,
|
||||
});
|
||||
|
||||
// Bulk selection state
|
||||
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
|
||||
@@ -492,44 +500,106 @@ export function ModelSelector({
|
||||
return hasIndividualApiKey || hasIndividualUrl;
|
||||
};
|
||||
|
||||
// Handle manual model addition
|
||||
const handleAddModel = async (newModel: ManualModel) => {
|
||||
try {
|
||||
// Find or create the provider group
|
||||
let providerGroup = groups.find((g) => g.provider === newModel.provider);
|
||||
const handleAddModelClick = (providerId: number) => {
|
||||
setModelDialogState({
|
||||
isOpen: true,
|
||||
providerId,
|
||||
mode: 'create',
|
||||
initialData: null,
|
||||
});
|
||||
};
|
||||
|
||||
if (!providerGroup) {
|
||||
providerGroup = await AdminService.createModelGroup({
|
||||
provider: newModel.provider,
|
||||
group_api_key: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const modelData = {
|
||||
name: newModel.name,
|
||||
full_name: newModel.name,
|
||||
input_cost: newModel.input_cost,
|
||||
output_cost: newModel.output_cost,
|
||||
provider: newModel.provider,
|
||||
modelType: newModel.modelType,
|
||||
description: newModel.description || undefined,
|
||||
contextLength: newModel.contextLength,
|
||||
};
|
||||
|
||||
await AdminService.createModel(modelData);
|
||||
await refetchModels();
|
||||
toast.success(`Model "${newModel.name}" added successfully!`);
|
||||
} catch (error) {
|
||||
console.error('Error adding model:', error);
|
||||
toast.error('Failed to add model. Please try again.');
|
||||
throw error; // Re-throw to let the form handle the error
|
||||
const handleEditModelClick = (model: Model) => {
|
||||
if (!model.provider_id) {
|
||||
toast.error('Provider ID missing for model');
|
||||
return;
|
||||
}
|
||||
const providerId = parseInt(model.provider_id);
|
||||
|
||||
// Construct AdminModel from Model
|
||||
const adminModel: AdminModel = {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
description: model.description || '',
|
||||
created: new Date(model.createdAt).getTime() / 1000,
|
||||
context_length: model.contextLength || 0,
|
||||
architecture: {
|
||||
modality: model.modelType,
|
||||
input_modalities: [model.modelType],
|
||||
output_modalities: [model.modelType],
|
||||
tokenizer: '',
|
||||
instruct_type: null,
|
||||
},
|
||||
pricing: {
|
||||
prompt: model.input_cost,
|
||||
completion: model.output_cost,
|
||||
request: model.min_cost_per_request,
|
||||
image: 0,
|
||||
web_search: 0,
|
||||
internal_reasoning: 0,
|
||||
},
|
||||
per_request_limits: null,
|
||||
top_provider: null,
|
||||
upstream_provider_id: providerId,
|
||||
enabled: model.isEnabled,
|
||||
alias_ids: model.alias_ids || null,
|
||||
};
|
||||
|
||||
setModelDialogState({
|
||||
isOpen: true,
|
||||
providerId,
|
||||
mode: 'edit',
|
||||
initialData: adminModel,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOverrideModelClick = (model: Model) => {
|
||||
if (!model.provider_id) {
|
||||
toast.error('Provider ID missing for model');
|
||||
return;
|
||||
}
|
||||
const providerId = parseInt(model.provider_id);
|
||||
|
||||
// Construct AdminModel from Model
|
||||
const adminModel: AdminModel = {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
description: model.description || '',
|
||||
created: new Date(model.createdAt).getTime() / 1000,
|
||||
context_length: model.contextLength || 0,
|
||||
architecture: {
|
||||
modality: model.modelType,
|
||||
input_modalities: [model.modelType],
|
||||
output_modalities: [model.modelType],
|
||||
tokenizer: '',
|
||||
instruct_type: null,
|
||||
},
|
||||
pricing: {
|
||||
prompt: model.input_cost,
|
||||
completion: model.output_cost,
|
||||
request: model.min_cost_per_request,
|
||||
image: 0,
|
||||
web_search: 0,
|
||||
internal_reasoning: 0,
|
||||
},
|
||||
per_request_limits: null,
|
||||
top_provider: null,
|
||||
upstream_provider_id: providerId,
|
||||
enabled: model.isEnabled,
|
||||
alias_ids: model.alias_ids || null,
|
||||
};
|
||||
|
||||
setModelDialogState({
|
||||
isOpen: true,
|
||||
providerId,
|
||||
mode: 'override',
|
||||
initialData: adminModel,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle model update
|
||||
const handleModelUpdate = async () => {
|
||||
await refetchModels();
|
||||
setEditingModel(null);
|
||||
};
|
||||
|
||||
// Handle group update
|
||||
@@ -832,11 +902,18 @@ export function ModelSelector({
|
||||
Delete All Overrides Permanently
|
||||
</Button>
|
||||
)}
|
||||
{/* Model Management Actions
|
||||
<Button onClick={() => setIsAddFormOpen(true)} className='gap-2'>
|
||||
<Plus className='h-4 w-4' />
|
||||
Add Model
|
||||
</Button>
|
||||
{/* Model Management Actions */}
|
||||
{groupData && (
|
||||
<Button
|
||||
onClick={() => handleAddModelClick(parseInt(groupData.id))}
|
||||
className='gap-2'
|
||||
variant='outline'
|
||||
>
|
||||
<CheckSquare className='h-4 w-4' />
|
||||
Add Custom Model
|
||||
</Button>
|
||||
)}
|
||||
{/*
|
||||
<Button
|
||||
onClick={() => setIsCollectDialogOpen(true)}
|
||||
variant='outline'
|
||||
@@ -1080,15 +1157,28 @@ export function ModelSelector({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingModel(model);
|
||||
}}
|
||||
>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Edit Model
|
||||
</DropdownMenuItem>
|
||||
{model.api_key_type !== 'remote' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditModelClick(model);
|
||||
}}
|
||||
>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Edit Model
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{model.api_key_type === 'remote' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOverrideModelClick(model);
|
||||
}}
|
||||
>
|
||||
<Edit3 className='mr-2 h-4 w-4' />
|
||||
Override
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{model.soft_deleted ? (
|
||||
<DropdownMenuItem
|
||||
@@ -1215,23 +1305,16 @@ export function ModelSelector({
|
||||
})}
|
||||
|
||||
{/* Forms and Dialogs */}
|
||||
<AddModelForm
|
||||
isOpen={isAddFormOpen}
|
||||
onModelAdd={handleAddModel}
|
||||
onCancel={() => setIsAddFormOpen(false)}
|
||||
/>
|
||||
|
||||
{editingModel && (
|
||||
<EditModelForm
|
||||
model={editingModel}
|
||||
providerId={
|
||||
editingModel.provider_id
|
||||
? parseInt(editingModel.provider_id)
|
||||
: undefined
|
||||
{modelDialogState.providerId && (
|
||||
<AddProviderModelDialog
|
||||
providerId={modelDialogState.providerId}
|
||||
isOpen={modelDialogState.isOpen}
|
||||
onClose={() =>
|
||||
setModelDialogState((prev) => ({ ...prev, isOpen: false }))
|
||||
}
|
||||
onModelUpdate={handleModelUpdate}
|
||||
onCancel={() => setEditingModel(null)}
|
||||
isOpen={!!editingModel}
|
||||
onSuccess={handleModelUpdate}
|
||||
initialData={modelDialogState.initialData}
|
||||
mode={modelDialogState.mode}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -48,20 +48,26 @@ export function CurrencyToggle() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-9 w-9 px-0 gap-2 w-auto px-3 font-normal">
|
||||
<Coins className="h-4 w-4" />
|
||||
<span className="hidden sm:inline-block">{getLabel(displayUnit)}</span>
|
||||
<span className="sm:hidden uppercase">{displayUnit}</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-9 w-9 w-auto gap-2 px-0 px-3 font-normal'
|
||||
>
|
||||
<Coins className='h-4 w-4' />
|
||||
<span className='hidden sm:inline-block'>
|
||||
{getLabel(displayUnit)}
|
||||
</span>
|
||||
<span className='uppercase sm:hidden'>{displayUnit}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => setDisplayUnit('msat')}>
|
||||
Millisatoshis (mSAT)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDisplayUnit('sat')}>
|
||||
Satoshis (sat)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayUnit('usd')}
|
||||
disabled={!usdPerSat}
|
||||
>
|
||||
@@ -71,4 +77,3 @@ export function CurrencyToggle() {
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,4 +96,3 @@ export function DashboardBalanceSummary({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
|
||||
<CardTitle>Recent Errors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-muted-foreground text-center py-8'>
|
||||
<p className='text-muted-foreground py-8 text-center'>
|
||||
No errors found in the selected time period
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user