ADCP types represent the standardized protocol schema. However, your implementation may need additional internal tracking fields (workflow IDs, timestamps, metadata, etc.) that shouldn't be sent over the wire.
This guide shows how to extend ADCP types safely while maintaining protocol compliance.
Pydantic v2 serialization note: Pydantic v2 uses a Rust-backed serializer that serializes nested child instances using the declared schema of the parent's field, not the child's
model_dump()override.AdCPBaseModel.model_dump()andmodel_dump_json()setserialize_as_any=Trueby default so that subclass@model_serializeroverrides do fire through base-typed parent fields, andField(exclude=True)keeps internal fields off the wire at every nesting depth. Adopters do not need to write parent-sidemodel_dumpoverrides to walk children — Pydantic does the walking; this guide covers the two seams (Field(exclude=True)and@model_serializer) that hook into it.
The simplest and most reliable way to keep internal fields off the wire. Fields annotated with
Field(exclude=True) are excluded by Pydantic's own serializer at every nesting depth — no
call-site exclude={} plumbing, no parent-model override required.
from typing import Any
from pydantic import Field
from adcp import Creative
from adcp.types.base import AdCPBaseModel
class InternalCreative(Creative):
"""Creative extended with seller-internal fields."""
internal_approval_id: str | None = Field(default=None, exclude=True)
seller_notes: str | None = Field(default=None, exclude=True)
class CreativePayload(AdCPBaseModel):
"""User-defined payload — creatives declared as base type."""
creatives: list[Creative]
resp = CreativePayload(
creatives=[
InternalCreative(
creative_id="c-1",
variants=[],
internal_approval_id="approv-42",
seller_notes="approved by legal",
)
]
)
wire = resp.model_dump()
# {"creatives": [{"creative_id": "c-1", "variants": []}]}
# internal_approval_id and seller_notes are absent — no parent override needed.Field(exclude=True) works with model_dump(), model_dump_json(), and all standard Pydantic
serialization options including exclude_none=True.
For cases where you need Python-level transformation logic beyond field exclusion — reshaping
output, conditional inclusion, derived computed fields — use Pydantic's
@model_serializer(mode='wrap').
When the parent extends AdCPBaseModel (which all SDK-generated response types do), the
parent's model_dump() defaults serialize_as_any=True, so subclass @model_serializer
overrides fire automatically through base-typed parent fields. No call-site kwarg is
required.
from typing import Any
from pydantic import SerializationInfo, model_serializer
from adcp import Creative
from adcp.types.base import AdCPBaseModel
class InternalCreative(Creative):
"""Creative with a normalized source_label field."""
source_label: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: Any, info: SerializationInfo) -> dict[str, Any]:
result = handler(self, info)
# Normalize source_label to lowercase before it hits the wire.
if result.get("source_label"):
result["source_label"] = result["source_label"].lower()
return result
# Direct serialization: serializer fires.
c = InternalCreative(creative_id="c-1", variants=[], source_label="HD_VIDEO")
c.model_dump() # {"creative_id": "c-1", "variants": [], "source_label": "hd_video"}
# Nested under an AdCPBaseModel parent with a base-type annotation:
class CreativePayload(AdCPBaseModel):
creatives: list[Creative] # declared as base type
payload = CreativePayload(creatives=[c])
payload.model_dump()
# {"creatives": [{"creative_id": "c-1", "variants": [], "source_label": "hd_video"}]}
# Subclass serializer fired automatically — AdCPBaseModel.model_dump() defaults
# serialize_as_any=True. Pass serialize_as_any=False explicitly to suppress it.If your parent extends plain pydantic.BaseModel (not AdCPBaseModel), you must pass
serialize_as_any=True yourself — the default kwarg only ships on AdCP types.
A common pattern in early SDK integrations is writing a parent override that manually re-calls
model_dump() on each child list:
# ❌ Fragile: every new response type needs this boilerplate, and missing one is silent.
class MyCreativePayload(AdCPBaseModel):
creatives: list[Creative]
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
result = super().model_dump(**kwargs)
if "creatives" in result and self.creatives:
result["creatives"] = [c.model_dump(**kwargs) for c in self.creatives]
return resultThis requires ~10 lines per response type, must be written for every parent, and silently produces wrong output if a new child list field is added without updating the override.
Migration — field exclusion only:
# ✅ Delete the parent override entirely. Move exclusion to the child via Field(exclude=True).
class InternalCreative(Creative):
internal_approval_id: str | None = Field(default=None, exclude=True)
# MyCreativePayload needs no model_dump() override — Pydantic handles it at all depths.Migration — custom Python logic:
# ✅ Move the logic to the child via @model_serializer.
# AdCPBaseModel parents default serialize_as_any=True so the subclass serializer
# fires automatically — no call-site kwarg needed.
class InternalCreative(Creative):
@model_serializer(mode="wrap")
def _serialize(self, handler: Any, info: SerializationInfo) -> dict[str, Any]:
result = handler(self, info)
# ... custom logic here ...
return result
# Parent: no model_dump() override needed.
payload = MyCreativePayload(creatives=[InternalCreative(creative_id="c-1", variants=[])])
wire = payload.model_dump()Adopter migration note (serialize_as_any default flip): If you have subclasses that
add fields without Field(exclude=True), those fields previously dropped at the
wire because the parent's base-type annotation acted as an accidental firewall. They will
now appear in model_dump() output. Audit each subclass and mark internal fields with
Field(exclude=True); the field is the canonical wire-isolation contract. If you need the
prior behavior at a specific call site, pass serialize_as_any=False explicitly.
from adcp import CreateMediaBuySuccessResponse
from pydantic import ConfigDict, Field
class CreateMediaBuySuccessExtended(CreateMediaBuySuccessResponse):
"""Extended with internal tracking fields."""
workflow_step_id: str | None = Field(None, description="Internal workflow step ID")
created_at: str | None = Field(None, description="Internal timestamp")
internal_notes: str | None = Field(None, description="Internal notes")
model_config = ConfigDict(extra='allow') # Allow extra fields
# Create extended response internally
internal_response = CreateMediaBuySuccessExtended(
# ADCP required fields
media_buy_id="mb_123",
buyer_ref="ref_456",
packages=[],
# Internal fields
workflow_step_id="ws_789",
created_at="2024-01-15T10:30:00Z",
internal_notes="First attempt"
)
# Serialize to ADCP spec before sending over wire
adcp_response = CreateMediaBuySuccessResponse.model_validate(
internal_response.model_dump(exclude={'workflow_step_id', 'created_at', 'internal_notes'})
)For consistent internal fields across all response types:
from typing import TypeVar, Generic
from pydantic import BaseModel, Field, ConfigDict
T = TypeVar('T', bound=BaseModel)
class InternalResponseWrapper(BaseModel, Generic[T]):
"""Wrapper for ADCP responses with internal tracking fields."""
response: T
workflow_step_id: str | None = None
internal_request_id: str | None = None
processing_time_ms: int | None = None
created_at: str | None = None
model_config = ConfigDict(extra='allow')
# Usage
from adcp import CreateMediaBuySuccessResponse
wrapper = InternalResponseWrapper[CreateMediaBuySuccessResponse](
response=CreateMediaBuySuccessResponse(
media_buy_id="mb_123",
buyer_ref="ref_456",
packages=[]
),
workflow_step_id="ws_789",
processing_time_ms=1234
)
# Access ADCP response
adcp_response = wrapper.response # Type: CreateMediaBuySuccessResponse
# Access internal fields
workflow_id = wrapper.workflow_step_idWhen storing responses in a database with internal metadata:
from datetime import datetime
from adcp import CreateMediaBuySuccessResponse
class MediaBuyRecord(BaseModel):
"""Database record combining ADCP response with internal metadata."""
# Internal database fields
id: int
created_at: datetime
updated_at: datetime
user_id: str
workflow_step_id: str
# ADCP response (stored as JSON)
response_data: CreateMediaBuySuccessResponse
@classmethod
def from_response(
cls,
response: CreateMediaBuySuccessResponse,
user_id: str,
workflow_step_id: str
) -> "MediaBuyRecord":
"""Create database record from ADCP response."""
return cls(
id=0, # Database will assign
created_at=datetime.now(),
updated_at=datetime.now(),
user_id=user_id,
workflow_step_id=workflow_step_id,
response_data=response
)
def to_adcp_response(self) -> CreateMediaBuySuccessResponse:
"""Extract ADCP response for wire protocol."""
return self.response_data
# Usage
response = await client.create_media_buy(request)
if isinstance(response, CreateMediaBuySuccessResponse):
record = MediaBuyRecord.from_response(
response,
user_id="user_123",
workflow_step_id="ws_789"
)
# Save to database...
# Later, send to another agent
adcp_response = record.to_adcp_response()When processing webhook payloads with internal routing metadata:
from adcp import McpMcpWebhookPayload
from pydantic import ConfigDict
class InternalWebhookPayload(McpWebhookPayload):
"""Extended webhook payload with internal routing."""
internal_destination: str | None = None
retry_count: int = 0
routing_key: str | None = None
model_config = ConfigDict(extra='allow')
async def process_webhook(payload: dict) -> None:
"""Process webhook with internal tracking."""
# Parse with extensions
internal_payload = InternalMcpWebhookPayload.model_validate(payload)
# Add internal routing
internal_payload.internal_destination = determine_destination(internal_payload)
internal_payload.routing_key = f"mediabuy.{internal_payload.task_type}"
# Route internally
await route_to_handler(internal_payload)
# When forwarding to another service, use base type
external_payload = McpWebhookPayload.model_validate(
internal_payload.model_dump(exclude={'internal_destination', 'retry_count', 'routing_key'})
)When adding internal context to outgoing requests:
from adcp import CreateMediaBuyRequest
from pydantic import ConfigDict
class CreateMediaBuyRequestInternal(CreateMediaBuyRequest):
"""Extended request with internal context."""
requesting_user_id: str | None = None
request_source: str | None = None
idempotency_key: str | None = None
model_config = ConfigDict(extra='allow')
def to_adcp_request(self) -> CreateMediaBuyRequest:
"""Convert to wire-protocol request."""
return CreateMediaBuyRequest.model_validate(
self.model_dump(exclude={
'requesting_user_id',
'request_source',
'idempotency_key'
})
)
# Usage
internal_request = CreateMediaBuyRequestInternal(
# ADCP fields
buyer_ref="ref_456",
targeting=targeting,
packages=[package],
# Internal fields
requesting_user_id="user_123",
request_source="api",
idempotency_key="req_xyz"
)
# Send to ADCP agent (internal fields stripped)
response = await client.create_media_buy(internal_request.to_adcp_request())Prefer Field(exclude=True) over call-site model_dump(exclude={...}). Field(exclude=True) is declared once on the field, works at every nesting depth automatically, and cannot be forgotten at a call site.
from adcp import CreateMediaBuySuccessResponse
from pydantic import Field
# ❌ BAD: Relying on field name conventions
class Extended(CreateMediaBuySuccessResponse):
_internal_id: str # Private field — may or may not serialize correctly
# ⚠ OK but fragile: call-site exclusion must be repeated every time model_dump() is called
class Extended(CreateMediaBuySuccessResponse):
internal_id: str
adcp_response = CreateMediaBuySuccessResponse.model_validate(
extended.model_dump(exclude={"internal_id"}) # Easy to forget, silent if omitted
)
# ✅ BEST: Field-level exclusion fires automatically at all nesting depths
class Extended(CreateMediaBuySuccessResponse):
internal_id: str = Field(exclude=True)
adcp_response = extended.model_dump() # internal_id is absent — no extra plumbingMake it clear which fields are internal:
class Extended(CreateMediaBuySuccessResponse):
"""Extended CreateMediaBuySuccessResponse with internal tracking.
Internal fields (not part of ADCP spec):
workflow_step_id: Internal workflow tracking
created_at: Internal timestamp
"""
workflow_step_id: str | None = Field(None, description="Internal: workflow step ID")
created_at: str | None = Field(None, description="Internal: creation timestamp")Ensure internal fields don't leak to wire protocol:
def test_internal_fields_excluded():
extended = CreateMediaBuySuccessExtended(
media_buy_id="mb_123",
buyer_ref="ref_456",
packages=[],
workflow_step_id="ws_789" # Internal field
)
# Convert to wire protocol
adcp_response = CreateMediaBuySuccessResponse.model_validate(
extended.model_dump(exclude={'workflow_step_id'})
)
# Verify internal field not present
serialized = adcp_response.model_dump()
assert 'workflow_step_id' not in serialized
assert serialized['media_buy_id'] == "mb_123"from typing import TypeGuard
def is_extended_response(
response: CreateMediaBuySuccessResponse
) -> TypeGuard[CreateMediaBuySuccessExtended]:
"""Check if response has extended internal fields."""
return isinstance(response, CreateMediaBuySuccessExtended)
# Usage
if is_extended_response(response):
# Type checker knows response has workflow_step_id
log_workflow_step(response.workflow_step_id)Define reusable field sets for exclusion:
from typing import ClassVar
class CreateMediaBuySuccessExtended(CreateMediaBuySuccessResponse):
workflow_step_id: str | None = None
created_at: str | None = None
# Define internal fields as class variable
INTERNAL_FIELDS: ClassVar[set[str]] = {'workflow_step_id', 'created_at'}
def to_adcp_response(self) -> CreateMediaBuySuccessResponse:
"""Convert to wire protocol, excluding internal fields."""
return CreateMediaBuySuccessResponse.model_validate(
self.model_dump(exclude=self.INTERNAL_FIELDS)
)# ❌ BAD: Sending extended type directly
extended_response = CreateMediaBuySuccessExtended(...)
await send_to_agent(extended_response) # Internal fields leak!
# ✅ GOOD: Convert to base type first
extended_response = CreateMediaBuySuccessExtended(...)
adcp_response = extended_response.to_adcp_response()
await send_to_agent(adcp_response)# ❌ BAD: Python 3.9 syntax in 3.10+ codebase
from typing import Optional
workflow_step_id: Optional[str] = None
# ✅ GOOD: Use 3.10+ union syntax
workflow_step_id: str | None = NoneAlways test both extension and conversion back to base type:
def test_roundtrip():
# Extend ADCP type
extended = CreateMediaBuySuccessExtended(
media_buy_id="mb_123",
buyer_ref="ref_456",
packages=[],
workflow_step_id="ws_789"
)
# Convert to base type
base = CreateMediaBuySuccessResponse.model_validate(
extended.model_dump(exclude={'workflow_step_id'})
)
# Verify base type is valid ADCP
assert base.media_buy_id == "mb_123"
assert not hasattr(base, 'workflow_step_id')
# Verify can parse from wire format
wire_format = base.model_dump_json()
parsed = CreateMediaBuySuccessResponse.model_validate_json(wire_format)
assert parsed.media_buy_id == "mb_123"When extending ADCP types:
- Subclass the ADCP type with your internal fields
- Use
model_config = ConfigDict(extra='allow')if accepting dynamic fields - Always exclude internal fields when converting to wire protocol
- Document which fields are internal vs ADCP spec
- Test serialization roundtrips to ensure no leakage