Compare commits

..
Author SHA1 Message Date
9qeklajc 558ef52e7c Merge branch 'fix-open-claude-cost-calculation' into otrta
# Conflicts:
#	routstr/upstream/base.py
2026-03-29 23:37:52 +02:00
9qeklajc dcd924b57d Merge branch 'fix-display-api-key-infos' into otrta 2026-03-29 23:36:32 +02:00
9qeklajc ce380d2aed Merge branch 'simplify-x-cashu-refund' into otrta 2026-03-29 23:36:31 +02:00
9qeklajc 8f96cb6e85 fix handling x cashu for messages endpoint 2026-03-29 23:35:26 +02:00
9qeklajc 5174c42e7c fix claude code and open code 2026-03-28 00:48:15 +01:00
9qeklajc 8c1ab05016 fix open code cost calculation 2026-03-27 22:53:40 +01:00
6 changed files with 996 additions and 873 deletions
+4 -9
View File
@@ -732,22 +732,20 @@ async def adjust_payment_for_tokens(
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
balance=col(ApiKey.balance) - cost.total_msats,
total_spent=col(ApiKey.total_spent) + cost.total_msats,
)
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent and reserved_balance on the child key if it's different
# Also update total_spent and balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
total_spent=col(ApiKey.total_spent) + cost.total_msats,
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
balance=col(ApiKey.balance) - cost.total_msats,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
@@ -819,23 +817,20 @@ async def adjust_payment_for_tokens(
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
balance=col(ApiKey.balance) - total_cost_msats,
total_spent=col(ApiKey.total_spent) + total_cost_msats,
)
)
await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent and reserved_balance on the child key if it's different
# Also update total_spent and balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
total_spent=col(ApiKey.total_spent) + total_cost_msats,
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
balance=col(ApiKey.balance) - total_cost_msats,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
+22 -57
View File
@@ -205,57 +205,37 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None:
_refund_cache[key] = (expiry, value)
@router.post("/refund", response_model=None)
@router.post("/refund")
async def refund_wallet_endpoint(
authorization: Annotated[str | None, Header()] = None,
authorization: Annotated[str, Header(...)],
x_cashu: Annotated[str | None, Header()] = None,
session: AsyncSession = Depends(get_session),
) -> JSONResponse | dict[str, str]:
if x_cashu:
# Find the "in" transaction by the original payment token
in_tx_result = await session.exec(
select(CashuTransaction).where(
CashuTransaction.token == x_cashu,
CashuTransaction.type == "in",
)
)
in_tx = in_tx_result.first()
if in_tx is None:
raise HTTPException(status_code=404, detail="Refund not found")
# Use the request_id to find the associated "out" (refund) transaction
if in_tx.request_id is None:
raise HTTPException(status_code=404, detail="Refund not found")
out_tx_result = await session.exec(
select(CashuTransaction).where(
CashuTransaction.request_id == in_tx.request_id,
CashuTransaction.type == "out",
)
)
out_tx = out_tx_result.first()
if out_tx is None:
raise HTTPException(status_code=404, detail="Refund not found")
if out_tx.swept:
raise HTTPException(status_code=410, detail="Refund has been swept")
out_tx.collected = True
session.add(out_tx)
await session.commit()
body: dict[str, str] = {"token": out_tx.token}
if out_tx.unit == "sat":
body["sats"] = str(out_tx.amount)
else:
body["msats"] = str(out_tx.amount)
return JSONResponse(content=body, headers={"X-Cashu": out_tx.token})
if authorization is None or not authorization.startswith("Bearer "):
) -> dict[str, str]:
if not authorization.startswith("Bearer "):
raise HTTPException(
status_code=401,
detail="Invalid authorization. Use 'Bearer <cashu-token>' or 'Bearer <api-key>'",
)
bearer_value: str = authorization[7:]
if x_cashu:
payment_token_hash = hashlib.sha256(x_cashu.strip().encode()).hexdigest()
result = await session.get(CashuTransaction, payment_token_hash)
if result is None:
raise HTTPException(status_code=404, detail="Refund not found")
if result.swept:
raise HTTPException(status_code=410, detail="Refund has been swept")
result.collected = True
session.add(result)
await session.commit()
body: dict[str, str] = {"token": result.token}
if result.unit == "sat":
body["sats"] = str(result.amount)
else:
body["msats"] = str(result.amount)
return JSONResponse(content=body, headers={"X-Cashu": result.token})
key: ApiKey = await validate_bearer_key(bearer_value, session)
if key.total_balance <= 0:
@@ -268,12 +248,6 @@ async def refund_wallet_endpoint(
detail="Cannot refund child key. Please refund the parent key instead.",
)
if key.reserved_balance > 0:
raise HTTPException(
status_code=400,
detail="Cannot refund key. There are ongoing requests for this api key.",
)
remaining_balance_msats: int = key.total_balance
if key.refund_currency == "sat":
@@ -328,20 +302,11 @@ async def refund_wallet_endpoint(
await _refund_cache_set(bearer_value, result)
previous_reserved_balance = key.reserved_balance
key.balance = 0
key.reserved_balance = 0
session.add(key)
await session.commit()
logger.info(
"refund_wallet_endpoint: refund successful",
extra={
"refunded_msats": remaining_balance_msats,
"previous_reserved_balance": previous_reserved_balance,
},
)
return result
+18 -41
View File
@@ -109,20 +109,12 @@ async def calculate_cost( # todo: can be sync
)
usd_cost = 0.0
input_usd = 0.0
output_usd = 0.0
# Prioritize cost_details.upstream_inference_cost
if "cost_details" in usage_data:
usd_cost = float(
usage_data["cost_details"].get("upstream_inference_cost", 0) or 0
)
input_usd = float(
usage_data["cost_details"].get("upstream_inference_prompt_cost", 0) or 0
)
output_usd = float(
usage_data["cost_details"].get("upstream_inference_completions_cost", 0)
or 0
)
# Fallback to cost field if upstream_inference_cost is 0
if usd_cost == 0 and "cost" in usage_data:
@@ -131,34 +123,12 @@ async def calculate_cost( # todo: can be sync
except Exception:
pass
MSATS_PER_1K_INPUT_TOKENS: float = (
float(settings.fixed_per_1k_input_tokens) * 1000.0
)
MSATS_PER_1K_OUTPUT_TOKENS: float = (
float(settings.fixed_per_1k_output_tokens) * 1000.0
)
if usd_cost > 0:
try:
sats_per_usd = 1.0 / sats_usd_price()
cost_in_sats = usd_cost * sats_per_usd
cost_in_msats = math.ceil(cost_in_sats * 1000)
input_msats = 0
output_msats = 0
if input_usd > 0 or output_usd > 0:
input_msats = int((input_usd * sats_per_usd) * 1000)
output_msats = int((output_usd * sats_per_usd) * 1000)
else:
total_tokens = input_tokens + output_tokens
if total_tokens > 0:
input_ratio = input_tokens / total_tokens
input_msats = int(cost_in_msats * input_ratio)
output_msats = cost_in_msats - input_msats
else:
output_msats = cost_in_msats
logger.info(
"Using cost from usage data/details",
extra={
@@ -170,9 +140,9 @@ async def calculate_cost( # todo: can be sync
)
return CostData(
base_msats=0,
input_msats=input_msats,
output_msats=output_msats,
base_msats=-1,
input_msats=-1, # Cost field doesn't break down by token type
output_msats=-1,
total_msats=cost_in_msats,
total_usd=usd_cost,
input_tokens=input_tokens,
@@ -189,6 +159,13 @@ async def calculate_cost( # todo: can be sync
)
# Fall through to token-based calculation
MSATS_PER_1K_INPUT_TOKENS: float = (
float(settings.fixed_per_1k_input_tokens) * 1000.0
)
MSATS_PER_1K_OUTPUT_TOKENS: float = (
float(settings.fixed_per_1k_output_tokens) * 1000.0
)
if not settings.fixed_pricing:
response_model = response_data.get("model", "")
logger.debug(
@@ -254,10 +231,10 @@ async def calculate_cost( # todo: can be sync
output_tokens=output_tokens,
)
calc_input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
calc_output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(calc_input_msats + calc_output_msats)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(input_msats + output_msats)
total_usd = (token_based_cost / 1000.0) * sats_usd_price()
logger.info(
@@ -265,8 +242,8 @@ async def calculate_cost( # todo: can be sync
extra={
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"input_cost_msats": calc_input_msats,
"output_cost_msats": calc_output_msats,
"input_cost_msats": input_msats,
"output_cost_msats": output_msats,
"total_cost_msats": token_based_cost,
"total_usd": total_usd,
"model": response_data.get("model", "unknown"),
@@ -275,8 +252,8 @@ async def calculate_cost( # todo: can be sync
return CostData(
base_msats=0,
input_msats=int(calc_input_msats),
output_msats=int(calc_output_msats),
input_msats=int(input_msats),
output_msats=int(output_msats),
total_msats=token_based_cost,
total_usd=total_usd,
input_tokens=input_tokens,
+934 -734
View File
File diff suppressed because it is too large Load Diff
+16 -32
View File
@@ -1,42 +1,27 @@
import json
import hashlib
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi.responses import JSONResponse
from routstr.balance import refund_wallet_endpoint
from routstr.core.db import CashuTransaction
def _make_cashu_tx(
token: str,
amount: int,
unit: str,
type: str = "out",
request_id: str | None = "req-abc",
swept: bool = False,
collected: bool = False,
) -> CashuTransaction:
tx = CashuTransaction(token=token, amount=amount, unit=unit, type=type, request_id=request_id)
def _make_cashu_tx(token: str, amount: int, unit: str, swept: bool = False) -> CashuTransaction:
tx = CashuTransaction(token=token, amount=amount, unit=unit)
tx.swept = swept
tx.collected = collected
tx.collected = False
return tx
def _exec_result(tx: CashuTransaction | None) -> MagicMock:
result = MagicMock()
result.first.return_value = tx
return result
@pytest.mark.asyncio
async def test_refund_x_cashu_returns_token() -> None:
x_cashu_token = "cashuAtest_token_value"
in_tx = _make_cashu_tx(token=x_cashu_token, amount=0, unit="msat", type="in", request_id="req-abc")
out_tx = _make_cashu_tx(token="cashuArefund_token", amount=1000, unit="msat", type="out", request_id="req-abc")
expected_hash = hashlib.sha256(x_cashu_token.strip().encode()).hexdigest()
tx = _make_cashu_tx(token="cashuArefund_token", amount=1000, unit="msat")
session = MagicMock()
session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)])
session.get = AsyncMock(return_value=tx)
session.add = MagicMock()
session.commit = AsyncMock()
@@ -46,22 +31,22 @@ async def test_refund_x_cashu_returns_token() -> None:
session=session,
)
assert isinstance(result, JSONResponse)
session.get.assert_awaited_once_with(CashuTransaction, expected_hash)
import json
body = json.loads(result.body)
assert body["token"] == "cashuArefund_token"
assert body["msats"] == "1000"
assert result.headers["X-Cashu"] == "cashuArefund_token"
assert out_tx.collected is True
assert tx.collected is True
@pytest.mark.asyncio
async def test_refund_x_cashu_sat_unit() -> None:
x_cashu_token = "cashuAsat_token"
in_tx = _make_cashu_tx(token=x_cashu_token, amount=0, unit="sat", type="in", request_id="req-sat")
out_tx = _make_cashu_tx(token="cashuArefund_sat", amount=500, unit="sat", type="out", request_id="req-sat")
tx = _make_cashu_tx(token="cashuArefund_sat", amount=500, unit="sat")
session = MagicMock()
session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)])
session.get = AsyncMock(return_value=tx)
session.add = MagicMock()
session.commit = AsyncMock()
@@ -71,7 +56,7 @@ async def test_refund_x_cashu_sat_unit() -> None:
session=session,
)
assert isinstance(result, JSONResponse)
import json
body = json.loads(result.body)
assert body["token"] == "cashuArefund_sat"
assert body["sats"] == "500"
@@ -84,7 +69,7 @@ async def test_refund_x_cashu_not_found_raises_404() -> None:
from fastapi import HTTPException
session = MagicMock()
session.exec = AsyncMock(return_value=_exec_result(None))
session.get = AsyncMock(return_value=None)
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
@@ -100,11 +85,10 @@ async def test_refund_x_cashu_not_found_raises_404() -> None:
async def test_refund_x_cashu_swept_raises_410() -> None:
from fastapi import HTTPException
in_tx = _make_cashu_tx(token="cashuAswept_token", amount=0, unit="msat", type="in", request_id="req-swept")
out_tx = _make_cashu_tx(token="cashuAswept", amount=100, unit="msat", type="out", request_id="req-swept", swept=True)
tx = _make_cashu_tx(token="cashuAswept", amount=100, unit="msat", swept=True)
session = MagicMock()
session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)])
session.get = AsyncMock(return_value=tx)
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
@@ -50,6 +50,7 @@ export function CashuPaymentWorkflow({
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
const [isCreatingKey, setIsCreatingKey] = useState(false);
const [isTopupLoading, setIsTopupLoading] = useState(false);
const [hasInteractedManage, setHasInteractedManage] = useState(false);
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
const [balanceLimit, setBalanceLimit] = useState<string>('');
const [balanceLimitReset, setBalanceLimitReset] = useState<string>('');
@@ -294,6 +295,7 @@ export function CashuPaymentWorkflow({
<ApiKeyInput
value={apiKeyInput}
onApiKeyChange={handleApiKeyChange}
onFocus={() => setHasInteractedManage(true)}
/>
<div className='flex gap-2'>
<Button