hdsp-jupyter-extension 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. agent_server/__init__.py +8 -0
  2. agent_server/core/__init__.py +92 -0
  3. agent_server/core/api_key_manager.py +427 -0
  4. agent_server/core/code_validator.py +1238 -0
  5. agent_server/core/context_condenser.py +308 -0
  6. agent_server/core/embedding_service.py +254 -0
  7. agent_server/core/error_classifier.py +577 -0
  8. agent_server/core/llm_client.py +95 -0
  9. agent_server/core/llm_service.py +649 -0
  10. agent_server/core/notebook_generator.py +274 -0
  11. agent_server/core/prompt_builder.py +35 -0
  12. agent_server/core/rag_manager.py +742 -0
  13. agent_server/core/reflection_engine.py +489 -0
  14. agent_server/core/retriever.py +248 -0
  15. agent_server/core/state_verifier.py +452 -0
  16. agent_server/core/summary_generator.py +484 -0
  17. agent_server/core/task_manager.py +198 -0
  18. agent_server/knowledge/__init__.py +9 -0
  19. agent_server/knowledge/watchdog_service.py +352 -0
  20. agent_server/main.py +160 -0
  21. agent_server/prompts/__init__.py +60 -0
  22. agent_server/prompts/file_action_prompts.py +113 -0
  23. agent_server/routers/__init__.py +9 -0
  24. agent_server/routers/agent.py +591 -0
  25. agent_server/routers/chat.py +188 -0
  26. agent_server/routers/config.py +100 -0
  27. agent_server/routers/file_resolver.py +293 -0
  28. agent_server/routers/health.py +42 -0
  29. agent_server/routers/rag.py +163 -0
  30. agent_server/schemas/__init__.py +60 -0
  31. hdsp_agent_core/__init__.py +158 -0
  32. hdsp_agent_core/factory.py +252 -0
  33. hdsp_agent_core/interfaces.py +203 -0
  34. hdsp_agent_core/knowledge/__init__.py +31 -0
  35. hdsp_agent_core/knowledge/chunking.py +356 -0
  36. hdsp_agent_core/knowledge/libraries/dask.md +188 -0
  37. hdsp_agent_core/knowledge/libraries/matplotlib.md +164 -0
  38. hdsp_agent_core/knowledge/libraries/polars.md +68 -0
  39. hdsp_agent_core/knowledge/loader.py +337 -0
  40. hdsp_agent_core/llm/__init__.py +13 -0
  41. hdsp_agent_core/llm/service.py +556 -0
  42. hdsp_agent_core/managers/__init__.py +22 -0
  43. hdsp_agent_core/managers/config_manager.py +133 -0
  44. hdsp_agent_core/managers/session_manager.py +251 -0
  45. hdsp_agent_core/models/__init__.py +115 -0
  46. hdsp_agent_core/models/agent.py +316 -0
  47. hdsp_agent_core/models/chat.py +41 -0
  48. hdsp_agent_core/models/common.py +95 -0
  49. hdsp_agent_core/models/rag.py +368 -0
  50. hdsp_agent_core/prompts/__init__.py +63 -0
  51. hdsp_agent_core/prompts/auto_agent_prompts.py +1260 -0
  52. hdsp_agent_core/prompts/cell_action_prompts.py +98 -0
  53. hdsp_agent_core/services/__init__.py +18 -0
  54. hdsp_agent_core/services/agent_service.py +438 -0
  55. hdsp_agent_core/services/chat_service.py +205 -0
  56. hdsp_agent_core/services/rag_service.py +262 -0
  57. hdsp_agent_core/tests/__init__.py +1 -0
  58. hdsp_agent_core/tests/conftest.py +102 -0
  59. hdsp_agent_core/tests/test_factory.py +251 -0
  60. hdsp_agent_core/tests/test_services.py +326 -0
  61. hdsp_jupyter_extension-2.0.0.data/data/etc/jupyter/jupyter_server_config.d/hdsp_jupyter_extension.json +7 -0
  62. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/build_log.json +738 -0
  63. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/install.json +5 -0
  64. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/package.json +134 -0
  65. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.2607ff74c74acfa83158.js +4369 -0
  66. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.2607ff74c74acfa83158.js.map +1 -0
  67. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.622c1a5918b3aafb2315.js +12496 -0
  68. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.622c1a5918b3aafb2315.js.map +1 -0
  69. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b80.c095373419d05e6f141a.js +94 -0
  70. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b80.c095373419d05e6f141a.js.map +1 -0
  71. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b81.61e75fb98ecff46cf836.js +94 -0
  72. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b81.61e75fb98ecff46cf836.js.map +1 -0
  73. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.dae97cde171e13b8c834.js +623 -0
  74. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.dae97cde171e13b8c834.js.map +1 -0
  75. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/style.js +4 -0
  76. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_babel_runtime_helpers_esm_extends_js-node_modules_emotion_serialize_dist-051195.e2553aab0c3963b83dd7.js +507 -0
  77. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_babel_runtime_helpers_esm_extends_js-node_modules_emotion_serialize_dist-051195.e2553aab0c3963b83dd7.js.map +1 -0
  78. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js-node_modules-782ee5.d9ed8645ef1d311657d8.js +2071 -0
  79. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js-node_modules-782ee5.d9ed8645ef1d311657d8.js.map +1 -0
  80. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js +1059 -0
  81. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js.map +1 -0
  82. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js +376 -0
  83. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js.map +1 -0
  84. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js +60336 -0
  85. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js.map +1 -0
  86. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js +7132 -0
  87. hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js.map +1 -0
  88. hdsp_jupyter_extension-2.0.0.dist-info/METADATA +152 -0
  89. hdsp_jupyter_extension-2.0.0.dist-info/RECORD +121 -0
  90. hdsp_jupyter_extension-2.0.0.dist-info/WHEEL +4 -0
  91. hdsp_jupyter_extension-2.0.0.dist-info/licenses/LICENSE +21 -0
  92. jupyter_ext/__init__.py +233 -0
  93. jupyter_ext/_version.py +4 -0
  94. jupyter_ext/config.py +111 -0
  95. jupyter_ext/etc/jupyter/jupyter_server_config.d/hdsp_jupyter_extension.json +7 -0
  96. jupyter_ext/handlers.py +632 -0
  97. jupyter_ext/labextension/build_log.json +738 -0
  98. jupyter_ext/labextension/package.json +134 -0
  99. jupyter_ext/labextension/static/frontend_styles_index_js.2607ff74c74acfa83158.js +4369 -0
  100. jupyter_ext/labextension/static/frontend_styles_index_js.2607ff74c74acfa83158.js.map +1 -0
  101. jupyter_ext/labextension/static/lib_index_js.622c1a5918b3aafb2315.js +12496 -0
  102. jupyter_ext/labextension/static/lib_index_js.622c1a5918b3aafb2315.js.map +1 -0
  103. jupyter_ext/labextension/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b80.c095373419d05e6f141a.js +94 -0
  104. jupyter_ext/labextension/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b80.c095373419d05e6f141a.js.map +1 -0
  105. jupyter_ext/labextension/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b81.61e75fb98ecff46cf836.js +94 -0
  106. jupyter_ext/labextension/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b81.61e75fb98ecff46cf836.js.map +1 -0
  107. jupyter_ext/labextension/static/remoteEntry.dae97cde171e13b8c834.js +623 -0
  108. jupyter_ext/labextension/static/remoteEntry.dae97cde171e13b8c834.js.map +1 -0
  109. jupyter_ext/labextension/static/style.js +4 -0
  110. jupyter_ext/labextension/static/vendors-node_modules_babel_runtime_helpers_esm_extends_js-node_modules_emotion_serialize_dist-051195.e2553aab0c3963b83dd7.js +507 -0
  111. jupyter_ext/labextension/static/vendors-node_modules_babel_runtime_helpers_esm_extends_js-node_modules_emotion_serialize_dist-051195.e2553aab0c3963b83dd7.js.map +1 -0
  112. jupyter_ext/labextension/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js-node_modules-782ee5.d9ed8645ef1d311657d8.js +2071 -0
  113. jupyter_ext/labextension/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js-node_modules-782ee5.d9ed8645ef1d311657d8.js.map +1 -0
  114. jupyter_ext/labextension/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js +1059 -0
  115. jupyter_ext/labextension/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js.map +1 -0
  116. jupyter_ext/labextension/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js +376 -0
  117. jupyter_ext/labextension/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js.map +1 -0
  118. jupyter_ext/labextension/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js +60336 -0
  119. jupyter_ext/labextension/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js.map +1 -0
  120. jupyter_ext/labextension/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js +7132 -0
  121. jupyter_ext/labextension/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js.map +1 -0
