• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

liqd / roots / 22072536647

16 Feb 2026 05:41PM UTC coverage: 42.093%. First build
22072536647

Pull #59

github

Pull Request #59: apps/summerization: Integrate Document Summary into Workflow

51 of 314 new or added lines in 7 files covered. (16.24%)

3564 of 8467 relevant lines covered (42.09%)

0.42 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

27.85
/apps/summarization/providers.py
1
"""Provider implementation for AI services."""
2

3
import logging
1✔
4
from abc import ABC
5
from typing import TypeVar
1✔
6
from typing import cast
1✔
7

1✔
8
from django.conf import settings
1✔
9
from pydantic import BaseModel
1✔
10
from pydantic_ai import Agent
1✔
11
from pydantic_ai import ImageUrl
1✔
12
from pydantic_ai import TextOutput
1✔
13
from pydantic_ai.models.mistral import MistralModel
14
from pydantic_ai.models.openai import OpenAIChatModel
15
from pydantic_ai.providers.mistral import MistralProvider
1✔
16
from pydantic_ai.providers.openai import OpenAIProvider
17
from sentry_sdk import capture_exception
18

1✔
19
from .llm_json import parse_structured_llm_json
20
from .pydantic_models import DocumentSummaryResponse
21
from .sentry_tags import ensure_sentry_project_tags
22

23
logger = logging.getLogger(__name__)
24

25
TModel = TypeVar("TModel", bound=BaseModel)
26

27

28
def _make_json_parse_fn(result_type: type[TModel]):
29
    def parse(text: str) -> TModel:
30
        return parse_structured_llm_json(text, result_type)
31

32
    return parse
33

34

35
class ProviderConfig:
36
    """Configuration for an AI provider."""
37

38
    def __init__(
×
39
        self,
×
40
        api_key: str,
×
41
        model_name: str,
×
NEW
42
        base_url: str,
×
NEW
43
        handle: str,
×
44
        supports_images: bool = True,
45
        supports_documents: bool = False,
1✔
46
    ):
1✔
47
        """
48
        Initialize provider configuration.
49

50
        Args:
51
            api_key: API key for the provider
52
            model_name: Name of the model to use
53
            base_url: Base URL for the API
54
            handle: Unique identifier/name for this provider configuration
55
            supports_images: Whether this provider supports image processing via vision API
56
            supports_documents: Whether this provider supports document processing (PDFs, etc.)
57
        """
58
        self.api_key = api_key
59
        self.model_name = model_name
60
        self.base_url = base_url
×
61
        self.handle = handle
62
        self.supports_images = supports_images
×
63
        self.supports_documents = supports_documents
×
64

65
    @classmethod
66
    def from_handle(cls, handle: str) -> "ProviderConfig":
67
        """
×
68
        Create ProviderConfig from handle by loading configuration from Django settings.
×
69

×
70
        Args:
71
            handle: Handle/name of the provider configuration
72

73
        Returns:
74
            ProviderConfig instance
×
75

76
        Raises:
77
            ValueError: If provider configuration is missing or invalid
×
78
        """
×
79
        # Get provider configurations from settings
80
        provider_configs = getattr(settings, "AI_PROVIDERS", {})
81

×
82
        if not provider_configs:
×
83
            raise ValueError(
84
                "AI_PROVIDERS not configured. " "Please configure providers in local.py"
85
            )
86

87
        if handle not in provider_configs:
×
88
            available = ", ".join(provider_configs.keys())
89
            raise ValueError(
90
                f"Unknown provider handle: {handle}. "
91
                f"Available providers: {available}"
92
            )
93

94
        config_dict = provider_configs[handle]
95

96
        # Validate required fields
97
        required_fields = ["api_key", "model_name", "base_url"]
1✔
98
        missing_fields = [
1✔
99
            field for field in required_fields if field not in config_dict
100
        ]
1✔
101
        if missing_fields:
×
102
            raise ValueError(
103
                f"Provider configuration '{handle}' is missing required fields: "
104
                f"{', '.join(missing_fields)}"
1✔
105
            )
106

107
        return cls(
1✔
108
            api_key=config_dict["api_key"],
109
            model_name=config_dict["model_name"],
110
            base_url=config_dict["base_url"],
111
            handle=handle,
112
            supports_images=config_dict.get("supports_images", True),
113
            supports_documents=config_dict.get("supports_documents", False),
114
        )
×
115

116

×
117
class AIRequest(ABC):
118
    vision_support = False
119

120
    def prompt(self) -> str:
121
        raise NotImplementedError("Subclasses must implement prompt()")
122

123

×
124
class AIProvider:
×
125
    """Unified provider for AI services using OpenAI-compatible APIs."""
126

127
    def __init__(self, config: ProviderConfig):
128
        """
×
129
        Initialize AI provider.
130

131
        Args:
×
132
            config: Provider configuration object
133
        """
134
        self.config = config
135

×
136
        self.system_prompt = getattr(
137
            settings,
1✔
138
            "SYSTEM_PROMPT",
139
            "",
140
        )
141

142
        # Use MistralProvider for Mistral, OpenAIProvider for others
143
        if config.handle == "mistral":
144
            self.provider = MistralProvider(
×
145
                api_key=config.api_key,
×
146
                base_url=config.base_url,
147
            )
1✔
148
            self.is_mistral = True
149
        else:
150
            # All other providers are OpenAI-compatible
151
            self.provider = OpenAIProvider(
152
                base_url=config.base_url,
153
                api_key=config.api_key,
154
            )
155
            self.is_mistral = False
156

157
    def _set_provider_handle(self, response: BaseModel) -> None:
158
        """
159
        Set provider handle on response if it has a provider field.
160

161
        Args:
×
162
            response: Response object to modify
×
163
        """
164
        if hasattr(response, "provider"):
165
            response.provider = self.config.handle
166

167
    def text_request(
×
168
        self, request: AIRequest, result_type: type[BaseModel]
169
    ) -> BaseModel:
170
        """
171
        Execute a text request with structured input and output.
172

×
173
        Args:
174
            request: Pydantic BaseModel with request data
175
            result_type: Pydantic BaseModel class for structured output
176

177
        Returns:
178
            Structured response as BaseModel instance
179
        """
×
180
        # Use MistralModel for Mistral, OpenAIChatModel for others
×
181
        if self.is_mistral:
182
            model = MistralModel(
×
183
                self.config.model_name,
184
                provider=self.provider,
×
185
            )
186
        else:
1✔
187
            model = OpenAIChatModel(
188
                model_name=self.config.model_name,
189
                provider=self.provider,
190
            )
191

NEW
192
        agent = Agent(
×
NEW
193
            model=model,
×
NEW
194
            system_prompt=self.system_prompt,
×
195
            output_type=TextOutput(_make_json_parse_fn(result_type)),
NEW
196
            tools=[],
×
197
        )
198

199
        try:
1✔
200
            ensure_sentry_project_tags()
201
            result = agent.run_sync(request.prompt())
202
            response = result.output
203

204
            self._set_provider_handle(response)
205

206
            return response
207
        except Exception as e:
208
            logger.error(
209
                f"AI text request failed with provider {self.config.handle} (model: {self.config.model_name}): {str(e)}",
210
                exc_info=True,
211
            )
212
            capture_exception(e)
213
            raise
214

215
    # Deprecate ? And Use text_request or vision_request instead ?
216
    def request(self, request: AIRequest, result_type: type[BaseModel]) -> BaseModel:
217
        """
×
218
        Automatically determines if it's a text or multimodal request.
×
219
        """
220
        # TODO: Check if the PROVIDER supports multimodal requests, or switch the Provider automaticly ?
221
        # Check if request supports vision (multimodal request)
222
        if getattr(request, "vision_support", False):
223
            image_urls = getattr(request, "image_urls", None) or []
NEW
224
            if not issubclass(result_type, DocumentSummaryResponse):
×
225
                raise TypeError(
226
                    "Vision requests require result_type to be DocumentSummaryResponse or a subclass."
227
                )
228
            return self.multimodal_request(
229
                request, cast(type[DocumentSummaryResponse], result_type), image_urls
×
230
            )
231
        else:
232
            return self.text_request(request, result_type)
233

234
    # Rename to vision_request instead and use only DocumentSummaryResponse for the result type?
235
    def multimodal_request(
236
        self,
237
        request: AIRequest,
238
        result_type: type[DocumentSummaryResponse],
239
        image_urls: list[str],
NEW
240
    ) -> DocumentSummaryResponse:
×
241
        """
242
        Execute a multimodal request with images using vision API.
243

244
        Args:
245
            request: Pydantic BaseModel with request data
246
            result_type: DocumentSummaryResponse (or a subclass at runtime)
247
            image_urls: List of image URLs to include in the request
248

249
        Returns:
250
            Structured response instance
251
        """
252
        # Use MistralModel for Mistral, OpenAIChatModel for others
253
        # Note: Mistral may not support vision/multimodal requests
254
        # Note: OpenAIResponsesModel uses /v1/responses endpoint which is not supported by all providers
NEW
255
        # Use OpenAIChatModel instead for better compatibility
×
NEW
256
        if self.is_mistral:
×
NEW
257
            model = MistralModel(
×
258
                self.config.model_name,
NEW
259
                provider=self.provider,
×
NEW
260
            )
×
261
        else:
NEW
262
            # Use OpenAIChatModel for image requests (better compatibility with OpenAI-compatible APIs)
×
NEW
263
            model = OpenAIChatModel(
×
NEW
264
                model_name=self.config.model_name,
×
265
                provider=self.provider,
266
            )
×
267

×
268
        agent = Agent(
269
            model=model,
×
270
            system_prompt=self.system_prompt,
271
            output_type=TextOutput(_make_json_parse_fn(result_type)),
×
272
            output_retries=3,
273
            tools=[],
274
        )
275

276
        # Build user content with prompt and image URLs
277
        # Filter URLs to only include supported formats
278
        # Both Mistral and OpenAI-compatible providers support images and PDFs
279
        supported_extensions = (
280
            ".jpg",
281
            ".jpeg",
282
            ".png",
283
            ".gif",
284
            ".webp",
285
            ".mpo",
286
            ".heif",
287
            ".avif",
288
            ".bmp",
289
            ".tiff",
290
            ".tif",
291
            ".pdf",  # PDFs supported by Mistral Vision and OpenAI-compatible providers
292
        )
293

294
        filtered_urls = []
295
        for url in image_urls:
296
            url_lower = url.lower()
297
            # Check if URL ends with supported extension
298
            if any(url_lower.endswith(ext) for ext in supported_extensions):
299
                filtered_urls.append(url)
300

301
        user_content = [request.prompt()]
302
        for url in filtered_urls:
303
            user_content.append(ImageUrl(url=url))
304

305
        try:
306
            ensure_sentry_project_tags()
307
            result = agent.run_sync(user_content)
308
            response = result.output
309

310
            self._set_provider_handle(response)
311

312
            return response
313
        except Exception as e:
314
            logger.error(
315
                f"AI multimodal request failed with provider {self.config.handle} (model: {self.config.model_name}, "
316
                f"images: {len(filtered_urls)}): {str(e)}",
317
                exc_info=True,
318
            )
319
            capture_exception(e)
320
            raise
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc