mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-28 08:49:42 +02:00
fix: create mps account on migrate to v2
This commit is contained in:
parent
8f241b89d2
commit
724e1d456b
14 changed files with 666 additions and 61 deletions
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
|
||||
|
||||
type ModelMode = "dograh" | "byok";
|
||||
type ModelMode = "realtime" | "dograh" | "byok";
|
||||
|
||||
interface DograhDefaults {
|
||||
voices: string[];
|
||||
|
|
@ -125,24 +125,35 @@ function effectiveConfigToLegacyShape(config: Record<string, unknown> | null): R
|
|||
};
|
||||
}
|
||||
|
||||
function emptyByokInitialConfig(): Record<string, unknown> {
|
||||
function emptyByokInitialConfig(isRealtime: boolean): Record<string, unknown> {
|
||||
return {
|
||||
is_realtime: false,
|
||||
is_realtime: isRealtime,
|
||||
};
|
||||
}
|
||||
|
||||
// The v2 editor surfaces realtime ("Speech to Speech") and pipeline (BYOK) as
|
||||
// separate tabs, so each tab gets its own initial config. A tab is pre-filled
|
||||
// only when the saved (or effective) configuration matches that tab's mode;
|
||||
// otherwise it starts empty so the other tab's data does not leak across.
|
||||
function getByokInitialConfig(
|
||||
configuration: Record<string, unknown> | null,
|
||||
effectiveConfiguration: Record<string, unknown> | null,
|
||||
wantRealtime: boolean,
|
||||
): Record<string, unknown> {
|
||||
const byokConfiguration = byokConfigToLegacyShape(configuration);
|
||||
if (byokConfiguration) return byokConfiguration;
|
||||
const matchesTab = (config: Record<string, unknown> | null) =>
|
||||
config ? Boolean(config.is_realtime) === wantRealtime : false;
|
||||
|
||||
if (configuration?.mode === "dograh" || isDograhEffectiveConfig(effectiveConfiguration)) {
|
||||
return emptyByokInitialConfig();
|
||||
const byokConfiguration = byokConfigToLegacyShape(configuration);
|
||||
if (byokConfiguration) {
|
||||
return matchesTab(byokConfiguration) ? byokConfiguration : emptyByokInitialConfig(wantRealtime);
|
||||
}
|
||||
|
||||
return effectiveConfigToLegacyShape(effectiveConfiguration) || emptyByokInitialConfig();
|
||||
if (configuration?.mode === "dograh" || isDograhEffectiveConfig(effectiveConfiguration)) {
|
||||
return emptyByokInitialConfig(wantRealtime);
|
||||
}
|
||||
|
||||
const effective = effectiveConfigToLegacyShape(effectiveConfiguration);
|
||||
return matchesTab(effective) ? (effective as Record<string, unknown>) : emptyByokInitialConfig(wantRealtime);
|
||||
}
|
||||
|
||||
function buildDograhState(
|
||||
|
|
@ -185,10 +196,12 @@ function preferredMode(
|
|||
configuration: Record<string, unknown> | null,
|
||||
effectiveConfiguration: Record<string, unknown> | null,
|
||||
): ModelMode {
|
||||
if (configuration?.mode === "dograh" || configuration?.mode === "byok") {
|
||||
return configuration.mode;
|
||||
if (configuration?.mode === "dograh") return "dograh";
|
||||
if (configuration?.mode === "byok") {
|
||||
return asRecord(configuration.byok)?.mode === "realtime" ? "realtime" : "byok";
|
||||
}
|
||||
return isDograhEffectiveConfig(effectiveConfiguration) ? "dograh" : "byok";
|
||||
if (isDograhEffectiveConfig(effectiveConfiguration)) return "dograh";
|
||||
return Boolean(effectiveConfiguration?.is_realtime) ? "realtime" : "byok";
|
||||
}
|
||||
|
||||
function hasRequiredApiKey(
|
||||
|
|
@ -249,7 +262,8 @@ export function AIModelConfigurationV2Editor({
|
|||
speed: defaults.dograh.defaults.speed,
|
||||
language: defaults.dograh.defaults.language,
|
||||
}));
|
||||
const [byokInitialConfig, setByokInitialConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [realtimeInitialConfig, setRealtimeInitialConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [pipelineInitialConfig, setPipelineInitialConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [isSavingDograh, setIsSavingDograh] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -258,7 +272,8 @@ export function AIModelConfigurationV2Editor({
|
|||
const rawEffectiveConfiguration = asRecord(effectiveConfiguration);
|
||||
setMode(preferredMode(rawConfiguration, rawEffectiveConfiguration));
|
||||
setDograh(buildDograhState(defaults, rawConfiguration, rawEffectiveConfiguration));
|
||||
setByokInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration));
|
||||
setRealtimeInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, true));
|
||||
setPipelineInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, false));
|
||||
}, [configuration, defaults, effectiveConfiguration]);
|
||||
|
||||
const saveDograhConfiguration = async () => {
|
||||
|
|
@ -322,28 +337,30 @@ export function AIModelConfigurationV2Editor({
|
|||
)}
|
||||
|
||||
<Tabs value={mode} onValueChange={(value) => setMode(value as ModelMode)} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="realtime">Speech to Speech</TabsTrigger>
|
||||
<TabsTrigger value="dograh">Dograh</TabsTrigger>
|
||||
<TabsTrigger value="byok">BYOK</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="realtime" className="mt-0">
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
A single speech-to-speech model handles the conversation in realtime (no separate transcriber or voice). An LLM is still required for variable extraction and QA.
|
||||
</p>
|
||||
<ServiceConfigurationForm
|
||||
key={`realtime-${JSON.stringify(realtimeInitialConfig)}`}
|
||||
mode="global"
|
||||
forceRealtime
|
||||
configurationDefaults={defaultsForByok}
|
||||
initialConfig={realtimeInitialConfig}
|
||||
submitLabel={submitLabel}
|
||||
onSave={saveByokConfiguration}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="dograh" className="mt-0">
|
||||
<div className="rounded-lg border p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="dograh-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="dograh-api-key"
|
||||
className="pl-9"
|
||||
value={dograh.api_key}
|
||||
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Voice</Label>
|
||||
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
|
||||
|
|
@ -394,6 +411,20 @@ export function AIModelConfigurationV2Editor({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="dograh-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="dograh-api-key"
|
||||
className="pl-9"
|
||||
value={dograh.api_key}
|
||||
onChange={(event) => setDograh({ ...dograh, api_key: event.target.value })}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" className="mt-6 w-full" onClick={saveDograhConfiguration} disabled={isSavingDograh}>
|
||||
|
|
@ -405,10 +436,11 @@ export function AIModelConfigurationV2Editor({
|
|||
|
||||
<TabsContent value="byok" className="mt-0">
|
||||
<ServiceConfigurationForm
|
||||
key={JSON.stringify(byokInitialConfig)}
|
||||
key={`byok-${JSON.stringify(pipelineInitialConfig)}`}
|
||||
mode="global"
|
||||
forceRealtime={false}
|
||||
configurationDefaults={defaultsForByok}
|
||||
initialConfig={byokInitialConfig}
|
||||
initialConfig={pipelineInitialConfig}
|
||||
submitLabel={submitLabel}
|
||||
onSave={saveByokConfiguration}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -101,6 +101,13 @@ export interface ServiceConfigurationFormProps {
|
|||
submitLabel?: string;
|
||||
configurationDefaults?: ServiceConfigurationDefaults | null;
|
||||
initialConfig?: Record<string, unknown> | null;
|
||||
/**
|
||||
* When set, locks the realtime/pipeline mode to this value and hides the
|
||||
* in-form toggle. The v2 editor uses this to surface realtime
|
||||
* ("Speech to Speech") and pipeline (BYOK) as separate top-level tabs.
|
||||
* Leave undefined to keep the user-controllable toggle (legacy + overrides).
|
||||
*/
|
||||
forceRealtime?: boolean;
|
||||
}
|
||||
|
||||
function getProviderDisplayName(
|
||||
|
|
@ -130,10 +137,11 @@ export function ServiceConfigurationForm({
|
|||
submitLabel,
|
||||
configurationDefaults,
|
||||
initialConfig,
|
||||
forceRealtime,
|
||||
}: ServiceConfigurationFormProps) {
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isRealtime, setIsRealtime] = useState(false);
|
||||
const [isRealtime, setIsRealtime] = useState(forceRealtime ?? false);
|
||||
const { userConfig } = useUserConfig();
|
||||
const [schemas, setSchemas] = useState<Record<ServiceSegment, Record<string, ProviderSchema>>>({
|
||||
llm: {},
|
||||
|
|
@ -227,9 +235,9 @@ export function ServiceConfigurationForm({
|
|||
realtime: realtimeSchemas,
|
||||
});
|
||||
|
||||
// Restore realtime toggle
|
||||
// Restore realtime toggle (skip when the parent locks the mode)
|
||||
const configData = configSource as Record<string, unknown> | null;
|
||||
if (configData?.is_realtime) {
|
||||
if (forceRealtime === undefined && configData?.is_realtime) {
|
||||
setIsRealtime(true);
|
||||
}
|
||||
|
||||
|
|
@ -867,22 +875,24 @@ export function ServiceConfigurationForm({
|
|||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* Realtime toggle */}
|
||||
<div className="flex items-center justify-between mb-4 p-4 border rounded-lg">
|
||||
<div>
|
||||
<Label htmlFor="realtime-toggle" className="text-sm font-medium">
|
||||
Realtime Mode
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Uses a single speech-to-speech model (no separate STT/TTS). An LLM is still required for variable extraction and QA.
|
||||
</p>
|
||||
{/* Realtime toggle — hidden when the parent locks the mode (v2 tabs) */}
|
||||
{forceRealtime === undefined && (
|
||||
<div className="flex items-center justify-between mb-4 p-4 border rounded-lg">
|
||||
<div>
|
||||
<Label htmlFor="realtime-toggle" className="text-sm font-medium">
|
||||
Realtime Mode
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Uses a single speech-to-speech model (no separate STT/TTS). An LLM is still required for variable extraction and QA.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="realtime-toggle"
|
||||
checked={isRealtime}
|
||||
onCheckedChange={setIsRealtime}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
id="realtime-toggle"
|
||||
checked={isRealtime}
|
||||
onCheckedChange={setIsRealtime}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue