mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-09 11:04:36 +00:00
286 lines
9.5 KiB
Python
286 lines
9.5 KiB
Python
import json
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import Response, StreamingResponse
|
|
|
|
from .auth import pay_for_request, revert_pay_for_request, validate_bearer_key
|
|
from .core import get_logger
|
|
from .core.db import ApiKey, AsyncSession, get_session
|
|
from .core.settings import settings
|
|
from .payment.helpers import (
|
|
calculate_discounted_max_cost,
|
|
check_token_balance,
|
|
create_error_response,
|
|
get_max_cost_for_model,
|
|
)
|
|
from .upstream import (
|
|
UpstreamProvider,
|
|
handle_non_streaming_chat_completion,
|
|
handle_streaming_chat_completion,
|
|
map_upstream_error_response,
|
|
)
|
|
|
|
logger = get_logger(__name__)
|
|
proxy_router = APIRouter()
|
|
|
|
upstream = UpstreamProvider(
|
|
base_url=settings.upstream_base_url,
|
|
api_key=settings.upstream_api_key,
|
|
chat_completions_api_version=settings.chat_completions_api_version,
|
|
)
|
|
|
|
|
|
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
|
|
async def proxy(
|
|
request: Request, path: str, session: AsyncSession = Depends(get_session)
|
|
) -> Response | StreamingResponse:
|
|
"""Main proxy endpoint handler."""
|
|
request_body = await request.body()
|
|
headers = dict(request.headers)
|
|
|
|
if "x-cashu" not in headers and "authorization" not in headers.keys():
|
|
return create_error_response(
|
|
"unauthorized", "Unauthorized", 401, request=request
|
|
)
|
|
|
|
logger.info(
|
|
"Received proxy request",
|
|
extra={
|
|
"method": request.method,
|
|
"path": path,
|
|
"client_host": request.client.host if request.client else "unknown",
|
|
"user_agent": request.headers.get("user-agent", "unknown")[:100],
|
|
},
|
|
)
|
|
|
|
# Parse JSON body if present, handle empty/invalid JSON
|
|
request_body_dict = {}
|
|
if request_body:
|
|
try:
|
|
request_body_dict = json.loads(request_body)
|
|
logger.debug(
|
|
"Request body parsed",
|
|
extra={
|
|
"path": path,
|
|
"body_keys": list(request_body_dict.keys()),
|
|
"model": request_body_dict.get("model", "not_specified"),
|
|
},
|
|
)
|
|
except json.JSONDecodeError as e:
|
|
logger.error(
|
|
"Invalid JSON in request body",
|
|
extra={
|
|
"error": str(e),
|
|
"path": path,
|
|
"body_preview": request_body[:200].decode(errors="ignore")
|
|
if request_body
|
|
else "empty",
|
|
},
|
|
)
|
|
return Response(
|
|
content=json.dumps(
|
|
{"error": {"type": "invalid_request_error", "code": "invalid_json"}}
|
|
),
|
|
status_code=400,
|
|
media_type="application/json",
|
|
)
|
|
|
|
model = request_body_dict.get("model", "unknown")
|
|
_max_cost_for_model = await get_max_cost_for_model(model=model, session=session)
|
|
max_cost_for_model = await calculate_discounted_max_cost(
|
|
_max_cost_for_model, request_body_dict, session
|
|
)
|
|
check_token_balance(headers, request_body_dict, max_cost_for_model)
|
|
|
|
# Handle authentication
|
|
if x_cashu := headers.get("x-cashu", None):
|
|
logger.info(
|
|
"Processing X-Cashu payment",
|
|
extra={
|
|
"path": path,
|
|
"token_preview": x_cashu[:20] + "..." if len(x_cashu) > 20 else x_cashu,
|
|
},
|
|
)
|
|
return await upstream.handle_x_cashu(request, x_cashu, path, max_cost_for_model)
|
|
|
|
elif auth := headers.get("authorization", None):
|
|
logger.debug(
|
|
"Processing bearer token authentication",
|
|
extra={
|
|
"path": path,
|
|
"token_preview": auth[:20] + "..." if len(auth) > 20 else auth,
|
|
},
|
|
)
|
|
key = await get_bearer_token_key(headers, path, session, auth)
|
|
|
|
else:
|
|
if request.method not in ["GET"]:
|
|
logger.warning(
|
|
"Unauthorized request - no authentication provided",
|
|
extra={"method": request.method, "path": path},
|
|
)
|
|
return Response(
|
|
content=json.dumps({"detail": "Unauthorized"}),
|
|
status_code=401,
|
|
media_type="application/json",
|
|
)
|
|
|
|
logger.debug("Processing unauthenticated GET request", extra={"path": path})
|
|
# TODO: why is this needed? can we remove it?
|
|
headers = upstream.prepare_headers(dict(request.headers))
|
|
return await upstream.forward_get_request(
|
|
request, path, headers, map_upstream_error_response
|
|
)
|
|
|
|
# Only pay for request if we have request body data (for completions endpoints)
|
|
if request_body_dict:
|
|
logger.info(
|
|
"Processing payment for request",
|
|
extra={
|
|
"path": path,
|
|
"key_hash": key.hashed_key[:8] + "...",
|
|
"key_balance_before": key.balance,
|
|
"model": request_body_dict.get("model", "unknown"),
|
|
},
|
|
)
|
|
|
|
try:
|
|
await pay_for_request(key, max_cost_for_model, session)
|
|
logger.info(
|
|
"Payment processed successfully",
|
|
extra={
|
|
"path": path,
|
|
"key_hash": key.hashed_key[:8] + "...",
|
|
"key_balance_after": key.balance,
|
|
"model": request_body_dict.get("model", "unknown"),
|
|
},
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
"Payment processing failed",
|
|
extra={
|
|
"error": str(e),
|
|
"error_type": type(e).__name__,
|
|
"path": path,
|
|
"key_hash": key.hashed_key[:8] + "...",
|
|
},
|
|
)
|
|
raise
|
|
|
|
# Prepare headers for upstream
|
|
headers = upstream.prepare_headers(dict(request.headers))
|
|
|
|
# Forward to upstream and handle response
|
|
response = await upstream.forward_request(
|
|
request,
|
|
path,
|
|
headers,
|
|
request_body,
|
|
key,
|
|
max_cost_for_model,
|
|
session,
|
|
map_upstream_error_response,
|
|
handle_streaming_chat_completion,
|
|
handle_non_streaming_chat_completion,
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
await revert_pay_for_request(key, session, max_cost_for_model)
|
|
logger.warning(
|
|
"Upstream request failed, revert payment",
|
|
extra={
|
|
"status_code": response.status_code,
|
|
"path": path,
|
|
"key_hash": key.hashed_key[:8] + "...",
|
|
"key_balance": key.balance,
|
|
"max_cost_for_model": max_cost_for_model,
|
|
"upstream_headers": response.headers
|
|
if hasattr(response, "headers")
|
|
else None,
|
|
},
|
|
)
|
|
# Return the mapped error response generated earlier rather than masking with 502
|
|
return response
|
|
|
|
return response
|
|
|
|
|
|
async def get_bearer_token_key(
|
|
headers: dict, path: str, session: AsyncSession, auth: str
|
|
) -> ApiKey:
|
|
"""Handle bearer token authentication proxy requests."""
|
|
bearer_key = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
|
|
refund_address = headers.get("Refund-LNURL", None)
|
|
key_expiry_time = headers.get("Key-Expiry-Time", None)
|
|
|
|
logger.debug(
|
|
"Processing bearer token",
|
|
extra={
|
|
"path": path,
|
|
"has_refund_address": bool(refund_address),
|
|
"has_expiry_time": bool(key_expiry_time),
|
|
"bearer_key_preview": bearer_key[:20] + "..."
|
|
if len(bearer_key) > 20
|
|
else bearer_key,
|
|
},
|
|
)
|
|
|
|
# Validate key_expiry_time header
|
|
if key_expiry_time:
|
|
try:
|
|
key_expiry_time = int(key_expiry_time) # type: ignore
|
|
logger.debug(
|
|
"Key expiry time validated",
|
|
extra={"expiry_time": key_expiry_time, "path": path},
|
|
)
|
|
except ValueError:
|
|
logger.error(
|
|
"Invalid Key-Expiry-Time header",
|
|
extra={"key_expiry_time": key_expiry_time, "path": path},
|
|
)
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Invalid Key-Expiry-Time: must be a valid Unix timestamp",
|
|
)
|
|
if not refund_address:
|
|
logger.error(
|
|
"Missing Refund-LNURL header with Key-Expiry-Time",
|
|
extra={"path": path, "expiry_time": key_expiry_time},
|
|
)
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Error: Refund-LNURL header required when using Key-Expiry-Time",
|
|
)
|
|
else:
|
|
key_expiry_time = None
|
|
|
|
try:
|
|
key = await validate_bearer_key(
|
|
bearer_key,
|
|
session,
|
|
refund_address,
|
|
key_expiry_time, # type: ignore
|
|
)
|
|
logger.info(
|
|
"Bearer token validated successfully",
|
|
extra={
|
|
"path": path,
|
|
"key_hash": key.hashed_key[:8] + "...",
|
|
"key_balance": key.balance,
|
|
},
|
|
)
|
|
return key
|
|
except Exception as e:
|
|
logger.error(
|
|
"Bearer token validation failed",
|
|
extra={
|
|
"error": str(e),
|
|
"error_type": type(e).__name__,
|
|
"path": path,
|
|
"bearer_key_preview": bearer_key[:20] + "..."
|
|
if len(bearer_key) > 20
|
|
else bearer_key,
|
|
},
|
|
)
|
|
raise
|