Compare commits

...
7 changed files with 1056 additions and 11 deletions
+180
View File
@@ -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)],
+2
View File
@@ -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"""
+72 -1
View File
@@ -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"
)
+409
View File
@@ -0,0 +1,409 @@
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
+337 -10
View File
@@ -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 <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] =
@@ -64,6 +337,7 @@ 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',
@@ -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() {
<div className='grid gap-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='api_key'>API Key</Label>
{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'
{canCreateAccount(formData.provider_type) ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={handleCreateAccount}
disabled={isCreatingAccount}
className='h-6 text-xs'
>
Get Your API Key Here
</a>
{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>
)
)}
</div>
<Input
@@ -410,7 +733,11 @@ export default function ProvidersPage() {
{provider.base_url}
</CardDescription>
</div>
<div className='grid grid-cols-3 gap-2 sm:flex sm:flex-nowrap'>
<div className='flex flex-wrap items-center gap-2'>
{canShowBalance(provider.provider_type) &&
provider.api_key && (
<ProviderBalance providerId={provider.id} />
)}
<Button
variant='outline'
size='sm'
+5
View File
@@ -64,6 +64,11 @@ 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);
+51
View File
@@ -7,6 +7,9 @@ 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({
@@ -802,6 +805,54 @@ 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({