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,95 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from datetime import datetime, timezone
5
+ import hashlib
6
+ import hmac
7
+ import os
8
+ import threading
9
+
10
+ from ..continuation import Continuation, Correlator
11
+
12
+
13
+ class InMemoryContinuationStore: # implements AsyncContinuationStore
14
+ def __init__(self, secret: bytes | None = None):
15
+ self.secret = secret or os.urandom(32)
16
+ self._by_token: dict[str, Continuation] = {}
17
+ self._by_run_node: dict[tuple[str, str], Continuation] = {}
18
+ self._corr_index: dict[tuple[str, str, str, str], list[str]] = defaultdict(list)
19
+ self._lock = threading.RLock()
20
+
21
+ def _hmac(self, *parts: str) -> str:
22
+ h = hmac.new(self.secret, digestmod=hashlib.sha256)
23
+ for part in parts:
24
+ h.update(part.encode("utf-8"))
25
+ return h.hexdigest()
26
+
27
+ async def mint_token(self, run_id: str, node_id: str, attempts: int) -> str:
28
+ return self._hmac(run_id, node_id, str(attempts), os.urandom(8).hex())
29
+
30
+ async def save(self, cont: Continuation) -> None:
31
+ with self._lock:
32
+ self._by_token[cont.token] = cont
33
+ self._by_run_node[(cont.run_id, cont.node_id)] = cont
34
+
35
+ async def get(self, run_id: str, node_id: str) -> Continuation | None:
36
+ with self._lock:
37
+ return self._by_run_node.get((run_id, node_id))
38
+
39
+ async def delete(self, run_id: str, node_id: str) -> None:
40
+ with self._lock:
41
+ c = self._by_run_node.pop((run_id, node_id), None)
42
+ if c:
43
+ self._by_token.pop(c.token, None)
44
+
45
+ async def get_by_token(self, token: str) -> Continuation | None:
46
+ with self._lock:
47
+ return self._by_token.get(token)
48
+
49
+ async def mark_closed(self, token: str) -> None:
50
+ with self._lock:
51
+ c = self._by_token.get(token)
52
+ if c:
53
+ c.closed = True
54
+
55
+ async def verify_token(self, run_id: str, node_id: str, token: str) -> bool:
56
+ c = await self.get(run_id, node_id)
57
+ return bool(c and hmac.compare_digest(c.token, token))
58
+
59
+ async def bind_correlator(self, *, token: str, corr: Correlator) -> None:
60
+ key = corr.key()
61
+ with self._lock:
62
+ toks = self._corr_index[key]
63
+ if token not in toks:
64
+ toks.append(token)
65
+
66
+ async def find_by_correlator(self, *, corr: Correlator) -> Continuation | None:
67
+ with self._lock:
68
+ toks = list(self._corr_index.get(corr.key()) or [])
69
+ for t in reversed(toks):
70
+ c = await self.get_by_token(t)
71
+ if c and not c.closed:
72
+ if c.deadline and datetime.now(timezone.utc) > c.deadline.astimezone(timezone.utc):
73
+ continue
74
+ return c
75
+ return None
76
+
77
+ async def last_open(self, *, channel: str, kind: str) -> Continuation | None:
78
+ with self._lock:
79
+ for c in reversed(list(self._by_token.values())):
80
+ if not c.closed and c.channel == channel and c.kind == kind:
81
+ return c
82
+ return None
83
+
84
+ async def list_waits(self) -> list[dict]:
85
+ with self._lock:
86
+ return [c.to_dict() for c in self._by_token.values() if not c.closed]
87
+
88
+ async def clear(self) -> None:
89
+ with self._lock:
90
+ self._by_token.clear()
91
+ self._by_run_node.clear()
92
+ self._corr_index.clear()
93
+
94
+ async def alias_for(self, token: str) -> str | None:
95
+ return token[:24]
@@ -0,0 +1,21 @@
1
+ # services/eventbus/inmem.py
2
+ import asyncio
3
+ from collections import defaultdict
4
+ import threading
5
+
6
+
7
+ class InMemoryEventBus:
8
+ def __init__(self):
9
+ self._subs = defaultdict(list) # topic -> [handlers]
10
+ self._lock = threading.RLock() # TODO: check if we need thread safety
11
+
12
+ async def publish(self, topic, event):
13
+ # fanout without blocking publishers
14
+ with self._lock:
15
+ subs = list(self._subs.get(topic, []))
16
+ for h in subs:
17
+ asyncio.create_task(h(event)) # fire-and-forget
18
+
19
+ async def subscribe(self, topic, handler):
20
+ with self._lock:
21
+ self._subs[topic].append(handler)
@@ -0,0 +1,10 @@
1
+ # services/features/static.py
2
+ class StaticFeatures:
3
+ def __init__(self, flags: dict[str, bool]):
4
+ self._f = set(k for k, v in flags.items() if v)
5
+
6
+ def has(self, name: str) -> bool:
7
+ return name in self._f
8
+
9
+ def all(self) -> set[str]:
10
+ return set(self._f)
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import threading
5
+ import time
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class KVEntry:
11
+ value: Any
12
+ expire_at: float | None = None
13
+
14
+
15
+ class EphemeralKV:
16
+ """Process-local, transient KV (not for blobs)."""
17
+
18
+ def __init__(self, *, prefix: str = "") -> None:
19
+ self._data: dict[str, KVEntry] = {}
20
+ self._lock = threading.RLock()
21
+ self._prefix = prefix
22
+
23
+ def _k(self, k: str) -> str:
24
+ return f"{self._prefix}{k}" if self._prefix else k
25
+
26
+ async def get(self, key: str, default: Any = None) -> Any:
27
+ k = self._k(key)
28
+ with self._lock:
29
+ e = self._data.get(k)
30
+ if not e:
31
+ return default
32
+ if e.expire_at and e.expire_at < time.time():
33
+ del self._data[k]
34
+ return default
35
+ return e.value
36
+
37
+ async def set(self, key: str, value: Any, *, ttl_s: int | None = None) -> None:
38
+ k = self._k(key)
39
+ with self._lock:
40
+ self._data[k] = KVEntry(value=value, expire_at=(time.time() + ttl_s) if ttl_s else None)
41
+
42
+ async def delete(self, key: str) -> None:
43
+ k = self._k(key)
44
+ with self._lock:
45
+ self._data.pop(k, None)
46
+
47
+ async def list_append_unique(
48
+ self, key: str, items: list[dict], *, id_key: str = "id", ttl_s: int | None = None
49
+ ) -> list[dict]:
50
+ k = self._k(key)
51
+ with self._lock:
52
+ cur = list(self._data.get(k, KVEntry([])).value or [])
53
+ seen = {x.get(id_key) for x in cur if isinstance(x, dict)}
54
+ cur.extend([x for x in items if isinstance(x, dict) and x.get(id_key) not in seen])
55
+ self._data[k] = KVEntry(value=cur, expire_at=(time.time() + ttl_s) if ttl_s else None)
56
+ return cur
57
+
58
+ async def list_pop_all(self, key: str) -> list:
59
+ k = self._k(key)
60
+ with self._lock:
61
+ e = self._data.pop(k, None)
62
+ return list(e.value) if e and isinstance(e.value, list) else []
63
+
64
+ # Optional helpers
65
+ async def mget(self, keys: list[str]) -> list[Any]:
66
+ return [await self.get(k) for k in keys]
67
+
68
+ async def mset(self, kv: dict[str, Any], *, ttl_s: int | None = None) -> None:
69
+ for k, v in kv.items():
70
+ await self.set(k, v, ttl_s=ttl_s)
71
+
72
+ async def expire(self, key: str, ttl_s: int) -> None:
73
+ k = self._k(key)
74
+ with self._lock:
75
+ e = self._data.get(k)
76
+ if e:
77
+ e.expire_at = time.time() + ttl_s
78
+
79
+ async def purge_expired(self, limit: int = 1000) -> int:
80
+ n = 0
81
+ now = time.time()
82
+ with self._lock:
83
+ for k in list(self._data.keys()):
84
+ if n >= limit:
85
+ break
86
+ e = self._data.get(k)
87
+ if e and e.expire_at and e.expire_at < now:
88
+ self._data.pop(k, None)
89
+ n += 1
90
+ return n
@@ -0,0 +1,27 @@
1
+ import os
2
+
3
+ from .ephemeral import EphemeralKV
4
+ from .layered import LayeredKV
5
+ from .sqlite_kv import SQLiteKV
6
+
7
+
8
+ def make_kv():
9
+ kind = (os.getenv("KV_BACKEND", "layered")).lower()
10
+ prefix = os.getenv("KV_PREFIX", "") # e.g., tenant/project scoping
11
+
12
+ if kind == "ephemeral":
13
+ return EphemeralKV(prefix=prefix)
14
+
15
+ if kind == "sqlite":
16
+ path = os.getenv("KV_SQLITE_PATH", "./artifacts/kv.sqlite")
17
+ return SQLiteKV(path, prefix=prefix)
18
+
19
+ if kind == "layered":
20
+ cache = EphemeralKV(prefix=prefix)
21
+ durable = SQLiteKV(os.getenv("KV_SQLITE_PATH", "./artifacts/kv.sqlite"), prefix=prefix)
22
+ return LayeredKV(cache, durable)
23
+
24
+ # (future) cloud:
25
+ # if kind == "redis": return RedisKV(...)
26
+
27
+ raise ValueError(f"Unknown KV_BACKEND={kind}")
@@ -0,0 +1,41 @@
1
+ from typing import Any
2
+
3
+
4
+ class LayeredKV:
5
+ """Layered KV combining a fast ephemeral cache (e.g. in-memory) with a durable backend (e.g. SQLite, Redis).
6
+ Gets first check the cache, then the durable store; sets write to both.
7
+ List operations invalidate the cache to avoid staleness.
8
+ """
9
+
10
+ def __init__(self, cache, durable):
11
+ self.cache = cache # EphemeralKV
12
+ self.durable = durable # SQLiteKV / RedisKV
13
+
14
+ async def get(self, key: str, default: Any = None) -> Any:
15
+ v = await self.cache.get(key, None)
16
+ if v is not None:
17
+ return v
18
+ v = await self.durable.get(key, default)
19
+ if v is not None:
20
+ await self.cache.set(key, v, ttl_s=5) # short cache
21
+ return v
22
+
23
+ async def set(self, key: str, value: Any, *, ttl_s: int | None = None) -> None:
24
+ await self.durable.set(key, value, ttl_s=ttl_s)
25
+ await self.cache.set(key, value, ttl_s=min(ttl_s or 5, 5))
26
+
27
+ async def delete(self, key: str) -> None:
28
+ await self.durable.delete(key)
29
+ await self.cache.delete(key)
30
+
31
+ async def list_append_unique(self, *a, **k):
32
+ cur = await self.durable.list_append_unique(*a, **k)
33
+ await self.cache.delete(a[0]) # invalidate
34
+ return cur
35
+
36
+ async def list_pop_all(self, key: str) -> list:
37
+ items = await self.durable.list_pop_all(key)
38
+ await self.cache.delete(key)
39
+ return items
40
+
41
+ # mget/mset/expire/purge can just forward as needed
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sqlite3
6
+ import threading
7
+ import time
8
+ from typing import Any
9
+
10
+
11
+ class SQLiteKV:
12
+ """
13
+ Durable KV with TTL (JSON values).
14
+ Thread-safe via RLock; async callers can await these methods safely.
15
+ """
16
+
17
+ def __init__(self, path: str, *, prefix: str = ""):
18
+ os.makedirs(os.path.dirname(path), exist_ok=True)
19
+ self._db = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
20
+ self._db.execute("PRAGMA journal_mode=WAL;")
21
+ self._db.execute("PRAGMA synchronous=NORMAL;")
22
+ self._db.execute("""
23
+ CREATE TABLE IF NOT EXISTS kv (
24
+ k TEXT PRIMARY KEY,
25
+ v TEXT,
26
+ expire_at REAL
27
+ )
28
+ """)
29
+ self._db.execute("CREATE INDEX IF NOT EXISTS kv_exp_idx ON kv(expire_at);")
30
+ self._lock = threading.RLock()
31
+ self._prefix = prefix
32
+
33
+ def _k(self, k: str) -> str:
34
+ return f"{self._prefix}{k}" if self._prefix else k
35
+
36
+ async def get(self, key: str, default: Any = None) -> Any:
37
+ k = self._k(key)
38
+ with self._lock:
39
+ row = self._db.execute("SELECT v, expire_at FROM kv WHERE k=?", (k,)).fetchone()
40
+ if not row:
41
+ return default
42
+ v_txt, exp = row
43
+ if exp and exp < time.time():
44
+ self._db.execute("DELETE FROM kv WHERE k=?", (k,))
45
+ return default
46
+ try:
47
+ return json.loads(v_txt)
48
+ except Exception:
49
+ return default
50
+
51
+ async def set(self, key: str, value: Any, *, ttl_s: int | None = None) -> None:
52
+ k = self._k(key)
53
+ with self._lock:
54
+ exp = (time.time() + ttl_s) if ttl_s else None
55
+ v_txt = json.dumps(value)
56
+ self._db.execute(
57
+ "INSERT INTO kv(k,v,expire_at) VALUES(?,?,?) "
58
+ "ON CONFLICT(k) DO UPDATE SET v=excluded.v, expire_at=excluded.expire_at",
59
+ (k, v_txt, exp),
60
+ )
61
+
62
+ async def delete(self, key: str) -> None:
63
+ k = self._k(key)
64
+ with self._lock:
65
+ self._db.execute("DELETE FROM kv WHERE k=?", (k,))
66
+
67
+ async def list_append_unique(
68
+ self, key: str, items: list, *, id_key: str = "id", ttl_s: int | None = None
69
+ ) -> list:
70
+ k = self._k(key)
71
+ with self._lock:
72
+ row = self._db.execute("SELECT v FROM kv WHERE k=?", (k,)).fetchone()
73
+ cur = []
74
+ if row and row[0]:
75
+ try:
76
+ cur = json.loads(row[0])
77
+ except Exception:
78
+ cur = []
79
+ seen = {x.get(id_key) for x in cur if isinstance(x, dict)}
80
+ cur.extend([x for x in items if isinstance(x, dict) and x.get(id_key) not in seen])
81
+ exp = (time.time() + ttl_s) if ttl_s else None
82
+ self._db.execute(
83
+ "INSERT INTO kv(k,v,expire_at) VALUES(?,?,?) "
84
+ "ON CONFLICT(k) DO UPDATE SET v=excluded.v, expire_at=excluded.expire_at",
85
+ (k, json.dumps(cur), exp),
86
+ )
87
+ return cur
88
+
89
+ async def list_pop_all(self, key: str) -> list:
90
+ k = self._k(key)
91
+ with self._lock:
92
+ row = self._db.execute("SELECT v FROM kv WHERE k=?", (k,)).fetchone()
93
+ self._db.execute("DELETE FROM kv WHERE k=?", (k,))
94
+ if not row or not row[0]:
95
+ return []
96
+ try:
97
+ val = json.loads(row[0])
98
+ return list(val) if isinstance(val, list) else []
99
+ except Exception:
100
+ return []
101
+
102
+ # Optional helpers
103
+ async def mget(self, keys: list[str]) -> list[Any]:
104
+ out = []
105
+ for k in keys:
106
+ out.append(await self.get(k))
107
+ return out
108
+
109
+ async def mset(self, kv: dict[str, Any], *, ttl_s: int | None = None) -> None:
110
+ for k, v in kv.items():
111
+ await self.set(k, v, ttl_s=ttl_s)
112
+
113
+ async def expire(self, key: str, ttl_s: int) -> None:
114
+ k = self._k(key)
115
+ with self._lock:
116
+ self._db.execute("UPDATE kv SET expire_at=? WHERE k=?", (time.time() + ttl_s, k))
117
+
118
+ async def purge_expired(self, limit: int = 1000) -> int:
119
+ with self._lock:
120
+ now = time.time()
121
+ # sqlite lacks DELETE .. LIMIT in older versions; do it in two steps
122
+ rows = self._db.execute(
123
+ "SELECT k FROM kv WHERE expire_at IS NOT NULL AND expire_at < ? LIMIT ?",
124
+ (now, limit),
125
+ ).fetchall()
126
+ for (k,) in rows:
127
+ self._db.execute("DELETE FROM kv WHERE k=?", (k,))
128
+ return len(rows)
@@ -0,0 +1,157 @@
1
+ import logging
2
+ import os
3
+
4
+ from pydantic import SecretStr
5
+
6
+ from aethergraph.config.llm import LLMProfile, LLMSettings
7
+
8
+ from ..secrets.base import Secrets
9
+ from .generic_client import GenericLLMClient
10
+ from .providers import Provider
11
+
12
+
13
+ def _resolve_key(direct: SecretStr | None, ref: str | None, secrets: Secrets) -> str | None:
14
+ if direct:
15
+ return direct.get_secret_value()
16
+ if ref:
17
+ return secrets.get(ref)
18
+ return None
19
+
20
+
21
+ def _provider_default_base_url(provider: Provider) -> str | None:
22
+ # Fallback base URLs if not given in config or env
23
+ if provider == "openai":
24
+ return "https://api.openai.com/v1"
25
+ if provider == "azure":
26
+ # Must still rely on env/config for endpoint
27
+ return os.getenv("AZURE_OPENAI_ENDPOINT", "").rstrip("/") or None
28
+ if provider == "anthropic":
29
+ return "https://api.anthropic.com"
30
+ if provider == "google":
31
+ return "https://generativelanguage.googleapis.com"
32
+ if provider == "openrouter":
33
+ return "https://openrouter.ai/api/v1"
34
+ if provider == "lmstudio":
35
+ return os.getenv("LMSTUDIO_BASE_URL", "http://localhost:1234/v1")
36
+ if provider == "ollama":
37
+ return os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
38
+ return None
39
+
40
+
41
+ def _apply_env_overrides_to_profile(
42
+ name: str,
43
+ p: LLMProfile,
44
+ *,
45
+ is_default: bool,
46
+ secrets: Secrets,
47
+ ) -> LLMProfile:
48
+ """
49
+ Mutate + return profile with env-based overrides.
50
+ - For the default profile, allow generic LLM_* env vars.
51
+ - For all profiles, fill missing base_url / api_key from provider-specific env.
52
+ """
53
+ # 1) Generic overrides for DEFAULT profile (if user wants a quick global switch)
54
+ if is_default:
55
+ provider_env = os.getenv("LLM_PROVIDER")
56
+ model_env = os.getenv("LLM_MODEL")
57
+ base_env = os.getenv("LLM_BASE_URL")
58
+ timeout_env = os.getenv("LLM_TIMEOUT")
59
+
60
+ if provider_env:
61
+ p.provider = provider_env.lower() # type: ignore[assignment]
62
+ if model_env:
63
+ p.model = model_env
64
+ if base_env:
65
+ p.base_url = base_env
66
+ if timeout_env:
67
+ try:
68
+ p.timeout = float(timeout_env)
69
+ except ValueError:
70
+ logger = logging.getLogger("aethergraph.services.llm")
71
+ logger.warning(f"Invalid LLM_TIMEOUT value: {timeout_env}")
72
+
73
+ # 2) Provider-specific base_url fallback
74
+ if not p.base_url:
75
+ p.base_url = _provider_default_base_url(p.provider) # type: ignore[arg-type]
76
+
77
+ # 3) API key resolution:
78
+ # - prefer explicit api_key on profile
79
+ # - else api_key_ref + Secrets
80
+ # - else provider-specific env name
81
+ api_key = _resolve_key(p.api_key, p.api_key_ref, secrets)
82
+
83
+ if not api_key:
84
+ # Fallback to provider-specific env if nothing else was set
85
+ if p.provider == "openai":
86
+ api_key = os.getenv("OPENAI_API_KEY")
87
+ elif p.provider == "anthropic":
88
+ api_key = os.getenv("ANTHROPIC_API_KEY")
89
+ elif p.provider == "google":
90
+ api_key = os.getenv("GOOGLE_API_KEY")
91
+ elif p.provider == "openrouter":
92
+ api_key = os.getenv("OPENROUTER_API_KEY")
93
+ elif p.provider == "azure":
94
+ api_key = os.getenv("AZURE_OPENAI_KEY")
95
+
96
+ # If we found one, and no api_key_ref was configured, we can
97
+ # optionally set api_key_ref so it's visible in config
98
+ if api_key and not p.api_key_ref:
99
+ # Optional: record that this profile is using that env key
100
+ p.api_key_ref = {
101
+ "openai": "OPENAI_API_KEY",
102
+ "anthropic": "ANTHROPIC_API_KEY",
103
+ "google": "GOOGLE_API_KEY",
104
+ "openrouter": "OPENROUTER_API_KEY",
105
+ "azure": "AZURE_OPENAI_KEY",
106
+ }.get(p.provider, None) # type: ignore[index]
107
+
108
+ # Finally, store the resolved key back into api_key for the client factory
109
+ if api_key:
110
+ p.api_key = SecretStr(api_key)
111
+
112
+ return p
113
+
114
+
115
+ def client_from_profile(p: LLMProfile, secrets: Secrets) -> GenericLLMClient:
116
+ # At this point, _apply_env_overrides_to_profile has already filled
117
+ # p.base_url, p.api_key, etc. as much as possible.
118
+ api_key = _resolve_key(p.api_key, p.api_key_ref, secrets)
119
+
120
+ return GenericLLMClient(
121
+ provider=p.provider,
122
+ model=p.model,
123
+ embed_model=p.embed_model,
124
+ base_url=p.base_url,
125
+ api_key=api_key,
126
+ azure_deployment=p.azure_deployment,
127
+ timeout=p.timeout,
128
+ )
129
+
130
+
131
+ def build_llm_clients(cfg: LLMSettings, secrets: Secrets) -> dict[str, GenericLLMClient]:
132
+ """Returns dict of {profile_name: client}, always includes 'default' if enabled."""
133
+ if not cfg.enabled:
134
+ return {}
135
+
136
+ # Mutate cfg.llm.default in-place with env defaults
137
+ default_profile = _apply_env_overrides_to_profile(
138
+ name="default",
139
+ p=cfg.default,
140
+ is_default=True,
141
+ secrets=secrets,
142
+ )
143
+ clients: dict[str, GenericLLMClient] = {
144
+ "default": client_from_profile(default_profile, secrets)
145
+ }
146
+
147
+ # Extra profiles
148
+ for name, prof in (cfg.profiles or {}).items():
149
+ prof = _apply_env_overrides_to_profile(
150
+ name=name,
151
+ p=prof,
152
+ is_default=False,
153
+ secrets=secrets,
154
+ )
155
+ clients[name] = client_from_profile(prof, secrets)
156
+
157
+ return clients