mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-16 08:25:18 +02:00
feat: billing and credit management v2 (#429)
* feat: use mps generated correlation ID * chore: update pipecat submodule * feat: add credit purchase URL * feat: carve out billing page and show credit ledger * feat: deprecate dograh based quota tracking * fix: remove cost calculation from dograh codebase * fix: create mps account on migrate to v2 * chore: update pipecat
This commit is contained in:
parent
97d7103480
commit
1f1149f4d5
80 changed files with 3335 additions and 2057 deletions
416
ui/src/app/billing/page.tsx
Normal file
416
ui/src/app/billing/page.tsx
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleDollarSign,
|
||||
CreditCard,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPost, getBillingCreditsApiV1OrganizationsBillingCreditsGet } from "@/client/sdk.gen";
|
||||
import type { MpsBillingCreditsResponse, MpsCreditLedgerEntryResponse } from "@/client/types.gen";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
const LEDGER_PAGE_SIZE = 50;
|
||||
|
||||
const formatCredits = (value: number | null | undefined) => (
|
||||
(value ?? 0).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 0,
|
||||
})
|
||||
);
|
||||
|
||||
const formatAmount = (amountMinor?: number | null, currency?: string | null) => {
|
||||
if (amountMinor == null) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: currency || "USD",
|
||||
}).format(amountMinor / 100);
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => (
|
||||
new Date(value).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
);
|
||||
|
||||
const metricLabels: Record<string, string> = {
|
||||
voice_minutes: "Voice usage",
|
||||
platform_usage: "Platform usage",
|
||||
};
|
||||
|
||||
const formatTitleCase = (value: string | null | undefined) => (
|
||||
value ? value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()) : "-"
|
||||
);
|
||||
|
||||
const getLedgerEntryLabel = (entry: MpsCreditLedgerEntryResponse) => {
|
||||
if (entry.metric_code) {
|
||||
return metricLabels[entry.metric_code] ?? formatTitleCase(entry.metric_code);
|
||||
}
|
||||
|
||||
if (entry.entry_type === "grant") {
|
||||
return "Credit grant";
|
||||
}
|
||||
|
||||
if (entry.entry_type === "purchase") {
|
||||
return "Credit purchase";
|
||||
}
|
||||
|
||||
return formatTitleCase(entry.entry_type);
|
||||
};
|
||||
|
||||
const formatBillableQuantity = (entry: MpsCreditLedgerEntryResponse) => {
|
||||
if (entry.billable_quantity == null || !entry.quantity_unit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unit = entry.quantity_unit === "minute" ? "min" : entry.quantity_unit;
|
||||
return `${formatCredits(entry.billable_quantity)} ${unit}`;
|
||||
};
|
||||
|
||||
const getRunHref = (entry: MpsCreditLedgerEntryResponse) => {
|
||||
if (!entry.workflow_id || !entry.workflow_run_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/workflow/${entry.workflow_id}/run/${entry.workflow_run_id}`;
|
||||
};
|
||||
|
||||
const getPageFromSearchParams = (
|
||||
searchParams: { get: (name: string) => string | null },
|
||||
) => {
|
||||
const pageParam = searchParams.get("page");
|
||||
const page = pageParam ? Number.parseInt(pageParam, 10) : 1;
|
||||
return Number.isFinite(page) && page > 0 ? page : 1;
|
||||
};
|
||||
|
||||
export default function BillingPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const auth = useAuth();
|
||||
const { config } = useAppConfig();
|
||||
const [credits, setCredits] = useState<MpsBillingCreditsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [purchasing, setPurchasing] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(
|
||||
() => getPageFromSearchParams(searchParams),
|
||||
);
|
||||
|
||||
const isBillingV2 = credits?.billing_version === "v2";
|
||||
const canPurchaseCredits = isBillingV2 && config?.deploymentMode !== "oss";
|
||||
const totalQuota = credits?.total_quota ?? 0;
|
||||
const remainingCredits = credits?.remaining_credits ?? 0;
|
||||
const usedCredits = credits?.total_credits_used ?? 0;
|
||||
const usagePercent = totalQuota > 0 ? Math.min(100, Math.round((usedCredits / totalQuota) * 100)) : 0;
|
||||
|
||||
const ledgerEntries = useMemo(() => credits?.ledger_entries ?? [], [credits?.ledger_entries]);
|
||||
const ledgerPage = credits?.page ?? currentPage;
|
||||
const ledgerTotalCount = credits?.total_count ?? ledgerEntries.length;
|
||||
const ledgerTotalPages = credits?.total_pages ?? 0;
|
||||
|
||||
const fetchCredits = useCallback(async (
|
||||
page: number,
|
||||
{ silent = false }: { silent?: boolean } = {},
|
||||
) => {
|
||||
if (auth.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (silent) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getBillingCreditsApiV1OrganizationsBillingCreditsGet({
|
||||
query: { page, limit: LEDGER_PAGE_SIZE },
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error("Failed to fetch billing credits");
|
||||
}
|
||||
|
||||
setCredits(response.data ?? null);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch billing credits:", error);
|
||||
toast.error("Failed to fetch billing credits");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [auth.isAuthenticated, auth.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextPage = getPageFromSearchParams(searchParams);
|
||||
setCurrentPage((previousPage) => (
|
||||
previousPage === nextPage ? previousPage : nextPage
|
||||
));
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredits(currentPage);
|
||||
}, [currentPage, fetchCredits]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchCredits(currentPage, { silent: true });
|
||||
};
|
||||
|
||||
const updateUrlPage = useCallback((page: number) => {
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
if (page > 1) {
|
||||
newParams.set("page", page.toString());
|
||||
} else {
|
||||
newParams.delete("page");
|
||||
}
|
||||
|
||||
const queryString = newParams.toString();
|
||||
router.push(queryString ? `/billing?${queryString}` : "/billing");
|
||||
}, [router, searchParams]);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
const nextPage = Math.max(1, page);
|
||||
setCurrentPage(nextPage);
|
||||
updateUrlPage(nextPage);
|
||||
};
|
||||
|
||||
const handlePurchaseCredits = async () => {
|
||||
if (!canPurchaseCredits) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPurchasing(true);
|
||||
try {
|
||||
const response = await createMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPost();
|
||||
const checkoutUrl = response.data?.checkout_url;
|
||||
if (!checkoutUrl) {
|
||||
throw new Error("Missing checkout URL");
|
||||
}
|
||||
window.location.href = checkoutUrl;
|
||||
} catch (error) {
|
||||
console.error("Failed to create credit purchase URL:", error);
|
||||
toast.error("Failed to open checkout");
|
||||
setPurchasing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-5 w-96 max-w-full" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Skeleton className="h-36 rounded-lg" />
|
||||
<Skeleton className="h-36 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-80 rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Billing</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Credits, balance, and account usage for your organization.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
{canPurchaseCredits && (
|
||||
<Button onClick={handlePurchaseCredits} disabled={purchasing}>
|
||||
<CreditCard className="h-4 w-4 mr-2" />
|
||||
{purchasing ? "Opening..." : "Add Credits"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>{isBillingV2 ? "Credit balance" : "Credits remaining"}</CardDescription>
|
||||
<CardTitle className="flex items-center gap-2 text-3xl">
|
||||
<CircleDollarSign className="h-6 w-6 text-muted-foreground" />
|
||||
{formatCredits(remainingCredits)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">1 credit = 1 cent</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Credits used</CardDescription>
|
||||
<CardTitle className="text-3xl">{formatCredits(usedCredits)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isBillingV2 ? "Total ledger debits" : "Current allocation usage"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{isBillingV2 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Credit Ledger</CardTitle>
|
||||
<CardDescription>Recent grants, purchases, and usage debits.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{ledgerEntries.length > 0 ? (
|
||||
<div className="bg-card border rounded-lg overflow-x-auto shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Activity</TableHead>
|
||||
<TableHead>Origin</TableHead>
|
||||
<TableHead>Run</TableHead>
|
||||
<TableHead className="text-right">Delta</TableHead>
|
||||
<TableHead className="text-right">Balance</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ledgerEntries.map((entry) => {
|
||||
const delta = entry.credits_delta ?? 0;
|
||||
const runHref = getRunHref(entry);
|
||||
const billableQuantity = formatBillableQuantity(entry);
|
||||
return (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell>{formatDate(entry.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">{getLedgerEntryLabel(entry)}</span>
|
||||
{billableQuantity && (
|
||||
<span className="text-xs text-muted-foreground">{billableQuantity}</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{entry.origin ? (
|
||||
<Badge variant="secondary">{formatTitleCase(entry.origin)}</Badge>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{entry.workflow_run_id ? (
|
||||
runHref ? (
|
||||
<Link className="font-medium text-primary hover:underline" href={runHref}>
|
||||
#{entry.workflow_run_id}
|
||||
</Link>
|
||||
) : (
|
||||
<span>#{entry.workflow_run_id}</span>
|
||||
)
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className={`text-right font-medium ${delta >= 0 ? "text-green-600" : "text-destructive"}`}>
|
||||
{delta >= 0 ? "+" : ""}
|
||||
{formatCredits(delta)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{formatCredits(entry.balance_after)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(entry.amount_minor, entry.amount_currency)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
||||
No ledger entries yet
|
||||
</div>
|
||||
)}
|
||||
{ledgerTotalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {ledgerPage} of {ledgerTotalPages} ({ledgerTotalCount} total entries)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(ledgerPage - 1)}
|
||||
disabled={ledgerPage <= 1 || loading || refreshing}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(ledgerPage + 1)}
|
||||
disabled={ledgerPage >= ledgerTotalPages || loading || refreshing}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Credit Usage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Progress value={usagePercent} />
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{usagePercent}% used</span>
|
||||
<span>{formatCredits(remainingCredits)} of {formatCredits(totalQuota)} remaining</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,8 +12,8 @@ import SpinLoader from "@/components/SpinLoader";
|
|||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { AppConfigProvider } from "@/context/AppConfigContext";
|
||||
import { OnboardingProvider } from "@/context/OnboardingContext";
|
||||
import { OrgConfigProvider } from "@/context/OrgConfigContext";
|
||||
import { TelephonyConfigWarningsProvider } from "@/context/TelephonyConfigWarningsContext";
|
||||
import { UserConfigProvider } from "@/context/UserConfigContext";
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ export default function RootLayout({
|
|||
<AuthProvider>
|
||||
<AppConfigProvider>
|
||||
<Suspense fallback={<SpinLoader />}>
|
||||
<UserConfigProvider>
|
||||
<OrgConfigProvider>
|
||||
<TelephonyConfigWarningsProvider>
|
||||
<OnboardingProvider>
|
||||
<PostHogIdentify />
|
||||
|
|
@ -76,7 +76,7 @@ export default function RootLayout({
|
|||
<ChatwootWidget />
|
||||
</OnboardingProvider>
|
||||
</TelephonyConfigWarningsProvider>
|
||||
</UserConfigProvider>
|
||||
</OrgConfigProvider>
|
||||
</Suspense>
|
||||
</AppConfigProvider>
|
||||
</AuthProvider>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { addDays, format, subDays } from 'date-fns';
|
||||
import { Calendar, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
import { useEffect,useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
getDailyReportApiV1OrganizationsReportsDailyGet,
|
||||
|
|
@ -201,7 +201,9 @@ export default function ReportsPage() {
|
|||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<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">
|
||||
<h1 className="text-3xl font-bold">Daily Reports</h1>
|
||||
</div>
|
||||
|
||||
{/* Date Navigation & Workflow Selector */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { useCallback, useEffect, useId, useState } from 'react';
|
|||
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen';
|
||||
import type { DailyUsageBreakdownResponse, MpsCreditsResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
|
||||
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen';
|
||||
import type { DailyUsageBreakdownResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
|
||||
import { CallTypeCell } from '@/components/CallTypeCell';
|
||||
import { DailyUsageTable } from '@/components/DailyUsageTable';
|
||||
import { FilterBuilder } from '@/components/filters/FilterBuilder';
|
||||
|
|
@ -15,7 +15,6 @@ import { MediaPreviewButton, MediaPreviewDialog } from '@/components/MediaPrevie
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -39,10 +38,6 @@ export default function UsagePage() {
|
|||
const { organizationPricing } = useUserConfig();
|
||||
const auth = useAuth();
|
||||
|
||||
// MPS credits state
|
||||
const [mpsCredits, setMpsCredits] = useState<MpsCreditsResponse | null>(null);
|
||||
const [isLoadingCredits, setIsLoadingCredits] = useState(true);
|
||||
|
||||
// Usage history state
|
||||
const [usageHistory, setUsageHistory] = useState<UsageHistoryResponse | null>(null);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
|
|
@ -78,21 +73,6 @@ export default function UsagePage() {
|
|||
const [preferencesLoading, setPreferencesLoading] = useState(true);
|
||||
const timezoneSelectId = useId(); // Stable ID for react-select to prevent hydration mismatch
|
||||
|
||||
// Fetch MPS credits
|
||||
const fetchMpsCredits = useCallback(async () => {
|
||||
if (!auth.isAuthenticated) return;
|
||||
try {
|
||||
const response = await getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet();
|
||||
if (response.data) {
|
||||
setMpsCredits(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MPS credits:', error);
|
||||
} finally {
|
||||
setIsLoadingCredits(false);
|
||||
}
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
// Translate the FilterBuilder state into the query-param shape the
|
||||
// backend expects. Shared between the listing fetch and the CSV export
|
||||
// so they stay in lockstep.
|
||||
|
|
@ -251,10 +231,9 @@ export default function UsagePage() {
|
|||
// Initial load - fetch when auth becomes available
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
fetchMpsCredits();
|
||||
fetchUsageHistory(currentPage, appliedFilters);
|
||||
}
|
||||
}, [auth.isAuthenticated, currentPage, appliedFilters, fetchUsageHistory, fetchMpsCredits]);
|
||||
}, [auth.isAuthenticated, currentPage, appliedFilters, fetchUsageHistory]);
|
||||
|
||||
// Fetch daily usage when organizationPricing becomes available
|
||||
useEffect(() => {
|
||||
|
|
@ -428,46 +407,6 @@ export default function UsagePage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* MPS Credits Card */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Dograh Model Credits</CardTitle>
|
||||
<CardDescription>
|
||||
These track usage of Dograh models using Dograh Service Keys.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingCredits ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-muted rounded w-1/4"></div>
|
||||
<div className="h-8 bg-muted rounded"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/3"></div>
|
||||
</div>
|
||||
) : mpsCredits ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div>
|
||||
<p className="text-2xl font-bold">
|
||||
{mpsCredits.total_credits_used.toFixed(2)} <span className="text-lg font-normal text-muted-foreground">/ {mpsCredits.total_quota.toFixed(2)}</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Credits Used</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold">{mpsCredits.remaining_credits.toFixed(2)}</p>
|
||||
<p className="text-sm text-muted-foreground">Remaining</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mpsCredits.total_quota > 0 && (
|
||||
<Progress value={(mpsCredits.total_credits_used / mpsCredits.total_quota) * 100} className="h-3" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No Dograh service keys configured. Set up a service key in your model configuration to see usage.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Daily Usage Table - Only for paid organizations */}
|
||||
{organizationPricing?.price_per_second_usd && (
|
||||
<div className="mb-6">
|
||||
|
|
@ -535,9 +474,9 @@ export default function UsagePage() {
|
|||
<TableHead className="font-semibold">Disposition</TableHead>
|
||||
<TableHead className="font-semibold">Date</TableHead>
|
||||
<TableHead className="font-semibold text-right">Duration</TableHead>
|
||||
<TableHead className="font-semibold text-right">
|
||||
{organizationPricing?.price_per_second_usd ? 'Cost (USD)' : 'Tokens'}
|
||||
</TableHead>
|
||||
{organizationPricing?.price_per_second_usd && (
|
||||
<TableHead className="font-semibold text-right">Cost (USD)</TableHead>
|
||||
)}
|
||||
<TableHead className="font-semibold">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -574,12 +513,14 @@ export default function UsagePage() {
|
|||
<TableCell className="text-right">
|
||||
{formatDuration(run.call_duration_seconds)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{organizationPricing?.price_per_second_usd && run.charge_usd !== undefined && run.charge_usd !== null
|
||||
? `$${run.charge_usd.toFixed(2)}`
|
||||
: run.dograh_token_usage.toLocaleString()
|
||||
}
|
||||
</TableCell>
|
||||
{organizationPricing?.price_per_second_usd && (
|
||||
<TableCell className="text-right font-medium">
|
||||
{run.charge_usd !== undefined && run.charge_usd !== null
|
||||
? `$${run.charge_usd.toFixed(2)}`
|
||||
: '-'
|
||||
}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<MediaPreviewButton
|
||||
recordingUrl={run.recording_url}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1642,22 +1642,6 @@ export type CurrentUsageResponse = {
|
|||
* Used Dograh Tokens
|
||||
*/
|
||||
used_dograh_tokens: number;
|
||||
/**
|
||||
* Quota Dograh Tokens
|
||||
*/
|
||||
quota_dograh_tokens: number;
|
||||
/**
|
||||
* Percentage Used
|
||||
*/
|
||||
percentage_used: number;
|
||||
/**
|
||||
* Next Refresh Date
|
||||
*/
|
||||
next_refresh_date: string;
|
||||
/**
|
||||
* Quota Enabled
|
||||
*/
|
||||
quota_enabled: boolean;
|
||||
/**
|
||||
* Total Duration Seconds
|
||||
*/
|
||||
|
|
@ -1666,10 +1650,6 @@ export type CurrentUsageResponse = {
|
|||
* Used Amount Usd
|
||||
*/
|
||||
used_amount_usd?: number | null;
|
||||
/**
|
||||
* Quota Amount Usd
|
||||
*/
|
||||
quota_amount_usd?: number | null;
|
||||
/**
|
||||
* Currency
|
||||
*/
|
||||
|
|
@ -3107,6 +3087,165 @@ export type LoginRequest = {
|
|||
password: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* MPSBillingAccountResponse
|
||||
*/
|
||||
export type MpsBillingAccountResponse = {
|
||||
/**
|
||||
* Id
|
||||
*/
|
||||
id: number;
|
||||
/**
|
||||
* Organization Id
|
||||
*/
|
||||
organization_id: number;
|
||||
/**
|
||||
* Billing Mode
|
||||
*/
|
||||
billing_mode: string;
|
||||
/**
|
||||
* Cached Balance Credits
|
||||
*/
|
||||
cached_balance_credits: number;
|
||||
/**
|
||||
* Currency
|
||||
*/
|
||||
currency: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* MPSBillingCreditsResponse
|
||||
*/
|
||||
export type MpsBillingCreditsResponse = {
|
||||
/**
|
||||
* Billing Version
|
||||
*/
|
||||
billing_version: 'legacy' | 'v2';
|
||||
/**
|
||||
* Total Credits Used
|
||||
*/
|
||||
total_credits_used?: number;
|
||||
/**
|
||||
* Remaining Credits
|
||||
*/
|
||||
remaining_credits?: number;
|
||||
/**
|
||||
* Total Quota
|
||||
*/
|
||||
total_quota?: number;
|
||||
account?: MpsBillingAccountResponse | null;
|
||||
/**
|
||||
* Ledger Entries
|
||||
*/
|
||||
ledger_entries?: Array<MpsCreditLedgerEntryResponse>;
|
||||
/**
|
||||
* Total Count
|
||||
*/
|
||||
total_count?: number;
|
||||
/**
|
||||
* Page
|
||||
*/
|
||||
page?: number;
|
||||
/**
|
||||
* Limit
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* Total Pages
|
||||
*/
|
||||
total_pages?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* MPSCreditLedgerEntryResponse
|
||||
*/
|
||||
export type MpsCreditLedgerEntryResponse = {
|
||||
/**
|
||||
* Id
|
||||
*/
|
||||
id: number;
|
||||
/**
|
||||
* Entry Type
|
||||
*/
|
||||
entry_type: string;
|
||||
/**
|
||||
* Origin
|
||||
*/
|
||||
origin?: string | null;
|
||||
/**
|
||||
* Credits Delta
|
||||
*/
|
||||
credits_delta: number;
|
||||
/**
|
||||
* Balance After
|
||||
*/
|
||||
balance_after: number;
|
||||
/**
|
||||
* Amount Minor
|
||||
*/
|
||||
amount_minor?: number | null;
|
||||
/**
|
||||
* Amount Currency
|
||||
*/
|
||||
amount_currency?: string | null;
|
||||
/**
|
||||
* Payment Order Id
|
||||
*/
|
||||
payment_order_id?: number | null;
|
||||
/**
|
||||
* Metric Code
|
||||
*/
|
||||
metric_code?: string | null;
|
||||
/**
|
||||
* Correlation Id
|
||||
*/
|
||||
correlation_id?: string | null;
|
||||
/**
|
||||
* Aggregation Key
|
||||
*/
|
||||
aggregation_key?: string | null;
|
||||
/**
|
||||
* Usage Event Id
|
||||
*/
|
||||
usage_event_id?: number | null;
|
||||
/**
|
||||
* Workflow Run Id
|
||||
*/
|
||||
workflow_run_id?: number | null;
|
||||
/**
|
||||
* Workflow Id
|
||||
*/
|
||||
workflow_id?: number | null;
|
||||
/**
|
||||
* Billable Quantity
|
||||
*/
|
||||
billable_quantity?: number | null;
|
||||
/**
|
||||
* Quantity Unit
|
||||
*/
|
||||
quantity_unit?: string | null;
|
||||
/**
|
||||
* Metadata
|
||||
*/
|
||||
metadata?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* Created At
|
||||
*/
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* MPSCreditPurchaseUrlResponse
|
||||
*/
|
||||
export type MpsCreditPurchaseUrlResponse = {
|
||||
/**
|
||||
* Checkout Url
|
||||
*/
|
||||
checkout_url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* MPSCreditsResponse
|
||||
*/
|
||||
|
|
@ -3618,6 +3757,43 @@ export type OrganizationAiModelConfigurationV2 = {
|
|||
byok?: ByokaiModelConfiguration | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* OrganizationContextResponse
|
||||
*/
|
||||
export type OrganizationContextResponse = {
|
||||
/**
|
||||
* Organization Id
|
||||
*/
|
||||
organization_id?: number | null;
|
||||
/**
|
||||
* Organization Provider Id
|
||||
*/
|
||||
organization_provider_id?: string | null;
|
||||
model_services: OrganizationModelServicesContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* OrganizationModelServicesContext
|
||||
*/
|
||||
export type OrganizationModelServicesContext = {
|
||||
/**
|
||||
* Config Source
|
||||
*/
|
||||
config_source: 'organization_v2' | 'legacy_user_v1' | 'empty';
|
||||
/**
|
||||
* Has Model Configuration V2
|
||||
*/
|
||||
has_model_configuration_v2: boolean;
|
||||
/**
|
||||
* Managed Service Version
|
||||
*/
|
||||
managed_service_version?: number | null;
|
||||
/**
|
||||
* Uses Managed Service V2
|
||||
*/
|
||||
uses_managed_service_v2: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* OrganizationPreferences
|
||||
*/
|
||||
|
|
@ -9750,6 +9926,45 @@ export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses = {
|
|||
|
||||
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse = UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses[keyof UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses];
|
||||
|
||||
export type GetCurrentOrganizationContextApiV1OrganizationsContextGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/organizations/context';
|
||||
};
|
||||
|
||||
export type GetCurrentOrganizationContextApiV1OrganizationsContextGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetCurrentOrganizationContextApiV1OrganizationsContextGetError = GetCurrentOrganizationContextApiV1OrganizationsContextGetErrors[keyof GetCurrentOrganizationContextApiV1OrganizationsContextGetErrors];
|
||||
|
||||
export type GetCurrentOrganizationContextApiV1OrganizationsContextGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: OrganizationContextResponse;
|
||||
};
|
||||
|
||||
export type GetCurrentOrganizationContextApiV1OrganizationsContextGetResponse = GetCurrentOrganizationContextApiV1OrganizationsContextGetResponses[keyof GetCurrentOrganizationContextApiV1OrganizationsContextGetResponses];
|
||||
|
||||
export type GetTelephonyProvidersMetadataApiV1OrganizationsTelephonyProvidersMetadataGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
@ -11269,6 +11484,93 @@ export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses = {
|
|||
|
||||
export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponse = GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses[keyof GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses];
|
||||
|
||||
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: {
|
||||
/**
|
||||
* Page
|
||||
*/
|
||||
page?: number;
|
||||
/**
|
||||
* Limit
|
||||
*/
|
||||
limit?: number;
|
||||
};
|
||||
url: '/api/v1/organizations/billing/credits';
|
||||
};
|
||||
|
||||
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetError = GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors[keyof GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors];
|
||||
|
||||
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: MpsBillingCreditsResponse;
|
||||
};
|
||||
|
||||
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponse = GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses[keyof GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses];
|
||||
|
||||
export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/organizations/usage/mps-credits/purchase-url';
|
||||
};
|
||||
|
||||
export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostError = CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostErrors[keyof CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostErrors];
|
||||
|
||||
export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: MpsCreditPurchaseUrlResponse;
|
||||
};
|
||||
|
||||
export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponse = CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponses[keyof CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponses];
|
||||
|
||||
export type GetUsageHistoryApiV1OrganizationsUsageRunsGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
|
||||
|
||||
type ModelMode = "dograh" | "byok";
|
||||
type ModelMode = "realtime" | "dograh" | "byok";
|
||||
|
||||
interface DograhDefaults {
|
||||
voices: string[];
|
||||
|
|
@ -125,24 +125,35 @@ function effectiveConfigToLegacyShape(config: Record<string, unknown> | null): R
|
|||
};
|
||||
}
|
||||
|
||||
function emptyByokInitialConfig(): Record<string, unknown> {
|
||||
function emptyByokInitialConfig(isRealtime: boolean): Record<string, unknown> {
|
||||
return {
|
||||
is_realtime: false,
|
||||
is_realtime: isRealtime,
|
||||
};
|
||||
}
|
||||
|
||||
// The v2 editor surfaces realtime ("Speech to Speech") and pipeline (BYOK) as
|
||||
// separate tabs, so each tab gets its own initial config. A tab is pre-filled
|
||||
// only when the saved (or effective) configuration matches that tab's mode;
|
||||
// otherwise it starts empty so the other tab's data does not leak across.
|
||||
function getByokInitialConfig(
|
||||
configuration: Record<string, unknown> | null,
|
||||
effectiveConfiguration: Record<string, unknown> | null,
|
||||
wantRealtime: boolean,
|
||||
): Record<string, unknown> {
|
||||
const byokConfiguration = byokConfigToLegacyShape(configuration);
|
||||
if (byokConfiguration) return byokConfiguration;
|
||||
const matchesTab = (config: Record<string, unknown> | null) =>
|
||||
config ? Boolean(config.is_realtime) === wantRealtime : false;
|
||||
|
||||
if (configuration?.mode === "dograh" || isDograhEffectiveConfig(effectiveConfiguration)) {
|
||||
return emptyByokInitialConfig();
|
||||
const byokConfiguration = byokConfigToLegacyShape(configuration);
|
||||
if (byokConfiguration) {
|
||||
return matchesTab(byokConfiguration) ? byokConfiguration : emptyByokInitialConfig(wantRealtime);
|
||||
}
|
||||
|
||||
return effectiveConfigToLegacyShape(effectiveConfiguration) || emptyByokInitialConfig();
|
||||
if (configuration?.mode === "dograh" || isDograhEffectiveConfig(effectiveConfiguration)) {
|
||||
return emptyByokInitialConfig(wantRealtime);
|
||||
}
|
||||
|
||||
const effective = effectiveConfigToLegacyShape(effectiveConfiguration);
|
||||
return matchesTab(effective) ? (effective as Record<string, unknown>) : emptyByokInitialConfig(wantRealtime);
|
||||
}
|
||||
|
||||
function buildDograhState(
|
||||
|
|
@ -185,10 +196,12 @@ function preferredMode(
|
|||
configuration: Record<string, unknown> | null,
|
||||
effectiveConfiguration: Record<string, unknown> | null,
|
||||
): ModelMode {
|
||||
if (configuration?.mode === "dograh" || configuration?.mode === "byok") {
|
||||
return configuration.mode;
|
||||
if (configuration?.mode === "dograh") return "dograh";
|
||||
if (configuration?.mode === "byok") {
|
||||
return asRecord(configuration.byok)?.mode === "realtime" ? "realtime" : "byok";
|
||||
}
|
||||
return isDograhEffectiveConfig(effectiveConfiguration) ? "dograh" : "byok";
|
||||
if (isDograhEffectiveConfig(effectiveConfiguration)) return "dograh";
|
||||
return Boolean(effectiveConfiguration?.is_realtime) ? "realtime" : "byok";
|
||||
}
|
||||
|
||||
function hasRequiredApiKey(
|
||||
|
|
@ -249,7 +262,8 @@ export function AIModelConfigurationV2Editor({
|
|||
speed: defaults.dograh.defaults.speed,
|
||||
language: defaults.dograh.defaults.language,
|
||||
}));
|
||||
const [byokInitialConfig, setByokInitialConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [realtimeInitialConfig, setRealtimeInitialConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [pipelineInitialConfig, setPipelineInitialConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [isSavingDograh, setIsSavingDograh] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -258,7 +272,8 @@ export function AIModelConfigurationV2Editor({
|
|||
const rawEffectiveConfiguration = asRecord(effectiveConfiguration);
|
||||
setMode(preferredMode(rawConfiguration, rawEffectiveConfiguration));
|
||||
setDograh(buildDograhState(defaults, rawConfiguration, rawEffectiveConfiguration));
|
||||
setByokInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration));
|
||||
setRealtimeInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, true));
|
||||
setPipelineInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, false));
|
||||
}, [configuration, defaults, effectiveConfiguration]);
|
||||
|
||||
const saveDograhConfiguration = async () => {
|
||||
|
|
@ -322,28 +337,30 @@ export function AIModelConfigurationV2Editor({
|
|||
)}
|
||||
|
||||
<Tabs value={mode} onValueChange={(value) => setMode(value as ModelMode)} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="realtime">Speech to Speech</TabsTrigger>
|
||||
<TabsTrigger value="dograh">Dograh</TabsTrigger>
|
||||
<TabsTrigger value="byok">BYOK</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="realtime" className="mt-0">
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
A single speech-to-speech model handles the conversation in realtime (no separate transcriber or voice). An LLM is still required for variable extraction and QA.
|
||||
</p>
|
||||
<ServiceConfigurationForm
|
||||
key={`realtime-${JSON.stringify(realtimeInitialConfig)}`}
|
||||
mode="global"
|
||||
forceRealtime
|
||||
configurationDefaults={defaultsForByok}
|
||||
initialConfig={realtimeInitialConfig}
|
||||
submitLabel={submitLabel}
|
||||
onSave={saveByokConfiguration}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="dograh" className="mt-0">
|
||||
<div className="rounded-lg border p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="dograh-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="dograh-api-key"
|
||||
className="pl-9"
|
||||
value={dograh.api_key}
|
||||
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Voice</Label>
|
||||
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
|
||||
|
|
@ -394,6 +411,20 @@ export function AIModelConfigurationV2Editor({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="dograh-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="dograh-api-key"
|
||||
className="pl-9"
|
||||
value={dograh.api_key}
|
||||
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
|
||||
|
|
@ -405,10 +436,11 @@ export function AIModelConfigurationV2Editor({
|
|||
|
||||
<TabsContent value="byok" className="mt-0">
|
||||
<ServiceConfigurationForm
|
||||
key={JSON.stringify(byokInitialConfig)}
|
||||
key={`byok-${JSON.stringify(pipelineInitialConfig)}`}
|
||||
mode="global"
|
||||
forceRealtime={false}
|
||||
configurationDefaults={defaultsForByok}
|
||||
initialConfig={byokInitialConfig}
|
||||
initialConfig={pipelineInitialConfig}
|
||||
submitLabel={submitLabel}
|
||||
onSave={saveByokConfiguration}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -101,6 +101,13 @@ export interface ServiceConfigurationFormProps {
|
|||
submitLabel?: string;
|
||||
configurationDefaults?: ServiceConfigurationDefaults | null;
|
||||
initialConfig?: Record<string, unknown> | null;
|
||||
/**
|
||||
* When set, locks the realtime/pipeline mode to this value and hides the
|
||||
* in-form toggle. The v2 editor uses this to surface realtime
|
||||
* ("Speech to Speech") and pipeline (BYOK) as separate top-level tabs.
|
||||
* Leave undefined to keep the user-controllable toggle (legacy + overrides).
|
||||
*/
|
||||
forceRealtime?: boolean;
|
||||
}
|
||||
|
||||
function getProviderDisplayName(
|
||||
|
|
@ -130,10 +137,11 @@ export function ServiceConfigurationForm({
|
|||
submitLabel,
|
||||
configurationDefaults,
|
||||
initialConfig,
|
||||
forceRealtime,
|
||||
}: ServiceConfigurationFormProps) {
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isRealtime, setIsRealtime] = useState(false);
|
||||
const [isRealtime, setIsRealtime] = useState(forceRealtime ?? false);
|
||||
const { userConfig } = useUserConfig();
|
||||
const [schemas, setSchemas] = useState<Record<ServiceSegment, Record<string, ProviderSchema>>>({
|
||||
llm: {},
|
||||
|
|
@ -227,9 +235,9 @@ export function ServiceConfigurationForm({
|
|||
realtime: realtimeSchemas,
|
||||
});
|
||||
|
||||
// Restore realtime toggle
|
||||
// Restore realtime toggle (skip when the parent locks the mode)
|
||||
const configData = configSource as Record<string, unknown> | null;
|
||||
if (configData?.is_realtime) {
|
||||
if (forceRealtime === undefined && configData?.is_realtime) {
|
||||
setIsRealtime(true);
|
||||
}
|
||||
|
||||
|
|
@ -867,22 +875,24 @@ export function ServiceConfigurationForm({
|
|||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* Realtime toggle */}
|
||||
<div className="flex items-center justify-between mb-4 p-4 border rounded-lg">
|
||||
<div>
|
||||
<Label htmlFor="realtime-toggle" className="text-sm font-medium">
|
||||
Realtime Mode
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Uses a single speech-to-speech model (no separate STT/TTS). An LLM is still required for variable extraction and QA.
|
||||
</p>
|
||||
{/* Realtime toggle — hidden when the parent locks the mode (v2 tabs) */}
|
||||
{forceRealtime === undefined && (
|
||||
<div className="flex items-center justify-between mb-4 p-4 border rounded-lg">
|
||||
<div>
|
||||
<Label htmlFor="realtime-toggle" className="text-sm font-medium">
|
||||
Realtime Mode
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Uses a single speech-to-speech model (no separate STT/TTS). An LLM is still required for variable extraction and QA.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="realtime-toggle"
|
||||
checked={isRealtime}
|
||||
onCheckedChange={setIsRealtime}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
id="realtime-toggle"
|
||||
checked={isRealtime}
|
||||
onCheckedChange={setIsRealtime}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
|
|
|
|||
|
|
@ -136,6 +136,11 @@ const NAV_SECTIONS: SidebarNavSection[] = [
|
|||
url: "/usage",
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
title: "Billing",
|
||||
url: "/billing",
|
||||
icon: CircleDollarSign,
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
url: "/reports",
|
||||
|
|
|
|||
192
ui/src/context/OrgConfigContext.tsx
Normal file
192
ui/src/context/OrgConfigContext.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { client } from '@/client/client.gen';
|
||||
import { getCurrentOrganizationContextApiV1OrganizationsContextGet, getUserConfigurationsApiV1UserConfigurationsUserGet, updateUserConfigurationsApiV1UserConfigurationsUserPut } from '@/client/sdk.gen';
|
||||
import type { OrganizationContextResponse, UserConfigurationRequestResponseSchema } from '@/client/types.gen';
|
||||
import { setupAuthInterceptor } from '@/lib/apiClient';
|
||||
import type { AuthUser } from '@/lib/auth';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
interface TeamPermission {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface OrganizationPricing {
|
||||
price_per_second_usd: number | null;
|
||||
currency: string;
|
||||
billing_enabled: boolean;
|
||||
}
|
||||
|
||||
interface OrgConfigContextType {
|
||||
orgContext: OrganizationContextResponse | null;
|
||||
userConfig: UserConfigurationRequestResponseSchema | null;
|
||||
saveUserConfig: (userConfig: UserConfigurationRequestResponseSchema) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refreshConfig: () => Promise<void>;
|
||||
permissions: TeamPermission[];
|
||||
user: AuthUser | null;
|
||||
organizationPricing: OrganizationPricing | null;
|
||||
}
|
||||
|
||||
const OrgConfigContext = createContext<OrgConfigContextType | null>(null);
|
||||
|
||||
const pricingFromUserConfig = (
|
||||
userConfig: UserConfigurationRequestResponseSchema,
|
||||
): OrganizationPricing | null => {
|
||||
if (!userConfig.organization_pricing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
price_per_second_usd: userConfig.organization_pricing.price_per_second_usd as number | null,
|
||||
currency: (userConfig.organization_pricing.currency as string) || 'USD',
|
||||
billing_enabled: (userConfig.organization_pricing.billing_enabled as boolean) || false,
|
||||
};
|
||||
};
|
||||
|
||||
export function OrgConfigProvider({ children }: { children: ReactNode }) {
|
||||
const [orgContext, setOrgContext] = useState<OrganizationContextResponse | null>(null);
|
||||
const [userConfig, setUserConfig] = useState<UserConfigurationRequestResponseSchema | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [organizationPricing, setOrganizationPricing] = useState<OrganizationPricing | null>(null);
|
||||
const [permissions, setPermissions] = useState<TeamPermission[]>([]);
|
||||
|
||||
const auth = useAuth();
|
||||
|
||||
const authRef = useRef(auth);
|
||||
authRef.current = auth;
|
||||
|
||||
const hasFetchedConfig = useRef(false);
|
||||
const hasFetchedPermissions = useRef(false);
|
||||
|
||||
if (!auth.loading && auth.isAuthenticated) {
|
||||
setupAuthInterceptor(client, auth.getAccessToken);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.loading || hasFetchedPermissions.current) {
|
||||
return;
|
||||
}
|
||||
hasFetchedPermissions.current = true;
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
const currentAuth = authRef.current;
|
||||
if (currentAuth.provider === 'stack' && currentAuth.getSelectedTeam && currentAuth.listPermissions) {
|
||||
const selectedTeam = currentAuth.getSelectedTeam();
|
||||
if (selectedTeam) {
|
||||
try {
|
||||
const perms = await currentAuth.listPermissions(selectedTeam);
|
||||
setPermissions(Array.isArray(perms) ? perms : []);
|
||||
} catch {
|
||||
setPermissions([]);
|
||||
}
|
||||
} else {
|
||||
setPermissions([]);
|
||||
}
|
||||
} else {
|
||||
setPermissions([{ id: 'admin' }]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPermissions();
|
||||
}, [auth.loading, auth.provider]);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
const currentAuth = authRef.current;
|
||||
if (!currentAuth.isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [orgContextResponse, userConfigResponse] = await Promise.all([
|
||||
getCurrentOrganizationContextApiV1OrganizationsContextGet(),
|
||||
getUserConfigurationsApiV1UserConfigurationsUserGet(),
|
||||
]);
|
||||
|
||||
if (orgContextResponse.data) {
|
||||
setOrgContext(orgContextResponse.data);
|
||||
}
|
||||
|
||||
if (userConfigResponse.data) {
|
||||
setUserConfig(userConfigResponse.data);
|
||||
setOrganizationPricing(pricingFromUserConfig(userConfigResponse.data));
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch organization configuration'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.loading || !auth.isAuthenticated || hasFetchedConfig.current) {
|
||||
return;
|
||||
}
|
||||
hasFetchedConfig.current = true;
|
||||
fetchConfig();
|
||||
}, [auth.loading, auth.isAuthenticated, fetchConfig]);
|
||||
|
||||
const saveUserConfig = useCallback(async (userConfigRequest: UserConfigurationRequestResponseSchema) => {
|
||||
if (!authRef.current.isAuthenticated) throw new Error('No authentication available');
|
||||
const response = await updateUserConfigurationsApiV1UserConfigurationsUserPut({
|
||||
body: {
|
||||
...userConfig,
|
||||
...userConfigRequest,
|
||||
} as UserConfigurationRequestResponseSchema,
|
||||
});
|
||||
if (response.error) {
|
||||
let msg = 'Failed to save user configuration';
|
||||
const detail = (response.error as unknown as { detail?: string | { errors: { model: string; message: string }[] } }).detail;
|
||||
if (typeof detail === 'string') {
|
||||
msg = detail;
|
||||
} else if (Array.isArray(detail)) {
|
||||
msg = detail
|
||||
.map((e: { model: string; message: string }) => `${e.model}: ${e.message}`)
|
||||
.join('\n');
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
setUserConfig(response.data);
|
||||
setOrganizationPricing(pricingFromUserConfig(response.data));
|
||||
}
|
||||
}, [userConfig]);
|
||||
|
||||
const refreshConfig = useCallback(async () => {
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
return (
|
||||
<OrgConfigContext.Provider
|
||||
value={{
|
||||
orgContext,
|
||||
userConfig,
|
||||
saveUserConfig,
|
||||
loading,
|
||||
error,
|
||||
refreshConfig,
|
||||
permissions,
|
||||
user: auth.user,
|
||||
organizationPricing,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</OrgConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOrgConfig() {
|
||||
const context = useContext(OrgConfigContext);
|
||||
if (!context) {
|
||||
throw new Error('useOrgConfig must be used within an OrgConfigProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
@ -1,205 +1,3 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { client } from '@/client/client.gen';
|
||||
import { getUserConfigurationsApiV1UserConfigurationsUserGet, updateUserConfigurationsApiV1UserConfigurationsUserPut } from '@/client/sdk.gen';
|
||||
import type { UserConfigurationRequestResponseSchema } from '@/client/types.gen';
|
||||
import { setupAuthInterceptor } from '@/lib/apiClient';
|
||||
import type { AuthUser } from '@/lib/auth';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
|
||||
interface TeamPermission {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface OrganizationPricing {
|
||||
price_per_second_usd: number | null;
|
||||
currency: string;
|
||||
billing_enabled: boolean;
|
||||
}
|
||||
|
||||
interface UserConfigContextType {
|
||||
userConfig: UserConfigurationRequestResponseSchema | null;
|
||||
saveUserConfig: (userConfig: UserConfigurationRequestResponseSchema) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refreshConfig: () => Promise<void>;
|
||||
permissions: TeamPermission[];
|
||||
user: AuthUser | null;
|
||||
organizationPricing: OrganizationPricing | null;
|
||||
}
|
||||
|
||||
const UserConfigContext = createContext<UserConfigContextType | null>(null);
|
||||
|
||||
export function UserConfigProvider({ children }: { children: ReactNode }) {
|
||||
const [userConfig, setUserConfig] = useState<UserConfigurationRequestResponseSchema | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [organizationPricing, setOrganizationPricing] = useState<OrganizationPricing | null>(null);
|
||||
const [permissions, setPermissions] = useState<TeamPermission[]>([]);
|
||||
|
||||
const auth = useAuth();
|
||||
|
||||
// Store auth functions in refs to avoid dependency issues
|
||||
const authRef = useRef(auth);
|
||||
authRef.current = auth;
|
||||
|
||||
// Track initialization
|
||||
const hasFetchedConfig = useRef(false);
|
||||
const hasFetchedPermissions = useRef(false);
|
||||
|
||||
// Register the auth interceptor synchronously during render (not in useEffect)
|
||||
// so it's in place before any child effects fire API calls.
|
||||
// setupAuthInterceptor is idempotent — safe for strict mode double-renders.
|
||||
if (!auth.loading && auth.isAuthenticated) {
|
||||
setupAuthInterceptor(client, auth.getAccessToken);
|
||||
}
|
||||
|
||||
// Fetch permissions once when auth is ready
|
||||
useEffect(() => {
|
||||
if (auth.loading || hasFetchedPermissions.current) {
|
||||
return;
|
||||
}
|
||||
hasFetchedPermissions.current = true;
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
const currentAuth = authRef.current;
|
||||
if (currentAuth.provider === 'stack' && currentAuth.getSelectedTeam && currentAuth.listPermissions) {
|
||||
const selectedTeam = currentAuth.getSelectedTeam();
|
||||
if (selectedTeam) {
|
||||
try {
|
||||
const perms = await currentAuth.listPermissions(selectedTeam);
|
||||
setPermissions(Array.isArray(perms) ? perms : []);
|
||||
} catch {
|
||||
setPermissions([]);
|
||||
}
|
||||
} else {
|
||||
setPermissions([]);
|
||||
}
|
||||
} else {
|
||||
setPermissions([{ id: 'admin' }]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPermissions();
|
||||
}, [auth.loading, auth.provider]);
|
||||
|
||||
// Fetch user config once when auth is ready
|
||||
useEffect(() => {
|
||||
if (auth.loading || !auth.isAuthenticated || hasFetchedConfig.current) {
|
||||
return;
|
||||
}
|
||||
hasFetchedConfig.current = true;
|
||||
|
||||
const fetchUserConfig = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getUserConfigurationsApiV1UserConfigurationsUserGet();
|
||||
|
||||
if (response.data) {
|
||||
setUserConfig(response.data);
|
||||
if (response.data.organization_pricing) {
|
||||
setOrganizationPricing({
|
||||
price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null,
|
||||
currency: response.data.organization_pricing.currency as string || 'USD',
|
||||
billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false
|
||||
});
|
||||
} else {
|
||||
setOrganizationPricing(null);
|
||||
}
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch user configuration'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserConfig();
|
||||
}, [auth.loading, auth.isAuthenticated]);
|
||||
|
||||
const saveUserConfig = useCallback(async (userConfigRequest: UserConfigurationRequestResponseSchema) => {
|
||||
if (!authRef.current.isAuthenticated) throw new Error('No authentication available');
|
||||
const response = await updateUserConfigurationsApiV1UserConfigurationsUserPut({
|
||||
body: {
|
||||
...userConfig,
|
||||
...userConfigRequest
|
||||
} as UserConfigurationRequestResponseSchema,
|
||||
});
|
||||
if (response.error) {
|
||||
let msg = 'Failed to save user configuration';
|
||||
const detail = (response.error as unknown as { detail?: string | { errors: { model: string; message: string }[] } }).detail;
|
||||
if (typeof detail === 'string') {
|
||||
msg = detail;
|
||||
} else if (Array.isArray(detail)) {
|
||||
msg = detail
|
||||
.map((e: { model: string; message: string }) => `${e.model}: ${e.message}`)
|
||||
.join('\n');
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
setUserConfig(response.data!);
|
||||
|
||||
if (response.data?.organization_pricing) {
|
||||
setOrganizationPricing({
|
||||
price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null,
|
||||
currency: response.data.organization_pricing.currency as string || 'USD',
|
||||
billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false
|
||||
});
|
||||
}
|
||||
}, [userConfig]);
|
||||
|
||||
const refreshConfig = useCallback(async () => {
|
||||
const currentAuth = authRef.current;
|
||||
if (!currentAuth.isAuthenticated) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getUserConfigurationsApiV1UserConfigurationsUserGet();
|
||||
|
||||
if (response.data) {
|
||||
setUserConfig(response.data);
|
||||
if (response.data.organization_pricing) {
|
||||
setOrganizationPricing({
|
||||
price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null,
|
||||
currency: response.data.organization_pricing.currency as string || 'USD',
|
||||
billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false
|
||||
});
|
||||
}
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch user configuration'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UserConfigContext.Provider
|
||||
value={{
|
||||
userConfig,
|
||||
saveUserConfig,
|
||||
loading,
|
||||
error,
|
||||
refreshConfig,
|
||||
permissions,
|
||||
user: auth.user,
|
||||
organizationPricing,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</UserConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUserConfig() {
|
||||
const context = useContext(UserConfigContext);
|
||||
if (!context) {
|
||||
throw new Error('useUserConfig must be used within a UserConfigProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
export { OrgConfigProvider as UserConfigProvider, useOrgConfig as useUserConfig } from './OrgConfigContext';
|
||||
|
|
|
|||
|
|
@ -2,18 +2,33 @@
|
|||
* Extract a human-readable message from a backend error response.
|
||||
*
|
||||
* The generated API client returns `{ error }` on failure (it does not throw),
|
||||
* and FastAPI shapes that error as either `{ detail: string }` (HTTPException)
|
||||
* or `{ detail: [{ msg, loc, ... }] }` (422 validation). This normalizes both
|
||||
* to a single string so it can be rendered or thrown directly — never pass the
|
||||
* raw `detail` to React, as the 422 array crashes rendering.
|
||||
* and FastAPI shapes that error as `{ detail: string }`, `{ detail:
|
||||
* [{ msg, loc, ... }] }`, or backend validation arrays like `{ detail:
|
||||
* [{ model, message }] }`. This normalizes those to a single string so it can
|
||||
* be rendered or thrown directly.
|
||||
*/
|
||||
export function detailFromError(err: unknown, fallback = "Request failed"): string {
|
||||
if (typeof err === "string") return err;
|
||||
const e = err as { detail?: unknown };
|
||||
if (typeof e?.detail === "string") return e.detail;
|
||||
if (Array.isArray(e?.detail) && e.detail.length > 0) {
|
||||
const first = e.detail[0] as { msg?: string };
|
||||
if (first?.msg) return first.msg;
|
||||
const messages = e.detail
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const detail = item as { message?: unknown; msg?: unknown; model?: unknown };
|
||||
const message = typeof detail.message === "string"
|
||||
? detail.message
|
||||
: typeof detail.msg === "string"
|
||||
? detail.msg
|
||||
: null;
|
||||
if (!message) return null;
|
||||
return typeof detail.model === "string" && detail.model
|
||||
? `${detail.model}: ${message}`
|
||||
: message;
|
||||
})
|
||||
.filter((message): message is string => Boolean(message));
|
||||
if (messages.length > 0) return messages.join("\n");
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue