mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
feat: cap max_call_duration at 20 min via typed workflow_configurations request
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
41e887ec57
commit
d9f5237b63
8 changed files with 229 additions and 27 deletions
|
|
@ -687,6 +687,7 @@ async def _handle_telephony_websocket(
|
|||
# WebSocket already closed, ignore
|
||||
pass
|
||||
|
||||
|
||||
@router.post("/inbound/run")
|
||||
async def handle_inbound_run(request: Request):
|
||||
"""Workflow-agnostic inbound dispatcher.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from api.db.workflow_template_client import WorkflowTemplateClient
|
|||
from api.enums import CallType, PostHogEvent, StorageBackend, WorkflowStatus
|
||||
from api.schemas.ai_model_configuration import OrganizationAIModelConfigurationV2
|
||||
from api.schemas.workflow import WorkflowRunResponseSchema
|
||||
from api.schemas.workflow_configurations import WorkflowConfigurationDefaults
|
||||
from api.sdk_expose import sdk_expose
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.configuration.ai_model_configuration import (
|
||||
|
|
@ -284,7 +285,10 @@ class UpdateWorkflowRequest(BaseModel):
|
|||
name: str | None = None
|
||||
workflow_definition: dict | None = None
|
||||
template_context_variables: dict | None = None
|
||||
workflow_configurations: dict | None = None
|
||||
# Typed so field constraints (e.g. the max_call_duration cap) are
|
||||
# enforced by FastAPI; extra="allow" keeps passthrough keys like
|
||||
# model_configuration_v2_override intact.
|
||||
workflow_configurations: WorkflowConfigurationDefaults | None = None
|
||||
|
||||
|
||||
class WorkflowVersionResponse(BaseModel):
|
||||
|
|
@ -1039,7 +1043,13 @@ async def update_workflow(
|
|||
|
||||
# Validate model overrides. v2 uses a complete workflow-level model
|
||||
# configuration; legacy v1 uses partial service overlays.
|
||||
workflow_configurations = request.workflow_configurations
|
||||
# exclude_unset keeps stored configs sparse: keys the request didn't
|
||||
# send stay absent so runtime defaults keep applying to them.
|
||||
workflow_configurations = (
|
||||
request.workflow_configurations.model_dump(exclude_unset=True)
|
||||
if request.workflow_configurations is not None
|
||||
else None
|
||||
)
|
||||
if workflow_configurations and workflow_configurations.get(
|
||||
WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY
|
||||
):
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
DEFAULT_MAX_CALL_DURATION_SECONDS = 300
|
||||
# Hard ceiling on configurable call duration. Must stay <= the concurrency
|
||||
# rate limiter's stale_call_timeout (20 min): a call running past that has
|
||||
# its slot purged as stale and the org concurrency limit under-counts.
|
||||
MAX_CALL_DURATION_SECONDS = 1200
|
||||
DEFAULT_MAX_USER_IDLE_TIMEOUT_SECONDS = 10.0
|
||||
DEFAULT_SMART_TURN_STOP_SECS = 2.0
|
||||
DEFAULT_TURN_START_STRATEGY = "default"
|
||||
|
|
@ -22,10 +26,24 @@ class AmbientNoiseConfigurationDefaults(BaseModel):
|
|||
class WorkflowConfigurationDefaults(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _treat_null_as_unset(cls, data):
|
||||
# Stored configs (and older clients) carry explicit JSON nulls for
|
||||
# keys the user never configured; dropping them lets the field
|
||||
# defaults apply instead of failing validation.
|
||||
if isinstance(data, dict):
|
||||
return {k: v for k, v in data.items() if v is not None}
|
||||
return data
|
||||
|
||||
ambient_noise_configuration: AmbientNoiseConfigurationDefaults = Field(
|
||||
default_factory=AmbientNoiseConfigurationDefaults
|
||||
)
|
||||
max_call_duration: int = DEFAULT_MAX_CALL_DURATION_SECONDS
|
||||
max_call_duration: int = Field(
|
||||
default=DEFAULT_MAX_CALL_DURATION_SECONDS,
|
||||
gt=0,
|
||||
le=MAX_CALL_DURATION_SECONDS,
|
||||
)
|
||||
max_user_idle_timeout: float = DEFAULT_MAX_USER_IDLE_TIMEOUT_SECONDS
|
||||
smart_turn_stop_secs: float = DEFAULT_SMART_TURN_STOP_SECS
|
||||
turn_start_strategy: Literal["default", "min_words", "provisional_vad"] = (
|
||||
|
|
|
|||
|
|
@ -845,9 +845,7 @@ class TestAcquireConcurrentSlotScoping:
|
|||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_campaign_max_concurrency_skips_scope(
|
||||
self, mock_call_concurrency
|
||||
):
|
||||
async def test_no_campaign_max_concurrency_skips_scope(self, mock_call_concurrency):
|
||||
dispatcher = CampaignCallDispatcher()
|
||||
campaign = self._campaign({})
|
||||
|
||||
|
|
|
|||
61
api/tests/test_workflow_configurations_schema.py
Normal file
61
api/tests/test_workflow_configurations_schema.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from api.schemas.workflow_configurations import (
|
||||
DEFAULT_MAX_CALL_DURATION_SECONDS,
|
||||
MAX_CALL_DURATION_SECONDS,
|
||||
WorkflowConfigurationDefaults,
|
||||
)
|
||||
|
||||
|
||||
def test_max_call_duration_default_within_bounds():
|
||||
config = WorkflowConfigurationDefaults()
|
||||
assert config.max_call_duration == DEFAULT_MAX_CALL_DURATION_SECONDS
|
||||
|
||||
|
||||
def test_max_call_duration_accepts_cap():
|
||||
config = WorkflowConfigurationDefaults(max_call_duration=MAX_CALL_DURATION_SECONDS)
|
||||
assert config.max_call_duration == MAX_CALL_DURATION_SECONDS
|
||||
|
||||
|
||||
def test_max_call_duration_rejects_over_cap():
|
||||
with pytest.raises(ValidationError):
|
||||
WorkflowConfigurationDefaults(max_call_duration=MAX_CALL_DURATION_SECONDS + 1)
|
||||
|
||||
|
||||
def test_max_call_duration_rejects_non_positive():
|
||||
with pytest.raises(ValidationError):
|
||||
WorkflowConfigurationDefaults(max_call_duration=0)
|
||||
|
||||
|
||||
def test_null_values_treated_as_unset():
|
||||
"""Stored configs / older clients send explicit JSON nulls for keys the
|
||||
user never configured; they must validate as defaults, not fail."""
|
||||
config = WorkflowConfigurationDefaults.model_validate(
|
||||
{
|
||||
"max_call_duration": None,
|
||||
"turn_start_strategy": None,
|
||||
"turn_start_min_words": None,
|
||||
}
|
||||
)
|
||||
assert config.max_call_duration == DEFAULT_MAX_CALL_DURATION_SECONDS
|
||||
# Nulls count as unset, so a sparse round-trip drops them entirely.
|
||||
assert config.model_dump(exclude_unset=True) == {}
|
||||
|
||||
|
||||
def test_exclude_unset_round_trip_stays_sparse():
|
||||
config = WorkflowConfigurationDefaults.model_validate(
|
||||
{"max_call_duration": 600, "custom_extra_key": {"a": 1}}
|
||||
)
|
||||
assert config.model_dump(exclude_unset=True) == {
|
||||
"max_call_duration": 600,
|
||||
"custom_extra_key": {"a": 1},
|
||||
}
|
||||
|
||||
|
||||
def test_cap_stays_within_concurrency_stale_timeout():
|
||||
"""A call outliving the rate limiter's stale window has its concurrency
|
||||
slot purged mid-call, so the cap must never exceed it."""
|
||||
from api.services.campaign.rate_limiter import rate_limiter
|
||||
|
||||
assert MAX_CALL_DURATION_SECONDS <= rate_limiter.stale_call_timeout
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,6 @@
|
|||
# generated by datamodel-codegen:
|
||||
# filename: dograh-openapi-XXXXXX.json.d7UvbEKHrl
|
||||
# timestamp: 2026-07-03T12:48:12+00:00
|
||||
# filename: dograh-openapi-XXXXXX.json.0wZwIU8qiw
|
||||
# timestamp: 2026-07-09T12:46:24+00:00
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -10,6 +10,14 @@ from typing import Annotated, Any, Literal
|
|||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel
|
||||
|
||||
|
||||
class AmbientNoiseConfigurationDefaults(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='allow',
|
||||
)
|
||||
enabled: Annotated[bool | None, Field(title='Enabled')] = False
|
||||
volume: Annotated[float | None, Field(title='Volume')] = 0.3
|
||||
|
||||
|
||||
class CalculatorToolDefinition(BaseModel):
|
||||
"""
|
||||
Tool definition for Calculator tools.
|
||||
|
|
@ -582,19 +590,6 @@ class TransferCallToolDefinition(BaseModel):
|
|||
"""
|
||||
|
||||
|
||||
class UpdateWorkflowRequest(BaseModel):
|
||||
name: Annotated[str | None, Field(title='Name')] = None
|
||||
workflow_definition: Annotated[
|
||||
dict[str, Any] | None, Field(title='Workflow Definition')
|
||||
] = None
|
||||
template_context_variables: Annotated[
|
||||
dict[str, Any] | None, Field(title='Template Context Variables')
|
||||
] = None
|
||||
workflow_configurations: Annotated[
|
||||
dict[str, Any] | None, Field(title='Workflow Configurations')
|
||||
] = None
|
||||
|
||||
|
||||
class ValidationError(BaseModel):
|
||||
loc: Annotated[list[str | int], Field(title='Location')]
|
||||
msg: Annotated[str, Field(title='Message')]
|
||||
|
|
@ -603,6 +598,47 @@ class ValidationError(BaseModel):
|
|||
ctx: Annotated[dict[str, Any] | None, Field(title='Context')] = None
|
||||
|
||||
|
||||
class TurnStartStrategy(Enum):
|
||||
default = 'default'
|
||||
min_words = 'min_words'
|
||||
provisional_vad = 'provisional_vad'
|
||||
|
||||
|
||||
class TurnStopStrategy(Enum):
|
||||
transcription = 'transcription'
|
||||
turn_analyzer = 'turn_analyzer'
|
||||
|
||||
|
||||
class WorkflowConfigurationDefaults(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra='allow',
|
||||
)
|
||||
ambient_noise_configuration: AmbientNoiseConfigurationDefaults | None = None
|
||||
max_call_duration: Annotated[
|
||||
int | None, Field(gt=0, le=1200, title='Max Call Duration')
|
||||
] = 300
|
||||
max_user_idle_timeout: Annotated[
|
||||
float | None, Field(title='Max User Idle Timeout')
|
||||
] = 10.0
|
||||
smart_turn_stop_secs: Annotated[
|
||||
float | None, Field(title='Smart Turn Stop Secs')
|
||||
] = 2.0
|
||||
turn_start_strategy: Annotated[
|
||||
TurnStartStrategy | None, Field(title='Turn Start Strategy')
|
||||
] = 'default'
|
||||
turn_start_min_words: Annotated[int | None, Field(title='Turn Start Min Words')] = 3
|
||||
provisional_vad_pause_secs: Annotated[
|
||||
float | None, Field(title='Provisional Vad Pause Secs')
|
||||
] = 1.5
|
||||
turn_stop_strategy: Annotated[
|
||||
TurnStopStrategy | None, Field(title='Turn Stop Strategy')
|
||||
] = 'transcription'
|
||||
dictionary: Annotated[str | None, Field(title='Dictionary')] = ''
|
||||
context_compaction_enabled: Annotated[
|
||||
bool | None, Field(title='Context Compaction Enabled')
|
||||
] = False
|
||||
|
||||
|
||||
class WorkflowListResponse(BaseModel):
|
||||
"""
|
||||
Lightweight response for workflow listings (excludes large fields).
|
||||
|
|
@ -778,6 +814,17 @@ class RecordingListResponseSchema(BaseModel):
|
|||
total: Annotated[int, Field(title='Total')]
|
||||
|
||||
|
||||
class UpdateWorkflowRequest(BaseModel):
|
||||
name: Annotated[str | None, Field(title='Name')] = None
|
||||
workflow_definition: Annotated[
|
||||
dict[str, Any] | None, Field(title='Workflow Definition')
|
||||
] = None
|
||||
template_context_variables: Annotated[
|
||||
dict[str, Any] | None, Field(title='Template Context Variables')
|
||||
] = None
|
||||
workflow_configurations: WorkflowConfigurationDefaults | None = None
|
||||
|
||||
|
||||
class CreateToolRequest(BaseModel):
|
||||
"""
|
||||
Request schema for creating a reusable tool.
|
||||
|
|
|
|||
|
|
@ -272,6 +272,21 @@ export interface paths {
|
|||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/** AmbientNoiseConfigurationDefaults */
|
||||
AmbientNoiseConfigurationDefaults: {
|
||||
/**
|
||||
* Enabled
|
||||
* @default false
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Volume
|
||||
* @default 0.3
|
||||
*/
|
||||
volume: number;
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* CalculatorToolDefinition
|
||||
* @description Tool definition for Calculator tools.
|
||||
|
|
@ -1079,10 +1094,7 @@ export interface components {
|
|||
template_context_variables?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Workflow Configurations */
|
||||
workflow_configurations?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
workflow_configurations?: components["schemas"]["WorkflowConfigurationDefaults"] | null;
|
||||
};
|
||||
/** ValidationError */
|
||||
ValidationError: {
|
||||
|
|
@ -1097,6 +1109,59 @@ export interface components {
|
|||
/** Context */
|
||||
ctx?: Record<string, never>;
|
||||
};
|
||||
/** WorkflowConfigurationDefaults */
|
||||
WorkflowConfigurationDefaults: {
|
||||
ambient_noise_configuration?: components["schemas"]["AmbientNoiseConfigurationDefaults"];
|
||||
/**
|
||||
* Max Call Duration
|
||||
* @default 300
|
||||
*/
|
||||
max_call_duration: number;
|
||||
/**
|
||||
* Max User Idle Timeout
|
||||
* @default 10
|
||||
*/
|
||||
max_user_idle_timeout: number;
|
||||
/**
|
||||
* Smart Turn Stop Secs
|
||||
* @default 2
|
||||
*/
|
||||
smart_turn_stop_secs: number;
|
||||
/**
|
||||
* Turn Start Strategy
|
||||
* @default default
|
||||
* @enum {string}
|
||||
*/
|
||||
turn_start_strategy: "default" | "min_words" | "provisional_vad";
|
||||
/**
|
||||
* Turn Start Min Words
|
||||
* @default 3
|
||||
*/
|
||||
turn_start_min_words: number;
|
||||
/**
|
||||
* Provisional Vad Pause Secs
|
||||
* @default 1.5
|
||||
*/
|
||||
provisional_vad_pause_secs: number;
|
||||
/**
|
||||
* Turn Stop Strategy
|
||||
* @default transcription
|
||||
* @enum {string}
|
||||
*/
|
||||
turn_stop_strategy: "transcription" | "turn_analyzer";
|
||||
/**
|
||||
* Dictionary
|
||||
* @default
|
||||
*/
|
||||
dictionary: string;
|
||||
/**
|
||||
* Context Compaction Enabled
|
||||
* @default false
|
||||
*/
|
||||
context_compaction_enabled: boolean;
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* WorkflowListResponse
|
||||
* @description Lightweight response for workflow listings (excludes large fields).
|
||||
|
|
@ -1164,6 +1229,7 @@ export interface components {
|
|||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type AmbientNoiseConfigurationDefaults = components['schemas']['AmbientNoiseConfigurationDefaults'];
|
||||
export type CalculatorToolDefinition = components['schemas']['CalculatorToolDefinition'];
|
||||
export type CallDispositionCodes = components['schemas']['CallDispositionCodes'];
|
||||
export type CreateToolRequest = components['schemas']['CreateToolRequest'];
|
||||
|
|
@ -1201,6 +1267,7 @@ export type TransferCallConfig = components['schemas']['TransferCallConfig'];
|
|||
export type TransferCallToolDefinition = components['schemas']['TransferCallToolDefinition'];
|
||||
export type UpdateWorkflowRequest = components['schemas']['UpdateWorkflowRequest'];
|
||||
export type ValidationError = components['schemas']['ValidationError'];
|
||||
export type WorkflowConfigurationDefaults = components['schemas']['WorkflowConfigurationDefaults'];
|
||||
export type WorkflowListResponse = components['schemas']['WorkflowListResponse'];
|
||||
export type WorkflowResponse = components['schemas']['WorkflowResponse'];
|
||||
export type $defs = Record<string, never>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue