Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion routstr/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
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


Expand Down Expand Up @@ -37,9 +37,19 @@ def parse_env_var(cls, field_name: str, raw_value: str) -> Any: # type: ignore[

# 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")
disable_testnut_mock_upstream: bool = Field(
default=False, env="DISABLE_TESTNUT_MOCK_UPSTREAM"
)

# Pricing
# Default behavior: derive pricing from MODELS
Expand Down
23 changes: 23 additions & 0 deletions routstr/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
create_session,
get_session,
)
from .core.settings import settings
from .payment.helpers import (
calculate_discounted_max_cost,
check_token_balance,
Expand All @@ -25,6 +26,7 @@
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()
Expand Down Expand Up @@ -148,6 +150,27 @@ async def proxy(
else:
model_id = request_body_dict.get("model", "unknown")

if (
"https://testnut.cashu.space" in settings.cashu_mints
and not settings.disable_testnut_mock_upstream
):
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(
Expand Down
265 changes: 265 additions & 0 deletions routstr/upstream/fake.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
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: str | None = None,
usage: dict | None = None,
) -> bytes:
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),
),
]

def transform_model_name(self, model_id: str) -> str:
return "fake-model"

async def get_balance(self) -> float | None:
return 420.69
11 changes: 11 additions & 0 deletions routstr/upstream/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,17 @@ async def _init_single_provider(
results = await asyncio.gather(*tasks)
upstreams = [p for p in results if p is not None]

if (
"https://testnut.cashu.space" in settings.cashu_mints
and not settings.disable_testnut_mock_upstream
):
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


Expand Down
5 changes: 5 additions & 0 deletions routstr/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,11 @@ 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"
and not settings.disable_testnut_mock_upstream
):
continue
for unit in ["sat", "msat"]:
wallet = await get_wallet(mint_url, unit)
proofs = get_proofs_per_mint_and_unit(
Expand Down
Loading