Compare commits

...

45 Commits

Author SHA1 Message Date
Shroominic
ea655b748b optimize startup time 2025-12-26 12:33:42 +01:00
9qeklajc
2d247ddc8b fmt 2025-12-26 10:29:51 +01:00
9qeklajc
c064452aea Merge branch 'v0.2.1' into force-sats-pricing 2025-12-26 10:27:11 +01:00
9qeklajc
1c6a603042 Merge pull request #271 from Routstr/embedding-with-aliases
Embedding with aliases
2025-12-26 10:26:38 +01:00
9qeklajc
84b0007b05 force sats pricig 2025-12-26 09:55:15 +01:00
Shroominic
525476ccfa fix alias not displaying correctly 2025-12-25 22:34:52 +01:00
Shroominic
b54812cb04 fix: alias_id not displayed correctly by dashboard 2025-12-25 22:28:26 +01:00
Shroominic
ee508cbb3a rm model cleanup 2025-12-25 21:50:09 +01:00
Shroominic
0c61fdee07 ruff format 2025-12-25 21:48:20 +01:00
Shroominic
b9418db31f rm model cleanup task 2025-12-25 21:47:17 +01:00
Shroominic
71e7c2171b add missing migration 2025-12-25 19:36:26 +01:00
Shroominic
a3e8d5fd38 ignore cloudflare headers from logs 2025-12-24 15:09:03 +01:00
9qeklajc
5b8e56f590 Merge branch 'v0.2.1' into embedding-with-aliases
# Conflicts:
#	routstr/upstream/base.py
2025-12-23 23:43:46 +01:00
9qeklajc
a6d0bd1a19 update aliases 2025-12-23 23:43:16 +01:00
shroominic
9594e9fb52 Merge pull request #251 from Routstr/embeddings-integration
Embeddings integration
2025-12-23 20:53:26 +01:00
9qeklajc
b01c7b2e56 Merge branch 'v0.2.1' into embeddings 2025-12-22 21:04:28 +01:00
Shroominic
dd4ed7541f undo 43e9732 2025-12-22 11:31:19 +01:00
Shroominic
cc42534a97 manual alias due to openrouter api bug 2025-12-22 11:30:51 +01:00
shroominic
d203370f01 Merge pull request #265 from Routstr/update-cost-calculation
use cost field if available
2025-12-22 10:20:35 +01:00
Shroominic
e39742c429 rm completion_image pricing with manual overrides 2025-12-22 09:35:23 +01:00
Shroominic
301dd81215 include openrouters upstream_inference_cost + manually add gemini image completion cost 2025-12-21 09:55:25 +01:00
9qeklajc
5416cefd87 use cost field if available 2025-12-20 16:16:23 +01:00
shroominic
8edc3512c1 Merge pull request #258 from Routstr/remove-flaky-wallet-tests
Remove flaky wallet tests
2025-12-19 10:54:26 +01:00
9qeklajc
43e97326e0 fix model naming issue in response 2025-12-12 20:14:43 +01:00
9qeklajc
06770a0702 Merge branch 'v0.2.1' into embeddings-integration 2025-12-12 19:44:45 +01:00
Shroominic
590fb4bc2c ruff fix 2025-12-11 14:13:41 +08:00
Shroominic
5db9abc3ce remove flaky wallet tests 2025-12-11 14:12:34 +08:00
shroominic
c11cc107c8 Merge pull request #248 from Routstr/feature/dynamic-settings
feat(ui): make admin settings dynamic based on backend response
2025-12-11 14:04:49 +08:00
Shroominic
547365894d add simple test 2025-12-11 13:58:57 +08:00
shroominic
19b5f2889a Merge pull request #249 from Routstr/refactor-remove-unused-functions
refactor: rm unused functions
2025-12-11 13:30:59 +08:00
Shroominic
52601f89bd ruff fix 2025-12-11 13:28:28 +08:00
shroominic
72b281b815 Merge branch 'v0.2.1' into refactor-remove-unused-functions 2025-12-11 13:24:59 +08:00
Shroominic
c0176a5274 Merge branch 'v0.2.1' into embeddings-integration 2025-12-11 13:06:20 +08:00
Shroominic
329d22363f Merge branch 'v0.2.1' into embeddings-integration 2025-12-11 12:59:41 +08:00
shroominic
87b1443c23 Merge pull request #253 from Routstr/lightning
create and topup token with lightning
2025-12-11 12:58:36 +08:00
Shroominic
c97c74a2ee cleanup logs 2025-12-11 12:55:01 +08:00
Shroominic
4c7887fa4e update algorithm logs 2025-12-11 11:04:00 +08:00
9qeklajc
9438bc957f clean up 2025-12-03 22:24:43 +01:00
9qeklajc
5d2219880d embedding integration 2025-12-03 22:23:03 +01:00
9qeklajc
195da0c9da embedding integration 2025-12-03 22:22:57 +01:00
9qeklajc
8df0c17bc3 embedding integration 2025-12-03 22:20:31 +01:00
9qeklajc
7bc9ee0653 Merge branch 'gemini-upstream' into embeddings 2025-12-03 22:01:39 +01:00
9qeklajc
355f8601c1 embedding integration 2025-12-03 21:58:31 +01:00
Shroominic
6b4b3924a1 feat(ui): make admin settings dynamic based on backend response
This update changes the admin settings page to dynamically render settings fields based on the JSON response from the backend, rather than hardcoding them. This ensures that new settings added to the backend are automatically available in the UI without code changes.

- Specific handling for known fields like name, description, urls, keys, mints, and relays remains to provide a polished UX.
- All other fields are rendered dynamically based on their type (boolean, number, string, array).
- Sensitive fields (keys, passwords) are automatically masked.
- Specific internal/unused fields are ignored.
2025-12-02 15:23:00 +08:00
Shroominic
a226a78222 rm unused functions 2025-12-02 12:40:54 +08:00
23 changed files with 669 additions and 765 deletions

View File

@@ -0,0 +1,37 @@
"""alias-ids
Revision ID: b9667ffc5701
Revises: lightning_invoices
Create Date: 2025-12-25 19:30:44.673350
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "b9667ffc5701"
down_revision = "lightning_invoices"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic ###
op.add_column(
"models",
sa.Column("canonical_slug", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
)
op.add_column(
"models",
sa.Column("alias_ids", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("models", "alias_ids")
op.drop_column("models", "canonical_slug")
# ### end Alembic commands ###

View File

@@ -206,19 +206,20 @@ def create_model_mappings(
alias: str, model: "Model", provider: "BaseUpstreamProvider"
) -> None:
"""Set alias to model/provider if not set or if new model is preferred."""
existing_model = model_instances.get(alias)
alias_lower = alias.lower()
existing_model = model_instances.get(alias_lower)
if not existing_model:
# No existing mapping, set it
model_instances[alias] = model
provider_map[alias] = provider
model_instances[alias_lower] = model
provider_map[alias_lower] = provider
else:
# Check if candidate should replace existing
existing_provider = provider_map[alias]
existing_provider = provider_map[alias_lower]
if should_prefer_model(
model, provider, existing_model, existing_provider, alias
):
model_instances[alias] = model
provider_map[alias] = provider
model_instances[alias_lower] = model
provider_map[alias_lower] = provider
def process_provider_models(
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
@@ -282,12 +283,8 @@ def create_model_mappings(
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
logger.debug(
"Created model mappings",
extra={
"unique_model_count": len(unique_models),
"total_alias_count": len(model_instances),
"provider_distribution": provider_counts,
},
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",
extra={"provider_distribution": provider_counts},
)
return model_instances, provider_map, unique_models

View File

@@ -1493,20 +1493,8 @@ class ModelCreate(BaseModel):
per_request_limits: dict[str, object] | None = None
top_provider: dict[str, object] | None = None
upstream_provider_id: int | None = None
enabled: bool = True
class ModelUpdate(BaseModel):
id: str
name: str
description: str
created: int
context_length: int
architecture: dict[str, object]
pricing: dict[str, object]
per_request_limits: dict[str, object] | None = None
top_provider: dict[str, object] | None = None
upstream_provider_id: int | None = None
canonical_slug: str | None = None
alias_ids: list[str] | None = None
enabled: bool = True
@@ -2416,44 +2404,80 @@ async def admin_upstream_providers(request: Request) -> str:
"/api/upstream-providers/{provider_id}/models",
dependencies=[Depends(require_admin_api)],
)
async def create_provider_model(
async def upsert_provider_model(
provider_id: int, payload: ModelCreate
) -> dict[str, object]:
print(payload)
logger.info(
f"UPSERT_PROVIDER_MODEL called: provider_id={provider_id}, model_id={payload.id}"
)
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
exists = await session.get(ModelRow, (payload.id, provider_id))
if exists:
raise HTTPException(
status_code=409,
detail="Model with this ID already exists for this provider",
)
# Try to get existing model
existing_row = await session.get(ModelRow, (payload.id, provider_id))
row = ModelRow(
id=payload.id,
name=payload.name,
description=payload.description,
created=int(payload.created),
context_length=int(payload.context_length),
architecture=json.dumps(payload.architecture),
pricing=json.dumps(payload.pricing),
sats_pricing=None,
per_request_limits=(
if existing_row:
# Update existing model
logger.info(f"Updating existing model: {payload.id}")
existing_row.name = payload.name
existing_row.description = payload.description
existing_row.created = int(payload.created)
existing_row.context_length = int(payload.context_length)
existing_row.architecture = json.dumps(payload.architecture)
existing_row.pricing = json.dumps(payload.pricing)
existing_row.sats_pricing = None
existing_row.per_request_limits = (
json.dumps(payload.per_request_limits)
if payload.per_request_limits is not None
else None
),
top_provider=(
)
existing_row.top_provider = (
json.dumps(payload.top_provider) if payload.top_provider else None
),
upstream_provider_id=provider_id,
enabled=payload.enabled,
)
session.add(row)
await session.commit()
await session.refresh(row)
)
existing_row.canonical_slug = payload.canonical_slug
existing_row.alias_ids = (
json.dumps(payload.alias_ids) if payload.alias_ids else None
)
existing_row.enabled = payload.enabled
session.add(existing_row)
await session.commit()
await session.refresh(existing_row)
row = existing_row
else:
# Create new model
logger.info(f"Creating new model: {payload.id}")
row = ModelRow(
id=payload.id,
name=payload.name,
description=payload.description,
created=int(payload.created),
context_length=int(payload.context_length),
architecture=json.dumps(payload.architecture),
pricing=json.dumps(payload.pricing),
sats_pricing=None,
per_request_limits=(
json.dumps(payload.per_request_limits)
if payload.per_request_limits is not None
else None
),
top_provider=(
json.dumps(payload.top_provider) if payload.top_provider else None
),
canonical_slug=payload.canonical_slug,
alias_ids=(
json.dumps(payload.alias_ids) if payload.alias_ids else None
),
upstream_provider_id=provider_id,
enabled=payload.enabled,
)
session.add(row)
await session.commit()
await session.refresh(row)
await refresh_model_maps()
return _row_to_model(
@@ -2461,6 +2485,20 @@ async def create_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)],
@@ -2481,76 +2519,6 @@ 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)],

View File

@@ -68,6 +68,10 @@ 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")

View File

@@ -14,7 +14,6 @@ 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,
)
@@ -49,7 +48,6 @@ 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:
@@ -80,13 +78,17 @@ 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:
@@ -94,11 +96,6 @@ 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:
@@ -125,8 +122,6 @@ 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()
@@ -144,8 +139,6 @@ 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)

View File

@@ -55,7 +55,16 @@ class LoggingMiddleware(BaseHTTPMiddleware):
"headers": {
k: v
for k, v in request.headers.items()
if k.lower() not in ["authorization", "x-cashu", "cookie"]
if k.lower()
not in [
"authorization",
"x-cashu",
"cookie",
"cf-connecting-ip",
"cf-ipcountry",
"x-forwarded-for",
"x-real-ip",
]
},
"body_size": len(request_body) if request_body else 0,
},

View File

@@ -62,7 +62,9 @@ async def query_nostr_relay_for_providers(
if data[0] == "EVENT" and data[1] == sub_id:
event = data[2]
logger.debug(f"Found provider announcement: {event['id']}")
logger.debug(
f"Found provider announcement: {event['id'][:6]}...{event['id'][-6:]}"
)
events.append(event)
elif data[0] == "EOSE" and data[1] == sub_id:
logger.debug("Received EOSE message")

View File

@@ -215,7 +215,7 @@ async def query_nip91_events(
continue
events_out.append(ev_dict)
logger.debug(
f"Found existing NIP-91 event: {ev_dict.get('id', '')}"
f"Found listing event: {ev_dict.get('id', '')[:6]}...{ev_dict.get('id', '')[-6:]}"
)
if drained:
last_event_ts = time.time()

View File

@@ -5,6 +5,7 @@ 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__)
@@ -64,6 +65,56 @@ async def calculate_cost( # todo: can be sync
)
return cost_data
usage_data = response_data["usage"]
usd_cost = 0.0
# Prioritize cost_details.upstream_inference_cost
if "cost_details" in usage_data:
usd_cost = float(
usage_data["cost_details"].get("upstream_inference_cost", 0) or 0
)
# Fallback to cost field if upstream_inference_cost is 0
if usd_cost == 0 and "cost" in usage_data:
try:
usd_cost = float(usage_data.get("cost", 0) or 0)
except Exception:
pass
if usd_cost > 0:
try:
sats_per_usd = 1.0 / sats_usd_price()
cost_in_sats = usd_cost * sats_per_usd
cost_in_msats = math.ceil(cost_in_sats * 1000)
logger.info(
"Using cost from usage data/details",
extra={
"usd_cost": usd_cost,
"cost_in_sats": cost_in_sats,
"cost_in_msats": cost_in_msats,
"model": response_data.get("model", "unknown"),
},
)
return CostData(
base_msats=-1,
input_msats=-1, # Cost field doesn't break down by token type
output_msats=-1,
total_msats=cost_in_msats,
)
except Exception as e:
logger.warning(
"Error calculating cost from usage data",
extra={
"error": str(e),
"usd_cost": usd_cost,
"model": response_data.get("model", "unknown"),
},
)
# Fall through to token-based calculation
MSATS_PER_1K_INPUT_TOKENS: float = (
float(settings.fixed_per_1k_input_tokens) * 1000.0
)
@@ -129,10 +180,19 @@ async def calculate_cost( # todo: can be sync
)
return cost_data
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
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_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(input_msats + output_msats)

View File

@@ -1,16 +1,13 @@
import asyncio
import json
import random
from pathlib import Path
from urllib.request import urlopen
import httpx
from fastapi import APIRouter, Depends
from pydantic.v1 import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..core.db import ModelRow, create_session, get_session
from ..core.db import ModelRow, get_session
from ..core.logging import get_logger
from ..core.settings import settings
from .price import sats_usd_price
@@ -89,53 +86,38 @@ def _has_valid_pricing(model: dict) -> bool:
return True
def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
"""Fetches model information from OpenRouter API."""
base_url = "https://openrouter.ai/api/v1"
try:
with urlopen(f"{base_url}/models") as response:
data = json.loads(response.read().decode("utf-8"))
models_data: list[dict] = []
for model in data.get("data", []):
model_id = model.get("id", "")
if source_filter:
source_prefix = f"{source_filter}/"
if not model_id.startswith(source_prefix):
continue
model = dict(model)
model["id"] = model_id[len(source_prefix) :]
model_id = model["id"]
if "(free)" in model.get("name", ""):
continue
if not _has_valid_pricing(model):
continue
models_data.append(model)
return models_data
except Exception as e:
logger.error(f"Error fetching models from OpenRouter API: {e}")
return []
async def async_fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
"""Asynchronously fetch model information from OpenRouter API."""
base_url = "https://openrouter.ai/api/v1"
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{base_url}/models", timeout=30)
response.raise_for_status()
data = response.json()
models_response, embeddings_response = await asyncio.gather(
client.get(f"{base_url}/models", timeout=30),
client.get(f"{base_url}/embeddings/models", timeout=30),
return_exceptions=True,
)
def process_models_response(
response: httpx.Response | BaseException,
) -> list[dict]:
if not isinstance(response, BaseException):
response.raise_for_status()
data = response.json()
return [
model
for model in data.get("data", [])
if ":free" not in model.get("id", "").lower()
]
return []
models_data: list[dict] = []
for model in data.get("data", []):
models_data.extend(process_models_response(models_response))
models_data.extend(process_models_response(embeddings_response))
# Apply source filter and exclusions
filtered_models = []
for model in models_data:
model_id = model.get("id", "")
if source_filter:
@@ -153,9 +135,9 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
if not _has_valid_pricing(model):
continue
models_data.append(model)
filtered_models.append(model)
return models_data
return filtered_models
except Exception as e:
logger.error(f"Error (async) fetching models from OpenRouter API: {e}")
return []
@@ -169,69 +151,6 @@ def is_openrouter_upstream() -> bool:
return base.lower() == "https://openrouter.ai/api/v1"
def load_models() -> list[Model]:
"""Load model definitions from a JSON file or auto-generate from OpenRouter API.
The file path can be specified via the ``MODELS_PATH`` environment variable.
If a user-provided models.json exists, it will be used. Otherwise, models are
automatically fetched from OpenRouter API in memory. If the example file exists
and no user file is provided, it will be used as a fallback.
"""
try:
models_path = Path(settings.models_path)
except Exception:
models_path = Path("models.json")
# Check if user has actively provided a models.json file
if models_path.exists():
logger.info(f"Loading models from user-provided file: {models_path}")
try:
with models_path.open("r") as f:
data = json.load(f)
return [Model(**model) for model in data.get("models", [])] # type: ignore
except Exception as e:
logger.error(f"Error loading models from {models_path}: {e}")
# Fall through to auto-generation
# Only auto-generate from OpenRouter when upstream is OpenRouter
if not is_openrouter_upstream():
logger.info(
"Skipping auto-generation from OpenRouter because upstream_base_url is not https://openrouter.ai/api/v1"
)
return []
logger.info("Auto-generating models from OpenRouter API")
try:
source_filter = settings.source or None
except Exception:
source_filter = None
source_filter = source_filter if source_filter and source_filter.strip() else None
models_data = fetch_openrouter_models(source_filter=source_filter)
if not models_data:
logger.error("Failed to fetch models from OpenRouter API")
return []
logger.info(f"Successfully fetched {len(models_data)} models from OpenRouter API")
valid_models = []
for model_data in models_data:
try:
model = Model(**model_data) # type: ignore
valid_models.append(model)
except Exception as e:
model_id = model_data.get("id", "unknown")
logger.warning(f"Skipping model {model_id} - validation failed: {e}")
if len(valid_models) != len(models_data):
logger.warning(
f"Filtered out {len(models_data) - len(valid_models)} models with incomplete data"
)
return valid_models
def _row_to_model(
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
) -> Model:
@@ -265,6 +184,7 @@ 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:
@@ -460,60 +380,9 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
return model
async def ensure_models_bootstrapped() -> None:
async with create_session() as s:
existing = (await s.exec(select(ModelRow.id).limit(1))).all() # type: ignore
if existing:
return
try:
models_path = Path(settings.models_path)
except Exception:
models_path = Path("models.json")
models_to_insert: list[dict] = []
if models_path.exists():
try:
with models_path.open("r") as f:
data = json.load(f)
models_to_insert = data.get("models", [])
logger.info(
f"Bootstrapping {len(models_to_insert)} models from {models_path}"
)
except Exception as e:
logger.error(f"Error loading models from {models_path}: {e}")
if not models_to_insert and is_openrouter_upstream():
logger.info("Bootstrapping models from OpenRouter API")
source_filter = None
try:
src = settings.source or None
source_filter = src if src and src.strip() else None
except Exception:
pass
models_to_insert = fetch_openrouter_models(source_filter=source_filter)
elif not models_to_insert:
logger.info(
"No models.json found and upstream is not OpenRouter; skipping bootstrap"
)
for m in models_to_insert:
try:
model = Model(**m) # type: ignore
except Exception:
# Some OpenRouter models include extra fields; only map required ones
continue
exists = await s.get(ModelRow, model.id)
if exists:
continue
payload = _model_to_row_payload(model)
s.add(ModelRow(**payload)) # type: ignore
await s.commit()
async def _update_sats_pricing_once() -> None:
"""Update sats pricing once for all provider models (in-memory only)."""
from ..proxy import get_upstreams
from ..proxy import get_upstreams, refresh_model_maps
upstreams = get_upstreams()
sats_to_usd = sats_usd_price()
@@ -530,6 +399,7 @@ async def _update_sats_pricing_once() -> None:
if updated_count > 0:
logger.info("Updated sats pricing", extra={"models_updated": updated_count})
await refresh_model_maps()
async def update_sats_pricing() -> None:
@@ -540,7 +410,13 @@ async def update_sats_pricing() -> None:
except Exception:
pass
await _update_sats_pricing_once()
try:
await _update_sats_pricing_once()
except Exception as e:
logger.warning(
"Initial sats pricing update failed (will retry in loop)",
extra={"error": str(e)},
)
while True:
try:
@@ -564,184 +440,6 @@ async def update_sats_pricing() -> None:
logger.error(f"Error updating sats pricing: {e}")
async def cleanup_enabled_models_periodically() -> None:
"""Background task to clean up enabled models that match upstream pricing.
When model is enabled (enabled=True), remove it from DB if it matches upstream pricing.
Keep it in DB only if pricing differs from upstream or if it's disabled.
"""
interval = getattr(
settings, "models_cleanup_interval_seconds", 300
) # 5 minutes default
if not interval or interval <= 0:
return
while True:
try:
await _cleanup_enabled_models_once()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(
"Error during enabled models cleanup",
extra={"error": str(e), "error_type": type(e).__name__},
)
try:
jitter = max(0.0, float(interval) * 0.1)
await asyncio.sleep(interval + random.uniform(0, jitter))
except asyncio.CancelledError:
break
async def _cleanup_enabled_models_once() -> None:
"""Clean up enabled models that match upstream pricing."""
from ..proxy import get_upstreams
async with create_session() as session:
# Get all enabled models from DB
result = await session.exec(
select(ModelRow).where(
ModelRow.enabled, # Only enabled models
)
)
db_models = result.all()
if not db_models:
return
upstreams = get_upstreams()
models_to_remove = []
for db_model in db_models:
# Find corresponding upstream model
upstream_model = None
for upstream in upstreams:
upstream_model = upstream.get_cached_model_by_id(db_model.id)
if upstream_model:
break
if not upstream_model:
continue
# Compare pricing to see if they match
db_pricing = json.loads(db_model.pricing)
upstream_pricing = upstream_model.pricing.dict()
# Check if pricing matches (with small tolerance for float comparison)
pricing_matches = _pricing_matches(db_pricing, upstream_pricing)
if pricing_matches:
models_to_remove.append(db_model)
logger.info(
f"Removing enabled model {db_model.id} - matches upstream pricing",
extra={"model_id": db_model.id},
)
# Remove models that match upstream pricing
for model in models_to_remove:
await session.delete(model)
if models_to_remove:
await session.commit()
logger.info(
f"Cleaned up {len(models_to_remove)} enabled models that match upstream pricing"
)
def _pricing_matches(
db_pricing: dict, upstream_pricing: dict, tolerance: float = 0.0
) -> bool:
"""Check if pricing dictionaries match within tolerance."""
keys_to_compare = [
"prompt",
"completion",
"request",
"image",
"web_search",
"internal_reasoning",
]
for key in keys_to_compare:
db_val = int(float(db_pricing.get(key, 0.0)) * 1000000)
upstream_val = int(float(upstream_pricing.get(key, 0.0)) * 1000000)
if abs(db_val - upstream_val) > tolerance:
return False
return True
async def refresh_models_periodically() -> None:
"""Background task: periodically fetch OpenRouter models and insert new ones.
- Respects optional SOURCE filter from settings
- Does not overwrite existing rows
- Sleeps according to settings.models_refresh_interval_seconds; disabled when 0
"""
interval = getattr(settings, "models_refresh_interval_seconds", 0)
if not interval or interval <= 0:
return
# Only refresh from OpenRouter when upstream is OpenRouter
if not is_openrouter_upstream():
logger.info("Skipping models refresh: upstream_base_url is not OpenRouter")
return
while True:
try:
try:
if not settings.enable_models_refresh:
return
except Exception:
pass
try:
src = settings.source or None
source_filter = src if src and src.strip() else None
except Exception:
source_filter = None
models = fetch_openrouter_models(source_filter=source_filter)
if not models:
await asyncio.sleep(interval)
continue
async with create_session() as s:
result = await s.exec(select(ModelRow.id)) # type: ignore
existing_ids = {
row[0] if isinstance(row, tuple) else row for row in result.all()
}
inserted = 0
for m in models:
try:
model = Model(**m) # type: ignore
except Exception:
continue
if model.id in existing_ids:
continue
payload = _model_to_row_payload(model)
try:
s.add(ModelRow(**payload)) # type: ignore
except Exception:
pass
inserted += 1
if inserted:
await s.commit()
logger.info(f"Inserted {inserted} new models from OpenRouter")
except asyncio.CancelledError:
break
except Exception as e:
logger.error(
"Error during models refresh",
extra={"error": str(e), "error_type": type(e).__name__},
)
try:
jitter = max(0.0, float(interval) * 0.1)
await asyncio.sleep(interval + random.uniform(0, jitter))
except asyncio.CancelledError:
break
@models_router.get("/v1/models")
@models_router.get("/models", include_in_schema=False)
async def models(session: AsyncSession = Depends(get_session)) -> dict:

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)
return _model_instances.get(model_id.lower())
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
"""Get UpstreamProvider for model ID from global cache."""
return _provider_map.get(model_id)
return _provider_map.get(model_id.lower())
def get_unique_models() -> list[Model]:
@@ -80,31 +80,27 @@ def get_unique_models() -> list[Model]:
async def refresh_model_maps() -> None:
"""Refresh global model and provider maps using the cost-based algorithm."""
from sqlalchemy.orm import selectinload
global _model_instances, _provider_map, _unique_models
# Gather database overrides and disabled models
async with create_session() as session:
result = await session.exec(select(ModelRow).where(ModelRow.enabled))
override_rows = result.all()
provider_result = await session.exec(select(UpstreamProviderRow))
providers_by_id = {p.id: p for p in provider_result.all()}
overrides_by_id: dict[str, tuple[ModelRow, float]] = {
row.id: (
row,
providers_by_id[row.upstream_provider_id].provider_fee
if row.upstream_provider_id in providers_by_id
else 1.01,
)
for row in override_rows
if row.upstream_provider_id is not None
}
disabled_result = await session.exec(
select(ModelRow.id).where(ModelRow.enabled == False) # noqa: E712
# Fetch all providers with their models in a single logical operation
query = select(UpstreamProviderRow).options(
selectinload(UpstreamProviderRow.models) # type: ignore
)
disabled_model_ids = {row for row in disabled_result.all()}
result = await session.exec(query)
provider_rows = result.all()
overrides_by_id: dict[str, tuple[ModelRow, float]] = {}
disabled_model_ids: set[str] = set()
for provider in provider_rows:
for model in provider.models:
if model.enabled:
overrides_by_id[model.id] = (model, provider.provider_fee)
else:
disabled_model_ids.add(model.id)
_model_instances, _provider_map, _unique_models = create_model_mappings(
upstreams=_upstreams,

View File

@@ -734,51 +734,53 @@ class BaseUpstreamProvider:
await client.aclose()
return mapped_error
if path.endswith("chat/completions"):
client_wants_streaming = False
if request_body:
try:
request_data = json.loads(request_body)
client_wants_streaming = request_data.get("stream", False)
logger.debug(
"Chat completion request analysis",
extra={
"client_wants_streaming": client_wants_streaming,
"model": request_data.get("model", "unknown"),
"key_hash": key.hashed_key[:8] + "...",
},
)
except json.JSONDecodeError:
logger.warning(
"Failed to parse request body JSON for streaming detection"
)
if path.endswith("chat/completions") or path.endswith("embeddings"):
if path.endswith("chat/completions"):
client_wants_streaming = False
if request_body:
try:
request_data = json.loads(request_body)
client_wants_streaming = request_data.get("stream", False)
logger.debug(
"Chat completion request analysis",
extra={
"client_wants_streaming": client_wants_streaming,
"model": request_data.get("model", "unknown"),
"key_hash": key.hashed_key[:8] + "...",
},
)
except json.JSONDecodeError:
logger.warning(
"Failed to parse request body JSON for streaming detection"
)
content_type = response.headers.get("content-type", "")
upstream_is_streaming = "text/event-stream" in content_type
is_streaming = client_wants_streaming and upstream_is_streaming
content_type = response.headers.get("content-type", "")
upstream_is_streaming = "text/event-stream" in content_type
is_streaming = client_wants_streaming and upstream_is_streaming
logger.debug(
"Response type analysis",
extra={
"is_streaming": is_streaming,
"client_wants_streaming": client_wants_streaming,
"upstream_is_streaming": upstream_is_streaming,
"content_type": content_type,
"key_hash": key.hashed_key[:8] + "...",
},
)
if is_streaming and response.status_code == 200:
result = await self.handle_streaming_chat_completion(
response, key, max_cost_for_model
logger.debug(
"Response type analysis",
extra={
"is_streaming": is_streaming,
"client_wants_streaming": client_wants_streaming,
"upstream_is_streaming": upstream_is_streaming,
"content_type": content_type,
"key_hash": key.hashed_key[:8] + "...",
},
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
result.background = background_tasks
return result
elif response.status_code == 200:
if is_streaming and response.status_code == 200:
result = await self.handle_streaming_chat_completion(
response, key, max_cost_for_model
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
result.background = background_tasks
return result
# Handle both non-streaming chat completions and embeddings
if response.status_code == 200:
try:
return await self.handle_non_streaming_chat_completion(
response, key, session, max_cost_for_model
@@ -1519,9 +1521,9 @@ class BaseUpstreamProvider:
error_response.headers["X-Cashu"] = refund_token
return error_response
if path.endswith("chat/completions"):
if path.endswith("chat/completions") or path.endswith("embeddings"):
logger.debug(
"Processing chat completion response",
"Processing completion/embeddings response",
extra={"path": path, "amount": amount, "unit": unit},
)
@@ -1770,15 +1772,32 @@ class BaseUpstreamProvider:
async def _fetch_openrouter_models(self) -> list[dict]:
"""Fetch models from OpenRouter API."""
url = "https://openrouter.ai/api/v1/models"
embeddings_url = "https://openrouter.ai/api/v1/embeddings/models"
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url)
response.raise_for_status()
models = response.json()
return [
model
for model in models.get("data", [])
if ":free" not in model.get("id", "").lower()
]
models_response, embeddings_response = await asyncio.gather(
client.get(url), client.get(embeddings_url), return_exceptions=True
)
all_models = []
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 []
all_models.extend(process_models_response(models_response))
all_models.extend(process_models_response(embeddings_response))
return all_models
async def _fetch_provider_models(self) -> dict:
"""Fetch models from provider's API."""

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import os
import re
from typing import TYPE_CHECKING
@@ -7,6 +8,7 @@ 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
@@ -145,6 +147,17 @@ async def refresh_upstreams_models_periodically(
f"Error refreshing models for {upstream.base_url}",
extra={"error": str(e), "error_type": type(e).__name__},
)
try:
from ..payment.models import _update_sats_pricing_once
await _update_sats_pricing_once()
except Exception as e:
logger.warning(f"Failed to update pricing after model refresh: {e}")
from ..proxy import refresh_model_maps
await refresh_model_maps()
except asyncio.CancelledError:
break
except Exception as e:
@@ -166,8 +179,6 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
Seeds database with providers from settings if empty, then loads and instantiates
provider instances from database records, and refreshes their models cache.
"""
from sqlmodel import select
from ..core.settings import settings
async with create_session() as session:
@@ -183,16 +194,16 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
result = await session.exec(select(UpstreamProviderRow))
existing_providers = result.all()
upstreams: list[BaseUpstreamProvider] = []
for provider_row in existing_providers:
async def _init_single_provider(
provider_row: UpstreamProviderRow,
) -> BaseUpstreamProvider | None:
if not provider_row.enabled:
logger.debug(f"Skipping disabled provider: {provider_row.base_url}")
continue
return None
provider = _instantiate_provider(provider_row)
if provider:
await provider.refresh_models_cache()
upstreams.append(provider)
logger.debug(
f"Initialized {provider_row.provider_type} provider",
extra={
@@ -200,6 +211,12 @@ 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

View File

@@ -50,7 +50,13 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
async def fetch_models(self) -> list[Model]:
"""Fetch all OpenRouter models."""
models_data = await async_fetch_openrouter_models()
return [Model(**model) for model in models_data] # type: ignore
models = [Model(**model) for model in models_data] # type: ignore
# manual alias for openai/text-embedding-ada-002 due to openrouter api bug
for model in models:
if model.id == "openai/text-embedding-ada-002":
model.alias_ids = ["text-embedding-ada-002-v2"]
break
return models
async def get_balance(self) -> float | None:
"""Get the current account balance from OpenRouter.

View File

@@ -0,0 +1,97 @@
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
@pytest.mark.integration
@pytest.mark.asyncio
async def test_proxy_embeddings_endpoint(authenticated_client: AsyncClient) -> None:
"""Test the embeddings endpoint proxy functionality"""
test_payload = {
"model": "text-embedding-ada-002",
"input": "The quick brown fox",
}
mock_response_data = {
"object": "list",
"data": [
{"object": "embedding", "embedding": [0.0023, -0.0012, 0.0045], "index": 0}
],
"model": "text-embedding-ada-002",
"usage": {"prompt_tokens": 5, "total_tokens": 5},
}
with patch("httpx.AsyncClient.send") as mock_send:
# Create a proper async generator for iter_bytes
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
yield json.dumps(mock_response_data).encode()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.text = json.dumps(mock_response_data)
# Use MagicMock for synchronous .json() method
mock_response.json = MagicMock(return_value=mock_response_data)
mock_response.iter_bytes = mock_iter_bytes
mock_response.aiter_bytes = mock_iter_bytes
mock_send.return_value = mock_response
# Make POST request to embeddings endpoint
response = await authenticated_client.post("/v1/embeddings", json=test_payload)
assert response.status_code == 200
response_data = response.json()
assert response_data["object"] == "list"
assert len(response_data["data"]) == 1
assert response_data["data"][0]["object"] == "embedding"
# Verify request was forwarded
mock_send.assert_called_once()
forwarded_request = mock_send.call_args[0][0]
# Verify the path ends with embeddings
# Note: forwarded path might be full URL
assert str(forwarded_request.url).endswith("embeddings")
@pytest.mark.integration
@pytest.mark.asyncio
async def test_model_case_insensitivity(authenticated_client: AsyncClient) -> None:
"""Test that model lookups are case insensitive"""
# We'll use a mixed-case model ID that should match the lowercase one in the system
# We assume 'gpt-3.5-turbo' is available in the mock env/database
test_payload = {
"model": "GPT-3.5-TURBO",
"messages": [{"role": "user", "content": "Hello"}],
}
with patch("httpx.AsyncClient.send") as mock_send:
mock_response_data = {
"id": "chatcmpl-123",
"object": "chat.completion",
"choices": [{"message": {"content": "Hi"}}],
"usage": {"total_tokens": 10},
}
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
yield json.dumps(mock_response_data).encode()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.text = json.dumps(mock_response_data)
mock_response.json = MagicMock(return_value=mock_response_data)
mock_response.iter_bytes = mock_iter_bytes
mock_response.aiter_bytes = mock_iter_bytes
mock_send.return_value = mock_response
response = await authenticated_client.post(
"/v1/chat/completions", json=test_payload
)
assert response.status_code == 200

View File

@@ -3,7 +3,6 @@ Integration tests for wallet authentication system including API key generation
Tests POST /v1/wallet/topup endpoint and authorization header validation.
"""
import hashlib
from datetime import datetime, timedelta
from typing import Any
@@ -15,7 +14,6 @@ from routstr.core.db import ApiKey
from .utils import (
CashuTokenGenerator,
ConcurrencyTester,
ResponseValidator,
)
@@ -389,69 +387,6 @@ async def test_api_key_with_expiry_time(
# The expiry time and refund address functionality is tested elsewhere
@pytest.mark.integration
@pytest.mark.asyncio
async def test_concurrent_token_submissions(
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
) -> None:
"""Test concurrent submissions of different tokens"""
# Generate multiple unique tokens with known amounts
num_tokens = 10
tokens = []
expected_balances = {}
for i in range(num_tokens):
amount = 100 + i * 10
token = await testmint_wallet.mint_tokens(amount)
tokens.append(token)
# Store expected balance by token hash
hashed_key = hashlib.sha256(token.encode()).hexdigest()
expected_balances[hashed_key] = amount * 1000 # msats
# Create concurrent requests
requests = [
{
"method": "GET",
"url": "/v1/wallet/info",
"headers": {"Authorization": f"Bearer {token}"},
}
for token in tokens
]
# Execute concurrently
tester = ConcurrencyTester()
responses = await tester.run_concurrent_requests(
integration_client, requests, max_concurrent=5
)
# All should succeed
assert len(responses) == num_tokens
api_keys = set()
for response in responses:
assert response.status_code == 200
data = response.json()
api_key = data["api_key"]
api_keys.add(api_key)
# Verify balance matches the expected amount
hashed_key = api_key[3:] # Remove "sk-" prefix
assert data["balance"] == expected_balances[hashed_key]
# Should have created unique API keys
assert len(api_keys) == num_tokens
# Verify all keys exist in database
for api_key in api_keys:
hashed_key = api_key[3:] # Remove "sk-" prefix
result = await integration_session.execute(
select(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(
@@ -504,48 +439,6 @@ async def test_x_cashu_header_support(
assert response.status_code == 200
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.slow
async def test_api_key_consistency_under_load(
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
) -> None:
"""Test API key generation consistency under concurrent load"""
# Generate a single token
token = await testmint_wallet.mint_tokens(1000)
# First request to create the API key
integration_client.headers["Authorization"] = f"Bearer {token}"
initial_response = await integration_client.get("/v1/wallet/info")
assert initial_response.status_code == 200
expected_api_key = initial_response.json()["api_key"]
expected_balance = initial_response.json()["balance"]
# Try to use the same token concurrently multiple times
# All should return the same API key since it's already created
requests = [
{
"method": "GET",
"url": "/v1/wallet/info",
"headers": {"Authorization": f"Bearer {token}"},
}
for _ in range(20) # 20 concurrent attempts
]
tester = ConcurrencyTester()
responses = await tester.run_concurrent_requests(
integration_client, requests, max_concurrent=10
)
# All should succeed and return the same API key
for response in responses:
assert response.status_code == 200
data = response.json()
assert data["api_key"] == expected_api_key
assert data["balance"] == expected_balance
@pytest.mark.integration
@pytest.mark.asyncio
async def test_database_timestamp_accuracy(

View File

@@ -13,7 +13,7 @@ from sqlmodel import select, update
from routstr.core.db import ApiKey
from .utils import ConcurrencyTester, ResponseValidator
from .utils import ResponseValidator
@pytest.mark.integration
@@ -204,45 +204,6 @@ 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(

View File

@@ -15,7 +15,6 @@ from routstr.core.db import ApiKey
from .utils import (
CashuTokenGenerator,
ConcurrencyTester,
ResponseValidator,
)
@@ -284,60 +283,6 @@ async def test_transaction_history_tracking( # type: ignore[no-untyped-def]
assert response.status_code == 400
@pytest.mark.integration
@pytest.mark.asyncio
async def test_concurrent_topups_same_api_key( # type: ignore[no-untyped-def]
integration_client: AsyncClient,
authenticated_client: AsyncClient,
testmint_wallet: Any,
) -> None:
"""Test concurrent top-ups to the same API key"""
# Get API key
response = await authenticated_client.get("/v1/wallet/")
api_key = response.json()["api_key"]
initial_balance = response.json()["balance"]
# Generate multiple unique tokens
num_tokens = 10
tokens = []
total_amount = 0
for i in range(num_tokens):
amount = 100 + i * 10 # Different amounts
token = await testmint_wallet.mint_tokens(amount)
tokens.append(token)
total_amount += amount
# Create concurrent top-up requests
requests = [
{
"method": "POST",
"url": "/v1/wallet/topup",
"params": {"cashu_token": token},
"headers": {"Authorization": f"Bearer {api_key}"},
}
for token in tokens
]
# Execute concurrently
tester = ConcurrencyTester()
responses = await tester.run_concurrent_requests(
integration_client, requests, max_concurrent=5
)
# All should succeed
for response in responses:
assert response.status_code == 200
assert "msats" in response.json()
# Verify final balance is correct
final_response = await authenticated_client.get("/v1/wallet/")
final_balance = final_response.json()["balance"]
expected_balance = initial_balance + (total_amount * 1000)
assert final_balance == expected_balance
@pytest.mark.integration
@pytest.mark.asyncio
async def test_topup_during_active_proxy_request( # type: ignore[no-untyped-def]

View File

@@ -41,6 +41,10 @@ export function ModelSearchFilter({
model.name.toLowerCase().includes(query) ||
model.full_name.toLowerCase().includes(query) ||
model.provider.toLowerCase().includes(query) ||
(model.alias_ids &&
model.alias_ids.some((alias) =>
alias.toLowerCase().includes(query)
)) ||
(model.description &&
model.description.toLowerCase().includes(query)) ||
model.modelType.toLowerCase().includes(query)

View File

@@ -542,6 +542,7 @@ export function ModelSelector({
top_provider: null,
upstream_provider_id: providerId,
enabled: model.isEnabled,
alias_ids: model.alias_ids || null,
};
setModelDialogState({
@@ -585,6 +586,7 @@ export function ModelSelector({
top_provider: null,
upstream_provider_id: providerId,
enabled: model.isEnabled,
alias_ids: model.alias_ids || null,
};
setModelDialogState({

View File

@@ -18,6 +18,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle, Save, RefreshCw, Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import { Switch } from '@/components/ui/switch';
interface SettingsData {
name?: string;
@@ -28,9 +29,32 @@ interface SettingsData {
http_url?: string;
onion_url?: string;
cashu_mints?: string[];
relays?: string[];
[key: string]: unknown;
}
const HANDLED_KEYS = [
'name',
'description',
'http_url',
'onion_url',
'npub',
'nsec',
'cashu_mints',
'relays',
'admin_password',
'id',
'updated_at',
];
const IGNORED_KEYS = [
'upstream_base_url',
'upstream_api_key',
'upstream_provider_fee',
'exchange_fee',
'models_path',
];
interface PasswordData {
current_password: string;
new_password: string;
@@ -44,6 +68,7 @@ export function AdminSettings() {
const [error, setError] = useState<string>('');
const [showSecrets, setShowSecrets] = useState(false);
const [newMint, setNewMint] = useState('');
const [newRelay, setNewRelay] = useState('');
const [passwordData, setPasswordData] = useState<PasswordData>({
current_password: '',
new_password: '',
@@ -129,7 +154,7 @@ export function AdminSettings() {
}
};
const handleInputChange = (field: string, value: string | boolean) => {
const handleInputChange = (field: string, value: unknown) => {
setSettings((prev) => ({
...prev,
[field]: value,
@@ -153,6 +178,23 @@ export function AdminSettings() {
}));
};
const addRelay = () => {
if (newRelay.trim()) {
setSettings((prev) => ({
...prev,
relays: [...(prev.relays || []), newRelay.trim()],
}));
setNewRelay('');
}
};
const removeRelay = (index: number) => {
setSettings((prev) => ({
...prev,
relays: prev.relays?.filter((_, i) => i !== index) || [],
}));
};
const renderSecretField = (
field: string,
label: string,
@@ -162,7 +204,7 @@ export function AdminSettings() {
const displayValue = showSecrets ? value : value ? '••••••••' : '';
return (
<div className='space-y-2'>
<div key={field} className='space-y-2'>
<Label htmlFor={field}>{label}</Label>
<div className='flex gap-2'>
<Input
@@ -190,6 +232,89 @@ export function AdminSettings() {
);
};
const renderDynamicField = (key: string, value: unknown) => {
const label = key
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
if (typeof value === 'boolean') {
return (
<div
key={key}
className='flex items-center justify-between space-y-0 py-4'
>
<Label htmlFor={key}>{label}</Label>
<Switch
id={key}
checked={value}
onCheckedChange={(checked) => handleInputChange(key, checked)}
/>
</div>
);
}
if (typeof value === 'number') {
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
type='number'
value={value}
onChange={(e) => {
const val = e.target.value === '' ? 0 : Number(e.target.value);
handleInputChange(key, val);
}}
/>
</div>
);
}
if (Array.isArray(value)) {
const strValue = value.join(', ');
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Textarea
id={key}
value={strValue}
onChange={(e) => {
const arr = e.target.value
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
handleInputChange(key, arr);
}}
placeholder='Comma separated values'
rows={2}
/>
</div>
);
}
const isSecret =
key.includes('key') ||
key.includes('password') ||
key.includes('secret') ||
key.includes('nsec');
if (isSecret) {
return renderSecretField(key, label);
}
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
value={(value as string) || ''}
onChange={(e) => handleInputChange(key, e.target.value)}
/>
</div>
);
};
if (loading) {
return (
<div className='flex items-center justify-center py-8'>
@@ -337,6 +462,73 @@ export function AdminSettings() {
</CardContent>
</Card>
{/* Relays */}
<Card>
<CardHeader>
<CardTitle>Nostr Relays</CardTitle>
<CardDescription>
Configure Nostr relays for communication
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='newRelay'>Add Relay URL</Label>
<div className='flex gap-2'>
<Input
id='newRelay'
value={newRelay}
onChange={(e) => setNewRelay(e.target.value)}
placeholder='wss://relay.example.com'
/>
<Button onClick={addRelay} disabled={!newRelay.trim()}>
Add Relay
</Button>
</div>
</div>
{settings.relays && settings.relays.length > 0 && (
<div className='space-y-2'>
<Label>Configured Relays</Label>
<div className='space-y-2'>
{settings.relays.map((relay, index) => (
<div
key={index}
className='flex items-center gap-2 rounded border p-2'
>
<span className='flex-1 text-sm'>{relay}</span>
<Button
variant='outline'
size='sm'
onClick={() => removeRelay(index)}
>
Remove
</Button>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Other Settings */}
<Card>
<CardHeader>
<CardTitle>Advanced Settings</CardTitle>
<CardDescription>
Configure additional node settings
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{Object.keys(settings)
.filter(
(key) =>
!HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key)
)
.map((key) => renderDynamicField(key, settings[key]))}
</CardContent>
</Card>
<Card className='mt-6'>
<CardFooter className='flex justify-between'>
<Button

View File

@@ -31,6 +31,7 @@ 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

View File

@@ -121,6 +121,7 @@ export interface AdminModelAsModel {
soft_deleted?: boolean;
has_own_api_key: boolean;
api_key_type: string;
alias_ids?: string[] | null;
}
export interface AdminModelGroup {
@@ -207,6 +208,7 @@ export class AdminService {
soft_deleted: !adminModel.enabled,
has_own_api_key: false,
api_key_type: 'group',
alias_ids: adminModel.alias_ids,
};
}
@@ -354,8 +356,9 @@ export class AdminService {
original: data.pricing,
converted: payload.pricing,
});
const model = await apiClient.patch<AdminModel>(
`/admin/api/upstream-providers/${providerId}/models/${encodeURIComponent(modelId)}`,
// Use the same POST endpoint for both create and update (upsert)
const model = await apiClient.post<AdminModel>(
`/admin/api/upstream-providers/${providerId}/models`,
payload
);
return {