diff --git a/backend/app/alembic/versions/046_add_llm_call_table.py b/backend/app/alembic/versions/046_add_llm_call_table.py new file mode 100644 index 000000000..5ebd396af --- /dev/null +++ b/backend/app/alembic/versions/046_add_llm_call_table.py @@ -0,0 +1,201 @@ +"""add_llm_call_table + +Revision ID: 046 +Revises: 045 +Create Date: 2026-01-26 15:20:23.873332 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "046" +down_revision = "045" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "llm_call", + sa.Column( + "id", + sa.Uuid(), + nullable=False, + comment="Unique identifier for the LLM call record", + ), + sa.Column( + "job_id", + sa.Uuid(), + nullable=False, + comment="Reference to the parent job (status tracked in job table)", + ), + sa.Column( + "project_id", + sa.Integer(), + nullable=False, + comment="Reference to the project this LLM call belongs to", + ), + sa.Column( + "organization_id", + sa.Integer(), + nullable=False, + comment="Reference to the organization this LLM call belongs to", + ), + sa.Column( + "input", + sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + comment="User input - text string, binary data, or file path for multimodal", + ), + sa.Column( + "input_type", + sa.String(), + nullable=False, + comment="Input type: text, audio, image", + ), + sa.Column( + "output_type", + sa.String(), + nullable=True, + comment="Expected output type: text, audio, image", + ), + sa.Column( + "provider", + sa.String(), + nullable=False, + comment="AI provider: openai, google, anthropic", + ), + sa.Column( + "model", + sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + comment="Specific model used e.g. 'gpt-4o', 'gemini-2.5-pro'", + ), + sa.Column( + "provider_response_id", + sqlmodel.sql.sqltypes.AutoString(), + nullable=True, + comment="Original response ID from the provider (e.g., OpenAI's response ID)", + ), + sa.Column( + "content", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + comment="Response content: {text: '...'}, {audio_bytes: '...'}, or {image: '...'}", + ), + sa.Column( + "usage", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + comment="Token usage: {input_tokens, output_tokens, reasoning_tokens}", + ), + sa.Column( + "conversation_id", + sqlmodel.sql.sqltypes.AutoString(), + nullable=True, + comment="Identifier linking this response to its conversation thread", + ), + sa.Column( + "auto_create", + sa.Boolean(), + nullable=True, + comment="Whether to auto-create conversation if conversation_id doesn't exist (OpenAI specific)", + ), + sa.Column( + "config", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + comment="Configuration: {config_id, config_version} for stored config OR {config_blob} for ad-hoc config", + ), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + comment="Timestamp when the LLM call was created", + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + comment="Timestamp when the LLM call was last updated", + ), + sa.Column( + "deleted_at", + sa.DateTime(), + nullable=True, + comment="Timestamp when the record was soft-deleted", + ), + sa.ForeignKeyConstraint(["job_id"], ["job.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["organization_id"], ["organization.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "idx_llm_call_conversation_id", + "llm_call", + ["conversation_id"], + unique=False, + postgresql_where=sa.text("conversation_id IS NOT NULL AND deleted_at IS NULL"), + ) + op.create_index( + "idx_llm_call_job_id", + "llm_call", + ["job_id"], + unique=False, + postgresql_where=sa.text("deleted_at IS NULL"), + ) + op.alter_column( + "collection", + "llm_service_name", + existing_type=sa.VARCHAR(), + comment="Name of the LLM service", + existing_comment="Name of the LLM service provider", + existing_nullable=False, + ) + op.alter_column( + "llm_call", + "provider", + existing_type=sa.VARCHAR(), + comment="AI provider as sent by user (e.g openai, -native, google)", + existing_comment="AI provider: openai, google, anthropic", + existing_nullable=False, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "collection", + "llm_service_name", + existing_type=sa.VARCHAR(), + comment="Name of the LLM service provider", + existing_comment="Name of the LLM service", + existing_nullable=False, + ) + op.alter_column( + "llm_call", + "provider", + existing_type=sa.VARCHAR(), + comment="AI provider: openai, google, anthropic", + existing_comment="AI provider as sent by user (e.g openai, -native, google)", + existing_nullable=False, + ) + op.drop_index( + "idx_llm_call_job_id", + table_name="llm_call", + postgresql_where=sa.text("deleted_at IS NULL"), + ) + op.drop_index( + "idx_llm_call_conversation_id", + table_name="llm_call", + postgresql_where=sa.text("conversation_id IS NOT NULL AND deleted_at IS NULL"), + ) + op.drop_table("llm_call") + # ### end Alembic commands ### diff --git a/backend/app/api/routes/config/version.py b/backend/app/api/routes/config/version.py index 5f3e8626a..fd5e057f7 100644 --- a/backend/app/api/routes/config/version.py +++ b/backend/app/api/routes/config/version.py @@ -4,7 +4,7 @@ from app.api.deps import SessionDep, AuthContextDep from app.crud.config import ConfigCrud, ConfigVersionCrud from app.models import ( - ConfigVersionCreate, + ConfigVersionUpdate, ConfigVersionPublic, Message, ConfigVersionItems, @@ -24,13 +24,16 @@ ) def create_version( config_id: UUID, - version_create: ConfigVersionCreate, + version_create: ConfigVersionUpdate, current_user: AuthContextDep, session: SessionDep, ): """ Create a new version for an existing configuration. - The version number is automatically incremented. + + Only include the fields you want to update in config_blob. + Provider, model, and params can be changed. + Type is inherited from existing config and cannot be changed. """ version_crud = ConfigVersionCrud( session=session, project_id=current_user.project_.id, config_id=config_id diff --git a/backend/app/core/langfuse/langfuse.py b/backend/app/core/langfuse/langfuse.py index d70d07e09..a8a45f65b 100644 --- a/backend/app/core/langfuse/langfuse.py +++ b/backend/app/core/langfuse/langfuse.py @@ -6,11 +6,45 @@ from asgi_correlation_id import correlation_id from langfuse import Langfuse from langfuse.client import StatefulGenerationClient, StatefulTraceClient -from app.models.llm import NativeCompletionConfig, QueryParams, LLMCallResponse +from app.models.llm import ( + NativeCompletionConfig, + QueryParams, + LLMCallResponse, + TextOutput, + AudioOutput, +) logger = logging.getLogger(__name__) +def extract_output_value( + llm_output: TextOutput | AudioOutput | None, +) -> str | dict[str, Any]: + """Extract output value from LLM output for logging/tracing. + + Args: + llm_output: The output (TextOutput, AudioOutput, or None) + + Returns: + String value for text output, or dict with metadata for audio output + """ + if not llm_output: + return "" + + if isinstance(llm_output, TextOutput): + return llm_output.content.value + elif isinstance(llm_output, AudioOutput): + # For audio, return metadata instead of the full base64 data + return { + "type": "audio", + "format": llm_output.content.format, + "mime_type": llm_output.content.mime_type, + "length": len(llm_output.content.value), + } + else: + return str(llm_output) + + class LangfuseTracer: def __init__( self, @@ -228,7 +262,7 @@ def langfuse_call(fn, *args, **kwargs): generation.end, output={ "status": "success", - "output": response.response.output.text, + "output": extract_output_value(response.response.output), }, usage_details={ "input": response.usage.input_tokens, @@ -241,7 +275,7 @@ def langfuse_call(fn, *args, **kwargs): trace.update, output={ "status": "success", - "output": response.response.output.text, + "output": extract_output_value(response.response.output), }, session_id=session_id or response.response.conversation_id, ) diff --git a/backend/app/crud/config/version.py b/backend/app/crud/config/version.py index f834c168b..915d1b18d 100644 --- a/backend/app/crud/config/version.py +++ b/backend/app/crud/config/version.py @@ -1,13 +1,22 @@ import logging from uuid import UUID +from typing import Any from sqlmodel import Session, select, and_, func from fastapi import HTTPException from sqlalchemy.orm import defer +from pydantic import ValidationError from .config import ConfigCrud from app.core.util import now -from app.models import Config, ConfigVersion, ConfigVersionCreate, ConfigVersionItems +from app.models import ( + Config, + ConfigVersion, + ConfigVersionCreate, + ConfigVersionUpdate, + ConfigVersionItems, +) +from app.models.llm.request import ConfigBlob logger = logging.getLogger(__name__) @@ -22,19 +31,55 @@ def __init__(self, session: Session, config_id: UUID, project_id: int): self.project_id = project_id self.config_id = config_id - def create_or_raise(self, version_create: ConfigVersionCreate) -> ConfigVersion: + def create_or_raise(self, version_create: ConfigVersionUpdate) -> ConfigVersion: """ - Create a new version for an existing configuration. - Automatically increments the version number. + Create a new version from a partial config update. + + Fetches the latest version, merges the partial config with it, + validates the result, and creates the new version. + + Fields 'type' is inherited from the existing config + and cannot be changed. """ self._config_exists_or_raise(self.config_id) + + # Get the latest version (required for partial updates) + latest_version = self._get_latest_version() + if latest_version is None: + raise HTTPException( + status_code=400, + detail="Cannot create partial version: no existing version found. Use full config for initial version.", + ) + + # Merge partial config with existing config + merged_config = self._deep_merge( + base=latest_version.config_blob, + updates=version_create.config_blob, + ) + + # Validate that provider and type haven't been changed + self._validate_immutable_fields(latest_version.config_blob, merged_config) + + # Validate the merged config as ConfigBlob + try: + validated_blob = ConfigBlob.model_validate(merged_config) + except ValidationError as e: + logger.error( + f"[ConfigVersionCrud.create_from_partial] Validation failed | " + f"{{'config_id': '{self.config_id}', 'error': '{str(e)}'}}" + ) + raise HTTPException( + status_code=400, + detail=f"Invalid config after merge: {str(e)}", + ) + try: next_version = self._get_next_version(self.config_id) version = ConfigVersion( config_id=self.config_id, version=next_version, - config_blob=version_create.config_blob.model_dump(), + config_blob=validated_blob.model_dump(), commit_message=version_create.commit_message, ) @@ -43,7 +88,7 @@ def create_or_raise(self, version_create: ConfigVersionCreate) -> ConfigVersion: self.session.refresh(version) logger.info( - f"[ConfigVersionCrud.create] Version created successfully | " + f"[ConfigVersionCrud.create_from_partial] Version created successfully | " f"{{'config_id': '{self.config_id}', 'version_id': '{version.id}'}}" ) @@ -52,7 +97,7 @@ def create_or_raise(self, version_create: ConfigVersionCreate) -> ConfigVersion: except Exception as e: self.session.rollback() logger.error( - f"[ConfigVersionCrud.create] Failed to create version | " + f"[ConfigVersionCrud.create_from_partial] Failed to create version | " f"{{'config_id': '{self.config_id}', 'error': '{str(e)}'}}", exc_info=True, ) @@ -61,6 +106,62 @@ def create_or_raise(self, version_create: ConfigVersionCreate) -> ConfigVersion: detail="Unexpected error occurred: failed to create version", ) + def _get_latest_version(self) -> ConfigVersion | None: + """Get the latest version for the config.""" + stmt = ( + select(ConfigVersion) + .where( + and_( + ConfigVersion.config_id == self.config_id, + ConfigVersion.deleted_at.is_(None), + ) + ) + .order_by(ConfigVersion.version.desc()) + .limit(1) + ) + return self.session.exec(stmt).first() + + def _deep_merge( + self, base: dict[str, Any], updates: dict[str, Any] + ) -> dict[str, Any]: + """ + Deep merge two dictionaries. + Values from 'updates' override values in 'base'. + Nested dicts are merged recursively. + """ + result = base.copy() + + for key, value in updates.items(): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): + result[key] = self._deep_merge(result[key], value) + else: + result[key] = value + + return result + + def _validate_immutable_fields( + self, existing: dict[str, Any], merged: dict[str, Any] + ) -> None: + """ + Validate that immutable fields (type) haven't been changed. + Provider and model can change between versions. + """ + existing_completion = existing.get("completion", {}) + merged_completion = merged.get("completion", {}) + + existing_type = existing_completion.get("type") + merged_type = merged_completion.get("type") + + if existing_type != merged_type: + raise HTTPException( + status_code=400, + detail=f"Cannot change config type from '{existing_type}' to '{merged_type}'. Type is immutable.", + ) + def read_one(self, version_number: int) -> ConfigVersion | None: """ Read a specific configuration version by its version number. @@ -140,3 +241,55 @@ def _config_exists_or_raise(self, config_id: UUID) -> Config: """Check if a config exists in the project.""" config_crud = ConfigCrud(session=self.session, project_id=self.project_id) config_crud.exists_or_raise(config_id) + + def _validate_config_type_unchanged( + self, version_create: ConfigVersionCreate + ) -> None: + """ + Validate that the config type (text/stt/tts) in the new version matches + the type from the latest existing version. + Raises HTTPException if types don't match. + """ + # Get the latest version + stmt = ( + select(ConfigVersion) + .where( + and_( + ConfigVersion.config_id == self.config_id, + ConfigVersion.deleted_at.is_(None), + ) + ) + .order_by(ConfigVersion.version.desc()) + .limit(1) + ) + latest_version = self.session.exec(stmt).first() + + # If this is the first version, no validation needed + if latest_version is None: + return + + # Extract types from config blobs + old_type = latest_version.config_blob.get("completion", {}).get("type") + new_type = ( + version_create.config_blob.model_dump().get("completion", {}).get("type") + ) + + if old_type is None or new_type is None: + logger.error( + f"[ConfigVersionCrud._validate_config_type_unchanged] Missing type field | " + f"{{'config_id': '{self.config_id}', 'old_type': {old_type}, 'new_type': {new_type}}}" + ) + raise HTTPException( + status_code=400, + detail="Config type field is missing in configuration blob", + ) + + if old_type != new_type: + logger.warning( + f"[ConfigVersionCrud._validate_config_type_unchanged] Type mismatch | " + f"{{'config_id': '{self.config_id}', 'old_type': '{old_type}', 'new_type': '{new_type}'}}" + ) + raise HTTPException( + status_code=400, + detail=f"Cannot change config type from '{old_type}' to '{new_type}'. Config type must remain consistent across versions.", + ) diff --git a/backend/app/crud/evaluations/core.py b/backend/app/crud/evaluations/core.py index 81c5e2480..86f9f0264 100644 --- a/backend/app/crud/evaluations/core.py +++ b/backend/app/crud/evaluations/core.py @@ -497,5 +497,10 @@ def resolve_model_from_config( f"(config_id={eval_run.config_id}, version={eval_run.config_version}): {error}" ) - model = config.completion.params.model + # params is a dict, not a Pydantic model, so use dict access + model = config.completion.params.get("model") + if not model: + raise ValueError( + f"Config for evaluation {eval_run.id} does not contain a 'model' parameter" + ) return model diff --git a/backend/app/crud/evaluations/cron.py b/backend/app/crud/evaluations/cron.py index 22b711f90..e83937360 100644 --- a/backend/app/crud/evaluations/cron.py +++ b/backend/app/crud/evaluations/cron.py @@ -9,6 +9,7 @@ import logging from typing import Any + from sqlmodel import Session from app.crud.evaluations.processing import poll_all_pending_evaluations diff --git a/backend/app/crud/llm.py b/backend/app/crud/llm.py new file mode 100644 index 000000000..b5c23cd6e --- /dev/null +++ b/backend/app/crud/llm.py @@ -0,0 +1,224 @@ +""" +CRUD operations for LLM calls. + +This module handles database operations for LLM calls including: +1. Creating new LLM call records +2. Updating LLM call responses +3. Fetching LLM calls by ID +""" + +import logging +from typing import Any, Literal + +from uuid import UUID +from sqlmodel import Session, select +from app.core.util import now +import json +from app.models.llm import LlmCall, LLMCallRequest, ConfigBlob +from app.models.llm.request import ( + TextInput, + AudioInput, + QueryInput, +) + +logger = logging.getLogger(__name__) + + +def serialize_input(query_input: QueryInput | str) -> str: + """Serialize query input for database storage. + + For text: stores the actual content value + For audio: stores metadata (type, mime_type, size) + """ + # Handle string input (should be normalized by QueryParams validator, but be defensive) + if isinstance(query_input, str): + return query_input + elif isinstance(query_input, TextInput): + return query_input.content.value + elif isinstance(query_input, AudioInput): + return json.dumps( + { + "type": "audio", + "format": query_input.content.format, + "mime_type": query_input.content.mime_type, + "size_bytes": len(query_input.content.value), + } + ) + else: + return str(query_input) + + +def create_llm_call( + session: Session, + *, + request: LLMCallRequest, + job_id: UUID, + project_id: int, + organization_id: int, + resolved_config: ConfigBlob, + original_provider: str, +) -> LlmCall: + """ + Create a new LLM call record in the database. + + Args: + session: Database session + request: The LLM call request containing query and config + job_id: Reference to the parent job + project_id: Project this LLM call belongs to + organization_id: Organization this LLM call belongs to + resolved_config: The resolved configuration blob (either from stored config or ad-hoc) + + Returns: + LlmCall: The created LLM call record + """ + # Determine input/output types based on completion config type + completion_config = resolved_config.completion + completion_type = completion_config.type or getattr( + completion_config.params, "type", "text" + ) + + input_type: Literal["text", "audio", "image"] + output_type: Literal["text", "audio", "image"] | None + + if completion_type == "stt": + input_type = "audio" + output_type = "text" + elif completion_type == "tts": + input_type = "text" + output_type = "audio" + else: + input_type = "text" + output_type = "text" + + model = ( + completion_config.params.model + if hasattr(completion_config.params, "model") + else completion_config.params.get("model", "") + ) + + # Build config dict for storage + config_dict: dict[str, Any] + if request.config.is_stored_config: + config_dict = { + "config_id": str(request.config.id), + "config_version": request.config.version, + } + else: + config_dict = { + "config_blob": resolved_config.model_dump(), + } + + # Extract conversation info if present + conversation_id = None + auto_create = None + if request.query.conversation: + conversation_id = request.query.conversation.id + auto_create = request.query.conversation.auto_create + + db_llm_call = LlmCall( + job_id=job_id, + project_id=project_id, + organization_id=organization_id, + input=serialize_input(request.query.input), + input_type=input_type, + output_type=output_type, + provider=original_provider, + model=model, + conversation_id=conversation_id, + auto_create=auto_create, + config=config_dict, + ) + + session.add(db_llm_call) + session.commit() + session.refresh(db_llm_call) + + logger.info( + f"[create_llm_call] Created LLM call id={db_llm_call.id}, " + f"job_id={job_id}, provider={original_provider}, model={model}" + ) + + return db_llm_call + + +def update_llm_call_response( + session: Session, + *, + llm_call_id: UUID, + provider_response_id: str | None = None, + content: dict[str, Any] | None = None, + usage: dict[str, Any] | None = None, + conversation_id: str | None = None, +) -> LlmCall: + """ + Update an LLM call record with response data. + + Args: + session: Database session + llm_call_id: The LLM call record ID to update + provider_response_id: Original response ID from the provider + content: Response content dict + usage: Token usage dict + conversation_id: Conversation ID if created/updated + + Returns: + LlmCall: The updated LLM call record + + Raises: + ValueError: If the LLM call record is not found + """ + db_llm_call = session.get(LlmCall, llm_call_id) + if not db_llm_call: + raise ValueError(f"LLM call not found with id={llm_call_id}") + + if provider_response_id is not None: + db_llm_call.provider_response_id = provider_response_id + if content is not None: + db_llm_call.content = content + if usage is not None: + db_llm_call.usage = usage + if conversation_id is not None: + db_llm_call.conversation_id = conversation_id + + db_llm_call.updated_at = now() + + session.add(db_llm_call) + session.commit() + session.refresh(db_llm_call) + + logger.info(f"[update_llm_call_response] Updated LLM call id={llm_call_id}") + + return db_llm_call + + +def get_llm_call_by_id( + session: Session, + llm_call_id: UUID, + project_id: int | None = None, +) -> LlmCall | None: + statement = select(LlmCall).where( + LlmCall.id == llm_call_id, + LlmCall.deleted_at.is_(None), + ) + + if project_id is not None: + statement = statement.where(LlmCall.project_id == project_id) + + return session.exec(statement).first() + + +def get_llm_calls_by_job_id( + session: Session, + job_id: UUID, +) -> list[LlmCall]: + statement = ( + select(LlmCall) + .where( + LlmCall.job_id == job_id, + LlmCall.deleted_at.is_(None), + ) + .order_by(LlmCall.created_at.desc()) + ) + + return list(session.exec(statement).all()) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 12e6fefcd..a0149a820 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -34,6 +34,7 @@ ConfigVersion, ConfigVersionBase, ConfigVersionCreate, + ConfigVersionUpdate, ConfigVersionPublic, ConfigVersionItems, ) @@ -103,6 +104,7 @@ CompletionConfig, LLMCallRequest, LLMCallResponse, + LlmCall, ) from .message import Message diff --git a/backend/app/models/config/__init__.py b/backend/app/models/config/__init__.py index fa34aa1d6..26066524e 100644 --- a/backend/app/models/config/__init__.py +++ b/backend/app/models/config/__init__.py @@ -10,6 +10,7 @@ ConfigVersion, ConfigVersionBase, ConfigVersionCreate, + ConfigVersionUpdate, ConfigVersionPublic, ConfigVersionItems, ) @@ -23,6 +24,7 @@ "ConfigVersion", "ConfigVersionBase", "ConfigVersionCreate", + "ConfigVersionUpdate", "ConfigVersionItems", "ConfigVersionPublic", "ConfigWithVersion", diff --git a/backend/app/models/config/version.py b/backend/app/models/config/version.py index 5a374582e..8c62c469e 100644 --- a/backend/app/models/config/version.py +++ b/backend/app/models/config/version.py @@ -96,6 +96,26 @@ class ConfigVersionCreate(ConfigVersionBase): ) +class ConfigVersionUpdate(SQLModel): + """ + Partial update model for creating a new config version. + + Only the fields that need to change should be provided. + Fields like 'type'(text, stt,tts) are inherited from the existing config + and cannot be changed, + """ + + config_blob: dict[str, Any] = Field( + description="Partial config blob. Only include fields you want to update. " + "Provider and type are inherited from existing config and cannot be changed.", + ) + commit_message: str | None = Field( + default=None, + max_length=512, + description="Optional message describing the changes in this version", + ) + + class ConfigVersionPublic(ConfigVersionBase): id: UUID = Field(description="Unique id for the configuration version") config_id: UUID = Field(description="Id of the parent configuration") diff --git a/backend/app/models/llm/__init__.py b/backend/app/models/llm/__init__.py index 8738e2126..b183543c4 100644 --- a/backend/app/models/llm/__init__.py +++ b/backend/app/models/llm/__init__.py @@ -6,5 +6,15 @@ KaapiLLMParams, KaapiCompletionConfig, NativeCompletionConfig, + LlmCall, + AudioContent, + TextContent, +) +from app.models.llm.response import ( + LLMCallResponse, + LLMResponse, + LLMOutput, + Usage, + TextOutput, + AudioOutput, ) -from app.models.llm.response import LLMCallResponse, LLMResponse, LLMOutput, Usage diff --git a/backend/app/models/llm/request.py b/backend/app/models/llm/request.py index 4da892e92..79ce10bf1 100644 --- a/backend/app/models/llm/request.py +++ b/backend/app/models/llm/request.py @@ -1,24 +1,20 @@ from typing import Annotated, Any, Literal, Union -from uuid import UUID +from uuid import UUID, uuid4 from sqlmodel import Field, SQLModel from pydantic import Discriminator, model_validator, HttpUrl +from datetime import datetime +from app.core.util import now +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import Field, SQLModel, Index, text -class KaapiLLMParams(SQLModel): - """ - Kaapi-abstracted parameters for LLM providers. - These parameters are mapped internally to provider-specific API parameters. - Provides a unified contract across all LLM providers (OpenAI, Claude, Gemini, etc.). - Provider-specific mappings are handled at the mapper level. - """ - model: str = Field( - description="Model identifier to use for completion (e.g., 'gpt-4o', 'gpt-5')", - ) +class TextLLMParams(SQLModel): + model: str instructions: str | None = Field( default=None, - description="System instructions to guide the model's behavior", ) knowledge_base_ids: list[str] | None = Field( default=None, @@ -32,13 +28,71 @@ class KaapiLLMParams(SQLModel): default=None, ge=0.0, le=2.0, - description="Sampling temperature between 0 and 2", ) max_num_results: int | None = Field( default=None, ge=1, - description="Maximum number of results to return", + description="Maximum number of candidate results to return", + ) + + +class STTLLMParams(SQLModel): + model: str + instructions: str + input_language: str | None = None + output_language: str | None = None + response_format: Literal["text"] | None = Field( + None, + description="Currently supports text type", ) + temperature: float | None = Field( + default=0.2, + ge=0.0, + le=2.0, + ) + + +class TTSLLMParams(SQLModel): + model: str + voice: str + language: str + response_format: Literal["mp3", "wav", "ogg"] | None = "wav" + + +KaapiLLMParams = Union[TextLLMParams, STTLLMParams, TTSLLMParams] + + +# Input type models for discriminated union +class TextContent(SQLModel): + format: Literal["text"] = "text" + value: str = Field(..., description="Text content") + + +class AudioContent(SQLModel): + format: Literal["base64"] = "base64" + value: str = Field(..., min_length=1, description="Base64 encoded audio") + # keeping the mime_type liberal here, since does not affect transcription type + mime_type: str | None = Field( + None, + description="MIME type of the audio (e.g., audio/wav, audio/mp3, audio/ogg)", + ) + + +class TextInput(SQLModel): + type: Literal["text"] = "text" + content: TextContent + + +class AudioInput(SQLModel): + type: Literal["audio"] = "audio" + content: AudioContent + + +# Discriminated union for query input types +QueryInput = Annotated[ + Union[TextInput, AudioInput], + Field(discriminator="type"), +] class ConversationConfig(SQLModel): @@ -71,16 +125,30 @@ def validate_conversation_logic(self): class QueryParams(SQLModel): """Query-specific parameters for each LLM call.""" - input: str = Field( + input: str | QueryInput = Field( ..., - min_length=1, - description="User input question/query/prompt, used to generate a response.", + description=( + "User input - either a plain string (text) or a structured input object. " + ), ) conversation: ConversationConfig | None = Field( default=None, description="Conversation control configuration for context handling.", ) + @model_validator(mode="before") + @classmethod + def normalize_input(cls, data: Any) -> Any: + """Normalize plain string input to TextInput for consistency.""" + if isinstance(data, dict) and "input" in data: + input_val = data["input"] + if isinstance(input_val, str): + data["input"] = { + "type": "text", + "content": {"format": "text", "value": input_val}, + } + return data + class NativeCompletionConfig(SQLModel): """ @@ -89,14 +157,17 @@ class NativeCompletionConfig(SQLModel): Supports any LLM provider's native API format. """ - provider: Literal["openai-native"] = Field( - default="openai-native", + provider: Literal["openai-native", "google-native"] = Field( + ..., description="Native provider type (e.g., openai-native)", ) params: dict[str, Any] = Field( ..., description="Provider-specific parameters (schema varies by provider), should exactly match the provider's endpoint params structure", ) + type: Literal["text", "stt", "tts"] = Field( + ..., description="Completion config type. Params schema varies by type" + ) class KaapiCompletionConfig(SQLModel): @@ -106,12 +177,31 @@ class KaapiCompletionConfig(SQLModel): Supports multiple providers: OpenAI, Claude, Gemini, etc. """ - provider: Literal["openai"] = Field(..., description="LLM provider (openai)") - params: KaapiLLMParams = Field( + provider: Literal["openai", "google"] = Field( + ..., description="LLM provider (openai)" + ) + + type: Literal["text", "stt", "tts"] = Field( + ..., description="Completion config type. Params schema varies by type" + ) + params: dict[str, Any] = 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] + validated = model_class.model_validate(self.params) + self.params = validated.model_dump(exclude_none=True) + return self + # Discriminated union for completion configs based on provider field CompletionConfig = Annotated[ @@ -237,3 +327,172 @@ class LLMCallRequest(SQLModel): "The exact dictionary provided here will be returned in the response metadata field." ), ) + + +class LlmCall(SQLModel, table=True): + """ + Database model for tracking LLM API call requests and responses. + + Stores both request inputs and response outputs for traceability, + supporting multimodal inputs (text, audio, image) and various completion types. + """ + + __tablename__ = "llm_call" + __table_args__ = ( + Index( + "idx_llm_call_job_id", + "job_id", + postgresql_where=text("deleted_at IS NULL"), + ), + Index( + "idx_llm_call_conversation_id", + "conversation_id", + postgresql_where=text("conversation_id IS NOT NULL AND deleted_at IS NULL"), + ), + ) + + id: UUID = Field( + default_factory=uuid4, + primary_key=True, + sa_column_kwargs={"comment": "Unique identifier for the LLM call record"}, + ) + + job_id: UUID = Field( + foreign_key="job.id", + nullable=False, + ondelete="CASCADE", + sa_column_kwargs={ + "comment": "Reference to the parent job (status tracked in job table)" + }, + ) + + project_id: int = Field( + foreign_key="project.id", + nullable=False, + ondelete="CASCADE", + sa_column_kwargs={ + "comment": "Reference to the project this LLM call belongs to" + }, + ) + + organization_id: int = Field( + foreign_key="organization.id", + nullable=False, + ondelete="CASCADE", + sa_column_kwargs={ + "comment": "Reference to the organization this LLM call belongs to" + }, + ) + + # Request fields + input: str = Field( + ..., + sa_column_kwargs={ + "comment": "User input - text string, binary data, or file path for multimodal" + }, + ) + + input_type: Literal["text", "audio", "image"] = Field( + ..., + sa_column=sa.Column( + sa.String, + nullable=False, + comment="Input type: text, audio, image", + ), + ) + + output_type: Literal["text", "audio", "image"] | None = Field( + default=None, + sa_column=sa.Column( + sa.String, + nullable=True, + comment="Expected output type: text, audio, image", + ), + ) + + # Provider and model info + provider: str = Field( + ..., + sa_column=sa.Column( + sa.String, + nullable=False, + comment="AI provider as sent by user (e.g openai, -native, google)", + ), + ) + + model: str = Field( + ..., + sa_column_kwargs={ + "comment": "Specific model used e.g. 'gpt-4o', 'gemini-2.5-pro'" + }, + ) + + # Response fields + provider_response_id: str | None = Field( + default=None, + sa_column_kwargs={ + "comment": "Original response ID from the provider (e.g., OpenAI's response ID)" + }, + ) + + content: dict[str, Any] | None = Field( + default=None, + sa_column=sa.Column( + JSONB, + nullable=True, + comment="Response content: {text: '...'}, {audio_bytes: '...'}, or {image: '...'}", + ), + ) + + usage: dict[str, Any] | None = Field( + default=None, + sa_column=sa.Column( + JSONB, + nullable=True, + comment="Token usage: {input_tokens, output_tokens, reasoning_tokens}", + ), + ) + + # Conversation tracking + conversation_id: str | None = Field( + default=None, + sa_column_kwargs={ + "comment": "Identifier linking this response to its conversation thread" + }, + ) + + auto_create: bool | None = Field( + default=None, + sa_column_kwargs={ + "comment": "Whether to auto-create conversation if conversation_id doesn't exist (OpenAI specific)" + }, + ) + + # Configuration - stores either {config_id, config_version} or {config_blob} + config: dict[str, Any] | None = Field( + default=None, + sa_column=sa.Column( + JSONB, + nullable=True, + comment="Configuration: {config_id, config_version} for stored config OR {config_blob} for ad-hoc config", + ), + ) + + # Timestamps + created_at: datetime = Field( + default_factory=now, + nullable=False, + sa_column_kwargs={"comment": "Timestamp when the LLM call was created"}, + ) + + updated_at: datetime = Field( + default_factory=now, + nullable=False, + sa_column_kwargs={"comment": "Timestamp when the LLM call was last updated"}, + ) + + deleted_at: datetime | None = Field( + default=None, + nullable=True, + sa_column_kwargs={"comment": "Timestamp when the record was soft-deleted"}, + ) diff --git a/backend/app/models/llm/response.py b/backend/app/models/llm/response.py index 34c9b9d9b..7b13e301c 100644 --- a/backend/app/models/llm/response.py +++ b/backend/app/models/llm/response.py @@ -3,19 +3,31 @@ This module contains structured response models for LLM API calls. """ + from sqlmodel import SQLModel, Field +from typing import Literal, Annotated +from app.models.llm.request import AudioContent, TextContent class Usage(SQLModel): input_tokens: int output_tokens: int total_tokens: int + reasoning_tokens: int | None = None + + +class TextOutput(SQLModel): + type: Literal["text"] = "text" + content: TextContent + +class AudioOutput(SQLModel): + type: Literal["audio"] = "audio" + content: AudioContent -class LLMOutput(SQLModel): - """Standardized output format for LLM responses.""" - text: str = Field(..., description="Primary text content of the LLM response.") +# Type alias for LLM output (discriminated union) +LLMOutput = Annotated[TextOutput | AudioOutput | None, Field(discriminator="type")] class LLMResponse(SQLModel): diff --git a/backend/app/services/evaluations/evaluation.py b/backend/app/services/evaluations/evaluation.py index 8c3d01656..594122cb4 100644 --- a/backend/app/services/evaluations/evaluation.py +++ b/backend/app/services/evaluations/evaluation.py @@ -16,6 +16,7 @@ start_evaluation_batch, ) from app.models.evaluation import EvaluationRun +from app.models.llm.request import TextLLMParams, STTLLMParams, TTSLLMParams from app.services.llm.providers import LLMProvider from app.utils import get_langfuse_client, get_openai_client from app.core.cloud.storage import get_cloud_storage @@ -141,12 +142,21 @@ def start_evaluation( # Step 4: Start the batch evaluation try: + # Convert params dict to appropriate model instance based on type + param_models = { + "text": TextLLMParams, + "stt": STTLLMParams, + "tts": TTSLLMParams, + } + model_class = param_models[config.completion.type] + validated_params = model_class.model_validate(config.completion.params) + eval_run = start_evaluation_batch( langfuse=langfuse, openai_client=openai_client, session=session, eval_run=eval_run, - config=config.completion.params, + config=validated_params, ) logger.info( diff --git a/backend/app/services/llm/__init__.py b/backend/app/services/llm/__init__.py index 730a53fee..5ba7fa6ea 100644 --- a/backend/app/services/llm/__init__.py +++ b/backend/app/services/llm/__init__.py @@ -1,8 +1,5 @@ # Providers -from app.services.llm.providers import ( - BaseProvider, - OpenAIProvider, -) +from app.services.llm.providers import BaseProvider, OpenAIProvider, GoogleAIProvider from app.services.llm.providers import ( LLMProvider, get_llm_provider, diff --git a/backend/app/services/llm/input_resolver.py b/backend/app/services/llm/input_resolver.py new file mode 100644 index 000000000..69679c008 --- /dev/null +++ b/backend/app/services/llm/input_resolver.py @@ -0,0 +1,87 @@ +import base64 +import logging +import tempfile +from pathlib import Path + +from app.models.llm.request import ( + TextInput, + AudioInput, + QueryInput, +) + + +logger = logging.getLogger(__name__) + + +def get_file_extension(mime_type: str) -> str: + """Map MIME type to file extension.""" + mime_to_ext = { + "audio/wav": ".wav", + "audio/wave": ".wav", + "audio/x-wav": ".wav", + "audio/mp3": ".mp3", + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + "audio/flac": ".flac", + "audio/webm": ".webm", + "audio/mp4": ".mp4", + "audio/m4a": ".m4a", + } + return mime_to_ext.get(mime_type, ".audio") + + +# important!! +def resolve_input(query_input: QueryInput) -> tuple[str, str | None]: + """Resolve discriminated union input to content string. + + Args: + query_input: The input from QueryParams (TextInput or AudioInput) + + Returns: + (content_string, None) on success - for text returns content value, for audio returns temp file path + ("", error_message) on failure + """ + try: + if isinstance(query_input, TextInput): + return query_input.content.value, None + + elif isinstance(query_input, AudioInput): + # AudioInput content is base64-encoded audio + mime_type = query_input.content.mime_type or "audio/wav" + return resolve_audio_base64(query_input.content.value, mime_type) + + else: + return "", f"Unknown input type: {type(query_input)}" + + except Exception as e: + logger.error(f"[resolve_input] Failed to resolve input: {e}", exc_info=True) + return "", f"Failed to resolve input: {str(e)}" + + +def resolve_audio_base64(data: str, mime_type: str) -> tuple[str, str | None]: + """Decode base64 audio and write to temp file. Returns (file_path, error).""" + try: + audio_bytes = base64.b64decode(data) + except Exception as e: + return "", f"Invalid base64 audio data: {str(e)}" + + ext = get_file_extension(mime_type) + try: + with tempfile.NamedTemporaryFile( + suffix=ext, delete=False, prefix="audio_" + ) as tmp: + tmp.write(audio_bytes) + temp_path = tmp.name + + logger.info(f"[resolve_audio_base64] Wrote audio to temp file: {temp_path}") + return temp_path, None + except Exception as e: + return "", f"Failed to write audio to temp file: {str(e)}" + + +def cleanup_temp_file(file_path: str) -> None: + """Clean up a temporary file if it exists.""" + try: + Path(file_path).unlink(missing_ok=True) + except Exception as e: + logger.warning(f"[cleanup_temp_file] Failed to delete temp file: {e}") diff --git a/backend/app/services/llm/jobs.py b/backend/app/services/llm/jobs.py index 492c1dc2c..adc2051eb 100644 --- a/backend/app/services/llm/jobs.py +++ b/backend/app/services/llm/jobs.py @@ -11,11 +11,14 @@ from app.crud.config import ConfigVersionCrud from app.crud.credentials import get_provider_credential from app.crud.jobs import JobCrud -from app.models import JobStatus, JobType, JobUpdate, LLMCallRequest +from app.crud.llm import create_llm_call, update_llm_call_response +from app.models import JobStatus, JobType, JobUpdate, LLMCallRequest, Job from app.models.llm.request import ConfigBlob, LLMCallConfig, KaapiCompletionConfig from app.services.llm.guardrails import call_guardrails from app.services.llm.providers.registry import get_llm_provider from app.services.llm.mappers import transform_kaapi_config_to_native +from app.services.llm.input_resolver import resolve_input, cleanup_temp_file + from app.utils import APIResponse, send_callback logger = logging.getLogger(__name__) @@ -29,6 +32,14 @@ def start_job( job_crud = JobCrud(session=db) job = job_crud.create(job_type=JobType.LLM_API, trace_id=trace_id) + # Explicitly flush to ensure job is persisted before Celery task starts + db.flush() + db.commit() + + logger.info( + f"[start_job] Created job | job_id={job.id}, status={job.status}, project_id={project_id}" + ) + try: task_id = start_high_priority_job( function_path="app.services.llm.jobs.execute_job", @@ -140,6 +151,7 @@ def execute_job( output_guardrails = request.output_guardrails callback_response = None config_blob: ConfigBlob | None = None + llm_call_id: UUID | None = None # Track the LLM call record logger.info( f"[execute_job] Starting LLM job execution | job_id={job_id}, task_id={task_id}, " @@ -157,18 +169,20 @@ def execute_job( logger.info("[execute_job] Guardrails bypassed (service unavailable)") elif safe_input["success"]: - request.query.input = safe_input["data"]["safe_text"] + # Update the text value within the QueryInput structure + request.query.input.content.value = safe_input["data"]["safe_text"] if safe_input["data"]["rephrase_needed"]: callback_response = APIResponse.failure_response( - error=request.query.input, + error=safe_input["data"]["safe_text"], metadata=request.request_metadata, ) return handle_job_error( job_id, request.callback_url, callback_response ) else: - request.query.input = safe_input["error"] + # Update the text value with error message + request.query.input.content.value = safe_input["error"] callback_response = APIResponse.failure_response( error=safe_input["error"], @@ -179,6 +193,24 @@ def execute_job( with Session(engine) as session: # Update job status to PROCESSING job_crud = JobCrud(session=session) + logger.info(f"[execute_job] Attempting to fetch job | job_id={job_id}") + job = session.get(Job, job_id) + if not job: + # Log all jobs to see what's in the database + from sqlmodel import select + + all_jobs = session.exec( + select(Job).order_by(Job.created_at.desc()).limit(5) + ).all() + logger.error( + f"[execute_job] Job not found! | job_id={job_id} | " + f"Recent jobs in DB: {[(j.id, j.status) for j in all_jobs]}" + ) + else: + logger.info( + f"[execute_job] Found job | job_id={job_id}, status={job.status}" + ) + job_crud.update( job_id=job_id, job_update=JobUpdate(status=JobStatus.PROCESSING) ) @@ -204,16 +236,26 @@ def execute_job( else: config_blob = config.blob + user_sent_config_provider = "" + try: # Transform Kaapi config to native config if needed (before getting provider) completion_config = config_blob.completion + + original_provider = ( + config_blob.completion.provider + ) # openai, google or prefixed + if isinstance(completion_config, KaapiCompletionConfig): completion_config, warnings = transform_kaapi_config_to_native( completion_config ) + if request.request_metadata is None: request.request_metadata = {} request.request_metadata.setdefault("warnings", []).extend(warnings) + else: + pass except Exception as e: callback_response = APIResponse.failure_response( error=f"Error processing configuration: {str(e)}", @@ -221,10 +263,39 @@ def execute_job( ) return handle_job_error(job_id, request.callback_url, callback_response) + # Create LLM call record before execution + try: + # Rebuild ConfigBlob with transformed native config + resolved_config_blob = ConfigBlob(completion=completion_config) + + llm_call = create_llm_call( + session, + request=request, + job_id=job_id, + project_id=project_id, + organization_id=organization_id, + resolved_config=resolved_config_blob, + original_provider=original_provider, + ) + llm_call_id = llm_call.id + logger.info( + f"[execute_job] Created LLM call record | llm_call_id={llm_call_id}, job_id={job_id}" + ) + except Exception as e: + logger.error( + f"[execute_job] Failed to create LLM call record: {str(e)} | job_id={job_id}", + exc_info=True, + ) + callback_response = APIResponse.failure_response( + error=f"Failed to create LLM call record: {str(e)}", + metadata=request.request_metadata, + ) + return handle_job_error(job_id, request.callback_url, callback_response) + try: provider_instance = get_llm_provider( session=session, - provider_type=completion_config.provider, # Now always native provider type + provider_type=completion_config.provider, # Now always native provider type i.e openai-native, google-native regardless project_id=project_id, organization_id=organization_id, ) @@ -247,21 +318,36 @@ def execute_job( if request.query.conversation and request.query.conversation.id: conversation_id = request.query.conversation.id + # Resolve input (handles text, audio_base64, audio_url) + resolved_input, resolve_error = resolve_input(request.query.input) + if resolve_error: + callback_response = APIResponse.failure_response( + error=resolve_error, + metadata=request.request_metadata, + ) + return handle_job_error(job_id, request.callback_url, callback_response) + # Apply Langfuse observability decorator to provider execute method decorated_execute = observe_llm_execution( credentials=langfuse_credentials, session_id=conversation_id, )(provider_instance.execute) - response, error = decorated_execute( - completion_config=completion_config, - query=request.query, - include_provider_raw_response=request.include_provider_raw_response, - ) + try: + response, error = decorated_execute( + completion_config=completion_config, + query=request.query, + resolved_input=resolved_input, + include_provider_raw_response=request.include_provider_raw_response, + ) + finally: + # Clean up temp files for audio inputs + if resolved_input and resolved_input != request.query.input: + cleanup_temp_file(resolved_input) if response: if output_guardrails: - output_text = response.response.output.text + output_text = response.response.output.content.value safe_output = call_guardrails(output_text, output_guardrails, job_id) logger.info( @@ -274,7 +360,9 @@ def execute_job( ) elif safe_output["success"]: - response.response.output.text = safe_output["data"]["safe_text"] + response.response.output.content.value = safe_output["data"][ + "safe_text" + ] if safe_output["data"]["rephrase_needed"] == True: callback_response = APIResponse.failure_response( @@ -308,6 +396,27 @@ def execute_job( with Session(engine) as session: job_crud = JobCrud(session=session) + # Update LLM call record with response data + if llm_call_id: + try: + update_llm_call_response( + session, + llm_call_id=llm_call_id, + provider_response_id=response.response.provider_response_id, + content=response.response.output.model_dump(), + usage=response.usage.model_dump(), + conversation_id=response.response.conversation_id, + ) + logger.info( + f"[execute_job] Updated LLM call record | llm_call_id={llm_call_id}" + ) + except Exception as e: + logger.error( + f"[execute_job] Failed to update LLM call record: {str(e)} | llm_call_id={llm_call_id}", + exc_info=True, + ) + # Don't fail the job if updating the record fails + job_crud.update( job_id=job_id, job_update=JobUpdate(status=JobStatus.SUCCESS) ) diff --git a/backend/app/services/llm/mappers.py b/backend/app/services/llm/mappers.py index 9e076aa9a..4b982b601 100644 --- a/backend/app/services/llm/mappers.py +++ b/backend/app/services/llm/mappers.py @@ -1,17 +1,17 @@ """Parameter mappers for converting Kaapi-abstracted parameters to provider-specific formats.""" import litellm -from app.models.llm import KaapiLLMParams, KaapiCompletionConfig, NativeCompletionConfig +from app.models.llm import KaapiCompletionConfig, NativeCompletionConfig -def map_kaapi_to_openai_params(kaapi_params: KaapiLLMParams) -> tuple[dict, list[str]]: +def map_kaapi_to_openai_params(kaapi_params: dict) -> tuple[dict, list[str]]: """Map Kaapi-abstracted parameters to OpenAI API parameters. This mapper transforms standardized Kaapi parameters into OpenAI-specific parameter format, enabling provider-agnostic interface design. Args: - kaapi_params: KaapiLLMParams instance with standardized parameters + kaapi_params: Dictionary with standardized Kaapi parameters Supported Mapping: - model → model @@ -29,65 +29,132 @@ def map_kaapi_to_openai_params(kaapi_params: KaapiLLMParams) -> tuple[dict, list openai_params = {} warnings = [] - support_reasoning = litellm.supports_reasoning( - model="openai/" + f"{kaapi_params.model}" - ) + model = kaapi_params.get("model") + reasoning = kaapi_params.get("reasoning") + temperature = kaapi_params.get("temperature") + instructions = kaapi_params.get("instructions") + knowledge_base_ids = kaapi_params.get("knowledge_base_ids") + max_num_results = kaapi_params.get("max_num_results") + + support_reasoning = litellm.supports_reasoning(model=f"openai/{model}") # Handle reasoning vs temperature mutual exclusivity if support_reasoning: - if kaapi_params.reasoning is not None: - openai_params["reasoning"] = {"effort": kaapi_params.reasoning} + if reasoning is not None: + openai_params["reasoning"] = {"effort": reasoning} - if kaapi_params.temperature is not None: + if temperature is not None: warnings.append( "Parameter 'temperature' was suppressed because the selected model " "supports reasoning, and temperature is ignored when reasoning is enabled." ) else: - if kaapi_params.reasoning is not None: + if reasoning is not None: warnings.append( "Parameter 'reasoning' was suppressed because the selected model " "does not support reasoning." ) - if kaapi_params.temperature is not None: - openai_params["temperature"] = kaapi_params.temperature + if temperature is not None: + openai_params["temperature"] = temperature - if kaapi_params.model: - openai_params["model"] = kaapi_params.model + if model: + openai_params["model"] = model - if kaapi_params.instructions: - openai_params["instructions"] = kaapi_params.instructions + if instructions: + openai_params["instructions"] = instructions - if kaapi_params.knowledge_base_ids: + if knowledge_base_ids: openai_params["tools"] = [ { "type": "file_search", - "vector_store_ids": kaapi_params.knowledge_base_ids, - "max_num_results": kaapi_params.max_num_results or 20, + "vector_store_ids": knowledge_base_ids, + "max_num_results": max_num_results or 20, } ] return openai_params, warnings +def map_kaapi_to_google_params(kaapi_params: dict) -> tuple[dict, list[str]]: + """Map Kaapi-abstracted parameters to Google AI (Gemini) API parameters. + + This mapper transforms standardized Kaapi parameters into Google-specific + parameter format for the Gemini API. + + Args: + kaapi_params: Dictionary with standardized Kaapi parameters + + Supported Mapping: + - model → model + - instructions → instructions (for STT prompts, if available) + - temperature -> temperature parameter (0-2) + + Returns: + Tuple of: + - Dictionary of Google AI API parameters ready to be passed to the API + - List of warnings describing suppressed or ignored parameters + """ + google_params = {} + warnings = [] + + # Model is present in all param types + google_params["model"] = kaapi_params.get("model") + + # Instructions for STT prompts + instructions = kaapi_params.get("instructions") + if instructions: + google_params["instructions"] = instructions + + temperature = kaapi_params.get("temperature") + + if temperature is not None: + google_params["temperature"] = temperature + + # Warn about unsupported parameters + if kaapi_params.get("knowledge_base_ids"): + warnings.append( + "Parameter 'knowledge_base_ids' is not supported by Google AI and was ignored." + ) + + if kaapi_params.get("reasoning") is not None: + warnings.append( + "Parameter 'reasoning' is not applicable for Google AI and was ignored." + ) + + return google_params, warnings + + def transform_kaapi_config_to_native( kaapi_config: KaapiCompletionConfig, ) -> tuple[NativeCompletionConfig, list[str]]: """Transform Kaapi completion config to native provider config with mapped parameters. - Currently supports OpenAI. Future: Claude, Gemini mappers. + Supports OpenAI and Google AI providers. Args: kaapi_config: KaapiCompletionConfig with abstracted parameters Returns: - NativeCompletionConfig with provider-native parameters ready for API + Tuple of: + - NativeCompletionConfig with provider-native parameters ready for API + - List of warnings for suppressed/ignored parameters """ if kaapi_config.provider == "openai": mapped_params, warnings = map_kaapi_to_openai_params(kaapi_config.params) return ( - NativeCompletionConfig(provider="openai-native", params=mapped_params), + NativeCompletionConfig( + provider="openai-native", params=mapped_params, type=kaapi_config.type + ), + warnings, + ) + + if kaapi_config.provider == "google": + mapped_params, warnings = map_kaapi_to_google_params(kaapi_config.params) + return ( + NativeCompletionConfig( + provider="google-native", params=mapped_params, type=kaapi_config.type + ), warnings, ) diff --git a/backend/app/services/llm/providers/__init__.py b/backend/app/services/llm/providers/__init__.py index 7b95ee3f6..e8474553f 100644 --- a/backend/app/services/llm/providers/__init__.py +++ b/backend/app/services/llm/providers/__init__.py @@ -1,5 +1,6 @@ from app.services.llm.providers.base import BaseProvider -from app.services.llm.providers.openai import OpenAIProvider +from app.services.llm.providers.oai import OpenAIProvider +from app.services.llm.providers.gai import GoogleAIProvider from app.services.llm.providers.registry import ( LLMProvider, get_llm_provider, diff --git a/backend/app/services/llm/providers/base.py b/backend/app/services/llm/providers/base.py index 827f25910..d8f7cafe7 100644 --- a/backend/app/services/llm/providers/base.py +++ b/backend/app/services/llm/providers/base.py @@ -31,11 +31,20 @@ def __init__(self, client: Any): """ self.client = client + @staticmethod + @abstractmethod + def create_client(credentials: dict[str, Any]) -> Any: + """ + Static method to instantiate a client instance of the provider + """ + raise NotImplementedError("Providers must implement create_client method") + @abstractmethod def execute( self, completion_config: NativeCompletionConfig, query: QueryParams, + resolved_input: str, include_provider_raw_response: bool = False, ) -> tuple[LLMCallResponse | None, str | None]: """Execute LLM API call. @@ -45,6 +54,7 @@ def execute( Args: completion_config: LLM completion configuration, pass params as-is to provider API query: Query parameters including input and conversation_id + resolved_input: The resolved input content (text string or file path for audio) include_provider_raw_response: Whether to include the raw LLM provider response in the output Returns: diff --git a/backend/app/services/llm/providers/gai.py b/backend/app/services/llm/providers/gai.py new file mode 100644 index 000000000..0c848fb74 --- /dev/null +++ b/backend/app/services/llm/providers/gai.py @@ -0,0 +1,190 @@ +import logging + +from google import genai +from google.genai.types import ( + GenerateContentResponse, + GenerateContentConfig, + ThinkingConfig, +) +from typing import Any + +from app.models.llm import ( + NativeCompletionConfig, + LLMCallResponse, + QueryParams, + LLMResponse, + Usage, + TextOutput, + TextContent, +) +from app.services.llm.providers.base import BaseProvider + + +logger = logging.getLogger(__name__) + + +class GoogleAIProvider(BaseProvider): + def __init__(self, client: genai.Client): + """Initialize Google AI provider with client. + + Args: + client: Google AI client instance + """ + super().__init__(client) + self.client = client + + @staticmethod + def create_client(credentials: dict[str, Any]) -> Any: + if "api_key" not in credentials: + raise ValueError("API Key for Google Gemini Not Set") + return genai.Client(api_key=credentials["api_key"]) + + def _execute_stt( + self, + completion_config: NativeCompletionConfig, + resolved_input: str, + include_provider_raw_response: bool = False, + ) -> tuple[LLMCallResponse | None, str | None]: + """Execute speech-to-text completion using Google AI. + + Args: + completion_config: Configuration for the completion request + resolved_input: File path to the audio input + include_provider_raw_response: Whether to include raw provider response + + Returns: + Tuple of (LLMCallResponse, error_message) + """ + provider = completion_config.provider + generation_params = completion_config.params + # Validate input is a file path string + if not isinstance(resolved_input, str): + return None, f"{provider} STT requires file path as string" + + model = generation_params.get("model") + if not model: + return None, "Missing 'model' in native params" + + instructions = generation_params.get("instructions", "") + input_language = generation_params.get("input_language") or "auto" + output_language = generation_params.get("output_language", "") + temperature = generation_params.get("temperature", 0.7) + + # Build transcription/translation instruction + if input_language == "auto": + lang_instruction = ( + "Detect the spoken language automatically and transcribe the audio" + ) + else: + lang_instruction = f"Transcribe the audio from {input_language} in the native script of {input_language}" + + if output_language and output_language != input_language: + lang_instruction += f" and translate to {output_language} in the native script of {output_language}" + + forced_transcription_text = "Only return transcribed text and no other text." + # Merge user instructions with language instructions + if instructions: + merged_instruction = ( + f"{instructions}. {lang_instruction}. {forced_transcription_text}" + ) + else: + merged_instruction = f"{lang_instruction}. {forced_transcription_text}" + + # Upload file and generate content + gemini_file = self.client.files.upload(file=resolved_input) + + contents = [] + if merged_instruction: + contents.append(merged_instruction) + contents.append(gemini_file) + + response: GenerateContentResponse = self.client.models.generate_content( + model=model, + contents=contents, + # switch back default thinking configs for reasoning supported models in future + config=GenerateContentConfig( + # thinking_config=ThinkingConfig(thinking_level="low"), + temperature=temperature + ), + ) + + # Validate response has required fields + if not response.response_id: + return None, "Google AI response missing response_id" + + if not response.text: + return None, "Google AI response missing text content" + + # Extract usage metadata with null checks + if response.usage_metadata: + input_tokens = response.usage_metadata.prompt_token_count or 0 + output_tokens = response.usage_metadata.candidates_token_count or 0 + total_tokens = response.usage_metadata.total_token_count or 0 + reasoning_tokens = response.usage_metadata.thoughts_token_count or 0 + else: + logger.warning( + f"[GoogleAIProvider._execute_stt] Response missing usage_metadata, using zeros" + ) + input_tokens = 0 + output_tokens = 0 + total_tokens = 0 + reasoning_tokens = 0 + + # Build response + llm_response = LLMCallResponse( + response=LLMResponse( + provider_response_id=response.response_id, + model=response.model_version or model, + provider=provider, + output=TextOutput(content=TextContent(value=response.text)), + ), + usage=Usage( + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens, + reasoning_tokens=reasoning_tokens, + ), + ) + + if include_provider_raw_response: + llm_response.provider_raw_response = response.model_dump() + + logger.info( + f"[GoogleAIProvider._execute_stt] Successfully generated STT response: {response.response_id}" + ) + + return llm_response, None + + def execute( + self, + completion_config: NativeCompletionConfig, + query: QueryParams, # Not used by Google AI provider (no conversation support yet) + resolved_input: str, + include_provider_raw_response: bool = False, + ) -> tuple[LLMCallResponse | None, str | None]: + try: + completion_type = completion_config.type + + if completion_type == "stt": + return self._execute_stt( + completion_config=completion_config, + resolved_input=resolved_input, + include_provider_raw_response=include_provider_raw_response, + ) + else: + return ( + None, + f"Unsupported completion type '{completion_type}' for Google AI provider", + ) + + except TypeError as e: + # handle unexpected arguments gracefully + error_message = f"Invalid or unexpected parameter in Config: {str(e)}" + return None, error_message + + except Exception as e: + error_message = "Unexpected error occurred" + logger.error( + f"[GoogleAIProvider.execute] {error_message}: {str(e)}", exc_info=True + ) + return None, error_message diff --git a/backend/app/services/llm/providers/openai.py b/backend/app/services/llm/providers/oai.py similarity index 87% rename from backend/app/services/llm/providers/openai.py rename to backend/app/services/llm/providers/oai.py index 34e35e17e..676a420c3 100644 --- a/backend/app/services/llm/providers/openai.py +++ b/backend/app/services/llm/providers/oai.py @@ -4,13 +4,15 @@ from openai import OpenAI from openai.types.responses.response import Response +from typing import Any from app.models.llm import ( NativeCompletionConfig, LLMCallResponse, QueryParams, - LLMOutput, LLMResponse, Usage, + TextOutput, + TextContent, ) from app.services.llm.providers.base import BaseProvider @@ -28,10 +30,17 @@ def __init__(self, client: OpenAI): super().__init__(client) self.client = client + @staticmethod + def create_client(credentials: dict[str, Any]) -> Any: + if "api_key" not in credentials: + raise ValueError("OpenAI credentials not configured for this project.") + return OpenAI(api_key=credentials["api_key"]) + def execute( self, completion_config: NativeCompletionConfig, query: QueryParams, + resolved_input: str, include_provider_raw_response: bool = False, ) -> tuple[LLMCallResponse | None, str | None]: response: Response | None = None @@ -41,7 +50,7 @@ def execute( params = { **completion_config.params, } - params["input"] = query.input + params["input"] = resolved_input conversation_cfg = query.conversation @@ -69,7 +78,7 @@ def execute( conversation_id=conversation_id, model=response.model, provider=completion_config.provider, - output=LLMOutput(text=response.output_text), + output=TextOutput(content=TextContent(value=response.output_text)), ), usage=Usage( input_tokens=response.usage.input_tokens, diff --git a/backend/app/services/llm/providers/registry.py b/backend/app/services/llm/providers/registry.py index f5d17971f..70167f73f 100644 --- a/backend/app/services/llm/providers/registry.py +++ b/backend/app/services/llm/providers/registry.py @@ -1,3 +1,5 @@ +import os +from dotenv import load_dotenv import logging from sqlmodel import Session @@ -5,8 +7,23 @@ from app.crud import get_provider_credential from app.services.llm.providers.base import BaseProvider -from app.services.llm.providers.openai import OpenAIProvider +from app.services.llm.providers.oai import OpenAIProvider +from app.services.llm.providers.gai import GoogleAIProvider +from google.genai.types import GenerateContentConfig + +# temporary import + +from app.models.llm import ( + NativeCompletionConfig, + LLMCallResponse, + QueryParams, + LLMOutput, + LLMResponse, + Usage, +) + +load_dotenv() logger = logging.getLogger(__name__) @@ -16,23 +33,23 @@ class LLMProvider: OPENAI = "openai" # Future constants for native providers: # CLAUDE_NATIVE = "claude-native" - # GEMINI_NATIVE = "gemini-native" + GOOGLE_NATIVE = "google-native" _registry: dict[str, type[BaseProvider]] = { OPENAI_NATIVE: OpenAIProvider, OPENAI: OpenAIProvider, # Future native providers: # CLAUDE_NATIVE: ClaudeProvider, - # GEMINI_NATIVE: GeminiProvider, + GOOGLE_NATIVE: GoogleAIProvider, } @classmethod - def get(cls, name: str) -> type[BaseProvider]: + def get_provider_class(cls, provider_type: str) -> type[BaseProvider]: """Return the provider class for a given name.""" - provider = cls._registry.get(name) + provider = cls._registry.get(provider_type) if not provider: raise ValueError( - f"Provider '{name}' is not supported. " + f"Provider '{provider_type}' is not supported. " f"Supported providers: {', '.join(cls._registry.keys())}" ) return provider @@ -46,7 +63,10 @@ def supported_providers(cls) -> list[str]: def get_llm_provider( session: Session, provider_type: str, project_id: int, organization_id: int ) -> BaseProvider: - provider_class = LLMProvider.get(provider_type) + provider_class = LLMProvider.get_provider_class(provider_type) + + # e.g "openai-native" -> "openai", "claude-native" -> "claude" + credential_provider = provider_type.replace("-native", "") # e.g., "openai-native" → "openai", "claude-native" → "claude" credential_provider = provider_type.replace("-native", "") @@ -63,14 +83,12 @@ def get_llm_provider( f"Credentials for provider '{credential_provider}' not configured for this project." ) - if provider_type == LLMProvider.OPENAI_NATIVE: - if "api_key" not in credentials: - raise ValueError("OpenAI credentials not configured for this project.") - client = OpenAI(api_key=credentials["api_key"]) - else: - logger.error( - f"[get_llm_provider] Unsupported provider type requested: {provider_type}" - ) - raise ValueError(f"Provider '{provider_type}' is not supported.") - - return provider_class(client=client) + try: + client = provider_class.create_client(credentials=credentials) + return provider_class(client=client) + except ValueError: + # Re-raise ValueError for credential/configuration errors + raise + except Exception as e: + logger.error(f"Failed to initialize {provider_type} client: {e}", exc_info=True) + raise RuntimeError(f"Could not connect to {provider_type} services.") diff --git a/backend/app/tests/api/routes/configs/test_config.py b/backend/app/tests/api/routes/configs/test_config.py index 6953f7387..5ff36b252 100644 --- a/backend/app/tests/api/routes/configs/test_config.py +++ b/backend/app/tests/api/routes/configs/test_config.py @@ -19,7 +19,8 @@ def test_create_config_success( "description": "A test LLM configuration", "config_blob": { "completion": { - "provider": "openai-native", + "provider": "openai", + "type": "text", "params": { "model": "gpt-4", "temperature": 0.8, @@ -45,7 +46,17 @@ def test_create_config_success( assert "id" in data["data"] assert "version" in data["data"] assert data["data"]["version"]["version"] == 1 - assert data["data"]["version"]["config_blob"] == config_data["config_blob"] + # Kaapi config params are normalized - invalid fields like max_tokens are stripped + assert data["data"]["version"]["config_blob"]["completion"]["provider"] == "openai" + assert data["data"]["version"]["config_blob"]["completion"]["type"] == "text" + assert ( + data["data"]["version"]["config_blob"]["completion"]["params"]["model"] + == "gpt-4" + ) + assert ( + data["data"]["version"]["config_blob"]["completion"]["params"]["temperature"] + == 0.8 + ) def test_create_config_empty_blob_fails( @@ -88,6 +99,7 @@ def test_create_config_duplicate_name_fails( "config_blob": { "completion": { "provider": "openai", + "type": "text", "params": {"model": "gpt-4"}, } }, diff --git a/backend/app/tests/api/routes/configs/test_version.py b/backend/app/tests/api/routes/configs/test_version.py index 592233511..b5a4ad414 100644 --- a/backend/app/tests/api/routes/configs/test_version.py +++ b/backend/app/tests/api/routes/configs/test_version.py @@ -19,17 +19,17 @@ def test_create_version_success( client: TestClient, user_api_key: TestAuthContext, ) -> None: - """Test creating a new version for a config successfully.""" + """Test creating a new version with partial config update.""" config = create_test_config( db=db, project_id=user_api_key.project_id, name="test-config", ) + # Only send the fields we want to update (partial update) version_data = { "config_blob": { "completion": { - "provider": "openai-native", "params": { "model": "gpt-4-turbo", "temperature": 0.9, @@ -52,34 +52,16 @@ def test_create_version_success( assert ( data["data"]["version"] == 2 ) # First version created with config, this is second - assert data["data"]["config_blob"] == version_data["config_blob"] assert data["data"]["commit_message"] == version_data["commit_message"] assert data["data"]["config_id"] == str(config.id) + # Verify params were updated + config_blob = data["data"]["config_blob"] + assert config_blob["completion"]["params"]["model"] == "gpt-4-turbo" + assert config_blob["completion"]["params"]["temperature"] == 0.9 -def test_create_version_empty_blob_fails( - db: Session, - client: TestClient, - user_api_key: TestAuthContext, -) -> None: - """Test that creating a version with empty config_blob fails validation.""" - config = create_test_config( - db=db, - project_id=user_api_key.project_id, - name="test-config", - ) - - version_data = { - "config_blob": {}, - "commit_message": "Empty blob", - } - - response = client.post( - f"{settings.API_V1_STR}/configs/{config.id}/versions", - headers={"X-API-KEY": user_api_key.key}, - json=version_data, - ) - assert response.status_code == 422 + # Verify type was inherited from existing config + assert config_blob["completion"]["type"] == "text" def test_create_version_nonexistent_config( @@ -303,6 +285,7 @@ def test_get_version_by_number( config_blob=ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={"model": "gpt-4-turbo", "temperature": 0.5}, ) ), @@ -483,3 +466,413 @@ def test_versions_isolated_by_project( headers={"X-API-KEY": user_api_key.key}, ) assert response.status_code == 404 + + +def test_create_version_cannot_change_type_from_text_to_stt( + db: Session, + client: TestClient, + 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 + + # Create initial config with type='text' + config_blob = ConfigBlob( + completion=KaapiCompletionConfig( + provider="openai", + type="text", + params={"model": "gpt-4", "temperature": 0.7}, + ) + ) + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="text-config", + config_blob=config_blob, + ) + + # Try to create a new version with type='stt' + version_data = { + "config_blob": { + "completion": { + "provider": "openai", + "type": "stt", + "params": { + "model": "whisper-1", + "instructions": "Transcribe audio", + "temperature": 0.2, + }, + } + }, + "commit_message": "Attempting to change type to stt", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/{config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + json=version_data, + ) + assert response.status_code == 400 + error_detail = response.json().get("error", "") + assert "cannot change config type" in error_detail.lower() + assert "text" in error_detail + assert "stt" in error_detail + + +def test_create_version_cannot_change_type_from_stt_to_tts( + db: Session, + client: TestClient, + 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 + + # Create initial config with type='stt' + config_blob = ConfigBlob( + completion=KaapiCompletionConfig( + provider="openai", + type="stt", + params={ + "model": "whisper-1", + "instructions": "Transcribe audio", + "temperature": 0.2, + }, + ) + ) + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="stt-config", + config_blob=config_blob, + ) + + # Try to create a new version with type='tts' + version_data = { + "config_blob": { + "completion": { + "provider": "openai", + "type": "tts", + "params": { + "model": "tts-1", + "voice": "alloy", + "language": "en", + }, + } + }, + "commit_message": "Attempting to change type to tts", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/{config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + json=version_data, + ) + assert response.status_code == 400 + + +def test_create_version_cannot_change_type_from_tts_to_text( + db: Session, + client: TestClient, + 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 + + # Create initial config with type='tts' + config_blob = ConfigBlob( + completion=KaapiCompletionConfig( + provider="openai", + type="tts", + params={ + "model": "tts-1", + "voice": "alloy", + "language": "en", + }, + ) + ) + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="tts-config", + config_blob=config_blob, + ) + + # Try to create a new version with type='text' + version_data = { + "config_blob": { + "completion": { + "provider": "openai", + "type": "text", + "params": { + "model": "gpt-4", + "temperature": 0.7, + }, + } + }, + "commit_message": "Attempting to change type to text", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/{config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + json=version_data, + ) + assert response.status_code == 400 + + +def test_create_version_same_type_succeeds( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test that creating a new version with the same type succeeds.""" + from app.models.llm.request import KaapiCompletionConfig + + # Create initial config with type='text' + config_blob = ConfigBlob( + completion=KaapiCompletionConfig( + provider="openai", + type="text", + params={ + "model": "gpt-4", + "temperature": 0.7, + }, + ) + ) + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="text-config", + config_blob=config_blob, + ) + + # Create a new version with the same type='text' + version_data = { + "config_blob": { + "completion": { + "provider": "openai", + "type": "text", + "params": { + "model": "gpt-4-turbo", + "temperature": 0.9, + }, + } + }, + "commit_message": "Updated to gpt-4-turbo with same type", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/{config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + json=version_data, + ) + assert response.status_code == 201 + data = response.json() + assert data["success"] is True + assert data["data"]["version"] == 2 + assert data["data"]["config_blob"]["completion"]["type"] == "text" + + +def test_create_version_partial_update_params_only( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test partial update - only updating params, inheriting provider and type.""" + from app.models.llm.request import KaapiCompletionConfig + + # Create initial config + config_blob = ConfigBlob( + completion=KaapiCompletionConfig( + provider="openai", + type="text", + params={ + "model": "gpt-4", + "temperature": 0.7, + }, + ) + ) + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="partial-update-test", + config_blob=config_blob, + ) + + # Only send params update - provider and type will be inherited + version_data = { + "config_blob": { + "completion": { + "params": { + "model": "gpt-4-turbo", + "temperature": 0.9, + }, + } + }, + "commit_message": "Only updating model and temperature", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/{config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + json=version_data, + ) + assert response.status_code == 201 + data = response.json() + assert data["success"] is True + assert data["data"]["version"] == 2 + + config_blob_result = data["data"]["config_blob"] + # Provider and type should be inherited + assert config_blob_result["completion"]["provider"] == "openai" + assert config_blob_result["completion"]["type"] == "text" + # Params should be updated + assert config_blob_result["completion"]["params"]["model"] == "gpt-4-turbo" + assert config_blob_result["completion"]["params"]["temperature"] == 0.9 + + +def test_create_config_with_kaapi_provider_success( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test creating a config with Kaapi provider (openai) works correctly.""" + config_data = { + "name": "kaapi-text-config", + "description": "A Kaapi configuration for text completion", + "config_blob": { + "completion": { + "provider": "openai", + "type": "text", + "params": { + "model": "gpt-4", + "temperature": 0.7, + }, + } + }, + "commit_message": "Initial Kaapi configuration", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/", + headers={"X-API-KEY": user_api_key.key}, + json=config_data, + ) + assert response.status_code == 201 + data = response.json() + assert data["success"] is True + assert data["data"]["name"] == config_data["name"] + assert data["data"]["version"]["config_blob"]["completion"]["provider"] == "openai" + assert data["data"]["version"]["config_blob"]["completion"]["type"] == "text" + + +def test_create_version_with_kaapi_stt_provider_success( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test creating STT config and version with Kaapi provider works correctly.""" + from app.models.llm.request import KaapiCompletionConfig + + # Create initial STT config with Kaapi provider + config_blob = ConfigBlob( + completion=KaapiCompletionConfig( + provider="openai", + type="stt", + params={ + "model": "whisper-1", + "instructions": "Transcribe audio accurately", + "temperature": 0.2, + }, + ) + ) + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="kaapi-stt-config", + config_blob=config_blob, + ) + + # Create a new version with the same type='stt' + version_data = { + "config_blob": { + "completion": { + "provider": "openai", + "type": "stt", + "params": { + "model": "whisper-1", + "instructions": "Transcribe with high accuracy", + "temperature": 0.1, + }, + } + }, + "commit_message": "Updated STT instructions", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/{config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + json=version_data, + ) + assert response.status_code == 201 + data = response.json() + assert data["success"] is True + assert data["data"]["version"] == 2 + assert data["data"]["config_blob"]["completion"]["provider"] == "openai" + assert data["data"]["config_blob"]["completion"]["type"] == "stt" + + +def test_create_version_with_kaapi_tts_provider_success( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test creating TTS config and version with Kaapi provider works correctly.""" + from app.models.llm.request import KaapiCompletionConfig + + # Create initial TTS config with Kaapi provider + config_blob = ConfigBlob( + completion=KaapiCompletionConfig( + provider="openai", + type="tts", + params={ + "model": "tts-1", + "voice": "alloy", + "language": "en", + }, + ) + ) + config = create_test_config( + db=db, + project_id=user_api_key.project_id, + name="kaapi-tts-config", + config_blob=config_blob, + ) + + # Create a new version with the same type='tts' + version_data = { + "config_blob": { + "completion": { + "provider": "openai", + "type": "tts", + "params": { + "model": "tts-1-hd", + "voice": "nova", + "language": "en", + }, + } + }, + "commit_message": "Updated TTS to HD model with nova voice", + } + + response = client.post( + f"{settings.API_V1_STR}/configs/{config.id}/versions", + headers={"X-API-KEY": user_api_key.key}, + json=version_data, + ) + assert response.status_code == 201 + data = response.json() + assert data["success"] is True + assert data["data"]["version"] == 2 + assert data["data"]["config_blob"]["completion"]["provider"] == "openai" + assert data["data"]["config_blob"]["completion"]["type"] == "tts" diff --git a/backend/app/tests/api/routes/test_evaluation.py b/backend/app/tests/api/routes/test_evaluation.py index bf21fc3f0..36222e4d4 100644 --- a/backend/app/tests/api/routes/test_evaluation.py +++ b/backend/app/tests/api/routes/test_evaluation.py @@ -9,7 +9,7 @@ from app.crud.evaluations.batch import build_evaluation_jsonl from app.models import EvaluationDataset, EvaluationRun -from app.models.llm.request import KaapiLLMParams +from app.models.llm.request import TextLLMParams from app.tests.utils.auth import TestAuthContext from app.tests.utils.test_data import create_test_config, create_test_evaluation_dataset @@ -607,7 +607,7 @@ def test_build_batch_jsonl_basic(self) -> None: } ] - config = KaapiLLMParams( + config = TextLLMParams( model="gpt-4o", temperature=0.2, instructions="You are a helpful assistant", @@ -638,7 +638,7 @@ def test_build_batch_jsonl_with_tools(self) -> None: } ] - config = KaapiLLMParams( + config = TextLLMParams( model="gpt-4o-mini", instructions="Search documents", knowledge_base_ids=["vs_abc123"], @@ -662,7 +662,7 @@ def test_build_batch_jsonl_minimal_config(self) -> None: } ] - config = KaapiLLMParams(model="gpt-4o") # Only model provided + config = TextLLMParams(model="gpt-4o") # Only model provided jsonl_data = build_evaluation_jsonl(dataset_items, config) @@ -694,7 +694,7 @@ def test_build_batch_jsonl_skips_empty_questions(self) -> None: }, ] - config = KaapiLLMParams(model="gpt-4o", instructions="Test") + config = TextLLMParams(model="gpt-4o", instructions="Test") jsonl_data = build_evaluation_jsonl(dataset_items, config) @@ -714,7 +714,7 @@ def test_build_batch_jsonl_multiple_items(self) -> None: for i in range(5) ] - config = KaapiLLMParams( + config = TextLLMParams( model="gpt-4o", instructions="Answer questions", ) diff --git a/backend/app/tests/api/routes/test_llm.py b/backend/app/tests/api/routes/test_llm.py index 279911d8b..cc543ba69 100644 --- a/backend/app/tests/api/routes/test_llm.py +++ b/backend/app/tests/api/routes/test_llm.py @@ -7,7 +7,6 @@ QueryParams, LLMCallConfig, ConfigBlob, - KaapiLLMParams, KaapiCompletionConfig, NativeCompletionConfig, ) @@ -26,6 +25,7 @@ def test_llm_call_success( blob=ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={ "model": "gpt-4", "temperature": 0.7, @@ -64,11 +64,12 @@ def test_llm_call_with_kaapi_config( blob=ConfigBlob( completion=KaapiCompletionConfig( provider="openai", - params=KaapiLLMParams( - model="gpt-4o", - instructions="You are a physics expert", - temperature=0.5, - ), + type="text", + params={ + "model": "gpt-4o", + "instructions": "You are a physics expert", + "temperature": 0.5, + }, ) ) ), @@ -99,6 +100,7 @@ def test_llm_call_with_native_config( blob=ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={ "model": "gpt-4", "temperature": 0.9, @@ -192,6 +194,7 @@ def test_llm_call_success_with_guardrails( blob=ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={ "model": "gpt-4o", "temperature": 0.7, @@ -247,6 +250,7 @@ def test_llm_call_guardrails_bypassed_still_succeeds( blob=ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={ "model": "gpt-4", "temperature": 0.7, diff --git a/backend/app/tests/crud/config/test_config.py b/backend/app/tests/crud/config/test_config.py index 0267c0585..6fc9c7f19 100644 --- a/backend/app/tests/crud/config/test_config.py +++ b/backend/app/tests/crud/config/test_config.py @@ -21,6 +21,7 @@ def example_config_blob(): return ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={ "model": "gpt-4", "temperature": 0.8, diff --git a/backend/app/tests/crud/config/test_version.py b/backend/app/tests/crud/config/test_version.py index 8c6fa8eaa..dfbe137a4 100644 --- a/backend/app/tests/crud/config/test_version.py +++ b/backend/app/tests/crud/config/test_version.py @@ -4,7 +4,7 @@ from sqlmodel import Session from fastapi import HTTPException -from app.models import ConfigVersionCreate, ConfigBlob +from app.models import ConfigVersionUpdate, ConfigBlob from app.models.llm.request import NativeCompletionConfig from app.crud.config import ConfigVersionCrud from app.tests.utils.test_data import ( @@ -19,6 +19,7 @@ def example_config_blob(): return ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={ "model": "gpt-4", "temperature": 0.8, @@ -36,12 +37,12 @@ def test_create_version(db: Session, example_config_blob: ConfigBlob) -> None: ) config_blob = example_config_blob.model_dump() - version_create = ConfigVersionCreate( + version_update = ConfigVersionUpdate( config_blob=config_blob, commit_message="Updated model and parameters", ) - version = version_crud.create_or_raise(version_create) + version = version_crud.create_or_raise(version_update) assert version.id is not None assert version.config_id == config.id @@ -62,13 +63,19 @@ def test_create_version_auto_increment( # Create multiple versions version2 = version_crud.create_or_raise( - ConfigVersionCreate(config_blob=example_config_blob, commit_message="Version 2") + ConfigVersionUpdate( + config_blob=example_config_blob.model_dump(), commit_message="Version 2" + ) ) version3 = version_crud.create_or_raise( - ConfigVersionCreate(config_blob=example_config_blob, commit_message="Version 3") + ConfigVersionUpdate( + config_blob=example_config_blob.model_dump(), commit_message="Version 3" + ) ) version4 = version_crud.create_or_raise( - ConfigVersionCreate(config_blob=example_config_blob, commit_message="Version 4") + ConfigVersionUpdate( + config_blob=example_config_blob.model_dump(), commit_message="Version 4" + ) ) assert version2.version == 2 @@ -87,14 +94,14 @@ def test_create_version_config_not_found( session=db, project_id=project.id, config_id=non_existent_config_id ) - version_create = ConfigVersionCreate( - config_blob=example_config_blob, commit_message="Test" + version_update = ConfigVersionUpdate( + config_blob=example_config_blob.model_dump(), commit_message="Test" ) with pytest.raises( HTTPException, match=f"config with id '{non_existent_config_id}' not found" ): - version_crud.create_or_raise(version_create) + version_crud.create_or_raise(version_update) def test_read_one_version(db: Session, example_config_blob: ConfigBlob) -> None: @@ -390,7 +397,9 @@ def test_create_version_different_configs( session=db, project_id=project.id, config_id=config1.id ) version2_config1 = version_crud1.create_or_raise( - ConfigVersionCreate(config_blob=example_config_blob, commit_message="V2") + ConfigVersionUpdate( + config_blob=example_config_blob.model_dump(), commit_message="V2" + ) ) # Create versions for config2 @@ -398,7 +407,9 @@ def test_create_version_different_configs( session=db, project_id=project.id, config_id=config2.id ) version2_config2 = version_crud2.create_or_raise( - ConfigVersionCreate(config_blob=example_config_blob, commit_message="V2") + ConfigVersionUpdate( + config_blob=example_config_blob.model_dump(), commit_message="V2" + ) ) # Both should have version 2 (independent numbering) diff --git a/backend/app/tests/crud/test_credentials.py b/backend/app/tests/crud/test_credentials.py index 78bed64a7..b85845737 100644 --- a/backend/app/tests/crud/test_credentials.py +++ b/backend/app/tests/crud/test_credentials.py @@ -259,7 +259,7 @@ def test_langfuse_credential_validation(db: Session) -> None: invalid_credentials = { "langfuse": { "public_key": "test-public-key", - "secret_key": "test-secret-key" + "secret_key": "test-secret-key", # Missing host } } diff --git a/backend/app/tests/crud/test_llm.py b/backend/app/tests/crud/test_llm.py new file mode 100644 index 000000000..2251755fd --- /dev/null +++ b/backend/app/tests/crud/test_llm.py @@ -0,0 +1,413 @@ +from uuid import uuid4 + +import pytest +from sqlmodel import Session, select + +from app.crud import JobCrud +from app.crud.llm import ( + create_llm_call, + get_llm_call_by_id, + get_llm_calls_by_job_id, + update_llm_call_response, +) +from app.models import JobType, Project, Organization +from app.models.llm import ( + ConfigBlob, + LLMCallRequest, + LlmCall, + QueryParams, +) +from app.models.llm.request import ( + KaapiCompletionConfig, + LLMCallConfig, +) + + +@pytest.fixture +def test_project(db: Session) -> Project: + """Get the first available test project.""" + project = db.exec(select(Project).limit(1)).first() + assert project is not None, "No test project found in seed data" + return project + + +@pytest.fixture +def test_organization(db: Session, test_project: Project) -> Organization: + """Get the organization for the test project.""" + org = db.get(Organization, test_project.organization_id) + assert org is not None, "No organization found for test project" + return org + + +@pytest.fixture +def test_job(db: Session): + """Create a test job for LLM call tests.""" + crud = JobCrud(db) + return crud.create(job_type=JobType.LLM_API, trace_id="test-llm-trace") + + +@pytest.fixture +def text_config_blob() -> ConfigBlob: + """Create a text completion config blob.""" + return ConfigBlob( + completion=KaapiCompletionConfig( + provider="openai", + params={ + "model": "gpt-4o", + "instructions": "You are a helpful assistant", + "temperature": 0.7, + }, + type="text", + ) + ) + + +@pytest.fixture +def stt_config_blob() -> ConfigBlob: + """Create a speech-to-text config blob.""" + return ConfigBlob( + completion=KaapiCompletionConfig( + provider="openai", + params={ + "model": "whisper-1", + "instructions": "Transcribe", + "input_language": "en", + }, + type="stt", + ) + ) + + +@pytest.fixture +def tts_config_blob() -> ConfigBlob: + """Create a text-to-speech config blob.""" + return ConfigBlob( + completion=KaapiCompletionConfig( + provider="openai", + params={ + "model": "tts-1", + "voice": "alloy", + "language": "en", + }, + type="tts", + ) + ) + + +def test_create_llm_call_text( + db: Session, + test_job, + test_project: Project, + test_organization: Organization, + text_config_blob: ConfigBlob, +) -> None: + """Test creating a text completion LLM call.""" + request = LLMCallRequest( + query=QueryParams(input="Hello, how are you?"), + config=LLMCallConfig(blob=text_config_blob), + ) + + llm_call = create_llm_call( + db, + request=request, + job_id=test_job.id, + project_id=test_project.id, + organization_id=test_organization.id, + resolved_config=text_config_blob, + original_provider="openai", + ) + + assert llm_call.id is not None + assert llm_call.job_id == test_job.id + assert llm_call.project_id == test_project.id + assert llm_call.organization_id == test_organization.id + assert llm_call.input == "Hello, how are you?" + assert llm_call.input_type == "text" + assert llm_call.output_type == "text" + assert llm_call.provider == "openai" + assert llm_call.model == "gpt-4o" + assert llm_call.config is not None + assert "config_blob" in llm_call.config + + +def test_create_llm_call_stt( + db: Session, + test_job, + test_project: Project, + test_organization: Organization, + stt_config_blob: ConfigBlob, +) -> None: + """Test creating a speech-to-text LLM call.""" + request = LLMCallRequest( + query=QueryParams(input="/path/to/audio.wav"), + config=LLMCallConfig(blob=stt_config_blob), + ) + + llm_call = create_llm_call( + db, + request=request, + job_id=test_job.id, + project_id=test_project.id, + organization_id=test_organization.id, + resolved_config=stt_config_blob, + original_provider="openai", + ) + + assert llm_call.input_type == "audio" + assert llm_call.output_type == "text" + assert llm_call.model == "whisper-1" + + +def test_create_llm_call_tts( + db: Session, + test_job, + test_project: Project, + test_organization: Organization, + tts_config_blob: ConfigBlob, +) -> None: + """Test creating a text-to-speech LLM call.""" + request = LLMCallRequest( + query=QueryParams(input="Hello world"), + config=LLMCallConfig(blob=tts_config_blob), + ) + + llm_call = create_llm_call( + db, + request=request, + job_id=test_job.id, + project_id=test_project.id, + organization_id=test_organization.id, + resolved_config=tts_config_blob, + original_provider="openai", + ) + + assert llm_call.input_type == "text" + assert llm_call.output_type == "audio" + assert llm_call.model == "tts-1" + + +def test_create_llm_call_with_stored_config( + db: Session, + test_job, + test_project: Project, + test_organization: Organization, + text_config_blob: ConfigBlob, +) -> None: + """Test creating an LLM call with a stored config reference.""" + config_id = uuid4() + request = LLMCallRequest( + query=QueryParams(input="Test input"), + config=LLMCallConfig(id=config_id, version=1), + ) + + llm_call = create_llm_call( + db, + request=request, + job_id=test_job.id, + project_id=test_project.id, + organization_id=test_organization.id, + resolved_config=text_config_blob, + original_provider="openai", + ) + + assert llm_call.config is not None + assert "config_id" in llm_call.config + assert llm_call.config["config_id"] == str(config_id) + assert llm_call.config["config_version"] == 1 + + +def test_get_llm_call_by_id( + db: Session, + test_job, + test_project: Project, + test_organization: Organization, + text_config_blob: ConfigBlob, +) -> None: + """Test fetching an LLM call by ID.""" + request = LLMCallRequest( + query=QueryParams(input="Test input"), + config=LLMCallConfig(blob=text_config_blob), + ) + + created = create_llm_call( + db, + request=request, + job_id=test_job.id, + project_id=test_project.id, + organization_id=test_organization.id, + resolved_config=text_config_blob, + original_provider="openai", + ) + + fetched = get_llm_call_by_id(db, created.id) + assert fetched is not None + assert fetched.id == created.id + assert fetched.input == "Test input" + + +def test_get_llm_call_by_id_with_project_scope( + db: Session, + test_job, + test_project: Project, + test_organization: Organization, + text_config_blob: ConfigBlob, +) -> None: + """Test fetching an LLM call with project scoping.""" + request = LLMCallRequest( + query=QueryParams(input="Test input"), + config=LLMCallConfig(blob=text_config_blob), + ) + + created = create_llm_call( + db, + request=request, + job_id=test_job.id, + project_id=test_project.id, + organization_id=test_organization.id, + resolved_config=text_config_blob, + original_provider="openai", + ) + + # Should find with correct project + fetched = get_llm_call_by_id(db, created.id, project_id=test_project.id) + assert fetched is not None + + # Should not find with wrong project + fetched_wrong = get_llm_call_by_id(db, created.id, project_id=99999) + assert fetched_wrong is None + + +def test_get_llm_call_by_id_not_found(db: Session) -> None: + """Test fetching a non-existent LLM call.""" + fake_id = uuid4() + result = get_llm_call_by_id(db, fake_id) + assert result is None + + +def test_get_llm_calls_by_job_id( + db: Session, + test_job, + test_project: Project, + test_organization: Organization, + text_config_blob: ConfigBlob, +) -> None: + """Test fetching all LLM calls for a job.""" + # Create multiple LLM calls for the same job + for i in range(3): + request = LLMCallRequest( + query=QueryParams(input=f"Test input {i}"), + config=LLMCallConfig(blob=text_config_blob), + ) + create_llm_call( + db, + request=request, + job_id=test_job.id, + project_id=test_project.id, + organization_id=test_organization.id, + resolved_config=text_config_blob, + original_provider="openai", + ) + + llm_calls = get_llm_calls_by_job_id(db, test_job.id) + assert len(llm_calls) == 3 + + +def test_get_llm_calls_by_job_id_empty(db: Session) -> None: + """Test fetching LLM calls for a job with no calls.""" + fake_job_id = uuid4() + llm_calls = get_llm_calls_by_job_id(db, fake_job_id) + assert llm_calls == [] + + +def test_update_llm_call_response( + db: Session, + test_job, + test_project: Project, + test_organization: Organization, + text_config_blob: ConfigBlob, +) -> None: + """Test updating an LLM call with response data.""" + request = LLMCallRequest( + query=QueryParams(input="Test input"), + config=LLMCallConfig(blob=text_config_blob), + ) + + created = create_llm_call( + db, + request=request, + job_id=test_job.id, + project_id=test_project.id, + organization_id=test_organization.id, + resolved_config=text_config_blob, + original_provider="openai", + ) + + # Update with response data + content = {"text": "This is the response"} + usage = { + "input_tokens": 10, + "output_tokens": 20, + "total_tokens": 30, + "reasoning_tokens": None, + } + + updated = update_llm_call_response( + db, + llm_call_id=created.id, + provider_response_id="resp_123456", + content=content, + usage=usage, + conversation_id="conv_abc", + ) + + assert updated.provider_response_id == "resp_123456" + assert updated.content == content + assert updated.usage == usage + assert updated.conversation_id == "conv_abc" + + +def test_update_llm_call_response_partial( + db: Session, + test_job, + test_project: Project, + test_organization: Organization, + text_config_blob: ConfigBlob, +) -> None: + """Test partial update of an LLM call response.""" + request = LLMCallRequest( + query=QueryParams(input="Test input"), + config=LLMCallConfig(blob=text_config_blob), + ) + + created = create_llm_call( + db, + request=request, + job_id=test_job.id, + project_id=test_project.id, + organization_id=test_organization.id, + resolved_config=text_config_blob, + original_provider="openai", + ) + + # Only update provider_response_id + updated = update_llm_call_response( + db, + llm_call_id=created.id, + provider_response_id="resp_partial", + ) + + assert updated.provider_response_id == "resp_partial" + assert updated.content is None # Should remain None + assert updated.usage is None # Should remain None + + +def test_update_llm_call_response_not_found(db: Session) -> None: + """Test updating a non-existent LLM call.""" + fake_id = uuid4() + + with pytest.raises(ValueError, match=str(fake_id)): + update_llm_call_response( + db, + llm_call_id=fake_id, + provider_response_id="resp_123", + ) diff --git a/backend/app/tests/seed_data/seed_data.py b/backend/app/tests/seed_data/seed_data.py index 4aeace835..2db5420c1 100644 --- a/backend/app/tests/seed_data/seed_data.py +++ b/backend/app/tests/seed_data/seed_data.py @@ -350,6 +350,7 @@ def clear_database(session: Session) -> None: session.exec(delete(Assistant)) session.exec(delete(Document)) session.exec(delete(APIKey)) + # ConfigVersion and Config are cascade-deleted when Project is deleted session.exec(delete(Project)) session.exec(delete(Organization)) session.exec(delete(User)) diff --git a/backend/app/tests/services/llm/providers/test_gai.py b/backend/app/tests/services/llm/providers/test_gai.py new file mode 100644 index 000000000..63bcce078 --- /dev/null +++ b/backend/app/tests/services/llm/providers/test_gai.py @@ -0,0 +1,249 @@ +""" +Tests for the Google AI provider (STT). +""" + +import pytest +from unittest.mock import MagicMock +from types import SimpleNamespace + +from app.models.llm import ( + NativeCompletionConfig, + QueryParams, +) +from app.services.llm.providers.gai import GoogleAIProvider + + +def mock_google_response( + text: str = "Transcribed text", + model: str = "gemini-2.5-pro", + response_id: str = "resp_123", +) -> SimpleNamespace: + """Create a mock Google AI response object.""" + usage = SimpleNamespace( + prompt_token_count=50, + candidates_token_count=100, + total_token_count=150, + thoughts_token_count=0, + ) + + response = SimpleNamespace( + response_id=response_id, + model_version=model, + text=text, + usage_metadata=usage, + model_dump=lambda: { + "response_id": response_id, + "model_version": model, + "text": text, + "usage_metadata": { + "prompt_token_count": 50, + "candidates_token_count": 100, + "total_token_count": 150, + "thoughts_token_count": 0, + }, + }, + ) + return response + + +class TestGoogleAIProviderSTT: + """Test cases for GoogleAIProvider STT functionality.""" + + @pytest.fixture + def mock_client(self): + """Create a mock Google AI client.""" + client = MagicMock() + # Mock file upload + mock_file = MagicMock() + mock_file.name = "test_audio.wav" + client.files.upload.return_value = mock_file + return client + + @pytest.fixture + def provider(self, mock_client): + """Create a GoogleAIProvider instance with mock client.""" + return GoogleAIProvider(client=mock_client) + + @pytest.fixture + def stt_config(self): + """Create a basic STT completion config.""" + return NativeCompletionConfig( + provider="google-native", + type="stt", + params={ + "model": "gemini-2.5-pro", + }, + ) + + @pytest.fixture + def query_params(self): + """Create basic query parameters.""" + return QueryParams(input="Test audio input") + + def test_stt_success_with_auto_language( + self, provider, mock_client, stt_config, query_params + ): + """Test successful STT execution with auto language detection.""" + mock_response = mock_google_response(text="Hello world") + mock_client.models.generate_content.return_value = mock_response + + result, error = provider.execute(stt_config, query_params, "/path/to/audio.wav") + + assert error is None + assert result is not None + assert result.response.output.content.value == "Hello world" + assert result.response.model == "gemini-2.5-pro" + assert result.response.provider == "google-native" + assert result.usage.input_tokens == 50 + assert result.usage.output_tokens == 100 + assert result.usage.total_tokens == 150 + + # Verify file upload and content generation + mock_client.files.upload.assert_called_once_with(file="/path/to/audio.wav") + mock_client.models.generate_content.assert_called_once() + + # Verify instruction contains auto-detect + call_args = mock_client.models.generate_content.call_args + assert "Detect the spoken language automatically" in call_args[1]["contents"][0] + + def test_stt_with_specific_input_language( + self, provider, mock_client, stt_config, query_params + ): + """Test STT with specific input language.""" + stt_config.params["input_language"] = "English" + + mock_response = mock_google_response(text="Transcribed English text") + mock_client.models.generate_content.return_value = mock_response + + result, error = provider.execute(stt_config, query_params, "/path/to/audio.wav") + + assert error is None + assert result is not None + + # Verify instruction contains specific language + call_args = mock_client.models.generate_content.call_args + assert "Transcribe the audio from English" in call_args[1]["contents"][0] + + def test_stt_with_translation( + self, provider, mock_client, stt_config, query_params + ): + """Test STT with translation to different output language.""" + stt_config.params["input_language"] = "Spanish" + stt_config.params["output_language"] = "English" + + mock_response = mock_google_response(text="Translated text") + mock_client.models.generate_content.return_value = mock_response + + result, error = provider.execute(stt_config, query_params, "/path/to/audio.wav") + + assert error is None + assert result is not None + + # Verify instruction contains translation + call_args = mock_client.models.generate_content.call_args + instruction = call_args[1]["contents"][0] + assert "Transcribe the audio from Spanish" in instruction + assert "translate to English" in instruction + + def test_stt_with_custom_instructions( + self, provider, mock_client, stt_config, query_params + ): + """Test STT with custom instructions.""" + stt_config.params["instructions"] = "Include timestamps" + + mock_response = mock_google_response(text="Transcribed with timestamps") + mock_client.models.generate_content.return_value = mock_response + + result, error = provider.execute(stt_config, query_params, "/path/to/audio.wav") + + assert error is None + assert result is not None + + # Verify custom instructions are included + call_args = mock_client.models.generate_content.call_args + instruction = call_args[1]["contents"][0] + assert "Include timestamps" in instruction + + def test_stt_with_include_provider_raw_response( + self, provider, mock_client, stt_config, query_params + ): + """Test STT with include_provider_raw_response=True.""" + mock_response = mock_google_response(text="Raw response test") + mock_client.models.generate_content.return_value = mock_response + + result, error = provider.execute( + stt_config, + query_params, + "/path/to/audio.wav", + include_provider_raw_response=True, + ) + + assert error is None + assert result is not None + assert result.provider_raw_response is not None + assert isinstance(result.provider_raw_response, dict) + assert result.provider_raw_response["text"] == "Raw response test" + + def test_stt_missing_model_parameter(self, provider, mock_client, query_params): + """Test error handling when model parameter is missing.""" + stt_config = NativeCompletionConfig( + provider="google-native", + type="stt", + params={}, # Missing model + ) + + result, error = provider.execute(stt_config, query_params, "/path/to/audio.wav") + + assert result is None + assert error is not None + assert "Missing 'model' in native params" in error + + def test_stt_with_type_error(self, provider, mock_client, stt_config, query_params): + """Test handling of TypeError (invalid parameters).""" + mock_client.models.generate_content.side_effect = TypeError( + "unexpected keyword argument 'invalid_param'" + ) + + result, error = provider.execute(stt_config, query_params, "/path/to/audio.wav") + + assert result is None + assert error is not None + assert "Invalid or unexpected parameter in Config" in error + + def test_stt_with_generic_exception( + self, provider, mock_client, stt_config, query_params + ): + """Test handling of unexpected exceptions.""" + mock_client.files.upload.side_effect = Exception("File upload failed") + + result, error = provider.execute(stt_config, query_params, "/path/to/audio.wav") + + assert result is None + assert error is not None + assert "Unexpected error occurred" in error + + def test_stt_with_invalid_input_type( + self, provider, mock_client, stt_config, query_params + ): + """Test STT execution with invalid input type (non-string).""" + # Pass a dict instead of a string path + invalid_input = {"invalid": "data"} + + result, error = provider.execute(stt_config, query_params, invalid_input) + + assert result is None + assert error is not None + assert "STT requires file path as string" in error + + def test_stt_with_valid_file_path( + self, provider, mock_client, stt_config, query_params + ): + """Test STT execution with valid file path string.""" + mock_response = mock_google_response(text="Valid transcription") + mock_client.models.generate_content.return_value = mock_response + + result, error = provider.execute(stt_config, query_params, "/path/to/audio.wav") + + assert error is None + assert result is not None + assert result.response.output.content.value == "Valid transcription" diff --git a/backend/app/tests/services/llm/providers/test_openai.py b/backend/app/tests/services/llm/providers/test_openai.py index 4dfb671e6..f86395f58 100644 --- a/backend/app/tests/services/llm/providers/test_openai.py +++ b/backend/app/tests/services/llm/providers/test_openai.py @@ -1,6 +1,7 @@ """ Tests for the OpenAI provider. """ + import pytest from unittest.mock import MagicMock, patch @@ -11,8 +12,9 @@ QueryParams, ) from app.models.llm.request import ConversationConfig -from app.services.llm.providers.openai import OpenAIProvider -from app.tests.utils.llm_provider import mock_openai_response + +from app.services.llm.providers.oai import OpenAIProvider +from app.tests.utils.openai import mock_openai_response class TestOpenAIProvider: @@ -33,6 +35,7 @@ def completion_config(self): """Create a basic completion config.""" return NativeCompletionConfig( provider="openai-native", + type="text", params={"model": "gpt-4"}, ) @@ -53,11 +56,11 @@ def test_execute_success_without_conversation( mock_response = mock_openai_response(text="Test response", model="gpt-4") mock_client.responses.create.return_value = mock_response - result, error = provider.execute(completion_config, query_params) + result, error = provider.execute(completion_config, query_params, "Test query") assert error is None assert result is not None - assert result.response.output.text == mock_response.output_text + assert result.response.output.content.value == mock_response.output_text assert result.response.model == mock_response.model assert result.response.provider == "openai-native" assert result.response.conversation_id is None @@ -82,7 +85,7 @@ def test_execute_with_existing_conversation_id( ) mock_client.responses.create.return_value = mock_response - result, error = provider.execute(completion_config, query_params) + result, error = provider.execute(completion_config, query_params, "Test query") assert error is None assert result is not None @@ -93,7 +96,11 @@ def test_execute_with_existing_conversation_id( assert call_args[1]["conversation"] == {"id": conversation_id} def test_execute_with_auto_create_conversation( - self, provider, mock_client, completion_config, query_params + self, + provider, + mock_client, + completion_config, + query_params, ): """Test execution with auto-create conversation.""" new_conversation_id = "conv_auto_456" @@ -110,7 +117,7 @@ def test_execute_with_auto_create_conversation( ) mock_client.responses.create.return_value = mock_response - result, error = provider.execute(completion_config, query_params) + result, error = provider.execute(completion_config, query_params, "Test query") assert error is None assert result is not None @@ -133,7 +140,10 @@ def test_execute_with_include_provider_raw_response( mock_client.responses.create.return_value = mock_response result, error = provider.execute( - completion_config, query_params, include_provider_raw_response=True + completion_config, + query_params, + "Test query", + include_provider_raw_response=True, ) assert error is None @@ -150,7 +160,7 @@ def test_execute_with_type_error( "unexpected keyword argument 'invalid_param'" ) - result, error = provider.execute(completion_config, query_params) + result, error = provider.execute(completion_config, query_params, "Test query") assert result is None assert error is not None @@ -170,7 +180,9 @@ def test_execute_with_openai_api_error( with patch("app.utils.handle_openai_error") as mock_handler: mock_handler.return_value = "API request failed: rate limit exceeded" - result, error = provider.execute(completion_config, query_params) + result, error = provider.execute( + completion_config, query_params, "Test query" + ) assert result is None assert error is not None @@ -183,7 +195,7 @@ def test_execute_with_generic_exception( """Test handling of unexpected exceptions.""" mock_client.responses.create.side_effect = Exception("Timeout occurred") - result, error = provider.execute(completion_config, query_params) + result, error = provider.execute(completion_config, query_params, "Test query") assert result is None assert error is not None @@ -198,7 +210,7 @@ def test_execute_with_conversation_config_without_id_or_auto_create( mock_response = mock_openai_response(text="Test response", model="gpt-4") mock_client.responses.create.return_value = mock_response - result, error = provider.execute(completion_config, query_params) + result, error = provider.execute(completion_config, query_params, "Test query") assert error is None assert result is not None @@ -216,7 +228,7 @@ def test_execute_merges_params_correctly( mock_response = mock_openai_response(text="Test response", model="gpt-4") mock_client.responses.create.return_value = mock_response - result, error = provider.execute(completion_config, query_params) + result, error = provider.execute(completion_config, query_params, "Test query") assert error is None assert result is not None @@ -235,13 +247,14 @@ def test_execute_with_conversation_parameter_removed_when_no_config( # Create a config with conversation in params (should be removed) completion_config = NativeCompletionConfig( provider="openai-native", + type="text", params={"model": "gpt-4", "conversation": {"id": "old_conv"}}, ) mock_response = mock_openai_response(text="Test response", model="gpt-4") mock_client.responses.create.return_value = mock_response - result, error = provider.execute(completion_config, query_params) + result, error = provider.execute(completion_config, query_params, "Test query") assert error is None assert result is not None diff --git a/backend/app/tests/services/llm/providers/test_registry.py b/backend/app/tests/services/llm/providers/test_registry.py index c05222747..b3daa44c4 100644 --- a/backend/app/tests/services/llm/providers/test_registry.py +++ b/backend/app/tests/services/llm/providers/test_registry.py @@ -1,6 +1,7 @@ """ Tests for the LLM provider registry. """ + import pytest from unittest.mock import patch @@ -8,7 +9,7 @@ from openai import OpenAI from app.services.llm.providers.base import BaseProvider -from app.services.llm.providers.openai import OpenAIProvider +from app.services.llm.providers.oai import OpenAIProvider from app.services.llm.providers.registry import ( LLMProvider, get_llm_provider, diff --git a/backend/app/tests/services/llm/test_input_resolver.py b/backend/app/tests/services/llm/test_input_resolver.py new file mode 100644 index 000000000..5443f8feb --- /dev/null +++ b/backend/app/tests/services/llm/test_input_resolver.py @@ -0,0 +1,161 @@ +""" +Unit tests for LLM input resolver functions. + +Tests input resolution for text and base64 audio inputs. +""" + +import base64 +import tempfile +from pathlib import Path +from unittest.mock import patch, Mock + +import pytest + +from app.models.llm.request import TextInput, AudioInput, TextContent, AudioContent +from app.services.llm.input_resolver import ( + get_file_extension, + resolve_input, + resolve_audio_base64, + cleanup_temp_file, +) + + +class TestGetFileExtension: + """Test MIME type to file extension mapping.""" + + def test_common_audio_formats(self): + """Test common audio MIME types.""" + assert get_file_extension("audio/wav") == ".wav" + assert get_file_extension("audio/mp3") == ".mp3" + assert get_file_extension("audio/mpeg") == ".mp3" + assert get_file_extension("audio/ogg") == ".ogg" + + def test_wav_variants(self): + """Test various WAV MIME type variants.""" + assert get_file_extension("audio/wave") == ".wav" + assert get_file_extension("audio/x-wav") == ".wav" + + def test_unknown_mime_type(self): + """Test fallback for unknown MIME types.""" + assert get_file_extension("audio/unknown") == ".audio" + assert get_file_extension("application/octet-stream") == ".audio" + + +class TestResolveInput: + """Test main input resolution function.""" + + def test_text_input(self): + """Test resolving text input.""" + text_input = TextInput(content=TextContent(value="Hello world")) + content, error = resolve_input(text_input) + + assert content == "Hello world" + assert error is None + + def test_audio_base64_input(self): + """Test resolving base64 audio input.""" + # Create minimal valid audio data + audio_data = b"RIFF" + b"\x00" * 36 # Minimal WAV header + encoded = base64.b64encode(audio_data).decode() + + audio_input = AudioInput( + content=AudioContent(value=encoded, mime_type="audio/wav") + ) + file_path, error = resolve_input(audio_input) + + assert error is None + assert file_path != "" + assert Path(file_path).exists() + assert file_path.endswith(".wav") + + # Cleanup + cleanup_temp_file(file_path) + + def test_invalid_base64_data(self): + """Test handling of invalid base64 data.""" + audio_input = AudioInput( + content=AudioContent(value="not-valid-base64!!!", mime_type="audio/wav") + ) + content, error = resolve_input(audio_input) + + assert content == "" + assert error is not None + assert "base64" in error.lower() + + +class TestResolveAudioBase64: + """Test base64 audio resolution.""" + + def test_valid_base64_audio(self): + """Test decoding valid base64 audio data.""" + audio_data = b"Test audio content" + encoded = base64.b64encode(audio_data).decode() + + file_path, error = resolve_audio_base64(encoded, "audio/mp3") + + assert error is None + assert file_path != "" + assert Path(file_path).exists() + assert file_path.endswith(".mp3") + + # Verify content + with open(file_path, "rb") as f: + assert f.read() == audio_data + + # Cleanup + cleanup_temp_file(file_path) + + def test_invalid_base64_string(self): + """Test handling invalid base64 string.""" + file_path, error = resolve_audio_base64("invalid!!!base64", "audio/wav") + + assert file_path == "" + assert error is not None + assert "Invalid base64" in error + + def test_different_mime_types(self): + """Test file extension based on MIME type.""" + audio_data = b"Audio" + encoded = base64.b64encode(audio_data).decode() + + # Test WAV + file_path, _ = resolve_audio_base64(encoded, "audio/wav") + assert file_path.endswith(".wav") + cleanup_temp_file(file_path) + + # Test OGG + file_path, _ = resolve_audio_base64(encoded, "audio/ogg") + assert file_path.endswith(".ogg") + cleanup_temp_file(file_path) + + +# URL-based audio input tests removed - only base64 audio is supported + + +class TestCleanupTempFile: + """Test temporary file cleanup.""" + + def test_cleanup_existing_file(self): + """Test cleaning up an existing temp file.""" + # Create a temp file + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp.write(b"test data") + temp_path = tmp.name + + assert Path(temp_path).exists() + + # Cleanup + cleanup_temp_file(temp_path) + + # Verify deleted + assert not Path(temp_path).exists() + + def test_cleanup_nonexistent_file(self): + """Test cleaning up a non-existent file (should not error).""" + # Should not raise an exception + cleanup_temp_file("/tmp/nonexistent_file_12345.wav") + + def test_cleanup_invalid_path(self): + """Test cleanup with invalid path (should not error).""" + # Should handle gracefully + cleanup_temp_file("") diff --git a/backend/app/tests/services/llm/test_jobs.py b/backend/app/tests/services/llm/test_jobs.py index bae15c26b..15fc9a3cb 100644 --- a/backend/app/tests/services/llm/test_jobs.py +++ b/backend/app/tests/services/llm/test_jobs.py @@ -14,9 +14,10 @@ QueryParams, LLMCallResponse, LLMResponse, - LLMOutput, Usage, - KaapiLLMParams, + TextOutput, + TextContent, + # KaapiLLMParams, KaapiCompletionConfig, ) from app.models.llm.request import ConfigBlob, LLMCallConfig @@ -41,6 +42,7 @@ def llm_call_request(self): blob=ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={"model": "gpt-4"}, ) ) @@ -121,9 +123,10 @@ def test_handle_job_error(self, db: Session): callback_url = "https://example.com/callback" callback_response = APIResponse.failure_response(error="Test error occurred") - with patch("app.services.llm.jobs.Session") as mock_session_class, patch( - "app.services.llm.jobs.send_callback" - ) as mock_send_callback: + with ( + patch("app.services.llm.jobs.Session") as mock_session_class, + patch("app.services.llm.jobs.send_callback") as mock_send_callback, + ): mock_session_class.return_value.__enter__.return_value = db mock_session_class.return_value.__exit__.return_value = None @@ -158,9 +161,10 @@ def test_handle_job_error_without_callback_url(self, db: Session): callback_response = APIResponse.failure_response(error="Test error occurred") - with patch("app.services.llm.jobs.Session") as mock_session_class, patch( - "app.services.llm.jobs.send_callback" - ) as mock_send_callback: + with ( + patch("app.services.llm.jobs.Session") as mock_session_class, + patch("app.services.llm.jobs.send_callback") as mock_send_callback, + ): mock_session_class.return_value.__enter__.return_value = db mock_session_class.return_value.__exit__.return_value = None @@ -189,9 +193,10 @@ def test_handle_job_error_callback_failure_still_updates_job(self, db: Session): error="Test error with callback failure" ) - with patch("app.services.llm.jobs.Session") as mock_session_class, patch( - "app.services.llm.jobs.send_callback" - ) as mock_send_callback: + with ( + patch("app.services.llm.jobs.Session") as mock_session_class, + patch("app.services.llm.jobs.send_callback") as mock_send_callback, + ): mock_session_class.return_value.__enter__.return_value = db mock_session_class.return_value.__exit__.return_value = None @@ -225,6 +230,7 @@ def request_data(self): "config": { "blob": { "completion": { + "type": "text", "provider": "openai-native", "params": {"model": "gpt-4"}, } @@ -242,7 +248,7 @@ def mock_llm_response(self): conversation_id=None, model="gpt-4", provider="openai", - output=LLMOutput(text="Test response"), + output=TextOutput(content=TextContent(value="Test response")), ), usage=Usage(input_tokens=10, output_tokens=20, total_tokens=30), provider_raw_response=None, @@ -400,6 +406,7 @@ def test_stored_config_success(self, db, job_for_execution, mock_llm_response): config_blob = ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={"model": "gpt-4", "temperature": 0.7}, ) ) @@ -449,6 +456,7 @@ def test_stored_config_with_callback( config_blob = ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={"model": "gpt-3.5-turbo", "temperature": 0.5}, ) ) @@ -497,6 +505,7 @@ def test_stored_config_version_not_found(self, db, job_for_execution): config_blob = ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={"model": "gpt-4"}, ) ) @@ -532,11 +541,12 @@ def test_kaapi_config_success(self, db, job_for_execution, mock_llm_response): config_blob = ConfigBlob( completion=KaapiCompletionConfig( provider="openai", - params=KaapiLLMParams( - model="gpt-4", - temperature=0.7, - instructions="You are a helpful assistant", - ), + type="text", + params={ + "model": "gpt-4", + "temperature": 0.7, + "instructions": "You are a helpful assistant", + }, ) ) config = create_test_config(db, project_id=project.id, config_blob=config_blob) @@ -578,10 +588,12 @@ def test_kaapi_config_with_callback(self, db, job_for_execution, mock_llm_respon config_blob = ConfigBlob( completion=KaapiCompletionConfig( provider="openai", - params=KaapiLLMParams( - model="gpt-3.5-turbo", - temperature=0.5, - ), + type="text", + params={ + "model": "gpt-3.5-turbo", + "temperature": 0.7, + "instructions": "You are a helpful assistant", + }, ) ) config = create_test_config(db, project_id=project.id, config_blob=config_blob) @@ -628,10 +640,11 @@ def test_kaapi_config_warnings_passed_through_metadata( config_blob = ConfigBlob( completion=KaapiCompletionConfig( provider="openai", - params=KaapiLLMParams( - model="o1", # Reasoning model - temperature=0.7, # This will be suppressed with warning - ), + type="text", + params={ + "model": "o1", # Reasoning model + "temperature": 0.7, # This will be suppressed with warning + }, ) ) config = create_test_config(db, project_id=project.id, config_blob=config_blob) @@ -677,10 +690,11 @@ def test_kaapi_config_warnings_merged_with_existing_metadata( config_blob = ConfigBlob( completion=KaapiCompletionConfig( provider="openai", - params=KaapiLLMParams( - model="gpt-4", # Non-reasoning model - reasoning="high", # This will be suppressed with warning - ), + type="text", + params={ + "model": "gpt-4", # Non-reasoning model + "reasoning": "high", # This will be suppressed with warning + }, ) ) config = create_test_config(db, project_id=project.id, config_blob=config_blob) @@ -751,6 +765,7 @@ def test_guardrails_sanitize_input_before_provider( "blob": { "completion": { "provider": "openai-native", + "type": "text", "params": {"model": "gpt-4"}, } } @@ -764,8 +779,8 @@ def test_guardrails_sanitize_input_before_provider( result = self._execute_job(job_for_execution, db, request_data) provider_query = env["provider"].execute.call_args[0][1] - assert "[REDACTED]" in provider_query.input - assert "4111" not in provider_query.input + assert "[REDACTED]" in provider_query.input.content.value + assert "4111" not in provider_query.input.content.value assert result["success"] @@ -774,7 +789,7 @@ def test_guardrails_sanitize_output_after_provider( ): env = job_env - env["mock_llm_response"].response.output.text = "Aadhar no 123-45-6789" + env["mock_llm_response"].response.output.content.value = "Aadhar no 123-45-6789" env["provider"].execute.return_value = (env["mock_llm_response"], None) with patch("app.services.llm.jobs.call_guardrails") as mock_guardrails: @@ -793,6 +808,7 @@ def test_guardrails_sanitize_output_after_provider( "blob": { "completion": { "provider": "openai-native", + "type": "text", "params": {"model": "gpt-4"}, } } @@ -803,7 +819,7 @@ def test_guardrails_sanitize_output_after_provider( result = self._execute_job(job_for_execution, db, request_data) - assert "REDACTED" in result["data"]["response"]["output"]["text"] + assert "REDACTED" in result["data"]["response"]["output"]["content"]["value"] def test_guardrails_bypass_does_not_modify_input( self, db, job_env, job_for_execution @@ -830,6 +846,7 @@ def test_guardrails_bypass_does_not_modify_input( "blob": { "completion": { "provider": "openai-native", + "type": "text", "params": {"model": "gpt-4"}, } } @@ -840,7 +857,7 @@ def test_guardrails_bypass_does_not_modify_input( self._execute_job(job_for_execution, db, request_data) provider_query = env["provider"].execute.call_args[0][1] - assert provider_query.input == unsafe_input + assert provider_query.input.content.value == unsafe_input def test_guardrails_validation_failure_blocks_job( self, db, job_env, job_for_execution @@ -859,6 +876,7 @@ def test_guardrails_validation_failure_blocks_job( "blob": { "completion": { "provider": "openai-native", + "type": "text", "params": {"model": "gpt-4"}, } } @@ -893,6 +911,7 @@ def test_guardrails_rephrase_needed_blocks_job( "blob": { "completion": { "provider": "openai-native", + "type": "text", "params": {"model": "gpt-4"}, } } @@ -916,6 +935,7 @@ def test_resolve_config_blob_success(self, db: Session): config_blob = ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={"model": "gpt-4", "temperature": 0.8}, ) ) @@ -942,6 +962,7 @@ def test_resolve_config_blob_version_not_found(self, db: Session): config_blob = ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={"model": "gpt-4"}, ) ) @@ -967,6 +988,7 @@ def test_resolve_config_blob_invalid_blob_data(self, db: Session): config_blob = ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={"model": "gpt-4"}, ) ) @@ -996,7 +1018,7 @@ def test_resolve_config_blob_invalid_blob_data(self, db: Session): def test_resolve_config_blob_with_multiple_versions(self, db: Session): """Test resolving specific version when multiple versions exist.""" - from app.models.config import ConfigVersionCreate + from app.models.config import ConfigVersionUpdate project = get_project(db) @@ -1004,6 +1026,7 @@ def test_resolve_config_blob_with_multiple_versions(self, db: Session): config_blob_v1 = ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={"model": "gpt-3.5-turbo", "temperature": 0.5}, ) ) @@ -1019,14 +1042,15 @@ def test_resolve_config_blob_with_multiple_versions(self, db: Session): config_blob_v2 = ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={"model": "gpt-4", "temperature": 0.9}, ) ) - version_create = ConfigVersionCreate( - config_blob=config_blob_v2, + version_update = ConfigVersionUpdate( + config_blob=config_blob_v2.model_dump(), commit_message="Updated to gpt-4", ) - config_version_crud.create_or_raise(version_create) + config_version_crud.create_or_raise(version_update) db.commit() # Test resolving version 1 @@ -1058,11 +1082,12 @@ def test_resolve_kaapi_config_blob_success(self, db: Session): config_blob = ConfigBlob( completion=KaapiCompletionConfig( provider="openai", - params=KaapiLLMParams( - model="gpt-4", - temperature=0.8, - instructions="You are a helpful assistant", - ), + type="text", + params={ + "model": "gpt-4", + "temperature": 0.8, + "instructions": "You are a helpful assistant", + }, ) ) config = create_test_config(db, project_id=project.id, config_blob=config_blob) @@ -1079,10 +1104,10 @@ def test_resolve_kaapi_config_blob_success(self, db: Session): assert resolved_blob is not None assert isinstance(resolved_blob.completion, KaapiCompletionConfig) assert resolved_blob.completion.provider == "openai" - assert resolved_blob.completion.params.model == "gpt-4" - assert resolved_blob.completion.params.temperature == 0.8 + assert resolved_blob.completion.params["model"] == "gpt-4" + assert resolved_blob.completion.params["temperature"] == 0.8 assert ( - resolved_blob.completion.params.instructions + resolved_blob.completion.params["instructions"] == "You are a helpful assistant" ) @@ -1094,6 +1119,7 @@ def test_resolve_both_native_and_kaapi_configs(self, db: Session): native_blob = ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={"model": "gpt-3.5-turbo", "temperature": 0.5}, ) ) @@ -1105,10 +1131,11 @@ def test_resolve_both_native_and_kaapi_configs(self, db: Session): kaapi_blob = ConfigBlob( completion=KaapiCompletionConfig( provider="openai", - params=KaapiLLMParams( - model="gpt-4", - temperature=0.7, - ), + type="text", + params={ + "model": "gpt-4", + "temperature": 0.7, + }, ) ) kaapi_config = create_test_config( diff --git a/backend/app/tests/services/llm/test_mappers.py b/backend/app/tests/services/llm/test_mappers.py index c020753d2..2ecbcd7b2 100644 --- a/backend/app/tests/services/llm/test_mappers.py +++ b/backend/app/tests/services/llm/test_mappers.py @@ -3,11 +3,19 @@ Tests the transformation of Kaapi-abstracted parameters to provider-native formats. """ + import pytest -from app.models.llm import KaapiLLMParams, KaapiCompletionConfig, NativeCompletionConfig +from app.models.llm.request import ( + TextLLMParams, + STTLLMParams, + TTSLLMParams, + KaapiCompletionConfig, + NativeCompletionConfig, +) from app.services.llm.mappers import ( map_kaapi_to_openai_params, + map_kaapi_to_google_params, transform_kaapi_config_to_native, ) @@ -17,21 +25,25 @@ class TestMapKaapiToOpenAIParams: def test_basic_model_mapping(self): """Test basic model parameter mapping.""" - kaapi_params = KaapiLLMParams(model="gpt-4o") + kaapi_params = TextLLMParams(model="gpt-4o") - result, warnings = map_kaapi_to_openai_params(kaapi_params) + result, warnings = map_kaapi_to_openai_params( + kaapi_params.model_dump(exclude_none=True) + ) assert result == {"model": "gpt-4o"} assert warnings == [] def test_instructions_mapping(self): """Test instructions parameter mapping.""" - kaapi_params = KaapiLLMParams( + kaapi_params = TextLLMParams( model="gpt-4", instructions="You are a helpful assistant.", ) - result, warnings = map_kaapi_to_openai_params(kaapi_params) + result, warnings = map_kaapi_to_openai_params( + kaapi_params.model_dump(exclude_none=True) + ) assert result["model"] == "gpt-4" assert result["instructions"] == "You are a helpful assistant." @@ -39,12 +51,14 @@ def test_instructions_mapping(self): def test_temperature_mapping(self): """Test temperature parameter mapping for non-reasoning models.""" - kaapi_params = KaapiLLMParams( + kaapi_params = TextLLMParams( model="gpt-4", temperature=0.7, ) - result, warnings = map_kaapi_to_openai_params(kaapi_params) + result, warnings = map_kaapi_to_openai_params( + kaapi_params.model_dump(exclude_none=True) + ) assert result["model"] == "gpt-4" assert result["temperature"] == 0.7 @@ -52,24 +66,28 @@ def test_temperature_mapping(self): def test_temperature_zero_mapping(self): """Test that temperature=0 is correctly mapped (edge case).""" - kaapi_params = KaapiLLMParams( + kaapi_params = TextLLMParams( model="gpt-4", temperature=0.0, ) - result, warnings = map_kaapi_to_openai_params(kaapi_params) + result, warnings = map_kaapi_to_openai_params( + kaapi_params.model_dump(exclude_none=True) + ) assert result["temperature"] == 0.0 assert warnings == [] def test_reasoning_mapping_for_reasoning_models(self): """Test reasoning parameter mapping to OpenAI format for reasoning-capable models.""" - kaapi_params = KaapiLLMParams( + kaapi_params = TextLLMParams( model="o1", reasoning="high", ) - result, warnings = map_kaapi_to_openai_params(kaapi_params) + result, warnings = map_kaapi_to_openai_params( + kaapi_params.model_dump(exclude_none=True) + ) assert result["model"] == "o1" assert result["reasoning"] == {"effort": "high"} @@ -77,12 +95,14 @@ def test_reasoning_mapping_for_reasoning_models(self): def test_knowledge_base_ids_mapping(self): """Test knowledge_base_ids mapping to OpenAI tools format.""" - kaapi_params = KaapiLLMParams( + kaapi_params = TextLLMParams( model="gpt-4", knowledge_base_ids=["vs_abc123", "vs_def456"], ) - result, warnings = map_kaapi_to_openai_params(kaapi_params) + result, warnings = map_kaapi_to_openai_params( + kaapi_params.model_dump(exclude_none=True) + ) assert result["model"] == "gpt-4" assert "tools" in result @@ -94,20 +114,22 @@ def test_knowledge_base_ids_mapping(self): def test_knowledge_base_with_max_num_results(self): """Test knowledge_base_ids with custom max_num_results.""" - kaapi_params = KaapiLLMParams( + kaapi_params = TextLLMParams( model="gpt-4", knowledge_base_ids=["vs_abc123"], max_num_results=50, ) - result, warnings = map_kaapi_to_openai_params(kaapi_params) + result, warnings = map_kaapi_to_openai_params( + kaapi_params.model_dump(exclude_none=True) + ) assert result["tools"][0]["max_num_results"] == 50 assert warnings == [] def test_complete_parameter_mapping(self): """Test mapping all compatible parameters together.""" - kaapi_params = KaapiLLMParams( + kaapi_params = TextLLMParams( model="gpt-4o", instructions="You are an expert assistant.", temperature=0.8, @@ -115,7 +137,9 @@ def test_complete_parameter_mapping(self): max_num_results=30, ) - result, warnings = map_kaapi_to_openai_params(kaapi_params) + result, warnings = map_kaapi_to_openai_params( + kaapi_params.model_dump(exclude_none=True) + ) assert result["model"] == "gpt-4o" assert result["instructions"] == "You are an expert assistant." @@ -127,12 +151,14 @@ def test_complete_parameter_mapping(self): def test_reasoning_suppressed_for_non_reasoning_models(self): """Test that reasoning is suppressed with warning for non-reasoning models.""" - kaapi_params = KaapiLLMParams( + kaapi_params = TextLLMParams( model="gpt-4", reasoning="high", ) - result, warnings = map_kaapi_to_openai_params(kaapi_params) + result, warnings = map_kaapi_to_openai_params( + kaapi_params.model_dump(exclude_none=True) + ) assert result["model"] == "gpt-4" assert "reasoning" not in result @@ -142,13 +168,15 @@ def test_reasoning_suppressed_for_non_reasoning_models(self): def test_temperature_suppressed_for_reasoning_models(self): """Test that temperature is suppressed with warning for reasoning models when reasoning is set.""" - kaapi_params = KaapiLLMParams( + kaapi_params = TextLLMParams( model="o1", temperature=0.7, reasoning="high", ) - result, warnings = map_kaapi_to_openai_params(kaapi_params) + result, warnings = map_kaapi_to_openai_params( + kaapi_params.model_dump(exclude_none=True) + ) assert result["model"] == "o1" assert result["reasoning"] == {"effort": "high"} @@ -159,12 +187,14 @@ def test_temperature_suppressed_for_reasoning_models(self): def test_temperature_without_reasoning_for_reasoning_models(self): """Test that temperature is suppressed for reasoning models even without explicit reasoning parameter.""" - kaapi_params = KaapiLLMParams( + kaapi_params = TextLLMParams( model="o1", temperature=0.7, ) - result, warnings = map_kaapi_to_openai_params(kaapi_params) + result, warnings = map_kaapi_to_openai_params( + kaapi_params.model_dump(exclude_none=True) + ) assert result["model"] == "o1" assert "temperature" not in result @@ -175,21 +205,25 @@ def test_temperature_without_reasoning_for_reasoning_models(self): def test_minimal_params(self): """Test mapping with minimal parameters (only model).""" - kaapi_params = KaapiLLMParams(model="gpt-4") + kaapi_params = TextLLMParams(model="gpt-4") - result, warnings = map_kaapi_to_openai_params(kaapi_params) + result, warnings = map_kaapi_to_openai_params( + kaapi_params.model_dump(exclude_none=True) + ) assert result == {"model": "gpt-4"} assert warnings == [] def test_only_knowledge_base_ids(self): """Test mapping with only knowledge_base_ids and model.""" - kaapi_params = KaapiLLMParams( + kaapi_params = TextLLMParams( model="gpt-4", knowledge_base_ids=["vs_xyz"], ) - result, warnings = map_kaapi_to_openai_params(kaapi_params) + result, warnings = map_kaapi_to_openai_params( + kaapi_params.model_dump(exclude_none=True) + ) assert result["model"] == "gpt-4" assert "tools" in result @@ -197,6 +231,106 @@ def test_only_knowledge_base_ids(self): assert warnings == [] +class TestMapKaapiToGoogleParams: + """Test cases for map_kaapi_to_google_params function.""" + + def test_basic_model_mapping(self): + """Test basic model parameter mapping.""" + kaapi_params = TextLLMParams(model="gemini-2.5-pro") + + result, warnings = map_kaapi_to_google_params( + kaapi_params.model_dump(exclude_none=True) + ) + + assert result == {"model": "gemini-2.5-pro"} + assert warnings == [] + + def test_instructions_mapping(self): + """Test instructions parameter mapping.""" + kaapi_params = STTLLMParams( + model="gemini-2.5-pro", + instructions="Transcribe this audio accurately.", + ) + + result, warnings = map_kaapi_to_google_params( + kaapi_params.model_dump(exclude_none=True) + ) + + assert result["model"] == "gemini-2.5-pro" + assert result["instructions"] == "Transcribe this audio accurately." + assert warnings == [] + + def test_temperature_mapping(self): + """Test temperature parameter mapping.""" + kaapi_params = TextLLMParams( + model="gemini-2.5-pro", + temperature=0.7, + ) + + result, warnings = map_kaapi_to_google_params( + kaapi_params.model_dump(exclude_none=True) + ) + + assert result["model"] == "gemini-2.5-pro" + assert result["temperature"] == 0.7 + assert warnings == [] + + def test_knowledge_base_ids_warning(self): + """Test that knowledge_base_ids are not supported and generate warning.""" + kaapi_params = TextLLMParams( + model="gemini-2.5-pro", + knowledge_base_ids=["vs_abc123"], + ) + + result, warnings = map_kaapi_to_google_params( + kaapi_params.model_dump(exclude_none=True) + ) + + assert result["model"] == "gemini-2.5-pro" + assert "knowledge_base_ids" not in result + assert len(warnings) == 1 + assert "knowledge_base_ids" in warnings[0].lower() + assert "not supported" in warnings[0] + + def test_reasoning_warning(self): + """Test that reasoning parameter is not supported and generates warning.""" + kaapi_params = TextLLMParams( + model="gemini-2.5-pro", + reasoning="high", + ) + + result, warnings = map_kaapi_to_google_params( + kaapi_params.model_dump(exclude_none=True) + ) + + assert result["model"] == "gemini-2.5-pro" + assert "reasoning" not in result + assert len(warnings) == 1 + assert "reasoning" in warnings[0].lower() + assert "not applicable" in warnings[0] + + def test_multiple_unsupported_params(self): + """Test that multiple unsupported parameters generate multiple warnings.""" + kaapi_params = TextLLMParams( + model="gemini-2.5-pro", + reasoning="medium", + knowledge_base_ids=["vs_123"], + ) + + result, warnings = map_kaapi_to_google_params( + kaapi_params.model_dump(exclude_none=True) + ) + + assert result["model"] == "gemini-2.5-pro" + assert "reasoning" not in result + assert "knowledge_base_ids" not in result + assert len(warnings) == 2 + # Check both warnings are present + warning_text = " ".join(warnings).lower() + assert "reasoning" in warning_text + assert "knowledge_base_ids" in warning_text + + class TestTransformKaapiConfigToNative: """Test cases for transform_kaapi_config_to_native function.""" @@ -204,10 +338,11 @@ def test_transform_openai_config(self): """Test transformation of Kaapi OpenAI config to native format.""" kaapi_config = KaapiCompletionConfig( provider="openai", - params=KaapiLLMParams( - model="gpt-4", - temperature=0.7, - ), + type="text", + params={ + "model": "gpt-4", + "temperature": 0.7, + }, ) result, warnings = transform_kaapi_config_to_native(kaapi_config) @@ -222,13 +357,14 @@ def test_transform_with_all_params(self): """Test transformation with all Kaapi parameters.""" kaapi_config = KaapiCompletionConfig( provider="openai", - params=KaapiLLMParams( - model="gpt-4o", - instructions="System prompt here", - temperature=0.5, - knowledge_base_ids=["vs_abc"], - max_num_results=25, - ), + type="text", + params={ + "model": "gpt-4o", + "instructions": "System prompt here", + "temperature": 0.5, + "knowledge_base_ids": ["vs_abc"], + "max_num_results": 25, + }, ) result, warnings = transform_kaapi_config_to_native(kaapi_config) @@ -245,10 +381,11 @@ def test_transform_with_reasoning(self): """Test transformation with reasoning parameter for reasoning-capable models.""" kaapi_config = KaapiCompletionConfig( provider="openai", - params=KaapiLLMParams( - model="o1", - reasoning="medium", - ), + type="text", + params={ + "model": "o1", + "reasoning": "medium", + }, ) result, warnings = transform_kaapi_config_to_native(kaapi_config) @@ -262,11 +399,12 @@ def test_transform_with_both_temperature_and_reasoning(self): """Test that transformation handles temperature + reasoning intelligently for reasoning models.""" kaapi_config = KaapiCompletionConfig( provider="openai", - params=KaapiLLMParams( - model="o1", - temperature=0.7, - reasoning="high", - ), + type="text", + params={ + "model": "o1", + "temperature": 0.7, + "reasoning": "high", + }, ) result, warnings = transform_kaapi_config_to_native(kaapi_config) @@ -288,7 +426,7 @@ def test_unsupported_provider_raises_error(self): mock_config = MagicMock() mock_config.provider = "unsupported-provider" - mock_config.params = KaapiLLMParams(model="some-model") + mock_config.params = {"model": "some-model"} with pytest.raises(ValueError) as exc_info: transform_kaapi_config_to_native(mock_config) @@ -299,11 +437,12 @@ def test_transform_preserves_param_structure(self): """Test that transformation correctly structures nested parameters.""" kaapi_config = KaapiCompletionConfig( provider="openai", - params=KaapiLLMParams( - model="gpt-4", - knowledge_base_ids=["vs_1", "vs_2", "vs_3"], - max_num_results=15, - ), + type="text", + params={ + "model": "gpt-4", + "knowledge_base_ids": ["vs_1", "vs_2", "vs_3"], + "max_num_results": 15, + }, ) result, warnings = transform_kaapi_config_to_native(kaapi_config) @@ -314,3 +453,44 @@ def test_transform_preserves_param_structure(self): assert isinstance(result.params["tools"][0]["vector_store_ids"], list) assert len(result.params["tools"][0]["vector_store_ids"]) == 3 assert warnings == [] + + def test_transform_google_config(self): + """Test transformation of Kaapi Google AI config to native format.""" + kaapi_config = KaapiCompletionConfig( + provider="google", + type="stt", + params={ + "model": "gemini-2.5-pro", + "instructions": "Transcribe accurately", + "temperature": 0.2, + }, + ) + + result, warnings = transform_kaapi_config_to_native(kaapi_config) + + assert isinstance(result, NativeCompletionConfig) + assert result.provider == "google-native" + assert result.params["model"] == "gemini-2.5-pro" + assert result.params["instructions"] == "Transcribe accurately" + assert result.params["temperature"] == 0.2 + assert warnings == [] + + def test_transform_google_with_unsupported_params(self): + """Test that Google transformation warns about unsupported parameters.""" + kaapi_config = KaapiCompletionConfig( + provider="google", + type="text", + params={ + "model": "gemini-2.5-pro", + "knowledge_base_ids": ["vs_123"], + "reasoning": "high", + }, + ) + + result, warnings = transform_kaapi_config_to_native(kaapi_config) + + assert result.provider == "google-native" + assert result.params["model"] == "gemini-2.5-pro" + assert "knowledge_base_ids" not in result.params + assert "reasoning" not in result.params + assert len(warnings) == 2 diff --git a/backend/app/tests/utils/test_data.py b/backend/app/tests/utils/test_data.py index 8745195d7..1b16af35e 100644 --- a/backend/app/tests/utils/test_data.py +++ b/backend/app/tests/utils/test_data.py @@ -17,7 +17,7 @@ Config, ConfigCreate, ConfigVersion, - ConfigVersionCreate, + ConfigVersionUpdate, EvaluationDataset, ) from app.models.llm import KaapiLLMParams, KaapiCompletionConfig, NativeCompletionConfig @@ -269,10 +269,11 @@ def create_test_config( config_blob = ConfigBlob( completion=KaapiCompletionConfig( provider="openai", - params=KaapiLLMParams( - model="gpt-4", - temperature=0.7, - ), + type="text", + params={ + "model": "gpt-4", + "temperature": 0.7, + }, ) ) else: @@ -280,6 +281,7 @@ def create_test_config( config_blob = ConfigBlob( completion=NativeCompletionConfig( provider="openai-native", + type="text", params={ "model": "gpt-4", "temperature": 0.7, @@ -311,29 +313,87 @@ def create_test_version( """ Creates and returns a test version for an existing configuration. + If config_blob is not provided, fetches the latest version and creates + a new version with the same type, provider, and similar params. + Persists the version to the database. """ if config_blob is None: - config_blob = ConfigBlob( - completion=NativeCompletionConfig( - provider="openai-native", - params={ - "model": "gpt-4", - "temperature": 0.8, - "max_tokens": 1500, - }, + # Fetch the latest version to maintain type consistency + from sqlmodel import select, and_ + from app.models import ConfigVersion + + stmt = ( + select(ConfigVersion) + .where( + and_( + ConfigVersion.config_id == config_id, + ConfigVersion.deleted_at.is_(None), + ) ) + .order_by(ConfigVersion.version.desc()) + .limit(1) ) + latest_version = db.exec(stmt).first() + + if latest_version: + # Extract the type and provider from the latest version + completion_config = latest_version.config_blob.get("completion", {}) + config_type = completion_config.get("type") + provider = completion_config.get("provider", "openai-native") + + # Create a new config_blob maintaining the same type and provider + if provider in ["openai-native", "google-native"]: + config_blob = ConfigBlob( + completion=NativeCompletionConfig( + provider=provider, + type=config_type, + params={ + "model": completion_config.get("params", {}).get( + "model", "gpt-4" + ), + "temperature": 0.8, + "max_tokens": 1500, + }, + ) + ) + else: + # For Kaapi providers (openai, google) + config_blob = ConfigBlob( + completion=KaapiCompletionConfig( + provider=provider, + type=config_type, + params={ + "model": completion_config.get("params", {}).get( + "model", "gpt-4" + ), + "temperature": 0.8, + }, + ) + ) + else: + # Fallback if no previous version exists (shouldn't happen in normal flow) + config_blob = ConfigBlob( + completion=NativeCompletionConfig( + provider="openai-native", + type="text", + params={ + "model": "gpt-4", + "temperature": 0.8, + "max_tokens": 1500, + }, + ) + ) - version_create = ConfigVersionCreate( - config_blob=config_blob, + version_update = ConfigVersionUpdate( + config_blob=config_blob.model_dump(), commit_message=commit_message or "Test version commit", ) version_crud = ConfigVersionCrud( session=db, project_id=project_id, config_id=config_id ) - version = version_crud.create_or_raise(version_create=version_create) + version = version_crud.create_or_raise(version_create=version_update) return version diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 119ee9116..ce51387d4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -36,8 +36,7 @@ dependencies = [ "celery>=5.3.0,<6.0.0", "redis>=5.0.0,<6.0.0", "flower>=2.0.1", - "google-genai>=1.0.0", - "requests>=2.31.0", + "google-genai>=1.59.0", ] [tool.uv] diff --git a/backend/uv.lock b/backend/uv.lock index 667e43f0d..472d21025 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12, <4.0" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -137,16 +137,16 @@ wheels = [ [[package]] name = "alembic" -version = "1.18.1" +version = "1.18.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/cc/aca263693b2ece99fa99a09b6d092acb89973eb2bb575faef1777e04f8b4/alembic-1.18.1.tar.gz", hash = "sha256:83ac6b81359596816fb3b893099841a0862f2117b2963258e965d70dc62fb866", size = 2044319, upload-time = "2026-01-14T18:53:14.907Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/93/07f5ba5d8e4f4049e864faa9d822bbbbfb6f3223a4ffb1376768ab9ee4b8/alembic-1.18.2.tar.gz", hash = "sha256:1c3ddb635f26efbc80b1b90c5652548202022d4e760f6a78d6d85959280e3684", size = 2048272, upload-time = "2026-01-28T21:23:30.914Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/36/cd9cb6101e81e39076b2fbe303bfa3c85ca34e55142b0324fcbf22c5c6e2/alembic-1.18.1-py3-none-any.whl", hash = "sha256:f1c3b0920b87134e851c25f1f7f236d8a332c34b75416802d06971df5d1b7810", size = 260973, upload-time = "2026-01-14T18:53:17.533Z" }, + { url = "https://files.pythonhosted.org/packages/1a/60/ced4277ccf61f91eb03c4ac9f63b9567eb814f9ab1cd7835f00fbd5d0c14/alembic-1.18.2-py3-none-any.whl", hash = "sha256:18a5f6448af4864cc308aadf33eb37c0116da9a60fd9bb3f31ccb1b522b4a9b9", size = 261953, upload-time = "2026-01-28T21:23:32.508Z" }, ] [[package]] @@ -225,7 +225,6 @@ dependencies = [ { name = "pytest" }, { name = "python-multipart" }, { name = "redis" }, - { name = "requests" }, { name = "scikit-learn" }, { name = "sentry-sdk", extra = ["fastapi"] }, { name = "sqlmodel" }, @@ -254,7 +253,7 @@ requires-dist = [ { name = "emails", specifier = ">=0.6,<1.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.116.0" }, { name = "flower", specifier = ">=2.0.1" }, - { name = "google-genai", specifier = ">=1.0.0" }, + { name = "google-genai", specifier = ">=1.59.0" }, { name = "httpx", specifier = ">=0.25.1,<1.0.0" }, { name = "jinja2", specifier = ">=3.1.4,<4.0.0" }, { name = "langfuse", specifier = "==2.60.3" }, @@ -273,7 +272,6 @@ requires-dist = [ { name = "pytest", specifier = ">=7.4.4" }, { name = "python-multipart", specifier = ">=0.0.22,<1.0.0" }, { name = "redis", specifier = ">=5.0.0,<6.0.0" }, - { name = "requests", specifier = ">=2.31.0" }, { name = "scikit-learn", specifier = ">=1.7.1" }, { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.20.0" }, { name = "sqlmodel", specifier = ">=0.0.21,<1.0.0" }, @@ -352,39 +350,39 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.35" +version = "1.42.37" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/a4/e70cc79e8f91836c06021c35507c843e5bc39a2020a85a6a27a492b50f78/boto3-1.42.35.tar.gz", hash = "sha256:edbfbfbadd419e65888166dd044786d4b731cf60abeb2301b73e775e154d7c5e", size = 112928, upload-time = "2026-01-26T20:35:37.524Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/ef/0d6ceb88ae2b3638b956190a431e4a8a3697d5769d4bbbede8efcccacaea/boto3-1.42.37.tar.gz", hash = "sha256:d8b6c52c86f3bf04f71a5a53e7fb4d1527592afebffa5170cf3ef7d70966e610", size = 112830, upload-time = "2026-01-28T20:38:43.339Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/26/75b6301514c74c398207462086af6cfe2a875fd8700a6e508559bb1ed21a/boto3-1.42.35-py3-none-any.whl", hash = "sha256:4251bbac90e4a190680439973d9e9ed851e50292c10cd063c8bf0c365410ffe1", size = 140606, upload-time = "2026-01-26T20:35:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a4/cd334f74498acc6ad42a69c48e8c495f6f721d8abe13f8ef0d4b862fb1c0/boto3-1.42.37-py3-none-any.whl", hash = "sha256:e1e38fd178ffc66cfbe9cb6838b8c460000c3eb741e5f40f57eb730780ef0ed4", size = 140604, upload-time = "2026-01-28T20:38:42.135Z" }, ] [[package]] name = "botocore" -version = "1.42.35" +version = "1.42.37" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e2/3d/339edff36a3c6617900ec9d7a1203ffe4e06ffee1e5bd71126e31cd59e30/botocore-1.42.35.tar.gz", hash = "sha256:40a6e0f16afe9e5d42e956f0b6d909869793fadb21780e409063601fc3d094b8", size = 14903745, upload-time = "2026-01-26T20:35:25.85Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/94292e7686e64d2ede8dae7102bbb11a1474e407c830de4192f2518e6cff/botocore-1.42.37.tar.gz", hash = "sha256:3ec58eb98b0857f67a2ae6aa3ded51597e7335f7640be654e0e86da4f173b5b2", size = 14914621, upload-time = "2026-01-28T20:38:34.586Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/b6/68f0aec79462852f367128dd8892e47176da46a787386d1730ec5bbbfb01/botocore-1.42.35-py3-none-any.whl", hash = "sha256:b89f527987691abbd1374c4116cc2711471ce48e6da502db17e92b17b2af8d47", size = 14581567, upload-time = "2026-01-26T20:35:23.346Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/54042dd3ad8161964f8f47aa418785079bd8d2f17053c40d65bafb9f6eed/botocore-1.42.37-py3-none-any.whl", hash = "sha256:f13bb8b560a10714d96fb7b0c7f17828dfa6e6606a1ead8c01c6ebb8765acbd8", size = 14589390, upload-time = "2026-01-28T20:38:31.306Z" }, ] [[package]] name = "cachetools" -version = "6.2.5" +version = "6.2.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/e7/18ea2907d2ca91e9c0697596b8e60cd485b091152eb4109fad1e468e457d/cachetools-6.2.5.tar.gz", hash = "sha256:6d8bfbba1ba94412fb9d9196c4da7a87e9d4928fffc5e93542965dca4740c77f", size = 32168, upload-time = "2026-01-25T14:57:40.349Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/a6/24169d70ec5264b65ba54ba49b3d10f46d6b1ad97e185c94556539b3dfc8/cachetools-6.2.5-py3-none-any.whl", hash = "sha256:db3ae5465e90befb7c74720dd9308d77a09b7cf13433570e07caa0845c30d5fe", size = 11553, upload-time = "2026-01-25T14:57:39.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" }, ] [[package]] @@ -682,58 +680,55 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.3" +version = "46.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, + { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, + { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, + { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, + { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, + { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, + { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, + { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, + { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, + { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, + { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, + { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, + { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, + { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, + { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, + { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, + { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, ] [[package]] @@ -1133,7 +1128,7 @@ requests = [ [[package]] name = "google-genai" -version = "1.61.0" +version = "1.60.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1147,9 +1142,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/38/421cd7e70952a536be87a0249409f87297d84f523754a25b08fe94b97e7f/google_genai-1.61.0.tar.gz", hash = "sha256:5773a4e8ad5b2ebcd54a633a67d8e9c4f413032fef07977ee47ffa34a6d3bbdf", size = 489672, upload-time = "2026-01-30T20:50:27.177Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/3f/a753be0dcee352b7d63bc6d1ba14a72591d63b6391dac0cdff7ac168c530/google_genai-1.60.0.tar.gz", hash = "sha256:9768061775fddfaecfefb0d6d7a6cabefb3952ebd246cd5f65247151c07d33d1", size = 487721, upload-time = "2026-01-21T22:17:30.398Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/87/78dd70cb59f7acf3350f53c5144a7aa7bc39c6f425cd7dc1224b59fcdac3/google_genai-1.61.0-py3-none-any.whl", hash = "sha256:cb073ef8287581476c1c3f4d8e735426ee34478e500a56deef218fa93071e3ca", size = 721948, upload-time = "2026-01-30T20:50:25.551Z" }, + { url = "https://files.pythonhosted.org/packages/31/e5/384b1f383917b5f0ae92e28f47bc27b16e3d26cd9bacb25e9f8ecab3c8fe/google_genai-1.60.0-py3-none-any.whl", hash = "sha256:967338378ffecebec19a8ed90cf8797b26818bacbefd7846a9280beb1099f7f3", size = 719431, upload-time = "2026-01-21T22:17:28.086Z" }, ] [[package]] @@ -1572,7 +1567,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.81.3" +version = "1.81.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1588,9 +1583,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/dd/d70835d5b231617761717cd5ba60342b677693093a71d5ce13ae9d254aee/litellm-1.81.3.tar.gz", hash = "sha256:a7688b429a88abfdd02f2a8c3158ebb5385689cfb7f9d4ac1473d018b2047e1b", size = 13612652, upload-time = "2026-01-25T02:45:58.888Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/f4/c109bc5504520baa7b96a910b619d1b1b5af6cb5c28053e53adfed83e3ab/litellm-1.81.5.tar.gz", hash = "sha256:599994651cbb64b8ee7cd3b4979275139afc6e426bdd4aa840a61121bb3b04c9", size = 13615436, upload-time = "2026-01-29T01:37:54.817Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/62/d3f53c665261fdd5bb2401246e005a4ea8194ad1c4d8c663318ae3d638bf/litellm-1.81.3-py3-none-any.whl", hash = "sha256:3f60fd8b727587952ad3dd18b68f5fed538d6f43d15bb0356f4c3a11bccb2b92", size = 11946995, upload-time = "2026-01-25T02:45:55.887Z" }, + { url = "https://files.pythonhosted.org/packages/74/0f/5312b944208efeec5dcbf8e0ed956f8f7c430b0c6458301d206380c90b56/litellm-1.81.5-py3-none-any.whl", hash = "sha256:206505c5a0c6503e465154b9c979772be3ede3f5bf746d15b37dca5ae54d239f", size = 11950016, upload-time = "2026-01-29T01:37:52.6Z" }, ] [[package]] @@ -2017,7 +2012,7 @@ wheels = [ [[package]] name = "openai" -version = "2.15.0" +version = "2.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2029,9 +2024,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/f4/4690ecb5d70023ce6bfcfeabfe717020f654bde59a775058ec6ac4692463/openai-2.15.0.tar.gz", hash = "sha256:42eb8cbb407d84770633f31bf727d4ffb4138711c670565a41663d9439174fba", size = 627383, upload-time = "2026-01-09T22:10:08.603Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/6c/e4c964fcf1d527fdf4739e7cc940c60075a4114d50d03871d5d5b1e13a88/openai-2.16.0.tar.gz", hash = "sha256:42eaa22ca0d8ded4367a77374104d7a2feafee5bd60a107c3c11b5243a11cd12", size = 629649, upload-time = "2026-01-27T23:28:02.579Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/df/c306f7375d42bafb379934c2df4c2fa3964656c8c782bac75ee10c102818/openai-2.15.0-py3-none-any.whl", hash = "sha256:6ae23b932cd7230f7244e52954daa6602716d6b9bf235401a107af731baea6c3", size = 1067879, upload-time = "2026-01-09T22:10:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/16/83/0315bf2cfd75a2ce8a7e54188e9456c60cec6c0cf66728ed07bd9859ff26/openai-2.16.0-py3-none-any.whl", hash = "sha256:5f46643a8f42899a84e80c38838135d7038e7718333ce61396994f887b09a59b", size = 1068612, upload-time = "2026-01-27T23:28:00.356Z" }, ] [[package]] @@ -3239,15 +3234,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.50.0" +version = "2.51.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/8a/3c4f53d32c21012e9870913544e56bfa9e931aede080779a0f177513f534/sentry_sdk-2.50.0.tar.gz", hash = "sha256:873437a989ee1b8b25579847bae8384515bf18cfed231b06c591b735c1781fe3", size = 401233, upload-time = "2026-01-20T12:53:16.244Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/9f/094bbb6be5cf218ab6712c6528310687f3d3fe8818249fcfe1d74192f7c5/sentry_sdk-2.51.0.tar.gz", hash = "sha256:b89d64577075fd8c13088bc3609a2ce77a154e5beb8cba7cc16560b0539df4f7", size = 407447, upload-time = "2026-01-28T10:29:50.962Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/5b/cbc2bb9569f03c8e15d928357e7e6179e5cfab45544a3bbac8aec4caf9be/sentry_sdk-2.50.0-py2.py3-none-any.whl", hash = "sha256:0ef0ed7168657ceb5a0be081f4102d92042a125462d1d1a29277992e344e749e", size = 424961, upload-time = "2026-01-20T12:53:14.826Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/df379404d484ca9dede4ad8abead5de828cdcff35623cd44f0351cf6869c/sentry_sdk-2.51.0-py2.py3-none-any.whl", hash = "sha256:e21016d318a097c2b617bb980afd9fc737e1efc55f9b4f0cdc819982c9717d5f", size = 431426, upload-time = "2026-01-28T10:29:48.868Z" }, ] [package.optional-dependencies]