Merge remote-tracking branch 'origin/main' into feat/user-onboarding

This commit is contained in:
Abhishek Kumar 2026-06-12 18:54:48 +05:30
commit 093e888ce4
148 changed files with 10908 additions and 2815 deletions

View file

@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerBackendUrl } from "@/lib/apiClient";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const HOP_BY_HOP_HEADERS = [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
function trimTrailingSlash(url: string) {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
function buildBackendUrl(request: NextRequest) {
const backendUrl = trimTrailingSlash(getServerBackendUrl());
return `${backendUrl}${request.nextUrl.pathname}${request.nextUrl.search}`;
}
function createRequestHeaders(request: NextRequest) {
const headers = new Headers(request.headers);
for (const header of HOP_BY_HOP_HEADERS) {
headers.delete(header);
}
headers.delete("accept-encoding");
headers.delete("content-length");
headers.delete("host");
return headers;
}
function createResponseHeaders(response: Response) {
const headers = new Headers(response.headers);
const setCookies = response.headers.getSetCookie();
for (const header of HOP_BY_HOP_HEADERS) {
headers.delete(header);
}
headers.delete("content-encoding");
headers.delete("content-length");
headers.delete("set-cookie");
for (const cookie of setCookies) {
headers.append("set-cookie", cookie);
}
return headers;
}
async function getRequestBody(request: NextRequest) {
if (request.method === "GET" || request.method === "HEAD") {
return undefined;
}
return request.arrayBuffer();
}
async function proxyRequest(request: NextRequest) {
const backendUrl = buildBackendUrl(request);
try {
const response = await fetch(backendUrl, {
method: request.method,
headers: createRequestHeaders(request),
body: await getRequestBody(request),
cache: "no-store",
});
return new Response(request.method === "HEAD" ? null : response.body, {
status: response.status,
statusText: response.statusText,
headers: createResponseHeaders(response),
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown backend proxy error";
return NextResponse.json(
{
detail: `Backend request failed while proxying to ${backendUrl}: ${message}`,
},
{ status: 502 },
);
}
}
export const GET = proxyRequest;
export const POST = proxyRequest;
export const PUT = proxyRequest;
export const PATCH = proxyRequest;
export const DELETE = proxyRequest;
export const OPTIONS = proxyRequest;
export const HEAD = proxyRequest;

View file

@ -1,17 +1,416 @@
"use client";
import { DograhCreditsCard } from "@/components/billing/DograhCreditsCard";
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() {
return (
<div className="container mx-auto px-4 py-6">
<div className="mb-6">
<h1 className="text-3xl font-bold">Credits &amp; Billing</h1>
<p className="text-muted-foreground">
Track your Dograh model credits and request top-ups.
</p>
</div>
<DograhCreditsCard />
</div>
);
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

@ -13,8 +13,8 @@ import { ThemeProvider } from "@/components/ThemeProvider";
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";
@ -70,7 +70,7 @@ export default function RootLayout({
<AuthProvider>
<AppConfigProvider>
<Suspense fallback={<SpinLoader />}>
<UserConfigProvider>
<OrgConfigProvider>
<TelephonyConfigWarningsProvider>
<OnboardingProvider>
<PostHogIdentify />
@ -81,7 +81,7 @@ export default function RootLayout({
<ChatwootWidget />
</OnboardingProvider>
</TelephonyConfigWarningsProvider>
</UserConfigProvider>
</OrgConfigProvider>
</Suspense>
</AppConfigProvider>
</AuthProvider>

View file

@ -1,13 +1,25 @@
import ServiceConfiguration from "@/components/ServiceConfiguration";
import ModelConfigurationV2 from "@/components/ModelConfigurationV2";
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
export default function ServiceConfigurationPage() {
interface ServiceConfigurationPageProps {
searchParams?: Promise<{
action?: string | string[];
}>;
}
export default async function ServiceConfigurationPage({ searchParams }: ServiceConfigurationPageProps) {
const params = searchParams ? await searchParams : {};
const action = Array.isArray(params.action) ? params.action[0] : params.action;
return (
<div className="min-h-screen">
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<ServiceConfiguration docsUrl={SETTINGS_DOCUMENTATION_URLS.modelOverrides} />
<ModelConfigurationV2
docsUrl={SETTINGS_DOCUMENTATION_URLS.modelOverrides}
initialAction={action}
/>
</div>
</div>
</div>

View file

@ -2,11 +2,12 @@
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,
getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet,
getPreferencesApiV1OrganizationsPreferencesGet,
getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet
} from '@/client/sdk.gen';
import type { WorkflowRunDetail } from '@/client/types.gen';
@ -16,7 +17,6 @@ import { Card } from '@/components/ui/card';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { useUserConfig } from '@/context/UserConfigContext';
import { useAuth } from '@/lib/auth';
import { DispositionChart } from './components/DispositionChart';
@ -57,11 +57,9 @@ export default function ReportsPage() {
const [report, setReport] = useState<DailyReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { userConfig } = useUserConfig();
const [timezone, setTimezone] = useState('America/New_York');
const auth = useAuth();
const timezone = userConfig?.timezone || 'America/New_York';
// Fetch workflows on mount
useEffect(() => {
const fetchWorkflows = async () => {
@ -80,6 +78,22 @@ export default function ReportsPage() {
fetchWorkflows();
}, [auth.isAuthenticated]);
useEffect(() => {
const fetchPreferences = async () => {
if (!auth.isAuthenticated) return;
try {
const response = await getPreferencesApiV1OrganizationsPreferencesGet();
if (response.data?.timezone) {
setTimezone(response.data.timezone);
}
} catch (err) {
console.error('Failed to fetch organization preferences:', err);
}
};
fetchPreferences();
}, [auth.isAuthenticated]);
// Fetch report data when date or workflow changes
useEffect(() => {
const fetchReport = async () => {
@ -187,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

@ -3,6 +3,7 @@
import { ExternalLink } from "lucide-react";
import { MCPSection } from "@/components/MCPSection";
import { OrganizationPreferencesSection } from "@/components/OrganizationPreferencesSection";
import { TelemetrySection } from "@/components/TelemetrySection";
import {
Card,
@ -23,6 +24,19 @@ export default function SettingsPage() {
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Preferences</CardTitle>
<CardDescription>
Set organization-wide defaults such as the test phone number and
timezone.
</CardDescription>
</CardHeader>
<CardContent>
<OrganizationPreferencesSection />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>MCP Server</CardTitle>

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, getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen';
import type { DailyUsageBreakdownResponse, 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';
@ -35,7 +35,7 @@ const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
export default function UsagePage() {
const router = useRouter();
const searchParams = useSearchParams();
const { userConfig, saveUserConfig, loading: userConfigLoading, organizationPricing } = useUserConfig();
const { organizationPricing } = useUserConfig();
const auth = useAuth();
// Usage history state
@ -69,6 +69,8 @@ export default function UsagePage() {
const localTimezone = getLocalTimezone();
const [selectedTimezone, setSelectedTimezone] = useState<ITimezoneOption | string>('');
const [savingTimezone, setSavingTimezone] = useState(false);
const [preferences, setPreferences] = useState<OrganizationPreferences>({});
const [preferencesLoading, setPreferencesLoading] = useState(true);
const timezoneSelectId = useId(); // Stable ID for react-select to prevent hydration mismatch
// Translate the FilterBuilder state into the query-param shape the
@ -148,6 +150,23 @@ export default function UsagePage() {
}
}, [auth.isAuthenticated, organizationPricing]);
const fetchPreferences = useCallback(async () => {
if (!auth.isAuthenticated) return;
setPreferencesLoading(true);
try {
const response = await getPreferencesApiV1OrganizationsPreferencesGet();
const nextPreferences = response.data || {};
setPreferences(nextPreferences);
setSelectedTimezone(nextPreferences.timezone || localTimezone);
} catch (error) {
console.error('Failed to fetch organization preferences:', error);
setSelectedTimezone(localTimezone);
} finally {
setPreferencesLoading(false);
}
}, [auth.isAuthenticated, localTimezone]);
// Download a CSV of all runs matching the current filters.
const handleDownloadReport = async () => {
if (!auth.isAuthenticated) return;
@ -183,31 +202,31 @@ export default function UsagePage() {
const handleTimezoneChange = async (timezone: ITimezoneOption | string) => {
setSelectedTimezone(timezone);
setSavingTimezone(true);
const previousTimezone = preferences.timezone || localTimezone;
try {
const tzValue = typeof timezone === 'string' ? timezone : timezone.value;
await saveUserConfig({ timezone: tzValue });
const response = await savePreferencesApiV1OrganizationsPreferencesPut({
body: {
...preferences,
timezone: tzValue,
},
});
if (response.error) {
throw new Error('Failed to save timezone');
}
setPreferences(response.data || { ...preferences, timezone: tzValue });
} catch (error) {
console.error('Failed to save timezone:', error);
// Revert to previous timezone on error
const prevTz = userConfig?.timezone || localTimezone;
setSelectedTimezone(prevTz);
setSelectedTimezone(previousTimezone);
} finally {
setSavingTimezone(false);
}
};
// Update timezone when userConfig loads
// Update timezone when organization preferences load.
useEffect(() => {
if (!userConfigLoading) {
// Config has loaded - set the timezone
if (userConfig?.timezone) {
setSelectedTimezone(userConfig.timezone);
} else {
// No saved timezone, use local
setSelectedTimezone(localTimezone);
}
}
}, [userConfig, userConfigLoading, localTimezone]);
fetchPreferences();
}, [fetchPreferences]);
// Initial load - fetch when auth becomes available
useEffect(() => {
@ -319,8 +338,8 @@ export default function UsagePage() {
instanceId={timezoneSelectId}
value={selectedTimezone}
onChange={handleTimezoneChange}
isDisabled={savingTimezone || userConfigLoading}
placeholder={userConfigLoading ? "Loading..." : "Select timezone"}
isDisabled={savingTimezone || preferencesLoading}
placeholder={preferencesLoading ? "Loading..." : "Select timezone"}
styles={{
control: (base, state) => ({
...base,
@ -455,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>
@ -494,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}

View file

@ -4,15 +4,21 @@ import 'react-international-phone/style.css';
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { PhoneInput } from 'react-international-phone';
import {
getPreferencesApiV1OrganizationsPreferencesGet,
initiateCallApiV1TelephonyInitiateCallPost,
listPhoneNumbersApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersGet,
listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet
listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet,
savePreferencesApiV1OrganizationsPreferencesPut,
} from '@/client/sdk.gen';
import type { PhoneNumberResponse, TelephonyConfigurationListItem } from '@/client/types.gen';
import type {
OrganizationPreferences,
PhoneNumberResponse,
TelephonyConfigurationListItem,
} from '@/client/types.gen';
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -33,6 +39,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useUserConfig } from "@/context/UserConfigContext";
import { detailFromError } from "@/lib/apiError";
interface PhoneCallDialogProps {
open: boolean;
@ -48,21 +55,40 @@ export const PhoneCallDialog = ({
user,
}: PhoneCallDialogProps) => {
const router = useRouter();
const { userConfig, saveUserConfig } = useUserConfig();
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
const { refreshConfig } = useUserConfig();
const [preferences, setPreferences] = useState<OrganizationPreferences>({});
const [preferencesLoaded, setPreferencesLoaded] = useState(false);
const [phoneNumber, setPhoneNumber] = useState("");
const [callLoading, setCallLoading] = useState(false);
const [callError, setCallError] = useState<string | null>(null);
const [callSuccessMsg, setCallSuccessMsg] = useState<string | null>(null);
const [phoneChanged, setPhoneChanged] = useState(false);
const [checkingConfig, setCheckingConfig] = useState(false);
const [needsConfiguration, setNeedsConfiguration] = useState<boolean | null>(null);
const [sipMode, setSipMode] = useState(() => /^(PJSIP|SIP)\//i.test(userConfig?.test_phone_number || ""));
const [sipMode, setSipMode] = useState(false);
const [telephonyConfigs, setTelephonyConfigs] = useState<TelephonyConfigurationListItem[]>([]);
const [selectedConfigId, setSelectedConfigId] = useState<string>("");
const [fromPhoneNumbers, setFromPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
const [selectedFromPhoneNumberId, setSelectedFromPhoneNumberId] = useState<string>("");
const [loadingPhoneNumbers, setLoadingPhoneNumbers] = useState(false);
const fetchPreferences = useCallback(async () => {
const result =
await getPreferencesApiV1OrganizationsPreferencesGet();
if (result.error) {
throw new Error(detailFromError(result.error, "Failed to load phone preferences"));
}
return result.data || {};
}, []);
const applyPreferences = useCallback((nextPreferences: OrganizationPreferences) => {
const saved = nextPreferences.test_phone_number || "";
setPreferences(nextPreferences);
setPhoneNumber(saved);
setSipMode(/^(PJSIP|SIP)\//i.test(saved));
setPhoneChanged(false);
}, []);
// Check telephony configuration when dialog opens
useEffect(() => {
const checkConfig = async () => {
@ -97,6 +123,33 @@ export const PhoneCallDialog = ({
checkConfig();
}, [open]);
// Load organization-scoped call preferences when dialog opens.
useEffect(() => {
if (!open) return;
let cancelled = false;
setPreferencesLoaded(false);
const loadPreferences = async () => {
try {
const nextPreferences = await fetchPreferences();
if (cancelled) return;
applyPreferences(nextPreferences);
setPreferencesLoaded(true);
} catch (err) {
if (cancelled) return;
applyPreferences({});
setPreferencesLoaded(false);
setCallError(err instanceof Error ? err.message : "Failed to load phone preferences");
}
};
loadPreferences();
return () => {
cancelled = true;
};
}, [applyPreferences, fetchPreferences, open]);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
@ -149,22 +202,9 @@ export const PhoneCallDialog = ({
};
}, [open, selectedConfigId]);
// Keep phoneNumber in sync with userConfig when dialog opens
useEffect(() => {
if (open) {
const saved = userConfig?.test_phone_number || "";
setPhoneNumber(saved);
setSipMode(/^(PJSIP|SIP)\//i.test(saved));
setPhoneChanged(false);
setCallError(null);
setCallSuccessMsg(null);
setCallLoading(false);
}
}, [open, userConfig?.test_phone_number]);
const handlePhoneInputChange = (formattedValue: string) => {
setPhoneNumber(formattedValue);
setPhoneChanged(formattedValue !== userConfig?.test_phone_number);
setPhoneChanged(formattedValue !== (preferences.test_phone_number || ""));
setCallError(null);
setCallSuccessMsg(null);
};
@ -174,17 +214,39 @@ export const PhoneCallDialog = ({
router.push('/telephony-configurations');
};
const savePhoneNumberPreference = async () => {
const currentPreferences = preferencesLoaded ? preferences : await fetchPreferences();
const result =
await savePreferencesApiV1OrganizationsPreferencesPut({
body: {
...currentPreferences,
test_phone_number: phoneNumber || null,
},
});
if (result.error) {
throw new Error(detailFromError(result.error, "Failed to save phone preferences"));
}
if (!result.data) {
throw new Error("Failed to save phone preferences");
}
setPreferences(result.data);
setPreferencesLoaded(true);
setPhoneChanged(false);
await refreshConfig();
};
const handleStartCall = async () => {
setCallLoading(true);
setCallError(null);
setCallSuccessMsg(null);
try {
if (!user || !userConfig) return;
if (!user) return;
// Save phone number if it has changed
if (phoneChanged) {
await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber });
setPhoneChanged(false);
await savePhoneNumberPreference();
}
const response = await initiateCallApiV1TelephonyInitiateCallPost({

View file

@ -147,7 +147,7 @@ export function EmbeddedVoiceTester({
onOpenChange={setApiKeyModalOpen}
error={apiKeyError}
errorCode={apiKeyErrorCode}
onNavigateToCredits={() => router.push("/api-keys")}
onNavigateToBilling={() => router.push("/billing")}
onNavigateToModelConfig={() => router.push("/model-configurations")}
/>

View file

@ -8,7 +8,7 @@ interface ApiKeyErrorDialogProps {
onOpenChange: (open: boolean) => void;
error: string | null;
errorCode: string | null;
onNavigateToCredits: () => void;
onNavigateToBilling: () => void;
onNavigateToModelConfig: () => void;
}
@ -17,15 +17,16 @@ export const ApiKeyErrorDialog = ({
onOpenChange,
error,
errorCode,
onNavigateToCredits,
onNavigateToBilling,
onNavigateToModelConfig,
}: ApiKeyErrorDialogProps) => {
const isQuotaError = errorCode === 'quota_exceeded';
const isBillingCreditsError = errorCode === 'insufficient_credits';
const isQuotaError = isBillingCreditsError || errorCode === 'quota_exceeded';
const title = isQuotaError ? "Insufficient Credits" : "API Configuration Error";
const icon = isQuotaError ? <CreditCard className="h-5 w-5 text-orange-500" /> : <Key className="h-5 w-5 text-red-500" />;
const buttonText = isQuotaError ? "Add Credits" : "Go to Model Configurations";
const onNavigate = isQuotaError ? onNavigateToCredits : onNavigateToModelConfig;
const buttonText = isBillingCreditsError ? "Go to Billing" : "Go to Model Configurations";
const onNavigate = isBillingCreditsError ? onNavigateToBilling : onNavigateToModelConfig;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -40,9 +41,9 @@ export const ApiKeyErrorDialog = ({
<AlertCircle className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm space-y-1">
<p className="font-medium text-foreground">{error}</p>
{isQuotaError && (
{isBillingCreditsError && (
<p className="text-muted-foreground">
Your Dograh service credits are too low to start a call.
Purchase credits from Billing to continue using Dograh-managed models.
</p>
)}
</div>

View file

@ -19,6 +19,13 @@ interface UseWebSocketRTCProps {
onNodeTransition?: (transition: ConversationNodeTransitionItem) => void;
}
const HANDLED_SERVICE_ERROR_TYPES = new Set([
'quota_exceeded',
'insufficient_credits',
'invalid_service_key',
'quota_check_failed',
]);
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables, onNodeTransition }: UseWebSocketRTCProps) => {
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'connecting' | 'connected' | 'failed'>('idle');
const [connectionActive, setConnectionActive] = useState(false);
@ -265,9 +272,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
case 'error':
// Check if this is a quota/service key error
if (message.payload?.error_type === 'quota_exceeded' ||
message.payload?.error_type === 'invalid_service_key' ||
message.payload?.error_type === 'quota_check_failed') {
if (HANDLED_SERVICE_ERROR_TYPES.has(message.payload?.error_type)) {
// Log as info since it's a handled business logic case
logger.info('Quota/service key error, showing user dialog:', message.payload.message);

View file

@ -7,8 +7,22 @@ import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet, getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost, getWorkflowApiV1WorkflowFetchWorkflowIdGet } from "@/client/sdk.gen";
import type { WorkflowResponse } from "@/client/types.gen";
import {
downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet,
getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost,
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get,
getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet,
getWorkflowApiV1WorkflowFetchWorkflowIdGet,
} from "@/client/sdk.gen";
import type {
OrganizationAiModelConfigurationResponse,
OrganizationAiModelConfigurationV2,
WorkflowResponse,
} from "@/client/types.gen";
import {
AIModelConfigurationV2Editor,
type ModelConfigurationDefaultsV2,
} from "@/components/AIModelConfigurationV2Editor";
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { LLMConfigSelector } from "@/components/LLMConfigSelector";
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
@ -26,6 +40,7 @@ import { Textarea } from "@/components/ui/textarea";
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
import { UnsavedChangesProvider, useUnsavedChanges, useUnsavedChangesContext } from "@/context/UnsavedChangesContext";
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
import logger from "@/lib/logger";
import {
@ -1040,6 +1055,182 @@ function AgentUuidSection({ workflowUuid }: { workflowUuid: string }) {
);
}
// ---------------------------------------------------------------------------
// Section: Model Overrides
// ---------------------------------------------------------------------------
function withoutModelConfigurationOverrides(configurations: WorkflowConfigurations): WorkflowConfigurations {
const next = { ...configurations };
delete next.model_overrides;
delete next.model_configuration_v2_override;
return next;
}
function WorkflowModelOverridesSection({
workflowConfigurations,
workflowName,
onSave,
modelConfigurationDefaults,
organizationModelConfiguration,
modelConfigurationLoading,
modelConfigurationError,
}: {
workflowConfigurations: WorkflowConfigurations;
workflowName: string;
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
modelConfigurationDefaults: ModelConfigurationDefaultsV2 | null;
organizationModelConfiguration: OrganizationAiModelConfigurationResponse | null;
modelConfigurationLoading: boolean;
modelConfigurationError: string | null;
}) {
const savedV2Override = workflowConfigurations.model_configuration_v2_override;
const hasSavedModelOverride = Boolean(savedV2Override || workflowConfigurations.model_overrides);
const [overrideEnabled, setOverrideEnabled] = useState(Boolean(savedV2Override));
const [isRemovingOverride, setIsRemovingOverride] = useState(false);
useEffect(() => {
setOverrideEnabled(Boolean(workflowConfigurations.model_configuration_v2_override));
}, [workflowConfigurations.model_configuration_v2_override]);
const source = organizationModelConfiguration?.source || "empty";
const isV2 = source === "organization_v2";
const saveLegacyOverrides = async (config: Record<string, unknown>) => {
const nextConfigurations = withoutModelConfigurationOverrides(workflowConfigurations);
const modelOverrides = config.model_overrides as WorkflowConfigurations["model_overrides"] | undefined;
if (modelOverrides) {
nextConfigurations.model_overrides = modelOverrides;
}
await onSave(nextConfigurations, workflowName);
};
const saveV2Override = async (configuration: OrganizationAiModelConfigurationV2) => {
const nextConfigurations = withoutModelConfigurationOverrides(workflowConfigurations);
nextConfigurations.model_configuration_v2_override = configuration;
await onSave(nextConfigurations, workflowName);
toast.success("Model override saved");
};
const removeV2Override = async () => {
setIsRemovingOverride(true);
try {
await onSave(withoutModelConfigurationOverrides(workflowConfigurations), workflowName);
setOverrideEnabled(false);
toast.success("Using organization model configuration");
} finally {
setIsRemovingOverride(false);
}
};
return (
<Card id="models">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Brain className="h-4 w-4" />
Model Overrides
</CardTitle>
<CardDescription>
{isV2
? "Override the full organization model configuration for this workflow."
: "Override global model settings for this workflow. Toggle individual services to customize."}{" "}
<a href={SETTINGS_DOCUMENTATION_URLS.modelOverrides} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{modelConfigurationLoading && (
<div className="flex items-center gap-2 rounded-md border p-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading model configuration
</div>
)}
{modelConfigurationError && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{modelConfigurationError}
</div>
)}
{!modelConfigurationLoading && !modelConfigurationError && !isV2 && (
<>
{source === "legacy_user_v1" && (
<div className="flex flex-col gap-3 rounded-md border bg-muted/30 p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
This workflow is using legacy model overrides. Migrate organization model configuration to use v2 overrides.
</p>
<Button type="button" variant="outline" size="sm" asChild>
<Link href="/model-configurations?action=migrate_to_v2">Migrate to v2</Link>
</Button>
</div>
)}
<ServiceConfigurationForm
mode="override"
currentOverrides={workflowConfigurations.model_overrides}
submitLabel="Save Model Overrides"
onSave={saveLegacyOverrides}
/>
</>
)}
{!modelConfigurationLoading && !modelConfigurationError && isV2 && modelConfigurationDefaults && organizationModelConfiguration && (
<>
<div className="flex items-center justify-between rounded-md border p-4">
<div className="space-y-0.5">
<Label htmlFor="workflow-model-v2-override" className="text-sm font-medium">
Override for this workflow
</Label>
<p className="text-xs text-muted-foreground">
{overrideEnabled
? "This workflow uses its own complete model configuration."
: "This workflow uses the organization model configuration."}
</p>
</div>
<Switch
id="workflow-model-v2-override"
checked={overrideEnabled}
onCheckedChange={setOverrideEnabled}
/>
</div>
{overrideEnabled ? (
<AIModelConfigurationV2Editor
defaults={modelConfigurationDefaults}
configuration={
(savedV2Override as OrganizationAiModelConfigurationV2 | undefined)
|| (organizationModelConfiguration.configuration as OrganizationAiModelConfigurationV2 | null)
}
effectiveConfiguration={
savedV2Override
? null
: organizationModelConfiguration.effective_configuration
}
submitLabel="Save Model Override"
onSave={saveV2Override}
/>
) : (
<div className="rounded-md border bg-muted/20 p-4">
<p className="text-sm text-muted-foreground">
Using organization model configuration.
</p>
{hasSavedModelOverride && (
<Button
type="button"
variant="outline"
className="mt-3"
onClick={removeV2Override}
disabled={isRemovingOverride}
>
{isRemovingOverride ? "Saving..." : "Save Organization Configuration"}
</Button>
)}
</div>
)}
</>
)}
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
@ -1127,6 +1318,11 @@ function WorkflowSettingsInner({
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
const [activeSection, setActiveSection] = useState("general");
const [modelConfigurationDefaults, setModelConfigurationDefaults] = useState<ModelConfigurationDefaultsV2 | null>(null);
const [organizationModelConfiguration, setOrganizationModelConfiguration] = useState<OrganizationAiModelConfigurationResponse | null>(null);
const [modelConfigurationLoading, setModelConfigurationLoading] = useState(true);
const [modelConfigurationError, setModelConfigurationError] = useState<string | null>(null);
const hasFetchedModelConfiguration = useRef(false);
const workflowId = workflow.id;
@ -1166,6 +1362,37 @@ function WorkflowSettingsInner({
user,
});
useEffect(() => {
if (hasFetchedModelConfiguration.current) return;
hasFetchedModelConfiguration.current = true;
const loadModelConfiguration = async () => {
setModelConfigurationLoading(true);
setModelConfigurationError(null);
const [defaultsResult, configurationResult] = await Promise.all([
getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet(),
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(),
]);
if (defaultsResult.error) {
setModelConfigurationError(detailFromError(defaultsResult.error, "Failed to load model configuration defaults"));
setModelConfigurationLoading(false);
return;
}
if (configurationResult.error) {
setModelConfigurationError(detailFromError(configurationResult.error, "Failed to load model configuration"));
setModelConfigurationLoading(false);
return;
}
setModelConfigurationDefaults(defaultsResult.data as ModelConfigurationDefaultsV2);
setOrganizationModelConfiguration(configurationResult.data || null);
setModelConfigurationLoading(false);
};
loadModelConfiguration();
}, []);
// Intersection observer for active sidebar link
useEffect(() => {
const ids = NAV_ITEMS.map((n) => n.id);
@ -1218,37 +1445,15 @@ function WorkflowSettingsInner({
onSave={saveWorkflowConfigurations}
/>
{/* Model Overrides */}
<Card id="models">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Brain className="h-4 w-4" />
Model Overrides
</CardTitle>
<CardDescription>
Override global model settings for this workflow. Toggle individual services to
customize.{" "}
<a href={SETTINGS_DOCUMENTATION_URLS.modelOverrides} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
</CardDescription>
</CardHeader>
<CardContent>
<ServiceConfigurationForm
mode="override"
currentOverrides={workflowConfigurations.model_overrides}
submitLabel="Save Model Overrides"
onSave={async (config) => {
await saveWorkflowConfigurations(
{
...workflowConfigurations,
model_overrides:
config.model_overrides as WorkflowConfigurations["model_overrides"],
} as WorkflowConfigurations,
workflowName,
);
}}
/>
</CardContent>
</Card>
<WorkflowModelOverridesSection
workflowConfigurations={workflowConfigurations}
workflowName={workflowName}
onSave={saveWorkflowConfigurations}
modelConfigurationDefaults={modelConfigurationDefaults}
organizationModelConfiguration={organizationModelConfiguration}
modelConfigurationLoading={modelConfigurationLoading}
modelConfigurationError={modelConfigurationError}
/>
{/* Template Variables */}
<TemplateVariablesSection

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,451 @@
"use client";
import { KeyRound, Save } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { OrganizationAiModelConfigurationV2 } from "@/client/types.gen";
import {
type ProviderSchema,
type ServiceConfigurationDefaults,
ServiceConfigurationForm,
type ServiceSegment,
} from "@/components/ServiceConfigurationForm";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
type ModelMode = "realtime" | "dograh" | "byok";
interface DograhDefaults {
voices: string[];
speeds: number[];
languages: string[];
defaults: {
voice: string;
speed: number;
language: string;
};
}
export interface ModelConfigurationDefaultsV2 {
dograh: DograhDefaults;
byok: {
pipeline: ServiceConfigurationDefaults;
realtime: {
realtime: Record<string, ProviderSchema>;
llm: Record<string, ProviderSchema>;
embeddings: Record<string, ProviderSchema>;
default_providers: ServiceConfigurationDefaults["default_providers"];
};
};
}
interface DograhFormState {
api_key: string;
voice: string;
speed: number;
language: string;
}
interface AIModelConfigurationV2EditorProps {
defaults: ModelConfigurationDefaultsV2;
configuration?: OrganizationAiModelConfigurationV2 | Record<string, unknown> | null;
effectiveConfiguration?: Record<string, unknown> | null;
onSave: (configuration: OrganizationAiModelConfigurationV2) => Promise<void>;
submitLabel?: string;
}
function firstApiKey(value: unknown): string {
if (Array.isArray(value)) return String(value[0] || "");
return typeof value === "string" ? value : "";
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function isDograhEffectiveConfig(config: Record<string, unknown> | null | undefined): boolean {
if (!config || config.is_realtime) return false;
const llm = asRecord(config.llm);
const tts = asRecord(config.tts);
const stt = asRecord(config.stt);
return llm?.provider === "dograh" && tts?.provider === "dograh" && stt?.provider === "dograh";
}
function byokDefaults(defaults: ModelConfigurationDefaultsV2): ServiceConfigurationDefaults {
return {
llm: defaults.byok.pipeline.llm,
tts: defaults.byok.pipeline.tts,
stt: defaults.byok.pipeline.stt,
embeddings: defaults.byok.pipeline.embeddings,
realtime: defaults.byok.realtime.realtime,
default_providers: defaults.byok.pipeline.default_providers,
};
}
function byokConfigToLegacyShape(config: Record<string, unknown> | null): Record<string, unknown> | null {
if (!config || config.mode !== "byok") return null;
const byok = asRecord(config.byok);
if (!byok) return null;
if (byok.mode === "realtime") {
const realtime = asRecord(byok.realtime);
return {
is_realtime: true,
realtime: realtime?.realtime,
llm: realtime?.llm,
embeddings: realtime?.embeddings,
};
}
const pipeline = asRecord(byok.pipeline);
return {
is_realtime: false,
llm: pipeline?.llm,
tts: pipeline?.tts,
stt: pipeline?.stt,
embeddings: pipeline?.embeddings,
};
}
function effectiveConfigToLegacyShape(config: Record<string, unknown> | null): Record<string, unknown> | null {
if (!config) return null;
return {
is_realtime: Boolean(config.is_realtime),
llm: config.llm,
tts: config.tts,
stt: config.stt,
realtime: config.realtime,
embeddings: config.embeddings,
};
}
function emptyByokInitialConfig(isRealtime: boolean): Record<string, unknown> {
return {
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 matchesTab = (config: Record<string, unknown> | null) =>
config ? Boolean(config.is_realtime) === wantRealtime : false;
const byokConfiguration = byokConfigToLegacyShape(configuration);
if (byokConfiguration) {
return matchesTab(byokConfiguration) ? byokConfiguration : emptyByokInitialConfig(wantRealtime);
}
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(
defaults: ModelConfigurationDefaultsV2,
configuration: Record<string, unknown> | null,
effectiveConfiguration: Record<string, unknown> | null,
): DograhFormState {
const fallback = defaults.dograh.defaults;
const configuredDograh = configuration?.mode === "dograh" ? asRecord(configuration.dograh) : null;
if (configuredDograh) {
return {
api_key: String(configuredDograh.api_key || ""),
voice: String(configuredDograh.voice || fallback.voice),
speed: Number(configuredDograh.speed || fallback.speed),
language: String(configuredDograh.language || fallback.language),
};
}
if (isDograhEffectiveConfig(effectiveConfiguration)) {
const llm = asRecord(effectiveConfiguration?.llm);
const tts = asRecord(effectiveConfiguration?.tts);
const stt = asRecord(effectiveConfiguration?.stt);
return {
api_key: firstApiKey(llm?.api_key || tts?.api_key || stt?.api_key),
voice: String(tts?.voice || fallback.voice),
speed: Number(tts?.speed || fallback.speed),
language: String(stt?.language || fallback.language),
};
}
return {
api_key: "",
voice: fallback.voice,
speed: fallback.speed,
language: fallback.language,
};
}
function preferredMode(
configuration: Record<string, unknown> | null,
effectiveConfiguration: Record<string, unknown> | null,
): ModelMode {
if (configuration?.mode === "dograh") return "dograh";
if (configuration?.mode === "byok") {
return asRecord(configuration.byok)?.mode === "realtime" ? "realtime" : "byok";
}
if (isDograhEffectiveConfig(effectiveConfiguration)) return "dograh";
return Boolean(effectiveConfiguration?.is_realtime) ? "realtime" : "byok";
}
function hasRequiredApiKey(
service: ServiceSegment,
serviceConfiguration: Record<string, unknown>,
defaults: ServiceConfigurationDefaults,
): boolean {
const provider = serviceConfiguration.provider as string | undefined;
if (!provider) return false;
const providerSchema = service === "realtime"
? defaults.realtime?.[provider]
: defaults[service as "llm" | "tts" | "stt" | "embeddings"]?.[provider];
const requiresApiKey = providerSchema?.required?.includes("api_key") ?? false;
if (!requiresApiKey) return true;
const apiKey = serviceConfiguration.api_key;
if (Array.isArray(apiKey)) {
return apiKey.some((key) => typeof key === "string" && key.trim().length > 0);
}
return typeof apiKey === "string" && apiKey.trim().length > 0;
}
function requireByokService(
config: Record<string, unknown>,
service: ServiceSegment,
defaults: ServiceConfigurationDefaults,
): Record<string, unknown> {
const serviceConfiguration = asRecord(config[service]);
if (
!serviceConfiguration
|| !serviceConfiguration.provider
|| serviceConfiguration.provider === "dograh"
|| !hasRequiredApiKey(service, serviceConfiguration, defaults)
) {
throw new Error(`${service} configuration is required`);
}
return serviceConfiguration;
}
function optionalByokService(config: Record<string, unknown>, service: ServiceSegment): Record<string, unknown> | undefined {
const serviceConfiguration = asRecord(config[service]);
if (!serviceConfiguration?.provider || serviceConfiguration.provider === "dograh") return undefined;
return serviceConfiguration;
}
export function AIModelConfigurationV2Editor({
defaults,
configuration,
effectiveConfiguration,
onSave,
submitLabel = "Save Configuration",
}: AIModelConfigurationV2EditorProps) {
const defaultsForByok = useMemo(() => byokDefaults(defaults), [defaults]);
const [mode, setMode] = useState<ModelMode>("dograh");
const [dograh, setDograh] = useState<DograhFormState>(() => ({
api_key: "",
voice: defaults.dograh.defaults.voice,
speed: defaults.dograh.defaults.speed,
language: defaults.dograh.defaults.language,
}));
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);
useEffect(() => {
const rawConfiguration = asRecord(configuration);
const rawEffectiveConfiguration = asRecord(effectiveConfiguration);
setMode(preferredMode(rawConfiguration, rawEffectiveConfiguration));
setDograh(buildDograhState(defaults, rawConfiguration, rawEffectiveConfiguration));
setRealtimeInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, true));
setPipelineInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, false));
}, [configuration, defaults, effectiveConfiguration]);
const saveDograhConfiguration = async () => {
setIsSavingDograh(true);
setError(null);
try {
await onSave({
version: 2,
mode: "dograh",
dograh: {
api_key: dograh.api_key.trim(),
voice: dograh.voice,
speed: dograh.speed,
language: dograh.language,
},
});
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save configuration");
} finally {
setIsSavingDograh(false);
}
};
const saveByokConfiguration = async (config: Record<string, unknown>) => {
setError(null);
const isRealtime = Boolean(config.is_realtime);
const llm = requireByokService(config, "llm", defaultsForByok);
const embeddings = optionalByokService(config, "embeddings");
const body: OrganizationAiModelConfigurationV2 = {
version: 2,
mode: "byok",
byok: isRealtime
? {
mode: "realtime",
realtime: {
realtime: requireByokService(config, "realtime", defaultsForByok) as never,
llm: llm as never,
...(embeddings ? { embeddings: embeddings as never } : {}),
},
}
: {
mode: "pipeline",
pipeline: {
llm: llm as never,
tts: requireByokService(config, "tts", defaultsForByok) as never,
stt: requireByokService(config, "stt", defaultsForByok) as never,
...(embeddings ? { embeddings: embeddings as never } : {}),
},
},
};
await onSave(body);
};
return (
<div className="space-y-6">
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<Tabs value={mode} onValueChange={(value) => setMode(value as ModelMode)} className="space-y-6">
<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">
<Label>Voice</Label>
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select voice" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.voices.map((voice) => (
<SelectItem key={voice} value={voice}>
{voice}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Speed</Label>
<Select
value={String(dograh.speed)}
onValueChange={(speed) => setDograh({ ...dograh, speed: Number(speed) })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select speed" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.speeds.map((speed) => (
<SelectItem key={speed} value={String(speed)}>
{speed}x
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Language</Label>
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.languages.map((language) => (
<SelectItem key={language} value={language}>
{LANGUAGE_DISPLAY_NAMES[language] || language}
</SelectItem>
))}
</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}>
<Save className="mr-2 h-4 w-4" />
{isSavingDograh ? "Saving..." : submitLabel}
</Button>
</div>
</TabsContent>
<TabsContent value="byok" className="mt-0">
<ServiceConfigurationForm
key={`byok-${JSON.stringify(pipelineInitialConfig)}`}
mode="global"
forceRealtime={false}
configurationDefaults={defaultsForByok}
initialConfig={pipelineInitialConfig}
submitLabel={submitLabel}
onSave={saveByokConfiguration}
/>
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,274 @@
"use client";
import { ExternalLink, RefreshCw } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get,
getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet,
migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost,
saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put,
} from "@/client/sdk.gen";
import type {
OrganizationAiModelConfigurationResponse,
OrganizationAiModelConfigurationV2,
} from "@/client/types.gen";
import { AIModelConfigurationV2Editor, type ModelConfigurationDefaultsV2 } from "@/components/AIModelConfigurationV2Editor";
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useUserConfig } from "@/context/UserConfigContext";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
export default function ModelConfigurationV2({
docsUrl,
initialAction,
}: {
docsUrl?: string;
initialAction?: string;
}) {
const auth = useAuth();
const { refreshConfig, saveUserConfig } = useUserConfig();
const hasFetched = useRef(false);
const hasAppliedInitialMigrationAction = useRef(false);
const [defaults, setDefaults] = useState<ModelConfigurationDefaultsV2 | null>(null);
const [response, setResponse] = useState<OrganizationAiModelConfigurationResponse | null>(null);
const [loading, setLoading] = useState(true);
const [migrating, setMigrating] = useState(false);
const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const applyResponse = (nextResponse: OrganizationAiModelConfigurationResponse) => {
setResponse(nextResponse);
};
useEffect(() => {
if (auth.loading || !auth.user || hasFetched.current) return;
hasFetched.current = true;
const load = async () => {
setLoading(true);
setError(null);
const [defaultsResult, configResult] = await Promise.all([
getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet(),
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(),
]);
if (defaultsResult.error) {
setError(detailFromError(defaultsResult.error, "Failed to load model configuration defaults"));
setLoading(false);
return;
}
if (configResult.error) {
setError(detailFromError(configResult.error, "Failed to load model configuration"));
setLoading(false);
return;
}
const nextDefaults = defaultsResult.data as ModelConfigurationDefaultsV2;
if (!nextDefaults || !configResult.data) {
setError("Failed to load model configuration");
setLoading(false);
return;
}
setDefaults(nextDefaults);
applyResponse(configResult.data);
setLoading(false);
};
load();
}, [auth.loading, auth.user]);
useEffect(() => {
if (hasAppliedInitialMigrationAction.current) return;
if (initialAction !== "migrate_to_v2") return;
if (loading || response?.source !== "legacy_user_v1") return;
hasAppliedInitialMigrationAction.current = true;
setMigrationDialogOpen(true);
}, [initialAction, loading, response?.source]);
const saveConfiguration = async (configuration: OrganizationAiModelConfigurationV2) => {
if (!defaults) return;
setError(null);
setNotice(null);
const result = await saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put({
body: configuration,
});
if (result.error) {
throw new Error(detailFromError(result.error, "Failed to save model configuration"));
}
if (!result.data) {
throw new Error("Failed to save model configuration");
}
applyResponse(result.data);
await refreshConfig();
setNotice("Model configuration saved");
};
const migrateConfiguration = async () => {
if (!defaults) return;
setMigrating(true);
setError(null);
setNotice(null);
const result = await migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost();
if (result.error) {
setError(detailFromError(result.error, "Failed to migrate model configuration"));
} else if (!result.data) {
setError("Failed to migrate model configuration");
} else {
applyResponse(result.data);
await refreshConfig();
setNotice("Configuration migrated to v2");
setMigrationDialogOpen(false);
}
setMigrating(false);
};
const migrationWarningDialog = (
<AlertDialog open={migrationDialogOpen} onOpenChange={setMigrationDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Migrate model configuration to v2?</AlertDialogTitle>
<AlertDialogDescription>
Your configurations will be migrated to v2. After migration, check your global configuration and workflow model overrides, then run a test call to make sure everything is working.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={migrating}>Cancel</AlertDialogCancel>
<Button type="button" onClick={migrateConfiguration} disabled={migrating}>
{migrating ? "Migrating..." : "Migrate to v2"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
if (loading) {
return (
<div className="w-full max-w-4xl mx-auto space-y-6">
<Skeleton className="h-10 w-80" />
<Skeleton className="h-28 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
const source = response?.source || "empty";
if (source !== "organization_v2") {
return (
<div className="w-full max-w-4xl mx-auto space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-3xl font-bold">AI Models Configuration</h1>
<Badge variant="outline">
{source === "legacy_user_v1" ? "legacy" : "v1"}
</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">
Configure your AI model, voice, and transcription services.{" "}
{docsUrl && (
<a href={docsUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">
Learn more <ExternalLink className="h-3 w-3" />
</a>
)}
</p>
</div>
{source === "legacy_user_v1" && (
<Button type="button" variant="outline" onClick={() => setMigrationDialogOpen(true)} disabled={migrating}>
<RefreshCw className="mr-2 h-4 w-4" />
{migrating ? "Migrating..." : "Migrate to v2"}
</Button>
)}
</div>
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{notice && (
<div className="rounded-md border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-700 dark:text-green-300">
{notice}
</div>
)}
<ServiceConfigurationForm
mode="global"
onSave={async (config) => {
setError(null);
setNotice(null);
await saveUserConfig(config as Parameters<typeof saveUserConfig>[0]);
await refreshConfig();
if (defaults) {
const configResult = await getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get();
if (configResult.data) {
applyResponse(configResult.data);
}
}
setNotice("Configuration saved");
}}
/>
{migrationWarningDialog}
</div>
);
}
return (
<div className="w-full max-w-4xl mx-auto space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-bold">AI Models Configuration</h1>
<p className="mt-2 text-sm text-muted-foreground">
Organization-scoped model settings.{" "}
{docsUrl && (
<a href={docsUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">
Learn more <ExternalLink className="h-3 w-3" />
</a>
)}
</p>
</div>
</div>
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{notice && (
<div className="rounded-md border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-700 dark:text-green-300">
{notice}
</div>
)}
{defaults && response && (
<AIModelConfigurationV2Editor
defaults={defaults}
configuration={response.configuration}
effectiveConfiguration={response.effective_configuration}
onSave={saveConfiguration}
/>
)}
{migrationWarningDialog}
</div>
);
}

View file

@ -0,0 +1,221 @@
"use client";
import { Save } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import TimezoneSelect, { type ITimezoneOption } from "react-timezone-select";
import { toast } from "sonner";
import {
getPreferencesApiV1OrganizationsPreferencesGet,
savePreferencesApiV1OrganizationsPreferencesPut,
} from "@/client/sdk.gen";
import type { OrganizationPreferences } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useUserConfig } from "@/context/UserConfigContext";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
const emptyPreferences: OrganizationPreferences = {
test_phone_number: "",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
};
const timezoneSelectStyles = {
control: (base: Record<string, unknown>, state: { isFocused: boolean }) => ({
...base,
minHeight: "36px",
fontSize: "14px",
backgroundColor: "var(--background)",
borderColor: state.isFocused ? "var(--ring)" : "var(--border)",
boxShadow: state.isFocused
? "0 0 0 2px color-mix(in srgb, var(--ring) 20%, transparent)"
: "none",
"&:hover": { borderColor: "var(--border)" },
}),
menu: (base: Record<string, unknown>) => ({
...base,
zIndex: 9999,
backgroundColor: "var(--popover)",
border: "1px solid var(--border)",
boxShadow:
"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
}),
menuList: (base: Record<string, unknown>) => ({
...base,
backgroundColor: "var(--popover)",
padding: 0,
}),
option: (
base: Record<string, unknown>,
state: { isFocused: boolean; isSelected: boolean },
) => ({
...base,
backgroundColor: state.isSelected
? "var(--accent)"
: state.isFocused
? "var(--accent)"
: "var(--popover)",
color: "var(--foreground)",
cursor: "pointer",
"&:active": { backgroundColor: "var(--accent)" },
}),
singleValue: (base: Record<string, unknown>) => ({
...base,
color: "var(--foreground)",
}),
input: (base: Record<string, unknown>) => ({
...base,
color: "var(--foreground)",
}),
placeholder: (base: Record<string, unknown>) => ({
...base,
color: "var(--muted-foreground)",
}),
indicatorSeparator: (base: Record<string, unknown>) => ({
...base,
backgroundColor: "var(--border)",
}),
dropdownIndicator: (base: Record<string, unknown>) => ({
...base,
color: "var(--muted-foreground)",
"&:hover": { color: "var(--foreground)" },
}),
};
function getTimezoneValue(tz: ITimezoneOption | string): string {
return typeof tz === "string" ? tz : tz.value;
}
export function OrganizationPreferencesSection() {
const { user, loading: authLoading } = useAuth();
const { refreshConfig } = useUserConfig();
const timezoneSelectId = useId();
const hasFetched = useRef(false);
const [preferences, setPreferences] =
useState<OrganizationPreferences>(emptyPreferences);
const [timezone, setTimezone] = useState<ITimezoneOption | string>(
emptyPreferences.timezone || "UTC",
);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (authLoading || !user || hasFetched.current) {
return;
}
hasFetched.current = true;
void fetchPreferences();
}, [authLoading, user]);
async function fetchPreferences() {
setLoading(true);
try {
const result =
await getPreferencesApiV1OrganizationsPreferencesGet();
if (result.error) {
toast.error(
detailFromError(
result.error,
"Failed to load organization preferences",
),
);
return;
}
const nextPreferences = result.data || emptyPreferences;
setPreferences({
test_phone_number: nextPreferences.test_phone_number || "",
timezone: nextPreferences.timezone || emptyPreferences.timezone,
});
setTimezone(
nextPreferences.timezone || emptyPreferences.timezone || "UTC",
);
} catch {
toast.error("Failed to load organization preferences");
} finally {
setLoading(false);
}
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
const result =
await savePreferencesApiV1OrganizationsPreferencesPut(
{
body: {
test_phone_number: preferences.test_phone_number || null,
timezone: getTimezoneValue(timezone),
},
},
);
if (result.error) {
toast.error(detailFromError(result.error, "Failed to save preferences"));
return;
}
if (!result.data) {
toast.error("Failed to save preferences");
return;
}
setPreferences({
test_phone_number: result.data.test_phone_number || "",
timezone: result.data.timezone || emptyPreferences.timezone,
});
setTimezone(result.data.timezone || emptyPreferences.timezone || "UTC");
await refreshConfig();
toast.success("Preferences saved");
} catch {
toast.error("Failed to save preferences");
} finally {
setSaving(false);
}
}
if (loading) {
return <p className="text-sm text-muted-foreground">Loading...</p>;
}
return (
<form onSubmit={handleSave} className="space-y-4">
<p className="text-sm text-muted-foreground">
Set organization-wide defaults used by testing and scheduling flows.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="settings-test-phone-number">Test Phone Number</Label>
<Input
id="settings-test-phone-number"
value={preferences.test_phone_number || ""}
onChange={(event) =>
setPreferences({
...preferences,
test_phone_number: event.target.value,
})
}
placeholder="+15551234567"
/>
</div>
<div className="space-y-2">
<Label>Timezone</Label>
<TimezoneSelect
instanceId={timezoneSelectId}
value={timezone}
onChange={setTimezone}
styles={timezoneSelectStyles}
/>
</div>
</div>
<Button type="submit" disabled={saving}>
<Save className="mr-2 h-4 w-4" />
{saving ? "Saving..." : "Save"}
</Button>
</form>
);
}

View file

@ -19,7 +19,7 @@ import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
import { useUserConfig } from "@/context/UserConfigContext";
import type { ModelOverrides } from "@/types/workflow-configurations";
type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime";
export type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime";
interface SchemaProperty {
type?: string;
@ -35,7 +35,7 @@ interface SchemaProperty {
docs_url?: string;
}
interface ProviderSchema {
export interface ProviderSchema {
title?: string;
description?: string;
provider_docs_url?: string;
@ -49,6 +49,15 @@ interface FormValues {
[key: string]: string | number | boolean;
}
export interface ServiceConfigurationDefaults {
llm: Record<string, ProviderSchema>;
tts: Record<string, ProviderSchema>;
stt: Record<string, ProviderSchema>;
embeddings: Record<string, ProviderSchema>;
realtime?: Record<string, ProviderSchema>;
default_providers: Partial<Record<ServiceSegment, string>>;
}
const STANDARD_TABS: { key: ServiceSegment; label: string }[] = [
{ key: "llm", label: "LLM" },
{ key: "tts", label: "Voice" },
@ -90,6 +99,15 @@ export interface ServiceConfigurationFormProps {
onSave: (config: Record<string, unknown>) => Promise<void>;
/** Text for the submit button. Defaults to "Save Configuration". */
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(
@ -117,10 +135,13 @@ export function ServiceConfigurationForm({
currentOverrides,
onSave,
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: {},
@ -165,15 +186,16 @@ export function ServiceConfigurationForm({
// Build effective config source: overlay overrides onto global config
const configSource = useMemo(() => {
if (mode === 'global' || !currentOverrides) return userConfig;
const baseConfig = initialConfig ?? userConfig;
if (mode === 'global' || !currentOverrides) return baseConfig;
// Merge overrides onto global config for form initialization
const merged = { ...userConfig } as Record<string, unknown>;
const merged = { ...baseConfig } as Record<string, unknown>;
const overrideServices: (keyof ModelOverrides)[] = ["llm", "tts", "stt", "realtime"];
for (const svc of overrideServices) {
if (svc === "is_realtime") continue;
const overrideVal = currentOverrides[svc];
if (overrideVal && typeof overrideVal === "object") {
const globalVal = (userConfig as Record<string, unknown> | null)?.[svc] as Record<string, unknown> | undefined;
const globalVal = (baseConfig as Record<string, unknown> | null)?.[svc] as Record<string, unknown> | undefined;
merged[svc] = { ...globalVal, ...overrideVal };
}
}
@ -181,39 +203,50 @@ export function ServiceConfigurationForm({
merged.is_realtime = currentOverrides.is_realtime;
}
return merged as typeof userConfig;
}, [mode, userConfig, currentOverrides]);
}, [mode, userConfig, currentOverrides, initialConfig]);
useEffect(() => {
const fetchConfigurations = async () => {
const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet();
if (!response.data) {
console.error("Failed to fetch configurations");
return;
let defaultsData = configurationDefaults;
if (!defaultsData) {
const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet();
if (!response.data) {
console.error("Failed to fetch configurations");
return;
}
defaultsData = response.data as ServiceConfigurationDefaults;
}
const data = response.data as Record<string, unknown>;
const realtimeSchemas = (data.realtime || {}) as Record<string, ProviderSchema>;
const realtimeSchemas = (defaultsData.realtime || {}) as Record<string, ProviderSchema>;
const pickDefaultProvider = (
service: ServiceSegment,
schemaMap: Record<string, ProviderSchema>,
) => {
const preferred = defaultsData.default_providers?.[service];
if (preferred && schemaMap[preferred]) return preferred;
return Object.keys(schemaMap)[0] || "";
};
setSchemas({
llm: response.data.llm as Record<string, ProviderSchema>,
tts: response.data.tts as Record<string, ProviderSchema>,
stt: response.data.stt as Record<string, ProviderSchema>,
embeddings: response.data.embeddings as Record<string, ProviderSchema>,
llm: defaultsData.llm,
tts: defaultsData.tts,
stt: defaultsData.stt,
embeddings: defaultsData.embeddings,
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);
}
const defaultValues: Record<string, string | number | boolean> = {};
const selectedProviders: Record<ServiceSegment, string> = {
llm: response.data.default_providers.llm,
tts: response.data.default_providers.tts,
stt: response.data.default_providers.stt,
embeddings: response.data.default_providers.embeddings,
llm: pickDefaultProvider("llm", defaultsData.llm),
tts: pickDefaultProvider("tts", defaultsData.tts),
stt: pickDefaultProvider("stt", defaultsData.stt),
embeddings: pickDefaultProvider("embeddings", defaultsData.embeddings),
realtime: "",
};
@ -237,7 +270,7 @@ export function ServiceConfigurationForm({
const schemaSource = service === "realtime"
? realtimeSchemas
: response.data![service as "llm" | "tts" | "stt" | "embeddings"] as Record<string, ProviderSchema> | undefined;
: defaultsData[service as "llm" | "tts" | "stt" | "embeddings"] as Record<string, ProviderSchema> | undefined;
if (src?.provider) {
Object.entries(src).forEach(([field, value]) => {
@ -296,7 +329,7 @@ export function ServiceConfigurationForm({
// Detect custom inputs
const detectedCustomInput: Record<string, boolean> = {};
const allSchemas = { ...response.data, realtime: realtimeSchemas } as unknown as Record<string, Record<string, ProviderSchema>>;
const allSchemas = { ...defaultsData, realtime: realtimeSchemas } as unknown as Record<string, Record<string, ProviderSchema>>;
(["llm", "tts", "stt", "embeddings", "realtime"] as ServiceSegment[]).forEach(service => {
const provider = selectedProviders[service];
const providerSchema = allSchemas[service]?.[provider];
@ -337,7 +370,7 @@ export function ServiceConfigurationForm({
};
fetchConfigurations();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reset, configSource]);
}, [reset, configSource, configurationDefaults]);
// Reset voice when TTS model changes if the provider has model-dependent voice options
const ttsModel = watch("tts_model");
@ -842,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

@ -139,6 +139,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;
}

View file

@ -1,5 +1,7 @@
import "server-only";
import { getServerBackendUrl } from "@/lib/apiClient";
let cachedAuthProvider: string | null = null;
/**
@ -12,7 +14,7 @@ export async function getAuthProvider(): Promise<string> {
}
try {
const backendUrl = process.env.BACKEND_URL || "http://localhost:8000";
const backendUrl = getServerBackendUrl();
const res = await fetch(`${backendUrl}/api/v1/health`, {
next: { revalidate: 300 },
});

View file

@ -1,6 +1,8 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getServerBackendUrl } from '@/lib/apiClient';
const OSS_TOKEN_COOKIE = 'dograh_auth_token';
// Paths that don't require authentication in OSS mode
@ -14,7 +16,7 @@ async function fetchAuthProvider(): Promise<string> {
}
try {
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000';
const backendUrl = getServerBackendUrl();
const res = await fetch(`${backendUrl}/api/v1/health`);
if (res.ok) {
const data = await res.json();

View file

@ -1,3 +1,5 @@
import type { OrganizationAiModelConfigurationV2 } from "@/client/types.gen";
export interface AmbientNoiseConfiguration {
enabled: boolean;
volume: number;
@ -64,6 +66,7 @@ export interface WorkflowConfigurations {
voicemail_detection?: VoicemailDetectionConfiguration;
context_compaction_enabled?: boolean; // Summarize context on node transitions to remove stale tool calls
model_overrides?: ModelOverrides; // Per-workflow model configuration overrides
model_configuration_v2_override?: OrganizationAiModelConfigurationV2; // Full v2 model configuration override
[key: string]: unknown; // Allow additional properties for future configurations
}