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.
- elidia/__init__.py +0 -0
- elidia/__main__.py +4 -0
- elidia/agent/__init__.py +3 -0
- elidia/agent/loop.py +449 -0
- elidia/agent/personas.py +130 -0
- elidia/agent/portal.py +115 -0
- elidia/api/__init__.py +0 -0
- elidia/api/client.py +221 -0
- elidia/api/streaming.py +55 -0
- elidia/auth/__init__.py +0 -0
- elidia/auth/keychain.py +86 -0
- elidia/cache/__init__.py +1 -0
- elidia/cache/lru.py +158 -0
- elidia/cli/__init__.py +0 -0
- elidia/cli/commands.py +122 -0
- elidia/cli/main.py +293 -0
- elidia/cli/pager.py +104 -0
- elidia/cli/progress.py +173 -0
- elidia/cli/renderer.py +32 -0
- elidia/cli/repl.py +1234 -0
- elidia/cli/themes.py +214 -0
- elidia/config/__init__.py +0 -0
- elidia/config/defaults.py +5 -0
- elidia/config/rules.py +48 -0
- elidia/config/settings.py +217 -0
- elidia/creative/__init__.py +1 -0
- elidia/creative/audio.py +213 -0
- elidia/creative/display.py +260 -0
- elidia/creative/image.py +142 -0
- elidia/creative/local.py +163 -0
- elidia/creative/video.py +181 -0
- elidia/daemon/__init__.py +0 -0
- elidia/daemon/manager.py +349 -0
- elidia/db/__init__.py +0 -0
- elidia/db/database.py +87 -0
- elidia/mcp/__init__.py +0 -0
- elidia/mcp/client.py +198 -0
- elidia/mcp/config.py +93 -0
- elidia/mcp/registry.py +101 -0
- elidia/mcp/types.py +43 -0
- elidia/memory/__init__.py +3 -0
- elidia/memory/auto.py +164 -0
- elidia/memory/compaction.py +101 -0
- elidia/memory/embeddings.py +47 -0
- elidia/memory/outcomes.py +131 -0
- elidia/memory/patterns.py +79 -0
- elidia/memory/store.py +338 -0
- elidia/models/__init__.py +0 -0
- elidia/models/adaptive.py +53 -0
- elidia/models/router.py +115 -0
- elidia/modes/__init__.py +1 -0
- elidia/modes/autonomous.py +318 -0
- elidia/modes/budget.py +182 -0
- elidia/modes/classifier.py +169 -0
- elidia/modes/consensus.py +210 -0
- elidia/modes/deep_think.py +190 -0
- elidia/modes/swarm.py +229 -0
- elidia/modes/thinking.py +153 -0
- elidia/permissions/__init__.py +0 -0
- elidia/permissions/audit.py +138 -0
- elidia/permissions/manager.py +199 -0
- elidia/permissions/trust.py +63 -0
- elidia/rag/__init__.py +4 -0
- elidia/rag/chunker.py +216 -0
- elidia/rag/engine.py +345 -0
- elidia/rag/ingest.py +154 -0
- elidia/rag/portal_bridge.py +84 -0
- elidia/rag/watcher.py +98 -0
- elidia/research/__init__.py +1 -0
- elidia/research/export.py +237 -0
- elidia/research/orchestrator.py +310 -0
- elidia/research/sources.py +223 -0
- elidia/session/__init__.py +0 -0
- elidia/session/history.py +119 -0
- elidia/session/manager.py +82 -0
- elidia/tools/__init__.py +28 -0
- elidia/tools/base.py +70 -0
- elidia/tools/fetch.py +100 -0
- elidia/tools/filesystem.py +204 -0
- elidia/tools/git.py +156 -0
- elidia/tools/search.py +99 -0
- elidia/tools/terminal.py +77 -0
- elidia/widgets/__init__.py +1 -0
- elidia/widgets/protocol.py +248 -0
- elidia/widgets/renderer.py +225 -0
- elidia/workflow/__init__.py +1 -0
- elidia/workflow/engine.py +377 -0
- elidia_agent_cli-0.1.0.dist-info/METADATA +344 -0
- elidia_agent_cli-0.1.0.dist-info/RECORD +93 -0
- elidia_agent_cli-0.1.0.dist-info/WHEEL +5 -0
- elidia_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- elidia_agent_cli-0.1.0.dist-info/licenses/LICENSE +57 -0
- 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")
|
elidia/api/streaming.py
ADDED
|
@@ -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]}")
|
elidia/auth/__init__.py
ADDED
|
File without changes
|
elidia/auth/keychain.py
ADDED
|
@@ -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:]}"
|
elidia/cache/__init__.py
ADDED
|
@@ -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
|