elidia-agent-cli 0.1.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 (93) hide show
  1. elidia/__init__.py +0 -0
  2. elidia/__main__.py +4 -0
  3. elidia/agent/__init__.py +3 -0
  4. elidia/agent/loop.py +449 -0
  5. elidia/agent/personas.py +130 -0
  6. elidia/agent/portal.py +115 -0
  7. elidia/api/__init__.py +0 -0
  8. elidia/api/client.py +221 -0
  9. elidia/api/streaming.py +55 -0
  10. elidia/auth/__init__.py +0 -0
  11. elidia/auth/keychain.py +86 -0
  12. elidia/cache/__init__.py +1 -0
  13. elidia/cache/lru.py +158 -0
  14. elidia/cli/__init__.py +0 -0
  15. elidia/cli/commands.py +122 -0
  16. elidia/cli/main.py +293 -0
  17. elidia/cli/pager.py +104 -0
  18. elidia/cli/progress.py +173 -0
  19. elidia/cli/renderer.py +32 -0
  20. elidia/cli/repl.py +1234 -0
  21. elidia/cli/themes.py +214 -0
  22. elidia/config/__init__.py +0 -0
  23. elidia/config/defaults.py +5 -0
  24. elidia/config/rules.py +48 -0
  25. elidia/config/settings.py +217 -0
  26. elidia/creative/__init__.py +1 -0
  27. elidia/creative/audio.py +213 -0
  28. elidia/creative/display.py +260 -0
  29. elidia/creative/image.py +142 -0
  30. elidia/creative/local.py +163 -0
  31. elidia/creative/video.py +181 -0
  32. elidia/daemon/__init__.py +0 -0
  33. elidia/daemon/manager.py +349 -0
  34. elidia/db/__init__.py +0 -0
  35. elidia/db/database.py +87 -0
  36. elidia/mcp/__init__.py +0 -0
  37. elidia/mcp/client.py +198 -0
  38. elidia/mcp/config.py +93 -0
  39. elidia/mcp/registry.py +101 -0
  40. elidia/mcp/types.py +43 -0
  41. elidia/memory/__init__.py +3 -0
  42. elidia/memory/auto.py +164 -0
  43. elidia/memory/compaction.py +101 -0
  44. elidia/memory/embeddings.py +47 -0
  45. elidia/memory/outcomes.py +131 -0
  46. elidia/memory/patterns.py +79 -0
  47. elidia/memory/store.py +338 -0
  48. elidia/models/__init__.py +0 -0
  49. elidia/models/adaptive.py +53 -0
  50. elidia/models/router.py +115 -0
  51. elidia/modes/__init__.py +1 -0
  52. elidia/modes/autonomous.py +318 -0
  53. elidia/modes/budget.py +182 -0
  54. elidia/modes/classifier.py +169 -0
  55. elidia/modes/consensus.py +210 -0
  56. elidia/modes/deep_think.py +190 -0
  57. elidia/modes/swarm.py +229 -0
  58. elidia/modes/thinking.py +153 -0
  59. elidia/permissions/__init__.py +0 -0
  60. elidia/permissions/audit.py +138 -0
  61. elidia/permissions/manager.py +199 -0
  62. elidia/permissions/trust.py +63 -0
  63. elidia/rag/__init__.py +4 -0
  64. elidia/rag/chunker.py +216 -0
  65. elidia/rag/engine.py +345 -0
  66. elidia/rag/ingest.py +154 -0
  67. elidia/rag/portal_bridge.py +84 -0
  68. elidia/rag/watcher.py +98 -0
  69. elidia/research/__init__.py +1 -0
  70. elidia/research/export.py +237 -0
  71. elidia/research/orchestrator.py +310 -0
  72. elidia/research/sources.py +223 -0
  73. elidia/session/__init__.py +0 -0
  74. elidia/session/history.py +119 -0
  75. elidia/session/manager.py +82 -0
  76. elidia/tools/__init__.py +28 -0
  77. elidia/tools/base.py +70 -0
  78. elidia/tools/fetch.py +100 -0
  79. elidia/tools/filesystem.py +204 -0
  80. elidia/tools/git.py +156 -0
  81. elidia/tools/search.py +99 -0
  82. elidia/tools/terminal.py +77 -0
  83. elidia/widgets/__init__.py +1 -0
  84. elidia/widgets/protocol.py +248 -0
  85. elidia/widgets/renderer.py +225 -0
  86. elidia/workflow/__init__.py +1 -0
  87. elidia/workflow/engine.py +377 -0
  88. elidia_agent_cli-0.1.0.dist-info/METADATA +344 -0
  89. elidia_agent_cli-0.1.0.dist-info/RECORD +93 -0
  90. elidia_agent_cli-0.1.0.dist-info/WHEEL +5 -0
  91. elidia_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  92. elidia_agent_cli-0.1.0.dist-info/licenses/LICENSE +57 -0
  93. elidia_agent_cli-0.1.0.dist-info/top_level.txt +1 -0
elidia/agent/portal.py ADDED
@@ -0,0 +1,115 @@
1
+ import json
2
+ import logging
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from elidia.tools.base import ToolDefinition, ToolRegistry, ToolResult
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class PortalToolBridge:
13
+ """Executes AiUtils portal tools (111 tools) via the Developer API."""
14
+
15
+ def __init__(self, api_key: str, base_url: str = "https://developer.aiutils.io/v1") -> None:
16
+ logger.debug(f"Entered into PortalToolBridge.__init__: base_url={base_url}")
17
+ self._api_key = api_key
18
+ self._base_url = base_url.rstrip("/")
19
+ self._tools: list[dict[str, Any]] = []
20
+
21
+ async def discover_tools(self) -> list[dict[str, Any]]:
22
+ logger.debug("Entered into discover_tools")
23
+ async with httpx.AsyncClient(
24
+ timeout=30,
25
+ headers={
26
+ "Authorization": f"Bearer {self._api_key}",
27
+ "Content-Type": "application/json",
28
+ },
29
+ ) as client:
30
+ try:
31
+ resp = await client.get(f"{self._base_url}/tools")
32
+ resp.raise_for_status()
33
+ data = resp.json()
34
+ self._tools = data.get("tools", data.get("data", []))
35
+ logger.info(f"Discovered {len(self._tools)} portal tools")
36
+ return self._tools
37
+ except Exception as e:
38
+ logger.warning(f"Failed to discover portal tools: {e}")
39
+ return []
40
+
41
+ async def execute_tool(self, tool_slug: str, inputs: dict[str, Any]) -> ToolResult:
42
+ logger.debug(f"Entered into execute_tool: tool_slug={tool_slug}")
43
+ async with httpx.AsyncClient(
44
+ timeout=120,
45
+ headers={
46
+ "Authorization": f"Bearer {self._api_key}",
47
+ "Content-Type": "application/json",
48
+ },
49
+ ) as client:
50
+ try:
51
+ resp = await client.post(
52
+ f"{self._base_url}/tools/{tool_slug}/execute",
53
+ json={"inputs": inputs},
54
+ )
55
+ if resp.status_code == 402:
56
+ return ToolResult(content="Insufficient balance for this tool", is_error=True)
57
+ if resp.status_code == 404:
58
+ return ToolResult(content=f"Tool '{tool_slug}' not found on portal", is_error=True)
59
+ resp.raise_for_status()
60
+
61
+ data = resp.json()
62
+ output = data.get("output", data.get("result", data))
63
+
64
+ if isinstance(output, dict):
65
+ content = json.dumps(output, indent=2, default=str)
66
+ elif isinstance(output, list):
67
+ content = json.dumps(output, indent=2, default=str)
68
+ else:
69
+ content = str(output)
70
+
71
+ return ToolResult(
72
+ content=content,
73
+ metadata={"tool_slug": tool_slug, "credits_used": data.get("credits_used", 0)},
74
+ )
75
+ except httpx.HTTPStatusError as e:
76
+ return ToolResult(content=f"Portal API error {e.response.status_code}: {e}", is_error=True)
77
+ except Exception as e:
78
+ return ToolResult(content=f"Portal tool error: {e}", is_error=True)
79
+
80
+ def register_portal_tools(self, registry: ToolRegistry) -> None:
81
+ logger.debug("Entered into register_portal_tools")
82
+ for tool in self._tools:
83
+ slug = tool.get("slug", tool.get("name", ""))
84
+ if not slug:
85
+ continue
86
+
87
+ description = tool.get("description", f"Portal tool: {slug}")
88
+ input_schema = tool.get("input_schema", tool.get("parameters", {}))
89
+ if not input_schema:
90
+ input_schema = {"type": "object", "properties": {}}
91
+
92
+ async def handler(tool_slug: str = slug, **kwargs: Any) -> ToolResult:
93
+ return await self.execute_tool(tool_slug, kwargs)
94
+
95
+ registry.register(ToolDefinition(
96
+ name=f"portal__{slug}",
97
+ description=description[:200],
98
+ parameters=input_schema,
99
+ handler=handler,
100
+ category="portal",
101
+ ))
102
+
103
+ logger.info(f"Registered {len(self._tools)} portal tools in registry")
104
+
105
+ def get_tool_list(self) -> list[dict[str, str]]:
106
+ logger.debug("Entered into get_tool_list")
107
+ return [
108
+ {
109
+ "slug": t.get("slug", t.get("name", "")),
110
+ "name": t.get("name", ""),
111
+ "description": t.get("description", "")[:100],
112
+ "genre": t.get("genre", ""),
113
+ }
114
+ for t in self._tools
115
+ ]
elidia/api/__init__.py ADDED
File without changes
elidia/api/client.py ADDED
@@ -0,0 +1,221 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import time
5
+ from collections.abc import AsyncIterator
6
+ from dataclasses import dataclass
7
+
8
+ import httpx
9
+
10
+ from elidia.api.streaming import SSEEvent, parse_sse_stream
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class ChatMessage:
17
+ role: str # "user", "assistant", "system"
18
+ content: str
19
+
20
+
21
+ @dataclass
22
+ class ChatResponse:
23
+ content: str = ""
24
+ model: str = ""
25
+ tokens_in: int = 0
26
+ tokens_out: int = 0
27
+ cost_dt: float = 0.0
28
+ finish_reason: str = ""
29
+ elapsed_ms: int = 0
30
+
31
+
32
+ class AiUtilsClient:
33
+ """Client for the AiUtils Developer API."""
34
+
35
+ def __init__(
36
+ self,
37
+ api_key: str,
38
+ base_url: str = "https://developer.aiutils.io/v1",
39
+ timeout: int = 120,
40
+ max_retries: int = 3,
41
+ ):
42
+ logger.debug(f"Entered into AiUtilsClient.__init__: base_url={base_url}")
43
+ self._api_key = api_key
44
+ self._base_url = base_url.rstrip("/")
45
+ self._timeout = timeout
46
+ self._max_retries = max_retries
47
+ self._client: httpx.AsyncClient | None = None
48
+
49
+ async def _get_client(self) -> httpx.AsyncClient:
50
+ logger.debug("Entered into _get_client")
51
+ if self._client is None or self._client.is_closed:
52
+ pool_limits = httpx.Limits(
53
+ max_connections=20,
54
+ max_keepalive_connections=10,
55
+ keepalive_expiry=30.0,
56
+ )
57
+ self._client = httpx.AsyncClient(
58
+ base_url=self._base_url,
59
+ headers={
60
+ "Authorization": f"Bearer {self._api_key}",
61
+ "Content-Type": "application/json",
62
+ "User-Agent": "elidia-cli/0.1.0",
63
+ },
64
+ timeout=httpx.Timeout(self._timeout, connect=10.0),
65
+ limits=pool_limits,
66
+ http2=True,
67
+ )
68
+ return self._client
69
+
70
+ async def close(self) -> None:
71
+ logger.debug("Entered into AiUtilsClient.close")
72
+ if self._client and not self._client.is_closed:
73
+ await self._client.aclose()
74
+ self._client = None
75
+
76
+ async def chat_completion_stream(
77
+ self,
78
+ messages: list[ChatMessage],
79
+ model: str = "deepseek-chat",
80
+ temperature: float = 0.7,
81
+ max_tokens: int | None = None,
82
+ ) -> AsyncIterator[SSEEvent]:
83
+ """Stream a chat completion from the AiUtils API."""
84
+ logger.debug(f"Entered into chat_completion_stream: model={model}, messages={len(messages)}")
85
+
86
+ client = await self._get_client()
87
+ payload = {
88
+ "model": model,
89
+ "messages": [{"role": m.role, "content": m.content} for m in messages],
90
+ "stream": True,
91
+ "temperature": temperature,
92
+ }
93
+ if max_tokens:
94
+ payload["max_tokens"] = max_tokens
95
+
96
+ last_error: object = None
97
+ for attempt in range(self._max_retries):
98
+ try:
99
+ async with client.stream("POST", "/chat/completions", json=payload) as response:
100
+ if response.status_code == 401:
101
+ yield SSEEvent(
102
+ event_type="error",
103
+ data={"message": "Invalid API key. Run 'elidia auth login' to set a valid key."},
104
+ )
105
+ return
106
+ if response.status_code == 402:
107
+ yield SSEEvent(
108
+ event_type="error",
109
+ data={"message": "Insufficient balance. Top up at developer.aiutils.io"},
110
+ )
111
+ return
112
+ if response.status_code == 429:
113
+ retry_after = int(response.headers.get("retry-after", 2 ** attempt))
114
+ last_error = "rate limited (429)"
115
+ logger.warning(f"Rate limited, retrying in {retry_after}s (attempt {attempt + 1})")
116
+ await asyncio.sleep(retry_after)
117
+ continue
118
+ if response.status_code >= 500:
119
+ last_error = f"server error ({response.status_code})"
120
+ logger.warning(f"Server error {response.status_code}, retrying (attempt {attempt + 1})")
121
+ await asyncio.sleep(2 ** attempt)
122
+ continue
123
+ response.raise_for_status()
124
+ content_type = response.headers.get("content-type", "")
125
+ if "text/event-stream" in content_type:
126
+ async for event in parse_sse_stream(response):
127
+ yield event
128
+ else:
129
+ # Gateway returns non-streaming JSON — parse it as a single response
130
+ body = await response.aread()
131
+ async for event in _parse_json_response(body):
132
+ yield event
133
+ return
134
+ except httpx.HTTPStatusError as e:
135
+ logger.warning(f"HTTP error {e.response.status_code} from chat_completion_stream: {e}")
136
+ yield SSEEvent(
137
+ event_type="error",
138
+ data={"message": f"API error {e.response.status_code}: {e}"},
139
+ )
140
+ return
141
+ except httpx.ConnectError as e:
142
+ last_error = e
143
+ logger.warning(f"Connection failed (attempt {attempt + 1}): {e}")
144
+ await asyncio.sleep(2 ** attempt)
145
+ except httpx.ReadTimeout as e:
146
+ last_error = e
147
+ logger.warning(f"Read timeout (attempt {attempt + 1})")
148
+ await asyncio.sleep(1)
149
+
150
+ yield SSEEvent(event_type="error", data={"message": f"Failed after {self._max_retries} attempts: {last_error}"})
151
+
152
+ async def chat_completion(
153
+ self,
154
+ messages: list[ChatMessage],
155
+ model: str = "deepseek-chat",
156
+ temperature: float = 0.7,
157
+ max_tokens: int | None = None,
158
+ ) -> ChatResponse:
159
+ """Non-streaming chat completion (collects full response)."""
160
+ logger.debug(f"Entered into chat_completion: model={model}")
161
+ start = time.monotonic()
162
+ result = ChatResponse(model=model)
163
+ async for event in self.chat_completion_stream(messages, model, temperature, max_tokens):
164
+ if event.event_type == "content":
165
+ result.content += event.data
166
+ elif event.event_type == "usage" and isinstance(event.data, dict):
167
+ result.tokens_in = event.data.get("prompt_tokens", 0)
168
+ result.tokens_out = event.data.get("completion_tokens", 0)
169
+ elif event.event_type == "error":
170
+ msg = event.data.get("message", str(event.data)) if isinstance(event.data, dict) else str(event.data)
171
+ raise RuntimeError(msg)
172
+ result.elapsed_ms = int((time.monotonic() - start) * 1000)
173
+ return result
174
+
175
+ async def list_models(self) -> list[dict]:
176
+ """Fetch available models from the API."""
177
+ logger.debug("Entered into list_models")
178
+ client = await self._get_client()
179
+ response = await client.get("/models")
180
+ response.raise_for_status()
181
+ data = response.json()
182
+ return data.get("data", data.get("models", []))
183
+
184
+ async def get_balance(self) -> dict:
185
+ """Fetch current DT balance."""
186
+ logger.debug("Entered into get_balance")
187
+ client = await self._get_client()
188
+ try:
189
+ response = await client.get("/wallet/balance")
190
+ response.raise_for_status()
191
+ return response.json()
192
+ except Exception as e:
193
+ logger.warning(f"Failed to fetch balance: {e}")
194
+ return {"balance_dt": -1, "error": str(e)}
195
+
196
+
197
+ async def _parse_json_response(body: bytes) -> AsyncIterator[SSEEvent]:
198
+ """Parse a non-streaming JSON chat completion response into SSEEvents."""
199
+ logger.debug("Entered into _parse_json_response")
200
+ try:
201
+ data = json.loads(body)
202
+ except json.JSONDecodeError:
203
+ yield SSEEvent(event_type="error", data={"message": f"Invalid JSON response: {body[:200].decode()}"})
204
+ return
205
+
206
+ if "error" in data:
207
+ yield SSEEvent(event_type="error", data=data["error"])
208
+ return
209
+
210
+ choices = data.get("choices", [])
211
+ if choices:
212
+ message = choices[0].get("message", {})
213
+ content = message.get("content")
214
+ if content:
215
+ yield SSEEvent(event_type="content", data=content)
216
+
217
+ usage = data.get("usage")
218
+ if usage:
219
+ yield SSEEvent(event_type="usage", data=usage)
220
+
221
+ yield SSEEvent(event_type="done")
@@ -0,0 +1,55 @@
1
+ import json
2
+ import logging
3
+ from collections.abc import AsyncIterator
4
+ from dataclasses import dataclass
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ @dataclass
10
+ class SSEEvent:
11
+ event_type: str # "content", "usage", "error", "done"
12
+ data: dict | str | None = None
13
+
14
+
15
+ async def parse_sse_stream(response) -> AsyncIterator[SSEEvent]:
16
+ """Parse SSE lines from an httpx streaming response.
17
+
18
+ Expected format (OpenAI-compatible):
19
+ data: {"choices": [{"delta": {"content": "Hello"}}]}
20
+ data: {"usage": {"prompt_tokens": 10, "completion_tokens": 5}}
21
+ data: [DONE]
22
+ """
23
+ logger.debug("Entered into parse_sse_stream")
24
+ buffer = ""
25
+ async for chunk in response.aiter_text():
26
+ buffer += chunk
27
+ while "\n" in buffer:
28
+ line, buffer = buffer.split("\n", 1)
29
+ line = line.strip()
30
+ if not line:
31
+ continue
32
+ if line.startswith("data: "):
33
+ data_str = line[6:]
34
+ if data_str == "[DONE]":
35
+ yield SSEEvent(event_type="done")
36
+ return
37
+ try:
38
+ data = json.loads(data_str)
39
+ choices = data.get("choices", [])
40
+ if choices:
41
+ delta = choices[0].get("delta", {})
42
+ content = delta.get("content")
43
+ if content is not None:
44
+ yield SSEEvent(event_type="content", data=content)
45
+ finish_reason = choices[0].get("finish_reason")
46
+ if finish_reason:
47
+ usage = data.get("usage")
48
+ if usage:
49
+ yield SSEEvent(event_type="usage", data=usage)
50
+ elif "usage" in data:
51
+ yield SSEEvent(event_type="usage", data=data["usage"])
52
+ elif "error" in data:
53
+ yield SSEEvent(event_type="error", data=data["error"])
54
+ except json.JSONDecodeError:
55
+ logger.warning(f"Failed to parse SSE data: {data_str[:100]}")
File without changes
@@ -0,0 +1,86 @@
1
+ import logging
2
+ import os
3
+ import stat
4
+
5
+ import keyring
6
+ import keyring.errors
7
+
8
+ from elidia.config.settings import ELIDIA_HOME
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ SERVICE_NAME = "elidia-cli"
13
+ ACCOUNT_NAME = "api_key"
14
+
15
+ _FALLBACK_KEY_PATH = ELIDIA_HOME / ".api_key"
16
+
17
+
18
+ def store_api_key(key: str) -> None:
19
+ """Store API key in OS keychain. Fallback to encrypted file."""
20
+ logger.debug("Entered into store_api_key: storing API key")
21
+ try:
22
+ keyring.set_password(SERVICE_NAME, ACCOUNT_NAME, key)
23
+ except keyring.errors.KeyringError as exc:
24
+ logger.warning(
25
+ f"Entered into store_api_key: keychain unavailable ({exc.__class__.__name__}), falling back to file storage"
26
+ )
27
+ _store_api_key_fallback(key)
28
+
29
+
30
+ def _store_api_key_fallback(key: str) -> None:
31
+ logger.debug("Entered into _store_api_key_fallback: writing API key to fallback file")
32
+ ELIDIA_HOME.mkdir(parents=True, exist_ok=True)
33
+ _FALLBACK_KEY_PATH.write_text(key, encoding="utf-8")
34
+ os.chmod(_FALLBACK_KEY_PATH, stat.S_IRUSR | stat.S_IWUSR)
35
+
36
+
37
+ def get_api_key() -> str | None:
38
+ """Retrieve API key from OS keychain, then fallback file, then env, else None."""
39
+ logger.debug("Entered into get_api_key: retrieving API key")
40
+ try:
41
+ value = keyring.get_password(SERVICE_NAME, ACCOUNT_NAME)
42
+ if value:
43
+ return value
44
+ except keyring.errors.KeyringError as exc:
45
+ logger.warning(
46
+ f"Entered into get_api_key: keychain unavailable ({exc.__class__.__name__}), checking fallback file"
47
+ )
48
+
49
+ if _FALLBACK_KEY_PATH.exists():
50
+ value = _FALLBACK_KEY_PATH.read_text(encoding="utf-8").strip()
51
+ if value:
52
+ return value
53
+
54
+ env_value = os.environ.get("AIUTILS_API_KEY")
55
+ if env_value:
56
+ return env_value
57
+
58
+ return None
59
+
60
+
61
+ def delete_api_key() -> None:
62
+ """Remove API key from keychain and fallback file."""
63
+ logger.debug("Entered into delete_api_key: removing API key")
64
+ try:
65
+ keyring.delete_password(SERVICE_NAME, ACCOUNT_NAME)
66
+ except keyring.errors.PasswordDeleteError:
67
+ pass
68
+ except keyring.errors.KeyringError as exc:
69
+ logger.warning(f"Entered into delete_api_key: keychain unavailable ({exc.__class__.__name__})")
70
+
71
+ if _FALLBACK_KEY_PATH.exists():
72
+ _FALLBACK_KEY_PATH.unlink()
73
+
74
+
75
+ def validate_api_key(key: str) -> bool:
76
+ """Check key format: must start with 'ak-dev-'."""
77
+ logger.debug("Entered into validate_api_key: checking key format")
78
+ return key.startswith("ak-dev-") and len(key) > 10
79
+
80
+
81
+ def mask_api_key(key: str) -> str:
82
+ """Return masked version: ak-dev-****7f2a"""
83
+ logger.debug("Entered into mask_api_key: masking key for display")
84
+ if len(key) <= 12:
85
+ return "ak-dev-****"
86
+ return f"{key[:7]}****{key[-4:]}"
@@ -0,0 +1 @@
1
+ """Response cache — LRU caching for LLM and tool responses."""
elidia/cache/lru.py ADDED
@@ -0,0 +1,158 @@
1
+ """LRU response cache — caches LLM and tool responses within a session.
2
+
3
+ Provides a bounded cache with TTL support for deduplicating identical
4
+ queries within a session. The cache key is derived from the model,
5
+ message content, and temperature to avoid cross-contamination.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import logging
12
+ import time
13
+ from collections import OrderedDict
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class CacheEntry:
22
+ key: str
23
+ value: Any
24
+ created_at: float = field(default_factory=time.time)
25
+ hits: int = 0
26
+ ttl: float = 0.0
27
+
28
+ @property
29
+ def is_expired(self) -> bool:
30
+ if self.ttl <= 0:
31
+ return False
32
+ return (time.time() - self.created_at) > self.ttl
33
+
34
+
35
+ class ResponseCache:
36
+ """Bounded LRU cache with TTL for LLM responses."""
37
+
38
+ def __init__(
39
+ self,
40
+ max_size: int = 128,
41
+ default_ttl: float = 300.0,
42
+ enabled: bool = True,
43
+ ) -> None:
44
+ logger.debug(f"Entered into ResponseCache.__init__: max_size={max_size}, ttl={default_ttl}")
45
+ self._max_size = max_size
46
+ self._default_ttl = default_ttl
47
+ self._enabled = enabled
48
+ self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
49
+ self._total_hits: int = 0
50
+ self._total_misses: int = 0
51
+
52
+ @property
53
+ def enabled(self) -> bool:
54
+ return self._enabled
55
+
56
+ @enabled.setter
57
+ def enabled(self, value: bool) -> None:
58
+ self._enabled = value
59
+
60
+ def make_key(
61
+ self,
62
+ model: str,
63
+ messages: list[dict[str, str]],
64
+ temperature: float = 0.7,
65
+ ) -> str:
66
+ logger.debug(f"Entered into make_key: model={model}")
67
+ payload = json.dumps({
68
+ "model": model,
69
+ "messages": messages,
70
+ "temperature": temperature,
71
+ }, sort_keys=True, default=str)
72
+ return hashlib.sha256(payload.encode()).hexdigest()[:32]
73
+
74
+ def get(self, key: str) -> Any | None:
75
+ logger.debug(f"Entered into get: key={key[:12]}")
76
+ if not self._enabled:
77
+ return None
78
+
79
+ entry = self._cache.get(key)
80
+ if entry is None:
81
+ self._total_misses += 1
82
+ return None
83
+
84
+ if entry.is_expired:
85
+ del self._cache[key]
86
+ self._total_misses += 1
87
+ return None
88
+
89
+ entry.hits += 1
90
+ self._total_hits += 1
91
+ self._cache.move_to_end(key)
92
+ return entry.value
93
+
94
+ def put(
95
+ self,
96
+ key: str,
97
+ value: Any,
98
+ ttl: float | None = None,
99
+ ) -> None:
100
+ logger.debug(f"Entered into put: key={key[:12]}")
101
+ if not self._enabled:
102
+ return
103
+
104
+ if key in self._cache:
105
+ self._cache.move_to_end(key)
106
+ self._cache[key].value = value
107
+ self._cache[key].created_at = time.time()
108
+ return
109
+
110
+ while len(self._cache) >= self._max_size:
111
+ self._cache.popitem(last=False)
112
+
113
+ self._cache[key] = CacheEntry(
114
+ key=key,
115
+ value=value,
116
+ ttl=ttl if ttl is not None else self._default_ttl,
117
+ )
118
+
119
+ def invalidate(self, key: str) -> bool:
120
+ logger.debug(f"Entered into invalidate: key={key[:12]}")
121
+ if key in self._cache:
122
+ del self._cache[key]
123
+ return True
124
+ return False
125
+
126
+ def clear(self) -> int:
127
+ logger.debug("Entered into clear")
128
+ count = len(self._cache)
129
+ self._cache.clear()
130
+ return count
131
+
132
+ def evict_expired(self) -> int:
133
+ logger.debug("Entered into evict_expired")
134
+ expired_keys = [k for k, v in self._cache.items() if v.is_expired]
135
+ for k in expired_keys:
136
+ del self._cache[k]
137
+ return len(expired_keys)
138
+
139
+ def get_stats(self) -> dict[str, Any]:
140
+ logger.debug("Entered into get_stats")
141
+ total = self._total_hits + self._total_misses
142
+ hit_rate = (self._total_hits / total * 100) if total > 0 else 0.0
143
+ return {
144
+ "size": len(self._cache),
145
+ "max_size": self._max_size,
146
+ "hits": self._total_hits,
147
+ "misses": self._total_misses,
148
+ "hit_rate_pct": round(hit_rate, 1),
149
+ "default_ttl": self._default_ttl,
150
+ "enabled": self._enabled,
151
+ }
152
+
153
+ def __len__(self) -> int:
154
+ return len(self._cache)
155
+
156
+ def __contains__(self, key: str) -> bool:
157
+ entry = self._cache.get(key)
158
+ return entry is not None and not entry.is_expired
elidia/cli/__init__.py ADDED
File without changes