mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
Merge remote-tracking branch 'origin/main' into fix/call-concurrency-limit
# Conflicts: # api/services/auth/depends.py # docs/api-reference/openapi.json # sdk/python/src/dograh_sdk/_generated_models.py
This commit is contained in:
commit
d3326d8fad
57 changed files with 1163 additions and 1354 deletions
|
|
@ -29,7 +29,7 @@ Contributor setup and service startup are documented in `docs/contribution/setup
|
|||
|
||||
## Environment Configuration
|
||||
|
||||
- `api/.env` - Backend environment variables. Source this when running diagnostic scripts or one-off services against the dev DB (e.g. `python -m api.services.admin_utils.local_exec`).
|
||||
- `api/.env` - Backend environment variables. Source this when running repo-owned backend scripts against the dev DB (e.g. `python -m scripts.dump_docs_openapi`).
|
||||
- `api/.env.test` - Test-only environment variables. Source this when running pytest so tests hit the test DB and never the dev/prod credentials in `api/.env`.
|
||||
- `ui/.env` - Frontend environment variables
|
||||
|
||||
|
|
@ -39,6 +39,6 @@ Typical invocation:
|
|||
# Tests
|
||||
source venv/bin/activate && set -a && source api/.env.test && set +a && python -m pytest api/tests/...
|
||||
|
||||
# Diagnostics / scripts
|
||||
source venv/bin/activate && set -a && source api/.env && set +a && python -m api.services.admin_utils.local_exec
|
||||
# Backend scripts
|
||||
source venv/bin/activate && set -a && source api/.env && set +a && python -m scripts.dump_docs_openapi
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import exists
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.future import select
|
||||
|
||||
|
|
@ -108,6 +109,21 @@ class OrganizationClient(BaseDBClient):
|
|||
return organization, was_created
|
||||
return organization, False
|
||||
|
||||
async def is_user_member_of_organization(
|
||||
self, user_id: int, organization_id: int
|
||||
) -> bool:
|
||||
"""Return True if the user belongs to the given organization."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(
|
||||
exists().where(
|
||||
(organization_users_association.c.user_id == user_id)
|
||||
& (organization_users_association.c.organization_id == organization_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
return bool(result.scalar())
|
||||
|
||||
async def add_user_to_organization(
|
||||
self, user_id: int, organization_id: int
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -402,7 +402,7 @@ async def search_chunks(
|
|||
)
|
||||
|
||||
# Manual search runs outside any workflow run, so resolve the MPS
|
||||
# correlation id here (mint only for orgs already on v2; never create one).
|
||||
# correlation id here.
|
||||
embedding_service = await build_embedding_service(
|
||||
db_client=db_client,
|
||||
provider=embeddings_provider,
|
||||
|
|
@ -411,8 +411,6 @@ async def search_chunks(
|
|||
base_url=embeddings_base_url,
|
||||
endpoint=embeddings_endpoint,
|
||||
api_version=embeddings_api_version,
|
||||
organization_id=user.selected_organization_id,
|
||||
created_by=str(user.provider_id),
|
||||
resolve_correlation=True,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ from api.schemas.telephony_phone_number import (
|
|||
ProviderSyncStatus,
|
||||
)
|
||||
from api.services.auth.depends import (
|
||||
_sync_posthog_organization_mps_billing_v2_status,
|
||||
get_user,
|
||||
get_user_with_selected_organization,
|
||||
)
|
||||
|
|
@ -391,22 +390,21 @@ async def migrate_model_configuration_v2(
|
|||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=exc.args[0])
|
||||
|
||||
billing_account_status = None
|
||||
if DEPLOYMENT_MODE != "oss":
|
||||
try:
|
||||
billing_account_status = await ensure_hosted_mps_billing_account_v2(
|
||||
await ensure_hosted_mps_billing_account_v2(
|
||||
organization_id,
|
||||
created_by=str(user.provider_id),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to initialize MPS billing v2 account for organization {}: {}",
|
||||
"Failed to initialize MPS billing account for organization {}: {}",
|
||||
organization_id,
|
||||
exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Failed to initialize MPS billing v2 account",
|
||||
detail="Failed to initialize MPS billing account",
|
||||
)
|
||||
|
||||
await upsert_organization_ai_model_configuration_v2(
|
||||
|
|
@ -417,14 +415,6 @@ async def migrate_model_configuration_v2(
|
|||
organization_id=organization_id,
|
||||
fallback_user_config=legacy,
|
||||
)
|
||||
if DEPLOYMENT_MODE != "oss":
|
||||
_sync_posthog_organization_mps_billing_v2_status(
|
||||
organization_id,
|
||||
uses_mps_billing_v2=bool(
|
||||
billing_account_status
|
||||
and billing_account_status.get("billing_mode") == "v2"
|
||||
),
|
||||
)
|
||||
return await _model_configuration_v2_response(
|
||||
user=user,
|
||||
configuration=configuration,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
|
@ -29,12 +29,6 @@ class CurrentUsageResponse(BaseModel):
|
|||
price_per_second_usd: Optional[float] = None
|
||||
|
||||
|
||||
class MPSCreditsResponse(BaseModel):
|
||||
total_credits_used: float
|
||||
remaining_credits: float
|
||||
total_quota: float
|
||||
|
||||
|
||||
class MPSCreditPurchaseUrlResponse(BaseModel):
|
||||
checkout_url: str
|
||||
|
||||
|
|
@ -69,7 +63,6 @@ class MPSCreditLedgerEntryResponse(BaseModel):
|
|||
|
||||
|
||||
class MPSBillingCreditsResponse(BaseModel):
|
||||
billing_version: Literal["legacy", "v2"]
|
||||
total_credits_used: float = 0.0
|
||||
remaining_credits: float = 0.0
|
||||
total_quota: float = 0.0
|
||||
|
|
@ -162,69 +155,13 @@ async def get_current_period_usage(user: UserModel = Depends(get_user)):
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/usage/mps-credits", response_model=MPSCreditsResponse)
|
||||
async def get_mps_credits(user: UserModel = Depends(get_user)):
|
||||
"""Get aggregated usage and quota from MPS.
|
||||
|
||||
OSS users: queries by provider_id (created_by).
|
||||
Hosted users: queries by organization_id.
|
||||
"""
|
||||
try:
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
usage = await mps_service_key_client.get_usage_by_created_by(
|
||||
str(user.provider_id)
|
||||
)
|
||||
else:
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
usage = await mps_service_key_client.get_usage_by_organization(
|
||||
user.selected_organization_id
|
||||
)
|
||||
|
||||
total_used = usage.get("total_credits_used", 0.0)
|
||||
total_remaining = usage.get("remaining_credits", 0.0)
|
||||
|
||||
return MPSCreditsResponse(
|
||||
total_credits_used=total_used,
|
||||
remaining_credits=total_remaining,
|
||||
total_quota=total_used + total_remaining,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch MPS credits: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
async def _get_mps_billing_account_status(
|
||||
user: UserModel, organization_id: int
|
||||
) -> Optional[dict]:
|
||||
return await mps_service_key_client.get_billing_account_status(
|
||||
organization_id=organization_id,
|
||||
created_by=str(user.provider_id),
|
||||
)
|
||||
|
||||
|
||||
def _is_mps_billing_v2(account: Optional[dict]) -> bool:
|
||||
return bool(account and account.get("billing_mode") == "v2")
|
||||
|
||||
|
||||
async def _legacy_mps_credits_response(user: UserModel) -> MPSBillingCreditsResponse:
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
usage = await mps_service_key_client.get_usage_by_created_by(
|
||||
str(user.provider_id)
|
||||
)
|
||||
else:
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
usage = await mps_service_key_client.get_usage_by_organization(
|
||||
user.selected_organization_id
|
||||
)
|
||||
async def _oss_mps_credits_response(user: UserModel) -> MPSBillingCreditsResponse:
|
||||
"""Aggregate per-key MPS credits for OSS deployments (no billing account)."""
|
||||
usage = await mps_service_key_client.get_usage_by_created_by(str(user.provider_id))
|
||||
|
||||
total_used = float(usage.get("total_credits_used", 0.0))
|
||||
total_remaining = float(usage.get("remaining_credits", 0.0))
|
||||
return MPSBillingCreditsResponse(
|
||||
billing_version="legacy",
|
||||
total_credits_used=total_used,
|
||||
remaining_credits=total_remaining,
|
||||
total_quota=total_used + total_remaining,
|
||||
|
|
@ -237,16 +174,15 @@ async def get_billing_credits(
|
|||
limit: int = Query(50, ge=1, le=100),
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""Return legacy MPS credits or paginated v2 billing ledger details for the org."""
|
||||
"""Return per-key MPS credits (OSS) or the org's paginated billing ledger."""
|
||||
try:
|
||||
if DEPLOYMENT_MODE == "oss" or not user.selected_organization_id:
|
||||
return await _legacy_mps_credits_response(user)
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
return await _oss_mps_credits_response(user)
|
||||
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
organization_id = user.selected_organization_id
|
||||
account_status = await _get_mps_billing_account_status(user, organization_id)
|
||||
if not _is_mps_billing_v2(account_status):
|
||||
return await _legacy_mps_credits_response(user)
|
||||
|
||||
ledger = await mps_service_key_client.get_credit_ledger(
|
||||
organization_id=organization_id,
|
||||
page=page,
|
||||
|
|
@ -287,7 +223,6 @@ async def get_billing_credits(
|
|||
total_debits = float(ledger["total_debits_credits"])
|
||||
|
||||
return MPSBillingCreditsResponse(
|
||||
billing_version="v2",
|
||||
total_credits_used=total_debits,
|
||||
remaining_credits=balance,
|
||||
total_quota=balance + total_debits,
|
||||
|
|
@ -350,7 +285,7 @@ async def get_billing_credits(
|
|||
async def create_mps_credit_purchase_url(
|
||||
user: UserModel = Depends(get_user_with_selected_organization),
|
||||
):
|
||||
"""Create a checkout URL for organizations using Dograh-managed MPS v2."""
|
||||
"""Create a checkout URL for purchasing organization credits."""
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
|
|
@ -359,14 +294,6 @@ async def create_mps_credit_purchase_url(
|
|||
|
||||
organization_id = user.selected_organization_id
|
||||
assert organization_id is not None
|
||||
account_status = await _get_mps_billing_account_status(user, organization_id)
|
||||
if not _is_mps_billing_v2(account_status):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"Credit purchases are available only for organizations using billing v2"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
session = await mps_service_key_client.create_credit_purchase_url(
|
||||
|
|
|
|||
|
|
@ -34,9 +34,6 @@ async def require_local_auth() -> None:
|
|||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
||||
POSTHOG_ORGANIZATION_USES_MPS_BILLING_V2_PROPERTY = "uses_mps_billing_v2"
|
||||
|
||||
|
||||
async def get_user(
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
|
||||
|
|
@ -193,7 +190,6 @@ def _sync_created_organization_to_posthog(
|
|||
organization,
|
||||
stack_user: dict | None = None,
|
||||
created_by_provider_id: str | None = None,
|
||||
uses_mps_billing_v2: bool | None = None,
|
||||
) -> None:
|
||||
"""Create/update the PostHog organization group for a newly-created org."""
|
||||
try:
|
||||
|
|
@ -209,10 +205,6 @@ def _sync_created_organization_to_posthog(
|
|||
}
|
||||
if created_by:
|
||||
properties["created_by_provider_id"] = created_by
|
||||
if uses_mps_billing_v2 is not None:
|
||||
properties[POSTHOG_ORGANIZATION_USES_MPS_BILLING_V2_PROPERTY] = (
|
||||
uses_mps_billing_v2
|
||||
)
|
||||
|
||||
group_identify(
|
||||
POSTHOG_ORGANIZATION_GROUP_TYPE,
|
||||
|
|
@ -231,50 +223,6 @@ def _sync_created_organization_to_posthog(
|
|||
logger.exception("Failed to sync created organization to PostHog")
|
||||
|
||||
|
||||
def _sync_posthog_organization_group_properties(
|
||||
*,
|
||||
organization,
|
||||
uses_mps_billing_v2: bool | None = None,
|
||||
) -> None:
|
||||
"""Update PostHog organization group properties without creating a person."""
|
||||
try:
|
||||
organization_id = int(organization.id)
|
||||
properties = {
|
||||
"organization_id": organization_id,
|
||||
"organization_provider_id": getattr(organization, "provider_id", None),
|
||||
"auth_provider": "stack",
|
||||
}
|
||||
if uses_mps_billing_v2 is not None:
|
||||
properties[POSTHOG_ORGANIZATION_USES_MPS_BILLING_V2_PROPERTY] = (
|
||||
uses_mps_billing_v2
|
||||
)
|
||||
|
||||
group_identify(
|
||||
POSTHOG_ORGANIZATION_GROUP_TYPE,
|
||||
str(organization_id),
|
||||
properties,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to sync organization group properties to PostHog")
|
||||
|
||||
|
||||
def _sync_posthog_organization_mps_billing_v2_status(
|
||||
organization_id: int,
|
||||
*,
|
||||
uses_mps_billing_v2: bool,
|
||||
) -> None:
|
||||
"""Update the PostHog organization group with current MPS billing status."""
|
||||
try:
|
||||
organization_id = int(organization_id)
|
||||
group_identify(
|
||||
POSTHOG_ORGANIZATION_GROUP_TYPE,
|
||||
str(organization_id),
|
||||
{POSTHOG_ORGANIZATION_USES_MPS_BILLING_V2_PROPERTY: uses_mps_billing_v2},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to sync organization billing status to PostHog")
|
||||
|
||||
|
||||
def _associate_user_with_posthog_organization(
|
||||
*,
|
||||
user: UserModel,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ class UserConfigurationValidator:
|
|||
ServiceProviders.RIME.value: self._check_rime_api_key,
|
||||
ServiceProviders.MINIMAX.value: self._check_minimax_api_key,
|
||||
ServiceProviders.SMALLEST.value: self._check_smallest_api_key,
|
||||
ServiceProviders.XAI.value: self._check_xai_api_key,
|
||||
}
|
||||
|
||||
async def validate(
|
||||
|
|
@ -376,6 +377,32 @@ class UserConfigurationValidator:
|
|||
def _check_grok_realtime_api_key(self, model: str, api_key: str) -> bool:
|
||||
return True
|
||||
|
||||
def _check_xai_api_key(self, model: str, api_key: str) -> bool:
|
||||
# Use the TTS voices endpoint as a best-effort smoke test. Some xAI keys
|
||||
# can be scoped in ways that block listing voices even though the key is
|
||||
# still intended for TTS usage, so only a clear auth failure rejects save.
|
||||
try:
|
||||
response = httpx.get(
|
||||
"https://api.x.ai/v1/tts/voices",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=10.0,
|
||||
)
|
||||
except httpx.RequestError:
|
||||
raise ValueError(
|
||||
"Could not connect to the xAI API. Please check your network "
|
||||
"connection and try again."
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
if response.status_code == 401:
|
||||
raise ValueError(
|
||||
"Invalid xAI API key. The key was rejected by the xAI API. "
|
||||
"Please check that your API key is correct and active. "
|
||||
"You can verify your keys at "
|
||||
"https://console.x.ai."
|
||||
)
|
||||
return True
|
||||
|
||||
def _check_ultravox_realtime_api_key(self, model: str, api_key: str) -> bool:
|
||||
return True
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ class ServiceProviders(str, Enum):
|
|||
GOOGLE_VERTEX_REALTIME = "google_vertex_realtime"
|
||||
AZURE_REALTIME = "azure_realtime"
|
||||
SMALLEST = "smallest"
|
||||
XAI = "xai"
|
||||
|
||||
|
||||
class BaseServiceConfiguration(BaseModel):
|
||||
|
|
@ -122,6 +123,7 @@ class BaseServiceConfiguration(BaseModel):
|
|||
ServiceProviders.AZURE_REALTIME,
|
||||
ServiceProviders.SARVAM,
|
||||
ServiceProviders.SMALLEST,
|
||||
ServiceProviders.XAI,
|
||||
]
|
||||
api_key: str | list[str]
|
||||
|
||||
|
|
@ -256,6 +258,7 @@ GOOGLE_VERTEX_REALTIME_PROVIDER_MODEL_CONFIG = provider_model_config(
|
|||
DEEPGRAM_PROVIDER_MODEL_CONFIG = provider_model_config("Deepgram")
|
||||
ELEVENLABS_PROVIDER_MODEL_CONFIG = provider_model_config("ElevenLabs")
|
||||
CARTESIA_PROVIDER_MODEL_CONFIG = provider_model_config("Cartesia")
|
||||
XAI_PROVIDER_MODEL_CONFIG = provider_model_config("xAI")
|
||||
INWORLD_PROVIDER_MODEL_CONFIG = provider_model_config(
|
||||
"Inworld",
|
||||
description=(
|
||||
|
|
@ -531,6 +534,7 @@ class HuggingFaceLLMConfiguration(BaseLLMConfiguration):
|
|||
MINIMAX_MODELS = [
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
"MiniMax-M3",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -1278,6 +1282,32 @@ class SmallestAITTSConfiguration(BaseTTSConfiguration):
|
|||
)
|
||||
|
||||
|
||||
XAI_TTS_VOICES = ["eve", "ara", "leo", "rex", "sal"]
|
||||
|
||||
|
||||
@register_tts
|
||||
class XAITTSConfiguration(BaseServiceConfiguration):
|
||||
model_config = XAI_PROVIDER_MODEL_CONFIG
|
||||
provider: Literal[ServiceProviders.XAI] = ServiceProviders.XAI
|
||||
voice: str = Field(
|
||||
default="eve",
|
||||
description="xAI voice persona.",
|
||||
json_schema_extra={"examples": XAI_TTS_VOICES, "allow_custom_input": True},
|
||||
)
|
||||
language: str = Field(
|
||||
default="en",
|
||||
description="BCP-47 language code for synthesis (e.g. 'en', 'fr', 'de'), or 'auto' for automatic language detection.",
|
||||
json_schema_extra={"allow_custom_input": True},
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def model(self) -> str:
|
||||
# xAI TTS has no separate model selector; the voice fully specifies the
|
||||
# output. A constant keeps the shared `.model` contract satisfied.
|
||||
return "xai-tts"
|
||||
|
||||
|
||||
TTSConfig = Annotated[
|
||||
Union[
|
||||
DeepgramTTSConfiguration,
|
||||
|
|
@ -1294,6 +1324,7 @@ TTSConfig = Annotated[
|
|||
MiniMaxTTSConfiguration,
|
||||
AzureSpeechTTSConfiguration,
|
||||
SmallestAITTSConfiguration,
|
||||
XAITTSConfiguration,
|
||||
],
|
||||
Field(discriminator="provider"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
|
||||
Centralizes the provider branching (Azure BYOK / Dograh-managed / OpenAI-compatible
|
||||
BYOK) that was previously duplicated across document ingestion, the search route,
|
||||
and the RAG tool, and resolves the MPS billing v2 protocol the same way the voice
|
||||
path does: attach it only for orgs already on v2, and never create a billing
|
||||
account to do so.
|
||||
and the RAG tool, and resolves the MPS correlation id the same way the voice
|
||||
path does.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
|
@ -24,46 +23,23 @@ DEFAULT_AZURE_API_VERSION = "2024-02-15-preview"
|
|||
|
||||
async def resolve_embedding_correlation_id(
|
||||
*,
|
||||
organization_id: Optional[int],
|
||||
service_key: Optional[str],
|
||||
created_by: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve an MPS correlation id for a managed embedding call made outside a run.
|
||||
"""Mint an MPS correlation id for a managed embedding call made outside a run.
|
||||
|
||||
Mirrors the voice path's gating:
|
||||
|
||||
- OSS deployments use a pasted hosted v2 key (v2 by definition), so mint
|
||||
directly via the bearer endpoint — matching ``_authorize_oss_managed_v2_correlation``.
|
||||
- Hosted/SaaS: read the org's billing mode (no side effects) and mint only when
|
||||
it is already v2. Minting for an already-v2 org is a no-op on the account.
|
||||
|
||||
Returns ``None`` when the call should be sent without the protocol; MPS accepts
|
||||
un-gated embedding calls from v1 orgs. Never creates a v2 billing account.
|
||||
Matches the voice path's ``_authorize_oss_managed_v2_correlation``: the
|
||||
correlation is minted via the bearer service-key endpoint, so it works for
|
||||
hosted orgs and OSS keys alike. Returns ``None`` when minting fails; MPS
|
||||
accepts un-correlated embedding calls.
|
||||
"""
|
||||
if not service_key:
|
||||
return None
|
||||
|
||||
# Imported lazily to avoid import-time cycles between the gen_ai and service
|
||||
# layers (matches the inline-import convention used elsewhere in the app).
|
||||
from api.constants import DEPLOYMENT_MODE
|
||||
from api.services.mps_service_key_client import mps_service_key_client
|
||||
|
||||
try:
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
minted = await mps_service_key_client.create_correlation_id(
|
||||
service_key=service_key
|
||||
)
|
||||
return minted.get("correlation_id")
|
||||
|
||||
if organization_id is None:
|
||||
return None
|
||||
|
||||
status = await mps_service_key_client.get_billing_account_status(
|
||||
organization_id, created_by=created_by
|
||||
)
|
||||
if not status or status.get("billing_mode") != "v2":
|
||||
return None
|
||||
|
||||
minted = await mps_service_key_client.create_correlation_id(
|
||||
service_key=service_key
|
||||
)
|
||||
|
|
@ -71,7 +47,7 @@ async def resolve_embedding_correlation_id(
|
|||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not resolve MPS correlation id for managed embeddings; "
|
||||
"sending without v2 protocol: {}",
|
||||
"sending without it: {}",
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
|
@ -87,8 +63,6 @@ async def build_embedding_service(
|
|||
endpoint: Optional[str] = None,
|
||||
api_version: Optional[str] = None,
|
||||
correlation_id: Optional[str] = None,
|
||||
organization_id: Optional[int] = None,
|
||||
created_by: Optional[str] = None,
|
||||
resolve_correlation: bool = False,
|
||||
) -> BaseEmbeddingService:
|
||||
"""Construct the right embedding service for a provider/config.
|
||||
|
|
@ -116,11 +90,7 @@ async def build_embedding_service(
|
|||
if provider == ServiceProviders.DOGRAH.value:
|
||||
cid = correlation_id
|
||||
if cid is None and resolve_correlation:
|
||||
cid = await resolve_embedding_correlation_id(
|
||||
organization_id=organization_id,
|
||||
service_key=api_key,
|
||||
created_by=created_by,
|
||||
)
|
||||
cid = await resolve_embedding_correlation_id(service_key=api_key)
|
||||
return DograhEmbeddingService(
|
||||
db_client=db_client,
|
||||
api_key=api_key,
|
||||
|
|
|
|||
|
|
@ -241,19 +241,12 @@ class MPSServiceKeyClient:
|
|||
)
|
||||
return False
|
||||
|
||||
async def check_service_key_usage(
|
||||
self,
|
||||
service_key: str,
|
||||
organization_id: Optional[int] = None,
|
||||
created_by: Optional[str] = None,
|
||||
) -> dict:
|
||||
async def check_service_key_usage(self, service_key: str) -> dict:
|
||||
"""
|
||||
Check the usage and quota of a service key.
|
||||
|
||||
Args:
|
||||
service_key: The service key to check usage for
|
||||
organization_id: Organization ID (for authenticated mode)
|
||||
created_by: User provider ID (for OSS mode)
|
||||
|
||||
Returns:
|
||||
Dictionary containing:
|
||||
|
|
@ -321,39 +314,6 @@ class MPSServiceKeyClient:
|
|||
response=response,
|
||||
)
|
||||
|
||||
async def get_usage_by_organization(self, organization_id: int) -> dict:
|
||||
"""
|
||||
Get aggregated usage for all service keys belonging to an organization (hosted mode).
|
||||
|
||||
Args:
|
||||
organization_id: The organization's ID
|
||||
|
||||
Returns:
|
||||
Dictionary containing total_credits_used and remaining_credits
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/v1/service-keys/usage/organization",
|
||||
json={"organization_id": organization_id},
|
||||
headers=self._get_headers(organization_id=organization_id),
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
"total_credits_used": data.get("total_credits_used", 0.0),
|
||||
"remaining_credits": data.get("remaining_credits", 0.0),
|
||||
}
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to get usage by organization: {response.status_code} - {response.text}"
|
||||
)
|
||||
raise httpx.HTTPStatusError(
|
||||
f"Failed to get usage by organization: {response.text}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
async def create_credit_purchase_url(
|
||||
self,
|
||||
organization_id: int,
|
||||
|
|
@ -422,34 +382,6 @@ class MPSServiceKeyClient:
|
|||
response=response,
|
||||
)
|
||||
|
||||
async def get_billing_account_status(
|
||||
self,
|
||||
organization_id: int,
|
||||
created_by: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Get an existing MPS v2 billing account without creating one."""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/v1/billing/accounts/{organization_id}/status",
|
||||
headers=self._get_headers(
|
||||
organization_id=organization_id,
|
||||
created_by=created_by,
|
||||
),
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
logger.error(
|
||||
"Failed to get MPS billing account status: "
|
||||
f"{response.status_code} - {response.text}"
|
||||
)
|
||||
raise httpx.HTTPStatusError(
|
||||
f"Failed to get MPS billing account status: {response.text}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
async def ensure_billing_account_v2(
|
||||
self,
|
||||
organization_id: int,
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ def register_event_handlers(
|
|||
pre_call_fetch_task: asyncio.Task | None = None,
|
||||
user_provider_id: str | None = None,
|
||||
integration_runtime_sessions: list[IntegrationRuntimeSession] | None = None,
|
||||
include_transcript_end_timestamps: bool = False,
|
||||
):
|
||||
"""Register all event handlers for transport and task events.
|
||||
|
||||
|
|
@ -386,7 +387,9 @@ def register_event_handlers(
|
|||
else:
|
||||
logger.debug("Bot audio buffer is empty, skipping upload")
|
||||
|
||||
transcript_text = in_memory_logs_buffer.generate_transcript_text()
|
||||
transcript_text = in_memory_logs_buffer.generate_transcript_text(
|
||||
include_end_timestamps=include_transcript_end_timestamps
|
||||
)
|
||||
if not transcript_text:
|
||||
logger.debug("No transcript events in logs buffer, skipping upload")
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,12 @@ class InMemoryLogsBuffer:
|
|||
self._turn_counter = 0
|
||||
self._current_node_id: Optional[str] = None
|
||||
self._current_node_name: Optional[str] = None
|
||||
self._user_speech_start_timestamp: Optional[str] = None
|
||||
self._user_speech_end_timestamp: Optional[str] = None
|
||||
self._user_speech_start_from_vad = False
|
||||
self._user_speech_end_from_vad = False
|
||||
self._bot_speech_start_timestamp: Optional[str] = None
|
||||
self._bot_speech_end_timestamp: Optional[str] = None
|
||||
|
||||
def set_current_node(self, node_id: str, node_name: str):
|
||||
"""Set the current node ID and name to be injected into subsequent events."""
|
||||
|
|
@ -123,11 +129,126 @@ class InMemoryLogsBuffer:
|
|||
"""Get the current node name."""
|
||||
return self._current_node_name
|
||||
|
||||
@staticmethod
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="milliseconds")
|
||||
|
||||
def mark_user_started_speaking(
|
||||
self, timestamp: Optional[str] = None, *, from_vad: bool = False
|
||||
):
|
||||
"""Record when the user started speaking for the current turn."""
|
||||
vad_interval_is_open = (
|
||||
self._user_speech_start_from_vad and self._user_speech_end_timestamp is None
|
||||
)
|
||||
if vad_interval_is_open and not from_vad:
|
||||
return
|
||||
|
||||
self._user_speech_start_timestamp = timestamp or self._now_iso()
|
||||
self._user_speech_end_timestamp = None
|
||||
self._user_speech_start_from_vad = from_vad
|
||||
self._user_speech_end_from_vad = False
|
||||
self._update_latest_payload_start_timestamp(
|
||||
RealtimeFeedbackType.USER_TRANSCRIPTION.value,
|
||||
self._user_speech_start_timestamp,
|
||||
require_final=True,
|
||||
)
|
||||
|
||||
def mark_user_stopped_speaking(
|
||||
self, timestamp: Optional[str] = None, *, from_vad: bool = False
|
||||
):
|
||||
"""Record when the user stopped speaking and update the latest user event."""
|
||||
if self._user_speech_end_from_vad and not from_vad:
|
||||
return
|
||||
|
||||
self._user_speech_end_timestamp = timestamp or self._now_iso()
|
||||
self._user_speech_end_from_vad = from_vad
|
||||
self._update_latest_payload_end_timestamp(
|
||||
RealtimeFeedbackType.USER_TRANSCRIPTION.value,
|
||||
self._user_speech_end_timestamp,
|
||||
require_final=True,
|
||||
)
|
||||
|
||||
def mark_bot_started_speaking(self, timestamp: Optional[str] = None):
|
||||
"""Record when the bot started speaking for the current assistant turn."""
|
||||
self._bot_speech_start_timestamp = timestamp or self._now_iso()
|
||||
self._bot_speech_end_timestamp = None
|
||||
self._update_latest_payload_start_timestamp(
|
||||
RealtimeFeedbackType.BOT_TEXT.value,
|
||||
self._bot_speech_start_timestamp,
|
||||
)
|
||||
|
||||
def mark_bot_stopped_speaking(self, timestamp: Optional[str] = None):
|
||||
"""Record when the bot stopped speaking and update the latest bot event."""
|
||||
self._bot_speech_end_timestamp = timestamp or self._now_iso()
|
||||
self._update_latest_payload_end_timestamp(
|
||||
RealtimeFeedbackType.BOT_TEXT.value,
|
||||
self._bot_speech_end_timestamp,
|
||||
)
|
||||
|
||||
def _find_latest_open_payload(
|
||||
self, event_type: str, *, require_final: bool = False
|
||||
) -> dict | None:
|
||||
for event in reversed(self._events):
|
||||
if event.get("type") != event_type:
|
||||
continue
|
||||
payload = event.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
if require_final and payload.get("final") is not True:
|
||||
continue
|
||||
if payload.get("end_timestamp"):
|
||||
continue
|
||||
return payload
|
||||
return None
|
||||
|
||||
def _update_latest_payload_start_timestamp(
|
||||
self, event_type: str, start_timestamp: str, *, require_final: bool = False
|
||||
):
|
||||
payload = self._find_latest_open_payload(
|
||||
event_type, require_final=require_final
|
||||
)
|
||||
if payload is not None:
|
||||
payload["timestamp"] = start_timestamp
|
||||
|
||||
def _update_latest_payload_end_timestamp(
|
||||
self, event_type: str, end_timestamp: str, *, require_final: bool = False
|
||||
):
|
||||
payload = self._find_latest_open_payload(
|
||||
event_type, require_final=require_final
|
||||
)
|
||||
if payload is not None:
|
||||
payload["end_timestamp"] = end_timestamp
|
||||
|
||||
def _event_with_speech_timestamps(self, event: dict) -> dict:
|
||||
event_type = event.get("type")
|
||||
payload = event.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
return event
|
||||
|
||||
payload_with_timestamps = dict(payload)
|
||||
if (
|
||||
event_type == RealtimeFeedbackType.USER_TRANSCRIPTION.value
|
||||
and payload.get("final") is True
|
||||
):
|
||||
if self._user_speech_start_timestamp:
|
||||
payload_with_timestamps["timestamp"] = self._user_speech_start_timestamp
|
||||
if self._user_speech_end_timestamp:
|
||||
payload_with_timestamps["end_timestamp"] = self._user_speech_end_timestamp
|
||||
elif event_type == RealtimeFeedbackType.BOT_TEXT.value:
|
||||
bot_interval_is_active = self._bot_speech_end_timestamp is None
|
||||
if bot_interval_is_active and self._bot_speech_start_timestamp:
|
||||
payload_with_timestamps["timestamp"] = self._bot_speech_start_timestamp
|
||||
|
||||
if payload_with_timestamps == payload:
|
||||
return event
|
||||
return {**event, "payload": payload_with_timestamps}
|
||||
|
||||
async def append(self, event: dict):
|
||||
"""Append a feedback event to the buffer with timestamp and current node."""
|
||||
event = self._event_with_speech_timestamps(event)
|
||||
timestamped_event = stamp_realtime_feedback_event(
|
||||
event,
|
||||
timestamp=datetime.now(UTC).isoformat(),
|
||||
timestamp=self._now_iso(),
|
||||
turn=self._turn_counter,
|
||||
node_id=self._current_node_id,
|
||||
node_name=self._current_node_name,
|
||||
|
|
@ -166,13 +287,15 @@ class InMemoryLogsBuffer:
|
|||
return True
|
||||
return False
|
||||
|
||||
def generate_transcript_text(self) -> str:
|
||||
def generate_transcript_text(self, *, include_end_timestamps: bool = False) -> str:
|
||||
"""Generate transcript text from logged events.
|
||||
|
||||
Filters for rtf-user-transcription (final) and rtf-bot-text events,
|
||||
formats them as '[timestamp] user/assistant: text\\n'.
|
||||
"""
|
||||
return _generate_transcript_text(self._sorted_events())
|
||||
return _generate_transcript_text(
|
||||
self._sorted_events(), include_end_timestamps=include_end_timestamps
|
||||
)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ def build_user_transcription_event(
|
|||
text: str,
|
||||
final: bool,
|
||||
timestamp: str | None = None,
|
||||
end_timestamp: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
|
|
@ -38,6 +39,8 @@ def build_user_transcription_event(
|
|||
}
|
||||
if timestamp is not None:
|
||||
payload["timestamp"] = timestamp
|
||||
if end_timestamp is not None:
|
||||
payload["end_timestamp"] = end_timestamp
|
||||
if user_id is not None:
|
||||
payload["user_id"] = user_id
|
||||
return {
|
||||
|
|
@ -50,10 +53,13 @@ def build_bot_text_event(
|
|||
*,
|
||||
text: str,
|
||||
timestamp: str | None = None,
|
||||
end_timestamp: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"text": text}
|
||||
if timestamp is not None:
|
||||
payload["timestamp"] = timestamp
|
||||
if end_timestamp is not None:
|
||||
payload["end_timestamp"] = end_timestamp
|
||||
return {
|
||||
"type": RealtimeFeedbackType.BOT_TEXT.value,
|
||||
"payload": payload,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ node changes.
|
|||
"""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Awaitable, Callable, Optional, Set
|
||||
|
||||
from loguru import logger
|
||||
|
|
@ -52,8 +53,12 @@ from pipecat.frames.frames import (
|
|||
TranscriptionFrame,
|
||||
TTSSpeakFrame,
|
||||
TTSTextFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
UserMuteStartedFrame,
|
||||
UserMuteStoppedFrame,
|
||||
VADUserStartedSpeakingFrame,
|
||||
VADUserStoppedSpeakingFrame,
|
||||
)
|
||||
from pipecat.metrics.metrics import TTFBMetricsData
|
||||
from pipecat.observers.base_observer import BaseObserver, FramePushed
|
||||
|
|
@ -62,6 +67,10 @@ from pipecat.transports.base_output import BaseOutputTransport
|
|||
from pipecat.utils.enums import RealtimeFeedbackType
|
||||
|
||||
|
||||
def _epoch_seconds_to_utc_iso(timestamp: float) -> str:
|
||||
return datetime.fromtimestamp(timestamp, UTC).isoformat(timespec="milliseconds")
|
||||
|
||||
|
||||
class RealtimeFeedbackObserver(BaseObserver):
|
||||
"""Observer that sends real-time events via WebSocket and persists final transcripts.
|
||||
|
||||
|
|
@ -138,13 +147,35 @@ class RealtimeFeedbackObserver(BaseObserver):
|
|||
return
|
||||
# Bot speaking state - WS only (ephemeral state signals, not persisted)
|
||||
elif isinstance(frame, BotStartedSpeakingFrame):
|
||||
if self._logs_buffer:
|
||||
self._logs_buffer.mark_bot_started_speaking()
|
||||
await self._send_ws(
|
||||
{"type": RealtimeFeedbackType.BOT_STARTED_SPEAKING.value, "payload": {}}
|
||||
)
|
||||
elif isinstance(frame, BotStoppedSpeakingFrame):
|
||||
if self._logs_buffer:
|
||||
self._logs_buffer.mark_bot_stopped_speaking()
|
||||
await self._send_ws(
|
||||
{"type": RealtimeFeedbackType.BOT_STOPPED_SPEAKING.value, "payload": {}}
|
||||
)
|
||||
elif isinstance(frame, UserStartedSpeakingFrame):
|
||||
if self._logs_buffer:
|
||||
self._logs_buffer.mark_user_started_speaking()
|
||||
elif isinstance(frame, UserStoppedSpeakingFrame):
|
||||
if self._logs_buffer:
|
||||
self._logs_buffer.mark_user_stopped_speaking()
|
||||
elif isinstance(frame, VADUserStartedSpeakingFrame):
|
||||
if self._logs_buffer:
|
||||
self._logs_buffer.mark_user_started_speaking(
|
||||
_epoch_seconds_to_utc_iso(frame.timestamp - frame.start_secs),
|
||||
from_vad=True,
|
||||
)
|
||||
elif isinstance(frame, VADUserStoppedSpeakingFrame):
|
||||
if self._logs_buffer:
|
||||
self._logs_buffer.mark_user_stopped_speaking(
|
||||
_epoch_seconds_to_utc_iso(frame.timestamp - frame.stop_secs),
|
||||
from_vad=True,
|
||||
)
|
||||
# User mute state - WS only (ephemeral state signals, not persisted)
|
||||
elif isinstance(frame, UserMuteStartedFrame):
|
||||
await self._send_ws(
|
||||
|
|
@ -314,6 +345,7 @@ def register_turn_log_handlers(
|
|||
text=message.content,
|
||||
final=True,
|
||||
timestamp=message.timestamp,
|
||||
end_timestamp=getattr(message, "end_timestamp", None),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
@ -327,6 +359,7 @@ def register_turn_log_handlers(
|
|||
build_bot_text_event(
|
||||
text=message.content,
|
||||
timestamp=message.timestamp,
|
||||
end_timestamp=getattr(message, "end_timestamp", None),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -557,6 +557,10 @@ async def _run_pipeline_impl(
|
|||
max_call_duration_seconds = DEFAULT_MAX_CALL_DURATION_SECONDS
|
||||
max_user_idle_timeout = DEFAULT_MAX_USER_IDLE_TIMEOUT_SECONDS
|
||||
keyterms = None # Dictionary words for STT boosting
|
||||
transcript_config = run_configs.get("transcript_configuration") or {}
|
||||
include_transcript_end_timestamps = bool(
|
||||
transcript_config.get("include_end_timestamps", False)
|
||||
)
|
||||
|
||||
if run_configs:
|
||||
if "max_call_duration" in run_configs:
|
||||
|
|
@ -1057,6 +1061,7 @@ async def _run_pipeline_impl(
|
|||
pre_call_fetch_task=pre_call_fetch_task,
|
||||
user_provider_id=user_provider_id,
|
||||
integration_runtime_sessions=integration_runtime_sessions,
|
||||
include_transcript_end_timestamps=include_transcript_end_timestamps,
|
||||
)
|
||||
|
||||
register_audio_data_handler(audio_buffer, workflow_run_id, in_memory_audio_buffer)
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ from pipecat.services.speechmatics.stt import (
|
|||
SpeechmaticsSTTService,
|
||||
SpeechmaticsSTTSettings,
|
||||
)
|
||||
from pipecat.services.xai.tts import XAIHttpTTSService, XAITTSSettings
|
||||
from pipecat.transcriptions.language import Language
|
||||
from pipecat.utils.text.xml_function_tag_filter import XMLFunctionTagFilter
|
||||
|
||||
|
|
@ -740,6 +741,28 @@ def create_tts_service(
|
|||
skip_aggregator_types=["recording_router", "recording"],
|
||||
silence_time_s=1.0,
|
||||
)
|
||||
elif user_config.tts.provider == ServiceProviders.XAI.value:
|
||||
voice = getattr(user_config.tts, "voice", None) or "eve"
|
||||
language_code = getattr(user_config.tts, "language", None) or "en"
|
||||
if language_code.lower() == "auto":
|
||||
pipecat_language = "auto"
|
||||
else:
|
||||
try:
|
||||
pipecat_language = Language(language_code)
|
||||
except ValueError:
|
||||
pipecat_language = Language.EN
|
||||
return XAIHttpTTSService(
|
||||
api_key=user_config.tts.api_key,
|
||||
sample_rate=audio_config.transport_out_sample_rate,
|
||||
encoding="pcm",
|
||||
settings=XAITTSSettings(
|
||||
voice=voice,
|
||||
language=pipecat_language,
|
||||
),
|
||||
text_filters=[xml_function_tag_filter],
|
||||
skip_aggregator_types=["recording_router", "recording"],
|
||||
silence_time_s=1.0,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid TTS provider {user_config.tts.provider}"
|
||||
|
|
|
|||
|
|
@ -25,13 +25,13 @@ from api.services.mps_service_key_client import mps_service_key_client
|
|||
|
||||
MINIMUM_DOGRAH_CREDITS_FOR_CALL = 0.10
|
||||
|
||||
LEGACY_QUOTA_EXCEEDED_MESSAGE = (
|
||||
OSS_QUOTA_EXCEEDED_MESSAGE = (
|
||||
"You have exhausted your trial credits. "
|
||||
"Please email founders@dograh.com for additional Dograh credits "
|
||||
"or change providers in Models configurations."
|
||||
"Please sign up on app.dograh.com to create a "
|
||||
"new service key and set up in your model configurations."
|
||||
)
|
||||
|
||||
BILLING_V2_QUOTA_EXCEEDED_MESSAGE = (
|
||||
HOSTED_QUOTA_EXCEEDED_MESSAGE = (
|
||||
"You have exhausted your Dograh credits. "
|
||||
"Please purchase more credits from /billing "
|
||||
"or change providers in Models configurations."
|
||||
|
|
@ -60,19 +60,19 @@ def _safe_float(value: Any, default: float = 0.0) -> float:
|
|||
return default
|
||||
|
||||
|
||||
def _insufficient_billing_v2_quota_result() -> QuotaCheckResult:
|
||||
def _insufficient_hosted_quota_result() -> QuotaCheckResult:
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="insufficient_credits",
|
||||
error_message=BILLING_V2_QUOTA_EXCEEDED_MESSAGE,
|
||||
error_message=HOSTED_QUOTA_EXCEEDED_MESSAGE,
|
||||
)
|
||||
|
||||
|
||||
def _insufficient_legacy_quota_result() -> QuotaCheckResult:
|
||||
def _insufficient_oss_quota_result() -> QuotaCheckResult:
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="quota_exceeded",
|
||||
error_message=LEGACY_QUOTA_EXCEEDED_MESSAGE,
|
||||
error_message=OSS_QUOTA_EXCEEDED_MESSAGE,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -157,10 +157,10 @@ async def _authorize_hosted_workflow_run_start(
|
|||
workflow_id: int | None,
|
||||
workflow_run_id: int | None,
|
||||
user_config: Any,
|
||||
) -> tuple[QuotaCheckResult, bool]:
|
||||
"""Authorize hosted v2 billing and return whether MPS handled enforcement."""
|
||||
if DEPLOYMENT_MODE == "oss" or organization_id is None:
|
||||
return QuotaCheckResult(has_quota=True), False
|
||||
) -> QuotaCheckResult:
|
||||
"""Authorize a hosted workflow run against the org's MPS billing account."""
|
||||
if organization_id is None:
|
||||
return QuotaCheckResult(has_quota=True)
|
||||
|
||||
requires_correlation = bool(
|
||||
workflow_run_id and uses_managed_model_services_v2(user_config)
|
||||
|
|
@ -169,16 +169,13 @@ async def _authorize_hosted_workflow_run_start(
|
|||
get_dograh_service_api_key(user_config) if requires_correlation else None
|
||||
)
|
||||
if requires_correlation and not service_key:
|
||||
return (
|
||||
QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="invalid_service_key",
|
||||
error_message=(
|
||||
"You have invalid keys in your model configuration. "
|
||||
"Please validate the service keys."
|
||||
),
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="invalid_service_key",
|
||||
error_message=(
|
||||
"You have invalid keys in your model configuration. "
|
||||
"Please validate the service keys."
|
||||
),
|
||||
True,
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -205,38 +202,28 @@ async def _authorize_hosted_workflow_run_start(
|
|||
e,
|
||||
)
|
||||
if _is_service_key_org_mismatch_error(e):
|
||||
return (
|
||||
QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="service_key_org_mismatch",
|
||||
error_message=SERVICE_TOKEN_ORG_MISMATCH_MESSAGE,
|
||||
),
|
||||
True,
|
||||
)
|
||||
return (
|
||||
QuotaCheckResult(
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="quota_check_failed",
|
||||
error_message="Could not verify Dograh credits. Please try again.",
|
||||
),
|
||||
True,
|
||||
error_code="service_key_org_mismatch",
|
||||
error_message=SERVICE_TOKEN_ORG_MISMATCH_MESSAGE,
|
||||
)
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="quota_check_failed",
|
||||
error_message="Could not verify Dograh credits. Please try again.",
|
||||
)
|
||||
|
||||
billing_mode = authorization.get("billing_mode")
|
||||
if billing_mode != "v2":
|
||||
return QuotaCheckResult(has_quota=True), False
|
||||
|
||||
remaining = _safe_float(authorization.get("remaining_credits"))
|
||||
if (
|
||||
not authorization.get("allowed", False)
|
||||
or remaining < MINIMUM_DOGRAH_CREDITS_FOR_CALL
|
||||
):
|
||||
logger.warning(
|
||||
"Insufficient Dograh billing v2 credits for org {}: {:.2f} credits remaining",
|
||||
"Insufficient Dograh credits for org {}: {:.2f} credits remaining",
|
||||
organization_id,
|
||||
remaining,
|
||||
)
|
||||
return _insufficient_billing_v2_quota_result(), True
|
||||
return _insufficient_hosted_quota_result()
|
||||
|
||||
try:
|
||||
await _store_run_correlation_id(
|
||||
|
|
@ -249,35 +236,27 @@ async def _authorize_hosted_workflow_run_start(
|
|||
workflow_run_id,
|
||||
e,
|
||||
)
|
||||
return (
|
||||
QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="quota_check_failed",
|
||||
error_message="Could not verify Dograh credits. Please try again.",
|
||||
),
|
||||
True,
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="quota_check_failed",
|
||||
error_message="Could not verify Dograh credits. Please try again.",
|
||||
)
|
||||
logger.info(
|
||||
"Dograh billing v2 run authorization passed for org {}: {:.2f} credits remaining",
|
||||
"Dograh run authorization passed for org {}: {:.2f} credits remaining",
|
||||
organization_id,
|
||||
remaining,
|
||||
)
|
||||
return QuotaCheckResult(has_quota=True), True
|
||||
return QuotaCheckResult(has_quota=True)
|
||||
|
||||
|
||||
async def _authorize_legacy_dograh_keys(
|
||||
async def _authorize_oss_dograh_keys(
|
||||
*,
|
||||
dograh_api_keys: set[str],
|
||||
organization_id: int | None,
|
||||
workflow_owner: UserModel,
|
||||
) -> QuotaCheckResult:
|
||||
"""Check per-key MPS credits for OSS deployments before a run starts."""
|
||||
for api_key in dograh_api_keys:
|
||||
try:
|
||||
usage = await mps_service_key_client.check_service_key_usage(
|
||||
api_key,
|
||||
organization_id=organization_id,
|
||||
created_by=workflow_owner.provider_id,
|
||||
)
|
||||
usage = await mps_service_key_client.check_service_key_usage(api_key)
|
||||
remaining = usage.get("remaining_credits", 0.0)
|
||||
|
||||
# Require at least $0.10 for a short call
|
||||
|
|
@ -286,7 +265,7 @@ async def _authorize_legacy_dograh_keys(
|
|||
f"Insufficient Dograh credits for key ...{api_key[-8:]}: "
|
||||
f"${remaining:.2f} remaining"
|
||||
)
|
||||
return _insufficient_legacy_quota_result()
|
||||
return _insufficient_oss_quota_result()
|
||||
|
||||
logger.info(
|
||||
f"Dograh quota check passed for key ...{api_key[-8:]}: "
|
||||
|
|
@ -363,9 +342,9 @@ async def authorize_workflow_run_start(
|
|||
) -> QuotaCheckResult:
|
||||
"""Authorize a workflow run before any billable call/text runtime starts.
|
||||
|
||||
The workflow organization is the billing subject for hosted v2. The workflow
|
||||
owner is used only to resolve the effective model configuration and legacy
|
||||
service-key metadata.
|
||||
The workflow organization is the billing subject for hosted deployments.
|
||||
OSS deployments are billed per service key instead. The workflow owner is
|
||||
used only to resolve the effective model configuration.
|
||||
"""
|
||||
try:
|
||||
workflow = await db_client.get_workflow_by_id(workflow_id)
|
||||
|
|
@ -376,19 +355,38 @@ async def authorize_workflow_run_start(
|
|||
error_message="Workflow not found",
|
||||
)
|
||||
|
||||
actor_org_id = getattr(actor_user, "selected_organization_id", None)
|
||||
if actor_org_id is not None and actor_org_id != workflow.organization_id:
|
||||
logger.warning(
|
||||
"Workflow start authorization denied: actor org {} does not match workflow {} org {}",
|
||||
actor_org_id,
|
||||
workflow_id,
|
||||
workflow.organization_id,
|
||||
)
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="workflow_not_found",
|
||||
error_message="Workflow not found",
|
||||
)
|
||||
actor_id = getattr(actor_user, "id", None)
|
||||
if actor_id is not None and workflow.organization_id is not None:
|
||||
try:
|
||||
is_member = await db_client.is_user_member_of_organization(
|
||||
user_id=actor_id,
|
||||
organization_id=workflow.organization_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Workflow start authorization denied: failed to validate actor {} membership for workflow {} org {}: {}",
|
||||
actor_id,
|
||||
workflow_id,
|
||||
workflow.organization_id,
|
||||
e,
|
||||
)
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="workflow_not_found",
|
||||
error_message="Workflow not found",
|
||||
)
|
||||
if not is_member:
|
||||
logger.warning(
|
||||
"Workflow start authorization denied: actor {} is not a member of workflow {} org {}",
|
||||
actor_id,
|
||||
workflow_id,
|
||||
workflow.organization_id,
|
||||
)
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="workflow_not_found",
|
||||
error_message="Workflow not found",
|
||||
)
|
||||
|
||||
workflow_owner = await db_client.get_user_by_id(workflow.user_id)
|
||||
if not workflow_owner:
|
||||
|
|
@ -405,38 +403,27 @@ async def authorize_workflow_run_start(
|
|||
)
|
||||
|
||||
if DEPLOYMENT_MODE != "oss":
|
||||
hosted_result, hosted_enforced = await _authorize_hosted_workflow_run_start(
|
||||
return await _authorize_hosted_workflow_run_start(
|
||||
workflow_owner=workflow_owner,
|
||||
organization_id=workflow.organization_id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
user_config=user_config,
|
||||
)
|
||||
if hosted_enforced or not hosted_result.has_quota:
|
||||
return hosted_result
|
||||
|
||||
dograh_api_keys = _dograh_api_keys(user_config)
|
||||
if not dograh_api_keys:
|
||||
return QuotaCheckResult(has_quota=True)
|
||||
|
||||
legacy_result = await _authorize_legacy_dograh_keys(
|
||||
dograh_api_keys=dograh_api_keys,
|
||||
organization_id=(
|
||||
None if DEPLOYMENT_MODE == "oss" else workflow.organization_id
|
||||
),
|
||||
workflow_owner=workflow_owner,
|
||||
)
|
||||
if not legacy_result.has_quota:
|
||||
return legacy_result
|
||||
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
return await _authorize_oss_managed_v2_correlation(
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
user_config=user_config,
|
||||
if dograh_api_keys:
|
||||
oss_result = await _authorize_oss_dograh_keys(
|
||||
dograh_api_keys=dograh_api_keys,
|
||||
)
|
||||
if not oss_result.has_quota:
|
||||
return oss_result
|
||||
|
||||
return QuotaCheckResult(has_quota=True)
|
||||
return await _authorize_oss_managed_v2_correlation(
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
user_config=user_config,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during quota check: {str(e)}")
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
"""Rule-based audit of a workflow definition's nodes + edges.
|
||||
|
||||
Pure, dependency-free helpers derived from `NodeSpec.graph_constraints`.
|
||||
Lives in tracked code so the regression tests in
|
||||
`test_workflow_graph_constraints.py` can pin it; the admin cleanup
|
||||
script in `api/services/admin_utils/local_exec.py` is the production
|
||||
consumer.
|
||||
Lives in tracked code so `test_workflow_graph_constraints.py` can pin the
|
||||
verdicts that one-off cleanup tooling needs to share with runtime validation.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
|
|
|
|||
|
|
@ -266,8 +266,7 @@ async def _perform_retrieval(
|
|||
)
|
||||
|
||||
# Search runs inside a workflow run: reuse the run's MPS correlation
|
||||
# id (present only for v2 orgs; None otherwise → sent without the
|
||||
# protocol). The Dograh-managed path forwards it via request metadata.
|
||||
# id. The Dograh-managed path forwards it via request metadata.
|
||||
embedding_service = await build_embedding_service(
|
||||
db_client=db_client,
|
||||
provider=embeddings_provider,
|
||||
|
|
|
|||
|
|
@ -32,13 +32,6 @@ def _duration_seconds_from_usage_info(workflow_run) -> float | None:
|
|||
return duration_seconds if duration_seconds > 0 else None
|
||||
|
||||
|
||||
async def _organization_uses_mps_billing_v2(organization_id: int) -> bool:
|
||||
account = await mps_service_key_client.get_billing_account_status(
|
||||
organization_id=organization_id
|
||||
)
|
||||
return bool(account and account.get("billing_mode") == "v2")
|
||||
|
||||
|
||||
def _is_usage_not_ready_error(exc: Exception) -> bool:
|
||||
response = getattr(exc, "response", None)
|
||||
if getattr(response, "status_code", None) != 409:
|
||||
|
|
@ -79,12 +72,6 @@ async def report_workflow_run_platform_usage(workflow_run) -> None:
|
|||
return
|
||||
|
||||
try:
|
||||
if not await _organization_uses_mps_billing_v2(organization_id):
|
||||
logger.debug(
|
||||
"Not reporting platform usage since org not using mps billing v2"
|
||||
)
|
||||
return
|
||||
|
||||
result = await mps_service_key_client.report_platform_usage(
|
||||
organization_id=organization_id,
|
||||
correlation_id=correlation_id,
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ async def process_knowledge_base_document(
|
|||
return
|
||||
|
||||
# Ingestion runs outside any workflow run, so resolve the MPS correlation
|
||||
# id here (mint only for orgs already on v2; never create an account).
|
||||
# id here.
|
||||
embedding_service = await build_embedding_service(
|
||||
db_client=db_client,
|
||||
provider=embeddings_provider,
|
||||
|
|
@ -224,8 +224,6 @@ async def process_knowledge_base_document(
|
|||
base_url=embeddings_base_url,
|
||||
endpoint=embeddings_endpoint,
|
||||
api_version=embeddings_api_version,
|
||||
organization_id=organization_id,
|
||||
created_by=created_by_provider_id,
|
||||
resolve_correlation=True,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,111 +1,31 @@
|
|||
import asyncio
|
||||
import os
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
from api.services.workflow_run_artifacts import upload_workflow_run_artifacts
|
||||
from api.services.workflow_run_billing import (
|
||||
report_completed_workflow_run_platform_usage,
|
||||
)
|
||||
from api.tasks.run_integrations import run_integrations_post_workflow_run
|
||||
|
||||
|
||||
def _read_and_remove_temp_file(temp_file_path: str | None, label: str) -> bytes | None:
|
||||
if not temp_file_path:
|
||||
return None
|
||||
try:
|
||||
if not os.path.exists(temp_file_path):
|
||||
logger.warning(f"{label} temp file not found: {temp_file_path}")
|
||||
return None
|
||||
with open(temp_file_path, "rb") as f:
|
||||
data = f.read()
|
||||
os.remove(temp_file_path)
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading legacy {label} temp file {temp_file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _upload_legacy_temp_artifacts(
|
||||
workflow_run_id: int,
|
||||
audio_temp_path: str | None,
|
||||
transcript_temp_path: str | None,
|
||||
user_audio_temp_path: str | None,
|
||||
bot_audio_temp_path: str | None,
|
||||
) -> None:
|
||||
"""Handle jobs enqueued before uploads moved into the pipeline process.
|
||||
|
||||
Pre-refactor web workers passed local temp-file paths; upload them if this
|
||||
worker can still see the files (same host / shared volume).
|
||||
|
||||
Deprecated: remove once no pre-refactor jobs can remain in the queue.
|
||||
"""
|
||||
logger.info(
|
||||
f"Processing legacy temp-file artifacts for workflow run {workflow_run_id}"
|
||||
)
|
||||
transcript_bytes = await asyncio.to_thread(
|
||||
_read_and_remove_temp_file, transcript_temp_path, "transcript"
|
||||
)
|
||||
await upload_workflow_run_artifacts(
|
||||
workflow_run_id,
|
||||
mixed_audio_wav=await asyncio.to_thread(
|
||||
_read_and_remove_temp_file, audio_temp_path, "mixed audio"
|
||||
),
|
||||
user_audio_wav=await asyncio.to_thread(
|
||||
_read_and_remove_temp_file, user_audio_temp_path, "user audio"
|
||||
),
|
||||
bot_audio_wav=await asyncio.to_thread(
|
||||
_read_and_remove_temp_file, bot_audio_temp_path, "bot audio"
|
||||
),
|
||||
transcript_text=(
|
||||
transcript_bytes.decode("utf-8") if transcript_bytes else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def process_workflow_completion(
|
||||
_ctx,
|
||||
workflow_run_id: int,
|
||||
audio_temp_path: str | None = None,
|
||||
transcript_temp_path: str | None = None,
|
||||
user_audio_temp_path: str | None = None,
|
||||
bot_audio_temp_path: str | None = None,
|
||||
):
|
||||
"""Process workflow completion: run integrations and report billing.
|
||||
|
||||
Recording/transcript uploads happen in the pipeline process itself
|
||||
(api/services/workflow_run_artifacts.py) before this job is enqueued,
|
||||
so this task needs no shared filesystem with the web tier. The temp-path
|
||||
arguments only exist for jobs enqueued by pre-refactor web workers.
|
||||
so this task needs no shared filesystem with the web tier.
|
||||
|
||||
Args:
|
||||
_ctx: ARQ context (unused)
|
||||
workflow_run_id: The workflow run ID
|
||||
audio_temp_path: Deprecated, pre-refactor jobs only
|
||||
transcript_temp_path: Deprecated, pre-refactor jobs only
|
||||
user_audio_temp_path: Deprecated, pre-refactor jobs only
|
||||
bot_audio_temp_path: Deprecated, pre-refactor jobs only
|
||||
"""
|
||||
run_id = str(workflow_run_id)
|
||||
set_current_run_id(run_id)
|
||||
|
||||
logger.info(f"Processing workflow completion for run {workflow_run_id}")
|
||||
|
||||
if (
|
||||
audio_temp_path
|
||||
or transcript_temp_path
|
||||
or user_audio_temp_path
|
||||
or bot_audio_temp_path
|
||||
):
|
||||
await _upload_legacy_temp_artifacts(
|
||||
workflow_run_id,
|
||||
audio_temp_path,
|
||||
transcript_temp_path,
|
||||
user_audio_temp_path,
|
||||
bot_audio_temp_path,
|
||||
)
|
||||
|
||||
# Run integrations including QA analysis (after uploads are complete)
|
||||
try:
|
||||
await run_integrations_post_workflow_run(_ctx, workflow_run_id)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
|
@ -442,7 +442,6 @@ async def test_migrate_model_configuration_v2_initializes_hosted_mps_billing(
|
|||
ensure_billing = AsyncMock(return_value={"billing_mode": "v2"})
|
||||
upsert = AsyncMock()
|
||||
migrate_workflows = AsyncMock()
|
||||
sync_posthog_billing = Mock()
|
||||
|
||||
monkeypatch.setattr(organization_routes, "DEPLOYMENT_MODE", "saas")
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -480,11 +479,6 @@ async def test_migrate_model_configuration_v2_initializes_hosted_mps_billing(
|
|||
"_model_configuration_v2_response",
|
||||
AsyncMock(return_value=expected_response),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
organization_routes,
|
||||
"_sync_posthog_organization_mps_billing_v2_status",
|
||||
sync_posthog_billing,
|
||||
)
|
||||
|
||||
user = SimpleNamespace(
|
||||
id=7,
|
||||
|
|
@ -503,5 +497,4 @@ async def test_migrate_model_configuration_v2_initializes_hosted_mps_billing(
|
|||
organization_id=42,
|
||||
fallback_user_config=legacy,
|
||||
)
|
||||
sync_posthog_billing.assert_called_once_with(42, uses_mps_billing_v2=True)
|
||||
assert response == expected_response
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ def test_associate_user_with_posthog_org_supports_backfill_arguments(monkeypatch
|
|||
assert "backfilled" not in capture_kwargs["properties"]
|
||||
|
||||
|
||||
def test_sync_created_organization_to_posthog_supports_billing_flag(monkeypatch):
|
||||
def test_sync_created_organization_to_posthog_with_provider_id(monkeypatch):
|
||||
organization = SimpleNamespace(id=42, provider_id="team-1")
|
||||
group_calls = []
|
||||
capture_calls = []
|
||||
|
|
@ -228,11 +228,9 @@ def test_sync_created_organization_to_posthog_supports_billing_flag(monkeypatch)
|
|||
auth_depends._sync_created_organization_to_posthog(
|
||||
organization=organization,
|
||||
created_by_provider_id="stack-user-1",
|
||||
uses_mps_billing_v2=True,
|
||||
)
|
||||
|
||||
_, group_kwargs = group_calls[0]
|
||||
group_args, _ = group_calls[0]
|
||||
group_args, group_kwargs = group_calls[0]
|
||||
assert group_args == (
|
||||
"organization",
|
||||
"42",
|
||||
|
|
@ -241,69 +239,9 @@ def test_sync_created_organization_to_posthog_supports_billing_flag(monkeypatch)
|
|||
"organization_provider_id": "team-1",
|
||||
"auth_provider": "stack",
|
||||
"created_by_provider_id": "stack-user-1",
|
||||
"uses_mps_billing_v2": True,
|
||||
},
|
||||
)
|
||||
assert group_kwargs == {"distinct_id": "stack-user-1"}
|
||||
|
||||
_, capture_kwargs = capture_calls[0]
|
||||
assert capture_kwargs["distinct_id"] == "stack-user-1"
|
||||
assert capture_kwargs["properties"]["uses_mps_billing_v2"] is True
|
||||
|
||||
|
||||
def test_sync_posthog_organization_group_properties_has_no_distinct_id(monkeypatch):
|
||||
organization = SimpleNamespace(id=42, provider_id="team-1")
|
||||
group_calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_depends,
|
||||
"group_identify",
|
||||
lambda *args, **kwargs: group_calls.append((args, kwargs)),
|
||||
)
|
||||
|
||||
auth_depends._sync_posthog_organization_group_properties(
|
||||
organization=organization,
|
||||
uses_mps_billing_v2=True,
|
||||
)
|
||||
|
||||
assert group_calls == [
|
||||
(
|
||||
(
|
||||
"organization",
|
||||
"42",
|
||||
{
|
||||
"organization_id": 42,
|
||||
"organization_provider_id": "team-1",
|
||||
"auth_provider": "stack",
|
||||
"uses_mps_billing_v2": True,
|
||||
},
|
||||
),
|
||||
{},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_sync_posthog_organization_mps_billing_v2_status(monkeypatch):
|
||||
group_calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_depends,
|
||||
"group_identify",
|
||||
lambda *args, **kwargs: group_calls.append((args, kwargs)),
|
||||
)
|
||||
|
||||
auth_depends._sync_posthog_organization_mps_billing_v2_status(
|
||||
42,
|
||||
uses_mps_billing_v2=True,
|
||||
)
|
||||
|
||||
assert group_calls == [
|
||||
(
|
||||
(
|
||||
"organization",
|
||||
"42",
|
||||
{"uses_mps_billing_v2": True},
|
||||
),
|
||||
{},
|
||||
)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -49,114 +49,52 @@ async def test_dograh_embedding_sends_plain_without_correlation():
|
|||
await service.embed_texts(["hello"])
|
||||
|
||||
create.assert_awaited_once()
|
||||
# No correlation id (e.g. a v1 org) → no MPS metadata; MPS accepts plain calls.
|
||||
# No correlation id → no MPS metadata; MPS accepts plain calls.
|
||||
assert "extra_body" not in create.await_args.kwargs
|
||||
|
||||
|
||||
def _fake_mps_client(*, status_return=None, minted="minted"):
|
||||
def _fake_mps_client(*, minted="minted"):
|
||||
return SimpleNamespace(
|
||||
get_billing_account_status=AsyncMock(return_value=status_return),
|
||||
create_correlation_id=AsyncMock(return_value={"correlation_id": minted}),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_oss_mints_directly(monkeypatch):
|
||||
async def test_resolve_correlation_mints_via_service_key(monkeypatch):
|
||||
fake = _fake_mps_client()
|
||||
monkeypatch.setattr(
|
||||
"api.services.mps_service_key_client.mps_service_key_client", fake
|
||||
)
|
||||
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "oss")
|
||||
|
||||
result = await resolve_embedding_correlation_id(
|
||||
organization_id=None, service_key="sk-mps"
|
||||
)
|
||||
result = await resolve_embedding_correlation_id(service_key="sk-mps")
|
||||
|
||||
assert result == "minted"
|
||||
fake.create_correlation_id.assert_awaited_once_with(service_key="sk-mps")
|
||||
fake.get_billing_account_status.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_hosted_v2_mints(monkeypatch):
|
||||
fake = _fake_mps_client(status_return={"billing_mode": "v2"})
|
||||
monkeypatch.setattr(
|
||||
"api.services.mps_service_key_client.mps_service_key_client", fake
|
||||
)
|
||||
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
|
||||
|
||||
result = await resolve_embedding_correlation_id(
|
||||
organization_id=42, service_key="sk-mps", created_by="user-1"
|
||||
)
|
||||
|
||||
assert result == "minted"
|
||||
fake.get_billing_account_status.assert_awaited_once_with(42, created_by="user-1")
|
||||
fake.create_correlation_id.assert_awaited_once_with(service_key="sk-mps")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_hosted_v1_returns_none_without_minting(monkeypatch):
|
||||
fake = _fake_mps_client(status_return={"billing_mode": "v1"})
|
||||
monkeypatch.setattr(
|
||||
"api.services.mps_service_key_client.mps_service_key_client", fake
|
||||
)
|
||||
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
|
||||
|
||||
result = await resolve_embedding_correlation_id(
|
||||
organization_id=42, service_key="sk-mps"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
fake.create_correlation_id.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_hosted_no_account_returns_none(monkeypatch):
|
||||
fake = _fake_mps_client(status_return=None)
|
||||
monkeypatch.setattr(
|
||||
"api.services.mps_service_key_client.mps_service_key_client", fake
|
||||
)
|
||||
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
|
||||
|
||||
result = await resolve_embedding_correlation_id(
|
||||
organization_id=42, service_key="sk-mps"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
fake.create_correlation_id.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_no_service_key_returns_none(monkeypatch):
|
||||
fake = _fake_mps_client(status_return={"billing_mode": "v2"})
|
||||
fake = _fake_mps_client()
|
||||
monkeypatch.setattr(
|
||||
"api.services.mps_service_key_client.mps_service_key_client", fake
|
||||
)
|
||||
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
|
||||
|
||||
result = await resolve_embedding_correlation_id(
|
||||
organization_id=42, service_key=None
|
||||
)
|
||||
result = await resolve_embedding_correlation_id(service_key=None)
|
||||
|
||||
assert result is None
|
||||
fake.get_billing_account_status.assert_not_awaited()
|
||||
fake.create_correlation_id.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_swallows_errors(monkeypatch):
|
||||
fake = SimpleNamespace(
|
||||
get_billing_account_status=AsyncMock(side_effect=RuntimeError("mps down")),
|
||||
create_correlation_id=AsyncMock(),
|
||||
create_correlation_id=AsyncMock(side_effect=RuntimeError("mps down")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"api.services.mps_service_key_client.mps_service_key_client", fake
|
||||
)
|
||||
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
|
||||
|
||||
# A transient MPS failure must not break embeddings — fall back to no protocol.
|
||||
result = await resolve_embedding_correlation_id(
|
||||
organization_id=42, service_key="sk-mps"
|
||||
)
|
||||
# A transient MPS failure must not break embeddings — send without it.
|
||||
result = await resolve_embedding_correlation_id(service_key="sk-mps")
|
||||
|
||||
assert result is None
|
||||
|
|
|
|||
|
|
@ -130,51 +130,6 @@ async def test_create_correlation_id_uses_bearer_auth(monkeypatch):
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_billing_account_status_uses_hosted_org_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, "billing_mode": "v2"})
|
||||
|
||||
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_account_status(organization_id=42) == {
|
||||
"organization_id": 42,
|
||||
"billing_mode": "v2",
|
||||
}
|
||||
assert calls == [
|
||||
(
|
||||
"GET",
|
||||
f"{client.base_url}/api/v1/billing/accounts/42/status",
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"X-Secret-Key": "mps-secret",
|
||||
"X-Organization-Id": "42",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorize_workflow_run_start_uses_hosted_org_auth(monkeypatch):
|
||||
calls = []
|
||||
|
|
|
|||
|
|
@ -6,41 +6,36 @@ import pytest
|
|||
from api.routes import organization_usage
|
||||
|
||||
|
||||
def test_is_mps_billing_v2_depends_only_on_account_mode():
|
||||
assert organization_usage._is_mps_billing_v2({"billing_mode": "v2"}) is True
|
||||
assert organization_usage._is_mps_billing_v2({"billing_mode": "v1"}) is False
|
||||
assert organization_usage._is_mps_billing_v2({"billing_mode": "shadow"}) is False
|
||||
assert organization_usage._is_mps_billing_v2(None) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mps_billing_account_status_uses_user_provider_id(monkeypatch):
|
||||
get_status = AsyncMock(return_value={"billing_mode": "v2"})
|
||||
async def test_get_billing_credits_oss_aggregates_by_created_by(monkeypatch):
|
||||
monkeypatch.setattr(organization_usage, "DEPLOYMENT_MODE", "oss")
|
||||
get_usage = AsyncMock(
|
||||
return_value={"total_credits_used": 12.5, "remaining_credits": 487.5}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
organization_usage.mps_service_key_client,
|
||||
"get_billing_account_status",
|
||||
get_status,
|
||||
"get_usage_by_created_by",
|
||||
get_usage,
|
||||
)
|
||||
|
||||
user = SimpleNamespace(provider_id="provider-123")
|
||||
user = SimpleNamespace(provider_id="provider-123", selected_organization_id=None)
|
||||
|
||||
assert await organization_usage._get_mps_billing_account_status(user, 42) == {
|
||||
"billing_mode": "v2"
|
||||
}
|
||||
get_status.assert_awaited_once_with(
|
||||
organization_id=42,
|
||||
created_by="provider-123",
|
||||
response = await organization_usage.get_billing_credits(
|
||||
page=1,
|
||||
limit=50,
|
||||
user=user,
|
||||
)
|
||||
|
||||
get_usage.assert_awaited_once_with("provider-123")
|
||||
assert response.total_credits_used == 12.5
|
||||
assert response.remaining_credits == 487.5
|
||||
assert response.total_quota == 500.0
|
||||
assert response.ledger_entries == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_billing_credits_pages_v2_ledger(monkeypatch):
|
||||
async def test_get_billing_credits_pages_hosted_ledger(monkeypatch):
|
||||
monkeypatch.setattr(organization_usage, "DEPLOYMENT_MODE", "saas")
|
||||
monkeypatch.setattr(
|
||||
organization_usage,
|
||||
"_get_mps_billing_account_status",
|
||||
AsyncMock(return_value={"billing_mode": "v2"}),
|
||||
)
|
||||
get_ledger = AsyncMock(
|
||||
return_value={
|
||||
"account": {
|
||||
|
|
@ -90,7 +85,6 @@ async def test_get_billing_credits_pages_v2_ledger(monkeypatch):
|
|||
limit=25,
|
||||
created_by="provider-123",
|
||||
)
|
||||
assert response.billing_version == "v2"
|
||||
assert response.total_credits_used == 75
|
||||
assert response.total_count == 101
|
||||
assert response.page == 3
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import pytest
|
|||
from api.services import quota_service
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.managed_model_services import MPS_CORRELATION_ID_CONTEXT_KEY
|
||||
from api.services.quota_service import QuotaCheckResult
|
||||
|
||||
|
||||
def _dograh_config(
|
||||
|
|
@ -166,34 +167,22 @@ async def test_authorize_workflow_run_v2_insufficient_credits_prompts_billing(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorize_workflow_run_v1_uses_legacy_key_usage(
|
||||
async def test_authorize_workflow_run_oss_exhausted_key_blocks_run(
|
||||
monkeypatch,
|
||||
):
|
||||
api_key = "mps_sk_12345678"
|
||||
get_config = AsyncMock(return_value=_dograh_config(api_key))
|
||||
authorize = AsyncMock(
|
||||
return_value={
|
||||
"allowed": True,
|
||||
"billing_mode": "v1",
|
||||
"remaining_credits": "0.0000",
|
||||
}
|
||||
)
|
||||
check_usage = AsyncMock(
|
||||
return_value={"total_credits_used": 500.0, "remaining_credits": 0.0}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas")
|
||||
monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "oss")
|
||||
_patch_workflow_context(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
quota_service,
|
||||
"get_effective_ai_model_configuration_for_workflow",
|
||||
get_config,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
quota_service.mps_service_key_client,
|
||||
"authorize_workflow_run_start",
|
||||
authorize,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
quota_service.mps_service_key_client,
|
||||
"check_service_key_usage",
|
||||
|
|
@ -206,12 +195,7 @@ async def test_authorize_workflow_run_v1_uses_legacy_key_usage(
|
|||
assert result.error_code == "quota_exceeded"
|
||||
assert "founders@dograh.com" in result.error_message
|
||||
assert "/billing" not in result.error_message
|
||||
authorize.assert_awaited_once()
|
||||
check_usage.assert_awaited_once_with(
|
||||
api_key,
|
||||
organization_id=42,
|
||||
created_by="provider-123",
|
||||
)
|
||||
check_usage.assert_awaited_once_with(api_key)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -404,11 +388,7 @@ async def test_authorize_workflow_run_oss_uses_key_paths_not_workflow_org(
|
|||
|
||||
assert result.has_quota is True
|
||||
hosted_authorize.assert_not_awaited()
|
||||
check_usage.assert_awaited_once_with(
|
||||
api_key,
|
||||
organization_id=None,
|
||||
created_by="provider-123",
|
||||
)
|
||||
check_usage.assert_awaited_once_with(api_key)
|
||||
create_correlation.assert_awaited_once_with(
|
||||
service_key=api_key,
|
||||
workflow_run_id=88,
|
||||
|
|
@ -420,14 +400,108 @@ async def test_authorize_workflow_run_oss_uses_key_paths_not_workflow_org(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorize_workflow_run_rejects_actor_from_another_org(monkeypatch):
|
||||
async def test_authorize_workflow_run_rejects_actor_not_a_member(monkeypatch):
|
||||
monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas")
|
||||
_patch_workflow_context(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
quota_service.db_client,
|
||||
"is_user_member_of_organization",
|
||||
AsyncMock(return_value=False),
|
||||
)
|
||||
|
||||
result = await quota_service.authorize_workflow_run_start(
|
||||
workflow_id=7,
|
||||
actor_user=SimpleNamespace(selected_organization_id=999),
|
||||
actor_user=SimpleNamespace(id=456, selected_organization_id=999),
|
||||
)
|
||||
|
||||
assert result.has_quota is False
|
||||
assert result.error_code == "workflow_not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorize_workflow_run_membership_lookup_error_fails_closed(monkeypatch):
|
||||
monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas")
|
||||
_patch_workflow_context(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
quota_service.db_client,
|
||||
"is_user_member_of_organization",
|
||||
AsyncMock(side_effect=RuntimeError("database unavailable")),
|
||||
)
|
||||
|
||||
result = await quota_service.authorize_workflow_run_start(
|
||||
workflow_id=7,
|
||||
actor_user=SimpleNamespace(id=456, selected_organization_id=42),
|
||||
)
|
||||
|
||||
assert result.has_quota is False
|
||||
assert result.error_code == "workflow_not_found"
|
||||
quota_service.db_client.get_user_by_id.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorize_workflow_run_allows_invited_member(monkeypatch):
|
||||
"""User invited to an org can start workflows belonging to that org."""
|
||||
monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas")
|
||||
_patch_workflow_context(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
quota_service.db_client,
|
||||
"is_user_member_of_organization",
|
||||
AsyncMock(return_value=True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
quota_service,
|
||||
"get_effective_ai_model_configuration_for_workflow",
|
||||
AsyncMock(return_value=_byok_config()),
|
||||
)
|
||||
hosted_authorize = AsyncMock(return_value=QuotaCheckResult(has_quota=True))
|
||||
monkeypatch.setattr(
|
||||
quota_service,
|
||||
"_authorize_hosted_workflow_run_start",
|
||||
hosted_authorize,
|
||||
)
|
||||
|
||||
# actor_user.selected_organization_id=999 differs from workflow.organization_id=42,
|
||||
# but is_user_member_of_organization returns True so the run should be allowed.
|
||||
result = await quota_service.authorize_workflow_run_start(
|
||||
workflow_id=7,
|
||||
actor_user=SimpleNamespace(id=456, selected_organization_id=999),
|
||||
)
|
||||
|
||||
assert result.has_quota is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorize_workflow_run_allows_personal_workflow_with_actor(monkeypatch):
|
||||
"""Personal/legacy workflows (organization_id=None) bypass membership check."""
|
||||
personal_workflow = SimpleNamespace(
|
||||
id=7,
|
||||
user_id=123,
|
||||
organization_id=None,
|
||||
workflow_configurations={"model_overrides": {}},
|
||||
)
|
||||
monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas")
|
||||
_patch_workflow_context(monkeypatch, workflow=personal_workflow)
|
||||
is_member_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
quota_service.db_client,
|
||||
"is_user_member_of_organization",
|
||||
is_member_mock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
quota_service,
|
||||
"get_effective_ai_model_configuration_for_workflow",
|
||||
AsyncMock(return_value=_byok_config()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
quota_service,
|
||||
"_authorize_hosted_workflow_run_start",
|
||||
AsyncMock(return_value=QuotaCheckResult(has_quota=True)),
|
||||
)
|
||||
|
||||
result = await quota_service.authorize_workflow_run_start(
|
||||
workflow_id=7,
|
||||
actor_user=SimpleNamespace(id=456),
|
||||
)
|
||||
|
||||
assert result.has_quota is True
|
||||
is_member_mock.assert_not_awaited()
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
from api.services.pipecat.realtime_feedback_events import (
|
||||
build_bot_text_event,
|
||||
build_function_call_end_event,
|
||||
build_user_transcription_event,
|
||||
build_node_transition_event,
|
||||
realtime_feedback_event_sort_key,
|
||||
stamp_realtime_feedback_event,
|
||||
)
|
||||
from api.utils.transcript import generate_transcript_text
|
||||
|
||||
|
||||
def test_build_function_call_end_event_serializes_results():
|
||||
|
|
@ -51,3 +53,38 @@ def test_stamp_and_sort_realtime_feedback_events():
|
|||
assert events == [bot_text, node_transition]
|
||||
assert node_transition["node_id"] == "node-1"
|
||||
assert node_transition["node_name"] == "Greeting"
|
||||
|
||||
|
||||
def test_transcript_can_include_end_timestamps_without_changing_default_format():
|
||||
events = [
|
||||
stamp_realtime_feedback_event(
|
||||
build_bot_text_event(
|
||||
text="Can you confirm your date of birth?",
|
||||
timestamp="2026-01-01T00:00:01+00:00",
|
||||
end_timestamp="2026-01-01T00:00:04+00:00",
|
||||
),
|
||||
timestamp="2026-01-01T00:00:05+00:00",
|
||||
turn=0,
|
||||
),
|
||||
stamp_realtime_feedback_event(
|
||||
build_user_transcription_event(
|
||||
text="January fifth",
|
||||
final=True,
|
||||
timestamp="2026-01-01T00:00:06+00:00",
|
||||
end_timestamp="2026-01-01T00:00:08+00:00",
|
||||
),
|
||||
timestamp="2026-01-01T00:00:09+00:00",
|
||||
turn=1,
|
||||
),
|
||||
]
|
||||
|
||||
assert generate_transcript_text(events) == (
|
||||
"[2026-01-01T00:00:01+00:00] assistant: Can you confirm your date of birth?\n"
|
||||
"[2026-01-01T00:00:06+00:00] user: January fifth\n"
|
||||
)
|
||||
assert generate_transcript_text(events, include_end_timestamps=True) == (
|
||||
"[2026-01-01T00:00:01+00:00 -> 2026-01-01T00:00:04+00:00] "
|
||||
"assistant: Can you confirm your date of birth?\n"
|
||||
"[2026-01-01T00:00:06+00:00 -> 2026-01-01T00:00:08+00:00] "
|
||||
"user: January fifth\n"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
from types import SimpleNamespace
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from pipecat.frames.frames import TranscriptionFrame, TTSTextFrame
|
||||
from pipecat.frames.frames import (
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
TranscriptionFrame,
|
||||
TTSTextFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
VADUserStartedSpeakingFrame,
|
||||
VADUserStoppedSpeakingFrame,
|
||||
)
|
||||
from pipecat.observers.base_observer import FramePushed
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.transports.base_output import BaseOutputTransport
|
||||
|
|
@ -144,3 +154,139 @@ async def test_turn_log_handlers_persist_user_message_added_events():
|
|||
"timestamp": "2026-01-01T00:00:00+00:00",
|
||||
}
|
||||
assert events[0]["turn"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observer_attaches_backend_speaking_intervals_to_logged_transcript_events():
|
||||
async def ws_sender(_message):
|
||||
pass
|
||||
|
||||
logs_buffer = InMemoryLogsBuffer(workflow_run_id=123)
|
||||
observer = RealtimeFeedbackObserver(ws_sender=ws_sender, logs_buffer=logs_buffer)
|
||||
|
||||
user_aggregator = _FakeAggregator()
|
||||
assistant_aggregator = _FakeAggregator()
|
||||
register_turn_log_handlers(logs_buffer, user_aggregator, assistant_aggregator)
|
||||
|
||||
await observer.on_push_frame(
|
||||
_frame_pushed(UserStartedSpeakingFrame(), FrameDirection.DOWNSTREAM)
|
||||
)
|
||||
await observer.on_push_frame(
|
||||
_frame_pushed(UserStoppedSpeakingFrame(), FrameDirection.DOWNSTREAM)
|
||||
)
|
||||
await user_aggregator.handlers["on_user_turn_message_added"](
|
||||
user_aggregator,
|
||||
SimpleNamespace(
|
||||
content="January fifth",
|
||||
timestamp="aggregator-user-start",
|
||||
),
|
||||
)
|
||||
|
||||
await observer.on_push_frame(
|
||||
_frame_pushed(BotStartedSpeakingFrame(), FrameDirection.DOWNSTREAM)
|
||||
)
|
||||
await assistant_aggregator.handlers["on_assistant_turn_stopped"](
|
||||
assistant_aggregator,
|
||||
SimpleNamespace(
|
||||
content="Thank you",
|
||||
timestamp="aggregator-bot-start",
|
||||
),
|
||||
)
|
||||
await observer.on_push_frame(
|
||||
_frame_pushed(BotStoppedSpeakingFrame(), FrameDirection.DOWNSTREAM)
|
||||
)
|
||||
|
||||
user_event, bot_event = [
|
||||
event
|
||||
for event in logs_buffer.get_events()
|
||||
if event["type"] in {"rtf-user-transcription", "rtf-bot-text"}
|
||||
]
|
||||
|
||||
assert user_event["payload"]["timestamp"] != "aggregator-user-start"
|
||||
assert re.match(
|
||||
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\+00:00$",
|
||||
user_event["payload"]["timestamp"],
|
||||
)
|
||||
assert user_event["payload"]["end_timestamp"]
|
||||
assert bot_event["payload"]["timestamp"] != "aggregator-bot-start"
|
||||
assert re.match(
|
||||
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\+00:00$",
|
||||
bot_event["payload"]["timestamp"],
|
||||
)
|
||||
assert bot_event["payload"]["end_timestamp"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observer_uses_vad_user_speech_times_over_turn_closure_times():
|
||||
async def ws_sender(_message):
|
||||
pass
|
||||
|
||||
logs_buffer = InMemoryLogsBuffer(workflow_run_id=123)
|
||||
observer = RealtimeFeedbackObserver(ws_sender=ws_sender, logs_buffer=logs_buffer)
|
||||
user_aggregator = _FakeAggregator()
|
||||
assistant_aggregator = _FakeAggregator()
|
||||
register_turn_log_handlers(logs_buffer, user_aggregator, assistant_aggregator)
|
||||
|
||||
await observer.on_push_frame(
|
||||
_frame_pushed(
|
||||
VADUserStartedSpeakingFrame(start_secs=0.2, timestamp=1000.2),
|
||||
FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
)
|
||||
await observer.on_push_frame(
|
||||
_frame_pushed(
|
||||
VADUserStoppedSpeakingFrame(stop_secs=0.5, timestamp=1002.5),
|
||||
FrameDirection.DOWNSTREAM,
|
||||
)
|
||||
)
|
||||
await observer.on_push_frame(
|
||||
_frame_pushed(UserStoppedSpeakingFrame(), FrameDirection.DOWNSTREAM)
|
||||
)
|
||||
|
||||
await user_aggregator.handlers["on_user_turn_message_added"](
|
||||
user_aggregator,
|
||||
SimpleNamespace(content="Hello", timestamp="aggregator-user-start"),
|
||||
)
|
||||
|
||||
user_event = logs_buffer.get_events()[0]
|
||||
assert user_event["payload"]["timestamp"] == "1970-01-01T00:16:40.000+00:00"
|
||||
assert user_event["payload"]["end_timestamp"] == "1970-01-01T00:16:42.000+00:00"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_bot_speech_interval_is_not_reused_for_next_pre_playback_text():
|
||||
logs_buffer = InMemoryLogsBuffer(workflow_run_id=123)
|
||||
|
||||
logs_buffer.mark_bot_started_speaking("2026-01-01T00:00:01.000+00:00")
|
||||
await logs_buffer.append({"type": "rtf-bot-text", "payload": {"text": "First"}})
|
||||
logs_buffer.mark_bot_stopped_speaking("2026-01-01T00:00:02.000+00:00")
|
||||
|
||||
await logs_buffer.append({"type": "rtf-bot-text", "payload": {"text": "Second"}})
|
||||
second_event = logs_buffer.get_events()[-1]
|
||||
|
||||
assert second_event["payload"] == {"text": "Second"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_vad_user_turn_after_completed_vad_turn_gets_fresh_timestamps():
|
||||
logs_buffer = InMemoryLogsBuffer(workflow_run_id=123)
|
||||
|
||||
logs_buffer.mark_user_started_speaking(
|
||||
"2026-01-01T00:00:01.000+00:00", from_vad=True
|
||||
)
|
||||
logs_buffer.mark_user_stopped_speaking(
|
||||
"2026-01-01T00:00:02.000+00:00", from_vad=True
|
||||
)
|
||||
await logs_buffer.append(
|
||||
{"type": "rtf-user-transcription", "payload": {"text": "First", "final": True}}
|
||||
)
|
||||
|
||||
logs_buffer.mark_user_started_speaking("2026-01-01T00:00:10.000+00:00")
|
||||
logs_buffer.mark_user_stopped_speaking("2026-01-01T00:00:12.000+00:00")
|
||||
await logs_buffer.append(
|
||||
{"type": "rtf-user-transcription", "payload": {"text": "Second", "final": True}}
|
||||
)
|
||||
|
||||
second_event = logs_buffer.get_events()[-1]
|
||||
assert second_event["payload"]["timestamp"] == "2026-01-01T00:00:10.000+00:00"
|
||||
assert second_event["payload"]["end_timestamp"] == "2026-01-01T00:00:12.000+00:00"
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ category of violation we found in production. We pin two layers:
|
|||
layer ever stops rejecting one of these fixtures, the production
|
||||
write paths will quietly start accepting bad workflows again.
|
||||
2. audit_definition (api.services.workflow.audit) — read-only sweep
|
||||
over persisted rows used by the admin cleanup script to find
|
||||
over persisted rows for one-off cleanup tooling that finds
|
||||
legacy/imported breakage. Pinned so refactors of the rule set
|
||||
don't silently change the verdicts the migration relies on.
|
||||
don't silently change those cleanup verdicts.
|
||||
|
||||
DTO-level shape validation is covered by `test_dto.py` and isn't
|
||||
re-pinned here.
|
||||
|
|
|
|||
|
|
@ -40,15 +40,9 @@ async def test_report_workflow_run_platform_usage_reports_hosted_completion(
|
|||
monkeypatch,
|
||||
):
|
||||
workflow_run = _make_workflow_run()
|
||||
get_status = AsyncMock(return_value={"billing_mode": "v2"})
|
||||
report_usage = AsyncMock(return_value={"metered": True})
|
||||
|
||||
monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas")
|
||||
monkeypatch.setattr(
|
||||
workflow_run_billing_mod.mps_service_key_client,
|
||||
"get_billing_account_status",
|
||||
get_status,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workflow_run_billing_mod.mps_service_key_client,
|
||||
"report_platform_usage",
|
||||
|
|
@ -76,15 +70,9 @@ async def test_report_workflow_run_platform_usage_reports_duration_without_corre
|
|||
):
|
||||
workflow_run = _make_workflow_run()
|
||||
workflow_run.initial_context = {}
|
||||
get_status = AsyncMock(return_value={"billing_mode": "v2"})
|
||||
report_usage = AsyncMock(return_value={"metered": True})
|
||||
|
||||
monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas")
|
||||
monkeypatch.setattr(
|
||||
workflow_run_billing_mod.mps_service_key_client,
|
||||
"get_billing_account_status",
|
||||
get_status,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workflow_run_billing_mod.mps_service_key_client,
|
||||
"report_platform_usage",
|
||||
|
|
@ -106,30 +94,6 @@ async def test_report_workflow_run_platform_usage_reports_duration_without_corre
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_workflow_run_platform_usage_skips_non_v2_account(monkeypatch):
|
||||
workflow_run = _make_workflow_run()
|
||||
get_status = AsyncMock(return_value={"billing_mode": "v1"})
|
||||
report_usage = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas")
|
||||
monkeypatch.setattr(
|
||||
workflow_run_billing_mod.mps_service_key_client,
|
||||
"get_billing_account_status",
|
||||
get_status,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workflow_run_billing_mod.mps_service_key_client,
|
||||
"report_platform_usage",
|
||||
report_usage,
|
||||
)
|
||||
|
||||
await report_workflow_run_platform_usage(workflow_run)
|
||||
|
||||
get_status.assert_awaited_once_with(organization_id=42)
|
||||
report_usage.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_workflow_run_platform_usage_skips_missing_duration_without_correlation(
|
||||
monkeypatch,
|
||||
|
|
@ -137,15 +101,9 @@ async def test_report_workflow_run_platform_usage_skips_missing_duration_without
|
|||
workflow_run = _make_workflow_run()
|
||||
workflow_run.initial_context = {}
|
||||
workflow_run.usage_info = {}
|
||||
get_status = AsyncMock(return_value={"billing_mode": "v2"})
|
||||
report_usage = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas")
|
||||
monkeypatch.setattr(
|
||||
workflow_run_billing_mod.mps_service_key_client,
|
||||
"get_billing_account_status",
|
||||
get_status,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workflow_run_billing_mod.mps_service_key_client,
|
||||
"report_platform_usage",
|
||||
|
|
@ -154,7 +112,6 @@ async def test_report_workflow_run_platform_usage_skips_missing_duration_without
|
|||
|
||||
await report_workflow_run_platform_usage(workflow_run)
|
||||
|
||||
get_status.assert_not_awaited()
|
||||
report_usage.assert_not_awaited()
|
||||
|
||||
|
||||
|
|
@ -197,7 +154,6 @@ async def test_report_workflow_run_platform_usage_skips_incomplete(monkeypatch):
|
|||
async def test_report_completed_workflow_run_platform_usage_loads_run(monkeypatch):
|
||||
workflow_run = _make_workflow_run()
|
||||
get_run = AsyncMock(return_value=workflow_run)
|
||||
get_status = AsyncMock(return_value={"billing_mode": "v2"})
|
||||
report_usage = AsyncMock(return_value={"metered": True})
|
||||
|
||||
monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas")
|
||||
|
|
@ -206,11 +162,6 @@ async def test_report_completed_workflow_run_platform_usage_loads_run(monkeypatc
|
|||
"get_workflow_run_by_id",
|
||||
get_run,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workflow_run_billing_mod.mps_service_key_client,
|
||||
"get_billing_account_status",
|
||||
get_status,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workflow_run_billing_mod.mps_service_key_client,
|
||||
"report_platform_usage",
|
||||
|
|
|
|||
160
api/tests/test_xai_tts_service_factory.py
Normal file
160
api/tests/test_xai_tts_service_factory.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pipecat.transcriptions.language import Language
|
||||
|
||||
from api.services.configuration.check_validity import UserConfigurationValidator
|
||||
from api.services.configuration.registry import (
|
||||
XAI_TTS_VOICES,
|
||||
ServiceProviders,
|
||||
XAITTSConfiguration,
|
||||
)
|
||||
from api.services.pipecat.service_factory import create_tts_service
|
||||
|
||||
|
||||
def test_xai_tts_configuration_defaults():
|
||||
config = XAITTSConfiguration(api_key="test-key")
|
||||
|
||||
assert config.provider == ServiceProviders.XAI
|
||||
assert config.voice == "eve"
|
||||
assert config.language == "en"
|
||||
# xAI TTS has no model selector; a constant satisfies the shared contract.
|
||||
assert config.model == "xai-tts"
|
||||
assert XAI_TTS_VOICES == ["eve", "ara", "leo", "rex", "sal"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("transport_out_sample_rate", [8000, 16000])
|
||||
def test_create_xai_tts_service_uses_pipeline_compatible_audio_format(
|
||||
transport_out_sample_rate,
|
||||
):
|
||||
user_config = SimpleNamespace(
|
||||
tts=SimpleNamespace(
|
||||
provider=ServiceProviders.XAI.value,
|
||||
api_key="test-key",
|
||||
model="xai-tts",
|
||||
voice="rex",
|
||||
language="en",
|
||||
)
|
||||
)
|
||||
audio_config = SimpleNamespace(
|
||||
transport_out_sample_rate=transport_out_sample_rate,
|
||||
transport_in_sample_rate=16000,
|
||||
)
|
||||
|
||||
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
|
||||
create_tts_service(user_config, audio_config)
|
||||
|
||||
assert mock_service.call_count == 1
|
||||
kwargs = mock_service.call_args.kwargs
|
||||
assert kwargs["api_key"] == "test-key"
|
||||
assert kwargs["sample_rate"] == transport_out_sample_rate
|
||||
assert kwargs["encoding"] == "pcm"
|
||||
assert kwargs["settings"].voice == "rex"
|
||||
assert kwargs["settings"].language == Language.EN
|
||||
|
||||
|
||||
def test_create_xai_tts_service_converts_language():
|
||||
user_config = SimpleNamespace(
|
||||
tts=SimpleNamespace(
|
||||
provider=ServiceProviders.XAI.value,
|
||||
api_key="test-key",
|
||||
model="xai-tts",
|
||||
voice="eve",
|
||||
language="fr",
|
||||
)
|
||||
)
|
||||
audio_config = SimpleNamespace(
|
||||
transport_out_sample_rate=24000,
|
||||
transport_in_sample_rate=16000,
|
||||
)
|
||||
|
||||
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
|
||||
create_tts_service(user_config, audio_config)
|
||||
|
||||
kwargs = mock_service.call_args.kwargs
|
||||
assert kwargs["settings"].language == Language.FR
|
||||
|
||||
|
||||
def test_create_xai_tts_service_falls_back_to_english_for_unknown_language():
|
||||
user_config = SimpleNamespace(
|
||||
tts=SimpleNamespace(
|
||||
provider=ServiceProviders.XAI.value,
|
||||
api_key="test-key",
|
||||
model="xai-tts",
|
||||
voice="eve",
|
||||
language="not-a-language",
|
||||
)
|
||||
)
|
||||
audio_config = SimpleNamespace(
|
||||
transport_out_sample_rate=24000,
|
||||
transport_in_sample_rate=16000,
|
||||
)
|
||||
|
||||
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
|
||||
create_tts_service(user_config, audio_config)
|
||||
|
||||
kwargs = mock_service.call_args.kwargs
|
||||
assert kwargs["settings"].language == Language.EN
|
||||
|
||||
|
||||
def test_create_xai_tts_service_preserves_auto_language():
|
||||
user_config = SimpleNamespace(
|
||||
tts=SimpleNamespace(
|
||||
provider=ServiceProviders.XAI.value,
|
||||
api_key="test-key",
|
||||
model="xai-tts",
|
||||
voice="eve",
|
||||
language="auto",
|
||||
)
|
||||
)
|
||||
audio_config = SimpleNamespace(
|
||||
transport_out_sample_rate=24000,
|
||||
transport_in_sample_rate=16000,
|
||||
)
|
||||
|
||||
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
|
||||
create_tts_service(user_config, audio_config)
|
||||
|
||||
kwargs = mock_service.call_args.kwargs
|
||||
assert kwargs["settings"].language == "auto"
|
||||
|
||||
|
||||
def test_xai_is_registered_for_key_validation():
|
||||
validator = UserConfigurationValidator()
|
||||
assert ServiceProviders.XAI.value in validator._validator_map
|
||||
|
||||
|
||||
def test_xai_key_validation_accepts_valid_key():
|
||||
validator = UserConfigurationValidator()
|
||||
with patch(
|
||||
"api.services.configuration.check_validity.httpx.get"
|
||||
) as mock_get:
|
||||
mock_get.return_value.status_code = 200
|
||||
assert validator._check_xai_api_key("xai", "xai-valid-key") is True
|
||||
# Validates against the TTS-scoped voices endpoint, not /v1/models.
|
||||
called_url = mock_get.call_args.args[0]
|
||||
assert called_url == "https://api.x.ai/v1/tts/voices"
|
||||
assert (
|
||||
mock_get.call_args.kwargs["headers"]["Authorization"]
|
||||
== "Bearer xai-valid-key"
|
||||
)
|
||||
|
||||
|
||||
def test_xai_key_validation_rejects_bad_key():
|
||||
validator = UserConfigurationValidator()
|
||||
with patch(
|
||||
"api.services.configuration.check_validity.httpx.get"
|
||||
) as mock_get:
|
||||
mock_get.return_value.status_code = 401
|
||||
with pytest.raises(ValueError):
|
||||
validator._check_xai_api_key("xai", "bad-key")
|
||||
|
||||
|
||||
def test_xai_key_validation_allows_scoped_key_without_voice_list_access():
|
||||
validator = UserConfigurationValidator()
|
||||
with patch(
|
||||
"api.services.configuration.check_validity.httpx.get"
|
||||
) as mock_get:
|
||||
mock_get.return_value.status_code = 403
|
||||
assert validator._check_xai_api_key("xai", "tts-scoped-key") is True
|
||||
|
|
@ -3,11 +3,26 @@ from typing import List
|
|||
from pipecat.utils.enums import RealtimeFeedbackType
|
||||
|
||||
|
||||
def generate_transcript_text(events: List[dict]) -> str:
|
||||
def _format_timestamp_range(payload: dict, event: dict, include_end_timestamps: bool) -> str:
|
||||
start_timestamp = payload.get("timestamp") or event.get("timestamp", "")
|
||||
if not include_end_timestamps:
|
||||
return start_timestamp
|
||||
|
||||
end_timestamp = payload.get("end_timestamp")
|
||||
if end_timestamp:
|
||||
return f"{start_timestamp} -> {end_timestamp}" if start_timestamp else end_timestamp
|
||||
return start_timestamp
|
||||
|
||||
|
||||
def generate_transcript_text(
|
||||
events: List[dict], *, include_end_timestamps: bool = False
|
||||
) -> str:
|
||||
"""Generate transcript text from realtime feedback events.
|
||||
|
||||
Filters for rtf-user-transcription (final) and rtf-bot-text events,
|
||||
formats them as '[timestamp] user/assistant: text\\n'.
|
||||
formats them as '[timestamp] user/assistant: text\\n'. When
|
||||
include_end_timestamps is True, formats as
|
||||
'[start_timestamp -> end_timestamp] user/assistant: text\\n'.
|
||||
"""
|
||||
lines: List[str] = []
|
||||
for event in events:
|
||||
|
|
@ -18,11 +33,11 @@ def generate_transcript_text(events: List[dict]) -> str:
|
|||
event_type == RealtimeFeedbackType.USER_TRANSCRIPTION.value
|
||||
and payload.get("final") is True
|
||||
):
|
||||
timestamp = payload.get("timestamp") or event.get("timestamp", "")
|
||||
timestamp = _format_timestamp_range(payload, event, include_end_timestamps)
|
||||
prefix = f"[{timestamp}] " if timestamp else ""
|
||||
lines.append(f"{prefix}user: {payload.get('text', '')}\n")
|
||||
elif event_type == RealtimeFeedbackType.BOT_TEXT.value:
|
||||
timestamp = payload.get("timestamp") or event.get("timestamp", "")
|
||||
timestamp = _format_timestamp_range(payload, event, include_end_timestamps)
|
||||
prefix = f"[{timestamp}] " if timestamp else ""
|
||||
lines.append(f"{prefix}assistant: {payload.get('text', '')}\n")
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -64,6 +64,12 @@ For details on creating and using Service Keys, see [API Keys and Service Keys](
|
|||
|
||||
Use **BYOK** when you want to bring your own provider accounts and API keys. This gives you separate control over each model category.
|
||||
|
||||
<Warning>
|
||||
When you use BYOK or external model providers, Dograh sends only the data required for the selected service to operate. Depending on the provider and service type, this may include prompts, conversation history, transcripts, audio, generated text, tool/function definitions, tool inputs or results, and request metadata.
|
||||
|
||||
Provider data handling varies. Review each provider's data processing, retention, model training, and regional hosting policies before using sensitive data.
|
||||
</Warning>
|
||||
|
||||

|
||||
|
||||
The BYOK section has nested tabs:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ description: "Voice Agents use LLM (Large Language Models), which are trained to
|
|||
|
||||
Dograh platform supports OpenAI, Google AI Studio, Google Vertex AI, Azure OpenAI, AWS Bedrock, Groq, OpenRouter, Hugging Face, MiniMax, Sarvam, and Dograh-managed LLMs. There are some models provided by default for you to choose from the drop down.
|
||||
|
||||
<Warning>
|
||||
LLM providers receive the data needed to generate responses, such as prompts, conversation history, model settings, and any configured tool/function definitions or tool call context. Review the provider's data processing, retention, model training, and regional hosting policies before using sensitive data.
|
||||
</Warning>
|
||||
|
||||
For locally deployed or self-hosted LLMs, Dograh also supports OpenAI-compatible endpoints such as Ollama and vLLM.
|
||||
|
||||

|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ description: "Voice Agents use STT (Speech to Text), to transcribe what the user
|
|||
|
||||
Dograh platform supports Deepgram, OpenAI, Google, Azure Speech, AssemblyAI, Speechmatics, Cartesia, Gladia, Sarvam, Smallest AI, Hugging Face, and Dograh transcribers. You can take a look at the providers documentation of which language to select for your language requirements.
|
||||
|
||||
<Warning>
|
||||
Transcriber providers receive the data needed to transcribe speech, such as call audio, language settings, keyterms, and request metadata. Review the provider's data processing, retention, model training, and regional hosting policies before using sensitive data.
|
||||
</Warning>
|
||||
|
||||
For locally deployed or self-hosted STT models, Dograh also supports Speaches, an OpenAI API-compatible server for streaming transcription.
|
||||
|
||||
Example: Deepgram has their language support documentation at https://developers.deepgram.com/docs/models-languages-overview#nova-3
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ title: "Voice"
|
|||
description: "Voice Agents use TTS (Text to Speech), which generates audio that LLMs generate during the course of a conversation. This is the audio that the end user having the conversation listens to."
|
||||
---
|
||||
|
||||
Dograh platform supports ElevenLabs, OpenAI, Google, Azure Speech, Deepgram, Cartesia, Smallest AI, MiniMax, Sarvam, Rime, Inworld, Camb.ai, and Dograh TTS engines. There are some voices from the providers that we ship by default. You can refer to the providers API documentation to select a voice ID that's most relevant for your language requirement.
|
||||
Dograh platform supports ElevenLabs, OpenAI, Google, Azure Speech, Deepgram, Cartesia, Smallest AI, MiniMax, Sarvam, Rime, Inworld, Camb.ai, xAI, and Dograh TTS engines. There are some voices from the providers that we ship by default. You can refer to the providers API documentation to select a voice ID that's most relevant for your language requirement.
|
||||
|
||||
<Warning>
|
||||
Voice providers receive the data needed to synthesize speech, such as generated text, selected voice, model settings, and request metadata. Review the provider's data processing, retention, model training, and regional hosting policies before using sensitive data.
|
||||
</Warning>
|
||||
|
||||
For locally deployed or self-hosted TTS models, Dograh also supports Speaches, an OpenAI API-compatible server for speech generation.
|
||||
|
||||
|
|
|
|||
2
pipecat
2
pipecat
|
|
@ -1 +1 @@
|
|||
Subproject commit 63f0bc437ebe50ae4616b1cc2d69667c4ae3dc58
|
||||
Subproject commit cc535a0c86b799804469b68daa6d9d9fd696cfe6
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# generated by datamodel-codegen:
|
||||
# filename: dograh-openapi-XXXXXX.json.0wZwIU8qiw
|
||||
# timestamp: 2026-07-09T12:46:24+00:00
|
||||
# filename: dograh-openapi-XXXXXX.json.c3Lmi0soSL
|
||||
# timestamp: 2026-07-09T12:55:26+00:00
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
170
ui/package-lock.json
generated
170
ui/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "ui",
|
||||
"version": "1.39.0",
|
||||
"version": "1.40.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ui",
|
||||
"version": "1.39.0",
|
||||
"version": "1.40.0",
|
||||
"dependencies": {
|
||||
"@calcom/embed-react": "^1.5.3",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
|
|
@ -482,7 +482,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
|
|
@ -669,6 +668,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
|
|
@ -812,6 +812,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||
"integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.16.7",
|
||||
"@babel/runtime": "^7.18.3",
|
||||
|
|
@ -830,13 +831,15 @@
|
|||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin/node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -846,6 +849,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
|
||||
"integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
"@emotion/sheet": "^1.4.0",
|
||||
|
|
@ -858,19 +862,22 @@
|
|||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@emotion/memoize": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
|
||||
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@emotion/react": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
|
|
@ -895,6 +902,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
|
||||
"integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emotion/hash": "^0.9.2",
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
|
|
@ -907,19 +915,22 @@
|
|||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
|
||||
"integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@emotion/unitless": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
|
||||
"integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@emotion/use-insertion-effect-with-fallbacks": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
|
||||
"integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
|
|
@ -928,13 +939,15 @@
|
|||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
|
||||
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@emotion/weak-memoize": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
|
||||
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
|
|
@ -2223,6 +2236,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25"
|
||||
|
|
@ -2471,7 +2485,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
|
||||
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
|
|
@ -2493,7 +2506,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz",
|
||||
"integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
|
|
@ -2509,7 +2521,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz",
|
||||
"integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.214.0",
|
||||
"import-in-the-middle": "^3.0.0",
|
||||
|
|
@ -2560,7 +2571,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz",
|
||||
"integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.9.0",
|
||||
"@opentelemetry/resources": "2.9.0",
|
||||
|
|
@ -8545,7 +8555,6 @@
|
|||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -9740,7 +9749,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
|
||||
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
|
|
@ -10176,14 +10184,14 @@
|
|||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
|
@ -10194,7 +10202,6 @@
|
|||
"integrity": "sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
|
|
@ -10204,6 +10211,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
|
||||
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
|
|
@ -10718,6 +10726,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||
"integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/helper-numbers": "1.13.2",
|
||||
"@webassemblyjs/helper-wasm-bytecode": "1.13.2"
|
||||
|
|
@ -10727,25 +10736,29 @@
|
|||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
|
||||
"integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-api-error": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
|
||||
"integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-buffer": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
|
||||
"integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-numbers": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz",
|
||||
"integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/floating-point-hex-parser": "1.13.2",
|
||||
"@webassemblyjs/helper-api-error": "1.13.2",
|
||||
|
|
@ -10756,13 +10769,15 @@
|
|||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
|
||||
"integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-wasm-section": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz",
|
||||
"integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-buffer": "1.14.1",
|
||||
|
|
@ -10775,6 +10790,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz",
|
||||
"integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@xtuc/ieee754": "^1.2.0"
|
||||
}
|
||||
|
|
@ -10784,6 +10800,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz",
|
||||
"integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@xtuc/long": "4.2.2"
|
||||
}
|
||||
|
|
@ -10792,13 +10809,15 @@
|
|||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
|
||||
"integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/wasm-edit": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz",
|
||||
"integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-buffer": "1.14.1",
|
||||
|
|
@ -10815,6 +10834,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz",
|
||||
"integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
|
||||
|
|
@ -10828,6 +10848,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz",
|
||||
"integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-buffer": "1.14.1",
|
||||
|
|
@ -10840,6 +10861,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz",
|
||||
"integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-api-error": "1.13.2",
|
||||
|
|
@ -10854,6 +10876,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz",
|
||||
"integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@xtuc/long": "4.2.2"
|
||||
|
|
@ -10869,13 +10892,15 @@
|
|||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
||||
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@xtuc/long": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
|
||||
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
|
||||
"license": "Apache-2.0"
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.10.2",
|
||||
|
|
@ -10942,7 +10967,6 @@
|
|||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -10964,6 +10988,7 @@
|
|||
"resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz",
|
||||
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
},
|
||||
|
|
@ -11033,6 +11058,7 @@
|
|||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
||||
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
|
|
@ -11050,6 +11076,7 @@
|
|||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
|
|
@ -11065,7 +11092,8 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ansi-colors": {
|
||||
"version": "4.1.3",
|
||||
|
|
@ -11373,6 +11401,7 @@
|
|||
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
|
||||
"integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"cosmiconfig": "^7.0.0",
|
||||
|
|
@ -11490,7 +11519,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
|
|
@ -11695,6 +11723,7 @@
|
|||
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
|
||||
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
|
|
@ -11933,6 +11962,7 @@
|
|||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||
"integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/parse-json": "^4.0.0",
|
||||
"import-fresh": "^3.2.1",
|
||||
|
|
@ -12104,7 +12134,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -12456,6 +12485,7 @@
|
|||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
|
|
@ -12552,6 +12582,7 @@
|
|||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.2.1"
|
||||
}
|
||||
|
|
@ -12560,7 +12591,8 @@
|
|||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.23.9",
|
||||
|
|
@ -12832,7 +12864,6 @@
|
|||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -13006,7 +13037,6 @@
|
|||
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.8",
|
||||
|
|
@ -13293,6 +13323,7 @@
|
|||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
|
|
@ -13407,7 +13438,8 @@
|
|||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
|
|
@ -13455,7 +13487,8 @@
|
|||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
||||
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
|
|
@ -13916,6 +13949,7 @@
|
|||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
|
|
@ -13948,7 +13982,6 @@
|
|||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
|
||||
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
|
|
@ -14552,6 +14585,7 @@
|
|||
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
|
||||
"integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"merge-stream": "^2.0.0",
|
||||
|
|
@ -14566,6 +14600,7 @@
|
|||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
|
|
@ -14662,7 +14697,8 @@
|
|||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json-schema": {
|
||||
"version": "0.4.0",
|
||||
|
|
@ -15000,13 +15036,15 @@
|
|||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/loader-runner": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz",
|
||||
"integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.11.5"
|
||||
},
|
||||
|
|
@ -15090,13 +15128,15 @@
|
|||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
|
|
@ -15136,6 +15176,7 @@
|
|||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
|
|
@ -15179,6 +15220,7 @@
|
|||
"resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz",
|
||||
"integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"jest-worker": "^27.4.5",
|
||||
|
|
@ -15290,14 +15332,14 @@
|
|||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.5.20",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.20.tgz",
|
||||
"integrity": "sha512-cvyS3/geydan1xLtE3FA8VCgdoQ/Gg/dlOldFkFCbB5VcVYJV7090hQLBnvTW2PwT76Z/dHdzDZCsVhZpoOlUA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.20",
|
||||
"@swc/helpers": "0.5.15",
|
||||
|
|
@ -15684,6 +15726,7 @@
|
|||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.0.0",
|
||||
"error-ex": "^1.3.1",
|
||||
|
|
@ -15752,6 +15795,7 @@
|
|||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
|
|
@ -16125,7 +16169,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -16156,7 +16199,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
|
|
@ -16183,7 +16225,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz",
|
||||
"integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
|
@ -16208,15 +16249,13 @@
|
|||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
|
|
@ -16356,6 +16395,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
|
|
@ -16421,8 +16461,7 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
|
|
@ -16460,7 +16499,8 @@
|
|||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
|
|
@ -16497,6 +16537,7 @@
|
|||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -16580,7 +16621,6 @@
|
|||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.9"
|
||||
},
|
||||
|
|
@ -16752,6 +16792,7 @@
|
|||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
|
||||
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.9",
|
||||
"ajv": "^8.9.0",
|
||||
|
|
@ -16788,6 +16829,7 @@
|
|||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
|
||||
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3"
|
||||
},
|
||||
|
|
@ -16799,7 +16841,8 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.0.0",
|
||||
|
|
@ -17343,7 +17386,8 @@
|
|||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
|
||||
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
|
|
@ -17397,8 +17441,7 @@
|
|||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.1.tgz",
|
||||
"integrity": "sha512-QNbdmeS979Efzim2g/bEvfuh+fTcIdp1y7gA+sb6OYSW74rt7Cr7M78AKdf6HqWT3d5AiTb7SwTT3sLQxr4/qw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
|
|
@ -17427,6 +17470,7 @@
|
|||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz",
|
||||
"integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
|
|
@ -17444,7 +17488,8 @@
|
|||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "3.1.0",
|
||||
|
|
@ -17523,7 +17568,6 @@
|
|||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -17725,7 +17769,6 @@
|
|||
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -17852,6 +17895,7 @@
|
|||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
|
|
@ -17938,6 +17982,7 @@
|
|||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz",
|
||||
"integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2"
|
||||
},
|
||||
|
|
@ -17962,6 +18007,7 @@
|
|||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.108.3.tgz",
|
||||
"integrity": "sha512-hOpaCHmQVVY66IVTjofnH14IgSdmod2aquSGHGuYig/OIdWge01Hk2Wt988DZcwXumFUT4+FvJY5N+ikl8o/ww==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.8",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
|
|
@ -18007,6 +18053,7 @@
|
|||
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz",
|
||||
"integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
|
|
@ -18016,6 +18063,7 @@
|
|||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
|
||||
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^4.1.1"
|
||||
|
|
@ -18029,6 +18077,7 @@
|
|||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
|
||||
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
|
|
@ -18203,6 +18252,7 @@
|
|||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
|
||||
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
|
|
@ -18311,7 +18361,6 @@
|
|||
"resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz",
|
||||
"integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"property-expr": "^2.0.5",
|
||||
"tiny-case": "^1.0.3",
|
||||
|
|
@ -18352,7 +18401,6 @@
|
|||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export default function BillingPage() {
|
|||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const auth = useAuth();
|
||||
const { config } = useAppConfig();
|
||||
const { config, loading: configLoading } = useAppConfig();
|
||||
const [credits, setCredits] = useState<MpsBillingCreditsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
|
@ -125,9 +125,9 @@ export default function BillingPage() {
|
|||
() => getPageFromSearchParams(searchParams),
|
||||
);
|
||||
|
||||
const isBillingV2 = credits?.billing_version === "v2";
|
||||
const isOssMode = config?.deploymentMode === "oss";
|
||||
const canPurchaseCredits = isBillingV2 && !isOssMode;
|
||||
const hasAppConfig = !configLoading && config !== null;
|
||||
const isOssMode = hasAppConfig && config.deploymentMode === "oss";
|
||||
const canPurchaseCredits = hasAppConfig && config.deploymentMode !== "oss";
|
||||
const totalQuota = credits?.total_quota ?? 0;
|
||||
const remainingCredits = credits?.remaining_credits ?? 0;
|
||||
const usedCredits = credits?.total_credits_used ?? 0;
|
||||
|
|
@ -229,7 +229,7 @@ export default function BillingPage() {
|
|||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (loading || configLoading) {
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -301,7 +301,7 @@ export default function BillingPage() {
|
|||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>{isBillingV2 ? "Credit balance" : "Credits remaining"}</CardDescription>
|
||||
<CardDescription>{isOssMode ? "Credits remaining" : "Credit balance"}</CardDescription>
|
||||
<CardTitle className="flex items-center gap-2 text-3xl">
|
||||
<CircleDollarSign className="h-6 w-6 text-muted-foreground" />
|
||||
{formatCredits(remainingCredits)}
|
||||
|
|
@ -319,13 +319,13 @@ export default function BillingPage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isBillingV2 ? "Total ledger debits" : "Current allocation usage"}
|
||||
{isOssMode ? "Current allocation usage" : "Total ledger debits"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{isBillingV2 ? (
|
||||
{!isOssMode ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Credit Ledger</CardTitle>
|
||||
|
|
|
|||
|
|
@ -2,24 +2,12 @@
|
|||
import ModelConfigurationV2 from "@/components/ModelConfigurationV2";
|
||||
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
|
||||
|
||||
interface ServiceConfigurationPageProps {
|
||||
searchParams?: Promise<{
|
||||
action?: string | string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function ServiceConfigurationPage({ searchParams }: ServiceConfigurationPageProps) {
|
||||
const params = searchParams ? await searchParams : {};
|
||||
const action = Array.isArray(params.action) ? params.action[0] : params.action;
|
||||
|
||||
export default function ServiceConfigurationPage() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<ModelConfigurationV2
|
||||
docsUrl={SETTINGS_DOCUMENTATION_URLS.modelOverrides}
|
||||
initialAction={action}
|
||||
/>
|
||||
<ModelConfigurationV2 docsUrl={SETTINGS_DOCUMENTATION_URLS.modelOverrides} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export const ConfigurationsDialog = ({
|
|||
turn_start_min_words: turnStartMinWords,
|
||||
provisional_vad_pause_secs: provisionalVadPauseSecs,
|
||||
turn_stop_strategy: turnStopStrategy,
|
||||
transcript_configuration: resolvedWorkflowConfigurations.transcript_configuration,
|
||||
context_compaction_enabled: contextCompactionEnabled,
|
||||
}, name);
|
||||
onOpenChange(false);
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { WorkflowConfigurations } from "@/types/workflow-configurations";
|
||||
|
||||
interface ModelConfigurationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workflowConfigurations: WorkflowConfigurations | null;
|
||||
workflowName: string;
|
||||
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ModelConfigurationDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
workflowConfigurations,
|
||||
workflowName,
|
||||
onSave,
|
||||
}: ModelConfigurationDialogProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Model Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Override global model settings for this workflow. Toggle individual services to customize.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ServiceConfigurationForm
|
||||
mode="override"
|
||||
currentOverrides={workflowConfigurations?.model_overrides}
|
||||
submitLabel="Save"
|
||||
onSave={async (config) => {
|
||||
await onSave(
|
||||
{
|
||||
...workflowConfigurations,
|
||||
model_overrides: config.model_overrides as WorkflowConfigurations["model_overrides"],
|
||||
} as WorkflowConfigurations,
|
||||
workflowName,
|
||||
);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -25,7 +25,6 @@ import {
|
|||
} from "@/components/AIModelConfigurationV2Editor";
|
||||
import { FlowEdge, FlowNode } from "@/components/flow/types";
|
||||
import { LLMConfigSelector } from "@/components/LLMConfigSelector";
|
||||
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
|
||||
import SpinLoader from "@/components/SpinLoader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
|
|
@ -48,6 +47,7 @@ import {
|
|||
DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
|
||||
DEFAULT_TURN_START_MIN_WORDS,
|
||||
DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION,
|
||||
resolveWorkflowConfigurations,
|
||||
TURN_START_STRATEGY_OPTIONS,
|
||||
type TurnStartStrategy,
|
||||
type TurnStopStrategy,
|
||||
|
|
@ -293,6 +293,9 @@ function GeneralSection({
|
|||
const [contextCompactionEnabled, setContextCompactionEnabled] = useState(
|
||||
workflowConfigurations.context_compaction_enabled,
|
||||
);
|
||||
const [includeTranscriptEndTimestamps, setIncludeTranscriptEndTimestamps] = useState(
|
||||
workflowConfigurations.transcript_configuration?.include_end_timestamps ?? false,
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
|
||||
const [audioUploadError, setAudioUploadError] = useState<string | null>(null);
|
||||
|
|
@ -314,9 +317,11 @@ function GeneralSection({
|
|||
turnStartMinWords !== workflowConfigurations.turn_start_min_words ||
|
||||
provisionalVadPauseSecs !== workflowConfigurations.provisional_vad_pause_secs ||
|
||||
turnStopStrategy !== workflowConfigurations.turn_stop_strategy ||
|
||||
contextCompactionEnabled !== workflowConfigurations.context_compaction_enabled
|
||||
contextCompactionEnabled !== workflowConfigurations.context_compaction_enabled ||
|
||||
includeTranscriptEndTimestamps !==
|
||||
(workflowConfigurations.transcript_configuration?.include_end_timestamps ?? false)
|
||||
);
|
||||
}, [name, workflowName, ambientNoiseConfig, maxCallDuration, maxUserIdleTimeout, smartTurnStopSecs, turnStartStrategy, turnStartMinWords, provisionalVadPauseSecs, turnStopStrategy, contextCompactionEnabled, workflowConfigurations]);
|
||||
}, [name, workflowName, ambientNoiseConfig, maxCallDuration, maxUserIdleTimeout, smartTurnStopSecs, turnStartStrategy, turnStartMinWords, provisionalVadPauseSecs, turnStopStrategy, contextCompactionEnabled, includeTranscriptEndTimestamps, workflowConfigurations]);
|
||||
|
||||
useUnsavedChanges("general", isDirty);
|
||||
|
||||
|
|
@ -393,6 +398,10 @@ function GeneralSection({
|
|||
provisional_vad_pause_secs: provisionalVadPauseSecs,
|
||||
turn_stop_strategy: turnStopStrategy,
|
||||
context_compaction_enabled: contextCompactionEnabled,
|
||||
transcript_configuration: {
|
||||
...(workflowConfigurations.transcript_configuration ?? {}),
|
||||
include_end_timestamps: includeTranscriptEndTimestamps,
|
||||
},
|
||||
},
|
||||
name,
|
||||
);
|
||||
|
|
@ -686,6 +695,34 @@ function GeneralSection({
|
|||
|
||||
<Separator />
|
||||
|
||||
{/* Transcript */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Transcript</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Include start and stop timestamps for each speaker in the uploaded transcript.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="transcript-end-timestamps-enabled" className="text-sm">
|
||||
Enhanced Timestamped Transcript
|
||||
</Label>
|
||||
<Switch
|
||||
id="transcript-end-timestamps-enabled"
|
||||
checked={includeTranscriptEndTimestamps}
|
||||
onCheckedChange={setIncludeTranscriptEndTimestamps}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 p-3">
|
||||
<pre className="whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
|
||||
{`[2026-07-06T10:00:00.000Z -> 2026-07-06T10:00:04.800Z] assistant: Can you confirm your date of birth?
|
||||
[2026-07-06T10:00:06.200Z -> 2026-07-06T10:00:08.700Z] user: January fifth, nineteen ninety.`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Context Compaction */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
|
|
@ -1185,17 +1222,7 @@ function WorkflowModelOverridesSection({
|
|||
setOverrideEnabled(Boolean(workflowConfigurations.model_configuration_v2_override));
|
||||
}, [workflowConfigurations.model_configuration_v2_override]);
|
||||
|
||||
const source = organizationModelConfiguration?.source || "empty";
|
||||
const isV2 = source === "organization_v2";
|
||||
|
||||
const saveLegacyOverrides = async (config: Record<string, unknown>) => {
|
||||
const nextConfigurations = withoutModelConfigurationOverrides(workflowConfigurations);
|
||||
const modelOverrides = config.model_overrides as WorkflowConfigurations["model_overrides"] | undefined;
|
||||
if (modelOverrides) {
|
||||
nextConfigurations.model_overrides = modelOverrides;
|
||||
}
|
||||
await onSave(nextConfigurations, workflowName);
|
||||
};
|
||||
const hasOrgConfiguration = organizationModelConfiguration?.source === "organization_v2";
|
||||
|
||||
const saveV2Override = async (configuration: OrganizationAiModelConfigurationV2) => {
|
||||
const nextConfigurations = withoutModelConfigurationOverrides(workflowConfigurations);
|
||||
|
|
@ -1223,9 +1250,7 @@ function WorkflowModelOverridesSection({
|
|||
Model Overrides
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isV2
|
||||
? "Override the full organization model configuration for this workflow."
|
||||
: "Override global model settings for this workflow. Toggle individual services to customize."}{" "}
|
||||
Override the full organization model configuration for this workflow.{" "}
|
||||
<a href={SETTINGS_DOCUMENTATION_URLS.modelOverrides} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
|
@ -1243,28 +1268,18 @@ function WorkflowModelOverridesSection({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!modelConfigurationLoading && !modelConfigurationError && !isV2 && (
|
||||
<>
|
||||
{source === "legacy_user_v1" && (
|
||||
<div className="flex flex-col gap-3 rounded-md border bg-muted/30 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This workflow is using legacy model overrides. Migrate organization model configuration to use v2 overrides.
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" asChild>
|
||||
<Link href="/model-configurations?action=migrate_to_v2">Migrate to v2</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ServiceConfigurationForm
|
||||
mode="override"
|
||||
currentOverrides={workflowConfigurations.model_overrides}
|
||||
submitLabel="Save Model Overrides"
|
||||
onSave={saveLegacyOverrides}
|
||||
/>
|
||||
</>
|
||||
{!modelConfigurationLoading && !modelConfigurationError && !hasOrgConfiguration && (
|
||||
<div className="flex flex-col gap-3 rounded-md border bg-muted/30 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Set up your organization model configuration before overriding it per workflow.
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" asChild>
|
||||
<Link href="/model-configurations">Configure Models</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!modelConfigurationLoading && !modelConfigurationError && isV2 && modelConfigurationDefaults && organizationModelConfiguration && (
|
||||
{!modelConfigurationLoading && !modelConfigurationError && hasOrgConfiguration && modelConfigurationDefaults && organizationModelConfiguration && (
|
||||
<>
|
||||
<div className="flex items-center justify-between rounded-md border p-4">
|
||||
<div className="space-y-0.5">
|
||||
|
|
@ -1458,6 +1473,9 @@ function WorkflowSettingsInner({
|
|||
initialWorkflowConfigurations,
|
||||
user,
|
||||
});
|
||||
const resolvedWorkflowConfigurationsForRender = workflowConfigurations
|
||||
? resolveWorkflowConfigurations(workflowConfigurations)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFetchedModelConfiguration.current) return;
|
||||
|
|
@ -1532,18 +1550,18 @@ function WorkflowSettingsInner({
|
|||
<div className="mx-auto flex max-w-5xl gap-8 px-6 py-8">
|
||||
{/* Sections */}
|
||||
<div className="min-w-0 flex-1 space-y-8">
|
||||
{workflowConfigurations && (
|
||||
{resolvedWorkflowConfigurationsForRender && (
|
||||
<>
|
||||
{/* General */}
|
||||
<GeneralSection
|
||||
workflowConfigurations={workflowConfigurations}
|
||||
workflowConfigurations={resolvedWorkflowConfigurationsForRender}
|
||||
workflowName={workflowName || workflow.name}
|
||||
workflowId={workflowId}
|
||||
onSave={saveWorkflowConfigurations}
|
||||
/>
|
||||
|
||||
<WorkflowModelOverridesSection
|
||||
workflowConfigurations={workflowConfigurations}
|
||||
workflowConfigurations={resolvedWorkflowConfigurationsForRender}
|
||||
workflowName={workflowName}
|
||||
onSave={saveWorkflowConfigurations}
|
||||
modelConfigurationDefaults={modelConfigurationDefaults}
|
||||
|
|
@ -1563,7 +1581,7 @@ function WorkflowSettingsInner({
|
|||
|
||||
{/* Voicemail Detection */}
|
||||
<VoicemailSection
|
||||
workflowConfigurations={workflowConfigurations}
|
||||
workflowConfigurations={resolvedWorkflowConfigurationsForRender}
|
||||
workflowName={workflowName}
|
||||
onSave={saveWorkflowConfigurations}
|
||||
/>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -561,7 +561,9 @@ export type ByokPipelineAiModelConfiguration = {
|
|||
provider: 'azure_speech';
|
||||
} & AzureSpeechTtsConfiguration) | ({
|
||||
provider: 'smallest';
|
||||
} & SmallestAittsConfiguration);
|
||||
} & SmallestAittsConfiguration) | ({
|
||||
provider: 'xai';
|
||||
} & XaittsConfiguration);
|
||||
/**
|
||||
* Stt
|
||||
*/
|
||||
|
|
@ -3307,10 +3309,6 @@ export type MpsBillingAccountResponse = {
|
|||
* MPSBillingCreditsResponse
|
||||
*/
|
||||
export type MpsBillingCreditsResponse = {
|
||||
/**
|
||||
* Billing Version
|
||||
*/
|
||||
billing_version: 'legacy' | 'v2';
|
||||
/**
|
||||
* Total Credits Used
|
||||
*/
|
||||
|
|
@ -3436,24 +3434,6 @@ export type MpsCreditPurchaseUrlResponse = {
|
|||
checkout_url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* MPSCreditsResponse
|
||||
*/
|
||||
export type MpsCreditsResponse = {
|
||||
/**
|
||||
* Total Credits Used
|
||||
*/
|
||||
total_credits_used: number;
|
||||
/**
|
||||
* Remaining Credits
|
||||
*/
|
||||
remaining_credits: number;
|
||||
/**
|
||||
* Total Quota
|
||||
*/
|
||||
total_quota: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* McpRefreshResponse
|
||||
*
|
||||
|
|
@ -7178,6 +7158,32 @@ export type WorkflowVersionResponse = {
|
|||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* xAI
|
||||
*/
|
||||
export type XaittsConfiguration = {
|
||||
/**
|
||||
* Provider
|
||||
*/
|
||||
provider?: 'xai';
|
||||
/**
|
||||
* Api Key
|
||||
*/
|
||||
api_key: string | Array<string>;
|
||||
/**
|
||||
* Voice
|
||||
*
|
||||
* xAI voice persona.
|
||||
*/
|
||||
voice?: string;
|
||||
/**
|
||||
* Language
|
||||
*
|
||||
* BCP-47 language code for synthesis (e.g. 'en', 'fr', 'de'), or 'auto' for automatic language detection.
|
||||
*/
|
||||
language?: string;
|
||||
};
|
||||
|
||||
export type InitiateCallApiV1TelephonyInitiateCallPostData = {
|
||||
body: InitiateCallRequest;
|
||||
headers?: {
|
||||
|
|
@ -12031,45 +12037,6 @@ export type GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse
|
|||
|
||||
export type GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse = GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponses[keyof GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponses];
|
||||
|
||||
export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/organizations/usage/mps-credits';
|
||||
};
|
||||
|
||||
export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetError = GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetErrors[keyof GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetErrors];
|
||||
|
||||
export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: MpsCreditsResponse;
|
||||
};
|
||||
|
||||
export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponse = GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses[keyof GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses];
|
||||
|
||||
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound, Save } from "lucide-react";
|
||||
import { Info, KeyRound, Save } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { OrganizationAiModelConfigurationV2 } from "@/client/types.gen";
|
||||
|
|
@ -265,6 +265,23 @@ function optionalByokService(config: Record<string, unknown>, service: ServiceSe
|
|||
return serviceConfiguration;
|
||||
}
|
||||
|
||||
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">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Third-party provider data notice</p>
|
||||
<p className="mt-1 leading-6">
|
||||
Dograh sends data required by the selected model service. This may include prompts,
|
||||
transcripts, audio, generated text, tool data, and request metadata depending on the
|
||||
provider and service type. Review the provider's data and retention policies before
|
||||
using sensitive data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIModelConfigurationV2Editor({
|
||||
defaults,
|
||||
configuration,
|
||||
|
|
@ -383,6 +400,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 />
|
||||
<ServiceConfigurationForm
|
||||
key={`realtime-${JSON.stringify(realtimeInitialConfig)}`}
|
||||
mode="global"
|
||||
|
|
@ -472,6 +490,7 @@ export function AIModelConfigurationV2Editor({
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="byok" className="mt-0">
|
||||
<ThirdPartyProviderNotice />
|
||||
<ServiceConfigurationForm
|
||||
key={`byok-${JSON.stringify(pipelineInitialConfig)}`}
|
||||
mode="global"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get,
|
||||
getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet,
|
||||
migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost,
|
||||
saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put,
|
||||
} from "@/client/sdk.gen";
|
||||
import type {
|
||||
|
|
@ -14,47 +13,22 @@ import type {
|
|||
OrganizationAiModelConfigurationV2,
|
||||
} from "@/client/types.gen";
|
||||
import { AIModelConfigurationV2Editor, type ModelConfigurationDefaultsV2 } from "@/components/AIModelConfigurationV2Editor";
|
||||
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
export default function ModelConfigurationV2({
|
||||
docsUrl,
|
||||
initialAction,
|
||||
}: {
|
||||
docsUrl?: string;
|
||||
initialAction?: string;
|
||||
}) {
|
||||
export default function ModelConfigurationV2({ docsUrl }: { docsUrl?: string }) {
|
||||
const auth = useAuth();
|
||||
const { refreshConfig, saveUserConfig } = useUserConfig();
|
||||
const { refreshConfig } = useUserConfig();
|
||||
const hasFetched = useRef(false);
|
||||
const hasAppliedInitialMigrationAction = useRef(false);
|
||||
|
||||
const [defaults, setDefaults] = useState<ModelConfigurationDefaultsV2 | null>(null);
|
||||
const [response, setResponse] = useState<OrganizationAiModelConfigurationResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const applyResponse = (nextResponse: OrganizationAiModelConfigurationResponse) => {
|
||||
setResponse(nextResponse);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.loading || !auth.user || hasFetched.current) return;
|
||||
hasFetched.current = true;
|
||||
|
|
@ -85,7 +59,7 @@ export default function ModelConfigurationV2({
|
|||
return;
|
||||
}
|
||||
setDefaults(nextDefaults);
|
||||
applyResponse(configResult.data);
|
||||
setResponse(configResult.data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
|
|
@ -93,14 +67,6 @@ export default function ModelConfigurationV2({
|
|||
|
||||
}, [auth.loading, auth.user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAppliedInitialMigrationAction.current) return;
|
||||
if (initialAction !== "migrate_to_v2") return;
|
||||
if (loading || response?.source !== "legacy_user_v1") return;
|
||||
hasAppliedInitialMigrationAction.current = true;
|
||||
setMigrationDialogOpen(true);
|
||||
}, [initialAction, loading, response?.source]);
|
||||
|
||||
const saveConfiguration = async (configuration: OrganizationAiModelConfigurationV2) => {
|
||||
if (!defaults) return;
|
||||
setError(null);
|
||||
|
|
@ -117,50 +83,11 @@ export default function ModelConfigurationV2({
|
|||
throw new Error("Failed to save model configuration");
|
||||
}
|
||||
|
||||
applyResponse(result.data);
|
||||
setResponse(result.data);
|
||||
await refreshConfig();
|
||||
setNotice("Model configuration saved");
|
||||
};
|
||||
|
||||
const migrateConfiguration = async () => {
|
||||
if (!defaults) return;
|
||||
setMigrating(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
|
||||
const result = await migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost();
|
||||
if (result.error) {
|
||||
setError(detailFromError(result.error, "Failed to migrate model configuration"));
|
||||
} else if (!result.data) {
|
||||
setError("Failed to migrate model configuration");
|
||||
} else {
|
||||
applyResponse(result.data);
|
||||
await refreshConfig();
|
||||
setNotice("Configuration migrated to v2");
|
||||
setMigrationDialogOpen(false);
|
||||
}
|
||||
setMigrating(false);
|
||||
};
|
||||
|
||||
const migrationWarningDialog = (
|
||||
<AlertDialog open={migrationDialogOpen} onOpenChange={setMigrationDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Migrate model configuration to v2?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Your configurations will be migrated to v2. After migration, check your global configuration and workflow model overrides, then run a test call to make sure everything is working.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={migrating}>Cancel</AlertDialogCancel>
|
||||
<Button type="button" onClick={migrateConfiguration} disabled={migrating}>
|
||||
{migrating ? "Migrating..." : "Migrate to v2"}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto space-y-6">
|
||||
|
|
@ -171,68 +98,6 @@ export default function ModelConfigurationV2({
|
|||
);
|
||||
}
|
||||
|
||||
const source = response?.source || "empty";
|
||||
|
||||
if (source !== "organization_v2") {
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-3xl font-bold">AI Models Configuration</h1>
|
||||
<Badge variant="outline">
|
||||
{source === "legacy_user_v1" ? "legacy" : "v1"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Configure your AI model, voice, and transcription services.{" "}
|
||||
{docsUrl && (
|
||||
<a href={docsUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">
|
||||
Learn more <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{source === "legacy_user_v1" && (
|
||||
<Button type="button" variant="outline" onClick={() => setMigrationDialogOpen(true)} disabled={migrating}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{migrating ? "Migrating..." : "Migrate to v2"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="rounded-md border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-700 dark:text-green-300">
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ServiceConfigurationForm
|
||||
mode="global"
|
||||
onSave={async (config) => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
await saveUserConfig(config as Parameters<typeof saveUserConfig>[0]);
|
||||
await refreshConfig();
|
||||
if (defaults) {
|
||||
const configResult = await getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get();
|
||||
if (configResult.data) {
|
||||
applyResponse(configResult.data);
|
||||
}
|
||||
}
|
||||
setNotice("Configuration saved");
|
||||
}}
|
||||
/>
|
||||
{migrationWarningDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
|
|
@ -268,7 +133,6 @@ export default function ModelConfigurationV2({
|
|||
onSave={saveConfiguration}
|
||||
/>
|
||||
)}
|
||||
{migrationWarningDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
|
||||
interface ServiceConfigurationProps {
|
||||
docsUrl?: string;
|
||||
}
|
||||
|
||||
export default function ServiceConfiguration({ docsUrl }: ServiceConfigurationProps) {
|
||||
const { saveUserConfig } = useUserConfig();
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold mb-2">AI Models Configuration</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure your AI model, voice, and transcription services.{" "}
|
||||
{docsUrl && (
|
||||
<a href={docsUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">
|
||||
Learn more <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ServiceConfigurationForm
|
||||
mode="global"
|
||||
onSave={async (config) => {
|
||||
await saveUserConfig(config);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { UserRound } from "lucide-react";
|
||||
import posthog from "posthog-js";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet } from "@/client/sdk.gen";
|
||||
import type { MpsCreditsResponse } from "@/client/types.gen";
|
||||
import { BuyCreditsControl } from "@/components/billing/BuyCreditsControl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { PostHogEvent } from "@/constants/posthog-events";
|
||||
import { useLeadForms } from "@/context/LeadFormsContext";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
export function DograhCreditsCard() {
|
||||
const auth = useAuth();
|
||||
const { openHireExpert, openEnterprise } = useLeadForms();
|
||||
const [mpsCredits, setMpsCredits] = useState<MpsCreditsResponse | null>(null);
|
||||
const [isLoadingCredits, setIsLoadingCredits] = useState(true);
|
||||
|
||||
const fetchMpsCredits = useCallback(async () => {
|
||||
if (!auth.isAuthenticated) return;
|
||||
try {
|
||||
const response = await getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet();
|
||||
// The generated client resolves to { data, error } and does NOT throw on
|
||||
// 4xx/5xx (see ui/AGENTS.md) — check error explicitly.
|
||||
if (response.error) {
|
||||
console.error("Failed to fetch MPS credits:", response.error);
|
||||
} else if (response.data) {
|
||||
setMpsCredits(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch MPS credits:", error);
|
||||
} finally {
|
||||
setIsLoadingCredits(false);
|
||||
}
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
fetchMpsCredits();
|
||||
}
|
||||
}, [auth.isAuthenticated, fetchMpsCredits]);
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Dograh Model Credits</CardTitle>
|
||||
<CardDescription>
|
||||
These track usage of Dograh models using Dograh Service Keys.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingCredits ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-muted rounded w-1/4"></div>
|
||||
<div className="h-8 bg-muted rounded"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/3"></div>
|
||||
</div>
|
||||
) : mpsCredits ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div>
|
||||
<p className="text-2xl font-bold">
|
||||
{mpsCredits.total_credits_used.toFixed(2)}{" "}
|
||||
<span className="text-lg font-normal text-muted-foreground">
|
||||
/ {mpsCredits.total_quota.toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Credits Used</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold">{mpsCredits.remaining_credits.toFixed(2)}</p>
|
||||
<p className="text-sm text-muted-foreground">Remaining</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mpsCredits.total_quota > 0 && (
|
||||
<Progress value={Math.min(100, (mpsCredits.total_credits_used / mpsCredits.total_quota) * 100)} className="h-3" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
No Dograh service keys configured. Set up a service key in your model configuration to see usage.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer CTAs — self-serve + done-for-you side by side, with the
|
||||
custom-pricing link directly beneath. */}
|
||||
<div className="mt-6 space-y-4 border-t pt-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Running low?</p>
|
||||
<p className="text-sm text-muted-foreground">Top up instantly, or have us build it for you.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<BuyCreditsControl className="w-full sm:flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 sm:flex-1"
|
||||
onClick={() => openHireExpert("billing_card")}
|
||||
>
|
||||
<UserRound className="h-4 w-4" />
|
||||
Hire an Expert
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
posthog.capture(PostHogEvent.CUSTOM_PRICING_CLICKED);
|
||||
openEnterprise("billing_custom_pricing");
|
||||
}}
|
||||
className="block text-xs text-muted-foreground underline decoration-dashed underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
Book a Strategy Call: custom pricing for committed volume
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ export interface RealtimeFeedbackEvent {
|
|||
final?: boolean;
|
||||
user_id?: string;
|
||||
timestamp?: string;
|
||||
end_timestamp?: string;
|
||||
function_name?: string;
|
||||
tool_call_id?: string;
|
||||
arguments?: unknown;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { client } from '@/client/client.gen';
|
||||
import { getCurrentOrganizationContextApiV1OrganizationsContextGet, getUserConfigurationsApiV1UserConfigurationsUserGet, updateUserConfigurationsApiV1UserConfigurationsUserPut } from '@/client/sdk.gen';
|
||||
import { getCurrentOrganizationContextApiV1OrganizationsContextGet, getUserConfigurationsApiV1UserConfigurationsUserGet } from '@/client/sdk.gen';
|
||||
import type { OrganizationContextResponse, UserConfigurationRequestResponseSchema } from '@/client/types.gen';
|
||||
import { setupAuthInterceptor } from '@/lib/apiClient';
|
||||
import type { AuthUser } from '@/lib/auth';
|
||||
|
|
@ -22,7 +22,6 @@ interface OrganizationPricing {
|
|||
interface OrgConfigContextType {
|
||||
orgContext: OrganizationContextResponse | null;
|
||||
userConfig: UserConfigurationRequestResponseSchema | null;
|
||||
saveUserConfig: (userConfig: UserConfigurationRequestResponseSchema) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refreshConfig: () => Promise<void>;
|
||||
|
|
@ -133,33 +132,6 @@ export function OrgConfigProvider({ children }: { children: ReactNode }) {
|
|||
fetchConfig();
|
||||
}, [auth.loading, auth.isAuthenticated, fetchConfig]);
|
||||
|
||||
const saveUserConfig = useCallback(async (userConfigRequest: UserConfigurationRequestResponseSchema) => {
|
||||
if (!authRef.current.isAuthenticated) throw new Error('No authentication available');
|
||||
const response = await updateUserConfigurationsApiV1UserConfigurationsUserPut({
|
||||
body: {
|
||||
...userConfig,
|
||||
...userConfigRequest,
|
||||
} as UserConfigurationRequestResponseSchema,
|
||||
});
|
||||
if (response.error) {
|
||||
let msg = 'Failed to save user configuration';
|
||||
const detail = (response.error as unknown as { detail?: string | { errors: { model: string; message: string }[] } }).detail;
|
||||
if (typeof detail === 'string') {
|
||||
msg = detail;
|
||||
} else if (Array.isArray(detail)) {
|
||||
msg = detail
|
||||
.map((e: { model: string; message: string }) => `${e.model}: ${e.message}`)
|
||||
.join('\n');
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
setUserConfig(response.data);
|
||||
setOrganizationPricing(pricingFromUserConfig(response.data));
|
||||
}
|
||||
}, [userConfig]);
|
||||
|
||||
const refreshConfig = useCallback(async () => {
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
|
@ -169,7 +141,6 @@ export function OrgConfigProvider({ children }: { children: ReactNode }) {
|
|||
value={{
|
||||
orgContext,
|
||||
userConfig,
|
||||
saveUserConfig,
|
||||
loading,
|
||||
error,
|
||||
refreshConfig,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,14 @@ export const DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION: VoicemailDetectionConfig
|
|||
long_speech_timeout: 8.0,
|
||||
};
|
||||
|
||||
export interface TranscriptConfiguration {
|
||||
include_end_timestamps: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_TRANSCRIPT_CONFIGURATION: TranscriptConfiguration = {
|
||||
include_end_timestamps: false,
|
||||
};
|
||||
|
||||
export interface ModelOverrides {
|
||||
llm?: {
|
||||
provider?: string;
|
||||
|
|
@ -115,6 +123,7 @@ export type WorkflowConfigurations = WorkflowConfigurationBase & {
|
|||
turn_stop_strategy: TurnStopStrategy; // Strategy for detecting end of user turn
|
||||
dictionary?: string; // Comma-separated words for voice agent to listen for
|
||||
voicemail_detection?: VoicemailDetectionConfiguration;
|
||||
transcript_configuration: TranscriptConfiguration;
|
||||
context_compaction_enabled: boolean; // Summarize context on node transitions to remove stale tool calls
|
||||
model_overrides?: ModelOverrides; // Per-workflow model configuration overrides
|
||||
model_configuration_v2_override?: OrganizationAiModelConfigurationV2; // Full v2 model configuration override
|
||||
|
|
@ -134,6 +143,7 @@ const FALLBACK_WORKFLOW_CONFIGURATIONS: WorkflowConfigurations = {
|
|||
provisional_vad_pause_secs: DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
|
||||
turn_stop_strategy: 'transcription', // Default to transcription-based detection
|
||||
dictionary: '',
|
||||
transcript_configuration: DEFAULT_TRANSCRIPT_CONFIGURATION,
|
||||
context_compaction_enabled: false,
|
||||
};
|
||||
|
||||
|
|
@ -186,5 +196,10 @@ export function resolveWorkflowConfigurations(
|
|||
configurations?.context_compaction_enabled
|
||||
?? defaults?.context_compaction_enabled
|
||||
?? FALLBACK_WORKFLOW_CONFIGURATIONS.context_compaction_enabled,
|
||||
transcript_configuration: {
|
||||
...DEFAULT_TRANSCRIPT_CONFIGURATION,
|
||||
...(defaults?.transcript_configuration as Partial<TranscriptConfiguration> | undefined),
|
||||
...(configurations?.transcript_configuration as Partial<TranscriptConfiguration> | undefined),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue