mito-ai 0.1.33__py3-none-any.whl → 0.1.35__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mito-ai might be problematic. Click here for more details.

Files changed (58) hide show
  1. mito_ai/_version.py +1 -1
  2. mito_ai/anthropic_client.py +52 -54
  3. mito_ai/app_builder/handlers.py +2 -4
  4. mito_ai/completions/models.py +15 -1
  5. mito_ai/completions/prompt_builders/agent_system_message.py +10 -2
  6. mito_ai/completions/providers.py +79 -39
  7. mito_ai/constants.py +11 -24
  8. mito_ai/gemini_client.py +44 -48
  9. mito_ai/openai_client.py +30 -44
  10. mito_ai/tests/message_history/test_generate_short_chat_name.py +0 -4
  11. mito_ai/tests/open_ai_utils_test.py +18 -22
  12. mito_ai/tests/{test_anthropic_client.py → providers/test_anthropic_client.py} +37 -32
  13. mito_ai/tests/providers/test_azure.py +2 -6
  14. mito_ai/tests/providers/test_capabilities.py +120 -0
  15. mito_ai/tests/{test_gemini_client.py → providers/test_gemini_client.py} +40 -36
  16. mito_ai/tests/providers/test_mito_server_utils.py +448 -0
  17. mito_ai/tests/providers/test_model_resolution.py +130 -0
  18. mito_ai/tests/providers/test_openai_client.py +57 -0
  19. mito_ai/tests/providers/test_provider_completion_exception.py +66 -0
  20. mito_ai/tests/providers/test_provider_limits.py +42 -0
  21. mito_ai/tests/providers/test_providers.py +382 -0
  22. mito_ai/tests/providers/test_retry_logic.py +389 -0
  23. mito_ai/tests/providers/utils.py +85 -0
  24. mito_ai/tests/test_constants.py +15 -2
  25. mito_ai/tests/test_telemetry.py +12 -0
  26. mito_ai/utils/anthropic_utils.py +21 -29
  27. mito_ai/utils/gemini_utils.py +18 -22
  28. mito_ai/utils/mito_server_utils.py +92 -0
  29. mito_ai/utils/open_ai_utils.py +22 -46
  30. mito_ai/utils/provider_utils.py +49 -0
  31. mito_ai/utils/telemetry_utils.py +11 -1
  32. {mito_ai-0.1.33.data → mito_ai-0.1.35.data}/data/share/jupyter/labextensions/mito_ai/build_log.json +1 -1
  33. {mito_ai-0.1.33.data → mito_ai-0.1.35.data}/data/share/jupyter/labextensions/mito_ai/package.json +2 -2
  34. {mito_ai-0.1.33.data → mito_ai-0.1.35.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/package.json.orig +1 -1
  35. mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.281f4b9af60d620c6fb1.js → mito_ai-0.1.35.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.a20772bc113422d0f505.js +737 -319
  36. mito_ai-0.1.35.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.a20772bc113422d0f505.js.map +1 -0
  37. mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.4f1d00fd0c58fcc05d8d.js → mito_ai-0.1.35.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.d2eea6519fa332d79efb.js +13 -16
  38. mito_ai-0.1.35.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.d2eea6519fa332d79efb.js.map +1 -0
  39. mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.06083e515de4862df010.js → mito_ai-0.1.35.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.76efcc5c3be4056457ee.js +6 -2
  40. mito_ai-0.1.35.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.76efcc5c3be4056457ee.js.map +1 -0
  41. {mito_ai-0.1.33.dist-info → mito_ai-0.1.35.dist-info}/METADATA +1 -1
  42. {mito_ai-0.1.33.dist-info → mito_ai-0.1.35.dist-info}/RECORD +52 -43
  43. mito_ai/tests/providers_test.py +0 -438
  44. mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.281f4b9af60d620c6fb1.js.map +0 -1
  45. mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.4f1d00fd0c58fcc05d8d.js.map +0 -1
  46. mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.06083e515de4862df010.js.map +0 -1
  47. mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_html2canvas_dist_html2canvas_js.ea47e8c8c906197f8d19.js +0 -7842
  48. mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_html2canvas_dist_html2canvas_js.ea47e8c8c906197f8d19.js.map +0 -1
  49. {mito_ai-0.1.33.data → mito_ai-0.1.35.data}/data/etc/jupyter/jupyter_server_config.d/mito_ai.json +0 -0
  50. {mito_ai-0.1.33.data → mito_ai-0.1.35.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/toolbar-buttons.json +0 -0
  51. {mito_ai-0.1.33.data → mito_ai-0.1.35.data}/data/share/jupyter/labextensions/mito_ai/static/style.js +0 -0
  52. {mito_ai-0.1.33.data → mito_ai-0.1.35.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.9795f79265ddb416864b.js +0 -0
  53. {mito_ai-0.1.33.data → mito_ai-0.1.35.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.9795f79265ddb416864b.js.map +0 -0
  54. {mito_ai-0.1.33.data → mito_ai-0.1.35.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js +0 -0
  55. {mito_ai-0.1.33.data → mito_ai-0.1.35.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js.map +0 -0
  56. {mito_ai-0.1.33.dist-info → mito_ai-0.1.35.dist-info}/WHEEL +0 -0
  57. {mito_ai-0.1.33.dist-info → mito_ai-0.1.35.dist-info}/entry_points.txt +0 -0
  58. {mito_ai-0.1.33.dist-info → mito_ai-0.1.35.dist-info}/licenses/LICENSE +0 -0
@@ -5,12 +5,13 @@ import pytest
5
5
  import ast
6
6
  import inspect
7
7
  import requests
8
- from mito_ai.gemini_client import GeminiClient, GEMINI_FAST_MODEL, get_gemini_system_prompt_and_messages
9
- from mito_ai.utils.gemini_utils import get_gemini_completion_function_params
8
+ from mito_ai.gemini_client import GeminiClient, get_gemini_system_prompt_and_messages
9
+ from mito_ai.utils.gemini_utils import get_gemini_completion_function_params, FAST_GEMINI_MODEL
10
10
  from google.genai.types import Part, GenerateContentResponse, Candidate, Content
11
11
  from mito_ai.completions.models import ResponseFormatInfo, AgentResponse
12
12
  from unittest.mock import MagicMock, patch
13
13
  from typing import List, Dict, Any
14
+ from mito_ai.completions.models import MessageType
14
15
 
15
16
  # Dummy base64 image (1x1 PNG)
16
17
  DUMMY_IMAGE_DATA_URL = (
@@ -73,7 +74,7 @@ async def test_json_response_handling():
73
74
  )
74
75
 
75
76
  # Create a mock client with the response
76
- client = GeminiClient(api_key="test_key", model="test-model")
77
+ client = GeminiClient(api_key="test_key")
77
78
  client.client = MagicMock()
78
79
  client.client.models.generate_content.return_value = mock_response
79
80
 
@@ -81,6 +82,7 @@ async def test_json_response_handling():
81
82
  response_format_info = ResponseFormatInfo(name="agent_response", format=AgentResponse)
82
83
  result = await client.request_completions(
83
84
  messages=[{"role": "user", "content": "Test message"}],
85
+ model="test-model",
84
86
  response_format_info=response_format_info
85
87
  )
86
88
  assert result == '{"key": "value"}'
@@ -107,7 +109,7 @@ async def test_json_response_handling_with_invalid_json():
107
109
  )
108
110
 
109
111
  # Create a mock client with the response
110
- client = GeminiClient(api_key="test_key", model="test-model")
112
+ client = GeminiClient(api_key="test_key")
111
113
  client.client = MagicMock()
112
114
  client.client.models.generate_content.return_value = mock_response
113
115
 
@@ -115,6 +117,7 @@ async def test_json_response_handling_with_invalid_json():
115
117
  response_format_info = ResponseFormatInfo(name="agent_response", format=AgentResponse)
116
118
  result = await client.request_completions(
117
119
  messages=[{"role": "user", "content": "Test message"}],
120
+ model="test-model",
118
121
  response_format_info=response_format_info
119
122
  )
120
123
  # Should return the raw string even if JSON is invalid
@@ -138,7 +141,7 @@ async def test_json_response_handling_with_multiple_parts():
138
141
  )
139
142
 
140
143
  # Create a mock client with the response
141
- client = GeminiClient(api_key="test_key", model="test-model")
144
+ client = GeminiClient(api_key="test_key")
142
145
  client.client = MagicMock()
143
146
  client.client.models.generate_content.return_value = mock_response
144
147
 
@@ -146,46 +149,47 @@ async def test_json_response_handling_with_multiple_parts():
146
149
  response_format_info = ResponseFormatInfo(name="agent_response", format=AgentResponse)
147
150
  result = await client.request_completions(
148
151
  messages=[{"role": "user", "content": "Test message"}],
152
+ model="test-model",
149
153
  response_format_info=response_format_info
150
154
  )
151
155
  # Should concatenate all parts
152
156
  assert result == 'Here is the JSON: {"key": "value"} End of response'
153
157
 
154
- CUSTOM_MODEL = "gemini-1.5-pro"
155
- @pytest.mark.parametrize("response_format_info, expected_model", [
156
- (ResponseFormatInfo(name="agent_response", format=AgentResponse), CUSTOM_MODEL), # With response_format_info - should use self.model
157
- (None, GEMINI_FAST_MODEL), # Without response_format_info - should use GEMINI_FAST_MODEL
158
+ CUSTOM_MODEL = "smart-gemini-model"
159
+ @pytest.mark.parametrize("message_type, expected_model", [
160
+ (MessageType.CHAT, CUSTOM_MODEL), #
161
+ (MessageType.SMART_DEBUG, CUSTOM_MODEL), #
162
+ (MessageType.CODE_EXPLAIN, CUSTOM_MODEL), #
163
+ (MessageType.AGENT_EXECUTION, CUSTOM_MODEL), #
164
+ (MessageType.AGENT_AUTO_ERROR_FIXUP, CUSTOM_MODEL), #
165
+ (MessageType.INLINE_COMPLETION, FAST_GEMINI_MODEL), #
166
+ (MessageType.CHAT_NAME_GENERATION, FAST_GEMINI_MODEL), #
158
167
  ])
159
- @pytest.mark.asyncio
160
- async def test_model_selection_based_on_response_format_info(response_format_info, expected_model):
168
+ @pytest.mark.asyncio
169
+ async def test_get_completion_model_selection_based_on_message_type(message_type, expected_model):
161
170
  """
162
- Tests that the correct model is selected based on whether response_format_info is provided.
171
+ Tests that the correct model is selected based on the message type.
163
172
  """
164
-
165
- # Create a GeminiClient with a specific model
166
- custom_model = CUSTOM_MODEL
167
- client = GeminiClient(api_key="test_key", model=custom_model)
168
-
169
- # Mock the generate_content method to avoid actual API calls
170
- client.client = MagicMock()
171
- mock_response = GenerateContentResponse(
172
- candidates=[
173
- Candidate(
174
- content=Content(
175
- parts=[Part(text='Test response')]
176
- )
177
- )
178
- ]
179
- )
180
- client.client.models.generate_content.return_value = mock_response
181
-
182
- with patch('mito_ai.gemini_client.get_gemini_completion_function_params', wraps=get_gemini_completion_function_params) as mock_get_params:
173
+ with patch('google.genai.Client') as mock_genai_class:
174
+ mock_client = MagicMock()
175
+ mock_models = MagicMock()
176
+ mock_client.models = mock_models
177
+ mock_genai_class.return_value = mock_client
178
+
179
+ client = GeminiClient(api_key="test_key")
180
+
181
+ # Create a mock response
182
+ mock_response = 'test-response'
183
+ mock_models.generate_content.return_value = mock_response
184
+
183
185
  await client.request_completions(
184
186
  messages=[{"role": "user", "content": "Test message"}],
185
- response_format_info=response_format_info
187
+ model=CUSTOM_MODEL,
188
+ message_type=message_type,
189
+ response_format_info=None
186
190
  )
187
191
 
188
- # Verify that get_gemini_completion_function_params was called with the expected model
189
- mock_get_params.assert_called_once()
190
- call_args = mock_get_params.call_args
191
- assert call_args[1]['model'] == expected_model
192
+ # Verify that generate_content was called with the expected model
193
+ mock_models.generate_content.assert_called_once()
194
+ call_args = mock_models.generate_content.call_args
195
+ assert call_args[1]['model'] == expected_model
@@ -0,0 +1,448 @@
1
+ # Copyright (c) Saga Inc.
2
+ # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
+
4
+ import pytest
5
+ import json
6
+ import time
7
+ from unittest.mock import MagicMock, patch, AsyncMock
8
+ from tornado.httpclient import HTTPResponse
9
+
10
+ from mito_ai.utils.mito_server_utils import (
11
+ ProviderCompletionException,
12
+ get_response_from_mito_server
13
+ )
14
+ from mito_ai.completions.models import MessageType
15
+
16
+
17
+ @pytest.fixture
18
+ def mock_request_params():
19
+ """Standard request parameters for testing."""
20
+ return {
21
+ "url": "https://api.example.com",
22
+ "headers": {"Content-Type": "application/json"},
23
+ "data": {"query": "test query"},
24
+ "timeout": 30,
25
+ "max_retries": 3,
26
+ "message_type": MessageType.CHAT,
27
+ "provider_name": "Test Provider"
28
+ }
29
+
30
+
31
+ @pytest.fixture
32
+ def mock_http_dependencies():
33
+ """Mock the HTTP client and related dependencies."""
34
+ with patch('mito_ai.utils.mito_server_utils._create_http_client') as mock_create_client, \
35
+ patch('mito_ai.utils.mito_server_utils.update_mito_server_quota') as mock_update_quota, \
36
+ patch('mito_ai.utils.mito_server_utils.check_mito_server_quota') as mock_check_quota, \
37
+ patch('mito_ai.utils.mito_server_utils.time.time') as mock_time:
38
+
39
+ # Setup mock HTTP client
40
+ mock_http_client = MagicMock()
41
+ mock_create_client.return_value = (mock_http_client, 30)
42
+
43
+ # Setup mock time
44
+ mock_time.side_effect = [0.0, 1.5] # start_time, end_time
45
+
46
+ yield {
47
+ 'mock_check_quota': mock_check_quota,
48
+ 'mock_create_client': mock_create_client,
49
+ 'mock_http_client': mock_http_client,
50
+ 'mock_update_quota': mock_update_quota,
51
+ 'mock_time': mock_time
52
+ }
53
+
54
+
55
+ def create_mock_response(body_content: dict):
56
+ """Helper to create mock HTTP response."""
57
+ mock_response = MagicMock(spec=HTTPResponse)
58
+ mock_response.body.decode.return_value = json.dumps(body_content)
59
+ return mock_response
60
+
61
+
62
+ class TestProviderCompletionException:
63
+ """Test the ProviderCompletionException class."""
64
+
65
+ @pytest.mark.parametrize("error_message,provider_name,error_type,expected_title,expected_hint_contains", [
66
+ (
67
+ "Something went wrong",
68
+ "LLM Provider",
69
+ "LLMProviderError",
70
+ "LLM Provider Error: Something went wrong",
71
+ "LLM Provider"
72
+ ),
73
+ (
74
+ "API key is invalid",
75
+ "OpenAI",
76
+ "AuthenticationError",
77
+ "OpenAI Error: API key is invalid",
78
+ "OpenAI"
79
+ ),
80
+ (
81
+ "There was an error accessing the Anthropic API: Error code: 529 - {'type': 'error', 'error': {'type': 'overloaded_error', 'message': 'Overloaded'}}",
82
+ "Anthropic",
83
+ "LLMProviderError",
84
+ "Anthropic Error: There was an error accessing the Anthropic API: Error code: 529 - {'type': 'error', 'error': {'type': 'overloaded_error', 'message': 'Overloaded'}}",
85
+ "Anthropic"
86
+ ),
87
+ ])
88
+ def test_exception_initialization(
89
+ self,
90
+ error_message: str,
91
+ provider_name: str,
92
+ error_type: str,
93
+ expected_title: str,
94
+ expected_hint_contains: str
95
+ ):
96
+ """Test exception initialization with various parameter combinations."""
97
+ exception = ProviderCompletionException(
98
+ error_message,
99
+ provider_name=provider_name,
100
+ error_type=error_type
101
+ )
102
+
103
+ assert exception.error_message == error_message
104
+ assert exception.provider_name == provider_name
105
+ assert exception.error_type == error_type
106
+ assert exception.user_friendly_title == expected_title
107
+ assert expected_hint_contains in exception.user_friendly_hint
108
+ assert str(exception) == expected_title
109
+ assert exception.args[0] == expected_title
110
+
111
+ def test_default_initialization(self):
112
+ """Test exception initialization with default values."""
113
+ error_msg = "Something went wrong"
114
+ exception = ProviderCompletionException(error_msg)
115
+
116
+ assert exception.error_message == error_msg
117
+ assert exception.provider_name == "LLM Provider"
118
+ assert exception.error_type == "LLMProviderError"
119
+ assert exception.user_friendly_title == "LLM Provider Error: Something went wrong"
120
+ assert "LLM Provider" in exception.user_friendly_hint
121
+
122
+
123
+ class TestGetResponseFromMitoServer:
124
+ """Test the get_response_from_mito_server function."""
125
+
126
+ @pytest.mark.parametrize("completion_value,message_type", [
127
+ ("This is the AI response", MessageType.CHAT),
128
+ ("Code completion here", MessageType.INLINE_COMPLETION),
129
+ ("", MessageType.CHAT), # Empty string
130
+ (None, MessageType.INLINE_COMPLETION), # None value
131
+ ("Multi-line\nresponse\nhere", MessageType.CHAT), # Multi-line response
132
+ ])
133
+ @pytest.mark.asyncio
134
+ async def test_successful_completion_responses(
135
+ self,
136
+ completion_value,
137
+ message_type: MessageType,
138
+ mock_request_params,
139
+ mock_http_dependencies
140
+ ):
141
+ """Test successful responses with various completion values."""
142
+ # Setup
143
+ response_body = {"completion": completion_value}
144
+ mock_response = create_mock_response(response_body)
145
+ mock_http_dependencies['mock_http_client'].fetch = AsyncMock(return_value=mock_response)
146
+
147
+ # Update request params
148
+ mock_request_params["message_type"] = message_type
149
+
150
+ # Execute
151
+ result = await get_response_from_mito_server(**mock_request_params)
152
+
153
+ # Verify
154
+ assert result == completion_value
155
+ mock_http_dependencies['mock_check_quota'].assert_called_once_with(message_type)
156
+ mock_http_dependencies['mock_update_quota'].assert_called_once_with(message_type)
157
+ mock_http_dependencies['mock_http_client'].close.assert_called_once()
158
+
159
+ # Verify HTTP request was made correctly
160
+ mock_http_dependencies['mock_http_client'].fetch.assert_called_once_with(
161
+ mock_request_params["url"],
162
+ method="POST",
163
+ headers=mock_request_params["headers"],
164
+ body=json.dumps(mock_request_params["data"]),
165
+ request_timeout=30
166
+ )
167
+
168
+ @pytest.mark.parametrize("error_message,provider_name,expected_exception_provider", [
169
+ (
170
+ "There was an error accessing the Anthropic API: Error code: 529 - {'type': 'error', 'error': {'type': 'overloaded_error', 'message': 'Overloaded'}}",
171
+ "Anthropic",
172
+ "Anthropic"
173
+ ),
174
+ (
175
+ "Rate limit exceeded",
176
+ "OpenAI",
177
+ "OpenAI"
178
+ ),
179
+ (
180
+ "Invalid API key",
181
+ "Custom Provider",
182
+ "Custom Provider"
183
+ ),
184
+ (
185
+ "Server timeout",
186
+ "Mito Server",
187
+ "Mito Server"
188
+ ),
189
+ ])
190
+ @pytest.mark.asyncio
191
+ async def test_error_responses_from_server(
192
+ self,
193
+ error_message: str,
194
+ provider_name: str,
195
+ expected_exception_provider: str,
196
+ mock_request_params,
197
+ mock_http_dependencies
198
+ ):
199
+ """Test server returns error response with various error messages and providers."""
200
+ # Setup
201
+ response_body = {"error": error_message}
202
+ mock_response = create_mock_response(response_body)
203
+ mock_http_dependencies['mock_http_client'].fetch = AsyncMock(return_value=mock_response)
204
+
205
+ # Update request params
206
+ mock_request_params["provider_name"] = provider_name
207
+
208
+ # Execute and verify exception
209
+ with pytest.raises(ProviderCompletionException) as exc_info:
210
+ await get_response_from_mito_server(**mock_request_params)
211
+
212
+ # Verify exception details
213
+ exception = exc_info.value
214
+ assert exception.error_message == error_message
215
+ assert exception.provider_name == expected_exception_provider
216
+ assert f"{expected_exception_provider} Error" in str(exception)
217
+
218
+ # Verify quota was updated and client was closed
219
+ mock_http_dependencies['mock_update_quota'].assert_called_once_with(mock_request_params["message_type"])
220
+ mock_http_dependencies['mock_http_client'].close.assert_called_once()
221
+
222
+ @pytest.mark.parametrize("response_body,expected_error_contains", [
223
+ ({"some_other_field": "value"}, "No completion found in response"),
224
+ ({"data": "value", "status": "ok"}, "No completion found in response"),
225
+ ({}, "No completion found in response"),
226
+ ({"completion": None, "error": "also present"}, None), # completion takes precedence
227
+ ])
228
+ @pytest.mark.asyncio
229
+ async def test_invalid_response_formats(
230
+ self,
231
+ response_body: dict,
232
+ expected_error_contains: str,
233
+ mock_request_params,
234
+ mock_http_dependencies
235
+ ):
236
+ """Test responses with invalid formats."""
237
+ # Setup
238
+ mock_response = create_mock_response(response_body)
239
+ mock_http_dependencies['mock_http_client'].fetch = AsyncMock(return_value=mock_response)
240
+
241
+ if "completion" in response_body:
242
+ # This should succeed because completion field exists
243
+ result = await get_response_from_mito_server(**mock_request_params)
244
+ assert result == response_body["completion"]
245
+ mock_http_dependencies['mock_update_quota'].assert_called_once()
246
+ else:
247
+ # Execute and verify exception
248
+ with pytest.raises(ProviderCompletionException) as exc_info:
249
+ await get_response_from_mito_server(**mock_request_params)
250
+
251
+ # Verify exception details
252
+ exception = exc_info.value
253
+ assert expected_error_contains in exception.error_message
254
+ assert str(response_body) in exception.error_message
255
+ assert exception.provider_name == mock_request_params["provider_name"]
256
+
257
+ # Verify quota was NOT updated
258
+ mock_http_dependencies['mock_update_quota'].assert_called_once_with(mock_request_params["message_type"])
259
+
260
+ # Client should always be closed
261
+ mock_http_dependencies['mock_http_client'].close.assert_called_once()
262
+
263
+ @pytest.mark.parametrize("invalid_json_content,expected_error_contains", [
264
+ ("invalid json content", "Error parsing response"),
265
+ ('{"incomplete": json', "Error parsing response"),
266
+ ("", "Error parsing response"),
267
+ ('{"malformed":', "Error parsing response"),
268
+ ])
269
+ @pytest.mark.asyncio
270
+ async def test_json_parsing_errors(
271
+ self,
272
+ invalid_json_content: str,
273
+ expected_error_contains: str,
274
+ mock_request_params,
275
+ mock_http_dependencies
276
+ ):
277
+ """Test response with invalid or malformed JSON."""
278
+ # Setup
279
+ mock_response = MagicMock(spec=HTTPResponse)
280
+ mock_response.body.decode.return_value = invalid_json_content
281
+ mock_http_dependencies['mock_http_client'].fetch = AsyncMock(return_value=mock_response)
282
+
283
+ # Execute and verify exception
284
+ with pytest.raises(ProviderCompletionException) as exc_info:
285
+ await get_response_from_mito_server(**mock_request_params)
286
+
287
+ # Verify exception details
288
+ exception = exc_info.value
289
+ assert expected_error_contains in exception.error_message
290
+ assert exception.provider_name == mock_request_params["provider_name"]
291
+
292
+ # Verify quota was updated and client was closed
293
+ mock_http_dependencies['mock_update_quota'].assert_called_once_with(mock_request_params["message_type"])
294
+ mock_http_dependencies['mock_http_client'].close.assert_called_once()
295
+
296
+ @pytest.mark.parametrize("timeout,max_retries", [
297
+ (30, 3),
298
+ (45, 5),
299
+ (60, 1),
300
+ (15, 0),
301
+ ])
302
+ @pytest.mark.asyncio
303
+ async def test_http_client_creation_parameters(
304
+ self,
305
+ timeout: int,
306
+ max_retries: int,
307
+ mock_request_params,
308
+ mock_http_dependencies
309
+ ):
310
+ """Test that HTTP client is created with correct parameters."""
311
+ # Setup
312
+ response_body = {"completion": "test response"}
313
+ mock_response = create_mock_response(response_body)
314
+ mock_http_dependencies['mock_http_client'].fetch = AsyncMock(return_value=mock_response)
315
+
316
+ # Update request params
317
+ mock_request_params["timeout"] = timeout
318
+ mock_request_params["max_retries"] = max_retries
319
+
320
+ # Execute
321
+ await get_response_from_mito_server(**mock_request_params)
322
+
323
+ # Verify HTTP client creation
324
+ mock_http_dependencies['mock_create_client'].assert_called_once_with(timeout, max_retries)
325
+
326
+ @pytest.mark.parametrize("exception_type,exception_message", [
327
+ (Exception, "Network error"),
328
+ (ConnectionError, "Connection failed"),
329
+ (TimeoutError, "Request timed out"),
330
+ (RuntimeError, "Runtime error occurred"),
331
+ ])
332
+ @pytest.mark.asyncio
333
+ async def test_http_client_always_closed_on_exception(
334
+ self,
335
+ exception_type,
336
+ exception_message: str,
337
+ mock_request_params,
338
+ mock_http_dependencies
339
+ ):
340
+ """Test that HTTP client is always closed even when exceptions occur."""
341
+ # Setup - make fetch raise an exception
342
+ test_exception = exception_type(exception_message)
343
+ mock_http_dependencies['mock_http_client'].fetch = AsyncMock(side_effect=test_exception)
344
+
345
+ # Execute and expect exception to bubble up
346
+ with pytest.raises(exception_type, match=exception_message):
347
+ await get_response_from_mito_server(**mock_request_params)
348
+
349
+ # Verify client was still closed despite the exception
350
+ mock_http_dependencies['mock_http_client'].close.assert_called_once()
351
+
352
+ @pytest.mark.asyncio
353
+ async def test_default_provider_name(self, mock_http_dependencies):
354
+ """Test that default provider name is used when not specified."""
355
+ # Setup
356
+ error_message = "Test error"
357
+ response_body = {"error": error_message}
358
+ mock_response = create_mock_response(response_body)
359
+ mock_http_dependencies['mock_http_client'].fetch = AsyncMock(return_value=mock_response)
360
+
361
+ # Test data without provider_name parameter
362
+ request_params = {
363
+ "url": "https://api.example.com",
364
+ "headers": {"Content-Type": "application/json"},
365
+ "data": {"query": "test query"},
366
+ "timeout": 30,
367
+ "max_retries": 3,
368
+ "message_type": MessageType.CHAT,
369
+ # Note: not providing provider_name parameter
370
+ }
371
+
372
+ # Execute and verify exception
373
+ with pytest.raises(ProviderCompletionException) as exc_info:
374
+ await get_response_from_mito_server(**request_params) # type: ignore
375
+
376
+ # Verify default provider name is used
377
+ exception = exc_info.value
378
+ assert exception.provider_name == "Mito Server"
379
+ assert "Mito Server Error" in str(exception)
380
+
381
+ @pytest.mark.asyncio
382
+ async def test_provider_completion_exception_reraised(self, mock_request_params, mock_http_dependencies):
383
+ """Test that ProviderCompletionException is re-raised correctly during JSON parsing."""
384
+ # Setup - simulate ProviderCompletionException during JSON parsing
385
+ mock_response = MagicMock(spec=HTTPResponse)
386
+ mock_response.body.decode.return_value = "some json content" # This will trigger json.loads
387
+
388
+ def mock_json_loads(content, **kwargs):
389
+ raise ProviderCompletionException("Custom parsing error", "Custom Provider")
390
+
391
+ mock_http_dependencies['mock_http_client'].fetch = AsyncMock(return_value=mock_response)
392
+
393
+ with patch('mito_ai.utils.mito_server_utils.json.loads', side_effect=mock_json_loads), \
394
+ patch('mito_ai.utils.mito_server_utils.check_mito_server_quota') as mock_check_quota:
395
+
396
+ # Execute and verify exception
397
+ with pytest.raises(ProviderCompletionException) as exc_info:
398
+ await get_response_from_mito_server(**mock_request_params)
399
+
400
+ # Verify the original exception is preserved
401
+ exception = exc_info.value
402
+ assert exception.error_message == "Custom parsing error"
403
+ assert exception.provider_name == "Custom Provider"
404
+
405
+ # Verify quota check was called
406
+ mock_check_quota.assert_called_once_with(mock_request_params["message_type"])
407
+
408
+ # Verify client was closed
409
+ mock_http_dependencies['mock_http_client'].close.assert_called_once()
410
+
411
+
412
+ @pytest.mark.parametrize("scenario,response_setup,main_exception,quota_exception", [
413
+ ("successful_with_quota_error", {"completion": "Success"}, None, Exception("Quota update failed")),
414
+ ("server_error_with_quota_error", {"error": "Server error"}, ProviderCompletionException, Exception("Quota update failed")),
415
+ ("invalid_format_with_quota_error", {"invalid": "format"}, ProviderCompletionException, RuntimeError("Quota system down")),
416
+ ("success_with_quota_timeout", {"completion": "Success"}, None, TimeoutError("Quota service timeout")),
417
+ ])
418
+ @pytest.mark.asyncio
419
+ async def test_quota_update_exceptions_do_not_interfere(
420
+ self,
421
+ scenario: str,
422
+ response_setup: dict,
423
+ main_exception,
424
+ quota_exception,
425
+ mock_request_params,
426
+ mock_http_dependencies
427
+ ):
428
+ """Test that quota update exceptions don't interfere with main function logic."""
429
+ # Setup
430
+ mock_response = create_mock_response(response_setup)
431
+ mock_http_dependencies['mock_http_client'].fetch = AsyncMock(return_value=mock_response)
432
+ mock_http_dependencies['mock_update_quota'].side_effect = quota_exception
433
+
434
+ # Execute
435
+ if main_exception:
436
+ with pytest.raises(main_exception) as exc_info:
437
+ await get_response_from_mito_server(**mock_request_params)
438
+
439
+ # Verify the original error is preserved, not the quota error
440
+ if "error" in response_setup:
441
+ assert exc_info.value.error_message == response_setup["error"]
442
+ else:
443
+ # Should still succeed despite quota update failure
444
+ result = await get_response_from_mito_server(**mock_request_params)
445
+ assert result == response_setup["completion"]
446
+
447
+ # Verify quota update was attempted
448
+ mock_http_dependencies['mock_update_quota'].assert_called_once_with(mock_request_params["message_type"])