Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-relay-gateway/src/lexigram/ai/relay/gateway/di/provider.py: 61%

114 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 07:19 +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.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"]