diff --git a/.gitignore b/.gitignore index 38976d5a..69e86a9d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ wallet.sqlite3 *models.json .cashu .dockerignore +relay-data compose.override.yml diff --git a/compose.yml b/compose.yml index 0b3ba57e..8b0f67f2 100644 --- a/compose.yml +++ b/compose.yml @@ -12,6 +12,8 @@ services: - TOR_PROXY_URL=socks5://tor:9050 ports: - 8000:8000 + extra_hosts: # Needed to access locally running models + - "host.docker.internal:host-gateway" tor: image: ghcr.io/hundehausen/tor-hidden-service:latest diff --git a/router/core/main.py b/router/core/main.py index f4423936..075cf135 100644 --- a/router/core/main.py +++ b/router/core/main.py @@ -77,7 +77,6 @@ app.add_middleware( @app.get("/", include_in_schema=False) @app.get("/v1/info") async def info() -> dict: - logger.info("Info endpoint accessed") return { "name": app.title, "description": app.description, diff --git a/router/payment/helpers.py b/router/payment/helpers.py index 61b8120a..002b4093 100644 --- a/router/payment/helpers.py +++ b/router/payment/helpers.py @@ -1,5 +1,6 @@ import json import os +from typing import Optional from fastapi import HTTPException, Response @@ -148,7 +149,9 @@ def get_max_cost_for_model(model: str) -> int: return COST_PER_REQUEST -def create_error_response(error_type: str, message: str, status_code: int) -> Response: +def create_error_response( + error_type: str, message: str, status_code: int, token: Optional[str] = None +) -> Response: """Create a standardized error response.""" logger.info( "Creating error response", @@ -159,6 +162,9 @@ def create_error_response(error_type: str, message: str, status_code: int) -> Re }, ) + response_headers = {} + if token: + response_headers["X-Cashu"] = token return Response( content=json.dumps( { @@ -171,6 +177,7 @@ def create_error_response(error_type: str, message: str, status_code: int) -> Re ), status_code=status_code, media_type="application/json", + headers=dict(response_headers), ) diff --git a/router/payment/models.py b/router/payment/models.py index ef891bf2..96f59bfb 100644 --- a/router/payment/models.py +++ b/router/payment/models.py @@ -2,6 +2,7 @@ import asyncio import json import os from pathlib import Path +from urllib.request import urlopen from fastapi import APIRouter from pydantic.v1 import BaseModel @@ -51,31 +52,76 @@ class Model(BaseModel): MODELS: list[Model] = [] +def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]: + """Fetches model information from OpenRouter API.""" + base_url = os.getenv("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", "") + or model_id == "openrouter/auto" + or model_id == "google/gemini-2.5-pro-exp-03-25" + ): + continue + + models_data.append(model) + + return models_data + except Exception as e: + print(f"Error fetching models from OpenRouter API: {e}") + return [] + + def load_models() -> list[Model]: - """Load model definitions from a JSON file. + """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 ``models.json`` is not found, the bundled ``models.example.json`` is used - as a fallback. If neither file exists or an error occurs while loading, an - empty list is returned. + 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. """ models_path = Path(os.environ.get("MODELS_PATH", "models.json")) - if not models_path.exists(): - example = Path(__file__).resolve().parent.parent / "models.example.json" - if example.exists(): - models_path = example - else: - return [] - try: - with models_path.open("r") as f: - data = json.load(f) - except Exception as e: # pragma: no cover - log and continue - print(f"Error loading models from {models_path}: {e}") + # Check if user has actively provided a models.json file + if models_path.exists(): + print(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", [])] + except Exception as e: + print(f"Error loading models from {models_path}: {e}") + # Fall through to auto-generation + + # Auto-generate models from OpenRouter API + print("Auto-generating models from OpenRouter API") + source_filter = os.getenv("SOURCE") + 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: + print("Failed to fetch models from OpenRouter API") return [] - return [Model(**model) for model in data.get("models", [])] + print(f"Successfully fetched {len(models_data)} models from OpenRouter API") + return [Model(**model) for model in models_data] MODELS = load_models() @@ -91,7 +137,9 @@ async def update_sats_pricing() -> None: ) mspp = model.sats_pricing.prompt mspc = model.sats_pricing.completion - if model.top_provider: + if (tp := model.top_provider) and ( + tp.context_length or tp.max_completion_tokens + ): if (cl := model.top_provider.context_length) and ( mct := model.top_provider.max_completion_tokens ): diff --git a/router/payment/x_cashu.py b/router/payment/x_cashu.py index 591ae837..afbc0d7e 100644 --- a/router/payment/x_cashu.py +++ b/router/payment/x_cashu.py @@ -8,12 +8,7 @@ from fastapi.responses import Response, StreamingResponse from ..core import get_logger from ..wallet import CurrencyUnit, recieve_token, send_token -from .cost_caculation import ( - CostData, - CostDataError, - MaxCostData, - calculate_cost, -) +from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost from .helpers import ( UPSTREAM_BASE_URL, create_error_response, @@ -68,21 +63,30 @@ async def x_cashu_handler( "token_already_spent", "The provided CASHU token has already been spent", 400, + x_cashu_token, ) - elif "invalid token" in error_message.lower(): + + if "invalid token" in error_message.lower(): return create_error_response( - "invalid_token", "The provided CASHU token is invalid", 400 + "invalid_token", + "The provided CASHU token is invalid", + 400, + x_cashu_token, ) - elif "mint error" in error_message.lower(): + + if "mint error" in error_message.lower(): return create_error_response( - "mint_error", f"CASHU mint error: {error_message}", 422 - ) - else: - # Generic error for other cases - return create_error_response( - "cashu_error", f"CASHU token processing failed: {error_message}", 400 + "mint_error", f"CASHU mint error: {error_message}", 422, x_cashu_token ) + # Generic error for other cases + return create_error_response( + "cashu_error", + f"CASHU token processing failed: {error_message}", + 400, + x_cashu_token, + ) + async def forward_to_upstream( request: Request, path: str, headers: dict, amount: int, unit: CurrencyUnit diff --git a/scripts/models_meta.py b/scripts/models_meta.py index 1b441c28..d95b5ddd 100755 --- a/scripts/models_meta.py +++ b/scripts/models_meta.py @@ -43,24 +43,33 @@ class Model(TypedDict): OUTPUT_FILE = os.getenv("OUTPUT_FILE", "models.json") BASE_URL = os.getenv("BASE_URL", "https://openrouter.ai/api/v1") +SOURCE = os.getenv("SOURCE") -def fetch_openrouter_models() -> list[Model]: +def fetch_openrouter_models(source_filter: str | None = None) -> list[Model]: """Fetches model information from OpenRouter API.""" with urlopen(f"{BASE_URL}/models") as response: data = json.loads(response.read().decode("utf-8")) models_data: list[Model] = [] for model in data.get("data", []): - # Skip models with '(free)' in the name or id = 'openrouter/auto' + model_id = model.get("id", "") + + if source_filter: + source_prefix = f"{source_filter}/" + if not model_id.startswith(source_prefix): + continue + + model = dict(model) + model["id"] = model_id[len(source_prefix) :] + model_id = model["id"] + if ( "(free)" in model.get("name", "") - or model.get("id") == "openrouter/auto" + or model_id == "openrouter/auto" + or model_id == "google/gemini-2.5-pro-exp-03-25" ): continue - # Skip free Gemini 2.5 Pro Exp - if model.get("id") == "google/gemini-2.5-pro-exp-03-25": - continue models_data.append(model) @@ -68,10 +77,9 @@ def fetch_openrouter_models() -> list[Model]: def main() -> None: - models = fetch_openrouter_models() + source_filter = SOURCE if SOURCE and SOURCE.strip() else None + models = fetch_openrouter_models(source_filter=source_filter) - # Print the first model data in a nicely indented JSON format - # print(json.dumps(models[0], indent=4)) print(f"Writing {len(models)} models to {OUTPUT_FILE}") with open(OUTPUT_FILE, "w") as f: