diff --git a/examples/v3_reference_seller/src/platform.py b/examples/v3_reference_seller/src/platform.py index ed4c31501..5b14396ad 100644 --- a/examples/v3_reference_seller/src/platform.py +++ b/examples/v3_reference_seller/src/platform.py @@ -142,9 +142,10 @@ class V3ReferenceSeller(DecisioningPlatform, SalesPlatform): """ capabilities = DecisioningCapabilities( - specialisms=("sales-non-guaranteed",), - channels=("display", "video"), - pricing_models=("cpm",), + specialisms=["sales-non-guaranteed"], + channels=["display", "video"], + pricing_models=["cpm"], + supported_billing=["operator"], ) def __init__(self, *, sessionmaker: async_sessionmaker) -> None: diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index 9751deec6..7d5fceed9 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -112,6 +112,16 @@ } ) +#: Sales specialisms — the subset of :data:`SPEC_SPECIALISM_ENUM` whose +#: wire contracts include a media-buy billing relationship. Used by +#: :func:`validate_platform` to warn when ``capabilities.supported_billing`` +#: is empty: the AdCP spec requires ``account.supported_billing`` in the +#: ``get_adcp_capabilities`` response whenever a seller declares ``media_buy`` +#: support, and sales-* claims are the billing-relevant surface. +SALES_SPECIALISMS: frozenset[str] = frozenset( + s for s in SPEC_SPECIALISM_ENUM if s.startswith("sales-") +) + # --------------------------------------------------------------------------- # REQUIRED_METHODS_PER_SPECIALISM — what each specialism must implement @@ -630,6 +640,29 @@ def validate_platform(platform: DecisioningPlatform) -> None: }, ) + # supported_billing advisory (sales-* adopters). + # The AdCP spec requires account.supported_billing in the + # get_adcp_capabilities response for any seller declaring media_buy. + # Enforce via UserWarning now; a future minor will promote to AdcpError + # after adopters have had a deprecation window. + sales_specialisms_claimed = [ + s for s in platform.capabilities.specialisms if s in SALES_SPECIALISMS + ] + if sales_specialisms_claimed and not platform.capabilities.supported_billing: + warnings.warn( + ( + f"DecisioningPlatform claims sales-* specialism(s) " + f"{sorted(sales_specialisms_claimed)!r} but " + "capabilities.supported_billing is empty. The AdCP spec requires " + "account.supported_billing in the get_adcp_capabilities response " + "for media_buy sellers. Add supported_billing=[...] to your " + "DecisioningCapabilities — valid values: 'operator', 'agent', " + "'advertiser'. This will become a hard fail in a future minor." + ), + UserWarning, + stacklevel=2, + ) + def _has_overridden_method(platform: DecisioningPlatform, method_name: str) -> bool: """True when the platform subclass provides ``method_name``. @@ -1145,6 +1178,7 @@ async def _project_workflow_handoff( __all__ = [ "REQUIRED_METHODS_PER_SPECIALISM", + "SALES_SPECIALISMS", "SPEC_SPECIALISM_ENUM", "compose_caller_identity", "validate_platform", diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 2b488dce1..e65b85830 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -77,6 +77,7 @@ DeleteCollectionListResponse, DeletePropertyListRequest, DeletePropertyListResponse, + GetAdcpCapabilitiesRequest, GetBrandIdentityRequest, GetBrandIdentitySuccessResponse, GetCollectionListRequest, @@ -311,6 +312,48 @@ } +#: Maps each spec specialism slug to the ``supported_protocols`` value it +#: contributes to the ``get_adcp_capabilities`` response. Used by the +#: auto-generated :meth:`PlatformHandler.get_adcp_capabilities` shim so +#: adopters don't have to manually derive which protocol domains their +#: specialisms cover. +#: +#: Meta-claims (``signed-requests``, ``governance-aware-seller``) and +#: sub-feature claims (``audience-sync``) are excluded — they don't +#: directly introduce a standalone protocol domain. ``audience-sync`` gets +#: ``media_buy`` from the co-claimed sales-* specialism; advertising +#: ``media_buy`` without sales tools would be a buyer-visible lie. +SPECIALISM_TO_PROTOCOL: dict[str, str] = { + # Sales archetypes → media_buy + "sales-non-guaranteed": "media_buy", + "sales-guaranteed": "media_buy", + "sales-broadcast-tv": "media_buy", + "sales-social": "media_buy", + "sales-proposal-mode": "media_buy", + "sales-catalog-driven": "media_buy", + # Signals + "signal-marketplace": "signals", + "signal-owned": "signals", + # audience-sync intentionally omitted: it's a sub-feature of media_buy, + # not a standalone protocol domain. A co-claimed sales-* specialism + # drives the media_buy declaration; advertising media_buy without + # sales tools would be a buyer-visible lie. + # Creative + "creative-template": "creative", + "creative-generative": "creative", + "creative-ad-server": "creative", + # Governance agents + "governance-spend-authority": "governance", + "governance-delivery-monitor": "governance", + # Brand rights + "brand-rights": "brand", + # Content / list governance features + "content-standards": "governance", + "property-lists": "governance", + "collection-lists": "governance", +} + + async def _resolve_buyer_agent( registry: BuyerAgentRegistry, auth_info: AuthInfo | None, @@ -752,6 +795,49 @@ def _build_ctx( buyer_agent=buyer_agent, ) + # ----- Protocol discovery ----- + + async def get_adcp_capabilities( + self, + params: GetAdcpCapabilitiesRequest | dict[str, Any], + context: ToolContext | None = None, + ) -> dict[str, Any]: + """Auto-generate capabilities from :class:`DecisioningCapabilities`. + + Derives ``supported_protocols`` from the platform's claimed + specialisms via :data:`SPECIALISM_TO_PROTOCOL` and projects + ``account.supported_billing`` from + :attr:`~adcp.decisioning.DecisioningCapabilities.supported_billing` + when non-empty. Adopters who need to advertise additional + capability details (pricing models, media-buy features, idempotency + declaration, etc.) should override this method and call + :func:`adcp.server.responses.capabilities_response` directly. + """ + from adcp.server.responses import capabilities_response + + caps = self._platform.capabilities + + # Derive supported_protocols from claimed specialisms. + protocols: set[str] = set() + for specialism in caps.specialisms: + proto = SPECIALISM_TO_PROTOCOL.get(specialism) + if proto is not None: + protocols.add(proto) + + # Project account.supported_billing when declared. + account: dict[str, Any] | None = None + if caps.supported_billing: + account = {"supported_billing": list(caps.supported_billing)} + + # The spec requires adcp.idempotency. Default to unsupported — adopters + # with replay protection should override this method and pass + # idempotency=store.capability() from their IdempotencyStore. + return capabilities_response( + sorted(protocols), + account=account, + idempotency={"supported": False}, + ) + # ----- Sales tools ----- async def get_products( # type: ignore[override] diff --git a/src/adcp/decisioning/platform.py b/src/adcp/decisioning/platform.py index 5cc716049..8e2e234af 100644 --- a/src/adcp/decisioning/platform.py +++ b/src/adcp/decisioning/platform.py @@ -38,6 +38,14 @@ class DecisioningCapabilities: :param pricing_models: Pricing models the platform supports — ``'cpm'``, ``'cpc'``, ``'cpa'``, ``'cpcv'``. Surfaced on capabilities. + :param supported_billing: Billing models this seller supports. + Required by the AdCP spec when declaring ``media_buy`` support. + Valid values: ``'operator'`` (seller invoices the operator), + ``'agent'`` (agent consolidates billing), ``'advertiser'`` + (seller invoices the advertiser directly). Sales-* adopters + MUST declare at least one. A :class:`UserWarning` fires at + server boot when a sales-* specialism is claimed but this is + empty; a future minor will make it a hard fail. :param creative_agents: Optional list of creative-agent endpoints the platform delegates creative review/generation to. Empty list means "no creative-agent integration; review is in-house." @@ -64,6 +72,11 @@ class DecisioningCapabilities: specialisms: list[str] = field(default_factory=list) channels: list[str] = field(default_factory=list) pricing_models: list[str] = field(default_factory=list) + # Billing models this seller supports. Required by the AdCP spec + # (get-adcp-capabilities-response.json account.supported_billing, + # required: ["supported_billing"]) whenever a seller claims a sales-* + # specialism. Valid values: "operator", "agent", "advertiser". + supported_billing: list[str] = field(default_factory=list) creative_agents: list[Any] = field(default_factory=list) config: dict[str, Any] = field(default_factory=dict) governance_aware: bool = False diff --git a/src/adcp/server/responses.py b/src/adcp/server/responses.py index 765c1022e..ffffb8668 100644 --- a/src/adcp/server/responses.py +++ b/src/adcp/server/responses.py @@ -55,6 +55,7 @@ def capabilities_response( features: dict[str, Any] | None = None, idempotency: dict[str, Any] | None = None, compliance_testing: dict[str, Any] | None = None, + account: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build a get_adcp_capabilities response. @@ -89,6 +90,15 @@ def capabilities_response( compliance_testing: Optional top-level ``compliance_testing`` block to advertise compliance-testing capabilities. When provided, emitted as a sibling of ``adcp`` in the response. + account: Optional ``account`` capabilities block. When the seller + supports ``media_buy``, the AdCP spec requires this object and + its ``supported_billing`` field (values: ``"operator"``, + ``"agent"``, ``"advertiser"``). Pass + ``{"supported_billing": ["operator"]}`` (or the appropriate + billing model(s)) for any media-buy–capable server. The + :class:`~adcp.decisioning.DecisioningPlatform` framework + auto-populates this from + :attr:`~adcp.decisioning.DecisioningCapabilities.supported_billing`. Example:: @@ -129,6 +139,8 @@ def capabilities_response( resp["features"] = features if compliance_testing is not None: resp["compliance_testing"] = compliance_testing + if account is not None: + resp["account"] = account return resp diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index 5725700c4..ec92207ee 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -50,7 +50,10 @@ def executor(): class _ValidPlatform(DecisioningPlatform): - capabilities = DecisioningCapabilities(specialisms=["sales-non-guaranteed"]) + capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + supported_billing=["operator"], + ) accounts = SingletonAccounts(account_id="hello") def get_products(self, req, ctx): @@ -233,6 +236,62 @@ class _NoClaimsPlatform(DecisioningPlatform): validate_platform(_NoClaimsPlatform()) +def test_validate_platform_warns_when_sales_specialism_missing_supported_billing() -> None: + """Sales-* platform without supported_billing emits UserWarning. + + The AdCP spec requires account.supported_billing in the + get_adcp_capabilities response for media_buy sellers. The framework + warns at server boot so adopters discover the gap before shipping.""" + + class _SalesWithoutBilling(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["sales-non-guaranteed"]) + accounts = SingletonAccounts(account_id="hello") + + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {"media_buy_id": "mb_1"} + + def update_media_buy(self, media_buy_id, patch, ctx): + return {"media_buy_id": media_buy_id, "status": "active"} + + def sync_creatives(self, req, ctx): + return {"creatives": []} + + def get_media_buy_delivery(self, req, ctx): + return {"deliveries": []} + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", UserWarning) + validate_platform(_SalesWithoutBilling()) + billing_warnings = [w for w in caught if "supported_billing" in str(w.message)] + assert len(billing_warnings) == 1 + assert "media_buy" in str(billing_warnings[0].message) + + +def test_validate_platform_no_billing_warning_for_non_sales_specialisms() -> None: + """Signals-only platform does not emit the supported_billing warning + — billing is only required for sales-* (media_buy) sellers.""" + from adcp.decisioning.dispatch import validate_platform as _vp + + class _SignalsPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["signal-marketplace"]) + accounts = SingletonAccounts(account_id="hello") + + def get_signals(self, req, ctx): + return {"signals": []} + + def activate_signal(self, req, ctx): + return {"deployments": []} + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", UserWarning) + _vp(_SignalsPlatform()) + billing_warnings = [w for w in caught if "supported_billing" in str(w.message)] + assert not billing_warnings, "supported_billing warning must not fire for non-sales platforms" + + def test_required_methods_per_specialism_pinned_for_sales() -> None: """Contract test — locks the sales core method set so future spec churn surfaces as a visible test failure. Slugs covered are diff --git a/tests/test_decisioning_handler.py b/tests/test_decisioning_handler.py index 1edd15d08..eddbbde96 100644 --- a/tests/test_decisioning_handler.py +++ b/tests/test_decisioning_handler.py @@ -401,3 +401,116 @@ async def get_products(self, req, ctx): ctx, ) assert received_kind == ["bearer"] + + +# ---- get_adcp_capabilities shim ---- + + +@pytest.mark.asyncio +async def test_get_adcp_capabilities_projects_supported_billing(executor) -> None: + """PlatformHandler.get_adcp_capabilities auto-generates the response + from DecisioningCapabilities, including account.supported_billing.""" + + class _SalesPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + supported_billing=["operator"], + ) + accounts = SingletonAccounts(account_id="acme") + + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {"media_buy_id": "mb_1"} + + def update_media_buy(self, media_buy_id, patch, ctx): + return {"media_buy_id": media_buy_id, "status": "active"} + + def sync_creatives(self, req, ctx): + return {"creatives": []} + + def get_media_buy_delivery(self, req, ctx): + return {"deliveries": []} + + handler = _make_handler(_SalesPlatform(), executor) + resp = await handler.get_adcp_capabilities({}, ToolContext()) + assert isinstance(resp, dict) + assert "media_buy" in resp.get("supported_protocols", []) + assert resp.get("account", {}).get("supported_billing") == ["operator"] + + +@pytest.mark.asyncio +async def test_get_adcp_capabilities_omits_account_when_billing_empty(executor) -> None: + """When supported_billing is not declared, account block is omitted + (not emitted as account: {supported_billing: []}, which would fail + the schema's minItems: 1 constraint).""" + + class _NoBillingPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["signal-marketplace"]) + accounts = SingletonAccounts(account_id="acme") + + def get_signals(self, req, ctx): + return {"signals": []} + + def activate_signal(self, req, ctx): + return {"deployments": []} + + handler = _make_handler(_NoBillingPlatform(), executor) + resp = await handler.get_adcp_capabilities({}, ToolContext()) + assert isinstance(resp, dict) + assert "account" not in resp + + +@pytest.mark.asyncio +async def test_get_adcp_capabilities_response_is_schema_valid(executor) -> None: + """The auto-generated capabilities response passes JSON Schema + validation against the bundled get-adcp-capabilities-response.json + schema (schemas/cache/protocol/get-adcp-capabilities-response.json).""" + import json + import pathlib + + import jsonschema + + schema_path = ( + pathlib.Path(__file__).parent.parent + / "schemas" + / "cache" + / "protocol" + / "get-adcp-capabilities-response.json" + ) + with open(schema_path) as f: + schema = json.load(f) + + class _SalesPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + supported_billing=["operator"], + ) + accounts = SingletonAccounts(account_id="acme") + + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {"media_buy_id": "mb_1"} + + def update_media_buy(self, media_buy_id, patch, ctx): + return {"media_buy_id": media_buy_id, "status": "active"} + + def sync_creatives(self, req, ctx): + return {"creatives": []} + + def get_media_buy_delivery(self, req, ctx): + return {"deliveries": []} + + handler = _make_handler(_SalesPlatform(), executor) + resp = await handler.get_adcp_capabilities({}, ToolContext()) + + # Schema uses $ref relative paths — resolve against the schemas/cache dir + # so jsonschema can dereference cross-file $refs. + resolver = jsonschema.RefResolver( + base_uri=schema_path.parent.as_uri() + "/", + referrer=schema, + ) + jsonschema.validate(resp, schema, resolver=resolver) diff --git a/tests/test_decisioning_specialisms.py b/tests/test_decisioning_specialisms.py index 094bb6c6b..9fbda953c 100644 --- a/tests/test_decisioning_specialisms.py +++ b/tests/test_decisioning_specialisms.py @@ -238,7 +238,8 @@ def test_signals_platform_can_compose_with_sales() -> None: class _ComposedPlatform(DecisioningPlatform): capabilities = DecisioningCapabilities( - specialisms=["sales-non-guaranteed", "signal-marketplace"] + specialisms=["sales-non-guaranteed", "signal-marketplace"], + supported_billing=["operator"], ) accounts = SingletonAccounts(account_id="hello")