fix: create mps account on migrate to v2

This commit is contained in:
Abhishek Kumar 2026-06-12 14:53:36 +05:30
parent 8f241b89d2
commit 724e1d456b
14 changed files with 666 additions and 61 deletions

View file

@ -1,7 +1,14 @@
"use client";
import { CircleDollarSign, CreditCard, RefreshCw } from "lucide-react";
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";
@ -23,6 +30,8 @@ import {
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,
@ -93,13 +102,26 @@ const getRunHref = (entry: MpsCreditLedgerEntryResponse) => {
return `/workflow/${entry.workflow_id}/run/${entry.workflow_run_id}`;
};
const getPageFromSearchParams = (
searchParams: { get: (name: string) => string | null },
) => {
const pageParam = searchParams.get("page");
const page = pageParam ? Number.parseInt(pageParam, 10) : 1;
return Number.isFinite(page) && page > 0 ? page : 1;
};
export default function BillingPage() {
const router = useRouter();
const searchParams = useSearchParams();
const auth = useAuth();
const { config } = useAppConfig();
const [credits, setCredits] = useState<MpsBillingCreditsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [purchasing, setPurchasing] = useState(false);
const [currentPage, setCurrentPage] = useState(
() => getPageFromSearchParams(searchParams),
);
const isBillingV2 = credits?.billing_version === "v2";
const canPurchaseCredits = isBillingV2 && config?.deploymentMode !== "oss";
@ -109,8 +131,14 @@ export default function BillingPage() {
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 ({ silent = false }: { silent?: boolean } = {}) => {
const fetchCredits = useCallback(async (
page: number,
{ silent = false }: { silent?: boolean } = {},
) => {
if (auth.loading) {
return;
}
@ -128,7 +156,7 @@ export default function BillingPage() {
try {
const response = await getBillingCreditsApiV1OrganizationsBillingCreditsGet({
query: { limit: 50 },
query: { page, limit: LEDGER_PAGE_SIZE },
});
if (response.error) {
@ -146,11 +174,36 @@ export default function BillingPage() {
}, [auth.isAuthenticated, auth.loading]);
useEffect(() => {
fetchCredits();
}, [fetchCredits]);
const nextPage = getPageFromSearchParams(searchParams);
setCurrentPage((previousPage) => (
previousPage === nextPage ? previousPage : nextPage
));
}, [searchParams]);
useEffect(() => {
fetchCredits(currentPage);
}, [currentPage, fetchCredits]);
const handleRefresh = () => {
fetchCredits({ silent: true });
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 () => {
@ -233,7 +286,7 @@ export default function BillingPage() {
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{isBillingV2 ? "Recent ledger debit total" : "Current allocation usage"}
{isBillingV2 ? "Total ledger debits" : "Current allocation usage"}
</p>
</CardContent>
</Card>
@ -315,6 +368,33 @@ export default function BillingPage() {
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>
) : (

View file

@ -1256,7 +1256,7 @@ export const getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet = <ThrowOnError e
/**
* Get Billing Credits
*
* Return legacy MPS credits or v2 billing ledger details for the org.
* Return legacy MPS credits or paginated v2 billing ledger details for the org.
*/
export const getBillingCreditsApiV1OrganizationsBillingCreditsGet = <ThrowOnError extends boolean = false>(options?: Options<GetBillingCreditsApiV1OrganizationsBillingCreditsGetData, ThrowOnError>) => (options?.client ?? client).get<GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses, GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors, ThrowOnError>({ url: '/api/v1/organizations/billing/credits', ...options });

View file

@ -3138,6 +3138,22 @@ export type MpsBillingCreditsResponse = {
* Ledger Entries
*/
ledger_entries?: Array<MpsCreditLedgerEntryResponse>;
/**
* Total Count
*/
total_count?: number;
/**
* Page
*/
page?: number;
/**
* Limit
*/
limit?: number;
/**
* Total Pages
*/
total_pages?: number;
};
/**
@ -11482,6 +11498,10 @@ export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetData = {
};
path?: never;
query?: {
/**
* Page
*/
page?: number;
/**
* Limit
*/

View file

@ -17,7 +17,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
type ModelMode = "dograh" | "byok";
type ModelMode = "realtime" | "dograh" | "byok";
interface DograhDefaults {
voices: string[];
@ -125,24 +125,35 @@ function effectiveConfigToLegacyShape(config: Record<string, unknown> | null): R
};
}
function emptyByokInitialConfig(): Record<string, unknown> {
function emptyByokInitialConfig(isRealtime: boolean): Record<string, unknown> {
return {
is_realtime: false,
is_realtime: isRealtime,
};
}
// The v2 editor surfaces realtime ("Speech to Speech") and pipeline (BYOK) as
// separate tabs, so each tab gets its own initial config. A tab is pre-filled
// only when the saved (or effective) configuration matches that tab's mode;
// otherwise it starts empty so the other tab's data does not leak across.
function getByokInitialConfig(
configuration: Record<string, unknown> | null,
effectiveConfiguration: Record<string, unknown> | null,
wantRealtime: boolean,
): Record<string, unknown> {
const byokConfiguration = byokConfigToLegacyShape(configuration);
if (byokConfiguration) return byokConfiguration;
const matchesTab = (config: Record<string, unknown> | null) =>
config ? Boolean(config.is_realtime) === wantRealtime : false;
if (configuration?.mode === "dograh" || isDograhEffectiveConfig(effectiveConfiguration)) {
return emptyByokInitialConfig();
const byokConfiguration = byokConfigToLegacyShape(configuration);
if (byokConfiguration) {
return matchesTab(byokConfiguration) ? byokConfiguration : emptyByokInitialConfig(wantRealtime);
}
return effectiveConfigToLegacyShape(effectiveConfiguration) || emptyByokInitialConfig();
if (configuration?.mode === "dograh" || isDograhEffectiveConfig(effectiveConfiguration)) {
return emptyByokInitialConfig(wantRealtime);
}
const effective = effectiveConfigToLegacyShape(effectiveConfiguration);
return matchesTab(effective) ? (effective as Record<string, unknown>) : emptyByokInitialConfig(wantRealtime);
}
function buildDograhState(
@ -185,10 +196,12 @@ function preferredMode(
configuration: Record<string, unknown> | null,
effectiveConfiguration: Record<string, unknown> | null,
): ModelMode {
if (configuration?.mode === "dograh" || configuration?.mode === "byok") {
return configuration.mode;
if (configuration?.mode === "dograh") return "dograh";
if (configuration?.mode === "byok") {
return asRecord(configuration.byok)?.mode === "realtime" ? "realtime" : "byok";
}
return isDograhEffectiveConfig(effectiveConfiguration) ? "dograh" : "byok";
if (isDograhEffectiveConfig(effectiveConfiguration)) return "dograh";
return Boolean(effectiveConfiguration?.is_realtime) ? "realtime" : "byok";
}
function hasRequiredApiKey(
@ -249,7 +262,8 @@ export function AIModelConfigurationV2Editor({
speed: defaults.dograh.defaults.speed,
language: defaults.dograh.defaults.language,
}));
const [byokInitialConfig, setByokInitialConfig] = useState<Record<string, unknown> | null>(null);
const [realtimeInitialConfig, setRealtimeInitialConfig] = useState<Record<string, unknown> | null>(null);
const [pipelineInitialConfig, setPipelineInitialConfig] = useState<Record<string, unknown> | null>(null);
const [isSavingDograh, setIsSavingDograh] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -258,7 +272,8 @@ export function AIModelConfigurationV2Editor({
const rawEffectiveConfiguration = asRecord(effectiveConfiguration);
setMode(preferredMode(rawConfiguration, rawEffectiveConfiguration));
setDograh(buildDograhState(defaults, rawConfiguration, rawEffectiveConfiguration));
setByokInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration));
setRealtimeInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, true));
setPipelineInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, false));
}, [configuration, defaults, effectiveConfiguration]);
const saveDograhConfiguration = async () => {
@ -322,28 +337,30 @@ export function AIModelConfigurationV2Editor({
)}
<Tabs value={mode} onValueChange={(value) => setMode(value as ModelMode)} className="space-y-6">
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="realtime">Speech to Speech</TabsTrigger>
<TabsTrigger value="dograh">Dograh</TabsTrigger>
<TabsTrigger value="byok">BYOK</TabsTrigger>
</TabsList>
<TabsContent value="realtime" className="mt-0">
<p className="mb-4 text-sm text-muted-foreground">
A single speech-to-speech model handles the conversation in realtime (no separate transcriber or voice). An LLM is still required for variable extraction and QA.
</p>
<ServiceConfigurationForm
key={`realtime-${JSON.stringify(realtimeInitialConfig)}`}
mode="global"
forceRealtime
configurationDefaults={defaultsForByok}
initialConfig={realtimeInitialConfig}
submitLabel={submitLabel}
onSave={saveByokConfiguration}
/>
</TabsContent>
<TabsContent value="dograh" className="mt-0">
<div className="rounded-lg border p-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="dograh-api-key">API Key</Label>
<div className="relative">
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="dograh-api-key"
className="pl-9"
value={dograh.api_key}
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
placeholder="Enter API key"
/>
</div>
</div>
<div className="space-y-2">
<Label>Voice</Label>
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
@ -394,6 +411,20 @@ export function AIModelConfigurationV2Editor({
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="dograh-api-key">API Key</Label>
<div className="relative">
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="dograh-api-key"
className="pl-9"
value={dograh.api_key}
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
placeholder="Enter API key"
/>
</div>
</div>
</div>
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
@ -405,10 +436,11 @@ export function AIModelConfigurationV2Editor({
<TabsContent value="byok" className="mt-0">
<ServiceConfigurationForm
key={JSON.stringify(byokInitialConfig)}
key={`byok-${JSON.stringify(pipelineInitialConfig)}`}
mode="global"
forceRealtime={false}
configurationDefaults={defaultsForByok}
initialConfig={byokInitialConfig}
initialConfig={pipelineInitialConfig}
submitLabel={submitLabel}
onSave={saveByokConfiguration}
/>

View file

@ -101,6 +101,13 @@ export interface ServiceConfigurationFormProps {
submitLabel?: string;
configurationDefaults?: ServiceConfigurationDefaults | null;
initialConfig?: Record<string, unknown> | null;
/**
* When set, locks the realtime/pipeline mode to this value and hides the
* in-form toggle. The v2 editor uses this to surface realtime
* ("Speech to Speech") and pipeline (BYOK) as separate top-level tabs.
* Leave undefined to keep the user-controllable toggle (legacy + overrides).
*/
forceRealtime?: boolean;
}
function getProviderDisplayName(
@ -130,10 +137,11 @@ export function ServiceConfigurationForm({
submitLabel,
configurationDefaults,
initialConfig,
forceRealtime,
}: ServiceConfigurationFormProps) {
const [apiError, setApiError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isRealtime, setIsRealtime] = useState(false);
const [isRealtime, setIsRealtime] = useState(forceRealtime ?? false);
const { userConfig } = useUserConfig();
const [schemas, setSchemas] = useState<Record<ServiceSegment, Record<string, ProviderSchema>>>({
llm: {},
@ -227,9 +235,9 @@ export function ServiceConfigurationForm({
realtime: realtimeSchemas,
});
// Restore realtime toggle
// Restore realtime toggle (skip when the parent locks the mode)
const configData = configSource as Record<string, unknown> | null;
if (configData?.is_realtime) {
if (forceRealtime === undefined && configData?.is_realtime) {
setIsRealtime(true);
}
@ -867,22 +875,24 @@ export function ServiceConfigurationForm({
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Realtime toggle */}
<div className="flex items-center justify-between mb-4 p-4 border rounded-lg">
<div>
<Label htmlFor="realtime-toggle" className="text-sm font-medium">
Realtime Mode
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Uses a single speech-to-speech model (no separate STT/TTS). An LLM is still required for variable extraction and QA.
</p>
{/* Realtime toggle — hidden when the parent locks the mode (v2 tabs) */}
{forceRealtime === undefined && (
<div className="flex items-center justify-between mb-4 p-4 border rounded-lg">
<div>
<Label htmlFor="realtime-toggle" className="text-sm font-medium">
Realtime Mode
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Uses a single speech-to-speech model (no separate STT/TTS). An LLM is still required for variable extraction and QA.
</p>
</div>
<Switch
id="realtime-toggle"
checked={isRealtime}
onCheckedChange={setIsRealtime}
/>
</div>
<Switch
id="realtime-toggle"
checked={isRealtime}
onCheckedChange={setIsRealtime}
/>
</div>
)}
<Card>
<CardContent className="pt-6">