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
|
|
@ -68,6 +68,7 @@ from api.services.configuration.registry import (
|
|||
ServiceType,
|
||||
)
|
||||
from api.services.mps_billing import ensure_hosted_mps_billing_account_v2
|
||||
from api.services.mps_service_key_client import mps_service_key_client
|
||||
from api.services.organization_context import (
|
||||
OrganizationContextResponse,
|
||||
get_organization_context,
|
||||
|
|
@ -147,6 +148,22 @@ class TelephonyConfigWarningsResponse(BaseModel):
|
|||
vonage_missing_signature_secret_count: int
|
||||
|
||||
|
||||
class ModelConfigurationMetricPrice(BaseModel):
|
||||
metric_code: str
|
||||
display_name: str
|
||||
unit: str
|
||||
price_per_minute: float
|
||||
currency: str
|
||||
rounding_policy: str
|
||||
|
||||
|
||||
class ModelConfigurationPricingResponse(BaseModel):
|
||||
"""MPS-owned effective prices relevant to model configuration choices."""
|
||||
|
||||
platform_usage: ModelConfigurationMetricPrice | None = None
|
||||
dograh_model: ModelConfigurationMetricPrice | None = None
|
||||
|
||||
|
||||
@router.get("/context", response_model=OrganizationContextResponse)
|
||||
async def get_current_organization_context(user: UserModel = Depends(get_user)):
|
||||
"""Return organization-scoped configuration signals owned by Dograh."""
|
||||
|
|
@ -312,6 +329,34 @@ async def get_model_configuration_v2(
|
|||
return await _model_configuration_v2_response(user=user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/model-configurations/v2/pricing",
|
||||
response_model=ModelConfigurationPricingResponse,
|
||||
)
|
||||
async def get_model_configuration_pricing(
|
||||
user: UserModel = Depends(get_user_with_selected_organization),
|
||||
) -> ModelConfigurationPricingResponse:
|
||||
"""Return the hosted organization prices shown in Model Configurations."""
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
return ModelConfigurationPricingResponse()
|
||||
|
||||
try:
|
||||
pricing = await mps_service_key_client.get_billing_pricing(
|
||||
user.selected_organization_id,
|
||||
)
|
||||
return ModelConfigurationPricingResponse.model_validate(pricing)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to get MPS model-configuration pricing for organization {}: {}",
|
||||
user.selected_organization_id,
|
||||
exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Failed to retrieve model configuration pricing",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.put(
|
||||
"/model-configurations/v2",
|
||||
response_model=OrganizationAIModelConfigurationResponse,
|
||||
|
|
|
|||
|
|
@ -382,6 +382,30 @@ class MPSServiceKeyClient:
|
|||
response=response,
|
||||
)
|
||||
|
||||
async def get_billing_pricing(self, organization_id: int) -> dict:
|
||||
"""Return MPS-owned effective platform and Dograh model prices for an org."""
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
raise ValueError("OSS deployments do not fetch hosted billing prices")
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/v1/billing/accounts/{organization_id}/pricing",
|
||||
headers=self._get_headers(organization_id=organization_id),
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
logger.error(
|
||||
"Failed to get MPS billing pricing: "
|
||||
f"{response.status_code} - {response.text}"
|
||||
)
|
||||
raise httpx.HTTPStatusError(
|
||||
f"Failed to get MPS billing pricing: {response.text}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
async def ensure_billing_account_v2(
|
||||
self,
|
||||
organization_id: int,
|
||||
|
|
|
|||
66
api/tests/test_model_configuration_pricing.py
Normal file
66
api/tests/test_model_configuration_pricing.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from api.routes import organization as organization_routes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_configuration_pricing_returns_empty_in_oss(monkeypatch):
|
||||
get_pricing = AsyncMock()
|
||||
monkeypatch.setattr(organization_routes, "DEPLOYMENT_MODE", "oss")
|
||||
monkeypatch.setattr(
|
||||
organization_routes.mps_service_key_client,
|
||||
"get_billing_pricing",
|
||||
get_pricing,
|
||||
)
|
||||
|
||||
response = await organization_routes.get_model_configuration_pricing(
|
||||
SimpleNamespace(selected_organization_id=42),
|
||||
)
|
||||
|
||||
assert response.platform_usage is None
|
||||
assert response.dograh_model is None
|
||||
get_pricing.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_configuration_pricing_uses_selected_organization(monkeypatch):
|
||||
get_pricing = AsyncMock(
|
||||
return_value={
|
||||
"organization_id": 42,
|
||||
"platform_usage": {
|
||||
"metric_code": "platform_usage",
|
||||
"display_name": "Platform usage",
|
||||
"unit": "minute",
|
||||
"price_per_minute": 0.01,
|
||||
"currency": "USD",
|
||||
"rounding_policy": "ceil_minute",
|
||||
},
|
||||
"dograh_model": {
|
||||
"metric_code": "voice_minutes",
|
||||
"display_name": "Dograh model usage",
|
||||
"unit": "minute",
|
||||
"price_per_minute": 0.07,
|
||||
"currency": "USD",
|
||||
"rounding_policy": "ceil_minute",
|
||||
},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(organization_routes, "DEPLOYMENT_MODE", "saas")
|
||||
monkeypatch.setattr(
|
||||
organization_routes.mps_service_key_client,
|
||||
"get_billing_pricing",
|
||||
get_pricing,
|
||||
)
|
||||
|
||||
response = await organization_routes.get_model_configuration_pricing(
|
||||
SimpleNamespace(selected_organization_id=42),
|
||||
)
|
||||
|
||||
get_pricing.assert_awaited_once_with(42)
|
||||
assert response.platform_usage is not None
|
||||
assert response.platform_usage.price_per_minute == 0.01
|
||||
assert response.dograh_model is not None
|
||||
assert response.dograh_model.price_per_minute == 0.07
|
||||
|
|
@ -260,6 +260,59 @@ async def test_ensure_billing_account_v2_uses_balance_endpoint(monkeypatch):
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_billing_pricing_uses_hosted_organization_auth(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, timeout):
|
||||
self.timeout = timeout
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
async def get(self, url, headers):
|
||||
calls.append(("GET", url, headers))
|
||||
return _Response(
|
||||
200,
|
||||
{
|
||||
"organization_id": 42,
|
||||
"platform_usage": {"price_per_minute": 0.01},
|
||||
"dograh_model": {"price_per_minute": 0.07},
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.services.mps_service_key_client.httpx.AsyncClient", FakeAsyncClient
|
||||
)
|
||||
monkeypatch.setattr("api.services.mps_service_key_client.DEPLOYMENT_MODE", "saas")
|
||||
monkeypatch.setattr(
|
||||
"api.services.mps_service_key_client.DOGRAH_MPS_SECRET_KEY", "mps-secret"
|
||||
)
|
||||
|
||||
client = MPSServiceKeyClient()
|
||||
|
||||
assert await client.get_billing_pricing(42) == {
|
||||
"organization_id": 42,
|
||||
"platform_usage": {"price_per_minute": 0.01},
|
||||
"dograh_model": {"price_per_minute": 0.07},
|
||||
}
|
||||
assert calls == [
|
||||
(
|
||||
"GET",
|
||||
f"{client.base_url}/api/v1/billing/accounts/42/pricing",
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"X-Secret-Key": "mps-secret",
|
||||
"X-Organization-Id": "42",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_credit_ledger_sends_page_and_limit(monkeypatch):
|
||||
calls = []
|
||||
|
|
|
|||
|
|
@ -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