import json from typing import Any, Literal import dirtyjson # type: ignore[import-untyped] from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from constants.presentation import MAX_OUTLINE_CONTENT_WORDS class StrictSchemaModel(BaseModel): model_config = ConfigDict(extra="forbid", strict=True) class OpenAIStrictSchemaModel(StrictSchemaModel): @model_validator(mode="before") @classmethod def populate_missing_fields_with_none(cls, value: Any) -> Any: if not isinstance(value, dict): return value normalized = dict(value) for field_name, field in cls.model_fields.items(): alias = field.alias if field_name in normalized or (alias and alias in normalized): continue normalized[alias or field_name] = None return normalized class NoArgsInput(StrictSchemaModel): pass class AddOutlineInput(OpenAIStrictSchemaModel): content: str = Field( ..., min_length=1, max_length=20000, description=f"Markdown content for the new outline slide. Maximum {MAX_OUTLINE_CONTENT_WORDS} words.", ) index: int | None = Field( ..., ge=0, le=1000, description="Zero-based insert index. Use null to append to the end.", ) class UpdateOutlineInput(StrictSchemaModel): index: int = Field(ge=0, le=1000) content: str = Field( min_length=1, max_length=20000, description=f"Replacement markdown content for this outline slide. Maximum {MAX_OUTLINE_CONTENT_WORDS} words.", ) class DeleteOutlineInput(StrictSchemaModel): index: int = Field(ge=0, le=1000) class MoveOutlineInput(StrictSchemaModel): from_index: int = Field(alias="fromIndex", ge=0, le=1000) to_index: int = Field(alias="toIndex", ge=0, le=1000) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) class GetSlideAtIndexInput(StrictSchemaModel): index: int = Field(ge=0, le=1000) include_full_content: bool = Field(alias="includeFullContent") model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) class SearchSlidesInput(StrictSchemaModel): query: str = Field(min_length=1, max_length=1000) limit: int = Field(ge=1, le=10) class ReadSourceDocumentsInput(OpenAIStrictSchemaModel): query: str | None = Field( ..., min_length=1, max_length=1000, description=( "Optional focus query for retrieving uploaded/source document content. " "Use null when the user asks for a general summary." ), ) max_chars: int | None = Field( ..., alias="maxChars", ge=1000, le=30000, description="Maximum document text characters to return. Use null for the default.", ) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) class GetContentSchemaFromLayoutIdInput(StrictSchemaModel): layout_id: str = Field(alias="layoutId", min_length=1, max_length=200) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) class GetAvailableBlocksInput(OpenAIStrictSchemaModel): query: str | None = Field( ..., min_length=1, max_length=1000, description=( "Optional search text for block id, description, layout, or element types." ), ) layout_id: str | None = Field( ..., alias="layoutId", min_length=1, max_length=200, description="Optional layout id to restrict block candidates.", ) element_type: str | None = Field( ..., alias="elementType", min_length=1, max_length=80, description=( "Optional element type filter such as table, chart, image, text, " "or text-list. For title/header/subtitle blocks, use text with a " "title/header/subtitle query." ), ) block_id: str | None = Field( ..., alias="blockId", min_length=1, max_length=300, description=( "Optional exact block id returned by a previous getAvailableBlocks call." ), ) include_full_content: bool | None = Field( ..., alias="includeFullContent", description=( "Set true only when exact component JSON is needed for " "addComponent/createComponent." ), ) max_results: int | None = Field( ..., alias="maxResults", ge=1, le=50, description=( "Maximum matching block summaries to return. Use null for the default." ), ) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) class GenerateImageInput(StrictSchemaModel): prompt: str = Field(min_length=1, max_length=4000) class GenerateIconInput(StrictSchemaModel): query: str = Field(min_length=1, max_length=1000) class GenerateAssetItemInput(StrictSchemaModel): kind: Literal["image", "icon"] prompt: str = Field( min_length=1, max_length=4000, description="Image prompt or icon search query.", ) class GenerateAssetsInput(StrictSchemaModel): assets: list[GenerateAssetItemInput] = Field(min_length=1, max_length=12) class AddNewSlideInput(OpenAIStrictSchemaModel): index: int | None = Field( ..., ge=0, le=1000, description="Zero-based insert index. Use null to append.", ) class SaveSlideInput(StrictSchemaModel): content: str = Field( min_length=2, max_length=200000, description=( "A JSON-serialized object for slide content. " "Example: '{\"title\": \"Q4 Revenue\", \"bullets\": [\"North America +22%\"]}'" ), ) layout_id: str = Field(alias="layoutId", min_length=1, max_length=200) index: int = Field(ge=0, le=1000) replace_old_slide_at_index: bool = Field(alias="replaceOldSlideAtIndex") model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) @field_validator("content") @classmethod def validate_content(cls, value: str) -> str: try: parsed: Any = dirtyjson.loads(value) except Exception: parsed = json.loads(value) if not isinstance(parsed, dict): raise ValueError("'content' must be a JSON object.") return value class AddNewSlideLayoutInput(StrictSchemaModel): content: str = Field( min_length=2, max_length=200000, description="A JSON-serialized object matching the selected layout schema.", ) layout_id: str = Field(alias="layoutId", min_length=1, max_length=200) index: int = Field(ge=0, le=1000) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) @field_validator("content") @classmethod def validate_content(cls, value: str) -> str: try: parsed: Any = dirtyjson.loads(value) except Exception: parsed = json.loads(value) if not isinstance(parsed, dict): raise ValueError("'content' must be a JSON object.") return value class UpdateSlideInput(AddNewSlideLayoutInput): pass class DeleteSlideInput(StrictSchemaModel): index: int = Field(ge=0, le=1000) class GetSlideElementsInput(OpenAIStrictSchemaModel): index: int = Field( ..., ge=0, le=1000, description="Zero-based slide index whose rendered UI layout you want to inspect.", ) include_full_json: bool | None = Field( ..., alias="includeFullJson", description="Set true only when the exact UI layout JSON is required.", ) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) class SlideElementTableCellInput(OpenAIStrictSchemaModel): section: Literal["columns", "rows"] column_index: int = Field(..., alias="columnIndex", ge=0, le=100) row_index: int | None = Field( ..., alias="rowIndex", ge=0, le=100, description="Required when section is rows; ignored for columns.", ) text: str = Field(..., min_length=0, max_length=5000) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) @model_validator(mode="after") def validate_row_target(self) -> "SlideElementTableCellInput": if self.section == "rows" and self.row_index is None: raise ValueError("rowIndex is required when section is rows.") return self class SlideElementChartSeriesInput(OpenAIStrictSchemaModel): name: str = Field(..., min_length=1, max_length=200) values: list[float] = Field(..., min_length=1, max_length=100) @model_validator(mode="before") @classmethod def normalize_data_alias(cls, value: Any) -> Any: if not isinstance(value, dict): return value normalized = dict(value) if "values" not in normalized and "data" in normalized: normalized["values"] = normalized.pop("data") return normalized DataLabelPosition = Literal["base", "mid", "top", "outside"] class SlideElementChartInput(OpenAIStrictSchemaModel): chart_type: Literal[ "area", "bar", "bubble", "donut", "horizontal_bar", "horizontal_stacked_bar", "line", "pie", "polar_area", "radar", "scatter", "stacked_bar", ] | None = Field( ..., alias="chartType", description=( "Chart type. Supports the same chart types as the editor: bar, " "horizontal_bar, stacked_bar, horizontal_stacked_bar, line, area, " "pie, donut, scatter, bubble, radar, and polar_area." ), ) title: str | None = Field(..., min_length=0, max_length=500) title_color: str | None = Field( ..., alias="titleColor", min_length=1, max_length=32 ) categories: list[str] | None = Field(..., min_length=1, max_length=100) series: list[SlideElementChartSeriesInput] | None = Field( ..., min_length=1, max_length=20 ) colors: list[str] | None = Field( ..., min_length=1, max_length=12, description=( "Optional chart palette. Use user-specified colors when provided; " "otherwise omit/null so the current theme graph colors are used." ), ) axis_color: str | None = Field(..., alias="axisColor", min_length=1, max_length=32) grid_color: str | None = Field(..., alias="gridColor", min_length=1, max_length=32) x_axis: bool | None = Field(..., alias="xAxis") y_axis: bool | None = Field(..., alias="yAxis") x_axis_grid: bool | None = Field(..., alias="xAxisGrid") y_axis_grid: bool | None = Field(..., alias="yAxisGrid") x_axis_title: str | None = Field(..., alias="xAxisTitle", min_length=0, max_length=200) y_axis_title: str | None = Field(..., alias="yAxisTitle", min_length=0, max_length=200) data_labels: DataLabelPosition | None = Field( ..., alias="dataLabels", description=( "Optional data label placement. Use null to hide labels; otherwise " "use base, mid, top, or outside." ), ) legend: bool | None = Field(...) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) class SlideElementTableValueInput(StrictSchemaModel): text: str = Field(min_length=0, max_length=5000) SlideElementTableValue = ( str | int | float | bool | None | SlideElementTableValueInput ) class SlideElementTableInput(OpenAIStrictSchemaModel): columns: list[SlideElementTableValue] | None = Field( ..., min_length=1, max_length=100 ) headers: list[SlideElementTableValue] | None = Field( ..., min_length=1, max_length=100 ) rows: list[list[SlideElementTableValue]] = Field(min_length=1, max_length=100) @model_validator(mode="after") def validate_columns_or_headers(self) -> "SlideElementTableInput": if self.columns is None and self.headers is None: raise ValueError("columns or headers is required.") return self class SlideElementPositionInput(StrictSchemaModel): x: float = Field(ge=-10000, le=10000) y: float = Field(ge=-10000, le=10000) class SlideElementSizeInput(StrictSchemaModel): width: float = Field(ge=1, le=10000) height: float = Field(ge=1, le=10000) class SlideElementFontInput(OpenAIStrictSchemaModel): family: str | None = Field(..., min_length=1, max_length=200) size: float | None = Field(..., ge=1, le=512) color: str | None = Field(..., min_length=1, max_length=64) bold: bool | None = Field(...) italic: bool | None = Field(...) underline: bool | None = Field(...) line_height: float | None = Field(..., alias="lineHeight", ge=0.1, le=10) letter_spacing: float | None = Field(..., alias="letterSpacing", ge=-100, le=100) wrap: Literal["word", "char", "none"] | None = Field(...) opacity: float | None = Field(..., ge=0, le=1) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) @model_validator(mode="before") @classmethod def normalize_font_aliases(cls, value: Any) -> Any: if not isinstance(value, dict): return value normalized = dict(value) alias_pairs = ( ("fontFamily", "family"), ("font_family", "family"), ("fontName", "family"), ("font_name", "family"), ("name", "family"), ("fontSize", "size"), ("font_size", "size"), ("fontColor", "color"), ("font_color", "color"), ("textColor", "color"), ("text_color", "color"), ("line_height", "lineHeight"), ("line-height", "lineHeight"), ("letter_spacing", "letterSpacing"), ("letter-spacing", "letterSpacing"), ) for source_key, target_key in alias_pairs: if source_key in normalized and target_key not in normalized: normalized[target_key] = normalized.pop(source_key) return normalized class SlideElementAlignmentInput(OpenAIStrictSchemaModel): horizontal: Literal["left", "center", "right"] | None = Field(...) vertical: Literal["top", "middle", "bottom"] | None = Field(...) class SlideElementFillInput(OpenAIStrictSchemaModel): color: str | None = Field(..., min_length=1, max_length=64) opacity: float | None = Field(..., ge=0, le=1) class SlideElementStrokeInput(OpenAIStrictSchemaModel): color: str | None = Field(..., min_length=1, max_length=64) opacity: float | None = Field(..., ge=0, le=1) width: float | None = Field(..., ge=0, le=100) dash: list[float] | None = Field(..., min_length=1, max_length=12) class UpdateSlideElementInput(OpenAIStrictSchemaModel): index: int = Field(..., ge=0, le=1000) element_path: str = Field( ..., alias="elementPath", min_length=1, max_length=500, description=( "Element path returned by getSlideAtIndex, for example " "components[0].elements[1].children[0]." ), ) text: str | None = Field( ..., min_length=0, max_length=20000, description="Replacement text for a text element or replacement image/icon data.", ) items: list[str] | None = Field( ..., min_length=1, max_length=100, description="Replacement item text for a text-list element.", ) table_cell: SlideElementTableCellInput | None = Field( ..., alias="tableCell", description="A single table header/body cell update.", ) chart: SlideElementChartInput | None = Field( ..., description=( "Chart update using the new chart model: chartType, title, " "categories, series.values, colors, axes, data labels, and legend." ), ) table: SlideElementTableInput | None = Field( ..., description="Whole table update with columns/headers and rows.", ) element: str | None = Field( ..., min_length=2, max_length=120000, description=( "Optional JSON-serialized element patch for toolbar-style properties " "such as fill, stroke, font, alignment, opacity, crop, " "border_radius, padding, shadow, or line dash. Prefer the chart field " "for chart type, colors, axes, legend, and data labels. " "Object values are merged into the current element." ), ) font: SlideElementFontInput | None = Field( ..., description=( "Toolbar-style text font patch. For text, text-list, and table " "elements, this updates both the element font and existing text runs." ), ) alignment: SlideElementAlignmentInput | None = Field( ..., description="Toolbar-style text alignment patch for text elements.", ) fill: SlideElementFillInput | None = Field( ..., description="Toolbar-style fill/background patch for elements that support fill.", ) stroke: SlideElementStrokeInput | None = Field( ..., description="Toolbar-style stroke/border/line patch.", ) color: str | None = Field( ..., min_length=1, max_length=64, description=( "Convenience color patch. For text, text-list, and table elements this " "means font.color; for icons/images it updates element color; for " "basic shapes it updates fill color." ), ) opacity: float | None = Field( ..., ge=0, le=1, description="Toolbar-style element opacity patch.", ) position: SlideElementPositionInput | None = Field( ..., description="Optional element position update for move requests.", ) size: SlideElementSizeInput | None = Field( ..., description="Optional element size update for resize/shrink/grow requests.", ) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) @model_validator(mode="before") @classmethod def normalize_common_llm_payloads(cls, value: Any) -> Any: if not isinstance(value, dict): return value normalized = dict(value) chart_keys = ( "axisColor", "axis_color", "categories", "chartType", "chart_type", "colors", "dataLabels", "data_labels", "gridColor", "grid_color", "legend", "series", "title", "titleColor", "title_color", "xAxis", "xAxisGrid", "xAxisTitle", "x_axis", "x_axis_grid", "x_axis_title", "yAxis", "yAxisGrid", "yAxisTitle", "y_axis", "y_axis_grid", "y_axis_title", ) if "chart" not in normalized and any( key in normalized for key in chart_keys ): normalized["chart"] = { key: normalized.pop(key) for key in chart_keys if key in normalized } font_aliases = { "fontFamily": "family", "font_family": "family", "fontName": "family", "font_name": "family", "fontSize": "size", "font_size": "size", "fontColor": "color", "font_color": "color", "textColor": "color", "text_color": "color", "bold": "bold", "italic": "italic", "underline": "underline", "lineHeight": "lineHeight", "line_height": "lineHeight", "letterSpacing": "letterSpacing", "letter_spacing": "letterSpacing", "wrap": "wrap", } font_patch = ( dict(normalized["font"]) if isinstance(normalized.get("font"), dict) else {} ) for source_key, target_key in font_aliases.items(): if source_key in normalized and target_key not in font_patch: font_patch[target_key] = normalized.pop(source_key) if font_patch and "font" not in normalized: normalized["font"] = font_patch elif font_patch: normalized["font"] = font_patch alignment_aliases = { "align": "horizontal", "textAlign": "horizontal", "text_align": "horizontal", "horizontalAlign": "horizontal", "horizontal_align": "horizontal", "horizontalAlignment": "horizontal", "verticalAlign": "vertical", "vertical_align": "vertical", "verticalAlignment": "vertical", } alignment_patch = ( dict(normalized["alignment"]) if isinstance(normalized.get("alignment"), dict) else {} ) for source_key, target_key in alignment_aliases.items(): if source_key in normalized and target_key not in alignment_patch: alignment_patch[target_key] = normalized.pop(source_key) if alignment_patch: normalized["alignment"] = alignment_patch fill_patch = ( dict(normalized["fill"]) if isinstance(normalized.get("fill"), dict) else {} ) for source_key in ( "fillColor", "fill_color", "backgroundColor", "background_color", ): if source_key in normalized and "color" not in fill_patch: fill_patch["color"] = normalized.pop(source_key) if "fillOpacity" in normalized and "opacity" not in fill_patch: fill_patch["opacity"] = normalized.pop("fillOpacity") if "fill_opacity" in normalized and "opacity" not in fill_patch: fill_patch["opacity"] = normalized.pop("fill_opacity") if fill_patch: normalized["fill"] = fill_patch stroke_patch = ( dict(normalized["stroke"]) if isinstance(normalized.get("stroke"), dict) else {} ) for source_key in ("strokeColor", "stroke_color", "borderColor", "border_color"): if source_key in normalized and "color" not in stroke_patch: stroke_patch["color"] = normalized.pop(source_key) for source_key in ("strokeWidth", "stroke_width", "borderWidth", "border_width"): if source_key in normalized and "width" not in stroke_patch: stroke_patch["width"] = normalized.pop(source_key) if "strokeOpacity" in normalized and "opacity" not in stroke_patch: stroke_patch["opacity"] = normalized.pop("strokeOpacity") if "stroke_opacity" in normalized and "opacity" not in stroke_patch: stroke_patch["opacity"] = normalized.pop("stroke_opacity") if stroke_patch: normalized["stroke"] = stroke_patch return normalized class UpdateSlideComponentInput(OpenAIStrictSchemaModel): index: int = Field(..., ge=0, le=1000) component_id: str = Field(..., alias="componentId", min_length=1, max_length=120) position: SlideElementPositionInput | None = Field( ..., description="Optional component position update for move requests.", ) size: SlideElementSizeInput | None = Field( ..., description=( "Optional component size update for resize/shrink/grow requests. " "The component's contained elements scale with this size." ), ) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) class DeleteSlideComponentInput(StrictSchemaModel): index: int = Field(ge=0, le=1000) component_id: str = Field(alias="componentId", min_length=1, max_length=120) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) class DeleteSlideElementInput(StrictSchemaModel): index: int = Field(ge=0, le=1000) element_path: str = Field(alias="elementPath", min_length=1, max_length=500) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) class AddElementInput(OpenAIStrictSchemaModel): index: int = Field(..., ge=0, le=1000) element: str = Field( ..., min_length=2, max_length=120000, description=( "A JSON-serialized rendered UI element object. Use 1280 x 720 stage " "pixels and keep new free-component geometry fully inside that window." ), ) component_id: str | None = Field( ..., alias="componentId", min_length=1, max_length=120, description="Optional target component id. Use null to add as a new free component.", ) insert_index: int | None = Field( ..., alias="insertIndex", ge=0, le=1000, description="Zero-based insert position. Use null to append.", ) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) class AddSlideComponentInput(OpenAIStrictSchemaModel): index: int = Field(..., ge=0, le=1000) component: str = Field( ..., min_length=2, max_length=200000, description=( "A JSON-serialized component object to add to the slide: " '{"id": "...", "description": "...", "position": {"x": 128, "y": 120}, ' '"size": {"width": 1024, "height": 410}, "elements": [ ... ]}. ' "Use 1280 x 720 stage pixels, not normalized 0-1 values, and keep " "position/size fully inside that visible window. " "Copy the shape of an existing component from getAvailableBlocks or " "getSlideAtIndex(includeFullContent=true)." ), ) source_block_id: str | None = Field( ..., alias="sourceBlockId", min_length=1, max_length=300, description=( "Block id returned by getAvailableBlocks when this component is adapted " "from a reusable block. Required for table/chart component additions " "when a matching reusable block exists, and preferred for styled " "title/header/card/metric/text additions when a matching block exists." ), ) insert_index: int | None = Field( ..., alias="insertIndex", ge=0, le=1000, description="Zero-based position among components. Use null to append at the end.", ) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) class UpdateComponentInput(OpenAIStrictSchemaModel): index: int = Field(..., ge=0, le=1000) component_id: str = Field(..., alias="componentId", min_length=1, max_length=120) action: Literal[ "update", "group", "ungroup", "duplicate", "bring-to-front", "bring-forward", "send-backward", "send-to-back", "bringToFront", "bringForward", "sendBackward", "sendToBack", ] | None = Field( ..., description=( "Use update for move/resize/replace, group to combine components, " "ungroup to split one component, duplicate to copy a component, or " "a layer action to reorder it." ), ) component_ids: list[str] | None = Field( ..., alias="componentIds", min_length=2, max_length=20, description=( "Component ids to group. Include componentId in this list; ignored for " "update and ungroup." ), ) position: SlideElementPositionInput | None = Field( ..., description="Optional component position update for move requests.", ) size: SlideElementSizeInput | None = Field( ..., description=( "Optional component size update for resize/shrink/grow requests. " "The component's contained elements scale with this size." ), ) component: str | None = Field( ..., min_length=2, max_length=200000, description="Optional JSON-serialized replacement component.", ) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) class ThemeTextFontInput(OpenAIStrictSchemaModel): name: str | None = Field(..., min_length=1, max_length=200) url: str | None = Field(..., min_length=1, max_length=2000) class ThemeFontsInput(OpenAIStrictSchemaModel): textFont: ThemeTextFontInput | None = Field(...) class ThemeColorsInput(OpenAIStrictSchemaModel): primary: str | None = Field(..., min_length=4, max_length=16) background: str | None = Field(..., min_length=4, max_length=16) card: str | None = Field(..., min_length=4, max_length=16) stroke: str | None = Field(..., min_length=4, max_length=16) primary_text: str | None = Field(..., min_length=4, max_length=16) background_text: str | None = Field(..., min_length=4, max_length=16) graph_0: str | None = Field(..., min_length=4, max_length=16) graph_1: str | None = Field(..., min_length=4, max_length=16) graph_2: str | None = Field(..., min_length=4, max_length=16) graph_3: str | None = Field(..., min_length=4, max_length=16) graph_4: str | None = Field(..., min_length=4, max_length=16) graph_5: str | None = Field(..., min_length=4, max_length=16) graph_6: str | None = Field(..., min_length=4, max_length=16) graph_7: str | None = Field(..., min_length=4, max_length=16) graph_8: str | None = Field(..., min_length=4, max_length=16) graph_9: str | None = Field(..., min_length=4, max_length=16) class CustomThemeDataInput(OpenAIStrictSchemaModel): name: str | None = Field(..., min_length=1, max_length=200) description: str | None = Field(..., min_length=1, max_length=1000) colors: ThemeColorsInput | None = Field(...) fonts: ThemeFontsInput | None = Field(...) textFont: ThemeTextFontInput | None = Field(...) class CustomThemeInput(OpenAIStrictSchemaModel): id: str | None = Field(..., min_length=1, max_length=200) name: str | None = Field(..., min_length=1, max_length=200) description: str | None = Field(..., min_length=1, max_length=1000) user: str | None = Field(..., min_length=1, max_length=100) logo: str | None = Field(..., min_length=1, max_length=500) logo_url: str | None = Field( ..., alias="logoUrl", min_length=1, max_length=2000, ) company_name: str | None = Field( ..., alias="companyName", min_length=1, max_length=200, ) data: CustomThemeDataInput | None = Field(...) colors: ThemeColorsInput | None = Field(...) fonts: ThemeFontsInput | None = Field(...) textFont: ThemeTextFontInput | None = Field(...) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) class SetPresentationThemeInput(OpenAIStrictSchemaModel): theme: str | None = Field( ..., min_length=1, max_length=200, description=( "Theme target requested by the user (e.g., 'dark', " "'professional-dark', 'light rose', or 'another'). Optional " "when customTheme is provided." ), ) custom_theme: CustomThemeInput | None = Field( ..., alias="customTheme", description=( "Optional custom theme payload. Supports minimal colors/fonts payloads or " "a full theme object using only declared keys such as id, name, description, " "data.colors, and data.fonts.textFont." ), ) save_custom_theme: bool | None = Field( ..., alias="saveCustomTheme", description=( "When customTheme is provided, persist it into local custom themes for reuse." ), ) model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True) @model_validator(mode="after") def validate_theme_request(self) -> "SetPresentationThemeInput": if self.save_custom_theme is None: object.__setattr__(self, "save_custom_theme", True) if self.theme is None and self.custom_theme is None: raise ValueError("Either 'theme' or 'customTheme' must be provided.") return self