Coverage for src / lexigram / ai / relay / gateway / web / shared.py: 96%

49 statements  

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

1"""Shared request/response helpers for the relay gateway web layer. 

2 

3Error envelopes, body parsing, and header filtering are common to every 

4inbound route family (chat relay, passthrough, audio, images, job 

5relay). They live here so endpoint modules can share them without 

6importing from ``routes`` and forming an import cycle. 

7""" 

8 

9from __future__ import annotations 

10 

11from collections.abc import Awaitable, Callable, Mapping 

12from typing import Any, TypeAlias 

13 

14from starlette.requests import Request 

15from starlette.responses import JSONResponse, Response 

16 

17from lexigram.ai.relay.gateway.passthrough import PassthroughService 

18from lexigram.contracts.ai.relay import RelayFormat, RelayGatewayError 

19from lexigram.contracts.ai.relay.gateway import RelayGatewayErrorCode 

20from lexigram.serialization import loads 

21 

22__all__ = [ 

23 "_DEFAULT_ERROR_TYPES", 

24 "_ERROR_TYPE_MAP", 

25 "_HOP_BY_HOP_HEADERS", 

26 "ResolvePassthrough", 

27 "_error_response", 

28 "_error_types", 

29 "_parse_body", 

30 "_safe_headers", 

31] 

32 

33ResolvePassthrough: TypeAlias = Callable[[Request], Awaitable[PassthroughService]] 

34"""Resolver of a passthrough service from a Starlette request.""" 

35 

36_HOP_BY_HOP_HEADERS: frozenset[str] = frozenset( 

37 { 

38 "connection", 

39 "keep-alive", 

40 "proxy-authenticate", 

41 "proxy-authorization", 

42 "te", 

43 "trailer", 

44 "transfer-encoding", 

45 "upgrade", 

46 } 

47) 

48"""Hop-by-hop headers that must never be relayed to clients.""" 

49 

50_ERROR_TYPE_MAP: dict[int, tuple[str, str, str]] = { 

51 400: ("invalid_request_error", "invalid_request_error", "INVALID_ARGUMENT"), 

52 401: ("authentication_error", "authentication_error", "UNAUTHENTICATED"), 

53 403: ("permission_denied_error", "permission_denied_error", "PERMISSION_DENIED"), 

54 404: ("invalid_request_error", "not_found_error", "NOT_FOUND"), 

55 409: ("conflict_error", "conflict_error", "FAILED_PRECONDITION"), 

56 429: ("rate_limit_error", "rate_limit_error", "RESOURCE_EXHAUSTED"), 

57 499: ("cancelled_error", "cancelled_error", "CANCELLED"), 

58 502: ("server_error", "api_error", "INTERNAL"), 

59 504: ("server_error", "api_error", "DEADLINE_EXCEEDED"), 

60} 

61"""Per-status error type names for the OpenAI, Claude, and Google families.""" 

62 

63_DEFAULT_ERROR_TYPES: tuple[str, str, str] = ("server_error", "api_error", "INTERNAL") 

64"""Error type names for every unmapped status code (including 500).""" 

65 

66 

67def _error_types(status_code: int) -> tuple[str, str, str]: 

68 """Map an HTTP status to per-protocol error type names. 

69 

70 Args: 

71 status_code: The gateway error's status code. 

72 

73 Returns: 

74 ``(openai_type, claude_type, google_status)`` for the status; 

75 the server-error triple for unmapped statuses. 

76 """ 

77 return _ERROR_TYPE_MAP.get(status_code, _DEFAULT_ERROR_TYPES) 

78 

79 

80def _error_response(source: RelayFormat, error: RelayGatewayError) -> Response: 

81 """Build the inbound-protocol error envelope for a gateway error. 

82 

83 Never includes request payloads, headers, or tracebacks; only the 

84 safe error fields the protocol documents. 

85 

86 Args: 

87 source: The inbound wire format determining the envelope shape. 

88 error: The gateway error to render. 

89 

90 Returns: 

91 A JSON response with the protocol's error envelope and the 

92 error's status code. 

93 """ 

94 openai_type, claude_type, google_status = _error_types(error.status_code) 

95 if source == RelayFormat.OPENAI_CHAT: 

96 envelope: dict[str, Any] = { 

97 "error": { 

98 "message": error.message, 

99 "type": openai_type, 

100 "code": error.code, 

101 "request_id": error.request_id, 

102 } 

103 } 

104 elif source == RelayFormat.OPENAI_RESPONSES: 

105 envelope = { 

106 "error": { 

107 "message": error.message, 

108 "type": openai_type, 

109 "code": error.code, 

110 } 

111 } 

112 elif source == RelayFormat.CLAUDE: 

113 envelope = { 

114 "type": "error", 

115 "error": {"type": claude_type, "message": error.message}, 

116 } 

117 else: 

118 envelope = { 

119 "error": { 

120 "code": error.status_code, 

121 "message": error.message, 

122 "status": google_status, 

123 } 

124 } 

125 return JSONResponse(content=envelope, status_code=error.status_code) 

126 

127 

128def _parse_body( 

129 raw: bytes, source: RelayFormat, request_id: str 

130) -> dict[str, Any] | Response: 

131 """Decode the request body, returning a 400 response when malformed. 

132 

133 Args: 

134 raw: The raw request body bytes. 

135 source: The inbound wire format for the error envelope. 

136 request_id: Request id stamped on the error. 

137 

138 Returns: 

139 The decoded JSON object, or a 400 ``INVALID_REQUEST`` response 

140 for malformed JSON, non-object roots, and empty bodies. 

141 """ 

142 try: 

143 decoded = loads(raw) 

144 except (TypeError, ValueError): 

145 return _error_response( 

146 source, 

147 RelayGatewayError( 

148 code=RelayGatewayErrorCode.INVALID_REQUEST, 

149 message="malformed JSON body", 

150 status_code=400, 

151 request_id=request_id, 

152 ), 

153 ) 

154 if not isinstance(decoded, dict): 

155 return _error_response( 

156 source, 

157 RelayGatewayError( 

158 code=RelayGatewayErrorCode.INVALID_REQUEST, 

159 message="request body must be a JSON object", 

160 status_code=400, 

161 request_id=request_id, 

162 ), 

163 ) 

164 return decoded 

165 

166 

167def _safe_headers( 

168 headers: Mapping[str, str], request_id: str, trace_id: str 

169) -> dict[str, str]: 

170 """Filter result headers and stamp request metadata. 

171 

172 Drops ``set-cookie`` and all hop-by-hop headers case-insensitively, 

173 keeps everything else, and always adds ``x-request-id`` plus 

174 ``x-trace-id`` when a trace id was provided. 

175 

176 Args: 

177 headers: The result headers to filter. 

178 request_id: Request id stamped as ``x-request-id``. 

179 trace_id: Trace id stamped as ``x-trace-id`` when non-empty. 

180 

181 Returns: 

182 The safe header dict. 

183 """ 

184 safe: dict[str, str] = {} 

185 for key, value in headers.items(): 

186 lowered = key.lower() 

187 if lowered == "set-cookie" or lowered in _HOP_BY_HOP_HEADERS: 

188 continue 

189 safe[key] = value 

190 safe["x-request-id"] = request_id 

191 if trace_id: 

192 safe["x-trace-id"] = trace_id 

193 return safe