• 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

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

1✔
16
logger = logging.getLogger(__name__)
17

18

1✔
19
class ProviderConfig:
20
    """Configuration for an AI provider."""
21

22
    def __init__(
23
        self,
24
        api_key: str,
25
        model_name: str,
26
        base_url: str,
27
        handle: str,
28
        supports_images: bool = True,
29
        supports_documents: bool = False,
30
    ):
31
        """
32
        Initialize provider configuration.
33

34
        Args:
35
            api_key: API key for the provider
36
            model_name: Name of the model to use
37
            base_url: Base URL for the API
38
            handle: Unique identifier/name for this provider configuration
×
39
            supports_images: Whether this provider supports image processing via vision API
×
40
            supports_documents: Whether this provider supports document processing (PDFs, etc.)
×
41
        """
×
NEW
42
        self.api_key = api_key
×
NEW
43
        self.model_name = model_name
×
44
        self.base_url = base_url
45
        self.handle = handle
1✔
46
        self.supports_images = supports_images
1✔
47
        self.supports_documents = supports_documents
48

49
    @classmethod
50
    def from_handle(cls, handle: str) -> "ProviderConfig":
51
        """
52
        Create ProviderConfig from handle by loading configuration from Django settings.
53

54
        Args:
55
            handle: Handle/name of the provider configuration
56

57
        Returns:
58
            ProviderConfig instance
59

60
        Raises:
×
61
            ValueError: If provider configuration is missing or invalid
62
        """
×
63
        # Get provider configurations from settings
×
64
        provider_configs = getattr(settings, "AI_PROVIDERS", {})
65

66
        if not provider_configs:
67
            raise ValueError(
×
68
                "AI_PROVIDERS not configured. " "Please configure providers in local.py"
×
69
            )
×
70

71
        if handle not in provider_configs:
72
            available = ", ".join(provider_configs.keys())
73
            raise ValueError(
74
                f"Unknown provider handle: {handle}. "
×
75
                f"Available providers: {available}"
76
            )
77

×
78
        config_dict = provider_configs[handle]
×
79

80
        # Validate required fields
81
        required_fields = ["api_key", "model_name", "base_url"]
×
82
        missing_fields = [
×
83
            field for field in required_fields if field not in config_dict
84
        ]
85
        if missing_fields:
86
            raise ValueError(
87
                f"Provider configuration '{handle}' is missing required fields: "
×
88
                f"{', '.join(missing_fields)}"
89
            )
90

91
        return cls(
92
            api_key=config_dict["api_key"],
93
            model_name=config_dict["model_name"],
94
            base_url=config_dict["base_url"],
95
            handle=handle,
96
            supports_images=config_dict.get("supports_images", True),
97
            supports_documents=config_dict.get("supports_documents", False),
1✔
98
        )
1✔
99

100

1✔
101
class AIRequest(ABC):
×
102
    vision_support = False
103

104
    def prompt(self) -> str:
1✔
105
        raise NotImplementedError("Subclasses must implement prompt()")
106

107

1✔
108
class AIProvider:
109
    """Unified provider for AI services using OpenAI-compatible APIs."""
110

111
    def __init__(self, config: ProviderConfig):
112
        """
113
        Initialize AI provider.
114

×
115
        Args:
116
            config: Provider configuration object
×
117
        """
118
        self.config = config
119

120
        self.system_prompt = getattr(
121
            settings,
122
            "SYSTEM_PROMPT",
123
            "",
×
124
        )
×
125

126
        # Use MistralProvider for Mistral, OpenAIProvider for others
127
        if config.handle == "mistral":
128
            self.provider = MistralProvider(
×
129
                api_key=config.api_key,
130
                base_url=config.base_url,
131
            )
×
132
            self.is_mistral = True
133
        else:
134
            # All other providers are OpenAI-compatible
135
            self.provider = OpenAIProvider(
×
136
                base_url=config.base_url,
137
                api_key=config.api_key,
1✔
138
            )
139
            self.is_mistral = False
140

141
    def _set_provider_handle(self, response: BaseModel) -> None:
142
        """
143
        Set provider handle on response if it has a provider field.
144

×
145
        Args:
×
146
            response: Response object to modify
147
        """
1✔
148
        if hasattr(response, "provider"):
149
            response.provider = self.config.handle
150

151
    def text_request(
152
        self, request: AIRequest, result_type: type[BaseModel]
153
    ) -> BaseModel:
154
        """
155
        Execute a text request with structured input and output.
156

157
        Args:
158
            request: Pydantic BaseModel with request data
159
            result_type: Pydantic BaseModel class for structured output
160

161
        Returns:
×
162
            Structured response as BaseModel instance
×
163
        """
164
        # Use MistralModel for Mistral, OpenAIChatModel for others
165
        if self.is_mistral:
166
            model = MistralModel(
167
                self.config.model_name,
×
168
                provider=self.provider,
169
            )
170
        else:
171
            model = OpenAIChatModel(
172
                model_name=self.config.model_name,
×
173
                provider=self.provider,
174
            )
175

176
        agent = Agent(
177
            model=model,
178
            system_prompt=self.system_prompt,
179
            output_type=result_type,
×
180
            tools=[],  # Disable tool_calls to avoid validation errors with non-standard providers
×
181
        )
182

×
183
        try:
184
            result = agent.run_sync(request.prompt())
×
185
            response = result.output
186

1✔
187
            self._set_provider_handle(response)
188

189
            return response
190
        except Exception as e:
191
            logger.error(
NEW
192
                f"AI text request failed with provider {self.config.handle} (model: {self.config.model_name}): {str(e)}",
×
NEW
193
                exc_info=True,
×
NEW
194
            )
×
195
            capture_exception(e)
NEW
196
            raise
×
197

198
    def request(self, request: AIRequest, result_type: type[BaseModel]) -> BaseModel:
199
        """
1✔
200
        Automatically determines if it's a text or multimodal request.
201
        """
202
        # TODO: Check if the PROVIDER supports multimodal requests, or switch the Provider automaticly ?
203
        # Check if request supports vision (multimodal request)
204
        if getattr(request, "vision_support", False):
205
            image_urls = getattr(request, "image_urls", None) or []
206
            return self.multimodal_request(request, result_type, image_urls)
207
        else:
208
            return self.text_request(request, result_type)
209

210
    # TODO: Deprectaed ? Use separate Vison Requests instead ?
211
    def multimodal_request(
212
        self, request: AIRequest, result_type: type[BaseModel], image_urls: list[str]
213
    ) -> BaseModel:
214
        """
215
        Execute a multimodal request with images using vision API.
216

217
        Args:
×
218
            request: Pydantic BaseModel with request data
×
219
            result_type: Pydantic BaseModel class for structured output
220
            image_urls: List of image URLs to include in the request
221

222
        Returns:
223
            Structured response as BaseModel instance
NEW
224
        """
×
225
        # Use MistralModel for Mistral, OpenAIChatModel for others
226
        # Note: Mistral may not support vision/multimodal requests
227
        # Note: OpenAIResponsesModel uses /v1/responses endpoint which is not supported by all providers
228
        # Use OpenAIChatModel instead for better compatibility
229
        if self.is_mistral:
×
230
            model = MistralModel(
231
                self.config.model_name,
232
                provider=self.provider,
233
            )
234
        else:
235
            # Use OpenAIChatModel for image requests (better compatibility with OpenAI-compatible APIs)
236
            model = OpenAIChatModel(
237
                model_name=self.config.model_name,
238
                provider=self.provider,
239
            )
NEW
240

×
241
        agent = Agent(
242
            model=model,
243
            system_prompt=self.system_prompt,
244
            output_type=result_type,
245
            output_retries=3,  # Allow more retries for vision output validation
246
            tools=[],  # Disable tool_calls to avoid validation errors with non-standard providers
247
        )
248

249
        # Build user content with prompt and image URLs
250
        # Filter URLs to only include supported formats
251
        # Both Mistral and OpenAI-compatible providers support images and PDFs
252
        supported_extensions = (
253
            ".jpg",
254
            ".jpeg",
NEW
255
            ".png",
×
NEW
256
            ".gif",
×
NEW
257
            ".webp",
×
258
            ".mpo",
NEW
259
            ".heif",
×
NEW
260
            ".avif",
×
261
            ".bmp",
NEW
262
            ".tiff",
×
NEW
263
            ".tif",
×
NEW
264
            ".pdf",  # PDFs supported by Mistral Vision and OpenAI-compatible providers
×
265
        )
266

×
267
        filtered_urls = []
×
268
        for url in image_urls:
269
            url_lower = url.lower()
×
270
            # Check if URL ends with supported extension
271
            if any(url_lower.endswith(ext) for ext in supported_extensions):
×
272
                filtered_urls.append(url)
273

274
        user_content = [request.prompt()]
275
        for url in filtered_urls:
276
            user_content.append(ImageUrl(url=url))
277

278
        try:
279
            result = agent.run_sync(user_content)
280
            response = result.output
281

282
            self._set_provider_handle(response)
283

284
            return response
285
        except Exception as e:
286
            logger.error(
287
                f"AI multimodal request failed with provider {self.config.handle} (model: {self.config.model_name}, "
288
                f"images: {len(filtered_urls)}): {str(e)}",
289
                exc_info=True,
290
            )
291
            capture_exception(e)
292
            raise
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc