From b56a04526c179880f57d731a37150938f066a330 Mon Sep 17 00:00:00 2001 From: rp-maintainers Date: Thu, 24 Sep 2026 14:37:15 +0000 Subject: [PATCH] fix(finops): align Python SDK contracts --- src/routeplane/__init__.py | 20 +- src/routeplane/headers.py | 20 +- src/routeplane/meta.py | 36 +++- src/routeplane/resources/__init__.py | 16 +- src/routeplane/resources/finops.py | 279 +++++++++++++++++++++++++-- tests/test_headers.py | 23 ++- tests/test_meta.py | 20 +- tests/test_resources.py | 136 ++++++++++++- 8 files changed, 520 insertions(+), 30 deletions(-) diff --git a/src/routeplane/__init__.py b/src/routeplane/__init__.py index bd891eb..e96c35e 100644 --- a/src/routeplane/__init__.py +++ b/src/routeplane/__init__.py @@ -14,8 +14,17 @@ from .async_client import AsyncRouteplane from .client import Routeplane from .headers import headers -from .meta import RouteplaneMeta -from .resources import Decision, RunStep +from .meta import RouteplaneMeta, RouteplaneRateLimits +from .resources import ( + CacheSavings, + DailyPricingEvidence, + DailyUsageReport, + Decision, + RunStep, + SaverMetrics, + TimeseriesData, + UsageData, +) __all__ = [ "Routeplane", @@ -24,7 +33,14 @@ "AsyncRouteplaneStream", "headers", "RouteplaneMeta", + "RouteplaneRateLimits", "Decision", + "CacheSavings", + "DailyPricingEvidence", + "DailyUsageReport", "RunStep", + "SaverMetrics", + "TimeseriesData", + "UsageData", "__version__", ] diff --git a/src/routeplane/headers.py b/src/routeplane/headers.py index 1ec30a7..edb8b81 100644 --- a/src/routeplane/headers.py +++ b/src/routeplane/headers.py @@ -22,6 +22,7 @@ "provider": "x-routeplane-provider", "residency": "x-routeplane-residency", "strategy": "x-routeplane-strategy", + "canary_share_bps": "x-routeplane-canary-share-bps", "config": "x-routeplane-config", "timeout_ms": "x-routeplane-timeout-ms", "use_case": "x-routeplane-use-case", @@ -43,8 +44,11 @@ def headers( *, provider: str | None = None, residency: str | None = None, - strategy: Literal["priority", "weighted", "cost", "latency", "round_robin", "least_busy"] + strategy: Literal[ + "priority", "weighted", "cost", "latency", "round_robin", "least_busy", "canary" + ] | None = None, + canary_share_bps: int | None = None, config: dict[str, Any] | None = None, timeout_ms: int | None = None, use_case: str | None = None, @@ -79,6 +83,10 @@ def headers( gateway matches case-insensitively and falls back to ``"priority"`` for an unknown or empty value rather than erroring, so a typo silently routes by priority. Superseded by a routing config. + canary_share_bps: Candidate share for ``strategy="canary"`` in basis + points (0..10000). Assignment is deterministic per virtual key and + promotes the second provider in the chain; it is not a per-request + random split. config: Inline routing/policy config, JSON-serialized onto the wire. timeout_ms: Per-request upstream timeout in milliseconds. use_case: Free-form use-case label for analytics/FinOps attribution. @@ -90,7 +98,8 @@ def headers( output_mask: Output masking policy reference. cache_control: Response-cache directive. idempotency_key: Client-supplied idempotency key for safe retries. - cohort: Experiment/cohort label. + cohort: Prompt-variant cohort label. This is separate from provider + canary assignment and overrides the prompt registry's derived cohort. batch: Batch identifier. trace_id: Client-supplied distributed-trace id (echoed back on the response). @@ -98,10 +107,17 @@ def headers( A ``dict[str, str]`` of header name to value, suitable to splat into any client's extra/default headers. """ + if canary_share_bps is not None: + if isinstance(canary_share_bps, bool) or not isinstance(canary_share_bps, int): + raise TypeError("canary_share_bps must be an integer") + if not 0 <= canary_share_bps <= 10000: + raise ValueError("canary_share_bps must be between 0 and 10000") + values: dict[str, Any] = { "provider": provider, "residency": residency, "strategy": strategy, + "canary_share_bps": canary_share_bps, "config": config, "timeout_ms": timeout_ms, "use_case": use_case, diff --git a/src/routeplane/meta.py b/src/routeplane/meta.py index 685a09c..5cac288 100644 --- a/src/routeplane/meta.py +++ b/src/routeplane/meta.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from typing import Mapping, Optional -__all__ = ["RouteplaneMeta"] +__all__ = ["RouteplaneMeta", "RouteplaneRateLimits"] _TRUE = {"true", "1", "yes", "on"} @@ -25,6 +25,28 @@ def _flag(headers: Mapping[str, str], name: str) -> bool: return value.strip().lower() in _TRUE +def _integer(headers: Mapping[str, str], name: str) -> Optional[int]: + value = headers.get(name) + if value is None: + return None + try: + return int(value) + except ValueError: + return None + + +@dataclass(frozen=True) +class RouteplaneRateLimits: + """Advisory values from the gateway's standard ``x-ratelimit-*`` headers.""" + + requests_limit: Optional[int] = None + requests_remaining: Optional[int] = None + requests_reset: Optional[str] = None + tokens_limit: Optional[int] = None + tokens_remaining: Optional[int] = None + tokens_reset: Optional[str] = None + + @dataclass(frozen=True) class RouteplaneMeta: """Typed view over the ``x-routeplane-*`` response headers. @@ -45,6 +67,7 @@ class RouteplaneMeta: compliance_warning: Optional[str] = None pii_masked: bool = False idempotent_replayed: bool = False + rate_limits: Optional[RouteplaneRateLimits] = None @classmethod def from_headers(cls, headers: Mapping[str, str]) -> "RouteplaneMeta": @@ -59,6 +82,16 @@ def from_headers(cls, headers: Mapping[str, str]) -> "RouteplaneMeta": A populated :class:`RouteplaneMeta`. Missing headers fall back to ``None`` (strings) or ``False`` (flags). """ + rate_limits: Optional[RouteplaneRateLimits] = RouteplaneRateLimits( + requests_limit=_integer(headers, "x-ratelimit-limit-requests"), + requests_remaining=_integer(headers, "x-ratelimit-remaining-requests"), + requests_reset=headers.get("x-ratelimit-reset-requests"), + tokens_limit=_integer(headers, "x-ratelimit-limit-tokens"), + tokens_remaining=_integer(headers, "x-ratelimit-remaining-tokens"), + tokens_reset=headers.get("x-ratelimit-reset-tokens"), + ) + if rate_limits == RouteplaneRateLimits(): + rate_limits = None return cls( provider=headers.get("x-routeplane-provider"), trace_id=headers.get("x-routeplane-trace-id"), @@ -72,4 +105,5 @@ def from_headers(cls, headers: Mapping[str, str]) -> "RouteplaneMeta": compliance_warning=headers.get("x-routeplane-compliance-warning"), pii_masked=_flag(headers, "x-routeplane-pii-masked"), idempotent_replayed=_flag(headers, "x-routeplane-idempotent-replayed"), + rate_limits=rate_limits, ) diff --git a/src/routeplane/resources/__init__.py b/src/routeplane/resources/__init__.py index 67317de..1bf80a1 100644 --- a/src/routeplane/resources/__init__.py +++ b/src/routeplane/resources/__init__.py @@ -9,7 +9,15 @@ from .analytics import AnalyticsResource from .cache import CacheResource from .feedback import FeedbackResource -from .finops import FinopsResource +from .finops import ( + CacheSavings, + DailyPricingEvidence, + DailyUsageReport, + FinopsResource, + SaverMetrics, + TimeseriesData, + UsageData, +) from .logs import LogsResource from .mcp import Decision, McpResource, RunStep from .models import ModelsResource @@ -22,12 +30,18 @@ "BaseResource", "AnalyticsResource", "CacheResource", + "CacheSavings", + "DailyPricingEvidence", "Decision", + "DailyUsageReport", "FeedbackResource", "FinopsResource", "LogsResource", "McpResource", "RunStep", + "SaverMetrics", + "TimeseriesData", + "UsageData", "ModelsResource", "PromptsResource", "ProvidersResource", diff --git a/src/routeplane/resources/finops.py b/src/routeplane/resources/finops.py index 816bd4a..76ab2fb 100644 --- a/src/routeplane/resources/finops.py +++ b/src/routeplane/resources/finops.py @@ -2,19 +2,239 @@ from __future__ import annotations -from typing import Any, Optional +import math +import warnings +from datetime import datetime +from typing import Literal, Optional, TypedDict from ._base import BaseResource, prune_none -__all__ = ["FinopsResource"] +__all__ = [ + "CacheSavings", + "DailyPricingEvidence", + "DailyUsageReport", + "FinopsResource", + "SaverMetrics", + "TimeseriesData", + "UsageData", +] + + +class FinOpsUsageTotals(TypedDict): + requests: int + successful_requests: int + prompt_tokens: int + completion_tokens: int + total_tokens: int + cost_micro_usd: int + cost_by_currency: dict[str, int] + + +class _FinOpsLatencyRequired(TypedDict): + count: int + + +class FinOpsLatencyPercentiles(_FinOpsLatencyRequired, total=False): + p50_ms: int + p95_ms: int + p99_ms: int + max_ms: int + + +class _UsageDataRequired(TypedDict): + tenant_id: str + window: int + events_matched: int + totals: FinOpsUsageTotals + by_model: dict[str, FinOpsUsageTotals] + by_key: dict[str, FinOpsUsageTotals] + latency: FinOpsLatencyPercentiles + + +class UsageData(_UsageDataRequired, total=False): + by_use_case: dict[str, FinOpsUsageTotals] + + +class DailyModelUsage(TypedDict): + provider: str + model: str + requests: int + errors: int + total_tokens: int + cost_micro_usd: Optional[int] + pricing: "DailyPricingEvidence" + + +class DailyKeyUsage(DailyModelUsage): + key: str + prompt_tokens: int + completion_tokens: int + cost_inr_paise: Optional[int] + + +DailyPricingSource = TypedDict( + "DailyPricingSource", + { + "class": Literal["durable_rollup"], + "systems": list[Literal["telemetry_rung_1"]], + "schema_version": Literal["routeplane.finops.cost.v1"], + }, +) + + +class DailyPricingCoverage(TypedDict): + eligible_count: int + observed_count: int + priced_count: int + unpriced_count: int + invalid_pricing_count: int + versioned_priced_count: int + pricing_coverage_state: Literal["known", "legacy_unknown", "corrupt"] + pricing_book_versions: list[str] + pricing_book_versions_truncated: bool + numeric_overflowed: bool + missing_reasons: list[str] + + +class DailyPricingComponentCoverage(TypedDict): + input_output_split_available: bool + inr_view_available: bool + + +class DailyPricingEvidence(TypedDict): + data_state: Literal["live", "partial", "unavailable"] + availability: Literal["available", "partial", "unavailable"] + value: Optional[int] + unit: Literal["micro_currency"] + currency: Literal["USD"] + scale: Literal[6] + cost_status: Literal["estimated", "unpriced", "unavailable"] + source: DailyPricingSource + coverage: DailyPricingCoverage + component_coverage: DailyPricingComponentCoverage + + +class DailyLatency(TypedDict): + p50: Optional[float] + p95: Optional[float] + p99: Optional[float] + + +class DailyUsageTotals(TypedDict): + requests: int + errors: int + prompt_tokens: int + completion_tokens: int + total_tokens: int + cost_micro_usd: Optional[int] + input_cost_micro_usd: Optional[int] + output_cost_micro_usd: Optional[int] + cost_inr_paise: Optional[int] + pricing: DailyPricingEvidence + + +class DailyUsage(DailyUsageTotals): + date: str + streaming: int + sovereign_routed: int + latency_ms: DailyLatency + by_model: list[DailyModelUsage] + by_key: list[DailyKeyUsage] + + +class TimeseriesBucket(TypedDict): + ts: str + requests: int + errors: int + cost_micro_usd: int + tokens: int + avg_latency_ms: int + + +class TimeseriesData(TypedDict): + tenant_id: str + window_mins: int + window_secs: int + bucket_secs: int + total_events_in_window: int + buckets: list[TimeseriesBucket] + note: str + + +class CacheSavings(TypedDict): + tenant_id: str + window_mins: int + cache_hits: int + cacheable_lookups: int + saved_cost_micro_usd: int + saved_tokens: int + note: str + + +class SaverCache(TypedDict): + hits: int + misses: int + cacheable_lookups: int + hit_rate: float + saved_cost_micro_usd: int + saved_tokens: int + + +class SaverTenant(TypedDict): + cache: SaverCache + note: str + + +class SaverMetrics(TypedDict): + tenant_id: str + window_mins: int + tenant: SaverTenant + not_instrumented: dict[str, str] + + +# Durable daily estimated usage; not a provider invoice or reconciled bill. +# Functional syntax is required because `from` is a Python keyword but is the +# exact JSON field name on the wire. +DailyUsageReport = TypedDict( + "DailyUsageReport", + { + "tenant_id": str, + "from": str, + "to": str, + "days": list[DailyUsage], + "totals": DailyUsageTotals, + "note": str, + }, +) + + +def _legacy_window_mins(from_date: str, to_date: str) -> int: + """Convert a legacy absolute range to the relative window the server supports.""" + + def parse(value: str) -> datetime: + try: + # Python 3.9's fromisoformat does not accept the common trailing Z. + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("legacy timeseries dates must be ISO-8601 values") from exc + + start = parse(from_date) + end = parse(to_date) + try: + seconds = (end - start).total_seconds() + except TypeError as exc: + raise ValueError("legacy timeseries dates must use compatible timezones") from exc + if seconds < 0: + raise ValueError("legacy timeseries from_date must not be after to_date") + return max(1, math.ceil(seconds / 60)) class FinopsResource(BaseResource): """Usage rollups, cost time-series, and cost-saver metrics.""" - def usage(self) -> dict[str, Any]: - """``GET /v1/finops/usage`` — the durable usage summary.""" - data: dict[str, Any] = self._get("finops/usage").json() + def usage(self) -> UsageData: + """``GET /v1/finops/usage`` — recent process-local estimated usage.""" + data: UsageData = self._get("finops/usage").json() return data def usage_daily( @@ -22,29 +242,54 @@ def usage_daily( *, from_date: Optional[str] = None, to_date: Optional[str] = None, - ) -> list[dict[str, Any]]: - """``GET /v1/finops/usage/daily`` — daily usage rollups over a date range.""" + ) -> DailyUsageReport: + """Return the complete durable daily report envelope. + + The response retains its tenant/date scope, totals, and provenance note; + monetary fields are null unless the adjacent pricing evidence proves + complete, versioned coverage. A fully priced zero remains numeric zero; + costs are gateway price-book estimates, not billed or reconciled amounts. + """ params = prune_none({"from": from_date, "to": to_date}) - data: list[dict[str, Any]] = self._get("finops/usage/daily", params=params).json() + data: DailyUsageReport = self._get("finops/usage/daily", params=params).json() return data def timeseries( self, *, + window_mins: Optional[int] = None, + buckets: Optional[int] = None, from_date: Optional[str] = None, to_date: Optional[str] = None, - ) -> dict[str, Any]: - """``GET /v1/finops/timeseries`` — cost/usage time-series.""" - params = prune_none({"from": from_date, "to": to_date}) - data: dict[str, Any] = self._get("finops/timeseries", params=params).json() + ) -> TimeseriesData: + """Return the recent process-local cost/usage time series. + + ``window_mins`` and ``buckets`` are the gateway's native query contract. + The legacy ``from_date``/``to_date`` pair remains accepted for source + compatibility, but is converted to a relative duration and emits a + :class:`DeprecationWarning`; it cannot select absolute durable history. + """ + if from_date is not None or to_date is not None: + if window_mins is not None or buckets is not None: + raise ValueError("do not combine timeseries date-range and window options") + if from_date is None or to_date is None: + raise ValueError("legacy timeseries ranges require both from_date and to_date") + warnings.warn( + "timeseries(from_date=..., to_date=...) is deprecated; use window_mins=...", + DeprecationWarning, + stacklevel=2, + ) + window_mins = _legacy_window_mins(from_date, to_date) + params = prune_none({"window_mins": window_mins, "buckets": buckets}) + data: TimeseriesData = self._get("finops/timeseries", params=params).json() return data - def cache_savings(self) -> dict[str, Any]: + def cache_savings(self) -> CacheSavings: """``GET /v1/finops/cache-savings`` — spend avoided by the response cache.""" - data: dict[str, Any] = self._get("finops/cache-savings").json() + data: CacheSavings = self._get("finops/cache-savings").json() return data - def saver_metrics(self) -> dict[str, Any]: - """``GET /v1/finops/saver-metrics`` — difficulty-router / downgrade savings.""" - data: dict[str, Any] = self._get("finops/saver-metrics").json() + def saver_metrics(self) -> SaverMetrics: + """Return recent cache saver metrics plus explicit uninstrumented reasons.""" + data: SaverMetrics = self._get("finops/saver-metrics").json() return data diff --git a/tests/test_headers.py b/tests/test_headers.py index 292a7b2..621ecc3 100644 --- a/tests/test_headers.py +++ b/tests/test_headers.py @@ -1,6 +1,8 @@ import json import re +import pytest + from routeplane import headers from routeplane.headers import HEADER_NAMES @@ -43,11 +45,12 @@ def test_int_headers_are_stringified(): assert isinstance(result["x-routeplane-timeout-ms"], str) -def test_all_seventeen_headers_present(): +def test_all_eighteen_headers_present(): result = headers( provider="openai", residency="IN", strategy="latency", + canary_share_bps=500, config={"a": 1}, timeout_ms=1000, use_case="chatbot", @@ -65,7 +68,7 @@ def test_all_seventeen_headers_present(): ) # Every declared header name is produced exactly once. assert set(result.keys()) == set(HEADER_NAMES.values()) - assert len(result) == 17 + assert len(result) == 18 def test_header_name_mapping_is_kebab_prefixed(): @@ -100,4 +103,20 @@ def test_strategy_hint_covers_every_gateway_strategy(): "latency", "round_robin", "least_busy", + "canary", } + + +def test_canary_and_prompt_cohort_headers_are_distinct(): + result = headers(strategy="canary", canary_share_bps=500, cohort="prompt-arm-b") + assert result == { + "x-routeplane-strategy": "canary", + "x-routeplane-canary-share-bps": "500", + "x-routeplane-cohort": "prompt-arm-b", + } + + +def test_canary_share_is_bounded_basis_points(): + for invalid in (-1, 10001): + with pytest.raises(ValueError, match="between 0 and 10000"): + headers(canary_share_bps=invalid) diff --git a/tests/test_meta.py b/tests/test_meta.py index 34af41f..bfef0c2 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -1,4 +1,4 @@ -from routeplane import RouteplaneMeta +from routeplane import RouteplaneMeta, RouteplaneRateLimits def test_empty_headers_yield_defaults(): @@ -9,6 +9,24 @@ def test_empty_headers_yield_defaults(): assert meta.shed is False assert meta.pii_masked is False assert meta.idempotent_replayed is False + assert meta.rate_limits is None + + +def test_standard_rate_limit_headers_are_typed(): + meta = RouteplaneMeta.from_headers( + { + "x-ratelimit-limit-requests": "100", + "x-ratelimit-remaining-requests": "97", + "x-ratelimit-reset-requests": "42", + "x-ratelimit-limit-tokens": "100000", + } + ) + assert meta.rate_limits == RouteplaneRateLimits( + requests_limit=100, + requests_remaining=97, + requests_reset="42", + tokens_limit=100000, + ) def test_parses_string_fields(): diff --git a/tests/test_resources.py b/tests/test_resources.py index 73d4bd5..e73033e 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -147,22 +147,150 @@ def test_finops_usage(): @respx.mock def test_finops_usage_daily_range(): + available = { + "data_state": "live", + "availability": "available", + "value": 0, + "unit": "micro_currency", + "currency": "USD", + "scale": 6, + "cost_status": "estimated", + "source": { + "class": "durable_rollup", + "systems": ["telemetry_rung_1"], + "schema_version": "routeplane.finops.cost.v1", + }, + "coverage": { + "eligible_count": 1, + "observed_count": 1, + "priced_count": 1, + "unpriced_count": 0, + "invalid_pricing_count": 0, + "versioned_priced_count": 1, + "pricing_coverage_state": "known", + "pricing_book_versions": ["pb_1"], + "pricing_book_versions_truncated": False, + "numeric_overflowed": False, + "missing_reasons": [], + }, + "component_coverage": { + "input_output_split_available": False, + "inr_view_available": False, + }, + } + report = { + "tenant_id": "t_1", + "from": "2026-07-01", + "to": "2026-07-20", + "days": [], + "totals": { + "requests": 1, + "errors": 0, + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + "cost_micro_usd": 0, + "input_cost_micro_usd": None, + "output_cost_micro_usd": None, + "cost_inr_paise": None, + "pricing": available, + }, + "note": "durable estimated usage", + } route = respx.get(f"{BASE}/finops/usage/daily").mock( - return_value=httpx.Response(200, json=[{"day": "2026-07-01"}]) + return_value=httpx.Response(200, json=report) ) out = FinopsResource(**_kwargs()).usage_daily(from_date="2026-07-01", to_date="2026-07-20") - assert out == [{"day": "2026-07-01"}] + assert out == report params = _sent(route).url.params assert params["from"] == "2026-07-01" assert params["to"] == "2026-07-20" +@respx.mock +def test_finops_usage_daily_preserves_unavailable_cost_and_usage(): + unavailable = { + "data_state": "unavailable", + "availability": "unavailable", + "value": None, + "unit": "micro_currency", + "currency": "USD", + "scale": 6, + "cost_status": "unavailable", + "source": { + "class": "durable_rollup", + "systems": ["telemetry_rung_1"], + "schema_version": "routeplane.finops.cost.v1", + }, + "coverage": { + "eligible_count": 1, + "observed_count": 1, + "priced_count": 0, + "unpriced_count": 0, + "invalid_pricing_count": 1, + "versioned_priced_count": 0, + "pricing_coverage_state": "corrupt", + "pricing_book_versions": [], + "pricing_book_versions_truncated": False, + "numeric_overflowed": False, + "missing_reasons": ["pricing_coverage_corrupt"], + }, + "component_coverage": { + "input_output_split_available": False, + "inr_view_available": False, + }, + } + report = { + "tenant_id": "t_1", + "from": "2026-07-01", + "to": "2026-07-01", + "days": [], + "totals": { + "requests": 1, + "errors": 0, + "prompt_tokens": 4, + "completion_tokens": 2, + "total_tokens": 6, + "cost_micro_usd": None, + "input_cost_micro_usd": None, + "output_cost_micro_usd": None, + "cost_inr_paise": None, + "pricing": unavailable, + }, + "note": "pricing unavailable", + } + respx.get(f"{BASE}/finops/usage/daily").mock(return_value=httpx.Response(200, json=report)) + + out = FinopsResource(**_kwargs()).usage_daily() + assert out["totals"]["cost_micro_usd"] is None + assert out["totals"]["pricing"]["coverage"]["pricing_coverage_state"] == "corrupt" + assert out["totals"]["total_tokens"] == 6 + + @respx.mock def test_finops_timeseries(): - respx.get(f"{BASE}/finops/timeseries").mock( + route = respx.get(f"{BASE}/finops/timeseries").mock( return_value=httpx.Response(200, json={"points": []}) ) - assert FinopsResource(**_kwargs()).timeseries() == {"points": []} + assert FinopsResource(**_kwargs()).timeseries(window_mins=60, buckets=12) == {"points": []} + assert dict(_sent(route).url.params) == {"window_mins": "60", "buckets": "12"} + + +@respx.mock +def test_finops_timeseries_legacy_range_is_explicitly_converted(): + route = respx.get(f"{BASE}/finops/timeseries").mock( + return_value=httpx.Response(200, json={"buckets": []}) + ) + with pytest.warns(DeprecationWarning, match="window_mins"): + FinopsResource(**_kwargs()).timeseries( + from_date="2026-07-01T00:00:00Z", to_date="2026-07-01T02:00:00Z" + ) + assert dict(_sent(route).url.params) == {"window_mins": "120"} + + +def test_finops_timeseries_rejects_a_partial_legacy_range(): + with pytest.raises(ValueError, match="require both"): + FinopsResource(**_kwargs()).timeseries(from_date="2026-07-01") @respx.mock