Coverage for src / lexigram / ai / relay / gateway / di / provider.py: 100%

79 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-08 23:08 +0800

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.channels import RelayChannelRegistry 

14from lexigram.ai.relay.gateway.codec import RelayPayloadCodec 

15from lexigram.ai.relay.gateway.config import RelayGatewayConfig 

16from lexigram.ai.relay.gateway.operations.auto_test import RelayChannelAutoTester 

17from lexigram.ai.relay.gateway.operations.controls import ( 

18 InMemoryRelayPolicyStore, 

19 RelayControlsService, 

20) 

21from lexigram.ai.relay.gateway.operations.health import ( 

22 RelayChannelCheckerProtocol, 

23 RelayHealthService, 

24) 

25from lexigram.ai.relay.gateway.operations.metrics import ( 

26 RelayMetricsService, 

27 RelayRouteEventSourceProtocol, 

28) 

29from lexigram.ai.relay.gateway.operations.streams import RelayStreamRegistry 

30from lexigram.ai.relay.gateway.passthrough import PassthroughService 

31from lexigram.ai.relay.gateway.service import RelayGatewayService 

32from lexigram.ai.relay.gateway.upstream import HTTPUpstreamAdapter 

33from lexigram.contracts.ai.governance import ( 

34 AIAuditStoreProtocol, 

35 RelayBillingProtocol, 

36) 

37from lexigram.contracts.ai.relay import ( 

38 MediaResolverProtocol, 

39 RelayConverterProtocol, 

40 RelayGatewayProtocol, 

41 RelayPolicyStoreProtocol, 

42 RelayRegistryProtocol, 

43) 

44from lexigram.contracts.auth.guard import AuthorizerProtocol 

45from lexigram.contracts.core.provider import ProviderPriority 

46from lexigram.contracts.web import HTTPClientProtocol 

47from lexigram.di.provider import Provider 

48from lexigram.logging import get_logger 

49 

50if TYPE_CHECKING: 

51 from lexigram.contracts.core.di import ( 

52 BootContainerProtocol, 

53 ContainerRegistrarProtocol, 

54 ) 

55 

56logger = get_logger(__name__) 

57 

58 

59class RelayGatewayProvider(Provider): 

60 """Provider registering the relay gateway behind ``RelayGatewayProtocol``. 

61 

62 The caller owns the static configuration, the conversion engine, and 

63 the HTTP client; the provider wires them into a ready-to-serve 

64 :class:`RelayGatewayService`. Optional governance hooks (authorizer, 

65 media resolver, billing) are forwarded to the service as-is. 

66 

67 Registers: 

68 - ``RelayGatewayConfig`` — the injected configuration (always) 

69 - ``RelayChannelRegistry`` — a registry built from the configuration 

70 - ``RelayPolicyStoreProtocol`` — the runtime policy backend (always) 

71 - ``RelayHealthService`` — channel health probing (always) 

72 - ``RelayMetricsService`` — route metrics aggregation (always) 

73 - ``RelayControlsService`` — permissioned control mutations (always) 

74 - ``RelayChannelAutoTester`` — background channel auto-tester (only 

75 when ``auto_test_channels`` is enabled in the configuration) 

76 - ``RelayGatewayProtocol`` — the gateway service (only when both the 

77 converter and an HTTP client are available) 

78 - ``PassthroughService`` — passthrough endpoint dispatch (same 

79 availability as the gateway service) 

80 

81 Args: 

82 config: Gateway channel table and conversion metadata. Defaults 

83 to an empty configuration when omitted. 

84 converter: Conversion engine implementing 

85 ``RelayConverterProtocol``. When ``None`` a startup 

86 diagnostic is logged and the gateway binding is skipped. 

87 http_client: HTTP client driving the upstream adapter. When 

88 ``None`` a startup diagnostic is logged and the gateway 

89 binding is skipped. 

90 authorizer: Optional authorizer enforced before dispatch. 

91 media_resolver: Optional media resolver placed on the conversion 

92 context. 

93 billing: Optional billing lifecycle; when ``None`` the gateway 

94 runs without admission control or settlement. 

95 converter_registry: Converter registry backing health and metrics 

96 diagnostics and route quality. Optional; when ``None`` the 

97 diagnostic surfaces are unavailable (``DEPENDENCY_UNAVAILABLE``). 

98 channel_checker: Optional channel checker driving per-channel 

99 probes; when ``None`` the health service reports unchecked 

100 channels. 

101 metrics_events: Optional route event source feeding metrics 

102 aggregation; when ``None`` the metrics surface is unavailable. 

103 policy_store: Optional runtime policy backend. ``None`` installs 

104 an in-process ``InMemoryRelayPolicyStore`` seeded from the 

105 configuration. 

106 audit: Optional audit backend for control mutations. ``None`` 

107 disables audit emission. 

108 """ 

109 

110 name = "ai-relay-gateway" 

111 priority = ProviderPriority.DOMAIN 

112 

113 def __init__( 

114 self, 

115 *, 

116 config: RelayGatewayConfig | None = None, 

117 converter: RelayConverterProtocol | None = None, 

118 http_client: HTTPClientProtocol | None = None, 

119 authorizer: AuthorizerProtocol | None = None, 

120 media_resolver: MediaResolverProtocol | None = None, 

121 billing: RelayBillingProtocol | None = None, 

122 converter_registry: RelayRegistryProtocol | None = None, 

123 channel_checker: RelayChannelCheckerProtocol | None = None, 

124 metrics_events: RelayRouteEventSourceProtocol | None = None, 

125 policy_store: RelayPolicyStoreProtocol | None = None, 

126 audit: AIAuditStoreProtocol | None = None, 

127 ) -> None: 

128 super().__init__() 

129 self._config = config if config is not None else RelayGatewayConfig() 

130 self._converter = converter 

131 self._http_client = http_client 

132 self._authorizer = authorizer 

133 self._media_resolver = media_resolver 

134 self._billing = billing 

135 self._converter_registry = converter_registry 

136 self._channel_checker = channel_checker 

137 self._metrics_events = metrics_events 

138 self._policy_store = ( 

139 policy_store 

140 if policy_store is not None 

141 else InMemoryRelayPolicyStore.with_defaults(self._config) 

142 ) 

143 self._audit = audit 

144 self._auto_tester: RelayChannelAutoTester | None = None 

145 

146 async def register(self, container: ContainerRegistrarProtocol) -> None: 

147 """Register the gateway configuration, registry, and service. 

148 

149 The configuration and registry are always bound. The gateway 

150 service itself is only bound when the converter and HTTP client 

151 are both present; otherwise a startup diagnostic is logged so the 

152 missing dependency is discoverable. 

153 

154 Args: 

155 container: The container registrar to bind into. 

156 """ 

157 registry = RelayChannelRegistry(self._config) 

158 streams = RelayStreamRegistry() 

159 container.singleton(RelayGatewayConfig, self._config) 

160 container.singleton(RelayChannelRegistry, registry) 

161 container.singleton(RelayStreamRegistry, streams) 

162 container.singleton(RelayPolicyStoreProtocol, self._policy_store) 

163 health_service = RelayHealthService( 

164 registry=registry, 

165 checker=self._channel_checker, 

166 converter=self._converter_registry, 

167 policy=self._policy_store, 

168 ) 

169 container.singleton(RelayHealthService, health_service) 

170 if self._config.auto_test_channels: 

171 self._auto_tester = RelayChannelAutoTester( 

172 health=health_service, 

173 registry=registry, 

174 interval_seconds=self._config.auto_test_interval_seconds, 

175 ) 

176 container.singleton(RelayChannelAutoTester, self._auto_tester) 

177 metrics_service = RelayMetricsService( 

178 events=self._metrics_events, 

179 converter=self._converter_registry, 

180 ) 

181 container.singleton(RelayMetricsService, metrics_service) 

182 controls_service = RelayControlsService( 

183 registry=registry, 

184 store=self._policy_store, 

185 authorizer=self._authorizer, 

186 audit=self._audit, 

187 streams=streams, 

188 ) 

189 container.singleton(RelayControlsService, controls_service) 

190 if self._converter is None: 

191 logger.warning( 

192 "relay_gateway_missing_dependency", 

193 missing="RelayConverterProtocol", 

194 ) 

195 return 

196 if self._http_client is None: 

197 logger.warning( 

198 "relay_gateway_missing_dependency", 

199 missing="HTTPClientProtocol", 

200 ) 

201 return 

202 service = RelayGatewayService( 

203 converter=self._converter, 

204 codec=RelayPayloadCodec(), 

205 registry=registry, 

206 upstream=HTTPUpstreamAdapter(self._http_client), 

207 config=self._config, 

208 authorizer=self._authorizer, 

209 billing=self._billing, 

210 media_resolver=self._media_resolver, 

211 streams=streams, 

212 ) 

213 container.singleton(RelayGatewayProtocol, service) 

214 passthrough_service = PassthroughService( 

215 registry=registry, 

216 upstream=HTTPUpstreamAdapter(self._http_client), 

217 config=self._config, 

218 authorizer=self._authorizer, 

219 billing=self._billing, 

220 ) 

221 container.singleton(PassthroughService, passthrough_service) 

222 logger.info("relay_gateway_provider_registered") 

223 

224 async def boot(self, container: BootContainerProtocol) -> None: 

225 """Reconcile persisted policy drains into runtime selection. 

226 

227 Channels the policy store marks disabled (while the static 

228 configuration still enables them) are drained in the runtime 

229 registry so dispatch honors the persisted policy from the first 

230 request onward. When auto-testing is enabled, the background 

231 sweep is started after reconciliation. 

232 

233 Args: 

234 container: The booted container used to resolve the policy 

235 store and channel registry. 

236 """ 

237 registry = await container.resolve(RelayChannelRegistry) 

238 policy = await container.resolve(RelayPolicyStoreProtocol) 

239 snapshot = await policy.load() 

240 for channel in self._config.channels: 

241 if channel.enabled and not snapshot.enabled_channels.get( 

242 channel.name, True 

243 ): 

244 registry.set_runtime_enabled(channel.name, False) 

245 if self._auto_tester is not None: 

246 await self._auto_tester.start() 

247 logger.info("relay_gateway_provider_booted") 

248 

249 async def shutdown(self) -> None: 

250 """Stop the background auto-tester, if one was started.""" 

251 if self._auto_tester is not None: 

252 await self._auto_tester.stop() 

253 

254 

255__all__ = ["RelayGatewayProvider"]