mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-01 08:59:46 +02:00
feat: add credit purchase URL
This commit is contained in:
parent
d4d7ae6e2e
commit
e33fec17db
3 changed files with 220 additions and 5 deletions
|
|
@ -7,10 +7,13 @@ from fastapi.responses import StreamingResponse
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from api.constants import DEPLOYMENT_MODE
|
from api.constants import DEPLOYMENT_MODE, UI_APP_URL
|
||||||
from api.db import db_client
|
from api.db import db_client
|
||||||
from api.db.models import UserModel
|
from api.db.models import UserModel
|
||||||
from api.services.auth.depends import get_user
|
from api.services.auth.depends import get_user, get_user_with_selected_organization
|
||||||
|
from api.services.configuration.ai_model_configuration import (
|
||||||
|
get_resolved_ai_model_configuration,
|
||||||
|
)
|
||||||
from api.services.mps_service_key_client import mps_service_key_client
|
from api.services.mps_service_key_client import mps_service_key_client
|
||||||
from api.services.reports import generate_usage_runs_report_csv
|
from api.services.reports import generate_usage_runs_report_csv
|
||||||
from api.utils.artifacts import artifact_url
|
from api.utils.artifacts import artifact_url
|
||||||
|
|
@ -40,6 +43,10 @@ class MPSCreditsResponse(BaseModel):
|
||||||
total_quota: float
|
total_quota: float
|
||||||
|
|
||||||
|
|
||||||
|
class MPSCreditPurchaseUrlResponse(BaseModel):
|
||||||
|
checkout_url: str
|
||||||
|
|
||||||
|
|
||||||
class WorkflowRunUsageResponse(BaseModel):
|
class WorkflowRunUsageResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
workflow_id: int
|
workflow_id: int
|
||||||
|
|
@ -142,6 +149,66 @@ async def get_mps_credits(user: UserModel = Depends(get_user)):
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/usage/mps-credits/purchase-url",
|
||||||
|
response_model=MPSCreditPurchaseUrlResponse,
|
||||||
|
)
|
||||||
|
async def create_mps_credit_purchase_url(
|
||||||
|
user: UserModel = Depends(get_user_with_selected_organization),
|
||||||
|
):
|
||||||
|
"""Create a checkout URL for organizations using Dograh-managed MPS v2."""
|
||||||
|
if DEPLOYMENT_MODE == "oss":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Credit purchases are not available in OSS mode",
|
||||||
|
)
|
||||||
|
|
||||||
|
organization_id = user.selected_organization_id
|
||||||
|
assert organization_id is not None
|
||||||
|
resolved = await get_resolved_ai_model_configuration(
|
||||||
|
user_id=user.id,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
resolved.source != "organization_v2"
|
||||||
|
or resolved.effective.managed_service_version != 2
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=(
|
||||||
|
"Credit purchases are available only for organizations using "
|
||||||
|
"Dograh managed model configuration v2"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = await mps_service_key_client.create_credit_purchase_url(
|
||||||
|
organization_id=organization_id,
|
||||||
|
created_by=str(user.provider_id),
|
||||||
|
return_url=f"{UI_APP_URL.rstrip('/')}/reports",
|
||||||
|
billing_details={
|
||||||
|
"source": "dograh_reports",
|
||||||
|
"dograh_user_id": str(user.id),
|
||||||
|
"dograh_provider_id": str(user.provider_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to create MPS credit purchase URL: {exc}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail="Failed to create credit purchase URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
checkout_url = session.get("checkout_url")
|
||||||
|
if not checkout_url:
|
||||||
|
logger.error(f"MPS checkout session response missing checkout_url: {session}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail="MPS checkout session response missing checkout_url",
|
||||||
|
)
|
||||||
|
return MPSCreditPurchaseUrlResponse(checkout_url=checkout_url)
|
||||||
|
|
||||||
|
|
||||||
FILTERS_DESCRIPTION = """\
|
FILTERS_DESCRIPTION = """\
|
||||||
JSON-encoded array of filter objects. Each object has the shape:
|
JSON-encoded array of filter objects. Each object has the shape:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,43 @@ class MPSServiceKeyClient:
|
||||||
response=response,
|
response=response,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def create_credit_purchase_url(
|
||||||
|
self,
|
||||||
|
organization_id: int,
|
||||||
|
created_by: Optional[str] = None,
|
||||||
|
return_url: Optional[str] = None,
|
||||||
|
billing_details: Optional[dict] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Create a short-lived MPS checkout URL for adding organization credits."""
|
||||||
|
payload = {
|
||||||
|
"created_by": created_by,
|
||||||
|
"return_url": return_url,
|
||||||
|
"billing_details": billing_details or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/api/v1/billing/accounts/{organization_id}/checkout-sessions",
|
||||||
|
json=payload,
|
||||||
|
headers=self._get_headers(
|
||||||
|
organization_id=organization_id,
|
||||||
|
created_by=created_by,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
"Failed to create MPS credit purchase URL: "
|
||||||
|
f"{response.status_code} - {response.text}"
|
||||||
|
)
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
f"Failed to create MPS credit purchase URL: {response.text}",
|
||||||
|
request=response.request,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
|
||||||
async def create_correlation_id(
|
async def create_correlation_id(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { addDays, format, subDays } from 'date-fns';
|
import { addDays, format, subDays } from 'date-fns';
|
||||||
import { Calendar, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
import { Calendar, ChevronLeft, ChevronRight, CreditCard, Download, Loader2 } from 'lucide-react';
|
||||||
import { useEffect,useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { client } from '@/client/client.gen';
|
||||||
import {
|
import {
|
||||||
getDailyReportApiV1OrganizationsReportsDailyGet,
|
getDailyReportApiV1OrganizationsReportsDailyGet,
|
||||||
getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet,
|
getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet,
|
||||||
|
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get,
|
||||||
getPreferencesApiV1OrganizationsPreferencesGet,
|
getPreferencesApiV1OrganizationsPreferencesGet,
|
||||||
getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet
|
getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet
|
||||||
} from '@/client/sdk.gen';
|
} from '@/client/sdk.gen';
|
||||||
|
|
@ -17,6 +19,7 @@ import { Card } from '@/components/ui/card';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { detailFromError } from '@/lib/apiError';
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
|
|
||||||
import { DispositionChart } from './components/DispositionChart';
|
import { DispositionChart } from './components/DispositionChart';
|
||||||
|
|
@ -50,6 +53,10 @@ interface DailyReport {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreditPurchaseUrlResponse = {
|
||||||
|
checkout_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
const [selectedWorkflow, setSelectedWorkflow] = useState<string>('all');
|
const [selectedWorkflow, setSelectedWorkflow] = useState<string>('all');
|
||||||
|
|
@ -57,6 +64,9 @@ export default function ReportsPage() {
|
||||||
const [report, setReport] = useState<DailyReport | null>(null);
|
const [report, setReport] = useState<DailyReport | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [canBuyCredits, setCanBuyCredits] = useState(false);
|
||||||
|
const [purchaseLoading, setPurchaseLoading] = useState(false);
|
||||||
|
const [purchaseError, setPurchaseError] = useState<string | null>(null);
|
||||||
const [timezone, setTimezone] = useState('America/New_York');
|
const [timezone, setTimezone] = useState('America/New_York');
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
|
|
@ -94,6 +104,50 @@ export default function ReportsPage() {
|
||||||
fetchPreferences();
|
fetchPreferences();
|
||||||
}, [auth.isAuthenticated]);
|
}, [auth.isAuthenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth.loading) return;
|
||||||
|
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
setCanBuyCredits(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const fetchCreditPurchaseAvailability = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (response.error || !response.data) {
|
||||||
|
setCanBuyCredits(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuration = response.data.configuration as {
|
||||||
|
mode?: unknown;
|
||||||
|
dograh?: unknown;
|
||||||
|
} | null;
|
||||||
|
setCanBuyCredits(
|
||||||
|
response.data.source === 'organization_v2' &&
|
||||||
|
configuration?.mode === 'dograh' &&
|
||||||
|
Boolean(configuration.dograh)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check credit purchase availability:', err);
|
||||||
|
if (!cancelled) {
|
||||||
|
setCanBuyCredits(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCreditPurchaseAvailability();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [auth.loading, auth.isAuthenticated]);
|
||||||
|
|
||||||
// Fetch report data when date or workflow changes
|
// Fetch report data when date or workflow changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchReport = async () => {
|
const fetchReport = async () => {
|
||||||
|
|
@ -195,13 +249,70 @@ export default function ReportsPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBuyCredits = async () => {
|
||||||
|
if (!auth.isAuthenticated || purchaseLoading) return;
|
||||||
|
|
||||||
|
setPurchaseLoading(true);
|
||||||
|
setPurchaseError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.post<
|
||||||
|
{ 200: CreditPurchaseUrlResponse },
|
||||||
|
{ detail?: unknown }
|
||||||
|
>({
|
||||||
|
url: '/api/v1/organizations/usage/mps-credits/purchase-url',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
throw new Error(
|
||||||
|
detailFromError(response.error, 'Failed to create credit purchase URL')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkoutUrl = response.data?.checkout_url;
|
||||||
|
if (!checkoutUrl) {
|
||||||
|
throw new Error('Failed to create credit purchase URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = checkoutUrl;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create credit purchase URL:', err);
|
||||||
|
setPurchaseError(
|
||||||
|
err instanceof Error ? err.message : 'Failed to create credit purchase URL'
|
||||||
|
);
|
||||||
|
setPurchaseLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isToday = format(selectedDate, 'yyyy-MM-dd') === format(new Date(), 'yyyy-MM-dd');
|
const isToday = format(selectedDate, 'yyyy-MM-dd') === format(new Date(), 'yyyy-MM-dd');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
<h1 className="text-3xl font-bold">Daily Reports</h1>
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-3xl font-bold">Daily Reports</h1>
|
||||||
|
{canBuyCredits && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBuyCredits}
|
||||||
|
disabled={purchaseLoading}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{purchaseLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CreditCard className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Buy Credits
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{purchaseError && (
|
||||||
|
<p className="text-sm text-red-500">{purchaseError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Date Navigation & Workflow Selector */}
|
{/* Date Navigation & Workflow Selector */}
|
||||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue