feat(decisioning): mock-mode upstream URL routing (Phase 2)#487
Merged
Conversation
…'mock_upstream_url'] + DecisioningPlatform.upstream_for (Phase 2) Phase 2 of the lifecycle-state-and-sandbox-authority work. When a resolved Account has mode='mock', the framework points the adapter's UpstreamHttpClient at the mock-server fixture URL declared on the account's metadata. Adapter code is unchanged across modes; only the upstream URL changes per request. The reframe vs. earlier proposal sketches: the adapter knows its own production URL (GAM is always googleads.googleapis.com, Kevel is always api.kevel.co). Per-tenant variation already flows through ctx.auth_info (credentials) and ctx.account.metadata; mock-mode adds one more variable — the upstream URL — when the adopter wants to exercise their adapter against the spec's reference path (bin/adcp.js mock-server <specialism>) without standing up a real backend. Surface: - DecisioningPlatform.upstream_url: ClassVar[str | None] — adopter declares the production URL on their subclass. None is allowed for pure in-process platforms. - DecisioningPlatform.upstream_for(ctx, *, auth, default_headers, timeout, treat_404_as_none) -> UpstreamHttpClient — picks the right URL based on ctx.account.mode, caches by (base_url, id(auth)) per platform instance, fail-closes with CONFIGURATION_ERROR when mock-mode is missing metadata['mock_upstream_url'] or live/sandbox mode lacks upstream_url. - get_mock_upstream_url(account) — type-safe accessor that handles dict, non-mapping, missing, empty, and non-string cases. Mock-server lifecycle is NOT managed by the SDK. Adopters or CI start bin/adcp.js mock-server <specialism> and populate the URL on the account in their AccountStore.resolve. Tests cover happy paths (live, sandbox, two distinct mock URLs), fail-closed (missing/empty/non-string mock URL, missing platform upstream_url for live/sandbox), cache identity (same auth → same client; different auth → different client; per-platform-instance isolation), and auth threading. Phase 1 (PR #483) shipped Account.mode + the comply-controller gate; Phase 2 wires the routing. Phase 3 (composition / opt-out for adopters with bespoke sandbox needs) is deferred. References: - JS umbrella: adcontextprotocol/adcp-client#1435 - Phase 1: #483 - Proposal: docs/proposals/lifecycle-state-and-sandbox-authority.md (note: proposal envisioned out-of-process forwarding; the actual mock-server is a per-specialism upstream-API REST fixture, so the routing is a base-URL swap rather than a transport forwarder) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
b1c3d42 to
737cc2e
Compare
This was referenced May 3, 2026
bokelley
added a commit
that referenced
this pull request
May 3, 2026
…de (Phase 3) (#488) * refactor(examples/v3-reference-seller): adopt upstream_for + Account.mode='mock' pattern (Phase 3 of #1435 port) Migrates the v3 reference seller off its bespoke httpx client and onto the SDK's UpstreamHttpClient + DecisioningPlatform.upstream_for routing shipped in Phase 2 (#487). The reference seller is the canonical adopter migration template — what salesagent and other translator- pattern adopters mirror. Concrete changes: * src/upstream.py — replaces the bespoke MockUpstreamClient (httpx.AsyncClient + UpstreamError + per-call _request) with module-level domain helpers (list_products, create_order, etc.) taking a pooled adcp.decisioning.UpstreamHttpClient. Auth + 4xx/5xx → AdcpError projection now flow through the SDK client; the helpers shape only the upstream-specific paths/payloads. * src/platform.py — declares upstream_url class attribute (production- shape placeholder adopters replace when migrating to mode='live'). Method bodies resolve the client via self.upstream_for(ctx, auth=self._upstream_auth) instead of holding a constructed client. Constructor takes upstream_api_key (wired into a single StaticBearer shared across upstream_for() calls so the framework caches one pooled httpx.AsyncClient per base URL). Drops the bespoke _translate_upstream helper — the SDK's UpstreamHttpClient already projects every status code we cared about, with not_found_code override per call where the right AdCP code wasn't MEDIA_BUY_NOT_FOUND. * src/platform.py::_make_account_store — every resolved account is stamped mode='mock' + metadata['mock_upstream_url'] (Phase 1 + Phase 2 wiring). Adopters with a real production upstream branch on row lifecycle to set mode='live' for production accounts and reserve mode='mock' for conformance / storyboard accounts only. * src/app.py — drops MockUpstreamClient construction; passes upstream_api_key + mock_upstream_url (sourced from the existing MOCK_AD_SERVER_URL / MOCK_AD_SERVER_API_KEY env vars — env contract preserved, only the wiring changes). Storyboard CI behavior is unchanged. * tests/ — every translator-pattern test updated to construct Account(mode='mock', metadata={'mock_upstream_url': ...}) and let the framework's upstream_for() route. Removed the four _translate_upstream unit tests (the helper is gone); end-to-end status-code mapping coverage stays via the existing respx-driven tests (401→AUTH_REQUIRED, 404→ACCOUNT_NOT_FOUND, 429→RATE_LIMITED, 500→SERVICE_UNAVAILABLE). Added tests asserting the AccountStore stamps mode='mock' + mock_upstream_url and that the platform declares upstream_url. * MIGRATION.md / README.md — updated to walk adopters through the new pattern. Adopters declaring upstream_url + branching account.mode in their AccountStore.resolve get the same code path against live and mock without per-call branching. Quality gates green: ruff clean, mypy unchanged from baseline (17 pre-existing errors, none introduced), pytest examples (38 passed), pytest tests (3616 passed). Storyboard CI needs no workflow changes — the MOCK_AD_SERVER_URL / MOCK_AD_SERVER_API_KEY env contract is preserved. Refs Phase 1 (#483), Phase 2 (#487). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(examples/v3-reference-seller): MIGRATION.md recovery row, mock-url loader fail-fast, Phase 2→3 delta Address review-pack on PR #488: * MIGRATION.md spec-error-codes table: 400 → INVALID_REQUEST projects to ``recovery='retry_with_changes'`` per the SDK's ``UpstreamHttpClient._project_status``, not ``terminal``. Add a note clarifying the table reflects SDK defaults and adopters override per-call. * ``_make_account_store`` fails-fast with ``CONFIGURATION_ERROR`` when ``mock_upstream_url`` is ``None`` and the loader is invoked. Drops the ``mock-upstream-not-configured.invalid`` placeholder that would otherwise cascade into an unprojected ``httpx.ConnectError``. Tests that bypass the AccountStore (constructing ``Account`` objects directly) keep working — the constructor still accepts ``mock_upstream_url=None``, the loader just refuses to resolve. * MIGRATION.md gains a "Migrating from a bespoke httpx upstream client (Phase 2 → Phase 3)" section with grep-replace before/after blocks for: class field, per-method usage, account wiring, error handling, and constructor-arg test updates. Nice-to-have: drop the misleading ``# Forward optional filtering hints`` comment in ``get_products`` (the call passes only ``network_code``); annotate the ``handoff._fn`` private-attr access with ``# noqa: SLF001`` and a one-line explanation pointing to the absent public driver. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 2 of the lifecycle-state-and-sandbox-authority work. When a resolved
Accounthasmode='mock', the framework points the adapter'sUpstreamHttpClientat the per-tenant mock-server fixture URL declared onaccount.metadata['mock_upstream_url']. Adapter business logic runs unchanged across all three modes; only the upstream URL changes per request.Reframe vs. earlier proposal sketches. The proposal doc envisioned the SDK forwarding mock-mode tool calls out-of-process to a
bin/adcp.js mock-serverAdCP-MCP forwarder. The earlier agent on this work correctly determined that doesn't fit the existing mock-server (it's a per-specialism upstream-API REST fixture, not an MCP forwarder). The user (proposal author) confirmed the right model: the adapter knows its own production URL (GAM is alwaysgoogleads.googleapis.com, Kevel is alwaysapi.kevel.co); mock-mode just swaps the upstream URL the adapter's HTTP client points at. Adapter code runs unchanged.Surface
DecisioningPlatform.upstream_url: str | None— adopter declares the production upstream URL on their subclass (e.g."https://googleads.googleapis.com").Noneis allowed for pure in-process platforms.DecisioningPlatform.upstream_for(ctx, *, auth=None, default_headers=None, timeout=30.0, treat_404_as_none=True) -> UpstreamHttpClient— returns a cached client pointed at the right URL forctx.account.mode. Fail-closes withAdcpError(code='CONFIGURATION_ERROR', recovery='terminal')when:mode='mock'andmetadata['mock_upstream_url']is missing/empty/non-string.mode='live'/mode='sandbox'andplatform.upstream_url is None.get_mock_upstream_url(account) -> str | None— type-safe accessor that handles dict, non-mapping, missing, empty, and non-string cases.Cache key:
(base_url, id(auth)), per-platform-instance. Different platform instances don't share clients (no cross-adapter auth-header leak); different auth strategies on the same platform get distinct clients (the auth is injected at construction and can't be swapped per-request from a cached client).What ships
src/adcp/decisioning/account_mode.pyget_mock_upstream_url(account)helper.src/adcp/decisioning/platform.pyupstream_urlclass attr +upstream_for(ctx)method + per-instance cache.src/adcp/decisioning/types.pymetadata['mock_upstream_url']contract onAccountdocstring.src/adcp/decisioning/__init__.pyget_mock_upstream_url.tests/test_upstream_for.pytests/test_error_code_conformance.pyCONFIGURATION_ERROR(server-side adopter-misconfig signal, distinct fromINVALID_REQUESTandSERVICE_UNAVAILABLE).examples/hello_mock_seller.pydocs/handler-authoring.mdHello-mock four-account example
Mock-server lifecycle is not managed by the SDK. Adopters or CI start
bin/adcp.js mock-server <specialism>and populate the URL on mock-mode accounts in theirAccountStore.resolve.What's NOT in this PR
PlatformRouter) — separate work (feat(examples): multi-platform-per-process proof — mock platforms with PlatformRouter, refactor v3 reference seller along the way #477).MockBackendClient— doesn't fit the existing mock-server.comply_test_controllerrouting changes — Phase 1's gate stays as-is.References
docs/proposals/lifecycle-state-and-sandbox-authority.mdTest plan
pytest tests/test_upstream_for.py -v(22 tests pass)pytest tests/full regression (3604 passed, 32 skipped, 0 failed)ruff check src/ tests/test_upstream_for.py examples/hello_mock_seller.pymypy src/adcp/(768 source files, 0 errors)python examples/hello_mock_seller.py— MCP listening on http://0.0.0.0:3001/mcp without errors(base_url, id(auth))matches expected pooling semanticsCONFIGURATION_ERRORallowlist entry is acceptable, or suggest spec-conformant alternative🤖 Generated with Claude Code