Compare commits

..
Author SHA1 Message Date
Cursor Agent 46a1e8ebef CI: run pytest with junitxml and coverage artifacts
Generate pytest.xml and coverage output so artifact upload step stops warning.
2025-10-05 02:30:31 +00:00
Cursor Agent 9593437a81 Fix: mypy error for httpx proxies across versions to stabilize CI
CI was failing pytests; locally ensured tests pass and added a minor typing fix in discovery to avoid mypy breakage across httpx versions. Also validated ruff/mypy in workflow context.
2025-10-04 08:55:38 +00:00
redshiftandGitHub 801bc50fd7 Added url normalization for mint urls from .env
Otherwise even valid mint tokens are unnecessarily swapped to primary mint. Faced this issue myself.
2025-09-24 13:29:30 +00:00
27 changed files with 150 additions and 1926 deletions
+5 -2
View File
@@ -35,12 +35,15 @@ jobs:
run: |
uv run mypy .
- name: Run tests with pytest
- name: Run tests with pytest (with coverage & junit)
env:
UPSTREAM_BASE_URL: "http://test"
UPSTREAM_API_KEY: "test"
run: |
uv run pytest --verbose --tb=short
uv run pytest \
--verbose --tb=short \
--junitxml=pytest.xml \
--cov=routstr --cov-report=term
- name: Upload test results
if: always()
-2
View File
@@ -12,8 +12,6 @@ dist/
# Development
.notes
.*keys.db
*.db-shm
*.db-wal
.*wallet.sqlite3
*models.json
.cashu
-1
View File
@@ -1 +0,0 @@
3.11
+1 -1
View File
@@ -347,7 +347,7 @@ GET /health
Response:
{
"status": "healthy",
"version": "0.1.4",
"version": "0.1.3",
"timestamp": "2024-01-01T00:00:00Z",
"checks": {
"database": "ok",
+1 -1
View File
@@ -348,7 +348,7 @@ Project metadata and dependencies:
```toml
[project]
name = "routstr"
version = "0.1.4"
version = "0.1.3"
dependencies = [
"fastapi[standard]>=0.115",
"sqlmodel>=0.0.24",
+1 -1
View File
@@ -67,7 +67,7 @@ You should see:
{
"name": "ARoutstrNode",
"description": "A Routstr Node",
"version": "0.1.4",
"version": "0.1.3",
"npub": "",
"mints": ["https://mint.minibits.cash/Bitcoin"],
"models": {...}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.1.4"
version = "0.1.3"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"
+10 -26
View File
@@ -8,15 +8,12 @@ from pydantic import BaseModel
from .auth import validate_bearer_key
from .core.db import ApiKey, AsyncSession, get_session
from .core.logging import get_logger
from .core.settings import settings
from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token
from .wallet import credit_balance, send_to_lnurl, send_token
router = APIRouter()
balance_router = APIRouter(prefix="/v1/balance")
logger = get_logger(__name__)
async def get_key_from_header(
authorization: Annotated[str, Header(...)],
@@ -155,19 +152,14 @@ async def refund_wallet_endpoint(
key: ApiKey = await validate_bearer_key(bearer_value, session)
remaining_balance_msats: int = key.balance
if key.refund_currency == "sat":
remaining_balance = remaining_balance_msats // 1000
else:
remaining_balance = remaining_balance_msats
if remaining_balance_msats > 0 and remaining_balance <= 0:
raise HTTPException(status_code=400, detail="Balance too small to refund")
elif remaining_balance <= 0:
if remaining_balance_msats <= 0:
raise HTTPException(status_code=400, detail="No balance to refund")
# Perform refund operation first, before modifying balance
try:
if key.refund_address:
if key.refund_currency == "sat":
remaining_balance = remaining_balance_msats // 1000
from .core.settings import settings as global_settings
await send_to_lnurl(
@@ -178,9 +170,14 @@ async def refund_wallet_endpoint(
)
result = {"recipient": key.refund_address}
else:
refund_amount = (
remaining_balance_msats // 1000
if key.refund_currency == "sat"
else remaining_balance_msats
)
refund_currency = key.refund_currency or "sat"
token = await send_token(
remaining_balance, refund_currency, key.refund_mint_url
refund_amount, refund_currency, key.refund_mint_url
)
result = {"token": token}
@@ -213,19 +210,6 @@ async def refund_wallet_endpoint(
return result
@router.post("/donate")
async def donate(token: str, ref: str | None = None) -> str:
try:
amount, unit, _ = await recieve_token(token)
if ref:
logger.info(
"donation received", extra={"ref": ref, "amount": amount, "unit": unit}
)
return "Thanks!"
except Exception:
return "Invalid token."
@router.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "DELETE"],
+3 -805
View File
@@ -8,7 +8,6 @@ from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlmodel import select
from ..payment.models import Model, get_model_by_id, list_models
from ..wallet import (
fetch_all_balances,
get_proofs_per_mint_and_unit,
@@ -16,7 +15,7 @@ from ..wallet import (
send_token,
slow_filter_spend_proofs,
)
from .db import ApiKey, ModelRow, create_session
from .db import ApiKey, create_session
from .logging import get_logger
from .settings import SettingsService, settings
@@ -164,28 +163,6 @@ async def update_settings(request: Request, update: SettingsUpdate) -> dict:
return data
class SetupRequest(BaseModel):
password: str
@admin_router.post("/api/setup")
async def initial_setup(request: Request, payload: SetupRequest) -> dict[str, object]:
try:
current = SettingsService.get()
except Exception:
current = settings
if getattr(current, "admin_password", ""):
raise HTTPException(status_code=409, detail="Admin password already set")
pw = (payload.password or "").strip()
if len(pw) < 8:
raise HTTPException(
status_code=400, detail="Password must be at least 8 characters"
)
async with create_session() as session:
await SettingsService.update({"admin_password": pw}, session)
return {"ok": True}
class WithdrawRequest(BaseModel):
amount: int
mint_url: str | None = None
@@ -228,67 +205,6 @@ def login_form() -> str:
"""
def setup_form() -> str:
return """<!DOCTYPE html>
<html>
<head>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background: #f5f7fa; }
.setup-card { background: white; padding: 2.5rem; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); width: 360px; }
h2 { margin-bottom: 1.25rem; color: #1a202c; text-align: center; }
p { color: #4a5568; font-size: 0.95rem; margin-bottom: 1rem; text-align: center; }
input[type="password"] { width: 100%; padding: 12px; border: 2px solid #e2e8f0; border-radius: 6px; font-size: 16px; transition: border 0.2s; }
input[type="password"]:focus { outline: none; border-color: #4299e1; }
button { width: 100%; padding: 12px; margin-top: 1rem; background: #4299e1; color: white; border: none; border-radius: 6px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
button:hover { background: #3182ce; transform: translateY(-1px); box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.error { color: #e53e3e; margin-top: 10px; text-align: center; }
</style>
<script>
async function handleSetupSubmit(e) {
e.preventDefault();
const pw = document.getElementById('password').value;
const pw2 = document.getElementById('password2').value;
const err = document.getElementById('error');
err.textContent = '';
if (pw.length < 8) { err.textContent = 'Password must be at least 8 characters'; return; }
if (pw !== pw2) { err.textContent = 'Passwords do not match'; return; }
try {
const resp = await fetch('/admin/api/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ password: pw })
});
if (!resp.ok) {
let msg = 'Failed to set password';
try { const j = await resp.json(); if (j && j.detail) msg = typeof j.detail === 'string' ? j.detail : JSON.stringify(j.detail); } catch(_) {}
throw new Error(msg);
}
document.cookie = `admin_password=${pw}; path=/; max-age=86400; samesite=lax`;
window.location.replace('/admin');
} catch (e) {
err.textContent = e.message || String(e);
}
}
</script>
</head>
<body>
<div class="setup-card">
<h2>🔧 Initial Admin Setup</h2>
<p>Create a secure password for your admin dashboard.</p>
<form onsubmit="handleSetupSubmit(event)">
<input type="password" id="password" placeholder="New Password" required autofocus>
<input type="password" id="password2" placeholder="Confirm Password" required style="margin-top:10px;">
<button type="submit">Set Password</button>
<div id="error" class="error"></div>
</form>
</div>
</body>
</html>
"""
def info(content: str) -> str:
return f"""<!DOCTYPE html>
<html>
@@ -316,7 +232,7 @@ def admin_auth() -> str:
except Exception:
admin_pw = os.getenv("ADMIN_PASSWORD", "")
if admin_pw == "":
return setup_form()
return info("Please set a secure ADMIN_PASSWORD= in your ENV variables.")
else:
return login_form()
@@ -613,9 +529,6 @@ async def dashboard(request: Request) -> str:
<button class="investigate-btn" onclick="openInvestigateModal()">
🔍 Investigate Logs
</button>
<button onclick="window.location.href='/admin/models'">
🧩 Edit Models
</button>
<button onclick="openSettingsModal()">
⚙️ Settings
</button>
@@ -837,721 +750,6 @@ async def withdraw(
return {"token": token}
DASHBOARD_MODELS_JS: str = """<!--html-->
<script>
let modelsList = [];
let currentQuery = '';
let editModelCreated = 0;
async function fetchModels() {
const tableBody = document.getElementById('models-tbody');
tableBody.innerHTML = '<tr><td colspan="1" style="color:#718096;">Loading…</td></tr>';
try {
const resp = await fetch('/admin/api/models', { credentials: 'same-origin' });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json();
modelsList = data;
renderModelsTable();
} catch (e) {
tableBody.innerHTML = '<tr><td colspan="1" style="color:#e53e3e;">Failed to load models: ' + e.message + '</td></tr>';
}
}
function renderModelsTable() {
const tableBody = document.getElementById('models-tbody');
if (!Array.isArray(modelsList) || !modelsList.length) {
tableBody.innerHTML = '<tr><td colspan="1" style="color:#718096;">No models found</td></tr>';
return;
}
const q = (currentQuery || '').trim().toLowerCase();
const items = q ? modelsList.filter(m => {
const id = (m.id || '').toLowerCase();
return id.includes(q);
}) : modelsList;
if (!items.length) {
tableBody.innerHTML = '<tr><td colspan="1" style="color:#718096;">No models match your search</td></tr>';
return;
}
const rows = items.map(m => `
<tr>
<td>
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<span style="font-family:monospace; word-break: break-all;">${m.id}</span>
<span>
<button onclick=\"openModelEditor('${m.id}')\">Edit</button>
<button onclick=\"deleteModel(event, '${m.id}')\" style=\"background:#e53e3e;\">Delete</button>
</span>
</div>
</td>
</tr>
`).join('');
tableBody.innerHTML = rows;
}
function handleSearch(query) {
currentQuery = String(query || '');
renderModelsTable();
}
async function deleteModel(ev, modelId) {
if (!confirm('Are you sure you want to delete model: ' + modelId + '?')) return;
const btn = ev && ev.currentTarget ? ev.currentTarget : null;
if (btn) { btn.disabled = true; btn.textContent = 'Deleting…'; }
const errorBox = document.getElementById('models-error');
if (errorBox) { errorBox.style.display = 'none'; errorBox.textContent = ''; }
try {
const resp = await fetch('/admin/api/models/' + encodeURIComponent(modelId), {
method: 'DELETE',
credentials: 'same-origin'
});
if (!resp.ok) {
let errText = 'Failed to delete model';
try { const err = await resp.json(); if (err && err.detail) errText = typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail); } catch (_) {}
throw new Error(errText);
}
await fetchModels();
} catch (e) {
if (errorBox) { errorBox.style.display = 'block'; errorBox.textContent = e.message; }
else { alert(e.message); }
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Delete'; }
}
}
async function deleteAllModels() {
if (!confirm('Are you absolutely sure you want to delete ALL models?')) return;
const errorBox = document.getElementById('models-error');
if (errorBox) { errorBox.style.display = 'none'; errorBox.textContent = ''; }
try {
const resp = await fetch('/admin/api/models', { method: 'DELETE', credentials: 'same-origin' });
if (!resp.ok) {
let errText = 'Failed to delete all models';
try { const err = await resp.json(); if (err && err.detail) errText = typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail); } catch (_) {}
throw new Error(errText);
}
await fetchModels();
} catch (e) {
if (errorBox) { errorBox.style.display = 'block'; errorBox.textContent = e.message; }
else { alert(e.message); }
}
}
async function openModelEditor(modelId) {
const modal = document.getElementById('model-edit-modal');
const errorBox = document.getElementById('model-error');
errorBox.style.display = 'none';
errorBox.textContent = '';
document.getElementById('model-id').value = modelId;
try {
const resp = await fetch('/admin/api/models/' + encodeURIComponent(modelId), { credentials: 'same-origin' });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const m = await resp.json();
document.getElementById('model-name').value = m.name || '';
document.getElementById('model-description').value = m.description || '';
editModelCreated = m.created || Math.floor(Date.now()/1000);
document.getElementById('model-context').value = m.context_length || 0;
document.getElementById('model-architecture').value = JSON.stringify(m.architecture || {
modality: '',
input_modalities: [],
output_modalities: [],
tokenizer: '',
instruct_type: null
}, null, 2);
const pricingObj = m.pricing || {
prompt: 0.0,
completion: 0.0,
request: 0.0,
image: 0.0,
web_search: 0.0,
internal_reasoning: 0.0
};
delete pricingObj.max_prompt_cost;
delete pricingObj.max_completion_cost;
delete pricingObj.max_cost;
document.getElementById('model-pricing').value = JSON.stringify(pricingObj, null, 2);
document.getElementById('model-per-request-limits').value = m.per_request_limits ? JSON.stringify(m.per_request_limits, null, 2) : '';
document.getElementById('model-top-provider').value = m.top_provider ? JSON.stringify(m.top_provider, null, 2) : '';
} catch (e) {
errorBox.style.display = 'block';
errorBox.textContent = 'Failed to load model: ' + e.message;
}
modal.style.display = 'block';
}
function closeModelEditor() {
const modal = document.getElementById('model-edit-modal');
modal.style.display = 'none';
}
async function saveModel() {
const modelId = document.getElementById('model-id').value;
const errorBox = document.getElementById('model-error');
errorBox.style.display = 'none';
errorBox.style.color = '#e53e3e';
let payload = {};
try {
const name = document.getElementById('model-name').value;
const description = document.getElementById('model-description').value;
const contextLength = parseInt(document.getElementById('model-context').value) || 0;
const architecture = JSON.parse(document.getElementById('model-architecture').value || '{}');
const pricing = JSON.parse(document.getElementById('model-pricing').value || '{}');
const perReqLimitsStr = document.getElementById('model-per-request-limits').value.trim();
const topProviderStr = document.getElementById('model-top-provider').value.trim();
payload = {
id: modelId,
name: name,
description: description,
created: editModelCreated,
context_length: contextLength,
architecture: architecture,
pricing: pricing,
per_request_limits: perReqLimitsStr === '' ? null : JSON.parse(perReqLimitsStr),
top_provider: topProviderStr === '' ? null : JSON.parse(topProviderStr)
};
} catch (e) {
errorBox.style.display = 'block';
errorBox.textContent = 'Invalid input: ' + e.message;
return;
}
const saveBtn = document.getElementById('model-save-btn');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
try {
const resp = await fetch('/admin/api/models/' + encodeURIComponent(modelId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(payload)
});
if (!resp.ok) {
let errText = 'Failed to save model';
try { const err = await resp.json(); if (err && err.detail) errText = typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail); } catch (_) {}
throw new Error(errText);
}
await resp.json();
closeModelEditor();
fetchModels();
} catch (e) {
errorBox.style.display = 'block';
errorBox.textContent = e.message;
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save';
}
}
async function deleteAllModels() {
if (!confirm('Are you absolutely sure you want to delete ALL models?')) return;
const errorBox = document.getElementById('models-error');
if (errorBox) { errorBox.style.display = 'none'; errorBox.textContent = ''; }
try {
const resp = await fetch('/admin/api/models', { method: 'DELETE', credentials: 'same-origin' });
if (!resp.ok) {
let errText = 'Failed to delete all models';
try { const err = await resp.json(); if (err && err.detail) errText = typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail); } catch (_) {}
throw new Error(errText);
}
await fetchModels();
} catch (e) {
if (errorBox) { errorBox.style.display = 'block'; errorBox.textContent = e.message; }
else { alert(e.message); }
}
}
window.addEventListener('DOMContentLoaded', fetchModels);
window.onclick = function(event) {
const modal = document.getElementById('model-edit-modal');
if (event.target == modal) {
closeModelEditor();
}
const createModal = document.getElementById('model-create-modal');
if (event.target == createModal) {
closeCreateModel();
}
const batchModal = document.getElementById('model-batch-modal');
if (event.target == batchModal) {
closeBatchModal();
}
}
function openCreateModel() {
const modal = document.getElementById('model-create-modal');
const err = document.getElementById('model-create-error');
err.style.display = 'none';
err.textContent = '';
const defaults = {
architecture: {
modality: 'text',
input_modalities: ['text'],
output_modalities: ['text'],
tokenizer: '',
instruct_type: null
},
pricing: {
prompt: 0.0,
completion: 0.0,
request: 0.0,
image: 0.0,
web_search: 0.0,
internal_reasoning: 0.0
}
};
document.getElementById('create-id').value = '';
document.getElementById('create-name').value = '';
document.getElementById('create-description').value = '';
document.getElementById('create-context').value = 0;
document.getElementById('create-architecture').value = JSON.stringify(defaults.architecture, null, 2);
document.getElementById('create-pricing').value = JSON.stringify(defaults.pricing, null, 2);
document.getElementById('create-per-request-limits').value = '';
document.getElementById('create-top-provider').value = '';
modal.style.display = 'block';
}
function closeCreateModel() {
const modal = document.getElementById('model-create-modal');
modal.style.display = 'none';
}
async function createModel() {
const err = document.getElementById('model-create-error');
err.style.display = 'none';
err.textContent = '';
const btn = document.getElementById('model-create-btn');
btn.disabled = true;
btn.textContent = 'Creating…';
try {
const id = document.getElementById('create-id').value.trim();
const name = document.getElementById('create-name').value.trim();
const description = document.getElementById('create-description').value.trim();
const contextStr = document.getElementById('create-context').value.trim();
const architectureStr = document.getElementById('create-architecture').value.trim();
const pricingStr = document.getElementById('create-pricing').value.trim();
const perReqLimitsStr = document.getElementById('create-per-request-limits').value.trim();
const topProviderStr = document.getElementById('create-top-provider').value.trim();
if (!id) throw new Error('ID is required');
if (!name) throw new Error('Name is required');
if (!description) throw new Error('Description is required');
const created = Math.floor(Date.now()/1000);
if (!contextStr) throw new Error('Context length is required');
const context_length = parseInt(contextStr);
if (!architectureStr) throw new Error('Architecture JSON is required');
const architecture = JSON.parse(architectureStr);
if (!pricingStr) throw new Error('Pricing JSON is required');
const pricing = JSON.parse(pricingStr);
const payload = {
id: id,
name: name,
description: description,
created: created,
context_length: context_length,
architecture: architecture,
pricing: pricing,
per_request_limits: perReqLimitsStr ? JSON.parse(perReqLimitsStr) : null,
top_provider: topProviderStr ? JSON.parse(topProviderStr) : null
};
const resp = await fetch('/admin/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(payload)
});
if (!resp.ok) {
let errText = 'Failed to create model';
try { const e = await resp.json(); if (e && e.detail) errText = typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail); } catch(_) {}
throw new Error(errText);
}
closeCreateModel();
await fetchModels();
} catch (e) {
err.style.display = 'block';
err.textContent = e.message || String(e);
} finally {
btn.disabled = false;
btn.textContent = ' Create';
}
}
function openBatchModal() {
const modal = document.getElementById('model-batch-modal');
if (!modal) { alert('Batch modal not found'); return; }
const err = document.getElementById('batch-error');
if (err) { err.style.display = 'none'; err.textContent = ''; }
const textarea = document.getElementById('batch-json');
if (textarea && !textarea.value.trim()) {
const sample = {
models: [
{
id: 'provider/model-id',
name: 'Model Name',
description: 'Description',
created: Math.floor(Date.now()/1000),
context_length: 0,
architecture: { modality: 'text', input_modalities: ['text'], output_modalities: ['text'], tokenizer: '', instruct_type: null },
pricing: { prompt: 0.0, completion: 0.0, request: 0.0, image: 0.0, web_search: 0.0, internal_reasoning: 0.0 },
per_request_limits: null,
top_provider: null
}
]
};
textarea.value = JSON.stringify(sample, null, 2);
}
modal.style.display = 'block';
}
function closeBatchModal() {
const modal = document.getElementById('model-batch-modal');
if (modal) modal.style.display = 'none';
}
async function performBatchAdd() {
const textarea = document.getElementById('batch-json');
const err = document.getElementById('batch-error');
const btn = document.getElementById('batch-submit-btn');
if (err) { err.style.display = 'none'; err.textContent = ''; }
if (btn) { btn.disabled = true; btn.textContent = 'Adding…'; }
try {
if (!textarea) throw new Error('Input not found');
const data = JSON.parse(textarea.value);
if (!data || !Array.isArray(data.models) || data.models.length === 0) {
throw new Error('Payload must include a non-empty "models" array');
}
const resp = await fetch('/admin/api/models/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(data)
});
if (!resp.ok) {
let errText = 'Failed to add models';
try { const e = await resp.json(); if (e && e.detail) errText = typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail); } catch(_) {}
throw new Error(errText);
}
closeBatchModal();
await fetchModels();
} catch (e) {
if (err) { err.style.display = 'block'; err.textContent = e.message || String(e); }
else { alert(e.message || String(e)); }
} finally {
if (btn) { btn.disabled = false; btn.textContent = ' Add Models'; }
}
}
</script>
"""
def models_page() -> str:
return (
f"""<!DOCTYPE html>
<html>
<head>
<style>{DASHBOARD_CSS}</style>
{DASHBOARD_MODELS_JS}
</head>
"""
+ """<!--html-->
<body>
<a href="/admin" class="back-btn">← Back to Dashboard</a>
<h1>Models</h1>
<div class="balance-card">
<h2>Models Table</h2>
<div style="display:flex; gap:10px; align-items:center; margin: 8px 0 12px;">
<input type="text" id="models-search" placeholder="Search by id" oninput="handleSearch(this.value)" style="flex:1; padding: 10px; border: 2px solid #e2e8f0; border-radius: 6px;">
<button onclick="openCreateModel()"> Create Model</button>
</div>
<div id="models-error" style="display:none; margin: 8px 0; color:#e53e3e;"></div>
<table>
<thead>
<tr>
<th>ID</th>
</tr>
</thead>
<tbody id="models-tbody">
<tr><td colspan="1" style="color:#718096;">Loading…</td></tr>
</tbody>
</table>
<div style="margin-top: 12px; display:flex; justify-content:flex-end;">
<button onclick="deleteAllModels()" style="background:#e53e3e;">🗑️ Delete All</button>
<button onclick="openBatchModal()" style="background:#4a5568;">📥 Batch Add</button>
</div>
</div>
<div id="model-edit-modal" class="modal">
<div class="modal-content" style="max-width: 720px;">
<span class="close" onclick="closeModelEditor()">&times;</span>
<h3>Edit Model: <span id="model-id" style="font-family:monospace;"></span></h3>
<div id="model-error" style="display:none; margin: 10px 0; color:#e53e3e;"></div>
<label>ID</label>
<input type="text" id="model-id" placeholder="model-id" disabled>
<label>Name</label>
<input type="text" id="model-name" placeholder="Name">
<label>Description</label>
<input type="text" id="model-description" placeholder="Description">
<label>Context Length</label>
<input type="number" id="model-context" min="0" placeholder="Context length">
<h4 style="margin-top:10px;">Architecture (JSON)</h4>
<textarea id="model-architecture" style="width:100%; min-height: 160px; font-family: 'Monaco', monospace; font-size: 13px; background:#f8fafc; color:#2d3748; padding: 12px; border: 2px solid #e2e8f0; border-radius: 6px;"></textarea>
<h4 style="margin-top:10px;">Pricing (JSON)</h4>
<textarea id="model-pricing" style="width:100%; min-height: 160px; font-family: 'Monaco', monospace; font-size: 13px; background:#f8fafc; color:#2d3748; padding: 12px; border: 2px solid #e2e8f0; border-radius: 6px;"></textarea>
<h4 style="margin-top:10px;">Per Request Limits (JSON, optional) — leave blank to clear</h4>
<textarea id="model-per-request-limits" style="width:100%; min-height: 120px; font-family: 'Monaco', monospace; font-size: 13px; background:#f8fafc; color:#2d3748; padding: 12px; border: 2px solid #e2e8f0; border-radius: 6px;"></textarea>
<h4 style="margin-top:10px;">Top Provider (JSON, optional) — leave blank to clear</h4>
<textarea id="model-top-provider" style="width:100%; min-height: 120px; font-family: 'Monaco', monospace; font-size: 13px; background:#f8fafc; color:#2d3748; padding: 12px; border: 2px solid #e2e8f0; border-radius: 6px;"></textarea>
<div style="margin-top: 12px; display: flex; gap: 10px;">
<button id="model-save-btn" onclick="saveModel()">💾 Save</button>
<button onclick="closeModelEditor()" style="background-color: #718096;">Cancel</button>
</div>
</div>
</div>
<div id="model-create-modal" class="modal">
<div class="modal-content" style="max-width: 720px;">
<span class="close" onclick="closeCreateModel()">&times;</span>
<h3>Create Model</h3>
<div id="model-create-error" style="display:none; margin: 10px 0; color:#e53e3e;"></div>
<label>ID</label>
<input type="text" id="create-id" placeholder="model-id">
<label>Name</label>
<input type="text" id="create-name" placeholder="Name">
<label>Description</label>
<input type="text" id="create-description" placeholder="Description">
<label>Context Length</label>
<input type="number" id="create-context" min="0" placeholder="Context length">
<h4 style="margin-top:10px;">Architecture (JSON)</h4>
<textarea id="create-architecture" style="width:100%; min-height: 160px; font-family: 'Monaco', monospace; font-size: 13px; background:#f8fafc; color:#2d3748; padding: 12px; border: 2px solid #e2e8f0; border-radius: 6px;"></textarea>
<h4 style="margin-top:10px;">Pricing (JSON)</h4>
<textarea id="create-pricing" style="width:100%; min-height: 160px; font-family: 'Monaco', monospace; font-size: 13px; background:#f8fafc; color:#2d3748; padding: 12px; border: 2px solid #e2e8f0; border-radius: 6px;"></textarea>
<h4 style="margin-top:10px;">Per Request Limits (JSON, optional)</h4>
<textarea id="create-per-request-limits" style="width:100%; min-height: 120px; font-family: 'Monaco', monospace; font-size: 13px; background:#f8fafc; color:#2d3748; padding: 12px; border: 2px solid #e2e8f0; border-radius: 6px;"></textarea>
<h4 style="margin-top:10px;">Top Provider (JSON, optional)</h4>
<textarea id="create-top-provider" style="width:100%; min-height: 120px; font-family: 'Monaco', monospace; font-size: 13px; background:#f8fafc; color:#2d3748; padding: 12px; border: 2px solid #e2e8f0; border-radius: 6px;"></textarea>
<div style="margin-top: 12px; display: flex; gap: 10px;">
<button id="model-create-btn" onclick="createModel()"> Create</button>
<button onclick="closeCreateModel()" style="background-color: #718096;">Cancel</button>
</div>
</div>
</div>
<div id="model-batch-modal" class="modal">
<div class="modal-content" style="max-width: 840px;">
<span class="close" onclick="closeBatchModal()">&times;</span>
<h3>Batch Add Models</h3>
<p style="font-size: 0.9rem; color: #718096; margin: 6px 0 10px;">Paste JSON in the format like models.example.json</p>
<div id="batch-error" style="display:none; margin: 10px 0; color:#e53e3e;"></div>
<textarea id="batch-json" style="width:100%; min-height: 320px; font-family: 'Monaco', monospace; font-size: 13px; background:#f8fafc; color:#2d3748; padding: 12px; border: 2px solid #e2e8f0; border-radius: 6px;"></textarea>
<div style="margin-top: 12px; display:flex; gap:10px;">
<button id="batch-submit-btn" onclick="performBatchAdd()"> Add Models</button>
<button onclick="closeBatchModal()" style="background-color:#718096;">Cancel</button>
</div>
</div>
</div>
</body>
</html>
"""
)
@admin_router.get("/models", response_class=HTMLResponse)
async def admin_models(request: Request) -> str:
if is_admin_authenticated(request):
return models_page()
return admin_auth()
@admin_router.get("/api/models", dependencies=[Depends(require_admin_api)])
async def get_models_admin_api(request: Request) -> list[dict[str, object]]:
items = await list_models()
return [m.dict() for m in items] # type: ignore
@admin_router.post("/api/models", dependencies=[Depends(require_admin_api)])
async def create_model_admin_api(payload: Model) -> dict[str, object]:
async with create_session() as session:
exists = await session.get(ModelRow, payload.id)
if exists:
raise HTTPException(
status_code=409, detail="Model with this ID already exists"
)
pricing_dict = payload.pricing.dict()
for k in ("max_prompt_cost", "max_completion_cost", "max_cost"):
pricing_dict.pop(k, None)
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.dict()),
pricing=json.dumps(pricing_dict),
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.dict())
if payload.top_provider
else None
),
)
session.add(row)
await session.commit()
created_model = await get_model_by_id(payload.id)
return created_model.dict() if created_model else {"id": payload.id} # type: ignore
@admin_router.post("/api/models/batch", dependencies=[Depends(require_admin_api)])
async def batch_create_models(payload: dict[str, object]) -> dict[str, int]:
models = payload.get("models")
if not isinstance(models, list) or not models:
raise HTTPException(
status_code=400, detail="Payload must include non-empty 'models' array"
)
created = 0
skipped = 0
async with create_session() as session:
for m in models:
try:
model = Model(**m) # type: ignore[arg-type]
except Exception:
skipped += 1
continue
exists = await session.get(ModelRow, model.id)
if exists:
skipped += 1
continue
pricing_dict = model.pricing.dict()
for k in ("max_prompt_cost", "max_completion_cost", "max_cost"):
pricing_dict.pop(k, None)
row = ModelRow(
id=model.id,
name=model.name,
description=model.description,
created=int(model.created),
context_length=int(model.context_length),
architecture=json.dumps(model.architecture.dict()),
pricing=json.dumps(pricing_dict),
sats_pricing=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
else None
),
)
session.add(row)
created += 1
if created:
await session.commit()
return {"created": created, "skipped": skipped}
@admin_router.get(
"/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)]
)
async def get_model_admin_api(model_id: str) -> dict[str, object]:
model = await get_model_by_id(model_id)
if not model:
raise HTTPException(status_code=404, detail="Model not found")
return model.dict() # type: ignore
@admin_router.patch(
"/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)]
)
async def update_model_admin_api(model_id: str, payload: Model) -> 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:
row = await session.get(ModelRow, model_id)
if not row:
raise HTTPException(status_code=404, detail="Model not found")
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.dict())
pricing_dict = payload.pricing.dict()
for k in ("max_prompt_cost", "max_completion_cost", "max_cost"):
pricing_dict.pop(k, None)
row.pricing = json.dumps(pricing_dict)
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.dict()) if payload.top_provider else None
)
session.add(row)
await session.commit()
updated = await get_model_by_id(model_id)
if not updated:
raise HTTPException(status_code=404, detail="Model not found after update")
return updated.dict() # type: ignore
@admin_router.delete(
"/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)]
)
async def delete_model_admin_api(model_id: str) -> dict[str, object]:
async with create_session() as session:
row = await session.get(ModelRow, model_id)
if not row:
raise HTTPException(status_code=404, detail="Model not found")
await session.delete(row)
await session.commit()
return {"ok": True, "deleted_id": model_id}
@admin_router.delete("/api/models", dependencies=[Depends(require_admin_api)])
async def delete_all_models_admin_api() -> dict[str, object]:
async with create_session() as session:
result = await session.exec(select(ModelRow)) # type: ignore
rows = result.all()
for row in rows:
await session.delete(row) # type: ignore
await session.commit()
return {"ok": True, "deleted": "all"}
DASHBOARD_CSS: str = """
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f7fa; color: #2c3e50; line-height: 1.6; padding: 2rem; }
@@ -1587,7 +785,7 @@ button:disabled { background: #a0aec0; cursor: not-allowed; transform: none; }
.copy-btn { background: #38a169; padding: 6px 12px; font-size: 14px; }
.copy-btn:hover { background: #2f855a; }
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); }
.modal-content { background: white; margin: 5% auto; padding: 0.75rem 1rem 2.25rem; width: 90%; max-width: 720px; max-height: 85vh; overflow-y: auto; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); animation: slideIn 0.3s ease; }
.modal-content { background: white; margin: 10% auto; padding: 2rem; width: 90%; max-width: 400px; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); animation: slideIn 0.3s ease; }
@keyframes slideIn { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.close { color: #a0aec0; float: right; font-size: 28px; font-weight: bold; cursor: pointer; margin: -10px -10px 0 0; }
.close:hover { color: #2d3748; }
-2
View File
@@ -79,8 +79,6 @@ async def balances_for_mint_and_unit(
async def init_db() -> None:
"""Initializes the database and creates tables if they don't exist."""
async with engine.begin() as conn:
if DATABASE_URL.startswith("sqlite"):
await conn.exec_driver_sql("PRAGMA journal_mode=WAL")
await conn.run_sync(SQLModel.metadata.create_all)
+1 -10
View File
@@ -1,5 +1,4 @@
import asyncio
import os
from contextlib import asynccontextmanager
from typing import AsyncGenerator
@@ -31,10 +30,7 @@ from .settings import settings as global_settings
setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.1.4-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.1.4"
__version__ = "0.1.3"
@asynccontextmanager
@@ -162,11 +158,6 @@ async def admin_redirect() -> RedirectResponse:
return RedirectResponse("/admin/")
@app.get("/v1/providers")
async def providers() -> RedirectResponse:
return RedirectResponse("/v1/providers/")
app.include_router(models_router)
app.include_router(admin_router)
app.include_router(balance_router)
+2 -2
View File
@@ -64,7 +64,7 @@ class Settings(BaseSettings):
default=120, env="PRICING_REFRESH_INTERVAL_SECONDS"
)
models_refresh_interval_seconds: int = Field(
default=360, env="MODELS_REFRESH_INTERVAL_SECONDS"
default=0, env="MODELS_REFRESH_INTERVAL_SECONDS"
)
enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH")
enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH")
@@ -234,7 +234,7 @@ class SettingsService:
merged_dict: dict[str, Any] = dict(env_resolved.dict())
merged_dict.update(
{k: v for k, v in db_json.items() if v not in (None, "", []) and v}
{k: v for k, v in db_json.items() if v not in (None, "")}
)
# Ensure primary_mint is consistent with cashu_mints if not explicitly set
+5 -3
View File
@@ -320,18 +320,20 @@ async def fetch_provider_health(endpoint_url: str) -> dict[str, Any]:
is_onion = ".onion" in endpoint_url
# Set up client arguments conditionally
proxies = None
proxies: dict[str, str] | None = None
if is_onion:
try:
tor_proxy = settings.tor_proxy_url
except Exception:
tor_proxy = "socks5://127.0.0.1:9050"
proxies = {"http://": tor_proxy, "https://": tor_proxy} # type: ignore[assignment]
proxies = {"http://": tor_proxy, "https://": tor_proxy}
async with httpx.AsyncClient(
timeout=httpx.Timeout(30.0),
follow_redirects=True,
proxies=proxies, # type: ignore[arg-type]
# NOTE: httpx < 0.28 supports the 'proxies' kwarg, 0.28+ removed it.
# We ignore the call-arg type here to keep compatibility across versions.
proxies=proxies, # type: ignore[call-arg]
) as client:
# Prefer provider's /v1/info for full details
info_url = f"{endpoint_url.rstrip('/')}/v1/info"
+1 -14
View File
@@ -230,11 +230,7 @@ async def get_lnurl_invoice(
async def raw_send_to_lnurl(
wallet: Wallet,
proofs: list[Proof],
lnurl: str,
unit: str,
amount: int | None = None,
wallet: Wallet, proofs: list[Proof], lnurl: str, unit: str
) -> int:
"""Send funds to an LNURL address.
@@ -259,11 +255,6 @@ async def raw_send_to_lnurl(
paid = await wallet.send_to_lnurl("user@getalby.com", 50, unit="usd")
"""
total_balance = sum(proof.amount for proof in proofs)
if amount and total_balance < amount:
raise ValueError("Amount to send is higher than available proofs.")
else:
assert isinstance(amount, int)
total_balance = amount
lnurl_data = await get_lnurl_data(lnurl)
if unit == "sat":
@@ -294,10 +285,6 @@ async def raw_send_to_lnurl(
melt_quote_resp = await wallet.melt_quote(
invoice=bolt11_invoice, amount_msat=final_amount
)
if amount:
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
_ = await wallet.melt(
proofs=proofs,
invoice=bolt11_invoice,
+3 -52
View File
@@ -83,9 +83,6 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
"(free)" in model.get("name", "")
or model_id == "openrouter/auto"
or model_id == "google/gemini-2.5-pro-exp-03-25"
or model_id == "opengvlab/internvl3-78b"
or model_id == "openrouter/sonoma-dusk-alpha"
or model_id == "openrouter/sonoma-sky-alpha"
):
continue
@@ -97,14 +94,6 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
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 load_models() -> list[Model]:
"""Load model definitions from a JSON file or auto-generate from OpenRouter API.
@@ -130,13 +119,7 @@ def load_models() -> list[Model]:
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 []
# Auto-generate models from OpenRouter API
logger.info("Auto-generating models from OpenRouter API")
try:
source_filter = settings.source or None
@@ -192,29 +175,6 @@ def _row_to_model(row: ModelRow) -> Model:
def _model_to_row_payload(model: Model) -> dict[str, str | int | None]:
# Apply fees to USD pricing when storing in database
exchange_fee = settings.exchange_fee
upstream_provider_fee = settings.upstream_provider_fee
total_fee_multiplier = exchange_fee * upstream_provider_fee
# Create adjusted pricing with fees applied
adjusted_pricing = model.pricing.dict()
for key in [
"prompt",
"completion",
"request",
"image",
"web_search",
"internal_reasoning",
]:
if key in adjusted_pricing:
adjusted_pricing[key] = adjusted_pricing[key] * total_fee_multiplier
# Also adjust max costs if present
for key in ["max_prompt_cost", "max_completion_cost", "max_cost"]:
if key in adjusted_pricing:
adjusted_pricing[key] = adjusted_pricing[key] * total_fee_multiplier
return {
"id": model.id,
"name": model.name,
@@ -222,7 +182,7 @@ def _model_to_row_payload(model: Model) -> dict[str, str | int | None]:
"description": model.description,
"context_length": model.context_length,
"architecture": json.dumps(model.architecture.dict()),
"pricing": json.dumps(adjusted_pricing),
"pricing": json.dumps(model.pricing.dict()),
"sats_pricing": json.dumps(model.sats_pricing.dict())
if model.sats_pricing
else None,
@@ -280,7 +240,7 @@ async def ensure_models_bootstrapped() -> None:
except Exception as e:
logger.error(f"Error loading models from {models_path}: {e}")
if not models_to_insert and is_openrouter_upstream():
if not models_to_insert:
logger.info("Bootstrapping models from OpenRouter API")
source_filter = None
try:
@@ -289,10 +249,6 @@ async def ensure_models_bootstrapped() -> 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:
@@ -434,11 +390,6 @@ async def refresh_models_periodically() -> None:
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:
+8 -11
View File
@@ -46,7 +46,7 @@ async def x_cashu_handler(
)
return await forward_to_upstream(
request, path, headers, amount, unit, max_cost_for_model, mint
request, path, headers, amount, unit, max_cost_for_model
)
except Exception as e:
error_message = str(e)
@@ -105,7 +105,6 @@ async def forward_to_upstream(
amount: int,
unit: str,
max_cost_for_model: int,
mint: str,
) -> Response | StreamingResponse:
"""Forward request to upstream and handle the response."""
if path.startswith("v1/"):
@@ -160,7 +159,7 @@ async def forward_to_upstream(
},
)
refund_token = await send_refund(amount - 60, unit, mint)
refund_token = await send_refund(amount - 60, unit)
logger.info(
"Refund processed for failed upstream request",
@@ -198,7 +197,7 @@ async def forward_to_upstream(
)
result = await handle_x_cashu_chat_completion(
response, amount, unit, max_cost_for_model, mint
response, amount, unit, max_cost_for_model
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
@@ -243,7 +242,7 @@ async def forward_to_upstream(
async def handle_x_cashu_chat_completion(
response: httpx.Response, amount: int, unit: str, max_cost_for_model: int, mint: str
response: httpx.Response, amount: int, unit: str, max_cost_for_model: int
) -> StreamingResponse | Response:
"""Handle both streaming and non-streaming chat completion responses with token-based pricing."""
logger.debug(
@@ -268,11 +267,11 @@ async def handle_x_cashu_chat_completion(
if is_streaming:
return await handle_streaming_response(
content_str, response, amount, unit, max_cost_for_model, mint
content_str, response, amount, unit, max_cost_for_model
)
else:
return await handle_non_streaming_response(
content_str, response, amount, unit, max_cost_for_model, mint
content_str, response, amount, unit, max_cost_for_model
)
except Exception as e:
@@ -299,7 +298,6 @@ async def handle_streaming_response(
amount: int,
unit: str,
max_cost_for_model: int,
mint: str,
) -> StreamingResponse:
"""Handle Server-Sent Events (SSE) streaming response."""
logger.debug(
@@ -374,7 +372,7 @@ async def handle_streaming_response(
},
)
refund_token = await send_refund(refund_amount, unit, mint)
refund_token = await send_refund(refund_amount, unit)
response_headers["X-Cashu"] = refund_token
logger.info(
@@ -426,7 +424,6 @@ async def handle_non_streaming_response(
amount: int,
unit: str,
max_cost_for_model: int,
mint: str,
) -> Response:
"""Handle regular JSON response."""
logger.debug(
@@ -487,7 +484,7 @@ async def handle_non_streaming_response(
)
if refund_amount > 0:
refund_token = await send_refund(refund_amount, unit, mint)
refund_token = await send_refund(refund_amount, unit)
response_headers["X-Cashu"] = refund_token
logger.info(
+10 -123
View File
@@ -30,104 +30,6 @@ logger = get_logger(__name__)
proxy_router = APIRouter()
def _extract_upstream_error_message(body_bytes: bytes) -> tuple[str, str | None]:
"""Extract a human-friendly message and optional upstream error code from a response body."""
message: str = "Upstream request failed"
upstream_code: str | None = None
if not body_bytes:
return message, upstream_code
try:
data = json.loads(body_bytes)
if isinstance(data, dict):
err = data.get("error")
if isinstance(err, dict):
raw_msg = err.get("message") or err.get("detail") or err.get("error")
if isinstance(raw_msg, (str, int, float)):
message = str(raw_msg)
upstream_code_raw = err.get("code") or err.get("type")
if isinstance(upstream_code_raw, (str, int, float)):
upstream_code = str(upstream_code_raw)
elif "message" in data and isinstance(data["message"], (str, int, float)):
message = str(data["message"]) # type: ignore[arg-type]
elif "detail" in data and isinstance(data["detail"], (str, int, float)):
message = str(data["detail"]) # type: ignore[arg-type]
except Exception:
preview = body_bytes.decode("utf-8", errors="ignore").strip()
if preview:
message = preview[:500]
return message, upstream_code
async def map_upstream_error_response(
request: Request,
path: str,
upstream_response: httpx.Response,
) -> Response:
"""Map upstream non-200 responses to standardized error responses.
- Known cases are mapped to friendly messages and appropriate status codes
- Unknown errors are converted to a generic 502
"""
status_code = upstream_response.status_code
headers = dict(upstream_response.headers)
content_type = headers.get("content-type", "")
try:
body_bytes = await upstream_response.aread()
except Exception:
body_bytes = b""
message, upstream_code = _extract_upstream_error_message(body_bytes)
lowered_message = message.lower()
lowered_code = (upstream_code or "").lower()
error_type = "upstream_error"
mapped_status = 502
# Specific mappings
if status_code in (400, 422):
error_type = "invalid_request_error"
mapped_status = 400
elif status_code in (401, 403):
error_type = "upstream_auth_error"
mapped_status = 502
elif status_code == 404:
# Many providers return 404 for unknown models or routes
if path.endswith("chat/completions"):
error_type = "invalid_model"
mapped_status = 400
if not message or message == "Upstream request failed":
message = "Requested model is not available upstream"
elif "model" in lowered_message or "model" in lowered_code:
error_type = "invalid_model"
mapped_status = 400
if not message or message == "Upstream request failed":
message = "Requested model is not available upstream"
else:
error_type = "upstream_error"
mapped_status = 502
elif status_code == 429:
error_type = "rate_limit_exceeded"
mapped_status = 429
elif status_code >= 500:
error_type = "upstream_error"
mapped_status = 502
# Include upstream content type hint in logs for diagnostics
logger.debug(
"Mapped upstream error",
extra={
"path": path,
"upstream_status": status_code,
"mapped_status": mapped_status,
"error_type": error_type,
"upstream_content_type": content_type,
"message_preview": message[:200],
},
)
return create_error_response(error_type, message, mapped_status, request=request)
async def handle_streaming_chat_completion(
response: httpx.Response, key: ApiKey, max_cost_for_model: int
) -> StreamingResponse:
@@ -460,17 +362,6 @@ async def forward_to_upstream(
},
)
# Map and return errors immediately to provide clear messages
if response.status_code != 200:
try:
mapped_error = await map_upstream_error_response(
request, path, response
)
finally:
await response.aclose()
await client.aclose()
return mapped_error
# For chat completions, we need to handle token-based pricing
if path.endswith("chat/completions"):
# Check if client requested streaming
@@ -636,12 +527,6 @@ async def proxy(
if request_body:
try:
request_body_dict = json.loads(request_body)
if "max_tokens" in request_body_dict:
raise HTTPException(
status_code=400,
detail={"error": "max_tokens must be an integer (without quotes)"},
)
logger.debug(
"Request body parsed",
extra={
@@ -770,10 +655,18 @@ async def proxy(
"upstream_headers": response.headers
if hasattr(response, "headers")
else None,
"upstream_response": response.body
if hasattr(response, "body")
else None,
},
)
# Return the mapped error response generated earlier rather than masking with 502
return response
request_id = (
request.state.request_id if hasattr(request.state, "request_id") else None
)
raise HTTPException(
status_code=502,
detail=f"Upstream request failed, please contact support with request id: {request_id}",
)
return response
@@ -893,12 +786,6 @@ async def forward_get_to_upstream(
"GET request forwarded successfully",
extra={"path": path, "status_code": response.status_code},
)
if response.status_code != 200:
try:
mapped = await map_upstream_error_response(request, path, response)
finally:
await response.aclose()
return mapped
return StreamingResponse(
response.aiter_bytes(),
+6 -8
View File
@@ -28,7 +28,7 @@ async def recieve_token(
wallet = await get_wallet(token_obj.mint, token_obj.unit, load=False)
wallet.keyset_id = token_obj.keysets[0]
if token_obj.mint not in settings.cashu_mints:
if token_obj.mint.rstrip("/") not in [mint.rstrip("/") for mint in settings.cashu_mints]:
return await swap_to_primary_mint(token_obj, wallet)
wallet.verify_proofs_dleq(token_obj.proofs)
@@ -152,7 +152,9 @@ async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wal
global _wallets
id = f"{mint_url}_{unit}"
if id not in _wallets:
_wallets[id] = await Wallet.with_db(mint_url, db=".wallet", unit=unit)
_wallets[id] = await Wallet.with_db(
mint_url, db=".wallet", unit=unit
)
if load:
await _wallets[id].load_mint()
@@ -297,7 +299,7 @@ async def periodic_payout() -> None:
logger.error("RECEIVE_LN_ADDRESS is not set, skipping payout")
return
while True:
await asyncio.sleep(60 * 15)
await asyncio.sleep(60 * 5)
try:
async with db.create_session() as session:
for mint_url in settings.cashu_mints:
@@ -317,11 +319,7 @@ async def periodic_payout() -> None:
min_amount = 210 if unit == "sat" else 210000
if available_balance > min_amount:
amount_received = await raw_send_to_lnurl(
wallet,
proofs,
settings.receive_ln_address,
unit,
amount=available_balance,
wallet, proofs, settings.receive_ln_address, unit
)
logger.info(
"Payout sent successfully",
-1
View File
@@ -63,7 +63,6 @@ else:
# Set test environment variables before importing the app
os.environ.update(test_env)
os.environ.pop("ADMIN_PASSWORD", None)
from routstr.core.db import ApiKey, get_session # noqa: E402
from routstr.core.main import app, lifespan # noqa: E402
@@ -549,9 +549,9 @@ class TestPerformance:
max_time = max(times)
# Average should be well under 100ms
assert (
avg_time < 100
), f"{op_type} average time {avg_time}ms exceeds 100ms"
assert avg_time < 100, (
f"{op_type} average time {avg_time}ms exceeds 100ms"
)
# No single operation should exceed 200ms
assert max_time < 200, f"{op_type} max time {max_time}ms exceeds 200ms"
@@ -271,23 +271,36 @@ async def test_models_endpoint_accept_headers(integration_client: AsyncClient) -
async def test_admin_endpoint_unauthenticated(
integration_client: AsyncClient, db_snapshot: Any
) -> None:
"""Test GET /admin/ endpoint without authentication shows setup form"""
"""Test GET /admin/ endpoint without authentication"""
# Capture initial database state
await db_snapshot.capture()
response = await integration_client.get("/admin/")
# Should return 200 with login form (not 401/403)
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
# Response should be HTML
html_content = response.text
assert "<!DOCTYPE html>" in html_content
assert "<html>" in html_content
assert "<form" in html_content
assert 'type="password"' in html_content
assert "password" in html_content.lower()
assert "<script>" in html_content or "<script " in html_content
assert "Initial Admin Setup" in html_content or "setup" in html_content.lower()
# Either shows login form or message about setting ADMIN_PASSWORD
if "ADMIN_PASSWORD" in html_content:
# When ADMIN_PASSWORD is not set, it shows a message
assert "Please set a secure ADMIN_PASSWORD" in html_content
else:
# When ADMIN_PASSWORD is set, it shows a login form
assert "<form" in html_content
assert 'type="password"' in html_content
assert "password" in html_content.lower()
assert "login" in html_content.lower()
# Should have JavaScript for form handling
assert "<script>" in html_content or "<script " in html_content
# Verify no database state changes
diff = await db_snapshot.diff()
assert len(diff["api_keys"]["added"]) == 0
assert len(diff["api_keys"]["removed"]) == 0
@@ -359,15 +372,15 @@ async def test_all_info_endpoints_no_database_changes(
# Check no database changes after each request
diff = await db_snapshot.diff()
assert (
len(diff["api_keys"]["added"]) == 0
), f"Endpoint {endpoint} added API keys"
assert (
len(diff["api_keys"]["removed"]) == 0
), f"Endpoint {endpoint} removed API keys"
assert (
len(diff["api_keys"]["modified"]) == 0
), f"Endpoint {endpoint} modified API keys"
assert len(diff["api_keys"]["added"]) == 0, (
f"Endpoint {endpoint} added API keys"
)
assert len(diff["api_keys"]["removed"]) == 0, (
f"Endpoint {endpoint} removed API keys"
)
assert len(diff["api_keys"]["modified"]) == 0, (
f"Endpoint {endpoint} modified API keys"
)
# Final verification - database state should be identical
final_state = await db_snapshot.capture()
+27 -27
View File
@@ -131,9 +131,9 @@ class TestPerformanceBaseline:
# Verify 95th percentile < 500ms
p95 = sorted(response_times)[int(len(response_times) * 0.95)]
assert (
p95 < 500
), f"{method} {path} p95 response time {p95}ms exceeds 500ms limit"
assert p95 < 500, (
f"{method} {path} p95 response time {p95}ms exceeds 500ms limit"
)
print(f"\n{method} {path}:")
print(f" Mean: {statistics.mean(response_times):.2f}ms")
@@ -175,9 +175,9 @@ class TestPerformanceBaseline:
query_times.append(duration)
# All queries should complete < 100ms
assert (
max(query_times) < 100
), f"Max query time {max(query_times)}ms exceeds 100ms limit"
assert max(query_times) < 100, (
f"Max query time {max(query_times)}ms exceeds 100ms limit"
)
print("\nDatabase query performance:")
print(f" Mean: {statistics.mean(query_times):.2f}ms")
print(f" Max: {max(query_times):.2f}ms")
@@ -272,9 +272,9 @@ class TestLoadScenarios:
# Performance requirements
assert summary["error_rate"] < 0.05, "Error rate exceeds 5%"
assert (
summary["response_times"]["p95"] < 2.0
), "P95 response time exceeds 2 seconds"
assert summary["response_times"]["p95"] < 2.0, (
"P95 response time exceeds 2 seconds"
)
@pytest.mark.asyncio
async def test_sustained_load_1000_rpm(
@@ -354,13 +354,13 @@ class TestLoadScenarios:
)
# Verify performance
assert (
actual_rps >= target_rps * 0.95
), f"Could not sustain target rate (achieved {actual_rps:.2f} req/s)"
assert actual_rps >= target_rps * 0.95, (
f"Could not sustain target rate (achieved {actual_rps:.2f} req/s)"
)
assert summary["error_rate"] < 0.01, "Error rate exceeds 1%"
assert (
summary["response_times"]["p95"] < 1.0
), "P95 response time exceeds 1 second"
assert summary["response_times"]["p95"] < 1.0, (
"P95 response time exceeds 1 second"
)
@pytest.mark.integration
@@ -418,12 +418,12 @@ class TestMemoryLeaks:
# Check for significant memory leaks
# Allow some growth but not more than 20% or 50MB total
assert (
memory_growth < 50
), f"Memory grew by {memory_growth} MB, indicating a potential leak"
assert (
memory_samples[-1] < memory_samples[0] * 1.2
), "Memory grew by more than 20%"
assert memory_growth < 50, (
f"Memory grew by {memory_growth} MB, indicating a potential leak"
)
assert memory_samples[-1] < memory_samples[0] * 1.2, (
"Memory grew by more than 20%"
)
@pytest.mark.integration
@@ -471,9 +471,9 @@ class TestPerformanceRegression:
print(f" Current: {mean_time * 1000:.1f}ms")
print(f" Difference: {((mean_time / baseline - 1) * 100):.1f}%")
assert (
mean_time <= max_allowed
), f"{endpoint} performance degraded by more than 20% (baseline: {baseline}s, current: {mean_time}s)"
assert mean_time <= max_allowed, (
f"{endpoint} performance degraded by more than 20% (baseline: {baseline}s, current: {mean_time}s)"
)
# Get overall validation results
results = {}
@@ -482,9 +482,9 @@ class TestPerformanceRegression:
endpoint, max_duration=baselines[endpoint] * 1.2, percentile=0.95
)
results[endpoint] = result
assert result[
"valid"
], f"Performance validation failed for {endpoint}: {result}"
assert result["valid"], (
f"Performance validation failed for {endpoint}: {result}"
)
# Performance test utilities
@@ -42,12 +42,12 @@ async def test_reserved_balance_never_negative(integration_client: AsyncClient)
async with create_session() as session:
key = await session.get(ApiKey, "test_reserved_balance_key")
assert key is not None
assert (
key.reserved_balance >= 0
), f"Reserved balance went negative: {key.reserved_balance}"
assert (
key.balance == 1000
), "Balance should remain unchanged after failed request"
assert key.reserved_balance >= 0, (
f"Reserved balance went negative: {key.reserved_balance}"
)
assert key.balance == 1000, (
"Balance should remain unchanged after failed request"
)
# Test 2: Simulate concurrent failed requests
# This tests the race condition protection
@@ -71,9 +71,9 @@ async def test_reserved_balance_never_negative(integration_client: AsyncClient)
async with create_session() as session:
key = await session.get(ApiKey, "test_reserved_balance_key")
assert key is not None
assert (
key.reserved_balance >= 0
), f"Reserved balance went negative after concurrent requests: {key.reserved_balance}"
assert key.reserved_balance >= 0, (
f"Reserved balance went negative after concurrent requests: {key.reserved_balance}"
)
print(f"Final state - Balance: {key.balance}, Reserved: {key.reserved_balance}")
@@ -113,20 +113,20 @@ async def test_reserved_balance_with_successful_requests(
async with create_session() as session:
key = await session.get(ApiKey, unique_key)
assert key is not None
assert (
key.reserved_balance >= 0
), f"Reserved balance went negative: {key.reserved_balance}"
assert key.reserved_balance >= 0, (
f"Reserved balance went negative: {key.reserved_balance}"
)
# Check if the request was processed (might fail due to model pricing in test env)
# The important part is that reserved_balance doesn't go negative
if key.total_spent > 0:
assert (
key.balance < 100000
), "Balance should decrease after successful request"
assert key.balance < 100000, (
"Balance should decrease after successful request"
)
else:
# Request failed, but reserved balance should still be non-negative
assert (
key.balance == 100000
), "Balance should remain unchanged if request failed"
assert key.balance == 100000, (
"Balance should remain unchanged if request failed"
)
print(
f"After successful request - Balance: {key.balance}, Reserved: {key.reserved_balance}, Spent: {key.total_spent}"
)
@@ -157,9 +157,9 @@ async def test_insufficient_reserved_balance_for_revert(
await integration_session.refresh(test_key)
# Current implementation allows negative reserved balance
assert (
test_key.reserved_balance == -100
), f"Expected reserved_balance to be -100, got: {test_key.reserved_balance}"
assert (
test_key.total_requests == -1
), f"Expected total_requests to be -1, got: {test_key.total_requests}"
assert test_key.reserved_balance == -100, (
f"Expected reserved_balance to be -100, got: {test_key.reserved_balance}"
)
assert test_key.total_requests == -1, (
f"Expected total_requests to be -1, got: {test_key.total_requests}"
)
@@ -94,9 +94,9 @@ async def test_api_key_generation_invalid_token(
response = await integration_client.get("/v1/wallet/info")
# Should fail with 401
assert (
response.status_code == 401
), f"Token {invalid_token[:20]}... should be invalid"
assert response.status_code == 401, (
f"Token {invalid_token[:20]}... should be invalid"
)
# Validate error response
validator = ResponseValidator()
@@ -198,9 +198,9 @@ async def test_authorization_header_validation(
# Make request to protected endpoint
response = await integration_client.get("/v1/wallet/")
assert (
response.status_code == expected_status
), f"{description}: Expected {expected_status}, got {response.status_code}"
assert response.status_code == expected_status, (
f"{description}: Expected {expected_status}, got {response.status_code}"
)
if expected_status == 401:
assert "detail" in response.json()
+3 -3
View File
@@ -133,9 +133,9 @@ async def test_topup_with_invalid_token(
)
# Should fail with 400
assert (
response.status_code == 400
), f"Token {invalid_token[:20]}... should be invalid"
assert response.status_code == 400, (
f"Token {invalid_token[:20]}... should be invalid"
)
# Validate error response
validator = ResponseValidator()
-781
View File
@@ -1,781 +0,0 @@
"""Unit tests for upstream provider fee application to USD pricing.
This module tests the fix for issue #188: "Upstream provider fee not being applied
to USD pricing (only sats)". The fix ensures that both exchange_fee and
upstream_provider_fee are correctly applied to USD pricing when models are stored
in the database via the _model_to_row_payload function.
Key behaviors tested:
1. exchange_fee is applied to all USD pricing fields
2. upstream_provider_fee is applied to all USD pricing fields
3. Both fees are compounded correctly (multiplied together)
4. Fees apply to all pricing attributes (prompt, completion, request, etc.)
5. Fees apply to max cost fields (max_prompt_cost, max_completion_cost, max_cost)
6. sats_pricing remains unaffected (fees not double-applied)
7. Zero-value pricing fields are handled correctly
8. Edge cases and boundary conditions are properly handled
"""
import json
import os
from unittest.mock import patch
import pytest
# Set required env vars before importing
os.environ["UPSTREAM_BASE_URL"] = "http://test"
os.environ["UPSTREAM_API_KEY"] = "test"
from routstr.core.settings import settings # noqa: E402
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, # $0.001 per prompt token
completion=0.002, # $0.002 per completion token
request=0.01, # $0.01 per request
image=0.05, # $0.05 per image
web_search=0.03, # $0.03 per web search
internal_reasoning=0.015, # $0.015 per internal reasoning token
max_prompt_cost=10.0, # $10 max prompt cost
max_completion_cost=20.0, # $20 max completion cost
max_cost=30.0, # $30 max total cost
)
@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 for fee application",
context_length=8192,
architecture=base_architecture,
pricing=standard_pricing,
)
# =============================================================================
# Individual Fee Application Tests
# =============================================================================
def test_exchange_fee_applied_to_usd_pricing(standard_model: Model) -> None:
"""Verify exchange_fee is applied to all USD pricing fields."""
exchange_fee = 1.005 # 0.5% fee
upstream_fee = 1.0 # No upstream fee
with patch.object(settings, "exchange_fee", exchange_fee):
with patch.object(settings, "upstream_provider_fee", upstream_fee):
payload = _model_to_row_payload(standard_model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
# Verify all pricing fields have exchange fee applied
assert pricing["prompt"] == pytest.approx(0.001 * exchange_fee, rel=1e-9)
assert pricing["completion"] == pytest.approx(
0.002 * exchange_fee, rel=1e-9
)
assert pricing["request"] == pytest.approx(0.01 * exchange_fee, rel=1e-9)
assert pricing["image"] == pytest.approx(0.05 * exchange_fee, rel=1e-9)
assert pricing["web_search"] == pytest.approx(0.03 * exchange_fee, rel=1e-9)
assert pricing["internal_reasoning"] == pytest.approx(
0.015 * exchange_fee, rel=1e-9
)
# Verify max cost fields have exchange fee applied
assert pricing["max_prompt_cost"] == pytest.approx(
10.0 * exchange_fee, rel=1e-9
)
assert pricing["max_completion_cost"] == pytest.approx(
20.0 * exchange_fee, rel=1e-9
)
assert pricing["max_cost"] == pytest.approx(30.0 * exchange_fee, rel=1e-9)
def test_upstream_provider_fee_applied_to_usd_pricing(standard_model: Model) -> None:
"""Verify upstream_provider_fee is applied to all USD pricing fields."""
exchange_fee = 1.0 # No exchange fee
upstream_fee = 1.05 # 5% upstream provider fee
with patch.object(settings, "exchange_fee", exchange_fee):
with patch.object(settings, "upstream_provider_fee", upstream_fee):
payload = _model_to_row_payload(standard_model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
# Verify all pricing fields have upstream fee applied
assert pricing["prompt"] == pytest.approx(0.001 * upstream_fee, rel=1e-9)
assert pricing["completion"] == pytest.approx(
0.002 * upstream_fee, rel=1e-9
)
assert pricing["request"] == pytest.approx(0.01 * upstream_fee, rel=1e-9)
assert pricing["image"] == pytest.approx(0.05 * upstream_fee, rel=1e-9)
assert pricing["web_search"] == pytest.approx(0.03 * upstream_fee, rel=1e-9)
assert pricing["internal_reasoning"] == pytest.approx(
0.015 * upstream_fee, rel=1e-9
)
# Verify max cost fields have upstream fee applied
assert pricing["max_prompt_cost"] == pytest.approx(
10.0 * upstream_fee, rel=1e-9
)
assert pricing["max_completion_cost"] == pytest.approx(
20.0 * upstream_fee, rel=1e-9
)
assert pricing["max_cost"] == pytest.approx(30.0 * upstream_fee, rel=1e-9)
# =============================================================================
# Combined Fee Application Tests (Core Issue #188 Fix)
# =============================================================================
def test_both_fees_compounded_correctly(standard_model: Model) -> None:
"""Test that exchange_fee and upstream_provider_fee are compounded (multiplied).
This is the PRIMARY test for issue #188. Before the fix, only exchange_fee was
applied to USD pricing. The fix ensures both fees are compounded correctly.
"""
exchange_fee = 1.005 # 0.5% exchange fee
upstream_fee = 1.05 # 5% upstream provider fee
expected_multiplier = exchange_fee * upstream_fee # 1.05525
with patch.object(settings, "exchange_fee", exchange_fee):
with patch.object(settings, "upstream_provider_fee", upstream_fee):
payload = _model_to_row_payload(standard_model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
# All pricing fields should be multiplied by the combined fee
assert pricing["prompt"] == pytest.approx(
0.001 * expected_multiplier, rel=1e-9
)
assert pricing["completion"] == pytest.approx(
0.002 * expected_multiplier, rel=1e-9
)
assert pricing["request"] == pytest.approx(
0.01 * expected_multiplier, rel=1e-9
)
assert pricing["image"] == pytest.approx(
0.05 * expected_multiplier, rel=1e-9
)
assert pricing["web_search"] == pytest.approx(
0.03 * expected_multiplier, rel=1e-9
)
assert pricing["internal_reasoning"] == pytest.approx(
0.015 * expected_multiplier, rel=1e-9
)
# Max cost fields should also have combined fee applied
assert pricing["max_prompt_cost"] == pytest.approx(
10.0 * expected_multiplier, rel=1e-9
)
assert pricing["max_completion_cost"] == pytest.approx(
20.0 * expected_multiplier, rel=1e-9
)
assert pricing["max_cost"] == pytest.approx(
30.0 * expected_multiplier, rel=1e-9
)
def test_default_fee_values_from_settings(standard_model: Model) -> None:
"""Test with actual default fee values from settings.
Default values per routstr/core/settings.py:
- exchange_fee: 1.005 (0.5%)
- upstream_provider_fee: 1.05 (5%)
Combined: 1.05525 (5.525% total fee)
"""
# Use actual default values (don't mock)
payload = _model_to_row_payload(standard_model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
# Calculate expected multiplier with production defaults
default_exchange_fee = 1.005
default_upstream_fee = 1.05
expected_multiplier = default_exchange_fee * default_upstream_fee # 1.05525
# Verify pricing is higher than original due to fees
assert pricing["prompt"] > standard_model.pricing.prompt
assert pricing["completion"] > standard_model.pricing.completion
assert pricing["request"] > standard_model.pricing.request
# Verify exact values with default fees
assert pricing["prompt"] == pytest.approx(0.001 * expected_multiplier, rel=1e-9)
assert pricing["completion"] == pytest.approx(0.002 * expected_multiplier, rel=1e-9)
assert pricing["request"] == pytest.approx(0.01 * expected_multiplier, rel=1e-9)
assert pricing["image"] == pytest.approx(0.05 * expected_multiplier, rel=1e-9)
assert pricing["web_search"] == pytest.approx(0.03 * expected_multiplier, rel=1e-9)
assert pricing["internal_reasoning"] == pytest.approx(
0.015 * expected_multiplier, rel=1e-9
)
# =============================================================================
# Varied Fee Scenarios
# =============================================================================
def test_higher_fee_values(standard_model: Model) -> None:
"""Test with significantly higher fee values to ensure scalability."""
exchange_fee = 1.02 # 2% exchange fee
upstream_fee = 1.15 # 15% upstream provider fee
expected_multiplier = exchange_fee * upstream_fee # 1.173
with patch.object(settings, "exchange_fee", exchange_fee):
with patch.object(settings, "upstream_provider_fee", upstream_fee):
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 * expected_multiplier, rel=1e-9
)
assert pricing["completion"] == pytest.approx(
0.002 * expected_multiplier, rel=1e-9
)
assert pricing["request"] == pytest.approx(
0.01 * expected_multiplier, rel=1e-9
)
assert pricing["max_cost"] == pytest.approx(
30.0 * expected_multiplier, rel=1e-9
)
def test_minimal_fee_values(standard_model: Model) -> None:
"""Test with fees very close to 1.0 (minimal markup)."""
exchange_fee = 1.001 # 0.1% exchange fee
upstream_fee = 1.001 # 0.1% upstream provider fee
expected_multiplier = exchange_fee * upstream_fee # 1.002001
with patch.object(settings, "exchange_fee", exchange_fee):
with patch.object(settings, "upstream_provider_fee", upstream_fee):
payload = _model_to_row_payload(standard_model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
# Verify precise calculation even with small fees
assert pricing["prompt"] == pytest.approx(
0.001 * expected_multiplier, rel=1e-9
)
assert pricing["completion"] == pytest.approx(
0.002 * expected_multiplier, rel=1e-9
)
def test_no_fees_applied(standard_model: Model) -> None:
"""Test with both fees set to 1.0 (no markup)."""
exchange_fee = 1.0 # No fee
upstream_fee = 1.0 # No fee
with patch.object(settings, "exchange_fee", exchange_fee):
with patch.object(settings, "upstream_provider_fee", upstream_fee):
payload = _model_to_row_payload(standard_model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
# Prices should remain unchanged
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["max_cost"] == pytest.approx(30.0, rel=1e-9)
# =============================================================================
# Zero and Edge Case Pricing Tests
# =============================================================================
def test_zero_value_pricing_fields(base_architecture: Architecture) -> None:
"""Verify that zero-value pricing fields are handled correctly.
Zero values should remain zero after fee application (0 * multiplier = 0).
"""
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-zero-pricing",
name="Zero Pricing Model",
created=1234567890,
description="Model with all zero pricing",
context_length=8192,
architecture=base_architecture,
pricing=zero_pricing,
)
exchange_fee = 1.005
upstream_fee = 1.05
with patch.object(settings, "exchange_fee", exchange_fee):
with patch.object(settings, "upstream_provider_fee", upstream_fee):
payload = _model_to_row_payload(model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
# All values should remain zero
assert pricing["prompt"] == pytest.approx(0.0, abs=1e-9)
assert pricing["completion"] == pytest.approx(0.0, abs=1e-9)
assert pricing["request"] == pytest.approx(0.0, abs=1e-9)
assert pricing["image"] == pytest.approx(0.0, abs=1e-9)
assert pricing["web_search"] == pytest.approx(0.0, abs=1e-9)
assert pricing["internal_reasoning"] == pytest.approx(0.0, abs=1e-9)
assert pricing["max_prompt_cost"] == pytest.approx(0.0, abs=1e-9)
assert pricing["max_completion_cost"] == pytest.approx(0.0, abs=1e-9)
assert pricing["max_cost"] == pytest.approx(0.0, abs=1e-9)
def test_mixed_zero_and_nonzero_pricing(base_architecture: Architecture) -> None:
"""Test models with some zero and some non-zero pricing fields."""
mixed_pricing = Pricing(
prompt=0.001, # Non-zero
completion=0.002, # Non-zero
request=0.0, # Zero
image=0.0, # Zero
web_search=0.03, # Non-zero
internal_reasoning=0.0, # Zero
max_prompt_cost=10.0, # Non-zero
max_completion_cost=0.0, # Zero
max_cost=15.0, # Non-zero
)
model = Model(
id="test-mixed-pricing",
name="Mixed Pricing Model",
created=1234567890,
description="Model with mixed zero/non-zero pricing",
context_length=8192,
architecture=base_architecture,
pricing=mixed_pricing,
)
exchange_fee = 1.005
upstream_fee = 1.05
expected_multiplier = exchange_fee * upstream_fee
with patch.object(settings, "exchange_fee", exchange_fee):
with patch.object(settings, "upstream_provider_fee", upstream_fee):
payload = _model_to_row_payload(model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
# Non-zero values should have fees applied
assert pricing["prompt"] == pytest.approx(
0.001 * expected_multiplier, rel=1e-9
)
assert pricing["completion"] == pytest.approx(
0.002 * expected_multiplier, rel=1e-9
)
assert pricing["web_search"] == pytest.approx(
0.03 * expected_multiplier, rel=1e-9
)
assert pricing["max_prompt_cost"] == pytest.approx(
10.0 * expected_multiplier, rel=1e-9
)
assert pricing["max_cost"] == pytest.approx(
15.0 * expected_multiplier, rel=1e-9
)
# Zero values should remain zero
assert pricing["request"] == pytest.approx(0.0, abs=1e-9)
assert pricing["image"] == pytest.approx(0.0, abs=1e-9)
assert pricing["internal_reasoning"] == pytest.approx(0.0, abs=1e-9)
assert pricing["max_completion_cost"] == pytest.approx(0.0, abs=1e-9)
def test_very_small_pricing_values(base_architecture: Architecture) -> None:
"""Test with very small pricing values to verify precision."""
tiny_pricing = Pricing(
prompt=0.000001, # $0.000001 per token
completion=0.000002,
request=0.00001,
image=0.0001,
web_search=0.0001,
internal_reasoning=0.000001,
max_prompt_cost=0.01,
max_completion_cost=0.02,
max_cost=0.03,
)
model = Model(
id="test-tiny-pricing",
name="Tiny Pricing Model",
created=1234567890,
description="Model with very small pricing values",
context_length=8192,
architecture=base_architecture,
pricing=tiny_pricing,
)
exchange_fee = 1.005
upstream_fee = 1.05
expected_multiplier = exchange_fee * upstream_fee
with patch.object(settings, "exchange_fee", exchange_fee):
with patch.object(settings, "upstream_provider_fee", upstream_fee):
payload = _model_to_row_payload(model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
# Verify precision is maintained for very small values
assert pricing["prompt"] == pytest.approx(
0.000001 * expected_multiplier, rel=1e-6
)
assert pricing["completion"] == pytest.approx(
0.000002 * expected_multiplier, rel=1e-6
)
assert pricing["request"] == pytest.approx(
0.00001 * expected_multiplier, rel=1e-6
)
def test_very_large_pricing_values(base_architecture: Architecture) -> None:
"""Test with very large pricing values to ensure no overflow."""
large_pricing = Pricing(
prompt=100.0,
completion=200.0,
request=500.0,
image=1000.0,
web_search=750.0,
internal_reasoning=150.0,
max_prompt_cost=100000.0,
max_completion_cost=200000.0,
max_cost=500000.0,
)
model = Model(
id="test-large-pricing",
name="Large Pricing Model",
created=1234567890,
description="Model with very large pricing values",
context_length=8192,
architecture=base_architecture,
pricing=large_pricing,
)
exchange_fee = 1.005
upstream_fee = 1.05
expected_multiplier = exchange_fee * upstream_fee
with patch.object(settings, "exchange_fee", exchange_fee):
with patch.object(settings, "upstream_provider_fee", upstream_fee):
payload = _model_to_row_payload(model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
# Verify large values are handled correctly
assert pricing["prompt"] == pytest.approx(
100.0 * expected_multiplier, rel=1e-9
)
assert pricing["max_cost"] == pytest.approx(
500000.0 * expected_multiplier, rel=1e-9
)
# =============================================================================
# Sats Pricing Isolation Tests
# =============================================================================
def test_sats_pricing_not_modified(
base_architecture: Architecture, standard_pricing: Pricing
) -> None:
"""Verify that sats_pricing is NOT affected by USD fee application.
This ensures the fix doesn't break existing sats pricing behavior.
The fees should only be applied to USD pricing, not sats pricing.
"""
sats_pricing = Pricing(
prompt=10.0,
completion=20.0,
request=100.0,
image=500.0,
web_search=300.0,
internal_reasoning=150.0,
max_prompt_cost=10000.0,
max_completion_cost=20000.0,
max_cost=30000.0,
)
model = Model(
id="test-with-sats",
name="Model With Sats Pricing",
created=1234567890,
description="Model with both USD and sats pricing",
context_length=8192,
architecture=base_architecture,
pricing=standard_pricing,
sats_pricing=sats_pricing,
)
exchange_fee = 1.005
upstream_fee = 1.05
with patch.object(settings, "exchange_fee", exchange_fee):
with patch.object(settings, "upstream_provider_fee", upstream_fee):
payload = _model_to_row_payload(model)
sats_pricing_str = payload["sats_pricing"]
assert isinstance(sats_pricing_str, str)
sats_pricing_result = json.loads(sats_pricing_str)
# Sats pricing should be completely unchanged
assert sats_pricing_result["prompt"] == pytest.approx(10.0, rel=1e-9)
assert sats_pricing_result["completion"] == pytest.approx(20.0, rel=1e-9)
assert sats_pricing_result["request"] == pytest.approx(100.0, rel=1e-9)
assert sats_pricing_result["image"] == pytest.approx(500.0, rel=1e-9)
assert sats_pricing_result["web_search"] == pytest.approx(300.0, rel=1e-9)
assert sats_pricing_result["internal_reasoning"] == pytest.approx(
150.0, rel=1e-9
)
assert sats_pricing_result["max_prompt_cost"] == pytest.approx(
10000.0, rel=1e-9
)
assert sats_pricing_result["max_completion_cost"] == pytest.approx(
20000.0, rel=1e-9
)
assert sats_pricing_result["max_cost"] == pytest.approx(30000.0, rel=1e-9)
def test_model_without_sats_pricing(standard_model: Model) -> None:
"""Test models that don't have sats_pricing (None value)."""
assert standard_model.sats_pricing is None
exchange_fee = 1.005
upstream_fee = 1.05
with patch.object(settings, "exchange_fee", exchange_fee):
with patch.object(settings, "upstream_provider_fee", upstream_fee):
payload = _model_to_row_payload(standard_model)
# sats_pricing should remain None
assert payload["sats_pricing"] is None
# =============================================================================
# Payload Structure Tests
# =============================================================================
def test_payload_structure_unchanged(standard_model: Model) -> None:
"""Verify the database row payload structure is not corrupted by the fix."""
with patch.object(settings, "exchange_fee", 1.005):
with patch.object(settings, "upstream_provider_fee", 1.05):
payload = _model_to_row_payload(standard_model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
# Verify all expected keys exist
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
# Verify types
assert isinstance(payload["id"], str)
assert isinstance(payload["name"], str)
assert isinstance(payload["created"], int)
assert isinstance(payload["description"], str)
assert isinstance(payload["context_length"], int)
assert isinstance(payload["architecture"], str) # JSON string
assert isinstance(payload["pricing"], str) # JSON string
assert payload["sats_pricing"] is None # None for this test model
# Verify JSON fields can be parsed
architecture_str = payload["architecture"]
assert isinstance(architecture_str, str)
architecture = json.loads(architecture_str)
pricing = json.loads(pricing_str)
assert isinstance(architecture, dict)
assert isinstance(pricing, dict)
# Verify pricing has all expected fields
expected_pricing_keys = {
"prompt",
"completion",
"request",
"image",
"web_search",
"internal_reasoning",
"max_prompt_cost",
"max_completion_cost",
"max_cost",
}
assert set(pricing.keys()) == expected_pricing_keys
def test_all_pricing_fields_present_after_fee_application(
standard_model: Model,
) -> None:
"""Ensure no pricing fields are accidentally dropped during fee application."""
with patch.object(settings, "exchange_fee", 1.005):
with patch.object(settings, "upstream_provider_fee", 1.05):
payload = _model_to_row_payload(standard_model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
# All original pricing fields must be present
assert "prompt" in pricing
assert "completion" in pricing
assert "request" in pricing
assert "image" in pricing
assert "web_search" in pricing
assert "internal_reasoning" in pricing
assert "max_prompt_cost" in pricing
assert "max_completion_cost" in pricing
assert "max_cost" in pricing
# No extra fields should be added
assert len(pricing) == 9
# =============================================================================
# Consistency and Regression Tests
# =============================================================================
def test_fee_consistency_across_all_fields(standard_model: Model) -> None:
"""Verify the same fee multiplier is applied consistently to all fields."""
exchange_fee = 1.005
upstream_fee = 1.05
expected_multiplier = exchange_fee * upstream_fee
with patch.object(settings, "exchange_fee", exchange_fee):
with patch.object(settings, "upstream_provider_fee", upstream_fee):
payload = _model_to_row_payload(standard_model)
pricing_str = payload["pricing"]
assert isinstance(pricing_str, str)
pricing = json.loads(pricing_str)
# Calculate actual multipliers for each field
prompt_multiplier = pricing["prompt"] / standard_model.pricing.prompt
completion_multiplier = (
pricing["completion"] / standard_model.pricing.completion
)
request_multiplier = pricing["request"] / standard_model.pricing.request
image_multiplier = pricing["image"] / standard_model.pricing.image
web_search_multiplier = (
pricing["web_search"] / standard_model.pricing.web_search
)
internal_reasoning_multiplier = (
pricing["internal_reasoning"]
/ standard_model.pricing.internal_reasoning
)
max_prompt_multiplier = (
pricing["max_prompt_cost"] / standard_model.pricing.max_prompt_cost
)
max_completion_multiplier = (
pricing["max_completion_cost"]
/ standard_model.pricing.max_completion_cost
)
max_cost_multiplier = pricing["max_cost"] / standard_model.pricing.max_cost
# All multipliers should be identical and equal to expected multiplier
assert prompt_multiplier == pytest.approx(expected_multiplier, rel=1e-9)
assert completion_multiplier == pytest.approx(expected_multiplier, rel=1e-9)
assert request_multiplier == pytest.approx(expected_multiplier, rel=1e-9)
assert image_multiplier == pytest.approx(expected_multiplier, rel=1e-9)
assert web_search_multiplier == pytest.approx(expected_multiplier, rel=1e-9)
assert internal_reasoning_multiplier == pytest.approx(
expected_multiplier, rel=1e-9
)
assert max_prompt_multiplier == pytest.approx(expected_multiplier, rel=1e-9)
assert max_completion_multiplier == pytest.approx(
expected_multiplier, rel=1e-9
)
assert max_cost_multiplier == pytest.approx(expected_multiplier, rel=1e-9)
def test_multiple_calls_produce_consistent_results(standard_model: Model) -> None:
"""Verify that calling _model_to_row_payload multiple times is idempotent."""
with patch.object(settings, "exchange_fee", 1.005):
with patch.object(settings, "upstream_provider_fee", 1.05):
# Call multiple times
payload1 = _model_to_row_payload(standard_model)
payload2 = _model_to_row_payload(standard_model)
payload3 = _model_to_row_payload(standard_model)
pricing1_str = payload1["pricing"]
pricing2_str = payload2["pricing"]
pricing3_str = payload3["pricing"]
assert isinstance(pricing1_str, str)
assert isinstance(pricing2_str, str)
assert isinstance(pricing3_str, str)
pricing1 = json.loads(pricing1_str)
pricing2 = json.loads(pricing2_str)
pricing3 = json.loads(pricing3_str)
# All results should be identical
assert pricing1 == pricing2
assert pricing2 == pricing3
# Original model should not be mutated
assert standard_model.pricing.prompt == 0.001
assert standard_model.pricing.completion == 0.002
def test_original_model_not_mutated(standard_model: Model) -> None:
"""Ensure the original model object is not modified by fee application."""
original_prompt = standard_model.pricing.prompt
original_completion = standard_model.pricing.completion
original_max_cost = standard_model.pricing.max_cost
with patch.object(settings, "exchange_fee", 1.005):
with patch.object(settings, "upstream_provider_fee", 1.05):
_ = _model_to_row_payload(standard_model)
# Original model should be unchanged
assert standard_model.pricing.prompt == original_prompt
assert standard_model.pricing.completion == original_completion
assert standard_model.pricing.max_cost == original_max_cost
Generated
+1 -1
View File
@@ -1783,7 +1783,7 @@ wheels = [
[[package]]
name = "routstr"
version = "0.1.4"
version = "0.1.3"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },