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.
- agent_server/__init__.py +8 -0
- agent_server/core/__init__.py +92 -0
- agent_server/core/api_key_manager.py +427 -0
- agent_server/core/code_validator.py +1238 -0
- agent_server/core/context_condenser.py +308 -0
- agent_server/core/embedding_service.py +254 -0
- agent_server/core/error_classifier.py +577 -0
- agent_server/core/llm_client.py +95 -0
- agent_server/core/llm_service.py +649 -0
- agent_server/core/notebook_generator.py +274 -0
- agent_server/core/prompt_builder.py +35 -0
- agent_server/core/rag_manager.py +742 -0
- agent_server/core/reflection_engine.py +489 -0
- agent_server/core/retriever.py +248 -0
- agent_server/core/state_verifier.py +452 -0
- agent_server/core/summary_generator.py +484 -0
- agent_server/core/task_manager.py +198 -0
- agent_server/knowledge/__init__.py +9 -0
- agent_server/knowledge/watchdog_service.py +352 -0
- agent_server/main.py +160 -0
- agent_server/prompts/__init__.py +60 -0
- agent_server/prompts/file_action_prompts.py +113 -0
- agent_server/routers/__init__.py +9 -0
- agent_server/routers/agent.py +591 -0
- agent_server/routers/chat.py +188 -0
- agent_server/routers/config.py +100 -0
- agent_server/routers/file_resolver.py +293 -0
- agent_server/routers/health.py +42 -0
- agent_server/routers/rag.py +163 -0
- agent_server/schemas/__init__.py +60 -0
- hdsp_agent_core/__init__.py +158 -0
- hdsp_agent_core/factory.py +252 -0
- hdsp_agent_core/interfaces.py +203 -0
- hdsp_agent_core/knowledge/__init__.py +31 -0
- hdsp_agent_core/knowledge/chunking.py +356 -0
- hdsp_agent_core/knowledge/libraries/dask.md +188 -0
- hdsp_agent_core/knowledge/libraries/matplotlib.md +164 -0
- hdsp_agent_core/knowledge/libraries/polars.md +68 -0
- hdsp_agent_core/knowledge/loader.py +337 -0
- hdsp_agent_core/llm/__init__.py +13 -0
- hdsp_agent_core/llm/service.py +556 -0
- hdsp_agent_core/managers/__init__.py +22 -0
- hdsp_agent_core/managers/config_manager.py +133 -0
- hdsp_agent_core/managers/session_manager.py +251 -0
- hdsp_agent_core/models/__init__.py +115 -0
- hdsp_agent_core/models/agent.py +316 -0
- hdsp_agent_core/models/chat.py +41 -0
- hdsp_agent_core/models/common.py +95 -0
- hdsp_agent_core/models/rag.py +368 -0
- hdsp_agent_core/prompts/__init__.py +63 -0
- hdsp_agent_core/prompts/auto_agent_prompts.py +1260 -0
- hdsp_agent_core/prompts/cell_action_prompts.py +98 -0
- hdsp_agent_core/services/__init__.py +18 -0
- hdsp_agent_core/services/agent_service.py +438 -0
- hdsp_agent_core/services/chat_service.py +205 -0
- hdsp_agent_core/services/rag_service.py +262 -0
- hdsp_agent_core/tests/__init__.py +1 -0
- hdsp_agent_core/tests/conftest.py +102 -0
- hdsp_agent_core/tests/test_factory.py +251 -0
- hdsp_agent_core/tests/test_services.py +326 -0
- hdsp_jupyter_extension-2.0.0.data/data/etc/jupyter/jupyter_server_config.d/hdsp_jupyter_extension.json +7 -0
- hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/build_log.json +738 -0
- hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/install.json +5 -0
- hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/package.json +134 -0
- hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.2607ff74c74acfa83158.js +4369 -0
- hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/frontend_styles_index_js.2607ff74c74acfa83158.js.map +1 -0
- hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.622c1a5918b3aafb2315.js +12496 -0
- hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/lib_index_js.622c1a5918b3aafb2315.js.map +1 -0
- 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
- 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
- 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
- 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
- hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.dae97cde171e13b8c834.js +623 -0
- hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/remoteEntry.dae97cde171e13b8c834.js.map +1 -0
- hdsp_jupyter_extension-2.0.0.data/data/share/jupyter/labextensions/hdsp-agent/static/style.js +4 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- hdsp_jupyter_extension-2.0.0.dist-info/METADATA +152 -0
- hdsp_jupyter_extension-2.0.0.dist-info/RECORD +121 -0
- hdsp_jupyter_extension-2.0.0.dist-info/WHEEL +4 -0
- hdsp_jupyter_extension-2.0.0.dist-info/licenses/LICENSE +21 -0
- jupyter_ext/__init__.py +233 -0
- jupyter_ext/_version.py +4 -0
- jupyter_ext/config.py +111 -0
- jupyter_ext/etc/jupyter/jupyter_server_config.d/hdsp_jupyter_extension.json +7 -0
- jupyter_ext/handlers.py +632 -0
- jupyter_ext/labextension/build_log.json +738 -0
- jupyter_ext/labextension/package.json +134 -0
- jupyter_ext/labextension/static/frontend_styles_index_js.2607ff74c74acfa83158.js +4369 -0
- jupyter_ext/labextension/static/frontend_styles_index_js.2607ff74c74acfa83158.js.map +1 -0
- jupyter_ext/labextension/static/lib_index_js.622c1a5918b3aafb2315.js +12496 -0
- jupyter_ext/labextension/static/lib_index_js.622c1a5918b3aafb2315.js.map +1 -0
- jupyter_ext/labextension/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b80.c095373419d05e6f141a.js +94 -0
- jupyter_ext/labextension/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b80.c095373419d05e6f141a.js.map +1 -0
- jupyter_ext/labextension/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b81.61e75fb98ecff46cf836.js +94 -0
- jupyter_ext/labextension/static/node_modules_emotion_use-insertion-effect-with-fallbacks_dist_emotion-use-insertion-effect-wi-3ba6b81.61e75fb98ecff46cf836.js.map +1 -0
- jupyter_ext/labextension/static/remoteEntry.dae97cde171e13b8c834.js +623 -0
- jupyter_ext/labextension/static/remoteEntry.dae97cde171e13b8c834.js.map +1 -0
- jupyter_ext/labextension/static/style.js +4 -0
- jupyter_ext/labextension/static/vendors-node_modules_babel_runtime_helpers_esm_extends_js-node_modules_emotion_serialize_dist-051195.e2553aab0c3963b83dd7.js +507 -0
- jupyter_ext/labextension/static/vendors-node_modules_babel_runtime_helpers_esm_extends_js-node_modules_emotion_serialize_dist-051195.e2553aab0c3963b83dd7.js.map +1 -0
- jupyter_ext/labextension/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js-node_modules-782ee5.d9ed8645ef1d311657d8.js +2071 -0
- jupyter_ext/labextension/static/vendors-node_modules_emotion_cache_dist_emotion-cache_browser_development_esm_js-node_modules-782ee5.d9ed8645ef1d311657d8.js.map +1 -0
- jupyter_ext/labextension/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js +1059 -0
- jupyter_ext/labextension/static/vendors-node_modules_emotion_react_dist_emotion-react_browser_development_esm_js.36b49c71871f98d4f549.js.map +1 -0
- jupyter_ext/labextension/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js +376 -0
- jupyter_ext/labextension/static/vendors-node_modules_emotion_styled_dist_emotion-styled_browser_development_esm_js.661fb5836f4978a7c6e1.js.map +1 -0
- jupyter_ext/labextension/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js +60336 -0
- jupyter_ext/labextension/static/vendors-node_modules_mui_material_index_js.985697e0162d8d088ca2.js.map +1 -0
- jupyter_ext/labextension/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js +7132 -0
- jupyter_ext/labextension/static/vendors-node_modules_mui_material_utils_createSvgIcon_js.2e13df4ea61496e95d45.js.map +1 -0
agent_server/__init__.py
ADDED
|
@@ -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)
|