diff --git a/api/routes/organization.py b/api/routes/organization.py index 8abfa777..d6599bc8 100644 --- a/api/routes/organization.py +++ b/api/routes/organization.py @@ -17,7 +17,10 @@ from api.enums import OrganizationConfigurationKey, PostHogEvent from api.schemas.ai_model_configuration import ( DOGRAH_DEFAULT_LANGUAGE, DOGRAH_DEFAULT_VOICE, + DOGRAH_SPEED_MAX, + DOGRAH_SPEED_MIN, DOGRAH_SPEED_OPTIONS, + DOGRAH_SPEED_STEP, OrganizationAIModelConfigurationResponse, OrganizationAIModelConfigurationV2, ) @@ -265,6 +268,11 @@ async def get_model_configuration_v2_defaults( "voices": [DOGRAH_DEFAULT_VOICE], "allow_custom_input": _dograh_allows_custom_voice(), "speeds": list(DOGRAH_SPEED_OPTIONS), + "speed_range": { + "min": DOGRAH_SPEED_MIN, + "max": DOGRAH_SPEED_MAX, + "step": DOGRAH_SPEED_STEP, + }, "languages": DOGRAH_STT_LANGUAGES, "defaults": { "voice": DOGRAH_DEFAULT_VOICE, diff --git a/api/schemas/ai_model_configuration.py b/api/schemas/ai_model_configuration.py index c5403b04..05cfcf0b 100644 --- a/api/schemas/ai_model_configuration.py +++ b/api/schemas/ai_model_configuration.py @@ -18,6 +18,9 @@ from api.services.configuration.registry import ( TTSConfig, ) +DOGRAH_SPEED_MIN = 0.5 +DOGRAH_SPEED_MAX = 2.0 +DOGRAH_SPEED_STEP = 0.1 DOGRAH_SPEED_OPTIONS: tuple[float, ...] = (0.8, 1.0, 1.2) DOGRAH_DEFAULT_VOICE = "default" DOGRAH_DEFAULT_LANGUAGE = "multi" @@ -49,16 +52,9 @@ class EffectiveAIModelConfiguration(BaseModel): class DograhManagedAIModelConfiguration(BaseModel): api_key: str voice: str = DOGRAH_DEFAULT_VOICE - speed: float = Field(default=1.0) + speed: float = Field(default=1.0, ge=DOGRAH_SPEED_MIN, le=DOGRAH_SPEED_MAX) language: str = DOGRAH_DEFAULT_LANGUAGE - @model_validator(mode="after") - def validate_speed(self): - if self.speed not in DOGRAH_SPEED_OPTIONS: - allowed = ", ".join(str(speed) for speed in DOGRAH_SPEED_OPTIONS) - raise ValueError(f"Dograh speed must be one of: {allowed}") - return self - class BYOKPipelineAIModelConfiguration(BaseModel): llm: LLMConfig diff --git a/api/services/configuration/ai_model_configuration.py b/api/services/configuration/ai_model_configuration.py index c5331515..a6979528 100644 --- a/api/services/configuration/ai_model_configuration.py +++ b/api/services/configuration/ai_model_configuration.py @@ -16,7 +16,8 @@ from api.enums import OrganizationConfigurationKey from api.schemas.ai_model_configuration import ( DOGRAH_DEFAULT_LANGUAGE, DOGRAH_DEFAULT_VOICE, - DOGRAH_SPEED_OPTIONS, + DOGRAH_SPEED_MAX, + DOGRAH_SPEED_MIN, BYOKAIModelConfiguration, BYOKPipelineAIModelConfiguration, BYOKRealtimeAIModelConfiguration, @@ -436,7 +437,11 @@ def _convert_any_dograh_legacy_configuration( dograh_key: str, ) -> OrganizationAIModelConfigurationV2: speed = getattr(configuration.tts, "speed", 1.0) - if speed not in DOGRAH_SPEED_OPTIONS: + try: + speed = float(speed) + except (TypeError, ValueError): + speed = 1.0 + if not DOGRAH_SPEED_MIN <= speed <= DOGRAH_SPEED_MAX: speed = 1.0 return OrganizationAIModelConfigurationV2( mode="dograh", diff --git a/api/tests/test_ai_model_configuration_v2.py b/api/tests/test_ai_model_configuration_v2.py index e39f85e1..86faa549 100644 --- a/api/tests/test_ai_model_configuration_v2.py +++ b/api/tests/test_ai_model_configuration_v2.py @@ -59,13 +59,27 @@ def test_dograh_v2_compiles_to_effective_managed_pipeline_with_embeddings(): assert effective.managed_service_version == 2 -def test_dograh_v2_rejects_non_predefined_speed(): +def test_dograh_v2_accepts_numeric_speed_in_registry_range(): + config = OrganizationAIModelConfigurationV2( + mode="dograh", + dograh=DograhManagedAIModelConfiguration( + api_key="mps-secret", + speed=1.5, + ), + ) + + effective = compile_ai_model_configuration_v2(config) + + assert effective.tts.speed == 1.5 + + +def test_dograh_v2_rejects_out_of_range_speed(): with pytest.raises(ValidationError): OrganizationAIModelConfigurationV2( mode="dograh", dograh=DograhManagedAIModelConfiguration( api_key="mps-secret", - speed=1.5, + speed=2.5, ), ) @@ -238,6 +252,33 @@ def test_legacy_all_dograh_pipeline_converts_to_dograh_v2(): assert config.dograh.api_key == "mps-secret" +def test_legacy_dograh_pipeline_conversion_preserves_numeric_speed(): + legacy = EffectiveAIModelConfiguration( + llm=DograhLLMService( + provider="dograh", + api_key=["mps-secret"], + model="default", + ), + tts=DograhTTSService( + provider="dograh", + api_key=["mps-secret"], + model="default", + voice="default", + speed=1.5, + ), + stt=DograhSTTService( + provider="dograh", + api_key=["mps-secret"], + model="default", + ), + ) + + config = convert_legacy_ai_model_configuration_to_v2(legacy) + + assert config.mode == "dograh" + assert config.dograh.speed == 1.5 + + def test_legacy_mixed_dograh_pipeline_converts_to_dograh_v2(): legacy = EffectiveAIModelConfiguration( llm=OpenAILLMService( diff --git a/ui/src/components/AIModelConfigurationV2Editor.tsx b/ui/src/components/AIModelConfigurationV2Editor.tsx index 86a2b9a0..1a1db1c1 100644 --- a/ui/src/components/AIModelConfigurationV2Editor.tsx +++ b/ui/src/components/AIModelConfigurationV2Editor.tsx @@ -25,6 +25,11 @@ interface DograhDefaults { voices: string[]; allow_custom_input?: boolean; speeds: number[]; + speed_range?: { + min: number; + max: number; + step?: number; + }; languages: string[]; defaults: { voice: string; @@ -66,6 +71,11 @@ function firstApiKey(value: unknown): string { return typeof value === "string" ? value : ""; } +function numberOrDefault(value: unknown, fallback: number): number { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record @@ -170,7 +180,7 @@ function buildDograhState( return { api_key: String(configuredDograh.api_key || ""), voice: String(configuredDograh.voice || fallback.voice), - speed: Number(configuredDograh.speed || fallback.speed), + speed: numberOrDefault(configuredDograh.speed, fallback.speed), language: String(configuredDograh.language || fallback.language), }; } @@ -182,7 +192,7 @@ function buildDograhState( return { api_key: firstApiKey(llm?.api_key || tts?.api_key || stt?.api_key), voice: String(tts?.voice || fallback.voice), - speed: Number(tts?.speed || fallback.speed), + speed: numberOrDefault(tts?.speed, fallback.speed), language: String(stt?.language || fallback.language), }; } @@ -272,6 +282,7 @@ export function AIModelConfigurationV2Editor({ const [error, setError] = useState(null); const allowCustomVoice = defaults.dograh.allow_custom_input ?? false; + const dograhSpeedRange = defaults.dograh.speed_range ?? { min: 0.5, max: 2.0, step: 0.1 }; useEffect(() => { const rawConfiguration = asRecord(configuration); @@ -288,6 +299,15 @@ export function AIModelConfigurationV2Editor({ setIsSavingDograh(true); setError(null); try { + if ( + !Number.isFinite(dograh.speed) + || dograh.speed < dograhSpeedRange.min + || dograh.speed > dograhSpeedRange.max + ) { + throw new Error( + `Dograh speed must be between ${dograhSpeedRange.min} and ${dograhSpeedRange.max}.`, + ); + } await onSave({ version: 2, mode: "dograh", @@ -413,22 +433,22 @@ export function AIModelConfigurationV2Editor({
- - + + { + const speed = event.currentTarget.valueAsNumber; + setDograh({ + ...dograh, + speed: Number.isFinite(speed) ? speed : defaults.dograh.defaults.speed, + }); + }} + />