@@ -0,0 +1,8 @@
1
+ """
2
+ HDSP Agent Server
3
+
4
+ AI Agent Server for IDE integrations - provides intelligent code assistance
5
+ for JupyterLab, VS Code, PyCharm, and other development environments.
6
+ """
7
+
8
+ __version__ = "1.0.0"
@@ -0,0 +1,92 @@
1
+ """
2
+ Core services for HDSP Agent Server
3
+ """
4
+
5
+ from hdsp_agent_core.managers.config_manager import ConfigManager
6
+ from hdsp_agent_core.managers.session_manager import (
7
+ ChatMessage,
8
+ Session,
9
+ SessionManager,
10
+ get_session_manager,
11
+ )
12
+
13
+ from .api_key_manager import GeminiKeyManager, KeyStatus, get_key_manager
14
+ from .code_validator import (
15
+ CodeValidator,
16
+ ValidationIssue,
17
+ ValidationResult,
18
+ get_api_pattern_checker,
19
+ )
20
+ from .context_condenser import (
21
+ CompressionStats,
22
+ CompressionStrategy,
23
+ ContextCondenser,
24
+ get_context_condenser,
25
+ )
26
+ from .error_classifier import (
27
+ ErrorAnalysis,
28
+ ErrorClassifier,
29
+ ReplanDecision,
30
+ get_error_classifier,
31
+ )
32
+ from .llm_client import LLMClient
33
+ from .llm_service import LLMService
34
+ from .prompt_builder import PromptBuilder
35
+ from .reflection_engine import ReflectionEngine, ReflectionResult
36
+ from .state_verifier import (
37
+ CONFIDENCE_THRESHOLDS,
38
+ ConfidenceScore,
39
+ MismatchType,
40
+ Recommendation,
41
+ Severity,
42
+ StateMismatch,
43
+ StateVerificationResult,
44
+ StateVerifier,
45
+ get_state_verifier,
46
+ )
47
+ from .summary_generator import SummaryGenerator, TaskType, get_summary_generator
48
+
49
+ __all__ = [
50
+ "ConfigManager",
51
+ "LLMClient",
52
+ "LLMService",
53
+ "PromptBuilder",
54
+ "CodeValidator",
55
+ "ValidationResult",
56
+ "ValidationIssue",
57
+ "ReflectionEngine",
58
+ "ReflectionResult",
59
+ # 신규 추가 (LLM 호출 대체)
60
+ "ErrorClassifier",
61
+ "get_error_classifier",
62
+ "ReplanDecision",
63
+ "ErrorAnalysis",
64
+ "SummaryGenerator",
65
+ "get_summary_generator",
66
+ "TaskType",
67
+ "get_api_pattern_checker",
68
+ # API Key Manager (Multi-key rotation)
69
+ "GeminiKeyManager",
70
+ "get_key_manager",
71
+ "KeyStatus",
72
+ # State Verifier (Phase 1: 상태 검증 레이어)
73
+ "StateVerifier",
74
+ "get_state_verifier",
75
+ "StateVerificationResult",
76
+ "StateMismatch",
77
+ "ConfidenceScore",
78
+ "MismatchType",
79
+ "Severity",
80
+ "Recommendation",
81
+ "CONFIDENCE_THRESHOLDS",
82
+ # Session Manager (Persistence Layer)
83
+ "SessionManager",
84
+ "get_session_manager",
85
+ "Session",
86
+ "ChatMessage",
87
+ # Context Condenser (Token Optimization)
88
+ "ContextCondenser",
89
+ "get_context_condenser",
90
+ "CompressionStrategy",
91
+ "CompressionStats",
92
+ ]
@@ -0,0 +1,427 @@
1
+ """
2
+ API Key Manager - Handles multi-key rotation with intelligent cooldown tracking for Gemini API
3
+
4
+ Features:
5
+ - Support up to 10 API keys
6
+ - Automatic key rotation on rate limits (429)
7
+ - Smart cooldown parsing from retry-after headers
8
+ - Auto re-enable after cooldown expires
9
+ - Persistent state across server restarts
10
+ """
11
+
12
+ import asyncio
13
+ import re
14
+ import uuid
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timedelta
17
+ from enum import Enum
18
+ from typing import Any, Dict, List, Optional, Tuple
19
+
20
+
21
+ class KeyStatus(Enum):
22
+ """Status of an API key"""
23
+
24
+ ACTIVE = "active"
25
+ COOLDOWN = "cooldown"
26
+ DISABLED = "disabled"
27
+
28
+
29
+ @dataclass
30
+ class KeyState:
31
+ """Runtime state for an API key"""
32
+
33
+ key: str
34
+ id: str
35
+ enabled: bool = True
36
+ cooldown_until: Optional[datetime] = None
37
+ last_used: Optional[datetime] = None
38
+ failure_count: int = 0
39
+ added_at: datetime = field(default_factory=datetime.utcnow)
40
+
41
+ @property
42
+ def status(self) -> KeyStatus:
43
+ """Get current status of the key"""
44
+ if not self.enabled:
45
+ return KeyStatus.DISABLED
46
+ if self.cooldown_until and datetime.utcnow() < self.cooldown_until:
47
+ return KeyStatus.COOLDOWN
48
+ return KeyStatus.ACTIVE
49
+
50
+ @property
51
+ def cooldown_remaining_seconds(self) -> int:
52
+ """Get remaining cooldown time in seconds"""
53
+ if not self.cooldown_until:
54
+ return 0
55
+ remaining = (self.cooldown_until - datetime.utcnow()).total_seconds()
56
+ return max(0, int(remaining))
57
+
58
+ def to_dict(self) -> Dict[str, Any]:
59
+ """Serialize to dictionary for config persistence"""
60
+ return {
61
+ "key": self.key,
62
+ "id": self.id,
63
+ "enabled": self.enabled,
64
+ "cooldownUntil": self.cooldown_until.isoformat()
65
+ if self.cooldown_until
66
+ else None,
67
+ "lastUsed": self.last_used.isoformat() if self.last_used else None,
68
+ "failureCount": self.failure_count,
69
+ "addedAt": self.added_at.isoformat(),
70
+ }
71
+
72
+ @classmethod
73
+ def from_dict(cls, data: Dict[str, Any]) -> "KeyState":
74
+ """Deserialize from dictionary"""
75
+ return cls(
76
+ key=data["key"],
77
+ id=data.get("id", f"key_{uuid.uuid4().hex[:8]}"),
78
+ enabled=data.get("enabled", True),
79
+ cooldown_until=datetime.fromisoformat(data["cooldownUntil"])
80
+ if data.get("cooldownUntil")
81
+ else None,
82
+ last_used=datetime.fromisoformat(data["lastUsed"])
83
+ if data.get("lastUsed")
84
+ else None,
85
+ failure_count=data.get("failureCount", 0),
86
+ added_at=datetime.fromisoformat(data["addedAt"])
87
+ if data.get("addedAt")
88
+ else datetime.utcnow(),
89
+ )
90
+
91
+
92
+ class GeminiKeyManager:
93
+ """
94
+ Manages multiple Gemini API keys with intelligent rotation and cooldown tracking.
95
+
96
+ Features:
97
+ - Round-robin rotation on rate limits
98
+ - Automatic cooldown parsing from retry-after headers
99
+ - Auto re-enable after cooldown expires
100
+ - Persistence to config file
101
+ """
102
+
103
+ MAX_KEYS = 10
104
+ DEFAULT_COOLDOWN_SECONDS = 60
105
+
106
+ _instance: Optional["GeminiKeyManager"] = None
107
+ _lock: asyncio.Lock = asyncio.Lock()
108
+
109
+ def __init__(self, config_manager):
110
+ self._config_manager = config_manager
111
+ self._keys: List[KeyState] = []
112
+ self._current_index: int = 0
113
+ self._load_keys()
114
+
115
+ @classmethod
116
+ def get_instance(cls, config_manager) -> "GeminiKeyManager":
117
+ """Get singleton instance"""
118
+ if cls._instance is None:
119
+ cls._instance = cls(config_manager)
120
+ return cls._instance
121
+
122
+ @classmethod
123
+ def reset_instance(cls):
124
+ """Reset singleton (for testing or config reload)"""
125
+ cls._instance = None
126
+
127
+ def _load_keys(self):
128
+ """Load keys from config, handling migration from single-key format"""
129
+ config = self._config_manager.get_config()
130
+ gemini_config = config.get("gemini", {})
131
+
132
+ # Check for new multi-key format
133
+ if "keys" in gemini_config and isinstance(gemini_config["keys"], list):
134
+ self._keys = [KeyState.from_dict(k) for k in gemini_config["keys"]]
135
+ self._current_index = gemini_config.get("activeKeyIndex", 0)
136
+ # Ensure index is valid
137
+ if self._current_index >= len(self._keys):
138
+ self._current_index = 0
139
+ # Migration from single-key format
140
+ elif "apiKey" in gemini_config and gemini_config["apiKey"]:
141
+ single_key = gemini_config["apiKey"]
142
+ # Don't migrate masked keys
143
+ if not single_key.startswith("****"):
144
+ self._keys = [
145
+ KeyState(key=single_key, id=f"key_{uuid.uuid4().hex[:8]}")
146
+ ]
147
+ self._current_index = 0
148
+ # Migrate to new format
149
+ self._save_keys()
150
+ else:
151
+ self._keys = []
152
+ self._current_index = 0
153
+ else:
154
+ self._keys = []
155
+ self._current_index = 0
156
+
157
+ def _save_keys(self):
158
+ """Persist keys to config file"""
159
+ config = self._config_manager.get_config()
160
+
161
+ # Preserve model setting
162
+ model = config.get("gemini", {}).get("model", "gemini-2.5-pro")
163
+
164
+ config["gemini"] = {
165
+ "model": model,
166
+ "keys": [k.to_dict() for k in self._keys],
167
+ "activeKeyIndex": self._current_index,
168
+ "rotationStrategy": "round-robin",
169
+ }
170
+
171
+ # Also set legacy apiKey for backward compatibility
172
+ if self._keys:
173
+ active_key = self._get_active_key_state()
174
+ if active_key:
175
+ config["gemini"]["apiKey"] = active_key.key
176
+
177
+ self._config_manager.save_config(config)
178
+
179
+ def _get_active_key_state(self) -> Optional[KeyState]:
180
+ """Get current active key state"""
181
+ if not self._keys:
182
+ return None
183
+ if 0 <= self._current_index < len(self._keys):
184
+ return self._keys[self._current_index]
185
+ return None
186
+
187
+ def _get_key_by_id(self, key_id: str) -> Optional[str]:
188
+ """Get actual API key string by key ID"""
189
+ for key_state in self._keys:
190
+ if key_state.id == key_id:
191
+ return key_state.key
192
+ return None
193
+
194
+ async def get_available_key(self) -> Tuple[Optional[str], Optional[str]]:
195
+ """
196
+ Get an available API key for use.
197
+
198
+ Returns:
199
+ Tuple of (api_key, key_id) or (None, None) if no keys available
200
+ """
201
+ async with self._lock:
202
+ # First pass: check current and rotate if needed
203
+ self._auto_expire_cooldowns()
204
+
205
+ if not self._keys:
206
+ return None, None
207
+
208
+ # Try to find an active key starting from current index
209
+ attempts = 0
210
+ while attempts < len(self._keys):
211
+ key_state = self._keys[self._current_index]
212
+
213
+ if key_state.status == KeyStatus.ACTIVE:
214
+ return key_state.key, key_state.id
215
+
216
+ # Move to next key
217
+ self._current_index = (self._current_index + 1) % len(self._keys)
218
+ attempts += 1
219
+
220
+ # All keys in cooldown - return the one with shortest wait
221
+ return self._get_shortest_cooldown_key()
222
+
223
+ def _get_shortest_cooldown_key(self) -> Tuple[Optional[str], Optional[str]]:
224
+ """Get key with shortest remaining cooldown"""
225
+ available_keys = [k for k in self._keys if k.enabled]
226
+ if not available_keys:
227
+ return None, None
228
+
229
+ # Sort by cooldown expiry
230
+ available_keys.sort(key=lambda k: k.cooldown_until or datetime.min)
231
+
232
+ return available_keys[0].key, available_keys[0].id
233
+
234
+ def _auto_expire_cooldowns(self):
235
+ """Clear expired cooldowns"""
236
+ now = datetime.utcnow()
237
+ for key_state in self._keys:
238
+ if key_state.cooldown_until and now >= key_state.cooldown_until:
239
+ key_state.cooldown_until = None
240
+ key_state.failure_count = 0
241
+
242
+ async def mark_key_success(self, key_id: str):
243
+ """Mark a key as successfully used"""
244
+ async with self._lock:
245
+ for key_state in self._keys:
246
+ if key_state.id == key_id:
247
+ key_state.last_used = datetime.utcnow()
248
+ key_state.failure_count = 0
249
+ key_state.cooldown_until = None
250
+ self._save_keys()
251
+ break
252
+
253
+ async def mark_key_rate_limited(
254
+ self,
255
+ key_id: str,
256
+ retry_after: Optional[int] = None,
257
+ error_message: Optional[str] = None,
258
+ ):
259
+ """
260
+ Mark a key as rate limited and set cooldown.
261
+
262
+ Args:
263
+ key_id: The key identifier
264
+ retry_after: Seconds from retry-after header (if available)
265
+ error_message: Error message to parse for cooldown duration
266
+ """
267
+ async with self._lock:
268
+ cooldown_seconds = self._parse_cooldown_duration(retry_after, error_message)
269
+
270
+ for key_state in self._keys:
271
+ if key_state.id == key_id:
272
+ key_state.cooldown_until = datetime.utcnow() + timedelta(
273
+ seconds=cooldown_seconds
274
+ )
275
+ key_state.failure_count += 1
276
+
277
+ # Rotate to next key
278
+ self._current_index = (self._current_index + 1) % len(self._keys)
279
+
280
+ self._save_keys()
281
+ print(
282
+ f"[KeyManager] Key {key_id} rate limited. Cooldown: {cooldown_seconds}s. Rotating to next key."
283
+ )
284
+ break
285
+
286
+ def _parse_cooldown_duration(
287
+ self, retry_after: Optional[int], error_message: Optional[str]
288
+ ) -> int:
289
+ """
290
+ Parse cooldown duration from various sources.
291
+
292
+ Priority:
293
+ 1. retry-after header value
294
+ 2. Parsed from error message
295
+ 3. Default value (60 seconds)
296
+ """
297
+ if retry_after and retry_after > 0:
298
+ return retry_after
299
+
300
+ if error_message:
301
+ # Try to parse "retry after X seconds" patterns
302
+ patterns = [
303
+ r"retry.?after[:\s]+(\d+)\s*(?:second|sec|s)?",
304
+ r"wait[:\s]+(\d+)\s*(?:second|sec|s)?",
305
+ r"(\d+)\s*(?:second|sec)s?\s*(?:before|until)",
306
+ r"try again in (\d+)",
307
+ ]
308
+ for pattern in patterns:
309
+ match = re.search(pattern, error_message, re.IGNORECASE)
310
+ if match:
311
+ return int(match.group(1))
312
+
313
+ return self.DEFAULT_COOLDOWN_SECONDS
314
+
315
+ # ========== Key Management API ==========
316
+
317
+ def add_key(self, api_key: str) -> Tuple[bool, str]:
318
+ """
319
+ Add a new API key.
320
+
321
+ Returns:
322
+ Tuple of (success, message)
323
+ """
324
+ if len(self._keys) >= self.MAX_KEYS:
325
+ return False, f"Maximum {self.MAX_KEYS} keys allowed"
326
+
327
+ # Check for duplicate
328
+ for k in self._keys:
329
+ if k.key == api_key:
330
+ return False, "Key already exists"
331
+
332
+ new_key = KeyState(key=api_key, id=f"key_{uuid.uuid4().hex[:8]}")
333
+ self._keys.append(new_key)
334
+ self._save_keys()
335
+
336
+ return True, f"Key added: {new_key.id}"
337
+
338
+ def remove_key(self, key_id: str) -> Tuple[bool, str]:
339
+ """Remove a key by ID"""
340
+ for i, k in enumerate(self._keys):
341
+ if k.id == key_id:
342
+ self._keys.pop(i)
343
+ # Adjust current index if needed
344
+ if self._current_index >= len(self._keys):
345
+ self._current_index = max(0, len(self._keys) - 1)
346
+ self._save_keys()
347
+ return True, f"Key removed: {key_id}"
348
+
349
+ return False, f"Key not found: {key_id}"
350
+
351
+ def toggle_key(self, key_id: str, enabled: bool) -> Tuple[bool, str]:
352
+ """Enable or disable a key"""
353
+ for k in self._keys:
354
+ if k.id == key_id:
355
+ k.enabled = enabled
356
+ self._save_keys()
357
+ status = "enabled" if enabled else "disabled"
358
+ return True, f"Key {key_id} {status}"
359
+
360
+ return False, f"Key not found: {key_id}"
361
+
362
+ def get_all_keys_status(self) -> List[Dict[str, Any]]:
363
+ """
364
+ Get status of all keys for UI display.
365
+ Keys are masked for security.
366
+ """
367
+ self._auto_expire_cooldowns()
368
+
369
+ result = []
370
+ for i, k in enumerate(self._keys):
371
+ masked_key = f"****{k.key[-4:]}" if len(k.key) > 4 else "****"
372
+ result.append(
373
+ {
374
+ "id": k.id,
375
+ "maskedKey": masked_key,
376
+ "status": k.status.value,
377
+ "cooldownRemaining": k.cooldown_remaining_seconds,
378
+ "lastUsed": k.last_used.isoformat() if k.last_used else None,
379
+ "failureCount": k.failure_count,
380
+ "isActive": i == self._current_index,
381
+ "enabled": k.enabled,
382
+ }
383
+ )
384
+
385
+ return result
386
+
387
+ def get_key_count(self) -> int:
388
+ """Get total number of keys"""
389
+ return len(self._keys)
390
+
391
+ def has_available_key(self) -> bool:
392
+ """Check if any key is available (not in cooldown)"""
393
+ self._auto_expire_cooldowns()
394
+ return any(k.status == KeyStatus.ACTIVE for k in self._keys)
395
+
396
+ async def wait_for_available_key(self) -> Tuple[Optional[str], Optional[str]]:
397
+ """
398
+ Wait for a key to become available.
399
+ Used as fallback when all keys are in cooldown.
400
+ """
401
+ self._auto_expire_cooldowns()
402
+
403
+ # Find shortest cooldown
404
+ cooldown_keys = [k for k in self._keys if k.enabled and k.cooldown_until]
405
+ if not cooldown_keys:
406
+ return await self.get_available_key()
407
+
408
+ shortest = min(cooldown_keys, key=lambda k: k.cooldown_until)
409
+ wait_seconds = shortest.cooldown_remaining_seconds
410
+
411
+ if wait_seconds > 0:
412
+ print(
413
+ f"[KeyManager] All keys in cooldown. Waiting {wait_seconds}s for key {shortest.id}..."
414
+ )
415
+ await asyncio.sleep(wait_seconds + 1) # +1 for safety margin
416
+
417
+ return await self.get_available_key()
418
+
419
+ def reload_keys(self):
420
+ """Force reload keys from config (useful after external config changes)"""
421
+ self._load_keys()
422
+
423
+
424
+ # Module-level singleton getter
425
+ def get_key_manager(config_manager) -> GeminiKeyManager:
426
+ """Get the singleton key manager instance"""
427
+ return GeminiKeyManager.get_instance(config_manager)