aethergraph 0.1.0a1__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.
- aethergraph/__init__.py +49 -0
- aethergraph/config/__init__.py +0 -0
- aethergraph/config/config.py +121 -0
- aethergraph/config/context.py +16 -0
- aethergraph/config/llm.py +26 -0
- aethergraph/config/loader.py +60 -0
- aethergraph/config/runtime.py +9 -0
- aethergraph/contracts/errors/errors.py +44 -0
- aethergraph/contracts/services/artifacts.py +142 -0
- aethergraph/contracts/services/channel.py +72 -0
- aethergraph/contracts/services/continuations.py +23 -0
- aethergraph/contracts/services/eventbus.py +12 -0
- aethergraph/contracts/services/kv.py +24 -0
- aethergraph/contracts/services/llm.py +17 -0
- aethergraph/contracts/services/mcp.py +22 -0
- aethergraph/contracts/services/memory.py +108 -0
- aethergraph/contracts/services/resume.py +28 -0
- aethergraph/contracts/services/state_stores.py +33 -0
- aethergraph/contracts/services/wakeup.py +28 -0
- aethergraph/core/execution/base_scheduler.py +77 -0
- aethergraph/core/execution/forward_scheduler.py +777 -0
- aethergraph/core/execution/global_scheduler.py +634 -0
- aethergraph/core/execution/retry_policy.py +22 -0
- aethergraph/core/execution/step_forward.py +411 -0
- aethergraph/core/execution/step_result.py +18 -0
- aethergraph/core/execution/wait_types.py +72 -0
- aethergraph/core/graph/graph_builder.py +192 -0
- aethergraph/core/graph/graph_fn.py +219 -0
- aethergraph/core/graph/graph_io.py +67 -0
- aethergraph/core/graph/graph_refs.py +154 -0
- aethergraph/core/graph/graph_spec.py +115 -0
- aethergraph/core/graph/graph_state.py +59 -0
- aethergraph/core/graph/graphify.py +128 -0
- aethergraph/core/graph/interpreter.py +145 -0
- aethergraph/core/graph/node_handle.py +33 -0
- aethergraph/core/graph/node_spec.py +46 -0
- aethergraph/core/graph/node_state.py +63 -0
- aethergraph/core/graph/task_graph.py +747 -0
- aethergraph/core/graph/task_node.py +82 -0
- aethergraph/core/graph/utils.py +37 -0
- aethergraph/core/graph/visualize.py +239 -0
- aethergraph/core/runtime/ad_hoc_context.py +61 -0
- aethergraph/core/runtime/base_service.py +153 -0
- aethergraph/core/runtime/bind_adapter.py +42 -0
- aethergraph/core/runtime/bound_memory.py +69 -0
- aethergraph/core/runtime/execution_context.py +220 -0
- aethergraph/core/runtime/graph_runner.py +349 -0
- aethergraph/core/runtime/lifecycle.py +26 -0
- aethergraph/core/runtime/node_context.py +203 -0
- aethergraph/core/runtime/node_services.py +30 -0
- aethergraph/core/runtime/recovery.py +159 -0
- aethergraph/core/runtime/run_registration.py +33 -0
- aethergraph/core/runtime/runtime_env.py +157 -0
- aethergraph/core/runtime/runtime_registry.py +32 -0
- aethergraph/core/runtime/runtime_services.py +224 -0
- aethergraph/core/runtime/wakeup_watcher.py +40 -0
- aethergraph/core/tools/__init__.py +10 -0
- aethergraph/core/tools/builtins/channel_tools.py +194 -0
- aethergraph/core/tools/builtins/toolset.py +134 -0
- aethergraph/core/tools/toolkit.py +510 -0
- aethergraph/core/tools/waitable.py +109 -0
- aethergraph/plugins/channel/__init__.py +0 -0
- aethergraph/plugins/channel/adapters/__init__.py +0 -0
- aethergraph/plugins/channel/adapters/console.py +106 -0
- aethergraph/plugins/channel/adapters/file.py +102 -0
- aethergraph/plugins/channel/adapters/slack.py +285 -0
- aethergraph/plugins/channel/adapters/telegram.py +302 -0
- aethergraph/plugins/channel/adapters/webhook.py +104 -0
- aethergraph/plugins/channel/adapters/webui.py +134 -0
- aethergraph/plugins/channel/routes/__init__.py +0 -0
- aethergraph/plugins/channel/routes/console_routes.py +86 -0
- aethergraph/plugins/channel/routes/slack_routes.py +49 -0
- aethergraph/plugins/channel/routes/telegram_routes.py +26 -0
- aethergraph/plugins/channel/routes/webui_routes.py +136 -0
- aethergraph/plugins/channel/utils/__init__.py +0 -0
- aethergraph/plugins/channel/utils/slack_utils.py +278 -0
- aethergraph/plugins/channel/utils/telegram_utils.py +324 -0
- aethergraph/plugins/channel/websockets/slack_ws.py +68 -0
- aethergraph/plugins/channel/websockets/telegram_polling.py +151 -0
- aethergraph/plugins/mcp/fs_server.py +128 -0
- aethergraph/plugins/mcp/http_server.py +101 -0
- aethergraph/plugins/mcp/ws_server.py +180 -0
- aethergraph/plugins/net/http.py +10 -0
- aethergraph/plugins/utils/data_io.py +359 -0
- aethergraph/runner/__init__.py +5 -0
- aethergraph/runtime/__init__.py +62 -0
- aethergraph/server/__init__.py +3 -0
- aethergraph/server/app_factory.py +84 -0
- aethergraph/server/start.py +122 -0
- aethergraph/services/__init__.py +10 -0
- aethergraph/services/artifacts/facade.py +284 -0
- aethergraph/services/artifacts/factory.py +35 -0
- aethergraph/services/artifacts/fs_store.py +656 -0
- aethergraph/services/artifacts/jsonl_index.py +123 -0
- aethergraph/services/artifacts/paths.py +23 -0
- aethergraph/services/artifacts/sqlite_index.py +209 -0
- aethergraph/services/artifacts/utils.py +124 -0
- aethergraph/services/auth/dev.py +16 -0
- aethergraph/services/channel/channel_bus.py +293 -0
- aethergraph/services/channel/factory.py +44 -0
- aethergraph/services/channel/session.py +511 -0
- aethergraph/services/channel/wait_helpers.py +57 -0
- aethergraph/services/clock/clock.py +9 -0
- aethergraph/services/container/default_container.py +320 -0
- aethergraph/services/continuations/continuation.py +56 -0
- aethergraph/services/continuations/factory.py +34 -0
- aethergraph/services/continuations/stores/fs_store.py +264 -0
- aethergraph/services/continuations/stores/inmem_store.py +95 -0
- aethergraph/services/eventbus/inmem.py +21 -0
- aethergraph/services/features/static.py +10 -0
- aethergraph/services/kv/ephemeral.py +90 -0
- aethergraph/services/kv/factory.py +27 -0
- aethergraph/services/kv/layered.py +41 -0
- aethergraph/services/kv/sqlite_kv.py +128 -0
- aethergraph/services/llm/factory.py +157 -0
- aethergraph/services/llm/generic_client.py +542 -0
- aethergraph/services/llm/providers.py +3 -0
- aethergraph/services/llm/service.py +105 -0
- aethergraph/services/logger/base.py +36 -0
- aethergraph/services/logger/compat.py +50 -0
- aethergraph/services/logger/formatters.py +106 -0
- aethergraph/services/logger/std.py +203 -0
- aethergraph/services/mcp/helpers.py +23 -0
- aethergraph/services/mcp/http_client.py +70 -0
- aethergraph/services/mcp/mcp_tools.py +21 -0
- aethergraph/services/mcp/registry.py +14 -0
- aethergraph/services/mcp/service.py +100 -0
- aethergraph/services/mcp/stdio_client.py +70 -0
- aethergraph/services/mcp/ws_client.py +115 -0
- aethergraph/services/memory/bound.py +106 -0
- aethergraph/services/memory/distillers/episode.py +116 -0
- aethergraph/services/memory/distillers/rolling.py +74 -0
- aethergraph/services/memory/facade.py +633 -0
- aethergraph/services/memory/factory.py +78 -0
- aethergraph/services/memory/hotlog_kv.py +27 -0
- aethergraph/services/memory/indices.py +74 -0
- aethergraph/services/memory/io_helpers.py +72 -0
- aethergraph/services/memory/persist_fs.py +40 -0
- aethergraph/services/memory/resolver.py +152 -0
- aethergraph/services/metering/noop.py +4 -0
- aethergraph/services/prompts/file_store.py +41 -0
- aethergraph/services/rag/chunker.py +29 -0
- aethergraph/services/rag/facade.py +593 -0
- aethergraph/services/rag/index/base.py +27 -0
- aethergraph/services/rag/index/faiss_index.py +121 -0
- aethergraph/services/rag/index/sqlite_index.py +134 -0
- aethergraph/services/rag/index_factory.py +52 -0
- aethergraph/services/rag/parsers/md.py +7 -0
- aethergraph/services/rag/parsers/pdf.py +14 -0
- aethergraph/services/rag/parsers/txt.py +7 -0
- aethergraph/services/rag/utils/hybrid.py +39 -0
- aethergraph/services/rag/utils/make_fs_key.py +62 -0
- aethergraph/services/redactor/simple.py +16 -0
- aethergraph/services/registry/key_parsing.py +44 -0
- aethergraph/services/registry/registry_key.py +19 -0
- aethergraph/services/registry/unified_registry.py +185 -0
- aethergraph/services/resume/multi_scheduler_resume_bus.py +65 -0
- aethergraph/services/resume/router.py +73 -0
- aethergraph/services/schedulers/registry.py +41 -0
- aethergraph/services/secrets/base.py +7 -0
- aethergraph/services/secrets/env.py +8 -0
- aethergraph/services/state_stores/externalize.py +135 -0
- aethergraph/services/state_stores/graph_observer.py +131 -0
- aethergraph/services/state_stores/json_store.py +67 -0
- aethergraph/services/state_stores/resume_policy.py +119 -0
- aethergraph/services/state_stores/serialize.py +249 -0
- aethergraph/services/state_stores/utils.py +91 -0
- aethergraph/services/state_stores/validate.py +78 -0
- aethergraph/services/tracing/noop.py +18 -0
- aethergraph/services/waits/wait_registry.py +91 -0
- aethergraph/services/wakeup/memory_queue.py +57 -0
- aethergraph/services/wakeup/scanner_producer.py +56 -0
- aethergraph/services/wakeup/worker.py +31 -0
- aethergraph/tools/__init__.py +25 -0
- aethergraph/utils/optdeps.py +8 -0
- aethergraph-0.1.0a1.dist-info/METADATA +410 -0
- aethergraph-0.1.0a1.dist-info/RECORD +182 -0
- aethergraph-0.1.0a1.dist-info/WHEEL +5 -0
- aethergraph-0.1.0a1.dist-info/entry_points.txt +2 -0
- aethergraph-0.1.0a1.dist-info/licenses/LICENSE +176 -0
- aethergraph-0.1.0a1.dist-info/licenses/NOTICE +31 -0
- aethergraph-0.1.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from aethergraph.contracts.services.llm import LLMClientProtocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---- Helpers --------------------------------------------------------------
|
|
14
|
+
class _Retry:
|
|
15
|
+
def __init__(self, tries=4, base=0.5, cap=8.0):
|
|
16
|
+
self.tries, self.base, self.cap = tries, base, cap
|
|
17
|
+
|
|
18
|
+
async def run(self, fn, *a, **k):
|
|
19
|
+
exc = None
|
|
20
|
+
for i in range(self.tries):
|
|
21
|
+
try:
|
|
22
|
+
return await fn(*a, **k)
|
|
23
|
+
except (httpx.ReadTimeout, httpx.ConnectError, httpx.HTTPStatusError) as e:
|
|
24
|
+
exc = e
|
|
25
|
+
await asyncio.sleep(min(self.cap, self.base * (2**i)))
|
|
26
|
+
raise exc
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _first_text(choices):
|
|
30
|
+
"""Extract text and usage from OpenAI-style choices list."""
|
|
31
|
+
if not choices:
|
|
32
|
+
return "", {}
|
|
33
|
+
c = choices[0]
|
|
34
|
+
text = (c.get("message", {}) or {}).get("content") or c.get("text") or ""
|
|
35
|
+
usage = {}
|
|
36
|
+
return text, usage
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---- Generic client -------------------------------------------------------
|
|
40
|
+
class GenericLLMClient(LLMClientProtocol):
|
|
41
|
+
"""
|
|
42
|
+
provider: one of {"openai","azure","anthropic","google","openrouter","lmstudio","ollama"}
|
|
43
|
+
Configuration (read from env by default, but you can pass in):
|
|
44
|
+
- OPENAI_API_KEY / OPENAI_BASE_URL
|
|
45
|
+
- AZURE_OPENAI_KEY / AZURE_OPENAI_ENDPOINT / AZURE_OPENAI_DEPLOYMENT
|
|
46
|
+
- ANTHROPIC_API_KEY
|
|
47
|
+
- GOOGLE_API_KEY
|
|
48
|
+
- OPENROUTER_API_KEY
|
|
49
|
+
- LMSTUDIO_BASE_URL (defaults http://localhost:1234/v1)
|
|
50
|
+
- OLLAMA_BASE_URL (defaults http://localhost:11434/v1)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
provider: str | None = None,
|
|
56
|
+
model: str | None = None,
|
|
57
|
+
embed_model: str | None = None,
|
|
58
|
+
*,
|
|
59
|
+
base_url: str | None = None,
|
|
60
|
+
api_key: str | None = None,
|
|
61
|
+
azure_deployment: str | None = None,
|
|
62
|
+
timeout: float = 60.0,
|
|
63
|
+
):
|
|
64
|
+
self.provider = (provider or os.getenv("LLM_PROVIDER") or "openai").lower()
|
|
65
|
+
self.model = model or os.getenv("LLM_MODEL") or "gpt-4o-mini"
|
|
66
|
+
self.embed_model = embed_model or os.getenv("EMBED_MODEL") or "text-embedding-3-small"
|
|
67
|
+
self._retry = _Retry()
|
|
68
|
+
self._client = httpx.AsyncClient(timeout=timeout)
|
|
69
|
+
self._bound_loop = None
|
|
70
|
+
|
|
71
|
+
# Resolve creds/base
|
|
72
|
+
self.api_key = (
|
|
73
|
+
api_key
|
|
74
|
+
or os.getenv("OPENAI_API_KEY")
|
|
75
|
+
or os.getenv("ANTHROPIC_API_KEY")
|
|
76
|
+
or os.getenv("GOOGLE_API_KEY")
|
|
77
|
+
or os.getenv("OPENROUTER_API_KEY")
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
self.base_url = (
|
|
81
|
+
base_url
|
|
82
|
+
or {
|
|
83
|
+
"openai": os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
|
|
84
|
+
"azure": os.getenv("AZURE_OPENAI_ENDPOINT", "").rstrip("/"),
|
|
85
|
+
"anthropic": "https://api.anthropic.com",
|
|
86
|
+
"google": "https://generativelanguage.googleapis.com",
|
|
87
|
+
"openrouter": "https://openrouter.ai/api/v1",
|
|
88
|
+
"lmstudio": os.getenv("LMSTUDIO_BASE_URL", "http://localhost:1234/v1"),
|
|
89
|
+
"ollama": os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"),
|
|
90
|
+
}[self.provider]
|
|
91
|
+
)
|
|
92
|
+
self.azure_deployment = azure_deployment or os.getenv("AZURE_OPENAI_DEPLOYMENT")
|
|
93
|
+
|
|
94
|
+
async def _ensure_client(self):
|
|
95
|
+
"""Ensure the httpx client is bound to the current event loop.
|
|
96
|
+
This allows safe usage across multiple async contexts.
|
|
97
|
+
"""
|
|
98
|
+
loop = asyncio.get_running_loop()
|
|
99
|
+
if self._client is None or self._bound_loop != loop:
|
|
100
|
+
# close old client if any
|
|
101
|
+
if self._client is not None:
|
|
102
|
+
try:
|
|
103
|
+
await self._client.aclose()
|
|
104
|
+
except Exception:
|
|
105
|
+
logger = logging.getLogger("aethergraph.services.llm.generic_client")
|
|
106
|
+
logger.warning("llm_client_close_failed")
|
|
107
|
+
self._client = httpx.AsyncClient(timeout=self._client.timeout)
|
|
108
|
+
self._bound_loop = loop
|
|
109
|
+
|
|
110
|
+
async def chat(
|
|
111
|
+
self,
|
|
112
|
+
messages: list[dict[str, Any]],
|
|
113
|
+
*,
|
|
114
|
+
reasoning_effort: str | None = None,
|
|
115
|
+
max_output_tokens: int | None = None,
|
|
116
|
+
**kw: Any,
|
|
117
|
+
) -> tuple[str, dict[str, int]]:
|
|
118
|
+
await self._ensure_client()
|
|
119
|
+
model = kw.get("model", self.model)
|
|
120
|
+
|
|
121
|
+
if self.provider != "openai":
|
|
122
|
+
# Make sure _chat_by_provider ALSO returns (str, usage),
|
|
123
|
+
# or wraps provider-specific structures into text.
|
|
124
|
+
return await self._chat_by_provider(messages, **kw)
|
|
125
|
+
|
|
126
|
+
body: dict[str, Any] = {
|
|
127
|
+
"model": model,
|
|
128
|
+
"input": messages,
|
|
129
|
+
}
|
|
130
|
+
if reasoning_effort is not None:
|
|
131
|
+
body["reasoning"] = {"effort": reasoning_effort}
|
|
132
|
+
if max_output_tokens is not None:
|
|
133
|
+
body["max_output_tokens"] = max_output_tokens
|
|
134
|
+
|
|
135
|
+
temperature = kw.get("temperature")
|
|
136
|
+
top_p = kw.get("top_p")
|
|
137
|
+
if temperature is not None:
|
|
138
|
+
body["temperature"] = temperature
|
|
139
|
+
if top_p is not None:
|
|
140
|
+
body["top_p"] = top_p
|
|
141
|
+
|
|
142
|
+
async def _call():
|
|
143
|
+
r = await self._client.post(
|
|
144
|
+
f"{self.base_url}/responses",
|
|
145
|
+
headers=self._headers_openai_like(),
|
|
146
|
+
json=body,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
r.raise_for_status()
|
|
151
|
+
except httpx.HTTPError as e:
|
|
152
|
+
raise RuntimeError(f"OpenAI Responses API error: {e.response.text}") from e
|
|
153
|
+
|
|
154
|
+
data = r.json()
|
|
155
|
+
output = data.get("output")
|
|
156
|
+
txt = ""
|
|
157
|
+
|
|
158
|
+
# NEW: handle list-of-messages shape
|
|
159
|
+
if isinstance(output, list) and output:
|
|
160
|
+
first = output[0]
|
|
161
|
+
if isinstance(first, dict) and first.get("type") == "message":
|
|
162
|
+
parts = first.get("content") or []
|
|
163
|
+
chunks: list[str] = []
|
|
164
|
+
for p in parts:
|
|
165
|
+
if "text" in p:
|
|
166
|
+
chunks.append(p["text"])
|
|
167
|
+
txt = "".join(chunks)
|
|
168
|
+
|
|
169
|
+
elif isinstance(output, dict) and output.get("type") == "message":
|
|
170
|
+
msg = output.get("message") or output
|
|
171
|
+
parts = msg.get("content") or []
|
|
172
|
+
chunks: list[str] = []
|
|
173
|
+
for p in parts:
|
|
174
|
+
if "text" in p:
|
|
175
|
+
chunks.append(p["text"])
|
|
176
|
+
txt = "".join(chunks)
|
|
177
|
+
|
|
178
|
+
elif isinstance(output, str):
|
|
179
|
+
txt = output
|
|
180
|
+
|
|
181
|
+
else:
|
|
182
|
+
txt = str(output) if output is not None else ""
|
|
183
|
+
|
|
184
|
+
usage = data.get("usage", {}) or {}
|
|
185
|
+
return txt, usage
|
|
186
|
+
|
|
187
|
+
return await self._retry.run(_call)
|
|
188
|
+
|
|
189
|
+
# ---------------- Chat ----------------
|
|
190
|
+
async def _chat_by_provider(
|
|
191
|
+
self, messages: list[dict[str, Any]], **kw
|
|
192
|
+
) -> tuple[str, dict[str, int]]:
|
|
193
|
+
await self._ensure_client()
|
|
194
|
+
|
|
195
|
+
temperature = kw.get("temperature", 0.5)
|
|
196
|
+
top_p = kw.get("top_p", 1.0)
|
|
197
|
+
model = kw.get("model", self.model)
|
|
198
|
+
|
|
199
|
+
if self.provider in {"openrouter", "lmstudio", "ollama"}:
|
|
200
|
+
|
|
201
|
+
async def _call():
|
|
202
|
+
body = {
|
|
203
|
+
"model": model,
|
|
204
|
+
"messages": messages,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
r = await self._client.post(
|
|
208
|
+
f"{self.base_url}/chat/completions",
|
|
209
|
+
headers=self._headers_openai_like(),
|
|
210
|
+
json=body,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
r.raise_for_status()
|
|
215
|
+
except httpx.HTTPError as e:
|
|
216
|
+
raise RuntimeError(f"OpenAI Responses API error: {e.response.text}") from e
|
|
217
|
+
data = r.json()
|
|
218
|
+
txt, _ = _first_text(data.get("choices", []))
|
|
219
|
+
return txt, data.get("usage", {}) or {}
|
|
220
|
+
|
|
221
|
+
return await self._retry.run(_call)
|
|
222
|
+
|
|
223
|
+
if self.provider == "azure":
|
|
224
|
+
if not (self.base_url and self.azure_deployment):
|
|
225
|
+
raise RuntimeError(
|
|
226
|
+
"Azure OpenAI requires AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async def _call():
|
|
230
|
+
r = await self._client.post(
|
|
231
|
+
f"{self.base_url}/openai/deployments/{self.azure_deployment}/chat/completions?api-version=2024-08-01-preview",
|
|
232
|
+
headers={"api-key": self.api_key, "Content-Type": "application/json"},
|
|
233
|
+
json={"messages": messages, "temperature": temperature, "top_p": top_p},
|
|
234
|
+
)
|
|
235
|
+
try:
|
|
236
|
+
r.raise_for_status()
|
|
237
|
+
except httpx.HTTPError as e:
|
|
238
|
+
raise RuntimeError(f"OpenAI Responses API error: {e.response.text}") from e
|
|
239
|
+
|
|
240
|
+
data = r.json()
|
|
241
|
+
txt, _ = _first_text(data.get("choices", []))
|
|
242
|
+
return txt, data.get("usage", {}) or {}
|
|
243
|
+
|
|
244
|
+
return await self._retry.run(_call)
|
|
245
|
+
|
|
246
|
+
if self.provider == "anthropic":
|
|
247
|
+
# Convert OpenAI-style messages -> Anthropic Messages API format
|
|
248
|
+
# 1) Collect system messages (as strings)
|
|
249
|
+
sys_msgs = [m["content"] for m in messages if m["role"] == "system"]
|
|
250
|
+
|
|
251
|
+
# 2) Convert non-system messages into Anthropic blocks
|
|
252
|
+
conv = []
|
|
253
|
+
for m in messages:
|
|
254
|
+
role = m["role"]
|
|
255
|
+
if role == "system":
|
|
256
|
+
continue # handled via `system` field
|
|
257
|
+
|
|
258
|
+
# Anthropic only accepts "user" or "assistant"
|
|
259
|
+
anthro_role = "assistant" if role == "assistant" else "user"
|
|
260
|
+
|
|
261
|
+
content = m["content"]
|
|
262
|
+
# Wrap string content into text blocks; if caller is already giving blocks, pass them through.
|
|
263
|
+
if isinstance(content, str):
|
|
264
|
+
content_blocks = [{"type": "text", "text": content}]
|
|
265
|
+
else:
|
|
266
|
+
# Assume caller knows what they're doing for multimodal content
|
|
267
|
+
content_blocks = content
|
|
268
|
+
|
|
269
|
+
conv.append({"role": anthro_role, "content": content_blocks})
|
|
270
|
+
|
|
271
|
+
# 3) Build payload
|
|
272
|
+
payload = {
|
|
273
|
+
"model": model,
|
|
274
|
+
"max_tokens": kw.get("max_tokens", 1024),
|
|
275
|
+
"messages": conv,
|
|
276
|
+
"temperature": temperature,
|
|
277
|
+
"top_p": top_p,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
# ✅ Anthropic v1/messages now expects `system` to be a list
|
|
281
|
+
if sys_msgs:
|
|
282
|
+
payload["system"] = "\n\n".join(sys_msgs)
|
|
283
|
+
|
|
284
|
+
async def _call():
|
|
285
|
+
r = await self._client.post(
|
|
286
|
+
f"{self.base_url}/v1/messages",
|
|
287
|
+
headers={
|
|
288
|
+
"x-api-key": self.api_key,
|
|
289
|
+
"anthropic-version": "2023-06-01",
|
|
290
|
+
"Content-Type": "application/json",
|
|
291
|
+
},
|
|
292
|
+
json=payload,
|
|
293
|
+
)
|
|
294
|
+
try:
|
|
295
|
+
r.raise_for_status()
|
|
296
|
+
except httpx.HTTPStatusError as e:
|
|
297
|
+
# keep the nice debugging message
|
|
298
|
+
raise RuntimeError(f"Anthropic API error: {e.response.text}") from e
|
|
299
|
+
|
|
300
|
+
data = r.json()
|
|
301
|
+
# data["content"] is a list of blocks
|
|
302
|
+
blocks = data.get("content") or []
|
|
303
|
+
txt = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
|
|
304
|
+
return txt, data.get("usage", {}) or {}
|
|
305
|
+
|
|
306
|
+
return await self._retry.run(_call)
|
|
307
|
+
|
|
308
|
+
if self.provider == "google":
|
|
309
|
+
# Merge system messages into a single preamble
|
|
310
|
+
system = "\n".join([m["content"] for m in messages if m["role"] == "system"])
|
|
311
|
+
|
|
312
|
+
# Non-system messages
|
|
313
|
+
turns = [
|
|
314
|
+
{
|
|
315
|
+
"role": "user" if m["role"] == "user" else "model",
|
|
316
|
+
"parts": [{"text": m["content"]}],
|
|
317
|
+
}
|
|
318
|
+
for m in messages
|
|
319
|
+
if m["role"] != "system"
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
if system:
|
|
323
|
+
turns.insert(
|
|
324
|
+
0,
|
|
325
|
+
{
|
|
326
|
+
"role": "user",
|
|
327
|
+
"parts": [{"text": f"System instructions: {system}"}],
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
async def _call():
|
|
332
|
+
payload = {
|
|
333
|
+
"contents": turns,
|
|
334
|
+
"generationConfig": {
|
|
335
|
+
"temperature": temperature,
|
|
336
|
+
"topP": top_p,
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
r = await self._client.post(
|
|
341
|
+
f"{self.base_url}/v1/models/{model}:generateContent?key={self.api_key}",
|
|
342
|
+
headers={"Content-Type": "application/json"},
|
|
343
|
+
json=payload,
|
|
344
|
+
)
|
|
345
|
+
try:
|
|
346
|
+
r.raise_for_status()
|
|
347
|
+
except httpx.HTTPStatusError as e:
|
|
348
|
+
raise RuntimeError(
|
|
349
|
+
f"Gemini generateContent failed ({e.response.status_code}): {e.response.text}"
|
|
350
|
+
) from e
|
|
351
|
+
|
|
352
|
+
data = r.json()
|
|
353
|
+
cand = (data.get("candidates") or [{}])[0]
|
|
354
|
+
txt = "".join(
|
|
355
|
+
p.get("text", "") for p in (cand.get("content", {}).get("parts") or [])
|
|
356
|
+
)
|
|
357
|
+
return txt, {} # usage parsing optional
|
|
358
|
+
|
|
359
|
+
return await self._retry.run(_call)
|
|
360
|
+
|
|
361
|
+
if self.provider == "openai":
|
|
362
|
+
raise RuntimeError(
|
|
363
|
+
"Internal error: OpenAI provider should use chat() or responses_chat() directly."
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
raise NotImplementedError(f"provider {self.provider}")
|
|
367
|
+
|
|
368
|
+
# ---------------- Embeddings ----------------
|
|
369
|
+
async def embed(self, texts: list[str], **kw) -> list[list[float]]:
|
|
370
|
+
# model override order: kw > self.embed_model > ENV > default
|
|
371
|
+
await self._ensure_client()
|
|
372
|
+
|
|
373
|
+
model = (
|
|
374
|
+
kw.get("model")
|
|
375
|
+
or self.embed_model
|
|
376
|
+
or os.getenv("EMBED_MODEL")
|
|
377
|
+
or "text-embedding-3-small"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if self.provider in {"openai", "openrouter", "lmstudio", "ollama"}:
|
|
381
|
+
|
|
382
|
+
async def _call():
|
|
383
|
+
r = await self._client.post(
|
|
384
|
+
f"{self.base_url}/embeddings",
|
|
385
|
+
headers=self._headers_openai_like(),
|
|
386
|
+
json={"model": model, "input": texts},
|
|
387
|
+
)
|
|
388
|
+
try:
|
|
389
|
+
r.raise_for_status()
|
|
390
|
+
except httpx.HTTPStatusError as e:
|
|
391
|
+
# Log or re-raise with more context
|
|
392
|
+
msg = f"Embeddings request failed ({e.response.status_code}): {e.response.text}"
|
|
393
|
+
raise RuntimeError(msg) from e
|
|
394
|
+
|
|
395
|
+
data = r.json()
|
|
396
|
+
return [d["embedding"] for d in data.get("data", [])]
|
|
397
|
+
|
|
398
|
+
return await self._retry.run(_call)
|
|
399
|
+
|
|
400
|
+
if self.provider == "azure":
|
|
401
|
+
|
|
402
|
+
async def _call():
|
|
403
|
+
r = await self._client.post(
|
|
404
|
+
f"{self.base_url}/openai/deployments/{self.azure_deployment}/embeddings?api-version=2024-08-01-preview",
|
|
405
|
+
headers={"api-key": self.api_key, "Content-Type": "application/json"},
|
|
406
|
+
json={"input": texts},
|
|
407
|
+
)
|
|
408
|
+
try:
|
|
409
|
+
r.raise_for_status()
|
|
410
|
+
except httpx.HTTPStatusError as e:
|
|
411
|
+
# Log or re-raise with more context
|
|
412
|
+
msg = f"Embeddings request failed ({e.response.status_code}): {e.response.text}"
|
|
413
|
+
raise RuntimeError(msg) from e
|
|
414
|
+
|
|
415
|
+
data = r.json()
|
|
416
|
+
return [d["embedding"] for d in data.get("data", [])]
|
|
417
|
+
|
|
418
|
+
return await self._retry.run(_call)
|
|
419
|
+
|
|
420
|
+
if self.provider == "google":
|
|
421
|
+
|
|
422
|
+
async def _call():
|
|
423
|
+
r = await self._client.post(
|
|
424
|
+
f"{self.base_url}/v1/models/{model}:embedContent?key={self.api_key}",
|
|
425
|
+
headers={"Content-Type": "application/json"},
|
|
426
|
+
json={"content": {"parts": [{"text": "\n".join(texts)}]}},
|
|
427
|
+
)
|
|
428
|
+
try:
|
|
429
|
+
r.raise_for_status()
|
|
430
|
+
except httpx.HTTPStatusError as e:
|
|
431
|
+
raise RuntimeError(
|
|
432
|
+
f"Gemini embedContent failed ({e.response.status_code}): {e.response.text}"
|
|
433
|
+
) from e
|
|
434
|
+
|
|
435
|
+
data = r.json()
|
|
436
|
+
return [data.get("embedding", {}).get("values", [])]
|
|
437
|
+
|
|
438
|
+
return await self._retry.run(_call)
|
|
439
|
+
|
|
440
|
+
# Anthropic: no embeddings endpoint
|
|
441
|
+
raise NotImplementedError(f"Embeddings not supported for {self.provider}")
|
|
442
|
+
|
|
443
|
+
# ---------------- Internals ----------------
|
|
444
|
+
def _headers_openai_like(self):
|
|
445
|
+
hdr = {"Content-Type": "application/json"}
|
|
446
|
+
if self.provider in {"openai", "openrouter"}:
|
|
447
|
+
hdr["Authorization"] = f"Bearer {self.api_key}"
|
|
448
|
+
return hdr
|
|
449
|
+
|
|
450
|
+
async def aclose(self):
|
|
451
|
+
await self._client.aclose()
|
|
452
|
+
|
|
453
|
+
def _default_headers_for_raw(self) -> dict[str, str]:
|
|
454
|
+
hdr = {"Content-Type": "application/json"}
|
|
455
|
+
|
|
456
|
+
if self.provider in {"openai", "openrouter"}:
|
|
457
|
+
if self.api_key:
|
|
458
|
+
hdr["Authorization"] = f"Bearer {self.api_key}"
|
|
459
|
+
else:
|
|
460
|
+
raise RuntimeError("OpenAI/OpenRouter requires an API key for raw() calls.")
|
|
461
|
+
|
|
462
|
+
elif self.provider == "anthropic":
|
|
463
|
+
if self.api_key:
|
|
464
|
+
hdr.update(
|
|
465
|
+
{
|
|
466
|
+
"x-api-key": self.api_key,
|
|
467
|
+
"anthropic-version": "2023-06-01",
|
|
468
|
+
}
|
|
469
|
+
)
|
|
470
|
+
else:
|
|
471
|
+
raise RuntimeError("Anthropic requires an API key for raw() calls.")
|
|
472
|
+
|
|
473
|
+
elif self.provider == "azure":
|
|
474
|
+
if self.api_key:
|
|
475
|
+
hdr["api-key"] = self.api_key
|
|
476
|
+
else:
|
|
477
|
+
raise RuntimeError("Azure OpenAI requires an API key for raw() calls.")
|
|
478
|
+
|
|
479
|
+
# For google, lmstudio, ollama we usually put keys in the URL or
|
|
480
|
+
# they’re local; leave headers minimal unless user overrides.
|
|
481
|
+
return hdr
|
|
482
|
+
|
|
483
|
+
async def raw(
|
|
484
|
+
self,
|
|
485
|
+
*,
|
|
486
|
+
method: str = "POST",
|
|
487
|
+
path: str | None = None,
|
|
488
|
+
url: str | None = None,
|
|
489
|
+
json: Any | None = None,
|
|
490
|
+
params: dict[str, Any] | None = None,
|
|
491
|
+
headers: dict[str, str] | None = None,
|
|
492
|
+
return_response: bool = False,
|
|
493
|
+
) -> Any:
|
|
494
|
+
"""
|
|
495
|
+
Low-level escape hatch: send a raw HTTP request using this client’s
|
|
496
|
+
base_url, auth, and retry logic.
|
|
497
|
+
|
|
498
|
+
- If `url` is provided, it is used as-is.
|
|
499
|
+
- Otherwise, `path` is joined to `self.base_url`.
|
|
500
|
+
- `json` and `params` are forwarded to httpx.
|
|
501
|
+
- Provider-specific default headers (auth, version, etc.) are applied,
|
|
502
|
+
then overridden by `headers` if provided.
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
- r.json() by default
|
|
506
|
+
- or the raw `httpx.Response` if `return_response=True`
|
|
507
|
+
"""
|
|
508
|
+
await self._ensure_client()
|
|
509
|
+
|
|
510
|
+
if not url and not path:
|
|
511
|
+
raise ValueError("Either `url` or `path` must be provided to raw().")
|
|
512
|
+
|
|
513
|
+
if not url:
|
|
514
|
+
url = f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"
|
|
515
|
+
|
|
516
|
+
base_headers = self._default_headers_for_raw()
|
|
517
|
+
if headers:
|
|
518
|
+
base_headers.update(headers)
|
|
519
|
+
|
|
520
|
+
async def _call():
|
|
521
|
+
r = await self._client.request(
|
|
522
|
+
method=method,
|
|
523
|
+
url=url,
|
|
524
|
+
headers=base_headers,
|
|
525
|
+
json=json,
|
|
526
|
+
params=params,
|
|
527
|
+
)
|
|
528
|
+
try:
|
|
529
|
+
r.raise_for_status()
|
|
530
|
+
except httpx.HTTPStatusError as e:
|
|
531
|
+
raise RuntimeError(
|
|
532
|
+
f"{self.provider} raw API error ({e.response.status_code}): {e.response.text}"
|
|
533
|
+
) from e
|
|
534
|
+
|
|
535
|
+
return r if return_response else r.json()
|
|
536
|
+
|
|
537
|
+
return await self._retry.run(_call)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# Convenience factory
|
|
541
|
+
def llm_from_env() -> GenericLLMClient:
|
|
542
|
+
return GenericLLMClient()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from ..secrets.base import Secrets
|
|
7
|
+
from .generic_client import GenericLLMClient
|
|
8
|
+
from .providers import Provider
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("aethergraph.services.llm")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LLMService:
|
|
14
|
+
"""Holds multiple LLM clients (default + named profiles)."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, clients: dict[str, GenericLLMClient], secrets: Secrets | None = None):
|
|
17
|
+
self._clients = clients
|
|
18
|
+
self._secrets = secrets
|
|
19
|
+
|
|
20
|
+
def get(self, name: str = "default") -> GenericLLMClient:
|
|
21
|
+
return self._clients[name]
|
|
22
|
+
|
|
23
|
+
def has(self, name: str) -> bool:
|
|
24
|
+
return name in self._clients
|
|
25
|
+
|
|
26
|
+
async def aclose(self):
|
|
27
|
+
for c in self._clients.values():
|
|
28
|
+
await c.aclose()
|
|
29
|
+
|
|
30
|
+
# --- Runtime profile helpers ---------------------------------
|
|
31
|
+
def configure_profile(
|
|
32
|
+
self,
|
|
33
|
+
profile: str = "default",
|
|
34
|
+
*,
|
|
35
|
+
provider: Provider | None = None,
|
|
36
|
+
model: str | None = None,
|
|
37
|
+
embed_model: str | None = None,
|
|
38
|
+
base_url: str | None = None,
|
|
39
|
+
api_key: str | None = None,
|
|
40
|
+
azure_deployment: str | None = None,
|
|
41
|
+
timeout: float | None = None,
|
|
42
|
+
) -> GenericLLMClient:
|
|
43
|
+
"""
|
|
44
|
+
Create or update a profile in memory. Returns the client.
|
|
45
|
+
Does NOT persist anything outside this process.
|
|
46
|
+
"""
|
|
47
|
+
if profile not in self._clients:
|
|
48
|
+
client = GenericLLMClient(
|
|
49
|
+
provider=provider,
|
|
50
|
+
model=model,
|
|
51
|
+
embed_model=embed_model,
|
|
52
|
+
base_url=base_url,
|
|
53
|
+
api_key=api_key,
|
|
54
|
+
azure_deployment=azure_deployment,
|
|
55
|
+
timeout=timeout or 60.0,
|
|
56
|
+
)
|
|
57
|
+
self._clients[profile] = client
|
|
58
|
+
return client
|
|
59
|
+
|
|
60
|
+
c = self._clients[profile]
|
|
61
|
+
if provider is not None:
|
|
62
|
+
c.provider = provider # type: ignore[assignment]
|
|
63
|
+
if model is not None:
|
|
64
|
+
c.model = model
|
|
65
|
+
if base_url is not None:
|
|
66
|
+
c.base_url = base_url
|
|
67
|
+
if api_key is not None:
|
|
68
|
+
c.api_key = api_key
|
|
69
|
+
if azure_deployment is not None:
|
|
70
|
+
c.azure_deployment = azure_deployment
|
|
71
|
+
if timeout is not None:
|
|
72
|
+
# Recreate client with new timeout
|
|
73
|
+
old_client = c._client
|
|
74
|
+
c._client = httpx.AsyncClient(timeout=timeout)
|
|
75
|
+
try:
|
|
76
|
+
# best-effort async close
|
|
77
|
+
asyncio.create_task(old_client.aclose())
|
|
78
|
+
except RuntimeError:
|
|
79
|
+
logger.warning("Failed to close old httpx client")
|
|
80
|
+
return c
|
|
81
|
+
|
|
82
|
+
# --- Quick start helpers ---
|
|
83
|
+
def set_key(
|
|
84
|
+
self, provider: str, model: str, api_key: str, profile: str = "default"
|
|
85
|
+
) -> GenericLLMClient:
|
|
86
|
+
"""
|
|
87
|
+
Quickly set/override an API key for a profile at runtime (in-memory).
|
|
88
|
+
Creates the profile if it doesn't exist yet.
|
|
89
|
+
"""
|
|
90
|
+
return self.configure_profile(
|
|
91
|
+
profile=profile,
|
|
92
|
+
provider=provider, # type: ignore[arg-type]
|
|
93
|
+
model=model,
|
|
94
|
+
api_key=api_key,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def persist_key(self, secret_name: str, api_key: str):
|
|
98
|
+
"""
|
|
99
|
+
Optional: store the key via the installed Secrets provider for later runs.
|
|
100
|
+
Implement only after Secrets supports write (e.g., dev file store). Env-based usually won't.
|
|
101
|
+
"""
|
|
102
|
+
raise NotImplementedError("persist_key not implemented in this Secrets provider")
|
|
103
|
+
if not self._secrets or not hasattr(self._secrets, "set"):
|
|
104
|
+
raise RuntimeError("Secrets provider is not writable")
|
|
105
|
+
self._secrets.set(secret_name, api_key) # type: ignore[attr-defined]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class LogContext:
|
|
11
|
+
run_id: str | None = None
|
|
12
|
+
node_id: str | None = None
|
|
13
|
+
graph_id: str | None = None
|
|
14
|
+
agent_id: str | None = None
|
|
15
|
+
|
|
16
|
+
def as_extra(self) -> Mapping[str, Any]:
|
|
17
|
+
# Only include non-None fields; logging.Formatter will lookup keys by name.
|
|
18
|
+
return {k: v for k, v in self.__dict__.items() if v is not None}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LoggerService(Protocol):
|
|
22
|
+
"""Contract used by the rest of the system (NodeContext, schedulers, etc.)."""
|
|
23
|
+
|
|
24
|
+
def base(self) -> logging.Logger: ...
|
|
25
|
+
def for_namespace(self, ns: str) -> logging.Logger: ...
|
|
26
|
+
def with_context(self, logger: logging.Logger, ctx: LogContext) -> logging.Logger: ...
|
|
27
|
+
|
|
28
|
+
# Back-compat helpers
|
|
29
|
+
def for_node(self, node_id: str) -> logging.Logger: ...
|
|
30
|
+
def for_run(self) -> logging.Logger: ...
|
|
31
|
+
def for_inspect(self) -> logging.Logger: ...
|
|
32
|
+
def for_scheduler(self) -> logging.Logger: ...
|
|
33
|
+
def for_node_ctx(
|
|
34
|
+
self, *, run_id: str, node_id: str, graph_id: str | None = None
|
|
35
|
+
) -> logging.Logger: ...
|
|
36
|
+
def for_run_ctx(self, *, run_id: str, graph_id: str | None = None) -> logging.Logger: ...
|