Compare commits

..
Author SHA1 Message Date
Shroominic d8a81fb137 Optimize streaming usage extraction and Cashu refund buffering 2025-12-22 20:33:37 +01:00
Shroominic b18714e092 ruff format 2025-12-22 19:31:37 +01:00
Shroominic 4181adaa23 rename responses to responses_api 2025-12-22 19:30:58 +01:00
9qeklajc 42546fd387 refactor 2025-12-15 21:48:15 +01:00
9qeklajc cf28088afe clean up 2025-12-15 21:20:36 +01:00
69 changed files with 11957 additions and 4344 deletions
+6 -11
View File
@@ -59,30 +59,25 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "pnpm"
cache-dependency-path: ui/pnpm-lock.yaml
cache: "npm"
cache-dependency-path: ui/package-lock.json
- name: Install UI dependencies
working-directory: ./ui
run: pnpm install --frozen-lockfile
run: npm ci
- name: Run UI format check
working-directory: ./ui
run: pnpm run format-check
run: npm run format-check
- name: Run UI linting
working-directory: ./ui
run: pnpm run lint
run: npm run lint
- name: Run UI build
working-directory: ./ui
run: pnpm run build
run: npm run build
+37
View File
@@ -0,0 +1,37 @@
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()
-11
View File
@@ -1,11 +0,0 @@
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())
-15
View File
@@ -1,15 +0,0 @@
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())
-12
View File
@@ -1,12 +0,0 @@
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())
-16
View File
@@ -1,16 +0,0 @@
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())
-15
View File
@@ -1,15 +0,0 @@
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)
-19
View File
@@ -1,19 +0,0 @@
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()
-31
View File
@@ -1,31 +0,0 @@
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)
-17
View File
@@ -1,17 +0,0 @@
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)
-20
View File
@@ -1,20 +0,0 @@
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()
-16
View File
@@ -1,16 +0,0 @@
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)
-28
View File
@@ -1,28 +0,0 @@
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})
-20
View File
@@ -1,20 +0,0 @@
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,37 +0,0 @@
"""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 ###
+1 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.2.2"
version = "0.2.1"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"
@@ -73,7 +73,6 @@ packages = ["routstr"]
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = ["E501"]
exclude = ["examples"]
[tool.mypy]
python_version = "3.11"
+6 -7
View File
@@ -206,20 +206,19 @@ 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."""
alias_lower = alias.lower()
existing_model = model_instances.get(alias_lower)
existing_model = model_instances.get(alias)
if not existing_model:
# No existing mapping, set it
model_instances[alias_lower] = model
provider_map[alias_lower] = provider
model_instances[alias] = model
provider_map[alias] = provider
else:
# Check if candidate should replace existing
existing_provider = provider_map[alias_lower]
existing_provider = provider_map[alias]
if should_prefer_model(
model, provider, existing_model, existing_provider, alias
):
model_instances[alias_lower] = model
provider_map[alias_lower] = provider
model_instances[alias] = model
provider_map[alias] = provider
def process_provider_models(
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
+20 -50
View File
@@ -441,29 +441,6 @@ async def adjust_payment_for_tokens(
},
)
async def release_reservation_only() -> None:
"""Fallback to release reservation without charging when main update fails."""
try:
release_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost)
)
await session.exec(release_stmt) # type: ignore[call-overload]
await session.commit()
logger.warning(
"Released reservation without charging (fallback)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
},
)
except Exception as e:
logger.error(
"Failed to release reservation in fallback",
extra={"error": str(e), "key_hash": key.hashed_key[:8] + "..."},
)
match await calculate_cost(response_data, deducted_max_cost, session):
case MaxCostData() as cost:
logger.debug(
@@ -488,7 +465,7 @@ async def adjust_payment_for_tokens(
await session.commit()
if result.rowcount == 0:
logger.error(
"Failed to finalize max-cost payment - retrying reservation release",
"Failed to finalize max-cost payment - insufficient reserved balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
@@ -497,7 +474,6 @@ async def adjust_payment_for_tokens(
"model": model,
},
)
await release_reservation_only()
else:
await session.refresh(key)
logger.info(
@@ -592,14 +568,13 @@ async def adjust_payment_for_tokens(
)
else:
logger.warning(
"Failed to finalize additional charge - releasing reservation",
"Failed to finalize additional charge (concurrent operation)",
extra={
"key_hash": key.hashed_key[:8] + "...",
"attempted_charge": total_cost_msats,
"model": model,
},
)
await release_reservation_only()
else:
# Refund some of the base cost
refund = abs(cost_difference)
@@ -628,7 +603,7 @@ async def adjust_payment_for_tokens(
if result.rowcount == 0:
logger.error(
"Failed to finalize payment - releasing reservation",
"Failed to finalize payment - insufficient reserved balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"deducted_max_cost": deducted_max_cost,
@@ -637,27 +612,28 @@ async def adjust_payment_for_tokens(
"model": model,
},
)
await release_reservation_only()
else:
cost.total_msats = total_cost_msats
await session.refresh(key)
# Still return the cost data even if we couldn't properly finalize
# The reservation was already made, so the user has paid
logger.info(
"Refund processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"refunded_amount": refund,
"new_balance": key.balance,
"final_cost": cost.total_msats,
"model": model,
},
)
cost.total_msats = total_cost_msats
await session.refresh(key)
logger.info(
"Refund processed successfully",
extra={
"key_hash": key.hashed_key[:8] + "...",
"refunded_amount": refund,
"new_balance": key.balance,
"final_cost": cost.total_msats,
"model": model,
},
)
return cost.dict()
case CostDataError() as error:
logger.error(
"Cost calculation error during payment adjustment - releasing reservation",
"Cost calculation error during payment adjustment",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": model,
@@ -665,7 +641,6 @@ async def adjust_payment_for_tokens(
"error_code": error.code,
},
)
await release_reservation_only()
raise HTTPException(
status_code=400,
@@ -677,12 +652,7 @@ async def adjust_payment_for_tokens(
}
},
)
# Fallback: should not reach here, but release reservation just in case
logger.error(
"Unexpected fallback in adjust_payment_for_tokens - releasing reservation",
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
)
await release_reservation_only()
# Fallback return to satisfy type checker; execution should not reach here
return {
"base_msats": deducted_max_cost,
"input_msats": 0,
+1 -2
View File
@@ -154,8 +154,7 @@ async def refund_wallet_endpoint(
return cached
key: ApiKey = await validate_bearer_key(bearer_value, session)
remaining_balance_msats: int = key.total_balance
remaining_balance_msats: int = key.balance
if key.refund_currency == "sat":
remaining_balance = remaining_balance_msats // 1000
+110 -100
View File
@@ -1493,8 +1493,20 @@ class ModelCreate(BaseModel):
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
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
enabled: bool = True
@@ -2404,80 +2416,44 @@ async def admin_upstream_providers(request: Request) -> str:
"/api/upstream-providers/{provider_id}/models",
dependencies=[Depends(require_admin_api)],
)
async def upsert_provider_model(
async def create_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")
# Try to get existing model
existing_row = await session.get(ModelRow, (payload.id, provider_id))
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",
)
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 = (
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
)
existing_row.top_provider = (
),
top_provider=(
json.dumps(payload.top_provider) if payload.top_provider else None
)
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)
),
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(
@@ -2485,20 +2461,6 @@ async def upsert_provider_model(
).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)],
@@ -2519,6 +2481,76 @@ async def get_provider_model(provider_id: int, model_id: str) -> dict[str, objec
).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 ..payment.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)],
@@ -3080,9 +3112,6 @@ async def get_logs_api(
level: str | None = None,
request_id: str | None = None,
search: str | None = None,
status_codes: str | None = Query(None, description="Comma-separated status codes"),
methods: str | None = Query(None, description="Comma-separated HTTP methods"),
endpoints: str | None = Query(None, description="Comma-separated endpoints"),
limit: int = 100,
) -> dict[str, object]:
"""
@@ -3093,32 +3122,16 @@ async def get_logs_api(
level: Filter by log level
request_id: Filter by request ID
search: Search text in message and name fields (case-insensitive)
status_codes: Comma-separated list of HTTP status codes
methods: Comma-separated list of HTTP methods
endpoints: Comma-separated list of endpoints
limit: Maximum number of entries to return
Returns:
Dict containing logs and filter metadata
"""
status_code_list = None
if status_codes:
try:
status_code_list = [int(s.strip()) for s in status_codes.split(",")]
except ValueError:
pass
method_list = [m.strip() for m in methods.split(",")] if methods else None
endpoint_list = [e.strip() for e in endpoints.split(",")] if endpoints else None
log_entries = log_manager.search_logs(
date=date,
level=level,
request_id=request_id,
search_text=search,
status_codes=status_code_list,
methods=method_list,
endpoints=endpoint_list,
limit=limit,
)
@@ -3129,9 +3142,6 @@ async def get_logs_api(
"level": level,
"request_id": request_id,
"search": search,
"status_codes": status_codes,
"methods": methods,
"endpoints": endpoints,
"limit": limit,
}
+1 -13
View File
@@ -6,7 +6,7 @@ from typing import AsyncGenerator
from alembic import command
from alembic.config import Config
from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlmodel import Field, Relationship, SQLModel, func, select, update
from sqlmodel import Field, Relationship, SQLModel, func, select
from sqlmodel.ext.asyncio.session import AsyncSession
from .logging import get_logger
@@ -53,14 +53,6 @@ class ApiKey(SQLModel, table=True): # type: ignore
return self.balance - self.reserved_balance
async def reset_all_reserved_balances(session: AsyncSession) -> None:
logger.info("Resetting all reserved balances to 0")
stmt = update(ApiKey).values(reserved_balance=0)
await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
logger.info("Reserved balances reset successfully")
class ModelRow(SQLModel, table=True): # type: ignore
__tablename__ = "models"
id: str = Field(primary_key=True)
@@ -76,10 +68,6 @@ 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")
+1 -43
View File
@@ -105,9 +105,6 @@ class LogManager:
level: str | None = None,
request_id: str | None = None,
search_text: str | None = None,
status_codes: list[int] | None = None,
methods: list[str] | None = None,
endpoints: list[str] | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
"""
@@ -137,13 +134,7 @@ class LogManager:
for log_data in iterator:
if not self._matches_filters(
log_data,
level,
request_id,
search_text_lower,
status_codes,
methods,
endpoints,
log_data, level, request_id, search_text_lower
):
continue
@@ -162,9 +153,6 @@ class LogManager:
level: str | None,
request_id: str | None,
search_text_lower: str | None,
status_codes: list[int] | None = None,
methods: list[str] | None = None,
endpoints: list[str] | None = None,
) -> bool:
if level and log_data.get("levelname", "").upper() != level.upper():
return False
@@ -172,36 +160,6 @@ class LogManager:
if request_id and log_data.get("request_id") != request_id:
return False
if status_codes:
entry_status = log_data.get("status_code")
if entry_status is not None:
try:
if int(entry_status) not in status_codes:
return False
except (ValueError, TypeError):
return False
else:
return False
if methods:
entry_method = log_data.get("method", "").upper()
if entry_method not in [m.upper() for m in methods]:
return False
if endpoints:
entry_path = log_data.get("path", "")
matched = False
for endpoint in endpoints:
clean_endpoint = endpoint.lstrip("/")
if entry_path.startswith(clean_endpoint):
matched = True
break
if clean_endpoint in entry_path:
matched = True
break
if not matched:
return False
if search_text_lower:
message = str(log_data.get("message", "")).lower()
name = str(log_data.get("name", "")).lower()
+14 -16
View File
@@ -14,6 +14,7 @@ from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_cache_refresher, providers_router
from ..nip91 import announce_provider
from ..payment.models import (
cleanup_enabled_models_periodically,
models_router,
update_sats_pricing,
)
@@ -33,9 +34,9 @@ setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.2.2-{os.getenv('VERSION_SUFFIX')}"
__version__ = f"0.2.1-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.2.2"
__version__ = "0.2.1"
@asynccontextmanager
@@ -48,6 +49,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
nip91_task = None
providers_task = None
models_refresh_task = None
models_cleanup_task = None
model_maps_refresh_task = None
try:
@@ -61,15 +63,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
# Initialize application settings (env -> computed -> DB precedence)
async with create_session() as session:
s = await SettingsService.initialize(session)
if s.reset_reserved_balance_on_startup:
from .db import reset_all_reserved_balances
await reset_all_reserved_balances(session)
if not s.admin_password:
logger.warning(
f"Admin password is not set. Visit {s.http_url or 'http://localhost:8000'}/admin to set the password."
)
# Apply app metadata from settings
try:
@@ -87,17 +80,13 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
_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())
)
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())
if global_settings.nsec:
@@ -105,6 +94,11 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
# ensure both setup tasks complete
await asyncio.gather(
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
)
yield
except asyncio.CancelledError:
@@ -131,6 +125,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, 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()
@@ -148,6 +144,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, 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)
+1 -10
View File
@@ -55,16 +55,7 @@ class LoggingMiddleware(BaseHTTPMiddleware):
"headers": {
k: v
for k, v in request.headers.items()
if k.lower()
not in [
"authorization",
"x-cashu",
"cookie",
"cf-connecting-ip",
"cf-ipcountry",
"x-forwarded-for",
"x-real-ip",
]
if k.lower() not in ["authorization", "x-cashu", "cookie"]
},
"body_size": len(request_body) if request_body else 0,
},
+1 -4
View File
@@ -54,15 +54,12 @@ class Settings(BaseSettings):
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
# Minimum per-request charge in millisatoshis when model pricing is free/zero
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
reset_reserved_balance_on_startup: bool = Field(
default=True, env="RESET_RESERVED_BALANCE_ON_STARTUP"
) # deactivate in horizontal scaling setups
# Network
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL")
providers_refresh_interval_seconds: int = Field(
default=0, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
default=300, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
)
pricing_refresh_interval_seconds: int = Field(
default=120, env="PRICING_REFRESH_INTERVAL_SECONDS"
+1 -4
View File
@@ -6,7 +6,7 @@ from typing import Any
import httpx
import websockets
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter
from .core.logging import get_logger
from .core.settings import settings
@@ -389,9 +389,6 @@ async def get_providers(
Return cached providers. If include_json, return provider+health; otherwise provider only.
Optional filter by pubkey.
"""
if settings.providers_refresh_interval_seconds == 0:
raise HTTPException(status_code=404, detail="Provider discovery is disabled")
cache = await get_cache()
if not cache:
await refresh_providers_cache(pubkey=pubkey)
+11 -73
View File
@@ -5,7 +5,6 @@ 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__)
@@ -48,6 +47,13 @@ async def calculate_cost( # todo: can be sync
},
)
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",
@@ -56,62 +62,7 @@ async def calculate_cost( # todo: can be sync
"model": response_data.get("model", "unknown"),
},
)
return MaxCostData(
base_msats=0,
input_msats=0,
output_msats=0,
total_msats=0,
)
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
return cost_data
MSATS_PER_1K_INPUT_TOKENS: float = (
float(settings.fixed_per_1k_input_tokens) * 1000.0
@@ -176,23 +127,10 @@ async def calculate_cost( # todo: can be sync
"model": response_data.get("model", "unknown"),
},
)
return MaxCostData(
base_msats=max_cost,
input_msats=0,
output_msats=0,
total_msats=max_cost,
)
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)
)
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
# added for response api
input_tokens = (
+1 -1
View File
@@ -283,7 +283,7 @@ async def raw_send_to_lnurl(
f"({min_sendable_sat} - {max_sendable_sat} {unit})"
)
estimated_fees_sat = int(max(math.ceil((amount_msat / 1000) * 0.01), 2)) + 1
estimated_fees_sat = int(max(math.ceil((amount_msat / 1000) * 0.01), 2))
estimated_fees_msat = estimated_fees_sat * 1000
final_amount = amount_msat - estimated_fees_msat
+118 -44
View File
@@ -5,9 +5,10 @@ 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, get_session
from ..core.db import ModelRow, create_session, get_session
from ..core.logging import get_logger
from ..core.settings import settings
from .price import sats_usd_price
@@ -92,32 +93,12 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
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 []
response = await client.get(f"{base_url}/models", timeout=30)
response.raise_for_status()
data = response.json()
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:
for model in data.get("data", []):
model_id = model.get("id", "")
if source_filter:
@@ -135,9 +116,9 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
if not _has_valid_pricing(model):
continue
filtered_models.append(model)
models_data.append(model)
return filtered_models
return models_data
except Exception as e:
logger.error(f"Error (async) fetching models from OpenRouter API: {e}")
return []
@@ -184,7 +165,6 @@ def _row_to_model(
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:
@@ -253,11 +233,6 @@ async def list_models(
else 1.01,
)
for r in rows
if include_disabled
or (
r.upstream_provider_id in providers_by_id
and providers_by_id[r.upstream_provider_id].enabled
)
]
@@ -270,8 +245,6 @@ async def get_model_by_id(
if not row or not row.enabled:
return None
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider or not provider.enabled:
return None
provider_fee = provider.provider_fee if provider else 1.01
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
@@ -389,7 +362,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, refresh_model_maps
from ..proxy import get_upstreams
upstreams = get_upstreams()
sats_to_usd = sats_usd_price()
@@ -406,7 +379,6 @@ 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:
@@ -417,13 +389,7 @@ async def update_sats_pricing() -> None:
except Exception:
pass
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)},
)
await _update_sats_pricing_once()
while True:
try:
@@ -447,6 +413,114 @@ 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
@models_router.get("/v1/models")
@models_router.get("/models", include_in_schema=False)
async def models(session: AsyncSession = Depends(get_session)) -> dict:
+6 -20
View File
@@ -79,29 +79,15 @@ async def _fetch_btc_usd_price() -> float:
"""Fetch the lowest BTC/USD price from multiple exchanges."""
async with httpx.AsyncClient(timeout=30.0) as client:
try:
tasks = [
asyncio.create_task(_kraken_btc_usd(client)),
asyncio.create_task(_coinbase_btc_usd(client)),
asyncio.create_task(_binance_btc_usdt(client)),
]
valid_prices: list[float] = []
for future in asyncio.as_completed(tasks):
price = await future
if price is not None:
valid_prices.append(price)
if len(valid_prices) >= 2:
break
for task in tasks:
if not task.done():
task.cancel()
prices = await asyncio.gather(
_kraken_btc_usd(client),
_coinbase_btc_usd(client),
_binance_btc_usdt(client),
)
valid_prices = [price for price in prices if price is not None]
if not valid_prices:
logger.error("No valid BTC prices obtained from any exchange")
raise ValueError("Unable to fetch BTC price from any exchange")
return min(valid_prices)
except Exception as e:
logger.error(
+27 -25
View File
@@ -65,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.lower())
return _model_instances.get(model_id)
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
"""Get UpstreamProvider for model ID from global cache."""
return _provider_map.get(model_id.lower())
return _provider_map.get(model_id)
def get_unique_models() -> list[Model]:
@@ -80,29 +80,31 @@ 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:
# Fetch all providers with their models in a single logical operation
query = select(UpstreamProviderRow).options(
selectinload(UpstreamProviderRow.models) # type: ignore
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
)
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:
if not provider.enabled:
continue
for model in provider.models:
if model.enabled:
overrides_by_id[model.id] = (model, provider.provider_fee)
else:
disabled_model_ids.add(model.id)
disabled_model_ids = {row for row in disabled_result.all()}
_model_instances, _provider_map, _unique_models = create_model_mappings(
upstreams=_upstreams,
@@ -144,7 +146,7 @@ async def proxy(
request_body_dict = parse_request_body_json(request_body, path)
if is_responses_api:
model_id = extract_model_from_responses_request(request_body_dict)
model_id = extract_model_from_responses_api_request(request_body_dict)
else:
model_id = request_body_dict.get("model", "unknown")
@@ -173,7 +175,7 @@ async def proxy(
if x_cashu := headers.get("x-cashu", None):
if is_responses_api:
return await upstream.handle_x_cashu_responses(
return await upstream.handle_x_cashu_responses_api(
request, x_cashu, path, max_cost_for_model, model_obj
)
else:
@@ -203,7 +205,7 @@ async def proxy(
headers = upstream.prepare_headers(dict(request.headers))
if is_responses_api:
response = await upstream.forward_responses_request(
response = await upstream.forward_responses_api_request(
request,
path,
headers,
@@ -326,7 +328,7 @@ async def get_bearer_token_key(
raise
def extract_model_from_responses_request(request_body_dict: dict[str, Any]) -> str:
def extract_model_from_responses_api_request(request_body_dict: dict[str, Any]) -> str:
if model := request_body_dict.get("model"):
return model
+270 -2218
View File
File diff suppressed because it is too large Load Diff
+1 -37
View File
@@ -187,44 +187,11 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
)
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
payment_finalized = False
async def finalize_payment() -> None:
nonlocal payment_finalized
if payment_finalized:
return
from ..auth import adjust_payment_for_tokens
from ..core.db import create_session
async with create_session() as new_session:
fresh_key = await new_session.get(
key.__class__, key.hashed_key
)
if fresh_key:
try:
await adjust_payment_for_tokens(
fresh_key,
{
"model": model_obj.id,
"usage": final_usage_data,
},
new_session,
max_cost_for_model,
)
payment_finalized = True
except Exception as cost_error:
logger.error(
"Error finalizing Gemini streaming payment in fallback",
extra={
"error": str(cost_error),
"key_hash": key.hashed_key[:8] + "...",
},
)
try:
async for chunk in response_generator:
sse_data = f"data: {json.dumps(chunk)}\n\n"
yield sse_data.encode()
except Exception as e:
logger.error(
"Error in Gemini streaming response",
@@ -235,9 +202,6 @@ class GeminiUpstreamProvider(BaseUpstreamProvider):
},
)
raise
finally:
if not payment_finalized:
await finalize_payment()
return StreamingResponse(
stream_with_cost(),
@@ -0,0 +1,566 @@
"""X-Cashu payment request handlers for different API types."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Callable
import httpx
from fastapi import BackgroundTasks, Request
from fastapi.responses import Response, StreamingResponse
from ...core import get_logger
from ...wallet import send_token
from ..payment.cashu_handler import (
CashuTokenError,
create_cashu_error_response,
validate_and_redeem_token,
)
from ..processing.http_client import HttpForwarder
from ..processing.response_handler import ChatCompletionProcessor, ResponsesApiProcessor
if TYPE_CHECKING:
from ...payment.models import Model
logger = get_logger(__name__)
class BaseCashuHandler:
"""Base handler for X-Cashu payment requests."""
def __init__(self, base_url: str):
self.base_url = base_url
self.http_forwarder = HttpForwarder(base_url)
async def send_refund(self, amount: int, unit: str, mint: str | None = None) -> str:
"""Create and send a refund token to the user."""
logger.debug(
"Creating refund token",
extra={"amount": amount, "unit": unit, "mint": mint},
)
max_retries = 3
last_exception = None
for attempt in range(max_retries):
try:
refund_token = await send_token(amount, unit=unit, mint_url=mint)
logger.info(
"Refund token created successfully",
extra={
"amount": amount,
"unit": unit,
"mint": mint,
"attempt": attempt + 1,
"token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
return refund_token
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
logger.warning(
"Refund token creation failed, retrying",
extra={
"error": str(e),
"error_type": type(e).__name__,
"attempt": attempt + 1,
"max_retries": max_retries,
"amount": amount,
"unit": unit,
"mint": mint,
},
)
else:
logger.error(
"Failed to create refund token after all retries",
extra={
"error": str(e),
"error_type": type(e).__name__,
"attempt": attempt + 1,
"max_retries": max_retries,
"amount": amount,
"unit": unit,
"mint": mint,
},
)
raise Exception(
f"failed to create refund after {max_retries} attempts: {str(last_exception)}"
)
def _calculate_refund_amount(self, amount: int, unit: str, cost_msats: int) -> int:
"""Calculate refund amount based on unit and cost."""
if unit == "msat":
return amount - cost_msats
elif unit == "sat":
return amount - (cost_msats + 999) // 1000
else:
raise ValueError(f"Invalid unit: {unit}")
async def _handle_upstream_error(
self,
response: httpx.Response,
amount: int,
unit: str,
mint: str | None,
api_type: str = "API",
) -> Response:
"""Handle upstream service errors with refund."""
logger.warning(
f"Upstream {api_type} request failed, processing refund",
extra={
"status_code": response.status_code,
"amount": amount,
"unit": unit,
},
)
refund_token = await self.send_refund(amount - 60, unit, mint)
logger.info(
f"Refund processed for failed upstream {api_type} request",
extra={
"status_code": response.status_code,
"refund_amount": amount,
"unit": unit,
"refund_token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
error_response = Response(
content=json.dumps(
{
"error": {
"message": f"Error forwarding {api_type} request to upstream",
"type": "upstream_error",
"code": response.status_code,
"refund_token": refund_token,
}
}
),
status_code=response.status_code,
media_type="application/json",
)
error_response.headers["X-Cashu"] = refund_token
return error_response
class ChatCompletionCashuHandler(BaseCashuHandler):
"""Handler for X-Cashu paid chat completion requests."""
async def handle_request(
self,
request: Request,
x_cashu_token: str,
path: str,
headers: dict,
max_cost_for_model: int,
model_obj: Model,
prepare_request_body_func: Callable,
get_x_cashu_cost_func: Callable,
query_params_func: Callable,
) -> Response | StreamingResponse:
"""Handle chat completion request with X-Cashu payment."""
try:
amount, unit, mint = await validate_and_redeem_token(x_cashu_token, request)
# Forward request to upstream
request_body = await request.body()
response = await self.http_forwarder.forward_request(
request=request,
path=path,
headers=headers,
request_body=request_body,
query_params=query_params_func(path, request.query_params),
transform_body_func=prepare_request_body_func,
model_obj=model_obj,
)
# Handle upstream errors
if response.status_code != 200:
return await self._handle_upstream_error(
response, amount, unit, mint, "chat completion"
)
# Process chat completion response
if path.endswith("chat/completions"):
result = await self._handle_chat_completion_response(
response,
amount,
unit,
max_cost_for_model,
mint,
get_x_cashu_cost_func,
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
result.background = background_tasks
return result
# Default streaming response for other endpoints
return self._create_default_streaming_response(response)
except CashuTokenError as e:
return create_cashu_error_response(e, request, x_cashu_token)
async def _handle_chat_completion_response(
self,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None,
get_x_cashu_cost_func: Callable,
) -> Response | StreamingResponse:
"""Handle chat completion response processing."""
# For X-Cashu requests, we force non-streaming response even if upstream is streaming,
# because we need to calculate the refund and include it in the headers.
# Refund token must be in the headers, which are sent before the body.
content = await response.aread()
content_str = content.decode("utf-8") if isinstance(content, bytes) else content
# Check if it was streaming content to process usage correctly
is_streaming_content = ChatCompletionProcessor.is_streaming_response(
content_str
)
if is_streaming_content:
# Buffer streaming content to calculate refund before sending headers.
# Headers (containing refund token) must be sent before body, preventing
# true streaming while supporting dynamic refunds.
# Extract usage data from buffered content
usage_data, model = ChatCompletionProcessor.extract_usage_from_streaming(
content_str
)
if usage_data and model:
try:
response_data = {"usage": usage_data, "model": model}
cost_data = await get_x_cashu_cost_func(
response_data, max_cost_for_model
)
if cost_data:
refund_amount = self._calculate_refund_amount(
amount, unit, cost_data.total_msats
)
if refund_amount > 0:
refund_token = await self.send_refund(
refund_amount, unit, mint
)
# Refund token will be added to new response headers below
except Exception as e:
logger.error(
"Error calculating refund for buffered streaming response",
extra={"error": str(e)},
)
# We return a StreamingResponse that yields the buffered content
# This satisfies the client expecting a stream, while allowing us to set headers.
response_headers = ChatCompletionProcessor.clean_response_headers(
dict(response.headers)
)
if "refund_token" in locals() and refund_token:
response_headers["X-Cashu"] = refund_token
lines = content_str.strip().split("\n")
return StreamingResponse(
ChatCompletionProcessor.create_streaming_generator(lines),
status_code=response.status_code,
headers=response_headers,
media_type="text/event-stream", # Keep original media type if possible, or text/event-stream
)
else:
return await self._handle_non_streaming_response(
content_str,
response,
amount,
unit,
max_cost_for_model,
mint,
get_x_cashu_cost_func,
)
async def _handle_non_streaming_response(
self,
content_str: str,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None,
get_x_cashu_cost_func: Callable,
) -> Response:
"""Handle non-streaming chat completion response with refund calculation."""
response_headers = ChatCompletionProcessor.clean_response_headers(
dict(response.headers)
)
try:
response_json = json.loads(content_str)
cost_data = await get_x_cashu_cost_func(response_json, max_cost_for_model)
if cost_data:
refund_amount = self._calculate_refund_amount(
amount, unit, cost_data.total_msats
)
if refund_amount > 0:
refund_token = await self.send_refund(refund_amount, unit, mint)
response_headers["X-Cashu"] = refund_token
return Response(
content=content_str,
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
except json.JSONDecodeError:
# Emergency refund on parse error
emergency_refund = amount
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
response_headers["X-Cashu"] = refund_token
logger.warning(
"Emergency refund issued due to JSON parse error",
extra={
"original_amount": amount,
"refund_amount": emergency_refund,
"deduction": 60,
},
)
return Response(
content=content_str,
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
def _create_default_streaming_response(
self, response: httpx.Response
) -> StreamingResponse:
"""Create default streaming response for non-chat endpoints."""
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
background=background_tasks,
)
class ResponsesApiCashuHandler(BaseCashuHandler):
"""Handler for X-Cashu paid Responses API requests."""
async def handle_request(
self,
request: Request,
x_cashu_token: str,
path: str,
headers: dict,
max_cost_for_model: int,
model_obj: Model,
prepare_responses_api_request_body_func: Callable,
get_x_cashu_cost_func: Callable,
query_params_func: Callable,
) -> Response | StreamingResponse:
"""Handle Responses API request with X-Cashu payment."""
try:
amount, unit, mint = await validate_and_redeem_token(x_cashu_token, request)
# Forward request to upstream
request_body = await request.body()
response = await self.http_forwarder.forward_request(
request=request,
path=path,
headers=headers,
request_body=request_body,
query_params=query_params_func(path, request.query_params),
transform_body_func=prepare_responses_api_request_body_func,
model_obj=model_obj,
)
# Handle upstream errors
if response.status_code != 200:
return await self._handle_upstream_error(
response, amount, unit, mint, "Responses API"
)
# Process Responses API response
if path.startswith("responses"):
result = await self._handle_responses_api_completion(
response,
amount,
unit,
max_cost_for_model,
mint,
get_x_cashu_cost_func,
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
result.background = background_tasks
return result
# Default streaming response for other endpoints
return self._create_default_streaming_response(response)
except CashuTokenError as e:
return create_cashu_error_response(e, request, x_cashu_token)
async def _handle_responses_api_completion(
self,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None,
get_x_cashu_cost_func: Callable,
) -> Response | StreamingResponse:
"""Handle Responses API completion response processing."""
# Force non-streaming for X-Cashu to handle refunds in headers
content = await response.aread()
content_str = content.decode("utf-8") if isinstance(content, bytes) else content
is_streaming_content = ResponsesApiProcessor.is_streaming_response(content_str)
if is_streaming_content:
usage_data, model = (
ResponsesApiProcessor.extract_usage_with_reasoning_tokens(content_str)
)
refund_token = None
if usage_data and model:
try:
response_data = {"usage": usage_data, "model": model}
cost_data = await get_x_cashu_cost_func(
response_data, max_cost_for_model
)
if cost_data:
refund_amount = self._calculate_refund_amount(
amount, unit, cost_data.total_msats
)
if refund_amount > 0:
refund_token = await self.send_refund(
refund_amount, unit, mint
)
except Exception as e:
logger.error(
"Error calculating refund for buffered Responses API response",
extra={"error": str(e)},
)
response_headers = ResponsesApiProcessor.clean_response_headers(
dict(response.headers)
)
if refund_token:
response_headers["X-Cashu"] = refund_token
lines = content_str.strip().split("\n")
return StreamingResponse(
ResponsesApiProcessor.create_streaming_generator(lines),
status_code=response.status_code,
headers=response_headers,
media_type="text/event-stream",
)
else:
return await self._handle_non_streaming_responses_api_response(
content_str,
response,
amount,
unit,
max_cost_for_model,
mint,
get_x_cashu_cost_func,
)
async def _handle_non_streaming_responses_api_response(
self,
content_str: str,
response: httpx.Response,
amount: int,
unit: str,
max_cost_for_model: int,
mint: str | None,
get_x_cashu_cost_func: Callable,
) -> Response:
"""Handle non-streaming Responses API response with refund calculation."""
response_headers = ResponsesApiProcessor.clean_response_headers(
dict(response.headers)
)
try:
response_json = json.loads(content_str)
cost_data = await get_x_cashu_cost_func(response_json, max_cost_for_model)
if cost_data:
refund_amount = self._calculate_refund_amount(
amount, unit, cost_data.total_msats
)
if refund_amount > 0:
refund_token = await self.send_refund(refund_amount, unit, mint)
response_headers["X-Cashu"] = refund_token
return Response(
content=content_str,
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
except json.JSONDecodeError:
# Emergency refund on parse error
emergency_refund = amount
refund_token = await self.send_refund(
emergency_refund, unit=unit, mint=mint
)
response_headers["X-Cashu"] = refund_token
logger.warning(
"Emergency refund issued due to JSON parse error in Responses API",
extra={
"original_amount": amount,
"refund_amount": emergency_refund,
"deduction": 60,
},
)
return Response(
content=content_str,
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
def _create_default_streaming_response(
self, response: httpx.Response
) -> StreamingResponse:
"""Create default streaming response for non-chat endpoints."""
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
background=background_tasks,
)
+6 -25
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import asyncio
import os
import re
from typing import TYPE_CHECKING
@@ -8,7 +7,6 @@ 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
@@ -102,8 +100,6 @@ async def get_all_models_with_overrides(
)
for row in override_rows
if row.upstream_provider_id is not None
and row.upstream_provider_id in providers_by_id
and providers_by_id[row.upstream_provider_id].enabled
}
all_models: dict[str, Model] = {}
@@ -149,17 +145,6 @@ 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:
@@ -181,6 +166,8 @@ 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:
@@ -196,16 +183,16 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
result = await session.exec(select(UpstreamProviderRow))
existing_providers = result.all()
async def _init_single_provider(
provider_row: UpstreamProviderRow,
) -> BaseUpstreamProvider | None:
upstreams: list[BaseUpstreamProvider] = []
for provider_row in existing_providers:
if not provider_row.enabled:
logger.debug(f"Skipping disabled provider: {provider_row.base_url}")
return None
continue
provider = _instantiate_provider(provider_row)
if provider:
await provider.refresh_models_cache()
upstreams.append(provider)
logger.debug(
f"Initialized {provider_row.provider_type} provider",
extra={
@@ -213,12 +200,6 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
"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
+1 -7
View File
@@ -50,13 +50,7 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
async def fetch_models(self) -> list[Model]:
"""Fetch all OpenRouter models."""
models_data = await async_fetch_openrouter_models()
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
return [Model(**model) for model in models_data] # type: ignore
async def get_balance(self) -> float | None:
"""Get the current account balance from OpenRouter.
+111
View File
@@ -0,0 +1,111 @@
"""X-Cashu payment handling utilities."""
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi import Request
from fastapi.responses import Response
from ...core import get_logger
from ...payment.helpers import create_error_response
from ...wallet import recieve_token
if TYPE_CHECKING:
pass
logger = get_logger(__name__)
class CashuTokenError(Exception):
"""Exception raised for X-Cashu token processing errors."""
def __init__(self, error_type: str, message: str, status_code: int = 400):
self.error_type = error_type
self.message = message
self.status_code = status_code
super().__init__(message)
async def validate_and_redeem_token(
x_cashu_token: str, request: Request
) -> tuple[int, str, str | None]:
"""Validate and redeem X-Cashu token.
Args:
x_cashu_token: The X-Cashu token to validate and redeem
request: Original FastAPI request (for error responses)
Returns:
Tuple of (amount, unit, mint_url)
Raises:
CashuTokenError: If token validation or redemption fails
"""
logger.info(
"Processing X-Cashu token redemption",
extra={
"token_preview": x_cashu_token[:20] + "..."
if len(x_cashu_token) > 20
else x_cashu_token,
},
)
try:
amount, unit, mint = await recieve_token(x_cashu_token)
logger.info(
"X-Cashu token redeemed successfully",
extra={"amount": amount, "unit": unit, "mint": mint},
)
return amount, unit, mint
except Exception as e:
error_message = str(e)
logger.error(
"X-Cashu token redemption failed",
extra={
"error": error_message,
"error_type": type(e).__name__,
},
)
# Determine specific error type
if "already spent" in error_message.lower():
raise CashuTokenError(
"token_already_spent",
"The provided CASHU token has already been spent",
400,
)
elif "invalid token" in error_message.lower():
raise CashuTokenError(
"invalid_token", "The provided CASHU token is invalid", 400
)
elif "mint error" in error_message.lower():
raise CashuTokenError(
"mint_error", f"CASHU mint error: {error_message}", 422
)
else:
raise CashuTokenError(
"cashu_error", f"CASHU token processing failed: {error_message}", 400
)
def create_cashu_error_response(
error: CashuTokenError, request: Request, x_cashu_token: str
) -> Response:
"""Create error response for X-Cashu token errors.
Args:
error: The CashuTokenError that occurred
request: Original FastAPI request
x_cashu_token: The token that caused the error
Returns:
Error response with appropriate status code and message
"""
return create_error_response(
error.error_type,
error.message,
error.status_code,
request=request,
token=x_cashu_token,
)
+200
View File
@@ -0,0 +1,200 @@
"""HTTP client utilities for upstream requests."""
from __future__ import annotations
import traceback
from typing import TYPE_CHECKING, Callable, Mapping
import httpx
from fastapi import BackgroundTasks, Request
from fastapi.responses import StreamingResponse
from ...core import get_logger
if TYPE_CHECKING:
from ...payment.models import Model
logger = get_logger(__name__)
class HttpForwarder:
"""Handles HTTP request forwarding to upstream services."""
def __init__(self, base_url: str):
self.base_url = base_url
def _prepare_path(self, path: str) -> str:
"""Prepare path by removing v1/ prefix if present."""
if path.startswith("v1/"):
path = path.replace("v1/", "")
return path
async def forward_request(
self,
request: Request,
path: str,
headers: dict,
request_body: bytes | None,
query_params: Mapping[str, str] | None,
transform_body_func: Callable | None = None,
model_obj: Model | None = None,
) -> httpx.Response:
"""Forward HTTP request to upstream service.
Args:
request: Original FastAPI request
path: Request path
headers: Prepared headers for upstream
request_body: Request body bytes, if any
query_params: Query parameters for the request
transform_body_func: Optional function to transform request body
model_obj: Model object for request body transformation
Returns:
Response from upstream service
Raises:
httpx.RequestError: If request fails
Exception: For other unexpected errors
"""
path = self._prepare_path(path)
url = f"{self.base_url}/{path}"
# Transform body if function provided
transformed_body = request_body
if transform_body_func and request_body and model_obj:
transformed_body = transform_body_func(request_body, model_obj)
logger.info(
"Forwarding request to upstream",
extra={
"url": url,
"method": request.method,
"path": path,
"has_request_body": request_body is not None,
},
)
client = httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=None,
)
try:
if transformed_body is not None:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=transformed_body,
params=query_params,
),
stream=True,
)
else:
response = await client.send(
client.build_request(
request.method,
url,
headers=headers,
content=request.stream(),
params=query_params,
),
stream=True,
)
logger.info(
"Received upstream response",
extra={
"status_code": response.status_code,
"path": path,
"content_type": response.headers.get("content-type", "unknown"),
},
)
return response
except httpx.RequestError as exc:
await client.aclose()
error_type = type(exc).__name__
error_details = str(exc)
logger.error(
"HTTP request error to upstream",
extra={
"error_type": error_type,
"error_details": error_details,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
},
)
if isinstance(exc, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
elif isinstance(exc, httpx.TimeoutException):
error_message = "Upstream service request timed out"
elif isinstance(exc, httpx.NetworkError):
error_message = "Network error while connecting to upstream service"
else:
error_message = f"Error connecting to upstream service: {error_type}"
raise httpx.RequestError(error_message) from exc
except Exception as exc:
await client.aclose()
tb = traceback.format_exc()
logger.error(
"Unexpected error in upstream forwarding",
extra={
"error": str(exc),
"error_type": type(exc).__name__,
"method": request.method,
"url": url,
"path": path,
"query_params": dict(request.query_params),
"traceback": tb,
},
)
raise
class StreamingResponseWrapper:
"""Wrapper for creating streaming responses with proper cleanup."""
@staticmethod
def create_streaming_response(
response: httpx.Response,
client: httpx.AsyncClient,
) -> StreamingResponse:
"""Create a streaming response with background cleanup tasks.
Args:
response: httpx response to stream
client: httpx client to clean up
Returns:
StreamingResponse with background cleanup
"""
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
logger.debug(
"Creating streaming response",
extra={
"status_code": response.status_code,
"content_type": response.headers.get("content-type", "unknown"),
},
)
return StreamingResponse(
response.aiter_bytes(),
status_code=response.status_code,
headers=dict(response.headers),
background=background_tasks,
)
@@ -0,0 +1,170 @@
"""Response processing utilities for different API types."""
from __future__ import annotations
import json
import re
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING
from ...core import get_logger
if TYPE_CHECKING:
pass
logger = get_logger(__name__)
class ResponseProcessor:
"""Processes responses from upstream services."""
@staticmethod
def is_streaming_response(content_str: str) -> bool:
"""Determine if response content indicates streaming.
Args:
content_str: Response content as string
Returns:
True if response is streaming, False otherwise
"""
return content_str.startswith("data:") or "data:" in content_str
@staticmethod
def extract_usage_from_streaming(
content_str: str,
) -> tuple[dict | None, str | None]:
"""Extract usage data and model from streaming response content.
Args:
content_str: Streaming response content as string
Returns:
Tuple of (usage_data, model) or (None, None) if not found
"""
usage_data = None
model = None
lines = content_str.strip().split("\n")
for line in lines:
if line.startswith("data: "):
try:
data_json = json.loads(line[6:])
if "usage" in data_json:
usage_data = data_json["usage"]
model = data_json.get("model")
elif "model" in data_json and not model:
model = data_json["model"]
except json.JSONDecodeError:
continue
return usage_data, model
@staticmethod
def clean_response_headers(headers: dict) -> dict:
"""Clean response headers by removing encoding-related headers.
Args:
headers: Original response headers
Returns:
Cleaned headers dict
"""
cleaned_headers = dict(headers)
headers_to_remove = ["transfer-encoding", "content-encoding", "content-length"]
for header in headers_to_remove:
cleaned_headers.pop(header, None)
return cleaned_headers
@staticmethod
async def create_streaming_generator(
lines: list[str],
) -> AsyncGenerator[bytes, None]:
"""Create async generator for streaming response lines.
Args:
lines: List of response lines to stream
Yields:
Encoded response lines
"""
for line in lines:
yield (line + "\n").encode("utf-8")
class ChatCompletionProcessor(ResponseProcessor):
"""Processor specifically for chat completion responses."""
@staticmethod
def extract_model_from_chunks(stored_chunks: list[bytes]) -> str | None:
"""Extract model name from stored response chunks.
Args:
stored_chunks: List of response chunks from streaming
Returns:
Model name if found, None otherwise
"""
last_model_seen = None
for i in range(len(stored_chunks) - 1, -1, -1):
chunk = stored_chunks[i]
if not chunk:
continue
try:
events = re.split(b"data: ", chunk)
for event_data in events:
if not event_data or event_data.strip() in (b"[DONE]", b""):
continue
try:
data = json.loads(event_data)
if isinstance(data, dict) and data.get("model"):
return str(data.get("model"))
except json.JSONDecodeError:
continue
except Exception as e:
logger.debug(
"Error processing chunk for model extraction",
extra={"error": str(e), "error_type": type(e).__name__},
)
return last_model_seen
class ResponsesApiProcessor(ResponseProcessor):
"""Processor specifically for Responses API responses."""
@staticmethod
def extract_usage_with_reasoning_tokens(
content_str: str,
) -> tuple[dict | None, str | None]:
"""Extract usage data including reasoning tokens from Responses API content.
Args:
content_str: Streaming response content as string
Returns:
Tuple of (usage_data, model) with reasoning tokens preserved
"""
usage_data = None
model = None
lines = content_str.strip().split("\n")
for line in lines:
if line.startswith("data: "):
try:
data_json = json.loads(line[6:])
if "usage" in data_json:
usage_data = data_json["usage"]
model = data_json.get("model")
# Note: We now only track input_tokens and output_tokens
# reasoning_tokens are ignored per the requirement
break
elif "model" in data_json and not model:
model = data_json["model"]
except json.JSONDecodeError:
continue
return usage_data, model
+1 -1
View File
@@ -81,7 +81,7 @@ async def swap_to_primary_mint(
amount_msat = token_amount
else:
raise ValueError("Invalid unit")
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2)) + 1
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2))
amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
-101
View File
@@ -1,101 +0,0 @@
import asyncio
import httpx
BASE_URL = input("Enter routstr URL: ")
API_KEY = input("Enter key or token: ")
async def get_balance(client: httpx.AsyncClient) -> int:
response = await client.get("/v1/balance/info")
response.raise_for_status()
data = response.json()
print(f"Current Balance Info: {data}")
return data.get("reserved", 0)
async def reproduce() -> None:
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
async with httpx.AsyncClient(
base_url=BASE_URL, headers=headers, timeout=30.0
) as client:
print("Checking initial balance...")
try:
initial_reserved = await get_balance(client)
except Exception as e:
print(f"Failed to get balance: {e}")
return
print("\nStarting streaming request...")
try:
# Create a separate client for the stream so we can close it independently if needed,
# but usually just breaking the loop and exiting the context manager is enough.
# However, to be sure we simulate a harsh disconnect, we can just cancel the task or close the client.
async with client.stream(
"POST",
"/v1/chat/completions",
json={
"model": "gpt-5-nano",
"messages": [
{
"role": "user",
"content": "Write a long poem about the ocean.",
}
],
"stream": True,
},
) as response:
print(f"Stream status: {response.status_code}")
if response.status_code != 200:
err_bytes = await response.aread()
try:
err_str = err_bytes.decode()
except Exception:
err_str = repr(err_bytes)
print(f"Error: {err_str}")
return
print("Stream started. Reading a few chunks...")
count = 0
async for chunk in response.aiter_bytes():
print(f"Received chunk: {len(chunk)} bytes")
count += 1
if count >= 3:
print("Simulating client disconnect (breaking stream)...")
break
except Exception as e:
print(f"Stream interrupted (expected): {e}")
# Wait a bit for the server to realize we disconnected (though with asyncio it might be immediate or depend on keepalive)
print("\nWaiting for server to process disconnect...")
await asyncio.sleep(21)
print("\nChecking final balance...")
try:
final_reserved = await get_balance(client)
except Exception:
# Retry once if connection was closed
async with httpx.AsyncClient(
base_url=BASE_URL, headers=headers, timeout=30.0
) as new_client:
final_reserved = await get_balance(new_client)
if final_reserved > initial_reserved:
print(
f"\n[FAIL] Bug reproduced! Reserved balance increased: {initial_reserved} -> {final_reserved}"
)
print(f"Accumulated reserved balance: {final_reserved - initial_reserved}")
else:
print(
f"\n[PASS] Reserved balance released correctly: {initial_reserved} -> {final_reserved}"
)
if __name__ == "__main__":
try:
asyncio.run(reproduce())
except KeyboardInterrupt:
pass
-97
View File
@@ -1,97 +0,0 @@
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
+24
View File
@@ -10,6 +10,7 @@ from httpx import AsyncClient
from .utils import (
CashuTokenGenerator,
PerformanceValidator,
ResponseValidator,
)
@@ -158,7 +159,30 @@ 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
+28 -33
View File
@@ -9,7 +9,6 @@ import gc
import statistics
import time
from typing import Any, Dict, List
from unittest.mock import patch
import psutil
import pytest
@@ -106,46 +105,42 @@ class TestPerformanceBaseline:
("GET", "/v1/wallet/info", authenticated_client, None),
]
# Enable provider discovery for this test
with patch(
"routstr.core.settings.settings.providers_refresh_interval_seconds", 300
):
# Warm up
for _ in range(10):
await integration_client.get("/")
# Warm up
for _ in range(10):
await integration_client.get("/")
# Test each endpoint
for method, path, client, data in endpoints:
response_times = []
# Test each endpoint
for method, path, client, data in endpoints:
response_times = []
for i in range(100):
start = time.time()
for i in range(100):
start = time.time()
if method == "GET":
response = await client.get(path)
else:
response = await client.post(path, json=data)
if method == "GET":
response = await client.get(path)
else:
response = await client.post(path, json=data)
duration = time.time() - start
response_times.append(duration * 1000) # Convert to ms
duration = time.time() - start
response_times.append(duration * 1000) # Convert to ms
assert response.status_code in [200, 201]
assert response.status_code in [200, 201]
if i % 10 == 0:
metrics.record_system_metrics()
if i % 10 == 0:
metrics.record_system_metrics()
# Verify 95th percentile < 500ms
p95 = sorted(response_times)[int(len(response_times) * 0.95)]
assert p95 < 500, (
f"{method} {path} p95 response time {p95}ms exceeds 500ms limit"
)
# Verify 95th percentile < 500ms
p95 = sorted(response_times)[int(len(response_times) * 0.95)]
assert p95 < 500, (
f"{method} {path} p95 response time {p95}ms exceeds 500ms limit"
)
print(f"\n{method} {path}:")
print(f" Mean: {statistics.mean(response_times):.2f}ms")
print(f" P95: {p95:.2f}ms")
print(
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
)
print(f"\n{method} {path}:")
print(f" Mean: {statistics.mean(response_times):.2f}ms")
print(f" P95: {p95:.2f}ms")
print(
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
)
@pytest.mark.integration
+42 -11
View File
@@ -3,7 +3,7 @@ Integration tests for provider management functionality.
Tests GET /v1/providers/ endpoint for listing and managing providers.
"""
from typing import Any, Generator
from typing import Any
from unittest.mock import patch
import pytest
@@ -11,7 +11,7 @@ from httpx import AsyncClient
from routstr.discovery import _PROVIDERS_CACHE
from .utils import ResponseValidator
from .utils import PerformanceValidator, ResponseValidator
@pytest.fixture(autouse=True)
@@ -19,15 +19,6 @@ def _clear_providers_cache() -> None:
_PROVIDERS_CACHE.clear()
@pytest.fixture(autouse=True)
def _enable_provider_discovery() -> Generator[None, Any, Any]:
"""Enable provider discovery for all tests in this module"""
with patch(
"routstr.core.settings.settings.providers_refresh_interval_seconds", 300
):
yield
@pytest.mark.integration
@pytest.mark.asyncio
async def test_providers_endpoint_default_response(
@@ -527,6 +518,46 @@ 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.discovery.query_nostr_relay_for_providers", return_value=mock_events
):
with patch("routstr.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(
@@ -18,6 +18,7 @@ from routstr.core.db import ApiKey
from .utils import (
ConcurrencyTester,
PerformanceValidator,
)
@@ -550,7 +551,39 @@ 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,6 +15,7 @@ from httpx import ASGITransport, AsyncClient
from .utils import (
ConcurrencyTester,
PerformanceValidator,
)
@@ -289,7 +290,55 @@ 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
@@ -3,6 +3,7 @@ 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
@@ -14,6 +15,7 @@ from routstr.core.db import ApiKey
from .utils import (
CashuTokenGenerator,
ConcurrencyTester,
ResponseValidator,
)
@@ -387,6 +389,69 @@ 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(ApiKey).where(ApiKey.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(
@@ -439,6 +504,48 @@ 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(
+67 -2
View File
@@ -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
@@ -13,7 +13,7 @@ from sqlmodel import select, update
from routstr.core.db import ApiKey
from .utils import ResponseValidator
from .utils import ConcurrencyTester, ResponseValidator
@pytest.mark.integration
@@ -204,6 +204,45 @@ async def test_expired_api_key_behavior(
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(
@@ -367,4 +406,30 @@ 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
+38
View File
@@ -537,4 +537,42 @@ 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
+55
View File
@@ -15,6 +15,7 @@ from routstr.core.db import ApiKey
from .utils import (
CashuTokenGenerator,
ConcurrencyTester,
ResponseValidator,
)
@@ -283,6 +284,60 @@ 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]
+3 -8
View File
@@ -4,22 +4,17 @@ FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN npm i
FROM base AS builder
WORKDIR /app
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm run build
RUN npm run build
FROM base AS runner
WORKDIR /app
+3 -5
View File
@@ -6,14 +6,12 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
RUN pnpm install --frozen-lockfile
COPY package.json package-lock.json* pnpm-lock.yaml* ./
RUN npm ci
# Build the UI
FROM base AS builder
WORKDIR /app
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@@ -29,7 +27,7 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Build the application
RUN pnpm run build && \
RUN npm run build && \
echo "UI build completed at $(date)"
# Use the builder stage as the final stage
+2 -450
View File
@@ -21,17 +21,7 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { Badge } from '@/components/ui/badge';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Checkbox } from '@/components/ui/checkbox';
import { CalendarIcon, Filter, X, Plus } from 'lucide-react';
import { CalendarIcon, Filter, X } from 'lucide-react';
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
@@ -41,17 +31,11 @@ interface LogFiltersProps {
selectedLevel: string;
requestId: string;
searchText: string;
selectedStatusCodes: string[];
selectedMethods: string[];
selectedEndpoints: string[];
limit: number;
onDateChange: (date: string) => void;
onLevelChange: (level: string) => void;
onRequestIdChange: (requestId: string) => void;
onSearchTextChange: (searchText: string) => void;
onStatusCodesChange: (statusCodes: string[]) => void;
onMethodsChange: (methods: string[]) => void;
onEndpointsChange: (endpoints: string[]) => void;
onLimitChange: (limit: number) => void;
onClearFilters: () => void;
}
@@ -59,87 +43,16 @@ interface LogFiltersProps {
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
const PRESET_LIMITS = ['25', '50', '100', '200', '500', '1000'];
const STATUS_CODE_OPTIONS = [
'200',
'201',
'204',
'400',
'401',
'402',
'403',
'404',
'422',
'429',
'500',
'502',
'503',
'504',
];
const METHOD_OPTIONS = [
'GET',
'POST',
'PUT',
'DELETE',
'PATCH',
'OPTIONS',
'HEAD',
];
const ENDPOINT_OPTIONS = [
'/chat/completions',
'/v1/chat/completions',
'/models',
'/v1/models',
'/responses',
'/v1/responses',
'v1/embeddings/models',
'/embeddings/models',
];
interface FilterBadgeProps {
value: string;
onRemove: (value: string) => void;
}
function FilterBadge({ value, onRemove }: FilterBadgeProps) {
return (
<Badge
variant='secondary'
className='flex items-center gap-1 px-1 font-normal'
>
{value}
<button
type='button'
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove(value);
}}
className='hover:bg-muted-foreground/20 rounded-full'
>
<X className='h-3 w-3' />
</button>
</Badge>
);
}
export function LogFilters({
selectedDate,
selectedLevel,
requestId,
searchText,
selectedStatusCodes,
selectedMethods,
selectedEndpoints,
limit,
onDateChange,
onLevelChange,
onRequestIdChange,
onSearchTextChange,
onStatusCodesChange,
onMethodsChange,
onEndpointsChange,
onLimitChange,
onClearFilters,
}: LogFiltersProps) {
@@ -155,10 +68,6 @@ export function LogFilters({
: undefined
);
const [statusSearch, setStatusSearch] = useState('');
const [methodSearch, setMethodSearch] = useState('');
const [endpointSearch, setEndpointSearch] = useState('');
useEffect(() => {
const currentIsPreset = PRESET_LIMITS.includes(limit.toString());
setIsCustom(!currentIsPreset);
@@ -220,31 +129,6 @@ export function LogFilters({
}
};
const toggleSelection = (
current: string[],
value: string,
onChange: (val: string[]) => void
) => {
if (current.includes(value)) {
onChange(current.filter((v) => v !== value));
} else {
onChange([...current, value]);
}
};
const handleQuickStatusCode = (range: '4xx' | '5xx') => {
const codes = STATUS_CODE_OPTIONS.filter((c) => c.startsWith(range[0]));
const newSelection = new Set([...selectedStatusCodes]);
const allIncluded = codes.every((c) => selectedStatusCodes.includes(c));
if (allIncluded) {
codes.forEach((c) => newSelection.delete(c));
} else {
codes.forEach((c) => newSelection.add(c));
}
onStatusCodesChange(Array.from(newSelection));
};
return (
<Card className='mb-6'>
<CardHeader>
@@ -253,8 +137,7 @@ export function LogFilters({
Filters
</CardTitle>
<CardDescription>
Filter logs by date, level, request ID, text search, status code,
method, endpoint and limit
Filter logs by date, level, request ID, text search, and limit
</CardDescription>
</CardHeader>
<CardContent>
@@ -314,337 +197,6 @@ export function LogFilters({
</Select>
</div>
<div className='space-y-2'>
<Label>Status Codes</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1'>
{selectedStatusCodes.length > 0 ? (
selectedStatusCodes.map((code) => (
<FilterBadge
key={code}
value={code}
onRemove={(val) =>
toggleSelection(
selectedStatusCodes,
val,
onStatusCodesChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>All codes</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-64 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add status code...'
value={statusSearch}
onValueChange={setStatusSearch}
/>
<CommandList>
{selectedStatusCodes.length > 0 && (
<CommandGroup heading='Selected'>
{selectedStatusCodes.map((code) => (
<CommandItem
key={`selected-${code}`}
onSelect={() =>
toggleSelection(
selectedStatusCodes,
code,
onStatusCodesChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{code}
</CommandItem>
))}
</CommandGroup>
)}
{statusSearch &&
!STATUS_CODE_OPTIONS.includes(statusSearch) &&
!selectedStatusCodes.includes(statusSearch) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
if (/^\d+$/.test(statusSearch)) {
toggleSelection(
selectedStatusCodes,
statusSearch,
onStatusCodesChange
);
setStatusSearch('');
}
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{statusSearch}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading='Quick Filters'>
<CommandItem
onSelect={() => handleQuickStatusCode('4xx')}
>
<Checkbox
checked={STATUS_CODE_OPTIONS.filter((c) =>
c.startsWith('4')
).every((c) => selectedStatusCodes.includes(c))}
className='mr-2'
/>
4xx Errors
</CommandItem>
<CommandItem
onSelect={() => handleQuickStatusCode('5xx')}
>
<Checkbox
checked={STATUS_CODE_OPTIONS.filter((c) =>
c.startsWith('5')
).every((c) => selectedStatusCodes.includes(c))}
className='mr-2'
/>
5xx Errors
</CommandItem>
</CommandGroup>
<CommandGroup heading='Common Codes'>
{STATUS_CODE_OPTIONS.filter(
(code) => !selectedStatusCodes.includes(code)
).map((code) => (
<CommandItem
key={code}
onSelect={() =>
toggleSelection(
selectedStatusCodes,
code,
onStatusCodesChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{code}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className='space-y-2'>
<Label>HTTP Methods</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1'>
{selectedMethods.length > 0 ? (
selectedMethods.map((method) => (
<FilterBadge
key={method}
value={method}
onRemove={(val) =>
toggleSelection(
selectedMethods,
val,
onMethodsChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>All methods</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-64 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add method...'
value={methodSearch}
onValueChange={setMethodSearch}
/>
<CommandList>
{selectedMethods.length > 0 && (
<CommandGroup heading='Selected'>
{selectedMethods.map((method) => (
<CommandItem
key={`selected-${method}`}
onSelect={() =>
toggleSelection(
selectedMethods,
method,
onMethodsChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{method}
</CommandItem>
))}
</CommandGroup>
)}
{methodSearch &&
!METHOD_OPTIONS.includes(methodSearch.toUpperCase()) &&
!selectedMethods.includes(methodSearch.toUpperCase()) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
toggleSelection(
selectedMethods,
methodSearch.toUpperCase(),
onMethodsChange
);
setMethodSearch('');
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{methodSearch.toUpperCase()}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{METHOD_OPTIONS.filter(
(method) => !selectedMethods.includes(method)
).map((method) => (
<CommandItem
key={method}
onSelect={() =>
toggleSelection(
selectedMethods,
method,
onMethodsChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{method}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className='space-y-2'>
<Label>Endpoints</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<div className='flex flex-wrap gap-1 overflow-hidden'>
{selectedEndpoints.length > 0 ? (
selectedEndpoints.map((endpoint) => (
<FilterBadge
key={endpoint}
value={endpoint}
onRemove={(val) =>
toggleSelection(
selectedEndpoints,
val,
onEndpointsChange
)
}
/>
))
) : (
<span className='text-muted-foreground'>
All endpoints
</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className='w-80 p-0' align='start'>
<Command>
<CommandInput
placeholder='Search or add endpoint pattern...'
value={endpointSearch}
onValueChange={setEndpointSearch}
/>
<CommandList>
{selectedEndpoints.length > 0 && (
<CommandGroup heading='Selected'>
{selectedEndpoints.map((endpoint) => (
<CommandItem
key={`selected-${endpoint}`}
onSelect={() =>
toggleSelection(
selectedEndpoints,
endpoint,
onEndpointsChange
)
}
>
<Checkbox checked={true} className='mr-2' />
{endpoint}
</CommandItem>
))}
</CommandGroup>
)}
{endpointSearch &&
!ENDPOINT_OPTIONS.includes(endpointSearch) &&
!selectedEndpoints.includes(endpointSearch) && (
<CommandGroup heading='Custom'>
<CommandItem
onSelect={() => {
toggleSelection(
selectedEndpoints,
endpointSearch,
onEndpointsChange
);
setEndpointSearch('');
}}
>
<Plus className='mr-2 h-4 w-4' />
Add &quot;{endpointSearch}&quot;
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading='Common Endpoints'>
{ENDPOINT_OPTIONS.filter(
(endpoint) => !selectedEndpoints.includes(endpoint)
).map((endpoint) => (
<CommandItem
key={endpoint}
onSelect={() =>
toggleSelection(
selectedEndpoints,
endpoint,
onEndpointsChange
)
}
>
<Checkbox checked={false} className='mr-2' />
{endpoint}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className='space-y-2'>
<Label htmlFor='request-id'>Request ID</Label>
<Input
+2 -84
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
@@ -22,66 +22,15 @@ import { LogFilters } from './log-filters';
import { LogEntryCard } from './log-entry-card';
import { LogDetailsDialog } from './log-details-dialog';
const STORAGE_KEY = 'routstr-log-filters';
export default function LogsPage() {
const [selectedDate, setSelectedDate] = useState<string>('all');
const [selectedLevel, setSelectedLevel] = useState<string>('all');
const [requestId, setRequestId] = useState<string>('');
const [searchText, setSearchText] = useState<string>('');
const [selectedStatusCodes, setSelectedStatusCodes] = useState<string[]>([]);
const [selectedMethods, setSelectedMethods] = useState<string[]>([]);
const [selectedEndpoints, setSelectedEndpoints] = useState<string[]>([]);
const [limit, setLimit] = useState<number>(100);
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
// Load filters from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.selectedDate) setSelectedDate(parsed.selectedDate);
if (parsed.selectedLevel) setSelectedLevel(parsed.selectedLevel);
if (parsed.requestId) setRequestId(parsed.requestId);
if (parsed.searchText) setSearchText(parsed.searchText);
if (parsed.selectedStatusCodes)
setSelectedStatusCodes(parsed.selectedStatusCodes);
if (parsed.selectedMethods) setSelectedMethods(parsed.selectedMethods);
if (parsed.selectedEndpoints)
setSelectedEndpoints(parsed.selectedEndpoints);
if (parsed.limit) setLimit(parsed.limit);
} catch (e) {
console.error('Failed to load filters from localStorage', e);
}
}
}, []);
// Save filters to localStorage whenever they change
useEffect(() => {
const filters = {
selectedDate,
selectedLevel,
requestId,
searchText,
selectedStatusCodes,
selectedMethods,
selectedEndpoints,
limit,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
}, [
selectedDate,
selectedLevel,
requestId,
searchText,
selectedStatusCodes,
selectedMethods,
selectedEndpoints,
limit,
]);
const {
data: logsData,
refetch: refetchLogs,
@@ -93,9 +42,6 @@ export default function LogsPage() {
selectedLevel,
requestId,
searchText,
selectedStatusCodes,
selectedMethods,
selectedEndpoints,
limit,
],
queryFn: () =>
@@ -104,16 +50,6 @@ export default function LogsPage() {
level: selectedLevel === 'all' ? undefined : selectedLevel,
request_id: requestId || undefined,
search: searchText || undefined,
status_codes:
selectedStatusCodes.length > 0
? selectedStatusCodes.join(',')
: undefined,
methods:
selectedMethods.length > 0 ? selectedMethods.join(',') : undefined,
endpoints:
selectedEndpoints.length > 0
? selectedEndpoints.join(',')
: undefined,
limit: limit,
}),
refetchInterval: 30000,
@@ -124,9 +60,6 @@ export default function LogsPage() {
setSelectedLevel('all');
setRequestId('');
setSearchText('');
setSelectedStatusCodes([]);
setSelectedMethods([]);
setSelectedEndpoints([]);
setLimit(100);
};
@@ -167,17 +100,11 @@ export default function LogsPage() {
selectedLevel={selectedLevel}
requestId={requestId}
searchText={searchText}
selectedStatusCodes={selectedStatusCodes}
selectedMethods={selectedMethods}
selectedEndpoints={selectedEndpoints}
limit={limit}
onDateChange={setSelectedDate}
onLevelChange={setSelectedLevel}
onRequestIdChange={setRequestId}
onSearchTextChange={setSearchText}
onStatusCodesChange={setSelectedStatusCodes}
onMethodsChange={setSelectedMethods}
onEndpointsChange={setSelectedEndpoints}
onLimitChange={setLimit}
onClearFilters={handleClearFilters}
/>
@@ -195,22 +122,13 @@ export default function LogsPage() {
{(selectedDate !== 'all' ||
selectedLevel !== 'all' ||
requestId ||
searchText ||
selectedStatusCodes.length > 0 ||
selectedMethods.length > 0 ||
selectedEndpoints.length > 0) && (
searchText) && (
<CardDescription className='text-xs sm:text-sm'>
Showing logs
{selectedDate !== 'all' && ` for ${selectedDate}`}
{selectedLevel !== 'all' && ` with level ${selectedLevel}`}
{requestId && ` with request ID ${requestId}`}
{searchText && ` matching "${searchText}"`}
{selectedStatusCodes.length > 0 &&
` with status ${selectedStatusCodes.join(', ')}`}
{selectedMethods.length > 0 &&
` with method ${selectedMethods.join(', ')}`}
{selectedEndpoints.length > 0 &&
` with endpoint ${selectedEndpoints.join(', ')}`}
</CardDescription>
)}
</CardHeader>
-3
View File
@@ -17,9 +17,6 @@ export interface LogsResponse {
level: string | null;
request_id: string | null;
search: string | null;
status_codes: string | null;
methods: string | null;
endpoints: string | null;
limit: number;
}
+1 -6
View File
@@ -208,12 +208,7 @@ function ProviderBalance({
return <Skeleton className='h-9 w-24' />;
}
if (
error ||
!balanceData?.ok ||
balanceData.balance_data === undefined ||
balanceData.balance_data === null
) {
if (error || !balanceData?.ok || !balanceData.balance_data) {
return null;
}
-4
View File
@@ -41,10 +41,6 @@ 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)
-2
View File
@@ -542,7 +542,6 @@ export function ModelSelector({
top_provider: null,
upstream_provider_id: providerId,
enabled: model.isEnabled,
alias_ids: model.alias_ids || null,
};
setModelDialogState({
@@ -586,7 +585,6 @@ export function ModelSelector({
top_provider: null,
upstream_provider_id: providerId,
enabled: model.isEnabled,
alias_ids: model.alias_ids || null,
};
setModelDialogState({
-1
View File
@@ -31,7 +31,6 @@ export const ModelSchema = z.object({
// API key type indicators
has_own_api_key: z.boolean(),
api_key_type: z.string(), // "individual" or "group"
alias_ids: z.array(z.string()).nullable().optional(),
});
// Schema for a model with additional provider-specific settings
+2 -5
View File
@@ -121,7 +121,6 @@ export interface AdminModelAsModel {
soft_deleted?: boolean;
has_own_api_key: boolean;
api_key_type: string;
alias_ids?: string[] | null;
}
export interface AdminModelGroup {
@@ -208,7 +207,6 @@ export class AdminService {
soft_deleted: !adminModel.enabled,
has_own_api_key: false,
api_key_type: 'group',
alias_ids: adminModel.alias_ids,
};
}
@@ -356,9 +354,8 @@ export class AdminService {
original: data.pricing,
converted: payload.pricing,
});
// Use the same POST endpoint for both create and update (upsert)
const model = await apiClient.post<AdminModel>(
`/admin/api/upstream-providers/${providerId}/models`,
const model = await apiClient.patch<AdminModel>(
`/admin/api/upstream-providers/${providerId}/models/${encodeURIComponent(modelId)}`,
payload
);
return {
+9257
View File
File diff suppressed because it is too large Load Diff
+9 -10
View File
@@ -15,7 +15,6 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-alert-dialog": "^1.1.10",
"@radix-ui/react-avatar": "^1.1.6",
@@ -41,17 +40,17 @@
"@radix-ui/react-toggle": "^1.1.6",
"@radix-ui/react-toggle-group": "^1.1.6",
"@radix-ui/react-tooltip": "^1.2.3",
"@tanstack/react-query": "^5.90.16",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"next": "15.5.9",
"lucide-react": "^0.501.0",
"next": "15.3.1",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
@@ -68,21 +67,21 @@
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.3",
"@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-query-devtools": "^5.91.2",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.74.4",
"@types/node": "^20",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9.25.0",
"eslint-config-next": "15.5.9",
"eslint-config-next": "15.3.1",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react": "^7.37.5",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.1.18",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+544 -544
View File
File diff suppressed because it is too large Load Diff
+1 -7
View File
@@ -22,12 +22,6 @@
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Generated
+1 -1
View File
@@ -1878,7 +1878,7 @@ wheels = [
[[package]]
name = "routstr"
version = "0.2.2"
version = "0.2.1"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },