codexa 0.4.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.
- codexa-0.4.0.dist-info/METADATA +650 -0
- codexa-0.4.0.dist-info/RECORD +189 -0
- codexa-0.4.0.dist-info/WHEEL +5 -0
- codexa-0.4.0.dist-info/entry_points.txt +2 -0
- codexa-0.4.0.dist-info/licenses/LICENSE +21 -0
- codexa-0.4.0.dist-info/top_level.txt +1 -0
- semantic_code_intelligence/__init__.py +5 -0
- semantic_code_intelligence/analysis/__init__.py +21 -0
- semantic_code_intelligence/analysis/ai_features.py +351 -0
- semantic_code_intelligence/bridge/__init__.py +28 -0
- semantic_code_intelligence/bridge/context_provider.py +245 -0
- semantic_code_intelligence/bridge/protocol.py +167 -0
- semantic_code_intelligence/bridge/server.py +348 -0
- semantic_code_intelligence/bridge/vscode.py +271 -0
- semantic_code_intelligence/ci/__init__.py +13 -0
- semantic_code_intelligence/ci/hooks.py +98 -0
- semantic_code_intelligence/ci/hotspots.py +272 -0
- semantic_code_intelligence/ci/impact.py +246 -0
- semantic_code_intelligence/ci/metrics.py +591 -0
- semantic_code_intelligence/ci/pr.py +412 -0
- semantic_code_intelligence/ci/quality.py +557 -0
- semantic_code_intelligence/ci/templates.py +164 -0
- semantic_code_intelligence/ci/trace.py +224 -0
- semantic_code_intelligence/cli/__init__.py +0 -0
- semantic_code_intelligence/cli/commands/__init__.py +0 -0
- semantic_code_intelligence/cli/commands/ask_cmd.py +153 -0
- semantic_code_intelligence/cli/commands/benchmark_cmd.py +303 -0
- semantic_code_intelligence/cli/commands/chat_cmd.py +252 -0
- semantic_code_intelligence/cli/commands/ci_gen_cmd.py +74 -0
- semantic_code_intelligence/cli/commands/context_cmd.py +120 -0
- semantic_code_intelligence/cli/commands/cross_refactor_cmd.py +113 -0
- semantic_code_intelligence/cli/commands/deps_cmd.py +91 -0
- semantic_code_intelligence/cli/commands/docs_cmd.py +101 -0
- semantic_code_intelligence/cli/commands/doctor_cmd.py +147 -0
- semantic_code_intelligence/cli/commands/evolve_cmd.py +171 -0
- semantic_code_intelligence/cli/commands/explain_cmd.py +112 -0
- semantic_code_intelligence/cli/commands/gate_cmd.py +135 -0
- semantic_code_intelligence/cli/commands/grep_cmd.py +234 -0
- semantic_code_intelligence/cli/commands/hotspots_cmd.py +119 -0
- semantic_code_intelligence/cli/commands/impact_cmd.py +131 -0
- semantic_code_intelligence/cli/commands/index_cmd.py +138 -0
- semantic_code_intelligence/cli/commands/init_cmd.py +152 -0
- semantic_code_intelligence/cli/commands/investigate_cmd.py +163 -0
- semantic_code_intelligence/cli/commands/languages_cmd.py +101 -0
- semantic_code_intelligence/cli/commands/lsp_cmd.py +49 -0
- semantic_code_intelligence/cli/commands/mcp_cmd.py +50 -0
- semantic_code_intelligence/cli/commands/metrics_cmd.py +264 -0
- semantic_code_intelligence/cli/commands/models_cmd.py +157 -0
- semantic_code_intelligence/cli/commands/plugin_cmd.py +275 -0
- semantic_code_intelligence/cli/commands/pr_summary_cmd.py +178 -0
- semantic_code_intelligence/cli/commands/quality_cmd.py +208 -0
- semantic_code_intelligence/cli/commands/refactor_cmd.py +103 -0
- semantic_code_intelligence/cli/commands/review_cmd.py +88 -0
- semantic_code_intelligence/cli/commands/search_cmd.py +236 -0
- semantic_code_intelligence/cli/commands/serve_cmd.py +117 -0
- semantic_code_intelligence/cli/commands/suggest_cmd.py +100 -0
- semantic_code_intelligence/cli/commands/summary_cmd.py +78 -0
- semantic_code_intelligence/cli/commands/tool_cmd.py +282 -0
- semantic_code_intelligence/cli/commands/trace_cmd.py +123 -0
- semantic_code_intelligence/cli/commands/tui_cmd.py +58 -0
- semantic_code_intelligence/cli/commands/viz_cmd.py +127 -0
- semantic_code_intelligence/cli/commands/watch_cmd.py +72 -0
- semantic_code_intelligence/cli/commands/web_cmd.py +61 -0
- semantic_code_intelligence/cli/commands/workspace_cmd.py +250 -0
- semantic_code_intelligence/cli/main.py +65 -0
- semantic_code_intelligence/cli/router.py +92 -0
- semantic_code_intelligence/config/__init__.py +0 -0
- semantic_code_intelligence/config/settings.py +260 -0
- semantic_code_intelligence/context/__init__.py +19 -0
- semantic_code_intelligence/context/engine.py +429 -0
- semantic_code_intelligence/context/memory.py +253 -0
- semantic_code_intelligence/daemon/__init__.py +1 -0
- semantic_code_intelligence/daemon/watcher.py +515 -0
- semantic_code_intelligence/docs/__init__.py +1080 -0
- semantic_code_intelligence/embeddings/__init__.py +0 -0
- semantic_code_intelligence/embeddings/enhanced.py +131 -0
- semantic_code_intelligence/embeddings/generator.py +149 -0
- semantic_code_intelligence/embeddings/model_registry.py +100 -0
- semantic_code_intelligence/evolution/__init__.py +1 -0
- semantic_code_intelligence/evolution/budget_guard.py +111 -0
- semantic_code_intelligence/evolution/commit_manager.py +88 -0
- semantic_code_intelligence/evolution/context_builder.py +131 -0
- semantic_code_intelligence/evolution/engine.py +249 -0
- semantic_code_intelligence/evolution/patch_generator.py +229 -0
- semantic_code_intelligence/evolution/task_selector.py +214 -0
- semantic_code_intelligence/evolution/test_runner.py +111 -0
- semantic_code_intelligence/indexing/__init__.py +0 -0
- semantic_code_intelligence/indexing/chunker.py +174 -0
- semantic_code_intelligence/indexing/parallel.py +86 -0
- semantic_code_intelligence/indexing/scanner.py +146 -0
- semantic_code_intelligence/indexing/semantic_chunker.py +337 -0
- semantic_code_intelligence/llm/__init__.py +62 -0
- semantic_code_intelligence/llm/cache.py +219 -0
- semantic_code_intelligence/llm/cached_provider.py +145 -0
- semantic_code_intelligence/llm/conversation.py +190 -0
- semantic_code_intelligence/llm/cross_refactor.py +272 -0
- semantic_code_intelligence/llm/investigation.py +274 -0
- semantic_code_intelligence/llm/mock_provider.py +77 -0
- semantic_code_intelligence/llm/ollama_provider.py +122 -0
- semantic_code_intelligence/llm/openai_provider.py +100 -0
- semantic_code_intelligence/llm/provider.py +92 -0
- semantic_code_intelligence/llm/rate_limiter.py +164 -0
- semantic_code_intelligence/llm/reasoning.py +438 -0
- semantic_code_intelligence/llm/safety.py +110 -0
- semantic_code_intelligence/llm/streaming.py +251 -0
- semantic_code_intelligence/lsp/__init__.py +609 -0
- semantic_code_intelligence/mcp/__init__.py +393 -0
- semantic_code_intelligence/parsing/__init__.py +19 -0
- semantic_code_intelligence/parsing/parser.py +375 -0
- semantic_code_intelligence/plugins/__init__.py +255 -0
- semantic_code_intelligence/plugins/examples/__init__.py +1 -0
- semantic_code_intelligence/plugins/examples/code_quality.py +73 -0
- semantic_code_intelligence/plugins/examples/search_annotator.py +56 -0
- semantic_code_intelligence/scalability/__init__.py +205 -0
- semantic_code_intelligence/search/__init__.py +0 -0
- semantic_code_intelligence/search/formatter.py +123 -0
- semantic_code_intelligence/search/grep.py +361 -0
- semantic_code_intelligence/search/hybrid_search.py +170 -0
- semantic_code_intelligence/search/keyword_search.py +311 -0
- semantic_code_intelligence/search/section_expander.py +103 -0
- semantic_code_intelligence/services/__init__.py +0 -0
- semantic_code_intelligence/services/indexing_service.py +630 -0
- semantic_code_intelligence/services/search_service.py +269 -0
- semantic_code_intelligence/storage/__init__.py +0 -0
- semantic_code_intelligence/storage/chunk_hash_store.py +86 -0
- semantic_code_intelligence/storage/hash_store.py +66 -0
- semantic_code_intelligence/storage/index_manifest.py +85 -0
- semantic_code_intelligence/storage/index_stats.py +138 -0
- semantic_code_intelligence/storage/query_history.py +160 -0
- semantic_code_intelligence/storage/symbol_registry.py +209 -0
- semantic_code_intelligence/storage/vector_store.py +297 -0
- semantic_code_intelligence/tests/__init__.py +0 -0
- semantic_code_intelligence/tests/test_ai_features.py +351 -0
- semantic_code_intelligence/tests/test_chunker.py +119 -0
- semantic_code_intelligence/tests/test_cli.py +188 -0
- semantic_code_intelligence/tests/test_config.py +154 -0
- semantic_code_intelligence/tests/test_context.py +381 -0
- semantic_code_intelligence/tests/test_embeddings.py +73 -0
- semantic_code_intelligence/tests/test_endtoend.py +1142 -0
- semantic_code_intelligence/tests/test_enhanced_embeddings.py +92 -0
- semantic_code_intelligence/tests/test_hash_store.py +79 -0
- semantic_code_intelligence/tests/test_logging.py +55 -0
- semantic_code_intelligence/tests/test_new_cli.py +138 -0
- semantic_code_intelligence/tests/test_parser.py +495 -0
- semantic_code_intelligence/tests/test_phase10.py +355 -0
- semantic_code_intelligence/tests/test_phase11.py +593 -0
- semantic_code_intelligence/tests/test_phase12.py +375 -0
- semantic_code_intelligence/tests/test_phase13.py +663 -0
- semantic_code_intelligence/tests/test_phase14.py +568 -0
- semantic_code_intelligence/tests/test_phase15.py +814 -0
- semantic_code_intelligence/tests/test_phase16.py +792 -0
- semantic_code_intelligence/tests/test_phase17.py +815 -0
- semantic_code_intelligence/tests/test_phase18.py +934 -0
- semantic_code_intelligence/tests/test_phase19.py +986 -0
- semantic_code_intelligence/tests/test_phase20.py +2753 -0
- semantic_code_intelligence/tests/test_phase20b.py +2058 -0
- semantic_code_intelligence/tests/test_phase20c.py +962 -0
- semantic_code_intelligence/tests/test_phase21.py +428 -0
- semantic_code_intelligence/tests/test_phase22.py +799 -0
- semantic_code_intelligence/tests/test_phase23.py +783 -0
- semantic_code_intelligence/tests/test_phase24.py +715 -0
- semantic_code_intelligence/tests/test_phase25.py +496 -0
- semantic_code_intelligence/tests/test_phase26.py +251 -0
- semantic_code_intelligence/tests/test_phase27.py +531 -0
- semantic_code_intelligence/tests/test_phase8.py +592 -0
- semantic_code_intelligence/tests/test_phase9.py +643 -0
- semantic_code_intelligence/tests/test_plugins.py +293 -0
- semantic_code_intelligence/tests/test_priority_features.py +727 -0
- semantic_code_intelligence/tests/test_router.py +41 -0
- semantic_code_intelligence/tests/test_scalability.py +138 -0
- semantic_code_intelligence/tests/test_scanner.py +125 -0
- semantic_code_intelligence/tests/test_search.py +160 -0
- semantic_code_intelligence/tests/test_semantic_chunker.py +255 -0
- semantic_code_intelligence/tests/test_tools.py +182 -0
- semantic_code_intelligence/tests/test_vector_store.py +151 -0
- semantic_code_intelligence/tests/test_watcher.py +211 -0
- semantic_code_intelligence/tools/__init__.py +442 -0
- semantic_code_intelligence/tools/executor.py +232 -0
- semantic_code_intelligence/tools/protocol.py +200 -0
- semantic_code_intelligence/tui/__init__.py +454 -0
- semantic_code_intelligence/utils/__init__.py +0 -0
- semantic_code_intelligence/utils/logging.py +112 -0
- semantic_code_intelligence/version.py +3 -0
- semantic_code_intelligence/web/__init__.py +11 -0
- semantic_code_intelligence/web/api.py +289 -0
- semantic_code_intelligence/web/server.py +397 -0
- semantic_code_intelligence/web/ui.py +659 -0
- semantic_code_intelligence/web/visualize.py +226 -0
- semantic_code_intelligence/workspace/__init__.py +427 -0
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
"""Phase 22 — LLM Caching + Rate Limiting.
|
|
2
|
+
|
|
3
|
+
Tests verify:
|
|
4
|
+
1. LLMCache — in-memory and disk-backed caching with TTL expiration
|
|
5
|
+
2. CacheStats — hit/miss/eviction tracking
|
|
6
|
+
3. RateLimiter — sliding-window RPM/TPM enforcement
|
|
7
|
+
4. RateLimitExceeded — exception behaviour
|
|
8
|
+
5. CachedProvider — transparent wrapper around any LLMProvider
|
|
9
|
+
6. LLMConfig — new cache/rate-limit config fields
|
|
10
|
+
7. _wrap_provider integration — CLI commands wire up caching
|
|
11
|
+
8. End-to-end — full caching + rate limiting flow
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
from unittest.mock import MagicMock, patch
|
|
22
|
+
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Imports under test
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
from semantic_code_intelligence.llm.cache import CacheStats, LLMCache
|
|
30
|
+
from semantic_code_intelligence.llm.cached_provider import CachedProvider
|
|
31
|
+
from semantic_code_intelligence.llm.mock_provider import MockProvider
|
|
32
|
+
from semantic_code_intelligence.llm.provider import (
|
|
33
|
+
LLMMessage,
|
|
34
|
+
LLMProvider,
|
|
35
|
+
LLMResponse,
|
|
36
|
+
MessageRole,
|
|
37
|
+
)
|
|
38
|
+
from semantic_code_intelligence.llm.rate_limiter import (
|
|
39
|
+
RateLimitExceeded,
|
|
40
|
+
RateLimiter,
|
|
41
|
+
RateLimiterStats,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
45
|
+
_SRC = _PROJECT_ROOT / "semantic_code_intelligence"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
49
|
+
# 1 — LLMCache: core operations
|
|
50
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestLLMCacheBasic:
|
|
54
|
+
"""Basic get/put/clear operations."""
|
|
55
|
+
|
|
56
|
+
def test_cache_miss_returns_none(self) -> None:
|
|
57
|
+
cache = LLMCache()
|
|
58
|
+
result = cache.get("openai", "gpt-4", prompt="hello")
|
|
59
|
+
assert result is None
|
|
60
|
+
|
|
61
|
+
def test_cache_put_and_get(self) -> None:
|
|
62
|
+
cache = LLMCache()
|
|
63
|
+
response = LLMResponse(content="world", model="gpt-4", provider="openai")
|
|
64
|
+
cache.put(response, "openai", "gpt-4", prompt="hello")
|
|
65
|
+
cached = cache.get("openai", "gpt-4", prompt="hello")
|
|
66
|
+
assert cached is not None
|
|
67
|
+
assert cached.content == "world"
|
|
68
|
+
|
|
69
|
+
def test_cache_put_get_with_messages(self) -> None:
|
|
70
|
+
cache = LLMCache()
|
|
71
|
+
msgs = [LLMMessage(role=MessageRole.USER, content="hi")]
|
|
72
|
+
response = LLMResponse(content="hello!", model="gpt-4", provider="openai")
|
|
73
|
+
cache.put(response, "openai", "gpt-4", messages=msgs)
|
|
74
|
+
cached = cache.get("openai", "gpt-4", messages=msgs)
|
|
75
|
+
assert cached is not None
|
|
76
|
+
assert cached.content == "hello!"
|
|
77
|
+
|
|
78
|
+
def test_cache_different_prompts_separate(self) -> None:
|
|
79
|
+
cache = LLMCache()
|
|
80
|
+
r1 = LLMResponse(content="a", model="m", provider="p")
|
|
81
|
+
r2 = LLMResponse(content="b", model="m", provider="p")
|
|
82
|
+
cache.put(r1, "p", "m", prompt="x")
|
|
83
|
+
cache.put(r2, "p", "m", prompt="y")
|
|
84
|
+
assert cache.get("p", "m", prompt="x") is not None
|
|
85
|
+
assert cache.get("p", "m", prompt="x").content == "a" # type: ignore[union-attr]
|
|
86
|
+
assert cache.get("p", "m", prompt="y").content == "b" # type: ignore[union-attr]
|
|
87
|
+
|
|
88
|
+
def test_cache_clear(self) -> None:
|
|
89
|
+
cache = LLMCache()
|
|
90
|
+
cache.put(LLMResponse(content="x"), "p", "m", prompt="q")
|
|
91
|
+
assert cache.size == 1
|
|
92
|
+
cache.clear()
|
|
93
|
+
assert cache.size == 0
|
|
94
|
+
assert cache.get("p", "m", prompt="q") is None
|
|
95
|
+
|
|
96
|
+
def test_cache_size_property(self) -> None:
|
|
97
|
+
cache = LLMCache()
|
|
98
|
+
assert cache.size == 0
|
|
99
|
+
cache.put(LLMResponse(content="x"), "p", "m", prompt="q1")
|
|
100
|
+
assert cache.size == 1
|
|
101
|
+
cache.put(LLMResponse(content="y"), "p", "m", prompt="q2")
|
|
102
|
+
assert cache.size == 2
|
|
103
|
+
|
|
104
|
+
def test_cache_different_temperature_separate_keys(self) -> None:
|
|
105
|
+
cache = LLMCache()
|
|
106
|
+
r1 = LLMResponse(content="cold")
|
|
107
|
+
r2 = LLMResponse(content="hot")
|
|
108
|
+
cache.put(r1, "p", "m", prompt="q", temperature=0.0)
|
|
109
|
+
cache.put(r2, "p", "m", prompt="q", temperature=1.0)
|
|
110
|
+
assert cache.get("p", "m", prompt="q", temperature=0.0).content == "cold" # type: ignore[union-attr]
|
|
111
|
+
assert cache.get("p", "m", prompt="q", temperature=1.0).content == "hot" # type: ignore[union-attr]
|
|
112
|
+
|
|
113
|
+
def test_cache_different_max_tokens_separate_keys(self) -> None:
|
|
114
|
+
cache = LLMCache()
|
|
115
|
+
r1 = LLMResponse(content="short")
|
|
116
|
+
r2 = LLMResponse(content="long")
|
|
117
|
+
cache.put(r1, "p", "m", prompt="q", max_tokens=100)
|
|
118
|
+
cache.put(r2, "p", "m", prompt="q", max_tokens=4000)
|
|
119
|
+
assert cache.get("p", "m", prompt="q", max_tokens=100).content == "short" # type: ignore[union-attr]
|
|
120
|
+
assert cache.get("p", "m", prompt="q", max_tokens=4000).content == "long" # type: ignore[union-attr]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TestLLMCacheTTL:
|
|
124
|
+
"""TTL expiration tests."""
|
|
125
|
+
|
|
126
|
+
def test_expired_entry_returns_none(self) -> None:
|
|
127
|
+
cache = LLMCache(ttl_hours=0) # 0 hours = immediate expiry
|
|
128
|
+
cache.put(LLMResponse(content="old"), "p", "m", prompt="q")
|
|
129
|
+
# Force timestamp to the past
|
|
130
|
+
for key in cache._entries:
|
|
131
|
+
cache._entries[key]["timestamp"] = time.time() - 1
|
|
132
|
+
result = cache.get("p", "m", prompt="q")
|
|
133
|
+
assert result is None
|
|
134
|
+
|
|
135
|
+
def test_non_expired_entry_returned(self) -> None:
|
|
136
|
+
cache = LLMCache(ttl_hours=24)
|
|
137
|
+
cache.put(LLMResponse(content="fresh"), "p", "m", prompt="q")
|
|
138
|
+
result = cache.get("p", "m", prompt="q")
|
|
139
|
+
assert result is not None
|
|
140
|
+
assert result.content == "fresh"
|
|
141
|
+
|
|
142
|
+
def test_ttl_eviction_increments_stats(self) -> None:
|
|
143
|
+
cache = LLMCache(ttl_hours=0)
|
|
144
|
+
cache.put(LLMResponse(content="old"), "p", "m", prompt="q")
|
|
145
|
+
for key in cache._entries:
|
|
146
|
+
cache._entries[key]["timestamp"] = time.time() - 1
|
|
147
|
+
cache.get("p", "m", prompt="q") # triggers eviction
|
|
148
|
+
assert cache.stats.evictions == 1
|
|
149
|
+
assert cache.stats.misses == 1
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TestLLMCacheEviction:
|
|
153
|
+
"""Max-entry eviction tests."""
|
|
154
|
+
|
|
155
|
+
def test_evicts_oldest_when_over_max(self) -> None:
|
|
156
|
+
cache = LLMCache(max_entries=2)
|
|
157
|
+
cache.put(LLMResponse(content="a"), "p", "m", prompt="q1")
|
|
158
|
+
time.sleep(0.01) # ensure distinct timestamps
|
|
159
|
+
cache.put(LLMResponse(content="b"), "p", "m", prompt="q2")
|
|
160
|
+
time.sleep(0.01)
|
|
161
|
+
cache.put(LLMResponse(content="c"), "p", "m", prompt="q3")
|
|
162
|
+
# "a" should have been evicted
|
|
163
|
+
assert cache.size == 2
|
|
164
|
+
assert cache.get("p", "m", prompt="q1") is None
|
|
165
|
+
assert cache.get("p", "m", prompt="q2") is not None
|
|
166
|
+
assert cache.get("p", "m", prompt="q3") is not None
|
|
167
|
+
|
|
168
|
+
def test_eviction_stats_tracked(self) -> None:
|
|
169
|
+
cache = LLMCache(max_entries=1)
|
|
170
|
+
cache.put(LLMResponse(content="a"), "p", "m", prompt="q1")
|
|
171
|
+
cache.put(LLMResponse(content="b"), "p", "m", prompt="q2")
|
|
172
|
+
assert cache.stats.evictions >= 1
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class TestCacheStats:
|
|
176
|
+
"""CacheStats dataclass."""
|
|
177
|
+
|
|
178
|
+
def test_initial_stats(self) -> None:
|
|
179
|
+
stats = CacheStats()
|
|
180
|
+
assert stats.hits == 0
|
|
181
|
+
assert stats.misses == 0
|
|
182
|
+
assert stats.evictions == 0
|
|
183
|
+
assert stats.size == 0
|
|
184
|
+
assert stats.hit_rate == 0.0
|
|
185
|
+
|
|
186
|
+
def test_hit_rate_calculation(self) -> None:
|
|
187
|
+
stats = CacheStats(hits=3, misses=1)
|
|
188
|
+
assert stats.hit_rate == 75.0
|
|
189
|
+
|
|
190
|
+
def test_hit_rate_zero_total(self) -> None:
|
|
191
|
+
stats = CacheStats()
|
|
192
|
+
assert stats.hit_rate == 0.0
|
|
193
|
+
|
|
194
|
+
def test_to_dict(self) -> None:
|
|
195
|
+
stats = CacheStats(hits=5, misses=2, evictions=1, size=10)
|
|
196
|
+
d = stats.to_dict()
|
|
197
|
+
assert d["hits"] == 5
|
|
198
|
+
assert d["misses"] == 2
|
|
199
|
+
assert d["evictions"] == 1
|
|
200
|
+
assert d["size"] == 10
|
|
201
|
+
assert "hit_rate" in d
|
|
202
|
+
|
|
203
|
+
def test_cache_stats_updated_on_hit(self) -> None:
|
|
204
|
+
cache = LLMCache()
|
|
205
|
+
cache.put(LLMResponse(content="x"), "p", "m", prompt="q")
|
|
206
|
+
cache.get("p", "m", prompt="q")
|
|
207
|
+
assert cache.stats.hits == 1
|
|
208
|
+
|
|
209
|
+
def test_cache_stats_updated_on_miss(self) -> None:
|
|
210
|
+
cache = LLMCache()
|
|
211
|
+
cache.get("p", "m", prompt="missing")
|
|
212
|
+
assert cache.stats.misses == 1
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class TestLLMCachePersistence:
|
|
216
|
+
"""Disk persistence (save/load)."""
|
|
217
|
+
|
|
218
|
+
def test_save_and_load(self, tmp_path: Path) -> None:
|
|
219
|
+
cache = LLMCache(cache_dir=str(tmp_path), ttl_hours=24)
|
|
220
|
+
cache.put(LLMResponse(content="saved", model="m", provider="p"), "p", "m", prompt="q")
|
|
221
|
+
cache.save()
|
|
222
|
+
|
|
223
|
+
cache2 = LLMCache(cache_dir=str(tmp_path), ttl_hours=24)
|
|
224
|
+
result = cache2.get("p", "m", prompt="q")
|
|
225
|
+
assert result is not None
|
|
226
|
+
assert result.content == "saved"
|
|
227
|
+
|
|
228
|
+
def test_save_creates_file(self, tmp_path: Path) -> None:
|
|
229
|
+
cache = LLMCache(cache_dir=str(tmp_path))
|
|
230
|
+
cache.put(LLMResponse(content="x"), "p", "m", prompt="q")
|
|
231
|
+
cache.save()
|
|
232
|
+
assert (tmp_path / "llm_cache.json").exists()
|
|
233
|
+
|
|
234
|
+
def test_load_skips_expired(self, tmp_path: Path) -> None:
|
|
235
|
+
# Write a cache file with old timestamps
|
|
236
|
+
data = {
|
|
237
|
+
"abc123": {
|
|
238
|
+
"response": {"content": "old", "model": "", "provider": "", "usage": {}},
|
|
239
|
+
"timestamp": time.time() - 100000,
|
|
240
|
+
"provider": "p",
|
|
241
|
+
"model": "m",
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
(tmp_path / "llm_cache.json").write_text(json.dumps(data))
|
|
245
|
+
cache = LLMCache(cache_dir=str(tmp_path), ttl_hours=1)
|
|
246
|
+
assert cache.size == 0 # expired entries not loaded
|
|
247
|
+
|
|
248
|
+
def test_load_invalid_json(self, tmp_path: Path) -> None:
|
|
249
|
+
(tmp_path / "llm_cache.json").write_text("not json!")
|
|
250
|
+
cache = LLMCache(cache_dir=str(tmp_path))
|
|
251
|
+
assert cache.size == 0
|
|
252
|
+
|
|
253
|
+
def test_load_nonexistent_dir(self, tmp_path: Path) -> None:
|
|
254
|
+
cache = LLMCache(cache_dir=str(tmp_path / "nonexistent"))
|
|
255
|
+
assert cache.size == 0
|
|
256
|
+
|
|
257
|
+
def test_no_cache_dir_save_noop(self) -> None:
|
|
258
|
+
cache = LLMCache()
|
|
259
|
+
cache.put(LLMResponse(content="x"), "p", "m", prompt="q")
|
|
260
|
+
cache.save() # Should not raise
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class TestLLMCacheKeyDeterminism:
|
|
264
|
+
"""Cache key generation is deterministic."""
|
|
265
|
+
|
|
266
|
+
def test_same_input_same_key(self) -> None:
|
|
267
|
+
msgs = [LLMMessage(role=MessageRole.USER, content="hello")]
|
|
268
|
+
k1 = LLMCache._make_key("openai", "gpt-4", msgs, None, 0.2, 2048)
|
|
269
|
+
k2 = LLMCache._make_key("openai", "gpt-4", msgs, None, 0.2, 2048)
|
|
270
|
+
assert k1 == k2
|
|
271
|
+
|
|
272
|
+
def test_different_provider_different_key(self) -> None:
|
|
273
|
+
k1 = LLMCache._make_key("openai", "gpt-4", None, "hi", 0.2, 2048)
|
|
274
|
+
k2 = LLMCache._make_key("ollama", "gpt-4", None, "hi", 0.2, 2048)
|
|
275
|
+
assert k1 != k2
|
|
276
|
+
|
|
277
|
+
def test_different_model_different_key(self) -> None:
|
|
278
|
+
k1 = LLMCache._make_key("openai", "gpt-4", None, "hi", 0.2, 2048)
|
|
279
|
+
k2 = LLMCache._make_key("openai", "gpt-3.5", None, "hi", 0.2, 2048)
|
|
280
|
+
assert k1 != k2
|
|
281
|
+
|
|
282
|
+
def test_key_is_sha256(self) -> None:
|
|
283
|
+
key = LLMCache._make_key("p", "m", None, "prompt", 0.2, 2048)
|
|
284
|
+
assert len(key) == 64 # SHA-256 hex digest length
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
288
|
+
# 2 — RateLimiter: sliding window enforcement
|
|
289
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class TestRateLimiterBasic:
|
|
293
|
+
"""Basic rate limiter operations."""
|
|
294
|
+
|
|
295
|
+
def test_unlimited_always_allows(self) -> None:
|
|
296
|
+
rl = RateLimiter(rpm=0, tpm=0)
|
|
297
|
+
rl.acquire() # Should not raise
|
|
298
|
+
assert not rl.is_enabled
|
|
299
|
+
|
|
300
|
+
def test_is_enabled_rpm(self) -> None:
|
|
301
|
+
rl = RateLimiter(rpm=10)
|
|
302
|
+
assert rl.is_enabled
|
|
303
|
+
|
|
304
|
+
def test_is_enabled_tpm(self) -> None:
|
|
305
|
+
rl = RateLimiter(tpm=1000)
|
|
306
|
+
assert rl.is_enabled
|
|
307
|
+
|
|
308
|
+
def test_acquire_within_limit(self) -> None:
|
|
309
|
+
rl = RateLimiter(rpm=100, blocking=False)
|
|
310
|
+
rl.acquire() # Should not raise
|
|
311
|
+
|
|
312
|
+
def test_acquire_exceeds_rpm_nonblocking(self) -> None:
|
|
313
|
+
rl = RateLimiter(rpm=2, blocking=False)
|
|
314
|
+
rl.acquire()
|
|
315
|
+
rl.acquire()
|
|
316
|
+
with pytest.raises(RateLimitExceeded):
|
|
317
|
+
rl.acquire()
|
|
318
|
+
|
|
319
|
+
def test_record_usage(self) -> None:
|
|
320
|
+
rl = RateLimiter(rpm=100)
|
|
321
|
+
rl.acquire()
|
|
322
|
+
rl.record_usage(500)
|
|
323
|
+
assert rl.stats.total_tokens == 500
|
|
324
|
+
|
|
325
|
+
def test_stats_tracking(self) -> None:
|
|
326
|
+
rl = RateLimiter(rpm=100)
|
|
327
|
+
rl.acquire()
|
|
328
|
+
rl.acquire()
|
|
329
|
+
assert rl.stats.total_requests == 2
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class TestRateLimiterTPM:
|
|
333
|
+
"""Tokens-per-minute enforcement."""
|
|
334
|
+
|
|
335
|
+
def test_tpm_limit_nonblocking(self) -> None:
|
|
336
|
+
rl = RateLimiter(tpm=100, blocking=False)
|
|
337
|
+
rl.acquire(estimated_tokens=80)
|
|
338
|
+
with pytest.raises(RateLimitExceeded):
|
|
339
|
+
rl.acquire(estimated_tokens=80)
|
|
340
|
+
|
|
341
|
+
def test_tpm_allows_within_limit(self) -> None:
|
|
342
|
+
rl = RateLimiter(tpm=1000, blocking=False)
|
|
343
|
+
rl.acquire(estimated_tokens=400)
|
|
344
|
+
rl.acquire(estimated_tokens=400) # 800 total, within 1000
|
|
345
|
+
|
|
346
|
+
def test_tpm_stats_current(self) -> None:
|
|
347
|
+
rl = RateLimiter(tpm=10000)
|
|
348
|
+
rl.acquire(estimated_tokens=500)
|
|
349
|
+
assert rl.stats.current_tpm == 500
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class TestRateLimitExceeded:
|
|
353
|
+
"""RateLimitExceeded exception."""
|
|
354
|
+
|
|
355
|
+
def test_exception_message(self) -> None:
|
|
356
|
+
exc = RateLimitExceeded("too fast")
|
|
357
|
+
assert str(exc) == "too fast"
|
|
358
|
+
|
|
359
|
+
def test_retry_after(self) -> None:
|
|
360
|
+
exc = RateLimitExceeded("slow down", retry_after=5.0)
|
|
361
|
+
assert exc.retry_after == 5.0
|
|
362
|
+
|
|
363
|
+
def test_default_retry_after(self) -> None:
|
|
364
|
+
exc = RateLimitExceeded()
|
|
365
|
+
assert exc.retry_after == 0.0
|
|
366
|
+
|
|
367
|
+
def test_rejected_requests_tracked(self) -> None:
|
|
368
|
+
rl = RateLimiter(rpm=1, blocking=False)
|
|
369
|
+
rl.acquire()
|
|
370
|
+
with pytest.raises(RateLimitExceeded):
|
|
371
|
+
rl.acquire()
|
|
372
|
+
assert rl.stats.rejected_requests == 1
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class TestRateLimiterStats:
|
|
376
|
+
"""RateLimiterStats dataclass."""
|
|
377
|
+
|
|
378
|
+
def test_initial_stats(self) -> None:
|
|
379
|
+
stats = RateLimiterStats()
|
|
380
|
+
assert stats.total_requests == 0
|
|
381
|
+
assert stats.total_tokens == 0
|
|
382
|
+
assert stats.rejected_requests == 0
|
|
383
|
+
|
|
384
|
+
def test_to_dict(self) -> None:
|
|
385
|
+
stats = RateLimiterStats(total_requests=5, total_tokens=1000)
|
|
386
|
+
d = stats.to_dict()
|
|
387
|
+
assert d["total_requests"] == 5
|
|
388
|
+
assert d["total_tokens"] == 1000
|
|
389
|
+
assert "current_rpm" in d
|
|
390
|
+
assert "current_tpm" in d
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class TestRateLimiterSlidingWindow:
|
|
394
|
+
"""Sliding window prune behaviour."""
|
|
395
|
+
|
|
396
|
+
def test_old_events_pruned(self) -> None:
|
|
397
|
+
rl = RateLimiter(rpm=2, blocking=False)
|
|
398
|
+
rl.acquire()
|
|
399
|
+
rl.acquire()
|
|
400
|
+
# Manually age the events
|
|
401
|
+
for ev in rl._events:
|
|
402
|
+
ev.timestamp -= 61.0
|
|
403
|
+
rl.acquire() # Should succeed because old events were pruned
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
407
|
+
# 3 — CachedProvider: wrapper for any LLMProvider
|
|
408
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class TestCachedProviderBasic:
|
|
412
|
+
"""CachedProvider wraps an LLMProvider with caching and rate limiting."""
|
|
413
|
+
|
|
414
|
+
def _make_mock(self, content: str = "mock response") -> MockProvider:
|
|
415
|
+
provider = MockProvider()
|
|
416
|
+
provider.enqueue_response(content)
|
|
417
|
+
return provider
|
|
418
|
+
|
|
419
|
+
def test_name_delegates_to_inner(self) -> None:
|
|
420
|
+
mock = self._make_mock()
|
|
421
|
+
cp = CachedProvider(mock)
|
|
422
|
+
assert cp.name == mock.name
|
|
423
|
+
|
|
424
|
+
def test_is_available_delegates(self) -> None:
|
|
425
|
+
mock = self._make_mock()
|
|
426
|
+
cp = CachedProvider(mock)
|
|
427
|
+
assert cp.is_available() == mock.is_available()
|
|
428
|
+
|
|
429
|
+
def test_inner_property(self) -> None:
|
|
430
|
+
mock = self._make_mock()
|
|
431
|
+
cp = CachedProvider(mock)
|
|
432
|
+
assert cp.inner is mock
|
|
433
|
+
|
|
434
|
+
def test_complete_without_cache(self) -> None:
|
|
435
|
+
mock = self._make_mock("hello")
|
|
436
|
+
cp = CachedProvider(mock)
|
|
437
|
+
result = cp.complete("test prompt")
|
|
438
|
+
assert result.content == "hello"
|
|
439
|
+
|
|
440
|
+
def test_chat_without_cache(self) -> None:
|
|
441
|
+
mock = self._make_mock("hi there")
|
|
442
|
+
cp = CachedProvider(mock)
|
|
443
|
+
msgs = [LLMMessage(role=MessageRole.USER, content="say hi")]
|
|
444
|
+
result = cp.chat(msgs)
|
|
445
|
+
assert result.content == "hi there"
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class TestCachedProviderCaching:
|
|
449
|
+
"""Caching behaviour through CachedProvider."""
|
|
450
|
+
|
|
451
|
+
def _make_provider(self) -> tuple[MockProvider, CachedProvider]:
|
|
452
|
+
mock = MockProvider()
|
|
453
|
+
mock.enqueue_response("first")
|
|
454
|
+
mock.enqueue_response("second")
|
|
455
|
+
cache = LLMCache(ttl_hours=24)
|
|
456
|
+
return mock, CachedProvider(mock, cache=cache)
|
|
457
|
+
|
|
458
|
+
def test_complete_caches_response(self) -> None:
|
|
459
|
+
mock, cp = self._make_provider()
|
|
460
|
+
r1 = cp.complete("query")
|
|
461
|
+
r2 = cp.complete("query")
|
|
462
|
+
assert r1.content == "first"
|
|
463
|
+
assert r2.content == "first" # same, from cache
|
|
464
|
+
|
|
465
|
+
def test_chat_caches_response(self) -> None:
|
|
466
|
+
mock, cp = self._make_provider()
|
|
467
|
+
msgs = [LLMMessage(role=MessageRole.USER, content="hello")]
|
|
468
|
+
r1 = cp.chat(msgs)
|
|
469
|
+
r2 = cp.chat(msgs)
|
|
470
|
+
assert r1.content == "first"
|
|
471
|
+
assert r2.content == "first" # cached
|
|
472
|
+
|
|
473
|
+
def test_different_prompts_not_cached(self) -> None:
|
|
474
|
+
mock, cp = self._make_provider()
|
|
475
|
+
r1 = cp.complete("query1")
|
|
476
|
+
r2 = cp.complete("query2")
|
|
477
|
+
assert r1.content == "first"
|
|
478
|
+
assert r2.content == "second" # different prompt → different result
|
|
479
|
+
|
|
480
|
+
def test_save_cache_delegates(self, tmp_path: Path) -> None:
|
|
481
|
+
mock = MockProvider()
|
|
482
|
+
mock.enqueue_response("data")
|
|
483
|
+
cache = LLMCache(cache_dir=str(tmp_path), ttl_hours=24)
|
|
484
|
+
cp = CachedProvider(mock, cache=cache)
|
|
485
|
+
cp.complete("test")
|
|
486
|
+
cp.save_cache()
|
|
487
|
+
assert (tmp_path / "llm_cache.json").exists()
|
|
488
|
+
|
|
489
|
+
def test_save_cache_noop_without_cache(self) -> None:
|
|
490
|
+
mock = MockProvider()
|
|
491
|
+
cp = CachedProvider(mock)
|
|
492
|
+
cp.save_cache() # Should not raise
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class TestCachedProviderRateLimiting:
|
|
496
|
+
"""Rate limiting through CachedProvider."""
|
|
497
|
+
|
|
498
|
+
def test_rate_limited_requests(self) -> None:
|
|
499
|
+
mock = MockProvider()
|
|
500
|
+
for r in ["a", "b", "c"]:
|
|
501
|
+
mock.enqueue_response(r)
|
|
502
|
+
rl = RateLimiter(rpm=2, blocking=False)
|
|
503
|
+
cp = CachedProvider(mock, rate_limiter=rl)
|
|
504
|
+
cp.complete("q1")
|
|
505
|
+
cp.complete("q2")
|
|
506
|
+
with pytest.raises(RateLimitExceeded):
|
|
507
|
+
cp.complete("q3")
|
|
508
|
+
|
|
509
|
+
def test_rate_limiter_records_usage(self) -> None:
|
|
510
|
+
mock = MockProvider()
|
|
511
|
+
mock.enqueue_response("result")
|
|
512
|
+
rl = RateLimiter(rpm=100)
|
|
513
|
+
cp = CachedProvider(mock, rate_limiter=rl)
|
|
514
|
+
cp.complete("test")
|
|
515
|
+
assert rl.stats.total_requests == 1
|
|
516
|
+
|
|
517
|
+
def test_cached_response_skips_rate_limit(self) -> None:
|
|
518
|
+
mock = MockProvider()
|
|
519
|
+
mock.enqueue_response("first")
|
|
520
|
+
mock.enqueue_response("second")
|
|
521
|
+
cache = LLMCache(ttl_hours=24)
|
|
522
|
+
rl = RateLimiter(rpm=1, blocking=False)
|
|
523
|
+
cp = CachedProvider(mock, cache=cache, rate_limiter=rl)
|
|
524
|
+
cp.complete("same-query") # Uses rate limit slot
|
|
525
|
+
cp.complete("same-query") # Cache hit — no rate limit needed
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class TestCachedProviderChat:
|
|
529
|
+
"""Chat-specific caching and rate limiting."""
|
|
530
|
+
|
|
531
|
+
def test_chat_cache_and_rate_limit_combined(self) -> None:
|
|
532
|
+
mock = MockProvider()
|
|
533
|
+
mock.enqueue_response("resp1")
|
|
534
|
+
mock.enqueue_response("resp2")
|
|
535
|
+
cache = LLMCache(ttl_hours=24)
|
|
536
|
+
rl = RateLimiter(rpm=100)
|
|
537
|
+
cp = CachedProvider(mock, cache=cache, rate_limiter=rl)
|
|
538
|
+
msgs = [LLMMessage(role=MessageRole.USER, content="hi")]
|
|
539
|
+
r1 = cp.chat(msgs)
|
|
540
|
+
r2 = cp.chat(msgs)
|
|
541
|
+
assert r1.content == "resp1"
|
|
542
|
+
assert r2.content == "resp1" # from cache
|
|
543
|
+
assert rl.stats.total_requests == 1 # only one actual API call
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
547
|
+
# 4 — LLMConfig: new cache/rate-limit fields
|
|
548
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
class TestLLMConfigFields:
|
|
552
|
+
"""LLMConfig has cache and rate limit configuration fields."""
|
|
553
|
+
|
|
554
|
+
def test_cache_enabled_default(self) -> None:
|
|
555
|
+
from semantic_code_intelligence.config.settings import LLMConfig
|
|
556
|
+
cfg = LLMConfig()
|
|
557
|
+
assert cfg.cache_enabled is True
|
|
558
|
+
|
|
559
|
+
def test_cache_ttl_hours_default(self) -> None:
|
|
560
|
+
from semantic_code_intelligence.config.settings import LLMConfig
|
|
561
|
+
cfg = LLMConfig()
|
|
562
|
+
assert cfg.cache_ttl_hours == 24
|
|
563
|
+
|
|
564
|
+
def test_cache_max_entries_default(self) -> None:
|
|
565
|
+
from semantic_code_intelligence.config.settings import LLMConfig
|
|
566
|
+
cfg = LLMConfig()
|
|
567
|
+
assert cfg.cache_max_entries == 1000
|
|
568
|
+
|
|
569
|
+
def test_rate_limit_rpm_default(self) -> None:
|
|
570
|
+
from semantic_code_intelligence.config.settings import LLMConfig
|
|
571
|
+
cfg = LLMConfig()
|
|
572
|
+
assert cfg.rate_limit_rpm == 0
|
|
573
|
+
|
|
574
|
+
def test_rate_limit_tpm_default(self) -> None:
|
|
575
|
+
from semantic_code_intelligence.config.settings import LLMConfig
|
|
576
|
+
cfg = LLMConfig()
|
|
577
|
+
assert cfg.rate_limit_tpm == 0
|
|
578
|
+
|
|
579
|
+
def test_custom_values(self) -> None:
|
|
580
|
+
from semantic_code_intelligence.config.settings import LLMConfig
|
|
581
|
+
cfg = LLMConfig(
|
|
582
|
+
cache_enabled=False,
|
|
583
|
+
cache_ttl_hours=48,
|
|
584
|
+
cache_max_entries=500,
|
|
585
|
+
rate_limit_rpm=30,
|
|
586
|
+
rate_limit_tpm=50000,
|
|
587
|
+
)
|
|
588
|
+
assert cfg.cache_enabled is False
|
|
589
|
+
assert cfg.cache_ttl_hours == 48
|
|
590
|
+
assert cfg.cache_max_entries == 500
|
|
591
|
+
assert cfg.rate_limit_rpm == 30
|
|
592
|
+
assert cfg.rate_limit_tpm == 50000
|
|
593
|
+
|
|
594
|
+
def test_serialization_roundtrip(self) -> None:
|
|
595
|
+
from semantic_code_intelligence.config.settings import LLMConfig
|
|
596
|
+
cfg = LLMConfig(cache_enabled=True, rate_limit_rpm=60)
|
|
597
|
+
data = json.loads(cfg.model_dump_json())
|
|
598
|
+
restored = LLMConfig.model_validate(data)
|
|
599
|
+
assert restored.cache_enabled is True
|
|
600
|
+
assert restored.rate_limit_rpm == 60
|
|
601
|
+
|
|
602
|
+
def test_config_in_appconfig(self) -> None:
|
|
603
|
+
from semantic_code_intelligence.config.settings import AppConfig
|
|
604
|
+
app = AppConfig()
|
|
605
|
+
assert hasattr(app.llm, "cache_enabled")
|
|
606
|
+
assert hasattr(app.llm, "rate_limit_rpm")
|
|
607
|
+
assert hasattr(app.llm, "rate_limit_tpm")
|
|
608
|
+
|
|
609
|
+
def test_config_json_persistence(self, tmp_path: Path) -> None:
|
|
610
|
+
from semantic_code_intelligence.config.settings import AppConfig, save_config, load_config
|
|
611
|
+
cfg = AppConfig(project_root=str(tmp_path))
|
|
612
|
+
cfg.llm.cache_enabled = False
|
|
613
|
+
cfg.llm.rate_limit_rpm = 42
|
|
614
|
+
save_config(cfg, tmp_path)
|
|
615
|
+
loaded = load_config(tmp_path)
|
|
616
|
+
assert loaded.llm.cache_enabled is False
|
|
617
|
+
assert loaded.llm.rate_limit_rpm == 42
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
621
|
+
# 5 — CLI _wrap_provider integration
|
|
622
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
class TestWrapProviderIntegration:
|
|
626
|
+
"""_wrap_provider correctly builds CachedProvider from config."""
|
|
627
|
+
|
|
628
|
+
def _make_config(
|
|
629
|
+
self,
|
|
630
|
+
cache_enabled: bool = True,
|
|
631
|
+
rpm: int = 0,
|
|
632
|
+
tpm: int = 0,
|
|
633
|
+
) -> Any:
|
|
634
|
+
from semantic_code_intelligence.config.settings import AppConfig, LLMConfig
|
|
635
|
+
cfg = AppConfig()
|
|
636
|
+
cfg.llm.cache_enabled = cache_enabled
|
|
637
|
+
cfg.llm.rate_limit_rpm = rpm
|
|
638
|
+
cfg.llm.rate_limit_tpm = tpm
|
|
639
|
+
return cfg
|
|
640
|
+
|
|
641
|
+
def test_wrap_returns_cached_provider(self) -> None:
|
|
642
|
+
from semantic_code_intelligence.cli.commands.ask_cmd import _wrap_provider
|
|
643
|
+
cfg = self._make_config(cache_enabled=True)
|
|
644
|
+
provider = MockProvider()
|
|
645
|
+
result = _wrap_provider(provider, cfg.llm, cfg)
|
|
646
|
+
assert isinstance(result, CachedProvider)
|
|
647
|
+
|
|
648
|
+
def test_wrap_no_cache_no_rate_limit(self) -> None:
|
|
649
|
+
from semantic_code_intelligence.cli.commands.ask_cmd import _wrap_provider
|
|
650
|
+
cfg = self._make_config(cache_enabled=False, rpm=0, tpm=0)
|
|
651
|
+
provider = MockProvider()
|
|
652
|
+
result = _wrap_provider(provider, cfg.llm, cfg)
|
|
653
|
+
assert result is provider # No wrapping
|
|
654
|
+
|
|
655
|
+
def test_wrap_rate_limit_only(self) -> None:
|
|
656
|
+
from semantic_code_intelligence.cli.commands.ask_cmd import _wrap_provider
|
|
657
|
+
cfg = self._make_config(cache_enabled=False, rpm=60)
|
|
658
|
+
provider = MockProvider()
|
|
659
|
+
result = _wrap_provider(provider, cfg.llm, cfg)
|
|
660
|
+
assert isinstance(result, CachedProvider)
|
|
661
|
+
|
|
662
|
+
def test_wrap_chat_cmd(self) -> None:
|
|
663
|
+
from semantic_code_intelligence.cli.commands.chat_cmd import _wrap_provider
|
|
664
|
+
cfg = self._make_config(cache_enabled=True)
|
|
665
|
+
provider = MockProvider()
|
|
666
|
+
result = _wrap_provider(provider, cfg.llm, cfg)
|
|
667
|
+
assert isinstance(result, CachedProvider)
|
|
668
|
+
|
|
669
|
+
def test_wrap_investigate_cmd(self) -> None:
|
|
670
|
+
from semantic_code_intelligence.cli.commands.investigate_cmd import _wrap_provider
|
|
671
|
+
cfg = self._make_config(cache_enabled=True)
|
|
672
|
+
provider = MockProvider()
|
|
673
|
+
result = _wrap_provider(provider, cfg.llm, cfg)
|
|
674
|
+
assert isinstance(result, CachedProvider)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
678
|
+
# 6 — Module exports and imports
|
|
679
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
class TestModuleExports:
|
|
683
|
+
"""Verify new classes are exported from the llm package."""
|
|
684
|
+
|
|
685
|
+
def test_cache_exported(self) -> None:
|
|
686
|
+
from semantic_code_intelligence.llm import LLMCache
|
|
687
|
+
assert LLMCache is not None
|
|
688
|
+
|
|
689
|
+
def test_cache_stats_exported(self) -> None:
|
|
690
|
+
from semantic_code_intelligence.llm import CacheStats
|
|
691
|
+
assert CacheStats is not None
|
|
692
|
+
|
|
693
|
+
def test_cached_provider_exported(self) -> None:
|
|
694
|
+
from semantic_code_intelligence.llm import CachedProvider
|
|
695
|
+
assert CachedProvider is not None
|
|
696
|
+
|
|
697
|
+
def test_rate_limiter_exported(self) -> None:
|
|
698
|
+
from semantic_code_intelligence.llm import RateLimiter
|
|
699
|
+
assert RateLimiter is not None
|
|
700
|
+
|
|
701
|
+
def test_rate_limit_exceeded_exported(self) -> None:
|
|
702
|
+
from semantic_code_intelligence.llm import RateLimitExceeded
|
|
703
|
+
assert RateLimitExceeded is not None
|
|
704
|
+
|
|
705
|
+
def test_rate_limiter_stats_exported(self) -> None:
|
|
706
|
+
from semantic_code_intelligence.llm import RateLimiterStats
|
|
707
|
+
assert RateLimiterStats is not None
|
|
708
|
+
|
|
709
|
+
def test_all_list_includes_new_classes(self) -> None:
|
|
710
|
+
import semantic_code_intelligence.llm as llm_mod
|
|
711
|
+
for name in ["CachedProvider", "LLMCache", "CacheStats",
|
|
712
|
+
"RateLimiter", "RateLimitExceeded", "RateLimiterStats"]:
|
|
713
|
+
assert name in llm_mod.__all__
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
717
|
+
# 7 — End-to-end flow
|
|
718
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
class TestEndToEndFlow:
|
|
722
|
+
"""Full caching + rate limiting pipeline."""
|
|
723
|
+
|
|
724
|
+
def test_full_flow_complete(self) -> None:
|
|
725
|
+
mock = MockProvider()
|
|
726
|
+
mock.enqueue_response("answer1")
|
|
727
|
+
mock.enqueue_response("answer2")
|
|
728
|
+
cache = LLMCache(ttl_hours=24)
|
|
729
|
+
rl = RateLimiter(rpm=100)
|
|
730
|
+
cp = CachedProvider(mock, cache=cache, rate_limiter=rl)
|
|
731
|
+
|
|
732
|
+
# First call — cache miss, hits provider
|
|
733
|
+
r1 = cp.complete("what is 2+2?")
|
|
734
|
+
assert r1.content == "answer1"
|
|
735
|
+
assert cache.stats.misses == 1
|
|
736
|
+
assert cache.stats.hits == 0
|
|
737
|
+
|
|
738
|
+
# Second call — cache hit
|
|
739
|
+
r2 = cp.complete("what is 2+2?")
|
|
740
|
+
assert r2.content == "answer1"
|
|
741
|
+
assert cache.stats.hits == 1
|
|
742
|
+
|
|
743
|
+
# Different query — cache miss
|
|
744
|
+
r3 = cp.complete("what is 3+3?")
|
|
745
|
+
assert r3.content == "answer2"
|
|
746
|
+
assert cache.stats.misses == 2
|
|
747
|
+
|
|
748
|
+
def test_full_flow_chat(self) -> None:
|
|
749
|
+
mock = MockProvider()
|
|
750
|
+
mock.enqueue_response("reply1")
|
|
751
|
+
mock.enqueue_response("reply2")
|
|
752
|
+
cache = LLMCache(ttl_hours=24)
|
|
753
|
+
cp = CachedProvider(mock, cache=cache)
|
|
754
|
+
|
|
755
|
+
msgs = [LLMMessage(role=MessageRole.USER, content="greet me")]
|
|
756
|
+
r1 = cp.chat(msgs)
|
|
757
|
+
r2 = cp.chat(msgs)
|
|
758
|
+
assert r1.content == "reply1"
|
|
759
|
+
assert r2.content == "reply1"
|
|
760
|
+
|
|
761
|
+
def test_full_flow_with_persistence(self, tmp_path: Path) -> None:
|
|
762
|
+
mock = MockProvider()
|
|
763
|
+
mock.enqueue_response("persisted")
|
|
764
|
+
cache = LLMCache(cache_dir=str(tmp_path), ttl_hours=24)
|
|
765
|
+
cp = CachedProvider(mock, cache=cache)
|
|
766
|
+
cp.complete("save me")
|
|
767
|
+
cp.save_cache()
|
|
768
|
+
|
|
769
|
+
# New provider instance loads from disk
|
|
770
|
+
cache2 = LLMCache(cache_dir=str(tmp_path), ttl_hours=24)
|
|
771
|
+
cp2 = CachedProvider(MockProvider(), cache=cache2)
|
|
772
|
+
result = cp2.complete("save me")
|
|
773
|
+
assert result is not None
|
|
774
|
+
assert result.content == "persisted"
|
|
775
|
+
|
|
776
|
+
def test_rate_limit_protects_provider(self) -> None:
|
|
777
|
+
mock = MockProvider()
|
|
778
|
+
for r in ["a", "b", "c", "d", "e"]:
|
|
779
|
+
mock.enqueue_response(r)
|
|
780
|
+
rl = RateLimiter(rpm=3, blocking=False)
|
|
781
|
+
cp = CachedProvider(mock, rate_limiter=rl)
|
|
782
|
+
cp.complete("q1")
|
|
783
|
+
cp.complete("q2")
|
|
784
|
+
cp.complete("q3")
|
|
785
|
+
with pytest.raises(RateLimitExceeded):
|
|
786
|
+
cp.complete("q4")
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
790
|
+
# 8 — Version check
|
|
791
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
class TestVersion:
|
|
795
|
+
"""Version should reflect Phase 22."""
|
|
796
|
+
|
|
797
|
+
def test_version_is_0_22_0(self) -> None:
|
|
798
|
+
from semantic_code_intelligence import __version__
|
|
799
|
+
assert __version__ == "0.4.0"
|