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.
- kolega_code/__init__.py +151 -0
- kolega_code/agent/__init__.py +42 -0
- kolega_code/agent/baseagent.py +998 -0
- kolega_code/agent/browseragent.py +123 -0
- kolega_code/agent/coder.py +157 -0
- kolega_code/agent/common.py +41 -0
- kolega_code/agent/compression.py +81 -0
- kolega_code/agent/context.py +112 -0
- kolega_code/agent/conversation.py +408 -0
- kolega_code/agent/generalagent.py +146 -0
- kolega_code/agent/investigationagent.py +123 -0
- kolega_code/agent/planningagent.py +187 -0
- kolega_code/agent/prompt_provider.py +196 -0
- kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
- kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
- kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
- kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
- kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
- kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
- kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
- kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
- kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
- kolega_code/agent/prompts.py +192 -0
- kolega_code/agent/tests/__init__.py +0 -0
- kolega_code/agent/tests/llm/__init__.py +0 -0
- kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
- kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
- kolega_code/agent/tests/llm/test_client.py +773 -0
- kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
- kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
- kolega_code/agent/tests/llm/test_exceptions.py +249 -0
- kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
- kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
- kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
- kolega_code/agent/tests/llm/test_model_specs.py +17 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
- kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
- kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
- kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
- kolega_code/agent/tests/services/__init__.py +1 -0
- kolega_code/agent/tests/services/test_browser.py +447 -0
- kolega_code/agent/tests/services/test_browser_parity.py +353 -0
- kolega_code/agent/tests/services/test_file_system.py +699 -0
- kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
- kolega_code/agent/tests/services/test_terminal.py +154 -0
- kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
- kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
- kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
- kolega_code/agent/tests/test_base_agent.py +1942 -0
- kolega_code/agent/tests/test_coder_attachments.py +330 -0
- kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
- kolega_code/agent/tests/test_commands.py +179 -0
- kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
- kolega_code/agent/tests/test_empty_message_handling.py +48 -0
- kolega_code/agent/tests/test_general_agent.py +242 -0
- kolega_code/agent/tests/test_html.py +320 -0
- kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
- kolega_code/agent/tests/test_planning_agent.py +227 -0
- kolega_code/agent/tests/test_prompt_provider.py +271 -0
- kolega_code/agent/tests/test_tool_registry.py +102 -0
- kolega_code/agent/tests/test_tools.py +549 -0
- kolega_code/agent/tests/tool_backend/__init__.py +0 -0
- kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
- kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
- kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
- kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
- kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
- kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
- kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
- kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
- kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
- kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
- kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
- kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
- kolega_code/agent/tool_backend/agent_tool.py +414 -0
- kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
- kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
- kolega_code/agent/tool_backend/base_tool.py +217 -0
- kolega_code/agent/tool_backend/browser_tool.py +271 -0
- kolega_code/agent/tool_backend/build_tool.py +93 -0
- kolega_code/agent/tool_backend/create_file_tool.py +52 -0
- kolega_code/agent/tool_backend/glob_tool.py +323 -0
- kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
- kolega_code/agent/tool_backend/memory_tool.py +79 -0
- kolega_code/agent/tool_backend/read_file_tool.py +119 -0
- kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
- kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
- kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
- kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
- kolega_code/agent/tool_backend/streaming_tool.py +47 -0
- kolega_code/agent/tool_backend/terminal_tool.py +643 -0
- kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
- kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
- kolega_code/agent/tools.py +1704 -0
- kolega_code/agent/utils/commands.py +94 -0
- kolega_code/cli/__init__.py +1 -0
- kolega_code/cli/app.py +2756 -0
- kolega_code/cli/config.py +280 -0
- kolega_code/cli/connection.py +49 -0
- kolega_code/cli/file_index.py +147 -0
- kolega_code/cli/main.py +564 -0
- kolega_code/cli/mentions.py +155 -0
- kolega_code/cli/messages.py +89 -0
- kolega_code/cli/provider_registry.py +96 -0
- kolega_code/cli/session_store.py +207 -0
- kolega_code/cli/settings.py +87 -0
- kolega_code/cli/skills.py +409 -0
- kolega_code/cli/slash_commands.py +108 -0
- kolega_code/cli/tests/__init__.py +1 -0
- kolega_code/cli/tests/test_app.py +4251 -0
- kolega_code/cli/tests/test_cli_config.py +171 -0
- kolega_code/cli/tests/test_connection.py +26 -0
- kolega_code/cli/tests/test_file_index.py +103 -0
- kolega_code/cli/tests/test_main.py +455 -0
- kolega_code/cli/tests/test_mentions.py +108 -0
- kolega_code/cli/tests/test_session_store.py +67 -0
- kolega_code/cli/tests/test_settings.py +62 -0
- kolega_code/cli/tests/test_skills.py +157 -0
- kolega_code/cli/tests/test_slash_commands.py +88 -0
- kolega_code/cli/theme.py +180 -0
- kolega_code/config.py +154 -0
- kolega_code/events.py +202 -0
- kolega_code/llm/client.py +300 -0
- kolega_code/llm/exceptions.py +285 -0
- kolega_code/llm/instrumented_client.py +520 -0
- kolega_code/llm/models.py +1368 -0
- kolega_code/llm/providers/__init__.py +0 -0
- kolega_code/llm/providers/anthropic.py +387 -0
- kolega_code/llm/providers/base.py +71 -0
- kolega_code/llm/providers/google.py +157 -0
- kolega_code/llm/providers/models.py +37 -0
- kolega_code/llm/providers/openai.py +363 -0
- kolega_code/llm/ratelimit.py +40 -0
- kolega_code/llm/specs.py +67 -0
- kolega_code/llm/tool_execution_ids.py +18 -0
- kolega_code/models/__init__.py +9 -0
- kolega_code/models/sandbox_terminal_state.py +47 -0
- kolega_code/runtime.py +50 -0
- kolega_code/sandbox/README.md +200 -0
- kolega_code/sandbox/__init__.py +21 -0
- kolega_code/sandbox/async_filesystem.py +475 -0
- kolega_code/sandbox/base.py +297 -0
- kolega_code/sandbox/browser.py +25 -0
- kolega_code/sandbox/event_loop.py +43 -0
- kolega_code/sandbox/filesystem.py +341 -0
- kolega_code/sandbox/local.py +118 -0
- kolega_code/sandbox/serializer.py +175 -0
- kolega_code/sandbox/terminal.py +868 -0
- kolega_code/sandbox/utils.py +216 -0
- kolega_code/services/base.py +255 -0
- kolega_code/services/browser.py +444 -0
- kolega_code/services/file_system.py +749 -0
- kolega_code/services/html.py +221 -0
- kolega_code/services/terminal.py +903 -0
- kolega_code/tools/__init__.py +22 -0
- kolega_code/tools/core.py +33 -0
- kolega_code/tools/definitions.py +81 -0
- kolega_code/tools/registry.py +73 -0
- kolega_code-0.1.0.dist-info/METADATA +157 -0
- kolega_code-0.1.0.dist-info/RECORD +171 -0
- kolega_code-0.1.0.dist-info/WHEEL +4 -0
- kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|