Coverage for src / lexigram / ai / relay / registry.py: 96%

114 statements  

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

1"""Caller-owned relay registry with derived directed routes. 

2 

3The registry stores one :class:`FormatMapper` per wire format and derives 

4the twelve directed route objects (including their static route metadata) 

5on demand. It implements :class:`RelayRegistryProtocol`; concrete routes 

6also carry a :class:`RouteSpec` so the engine can attach quality, loss, 

7and capability metadata to every conversion result. 

8""" 

9 

10from __future__ import annotations 

11 

12from dataclasses import dataclass 

13from typing import Any, cast 

14 

15from lexigram.ai.relay.context import ConversionContext 

16from lexigram.ai.relay.errors import duplicate_registration, unsupported_format 

17from lexigram.ai.relay.mappers.base import FormatMapper 

18from lexigram.ai.relay.mappers.claude import ClaudeMapper 

19from lexigram.ai.relay.mappers.gemini import GeminiMapper 

20from lexigram.ai.relay.mappers.openai_chat import OpenAIChatMapper 

21from lexigram.ai.relay.mappers.openai_responses import OpenAIResponsesMapper 

22from lexigram.ai.relay.quality import route_quality 

23from lexigram.contracts.ai.exceptions import RelayError 

24from lexigram.contracts.ai.relay.ir import ( 

25 RelayRequest, 

26 RelayResponse, 

27 StreamDelta, 

28 StreamState, 

29) 

30from lexigram.contracts.ai.relay.protocols import ( 

31 RelayMapperProtocol, 

32 RelayRegistryProtocol, 

33) 

34from lexigram.contracts.ai.relay.types import ConversionQuality, RelayFormat 

35from lexigram.contracts.core.result import Result 

36 

37__all__ = [ 

38 "CONVERTER_VERSION", 

39 "RelayConverterRegistry", 

40 "Route", 

41 "RouteSpec", 

42] 

43 

44CONVERTER_VERSION = "1.0.0" 

45"""Converter engine version reported in operational diagnostics.""" 

46 

47 

48@dataclass(frozen=True) 

49class RouteSpec: 

50 """Static metadata for one directed conversion route. 

51 

52 Attributes: 

53 source: Source wire format. 

54 target: Target wire format. 

55 quality: Semantic closeness of the conversion. 

56 request_supported: Whether request conversion is available. 

57 response_supported: Whether response conversion is available. 

58 stream_supported: Whether stateful stream conversion is available. 

59 feature_loss_policy: Machine-readable loss reasons this route is 

60 expected to record, in order. 

61 

62 Properties: 

63 converter_id: Stable ``"<source>_to_<target>"`` identifier. 

64 """ 

65 

66 source: RelayFormat 

67 target: RelayFormat 

68 quality: ConversionQuality 

69 request_supported: bool = True 

70 response_supported: bool = True 

71 stream_supported: bool = False 

72 feature_loss_policy: tuple[str, ...] = () 

73 

74 @property 

75 def converter_id(self) -> str: 

76 """Stable ``"<source>_to_<target>"`` identifier for this route.""" 

77 return f"{self.source.value}_to_{self.target.value}" 

78 

79 

80@dataclass(frozen=True) 

81class Route: 

82 """A delegating mapper for one directed pair plus its route spec. 

83 

84 ``request_to_ir`` / ``ir_to_request`` delegate to the source/target 

85 mapper respectively; stream operations delegate the same way and are 

86 finalized by the shared stream lifecycle in a later task. 

87 """ 

88 

89 spec: RouteSpec 

90 source_mapper: FormatMapper 

91 target_mapper: FormatMapper 

92 

93 def request_to_ir( 

94 self, payload: Any, *, context: ConversionContext | None = None 

95 ) -> Result[RelayRequest, RelayError]: 

96 """Convert a source request DTO into the canonical IR.""" 

97 return self.source_mapper.request_to_ir( 

98 payload, context=context or ConversionContext() 

99 ) 

100 

