Skip to content
7 changes: 7 additions & 0 deletions src/adcp/compat/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""AdCP wire-shape compatibility for buyers on older spec versions.

The framework natively validates against the SDK's pinned major (3.x).
Buyers on pre-3 wire shapes are handled by the per-tool adapter registry
in :mod:`adcp.compat.legacy` — see that module's docstring for the
``AdapterPair`` pattern and the JS-SDK parity notes.
"""
137 changes: 137 additions & 0 deletions src/adcp/compat/legacy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Per-tool adapters for buyers on legacy AdCP wire shapes.

The dispatcher consults :func:`get_legacy_adapter` when the buyer's
``adcp_version`` / ``adcp_major_version`` resolves into
:data:`LEGACY_ADAPTER_VERSIONS`. If an adapter is registered for the
``(version, tool)`` pair, the request is translated to the current
wire shape before validation + handler dispatch. If no adapter is
registered for that tool at that version, the dispatcher surfaces
``INVALID_REQUEST`` — the legacy version doesn't expose the tool.

Architecturally this replaces the heuristic
:func:`adcp.server.spec_compat.spec_compat_hooks` model. Each adapter
is hand-written (or, in time, codegen'd from declarative wire-delta
specs) and tested end-to-end so the translation is auditable rather
than implicit.

Adapters register themselves at import time via
:func:`register_adapter`. Importing :mod:`adcp.compat.legacy.v2_5`
populates the v2.5 registry; see that submodule's docstring for the
list of supported tools.

Mirrors ``src/lib/adapters/legacy/v2-5/`` in the TypeScript SDK
(``getV25Adapter`` / ``listV25AdapterTools``).
"""

from __future__ import annotations

from typing import Final

from adcp.compat.legacy.types import AdapterPair

#: Versions handled via the legacy-adapter path. Distinct from
#: ``COMPATIBLE_ADCP_VERSIONS`` in :mod:`adcp._version`, which lists the
#: versions the SDK natively validates against.
LEGACY_ADAPTER_VERSIONS: Final[tuple[str, ...]] = ("2.5",)

# Per-version adapter module list. Data, not control flow, so adding a
# tool to a version is a one-line append in this dict. Mapping is
# ``wire_version`` → ``(package_segment, (tool_module, ...))`` where
# ``package_segment`` is the Python-safe subpackage under
# ``adcp.compat.legacy`` (we use ``v2_5`` because Python identifiers
# can't start with a digit). ``_ensure_loaded`` imports each tool
# module and reads its top-level ``ADAPTER`` constant.
_VERSION_MODULES: Final[dict[str, tuple[str, tuple[str, ...]]]] = {
"2.5": ("v2_5", ("sync_creatives",)),
}


_REGISTRY: dict[tuple[str, str], AdapterPair] = {}


def register_adapter(version: str, adapter: AdapterPair) -> None:
"""Register an :class:`AdapterPair` under ``(version, tool_name)``.

Idempotent — re-registering the same pair (same callables) is a
no-op. Re-registering a *different* pair for the same key raises
:class:`ValueError`; tests should call :func:`_reset_registry_for_tests`
if they need to swap an adapter mid-suite.

Adapters self-register at module import time; the framework imports
:mod:`adcp.compat.legacy.v2_5` lazily on first dispatch so adopters
that don't speak legacy don't pay the cost.
"""
if version not in LEGACY_ADAPTER_VERSIONS:
raise ValueError(
f"register_adapter: version {version!r} is not in "
f"LEGACY_ADAPTER_VERSIONS={list(LEGACY_ADAPTER_VERSIONS)}. "
"Add the version to the constant first."
)
key = (version, adapter.tool_name)
existing = _REGISTRY.get(key)
if existing is not None and existing is not adapter:
raise ValueError(
f"register_adapter: an adapter is already registered for "
f"{key!r} ({existing!r}); refusing to overwrite with "
f"{adapter!r}."
)
_REGISTRY[key] = adapter


def get_legacy_adapter(version: str, tool_name: str) -> AdapterPair | None:
"""Return the adapter for ``(version, tool_name)`` or ``None``.

``None`` means "no translation registered for this tool at this
version" — the dispatcher converts that into ``INVALID_REQUEST``
because the buyer claimed a legacy version this seller doesn't
serve the tool on.
"""
_ensure_loaded(version)
return _REGISTRY.get((version, tool_name))


def list_legacy_adapter_tools(version: str) -> list[str]:
"""Tools with a registered adapter at this legacy version."""
_ensure_loaded(version)
return sorted(tool for (v, tool) in _REGISTRY if v == version)


