mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-13 11:22:14 +02:00
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:
parent
e7494e9c21
commit
c76076fb93
13 changed files with 445 additions and 9 deletions
|
|
@ -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
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
18
ui/src/lib/billingDisplay.test.ts
Normal file
18
ui/src/lib/billingDisplay.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
24
ui/src/lib/billingDisplay.ts
Normal file
24
ui/src/lib/billingDisplay.ts
Normal 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("_", " ")}.`;
|
||||
}
|
||||
17
ui/src/lib/modelConfigurationPricing.ts
Normal file
17
ui/src/lib/modelConfigurationPricing.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue