mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-31 15:56:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aa57959bf | ||
|
|
b1facd58d5 | ||
|
|
6288d6fef7 | ||
|
|
a1223ad610 |
File diff suppressed because it is too large
Load Diff
@@ -25,82 +25,6 @@ class LNURLError(Exception):
|
||||
"""LNURL related errors."""
|
||||
|
||||
|
||||
def parse_lightning_invoice_amount(invoice: str, currency: str = "sat") -> int:
|
||||
"""Parse Lightning invoice (BOLT-11) to extract amount in specified currency units.
|
||||
|
||||
Args:
|
||||
invoice: BOLT-11 Lightning invoice string
|
||||
currency: Target currency unit ("sat" or "msat")
|
||||
|
||||
Returns:
|
||||
Amount in the specified currency unit
|
||||
|
||||
Raises:
|
||||
LNURLError: If invoice format is invalid or amount cannot be parsed
|
||||
"""
|
||||
invoice = invoice.lower().strip()
|
||||
|
||||
if not invoice.startswith("ln"):
|
||||
raise LNURLError("Invalid Lightning invoice format")
|
||||
|
||||
# Find the network part (bc, tb, etc.)
|
||||
network_start = 2
|
||||
while network_start < len(invoice) and invoice[network_start] not in "0123456789":
|
||||
network_start += 1
|
||||
|
||||
if network_start >= len(invoice):
|
||||
raise LNURLError("Invalid Lightning invoice format")
|
||||
|
||||
# Parse amount and multiplier
|
||||
amount_str = ""
|
||||
multiplier = ""
|
||||
i = network_start
|
||||
|
||||
# Extract numeric part
|
||||
while i < len(invoice) and invoice[i].isdigit():
|
||||
amount_str += invoice[i]
|
||||
i += 1
|
||||
|
||||
# Extract multiplier if present
|
||||
if i < len(invoice) and invoice[i] in "munp":
|
||||
multiplier = invoice[i]
|
||||
i += 1
|
||||
|
||||
# Check if we have the required "1" separator
|
||||
if i >= len(invoice) or invoice[i] != "1":
|
||||
raise LNURLError("Invalid Lightning invoice format")
|
||||
|
||||
if not amount_str:
|
||||
raise LNURLError("Lightning invoice amount not specified")
|
||||
|
||||
# Convert to base units
|
||||
try:
|
||||
amount = int(amount_str)
|
||||
except ValueError:
|
||||
raise LNURLError("Invalid Lightning invoice amount")
|
||||
|
||||
# Apply multiplier to get millisatoshis
|
||||
if multiplier == "m": # milli = 10^-3
|
||||
amount_msat = amount * 100_000_000 # amount is in BTC * 10^-3
|
||||
elif multiplier == "u": # micro = 10^-6
|
||||
amount_msat = amount * 100_000 # amount is in BTC * 10^-6
|
||||
elif multiplier == "n": # nano = 10^-9
|
||||
amount_msat = amount * 100 # amount is in BTC * 10^-9
|
||||
elif multiplier == "p": # pico = 10^-12
|
||||
amount_msat = amount // 10 # amount is in BTC * 10^-12
|
||||
else:
|
||||
# No multiplier means the amount is in BTC
|
||||
amount_msat = amount * 100_000_000_000 # Convert BTC to msat
|
||||
|
||||
# Convert to target currency unit
|
||||
if currency == "msat":
|
||||
return amount_msat
|
||||
elif currency == "sat":
|
||||
return amount_msat // 1000
|
||||
else:
|
||||
raise LNURLError(f"Unsupported currency for Lightning: {currency}")
|
||||
|
||||
|
||||
async def decode_lnurl(lnurl: str) -> str:
|
||||
"""Decode LNURL to get the actual URL.
|
||||
|
||||
|
||||
@@ -143,14 +143,6 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
|
||||
return []
|
||||
|
||||
|
||||
def is_openrouter_upstream() -> bool:
|
||||
try:
|
||||
base = (settings.upstream_base_url or "").strip().rstrip("/")
|
||||
except Exception:
|
||||
return False
|
||||
return base.lower() == "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def _row_to_model(
|
||||
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
|
||||
) -> Model:
|
||||
@@ -203,29 +195,6 @@ def _row_to_model(
|
||||
return model
|
||||
|
||||
|
||||
def _model_to_row_payload(model: Model) -> dict[str, str | int | bool | None]:
|
||||
return {
|
||||
"id": model.id,
|
||||
"name": model.name,
|
||||
"created": model.created,
|
||||
"description": model.description,
|
||||
"context_length": model.context_length,
|
||||
"architecture": json.dumps(model.architecture.dict()),
|
||||
"pricing": json.dumps(model.pricing.dict()),
|
||||
"sats_pricing": json.dumps(model.sats_pricing.dict())
|
||||
if model.sats_pricing
|
||||
else None,
|
||||
"per_request_limits": json.dumps(model.per_request_limits)
|
||||
if model.per_request_limits is not None
|
||||
else None,
|
||||
"top_provider": json.dumps(model.top_provider.dict())
|
||||
if model.top_provider is not None
|
||||
else None,
|
||||
"enabled": model.enabled,
|
||||
"upstream_provider_id": model.upstream_provider_id,
|
||||
}
|
||||
|
||||
|
||||
async def list_models(
|
||||
session: AsyncSession,
|
||||
upstream_id: int,
|
||||
@@ -262,21 +231,6 @@ async def list_models(
|
||||
]
|
||||
|
||||
|
||||
async def get_model_by_id(
|
||||
model_id: str, provider_id: int, session: AsyncSession
|
||||
) -> Model | None:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
row = await session.get(ModelRow, (model_id, provider_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)
|
||||
|
||||
|
||||
def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
|
||||
"""Calculate max costs in USD based on model context/token limits.
|
||||
|
||||
|
||||
@@ -267,9 +267,10 @@ async def test_admin_endpoint_unauthenticated(
|
||||
"""Test GET /admin/ endpoint redirects to /"""
|
||||
await db_snapshot.capture()
|
||||
|
||||
response = await integration_client.get("/admin/api/settings")
|
||||
response = await integration_client.get("/admin/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.status_code == 307
|
||||
assert response.headers.get("location") == "/"
|
||||
|
||||
diff = await db_snapshot.diff()
|
||||
assert len(diff["api_keys"]["added"]) == 0
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
"""Unit tests for model row payload conversion.
|
||||
|
||||
This module tests that _model_to_row_payload correctly serializes model data
|
||||
for database storage. Pricing is stored as-is without fee application.
|
||||
Fees are now applied per-provider when reading from the database.
|
||||
|
||||
Key behaviors tested:
|
||||
1. Pricing is stored as-is without fee application
|
||||
2. All model fields are correctly serialized to JSON
|
||||
3. Optional fields are handled correctly (None values)
|
||||
4. Pricing structure is preserved
|
||||
5. Original model objects are not mutated
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
# Set required env vars before importing
|
||||
os.environ["UPSTREAM_BASE_URL"] = "http://test"
|
||||
os.environ["UPSTREAM_API_KEY"] = "test"
|
||||
|
||||
from routstr.payment.models import ( # noqa: E402
|
||||
Architecture,
|
||||
Model,
|
||||
Pricing,
|
||||
_model_to_row_payload,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_architecture() -> Architecture:
|
||||
"""Provide standard architecture for test models."""
|
||||
return Architecture(
|
||||
modality="text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="gpt",
|
||||
instruct_type="chat",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def standard_pricing() -> Pricing:
|
||||
"""Provide standard USD pricing with known values for testing."""
|
||||
return Pricing(
|
||||
prompt=0.001,
|
||||
completion=0.002,
|
||||
request=0.01,
|
||||
image=0.05,
|
||||
web_search=0.03,
|
||||
internal_reasoning=0.015,
|
||||
max_prompt_cost=10.0,
|
||||
max_completion_cost=20.0,
|
||||
max_cost=30.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def standard_model(base_architecture: Architecture, standard_pricing: Pricing) -> Model:
|
||||
"""Create a standard test model with known pricing."""
|
||||
return Model(
|
||||
id="test-model-standard",
|
||||
name="Test Model Standard",
|
||||
created=1234567890,
|
||||
description="A standard test model",
|
||||
context_length=8192,
|
||||
architecture=base_architecture,
|
||||
pricing=standard_pricing,
|
||||
)
|
||||
|
||||
|
||||
def test_pricing_stored_without_fees(standard_model: Model) -> None:
|
||||
"""Verify pricing is stored as-is without any fee application."""
|
||||
payload = _model_to_row_payload(standard_model)
|
||||
pricing_str = payload["pricing"]
|
||||
assert isinstance(pricing_str, str)
|
||||
pricing = json.loads(pricing_str)
|
||||
|
||||
assert pricing["prompt"] == pytest.approx(0.001, rel=1e-9)
|
||||
assert pricing["completion"] == pytest.approx(0.002, rel=1e-9)
|
||||
assert pricing["request"] == pytest.approx(0.01, rel=1e-9)
|
||||
assert pricing["image"] == pytest.approx(0.05, rel=1e-9)
|
||||
assert pricing["web_search"] == pytest.approx(0.03, rel=1e-9)
|
||||
assert pricing["internal_reasoning"] == pytest.approx(0.015, rel=1e-9)
|
||||
assert pricing["max_prompt_cost"] == pytest.approx(10.0, rel=1e-9)
|
||||
assert pricing["max_completion_cost"] == pytest.approx(20.0, rel=1e-9)
|
||||
assert pricing["max_cost"] == pytest.approx(30.0, rel=1e-9)
|
||||
|
||||
|
||||
def test_zero_value_pricing_fields(base_architecture: Architecture) -> None:
|
||||
"""Verify that zero-value pricing fields are stored correctly."""
|
||||
zero_pricing = Pricing(
|
||||
prompt=0.0,
|
||||
completion=0.0,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
max_prompt_cost=0.0,
|
||||
max_completion_cost=0.0,
|
||||
max_cost=0.0,
|
||||
)
|
||||
|
||||
model = Model(
|
||||
id="test-model-zero",
|
||||
name="Test Model Zero",
|
||||
created=1234567890,
|
||||
description="A model with zero pricing",
|
||||
context_length=8192,
|
||||
architecture=base_architecture,
|
||||
pricing=zero_pricing,
|
||||
)
|
||||
|
||||
payload = _model_to_row_payload(model)
|
||||
pricing_str = payload["pricing"]
|
||||
assert isinstance(pricing_str, str)
|
||||
pricing = json.loads(pricing_str)
|
||||
|
||||
assert pricing["prompt"] == pytest.approx(0.0, rel=1e-9)
|
||||
assert pricing["completion"] == pytest.approx(0.0, rel=1e-9)
|
||||
assert pricing["request"] == pytest.approx(0.0, rel=1e-9)
|
||||
|
||||
|
||||
def test_payload_structure_unchanged(standard_model: Model) -> None:
|
||||
"""Verify that payload structure matches expectations."""
|
||||
payload = _model_to_row_payload(standard_model)
|
||||
|
||||
assert "id" in payload
|
||||
assert "name" in payload
|
||||
assert "created" in payload
|
||||
assert "description" in payload
|
||||
assert "context_length" in payload
|
||||
assert "architecture" in payload
|
||||
assert "pricing" in payload
|
||||
assert "sats_pricing" in payload
|
||||
assert "per_request_limits" in payload
|
||||
assert "top_provider" in payload
|
||||
assert "enabled" in payload
|
||||
assert "upstream_provider_id" in payload
|
||||
|
||||
assert isinstance(payload["architecture"], str)
|
||||
assert isinstance(payload["pricing"], str)
|
||||
|
||||
|
||||
def test_original_model_not_mutated(standard_model: Model) -> None:
|
||||
"""Verify that the original model object is not mutated."""
|
||||
original_prompt = standard_model.pricing.prompt
|
||||
original_completion = standard_model.pricing.completion
|
||||
|
||||
_model_to_row_payload(standard_model)
|
||||
|
||||
assert standard_model.pricing.prompt == original_prompt
|
||||
assert standard_model.pricing.completion == original_completion
|
||||
@@ -3,28 +3,6 @@ import { apiClient } from '../client';
|
||||
import { ConfigurationService } from './configuration';
|
||||
import axios from 'axios';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
export type LoginRequest = z.infer<typeof loginSchema>;
|
||||
|
||||
export const loginResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type LoginResponse = z.infer<typeof loginResponseSchema>;
|
||||
|
||||
export async function login(data: LoginRequest): Promise<LoginResponse> {
|
||||
try {
|
||||
return await apiClient.post<LoginResponse>('/api/login', data);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const adminLoginSchema = z.object({
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
@@ -91,40 +69,3 @@ export async function adminLogout(): Promise<void> {
|
||||
ConfigurationService.clearToken();
|
||||
}
|
||||
}
|
||||
|
||||
export const registerSchema = z.object({
|
||||
npub: z.string().min(10, { message: 'must have at least 10 character' }),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
export type RegisterRequest = z.infer<typeof registerSchema>;
|
||||
export type SchemaRegisterProps = z.infer<typeof registerSchema>;
|
||||
|
||||
export const registerResponseSchema = z.object({
|
||||
user_id: z.string(),
|
||||
theme: z.string(),
|
||||
});
|
||||
|
||||
export type RegisterResponse = z.infer<typeof registerResponseSchema>;
|
||||
|
||||
export async function register(
|
||||
data: RegisterRequest
|
||||
): Promise<RegisterResponse> {
|
||||
try {
|
||||
return await apiClient.post<RegisterResponse>('/api/register', data);
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const registerUser = register;
|
||||
|
||||
export async function getUserSettings(): Promise<{ id: string }> {
|
||||
try {
|
||||
return await apiClient.get<{ id: string }>('/api/user/settings');
|
||||
} catch (error) {
|
||||
console.error('Error fetching user settings:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user