mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-31 07:46:15 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46a1e8ebef | ||
|
|
9593437a81 | ||
|
|
801bc50fd7 |
@@ -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()
|
||||
|
||||
@@ -12,8 +12,6 @@ dist/
|
||||
# Development
|
||||
.notes
|
||||
.*keys.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.*wallet.sqlite3
|
||||
*models.json
|
||||
.cashu
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
3.11
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()">×</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()">×</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()">×</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; }
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user