101 def ir_to_request( 

102 self, request: RelayRequest, *, context: ConversionContext | None = None 

103 ) -> Result[Any, RelayError]: 

104 """Convert the canonical IR into the target request DTO.""" 

105 return self.target_mapper.ir_to_request( 

106 request, context=context or ConversionContext() 

107 ) 

108 

109 def response_to_ir( 

110 self, payload: Any, *, context: ConversionContext | None = None 

111 ) -> Result[RelayResponse, RelayError]: 

112 """Convert a source response DTO into the canonical IR.""" 

113 return self.source_mapper.response_to_ir( 

114 payload, context=context or ConversionContext() 

115 ) 

116 

117 def ir_to_response( 

118 self, response: RelayResponse, *, context: ConversionContext | None = None 

119 ) -> Result[Any, RelayError]: 

120 """Convert the canonical IR into the target response DTO.""" 

121 return self.target_mapper.ir_to_response( 

122 response, context=context or ConversionContext() 

123 ) 

124 

125 def stream_to_delta( 

126 self, event: Any, *, state: StreamState 

127 ) -> Result[tuple[StreamDelta, ...], RelayError]: 

128 """Convert one source stream event into canonical deltas.""" 

129 return self.source_mapper.stream_to_delta(event, state=state) 

130 

131 def delta_to_stream( 

132 self, delta: StreamDelta, *, state: StreamState 

133 ) -> Result[tuple[Any, ...], RelayError]: 

134 """Convert one canonical delta into target stream events.""" 

135 return self.target_mapper.delta_to_stream(delta, state=state) 

136 

137 

138class RelayConverterRegistry(RelayRegistryProtocol): 

139 """Caller-owned registry of format mappers with derived routes. 

140 

141 ``__init__`` creates an empty registry; :meth:`with_defaults` prepopulates 

142 the four built-in mappers. A caller may build and mutate their own 

143 instance without touching the process-global default registry. 

144 """ 

145 

146 def __init__(self) -> None: 

147 """Create an empty registry.""" 

148 self._mappers: dict[RelayFormat, FormatMapper] = {} 

149 self._routes: dict[tuple[RelayFormat, RelayFormat], Route] = {} 

150 

151 @classmethod 

152 def with_defaults(cls) -> RelayConverterRegistry: 

153 """Return a registry prepopulated with the four built-in mappers.""" 

154 registry = cls() 

155 registry.register(OpenAIChatMapper()) 

156 registry.register(OpenAIResponsesMapper()) 

157 registry.register(ClaudeMapper()) 

158 registry.register(GeminiMapper()) 

159 return registry 

160 

161 def register(self, mapper: FormatMapper) -> None: 

162 """Register a mapper for its declared wire format. 

163 

164 Args: 

165 mapper: A mapper exposing a ``format`` :class:`RelayFormat` 

166 attribute. 

167 

168 Raises: 

169 RelayError: With code ``duplicate_registration`` when a mapper 

170 is already registered for the format, or 

171 ``unsupported_format`` when the mapper does not declare a 

172 ``RelayFormat`` ``format`` attribute. 

173 """ 

174 fmt = getattr(mapper, "format", None) 

175 if not isinstance(fmt, RelayFormat): 

176 raise unsupported_format( 

177 f"mapper {type(mapper).__name__} must declare a RelayFormat 'format' attribute" 

178 ) 

179 if fmt in self._mappers: 

180 raise duplicate_registration( 

181 f"a mapper is already registered for {fmt.value}" 

182 ) 

183 self._mappers[fmt] = mapper 

184 

185 def mapper( 

186 self, 

187 source: RelayFormat, 

188 target: RelayFormat, 

189 ) -> RelayMapperProtocol | None: 

190 """Return the delegating route for a directed pair, or ``None``. 

191 

192 Same-format pairs return ``None``; the engine treats those as a 

193 no-op conversion. 

194 

195 Args: 

196 source: Source wire format. 

197 target: Target wire format. 

198 

199 Returns: 

200 The route mapper, or ``None`` when no route exists. 

201 """ 

202 if source is target: 

