Coverage for src / lexigram / ai / relay / gateway / operations / controls.py: 93%

138 statements  

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

1"""Validated runtime control service for the relay gateway. 

2 

3``RelayControlsService`` applies channel drain/enable and typed policy 

4changes behind an explicit permission gate, persists every mutation 

5through a ``RelayPolicyStoreProtocol`` backend, refuses changes that 

6would strand the gateway without an available converter, and emits an 

7``AIAuditEvent`` for every applied mutation. Audit metadata never 

8carries credentials, upstream URLs, prompt content, or media data. 

9""" 

10 

11from __future__ import annotations 

12 

13import asyncio 

14from dataclasses import replace 

15from typing import Any 

16 

17from lexigram.ai.relay.gateway.channels import RelayChannelRegistry 

18from lexigram.ai.relay.gateway.config import RelayGatewayConfig 

19from lexigram.ai.relay.gateway.operations.streams import RelayStreamRegistry 

20from lexigram.contracts.ai.governance import ( 

21 AIAuditEvent, 

22 AIAuditStoreProtocol, 

23 AuditEventType, 

24) 

25from lexigram.contracts.ai.relay import ( 

26 RelayActiveStream, 

27 RelayGatewayError, 

28 RelayPolicyChange, 

29 RelayPolicySnapshot, 

30 RelayPolicyStoreProtocol, 

31) 

32from lexigram.contracts.auth.guard import AuthorizerProtocol 

33from lexigram.logging import get_logger 

34 

35__all__ = [ 

36 "InMemoryRelayPolicyStore", 

37 "RelayControlsService", 

38] 

39 

40logger = get_logger(__name__) 

41 

42PERMISSION_READ = "relay.read" 

43PERMISSION_CHANNEL_CONTROL = "relay.channel_control" 

44PERMISSION_POLICY_CONTROL = "relay.policy_control" 

45PERMISSION_STREAM_CONTROL = "relay.stream_control" 

46 

47 

48class InMemoryRelayPolicyStore(RelayPolicyStoreProtocol): 

49 """Process-local policy store seeded from the gateway configuration. 

50 

51 ``load`` always returns the current snapshot and ``save`` replaces it 

52 wholesale; the store is the single source of truth between control 

53 mutations in this process. 

54 """ 

55 

56 def __init__(self, initial: RelayPolicySnapshot) -> None: 

57 """Bind the store to its initial snapshot. 

58 

59 Args: 

60 initial: Snapshot the store serves until the first save. 

61 """ 

62 self._snapshot = initial 

63 

64 @classmethod 

65 def with_defaults(cls, config: RelayGatewayConfig) -> InMemoryRelayPolicyStore: 

66 """Build a store seeded from a gateway configuration. 

67 

68 Args: 

69 config: Gateway configuration; each channel contributes its 

70 enabled flag and declared models as the allowed options. 

71 

72 Returns: 

73 A store whose snapshot mirrors the configuration. 

74 """ 

75 enabled_channels = { 

76 channel.name: channel.enabled for channel in config.channels 

77 } 

78 allowed_options = { 

79 channel.name: frozenset(channel.models) for channel in config.channels 

80 } 

81 return cls( 

82 RelayPolicySnapshot( 

83 enabled_channels=enabled_channels, 

84 allowed_model_options=allowed_options, 

85 media_allowed_schemes=frozenset({"https"}), 

86 media_allowed_hosts=frozenset(), 

87 max_request_bytes=1024 * 1024, 

88 max_stream_seconds=300.0, 

89 ) 

90 ) 

91 

92 async def load(self) -> RelayPolicySnapshot: 

93 """Return the current snapshot.""" 

94 return self._snapshot 

95 

96 async def save(self, snapshot: RelayPolicySnapshot) -> None: 

97 """Atomically replace the stored snapshot.""" 

98 self._snapshot = snapshot 

99 

100 

101class RelayControlsService: 

102 """Apply permissioned, validated policy mutations for the gateway. 

103 

104 Every mutation is serialized through an in-process lock, validated 

105 against the static channel table, persisted to the policy store, and 

106 audited. A mutation that would leave the gateway without any 

107 enabled channel that serves at least one model option is rejected 

108 before persisting. 

109 """ 

110 

111 def __init__( 

112 self, 

113 registry: RelayChannelRegistry, 

114 store: RelayPolicyStoreProtocol, 

115 authorizer: AuthorizerProtocol | None = None, 

116 audit: AIAuditStoreProtocol | None = None, 

117 streams: RelayStreamRegistry | None = None, 

118 ) -> None: 

119 """Bind the controls service to its dependencies. 

120 

121 Args: 

122 registry: Channel table defining valid channel names and 

123 model options. 

124 store: Persistent backend that owns the current snapshot. 

125 authorizer: Permission gate for ``relay.*`` actions. When 

126 ``None`` no permission check is performed (development). 

127 audit: Audit backend for mutation events. When ``None`` 

128 mutations still apply without audit emission. 

129 streams: Registry of in-flight upstream streams. When 

130 ``None`` a private empty registry is created; share one 

131 instance with the streaming path so force-cancel reaches 

132 live streams. 

133 """ 

134 self._registry = registry 

135 self._store = store 

136 self._authorizer = authorizer 

137 self._audit = audit 

138 self.streams = streams if streams is not None else RelayStreamRegistry() 

139 self._lock = asyncio.Lock() 

140 

141 async def set_channel_state( 

142 self, 

143 channel: str, 

144 enabled: bool, 

145 actor_id: str, 

146 ) -> None: 

147 """Enable or drain *channel* for new requests. 

148 

149 Args: 

150 channel: Channel name; unknown names are rejected. 

151 enabled: ``False`` drains the channel for new requests while 

152 existing streams finish. 

153 actor_id: Operator identity recorded in the audit event. 

154 

155 Raises: 

156 ValueError: The channel is unknown. 

157 RelayGatewayError: With ``PERMISSION_DENIED`` when the actor 

158 lacks ``relay.channel_control``. 

159 """ 

160 await self._require(actor_id, PERMISSION_CHANNEL_CONTROL, f"channel:{channel}") 

161 async with self._lock: 

162 snapshot = await self._store.load() 

163 if channel not in snapshot.enabled_channels: 

164 raise ValueError(f"unknown channel {channel!r}") 

165 changed = replace( 

166 snapshot, 

167 enabled_channels={**snapshot.enabled_channels, channel: enabled}, 

168 ) 

169 self._require_router_survives(changed) 

170 await self._store.save(changed) 

171 self._registry.set_runtime_enabled(channel, enabled) 

172 await self._audit_change( 

173 actor_id=actor_id, 

174 action=PERMISSION_CHANNEL_CONTROL, 

175 resource=channel, 

176 old={"enabled": not enabled}, 

177 new={"enabled": enabled}, 

178 ) 

179 

180 async def update_policy( 

181 self, 

182 change: RelayPolicyChange, 

183 actor_id: str, 

184 ) -> None: 

185 """Apply a typed policy change. 

186 

187 Args: 

188 change: Partial mutation; only the fields explicitly set 

189 change. 

190 actor_id: Operator identity recorded in the audit event. 

191 

192 Raises: 

193 ValueError: The change references an unknown channel or 

194 model option values, or would remove every available 

195 model option. 

196 RelayGatewayError: With ``PERMISSION_DENIED`` when the actor 

197 lacks ``relay.policy_control``. 

198 """ 

199 await self._require(actor_id, PERMISSION_POLICY_CONTROL, "policy") 

200 async with self._lock: 

201 snapshot = await self._store.load() 

202 if change.channel is not None: 

203 if change.channel not in snapshot.enabled_channels: 

204 raise ValueError(f"unknown channel {change.channel!r}") 

205 if change.enabled is not None and change.channel is None: 

206 raise ValueError("channel is required when changing enabled state") 

207 if change.allowed_model_options is not None and change.channel is None: 

208 raise ValueError( 

209 "channel is required when changing allowed model options" 

210 ) 

211 self._validate_options(change) 

212 changed = self._compose(snapshot, change) 

213 self._require_router_survives(changed) 

214 await self._store.save(changed) 

215 if change.enabled is not None and change.channel is not None: 

216 self._registry.set_runtime_enabled(change.channel, change.enabled) 

217 await self._audit_change( 

218 actor_id=actor_id, 

219 action=PERMISSION_POLICY_CONTROL, 

220 resource="policy", 

221 old=self._changed_fields(snapshot, change), 

222 new=self._changed_fields(changed, change), 

223 ) 

224 

225 async def policy_snapshot(self, actor_id: str) -> RelayPolicySnapshot: 

226 """Return the current runtime policy snapshot. 

227 

228 Args: 

229 actor_id: Operator identity; ``relay.read`` permission is 

230 required. 

231 

232 Returns: 

233 The snapshot persisted by the policy store. 

234 

235 Raises: 

236 RelayGatewayError: With ``PERMISSION_DENIED`` when the actor 

237 lacks ``relay.read``. 

238 """ 

239 await self._require(actor_id, PERMISSION_READ, "policy") 

240 return await self._store.load() 

241 

242 def active_streams(self) -> tuple[RelayActiveStream, ...]: 

243 """Return the currently in-flight upstream streams. 

244 

245 Returns: 

246 One row per active stream, oldest first; an empty tuple when 

247 no stream is in flight. 

248 """ 

249 return self.streams.list() 

250 

251 async def force_cancel_stream( 

252 self, 

253 stream_id: str, 

254 actor_id: str, 

255 ) -> None: 

256 """Force-cancel an in-flight upstream stream. 

257 

258 Args: 

259 stream_id: Identifier of the stream to cancel. 

260 actor_id: Operator identity recorded in the audit event; 

261 ``relay.stream_control`` permission is required. 

262 

263 Raises: 

264 ValueError: The stream identifier is unknown. 

265 RelayGatewayError: With ``PERMISSION_DENIED`` when the actor 

266 lacks ``relay.stream_control``. 

267 """ 

268 await self._require(actor_id, PERMISSION_STREAM_CONTROL, f"stream:{stream_id}") 

269 if not self.streams.cancel(stream_id): 

270 raise ValueError(f"unknown stream {stream_id!r}") 

271 await self._audit_change( 

272 actor_id=actor_id, 

273 action=PERMISSION_STREAM_CONTROL, 

274 resource=stream_id, 

275 old={"cancelled": False}, 

276 new={"cancelled": True}, 

277 ) 

278 

279 def _validate_options(self, change: RelayPolicyChange) -> None: 

280 """Reject option names the target channel does not serve.""" 

281 options = change.allowed_model_options 

282 if options is None or change.channel is None: 

283 return 

284 channel = self._channel(change.channel) 

285 if channel is None: 

286 raise ValueError(f"unknown channel {change.channel!r}") 

287 unknown = options - set(channel.models) 

288 if unknown: 

289 name = sorted(unknown)[0] 

290 raise ValueError( 

291 f"unknown model option {name!r} for channel {change.channel!r}" 

292 ) 

293 

294 def _channel(self, name: str): 

295 """Return the configured channel with *name*, or ``None``.""" 

296 for candidate in self._registry.channels: 

297 if candidate.name == name: 

298 return candidate 

299 return None 

300 

301 async def _require(self, actor_id: str, action: str, resource: str) -> None: 

302 """Enforce *action* permission, raising ``PERMISSION_DENIED``.""" 

303 allowed = True 

304 if self._authorizer is not None: 

305 allowed = await self._authorizer.can(actor_id, action, resource) 

306 if not allowed: 

307 raise RelayGatewayError( 

308 code="PERMISSION_DENIED", 

309 message=f"{action} denied for the operator", 

310 status_code=403, 

311 request_id="", 

312 ) 

313 

314 @staticmethod 

315 def _compose( 

316 snapshot: RelayPolicySnapshot, 

317 change: RelayPolicyChange, 

318 ) -> RelayPolicySnapshot: 

319 """Build the snapshot after *change* is applied.""" 

320 enabled_channels = snapshot.enabled_channels 

321 allowed_model_options = snapshot.allowed_model_options 

322 if change.enabled is not None or change.allowed_model_options is not None: 

323 channel = change.channel 

324 if channel is None: 

325 raise ValueError( 

326 "channel is required for channel-scoped policy changes" 

327 ) 

328 if change.enabled is not None: 

329 enabled_channels = {**enabled_channels, channel: change.enabled} 

330 if change.allowed_model_options is not None: 

331 allowed_model_options = { 

332 **allowed_model_options, 

333 channel: change.allowed_model_options, 

334 } 

335 return replace( 

336 snapshot, 

337 enabled_channels=enabled_channels, 

338 allowed_model_options=allowed_model_options, 

339 media_allowed_schemes=( 

340 change.media_allowed_schemes 

341 if change.media_allowed_schemes is not None 

342 else snapshot.media_allowed_schemes 

343 ), 

344 media_allowed_hosts=( 

345 change.media_allowed_hosts 

346 if change.media_allowed_hosts is not None 

347 else snapshot.media_allowed_hosts 

348 ), 

349 max_request_bytes=( 

350 change.max_request_bytes 

351 if change.max_request_bytes is not None 

352 else snapshot.max_request_bytes 

353 ), 

354 max_stream_seconds=( 

355 change.max_stream_seconds 

356 if change.max_stream_seconds is not None 

357 else snapshot.max_stream_seconds 

358 ), 

359 ) 

360 

361 @staticmethod 

362 def _require_router_survives(snapshot: RelayPolicySnapshot) -> None: 

363 """Reject snapshots with no enabled channel serving options. 

364 

365 Raises: 

366 ValueError: When every channel is disabled or every 

367 remaining channel has no allowed model options. 

368 """ 

369 for name, options in snapshot.allowed_model_options.items(): 

370 if snapshot.enabled_channels.get(name, False) and options: 

371 return 

372 raise ValueError("policy change would remove all available converters") 

373 

374 @staticmethod 

375 def _changed_fields( 

376 snapshot: RelayPolicySnapshot, 

377 change: RelayPolicyChange, 

378 ) -> dict[str, Any]: 

379 """Extract the values changed by *change* from *snapshot*.""" 

380 fields: dict[str, Any] = {} 

381 if change.enabled is not None and change.channel is not None: 

382 fields[f"{change.channel}.enabled"] = snapshot.enabled_channels[ 

383 change.channel 

384 ] 

385 if change.allowed_model_options is not None and change.channel is not None: 

386 fields[f"{change.channel}.allowed_model_options"] = ( 

387 snapshot.allowed_model_options.get(change.channel, frozenset()) 

388 ) 

389 if change.media_allowed_schemes is not None: 

390 fields["media_allowed_schemes"] = snapshot.media_allowed_schemes 

391 if change.media_allowed_hosts is not None: 

392 fields["media_allowed_hosts"] = snapshot.media_allowed_hosts 

393 if change.max_request_bytes is not None: 

394 fields["max_request_bytes"] = snapshot.max_request_bytes 

395 if change.max_stream_seconds is not None: 

396 fields["max_stream_seconds"] = snapshot.max_stream_seconds 

397 return fields 

398 

399 async def _audit_change( 

400 self, 

401 actor_id: str, 

402 action: str, 

403 resource: str, 

404 old: dict[str, Any], 

405 new: dict[str, Any], 

406 ) -> None: 

407 """Persist one audit event for an applied mutation.""" 

408 if self._audit is None: 

409 return 

410 await self._audit.record( 

411 AIAuditEvent( 

412 event_type=AuditEventType.CONFIG_RELOADED, 

413 user_id=actor_id, 

414 status="success", 

415 metadata={ 

416 "action": action, 

417 "resource": resource, 

418 "old": old, 

419 "new": new, 

420 }, 

421 ) 

422 ) 

423 logger.debug( 

424 "relay_controls_mutated", 

425 action=action, 

426 resource=resource, 

427 )