mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 23:36:15 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bcf7bb948 |
@@ -2790,186 +2790,6 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
class CreateAccountRequest(BaseModel):
|
||||
provider_type: str
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/create-account",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def create_provider_account_by_type(
|
||||
payload: CreateAccountRequest,
|
||||
) -> dict[str, object]:
|
||||
"""Create a new account with a provider by provider type (before provider exists in DB)."""
|
||||
from ..upstream import upstream_provider_classes
|
||||
|
||||
provider_class = next(
|
||||
(
|
||||
cls
|
||||
for cls in upstream_provider_classes
|
||||
if cls.provider_type == payload.provider_type
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not provider_class:
|
||||
raise HTTPException(status_code=404, detail="Provider type not found")
|
||||
|
||||
try:
|
||||
account_data = await provider_class.create_account_static()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"account_data": account_data,
|
||||
"message": "Account created successfully",
|
||||
}
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider does not support account creation: {str(e)}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to create account for provider type {payload.provider_type}: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
class TopupRequest(BaseModel):
|
||||
amount: int
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/api/upstream-providers/{provider_id}/topup",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def initiate_provider_topup(
|
||||
provider_id: int, payload: TopupRequest
|
||||
) -> dict[str, object]:
|
||||
"""Initiate a Lightning Network top-up for the upstream provider account."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Initiating top-up for provider {provider_id}",
|
||||
extra={"amount": payload.amount},
|
||||
)
|
||||
topup_data = await upstream_instance.initiate_topup(payload.amount)
|
||||
logger.info(
|
||||
"Top-up initiated successfully",
|
||||
extra={
|
||||
"provider_id": provider_id,
|
||||
"invoice_id": topup_data.invoice_id,
|
||||
"amount": topup_data.amount,
|
||||
},
|
||||
)
|
||||
|
||||
response_data = {
|
||||
"ok": True,
|
||||
"topup_data": {
|
||||
"invoice_id": topup_data.invoice_id,
|
||||
"payment_request": topup_data.payment_request,
|
||||
"amount": topup_data.amount,
|
||||
"currency": topup_data.currency,
|
||||
"expires_at": topup_data.expires_at,
|
||||
"checkout_url": topup_data.checkout_url,
|
||||
},
|
||||
"message": "Top-up initiated successfully",
|
||||
}
|
||||
logger.info("Returning response", extra={"response": response_data})
|
||||
return response_data
|
||||
except NotImplementedError as e:
|
||||
logger.error(f"Provider does not support top-up: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Provider does not support top-up: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to initiate top-up for provider {provider_id}: {e}",
|
||||
extra={"error_type": type(e).__name__, "error": str(e)},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/upstream-providers/{provider_id}/topup/{invoice_id}/status",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, object]:
|
||||
"""Check the status of a Lightning Network top-up invoice."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
from ..upstream.ppqai import PPQAIUpstreamProvider
|
||||
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
if not isinstance(upstream_instance, PPQAIUpstreamProvider):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Provider does not support top-up status checking",
|
||||
)
|
||||
|
||||
try:
|
||||
paid = await upstream_instance.check_topup_status(invoice_id)
|
||||
return {"ok": True, "paid": paid}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to check top-up status for provider {provider_id}: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/upstream-providers/{provider_id}/balance",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def get_provider_balance(provider_id: int) -> dict[str, object]:
|
||||
"""Get the current account balance for the upstream provider."""
|
||||
from ..upstream.helpers import _instantiate_provider
|
||||
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
upstream_instance = _instantiate_provider(provider)
|
||||
if not upstream_instance:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Could not instantiate provider"
|
||||
)
|
||||
|
||||
try:
|
||||
balance_data = await upstream_instance.get_balance()
|
||||
return {"ok": True, "balance_data": balance_data}
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider does not support balance checking: {str(e)}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch balance for provider {provider_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/openrouter-presets",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
|
||||
@@ -8,7 +8,6 @@ from .ollama import OllamaUpstreamProvider
|
||||
from .openai import OpenAIUpstreamProvider
|
||||
from .openrouter import OpenRouterUpstreamProvider
|
||||
from .perplexity import PerplexityUpstreamProvider
|
||||
from .ppqai import PPQAIUpstreamProvider
|
||||
from .xai import XAIUpstreamProvider
|
||||
|
||||
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
@@ -21,7 +20,6 @@ upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
|
||||
OpenAIUpstreamProvider,
|
||||
OpenRouterUpstreamProvider,
|
||||
PerplexityUpstreamProvider,
|
||||
PPQAIUpstreamProvider,
|
||||
XAIUpstreamProvider,
|
||||
]
|
||||
"""List of all upstream classes"""
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Mapping
|
||||
import httpx
|
||||
from fastapi import BackgroundTasks, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..auth import adjust_payment_for_tokens
|
||||
from ..core import get_logger
|
||||
@@ -38,17 +37,6 @@ from ..wallet import recieve_token, send_token
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TopupData(BaseModel):
|
||||
"""Universal top-up data schema for Lightning Network invoices."""
|
||||
|
||||
invoice_id: str
|
||||
payment_request: str
|
||||
amount: int
|
||||
currency: str
|
||||
expires_at: int | None = None
|
||||
checkout_url: str | None = None
|
||||
|
||||
|
||||
class BaseUpstreamProvider:
|
||||
"""Provider for forwarding requests to an upstream AI service API."""
|
||||
|
||||
@@ -99,7 +87,7 @@ class BaseUpstreamProvider:
|
||||
"""Get metadata about this provider type for API responses.
|
||||
|
||||
Returns:
|
||||
Dict with provider type metadata including id, name, default_base_url, fixed_base_url, platform_url, can_create_account, can_topup, can_show_balance
|
||||
Dict with provider type metadata including id, name, default_base_url, fixed_base_url, platform_url
|
||||
"""
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
@@ -107,9 +95,6 @@ class BaseUpstreamProvider:
|
||||
"default_base_url": cls.default_base_url or "",
|
||||
"fixed_base_url": bool(cls.default_base_url),
|
||||
"platform_url": cls.platform_url,
|
||||
"can_create_account": False,
|
||||
"can_topup": False,
|
||||
"can_show_balance": False,
|
||||
}
|
||||
|
||||
def prepare_headers(self, request_headers: dict) -> dict:
|
||||
@@ -1847,59 +1832,3 @@ class BaseUpstreamProvider:
|
||||
Model object or None if not found
|
||||
"""
|
||||
return self._models_by_id.get(model_id)
|
||||
|
||||
@classmethod
|
||||
async def create_account_static(cls) -> dict[str, object]:
|
||||
"""Create a new account with the provider (class method, no instance needed).
|
||||
|
||||
Returns:
|
||||
Dict with account creation details including api_key
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If provider does not support account creation
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"Provider {cls.provider_type} does not support account creation"
|
||||
)
|
||||
|
||||
async def create_account(self) -> dict[str, object]:
|
||||
"""Create a new account with the provider.
|
||||
|
||||
Returns:
|
||||
Dict with account creation details including api_key
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If provider does not support account creation
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"Provider {self.provider_type} does not support account creation"
|
||||
)
|
||||
|
||||
async def initiate_topup(self, amount: int) -> TopupData:
|
||||
"""Initiate a Lightning Network top-up for the provider account.
|
||||
|
||||
Args:
|
||||
amount: Amount in currency units to top up
|
||||
|
||||
Returns:
|
||||
TopupData with standardized invoice information
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If provider does not support top-up
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"Provider {self.provider_type} does not support top-up"
|
||||
)
|
||||
|
||||
async def get_balance(self) -> dict[str, object]:
|
||||
"""Get the current account balance from the provider.
|
||||
|
||||
Returns:
|
||||
Dict with balance information
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If provider does not support balance checking
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"Provider {self.provider_type} does not support balance checking"
|
||||
)
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.logging import get_logger
|
||||
from ..payment.models import Architecture, Model, Pricing, async_fetch_openrouter_models
|
||||
from .base import BaseUpstreamProvider, TopupData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..core.db import UpstreamProviderRow
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PPQAIModelPricing(BaseModel):
|
||||
ui: dict[str, float]
|
||||
api: dict[str, float]
|
||||
|
||||
|
||||
class PPQAIModel(BaseModel):
|
||||
id: str
|
||||
provider: str
|
||||
name: str
|
||||
created_at: int
|
||||
context_length: int
|
||||
pricing: PPQAIModelPricing
|
||||
popular: bool
|
||||
|
||||
|
||||
class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
||||
"""Upstream provider for PPQ.AI API with Lightning Network top-up support."""
|
||||
|
||||
provider_type = "ppqai"
|
||||
default_base_url = "https://api.ppq.ai"
|
||||
platform_url = "https://ppq.ai/api-docs"
|
||||
|
||||
def __init__(self, api_key: str, provider_fee: float = 1.0):
|
||||
super().__init__(
|
||||
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_row(
|
||||
cls, provider_row: "UpstreamProviderRow"
|
||||
) -> "PPQAIUpstreamProvider":
|
||||
return cls(
|
||||
api_key=provider_row.api_key,
|
||||
provider_fee=provider_row.provider_fee,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_provider_metadata(cls) -> dict[str, object]:
|
||||
return {
|
||||
"id": cls.provider_type,
|
||||
"name": "PPQ.AI",
|
||||
"default_base_url": cls.default_base_url,
|
||||
"fixed_base_url": True,
|
||||
"platform_url": cls.platform_url,
|
||||
"can_create_account": True,
|
||||
"can_topup": True,
|
||||
"can_show_balance": True,
|
||||
}
|
||||
|
||||
def transform_model_name(self, model_id: str) -> str:
|
||||
return model_id
|
||||
|
||||
@classmethod
|
||||
async def create_account_static(cls) -> dict[str, object]:
|
||||
"""Create a new PPQ.AI account without requiring an instance.
|
||||
|
||||
Returns:
|
||||
Dict containing 'credit_id' and 'api_key' for the new account.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{cls.default_base_url}/accounts/create"
|
||||
|
||||
logger.info("Creating new PPQ.AI account", extra={"url": url})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url)
|
||||
response.raise_for_status()
|
||||
account_data = response.json()
|
||||
|
||||
logger.info(
|
||||
"Successfully created PPQ.AI account",
|
||||
extra={
|
||||
"credit_id": account_data.get("credit_id"),
|
||||
"has_api_key": bool(account_data.get("api_key")),
|
||||
},
|
||||
)
|
||||
|
||||
return account_data
|
||||
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch models from PPQ.AI API."""
|
||||
url = f"{self.base_url}/models"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
logger.debug(
|
||||
"Fetching models from PPQ.AI",
|
||||
extra={"url": url, "has_api_key": bool(self.api_key)},
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
models_data = data.get("data", [])
|
||||
logger.info(
|
||||
"Fetched models from PPQ.AI",
|
||||
extra={"model_count": len(models_data)},
|
||||
)
|
||||
|
||||
or_models = [
|
||||
Model(**model) # type: ignore
|
||||
for model in await async_fetch_openrouter_models()
|
||||
]
|
||||
|
||||
models = []
|
||||
for model_data in models_data:
|
||||
try:
|
||||
ppqai_model = PPQAIModel.parse_obj(model_data)
|
||||
or_model = next(
|
||||
(
|
||||
model
|
||||
for model in or_models
|
||||
if model.id == ppqai_model.id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if or_model:
|
||||
if input_price := ppqai_model.pricing.api.get(
|
||||
"input_per_1M"
|
||||
):
|
||||
or_model.pricing.prompt = input_price / 1_000_000
|
||||
if output_price := ppqai_model.pricing.api.get(
|
||||
"output_per_1M"
|
||||
):
|
||||
or_model.pricing.completion = output_price / 1_000_000
|
||||
if cl := ppqai_model.context_length:
|
||||
or_model.context_length = cl
|
||||
models.append(or_model)
|
||||
else:
|
||||
input_price = ppqai_model.pricing.api.get(
|
||||
"input_per_1M", 0.0
|
||||
)
|
||||
output_price = ppqai_model.pricing.api.get(
|
||||
"output_per_1M", 0.0
|
||||
)
|
||||
|
||||
models.append(
|
||||
Model(
|
||||
id=ppqai_model.id,
|
||||
name=ppqai_model.name,
|
||||
created=ppqai_model.created_at // 1000,
|
||||
description=f"{ppqai_model.provider} model",
|
||||
context_length=ppqai_model.context_length,
|
||||
architecture=Architecture(
|
||||
modality="text->text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="Unknown",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(
|
||||
prompt=input_price / 1_000_000,
|
||||
completion=output_price / 1_000_000,
|
||||
request=0.0,
|
||||
image=0.0,
|
||||
web_search=0.0,
|
||||
internal_reasoning=0.0,
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to parse PPQ.AI model",
|
||||
extra={
|
||||
"model_id": model_data.get("id", "unknown"),
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
return models
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
"HTTP error fetching models from PPQ.AI",
|
||||
extra={
|
||||
"status_code": e.response.status_code,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error fetching models from PPQ.AI",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
return []
|
||||
|
||||
async def create_account(self) -> dict[str, object]:
|
||||
"""Create a new PPQ.AI account.
|
||||
|
||||
Returns:
|
||||
Dict containing 'credit_id' and 'api_key' for the new account.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/accounts/create"
|
||||
|
||||
logger.info("Creating new PPQ.AI account", extra={"url": url})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url)
|
||||
response.raise_for_status()
|
||||
account_data = response.json()
|
||||
|
||||
logger.info(
|
||||
"Successfully created PPQ.AI account",
|
||||
extra={
|
||||
"credit_id": account_data.get("credit_id"),
|
||||
"has_api_key": bool(account_data.get("api_key")),
|
||||
},
|
||||
)
|
||||
|
||||
return account_data
|
||||
|
||||
async def create_lightning_topup(
|
||||
self, amount: int, currency: str
|
||||
) -> dict[str, object]:
|
||||
"""Create a Lightning Network top-up invoice for this account.
|
||||
|
||||
Args:
|
||||
amount: Amount to top up (in the specified currency)
|
||||
currency: Currency for the top-up (default: "USD")
|
||||
|
||||
Returns:
|
||||
Dict containing invoice details including 'invoice_id', 'payment_request', etc.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/topup/create/btc-lightning"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {"amount": amount, "currency": currency}
|
||||
|
||||
logger.info(
|
||||
"Creating Lightning top-up invoice",
|
||||
extra={"url": url, "amount": amount, "currency": currency},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
print(f"Payload: {payload}", "sending to", url)
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
invoice_data = response.json()
|
||||
|
||||
logger.info(
|
||||
"Successfully created Lightning top-up invoice",
|
||||
extra={
|
||||
"invoice_id": invoice_data.get("invoice_id"),
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
},
|
||||
)
|
||||
|
||||
return invoice_data
|
||||
|
||||
async def check_topup_status(self, invoice_id: str) -> bool:
|
||||
"""Check the status of a Lightning top-up invoice.
|
||||
|
||||
Args:
|
||||
invoice_id: The invoice ID to check
|
||||
|
||||
Returns:
|
||||
True if the invoice is paid (status == "Settled"), False otherwise
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/topup/status/{invoice_id}"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
logger.debug(
|
||||
"Checking Lightning top-up status",
|
||||
extra={"url": url, "invoice_id": invoice_id},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
status_data = response.json()
|
||||
|
||||
is_paid = status_data.get("status") == "Settled"
|
||||
|
||||
logger.debug(
|
||||
"Retrieved Lightning top-up status",
|
||||
extra={
|
||||
"invoice_id": invoice_id,
|
||||
"status": status_data.get("status"),
|
||||
"is_paid": is_paid,
|
||||
},
|
||||
)
|
||||
|
||||
return is_paid
|
||||
|
||||
async def initiate_topup(self, amount: int) -> TopupData:
|
||||
"""Initiate a Lightning Network top-up for the PPQ.AI account.
|
||||
|
||||
Args:
|
||||
amount: Amount in currency units to top up (will be sent to PPQ.AI API)
|
||||
|
||||
Returns:
|
||||
TopupData with standardized invoice information
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails
|
||||
"""
|
||||
ppq_response = await self.create_lightning_topup(amount, "USD")
|
||||
|
||||
logger.info(
|
||||
"PPQ.AI top-up response",
|
||||
extra={
|
||||
"ppq_response": ppq_response,
|
||||
"invoice_id": ppq_response.get("invoice_id"),
|
||||
"has_lightning_invoice": "lightning_invoice" in ppq_response,
|
||||
},
|
||||
)
|
||||
|
||||
expires_at_value = ppq_response.get("expires_at")
|
||||
checkout_url_value = ppq_response.get("checkout_url")
|
||||
|
||||
topup_data = TopupData(
|
||||
invoice_id=str(ppq_response["invoice_id"]),
|
||||
payment_request=str(ppq_response["lightning_invoice"]),
|
||||
amount=int(ppq_response["amount"])
|
||||
if isinstance(ppq_response["amount"], (int, float, str))
|
||||
else 0,
|
||||
currency=str(ppq_response["currency"]),
|
||||
expires_at=int(expires_at_value)
|
||||
if isinstance(expires_at_value, (int, float, str))
|
||||
and expires_at_value is not None
|
||||
else None,
|
||||
checkout_url=str(checkout_url_value)
|
||||
if checkout_url_value is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Created TopupData",
|
||||
extra={
|
||||
"invoice_id": topup_data.invoice_id,
|
||||
"payment_request_length": len(topup_data.payment_request),
|
||||
"amount": topup_data.amount,
|
||||
},
|
||||
)
|
||||
|
||||
return topup_data
|
||||
|
||||
async def get_balance(self) -> dict[str, object]:
|
||||
"""Get the current account balance from PPQ.AI.
|
||||
|
||||
Returns:
|
||||
Dict with balance information
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails
|
||||
"""
|
||||
return await self.check_balance()
|
||||
|
||||
async def check_balance(self) -> dict[str, object]:
|
||||
"""Check the account balance for this PPQ.AI account.
|
||||
|
||||
Returns:
|
||||
Dict containing balance information
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the API request fails.
|
||||
"""
|
||||
url = f"{self.base_url}/credits/balance"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
logger.debug("Checking PPQ.AI account balance", extra={"url": url})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, headers=headers, json={})
|
||||
response.raise_for_status()
|
||||
balance_data = response.json()
|
||||
|
||||
logger.debug(
|
||||
"Retrieved PPQ.AI account balance",
|
||||
extra={"balance": balance_data.get("balance")},
|
||||
)
|
||||
|
||||
return balance_data
|
||||
+10
-337
@@ -51,282 +51,9 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
function ProviderBalance({ providerId }: { providerId: number }) {
|
||||
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
|
||||
const [topupAmount, setTopupAmount] = useState('');
|
||||
const [topupError, setTopupError] = useState('');
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [invoiceData, setInvoiceData] = useState<{
|
||||
payment_request: string;
|
||||
invoice_id: string;
|
||||
} | null>(null);
|
||||
const [paymentStatus, setPaymentStatus] = useState<'pending' | 'paid' | null>(
|
||||
null
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: balanceData, isLoading, error } = useQuery({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
queryFn: () => AdminService.getProviderBalance(providerId),
|
||||
refetchInterval: 30000,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const { data: statusData } = useQuery({
|
||||
queryKey: ['topup-status', providerId, invoiceData?.invoice_id],
|
||||
queryFn: () =>
|
||||
AdminService.checkTopupStatus(providerId, invoiceData!.invoice_id),
|
||||
enabled: !!invoiceData && paymentStatus === 'pending',
|
||||
refetchInterval: 2000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (statusData?.paid === true) {
|
||||
setPaymentStatus('paid');
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['provider-balance', providerId],
|
||||
});
|
||||
toast.success('Payment received!', {
|
||||
description: 'Your balance has been updated.',
|
||||
});
|
||||
}
|
||||
}, [statusData, queryClient, providerId]);
|
||||
|
||||
const topupMutation = useMutation({
|
||||
mutationFn: async (amount: number) => {
|
||||
console.log('Calling top-up API with:', { providerId, amount });
|
||||
try {
|
||||
const result = await AdminService.initiateProviderTopup(providerId, amount);
|
||||
console.log('API returned:', result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('API call failed:', err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
console.log('Top-up response:', data);
|
||||
console.log('Type of data:', typeof data);
|
||||
console.log('Keys in data:', Object.keys(data || {}));
|
||||
|
||||
if (
|
||||
data?.topup_data?.payment_request &&
|
||||
data?.topup_data?.invoice_id
|
||||
) {
|
||||
setInvoiceData({
|
||||
payment_request: data.topup_data.payment_request as string,
|
||||
invoice_id: data.topup_data.invoice_id as string,
|
||||
});
|
||||
setPaymentStatus('pending');
|
||||
} else {
|
||||
console.error('Missing invoice data:', data);
|
||||
console.error('topup_data:', data?.topup_data);
|
||||
toast.error('No invoice returned from provider');
|
||||
setIsTopupDialogOpen(false);
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('Top-up mutation error:', error);
|
||||
toast.error(`Failed to initiate top-up: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleTopup = () => {
|
||||
const amount = parseFloat(topupAmount);
|
||||
|
||||
if (isNaN(amount)) {
|
||||
setTopupError('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount < 1 || amount > 500) {
|
||||
setTopupError('Amount must be between $1 and $500');
|
||||
return;
|
||||
}
|
||||
|
||||
topupMutation.mutate(amount);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setIsTopupDialogOpen(false);
|
||||
setTopupAmount('');
|
||||
setTopupError('');
|
||||
setInvoiceData(null);
|
||||
setPaymentStatus(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className='h-9 w-24' />;
|
||||
}
|
||||
|
||||
if (error || !balanceData?.ok || !balanceData.balance_data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const balance = balanceData.balance_data;
|
||||
let displayValue = 'N/A';
|
||||
|
||||
if (typeof balance.balance === 'number') {
|
||||
displayValue = `$${balance.balance.toFixed(2)}`;
|
||||
} else if (typeof balance.balance === 'string') {
|
||||
displayValue = balance.balance;
|
||||
} else if (balance.amount !== undefined) {
|
||||
displayValue = `$${Number(balance.amount).toFixed(2)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setIsTopupDialogOpen(true)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className='w-full font-mono sm:w-auto'
|
||||
>
|
||||
{isHovered ? 'Top Up' : displayValue}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isTopupDialogOpen} onOpenChange={handleCloseDialog}>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{paymentStatus === 'paid'
|
||||
? 'Payment Confirmed!'
|
||||
: 'Top Up Balance'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{paymentStatus === 'paid'
|
||||
? 'Your account balance has been updated.'
|
||||
: invoiceData
|
||||
? 'Scan the QR code or copy the Lightning invoice to pay.'
|
||||
: 'Enter the amount you want to add to your account balance.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{paymentStatus === 'paid' ? (
|
||||
<div className='flex flex-col items-center gap-4 py-6'>
|
||||
<div className='rounded-full bg-green-100 p-3 dark:bg-green-900'>
|
||||
<svg
|
||||
className='h-12 w-12 text-green-600 dark:text-green-400'
|
||||
fill='none'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
>
|
||||
<path d='M5 13l4 4L19 7'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='text-center font-semibold'>
|
||||
Top-up successful!
|
||||
</p>
|
||||
</div>
|
||||
) : invoiceData ? (
|
||||
<div className='flex flex-col items-center gap-4 py-4'>
|
||||
<div className='rounded-lg border-2 border-gray-200 p-2 dark:border-gray-800'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(invoiceData.payment_request)}`}
|
||||
alt='Lightning Invoice QR Code'
|
||||
className='h-64 w-64'
|
||||
/>
|
||||
</div>
|
||||
<div className='w-full space-y-2'>
|
||||
<Label htmlFor='invoice'>Lightning Invoice</Label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='invoice'
|
||||
value={invoiceData.payment_request}
|
||||
readOnly
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
invoiceData.payment_request
|
||||
);
|
||||
toast.success('Invoice copied to clipboard!');
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{paymentStatus === 'pending' && (
|
||||
<p className='text-muted-foreground text-center text-sm'>
|
||||
Waiting for payment...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid gap-4 py-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='topup_amount'>Amount (USD)</Label>
|
||||
<Input
|
||||
id='topup_amount'
|
||||
type='number'
|
||||
placeholder='Enter amount (1-500)'
|
||||
value={topupAmount}
|
||||
onChange={(e) => {
|
||||
setTopupAmount(e.target.value);
|
||||
setTopupError('');
|
||||
}}
|
||||
min='1'
|
||||
max='500'
|
||||
step='0.01'
|
||||
/>
|
||||
{topupError && (
|
||||
<p className='text-sm text-red-600 dark:text-red-400'>
|
||||
{topupError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{paymentStatus === 'paid' ? (
|
||||
<Button onClick={handleCloseDialog} className='w-full'>
|
||||
Done
|
||||
</Button>
|
||||
) : invoiceData ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleCloseDialog}
|
||||
className='w-full'
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant='outline' onClick={handleCloseDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTopup}
|
||||
disabled={topupMutation.isPending || !topupAmount}
|
||||
>
|
||||
{topupMutation.isPending
|
||||
? 'Processing...'
|
||||
: 'Generate Invoice'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProvidersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [editingProvider, setEditingProvider] =
|
||||
@@ -337,7 +64,6 @@ export default function ProvidersPage() {
|
||||
new Set()
|
||||
);
|
||||
const [viewingModels, setViewingModels] = useState<number | null>(null);
|
||||
const [isCreatingAccount, setIsCreatingAccount] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState<CreateUpstreamProvider>({
|
||||
provider_type: 'openrouter',
|
||||
@@ -412,30 +138,6 @@ export default function ProvidersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateAccount = async () => {
|
||||
setIsCreatingAccount(true);
|
||||
try {
|
||||
const response = await AdminService.createProviderAccountByType(
|
||||
formData.provider_type
|
||||
);
|
||||
if (response.ok && response.account_data.api_key) {
|
||||
setFormData({
|
||||
...formData,
|
||||
api_key: String(response.account_data.api_key),
|
||||
});
|
||||
toast.success('Account created successfully! API key has been filled in.');
|
||||
} else {
|
||||
toast.success('Account created, but no API key returned.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
toast.error(`Failed to create account: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsCreatingAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
provider_type: 'openrouter',
|
||||
@@ -497,16 +199,6 @@ export default function ProvidersPage() {
|
||||
return providerType?.platform_url || null;
|
||||
};
|
||||
|
||||
const canCreateAccount = (type: string) => {
|
||||
const providerType = providerTypes.find((pt) => pt.id === type);
|
||||
return providerType?.can_create_account || false;
|
||||
};
|
||||
|
||||
const canShowBalance = (type: string) => {
|
||||
const providerType = providerTypes.find((pt) => pt.id === type);
|
||||
return providerType?.can_show_balance || false;
|
||||
};
|
||||
|
||||
const toggleProviderExpansion = (providerId: number) => {
|
||||
const newExpanded = new Set(expandedProviders);
|
||||
if (newExpanded.has(providerId)) {
|
||||
@@ -600,30 +292,15 @@ export default function ProvidersPage() {
|
||||
<div className='grid gap-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='api_key'>API Key</Label>
|
||||
{canCreateAccount(formData.provider_type) ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleCreateAccount}
|
||||
disabled={isCreatingAccount}
|
||||
className='h-6 text-xs'
|
||||
{getPlatformUrl(formData.provider_type) && (
|
||||
<a
|
||||
href={getPlatformUrl(formData.provider_type)!}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
|
||||
>
|
||||
{isCreatingAccount
|
||||
? 'Creating...'
|
||||
: 'Create Account'}
|
||||
</Button>
|
||||
) : (
|
||||
getPlatformUrl(formData.provider_type) && (
|
||||
<a
|
||||
href={getPlatformUrl(formData.provider_type)!}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
|
||||
>
|
||||
Get Your API Key Here →
|
||||
</a>
|
||||
)
|
||||
Get Your API Key Here →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
@@ -733,11 +410,7 @@ export default function ProvidersPage() {
|
||||
{provider.base_url}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{canShowBalance(provider.provider_type) &&
|
||||
provider.api_key && (
|
||||
<ProviderBalance providerId={provider.id} />
|
||||
)}
|
||||
<div className='grid grid-cols-3 gap-2 sm:flex sm:flex-nowrap'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
|
||||
@@ -64,11 +64,6 @@ class ApiClient {
|
||||
data,
|
||||
config
|
||||
);
|
||||
console.log(`POST response from ${endpoint}:`, {
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
headers: response.headers,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleAuthError(error);
|
||||
|
||||
@@ -7,9 +7,6 @@ export const ProviderTypeSchema = z.object({
|
||||
default_base_url: z.string(),
|
||||
fixed_base_url: z.boolean(),
|
||||
platform_url: z.string().nullable(),
|
||||
can_create_account: z.boolean(),
|
||||
can_topup: z.boolean(),
|
||||
can_show_balance: z.boolean(),
|
||||
});
|
||||
|
||||
export const UpstreamProviderSchema = z.object({
|
||||
@@ -805,54 +802,6 @@ export class AdminService {
|
||||
'/admin/api/temporary-balances'
|
||||
);
|
||||
}
|
||||
|
||||
static async createProviderAccountByType(
|
||||
providerType: string
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
account_data: Record<string, unknown>;
|
||||
message: string;
|
||||
}> {
|
||||
return await apiClient.post<{
|
||||
ok: boolean;
|
||||
account_data: Record<string, unknown>;
|
||||
message: string;
|
||||
}>('/admin/api/upstream-providers/create-account', {
|
||||
provider_type: providerType,
|
||||
});
|
||||
}
|
||||
|
||||
static async initiateProviderTopup(
|
||||
providerId: number,
|
||||
amount: number
|
||||
): Promise<{ ok: boolean; topup_data: Record<string, unknown>; message: string }> {
|
||||
return await apiClient.post<{
|
||||
ok: boolean;
|
||||
topup_data: Record<string, unknown>;
|
||||
message: string;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/topup`, {
|
||||
amount: amount,
|
||||
});
|
||||
}
|
||||
|
||||
static async checkTopupStatus(
|
||||
providerId: number,
|
||||
invoiceId: string
|
||||
): Promise<{ ok: boolean; paid: boolean }> {
|
||||
return await apiClient.get<{
|
||||
ok: boolean;
|
||||
paid: boolean;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/topup/${invoiceId}/status`);
|
||||
}
|
||||
|
||||
static async getProviderBalance(
|
||||
providerId: number
|
||||
): Promise<{ ok: boolean; balance_data: Record<string, unknown> }> {
|
||||
return await apiClient.get<{
|
||||
ok: boolean;
|
||||
balance_data: Record<string, unknown>;
|
||||
}>(`/admin/api/upstream-providers/${providerId}/balance`);
|
||||
}
|
||||
}
|
||||
|
||||
export const TemporaryBalanceSchema = z.object({
|
||||
|
||||
Vendored
-1
Submodule vendor/cdk deleted from 52d796e9fe
Vendored
-1
Submodule vendor/cdk-python deleted from 915c6966b0
Reference in New Issue
Block a user