forked from cardosofelipe/fast-next-template
- Add type annotations for mypy compliance - Use UTC-aware datetimes consistently (datetime.now(UTC)) - Add type: ignore comments for LiteLLM incomplete stubs - Fix import ordering and formatting - Update pyproject.toml mypy configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
377 lines
12 KiB
Python
377 lines
12 KiB
Python
"""
|
|
Tests for exceptions module.
|
|
"""
|
|
|
|
from exceptions import (
|
|
AllProvidersFailedError,
|
|
CircuitOpenError,
|
|
ConfigurationError,
|
|
ContextTooLongError,
|
|
CostLimitExceededError,
|
|
ErrorCode,
|
|
InvalidModelError,
|
|
InvalidModelGroupError,
|
|
LLMGatewayError,
|
|
ModelNotAvailableError,
|
|
ProviderError,
|
|
RateLimitError,
|
|
StreamError,
|
|
TokenLimitExceededError,
|
|
)
|
|
|
|
|
|
class TestErrorCode:
|
|
"""Tests for ErrorCode enum."""
|
|
|
|
def test_error_code_values(self) -> None:
|
|
"""Test error code values."""
|
|
assert ErrorCode.UNKNOWN_ERROR.value == "LLM_UNKNOWN_ERROR"
|
|
assert ErrorCode.PROVIDER_ERROR.value == "LLM_PROVIDER_ERROR"
|
|
assert ErrorCode.CIRCUIT_OPEN.value == "LLM_CIRCUIT_OPEN"
|
|
assert ErrorCode.COST_LIMIT_EXCEEDED.value == "LLM_COST_LIMIT_EXCEEDED"
|
|
|
|
|
|
class TestLLMGatewayError:
|
|
"""Tests for LLMGatewayError base class."""
|
|
|
|
def test_basic_error(self) -> None:
|
|
"""Test basic error creation."""
|
|
error = LLMGatewayError("Something went wrong")
|
|
assert str(error) == "[LLM_UNKNOWN_ERROR] Something went wrong"
|
|
assert error.message == "Something went wrong"
|
|
assert error.code == ErrorCode.UNKNOWN_ERROR
|
|
assert error.details == {}
|
|
assert error.cause is None
|
|
|
|
def test_error_with_code(self) -> None:
|
|
"""Test error with custom code."""
|
|
error = LLMGatewayError(
|
|
"Provider failed",
|
|
code=ErrorCode.PROVIDER_ERROR,
|
|
)
|
|
assert error.code == ErrorCode.PROVIDER_ERROR
|
|
|
|
def test_error_with_details(self) -> None:
|
|
"""Test error with details."""
|
|
error = LLMGatewayError(
|
|
"Error",
|
|
details={"key": "value"},
|
|
)
|
|
assert error.details == {"key": "value"}
|
|
|
|
def test_error_with_cause(self) -> None:
|
|
"""Test error with cause exception."""
|
|
cause = ValueError("Original error")
|
|
error = LLMGatewayError("Wrapped error", cause=cause)
|
|
assert error.cause is cause
|
|
|
|
def test_to_dict(self) -> None:
|
|
"""Test converting error to dict."""
|
|
error = LLMGatewayError(
|
|
"Test error",
|
|
code=ErrorCode.INVALID_REQUEST,
|
|
details={"field": "value"},
|
|
)
|
|
result = error.to_dict()
|
|
|
|
assert result["error"] == "LLM_INVALID_REQUEST"
|
|
assert result["message"] == "Test error"
|
|
assert result["details"] == {"field": "value"}
|
|
|
|
def test_to_dict_no_details(self) -> None:
|
|
"""Test to_dict without details."""
|
|
error = LLMGatewayError("Test error")
|
|
result = error.to_dict()
|
|
|
|
assert "details" not in result
|
|
|
|
def test_repr(self) -> None:
|
|
"""Test error repr."""
|
|
error = LLMGatewayError("Test", details={"key": "val"})
|
|
repr_str = repr(error)
|
|
|
|
assert "LLMGatewayError" in repr_str
|
|
assert "Test" in repr_str
|
|
|
|
|
|
class TestProviderError:
|
|
"""Tests for ProviderError."""
|
|
|
|
def test_basic_provider_error(self) -> None:
|
|
"""Test basic provider error."""
|
|
error = ProviderError(
|
|
message="API call failed",
|
|
provider="anthropic",
|
|
)
|
|
|
|
assert error.provider == "anthropic"
|
|
assert error.model is None
|
|
assert error.status_code is None
|
|
assert error.code == ErrorCode.PROVIDER_ERROR
|
|
assert "provider" in error.details
|
|
|
|
def test_provider_error_with_model(self) -> None:
|
|
"""Test provider error with model info."""
|
|
error = ProviderError(
|
|
message="Model not found",
|
|
provider="openai",
|
|
model="gpt-5",
|
|
status_code=404,
|
|
)
|
|
|
|
assert error.model == "gpt-5"
|
|
assert error.status_code == 404
|
|
assert error.details["model"] == "gpt-5"
|
|
assert error.details["status_code"] == 404
|
|
|
|
def test_provider_error_with_cause(self) -> None:
|
|
"""Test provider error with cause."""
|
|
cause = ConnectionError("Network down")
|
|
error = ProviderError(
|
|
message="Connection failed",
|
|
provider="google",
|
|
cause=cause,
|
|
)
|
|
|
|
assert error.cause is cause
|
|
|
|
|
|
class TestRateLimitError:
|
|
"""Tests for RateLimitError."""
|
|
|
|
def test_internal_rate_limit(self) -> None:
|
|
"""Test internal rate limit error."""
|
|
error = RateLimitError(
|
|
message="Too many requests",
|
|
retry_after=60,
|
|
)
|
|
|
|
assert error.code == ErrorCode.RATE_LIMIT_EXCEEDED
|
|
assert error.provider is None
|
|
assert error.retry_after == 60
|
|
assert error.details["retry_after_seconds"] == 60
|
|
|
|
def test_provider_rate_limit(self) -> None:
|
|
"""Test provider rate limit error."""
|
|
error = RateLimitError(
|
|
message="OpenAI rate limit",
|
|
provider="openai",
|
|
retry_after=30,
|
|
)
|
|
|
|
assert error.code == ErrorCode.PROVIDER_RATE_LIMIT
|
|
assert error.provider == "openai"
|
|
assert error.details["provider"] == "openai"
|
|
|
|
|
|
class TestCircuitOpenError:
|
|
"""Tests for CircuitOpenError."""
|
|
|
|
def test_circuit_open_error(self) -> None:
|
|
"""Test circuit open error."""
|
|
error = CircuitOpenError(
|
|
provider="anthropic",
|
|
recovery_time=45,
|
|
)
|
|
|
|
assert error.provider == "anthropic"
|
|
assert error.recovery_time == 45
|
|
assert error.code == ErrorCode.CIRCUIT_OPEN
|
|
assert "Circuit breaker open" in error.message
|
|
assert error.details["recovery_time_seconds"] == 45
|
|
|
|
def test_circuit_open_no_recovery_time(self) -> None:
|
|
"""Test circuit open without recovery time."""
|
|
error = CircuitOpenError(provider="openai")
|
|
|
|
assert error.recovery_time is None
|
|
assert "recovery_time_seconds" not in error.details
|
|
|
|
|
|
class TestCostLimitExceededError:
|
|
"""Tests for CostLimitExceededError."""
|
|
|
|
def test_project_cost_limit(self) -> None:
|
|
"""Test project cost limit error."""
|
|
error = CostLimitExceededError(
|
|
entity_type="project",
|
|
entity_id="proj-123",
|
|
current_cost=150.0,
|
|
limit=100.0,
|
|
)
|
|
|
|
assert error.entity_type == "project"
|
|
assert error.entity_id == "proj-123"
|
|
assert error.current_cost == 150.0
|
|
assert error.limit == 100.0
|
|
assert error.code == ErrorCode.COST_LIMIT_EXCEEDED
|
|
assert "$150.00" in error.message
|
|
assert "$100.00" in error.message
|
|
|
|
def test_agent_cost_limit(self) -> None:
|
|
"""Test agent cost limit error."""
|
|
error = CostLimitExceededError(
|
|
entity_type="agent",
|
|
entity_id="agent-456",
|
|
current_cost=50.0,
|
|
limit=25.0,
|
|
)
|
|
|
|
assert error.entity_type == "agent"
|
|
assert error.details["entity_type"] == "agent"
|
|
|
|
|
|
class TestInvalidModelGroupError:
|
|
"""Tests for InvalidModelGroupError."""
|
|
|
|
def test_invalid_group_error(self) -> None:
|
|
"""Test invalid model group error."""
|
|
error = InvalidModelGroupError(
|
|
model_group="invalid_group",
|
|
available_groups=["reasoning", "code", "fast"],
|
|
)
|
|
|
|
assert error.model_group == "invalid_group"
|
|
assert error.available_groups == ["reasoning", "code", "fast"]
|
|
assert error.code == ErrorCode.INVALID_MODEL_GROUP
|
|
assert "invalid_group" in error.message
|
|
|
|
def test_invalid_group_no_available(self) -> None:
|
|
"""Test invalid group without available list."""
|
|
error = InvalidModelGroupError(model_group="unknown")
|
|
|
|
assert error.available_groups is None
|
|
assert "available_groups" not in error.details
|
|
|
|
|
|
class TestInvalidModelError:
|
|
"""Tests for InvalidModelError."""
|
|
|
|
def test_invalid_model_error(self) -> None:
|
|
"""Test invalid model error."""
|
|
error = InvalidModelError(
|
|
model="gpt-99",
|
|
reason="Model does not exist",
|
|
)
|
|
|
|
assert error.model == "gpt-99"
|
|
assert error.code == ErrorCode.INVALID_MODEL
|
|
assert "gpt-99" in error.message
|
|
assert "Model does not exist" in error.message
|
|
|
|
def test_invalid_model_no_reason(self) -> None:
|
|
"""Test invalid model without reason."""
|
|
error = InvalidModelError(model="unknown-model")
|
|
|
|
assert "reason" not in error.details
|
|
|
|
|
|
class TestModelNotAvailableError:
|
|
"""Tests for ModelNotAvailableError."""
|
|
|
|
def test_model_not_available(self) -> None:
|
|
"""Test model not available error."""
|
|
error = ModelNotAvailableError(
|
|
model="claude-opus-4",
|
|
provider="anthropic",
|
|
)
|
|
|
|
assert error.model == "claude-opus-4"
|
|
assert error.provider == "anthropic"
|
|
assert error.code == ErrorCode.MODEL_NOT_AVAILABLE
|
|
assert "not configured" in error.message
|
|
|
|
|
|
class TestAllProvidersFailedError:
|
|
"""Tests for AllProvidersFailedError."""
|
|
|
|
def test_all_providers_failed(self) -> None:
|
|
"""Test all providers failed error."""
|
|
errors = [
|
|
{"model": "claude-opus-4", "error": "Rate limited"},
|
|
{"model": "gpt-4.1", "error": "Timeout"},
|
|
]
|
|
error = AllProvidersFailedError(
|
|
model_group="reasoning",
|
|
attempted_models=["claude-opus-4", "gpt-4.1"],
|
|
errors=errors,
|
|
)
|
|
|
|
assert error.model_group == "reasoning"
|
|
assert error.attempted_models == ["claude-opus-4", "gpt-4.1"]
|
|
assert error.errors == errors
|
|
assert error.code == ErrorCode.ALL_PROVIDERS_FAILED
|
|
|
|
|
|
class TestStreamError:
|
|
"""Tests for StreamError."""
|
|
|
|
def test_stream_error(self) -> None:
|
|
"""Test stream error."""
|
|
cause = OSError("Connection reset")
|
|
error = StreamError(
|
|
message="Stream interrupted",
|
|
chunks_received=10,
|
|
cause=cause,
|
|
)
|
|
|
|
assert error.chunks_received == 10
|
|
assert error.cause is cause
|
|
assert error.code == ErrorCode.STREAM_ERROR
|
|
|
|
|
|
class TestTokenLimitExceededError:
|
|
"""Tests for TokenLimitExceededError."""
|
|
|
|
def test_token_limit_exceeded(self) -> None:
|
|
"""Test token limit exceeded error."""
|
|
error = TokenLimitExceededError(
|
|
model="claude-haiku",
|
|
token_count=10000,
|
|
limit=8192,
|
|
)
|
|
|
|
assert error.model == "claude-haiku"
|
|
assert error.token_count == 10000
|
|
assert error.limit == 8192
|
|
assert error.code == ErrorCode.TOKEN_LIMIT_EXCEEDED
|
|
|
|
|
|
class TestContextTooLongError:
|
|
"""Tests for ContextTooLongError."""
|
|
|
|
def test_context_too_long(self) -> None:
|
|
"""Test context too long error."""
|
|
error = ContextTooLongError(
|
|
model="gpt-4.1-mini",
|
|
context_length=150000,
|
|
max_context=100000,
|
|
)
|
|
|
|
assert error.model == "gpt-4.1-mini"
|
|
assert error.context_length == 150000
|
|
assert error.max_context == 100000
|
|
assert error.code == ErrorCode.CONTEXT_TOO_LONG
|
|
|
|
|
|
class TestConfigurationError:
|
|
"""Tests for ConfigurationError."""
|
|
|
|
def test_configuration_error(self) -> None:
|
|
"""Test configuration error."""
|
|
error = ConfigurationError(
|
|
message="Missing API key",
|
|
config_key="ANTHROPIC_API_KEY",
|
|
)
|
|
|
|
assert error.config_key == "ANTHROPIC_API_KEY"
|
|
assert error.code == ErrorCode.CONFIGURATION_ERROR
|
|
assert error.details["config_key"] == "ANTHROPIC_API_KEY"
|
|
|
|
def test_configuration_error_no_key(self) -> None:
|
|
"""Test configuration error without key."""
|
|
error = ConfigurationError(message="Invalid configuration")
|
|
|
|
assert error.config_key is None
|
|
assert "config_key" not in error.details
|