From b717c9739add7b04a663f915852ac7ce99ac0f5b Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 17 Nov 2025 20:47:33 -0800 Subject: [PATCH 1/2] ppq upstream --- routstr/upstream/__init__.py | 2 + routstr/upstream/ppqai.py | 405 +++++++++++++++++++++++++++++++++++ 2 files changed, 407 insertions(+) create mode 100644 routstr/upstream/ppqai.py diff --git a/routstr/upstream/__init__.py b/routstr/upstream/__init__.py index 7c2f4961..074cd14d 100644 --- a/routstr/upstream/__init__.py +++ b/routstr/upstream/__init__.py @@ -8,6 +8,7 @@ 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]] = [ @@ -20,6 +21,7 @@ upstream_provider_classes: list[type[BaseUpstreamProvider]] = [ OpenAIUpstreamProvider, OpenRouterUpstreamProvider, PerplexityUpstreamProvider, + PPQAIUpstreamProvider, XAIUpstreamProvider, ] """List of all upstream classes""" diff --git a/routstr/upstream/ppqai.py b/routstr/upstream/ppqai.py new file mode 100644 index 00000000..485e79b2 --- /dev/null +++ b/routstr/upstream/ppqai.py @@ -0,0 +1,405 @@ +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.01): + 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 = "SATS" + ) -> 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: "SATS") + + 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: + 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) -> dict[str, object]: + """Check the status of a Lightning top-up invoice. + + Args: + invoice_id: The invoice ID to check + + Returns: + Dict containing invoice status details + + 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() + + logger.debug( + "Retrieved Lightning top-up status", + extra={ + "invoice_id": invoice_id, + "status": status_data.get("status"), + }, + ) + + return status_data + + async def initiate_topup(self, amount_sats: int) -> TopupData: + """Initiate a Lightning Network top-up for the PPQ.AI account. + + Args: + amount_sats: Amount in satoshis 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_sats, "SATS") + + 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 From bd0764ee0d8c2ee5e3e2d37b88b1478df6917060 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Tue, 18 Nov 2025 14:38:51 -0800 Subject: [PATCH 2/2] easy create account and topup over the dashboard for providers that support it --- routstr/core/admin.py | 180 ++++++++++++++++++ routstr/upstream/base.py | 73 +++++++- routstr/upstream/ppqai.py | 22 ++- ui/app/providers/page.tsx | 347 ++++++++++++++++++++++++++++++++++- ui/lib/api/client.ts | 5 + ui/lib/api/services/admin.ts | 51 +++++ 6 files changed, 658 insertions(+), 20 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 3baad78b..c11cbe50 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -2790,6 +2790,186 @@ 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)], diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 7af1be85..6b908b8e 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -10,6 +10,7 @@ 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 @@ -37,6 +38,17 @@ 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.""" @@ -87,7 +99,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 + Dict with provider type metadata including id, name, default_base_url, fixed_base_url, platform_url, can_create_account, can_topup, can_show_balance """ return { "id": cls.provider_type, @@ -95,6 +107,9 @@ 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: @@ -1832,3 +1847,59 @@ 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" + ) diff --git a/routstr/upstream/ppqai.py b/routstr/upstream/ppqai.py index 485e79b2..f8a9c965 100644 --- a/routstr/upstream/ppqai.py +++ b/routstr/upstream/ppqai.py @@ -37,7 +37,7 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): default_base_url = "https://api.ppq.ai" platform_url = "https://ppq.ai/api-docs" - def __init__(self, api_key: str, provider_fee: float = 1.01): + 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 ) @@ -237,13 +237,13 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): return account_data async def create_lightning_topup( - self, amount: int, currency: str = "SATS" + 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: "SATS") + currency: Currency for the top-up (default: "USD") Returns: Dict containing invoice details including 'invoice_id', 'payment_request', etc. @@ -264,6 +264,7 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): ) 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() @@ -279,14 +280,14 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): return invoice_data - async def check_topup_status(self, invoice_id: str) -> dict[str, object]: + 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: - Dict containing invoice status details + True if the invoice is paid (status == "Settled"), False otherwise Raises: httpx.HTTPStatusError: If the API request fails. @@ -304,21 +305,24 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): 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 status_data + return is_paid - async def initiate_topup(self, amount_sats: int) -> TopupData: + async def initiate_topup(self, amount: int) -> TopupData: """Initiate a Lightning Network top-up for the PPQ.AI account. Args: - amount_sats: Amount in satoshis to top up (will be sent to PPQ.AI API) + amount: Amount in currency units to top up (will be sent to PPQ.AI API) Returns: TopupData with standardized invoice information @@ -326,7 +330,7 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): Raises: httpx.HTTPStatusError: If the API request fails """ - ppq_response = await self.create_lightning_topup(amount_sats, "SATS") + ppq_response = await self.create_lightning_topup(amount, "USD") logger.info( "PPQ.AI top-up response", diff --git a/ui/app/providers/page.tsx b/ui/app/providers/page.tsx index bb85248b..759874e1 100644 --- a/ui/app/providers/page.tsx +++ b/ui/app/providers/page.tsx @@ -51,9 +51,282 @@ import { } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { useState } from 'react'; +import { useState, useEffect } 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 ; + } + + 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 ( + <> + + + + + + + {paymentStatus === 'paid' + ? 'Payment Confirmed!' + : 'Top Up Balance'} + + + {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.'} + + + + {paymentStatus === 'paid' ? ( +
+
+ + + +
+

+ Top-up successful! +

+
+ ) : invoiceData ? ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Lightning Invoice QR Code +
+
+ +
+ + +
+
+ {paymentStatus === 'pending' && ( +

+ Waiting for payment... +

+ )} +
+ ) : ( +
+
+ + { + setTopupAmount(e.target.value); + setTopupError(''); + }} + min='1' + max='500' + step='0.01' + /> + {topupError && ( +

+ {topupError} +

+ )} +
+
+ )} + + + {paymentStatus === 'paid' ? ( + + ) : invoiceData ? ( + + ) : ( + <> + + + + )} + +
+
+ + ); +} + export default function ProvidersPage() { const queryClient = useQueryClient(); const [editingProvider, setEditingProvider] = @@ -64,6 +337,7 @@ export default function ProvidersPage() { new Set() ); const [viewingModels, setViewingModels] = useState(null); + const [isCreatingAccount, setIsCreatingAccount] = useState(false); const [formData, setFormData] = useState({ provider_type: 'openrouter', @@ -138,6 +412,30 @@ 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', @@ -199,6 +497,16 @@ 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)) { @@ -292,15 +600,30 @@ export default function ProvidersPage() {
- {getPlatformUrl(formData.provider_type) && ( - - Get Your API Key Here → - + {isCreatingAccount + ? 'Creating...' + : 'Create Account'} + + ) : ( + getPlatformUrl(formData.provider_type) && ( + + Get Your API Key Here → + + ) )}
-
+
+ {canShowBalance(provider.provider_type) && + provider.api_key && ( + + )}