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
« 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.
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"""
10from __future__ import annotations
12from dataclasses import dataclass, field
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
25__all__ = ["ConversionContext"]
28def _no_default_max_tokens(model: str) -> int | None:
29 """Nil-safe callback: no default ``max_tokens`` exists."""
30 return None
33def _no_safety_setting(category: str) -> str | None:
34 """Nil-safe callback: no safety threshold is configured."""
35 return None
38def _no_image_generation(model: str) -> bool:
39 """Nil-safe callback: image generation is unsupported."""
40 return False
43def _no_thinking_suffix(model: str) -> bool:
44 """Nil-safe callback: the thinking-suffix bypass policy is off."""
45 return False
48@dataclass(frozen=True)
49class ConversionContext:
50 """Per-conversion context with nil-safe callbacks and a loss sink.
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 """
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)
77 @classmethod
78 def wrap(cls, context: RelayConversionContext | None) -> ConversionContext:
79 """Adapt a host context, substituting nil-safe defaults.
81 Args:
82 context: Host context, or ``None`` when the gateway supplied
83 none.
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 )
103 def max_tokens_for(self, model: str) -> int | None:
104 """Return the default ``max_tokens`` for *model*.
106 Negative callback results are treated as invalid and yield
107 ``None``; mappers apply their own missing-option policy.
109 Args:
110 model: The already-selected upstream model name.
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
120 @staticmethod
121 def normalize_model(model: str) -> str:
122 """Normalize a model name without selecting a different model.
124 Args:
125 model: Raw model name from the source payload.
127 Returns:
128 The cleaned model name.
129 """
130 return model.strip()
132 def resolve_model(self, model: str) -> str:
133 """Normalize a model name, substituting the host upstream model.
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.
139 Args:
140 model: Raw model name from the source payload.
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