amd-gaia 0.15.0__py3-none-any.whl → 0.15.2__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.
- {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.2.dist-info}/METADATA +222 -223
- amd_gaia-0.15.2.dist-info/RECORD +182 -0
- {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.2.dist-info}/WHEEL +1 -1
- {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.2.dist-info}/entry_points.txt +1 -0
- {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.2.dist-info}/licenses/LICENSE.md +20 -20
- gaia/__init__.py +29 -29
- gaia/agents/__init__.py +19 -19
- gaia/agents/base/__init__.py +9 -9
- gaia/agents/base/agent.py +2132 -2177
- gaia/agents/base/api_agent.py +119 -120
- gaia/agents/base/console.py +1967 -1841
- gaia/agents/base/errors.py +237 -237
- gaia/agents/base/mcp_agent.py +86 -86
- gaia/agents/base/tools.py +88 -83
- gaia/agents/blender/__init__.py +7 -0
- gaia/agents/blender/agent.py +553 -556
- gaia/agents/blender/agent_simple.py +133 -135
- gaia/agents/blender/app.py +211 -211
- gaia/agents/blender/app_simple.py +41 -41
- gaia/agents/blender/core/__init__.py +16 -16
- gaia/agents/blender/core/materials.py +506 -506
- gaia/agents/blender/core/objects.py +316 -316
- gaia/agents/blender/core/rendering.py +225 -225
- gaia/agents/blender/core/scene.py +220 -220
- gaia/agents/blender/core/view.py +146 -146
- gaia/agents/chat/__init__.py +9 -9
- gaia/agents/chat/agent.py +809 -835
- gaia/agents/chat/app.py +1065 -1058
- gaia/agents/chat/session.py +508 -508
- gaia/agents/chat/tools/__init__.py +15 -15
- gaia/agents/chat/tools/file_tools.py +96 -96
- gaia/agents/chat/tools/rag_tools.py +1744 -1729
- gaia/agents/chat/tools/shell_tools.py +437 -436
- gaia/agents/code/__init__.py +7 -7
- gaia/agents/code/agent.py +549 -549
- gaia/agents/code/cli.py +377 -0
- gaia/agents/code/models.py +135 -135
- gaia/agents/code/orchestration/__init__.py +24 -24
- gaia/agents/code/orchestration/checklist_executor.py +1763 -1763
- gaia/agents/code/orchestration/checklist_generator.py +713 -713
- gaia/agents/code/orchestration/factories/__init__.py +9 -9
- gaia/agents/code/orchestration/factories/base.py +63 -63
- gaia/agents/code/orchestration/factories/nextjs_factory.py +118 -118
- gaia/agents/code/orchestration/factories/python_factory.py +106 -106
- gaia/agents/code/orchestration/orchestrator.py +841 -841
- gaia/agents/code/orchestration/project_analyzer.py +391 -391
- gaia/agents/code/orchestration/steps/__init__.py +67 -67
- gaia/agents/code/orchestration/steps/base.py +188 -188
- gaia/agents/code/orchestration/steps/error_handler.py +314 -314
- gaia/agents/code/orchestration/steps/nextjs.py +828 -828
- gaia/agents/code/orchestration/steps/python.py +307 -307
- gaia/agents/code/orchestration/template_catalog.py +469 -469
- gaia/agents/code/orchestration/workflows/__init__.py +14 -14
- gaia/agents/code/orchestration/workflows/base.py +80 -80
- gaia/agents/code/orchestration/workflows/nextjs.py +186 -186
- gaia/agents/code/orchestration/workflows/python.py +94 -94
- gaia/agents/code/prompts/__init__.py +11 -11
- gaia/agents/code/prompts/base_prompt.py +77 -77
- gaia/agents/code/prompts/code_patterns.py +2034 -2036
- gaia/agents/code/prompts/nextjs_prompt.py +40 -40
- gaia/agents/code/prompts/python_prompt.py +109 -109
- gaia/agents/code/schema_inference.py +365 -365
- gaia/agents/code/system_prompt.py +41 -41
- gaia/agents/code/tools/__init__.py +42 -42
- gaia/agents/code/tools/cli_tools.py +1138 -1138
- gaia/agents/code/tools/code_formatting.py +319 -319
- gaia/agents/code/tools/code_tools.py +769 -769
- gaia/agents/code/tools/error_fixing.py +1347 -1347
- gaia/agents/code/tools/external_tools.py +180 -180
- gaia/agents/code/tools/file_io.py +845 -845
- gaia/agents/code/tools/prisma_tools.py +190 -190
- gaia/agents/code/tools/project_management.py +1016 -1016
- gaia/agents/code/tools/testing.py +321 -321
- gaia/agents/code/tools/typescript_tools.py +122 -122
- gaia/agents/code/tools/validation_parsing.py +461 -461
- gaia/agents/code/tools/validation_tools.py +806 -806
- gaia/agents/code/tools/web_dev_tools.py +1758 -1758
- gaia/agents/code/validators/__init__.py +16 -16
- gaia/agents/code/validators/antipattern_checker.py +241 -241
- gaia/agents/code/validators/ast_analyzer.py +197 -197
- gaia/agents/code/validators/requirements_validator.py +145 -145
- gaia/agents/code/validators/syntax_validator.py +171 -171
- gaia/agents/docker/__init__.py +7 -7
- gaia/agents/docker/agent.py +643 -642
- gaia/agents/emr/__init__.py +8 -8
- gaia/agents/emr/agent.py +1504 -1506
- gaia/agents/emr/cli.py +1322 -1322
- gaia/agents/emr/constants.py +475 -475
- gaia/agents/emr/dashboard/__init__.py +4 -4
- gaia/agents/emr/dashboard/server.py +1972 -1974
- gaia/agents/jira/__init__.py +11 -11
- gaia/agents/jira/agent.py +894 -894
- gaia/agents/jira/jql_templates.py +299 -299
- gaia/agents/routing/__init__.py +7 -7
- gaia/agents/routing/agent.py +567 -570
- gaia/agents/routing/system_prompt.py +75 -75
- gaia/agents/summarize/__init__.py +11 -0
- gaia/agents/summarize/agent.py +885 -0
- gaia/agents/summarize/prompts.py +129 -0
- gaia/api/__init__.py +23 -23
- gaia/api/agent_registry.py +238 -238
- gaia/api/app.py +305 -305
- gaia/api/openai_server.py +575 -575
- gaia/api/schemas.py +186 -186
- gaia/api/sse_handler.py +373 -373
- gaia/apps/__init__.py +4 -4
- gaia/apps/llm/__init__.py +6 -6
- gaia/apps/llm/app.py +184 -169
- gaia/apps/summarize/app.py +116 -633
- gaia/apps/summarize/html_viewer.py +133 -133
- gaia/apps/summarize/pdf_formatter.py +284 -284
- gaia/audio/__init__.py +2 -2
- gaia/audio/audio_client.py +439 -439
- gaia/audio/audio_recorder.py +269 -269
- gaia/audio/kokoro_tts.py +599 -599
- gaia/audio/whisper_asr.py +432 -432
- gaia/chat/__init__.py +16 -16
- gaia/chat/app.py +428 -430
- gaia/chat/prompts.py +522 -522
- gaia/chat/sdk.py +1228 -1225
- gaia/cli.py +5659 -5632
- gaia/database/__init__.py +10 -10
- gaia/database/agent.py +176 -176
- gaia/database/mixin.py +290 -290
- gaia/database/testing.py +64 -64
- gaia/eval/batch_experiment.py +2332 -2332
- gaia/eval/claude.py +542 -542
- gaia/eval/config.py +37 -37
- gaia/eval/email_generator.py +512 -512
- gaia/eval/eval.py +3179 -3179
- gaia/eval/groundtruth.py +1130 -1130
- gaia/eval/transcript_generator.py +582 -582
- gaia/eval/webapp/README.md +167 -167
- gaia/eval/webapp/package-lock.json +875 -875
- gaia/eval/webapp/package.json +20 -20
- gaia/eval/webapp/public/app.js +3402 -3402
- gaia/eval/webapp/public/index.html +87 -87
- gaia/eval/webapp/public/styles.css +3661 -3661
- gaia/eval/webapp/server.js +415 -415
- gaia/eval/webapp/test-setup.js +72 -72
- gaia/installer/__init__.py +23 -0
- gaia/installer/init_command.py +1275 -0
- gaia/installer/lemonade_installer.py +619 -0
- gaia/llm/__init__.py +10 -2
- gaia/llm/base_client.py +60 -0
- gaia/llm/exceptions.py +12 -0
- gaia/llm/factory.py +70 -0
- gaia/llm/lemonade_client.py +3421 -3221
- gaia/llm/lemonade_manager.py +294 -294
- gaia/llm/providers/__init__.py +9 -0
- gaia/llm/providers/claude.py +108 -0
- gaia/llm/providers/lemonade.py +118 -0
- gaia/llm/providers/openai_provider.py +79 -0
- gaia/llm/vlm_client.py +382 -382
- gaia/logger.py +189 -189
- gaia/mcp/agent_mcp_server.py +245 -245
- gaia/mcp/blender_mcp_client.py +138 -138
- gaia/mcp/blender_mcp_server.py +648 -648
- gaia/mcp/context7_cache.py +332 -332
- gaia/mcp/external_services.py +518 -518
- gaia/mcp/mcp_bridge.py +811 -550
- gaia/mcp/servers/__init__.py +6 -6
- gaia/mcp/servers/docker_mcp.py +83 -83
- gaia/perf_analysis.py +361 -0
- gaia/rag/__init__.py +10 -10
- gaia/rag/app.py +293 -293
- gaia/rag/demo.py +304 -304
- gaia/rag/pdf_utils.py +235 -235
- gaia/rag/sdk.py +2194 -2194
- gaia/security.py +183 -163
- gaia/talk/app.py +287 -289
- gaia/talk/sdk.py +538 -538
- gaia/testing/__init__.py +87 -87
- gaia/testing/assertions.py +330 -330
- gaia/testing/fixtures.py +333 -333
- gaia/testing/mocks.py +493 -493
- gaia/util.py +46 -46
- gaia/utils/__init__.py +33 -33
- gaia/utils/file_watcher.py +675 -675
- gaia/utils/parsing.py +223 -223
- gaia/version.py +100 -100
- amd_gaia-0.15.0.dist-info/RECORD +0 -168
- gaia/agents/code/app.py +0 -266
- gaia/llm/llm_client.py +0 -723
- {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.2.dist-info}/top_level.txt +0 -0
gaia/mcp/context7_cache.py
CHANGED
|
@@ -1,332 +1,332 @@
|
|
|
1
|
-
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
-
# SPDX-License-Identifier: MIT
|
|
3
|
-
"""Persistent cache and rate protection for Context7 API calls."""
|
|
4
|
-
|
|
5
|
-
import hashlib
|
|
6
|
-
import json
|
|
7
|
-
from dataclasses import asdict, dataclass
|
|
8
|
-
from datetime import datetime, timedelta
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from typing import Dict, Optional
|
|
11
|
-
|
|
12
|
-
from gaia.logger import get_logger
|
|
13
|
-
|
|
14
|
-
logger = get_logger(__name__)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class Context7Cache:
|
|
18
|
-
"""File-based persistent cache for Context7 results.
|
|
19
|
-
|
|
20
|
-
Caches library ID mappings and documentation across sessions to reduce API calls.
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
# TTL values
|
|
24
|
-
TTL_LIBRARY_ID = timedelta(days=7)
|
|
25
|
-
TTL_DOCUMENTATION = timedelta(hours=24)
|
|
26
|
-
TTL_FAILED = timedelta(hours=1)
|
|
27
|
-
|
|
28
|
-
def __init__(self, cache_dir: Optional[Path] = None):
|
|
29
|
-
"""Initialize Context7 cache.
|
|
30
|
-
|
|
31
|
-
Args:
|
|
32
|
-
cache_dir: Optional custom cache directory (defaults to ~/.gaia/cache/context7)
|
|
33
|
-
"""
|
|
34
|
-
self.cache_dir = cache_dir or Path.home() / ".gaia" / "cache" / "context7"
|
|
35
|
-
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
-
self.docs_dir = self.cache_dir / "documentation"
|
|
37
|
-
self.docs_dir.mkdir(exist_ok=True)
|
|
38
|
-
|
|
39
|
-
self.library_ids_file = self.cache_dir / "library_ids.json"
|
|
40
|
-
self.rate_state_file = self.cache_dir / "rate_state.json"
|
|
41
|
-
|
|
42
|
-
def get_library_id(self, library_name: str) -> Optional[str]:
|
|
43
|
-
"""Get cached library ID if valid.
|
|
44
|
-
|
|
45
|
-
Args:
|
|
46
|
-
library_name: Library name to lookup (e.g., "nextjs")
|
|
47
|
-
|
|
48
|
-
Returns:
|
|
49
|
-
Cached library ID or None if not found/expired
|
|
50
|
-
"""
|
|
51
|
-
cache = self._load_json(self.library_ids_file)
|
|
52
|
-
key = library_name.lower()
|
|
53
|
-
|
|
54
|
-
if key in cache:
|
|
55
|
-
entry = cache[key]
|
|
56
|
-
if self._is_valid(entry, self.TTL_LIBRARY_ID):
|
|
57
|
-
return entry["value"]
|
|
58
|
-
|
|
59
|
-
return None
|
|
60
|
-
|
|
61
|
-
def set_library_id(self, library_name: str, library_id: Optional[str]):
|
|
62
|
-
"""Cache library ID resolution.
|
|
63
|
-
|
|
64
|
-
Args:
|
|
65
|
-
library_name: Library name (e.g., "nextjs")
|
|
66
|
-
library_id: Resolved Context7 ID (e.g., "/vercel/next.js") or None if failed
|
|
67
|
-
"""
|
|
68
|
-
cache = self._load_json(self.library_ids_file)
|
|
69
|
-
cache[library_name.lower()] = {
|
|
70
|
-
"value": library_id,
|
|
71
|
-
"timestamp": datetime.now().isoformat(),
|
|
72
|
-
}
|
|
73
|
-
self._save_json(self.library_ids_file, cache)
|
|
74
|
-
|
|
75
|
-
def get_documentation(self, library: str, query: str) -> Optional[str]:
|
|
76
|
-
"""Get cached documentation if valid.
|
|
77
|
-
|
|
78
|
-
Args:
|
|
79
|
-
library: Library identifier
|
|
80
|
-
query: Documentation query
|
|
81
|
-
|
|
82
|
-
Returns:
|
|
83
|
-
Cached documentation content or None if not found/expired
|
|
84
|
-
"""
|
|
85
|
-
cache_file = self._doc_cache_file(library, query)
|
|
86
|
-
if cache_file.exists():
|
|
87
|
-
entry = self._load_json(cache_file)
|
|
88
|
-
if self._is_valid(entry, self.TTL_DOCUMENTATION):
|
|
89
|
-
return entry.get("content")
|
|
90
|
-
|
|
91
|
-
return None
|
|
92
|
-
|
|
93
|
-
def set_documentation(self, library: str, query: str, content: str):
|
|
94
|
-
"""Cache documentation result.
|
|
95
|
-
|
|
96
|
-
Args:
|
|
97
|
-
library: Library identifier
|
|
98
|
-
query: Documentation query
|
|
99
|
-
content: Documentation content to cache
|
|
100
|
-
"""
|
|
101
|
-
cache_file = self._doc_cache_file(library, query)
|
|
102
|
-
self._save_json(
|
|
103
|
-
cache_file, {"content": content, "timestamp": datetime.now().isoformat()}
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
def _doc_cache_file(self, library: str, query: str) -> Path:
|
|
107
|
-
"""Generate cache filename for documentation.
|
|
108
|
-
|
|
109
|
-
Args:
|
|
110
|
-
library: Library identifier
|
|
111
|
-
query: Documentation query
|
|
112
|
-
|
|
113
|
-
Returns:
|
|
114
|
-
Path to cache file
|
|
115
|
-
"""
|
|
116
|
-
key = f"{library}:{query}"
|
|
117
|
-
hash_key = hashlib.md5(key.encode()).hexdigest()[:12]
|
|
118
|
-
safe_lib = library.replace("/", "_").replace(".", "_")
|
|
119
|
-
return self.docs_dir / f"{safe_lib}_{hash_key}.json"
|
|
120
|
-
|
|
121
|
-
def _is_valid(self, entry: Dict, ttl: timedelta) -> bool:
|
|
122
|
-
"""Check if cache entry is still valid.
|
|
123
|
-
|
|
124
|
-
Args:
|
|
125
|
-
entry: Cache entry dict
|
|
126
|
-
ttl: Time-to-live duration
|
|
127
|
-
|
|
128
|
-
Returns:
|
|
129
|
-
True if entry is valid and not expired
|
|
130
|
-
"""
|
|
131
|
-
if not entry or "timestamp" not in entry:
|
|
132
|
-
return False
|
|
133
|
-
|
|
134
|
-
try:
|
|
135
|
-
timestamp = datetime.fromisoformat(entry["timestamp"])
|
|
136
|
-
return datetime.now() - timestamp < ttl
|
|
137
|
-
except (ValueError, TypeError):
|
|
138
|
-
return False
|
|
139
|
-
|
|
140
|
-
def _load_json(self, path: Path) -> Dict:
|
|
141
|
-
"""Load JSON file or return empty dict.
|
|
142
|
-
|
|
143
|
-
Args:
|
|
144
|
-
path: Path to JSON file
|
|
145
|
-
|
|
146
|
-
Returns:
|
|
147
|
-
Loaded data or empty dict
|
|
148
|
-
"""
|
|
149
|
-
if path.exists():
|
|
150
|
-
try:
|
|
151
|
-
return json.loads(path.read_text(encoding="utf-8"))
|
|
152
|
-
except (json.JSONDecodeError, OSError):
|
|
153
|
-
logger.warning(f"Failed to load cache file: {path}")
|
|
154
|
-
return {}
|
|
155
|
-
return {}
|
|
156
|
-
|
|
157
|
-
def _save_json(self, path: Path, data: Dict):
|
|
158
|
-
"""Save data to JSON file.
|
|
159
|
-
|
|
160
|
-
Args:
|
|
161
|
-
path: Path to JSON file
|
|
162
|
-
data: Data to save
|
|
163
|
-
"""
|
|
164
|
-
try:
|
|
165
|
-
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
166
|
-
except OSError as e:
|
|
167
|
-
logger.error(f"Failed to save cache file: {path}: {e}")
|
|
168
|
-
|
|
169
|
-
def clear(self):
|
|
170
|
-
"""Clear all cached data."""
|
|
171
|
-
if self.library_ids_file.exists():
|
|
172
|
-
self.library_ids_file.unlink()
|
|
173
|
-
|
|
174
|
-
for f in self.docs_dir.glob("*.json"):
|
|
175
|
-
f.unlink()
|
|
176
|
-
|
|
177
|
-
logger.info("Context7 cache cleared")
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
@dataclass
|
|
181
|
-
class RateState:
|
|
182
|
-
"""Rate limiter state persisted to disk."""
|
|
183
|
-
|
|
184
|
-
tokens: float = 30.0 # Available tokens
|
|
185
|
-
last_update: str = "" # ISO timestamp
|
|
186
|
-
consecutive_failures: int = 0
|
|
187
|
-
circuit_open_until: str = "" # ISO timestamp if open
|
|
188
|
-
|
|
189
|
-
# Constants
|
|
190
|
-
MAX_TOKENS: float = 30.0
|
|
191
|
-
REFILL_RATE: float = 0.5 # tokens per minute (30/hour)
|
|
192
|
-
CIRCUIT_OPEN_DURATION: int = 300 # 5 minutes
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
class Context7RateLimiter:
|
|
196
|
-
"""Token bucket rate limiter with circuit breaker.
|
|
197
|
-
|
|
198
|
-
Protects against Context7 rate limiting by:
|
|
199
|
-
- Limiting requests to 30/hour (0.5/minute refill)
|
|
200
|
-
- Opening circuit breaker after 5 consecutive failures
|
|
201
|
-
- Reopening circuit after 5 minute cooldown
|
|
202
|
-
"""
|
|
203
|
-
|
|
204
|
-
def __init__(self, state_file: Optional[Path] = None):
|
|
205
|
-
"""Initialize rate limiter.
|
|
206
|
-
|
|
207
|
-
Args:
|
|
208
|
-
state_file: Optional custom state file path
|
|
209
|
-
"""
|
|
210
|
-
self.state_file = state_file or (
|
|
211
|
-
Path.home() / ".gaia" / "cache" / "context7" / "rate_state.json"
|
|
212
|
-
)
|
|
213
|
-
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
214
|
-
self.state = self._load_state()
|
|
215
|
-
|
|
216
|
-
def can_make_request(self) -> tuple[bool, str]:
|
|
217
|
-
"""Check if we can make a request.
|
|
218
|
-
|
|
219
|
-
Returns:
|
|
220
|
-
Tuple of (can_proceed, reason)
|
|
221
|
-
"""
|
|
222
|
-
self._refill_tokens()
|
|
223
|
-
|
|
224
|
-
# Check circuit breaker
|
|
225
|
-
if self.state.circuit_open_until:
|
|
226
|
-
try:
|
|
227
|
-
open_until = datetime.fromisoformat(self.state.circuit_open_until)
|
|
228
|
-
if datetime.now() < open_until:
|
|
229
|
-
remaining = (open_until - datetime.now()).seconds
|
|
230
|
-
return False, f"Circuit breaker open. Retry in {remaining}s"
|
|
231
|
-
else:
|
|
232
|
-
# Circuit recovered
|
|
233
|
-
self.state.circuit_open_until = ""
|
|
234
|
-
self.state.consecutive_failures = 0
|
|
235
|
-
logger.info("Circuit breaker closed - recovered from failures")
|
|
236
|
-
except (ValueError, TypeError):
|
|
237
|
-
# Invalid timestamp, clear it
|
|
238
|
-
self.state.circuit_open_until = ""
|
|
239
|
-
|
|
240
|
-
# Check tokens
|
|
241
|
-
if self.state.tokens < 1.0:
|
|
242
|
-
return False, "Rate limit reached. Try again in a few minutes."
|
|
243
|
-
|
|
244
|
-
return True, "OK"
|
|
245
|
-
|
|
246
|
-
def consume_token(self):
|
|
247
|
-
"""Consume a token for a request."""
|
|
248
|
-
self._refill_tokens()
|
|
249
|
-
self.state.tokens = max(0, self.state.tokens - 1)
|
|
250
|
-
self._save_state()
|
|
251
|
-
|
|
252
|
-
def record_success(self):
|
|
253
|
-
"""Record successful request."""
|
|
254
|
-
self.state.consecutive_failures = 0
|
|
255
|
-
self._save_state()
|
|
256
|
-
|
|
257
|
-
def record_failure(self, is_rate_limit: bool = False):
|
|
258
|
-
"""Record failed request.
|
|
259
|
-
|
|
260
|
-
Args:
|
|
261
|
-
is_rate_limit: True if failure was due to rate limiting (HTTP 429)
|
|
262
|
-
"""
|
|
263
|
-
self.state.consecutive_failures += 1
|
|
264
|
-
|
|
265
|
-
# Open circuit on rate limit or too many failures
|
|
266
|
-
if is_rate_limit or self.state.consecutive_failures >= 5:
|
|
267
|
-
open_until = datetime.now() + timedelta(
|
|
268
|
-
seconds=self.state.CIRCUIT_OPEN_DURATION
|
|
269
|
-
)
|
|
270
|
-
self.state.circuit_open_until = open_until.isoformat()
|
|
271
|
-
logger.warning(
|
|
272
|
-
f"Circuit breaker opened until {open_until} "
|
|
273
|
-
f"(failures: {self.state.consecutive_failures})"
|
|
274
|
-
)
|
|
275
|
-
|
|
276
|
-
self._save_state()
|
|
277
|
-
|
|
278
|
-
def _refill_tokens(self):
|
|
279
|
-
"""Refill tokens based on time elapsed."""
|
|
280
|
-
now = datetime.now()
|
|
281
|
-
|
|
282
|
-
if self.state.last_update:
|
|
283
|
-
try:
|
|
284
|
-
last = datetime.fromisoformat(self.state.last_update)
|
|
285
|
-
elapsed_minutes = (now - last).total_seconds() / 60
|
|
286
|
-
refill = elapsed_minutes * self.state.REFILL_RATE
|
|
287
|
-
self.state.tokens = min(
|
|
288
|
-
self.state.MAX_TOKENS, self.state.tokens + refill
|
|
289
|
-
)
|
|
290
|
-
except (ValueError, TypeError):
|
|
291
|
-
# Invalid timestamp, reset
|
|
292
|
-
self.state.tokens = self.state.MAX_TOKENS
|
|
293
|
-
|
|
294
|
-
self.state.last_update = now.isoformat()
|
|
295
|
-
|
|
296
|
-
def _load_state(self) -> RateState:
|
|
297
|
-
"""Load state from disk.
|
|
298
|
-
|
|
299
|
-
Returns:
|
|
300
|
-
Loaded rate state or new state if file doesn't exist
|
|
301
|
-
"""
|
|
302
|
-
if self.state_file.exists():
|
|
303
|
-
try:
|
|
304
|
-
data = json.loads(self.state_file.read_text(encoding="utf-8"))
|
|
305
|
-
return RateState(**data)
|
|
306
|
-
except (json.JSONDecodeError, TypeError, OSError) as e:
|
|
307
|
-
logger.warning(f"Failed to load rate state: {e}, creating new state")
|
|
308
|
-
|
|
309
|
-
return RateState(last_update=datetime.now().isoformat())
|
|
310
|
-
|
|
311
|
-
def _save_state(self):
|
|
312
|
-
"""Save state to disk."""
|
|
313
|
-
try:
|
|
314
|
-
self.state_file.write_text(
|
|
315
|
-
json.dumps(asdict(self.state), indent=2), encoding="utf-8"
|
|
316
|
-
)
|
|
317
|
-
except OSError as e:
|
|
318
|
-
logger.error(f"Failed to save rate state: {e}")
|
|
319
|
-
|
|
320
|
-
def get_status(self) -> dict:
|
|
321
|
-
"""Get current rate limiter status.
|
|
322
|
-
|
|
323
|
-
Returns:
|
|
324
|
-
Dict with status information
|
|
325
|
-
"""
|
|
326
|
-
self._refill_tokens()
|
|
327
|
-
return {
|
|
328
|
-
"tokens_available": round(self.state.tokens, 1),
|
|
329
|
-
"max_tokens": self.state.MAX_TOKENS,
|
|
330
|
-
"circuit_open": bool(self.state.circuit_open_until),
|
|
331
|
-
"consecutive_failures": self.state.consecutive_failures,
|
|
332
|
-
}
|
|
1
|
+
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""Persistent cache and rate protection for Context7 API calls."""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Optional
|
|
11
|
+
|
|
12
|
+
from gaia.logger import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Context7Cache:
|
|
18
|
+
"""File-based persistent cache for Context7 results.
|
|
19
|
+
|
|
20
|
+
Caches library ID mappings and documentation across sessions to reduce API calls.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# TTL values
|
|
24
|
+
TTL_LIBRARY_ID = timedelta(days=7)
|
|
25
|
+
TTL_DOCUMENTATION = timedelta(hours=24)
|
|
26
|
+
TTL_FAILED = timedelta(hours=1)
|
|
27
|
+
|
|
28
|
+
def __init__(self, cache_dir: Optional[Path] = None):
|
|
29
|
+
"""Initialize Context7 cache.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
cache_dir: Optional custom cache directory (defaults to ~/.gaia/cache/context7)
|
|
33
|
+
"""
|
|
34
|
+
self.cache_dir = cache_dir or Path.home() / ".gaia" / "cache" / "context7"
|
|
35
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
self.docs_dir = self.cache_dir / "documentation"
|
|
37
|
+
self.docs_dir.mkdir(exist_ok=True)
|
|
38
|
+
|
|
39
|
+
self.library_ids_file = self.cache_dir / "library_ids.json"
|
|
40
|
+
self.rate_state_file = self.cache_dir / "rate_state.json"
|
|
41
|
+
|
|
42
|
+
def get_library_id(self, library_name: str) -> Optional[str]:
|
|
43
|
+
"""Get cached library ID if valid.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
library_name: Library name to lookup (e.g., "nextjs")
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Cached library ID or None if not found/expired
|
|
50
|
+
"""
|
|
51
|
+
cache = self._load_json(self.library_ids_file)
|
|
52
|
+
key = library_name.lower()
|
|
53
|
+
|
|
54
|
+
if key in cache:
|
|
55
|
+
entry = cache[key]
|
|
56
|
+
if self._is_valid(entry, self.TTL_LIBRARY_ID):
|
|
57
|
+
return entry["value"]
|
|
58
|
+
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def set_library_id(self, library_name: str, library_id: Optional[str]):
|
|
62
|
+
"""Cache library ID resolution.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
library_name: Library name (e.g., "nextjs")
|
|
66
|
+
library_id: Resolved Context7 ID (e.g., "/vercel/next.js") or None if failed
|
|
67
|
+
"""
|
|
68
|
+
cache = self._load_json(self.library_ids_file)
|
|
69
|
+
cache[library_name.lower()] = {
|
|
70
|
+
"value": library_id,
|
|
71
|
+
"timestamp": datetime.now().isoformat(),
|
|
72
|
+
}
|
|
73
|
+
self._save_json(self.library_ids_file, cache)
|
|
74
|
+
|
|
75
|
+
def get_documentation(self, library: str, query: str) -> Optional[str]:
|
|
76
|
+
"""Get cached documentation if valid.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
library: Library identifier
|
|
80
|
+
query: Documentation query
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Cached documentation content or None if not found/expired
|
|
84
|
+
"""
|
|
85
|
+
cache_file = self._doc_cache_file(library, query)
|
|
86
|
+
if cache_file.exists():
|
|
87
|
+
entry = self._load_json(cache_file)
|
|
88
|
+
if self._is_valid(entry, self.TTL_DOCUMENTATION):
|
|
89
|
+
return entry.get("content")
|
|
90
|
+
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def set_documentation(self, library: str, query: str, content: str):
|
|
94
|
+
"""Cache documentation result.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
library: Library identifier
|
|
98
|
+
query: Documentation query
|
|
99
|
+
content: Documentation content to cache
|
|
100
|
+
"""
|
|
101
|
+
cache_file = self._doc_cache_file(library, query)
|
|
102
|
+
self._save_json(
|
|
103
|
+
cache_file, {"content": content, "timestamp": datetime.now().isoformat()}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _doc_cache_file(self, library: str, query: str) -> Path:
|
|
107
|
+
"""Generate cache filename for documentation.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
library: Library identifier
|
|
111
|
+
query: Documentation query
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Path to cache file
|
|
115
|
+
"""
|
|
116
|
+
key = f"{library}:{query}"
|
|
117
|
+
hash_key = hashlib.md5(key.encode()).hexdigest()[:12]
|
|
118
|
+
safe_lib = library.replace("/", "_").replace(".", "_")
|
|
119
|
+
return self.docs_dir / f"{safe_lib}_{hash_key}.json"
|
|
120
|
+
|
|
121
|
+
def _is_valid(self, entry: Dict, ttl: timedelta) -> bool:
|
|
122
|
+
"""Check if cache entry is still valid.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
entry: Cache entry dict
|
|
126
|
+
ttl: Time-to-live duration
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
True if entry is valid and not expired
|
|
130
|
+
"""
|
|
131
|
+
if not entry or "timestamp" not in entry:
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
timestamp = datetime.fromisoformat(entry["timestamp"])
|
|
136
|
+
return datetime.now() - timestamp < ttl
|
|
137
|
+
except (ValueError, TypeError):
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
def _load_json(self, path: Path) -> Dict:
|
|
141
|
+
"""Load JSON file or return empty dict.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
path: Path to JSON file
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Loaded data or empty dict
|
|
148
|
+
"""
|
|
149
|
+
if path.exists():
|
|
150
|
+
try:
|
|
151
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
152
|
+
except (json.JSONDecodeError, OSError):
|
|
153
|
+
logger.warning(f"Failed to load cache file: {path}")
|
|
154
|
+
return {}
|
|
155
|
+
return {}
|
|
156
|
+
|
|
157
|
+
def _save_json(self, path: Path, data: Dict):
|
|
158
|
+
"""Save data to JSON file.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
path: Path to JSON file
|
|
162
|
+
data: Data to save
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
166
|
+
except OSError as e:
|
|
167
|
+
logger.error(f"Failed to save cache file: {path}: {e}")
|
|
168
|
+
|
|
169
|
+
def clear(self):
|
|
170
|
+
"""Clear all cached data."""
|
|
171
|
+
if self.library_ids_file.exists():
|
|
172
|
+
self.library_ids_file.unlink()
|
|
173
|
+
|
|
174
|
+
for f in self.docs_dir.glob("*.json"):
|
|
175
|
+
f.unlink()
|
|
176
|
+
|
|
177
|
+
logger.info("Context7 cache cleared")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass
|
|
181
|
+
class RateState:
|
|
182
|
+
"""Rate limiter state persisted to disk."""
|
|
183
|
+
|
|
184
|
+
tokens: float = 30.0 # Available tokens
|
|
185
|
+
last_update: str = "" # ISO timestamp
|
|
186
|
+
consecutive_failures: int = 0
|
|
187
|
+
circuit_open_until: str = "" # ISO timestamp if open
|
|
188
|
+
|
|
189
|
+
# Constants
|
|
190
|
+
MAX_TOKENS: float = 30.0
|
|
191
|
+
REFILL_RATE: float = 0.5 # tokens per minute (30/hour)
|
|
192
|
+
CIRCUIT_OPEN_DURATION: int = 300 # 5 minutes
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class Context7RateLimiter:
|
|
196
|
+
"""Token bucket rate limiter with circuit breaker.
|
|
197
|
+
|
|
198
|
+
Protects against Context7 rate limiting by:
|
|
199
|
+
- Limiting requests to 30/hour (0.5/minute refill)
|
|
200
|
+
- Opening circuit breaker after 5 consecutive failures
|
|
201
|
+
- Reopening circuit after 5 minute cooldown
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def __init__(self, state_file: Optional[Path] = None):
|
|
205
|
+
"""Initialize rate limiter.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
state_file: Optional custom state file path
|
|
209
|
+
"""
|
|
210
|
+
self.state_file = state_file or (
|
|
211
|
+
Path.home() / ".gaia" / "cache" / "context7" / "rate_state.json"
|
|
212
|
+
)
|
|
213
|
+
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
self.state = self._load_state()
|
|
215
|
+
|
|
216
|
+
def can_make_request(self) -> tuple[bool, str]:
|
|
217
|
+
"""Check if we can make a request.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Tuple of (can_proceed, reason)
|
|
221
|
+
"""
|
|
222
|
+
self._refill_tokens()
|
|
223
|
+
|
|
224
|
+
# Check circuit breaker
|
|
225
|
+
if self.state.circuit_open_until:
|
|
226
|
+
try:
|
|
227
|
+
open_until = datetime.fromisoformat(self.state.circuit_open_until)
|
|
228
|
+
if datetime.now() < open_until:
|
|
229
|
+
remaining = (open_until - datetime.now()).seconds
|
|
230
|
+
return False, f"Circuit breaker open. Retry in {remaining}s"
|
|
231
|
+
else:
|
|
232
|
+
# Circuit recovered
|
|
233
|
+
self.state.circuit_open_until = ""
|
|
234
|
+
self.state.consecutive_failures = 0
|
|
235
|
+
logger.info("Circuit breaker closed - recovered from failures")
|
|
236
|
+
except (ValueError, TypeError):
|
|
237
|
+
# Invalid timestamp, clear it
|
|
238
|
+
self.state.circuit_open_until = ""
|
|
239
|
+
|
|
240
|
+
# Check tokens
|
|
241
|
+
if self.state.tokens < 1.0:
|
|
242
|
+
return False, "Rate limit reached. Try again in a few minutes."
|
|
243
|
+
|
|
244
|
+
return True, "OK"
|
|
245
|
+
|
|
246
|
+
def consume_token(self):
|
|
247
|
+
"""Consume a token for a request."""
|
|
248
|
+
self._refill_tokens()
|
|
249
|
+
self.state.tokens = max(0, self.state.tokens - 1)
|
|
250
|
+
self._save_state()
|
|
251
|
+
|
|
252
|
+
def record_success(self):
|
|
253
|
+
"""Record successful request."""
|
|
254
|
+
self.state.consecutive_failures = 0
|
|
255
|
+
self._save_state()
|
|
256
|
+
|
|
257
|
+
def record_failure(self, is_rate_limit: bool = False):
|
|
258
|
+
"""Record failed request.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
is_rate_limit: True if failure was due to rate limiting (HTTP 429)
|
|
262
|
+
"""
|
|
263
|
+
self.state.consecutive_failures += 1
|
|
264
|
+
|
|
265
|
+
# Open circuit on rate limit or too many failures
|
|
266
|
+
if is_rate_limit or self.state.consecutive_failures >= 5:
|
|
267
|
+
open_until = datetime.now() + timedelta(
|
|
268
|
+
seconds=self.state.CIRCUIT_OPEN_DURATION
|
|
269
|
+
)
|
|
270
|
+
self.state.circuit_open_until = open_until.isoformat()
|
|
271
|
+
logger.warning(
|
|
272
|
+
f"Circuit breaker opened until {open_until} "
|
|
273
|
+
f"(failures: {self.state.consecutive_failures})"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
self._save_state()
|
|
277
|
+
|
|
278
|
+
def _refill_tokens(self):
|
|
279
|
+
"""Refill tokens based on time elapsed."""
|
|
280
|
+
now = datetime.now()
|
|
281
|
+
|
|
282
|
+
if self.state.last_update:
|
|
283
|
+
try:
|
|
284
|
+
last = datetime.fromisoformat(self.state.last_update)
|
|
285
|
+
elapsed_minutes = (now - last).total_seconds() / 60
|
|
286
|
+
refill = elapsed_minutes * self.state.REFILL_RATE
|
|
287
|
+
self.state.tokens = min(
|
|
288
|
+
self.state.MAX_TOKENS, self.state.tokens + refill
|
|
289
|
+
)
|
|
290
|
+
except (ValueError, TypeError):
|
|
291
|
+
# Invalid timestamp, reset
|
|
292
|
+
self.state.tokens = self.state.MAX_TOKENS
|
|
293
|
+
|
|
294
|
+
self.state.last_update = now.isoformat()
|
|
295
|
+
|
|
296
|
+
def _load_state(self) -> RateState:
|
|
297
|
+
"""Load state from disk.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
Loaded rate state or new state if file doesn't exist
|
|
301
|
+
"""
|
|
302
|
+
if self.state_file.exists():
|
|
303
|
+
try:
|
|
304
|
+
data = json.loads(self.state_file.read_text(encoding="utf-8"))
|
|
305
|
+
return RateState(**data)
|
|
306
|
+
except (json.JSONDecodeError, TypeError, OSError) as e:
|
|
307
|
+
logger.warning(f"Failed to load rate state: {e}, creating new state")
|
|
308
|
+
|
|
309
|
+
return RateState(last_update=datetime.now().isoformat())
|
|
310
|
+
|
|
311
|
+
def _save_state(self):
|
|
312
|
+
"""Save state to disk."""
|
|
313
|
+
try:
|
|
314
|
+
self.state_file.write_text(
|
|
315
|
+
json.dumps(asdict(self.state), indent=2), encoding="utf-8"
|
|
316
|
+
)
|
|
317
|
+
except OSError as e:
|
|
318
|
+
logger.error(f"Failed to save rate state: {e}")
|
|
319
|
+
|
|
320
|
+
def get_status(self) -> dict:
|
|
321
|
+
"""Get current rate limiter status.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
Dict with status information
|
|
325
|
+
"""
|
|
326
|
+
self._refill_tokens()
|
|
327
|
+
return {
|
|
328
|
+
"tokens_available": round(self.state.tokens, 1),
|
|
329
|
+
"max_tokens": self.state.MAX_TOKENS,
|
|
330
|
+
"circuit_open": bool(self.state.circuit_open_until),
|
|
331
|
+
"consecutive_failures": self.state.consecutive_failures,
|
|
332
|
+
}
|