def _ensure_loaded(version: str) -> None:
"""Lazily ensure the per-version adapter package's ``AdapterPair``
constants are registered.

Checks the live registry rather than a one-shot ``_LOADED`` marker
so :func:`_reset_registry_for_tests` can wipe state and the next
call re-registers from the already-imported modules. Each adapter
module exposes ``ADAPTER`` as its registration constant; that's
the contract the dispatcher relies on.

Driven by ``_VERSION_MODULES`` — adding a tool to a version is a
one-line append to that dict, no control-flow change here.
"""
if any(v == version for (v, _tool) in _REGISTRY):
return
entry = _VERSION_MODULES.get(version)
if entry is None:
return
pkg_segment, modules = entry
import importlib

for mod_name in modules:
module = importlib.import_module(f"adcp.compat.legacy.{pkg_segment}.{mod_name}")
register_adapter(version, module.ADAPTER)


def _reset_registry_for_tests() -> None:
"""Test-only: drop all registrations. Subsequent lookups re-register
from the per-version adapter modules."""
_REGISTRY.clear()


__all__ = [
"AdapterPair",
"LEGACY_ADAPTER_VERSIONS",
"get_legacy_adapter",
"list_legacy_adapter_tools",
"register_adapter",
]
56 changes: 56 additions & 0 deletions src/adcp/compat/legacy/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""``AdapterPair`` — the typed contract for legacy-version translators.

Each tool the framework supports on a legacy wire shape gets its own
:class:`AdapterPair`. The pair owns two translations:

* :attr:`adapt_request` — takes a payload validated against the legacy
schema and returns a dict in the current (SDK-pinned) wire shape. The
framework then runs current-schema validation + Pydantic ``model_validate``
on the output, so a buggy translator surfaces as ``INVALID_REQUEST``
with a field-level pointer.
* :attr:`normalize_response` — optional reverse direction: takes a
current-shape response and rewrites it to the legacy shape the buyer
expects to see. ``None`` means "no rewriting needed" (legacy and
current shapes agree on the response side).

Mirrors ``src/lib/adapters/legacy/v2-5/types.ts`` in the TypeScript SDK.
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import Any


@dataclass(frozen=True)
class AdapterPair:
"""Translation pair for one tool at one legacy version.

Adapters live under ``adcp.compat.legacy.{version_key}.{tool_name}``
and register themselves via :func:`adcp.compat.legacy.register_adapter`
at import time. The dispatcher looks them up by
``(version_key, tool_name)`` once per request.

Contract every adapter must hold:

* **Sync + pure.** Both callables run synchronously and produce a
new dict — they MUST NOT mutate their input (callers rely on the
original being intact for retries, logging, and idempotency
tracking). Tests in
``tests/test_legacy_adapter_registry.py::test_v2_5_adapter_does_not_mutate_input``
assert this for shipped adapters; new adapters should add the
equivalent check.
* **No I/O.** Heavier work (resolving format references, calling
upstream services) belongs in handlers, not adapters.
* **Exception mapping.** A raise inside ``adapt_request`` surfaces
to the buyer as :class:`adcp.exceptions.ADCPTaskError` with code
``INVALID_REQUEST`` (translation = buyer-correctable, per spec).
A raise inside ``normalize_response`` surfaces as
``INTERNAL_ERROR`` (the handler produced a valid response that
the adapter can't rewrite — SDK bug, not buyer bug).
"""

tool_name: str
adapt_request: Callable[[dict[str, Any]], dict[str, Any]]
normalize_response: Callable[[dict[str, Any]], dict[str, Any]] | None = None
26 changes: 26 additions & 0 deletions src/adcp/compat/legacy/v2_5/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Adapters for buyers on the AdCP 2.5 wire shape.

Each submodule defines an :class:`AdapterPair` for one tool and
registers it under version ``"2.5"`` via
:func:`adcp.compat.legacy.register_adapter`. Importing this package
fires every registration at once.

Current coverage:

* ``sync_creatives`` — wraps bare ``format_id`` strings, infers
``asset_type`` discriminators, demotes mis-typed ``image`` assets
to ``url`` when dimensions are missing. The three coercions match
the spec text on the v3 ``sync_creatives`` schema's pre-v3
compatibility section.

The full v2.5 catalog (``get_products``, ``create_media_buy``,
``update_media_buy``, ``list_creative_formats``, ``preview_creative``)
ports incrementally — each tool ships as its own commit so reviewers
can audit translations one at a time.
"""

from __future__ import annotations

from adcp.compat.legacy.v2_5 import sync_creatives # noqa: F401

__all__ = ["sync_creatives"]
137 changes: 137 additions & 0 deletions src/adcp/compat/legacy/v2_5/sync_creatives.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""v2.5 → v3 adapter for ``sync_creatives``.

Three wire-shape changes between v2.5 and v3:

1. **``format_id`` shape.** v2.5 buyers sent a bare string
(``"display_300x250"``); v3 requires the structured form
``{"agent_url": "...", "id": "display_300x250"}``. We inject the
canonical creative-agent URL (the AdCP standard registry) when the
value is a string.
2. **``asset_type`` discriminator.** v3 requires every asset to declare
its type explicitly. v2.5 relied on the asset key as the type hint
(``{"image": {...}}``) or on field presence (``url`` + dims →
image). The adapter infers the discriminator using the same rules
the spec documents for backwards compatibility.
3. **``image`` → ``url`` demotion.** v3's image variant requires
``width`` and ``height``. A v2.5 asset typed as ``image`` but
missing dims is semantically a URL reference; demote rather than
reject.

Each rule is reversible in principle, but :attr:`AdapterPair.normalize_response`
is left ``None`` here — the response shape didn't change between v2.5
and v3 for ``sync_creatives``.

Direct port of ``src/lib/adapters/legacy/v2-5/sync_creatives.ts`` /
``src/lib/utils/sync-creatives-adapter.ts``.
"""

from __future__ import annotations

from typing import Any

from adcp.compat.legacy import register_adapter
from adcp.compat.legacy.types import AdapterPair

#: Canonical ``agent_url`` injected when wrapping a bare ``format_id``.
#: Mirrors ``adcp.server.spec_compat.CANONICAL_CREATIVE_AGENT_URL``.
_CANONICAL_CREATIVE_AGENT_URL = "https://creative.adcontextprotocol.org"

# Asset type discriminators the spec defines. Used by the key-based
# inference path — only exact key matches resolve to a type. Substring
# matches (``hero_image``) are intentionally excluded; they're asset
# IDs, not type hints.
_KNOWN_ASSET_TYPES: frozenset[str] = frozenset(
{
"image",
"video",
"audio",
"vast",
"text",
"url",
"html",
"javascript",
"webhook",
"css",
"daast",
"markdown",
"brief",
"catalog",
}
)


def _infer_asset_type(asset_key: str, asset: dict[str, Any]) -> str | None:
"""Infer ``asset_type`` from key + field presence. ``None`` if ambiguous."""
if asset_key in _KNOWN_ASSET_TYPES:
return asset_key
if "url" in asset:
if "width" in asset and "height" in asset:
return "image"
return "url"
if "content" in asset:
return "text"
return None


def _coerce_asset(asset_key: str, asset: dict[str, Any]) -> dict[str, Any]:
"""Apply the v2.5 → v3 coercions to a single asset dict."""
out = dict(asset)
if "asset_type" not in out:
inferred = _infer_asset_type(asset_key, out)
if inferred is not None:
out["asset_type"] = inferred

# ``image`` without both dims → demote to ``url`` (only when ``url``
# is actually present; otherwise the asset is structurally invalid
# either way, and current-schema validation reports it).
if out.get("asset_type") == "image" and not ("width" in out and "height" in out):
if "url" in out:
out.pop("width", None)
out.pop("height", None)
out["asset_type"] = "url"
return out


def adapt_request(payload: dict[str, Any]) -> dict[str, Any]:
"""Translate a v2.5 ``sync_creatives`` request to v3 shape.

Returns a new dict — callers can rely on the original being
untouched (idempotency under retry).
"""
out = dict(payload)
creatives = out.get("creatives")
if not isinstance(creatives, list):
return out

new_creatives: list[Any] = []
for creative in creatives:
if not isinstance(creative, dict):
new_creatives.append(creative)
continue

new_creative = dict(creative)

# Hook 1: bare format_id string → structured.
fid = new_creative.get("format_id")
if isinstance(fid, str):
new_creative["format_id"] = {
"agent_url": _CANONICAL_CREATIVE_AGENT_URL,
"id": fid,
}

# Hooks 2 + 3: per-asset coercions.
assets = new_creative.get("assets")
if isinstance(assets, dict):
new_creative["assets"] = {
key: _coerce_asset(key, value) if isinstance(value, dict) else value
for key, value in assets.items()
}

new_creatives.append(new_creative)

out["creatives"] = new_creatives
return out


ADAPTER = AdapterPair(tool_name="sync_creatives", adapt_request=adapt_request)
register_adapter("2.5", ADAPTER)
Loading
Loading