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:
Abhishek 2026-06-12 14:55:30 +05:30 committed by GitHub
parent 97d7103480
commit 1f1149f4d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 3335 additions and 2057 deletions

416
ui/src/app/billing/page.tsx Normal file
View 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>
);
}

View file

@ -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>

View file

@ -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">

View file

@ -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

View file

@ -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?: {

View file

@ -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}
/>

View file

@ -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">

View file

@ -136,6 +136,11 @@ const NAV_SECTIONS: SidebarNavSection[] = [
url: "/usage",
icon: TrendingUp,
},
{
title: "Billing",
url: "/billing",
icon: CircleDollarSign,
},
{
title: "Reports",
url: "/reports",

View 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;
}

View file

@ -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';

View file

@ -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;
}