203 return None 

204 route = self._obtain_route(source, target) 

205 if route is None: 

206 return None 

207 return cast("RelayMapperProtocol", route) 

208 

209 def converter_routes(self) -> tuple[tuple[RelayFormat, RelayFormat], ...]: 

210 """Return every supported directed route pair. 

211 

212 Returns: 

213 Sorted route pairs, excluding same-format no-op pairs. 

214 """ 

215 return tuple( 

216 (spec.source, spec.target) 

217 for spec in self.routes() 

218 if spec.source is not spec.target 

219 ) 

220 

221 def mapper_ids(self) -> tuple[str, ...]: 

222 """Return the registered mapper wire-format identifiers. 

223 

224 Returns: 

225 Sorted mapper ids, one per registered mapper. 

226 """ 

227 return tuple( 

228 sorted( 

229 cast("RelayFormat", mapper.format).value 

230 for mapper in self._mappers.values() 

231 ) 

232 ) 

233 

234 def converter_version(self) -> str: 

235 """Return the converter engine version string. 

236 

237 Returns: 

238 The module-level ``CONVERTER_VERSION`` constant. 

239 """ 

240 return CONVERTER_VERSION 

241 

242 def route_quality( 

243 self, 

244 source: RelayFormat, 

245 target: RelayFormat, 

246 ) -> ConversionQuality: 

247 """Return the semantic-closeness quality for a directed pair. 

248 

249 Same-format pairs and unconfigured routes fall back to 

250 ``GOOD``/``DISCOURAGED`` via the quality matrix. 

251 

252 Args: 

253 source: Source wire format. 

254 target: Target wire format. 

255 

256 Returns: 

257 The stable quality value for the pair. 

258 """ 

259 return route_quality(source, target) 

260 

261 def route(self, source: RelayFormat, target: RelayFormat) -> RouteSpec | None: 

262 """Return the route spec for a directed pair, or ``None``. 

263 

264 Same-format pairs return ``None`` (no-op conversion). 

265 

266 Args: 

267 source: Source wire format. 

268 target: Target wire format. 

269 

270 Returns: 

271 The route spec, or ``None`` when no route exists. 

272 """ 

273 if source is target: 

274 return None 

275 route = self._obtain_route(source, target) 

276 return route.spec if route is not None else None 

277 

278 def route_by_id(self, converter_id: str) -> RouteSpec | None: 

279 """Return the route spec carrying *converter_id*, or ``None``. 

280 

281 Args: 

282 converter_id: A stable ``"<source>_to_<target>"`` identifier. 

283 

284 Returns: 

285 The matching route spec, or ``None`` when unknown. 

286 """ 

287 for spec in self.routes(): 

288 if spec.converter_id == converter_id: 

289 return spec 

290 return None 

291 

292 def routes(self) -> tuple[RouteSpec, ...]: 

293 """Return specs for every registered directed pair, sorted by id.""" 

294 specs: list[RouteSpec] = [] 

295 for source in RelayFormat: 

296 for target in RelayFormat: 

297 if source is target: 

298 continue 

299 route = self._obtain_route(source, target) 

300 if route is not None: 

301 specs.append(route.spec) 

302 return tuple(sorted(specs, key=lambda spec: spec.converter_id)) 

303 

304 def _obtain_route(self, source: RelayFormat, target: RelayFormat) -> Route | None: 

305 """Return (caching) the route for *source* to *target*, or ``None``.""" 

306 key_pair = (source, target) 

307 cached = self._routes.get(key_pair) 

308 if cached is not None: 

309 return cached 

310 source_mapper = self._mappers.get(source) 

311 target_mapper = self._mappers.get(target) 

312 if source_mapper is None or target_mapper is None: 

313 return None 

314 route = Route( 

315 spec=RouteSpec( 

316 source=source, 

317 target=target, 

318 quality=route_quality(source, target), 

319 ), 

320 source_mapper=source_mapper, 

321 target_mapper=target_mapper, 

322 ) 

323 self._routes[key_pair] = route 

324 return route