1"""RelayGatewayProvider — registers the relay gateway behind its contract.
2
3The provider composes the gateway service from caller-owned dependencies.
4Configuration, the conversion engine, and the HTTP client are injected at
5construction; the registry, codec, upstream adapter, and service are built
6and registered against the container.
7"""
8
9from __future__ import annotations
10
11from typing import TYPE_CHECKING
12
13from lexigram.ai.relay.gateway.catalog import ModelCatalogService
14from lexigram.ai.relay.gateway.channels import RelayChannelRegistry
15from lexigram.ai.relay.gateway.codec import RelayPayloadCodec
16from lexigram.ai.relay.gateway.config import RelayGatewayConfig
17from lexigram.ai.relay.gateway.loader import DurableChannelLoader
18from lexigram.ai.relay.gateway.operations.auto_test import RelayChannelAutoTester
19from lexigram.ai.relay.gateway.operations.controls import (
20 InMemoryRelayPolicyStore,
21 RelayControlsService,
22)
23from lexigram.ai.relay.gateway.operations.failover import RelayFailoverTracker
24from lexigram.ai.relay.gateway.operations.health import (
25 RelayChannelCheckerProtocol,
26 RelayHealthService,
27)
28from lexigram.ai.relay.gateway.operations.metrics import (
29 RelayMetricsService,
30 RelayRouteEventSourceProtocol,
31)
32from lexigram.ai.relay.gateway.operations.streams import RelayStreamRegistry
33from lexigram.ai.relay.gateway.passthrough import PassthroughService
34from lexigram.ai.relay.gateway.service import RelayGatewayService
35from lexigram.ai.relay.gateway.upstream import HTTPUpstreamAdapter
36from lexigram.contracts.ai.governance import (
37 AIAuditStoreProtocol,
38 RelayBillingProtocol,
39)
40from lexigram.contracts.ai.relay import (
41 MediaResolverProtocol,
42 RelayChannelStoreProtocol,
43 RelayConverterProtocol,
44 RelayGatewayProtocol,
45 RelayPolicyStoreProtocol,
46 RelayRegistryProtocol,
47)
48from lexigram.contracts.auth.guard import AuthorizerProtocol
49from lexigram.contracts.core.provider import ProviderPriority
50from lexigram.contracts.web import HTTPClientProtocol
51from lexigram.di.provider import Provider
52from lexigram.logging import get_logger
53
54if TYPE_CHECKING:
55 from lexigram.contracts.core.di import (
56 BootContainerProtocol,
57 ContainerRegistrarProtocol,
58 )
59
60logger = get_logger(__name__)
61
62
63class RelayGatewayProvider(Provider):
64 """Provider registering the relay gateway behind ``RelayGatewayProtocol``.
65
66 The caller owns the static configuration, the conversion engine, and
67 the HTTP client; the provider wires them into a ready-to-serve
68 :class:`RelayGatewayService`. Optional governance hooks (authorizer,
69 media resolver, billing) are forwarded to the service as-is.
70
71 Configuration is explicit-only (a frozen channel/conversion table);
72 the gateway is not bound to a ``LexigramConfig`` section, so this
73 provider declares no ``config_key``/``config_model`` attributes.
74
75 Registers:
76 - ``RelayGatewayConfig`` — the injected configuration (always)
77 - ``RelayChannelRegistry`` — a registry built from the configuration
78 - ``RelayPolicyStoreProtocol`` — the runtime policy backend (always)
79 - ``RelayHealthService`` — channel health probing (always)
80 - ``RelayMetricsService`` — route metrics aggregation (always)
81 - ``RelayControlsService`` — permissioned control mutations (always)
82 - ``RelayChannelAutoTester`` — background channel auto-tester (only
83 when ``auto_test_channels`` is enabled in the configuration)
84 - ``RelayFailoverTracker`` — reactive consecutive-failure tracking
85 (only when ``auto_disable_on_failures`` is enabled)
86 - ``RelayGatewayProtocol`` — the gateway service (only when both the
87 converter and an HTTP client are available)
88 - ``PassthroughService`` — passthrough endpoint dispatch (same
89 availability as the gateway service)
90 - ``ModelCatalogService`` — the served-model catalog (always)
91
92 Args:
93 config: Gateway channel table and conversion metadata. Defaults
94 to an empty configuration when omitted.
95 converter: Conversion engine implementing
96 ``RelayConverterProtocol``. When ``None`` a startup
97 diagnostic is logged and the gateway binding is skipped.
98 http_client: HTTP client driving the upstream adapter. When
99 ``None`` a startup diagnostic is logged and the gateway
100 binding is skipped.
101 authorizer: Optional authorizer enforced before dispatch.
102 media_resolver: Optional media resolver placed on the conversion
103 context.
104 billing: Optional billing lifecycle; when ``None`` the gateway
105 runs without admission control or settlement.
106 converter_registry: Converter registry backing health and metrics
107 diagnostics and route quality. Optional; when ``None`` the
108 diagnostic surfaces are unavailable (``DEPENDENCY_UNAVAILABLE``).
109 channel_checker: Optional channel checker driving per-channel
110 probes; when ``None`` the health service reports unchecked
111 channels.
112 metrics_events: Optional route event source feeding metrics
113 aggregation; when ``None`` the metrics surface is unavailable.
114 policy_store: Optional runtime policy backend. ``None`` installs
115 an in-process ``InMemoryRelayPolicyStore`` seeded from the
116 configuration.
117 audit: Optional audit backend for control mutations. ``None``
118 disables audit emission.
119 """
120
121 name = "ai-relay-gateway"
122 priority = ProviderPriority.DOMAIN
123
124 def __init__(
125 self,
126 *,
127 config: RelayGatewayConfig | None = None,
128 converter: RelayConverterProtocol | None = None,
129 http_client: HTTPClientProtocol | None = None,
130 authorizer: AuthorizerProtocol | None = None,
131 media_resolver: MediaResolverProtocol | None = None,
132 billing: RelayBillingProtocol | None = None,
133 converter_registry: RelayRegistryProtocol | None = None,
134 channel_checker: RelayChannelCheckerProtocol | None = None,
135 metrics_events: RelayRouteEventSourceProtocol | None = None,
136 policy_store: RelayPolicyStoreProtocol | None = None,
137 audit: AIAuditStoreProtocol | None = None,
138 ) -> None:
139 super().__init__()
140 self._config = config if config is not None else RelayGatewayConfig()
141 self._converter = converter
142 self._http_client = http_client
143 self._authorizer = authorizer
144 self._media_resolver = media_resolver
145 self._billing = billing
146 self._converter_registry = converter_registry
147 self._channel_checker = channel_checker
148 self._metrics_events = metrics_events
149 self._policy_store = (
150 policy_store
151 if policy_store is not None
152 else InMemoryRelayPolicyStore.with_defaults(self._config)
153 )
154 self._audit = audit
155 self._auto_tester: RelayChannelAutoTester | None = None
156 self._failover: RelayFailoverTracker | None = None
157 self._registry: RelayChannelRegistry | None = None
158 self._stream_registry: RelayStreamRegistry | None = None
159 self._service_bound = False
160
161 async def register(self, container: ContainerRegistrarProtocol) -> None:
162 """Register the gateway configuration, registry, and service.
163
164 The configuration and registry are always bound. The gateway
165 service itself is only bound when the converter and HTTP client
166 are both present; otherwise a startup diagnostic is logged so the
167 missing dependency is discoverable.
168
169 Args:
170 container: The container registrar to bind into.
171 """
172 registry = RelayChannelRegistry(self._config)
173 streams = RelayStreamRegistry()
174 self._registry = registry
175 self._stream_registry = streams
176 container.singleton(RelayGatewayConfig, self._config)
177 container.singleton(RelayChannelRegistry, registry)
178 container.singleton(RelayStreamRegistry, streams)
179 container.singleton(RelayPolicyStoreProtocol, self._policy_store)
180 health_service = RelayHealthService(
181 registry=registry,
182 checker=self._channel_checker,
183 converter=self._converter_registry,
184 policy=self._policy_store,
185 )
186 container.singleton(RelayHealthService, health_service)
187 container.singleton(
188 ModelCatalogService,
189 ModelCatalogService(registry=registry),
190 )
191 if self._config.auto_test_channels:
192 self._auto_tester = RelayChannelAutoTester(
193 health=health_service,
194 registry=registry,
195 interval_seconds=self._config.auto_test_interval_seconds,
196 )
197 container.singleton(RelayChannelAutoTester, self._auto_tester)
198 if self._config.auto_disable_on_failures:
199 self._failover = RelayFailoverTracker(
200 registry=registry,
201 threshold=self._config.failover_failure_threshold,
202 )
203 container.singleton(RelayFailoverTracker, self._failover)
204 metrics_service = RelayMetricsService(
205 events=self._metrics_events,
206 converter=self._converter_registry,
207 )
208 container.singleton(RelayMetricsService, metrics_service)
209 controls_service = RelayControlsService(
210 registry=registry,
211 store=self._policy_store,
212 authorizer=self._authorizer,
213 audit=self._audit,
214 streams=streams,
215 )
216 container.singleton(RelayControlsService, controls_service)
217 if self._converter is None:
218 logger.warning(
219 "relay_gateway_missing_dependency",
220 missing="RelayConverterProtocol",
221 )
222 return
223 if self._http_client is None:
224 logger.warning(
225 "relay_gateway_missing_dependency",
226 missing="HTTPClientProtocol",
227 )
228 return
229 self._register_services(container, self._converter, self._http_client)
230
231 def _register_services(
232 self,
233 container: ContainerRegistrarProtocol,
234 converter: RelayConverterProtocol,
235 http_client: HTTPClientProtocol,
236 ) -> None:
237 """Bind the gateway and passthrough services to the container.
238
239 The failover tracker, when enabled, is shared with the gateway
240 service so consecutive upstream failures adjust runtime
241 selection state.
242
243 Args:
244 container: The container registrar to bind into.
245 converter: The conversion engine the gateway converts with.
246 http_client: The HTTP client driving the upstream adapter.
247 """
248 registry = (
249 self._registry
250 if self._registry is not None
251 else RelayChannelRegistry(self._config)
252 )
253 streams = (
254 self._stream_registry
255 if self._stream_registry is not None
256 else RelayStreamRegistry()
257 )
258 service = RelayGatewayService(
259 converter=converter,
260 codec=RelayPayloadCodec(),
261 registry=registry,
262 upstream=HTTPUpstreamAdapter(http_client),
263 config=self._config,
264 authorizer=self._authorizer,
265 billing=self._billing,
266 media_resolver=self._media_resolver,
267 streams=streams,
268 failover=self._failover,
269 )
270 container.singleton(RelayGatewayProtocol, service)
271 passthrough_service = PassthroughService(
272 registry=registry,
273 upstream=HTTPUpstreamAdapter(http_client),
274 config=self._config,
275 authorizer=self._authorizer,
276 billing=self._billing,
277 )
278 container.singleton(PassthroughService, passthrough_service)
279 self._service_bound = True
280 logger.info("relay_gateway_provider_registered")
281
282 async def boot(self, container: BootContainerProtocol) -> None:
283 """Reconcile durable channels and policy drains into selection.
284
285 When the container resolves ``RelayChannelStoreProtocol``, the
286 durable rows are merged over the static configuration and
287 installed in the runtime registry before the policy drain runs.
288 Channels the policy store marks disabled (while the static
289 configuration still enables them) are drained in the runtime
290 registry so dispatch honors the persisted policy from the first
291 request onward. When no converter or HTTP client was injected at
292 construction, the container's own ``RelayConverterProtocol`` and
293 ``HTTPClientProtocol`` bindings are resolved here and the gateway
294 services are bound late, so the module-only composition
295 (``RelayModule`` + ``RelayGatewayModule`` + ``HTTPModule``) gets
296 a working gateway without caller-owned instances. When
297 auto-testing is enabled, the background sweep is started after
298 reconciliation.
299
300 Args:
301 container: The booted container used to resolve the policy
302 store, channel registry, optional channel store, and
303 late-bound gateway dependencies.
304 """
305 registry = await container.resolve(RelayChannelRegistry)
306 policy = await container.resolve(RelayPolicyStoreProtocol)
307 channels = self._config.channels
308 store = await container.resolve_optional(RelayChannelStoreProtocol)
309 if store is not None:
310 container.bind(RelayChannelStoreProtocol, store)
311 loader = DurableChannelLoader(store)
312 merged = await loader.load(self._config.channels)
313 if merged is not self._config.channels:
314 registry.reload(merged)
315 channels = merged
316 logger.info("relay_gateway_channels_reconciled", count=len(merged))
317 snapshot = await policy.load()
318 for channel in channels:
319 if channel.enabled and not snapshot.enabled_channels.get(
320 channel.name, True
321 ):
322 registry.set_runtime_enabled(channel.name, False)
323 if not self._service_bound:
324 converter = await container.resolve_optional(RelayConverterProtocol)
325 http_client = await container.resolve_optional(HTTPClientProtocol)
326 if converter is not None and http_client is not None:
327 self._register_services(container, converter, http_client)
328 logger.info(
329 "relay_gateway_provider_late_bound",
330 dependency="container",
331 )
332 else:
333 logger.warning(
334 "relay_gateway_missing_dependency",
335 missing="RelayConverterProtocol/HTTPClientProtocol",
336 )
337 if self._auto_tester is not None:
338 await self._auto_tester.start()
339 logger.info("relay_gateway_provider_booted")
340
341 async def shutdown(self) -> None:
342 """Stop the background auto-tester, if one was started."""
343 if self._auto_tester is not None:
344 await self._auto_tester.stop()
345
346
347__all__ = ["RelayGatewayProvider"]