Skip to content

Commit 4185ced

Browse files
bokelleyclaude
andauthored
feat(v3-ref-seller): translator pattern with JS mock-server upstream (#447)
* feat(v3-ref-seller): translator pattern — call upstream over HTTP, drop in-process ad-ops persistence The v3 reference seller stops being an in-process AdCP seller that duplicates upstream persistence and becomes a translator: AdCP wire on the inside, the JS mock-server (@adcp/client sales-guaranteed) on the outside. Real adopters (Prebid salesagent, GAM-shaped publishers, etc.) have an existing ad server; the reference should demonstrate the translator seam — not the inverse "build an ad server inside your seller" pattern. Changes: * New src/upstream.py — httpx-based MockUpstreamClient mirroring the JS mock's openapi.yaml (products, orders, lineitems, creatives, delivery, conversions, tasks, forecast). * models.py drops MediaBuy / Creative / PerformanceFeedback. Account carries upstream routing (network_code, advertiser_id) on the ext JSON column. * platform.py rewires every ad-ops method to call upstream over HTTP and translate to AdCP wire shapes. create_media_buy returns a TaskHandoff for the upstream's pending_approval path; the framework surfaces a Submitted envelope to the buyer and runs a background poll until the upstream auto-approves. update_media_buy raises UNSUPPORTED_FEATURE (the mock has no order-update endpoint). sync_accounts / list_accounts stay local Postgres — the AdCP-account to upstream-network mapping is the durable record this seller owns. * Capabilities now claim BOTH sales-non-guaranteed AND sales-guaranteed. The mock supports delivery_type: guaranteed/non_guaranteed; real GAM-shaped publishers sell both surfaces. * Tests use respx to mock httpx so the Python pytest CI run doesn't need to boot Node. New CI jobs: v3-reference-seller-tests (pytest) and storyboard-v3-reference-seller (boots the JS mock + Python seller for the real storyboard runner). * New MIGRATION.md targeted at maintainers of pre-v3 sales agents (Prebid salesagent specifically) — fork this directory, replace MockUpstreamClient with your real ad-server client, reseed Account.ext, deploy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(v3-ref-seller): spec-conformant error codes + upstream 400 mapping (PR #447 fix-pack) Replace non-spec error codes (INTERNAL_ERROR, AUTH_INVALID) with canonical ErrorCode enum values so wire responses survive strict validation. Map upstream HTTP 400 to terminal INVALID_REQUEST instead of falling through to the default code. Tighten CAPI perf feedback to conversion_rate only (the only AdCP metric_type with even loose CAPI semantics) and document the gap in MIGRATION.md. - AdcpError("INTERNAL_ERROR", ...) → SERVICE_UNAVAILABLE / transient for upstream transient failures and onboarding misconfig - AdcpError("AUTH_INVALID", ...) → AUTH_REQUIRED / terminal for missing or rejected bearer / X-Network-Code - _translate_upstream: add explicit 400 → INVALID_REQUEST branch, add 429 → RATE_LIMITED branch, fix recovery="retry" (not in spec) to recovery="transient" on the default-code fall-through - provide_performance_feedback: gate to metric_type='conversion_rate' only; raise INVALID_REQUEST with field='metric_type' otherwise - get_products: filter out non-cpm pricing rows (seller declares pricing_models=('cpm',); skip-and-log rather than projecting) - _project_creative_status: paused → pending_review (was approved) - create_media_buy poll: jitter 0.5–1.5× the base interval - update_media_buy: clarify GAM LineItemService.performLineItemAction is the production wiring point - MIGRATION.md: document spec error codes (canonical enum vs legacy SDK codes), CAPI semantic mismatch, and "what this seller doesn't yet support upstream" (update_media_buy) - Tests: cover _translate_upstream (400/401/429/500), conversion_rate gate, account-loader SERVICE_UNAVAILABLE projection Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(v3-ref-seller): polling correctness + 404 context + status projection + MIGRATION (PR #447 fix-pack 2) * create_media_buy polling no longer fabricates success on: - loop exhaustion (now raises SERVICE_UNAVAILABLE/transient) - upstream rejected status (now raises PERMISSION_DENIED/terminal) - missing approval_task_id with non-terminal status (now refetches once and projects from actual current status, never enters polling loop) * _translate_upstream gains per-callsite not_found_code; get_products and list_creatives 404s now surface ACCOUNT_NOT_FOUND instead of the misleading MEDIA_BUY_NOT_FOUND. * get_media_buy_delivery double-fetches the order so AdCP MediaBuyStatus reflects upstream state (DeliveryReport doesn't carry status, so completed/canceled/rejected buys would have surfaced as 'active'). * list_accounts now sets pagination.total_count. * MIGRATION.md adds a Pre-v3 to v3 mapping table for Prebid salesagent porting, plus specialism-declaration upgrade, strict-validation gotchas, and spec-error-code reference sections. * CI readiness probe uses /_debug/traffic (non-network-scoped, no auth) so seed-data renames don't break the boot. Adds a post-seller-boot upstream-alive probe that fails fast if the seller crashed the upstream. * Tests: polling timeout, polling rejection, no-task refetch path, ACCOUNT_NOT_FOUND callsite override, 401/500/429/malformed-JSON failure paths, completed/canceled status projection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): install example-local deps in v3-reference-seller-tests job The new pytest job for the translator pattern was failing all tests with ModuleNotFoundError: 'sqlalchemy'. The example imports sqlalchemy + asyncpg + respx but the SDK's [dev] extras don't carry them. Install inline rather than adding a one-off optional-dependencies group. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 127f164 commit 4185ced

11 files changed

Lines changed: 3007 additions & 1060 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 97 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -422,24 +422,44 @@ jobs:
422422
path: storyboard-result.json
423423
if-no-files-found: warn
424424

425+
v3-reference-seller-tests:
426+
name: v3 reference seller — pytest (respx-mocked upstream)
427+
runs-on: ubuntu-latest
428+
429+
steps:
430+
- uses: actions/checkout@v4
431+
432+
- name: Set up Python 3.12
433+
uses: actions/setup-python@v5
434+
with:
435+
python-version: "3.12"
436+
437+
- name: Install dependencies
438+
run: |
439+
python -m pip install --upgrade pip
440+
pip install -e ".[dev]"
441+
# Example-local deps: the v3 reference seller imports
442+
# sqlalchemy + asyncpg + httpx-respx but those aren't in the
443+
# SDK's [dev] extras. Install them inline rather than adding
444+
# a separate optional-dependencies group for a single example.
445+
pip install "sqlalchemy>=2.0" "asyncpg>=0.29" "respx>=0.20"
446+
447+
- name: Run translator-pattern tests
448+
# The tests respx-mock the JS mock-server upstream so we don't
449+
# need to boot Node here. Storyboard CI (below) covers the
450+
# real boot-the-upstream path.
451+
run: |
452+
pytest examples/v3_reference_seller/tests/ -v
453+
425454
storyboard-v3-reference-seller:
426-
name: AdCP storyboard runner — examples/v3_reference_seller/src/app.py
455+
name: AdCP storyboard runner — v3 reference seller (translator)
427456
runs-on: ubuntu-latest
428-
# Non-blocking on first land. The v3 reference seller wires the
429-
# full Tier 2 commercial-identity gate, subdomain tenant routing,
430-
# validation in strict mode, and traffic counters — but it has
431-
# never been exercised by the canonical storyboard runner, so any
432-
# gap (auth shape, fixture mismatch, unimplemented sub-skill)
433-
# surfaces here first. Promote to required once the
434-
# sales-non-guaranteed bundle reports overall_status: passing.
457+
# Non-blocking until storyboard tooling settles for the translator
458+
# pattern. Promote to required once the JS mock-server's
459+
# sales-guaranteed surface is canonical.
435460
continue-on-error: true
436-
437461
services:
438462
postgres:
439-
# CI-local ephemeral database. Same trust-auth pattern as
440-
# ``pg-conformance`` above: GitHub's CI network is the trust
441-
# boundary, and shipping a literal password here flags
442-
# secret-scanners for no benefit.
443463
image: postgres:16
444464
env:
445465
POSTGRES_HOST_AUTH_METHOD: trust
@@ -466,156 +486,100 @@ jobs:
466486
node-version: "22"
467487

468488
- name: Install dependencies
469-
# The v3 reference seller pulls in SQLAlchemy + asyncpg, which
470-
# the SDK itself doesn't depend on. They're example-local
471-
# deps, installed inline so the CI job doesn't bloat the
472-
# SDK's own [dev] extra.
473489
run: |
474490
python -m pip install --upgrade pip
475-
pip install -e ".[dev]"
476-
pip install "sqlalchemy[asyncio]>=2.0" asyncpg
477-
478-
- name: Add acme.localhost hosts entry
479-
# SubdomainTenantMiddleware reads the ``Host`` header to pick
480-
# the tenant. The seed plants ``acme.localhost`` as the tenant
481-
# host, so the storyboard runner must reach the seller via
482-
# that name — not 127.0.0.1. Ubuntu runners route ``*.localhost``
483-
# via nss-myhostname today, but pinning the entry explicitly
484-
# avoids depending on distro NSS behavior.
491+
pip install -e ".[dev,pg]"
492+
493+
- name: Start JS mock-server upstream
485494
run: |
486-
echo "127.0.0.1 acme.localhost" | sudo tee -a /etc/hosts
487-
getent hosts acme.localhost
495+
npx -y -p @adcp/client@latest \
496+
adcp mock-server sales-guaranteed --port 4503 --api-key test-key &
497+
MOCK_PID=$!
498+
echo "MOCK_PID=$MOCK_PID" >> "$GITHUB_ENV"
499+
# Health-check via /_debug/traffic — non-network-scoped and
500+
# no-auth, so it doesn't break when the JS mock's seed-data
501+
# renames or removes a specific network. The endpoint is
502+
# always present on the harness-side mock.
503+
for i in $(seq 1 60); do
504+
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 \
505+
http://127.0.0.1:4503/_debug/traffic 2>/dev/null || echo "000")
506+
if [ "$HTTP_CODE" = "200" ]; then
507+
echo "Upstream mock ready (HTTP 200, pid $MOCK_PID)"
508+
break
509+
fi
510+
if [ "$i" -eq 60 ]; then
511+
echo "Upstream mock failed to start within 30s"
512+
kill "$MOCK_PID" 2>/dev/null || true
513+
exit 1
514+
fi
515+
sleep 0.5
516+
done
488517
489-
- name: Seed dev fixtures
518+
- name: Seed Postgres fixtures
490519
env:
491-
DATABASE_URL: postgresql+asyncpg://postgres@localhost:5432/adcp
520+
DATABASE_URL: postgresql+asyncpg://postgres@127.0.0.1:5432/adcp
492521
run: |
493522
cd examples/v3_reference_seller
494523
python -m seed
495524
496-
- name: Start v3 reference seller
525+
- name: Boot v3 reference seller (translator)
497526
env:
498-
DATABASE_URL: postgresql+asyncpg://postgres@localhost:5432/adcp
527+
DATABASE_URL: postgresql+asyncpg://postgres@127.0.0.1:5432/adcp
528+
MOCK_AD_SERVER_URL: http://127.0.0.1:4503
529+
MOCK_AD_SERVER_API_KEY: test-key
499530
PORT: "3001"
500531
run: |
501532
cd examples/v3_reference_seller
502533
python -m src.app &
503-
AGENT_PID=$!
504-
echo "AGENT_PID=$AGENT_PID" >> "$GITHUB_ENV"
534+
SELLER_PID=$!
535+
echo "SELLER_PID=$SELLER_PID" >> "$GITHUB_ENV"
505536
for i in $(seq 1 60); do
506-
# Hit the seller via the seeded tenant host so the
507-
# SubdomainTenantMiddleware resolves ``acme.localhost`` →
508-
# ``t_acme`` and the request progresses past the 404
509-
# ``unknown-host`` early-return.
510537
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 \
511-
http://acme.localhost:3001/mcp 2>/dev/null || echo "000")
512-
if [ "$HTTP_CODE" != "000" ] && [ "$HTTP_CODE" != "404" ]; then
513-
echo "v3 reference seller ready (HTTP ${HTTP_CODE}, pid ${AGENT_PID})"
538+
http://127.0.0.1:3001/mcp 2>/dev/null || echo "000")
539+
if [ "$HTTP_CODE" != "000" ]; then
540+
echo "Seller ready (HTTP ${HTTP_CODE}, pid ${SELLER_PID})"
514541
break
515542
fi
516-
if ! kill -0 "$AGENT_PID" 2>/dev/null; then
517-
echo "v3 reference seller process died during startup"
518-
exit 1
519-
fi
520543
if [ "$i" -eq 60 ]; then
521-
echo "v3 reference seller failed to start within 30s"
522-
kill "$AGENT_PID" 2>/dev/null || true
544+
echo "Seller failed to start within 30s"
545+
kill "$SELLER_PID" 2>/dev/null || true
523546
exit 1
524547
fi
525548
sleep 0.5
526549
done
550+
# Upstream-still-alive probe — guard against the upstream
551+
# dying during seller startup (e.g. seller's connection
552+
# handshake crashes the mock). If the upstream is gone,
553+
# the storyboard run will fail in confusing ways; fail
554+
# here with a clear diagnostic instead.
555+
UPSTREAM_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \
556+
-H "Authorization: Bearer test-key" \
557+
-H "X-Network-Code: net_premium_us" \
558+
http://127.0.0.1:4503/v1/products 2>/dev/null || echo "000")
559+
if [ "$UPSTREAM_CODE" != "200" ]; then
560+
echo "Upstream mock no longer responding after seller boot (HTTP ${UPSTREAM_CODE})"
561+
echo "The seller likely crashed the upstream during connection handshake."
562+
kill "$SELLER_PID" 2>/dev/null || true
563+
kill "$MOCK_PID" 2>/dev/null || true
564+
exit 1
565+
fi
566+
echo "Upstream still alive after seller boot (HTTP ${UPSTREAM_CODE})"
527567
528-
- name: Run storyboard suite (sales-non-guaranteed)
568+
- name: Run storyboard suite
529569
timeout-minutes: 5
530-
# The v3 reference seller declares the ``sales-non-guaranteed``
531-
# specialism (``V3ReferenceSeller.capabilities.specialisms``).
532-
# That bundle covers the nine sales-non-guaranteed methods the
533-
# platform ships — the right contract surface to grade. The
534-
# universal capability-discovery + error-compliance bundles
535-
# are also exercised by the runner's default capability-driven
536-
# selection, but pinning the bundle keeps the report focused.
537-
#
538-
# Bearer auth: ``seed.py`` plants ``dev-bearer-token-acme-1``
539-
# for ``ba_acme_bearer``. The SDK's adopter-bearer middleware
540-
# is not wired in ``app.py`` yet, so the runner reaches the
541-
# platform via the no-auth code path the framework allows for
542-
# dev seeds. If this proves insufficient (storyboard requires
543-
# an authenticated identity for some skills) the run will fail
544-
# here — that's the diagnostic signal to wire the bearer
545-
# middleware as a follow-up.
546570
run: |
571+
# /etc/hosts override so the buyer can reach acme.localhost
572+
# (the seeded tenant subdomain).
573+
echo "127.0.0.1 acme.localhost" | sudo tee -a /etc/hosts
547574
npx -y -p @adcp/client@latest adcp storyboard run \
548-
http://acme.localhost:3001/mcp sales-non-guaranteed \
575+
http://acme.localhost:3001/mcp media_buy_seller \
549576
--json --allow-http \
550-
> storyboard-result.json
551-
552-
- name: Assert storyboard pass
553-
run: |
554-
python -c "
555-
import json, sys, pathlib
556-
p = pathlib.Path('storyboard-result.json')
557-
if not p.exists() or p.stat().st_size == 0:
558-
print('storyboard-result.json missing or empty — runner produced no output')
559-
sys.exit(1)
560-
with p.open() as f:
561-
d = json.load(f)
562-
if d.get('overall_status') != 'passing':
563-
print(json.dumps(d, indent=2))
564-
sys.exit(1)
565-
"
566-
567-
- name: Assert anti-façade traffic counters non-zero
568-
# PR #405 wired ``InMemoryMockAdServer`` + ``/_debug/traffic``
569-
# so storyboard runs can prove the platform actually delegated
570-
# to its upstream ad server, rather than fabricating responses
571-
# in the dispatch layer. The seller's ``app.py`` opts in via
572-
# ``enable_debug_endpoints=True`` (the production default is
573-
# off). After the storyboard run, both ``media_buy.create``
574-
# and ``creatives.upload`` counters must be > 0 — anything
575-
# else means the storyboard didn't reach those skills, or
576-
# the platform is short-circuiting.
577-
run: |
578-
# The debug endpoint is mounted as the OUTERMOST asgi
579-
# middleware (see ``_prepend_debug_endpoint`` in
580-
# ``adcp.server.serve``), so it runs before
581-
# ``SubdomainTenantMiddleware`` and the host header doesn't
582-
# need to match a seeded tenant. 127.0.0.1 is fine here.
583-
curl -fsS http://127.0.0.1:3001/_debug/traffic > traffic.json
584-
cat traffic.json
585-
python -c "
586-
import json, sys
587-
with open('traffic.json') as f:
588-
t = json.load(f)
589-
# /_debug/traffic returns a flat dict of {method_name: count}
590-
# — see DebugTrafficMiddleware in adcp.server.debug_endpoints.
591-
# The reference seller's platform records ``media_buy.create``
592-
# and ``creative.upload`` (singular — see ``_record`` calls in
593-
# examples/v3_reference_seller/src/platform.py).
594-
create = t.get('media_buy.create', 0)
595-
upload = t.get('creative.upload', 0)
596-
if create == 0:
597-
print(f'media_buy.create counter is 0 — platform did not reach the mock ad server')
598-
print(json.dumps(t, indent=2))
599-
sys.exit(1)
600-
if upload == 0:
601-
print(f'creative.upload counter is 0 — platform did not reach the mock ad server')
602-
print(json.dumps(t, indent=2))
603-
sys.exit(1)
604-
print(f'OK — media_buy.create={create}, creative.upload={upload}')
605-
"
606-
607-
- name: Stop v3 reference seller
608-
if: always()
609-
run: |
610-
if [ -n "${AGENT_PID:-}" ]; then
611-
kill "$AGENT_PID" 2>/dev/null || true
612-
fi
577+
> v3-storyboard-result.json || true
578+
cat v3-storyboard-result.json | head -50
613579
614580
- if: always()
615581
uses: actions/upload-artifact@v4
616582
with:
617-
name: storyboard-v3-result-${{ github.run_attempt }}
618-
path: |
619-
storyboard-result.json
620-
traffic.json
583+
name: v3-storyboard-result-${{ github.run_attempt }}
584+
path: examples/v3_reference_seller/v3-storyboard-result.json
621585
if-no-files-found: warn

0 commit comments

Comments
 (0)