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
« 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.
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"""
10from __future__ import annotations
12from dataclasses import dataclass
13from typing import Any, cast
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
37__all__ = [
38 "CONVERTER_VERSION",
39 "RelayConverterRegistry",
40 "Route",
41 "RouteSpec",
42]
44CONVERTER_VERSION = "1.0.0"
45"""Converter engine version reported in operational diagnostics."""
48@dataclass(frozen=True)
49class RouteSpec:
50 """Static metadata for one directed conversion route.
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.
62 Properties:
63 converter_id: Stable ``"<source>_to_<target>"`` identifier.
64 """
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, ...] = ()
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}"
80@dataclass(frozen=True)
81class Route:
82 """A delegating mapper for one directed pair plus its route spec.
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 """
89 spec: RouteSpec
90 source_mapper: FormatMapper
91 target_mapper: FormatMapper
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 )
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 )
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 )
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 )
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)
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)
138class RelayConverterRegistry(RelayRegistryProtocol):
139 """Caller-owned registry of format mappers with derived routes.
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 """
146 def __init__(self) -> None:
147 """Create an empty registry."""
148 self._mappers: dict[RelayFormat, FormatMapper] = {}
149 self._routes: dict[tuple[RelayFormat, RelayFormat], Route] = {}
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
161 def register(self, mapper: FormatMapper) -> None:
162 """Register a mapper for its declared wire format.
164 Args:
165 mapper: A mapper exposing a ``format`` :class:`RelayFormat`
166 attribute.
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
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``.
192 Same-format pairs return ``None``; the engine treats those as a
193 no-op conversion.
195 Args:
196 source: Source wire format.
197 target: Target wire format.
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)
209 def converter_routes(self) -> tuple[tuple[RelayFormat, RelayFormat], ...]:
210 """Return every supported directed route pair.
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 )
221 def mapper_ids(self) -> tuple[str, ...]:
222 """Return the registered mapper wire-format identifiers.
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 )
234 def converter_version(self) -> str:
235 """Return the converter engine version string.
237 Returns:
238 The module-level ``CONVERTER_VERSION`` constant.
239 """
240 return CONVERTER_VERSION
242 def route_quality(
243 self,
244 source: RelayFormat,
245 target: RelayFormat,
246 ) -> ConversionQuality:
247 """Return the semantic-closeness quality for a directed pair.
249 Same-format pairs and unconfigured routes fall back to
250 ``GOOD``/``DISCOURAGED`` via the quality matrix.
252 Args:
253 source: Source wire format.
254 target: Target wire format.
256 Returns:
257 The stable quality value for the pair.
258 """
259 return route_quality(source, target)
261 def route(self, source: RelayFormat, target: RelayFormat) -> RouteSpec | None:
262 """Return the route spec for a directed pair, or ``None``.
264 Same-format pairs return ``None`` (no-op conversion).
266 Args:
267 source: Source wire format.
268 target: Target wire format.
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
278 def route_by_id(self, converter_id: str) -> RouteSpec | None:
279 """Return the route spec carrying *converter_id*, or ``None``.
281 Args:
282 converter_id: A stable ``"<source>_to_<target>"`` identifier.
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
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))
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