kolega-code 0.1.0__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.
Files changed (171) hide show
  1. kolega_code/__init__.py +151 -0
  2. kolega_code/agent/__init__.py +42 -0
  3. kolega_code/agent/baseagent.py +998 -0
  4. kolega_code/agent/browseragent.py +123 -0
  5. kolega_code/agent/coder.py +157 -0
  6. kolega_code/agent/common.py +41 -0
  7. kolega_code/agent/compression.py +81 -0
  8. kolega_code/agent/context.py +112 -0
  9. kolega_code/agent/conversation.py +408 -0
  10. kolega_code/agent/generalagent.py +146 -0
  11. kolega_code/agent/investigationagent.py +123 -0
  12. kolega_code/agent/planningagent.py +187 -0
  13. kolega_code/agent/prompt_provider.py +196 -0
  14. kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
  15. kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
  16. kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
  17. kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
  18. kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
  19. kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
  20. kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
  21. kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
  22. kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
  23. kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
  24. kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
  25. kolega_code/agent/prompts.py +192 -0
  26. kolega_code/agent/tests/__init__.py +0 -0
  27. kolega_code/agent/tests/llm/__init__.py +0 -0
  28. kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
  29. kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
  30. kolega_code/agent/tests/llm/test_client.py +773 -0
  31. kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
  32. kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
  33. kolega_code/agent/tests/llm/test_exceptions.py +249 -0
  34. kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
  35. kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
  36. kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
  37. kolega_code/agent/tests/llm/test_model_specs.py +17 -0
  38. kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
  39. kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
  40. kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
  41. kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
  42. kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
  43. kolega_code/agent/tests/services/__init__.py +1 -0
  44. kolega_code/agent/tests/services/test_browser.py +447 -0
  45. kolega_code/agent/tests/services/test_browser_parity.py +353 -0
  46. kolega_code/agent/tests/services/test_file_system.py +699 -0
  47. kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
  48. kolega_code/agent/tests/services/test_terminal.py +154 -0
  49. kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
  50. kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
  51. kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
  52. kolega_code/agent/tests/test_base_agent.py +1942 -0
  53. kolega_code/agent/tests/test_coder_attachments.py +330 -0
  54. kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
  55. kolega_code/agent/tests/test_commands.py +179 -0
  56. kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
  57. kolega_code/agent/tests/test_empty_message_handling.py +48 -0
  58. kolega_code/agent/tests/test_general_agent.py +242 -0
  59. kolega_code/agent/tests/test_html.py +320 -0
  60. kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
  61. kolega_code/agent/tests/test_planning_agent.py +227 -0
  62. kolega_code/agent/tests/test_prompt_provider.py +271 -0
  63. kolega_code/agent/tests/test_tool_registry.py +102 -0
  64. kolega_code/agent/tests/test_tools.py +549 -0
  65. kolega_code/agent/tests/tool_backend/__init__.py +0 -0
  66. kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
  67. kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
  68. kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
  69. kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
  70. kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
  71. kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
  72. kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
  73. kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
  74. kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
  75. kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
  76. kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
  77. kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
  78. kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
  79. kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
  80. kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
  81. kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
  82. kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
  83. kolega_code/agent/tool_backend/agent_tool.py +414 -0
  84. kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
  85. kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
  86. kolega_code/agent/tool_backend/base_tool.py +217 -0
  87. kolega_code/agent/tool_backend/browser_tool.py +271 -0
  88. kolega_code/agent/tool_backend/build_tool.py +93 -0
  89. kolega_code/agent/tool_backend/create_file_tool.py +52 -0
  90. kolega_code/agent/tool_backend/glob_tool.py +323 -0
  91. kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
  92. kolega_code/agent/tool_backend/memory_tool.py +79 -0
  93. kolega_code/agent/tool_backend/read_file_tool.py +119 -0
  94. kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
  95. kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
  96. kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
  97. kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
  98. kolega_code/agent/tool_backend/streaming_tool.py +47 -0
  99. kolega_code/agent/tool_backend/terminal_tool.py +643 -0
  100. kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
  101. kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
  102. kolega_code/agent/tools.py +1704 -0
  103. kolega_code/agent/utils/commands.py +94 -0
  104. kolega_code/cli/__init__.py +1 -0
  105. kolega_code/cli/app.py +2756 -0
  106. kolega_code/cli/config.py +280 -0
  107. kolega_code/cli/connection.py +49 -0
  108. kolega_code/cli/file_index.py +147 -0
  109. kolega_code/cli/main.py +564 -0
  110. kolega_code/cli/mentions.py +155 -0
  111. kolega_code/cli/messages.py +89 -0
  112. kolega_code/cli/provider_registry.py +96 -0
  113. kolega_code/cli/session_store.py +207 -0
  114. kolega_code/cli/settings.py +87 -0
  115. kolega_code/cli/skills.py +409 -0
  116. kolega_code/cli/slash_commands.py +108 -0
  117. kolega_code/cli/tests/__init__.py +1 -0
  118. kolega_code/cli/tests/test_app.py +4251 -0
  119. kolega_code/cli/tests/test_cli_config.py +171 -0
  120. kolega_code/cli/tests/test_connection.py +26 -0
  121. kolega_code/cli/tests/test_file_index.py +103 -0
  122. kolega_code/cli/tests/test_main.py +455 -0
  123. kolega_code/cli/tests/test_mentions.py +108 -0
  124. kolega_code/cli/tests/test_session_store.py +67 -0
  125. kolega_code/cli/tests/test_settings.py +62 -0
  126. kolega_code/cli/tests/test_skills.py +157 -0
  127. kolega_code/cli/tests/test_slash_commands.py +88 -0
  128. kolega_code/cli/theme.py +180 -0
  129. kolega_code/config.py +154 -0
  130. kolega_code/events.py +202 -0
  131. kolega_code/llm/client.py +300 -0
  132. kolega_code/llm/exceptions.py +285 -0
  133. kolega_code/llm/instrumented_client.py +520 -0
  134. kolega_code/llm/models.py +1368 -0
  135. kolega_code/llm/providers/__init__.py +0 -0
  136. kolega_code/llm/providers/anthropic.py +387 -0
  137. kolega_code/llm/providers/base.py +71 -0
  138. kolega_code/llm/providers/google.py +157 -0
  139. kolega_code/llm/providers/models.py +37 -0
  140. kolega_code/llm/providers/openai.py +363 -0
  141. kolega_code/llm/ratelimit.py +40 -0
  142. kolega_code/llm/specs.py +67 -0
  143. kolega_code/llm/tool_execution_ids.py +18 -0
  144. kolega_code/models/__init__.py +9 -0
  145. kolega_code/models/sandbox_terminal_state.py +47 -0
  146. kolega_code/runtime.py +50 -0
  147. kolega_code/sandbox/README.md +200 -0
  148. kolega_code/sandbox/__init__.py +21 -0
  149. kolega_code/sandbox/async_filesystem.py +475 -0
  150. kolega_code/sandbox/base.py +297 -0
  151. kolega_code/sandbox/browser.py +25 -0
  152. kolega_code/sandbox/event_loop.py +43 -0
  153. kolega_code/sandbox/filesystem.py +341 -0
  154. kolega_code/sandbox/local.py +118 -0
  155. kolega_code/sandbox/serializer.py +175 -0
  156. kolega_code/sandbox/terminal.py +868 -0
  157. kolega_code/sandbox/utils.py +216 -0
  158. kolega_code/services/base.py +255 -0
  159. kolega_code/services/browser.py +444 -0
  160. kolega_code/services/file_system.py +749 -0
  161. kolega_code/services/html.py +221 -0
  162. kolega_code/services/terminal.py +903 -0
  163. kolega_code/tools/__init__.py +22 -0
  164. kolega_code/tools/core.py +33 -0
  165. kolega_code/tools/definitions.py +81 -0
  166. kolega_code/tools/registry.py +73 -0
  167. kolega_code-0.1.0.dist-info/METADATA +157 -0
  168. kolega_code-0.1.0.dist-info/RECORD +171 -0
  169. kolega_code-0.1.0.dist-info/WHEEL +4 -0
  170. kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
  171. kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,32 @@
1
+ from kolega_code.llm.client import LLMClient
2
+ from kolega_code.llm.providers.anthropic import AnthropicProvider
3
+ from kolega_code.llm.providers.openai import OpenAIProvider
4
+
5
+
6
+ # TODO: Fix after qwen-3-coder-plus PR is merged - needs dashscope provider mapping
7
+ def test_llm_client_maps_dashscope_to_openai_provider():
8
+ client = LLMClient(provider='dashscope', api_key='sk-test')
9
+ assert isinstance(client.provider, OpenAIProvider)
10
+ assert client.provider.base_url == 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1'
11
+
12
+
13
+ def test_llm_client_maps_moonshot_to_anthropic_provider():
14
+ client = LLMClient(provider='moonshot', api_key='sk-test')
15
+ assert isinstance(client.provider, AnthropicProvider)
16
+ assert client.provider.base_url == 'https://api.moonshot.ai/anthropic'
17
+ assert client.provider.provider_name == 'moonshot'
18
+ assert client.provider.use_local_token_counting is True
19
+
20
+ thinking = client._prepare_thinking_param(8192)
21
+ assert thinking.budget_tokens == 8192
22
+
23
+
24
+ def test_llm_client_maps_deepseek_to_anthropic_provider():
25
+ client = LLMClient(provider='deepseek', api_key='sk-test')
26
+ assert isinstance(client.provider, AnthropicProvider)
27
+ assert client.provider.base_url == 'https://api.deepseek.com/anthropic'
28
+ assert client.provider.provider_name == 'deepseek'
29
+ assert client.provider.use_local_token_counting is True
30
+
31
+ thinking = client._prepare_thinking_param(8192)
32
+ assert thinking.budget_tokens == 8192
@@ -0,0 +1,322 @@
1
+ """Test suite for LLM client error boundary functionality.
2
+
3
+ This module tests that the LLM client correctly maps all exceptions to LLMError
4
+ subclasses, ensuring no raw exceptions escape from the client layer.
5
+ """
6
+
7
+ import os
8
+ import asyncio
9
+ from unittest.mock import AsyncMock, MagicMock, patch
10
+
11
+ import pytest
12
+ from anthropic import AnthropicError
13
+ from google.genai.errors import APIError as GoogleAPIError
14
+ from openai import OpenAIError
15
+
16
+ from kolega_code.llm.client import LLMClient
17
+ from kolega_code.llm.exceptions import (
18
+ LLMAuthenticationError,
19
+ LLMContextWindowExceededError,
20
+ LLMError,
21
+ LLMInternalServerError,
22
+ LLMInvalidRequestError,
23
+ LLMRateLimitError,
24
+ LLMTimeout,
25
+ map_to_llm_error,
26
+ )
27
+ from kolega_code.llm.models import Message, MessageHistory
28
+
29
+ # Check if running in CI environment
30
+ SKIP_IN_CI = bool(os.getenv("CI")) or bool(os.getenv("GITLAB_CI"))
31
+
32
+
33
+ class TestErrorMapping:
34
+ """Test the comprehensive error mapping function."""
35
+
36
+ def test_llm_error_passes_through(self):
37
+ """Test that LLMError instances pass through unchanged."""
38
+ original_error = LLMInvalidRequestError("test error", provider="test")
39
+ mapped_error = map_to_llm_error(original_error)
40
+ assert mapped_error is original_error
41
+
42
+ def test_value_error_mapping(self):
43
+ """Test that ValueError maps to LLMInvalidRequestError."""
44
+ error = ValueError("Invalid value")
45
+ mapped = map_to_llm_error(error, "test_provider")
46
+ assert isinstance(mapped, LLMInvalidRequestError)
47
+ assert "Invalid parameter" in str(mapped)
48
+ assert mapped.provider == "test_provider"
49
+
50
+ def test_timeout_error_mapping(self):
51
+ """Test that timeout errors map to LLMTimeout."""
52
+ # Test standard TimeoutError
53
+ error = TimeoutError("Request timed out")
54
+ mapped = map_to_llm_error(error, "test_provider")
55
+ assert isinstance(mapped, LLMTimeout)
56
+ assert "Request timeout" in str(mapped)
57
+
58
+ # Test asyncio.TimeoutError
59
+ error = asyncio.TimeoutError("Async timeout")
60
+ mapped = map_to_llm_error(error, "test_provider")
61
+ assert isinstance(mapped, LLMTimeout)
62
+
63
+ def test_connection_error_mapping(self):
64
+ """Test that ConnectionError maps to LLMInternalServerError."""
65
+ error = ConnectionError("Connection failed")
66
+ mapped = map_to_llm_error(error, "test_provider")
67
+ assert isinstance(mapped, LLMInternalServerError)
68
+ assert "Connection error" in str(mapped)
69
+
70
+ def test_key_error_mapping(self):
71
+ """Test that KeyError maps to LLMInvalidRequestError."""
72
+ error = KeyError("missing_param")
73
+ mapped = map_to_llm_error(error, "test_provider")
74
+ assert isinstance(mapped, LLMInvalidRequestError)
75
+ assert "Missing required parameter" in str(mapped)
76
+
77
+ def test_type_error_mapping(self):
78
+ """Test that TypeError maps to LLMInvalidRequestError."""
79
+ error = TypeError("Wrong type")
80
+ mapped = map_to_llm_error(error, "test_provider")
81
+ assert isinstance(mapped, LLMInvalidRequestError)
82
+ assert "Invalid parameter type" in str(mapped)
83
+
84
+ def test_runtime_error_mapping(self):
85
+ """Test that RuntimeError maps to LLMInternalServerError."""
86
+ error = RuntimeError("Runtime issue")
87
+ mapped = map_to_llm_error(error, "test_provider")
88
+ assert isinstance(mapped, LLMInternalServerError)
89
+ assert "Runtime error" in str(mapped)
90
+
91
+ def test_generic_exception_mapping(self):
92
+ """Test that generic exceptions map to base LLMError."""
93
+
94
+ class CustomException(Exception):
95
+ pass
96
+
97
+ error = CustomException("Custom error")
98
+ mapped = map_to_llm_error(error, "test_provider")
99
+ assert isinstance(mapped, LLMError)
100
+ assert not isinstance(mapped, (LLMInvalidRequestError, LLMInternalServerError, LLMTimeout))
101
+ assert "Unexpected error (CustomException)" in str(mapped)
102
+ assert mapped.provider == "test_provider"
103
+
104
+ def test_httpx_remote_protocol_error_mapping(self):
105
+ """Test that httpx.RemoteProtocolError maps to LLMInternalServerError."""
106
+ try:
107
+ import httpx # type: ignore
108
+ except Exception:
109
+ pytest.skip("httpx not installed in test environment")
110
+
111
+ error = httpx.RemoteProtocolError(
112
+ "peer closed connection without sending complete message body (incomplete chunked read)"
113
+ )
114
+ mapped = map_to_llm_error(error, "anthropic")
115
+ assert isinstance(mapped, LLMInternalServerError)
116
+ assert "HTTPX protocol error" in str(mapped)
117
+
118
+ def test_provider_error_mapping(self):
119
+ """Test that provider-specific errors are mapped correctly."""
120
+
121
+ # Create proper mock OpenAI error that inherits from OpenAIError
122
+ class MockOpenAIError(OpenAIError):
123
+ def __init__(self, status_code):
124
+ self.status_code = status_code
125
+ super().__init__("Rate limit exceeded")
126
+
127
+ openai_error = MockOpenAIError(429)
128
+ mapped = map_to_llm_error(openai_error)
129
+ assert isinstance(mapped, LLMRateLimitError)
130
+
131
+ # Create proper mock Anthropic error
132
+ class MockAnthropicError(AnthropicError):
133
+ def __init__(self, status_code):
134
+ self.status_code = status_code
135
+ super().__init__("Invalid API key")
136
+
137
+ anthropic_error = MockAnthropicError(401)
138
+ mapped = map_to_llm_error(anthropic_error)
139
+ assert isinstance(mapped, LLMAuthenticationError)
140
+
141
+
142
+ class TestLLMClientErrorBoundary:
143
+ """Test that the LLM client correctly implements the error boundary."""
144
+
145
+ def test_initialization_error_handling(self):
146
+ """Test that initialization errors are properly mapped."""
147
+ # Test unsupported provider
148
+ with pytest.raises(LLMInvalidRequestError) as exc_info:
149
+ LLMClient(provider="unsupported_provider", api_key="test_key")
150
+ assert "Invalid parameter: Unsupported provider" in str(exc_info.value)
151
+ assert exc_info.value.provider == "unsupported_provider"
152
+
153
+ @pytest.mark.asyncio
154
+ async def test_generate_error_handling(self):
155
+ """Test that generate method errors are properly mapped."""
156
+ client = LLMClient(provider="openai", api_key="test_key")
157
+
158
+ # Mock the provider to raise various exceptions
159
+ messages = MessageHistory([Message(role="user", content="Test")])
160
+
161
+ # Test ValueError
162
+ client.provider.generate = AsyncMock(side_effect=ValueError("Invalid parameter"))
163
+ with pytest.raises(LLMInvalidRequestError) as exc_info:
164
+ await client.generate(messages)
165
+ assert "Invalid parameter" in str(exc_info.value)
166
+ assert exc_info.value.provider == "openai"
167
+
168
+ # Test ConnectionError
169
+ client.provider.generate = AsyncMock(side_effect=ConnectionError("Connection lost"))
170
+ with pytest.raises(LLMInternalServerError) as exc_info:
171
+ await client.generate(messages)
172
+ assert "Connection error" in str(exc_info.value)
173
+
174
+ # Test that LLMError passes through
175
+ original_error = LLMRateLimitError("Rate limit hit", provider="openai")
176
+ client.provider.generate = AsyncMock(side_effect=original_error)
177
+ with pytest.raises(LLMRateLimitError) as exc_info:
178
+ await client.generate(messages)
179
+ assert exc_info.value is original_error
180
+
181
+ @pytest.mark.asyncio
182
+ async def test_count_tokens_error_handling(self):
183
+ """Test that count_tokens method errors are properly mapped."""
184
+ client = LLMClient(provider="anthropic", api_key="test_key")
185
+ messages = MessageHistory([Message(role="user", content="Test")])
186
+
187
+ # Test KeyError
188
+ client.provider.count_tokens = AsyncMock(side_effect=KeyError("model"))
189
+ with pytest.raises(LLMInvalidRequestError) as exc_info:
190
+ await client.count_tokens(messages)
191
+ assert "Missing required parameter" in str(exc_info.value)
192
+ assert exc_info.value.provider == "anthropic"
193
+
194
+ # Test TimeoutError
195
+ client.provider.count_tokens = AsyncMock(side_effect=asyncio.TimeoutError())
196
+ with pytest.raises(LLMTimeout) as exc_info:
197
+ await client.count_tokens(messages)
198
+ assert "Request timeout" in str(exc_info.value)
199
+
200
+ def test_stream_error_handling(self):
201
+ """Test that stream method errors are properly mapped."""
202
+ client = LLMClient(provider="google", api_key="test_key")
203
+ messages = MessageHistory([Message(role="user", content="Test")])
204
+
205
+ # Test RuntimeError
206
+ client.provider.stream = MagicMock(side_effect=RuntimeError("Stream failed"))
207
+ with pytest.raises(LLMInternalServerError) as exc_info:
208
+ client.stream(messages)
209
+ assert "Runtime error" in str(exc_info.value)
210
+ assert exc_info.value.provider == "google"
211
+
212
+ # Test TypeError
213
+ client.provider.stream = MagicMock(side_effect=TypeError("Invalid type"))
214
+ with pytest.raises(LLMInvalidRequestError) as exc_info:
215
+ client.stream(messages)
216
+ assert "Invalid parameter type" in str(exc_info.value)
217
+
218
+ @pytest.mark.asyncio
219
+ async def test_provider_specific_error_mapping(self):
220
+ """Test that provider-specific errors are correctly mapped through the client."""
221
+ # Test OpenAI error mapping
222
+ client = LLMClient(provider="openai", api_key="test_key")
223
+ messages = MessageHistory([Message(role="user", content="Test")])
224
+
225
+ # Create proper mock OpenAI error
226
+ class MockOpenAIError(OpenAIError):
227
+ def __init__(self, status_code, message="Invalid API key"):
228
+ self.status_code = status_code
229
+ super().__init__(message)
230
+
231
+ openai_error = MockOpenAIError(401)
232
+
233
+ client.provider.generate = AsyncMock(side_effect=openai_error)
234
+ with pytest.raises(LLMAuthenticationError) as exc_info:
235
+ await client.generate(messages)
236
+ assert "OpenAI APIError" in str(exc_info.value)
237
+
238
+ # Test Anthropic error mapping
239
+ client = LLMClient(provider="anthropic", api_key="test_key")
240
+
241
+ # Create proper mock Anthropic error
242
+ class MockAnthropicError(AnthropicError):
243
+ def __init__(self, status_code, message="Message too long"):
244
+ self.status_code = status_code
245
+ super().__init__(message)
246
+
247
+ anthropic_error = MockAnthropicError(413)
248
+
249
+ client.provider.generate = AsyncMock(side_effect=anthropic_error)
250
+ with pytest.raises(LLMContextWindowExceededError) as exc_info: # Should be mapped to context window error
251
+ await client.generate(messages)
252
+ assert "AnthropicError" in str(exc_info.value)
253
+
254
+ @pytest.mark.asyncio
255
+ async def test_no_raw_exceptions_escape(self):
256
+ """Comprehensive test ensuring no raw exceptions escape the client."""
257
+ client = LLMClient(provider="openai", api_key="test_key")
258
+ messages = MessageHistory([Message(role="user", content="Test")])
259
+
260
+ # List of various exception types to test
261
+ test_exceptions = [
262
+ ValueError("test"),
263
+ KeyError("test"),
264
+ TypeError("test"),
265
+ RuntimeError("test"),
266
+ ConnectionError("test"),
267
+ TimeoutError("test"),
268
+ asyncio.TimeoutError("test"),
269
+ AttributeError("test"),
270
+ IndexError("test"),
271
+ ZeroDivisionError("test"),
272
+ Exception("generic exception"),
273
+ ]
274
+
275
+ for original_exception in test_exceptions:
276
+ # Test generate method
277
+ client.provider.generate = AsyncMock(side_effect=original_exception)
278
+ with pytest.raises(LLMError) as exc_info:
279
+ await client.generate(messages)
280
+ # Verify it's an LLMError subclass, not the original exception
281
+ assert isinstance(exc_info.value, LLMError)
282
+ assert type(exc_info.value) != type(original_exception)
283
+
284
+ # Test count_tokens method
285
+ client.provider.count_tokens = AsyncMock(side_effect=original_exception)
286
+ with pytest.raises(LLMError) as exc_info:
287
+ await client.count_tokens(messages)
288
+ assert isinstance(exc_info.value, LLMError)
289
+
290
+ # Test stream method
291
+ client.provider.stream = MagicMock(side_effect=original_exception)
292
+ with pytest.raises(LLMError) as exc_info:
293
+ client.stream(messages)
294
+ assert isinstance(exc_info.value, LLMError)
295
+
296
+
297
+ class TestProviderInitialization:
298
+ """Test error handling during provider initialization."""
299
+
300
+ @pytest.mark.skipif(SKIP_IN_CI, reason="Skipping slow test in CI environment")
301
+ def test_all_supported_providers_initialize(self):
302
+ """Test that all supported providers can be initialized without error."""
303
+ supported_providers = ["anthropic", "openai", "google", "together", "groq", "fireworks", "llama", "xai"]
304
+
305
+ for provider in supported_providers:
306
+ try:
307
+ client = LLMClient(provider=provider, api_key="test_key")
308
+ assert client.provider_name == provider
309
+ assert client.provider is not None
310
+ except Exception as e:
311
+ # If any exception occurs, it should be an LLMError
312
+ assert isinstance(e, LLMError), f"Provider {provider} raised non-LLMError: {type(e)}"
313
+
314
+ def test_provider_initialization_with_error(self):
315
+ """Test that provider initialization errors are properly wrapped."""
316
+ # Mock the provider class to raise an error during initialization
317
+ with patch("kolega_code.llm.client.OpenAIProvider") as MockProvider:
318
+ MockProvider.side_effect = RuntimeError("Provider init failed")
319
+
320
+ with pytest.raises(LLMInternalServerError) as exc_info:
321
+ LLMClient(provider="openai", api_key="test_key")
322
+ assert "Runtime error: Provider init failed" in str(exc_info.value)
@@ -0,0 +1,249 @@
1
+ """
2
+ Tests for the LLM exception classes and mapping functions.
3
+ """
4
+
5
+ import pytest
6
+
7
+
8
+ # Assuming OpenAIError, GoogleAPIError, AnthropicError can be imported or mocked
9
+ # For simplicity, we'll mock them here.
10
+ class MockOpenAIError(Exception):
11
+ def __init__(self, message: str, status_code: int):
12
+ super().__init__(message)
13
+ self.status_code = status_code
14
+
15
+
16
+ class MockGoogleAPIError(Exception):
17
+ def __init__(self, message: str, status: int):
18
+ super().__init__(message)
19
+ self.status = status
20
+
21
+
22
+ class MockAnthropicError(Exception):
23
+ def __init__(self, message: str, status_code: int):
24
+ super().__init__(message)
25
+ self.status_code = status_code
26
+
27
+
28
+ from kolega_code.config import ModelProvider
29
+ from kolega_code.llm.exceptions import (
30
+ LLMAuthenticationError,
31
+ LLMBadRequestError,
32
+ LLMContentPolicyViolationError,
33
+ LLMContextWindowExceededError,
34
+ LLMError,
35
+ LLMInternalServerError,
36
+ LLMInvalidRequestError,
37
+ LLMNotFoundError,
38
+ LLMPermissionDeniedError,
39
+ LLMRateLimitError,
40
+ LLMTimeout,
41
+ LLMUnprocessableEntityError,
42
+ LLMUnsupportedParamsError,
43
+ map_anthropic_errors,
44
+ map_google_errors,
45
+ map_openai_errors,
46
+ )
47
+
48
+
49
+ # Test basic exception instantiation
50
+ @pytest.mark.parametrize(
51
+ "exception_class",
52
+ [
53
+ LLMError,
54
+ LLMBadRequestError,
55
+ LLMUnsupportedParamsError,
56
+ LLMContextWindowExceededError,
57
+ LLMContentPolicyViolationError,
58
+ LLMInvalidRequestError,
59
+ LLMAuthenticationError,
60
+ LLMPermissionDeniedError,
61
+ LLMNotFoundError,
62
+ LLMTimeout,
63
+ LLMUnprocessableEntityError,
64
+ LLMRateLimitError,
65
+ LLMInternalServerError,
66
+ ],
67
+ )
68
+ def test_llm_exception_instantiation(exception_class):
69
+ """Test that each LLM exception can be instantiated."""
70
+ message = "Test error message"
71
+ provider = "test_provider"
72
+ error = exception_class(message, provider=provider)
73
+ assert isinstance(error, LLMError) # Check inheritance
74
+ assert isinstance(error, Exception)
75
+ assert str(error) == message
76
+ assert error.provider == provider
77
+
78
+
79
+ # Test OpenAI error mapping
80
+ @pytest.mark.parametrize(
81
+ "status_code, expected_exception",
82
+ [
83
+ (400, LLMInvalidRequestError),
84
+ (401, LLMAuthenticationError),
85
+ (403, LLMPermissionDeniedError),
86
+ (404, LLMNotFoundError),
87
+ (422, LLMUnprocessableEntityError),
88
+ (429, LLMRateLimitError),
89
+ (500, LLMInternalServerError),
90
+ (999, LLMError), # Test default case
91
+ ],
92
+ )
93
+ def test_map_openai_errors(status_code, expected_exception):
94
+ """Test the mapping of OpenAI error status codes to LLM exceptions."""
95
+ original_error = MockOpenAIError("OpenAI test error", status_code=status_code)
96
+ mapped_error = map_openai_errors(original_error)
97
+ assert isinstance(mapped_error, expected_exception)
98
+ # Check provider is set correctly - should always be OPENAI
99
+ assert mapped_error.provider == ModelProvider.OPENAI.value
100
+ assert "OpenAI APIError:" in str(mapped_error)
101
+
102
+
103
+ def test_map_openai_errors_no_status_code():
104
+ """Test mapping OpenAI errors without a status code."""
105
+ original_error = Exception("Generic OpenAI error") # Mock error without status_code
106
+ # Ensure the base class or a simple Exception can be handled if needed
107
+ # Re-mocking OpenAIError as a simple Exception for this case
108
+ mapped_error = map_openai_errors(original_error)
109
+ assert isinstance(mapped_error, LLMError)
110
+ assert not isinstance(
111
+ mapped_error,
112
+ (
113
+ LLMInvalidRequestError,
114
+ LLMAuthenticationError,
115
+ LLMPermissionDeniedError,
116
+ LLMNotFoundError,
117
+ LLMUnprocessableEntityError,
118
+ LLMRateLimitError,
119
+ LLMInternalServerError,
120
+ ),
121
+ ) # Should be the base LLMError
122
+ assert mapped_error.provider == ModelProvider.OPENAI.value
123
+ assert "OpenAI APIError:" in str(mapped_error)
124
+
125
+
126
+ # Test Google error mapping
127
+ @pytest.mark.parametrize(
128
+ "status, expected_exception",
129
+ [
130
+ (400, LLMInvalidRequestError),
131
+ (403, LLMPermissionDeniedError),
132
+ (429, LLMRateLimitError),
133
+ (500, LLMInternalServerError),
134
+ (999, LLMError), # Test default case
135
+ ],
136
+ )
137
+ def test_map_google_errors(status, expected_exception):
138
+ """Test the mapping of Google error statuses to LLM exceptions."""
139
+ original_error = MockGoogleAPIError("Google test error", status=status)
140
+ mapped_error = map_google_errors(original_error)
141
+ assert isinstance(mapped_error, expected_exception)
142
+ assert mapped_error.provider == ModelProvider.GOOGLE.value
143
+ if status in [400, 403, 429, 500]:
144
+ assert "GoogleAPIError:" in str(mapped_error)
145
+ else:
146
+ assert "Google APIError:" in str(mapped_error) # Note the subtle difference in the default message
147
+
148
+
149
+ def test_map_google_errors_no_status():
150
+ """Test mapping Google errors without a status attribute."""
151
+ original_error = Exception("Generic Google error") # Mock error without status
152
+ mapped_error = map_google_errors(original_error)
153
+ assert isinstance(mapped_error, LLMError)
154
+ assert not isinstance(
155
+ mapped_error, (LLMInvalidRequestError, LLMPermissionDeniedError, LLMRateLimitError, LLMInternalServerError)
156
+ ) # Should be the base LLMError
157
+ assert mapped_error.provider == ModelProvider.GOOGLE.value
158
+ assert "Google APIError:" in str(mapped_error)
159
+
160
+
161
+ # Test Anthropic error mapping
162
+ @pytest.mark.parametrize(
163
+ "status_code, expected_exception",
164
+ [
165
+ (400, LLMInvalidRequestError),
166
+ (401, LLMAuthenticationError),
167
+ (403, LLMPermissionDeniedError),
168
+ (404, LLMNotFoundError),
169
+ (413, LLMContextWindowExceededError),
170
+ (429, LLMRateLimitError),
171
+ (500, LLMInternalServerError),
172
+ (529, LLMInternalServerError),
173
+ (999, LLMError), # Test default case
174
+ ],
175
+ )
176
+ def test_map_anthropic_errors(status_code, expected_exception):
177
+ """Test the mapping of Anthropic error status codes to LLM exceptions."""
178
+ original_error = MockAnthropicError("Anthropic test error", status_code=status_code)
179
+ mapped_error = map_anthropic_errors(original_error)
180
+ assert isinstance(mapped_error, expected_exception)
181
+ assert mapped_error.provider == ModelProvider.ANTHROPIC.value
182
+ assert "AnthropicError:" in str(mapped_error)
183
+
184
+
185
+ def test_map_anthropic_errors_no_status_code():
186
+ """Test mapping Anthropic errors without a status code."""
187
+ original_error = Exception("Generic Anthropic error") # Mock error without status_code
188
+ mapped_error = map_anthropic_errors(original_error)
189
+ assert isinstance(mapped_error, LLMError)
190
+ assert not isinstance(
191
+ mapped_error,
192
+ (
193
+ LLMInvalidRequestError,
194
+ LLMAuthenticationError,
195
+ LLMPermissionDeniedError,
196
+ LLMNotFoundError,
197
+ LLMContextWindowExceededError,
198
+ LLMRateLimitError,
199
+ LLMInternalServerError,
200
+ ),
201
+ ) # Should be the base LLMError
202
+ assert mapped_error.provider == ModelProvider.ANTHROPIC.value
203
+ assert "AnthropicError:" in str(mapped_error)
204
+
205
+
206
+ def test_map_anthropic_api_status_error_invalid_request():
207
+ """Ensure Anthropic APIStatusError with invalid_request_error maps to LLMContentPolicyViolationError when message indicates content filtering."""
208
+ import httpx
209
+ from anthropic import APIStatusError
210
+
211
+ # Build a minimal httpx.Response to satisfy APIStatusError constructor
212
+ response = httpx.Response(status_code=400, request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"))
213
+ body = {
214
+ "type": "error",
215
+ "error": {
216
+ "details": None,
217
+ "type": "invalid_request_error",
218
+ "message": "Output blocked by content filtering policy",
219
+ },
220
+ }
221
+
222
+ err = APIStatusError("invalid request", response=response, body=body)
223
+
224
+ from kolega_code.llm.exceptions import map_anthropic_errors, LLMContentPolicyViolationError
225
+
226
+ mapped = map_anthropic_errors(err)
227
+ assert isinstance(mapped, LLMContentPolicyViolationError)
228
+ assert mapped.provider == ModelProvider.ANTHROPIC.value
229
+ assert "AnthropicError:" in str(mapped)
230
+
231
+
232
+ def test_map_anthropic_api_status_error_token_limit():
233
+ import httpx
234
+ from anthropic import APIStatusError
235
+
236
+ response = httpx.Response(status_code=400, request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"))
237
+ body = {
238
+ "type": "error",
239
+ "error": {
240
+ "type": "invalid_request_error",
241
+ "message": "Invalid request: Your request exceeded model token limit: 262144 (requested: 1348145)",
242
+ },
243
+ }
244
+
245
+ err = APIStatusError("invalid request", response=response, body=body)
246
+
247
+ mapped = map_anthropic_errors(err)
248
+ assert isinstance(mapped, LLMContextWindowExceededError)
249
+ assert mapped.provider == ModelProvider.ANTHROPIC.value