feat: show model pricing in configuration UI (#528)

* feat: show model pricing in configuration UI

* fix: display effective pricing rounding policy
This commit is contained in:
Abhishek 2026-07-13 14:20:24 +05:30 committed by GitHub
parent e7494e9c21
commit c76076fb93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 445 additions and 9 deletions

View file

@ -15,6 +15,7 @@ import {
getWorkflowApiV1WorkflowFetchWorkflowIdGet,
} from "@/client/sdk.gen";
import type {
ModelConfigurationPricingResponse,
OrganizationAiModelConfigurationResponse,
OrganizationAiModelConfigurationV2,
WorkflowResponse,
@ -42,6 +43,7 @@ import { useAudioPlayback } from "@/hooks/useAudioPlayback";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
import logger from "@/lib/logger";
import { fetchModelConfigurationPricing } from "@/lib/modelConfigurationPricing";
import {
type AmbientNoiseConfiguration,
DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
@ -1202,6 +1204,7 @@ function WorkflowModelOverridesSection({
onSave,
modelConfigurationDefaults,
organizationModelConfiguration,
modelConfigurationPricing,
modelConfigurationLoading,
modelConfigurationError,
}: {
@ -1210,6 +1213,7 @@ function WorkflowModelOverridesSection({
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
modelConfigurationDefaults: ModelConfigurationDefaultsV2 | null;
organizationModelConfiguration: OrganizationAiModelConfigurationResponse | null;
modelConfigurationPricing: ModelConfigurationPricingResponse | null;
modelConfigurationLoading: boolean;
modelConfigurationError: string | null;
}) {
@ -1311,6 +1315,7 @@ function WorkflowModelOverridesSection({
? null
: organizationModelConfiguration.effective_configuration
}
pricing={modelConfigurationPricing}
submitLabel="Save Model Override"
onSave={saveV2Override}
/>
@ -1428,6 +1433,7 @@ function WorkflowSettingsInner({
const [activeSection, setActiveSection] = useState("general");
const [modelConfigurationDefaults, setModelConfigurationDefaults] = useState<ModelConfigurationDefaultsV2 | null>(null);
const [organizationModelConfiguration, setOrganizationModelConfiguration] = useState<OrganizationAiModelConfigurationResponse | null>(null);
const [modelConfigurationPricing, setModelConfigurationPricing] = useState<ModelConfigurationPricingResponse | null>(null);
const [modelConfigurationLoading, setModelConfigurationLoading] = useState(true);
const [modelConfigurationError, setModelConfigurationError] = useState<string | null>(null);
const hasFetchedModelConfiguration = useRef(false);
@ -1484,9 +1490,10 @@ function WorkflowSettingsInner({
const loadModelConfiguration = async () => {
setModelConfigurationLoading(true);
setModelConfigurationError(null);
const [defaultsResult, configurationResult] = await Promise.all([
const [defaultsResult, configurationResult, pricingResult] = await Promise.all([
getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet(),
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(),
fetchModelConfigurationPricing(),
]);
if (defaultsResult.error) {
@ -1502,6 +1509,7 @@ function WorkflowSettingsInner({
setModelConfigurationDefaults(defaultsResult.data as ModelConfigurationDefaultsV2);
setOrganizationModelConfiguration(configurationResult.data || null);
setModelConfigurationPricing(pricingResult);
setModelConfigurationLoading(false);
};
@ -1566,6 +1574,7 @@ function WorkflowSettingsInner({
onSave={saveWorkflowConfigurations}
modelConfigurationDefaults={modelConfigurationDefaults}
organizationModelConfiguration={organizationModelConfiguration}
modelConfigurationPricing={modelConfigurationPricing}
modelConfigurationLoading={modelConfigurationLoading}
modelConfigurationError={modelConfigurationError}
/>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -3673,6 +3673,46 @@ export type MiniMaxTtsConfiguration = {
group_id: string;
};
/**
* ModelConfigurationMetricPrice
*/
export type ModelConfigurationMetricPrice = {
/**
* Metric Code
*/
metric_code: string;
/**
* Display Name
*/
display_name: string;
/**
* Unit
*/
unit: string;
/**
* Price Per Minute
*/
price_per_minute: number;
/**
* Currency
*/
currency: string;
/**
* Rounding Policy
*/
rounding_policy: string;
};
/**
* ModelConfigurationPricingResponse
*
* MPS-owned effective prices relevant to model configuration choices.
*/
export type ModelConfigurationPricingResponse = {
platform_usage?: ModelConfigurationMetricPrice | null;
dograh_model?: ModelConfigurationMetricPrice | null;
};
/**
* MoveWorkflowToFolderRequest
*
@ -10828,6 +10868,45 @@ export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutRe
export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutResponse = SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutResponses[keyof SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutResponses];
export type GetModelConfigurationPricingApiV1OrganizationsModelConfigurationsV2PricingGetData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/organizations/model-configurations/v2/pricing';
};
export type GetModelConfigurationPricingApiV1OrganizationsModelConfigurationsV2PricingGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetModelConfigurationPricingApiV1OrganizationsModelConfigurationsV2PricingGetError = GetModelConfigurationPricingApiV1OrganizationsModelConfigurationsV2PricingGetErrors[keyof GetModelConfigurationPricingApiV1OrganizationsModelConfigurationsV2PricingGetErrors];
export type GetModelConfigurationPricingApiV1OrganizationsModelConfigurationsV2PricingGetResponses = {
/**
* Successful Response
*/
200: ModelConfigurationPricingResponse;
};
export type GetModelConfigurationPricingApiV1OrganizationsModelConfigurationsV2PricingGetResponse = GetModelConfigurationPricingApiV1OrganizationsModelConfigurationsV2PricingGetResponses[keyof GetModelConfigurationPricingApiV1OrganizationsModelConfigurationsV2PricingGetResponses];
export type PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetData = {
body?: never;
headers?: {

View file

@ -3,7 +3,11 @@
import { Info, KeyRound, Save } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { OrganizationAiModelConfigurationV2 } from "@/client/types.gen";
import type {
ModelConfigurationMetricPrice,
ModelConfigurationPricingResponse,
OrganizationAiModelConfigurationV2,
} from "@/client/types.gen";
import {
type ProviderSchema,
type ServiceConfigurationDefaults,
@ -18,6 +22,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { VoiceSelectorModal } from "@/components/VoiceSelectorModal";
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
import { formatRoundingPolicy } from "@/lib/billingDisplay";
type ModelMode = "realtime" | "dograh" | "byok";
@ -67,6 +72,7 @@ interface AIModelConfigurationV2EditorProps {
defaults: ModelConfigurationDefaultsV2;
configuration?: OrganizationAiModelConfigurationV2 | Record<string, unknown> | null;
effectiveConfiguration?: Record<string, unknown> | null;
pricing?: ModelConfigurationPricingResponse | null;
onSave: (configuration: OrganizationAiModelConfigurationV2) => Promise<void>;
submitLabel?: string;
}
@ -267,7 +273,7 @@ function optionalByokService(config: Record<string, unknown>, service: ServiceSe
function ThirdPartyProviderNotice() {
return (
<div className="mb-4 flex gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-200">
<div className="mt-4 flex gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-200">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="font-medium">Third-party provider data notice</p>
@ -282,10 +288,72 @@ function ThirdPartyProviderNotice() {
);
}
function formatPricePerMinute(price: ModelConfigurationMetricPrice): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: price.currency,
minimumFractionDigits: 2,
maximumFractionDigits: 4,
}).format(price.price_per_minute);
}
function MetricPrice({
label,
price,
}: {
label: string;
price: ModelConfigurationMetricPrice;
}) {
return (
<div className="space-y-0.5">
<p className="text-muted-foreground">
{label}: <span className="font-medium text-foreground">{formatPricePerMinute(price)}/{price.unit}</span>
</p>
<p className="text-xs text-muted-foreground">
{formatRoundingPolicy(price.rounding_policy)}
</p>
</div>
);
}
function PricingSummary({
pricing,
includeDograhModel,
thirdPartyModels,
}: {
pricing?: ModelConfigurationPricingResponse | null;
includeDograhModel: boolean;
thirdPartyModels?: boolean;
}) {
const platformPrice = pricing?.platform_usage;
const dograhModelPrice = includeDograhModel ? pricing?.dograh_model : null;
if (!platformPrice && !dograhModelPrice) return null;
return (
<Card className="mb-4 border-primary/20 bg-primary/[0.03]">
<CardContent className="space-y-2 pt-5 text-sm">
<p className="font-medium">Usage pricing</p>
{platformPrice && (
<MetricPrice label="Platform usage" price={platformPrice} />
)}
{dograhModelPrice && (
<MetricPrice label="Dograh model usage" price={dograhModelPrice} />
)}
{thirdPartyModels && (
<p className="text-muted-foreground">
Your selected model provider may charge separately for its usage.
</p>
)}
</CardContent>
</Card>
);
}
export function AIModelConfigurationV2Editor({
defaults,
configuration,
effectiveConfiguration,
pricing,
onSave,
submitLabel = "Save Configuration",
}: AIModelConfigurationV2EditorProps) {
@ -400,7 +468,7 @@ export function AIModelConfigurationV2Editor({
<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>
<ThirdPartyProviderNotice />
<PricingSummary pricing={pricing} includeDograhModel={false} thirdPartyModels />
<ServiceConfigurationForm
key={`realtime-${JSON.stringify(realtimeInitialConfig)}`}
mode="global"
@ -410,9 +478,24 @@ export function AIModelConfigurationV2Editor({
submitLabel={submitLabel}
onSave={saveByokConfiguration}
/>
<ThirdPartyProviderNotice />
</TabsContent>
<TabsContent value="dograh" className="mt-0">
<p className="mb-4 text-sm text-muted-foreground">
Dograh provides a managed transcriber, LLM, and voice pipeline. Select a voice and language while Dograh manages the underlying model providers.{" "}
We offer custom pricing and a 15-second pulse with a monthly commitment.{" "}
<a
href="https://www.dograh.com/contact"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Contact us
</a>
.
</p>
<PricingSummary pricing={pricing} includeDograhModel />
<Card>
<CardContent className="pt-6">
<div className="grid gap-4 sm:grid-cols-2">
@ -490,7 +573,10 @@ export function AIModelConfigurationV2Editor({
</TabsContent>
<TabsContent value="byok" className="mt-0">
<ThirdPartyProviderNotice />
<p className="mb-4 text-sm text-muted-foreground">
Configure separate transcriber, LLM, and voice providers using your own API keys. An embeddings model can also be configured for knowledge retrieval.
</p>
<PricingSummary pricing={pricing} includeDograhModel={false} thirdPartyModels />
<ServiceConfigurationForm
key={`byok-${JSON.stringify(pipelineInitialConfig)}`}
mode="global"
@ -500,6 +586,7 @@ export function AIModelConfigurationV2Editor({
submitLabel={submitLabel}
onSave={saveByokConfiguration}
/>
<ThirdPartyProviderNotice />
</TabsContent>
</Tabs>
</div>

View file

@ -9,6 +9,7 @@ import {
saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put,
} from "@/client/sdk.gen";
import type {
ModelConfigurationPricingResponse,
OrganizationAiModelConfigurationResponse,
OrganizationAiModelConfigurationV2,
} from "@/client/types.gen";
@ -17,6 +18,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useUserConfig } from "@/context/UserConfigContext";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
import { fetchModelConfigurationPricing } from "@/lib/modelConfigurationPricing";
export default function ModelConfigurationV2({ docsUrl }: { docsUrl?: string }) {
const auth = useAuth();
@ -25,6 +27,7 @@ export default function ModelConfigurationV2({ docsUrl }: { docsUrl?: string })
const [defaults, setDefaults] = useState<ModelConfigurationDefaultsV2 | null>(null);
const [response, setResponse] = useState<OrganizationAiModelConfigurationResponse | null>(null);
const [pricing, setPricing] = useState<ModelConfigurationPricingResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
@ -36,9 +39,10 @@ export default function ModelConfigurationV2({ docsUrl }: { docsUrl?: string })
const load = async () => {
setLoading(true);
setError(null);
const [defaultsResult, configResult] = await Promise.all([
const [defaultsResult, configResult, pricingResult] = await Promise.all([
getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet(),
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(),
fetchModelConfigurationPricing(),
]);
if (defaultsResult.error) {
@ -60,6 +64,7 @@ export default function ModelConfigurationV2({ docsUrl }: { docsUrl?: string })
}
setDefaults(nextDefaults);
setResponse(configResult.data);
setPricing(pricingResult);
setLoading(false);
};
@ -84,6 +89,7 @@ export default function ModelConfigurationV2({ docsUrl }: { docsUrl?: string })
}
setResponse(result.data);
void fetchModelConfigurationPricing().then(setPricing);
await refreshConfig();
setNotice("Model configuration saved");
};
@ -130,6 +136,7 @@ export default function ModelConfigurationV2({ docsUrl }: { docsUrl?: string })
defaults={defaults}
configuration={response.configuration}
effectiveConfiguration={response.effective_configuration}
pricing={pricing}
onSave={saveConfiguration}
/>
)}

View file

@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { formatRoundingPolicy } from "@/lib/billingDisplay";
describe("formatRoundingPolicy", () => {
it.each([
["ceil_minute", "Rounded up to whole minutes per call."],
["ceil_1_minute", "Rounded up to whole minutes per call."],
["ceil_15_seconds", "Rounded up in 15-second increments per call."],
["ceil_30_second", "Rounded up in 30-second increments per call."],
["ceil_2_minutes", "Rounded up in 2-minute increments per call."],
["none", "Billed using exact measured usage."],
["custom_contract", "Billing policy: custom contract."],
["", "Billing policy details are unavailable."],
])("formats %s", (policy, expected) => {
expect(formatRoundingPolicy(policy)).toBe(expected);
});
});

View file

@ -0,0 +1,24 @@
export function formatRoundingPolicy(roundingPolicy: string): string {
const normalizedPolicy = roundingPolicy.trim().toLowerCase();
if (!normalizedPolicy) {
return "Billing policy details are unavailable.";
}
if (normalizedPolicy === "ceil_minute" || normalizedPolicy === "ceil_1_minute") {
return "Rounded up to whole minutes per call.";
}
if (normalizedPolicy === "none") {
return "Billed using exact measured usage.";
}
const secondsMatch = normalizedPolicy.match(/^ceil_(\d+)_seconds?$/);
if (secondsMatch) {
return `Rounded up in ${Number(secondsMatch[1])}-second increments per call.`;
}
const minutesMatch = normalizedPolicy.match(/^ceil_(\d+)_minutes?$/);
if (minutesMatch) {
return `Rounded up in ${Number(minutesMatch[1])}-minute increments per call.`;
}
return `Billing policy: ${roundingPolicy.replaceAll("_", " ")}.`;
}

View file

@ -0,0 +1,17 @@
import { getModelConfigurationPricingApiV1OrganizationsModelConfigurationsV2PricingGet } from "@/client/sdk.gen";
import type { ModelConfigurationPricingResponse } from "@/client/types.gen";
import logger from "@/lib/logger";
export async function fetchModelConfigurationPricing(): Promise<ModelConfigurationPricingResponse | null> {
try {
const result = await getModelConfigurationPricingApiV1OrganizationsModelConfigurationsV2PricingGet();
if (result.error) {
logger.warn("Failed to load model configuration pricing", result.error);
return null;
}
return result.data ?? null;
} catch (error) {
logger.warn("Failed to load model configuration pricing", error);
return null;
}
}