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 = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue