Coverage for src / lexigram / contracts / ai / relay / gateway.py: 15%

92 statements  

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

1"""Gateway contracts for the relay engine. 

2 

3Defines the channel configuration, gateway request/result value types, 

4gateway error, and the gateway service protocol implemented by the 

5``lexigram-ai-relay-gateway`` extension package. 

6""" 

7 

8from __future__ import annotations 

9 

10from collections.abc import AsyncIterator, Mapping 

11from dataclasses import dataclass, field 

12from enum import StrEnum 

13from typing import Protocol, runtime_checkable 

14 

15from lexigram.contracts.ai.relay.transport import RelayWireEvent 

16from lexigram.contracts.ai.relay.types import ConversionQuality, JsonValue, RelayFormat 

17from lexigram.contracts.core.result import Result 

18 

19 

20@dataclass(frozen=True, slots=True) 

21class RelayChannel: 

22 """Configuration of one upstream endpoint a gateway can route to.""" 

23 

24 name: str 

25 upstream_base_url: str 

26 target_format: RelayFormat 

27 models: tuple[str, ...] 

28 capabilities: frozenset[str] = frozenset() 

29 endpoint_kinds: frozenset[str] = frozenset() 

30 priority: int = 100 

31 weight: int = 100 

32 enabled: bool = True 

33 timeout_seconds: float = 60.0 

34 model_map: Mapping[str, str] = field(default_factory=dict) 

35 

36 def __post_init__(self) -> None: 

37 """Validate the channel configuration.""" 

38 if not self.name: 

39 raise ValueError("name must not be empty") 

40 if not self.upstream_base_url: 

41 raise ValueError("upstream_base_url must not be empty") 

42 if not self.models: 

43 raise ValueError("models must not be empty") 

44 if self.timeout_seconds <= 0: 

45 raise ValueError("timeout_seconds must be positive") 

46 if self.weight < 0: 

47 raise ValueError("weight must not be negative") 

48 unknown = set(self.model_map) - set(self.models) 

49 if unknown: 

50 raise ValueError( 

51 f"model_map keys must be listed in models; unknown: {sorted(unknown)}" 

52 ) 

53 

54 def resolve_model(self, alias: str) -> str: 

55 """Resolve the upstream model name for *alias*. 

56 

57 The mapping wins when it carries *alias*; otherwise the alias is 

58 used as its own upstream name. 

59 

60 Args: 

61 alias: The client-visible model alias. 

62 

63 Returns: 

64 The model name sent to the channel's upstream. 

65 """ 

66 return self.model_map.get(alias, alias) 

67 

68 

69@dataclass(frozen=True, slots=True) 

70class RelayGatewayRequest: 

71 """A request accepted by the relay gateway for dispatch. 

72 

73 The ``channel`` field is a preferred-channel hint and audit 

74 snapshot: the service always re-selects the channel through the 

75 registry and never routes on this snapshot alone. 

76 """ 

77 

78 request_id: str 

79 tenant_id: str 

80 source: RelayFormat 

81 model: str 

82 stream: bool 

83 payload: Mapping[str, JsonValue] 

84 headers: Mapping[str, str] 

85 channel: RelayChannel | None = None 

86 

87 

88@dataclass(frozen=True, slots=True) 

89class RelayGatewayResult: 

90 """The outcome of a relay gateway handle call.""" 

91 

92 status_code: int 

93 headers: Mapping[str, str] 

94 payload: Mapping[str, JsonValue] | None = None 

95 stream: AsyncIterator[RelayWireEvent] | None = None 

96 metadata: RelayGatewayMetadata | None = None 

97 

98 

99@dataclass(frozen=True, slots=True) 

100class RelayGatewayMetadata: 

101 """Conversion bookkeeping attached to a gateway result.""" 

102 

103 converter_id: str 

104 source: RelayFormat 

105 target: RelayFormat 

106 quality: ConversionQuality 

107 loss_codes: tuple[str, ...] = () 

108 warnings: tuple[str, ...] = () 

109 

110 

111class RelayGatewayErrorCode(StrEnum): 

112 """Stable machine-readable codes carried by :class:`RelayGatewayError`. 

113 

114 Existing callers may construct ``RelayGatewayError`` with the plain 

115 string value (``"INVALID_REQUEST"``); the members compare equal to 

116 those strings. 

117 

118 Attributes: 

119 CHANNEL_DISABLED: No enabled channel exists. 

120 TARGET_FORMAT_UNSUPPORTED: No channel transforms the source format. 

121 CAPABILITY_UNAVAILABLE: No channel satisfies the capability filters. 

122 MODEL_NOT_FOUND: No channel serves the requested model. 

123 INVALID_REQUEST: The request payload is malformed. 

124 UNSUPPORTED_FORMAT: Unknown wire format. 

125 ENCODE_FAILED: Outbound payload serialization failed. 

126 DECODE_FAILED: Inbound payload deserialization failed. 

127 AUTH_DENIED: The caller is not authorized to invoke the model. 

128 PERMISSION_DENIED: The operator is not authorized for the 

129 requested control surface. 

130 UPSTREAM_ERROR: The upstream responded with a non-2xx status. 

131 UPSTREAM_TIMEOUT: The upstream request timed out. 

132 UPSTREAM_CANCELLED: The upstream request was cancelled. 

133 UPSTREAM_FAILED: Generic upstream transport failure. 

134 UPSTREAM_MALFORMED: A 2xx upstream body was malformed. 

135 CONVERSION_FAILED: Request/response conversion failed. 

136 STREAM_ERROR: A streaming session failed. 

137 QUOTA_EXCEEDED: Billing denied admission to the request. 

138 BILLING_FAILED: The billing pipeline failed unexpectedly. 

139 DEPENDENCY_UNAVAILABLE: An optional runtime dependency was not 

140 registered (e.g. the converter registry). 

141 """ 

142 

143 CHANNEL_DISABLED = "CHANNEL_DISABLED" 

144 TARGET_FORMAT_UNSUPPORTED = "TARGET_FORMAT_UNSUPPORTED" 

145 CAPABILITY_UNAVAILABLE = "CAPABILITY_UNAVAILABLE" 

146 MODEL_NOT_FOUND = "MODEL_NOT_FOUND" 

147 INVALID_REQUEST = "INVALID_REQUEST" 

148 UNSUPPORTED_FORMAT = "UNSUPPORTED_FORMAT" 

149 ENCODE_FAILED = "ENCODE_FAILED" 

150 DECODE_FAILED = "DECODE_FAILED" 

151 AUTH_DENIED = "AUTH_DENIED" 

152 PERMISSION_DENIED = "PERMISSION_DENIED" 

153 UPSTREAM_ERROR = "UPSTREAM_ERROR" 

154 UPSTREAM_TIMEOUT = "UPSTREAM_TIMEOUT" 

155 UPSTREAM_CANCELLED = "UPSTREAM_CANCELLED" 

156 UPSTREAM_FAILED = "UPSTREAM_FAILED" 

157 UPSTREAM_MALFORMED = "UPSTREAM_MALFORMED" 

158 CONVERSION_FAILED = "CONVERSION_FAILED" 

159 STREAM_ERROR = "STREAM_ERROR" 

160 QUOTA_EXCEEDED = "QUOTA_EXCEEDED" 

161 BILLING_FAILED = "BILLING_FAILED" 

162 DEPENDENCY_UNAVAILABLE = "DEPENDENCY_UNAVAILABLE" 

163 

164 

165@dataclass(frozen=True, slots=True) 

166class RelayGatewayError(Exception): 

167 """A domain error returned (or raised) by the relay gateway.""" 

168 

169 code: str 

170 message: str 

171 status_code: int 

172 request_id: str 

173 retryable: bool = False 

174 

175 

176@runtime_checkable 

177class RelayGatewayProtocol(Protocol): 

178 """Service protocol implemented by the relay gateway.""" 

179 

180 async def handle( 

181 self, request: RelayGatewayRequest 

182 ) -> Result[RelayGatewayResult, RelayGatewayError]: ...