Compare commits

...
Author SHA1 Message Date
9qeklajc b88e0d6fd0 usage calculation 2025-11-12 23:39:35 +01:00
2 changed files with 200 additions and 11 deletions
+177 -3
View File
@@ -1,4 +1,7 @@
import base64
import math import math
import struct
from typing import Optional
from pydantic.v1 import BaseModel from pydantic.v1 import BaseModel
@@ -25,8 +28,163 @@ class CostDataError(BaseModel):
code: str code: str
def get_image_resolution_from_data_url(data_url: str) -> Optional[tuple[int, int]]:
"""
Extract image resolution (width, height) from a base64 data URL without DOM.
Supports PNG and JPEG. Returns None if format unsupported or parsing fails.
"""
try:
if not isinstance(data_url, str) or not data_url.startswith("data:"):
return None
comma_idx = data_url.find(",")
if comma_idx == -1:
return None
meta = data_url[5:comma_idx] # e.g. "image/png;base64"
base64_data = data_url[comma_idx + 1:]
# Decode base64 to binary
try:
binary_data = base64.b64decode(base64_data)
except Exception:
return None
is_png = "image/png" in meta
is_jpeg = "image/jpeg" in meta or "image/jpg" in meta
# PNG: width/height are 4-byte big-endian at offsets 16 and 20
if is_png:
# Validate PNG signature
png_sig = b'\x89PNG\r\n\x1a\n'
if not binary_data.startswith(png_sig):
return None
if len(binary_data) < 24:
return None
# Width and height are at bytes 16-19 and 20-23 respectively
width = struct.unpack('>I', binary_data[16:20])[0]
height = struct.unpack('>I', binary_data[20:24])[0]
if width > 0 and height > 0:
return (width, height)
return None
# JPEG: parse markers to SOF0/SOF2 for dimensions
if is_jpeg:
offset = 0
# JPEG SOI 0xFFD8
if len(binary_data) < 2 or binary_data[0] != 0xFF or binary_data[1] != 0xD8:
return None
offset = 2
while offset < len(binary_data):
# Find marker
while offset < len(binary_data) and binary_data[offset] != 0xFF:
offset += 1
if offset + 1 >= len(binary_data):
break
# Skip fill bytes 0xFF
while offset < len(binary_data) and binary_data[offset] == 0xFF:
offset += 1
if offset >= len(binary_data):
break
marker = binary_data[offset]
offset += 1
# Standalone markers without length
if marker == 0xD8 or marker == 0xD9: # SOI/EOI
continue
if offset + 1 >= len(binary_data):
break
length = (binary_data[offset] << 8) | binary_data[offset + 1]
offset += 2
# SOF0 (0xC0) or SOF2 (0xC2) contain dimensions
if marker == 0xC0 or marker == 0xC2:
if length < 7 or offset + length - 2 > len(binary_data):
return None
# Skip precision byte
height = (binary_data[offset + 1] << 8) | binary_data[offset + 2]
width = (binary_data[offset + 3] << 8) | binary_data[offset + 4]
if width > 0 and height > 0:
return (width, height)
return None
else:
# Skip this segment
offset += length - 2
return None
# Unsupported formats (e.g., webp/gif) - skip for now
return None
except Exception:
return None
def calculate_image_tokens_from_messages(messages: list) -> int:
"""
Calculate image tokens from messages using 32px patch method.
"""
image_tokens = 0
try:
for msg in messages:
content = msg.get("content")
if isinstance(content, list):
for part in content:
if (isinstance(part, dict) and
part.get("type") == "image_url"):
image_url = part.get("image_url")
url: Optional[str] = None
if isinstance(image_url, str):
url = image_url
elif isinstance(image_url, dict):
url = image_url.get("url")
else:
continue
# Expecting a base64 data URL for local image inputs
if url and isinstance(url, str) and url.startswith("data:"):
resolution = get_image_resolution_from_data_url(url)
if resolution:
width, height = resolution
patch_size = 32
patches_w = (width + patch_size - 1) // patch_size
patches_h = (height + patch_size - 1) // patch_size
tokens_from_image = patches_w * patches_h
image_tokens += tokens_from_image
logger.debug(
"Calculated image tokens",
extra={
"width": width,
"height": height,
"tokens_from_image": tokens_from_image,
}
)
else:
logger.warning(
"Could not determine image resolution",
extra={"url_prefix": url[:50] + "..." if len(url) > 50 else url}
)
except Exception as e:
logger.error(
"Error calculating image tokens",
extra={"error": str(e), "error_type": type(e).__name__}
)
return image_tokens
async def calculate_cost( # todo: can be sync async def calculate_cost( # todo: can be sync
response_data: dict, max_cost: int, session: AsyncSession response_data: dict, max_cost: int, session: AsyncSession, request_data: Optional[dict] = None
) -> CostData | MaxCostData | CostDataError: ) -> CostData | MaxCostData | CostDataError:
""" """
Calculate the cost of an API request based on token usage. Calculate the cost of an API request based on token usage.
@@ -34,6 +192,8 @@ async def calculate_cost( # todo: can be sync
Args: Args:
response_data: Response data containing usage information response_data: Response data containing usage information
max_cost: Maximum cost in millisats max_cost: Maximum cost in millisats
session: Database session
request_data: Original request data containing messages (for image token calculation)
Returns: Returns:
Cost data or error information Cost data or error information
@@ -132,14 +292,28 @@ async def calculate_cost( # todo: can be sync
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0) input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0) output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3) # Calculate image tokens from request data if available
image_tokens = 0
if request_data and "messages" in request_data:
image_tokens = calculate_image_tokens_from_messages(request_data["messages"])
logger.debug(
"Image tokens calculated",
extra={"image_tokens": image_tokens, "text_input_tokens": input_tokens}
)
# Add image tokens to input tokens for cost calculation
total_input_tokens = input_tokens + image_tokens
input_msats = round(total_input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3) output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
token_based_cost = math.ceil(input_msats + output_msats) token_based_cost = math.ceil(input_msats + output_msats)
logger.info( logger.info(
"Calculated token-based cost", "Calculated token-based cost",
extra={ extra={
"input_tokens": input_tokens, "text_input_tokens": input_tokens,
"image_tokens": image_tokens,
"total_input_tokens": total_input_tokens,
"output_tokens": output_tokens, "output_tokens": output_tokens,
"input_cost_msats": input_msats, "input_cost_msats": input_msats,
"output_cost_msats": output_msats, "output_cost_msats": output_msats,
+23 -8
View File
@@ -4,7 +4,7 @@ import json
import re import re
import traceback import traceback
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import Mapping from typing import Mapping, Optional
import httpx import httpx
from fastapi import BackgroundTasks, HTTPException, Request from fastapi import BackgroundTasks, HTTPException, Request
@@ -875,13 +875,14 @@ class BaseUpstreamProvider:
) )
async def get_x_cashu_cost( async def get_x_cashu_cost(
self, response_data: dict, max_cost_for_model: int self, response_data: dict, max_cost_for_model: int, request_data: Optional[dict] = None
) -> MaxCostData | CostData | None: ) -> MaxCostData | CostData | None:
"""Calculate cost for X-Cashu payment based on response data. """Calculate cost for X-Cashu payment based on response data.
Args: Args:
response_data: Response data containing model and usage information response_data: Response data containing model and usage information
max_cost_for_model: Maximum cost for the model max_cost_for_model: Maximum cost for the model
request_data: Original request data containing messages (for image token calculation)
Returns: Returns:
Cost data object (MaxCostData or CostData) or None if calculation fails Cost data object (MaxCostData or CostData) or None if calculation fails
@@ -893,7 +894,7 @@ class BaseUpstreamProvider:
) )
async with create_session() as session: async with create_session() as session:
match await calculate_cost(response_data, max_cost_for_model, session): match await calculate_cost(response_data, max_cost_for_model, session, request_data):
case MaxCostData() as cost: case MaxCostData() as cost:
logger.debug( logger.debug(
"Using max cost pricing", "Using max cost pricing",
@@ -1017,6 +1018,7 @@ class BaseUpstreamProvider:
unit: str, unit: str,
max_cost_for_model: int, max_cost_for_model: int,
mint: str | None = None, mint: str | None = None,
request_data: Optional[dict] = None,
) -> StreamingResponse: ) -> StreamingResponse:
"""Handle streaming response for X-Cashu payment, calculating refund if needed. """Handle streaming response for X-Cashu payment, calculating refund if needed.
@@ -1075,7 +1077,7 @@ class BaseUpstreamProvider:
response_data = {"usage": usage_data, "model": model} response_data = {"usage": usage_data, "model": model}
try: try:
cost_data = await self.get_x_cashu_cost( cost_data = await self.get_x_cashu_cost(
response_data, max_cost_for_model response_data, max_cost_for_model, request_data
) )
if cost_data: if cost_data:
if unit == "msat": if unit == "msat":
@@ -1150,6 +1152,7 @@ class BaseUpstreamProvider:
unit: str, unit: str,
max_cost_for_model: int, max_cost_for_model: int,
mint: str | None = None, mint: str | None = None,
request_data: Optional[dict] = None,
) -> Response: ) -> Response:
"""Handle non-streaming response for X-Cashu payment, calculating refund if needed. """Handle non-streaming response for X-Cashu payment, calculating refund if needed.
@@ -1170,7 +1173,7 @@ class BaseUpstreamProvider:
try: try:
response_json = json.loads(content_str) response_json = json.loads(content_str)
cost_data = await self.get_x_cashu_cost(response_json, max_cost_for_model) cost_data = await self.get_x_cashu_cost(response_json, max_cost_for_model, request_data)
if not cost_data: if not cost_data:
logger.error( logger.error(
@@ -1280,6 +1283,7 @@ class BaseUpstreamProvider:
unit: str, unit: str,
max_cost_for_model: int, max_cost_for_model: int,
mint: str | None = None, mint: str | None = None,
request_data: Optional[dict] = None,
) -> StreamingResponse | Response: ) -> StreamingResponse | Response:
"""Handle chat completion response for X-Cashu payment, detecting streaming vs non-streaming. """Handle chat completion response for X-Cashu payment, detecting streaming vs non-streaming.
@@ -1316,11 +1320,11 @@ class BaseUpstreamProvider:
if is_streaming: if is_streaming:
return await self.handle_x_cashu_streaming_response( return await self.handle_x_cashu_streaming_response(
content_str, response, amount, unit, max_cost_for_model, mint content_str, response, amount, unit, max_cost_for_model, mint, request_data
) )
else: else:
return await self.handle_x_cashu_non_streaming_response( return await self.handle_x_cashu_non_streaming_response(
content_str, response, amount, unit, max_cost_for_model, mint content_str, response, amount, unit, max_cost_for_model, mint, request_data
) )
except Exception as e: except Exception as e:
@@ -1372,6 +1376,17 @@ class BaseUpstreamProvider:
request_body = await request.body() request_body = await request.body()
transformed_body = self.prepare_request_body(request_body, model_obj) transformed_body = self.prepare_request_body(request_body, model_obj)
# Parse request data for image token calculation
request_data = None
try:
if request_body:
request_data = json.loads(request_body.decode('utf-8'))
except Exception as e:
logger.warning(
"Could not parse request body for image token calculation",
extra={"error": str(e)}
)
logger.debug( logger.debug(
"Forwarding request to upstream", "Forwarding request to upstream",
extra={ extra={
@@ -1457,7 +1472,7 @@ class BaseUpstreamProvider:
) )
result = await self.handle_x_cashu_chat_completion( result = await self.handle_x_cashu_chat_completion(
response, amount, unit, max_cost_for_model, mint response, amount, unit, max_cost_for_model, mint, request_data
) )
background_tasks = BackgroundTasks() background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose) background_tasks.add_task(response.aclose)