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.
Files changed (182) hide show
  1. aethergraph/__init__.py +49 -0
  2. aethergraph/config/__init__.py +0 -0
  3. aethergraph/config/config.py +121 -0
  4. aethergraph/config/context.py +16 -0
  5. aethergraph/config/llm.py +26 -0
  6. aethergraph/config/loader.py +60 -0
  7. aethergraph/config/runtime.py +9 -0
  8. aethergraph/contracts/errors/errors.py +44 -0
  9. aethergraph/contracts/services/artifacts.py +142 -0
  10. aethergraph/contracts/services/channel.py +72 -0
  11. aethergraph/contracts/services/continuations.py +23 -0
  12. aethergraph/contracts/services/eventbus.py +12 -0
  13. aethergraph/contracts/services/kv.py +24 -0
  14. aethergraph/contracts/services/llm.py +17 -0
  15. aethergraph/contracts/services/mcp.py +22 -0
  16. aethergraph/contracts/services/memory.py +108 -0
  17. aethergraph/contracts/services/resume.py +28 -0
  18. aethergraph/contracts/services/state_stores.py +33 -0
  19. aethergraph/contracts/services/wakeup.py +28 -0
  20. aethergraph/core/execution/base_scheduler.py +77 -0
  21. aethergraph/core/execution/forward_scheduler.py +777 -0
  22. aethergraph/core/execution/global_scheduler.py +634 -0
  23. aethergraph/core/execution/retry_policy.py +22 -0
  24. aethergraph/core/execution/step_forward.py +411 -0
  25. aethergraph/core/execution/step_result.py +18 -0
  26. aethergraph/core/execution/wait_types.py +72 -0
  27. aethergraph/core/graph/graph_builder.py +192 -0
  28. aethergraph/core/graph/graph_fn.py +219 -0
  29. aethergraph/core/graph/graph_io.py +67 -0
  30. aethergraph/core/graph/graph_refs.py +154 -0
  31. aethergraph/core/graph/graph_spec.py +115 -0
  32. aethergraph/core/graph/graph_state.py +59 -0
  33. aethergraph/core/graph/graphify.py +128 -0
  34. aethergraph/core/graph/interpreter.py +145 -0
  35. aethergraph/core/graph/node_handle.py +33 -0
  36. aethergraph/core/graph/node_spec.py +46 -0
  37. aethergraph/core/graph/node_state.py +63 -0
  38. aethergraph/core/graph/task_graph.py +747 -0
  39. aethergraph/core/graph/task_node.py +82 -0
  40. aethergraph/core/graph/utils.py +37 -0
  41. aethergraph/core/graph/visualize.py +239 -0
  42. aethergraph/core/runtime/ad_hoc_context.py +61 -0
  43. aethergraph/core/runtime/base_service.py +153 -0
  44. aethergraph/core/runtime/bind_adapter.py +42 -0
  45. aethergraph/core/runtime/bound_memory.py +69 -0
  46. aethergraph/core/runtime/execution_context.py +220 -0
  47. aethergraph/core/runtime/graph_runner.py +349 -0
  48. aethergraph/core/runtime/lifecycle.py +26 -0
  49. aethergraph/core/runtime/node_context.py +203 -0
  50. aethergraph/core/runtime/node_services.py +30 -0
  51. aethergraph/core/runtime/recovery.py +159 -0
  52. aethergraph/core/runtime/run_registration.py +33 -0
  53. aethergraph/core/runtime/runtime_env.py +157 -0
  54. aethergraph/core/runtime/runtime_registry.py +32 -0
  55. aethergraph/core/runtime/runtime_services.py +224 -0
  56. aethergraph/core/runtime/wakeup_watcher.py +40 -0
  57. aethergraph/core/tools/__init__.py +10 -0
  58. aethergraph/core/tools/builtins/channel_tools.py +194 -0
  59. aethergraph/core/tools/builtins/toolset.py +134 -0
  60. aethergraph/core/tools/toolkit.py +510 -0
  61. aethergraph/core/tools/waitable.py +109 -0
  62. aethergraph/plugins/channel/__init__.py +0 -0
  63. aethergraph/plugins/channel/adapters/__init__.py +0 -0
  64. aethergraph/plugins/channel/adapters/console.py +106 -0
  65. aethergraph/plugins/channel/adapters/file.py +102 -0
  66. aethergraph/plugins/channel/adapters/slack.py +285 -0
  67. aethergraph/plugins/channel/adapters/telegram.py +302 -0
  68. aethergraph/plugins/channel/adapters/webhook.py +104 -0
  69. aethergraph/plugins/channel/adapters/webui.py +134 -0
  70. aethergraph/plugins/channel/routes/__init__.py +0 -0
  71. aethergraph/plugins/channel/routes/console_routes.py +86 -0
  72. aethergraph/plugins/channel/routes/slack_routes.py +49 -0
  73. aethergraph/plugins/channel/routes/telegram_routes.py +26 -0
  74. aethergraph/plugins/channel/routes/webui_routes.py +136 -0
  75. aethergraph/plugins/channel/utils/__init__.py +0 -0
  76. aethergraph/plugins/channel/utils/slack_utils.py +278 -0
  77. aethergraph/plugins/channel/utils/telegram_utils.py +324 -0
  78. aethergraph/plugins/channel/websockets/slack_ws.py +68 -0
  79. aethergraph/plugins/channel/websockets/telegram_polling.py +151 -0
  80. aethergraph/plugins/mcp/fs_server.py +128 -0
  81. aethergraph/plugins/mcp/http_server.py +101 -0
  82. aethergraph/plugins/mcp/ws_server.py +180 -0
  83. aethergraph/plugins/net/http.py +10 -0
  84. aethergraph/plugins/utils/data_io.py +359 -0
  85. aethergraph/runner/__init__.py +5 -0
  86. aethergraph/runtime/__init__.py +62 -0
  87. aethergraph/server/__init__.py +3 -0
  88. aethergraph/server/app_factory.py +84 -0
  89. aethergraph/server/start.py +122 -0
  90. aethergraph/services/__init__.py +10 -0
  91. aethergraph/services/artifacts/facade.py +284 -0
  92. aethergraph/services/artifacts/factory.py +35 -0
  93. aethergraph/services/artifacts/fs_store.py +656 -0
  94. aethergraph/services/artifacts/jsonl_index.py +123 -0
  95. aethergraph/services/artifacts/paths.py +23 -0
  96. aethergraph/services/artifacts/sqlite_index.py +209 -0
  97. aethergraph/services/artifacts/utils.py +124 -0
  98. aethergraph/services/auth/dev.py +16 -0
  99. aethergraph/services/channel/channel_bus.py +293 -0
  100. aethergraph/services/channel/factory.py +44 -0
  101. aethergraph/services/channel/session.py +511 -0
  102. aethergraph/services/channel/wait_helpers.py +57 -0
  103. aethergraph/services/clock/clock.py +9 -0
  104. aethergraph/services/container/default_container.py +320 -0
  105. aethergraph/services/continuations/continuation.py +56 -0
  106. aethergraph/services/continuations/factory.py +34 -0
  107. aethergraph/services/continuations/stores/fs_store.py +264 -0
  108. aethergraph/services/continuations/stores/inmem_store.py +95 -0
  109. aethergraph/services/eventbus/inmem.py +21 -0
  110. aethergraph/services/features/static.py +10 -0
  111. aethergraph/services/kv/ephemeral.py +90 -0
  112. aethergraph/services/kv/factory.py +27 -0
  113. aethergraph/services/kv/layered.py +41 -0
  114. aethergraph/services/kv/sqlite_kv.py +128 -0
  115. aethergraph/services/llm/factory.py +157 -0
  116. aethergraph/services/llm/generic_client.py +542 -0
  117. aethergraph/services/llm/providers.py +3 -0
  118. aethergraph/services/llm/service.py +105 -0
  119. aethergraph/services/logger/base.py +36 -0
  120. aethergraph/services/logger/compat.py +50 -0
  121. aethergraph/services/logger/formatters.py +106 -0
  122. aethergraph/services/logger/std.py +203 -0
  123. aethergraph/services/mcp/helpers.py +23 -0
  124. aethergraph/services/mcp/http_client.py +70 -0
  125. aethergraph/services/mcp/mcp_tools.py +21 -0
  126. aethergraph/services/mcp/registry.py +14 -0
  127. aethergraph/services/mcp/service.py +100 -0
  128. aethergraph/services/mcp/stdio_client.py +70 -0
  129. aethergraph/services/mcp/ws_client.py +115 -0
  130. aethergraph/services/memory/bound.py +106 -0
  131. aethergraph/services/memory/distillers/episode.py +116 -0
  132. aethergraph/services/memory/distillers/rolling.py +74 -0
  133. aethergraph/services/memory/facade.py +633 -0
  134. aethergraph/services/memory/factory.py +78 -0
  135. aethergraph/services/memory/hotlog_kv.py +27 -0
  136. aethergraph/services/memory/indices.py +74 -0
  137. aethergraph/services/memory/io_helpers.py +72 -0
  138. aethergraph/services/memory/persist_fs.py +40 -0
  139. aethergraph/services/memory/resolver.py +152 -0
  140. aethergraph/services/metering/noop.py +4 -0
  141. aethergraph/services/prompts/file_store.py +41 -0
  142. aethergraph/services/rag/chunker.py +29 -0
  143. aethergraph/services/rag/facade.py +593 -0
  144. aethergraph/services/rag/index/base.py +27 -0
  145. aethergraph/services/rag/index/faiss_index.py +121 -0
  146. aethergraph/services/rag/index/sqlite_index.py +134 -0
  147. aethergraph/services/rag/index_factory.py +52 -0
  148. aethergraph/services/rag/parsers/md.py +7 -0
  149. aethergraph/services/rag/parsers/pdf.py +14 -0
  150. aethergraph/services/rag/parsers/txt.py +7 -0
  151. aethergraph/services/rag/utils/hybrid.py +39 -0
  152. aethergraph/services/rag/utils/make_fs_key.py +62 -0
  153. aethergraph/services/redactor/simple.py +16 -0
  154. aethergraph/services/registry/key_parsing.py +44 -0
  155. aethergraph/services/registry/registry_key.py +19 -0
  156. aethergraph/services/registry/unified_registry.py +185 -0
  157. aethergraph/services/resume/multi_scheduler_resume_bus.py +65 -0
  158. aethergraph/services/resume/router.py +73 -0
  159. aethergraph/services/schedulers/registry.py +41 -0
  160. aethergraph/services/secrets/base.py +7 -0
  161. aethergraph/services/secrets/env.py +8 -0
  162. aethergraph/services/state_stores/externalize.py +135 -0
  163. aethergraph/services/state_stores/graph_observer.py +131 -0
  164. aethergraph/services/state_stores/json_store.py +67 -0
  165. aethergraph/services/state_stores/resume_policy.py +119 -0
  166. aethergraph/services/state_stores/serialize.py +249 -0
  167. aethergraph/services/state_stores/utils.py +91 -0
  168. aethergraph/services/state_stores/validate.py +78 -0
  169. aethergraph/services/tracing/noop.py +18 -0
  170. aethergraph/services/waits/wait_registry.py +91 -0
  171. aethergraph/services/wakeup/memory_queue.py +57 -0
  172. aethergraph/services/wakeup/scanner_producer.py +56 -0
  173. aethergraph/services/wakeup/worker.py +31 -0
  174. aethergraph/tools/__init__.py +25 -0
  175. aethergraph/utils/optdeps.py +8 -0
  176. aethergraph-0.1.0a1.dist-info/METADATA +410 -0
  177. aethergraph-0.1.0a1.dist-info/RECORD +182 -0
  178. aethergraph-0.1.0a1.dist-info/WHEEL +5 -0
  179. aethergraph-0.1.0a1.dist-info/entry_points.txt +2 -0
  180. aethergraph-0.1.0a1.dist-info/licenses/LICENSE +176 -0
  181. aethergraph-0.1.0a1.dist-info/licenses/NOTICE +31 -0
  182. 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,3 @@
1
+ from typing import Literal
2
+
3
+ Provider = Literal["openai", "azure", "anthropic", "google", "openrouter", "lmstudio", "ollama"]
@@ -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: ...