diff --git a/routstr/core/settings.py b/routstr/core/settings.py index 302df24d..52f6fae4 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -6,7 +6,7 @@ import os from datetime import datetime, timezone from typing import Any -from pydantic.v1 import BaseModel, BaseSettings, Field +from pydantic.v1 import BaseModel, BaseSettings, Field, validator from sqlmodel.ext.asyncio.session import AsyncSession @@ -37,6 +37,13 @@ class Settings(BaseSettings): # Cashu cashu_mints: list[str] = Field(default_factory=list, env="CASHU_MINTS") + + @validator("cashu_mints", pre=True, each_item=True) + def normalize_mint_url(cls, v: str) -> str: + if isinstance(v, str): + return v.rstrip("/") + return v + receive_ln_address: str = Field(default="", env="RECEIVE_LN_ADDRESS") primary_mint: str = Field(default="", env="PRIMARY_MINT_URL") primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT") diff --git a/routstr/proxy.py b/routstr/proxy.py index ce558fc5..3ebc7abe 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -16,6 +16,7 @@ from .core.db import ( create_session, get_session, ) +from .core.settings import settings from .payment.helpers import ( calculate_discounted_max_cost, check_token_balance, @@ -25,6 +26,7 @@ from .payment.helpers import ( from .payment.models import Model from .upstream import BaseUpstreamProvider from .upstream.helpers import init_upstreams +from .wallet import deserialize_token_from_string logger = get_logger(__name__) proxy_router = APIRouter() @@ -156,6 +158,24 @@ async def proxy( model_id = request_body_dict.get("model", "unknown") + if "https://testnut.cashu.space" in settings.cashu_mints: + try: + token_str = None + if x_cashu_header := headers.get("x-cashu"): + token_str = x_cashu_header + elif auth_header := headers.get("authorization"): + parts = auth_header.split(" ") + if len(parts) > 1 and not parts[1].startswith("sk-"): + token_str = parts[1] + + if token_str: + token_obj = deserialize_token_from_string(token_str) + if token_obj.mint == "https://testnut.cashu.space": + model_id = "mock/gpt-420-mock" + request_body_dict["model"] = model_id + except Exception: + pass + model_obj = get_model_instance(model_id) if not model_obj: return create_error_response( diff --git a/routstr/upstream/fake.py b/routstr/upstream/fake.py new file mode 100644 index 00000000..ea35cdd5 --- /dev/null +++ b/routstr/upstream/fake.py @@ -0,0 +1,261 @@ +import asyncio +import json +import random +from typing import AsyncIterator + +from fastapi import Request +from fastapi.responses import Response, StreamingResponse + +from ..core.db import ApiKey, AsyncSession +from ..payment.models import Architecture, Model, Pricing +from .base import BaseUpstreamProvider + + +class MockUpstreamProvider(BaseUpstreamProvider): + """Fack Mock Upstream provider specifically for Testing.""" + + provider_type = "mock" + + async def forward_request( + self, + request: Request, + path: str, + headers: dict, + request_body: bytes | None, + key: ApiKey, + max_cost_for_model: int, + session: AsyncSession, + model_obj: Model, + ) -> Response | StreamingResponse: + if path.endswith("chat/completions"): + is_streaming = False + if request_body: + request_data = json.loads(request_body) + is_streaming = request_data.get("stream", False) + + if is_streaming: + + async def fake_streaming_response( + chunk_size: int | None = None, + ) -> AsyncIterator[bytes]: + suffix = random.randint(1000, 9999) + req_id = f"gen-mock-stream-{suffix}" + created = 1766138895 + model = "mock/gpt-420-mock" + + def make_chunk(delta: dict, finish_reason=None, usage=None): + chunk = { + "id": req_id, + "provider": "MockProvider", + "model": model, + "object": "chat.completion.chunk", + "created": created, + "choices": [ + { + "index": 0, + "delta": delta, + "finish_reason": finish_reason, + "native_finish_reason": "completed" + if finish_reason + else None, + "logprobs": None, + } + ], + } + if usage: + chunk["usage"] = usage + return f"data: {json.dumps(chunk)}\n\n".encode() + + # 1. Initial chunk + yield make_chunk({"role": "assistant", "content": ""}) + await asyncio.sleep(0.02) + + # 2. Reasoning chunks + reasoning_tokens = ["Mock", " reason", "ing", "..."] + for token in reasoning_tokens: + delta = { + "role": "assistant", + "content": "", + "reasoning": token, + "reasoning_details": [ + { + "type": "reasoning.summary", + "summary": token, + "format": "openai-responses-v1", + "index": 0, + } + ], + } + yield make_chunk(delta) + await asyncio.sleep(0.03) + + # 3. Content chunks + content_tokens = ["This", " is", " a", " mock", " stream", "."] + for token in content_tokens: + yield make_chunk({"role": "assistant", "content": token}) + await asyncio.sleep(0.03) + + # 4. Finish chunk + yield make_chunk( + {"role": "assistant", "content": ""}, finish_reason="stop" + ) + + # 5. Usage chunk + usage_data = { + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30, + "cost": 0.001, + "is_byok": False, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0, + "video_tokens": 0, + }, + "cost_details": { + "upstream_inference_cost": None, + "upstream_inference_prompt_cost": 0, + "upstream_inference_completions_cost": 0.001, + }, + "completion_tokens_details": { + "reasoning_tokens": 10, + "image_tokens": 0, + }, + } + + usage_chunk = { + "id": req_id, + "provider": "MockProvider", + "model": model, + "object": "chat.completion.chunk", + "created": created, + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": ""}, + "finish_reason": None, + "native_finish_reason": None, + "logprobs": None, + } + ], + "usage": usage_data, + } + yield f"data: {json.dumps(usage_chunk)}\n\n".encode() + + # 6. DONE + yield b"data: [DONE]\n\n" + + # 7. Cost + cost_chunk = { + "cost": { + "base_msats": 0, + "input_msats": 2, + "output_msats": 10, + "total_msats": 12, + } + } + yield f"data: {json.dumps(cost_chunk)}\n\n".encode() + + return StreamingResponse( + fake_streaming_response(), + 200, + ) + + else: + suffix = random.randint(1000, 9999) + content_dict = { + "id": f"gen-mock-{suffix}", + "provider": "MockProvider", + "model": "mock/gpt-5-mini", + "object": "chat.completion", + "created": 1766138655, + "choices": [ + { + "logprobs": None, + "finish_reason": "length", + "native_finish_reason": "max_output_tokens", + "index": 0, + "message": { + "role": "assistant", + "content": f"Mock Content {suffix}", + "refusal": None, + "reasoning": f"Mock Reasoning {suffix}", + "reasoning_details": [ + { + "format": "openai-responses-v1", + "index": 0, + "type": "reasoning.summary", + "summary": f"Mock Summary {suffix}", + }, + { + "id": f"rs_mock_{suffix}", + "format": "openai-responses-v1", + "index": 0, + "type": "reasoning.encrypted", + "data": "mock_encrypted_data", + }, + ], + }, + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 10, + "total_tokens": 20, + "cost": 0, + "is_byok": False, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0, + "video_tokens": 0, + }, + "cost_details": { + "upstream_inference_cost": None, + "upstream_inference_prompt_cost": 0, + "upstream_inference_completions_cost": 0, + }, + "completion_tokens_details": { + "reasoning_tokens": 5, + "image_tokens": 0, + }, + }, + "cost": { + "base_msats": 0, + "input_msats": 0, + "output_msats": 0, + "total_msats": 0, + }, + } + return Response(json.dumps(content_dict).encode(), 200) + + elif path.endswith("embeddings"): + raise NotImplementedError + elif path.endswith("responses"): + raise NotImplementedError + else: + raise NotImplementedError + + async def fetch_models(self) -> list[Model]: + return [ + Model( + id="mock/gpt-420-mock", + name="mock/gpt-420-mock", + created=0, + description="mock model for testing", + context_length=8192, + architecture=Architecture( + modality="text", + input_modalities=["text"], + output_modalities=["text"], + tokenizer="", + instruct_type=None, + ), + pricing=Pricing(prompt=0.01, completion=0.01), + ), + ] + + async def transform_model_name(self, model_id: str) -> str: + return "fake-model" + + async def get_balance(self) -> float | None: + return 420.69 diff --git a/routstr/upstream/helpers.py b/routstr/upstream/helpers.py index b453af1f..145b0e59 100644 --- a/routstr/upstream/helpers.py +++ b/routstr/upstream/helpers.py @@ -200,6 +200,13 @@ async def init_upstreams() -> list[BaseUpstreamProvider]: "models_cached": len(provider.get_cached_models()), }, ) + if "https://testnut.cashu.space" in settings.cashu_mints: + from .fake import MockUpstreamProvider + + mock_provider = MockUpstreamProvider("mock", "mock") + await mock_provider.refresh_models_cache() + upstreams.append(mock_provider) + logger.info("Initialized MockUpstreamProvider for testnut mint") return upstreams diff --git a/routstr/wallet.py b/routstr/wallet.py index 34569b85..ca4834fa 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -313,6 +313,8 @@ async def periodic_payout() -> None: try: async with db.create_session() as session: for mint_url in settings.cashu_mints: + if mint_url == "https://testnut.cashu.space": + continue for unit in ["sat", "msat"]: wallet = await get_wallet(mint_url, unit) proofs = get_proofs_per_mint_and_unit(