Coverage for src / lexigram / ai / relay / context.py: 100%

41 statements  

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

1"""Nil-safe adapter over the optional host conversion context. 

2 

3The engine is synchronous and side-effect free. Host capabilities flow 

4in through :class:`lexigram.contracts.ai.relay.context.RelayConversionContext` 

5(or ``None``). This module wraps that context so mappers never guard 

6against ``None`` callbacks and never touch protocol-specific options 

7directly. 

8""" 

9 

10from __future__ import annotations 

11 

12from dataclasses import dataclass, field 

13 

14from lexigram.contracts.ai.relay.context import ( 

15 DefaultMaxTokensCallback, 

16 MediaResolverProtocol, 

17 PreserveThinkingSuffixCallback, 

18 RelayConversionContext, 

19 RelayOptions, 

20 SafetySettingCallback, 

21 SupportsImageGenerationCallback, 

22) 

23from lexigram.contracts.ai.relay.types import RelayLoss 

24 

25__all__ = ["ConversionContext"] 

26 

27 

28def _no_default_max_tokens(model: str) -> int | None: 

29 """Nil-safe callback: no default ``max_tokens`` exists.""" 

30 return None 

31 

32 

33def _no_safety_setting(category: str) -> str | None: 

34 """Nil-safe callback: no safety threshold is configured.""" 

35 return None 

36 

37 

38def _no_image_generation(model: str) -> bool: 

39 """Nil-safe callback: image generation is unsupported.""" 

40 return False 

41 

42 

43def _no_thinking_suffix(model: str) -> bool: 

44 """Nil-safe callback: the thinking-suffix bypass policy is off.""" 

45 return False 

46 

47 

48@dataclass(frozen=True) 

49class ConversionContext: 

50 """Per-conversion context with nil-safe callbacks and a loss sink. 

51 

52 Attributes: 

53 options: Cross-protocol adaptation options. Zero-value when the 

54 host supplied no context. 

55 default_max_tokens: Claude ``max_tokens`` fallback lookup, always 

56 callable. 

57 safety_setting: Gemini safety threshold lookup, always callable. 

58 supports_image_generation: Gemini image-generation capability 

59 lookup, always callable. 

60 preserve_thinking_suffix: Thinking-suffix bypass policy lookup, 

61 always callable. 

62 media_resolver: Resolver for URL media, or ``None``. 

63 upstream_model: Host model name substituted when the source 

64 payload carries no model; empty when unset. 

65 losses: Semantic losses recorded during conversion. 

66 """ 

67 

68 options: RelayOptions = field(default_factory=RelayOptions) 

69 default_max_tokens: DefaultMaxTokensCallback = _no_default_max_tokens 

70 safety_setting: SafetySettingCallback = _no_safety_setting 

71 supports_image_generation: SupportsImageGenerationCallback = _no_image_generation 

72 preserve_thinking_suffix: PreserveThinkingSuffixCallback = _no_thinking_suffix 

73 media_resolver: MediaResolverProtocol | None = None 

74 upstream_model: str = "" 

75 losses: list[RelayLoss] = field(default_factory=list) 

76 

77 @classmethod 

78 def wrap(cls, context: RelayConversionContext | None) -> ConversionContext: 

79 """Adapt a host context, substituting nil-safe defaults. 

80 

81 Args: 

82 context: Host context, or ``None`` when the gateway supplied 

83 none. 

84 

85 Returns: 

86 An adapter with callable callbacks and the host's loss list. 

87 """ 

88 if context is None: 

89 return cls() 

90 return cls( 

91 options=context.options, 

92 default_max_tokens=context.default_max_tokens or _no_default_max_tokens, 

93 safety_setting=context.safety_setting or _no_safety_setting, 

94 supports_image_generation=context.supports_image_generation 

95 or _no_image_generation, 

96 preserve_thinking_suffix=context.preserve_thinking_suffix 

97 or _no_thinking_suffix, 

98 media_resolver=context.media_resolver, 

99 upstream_model=(context.upstream_model or "").strip(), 

100 losses=context.losses, 

101 ) 

102 

103 def max_tokens_for(self, model: str) -> int | None: 

104 """Return the default ``max_tokens`` for *model*. 

105 

106 Negative callback results are treated as invalid and yield 

107 ``None``; mappers apply their own missing-option policy. 

108 

109 Args: 

110 model: The already-selected upstream model name. 

111 

112 Returns: 

113 A non-negative default, or ``None`` when absent or invalid. 

114 """ 

115 value = self.default_max_tokens(model) 

116 if value is None or value < 0: 

117 return None 

118 return value 

119 

120 @staticmethod 

121 def normalize_model(model: str) -> str: 

122 """Normalize a model name without selecting a different model. 

123 

124 Args: 

125 model: Raw model name from the source payload. 

126 

127 Returns: 

128 The cleaned model name. 

129 """ 

130 return model.strip() 

131 

132 def resolve_model(self, model: str) -> str: 

133 """Normalize a model name, substituting the host upstream model. 

134 

135 Empty source model names (e.g. Gemini responses that carry no 

136 model) fall back to the host ``upstream_model`` so downstream 

137 requests still identify the model. 

138 

139 Args: 

140 model: Raw model name from the source payload. 

141 

142 Returns: 

143 The cleaned model name, or the upstream fallback. 

144 """ 

145 model = self.normalize_model(model) 

146 if model: 

147 return model 

148 return self.upstream_model