@@ -2471,3 +2471,138 @@ class KeywordBidUpdateResult(BaseModel):
24712471 not_implemented : list [str ] = Field (default_factory = list )
24722472 yandex_units : int | None = None
24732473 yandex_error : str | None = None
2474+
2475+
2476+ # ---------------------------------------------------------------------------
2477+ # Bid modifiers update — semantic preview + Direct v5 set payload
2478+ # ---------------------------------------------------------------------------
2479+
2480+
2481+ class BidModifierAgeAdjustment (BaseModel ):
2482+ """Age/demographic bid modifier adjustment for a safe write preview.
2483+
2484+ Yandex Direct v5 ``bidmodifiers.set`` updates an existing modifier by
2485+ ``Id`` and ``BidModifier``. DirectPilot keeps the human-facing request in
2486+ adjustment-percent form (``-100`` means exclude the segment) and converts it
2487+ to the Direct coefficient where ``BidModifier = 100 + adjustment_percent``.
2488+ Therefore ``-100`` becomes Direct ``BidModifier=0``.
2489+ """
2490+
2491+ modifier_id : int | None = Field (
2492+ default = None ,
2493+ ge = 1 ,
2494+ description = (
2495+ "Existing Yandex Direct bid modifier Id. Required for live apply; "
2496+ "dry-run previews may omit it until readback resolves the modifier."
2497+ ),
2498+ )
2499+ age_range : Literal ["AGE_0_17" ] = Field (
2500+ default = "AGE_0_17" ,
2501+ description = "Under-18 age segment used for demographic exclusion previews." ,
2502+ )
2503+ adjustment_percent : int = Field (
2504+ ...,
2505+ ge = - 100 ,
2506+ le = 1200 ,
2507+ description = (
2508+ "Human-facing adjustment percent. Direct v5 uses BidModifier as a "
2509+ "coefficient in percent, so this value is converted with "
2510+ "BidModifier = 100 + adjustment_percent."
2511+ ),
2512+ )
2513+
2514+ @property
2515+ def direct_bid_modifier (self ) -> int :
2516+ return 100 + self .adjustment_percent
2517+
2518+ def to_preview_item (self , campaign_id : int | str ) -> dict :
2519+ item : dict = {
2520+ "CampaignId" : int (campaign_id ),
2521+ "AgeRange" : self .age_range ,
2522+ "AdjustmentPercent" : self .adjustment_percent ,
2523+ "BidModifier" : self .direct_bid_modifier ,
2524+ }
2525+ if self .modifier_id is not None :
2526+ item ["Id" ] = self .modifier_id
2527+ return item
2528+
2529+ def to_direct_set_item (self ) -> dict :
2530+ if self .modifier_id is None :
2531+ raise ValueError ("modifier_id is required to build bidmodifiers.set payload" )
2532+ return {"Id" : self .modifier_id , "BidModifier" : self .direct_bid_modifier }
2533+
2534+
2535+ class BidModifiersUpdateRequest (BaseModel ):
2536+ """Body model for a live-safe bid-modifier update endpoint.
2537+
2538+ ``dry_run=True`` is preview-only. A real ``bidmodifiers.set`` apply must be
2539+ gated at the store/route layer by ``DIRECTPILOT_MODE=live_write``,
2540+ ``approved=True``, valid ``idempotency_key``, and ``dry_run=False``.
2541+ """
2542+
2543+ dry_run : bool = True
2544+ approved : bool = False
2545+ idempotency_key : str | None = Field (default = None , min_length = 6 )
2546+ adjustments : list [BidModifierAgeAdjustment ] = Field (..., min_length = 1 , max_length = 1000 )
2547+ reason : str | None = None
2548+
2549+ def build_payload_preview (self , campaign_id : int | str ) -> dict :
2550+ return {
2551+ "BidModifiers" : [
2552+ adjustment .to_preview_item (campaign_id ) for adjustment in self .adjustments
2553+ ]
2554+ }
2555+
2556+ def build_direct_set_payload (self ) -> dict :
2557+ return {
2558+ "BidModifiers" : [
2559+ adjustment .to_direct_set_item () for adjustment in self .adjustments
2560+ ]
2561+ }
2562+
2563+
2564+ class BidModifierSetItemResult (BaseModel ):
2565+ """Per-item outcome from Direct v5 ``bidmodifiers.set`` ``SetResults``.
2566+
2567+ Only redacted provider fields are surfaced. ``has_errors=True`` means
2568+ this item was not safely accepted as applied; the aggregate response must
2569+ use ``applied=False`` and ``partial_failure=True``.
2570+ """
2571+
2572+ modifier_id : int
2573+ has_errors : bool = False
2574+ has_warnings : bool = False
2575+ errors : list ["ProviderWarning" ] = Field (default_factory = list )
2576+ warnings : list ["ProviderWarning" ] = Field (default_factory = list )
2577+
2578+
2579+ class BidModifiersUpdateResult (BaseModel ):
2580+ """Response for ``POST /yandex/campaigns/{campaign_id}/bid-modifiers``."""
2581+
2582+ campaign_id : str
2583+ mode : str
2584+ dry_run : bool
2585+ applied : bool
2586+ source : Literal ["mock" , "yandex" ] = "yandex"
2587+ read_only : bool = False
2588+ audit_id : str
2589+ payload_preview : dict | None = None
2590+ readback : list [dict ] | None = None
2591+ provider_warnings : list ["ProviderWarning" ] = Field (default_factory = list )
2592+ set_results : list ["BidModifierSetItemResult" ] | None = Field (
2593+ default = None ,
2594+ description = (
2595+ "Per-item outcomes from the v5 ``SetResults`` envelope. "
2596+ "Populated on live apply when Direct returns item-level results."
2597+ ),
2598+ )
2599+ partial_failure : bool = Field (
2600+ default = False ,
2601+ description = (
2602+ "True when the top-level v5 call succeeded but at least one "
2603+ "``SetResults`` item contains provider errors. In this case "
2604+ "``applied`` is false."
2605+ ),
2606+ )
2607+ yandex_units : int | None = None
2608+ yandex_error : str | None = None
0 commit comments