"""Request bodies for the AI Labs surfaces. Response shapes are hand-built dicts in the service layer (the platform convention — a Pydantic ``response_model`` would strip the standard envelope), so only inputs are declared here. Object-key lists go through :func:`sanitize_object_key_list`, the same validator every other domain that accepts browser-uploaded keys uses. It blocks ``..``, leading slashes and scheme prefixes before the key reaches the ownership check — belt and braces, since the key is also parsed for its uploader id downstream. """ from __future__ import annotations from typing import Any from pydantic import BaseModel, Field, field_validator from app.core.storage_uploads import MAX_OBJECT_KEY_LEN, sanitize_object_key_list from app.domains.ai_labs import catalog class CreateConversationBody(BaseModel): model_key: str | None = Field(default=None, max_length=64) thinking: bool | None = None class UpdateConversationBody(BaseModel): title: str | None = Field(default=None, max_length=200) pinned: bool | None = None model_key: str | None = Field(default=None, max_length=64) thinking: bool | None = None class ChatTurnBody(BaseModel): text: str = Field(..., max_length=catalog.MAX_MESSAGE_CHARS) attachment_object_keys: list[str] | None = Field( default=None, max_length=catalog.MAX_ATTACHMENTS_PER_TURN, ) #: Switching model or thinking mode mid-thread is allowed and sticks to #: the conversation, so the next turn continues the same way. model_key: str | None = Field(default=None, max_length=64) thinking: bool | None = None #: Per-send idempotency token. A retry of the same send must carry the #: same value; a new send must not. Without one the endpoint behaves as #: it always did, so an older client is not broken by this field — it #: just does not get the protection. client_token: str | None = Field(default=None, max_length=128) #: ``{object_key: display filename}``. The stored key carries no name #: for these purposes (see ``storage_uploads.OPAQUE_NAME_PURPOSES``), #: so the label the employee sees on their own attachment chip has to #: travel with the send and is kept on their own message row. Purely a #: label: nothing downstream trusts it for typing or routing. attachment_names: dict[str, str] | None = None @field_validator("attachment_names") @classmethod def _clean_names(cls, value: dict[str, str] | None) -> dict[str, str] | None: if not value: return None if len(value) > catalog.MAX_ATTACHMENTS_PER_TURN: raise ValueError("attachment_names has too many entries") cleaned: dict[str, str] = {} for key, name in value.items(): if not isinstance(key, str) or not isinstance(name, str): raise ValueError("attachment_names must be string to string") if len(key) > MAX_OBJECT_KEY_LEN or len(name) > 200: raise ValueError("attachment_names entry too long") trimmed = name.strip() if trimmed: cleaned[key.strip()] = trimmed return cleaned or None @field_validator("attachment_object_keys") @classmethod def _clean_keys(cls, value: list[str] | None) -> list[str] | None: return sanitize_object_key_list( value, field="attachment_object_keys", max_items=catalog.MAX_ATTACHMENTS_PER_TURN, ) class SubmitGenerationBody(BaseModel): capability: str = Field(..., max_length=32) prompt: str = Field(default="", max_length=8000) #: Catalogue-declared options only. Unknown keys are dropped by the #: service; a known key with a value the model was never verified to #: accept is rejected. params: dict[str, Any] | None = None input_object_keys: list[str] | None = Field(default=None, max_length=3) @field_validator("input_object_keys") @classmethod def _clean_keys(cls, value: list[str] | None) -> list[str] | None: return sanitize_object_key_list( value, field="input_object_keys", max_items=3, ) class TranslateBody(BaseModel): text: str = Field(..., max_length=catalog.TRANSLATE_MAX_CHARS) target_language: str = Field(..., max_length=32) class AdminSettingsBody(BaseModel): """Every knob an operator has. All optional — a PATCH-style write.""" enabled: bool | None = None daily_tokens: int | None = Field(default=None, ge=0, le=100_000_000) daily_images: int | None = Field(default=None, ge=0, le=100_000) daily_video_seconds: int | None = Field(default=None, ge=0, le=100_000) daily_speech: int | None = Field(default=None, ge=0, le=100_000) daily_transcribe: int | None = Field(default=None, ge=0, le=100_000) retention_days: int | None = Field(default=None, ge=0, le=3650) reporting_timezone: str | None = Field(default=None, max_length=64) #: ``{"": ""}``. An empty dict clears #: every override. model_overrides: dict[str, str] | None = None __all__ = [ "AdminSettingsBody", "ChatTurnBody", "CreateConversationBody", "SubmitGenerationBody", "TranslateBody", "UpdateConversationBody", ]