Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 51 additions & 21 deletions backend/app/api/routes/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,13 +21,54 @@
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)

PRESIGNED_AUDIO_URL_TTL_SECONDS = 3_600


def _resolve_llm_output(
raw_content: dict[str, object],
project_id: int,
session: Session,
job_id: UUID,
) -> LLMOutput:
"""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=PRESIGNED_AUDIO_URL_TTL_SECONDS
)
except Exception as e:
logger.warning(
f"[_resolve_llm_output] Failed to generate presigned URL for audio: {e} | job_id={job_id}"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
inner["value"] = ""
inner["format"] = "url"

return _LLM_OUTPUT_ADAPTER.validate_python(raw_content)


llm_callback_router = APIRouter()


Expand Down Expand Up @@ -155,32 +198,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
Expand Down
35 changes: 8 additions & 27 deletions backend/app/api/routes/llm_sts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,7 +18,6 @@
from app.models.llm.request import (
ChainBlock,
ConfigBlob,
KaapiCompletionConfig,
LLMCallConfig,
LLMChainRequest,
QueryParams,
Expand All @@ -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

Expand Down Expand Up @@ -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,
)
)
)
Expand Down Expand Up @@ -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 = [
Expand Down
6 changes: 5 additions & 1 deletion backend/app/core/langfuse/langfuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,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
Expand Down
6 changes: 5 additions & 1 deletion backend/app/crud/assessment/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
build_gemini_attachment_parts,
resolve_attachment_values,
)
from app.services.llm.mappers import kaapi_params_as_dict
from app.services.llm.providers.registry import LLMProvider
from app.utils import get_anthropic_client, get_openai_client

Expand Down Expand Up @@ -414,7 +415,10 @@ def submit_assessment_batch(
completion = config_blob.completion
provider_name = completion.provider or "openai"

params = dict(completion.params)
# Normalize params to a plain dict: for typed Kaapi params this applies the
# compact wire format (None fields and an unset temperature dropped), so
# the batch never forwards defaults the caller didn't set.
params = kaapi_params_as_dict(completion.params)

# Determine the base provider (openai or google)
base_provider = provider_name.replace("-native", "")
Expand Down
9 changes: 7 additions & 2 deletions backend/app/crud/evaluations/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -614,8 +614,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"
Expand Down
15 changes: 11 additions & 4 deletions backend/app/crud/model_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from app.models.config.config import ConfigTag
from app.models.llm.constants import CompletionType
from app.models.llm.constants import Provider as ProviderEnum
from app.models.llm.request import CompletionConfig, ConfigBlob
from app.models.llm.request import CompletionConfig, ConfigBlob, KaapiLLMParams
from app.models.model_config import (
ModelConfigBulkUpdateItem,
ModelConfigCreate,
Expand Down Expand Up @@ -193,12 +193,19 @@ def _validate_completion_model_or_raise(
)


def _get_param(params: KaapiLLMParams | dict[str, Any] | None, key: str) -> Any:
"""Read a field from completion params, dict or typed Kaapi model alike."""
if isinstance(params, dict):
return params.get(key)
return getattr(params, key, None)


def _validate_model_or_raise(
session: Session,
*,
raw_provider: str | None,
completion_type: str,
params: dict[str, Any] | None,
params: KaapiLLMParams | dict[str, Any] | None,
) -> None:
"""Reject a (provider, type, params) whose params.model is not in model_config.

Expand All @@ -222,7 +229,7 @@ def _validate_model_or_raise(

provider = _normalize_provider(raw_provider)

model_name = (params or {}).get("model") or None
model_name = _get_param(params, "model") or None
if not model_name:
raise HTTPException(
status_code=400,
Expand All @@ -241,7 +248,7 @@ def _validate_model_or_raise(
)

if completion_type == "tts" and model_row is not None:
voice = (params or {}).get("voice")
voice = _get_param(params, "voice")
voice_spec = (
model_row.config.get("voice")
if isinstance(model_row.config, dict)
Expand Down
26 changes: 21 additions & 5 deletions backend/app/models/config/assessment_blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
from sqlmodel import Field, SQLModel

from app.models.llm.constants import Provider, TextProvider
from app.models.llm.request import CompletionType, KaapiCompletionConfig, TextLLMParams
from app.models.llm.request import (
CompletionType,
KaapiTextCompletionConfig,
TextLLMParams,
)

# json_output_schema is validated shallowly at config time: it must be a non-empty
# object-typed dict. Provider strict-mode normalisation is a run-mode concern.
Expand Down Expand Up @@ -127,19 +131,31 @@ class AssessmentTextParams(TextLLMParams):
)


class AssessmentCompletionConfig(KaapiCompletionConfig):
class AssessmentCompletionConfig(KaapiTextCompletionConfig):
provider: TextProvider = Field(
..., description="Provider to use for the assessment completion call."
)
type: Literal[CompletionType.TEXT] = CompletionType.TEXT
# Overrides the inherited `TextLLMParams` field (same reasoning as
# `PreFilterBase.params` above): kept as a plain dict, not a typed model,
# so (a) input_schema/json_output_schema survive field validation instead
# of being dropped as unknown TextLLMParams keys, and (b) dumping this
# blob doesn't invoke `ParamSerialization`'s custom serializer against a
# value that no longer matches a typed field once this validator below
# normalizes it.
params: dict[str, JsonValue] = Field(
...,
description="Assessment-scoped Kaapi text params (adds input/output schemas).",
)

@model_validator(mode="after")
def validate_params(self): # overrides KaapiCompletionConfig.validate_params
def validate_params(self):
user_set_temp = "temperature" in self.params
validated = AssessmentTextParams.model_validate(self.params)
self.params = validated.model_dump(exclude_none=True)
dumped = validated.model_dump(exclude_none=True)
if not user_set_temp:
self.params.pop("temperature", None)
dumped.pop("temperature", None)
self.params = dumped
return self


Expand Down
5 changes: 5 additions & 0 deletions backend/app/models/llm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
ConfigBlob,
KaapiLLMParams,
KaapiCompletionConfig,
KaapiTextCompletionConfig,
KaapiSTTCompletionConfig,
KaapiTTSCompletionConfig,
ProxyCompletionConfig,
build_kaapi_completion_config,
NativeCompletionConfig,
LlmCall,
AudioContent,
Expand Down
Loading
Loading