From 934052be34e3082d0103ae650c9e2cd4f5b46682 Mon Sep 17 00:00:00 2001 From: Prajna1999 Date: Tue, 28 Jul 2026 15:20:02 +0530 Subject: [PATCH 1/2] genesis commit, yolo --- backend/app/api/routes/llm.py | 68 +++++--- backend/app/api/routes/llm_sts.py | 35 +--- backend/app/core/langfuse/langfuse.py | 6 +- backend/app/crud/assessment/batch.py | 8 +- backend/app/crud/evaluations/core.py | 9 +- backend/app/crud/model_config.py | 13 +- backend/app/models/llm/__init__.py | 5 + backend/app/models/llm/constants.py | 32 ++++ backend/app/models/llm/request.py | 156 +++++++++++------- backend/app/services/evaluations/batch_job.py | 3 +- backend/app/services/llm/chain/utils.py | 38 +---- backend/app/services/llm/jobs.py | 16 +- backend/app/services/llm/mappers.py | 36 +++- .../tests/api/routes/configs/test_version.py | 28 ++-- .../tests/api/routes/test_evaluation_fast.py | 6 +- .../tests/api/routes/test_evaluation_v2.py | 4 +- .../tests/api/routes/test_improve_prompt.py | 4 +- backend/app/tests/api/routes/test_llm.py | 4 +- .../tests/crud/evaluations/test_fast_judge.py | 4 +- backend/app/tests/crud/test_llm.py | 8 +- backend/app/tests/models/llm/test_request.py | 25 +-- backend/app/tests/services/llm/test_jobs.py | 25 +-- .../app/tests/services/llm/test_mappers.py | 20 +-- backend/app/tests/services/llm/test_sts.py | 99 ++++++----- backend/app/tests/utils/llm.py | 6 +- backend/app/tests/utils/test_data.py | 6 +- 26 files changed, 392 insertions(+), 272 deletions(-) diff --git a/backend/app/api/routes/llm.py b/backend/app/api/routes/llm.py index 8ce96cf76..8aff6615d 100644 --- a/backend/app/api/routes/llm.py +++ b/backend/app/api/routes/llm.py @@ -4,6 +4,8 @@ from fastapi import APIRouter, Depends, HTTPException from opentelemetry import trace +from pydantic import TypeAdapter +from sqlmodel import Session from app.api.deps import AuthContextDep, SessionDep from app.api.permissions import Permission, require_permission @@ -19,13 +21,50 @@ LLMJobPublic, JobStatus, ) -from app.models.llm.response import LLMResponse, Usage +from app.models.llm.response import LLMOutput, LLMResponse, Usage from app.services.llm.jobs import start_job from app.utils import APIResponse, validate_callback_url, load_description logger = logging.getLogger(__name__) router = APIRouter(tags=["LLM"]) + +_LLM_OUTPUT_ADAPTER: TypeAdapter[LLMOutput] = TypeAdapter(LLMOutput) + + +def _resolve_llm_output( + raw_content: dict, + project_id: int, + session: Session, + job_id: UUID, +) -> LLMOutput | None: + """Parse the persisted `llm_call.content` dict into the typed LLMOutput, + presigning the audio URL in place first. + + Persisted TTS content marks a not-yet-presigned S3 path with format="uri" — + not a valid AudioContent literal ("base64"/"url") — so that sentinel must be + resolved to a real "url" before the dict can validate into the typed model. + """ + inner = raw_content.get("content") + if ( + raw_content.get("type") == "audio" + and isinstance(inner, dict) + and inner.get("format") == "uri" + ): + s3_path = inner.get("value", "") + try: + storage = get_cloud_storage(session, project_id) + inner["value"] = storage.get_signed_url(s3_path, expires_in=3600) + except Exception as e: + logger.warning( + f"[get_llm_call_status] Failed to generate presigned URL for audio: {e} | job_id={job_id}" + ) + inner["value"] = "" + inner["format"] = "url" + + return _LLM_OUTPUT_ADAPTER.validate_python(raw_content) + + llm_callback_router = APIRouter() @@ -155,32 +194,19 @@ def get_llm_call_status( # Get the first LLM call from the list which will be the only call for the job id # since we initially won't be using this endpoint for llm chains llm_call = llm_calls[0] - output_payload = copy.deepcopy(llm_call.content) - if ( - isinstance(output_payload, dict) - and output_payload.get("type") == "audio" - and isinstance(output_payload.get("content"), dict) - and output_payload["content"].get("format") == "uri" - ): - s3_path = output_payload["content"].get("value", "") - try: - storage = get_cloud_storage(session, project_id) - output_payload["content"]["value"] = storage.get_signed_url( - s3_path, expires_in=3600 - ) - except Exception as e: - logger.warning( - f"[get_llm_call_status] Failed to generate presigned URL for audio: {e} | job_id={job_id}" - ) - output_payload["content"]["value"] = "" - output_payload["content"]["format"] = "url" + raw_content = copy.deepcopy(llm_call.content) + output = ( + _resolve_llm_output(raw_content, project_id, session, job_id) + if isinstance(raw_content, dict) + else None + ) llm_response = LLMResponse( provider_response_id=llm_call.provider_response_id or "", conversation_id=llm_call.conversation_id, provider=llm_call.provider, model=llm_call.model, - output=output_payload, + output=output, ) usage_payload = llm_call.usage diff --git a/backend/app/api/routes/llm_sts.py b/backend/app/api/routes/llm_sts.py index fd4f72a73..9d2cb5b49 100644 --- a/backend/app/api/routes/llm_sts.py +++ b/backend/app/api/routes/llm_sts.py @@ -4,7 +4,7 @@ from typing import Any, Literal from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends from app.api.deps import AuthContextDep, SessionDep from app.api.permissions import Permission, require_permission @@ -18,7 +18,6 @@ from app.models.llm.request import ( ChainBlock, ConfigBlob, - KaapiCompletionConfig, LLMCallConfig, LLMChainRequest, QueryParams, @@ -29,11 +28,9 @@ TextLLMParams, TTSBlockSpec, TTSLLMParams, + build_kaapi_completion_config, ) -from app.services.llm.chain.utils import ( - DEFAULT_RAG_INSTRUCTIONS, - SUPPORTED_LANGUAGE_CODES, -) +from app.services.llm.chain.utils import DEFAULT_RAG_INSTRUCTIONS from app.services.llm.jobs import start_chain_job from app.utils import APIResponse, load_description, validate_callback_url @@ -116,10 +113,10 @@ def _inline_call_config( ) -> LLMCallConfig: return LLMCallConfig( blob=ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider=provider, type=type_, - params=params.model_dump(exclude_none=True), + params=params, ) ) ) @@ -221,25 +218,9 @@ def speech_to_speech( if request.callback_url: validate_callback_url(str(request.callback_url)) - if ( - request.input_language - and request.input_language not in SUPPORTED_LANGUAGE_CODES - ): - raise HTTPException( - status_code=422, - detail=f"Unsupported input language code: {request.input_language}. Supported: {', '.join(SUPPORTED_LANGUAGE_CODES)}", - ) - - if request.output_language and ( - request.output_language not in SUPPORTED_LANGUAGE_CODES - or request.output_language in ("auto", "unknown") - ): - tts_supported = SUPPORTED_LANGUAGE_CODES - {"auto", "unknown"} - raise HTTPException( - status_code=422, - detail=f"Unsupported output language code: {request.output_language}. Supported: {', '.join(tts_supported)}", - ) - + # Code membership + the auto/unknown exclusion on output_language are now + # enforced by SpeechToSpeechRequest itself (STSLanguageCode Literal + + # validate_output_language), so FastAPI 422s before this handler runs. input_lang, output_lang = _resolve_languages(request) blocks = [ diff --git a/backend/app/core/langfuse/langfuse.py b/backend/app/core/langfuse/langfuse.py index 15b6ae4b0..cc617bce1 100644 --- a/backend/app/core/langfuse/langfuse.py +++ b/backend/app/core/langfuse/langfuse.py @@ -358,7 +358,11 @@ def langfuse_call(fn, *args, **kwargs): as_type="generation", name=f"{completion_config.provider}-completion", input=query.input, - model=completion_config.params.get("model"), + model=( + completion_config.params.get("model") + if isinstance(completion_config.params, dict) + else getattr(completion_config.params, "model", None) + ), ) response: LLMCallResponse | None diff --git a/backend/app/crud/assessment/batch.py b/backend/app/crud/assessment/batch.py index 2c2416086..12d477d8b 100644 --- a/backend/app/crud/assessment/batch.py +++ b/backend/app/crud/assessment/batch.py @@ -414,7 +414,13 @@ def submit_assessment_batch( completion = config_blob.completion provider_name = completion.provider or "openai" - params = dict(completion.params) + # Native params are a plain dict; Kaapi params are now a typed submodel. + raw_params = completion.params + params = ( + dict(raw_params) + if isinstance(raw_params, dict) + else raw_params.model_dump(exclude_none=True) + ) params.pop("instructions", None) params.pop("system_instruction", None) if isinstance(system_instruction, str) and system_instruction.strip(): diff --git a/backend/app/crud/evaluations/core.py b/backend/app/crud/evaluations/core.py index ed15f720e..62c36d016 100644 --- a/backend/app/crud/evaluations/core.py +++ b/backend/app/crud/evaluations/core.py @@ -597,8 +597,13 @@ def resolve_model_from_config( f"(config_id={eval_run.config_id}, version={eval_run.config_version}): {error}" ) - # params is a dict, not a Pydantic model, so use dict access - model = config.completion.params.get("model") + # Native params are a plain dict; Kaapi params are now a typed submodel. + completion_params = config.completion.params + model = ( + completion_params.get("model") + if isinstance(completion_params, dict) + else getattr(completion_params, "model", None) + ) if not model: raise ValueError( f"Config for evaluation {eval_run.id} does not contain a 'model' parameter" diff --git a/backend/app/crud/model_config.py b/backend/app/crud/model_config.py index 2cc2b3ef8..c0a22a901 100644 --- a/backend/app/crud/model_config.py +++ b/backend/app/crud/model_config.py @@ -151,7 +151,12 @@ def validate_blob_model_or_raise(session: Session, blob: ConfigBlob) -> None: provider = _normalize_provider(raw_provider) - model_name = (completion.params or {}).get("model") or None + params = completion.params + model_name = ( + params.get("model") + if isinstance(params, dict) + else getattr(params, "model", None) + ) or None if not model_name: raise HTTPException( status_code=400, @@ -170,7 +175,11 @@ def validate_blob_model_or_raise(session: Session, blob: ConfigBlob) -> None: ) if completion_type == "tts" and model_row is not None: - voice = (completion.params or {}).get("voice") + voice = ( + params.get("voice") + if isinstance(params, dict) + else getattr(params, "voice", None) + ) voice_spec = ( model_row.config.get("voice") if isinstance(model_row.config, dict) diff --git a/backend/app/models/llm/__init__.py b/backend/app/models/llm/__init__.py index cf3796a63..9860f8d62 100644 --- a/backend/app/models/llm/__init__.py +++ b/backend/app/models/llm/__init__.py @@ -5,6 +5,11 @@ ConfigBlob, KaapiLLMParams, KaapiCompletionConfig, + KaapiTextCompletionConfig, + KaapiSTTCompletionConfig, + KaapiTTSCompletionConfig, + ProxyCompletionConfig, + build_kaapi_completion_config, NativeCompletionConfig, LlmCall, AudioContent, diff --git a/backend/app/models/llm/constants.py b/backend/app/models/llm/constants.py index 31ac8352a..f70e83145 100644 --- a/backend/app/models/llm/constants.py +++ b/backend/app/models/llm/constants.py @@ -63,6 +63,38 @@ class Modality(StrEnum): FILES = "FILES" +# BCP-47 language codes accepted by the speech-to-speech endpoint (STT input / +# TTS output). Single source of truth: `SUPPORTED_LANGUAGE_CODES` in +# `app/services/llm/chain/utils.py` derives from this via `get_args`. +STSLanguageCode = Literal[ + "auto", + "unknown", + "en-IN", + "hi-IN", + "bn-IN", + "kn-IN", + "ml-IN", + "mr-IN", + "od-IN", + "pa-IN", + "ta-IN", + "te-IN", + "gu-IN", + "as-IN", + "ur-IN", + "ne-IN", + "kok-IN", + "ks-IN", + "sd-IN", + "sa-IN", + "sat-IN", + "mni-IN", + "brx-IN", + "mai-IN", + "doi-IN", +] + + DEFAULT_STT_MODEL = "gemini-2.5-pro" DEFAULT_TTS_MODEL = "gemini-2.5-flash-preview-tts" DEFAULT_TTS_VOICE = "Kore" diff --git a/backend/app/models/llm/request.py b/backend/app/models/llm/request.py index 227b83cf6..52256130d 100644 --- a/backend/app/models/llm/request.py +++ b/backend/app/models/llm/request.py @@ -18,6 +18,7 @@ NativeProvider, Provider, RAGProvider, + STSLanguageCode, STTProvider, TTSProvider, ) @@ -282,12 +283,8 @@ class NativeCompletionConfig(SQLModel): ) -class KaapiCompletionConfig(SQLModel): - """ - Kaapi abstraction for LLM completion providers. - Uses standardized Kaapi parameters that are mapped to provider-specific APIs internally. - Supports multiple providers: OpenAI, Claude, Gemini, etc. - """ +class _KaapiCompletionConfigBase(SQLModel): + """Common fields for the per-type Kaapi completion config variants.""" provider: KaapiProvider | None = Field( None, @@ -298,39 +295,81 @@ class KaapiCompletionConfig(SQLModel): ), ) - type: CompletionType = Field( + +class KaapiTextCompletionConfig(_KaapiCompletionConfigBase): + type: Literal[CompletionType.TEXT] = Field( ..., description="Completion config type. Params schema varies by type" ) - params: dict[str, Any] = Field( - ..., - description="Kaapi-standardized parameters mapped to provider-specific API", + params: TextLLMParams = Field( + ..., description="Kaapi-standardized parameters mapped to provider-specific API" ) - # validate all these 3 config types - @model_validator(mode="after") - def validate_params(self): - param_models = { - "text": TextLLMParams, - "stt": STTLLMParams, - "tts": TTSLLMParams, - } - model_class = param_models[self.type] - if ( - self.type in (CompletionType.STT, CompletionType.TTS) - and self.provider is None - ): +class KaapiSTTCompletionConfig(_KaapiCompletionConfigBase): + type: Literal[CompletionType.STT] = Field( + ..., description="Completion config type. Params schema varies by type" + ) + params: STTLLMParams = Field( + ..., description="Kaapi-standardized parameters mapped to provider-specific API" + ) + + @model_validator(mode="after") + def _default_provider(self) -> Self: + if self.provider is None: self.provider = Provider.GOOGLE + return self + - user_provided_temperature = "temperature" in self.params - validated = model_class.model_validate(self.params) +class KaapiTTSCompletionConfig(_KaapiCompletionConfigBase): + type: Literal[CompletionType.TTS] = Field( + ..., description="Completion config type. Params schema varies by type" + ) + params: TTSLLMParams = Field( + ..., description="Kaapi-standardized parameters mapped to provider-specific API" + ) - self.params = validated.model_dump(exclude_none=True) - if not user_provided_temperature: - self.params.pop("temperature", None) + @model_validator(mode="after") + def _default_provider(self) -> Self: + if self.provider is None: + self.provider = Provider.GOOGLE return self +# Kaapi abstraction for LLM completion providers, keyed on `type` (text/stt/tts). +# Uses standardized Kaapi parameters that are mapped to provider-specific APIs +# internally. Supports multiple providers: OpenAI, Claude, Gemini, etc. +# Kept under the old name since it's constructed/pattern-matched on directly +# across services and tests; this is now a nested discriminated union rather +# than a single model. +KaapiCompletionConfig = Annotated[ + Union[ + KaapiTextCompletionConfig, KaapiSTTCompletionConfig, KaapiTTSCompletionConfig + ], + Field(discriminator="type"), +] + +# `KaapiCompletionConfig` is a Union alias now, not a class — it can't be +# called directly or used with isinstance(). Callers that used to do +# `KaapiCompletionConfig(provider=..., type=..., params=...)` should go +# through this factory instead. +_KAAPI_CONFIG_BY_TYPE: dict[CompletionType, type[SQLModel]] = { + CompletionType.TEXT: KaapiTextCompletionConfig, + CompletionType.STT: KaapiSTTCompletionConfig, + CompletionType.TTS: KaapiTTSCompletionConfig, +} + + +def build_kaapi_completion_config( + *, + provider: KaapiProvider | None, + type: CompletionType, + params: TextLLMParams | STTLLMParams | TTSLLMParams, +) -> KaapiTextCompletionConfig | KaapiSTTCompletionConfig | KaapiTTSCompletionConfig: + """Construct the KaapiCompletionConfig variant matching `type`.""" + config_class = _KAAPI_CONFIG_BY_TYPE[CompletionType(type)] + return config_class(provider=provider, type=type, params=params) + + class ProxyCompletionConfig(SQLModel): """ Proxy completion: Kaapi forwards the (guardrail-sanitised) input to the @@ -347,23 +386,21 @@ class ProxyCompletionConfig(SQLModel): ), ) type: Literal["proxy"] = Field(..., description="Must be 'proxy'.") - params: dict[str, Any] = Field( + params: ProxyLLMParams = Field( ..., description="Proxy params (client_llm_url, ...)", ) - @model_validator(mode="after") - def validate_params(self) -> Self: - validated = ProxyLLMParams.model_validate(self.params) - # mode="json" coerces HttpUrl → plain str so downstream consumers - # (httpx.post, urlparse) get the type they expect from params dict. - self.params = validated.model_dump(mode="json", exclude_none=True) - return self - # Discriminated union for completion configs based on provider field CompletionConfig = Annotated[ - Union[NativeCompletionConfig, KaapiCompletionConfig, ProxyCompletionConfig], + Union[ + NativeCompletionConfig, + KaapiTextCompletionConfig, + KaapiSTTCompletionConfig, + KaapiTTSCompletionConfig, + ProxyCompletionConfig, + ], Field(discriminator="provider"), ] @@ -978,7 +1015,7 @@ class SpeechToSpeechRequest(SQLModel): ) # Optional language config (BCP-47 codes) - input_language: str = Field( + input_language: STSLanguageCode = Field( "auto", description=( "BCP-47 language code for STT input (auto-detect by default). " @@ -987,11 +1024,11 @@ class SpeechToSpeechRequest(SQLModel): "'sd-IN', 'sa-IN', 'sat-IN', 'mni-IN', 'brx-IN', 'mai-IN', 'doi-IN'" ), ) - output_language: str | None = Field( + output_language: STSLanguageCode | None = Field( None, description=( "BCP-47 language code for TTS output (defaults to input_language if not specified). " - "Supported codes: same as input_language (except 'auto')." + "Supported codes: same as input_language (except 'auto'/'unknown')." ), ) @@ -1034,20 +1071,27 @@ class SpeechToSpeechRequest(SQLModel): None, description="Client-provided metadata" ) - @model_validator(mode="after") - def validate_languages(self): - """Normalize BCP-47 language codes to standard format (e.g., 'hi-in' -> 'hi-IN').""" - # Normalize input_language - if self.input_language and self.input_language != "auto": - # Normalize BCP-47: lowercase language, uppercase region (e.g., "hi-IN") - parts = self.input_language.split("-") - if len(parts) == 2: - self.input_language = f"{parts[0].lower()}-{parts[1].upper()}" - - # Normalize output_language - if self.output_language: - parts = self.output_language.split("-") - if len(parts) == 2: - self.output_language = f"{parts[0].lower()}-{parts[1].upper()}" + @model_validator(mode="before") + @classmethod + def normalize_language_casing(cls, data: Any) -> Any: + """Normalize BCP-47 casing (e.g. 'hi-in' -> 'hi-IN') before the + STSLanguageCode Literal check runs, so case-insensitive input still + validates against the supported-code allowlist.""" + if not isinstance(data, dict): + return data + for field in ("input_language", "output_language"): + value = data.get(field) + if isinstance(value, str) and value not in ("auto", "unknown"): + parts = value.split("-") + if len(parts) == 2: + data[field] = f"{parts[0].lower()}-{parts[1].upper()}" + return data + @model_validator(mode="after") + def validate_output_language(self) -> Self: + """'auto'/'unknown' are STT-only detection modes, not valid TTS targets.""" + if self.output_language in ("auto", "unknown"): + raise ValueError( + f"output_language must be a concrete language code, not '{self.output_language}'" + ) return self diff --git a/backend/app/services/evaluations/batch_job.py b/backend/app/services/evaluations/batch_job.py index ec13045ee..ac2c2221f 100644 --- a/backend/app/services/evaluations/batch_job.py +++ b/backend/app/services/evaluations/batch_job.py @@ -13,6 +13,7 @@ ) from app.crud.evaluations.core import update_evaluation_run from app.models.evaluation import EvaluationRunUpdate +from app.services.llm.mappers import kaapi_params_as_dict from app.utils import get_langfuse_client logger = logging.getLogger(__name__) @@ -63,7 +64,7 @@ def execute_evaluation_batch_submission( langfuse=langfuse, session=session, eval_run=run, - params=config.completion.params, + params=kaapi_params_as_dict(config.completion.params), provider=config.completion.provider, ) return {"success": True, "batch_job_id": run.batch_job_id} diff --git a/backend/app/services/llm/chain/utils.py b/backend/app/services/llm/chain/utils.py index 2955d58c2..cb8e3bf49 100644 --- a/backend/app/services/llm/chain/utils.py +++ b/backend/app/services/llm/chain/utils.py @@ -1,35 +1,13 @@ """Utility functions for LLM chain operations, including speech-to-speech helpers.""" -# BCP-47 language codes accepted by the speech-to-speech endpoint. -SUPPORTED_LANGUAGE_CODES = { - "auto", - "unknown", - # Primary Indian languages - "en-IN", - "hi-IN", - "bn-IN", - "kn-IN", - "ml-IN", - "mr-IN", - "od-IN", - "pa-IN", - "ta-IN", - "te-IN", - "gu-IN", - # Additional languages - "as-IN", - "ur-IN", - "ne-IN", - "kok-IN", - "ks-IN", - "sd-IN", - "sa-IN", - "sat-IN", - "mni-IN", - "brx-IN", - "mai-IN", - "doi-IN", -} +from typing import get_args + +from app.models.llm.constants import STSLanguageCode + +# BCP-47 language codes accepted by the speech-to-speech endpoint. Derived from +# STSLanguageCode (app/models/llm/constants.py) so the request model and this +# set never drift apart. +SUPPORTED_LANGUAGE_CODES: set[str] = set(get_args(STSLanguageCode)) DEFAULT_RAG_INSTRUCTIONS = ( "Answer the user's question using the provided knowledge base. " diff --git a/backend/app/services/llm/jobs.py b/backend/app/services/llm/jobs.py index e95ec9cb1..06ac41f7d 100644 --- a/backend/app/services/llm/jobs.py +++ b/backend/app/services/llm/jobs.py @@ -47,7 +47,9 @@ ChainStatus, ConfigBlob, ImageInput, - KaapiCompletionConfig, + KaapiSTTCompletionConfig, + KaapiTextCompletionConfig, + KaapiTTSCompletionConfig, LLMCallConfig, NativeCompletionConfig, PDFInput, @@ -618,8 +620,7 @@ def execute_llm_call( return BlockResult( error="Proxy completion only supports text input" ) - proxy_params = config_blob.completion.params or {} - client_llm_url = proxy_params.get("client_llm_url") + client_llm_url = str(config_blob.completion.params.client_llm_url) if not client_llm_url: return BlockResult(error="Proxy config missing client_llm_url") try: @@ -833,7 +834,14 @@ def execute_llm_call( completion_config = config_blob.completion original_provider = completion_config.provider - if isinstance(completion_config, KaapiCompletionConfig): + if isinstance( + completion_config, + ( + KaapiTextCompletionConfig, + KaapiSTTCompletionConfig, + KaapiTTSCompletionConfig, + ), + ): completion_config, warnings = transform_kaapi_config_to_native( session=session, kaapi_config=completion_config ) diff --git a/backend/app/services/llm/mappers.py b/backend/app/services/llm/mappers.py index 7e8f04624..521fde873 100644 --- a/backend/app/services/llm/mappers.py +++ b/backend/app/services/llm/mappers.py @@ -1,9 +1,11 @@ import logging +from typing import Any from sqlmodel import Session from app.crud.model_config import is_reasoning_model from app.models.llm import KaapiCompletionConfig, NativeCompletionConfig +from app.models.llm.request import STTLLMParams, TextLLMParams, TTSLLMParams from app.models.llm.constants import ( BCP47_LOCALE_TO_GEMINI_LANG, BCP47_TO_ELEVENLABS_LANG, @@ -526,6 +528,26 @@ def map_kaapi_to_anthropic_params( return anthropic_params, warnings +def kaapi_params_as_dict( + params: TextLLMParams | STTLLMParams | TTSLLMParams | dict[str, Any], +) -> dict[str, Any]: + """Normalize a Kaapi completion config's `params` to a plain dict for the + provider mappers below, which are dict-in/dict-out. + + Strips `temperature` when the caller didn't explicitly set it, even + though the params model defaults it to 0.1 — mirrors the pre-refactor + behavior of KaapiCompletionConfig, where an unset temperature was never + forwarded to the provider mapper (e.g. it triggers a spurious "suppressed + because reasoning is enabled" warning for reasoning models otherwise). + """ + if isinstance(params, dict): + return dict(params) + dumped = params.model_dump(exclude_none=True) + if "temperature" in dumped and "temperature" not in params.model_fields_set: + dumped.pop("temperature") + return dumped + + def transform_kaapi_config_to_native( session: Session, kaapi_config: KaapiCompletionConfig, @@ -543,9 +565,11 @@ def transform_kaapi_config_to_native( - NativeCompletionConfig with provider-native parameters ready for API - List of warnings for suppressed/ignored parameters """ + kaapi_params = kaapi_params_as_dict(kaapi_config.params) + if kaapi_config.provider == Provider.OPENAI: mapped_params, warnings = map_kaapi_to_openai_params( - session=session, kaapi_params=kaapi_config.params + session=session, kaapi_params=kaapi_params ) return ( NativeCompletionConfig( @@ -556,7 +580,7 @@ def transform_kaapi_config_to_native( if kaapi_config.provider == Provider.GOOGLE_AISTUDIO: mapped_params, warnings = map_kaapi_to_google_params( - kaapi_config.params, kaapi_config.type + kaapi_params, kaapi_config.type ) return ( NativeCompletionConfig( @@ -569,7 +593,7 @@ def transform_kaapi_config_to_native( if kaapi_config.provider == Provider.SARVAMAI: mapped_params, warnings = map_kaapi_to_sarvam_params( - kaapi_config.params, kaapi_config.type + kaapi_params, kaapi_config.type ) return ( NativeCompletionConfig( @@ -580,7 +604,7 @@ def transform_kaapi_config_to_native( if kaapi_config.provider == Provider.ELEVENLABS: mapped_params, warnings = map_kaapi_to_elevenlabs_params( - kaapi_config.params, kaapi_config.type + kaapi_params, kaapi_config.type ) return ( NativeCompletionConfig( @@ -594,7 +618,7 @@ def transform_kaapi_config_to_native( if kaapi_config.provider == Provider.GOOGLE: # Kaapi STT/TTS param shape is identical to Google's; reuse the Google mapper. mapped_params, warnings = map_kaapi_to_google_params( - kaapi_config.params, kaapi_config.type + kaapi_params, kaapi_config.type ) return ( NativeCompletionConfig( @@ -610,7 +634,7 @@ def transform_kaapi_config_to_native( raise ValueError( f"Anthropic provider does not support completion type '{kaapi_config.type}'" ) - mapped_params, warnings = map_kaapi_to_anthropic_params(kaapi_config.params) + mapped_params, warnings = map_kaapi_to_anthropic_params(kaapi_params) return ( NativeCompletionConfig( provider="anthropic-native", diff --git a/backend/app/tests/api/routes/configs/test_version.py b/backend/app/tests/api/routes/configs/test_version.py index 77b2e8e62..1ce78cebd 100644 --- a/backend/app/tests/api/routes/configs/test_version.py +++ b/backend/app/tests/api/routes/configs/test_version.py @@ -526,11 +526,11 @@ def test_create_version_cannot_change_type_from_text_to_stt( user_api_key: TestAuthContext, ) -> None: """Test that config type cannot be changed from 'text' to 'stt' in a new version.""" - from app.models.llm.request import KaapiCompletionConfig, TextLLMParams + from app.models.llm.request import TextLLMParams, build_kaapi_completion_config # Create initial config with type='text' config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="text", params={"model": "gpt-4o", "temperature": 0.7}, @@ -577,11 +577,11 @@ def test_create_version_same_type_succeeds( user_api_key: TestAuthContext, ) -> None: """Test that creating a new version with the same type succeeds.""" - from app.models.llm.request import KaapiCompletionConfig + from app.models.llm.request import build_kaapi_completion_config # Create initial config with type='text' config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="text", params={ @@ -630,11 +630,11 @@ def test_create_version_partial_update_params_only( user_api_key: TestAuthContext, ) -> None: """Test partial update - only updating params, inheriting provider and type.""" - from app.models.llm.request import KaapiCompletionConfig + from app.models.llm.request import build_kaapi_completion_config # Create initial config config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="text", params={ @@ -688,10 +688,10 @@ def test_create_version_cannot_change_type_from_stt_to_tts( user_api_key: TestAuthContext, ) -> None: """Test that config type cannot be changed from 'stt' to 'tts' in a new version.""" - from app.models.llm.request import KaapiCompletionConfig + from app.models.llm.request import build_kaapi_completion_config config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="google", type="stt", params={"model": "gemini-2.5-pro"}, @@ -733,10 +733,10 @@ def test_create_version_cannot_change_type_from_tts_to_text( user_api_key: TestAuthContext, ) -> None: """Test that config type cannot be changed from 'tts' to 'text' in a new version.""" - from app.models.llm.request import KaapiCompletionConfig + from app.models.llm.request import build_kaapi_completion_config config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="google", type="tts", params={"model": "gemini-2.5-flash-preview-tts", "voice": "Kore"}, @@ -778,10 +778,10 @@ def test_create_version_with_kaapi_stt_provider_success( user_api_key: TestAuthContext, ) -> None: """Test creating a new STT version with tweaked params succeeds.""" - from app.models.llm.request import KaapiCompletionConfig + from app.models.llm.request import build_kaapi_completion_config config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="google", type="stt", params={"model": "gemini-2.5-pro"}, @@ -824,10 +824,10 @@ def test_create_version_with_kaapi_tts_provider_success( user_api_key: TestAuthContext, ) -> None: """Test creating a new TTS version switching model and voice succeeds.""" - from app.models.llm.request import KaapiCompletionConfig + from app.models.llm.request import build_kaapi_completion_config config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="google", type="tts", params={"model": "gemini-2.5-flash-preview-tts", "voice": "Kore"}, diff --git a/backend/app/tests/api/routes/test_evaluation_fast.py b/backend/app/tests/api/routes/test_evaluation_fast.py index 6b56377fb..8aa24a2bb 100644 --- a/backend/app/tests/api/routes/test_evaluation_fast.py +++ b/backend/app/tests/api/routes/test_evaluation_fast.py @@ -42,8 +42,8 @@ from app.models.evaluation import RunModeEnum from app.models.llm.request import ( ConfigBlob, - KaapiCompletionConfig, TextLLMParams, + build_kaapi_completion_config, ) from app.services.evaluations.fast import ( execute_fast_evaluation_chunk, @@ -157,7 +157,7 @@ def _make_text_openai_config(db: Session, project_id: int) -> Config: exact model name is immaterial to every assertion here. """ blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="text", params={"model": "gpt-4o-fast-eval-test", "temperature": 0.7}, @@ -415,7 +415,7 @@ def test_fr1_rejects_non_text_config( config = _make_text_openai_config(db, user_api_key.project_id) fake_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="stt", params={"model": "whisper-1"}, diff --git a/backend/app/tests/api/routes/test_evaluation_v2.py b/backend/app/tests/api/routes/test_evaluation_v2.py index 6f348320d..f980de0a6 100644 --- a/backend/app/tests/api/routes/test_evaluation_v2.py +++ b/backend/app/tests/api/routes/test_evaluation_v2.py @@ -18,7 +18,7 @@ from app.core.config import settings from app.models import Config, EvaluationDataset, EvaluationRun -from app.models.llm.request import ConfigBlob, KaapiCompletionConfig +from app.models.llm.request import ConfigBlob, build_kaapi_completion_config from app.tests.utils.auth import TestAuthContext from app.tests.utils.test_data import ( create_test_config, @@ -41,7 +41,7 @@ def _make_dataset(*, db: Session, user_api_key: TestAuthContext) -> EvaluationDa def _make_text_config(db: Session, project_id: int) -> Config: blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="text", params={"model": "gpt-4o-fast-eval-test", "temperature": 0.7}, diff --git a/backend/app/tests/api/routes/test_improve_prompt.py b/backend/app/tests/api/routes/test_improve_prompt.py index 37fe469e0..f87448ea1 100644 --- a/backend/app/tests/api/routes/test_improve_prompt.py +++ b/backend/app/tests/api/routes/test_improve_prompt.py @@ -172,11 +172,11 @@ def _make_config_with_instructions( ) -> Any: from app.crud.config import ConfigCrud from app.models.config.config import ConfigCreate - from app.models.llm import KaapiCompletionConfig + from app.models.llm import build_kaapi_completion_config from app.models.llm.request import ConfigBlob config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="text", params={ diff --git a/backend/app/tests/api/routes/test_llm.py b/backend/app/tests/api/routes/test_llm.py index 7dabcd791..96b9e5f90 100644 --- a/backend/app/tests/api/routes/test_llm.py +++ b/backend/app/tests/api/routes/test_llm.py @@ -12,8 +12,8 @@ LLMCallConfig, ConfigBlob, NativeCompletionConfig, - KaapiCompletionConfig, QueryParams, + build_kaapi_completion_config, ) from app.models.llm import LLMCallRequest from app.tests.utils.auth import TestAuthContext @@ -91,7 +91,7 @@ def test_llm_call_with_kaapi_config( query=QueryParams(input="Explain quantum computing"), config=LLMCallConfig( blob=ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="text", params={ diff --git a/backend/app/tests/crud/evaluations/test_fast_judge.py b/backend/app/tests/crud/evaluations/test_fast_judge.py index 81ee818b2..c5b1142c7 100644 --- a/backend/app/tests/crud/evaluations/test_fast_judge.py +++ b/backend/app/tests/crud/evaluations/test_fast_judge.py @@ -49,9 +49,9 @@ from app.models.evaluation import RunModeEnum from app.models.llm.request import ( ConfigBlob, - KaapiCompletionConfig, PromptTemplate, TextLLMParams, + build_kaapi_completion_config, ) from app.models.response import FileResultChunk from app.tests.utils.auth import TestAuthContext @@ -85,7 +85,7 @@ def _make_text_config( if instructions is not None: params["instructions"] = instructions blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="text", params=params, diff --git a/backend/app/tests/crud/test_llm.py b/backend/app/tests/crud/test_llm.py index 06e7e0621..09b90712b 100644 --- a/backend/app/tests/crud/test_llm.py +++ b/backend/app/tests/crud/test_llm.py @@ -21,8 +21,8 @@ QueryParams, ) from app.models.llm.request import ( - KaapiCompletionConfig, LLMCallConfig, + build_kaapi_completion_config, ) from app.tests.utils.utils import get_project, get_organization from app.tests.utils.llm import create_llm_job @@ -47,7 +47,7 @@ def test_job(db: Session): def text_config_blob() -> ConfigBlob: """Create a text completion config blob.""" return ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", params={ "model": "gpt-4o", @@ -63,7 +63,7 @@ def text_config_blob() -> ConfigBlob: def stt_config_blob() -> ConfigBlob: """Create a speech-to-text config blob.""" return ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", params={ "model": "whisper-1", @@ -79,7 +79,7 @@ def stt_config_blob() -> ConfigBlob: def tts_config_blob() -> ConfigBlob: """Create a text-to-speech config blob.""" return ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", params={ "model": "tts-1", diff --git a/backend/app/tests/models/llm/test_request.py b/backend/app/tests/models/llm/test_request.py index 39f732de3..6fd38480e 100644 --- a/backend/app/tests/models/llm/test_request.py +++ b/backend/app/tests/models/llm/test_request.py @@ -1,12 +1,13 @@ -from app.models.llm.request import KaapiCompletionConfig +from app.models.llm.request import build_kaapi_completion_config +from app.services.llm.mappers import kaapi_params_as_dict class TestKaapiCompletionConfigTemperature: - """Test temperature handling in KaapiCompletionConfig.validate_params.""" + """Test temperature handling in KaapiCompletionConfig / kaapi_params_as_dict.""" def test_temperature_preserved_when_user_provides_it(self) -> None: """When user explicitly provides temperature, it should be in params.""" - config = KaapiCompletionConfig( + config = build_kaapi_completion_config( provider="openai", type="text", params={ @@ -15,13 +16,13 @@ def test_temperature_preserved_when_user_provides_it(self) -> None: }, ) - assert "temperature" in config.params - assert config.params["temperature"] == 0.7 + assert config.params.temperature == 0.7 + assert kaapi_params_as_dict(config.params)["temperature"] == 0.7 def test_temperature_excluded_when_user_does_not_provide_it(self) -> None: - """When user does not provide temperature, it should NOT be in params - even though TextLLMParams has a default of 0.1.""" - config = KaapiCompletionConfig( + """When user does not provide temperature, kaapi_params_as_dict should + strip it even though TextLLMParams has a default of 0.1.""" + config = build_kaapi_completion_config( provider="openai", type="text", params={ @@ -29,11 +30,11 @@ def test_temperature_excluded_when_user_does_not_provide_it(self) -> None: }, ) - assert "temperature" not in config.params + assert "temperature" not in kaapi_params_as_dict(config.params) def test_temperature_zero_preserved_when_explicitly_set(self) -> None: """When user explicitly sets temperature to 0.0, it should be preserved.""" - config = KaapiCompletionConfig( + config = build_kaapi_completion_config( provider="openai", type="text", params={ @@ -42,5 +43,5 @@ def test_temperature_zero_preserved_when_explicitly_set(self) -> None: }, ) - assert "temperature" in config.params - assert config.params["temperature"] == 0.0 + assert config.params.temperature == 0.0 + assert kaapi_params_as_dict(config.params)["temperature"] == 0.0 diff --git a/backend/app/tests/services/llm/test_jobs.py b/backend/app/tests/services/llm/test_jobs.py index 2f56b14a0..9ce03f2ca 100644 --- a/backend/app/tests/services/llm/test_jobs.py +++ b/backend/app/tests/services/llm/test_jobs.py @@ -24,7 +24,8 @@ AudioOutput, AudioContent, # KaapiLLMParams, - KaapiCompletionConfig, + KaapiTextCompletionConfig, + build_kaapi_completion_config, ) from app.models.llm.request import ConfigBlob, LLMCallConfig, LLMChainRequest from app.models.llm.request import ChainBlock as ChainBlockModel @@ -1086,7 +1087,7 @@ def test_kaapi_config_success(self, db, job_for_execution, mock_llm_response): project = get_project(db) config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="text", params={ @@ -1133,7 +1134,7 @@ def test_kaapi_config_with_callback(self, db, job_for_execution, mock_llm_respon project = get_project(db) config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="text", params={ @@ -1185,7 +1186,7 @@ def test_kaapi_config_warnings_passed_through_metadata( # Use a config that will generate warnings (temperature on reasoning model) config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="text", params={ @@ -1235,7 +1236,7 @@ def test_kaapi_config_warnings_merged_with_existing_metadata( project = get_project(db) config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="text", params={ @@ -2477,7 +2478,7 @@ def test_resolve_kaapi_config_blob_success(self, db: Session): project = get_project(db) config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="text", params={ @@ -2499,12 +2500,12 @@ def test_resolve_kaapi_config_blob_success(self, db: Session): assert error is None assert resolved_blob is not None - assert isinstance(resolved_blob.completion, KaapiCompletionConfig) + assert isinstance(resolved_blob.completion, KaapiTextCompletionConfig) assert resolved_blob.completion.provider == "openai" - assert resolved_blob.completion.params["model"] == "gpt-4o" - assert resolved_blob.completion.params["temperature"] == 0.8 + assert resolved_blob.completion.params.model == "gpt-4o" + assert resolved_blob.completion.params.temperature == 0.8 assert ( - resolved_blob.completion.params["instructions"] + resolved_blob.completion.params.instructions == "You are a helpful assistant" ) @@ -2526,7 +2527,7 @@ def test_resolve_both_native_and_kaapi_configs(self, db: Session): # Create Kaapi config kaapi_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="text", params={ @@ -2561,5 +2562,5 @@ def test_resolve_both_native_and_kaapi_configs(self, db: Session): resolved_kaapi, error_kaapi = resolve_config_blob(kaapi_crud, kaapi_call_config) assert error_kaapi is None - assert isinstance(resolved_kaapi.completion, KaapiCompletionConfig) + assert isinstance(resolved_kaapi.completion, KaapiTextCompletionConfig) assert resolved_kaapi.completion.provider == "openai" diff --git a/backend/app/tests/services/llm/test_mappers.py b/backend/app/tests/services/llm/test_mappers.py index 51f2484a4..68e9a49fc 100644 --- a/backend/app/tests/services/llm/test_mappers.py +++ b/backend/app/tests/services/llm/test_mappers.py @@ -8,11 +8,11 @@ from sqlmodel import Session from app.models.llm.request import ( - KaapiCompletionConfig, NativeCompletionConfig, STTLLMParams, TextLLMParams, TTSLLMParams, + build_kaapi_completion_config, ) from app.services.llm.mappers import ( bcp47_to_elevenlabs_lang, @@ -892,7 +892,7 @@ class TestTransformGoogleVertexRouting: def test_text_completion_maps_via_google_mapper(self, db: Session): """``google`` text completions reuse the Google mapper and produce a ``google-native`` config (param shape is identical to Google's).""" - kaapi_config = KaapiCompletionConfig( + kaapi_config = build_kaapi_completion_config( provider="google", type="text", params={"model": "gemini-2.5-pro"}, @@ -908,7 +908,7 @@ def test_text_completion_maps_via_google_mapper(self, db: Session): def test_unsupported_language_emits_warning(self, db: Session): """Languages not in BCP47_LOCALE_TO_GEMINI_LANG fall back to auto-detect and surface a warning, rather than silently being dropped.""" - kaapi_config = KaapiCompletionConfig( + kaapi_config = build_kaapi_completion_config( provider="google", type="tts", params={ @@ -983,7 +983,7 @@ class TestTransformKaapiConfigToNative: def test_transform_elevenlabs_tts_config(self, db: Session): """Test transformation of ElevenLabs TTS config.""" - kaapi_config = KaapiCompletionConfig( + kaapi_config = build_kaapi_completion_config( provider="elevenlabs", type="tts", params={ @@ -1009,7 +1009,7 @@ def test_transform_elevenlabs_tts_config(self, db: Session): def test_transform_elevenlabs_stt_config(self, db: Session): """Test transformation of ElevenLabs STT config.""" - kaapi_config = KaapiCompletionConfig( + kaapi_config = build_kaapi_completion_config( provider="elevenlabs", type="stt", params={ @@ -1033,7 +1033,7 @@ def test_transform_elevenlabs_stt_config(self, db: Session): def test_transform_sarvamai_stt_with_saaras_model(self, db: Session): """Test transformation of SarvamAI STT with saaras:v3 model.""" - kaapi_config = KaapiCompletionConfig( + kaapi_config = build_kaapi_completion_config( provider="sarvamai", type="stt", params={ @@ -1061,7 +1061,7 @@ def test_transform_sarvamai_stt_with_saaras_model(self, db: Session): def test_transform_sarvamai_tts_with_voice(self, db: Session): """Test transformation of SarvamAI TTS with explicit voice.""" - kaapi_config = KaapiCompletionConfig( + kaapi_config = build_kaapi_completion_config( provider="sarvamai", type="tts", params={ @@ -1085,7 +1085,7 @@ def test_transform_sarvamai_tts_with_voice(self, db: Session): def test_transform_google_text_completion(self, db: Session): """Text completions route through ``google-aistudio`` (AI Studio).""" - kaapi_config = KaapiCompletionConfig( + kaapi_config = build_kaapi_completion_config( provider="google-aistudio", type="text", params={ @@ -1109,7 +1109,7 @@ def test_transform_google_text_completion(self, db: Session): def test_transform_google_stt_completion(self, db: Session): """Test transformation of Google STT completion.""" - kaapi_config = KaapiCompletionConfig( + kaapi_config = build_kaapi_completion_config( provider="google", type="stt", params={"model": "gemini-2.5-pro", "instructions": "Transcribe accurately"}, @@ -1128,7 +1128,7 @@ def test_transform_google_stt_completion(self, db: Session): def test_transform_google_tts_completion(self, db: Session): """Test transformation of Google TTS completion.""" - kaapi_config = KaapiCompletionConfig( + kaapi_config = build_kaapi_completion_config( provider="google", type="tts", params={ diff --git a/backend/app/tests/services/llm/test_sts.py b/backend/app/tests/services/llm/test_sts.py index 4141a7aa2..2f703f985 100644 --- a/backend/app/tests/services/llm/test_sts.py +++ b/backend/app/tests/services/llm/test_sts.py @@ -112,12 +112,12 @@ def test_default_models_voice_format_and_temperature( rag = blocks[1].config.blob.completion.params tts = blocks[2].config.blob.completion.params - assert stt["model"] == DEFAULT_STT_MODEL - assert rag["model"] == DEFAULT_RAG_MODEL - assert rag["temperature"] == 0.1 - assert tts["model"] == DEFAULT_TTS_MODEL - assert tts["voice"] == DEFAULT_TTS_VOICE - assert tts["response_format"] == "ogg" + assert stt.model == DEFAULT_STT_MODEL + assert rag.model == DEFAULT_RAG_MODEL + assert rag.temperature == 0.1 + assert tts.model == DEFAULT_TTS_MODEL + assert tts.voice == DEFAULT_TTS_VOICE + assert tts.response_format == "ogg" def test_rag_block_always_has_knowledge_base_ids( self, client, user_api_key_header, audio_input, kb_ids @@ -129,7 +129,7 @@ def test_rag_block_always_has_knowledge_base_ids( SpeechToSpeechRequest(query=audio_input, knowledge_base_ids=kb_ids), ) rag_params = _chain_request(mock).blocks[1].config.blob.completion.params - assert rag_params["knowledge_base_ids"] == kb_ids + assert rag_params.knowledge_base_ids == kb_ids def test_stt_and_rag_are_intermediate_tts_is_not( self, client, user_api_key_header, audio_input, kb_ids @@ -155,7 +155,7 @@ def test_default_stt_input_language_is_auto( SpeechToSpeechRequest(query=audio_input, knowledge_base_ids=kb_ids), ) stt_params = _chain_request(mock).blocks[0].config.blob.completion.params - assert stt_params["input_language"] == "auto" + assert stt_params.input_language == "auto" # ---------- Language resolution ---------- @@ -176,7 +176,7 @@ def test_pinned_input_propagates_to_tts_when_output_not_set( ), ) tts_params = _chain_request(mock).blocks[2].config.blob.completion.params - assert tts_params["language"] == "hi-IN" + assert tts_params.language == "hi-IN" def test_explicit_output_language_overrides_input( self, client, user_api_key_header, audio_input, kb_ids @@ -193,7 +193,7 @@ def test_explicit_output_language_overrides_input( ), ) tts_params = _chain_request(mock).blocks[2].config.blob.completion.params - assert tts_params["language"] == "ta-IN" + assert tts_params.language == "ta-IN" def test_auto_input_without_output_yields_detected_marker( self, client, user_api_key_header, audio_input, kb_ids @@ -209,7 +209,7 @@ def test_auto_input_without_output_yields_detected_marker( SpeechToSpeechRequest(query=audio_input, knowledge_base_ids=kb_ids), ) tts_params = _chain_request(mock).blocks[2].config.blob.completion.params - assert tts_params["language"] == "{{detected}}" + assert tts_params.language == "{{detected}}" def test_unknown_input_without_output_also_yields_detected_marker( self, client, user_api_key_header, audio_input, kb_ids @@ -227,7 +227,7 @@ def test_unknown_input_without_output_also_yields_detected_marker( ), ) tts_params = _chain_request(mock).blocks[2].config.blob.completion.params - assert tts_params["language"] == "{{detected}}" + assert tts_params.language == "{{detected}}" def test_auto_input_with_pinned_output( self, client, user_api_key_header, audio_input, kb_ids @@ -244,8 +244,8 @@ def test_auto_input_with_pinned_output( ) stt_params = _chain_request(mock).blocks[0].config.blob.completion.params tts_params = _chain_request(mock).blocks[2].config.blob.completion.params - assert stt_params["input_language"] == "auto" - assert tts_params["language"] == "kn-IN" + assert stt_params.input_language == "auto" + assert tts_params.language == "kn-IN" @pytest.mark.parametrize( "raw,normalised", @@ -270,7 +270,7 @@ def test_bcp47_normalisation( ) assert response.status_code == 200 tts_params = _chain_request(mock).blocks[2].config.blob.completion.params - assert tts_params["language"] == normalised + assert tts_params.language == normalised def test_route_always_owns_stt_input_language( self, client, user_api_key_header, audio_input, kb_ids @@ -290,7 +290,7 @@ def test_route_always_owns_stt_input_language( ), ) stt_params = _chain_request(mock).blocks[0].config.blob.completion.params - assert stt_params["input_language"] == "bn-IN" + assert stt_params.input_language == "bn-IN" def test_route_always_owns_tts_language( self, client, user_api_key_header, audio_input, kb_ids @@ -310,7 +310,7 @@ def test_route_always_owns_tts_language( ), ) tts_params = _chain_request(mock).blocks[2].config.blob.completion.params - assert tts_params["language"] == "te-IN" + assert tts_params.language == "te-IN" # ---------- Provider combos ---------- @@ -346,7 +346,7 @@ def test_google_stt_with_gemini_model( ) stt_completion = _chain_request(mock).blocks[0].config.blob.completion assert stt_completion.provider == "google" - assert stt_completion.params["model"] == "gemini-2.5-pro" + assert stt_completion.params.model == "gemini-2.5-pro" def test_all_three_providers_set_independently( self, client, user_api_key_header, audio_input, kb_ids @@ -393,9 +393,9 @@ def test_rag_model_instructions_and_temperature_override( ), ) rag_params = _chain_request(mock).blocks[1].config.blob.completion.params - assert rag_params["model"] == "gpt-4o-mini" - assert rag_params["instructions"] == "Be brief." - assert rag_params["temperature"] == 0.5 + assert rag_params.model == "gpt-4o-mini" + assert rag_params.instructions == "Be brief." + assert rag_params.temperature == 0.5 def test_rag_inline_still_injects_kb_ids( self, client, user_api_key_header, audio_input, kb_ids @@ -412,7 +412,7 @@ def test_rag_inline_still_injects_kb_ids( ), ) rag_params = _chain_request(mock).blocks[1].config.blob.completion.params - assert rag_params["knowledge_base_ids"] == kb_ids + assert rag_params.knowledge_base_ids == kb_ids # ---------- Stored config references ---------- @@ -541,48 +541,43 @@ class TestErrorPaths: def test_invalid_input_language_returns_422( self, client, user_api_key_header, audio_input, kb_ids ): - response = _post( - client, - user_api_key_header, - SpeechToSpeechRequest( - query=audio_input, - knowledge_base_ids=kb_ids, - input_language="hindi", - ), - ) + # "hindi" isn't a valid STSLanguageCode literal, so it can't be constructed + # into a SpeechToSpeechRequest in Python — post the raw dict so the + # invalid value reaches FastAPI's own request validation instead. + payload = SpeechToSpeechRequest( + query=audio_input, knowledge_base_ids=kb_ids + ).model_dump(mode="json") + payload["input_language"] = "hindi" + response = client.post(URL, json=payload, headers=user_api_key_header) assert response.status_code == 422 - assert "input language" in response.json()["error"].lower() + assert isinstance(response.json()["detail"], list) def test_invalid_output_language_returns_422( self, client, user_api_key_header, audio_input, kb_ids ): - response = _post( - client, - user_api_key_header, - SpeechToSpeechRequest( - query=audio_input, - knowledge_base_ids=kb_ids, - output_language="english", - ), - ) + payload = SpeechToSpeechRequest( + query=audio_input, knowledge_base_ids=kb_ids + ).model_dump(mode="json") + payload["output_language"] = "english" + response = client.post(URL, json=payload, headers=user_api_key_header) assert response.status_code == 422 - assert "output language" in response.json()["error"].lower() + assert isinstance(response.json()["detail"], list) @pytest.mark.parametrize("forbidden", ["unknown", "auto"]) def test_detection_sentinels_rejected_as_output_language( - self, client, user_api_key_header, audio_input, kb_ids, forbidden + self, audio_input, kb_ids, forbidden ): - """'unknown' / 'auto' are STT-only sentinels; TTS needs a concrete language.""" - response = _post( - client, - user_api_key_header, + """'unknown' / 'auto' are STT-only sentinels; TTS needs a concrete language. + + Rejected by SpeechToSpeechRequest's own model_validator, so it 422s at + construction time — before the request ever reaches the route. + """ + with pytest.raises(ValidationError, match="output_language"): SpeechToSpeechRequest( query=audio_input, knowledge_base_ids=kb_ids, output_language=forbidden, - ), - ) - assert response.status_code == 422 + ) def test_auto_as_input_language_is_valid( self, client, user_api_key_header, audio_input, kb_ids @@ -653,7 +648,7 @@ def test_multiple_knowledge_base_ids_forwarded_to_rag( SpeechToSpeechRequest(query=audio_input, knowledge_base_ids=many_kbs), ) rag_params = _chain_request(mock).blocks[1].config.blob.completion.params - assert rag_params["knowledge_base_ids"] == many_kbs + assert rag_params.knowledge_base_ids == many_kbs def test_kb_ids_overwrite_user_supplied_kb_ids_in_rag_params( self, client, user_api_key_header, audio_input, kb_ids @@ -675,4 +670,4 @@ def test_kb_ids_overwrite_user_supplied_kb_ids_in_rag_params( ), ) rag_params = _chain_request(mock).blocks[1].config.blob.completion.params - assert rag_params["knowledge_base_ids"] == kb_ids + assert rag_params.knowledge_base_ids == kb_ids diff --git a/backend/app/tests/utils/llm.py b/backend/app/tests/utils/llm.py index 1ea85df9b..a60059b87 100644 --- a/backend/app/tests/utils/llm.py +++ b/backend/app/tests/utils/llm.py @@ -6,9 +6,9 @@ from app.models.llm.response import LLMCallResponse from app.models.llm.request import ( ConfigBlob, - KaapiCompletionConfig, LLMCallConfig, QueryParams, + build_kaapi_completion_config, ) from app.tests.utils.utils import get_project from app.models.llm import LLMCallRequest @@ -35,7 +35,7 @@ def create_llm_call_with_response( so tests can assert against predictable data. """ config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", params={ "model": "gpt-4o", @@ -89,7 +89,7 @@ def create_llm_call_with_audio_uri_response( format='uri' (internal format, must be swapped to presigned URL on read). """ config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", params={ "model": "gpt-4o", diff --git a/backend/app/tests/utils/test_data.py b/backend/app/tests/utils/test_data.py index 2538b5380..accd824b0 100644 --- a/backend/app/tests/utils/test_data.py +++ b/backend/app/tests/utils/test_data.py @@ -33,7 +33,7 @@ UserProject, ) from app.models.config.config import ConfigTag -from app.models.llm import KaapiCompletionConfig, NativeCompletionConfig +from app.models.llm import NativeCompletionConfig, build_kaapi_completion_config from app.tests.utils.user import create_random_user from app.tests.utils.utils import ( generate_random_string, @@ -291,7 +291,7 @@ def create_test_config( if use_kaapi_schema: # Create Kaapi-format config config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider="openai", type="text", params={ @@ -386,7 +386,7 @@ def create_test_version( else: # For Kaapi providers (openai, google) config_blob = ConfigBlob( - completion=KaapiCompletionConfig( + completion=build_kaapi_completion_config( provider=provider, type=config_type, params={ From 438a894e046d50e771115299359ee6a4777e72a4 Mon Sep 17 00:00:00 2001 From: Prajna1999 Date: Tue, 28 Jul 2026 15:55:51 +0530 Subject: [PATCH 2/2] fix test cases --- backend/app/tests/services/llm/test_sts.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/tests/services/llm/test_sts.py b/backend/app/tests/services/llm/test_sts.py index 2f703f985..2b224e522 100644 --- a/backend/app/tests/services/llm/test_sts.py +++ b/backend/app/tests/services/llm/test_sts.py @@ -550,7 +550,7 @@ def test_invalid_input_language_returns_422( payload["input_language"] = "hindi" response = client.post(URL, json=payload, headers=user_api_key_header) assert response.status_code == 422 - assert isinstance(response.json()["detail"], list) + assert isinstance(response.json()["errors"], list) def test_invalid_output_language_returns_422( self, client, user_api_key_header, audio_input, kb_ids @@ -561,7 +561,7 @@ def test_invalid_output_language_returns_422( payload["output_language"] = "english" response = client.post(URL, json=payload, headers=user_api_key_header) assert response.status_code == 422 - assert isinstance(response.json()["detail"], list) + assert isinstance(response.json()["errors"], list) @pytest.mark.parametrize("forbidden", ["unknown", "auto"]) def test_detection_sentinels_rejected_as_output_language(