Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-relay/src/lexigram/ai/relay/registry.py: 48%
114 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +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(sorted(mapper.format.value for mapper in self._mappers.values()))
229 def converter_version(self) -> str:
230 """Return the converter engine version string.
232 Returns:
233 The module-level ``CONVERTER_VERSION`` constant.
234 """
235 return CONVERTER_VERSION
237 def route_quality(
238 self,
239 source: RelayFormat,
240 target: RelayFormat,
241 ) -> ConversionQuality:
242 """Return the semantic-closeness quality for a directed pair.
244 Same-format pairs and unconfigured routes fall back to
245 ``GOOD``/``DISCOURAGED`` via the quality matrix.
247 Args:
248 source: Source wire format.
249 target: Target wire format.
251 Returns:
252 The stable quality value for the pair.
253 """
254 return route_quality(source, target)
256 def route(self, source: RelayFormat, target: RelayFormat) -> RouteSpec | None:
257 """Return the route spec for a directed pair, or ``None``.
259 Same-format pairs return ``None`` (no-op conversion).
261 Args:
262 source: Source wire format.
263 target: Target wire format.
265 Returns:
266 The route spec, or ``None`` when no route exists.
267 """
268 if source is target:
269 return None
270 route = self._obtain_route(source, target)
271 return route.spec if route is not None else None
273 def route_by_id(self, converter_id: str) -> RouteSpec | None:
274 """Return the route spec carrying *converter_id*, or ``None``.
276 Args:
277 converter_id: A stable ``"<source>_to_<target>"`` identifier.
279 Returns:
280 The matching route spec, or ``None`` when unknown.
281 """
282 for spec in self.routes():
283 if spec.converter_id == converter_id:
284 return spec
285 return None
287 def routes(self) -> tuple[RouteSpec, ...]:
288 """Return specs for every registered directed pair, sorted by id."""
289 specs: list[RouteSpec] = []
290 for source in RelayFormat:
291 for target in RelayFormat:
292 if source is target:
293 continue
294 route = self._obtain_route(source, target)
295 if route is not None:
296 specs.append(route.spec)
297 return tuple(sorted(specs, key=lambda spec: spec.converter_id))
299 def _obtain_route(self, source: RelayFormat, target: RelayFormat) -> Route | None:
300 """Return (caching) the route for *source* to *target*, or ``None``."""
301 key_pair = (source, target)
302 cached = self._routes.get(key_pair)
303 if cached is not None:
304 return cached
305 source_mapper = self._mappers.get(source)
306 target_mapper = self._mappers.get(target)
307 if source_mapper is None or target_mapper is None:
308 return None
309 route = Route(
310 spec=RouteSpec(
311 source=source,
312 target=target,
313 quality=route_quality(source, target),
314 ),
315 source_mapper=source_mapper,
316 target_mapper=target_mapper,
317 )
318 self._routes[key_pair] = route
319 return route