loop-memory 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loop_memory/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- loop_memory-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""Pluggable LLM providers for memory consolidation.
|
|
2
|
+
|
|
3
|
+
The pipeline talks to any LLM through this small abstraction, so
|
|
4
|
+
the same loop_memory / consolidate / score / summarize machinery
|
|
5
|
+
works against:
|
|
6
|
+
|
|
7
|
+
* OpenAI-compatible HTTP APIs (OpenAI, Azure, OpenRouter, vLLM,
|
|
8
|
+
LM Studio, llama.cpp server) - ``OpenAICompatProvider``
|
|
9
|
+
* Anthropic Messages API - ``AnthropicProvider``
|
|
10
|
+
* Ollama's ``/api/chat`` - ``OllamaProvider``
|
|
11
|
+
* A no-key offline fallback - ``RuleBasedProvider`` (the default
|
|
12
|
+
when the user has not configured anything)
|
|
13
|
+
|
|
14
|
+
Adding a new provider is one class with a ``complete()`` method
|
|
15
|
+
that turns a ``ChatHistory`` into a string.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import re
|
|
23
|
+
import os
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from .base import ChatHistory, LLMClient
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ProviderSpec:
|
|
36
|
+
"""Static metadata about a provider for the UI dropdown."""
|
|
37
|
+
id: str
|
|
38
|
+
label: str
|
|
39
|
+
default_model: str
|
|
40
|
+
needs_api_key: bool
|
|
41
|
+
needs_base_url: bool
|
|
42
|
+
default_base_url: str | None = None
|
|
43
|
+
description: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
PROVIDERS: dict[str, ProviderSpec] = {
|
|
47
|
+
"MiniMax": ProviderSpec(
|
|
48
|
+
id="MiniMax",
|
|
49
|
+
label="MiniMax",
|
|
50
|
+
default_model="MiniMax-M2.7",
|
|
51
|
+
needs_api_key=True,
|
|
52
|
+
needs_base_url=True,
|
|
53
|
+
default_base_url="https://api.minimaxi.com/v1",
|
|
54
|
+
description="MiniMax (https://platform.minimaxi.com) — recommended default",
|
|
55
|
+
),
|
|
56
|
+
"openai": ProviderSpec(
|
|
57
|
+
id="openai",
|
|
58
|
+
label="OpenAI-compatible",
|
|
59
|
+
default_model="gpt-4o-mini",
|
|
60
|
+
needs_api_key=True,
|
|
61
|
+
needs_base_url=True,
|
|
62
|
+
default_base_url="https://api.openai.com/v1",
|
|
63
|
+
description="OpenAI / Azure OpenAI / OpenRouter / vLLM / LM Studio / llama.cpp server",
|
|
64
|
+
),
|
|
65
|
+
"anthropic": ProviderSpec(
|
|
66
|
+
id="anthropic",
|
|
67
|
+
label="Anthropic",
|
|
68
|
+
default_model="claude-3-5-haiku-latest",
|
|
69
|
+
needs_api_key=True,
|
|
70
|
+
needs_base_url=False,
|
|
71
|
+
default_base_url="https://api.anthropic.com",
|
|
72
|
+
description="Anthropic Claude (Messages API)",
|
|
73
|
+
),
|
|
74
|
+
"ollama": ProviderSpec(
|
|
75
|
+
id="ollama",
|
|
76
|
+
label="Ollama (local)",
|
|
77
|
+
default_model="qwen2.5:7b",
|
|
78
|
+
needs_api_key=False,
|
|
79
|
+
needs_base_url=True,
|
|
80
|
+
default_base_url="http://127.0.0.1:11434",
|
|
81
|
+
description="Local Ollama daemon - no API key, fully offline",
|
|
82
|
+
),
|
|
83
|
+
"echo": ProviderSpec(
|
|
84
|
+
id="echo",
|
|
85
|
+
label="Rule-based (offline, no LLM)",
|
|
86
|
+
default_model="rules",
|
|
87
|
+
needs_api_key=False,
|
|
88
|
+
needs_base_url=False,
|
|
89
|
+
description="Deterministic rules - no network, no API cost, decent baseline",
|
|
90
|
+
),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class LLMHttpError(RuntimeError):
|
|
95
|
+
"""HTTP error from an LLM provider with structured fields attached.
|
|
96
|
+
|
|
97
|
+
Attributes
|
|
98
|
+
----------
|
|
99
|
+
status : int
|
|
100
|
+
HTTP status code (e.g. 401, 429, 500).
|
|
101
|
+
url : str
|
|
102
|
+
The endpoint that was called.
|
|
103
|
+
raw : str
|
|
104
|
+
Raw response body (truncated to 500 chars).
|
|
105
|
+
provider_code : str | None
|
|
106
|
+
Provider-specific error code when the body is JSON, e.g. MiniMax's
|
|
107
|
+
``2049`` ("invalid api key") or ``1004`` ("Please carry the API key").
|
|
108
|
+
provider_message : str | None
|
|
109
|
+
Human-readable provider error message when present.
|
|
110
|
+
body_json : dict | None
|
|
111
|
+
The full parsed JSON body when the response is JSON, else None.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, status: int, url: str, raw: str) -> None:
|
|
115
|
+
self.status = int(status)
|
|
116
|
+
self.url = url
|
|
117
|
+
self.raw = (raw or "")[:500]
|
|
118
|
+
self.body_json: dict | None = None
|
|
119
|
+
self.provider_code: str | None = None
|
|
120
|
+
self.provider_message: str | None = None
|
|
121
|
+
try:
|
|
122
|
+
parsed = json.loads(self.raw)
|
|
123
|
+
if isinstance(parsed, dict):
|
|
124
|
+
self.body_json = parsed
|
|
125
|
+
err = parsed.get("error") or {}
|
|
126
|
+
# Extract a numeric MiniMax/OpenAI code when present in the
|
|
127
|
+
# message itself ("invalid api key (2049)"). This is the
|
|
128
|
+
# code the user will search the docs for.
|
|
129
|
+
msg = None
|
|
130
|
+
if isinstance(err, dict):
|
|
131
|
+
self.provider_code = (
|
|
132
|
+
str(err.get("code"))
|
|
133
|
+
or str(err.get("type"))
|
|
134
|
+
or None
|
|
135
|
+
)
|
|
136
|
+
msg = err.get("message")
|
|
137
|
+
elif isinstance(err, str):
|
|
138
|
+
msg = err
|
|
139
|
+
if msg:
|
|
140
|
+
self.provider_message = msg
|
|
141
|
+
m = re.search(r"\((\d{3,5})\)", msg)
|
|
142
|
+
if m and not (self.provider_code and self.provider_code.isdigit()):
|
|
143
|
+
self.provider_code = m.group(1)
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
msg = f"LLM HTTP {self.status}"
|
|
147
|
+
if self.provider_message:
|
|
148
|
+
msg += f": {self.provider_message[:200]}"
|
|
149
|
+
if self.provider_code:
|
|
150
|
+
msg += f" (code={self.provider_code})"
|
|
151
|
+
super().__init__(msg)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _http_post_json(url: str, body: dict, headers: dict, timeout: float) -> dict:
|
|
155
|
+
data = json.dumps(body).encode("utf-8")
|
|
156
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
157
|
+
try:
|
|
158
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
159
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
160
|
+
except urllib.error.HTTPError as e:
|
|
161
|
+
err = e.read().decode("utf-8", "replace")
|
|
162
|
+
log.error("LLM HTTP %s @ %s: %s", e.code, url, err[:400])
|
|
163
|
+
raise LLMHttpError(e.code, url, err)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class OpenAICompatProvider(LLMClient):
|
|
167
|
+
"""OpenAI-compatible chat completions client.
|
|
168
|
+
|
|
169
|
+
Works against any server that exposes POST /chat/completions
|
|
170
|
+
with model+messages and returns choices[0].message.content.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
model: str = "gpt-4o-mini",
|
|
176
|
+
api_key: str | None = None,
|
|
177
|
+
base_url: str = "https://api.openai.com/v1",
|
|
178
|
+
# Default raised 20s → 60s in v2: with max_output_tokens=4096 the
|
|
179
|
+
# server can take well over 30s to stream a long wiki body.
|
|
180
|
+
timeout: float = 60.0,
|
|
181
|
+
) -> None:
|
|
182
|
+
self.model = model
|
|
183
|
+
self.api_key = api_key or os.environ.get("OPENAI_API_KEY") or os.environ.get("LOOP_MEMORY_API_KEY")
|
|
184
|
+
# OPENAI_BASE_URL lets users point the OpenAI-compatible client at
|
|
185
|
+
# a self-hosted proxy (vLLM, LM Studio, llama.cpp server,
|
|
186
|
+
# OpenRouter) without passing --base-url. Mirrors the official
|
|
187
|
+
# OpenAI SDK env-var convention and matches the other
|
|
188
|
+
# OpenAI-compatible providers that already read this variable
|
|
189
|
+
# (see mem0ai/mem0#6322). Explicit constructor argument still
|
|
190
|
+
# wins, so every existing call site is unchanged.
|
|
191
|
+
env_base_url = os.environ.get("OPENAI_BASE_URL") or os.environ.get("OPENAI_API_BASE")
|
|
192
|
+
effective_base_url = base_url or env_base_url or "https://api.openai.com/v1"
|
|
193
|
+
self.base_url = effective_base_url.rstrip("/")
|
|
194
|
+
self.timeout = timeout
|
|
195
|
+
|
|
196
|
+
def complete(self, history: ChatHistory, **kwargs) -> str:
|
|
197
|
+
msgs: list[dict[str, str]] = []
|
|
198
|
+
if history.system:
|
|
199
|
+
msgs.append({"role": "system", "content": history.system})
|
|
200
|
+
for m in history.messages:
|
|
201
|
+
msgs.append({"role": m.role, "content": m.content})
|
|
202
|
+
body = {
|
|
203
|
+
"model": self.model,
|
|
204
|
+
"messages": msgs,
|
|
205
|
+
"temperature": float(kwargs.get("temperature", 0.3)),
|
|
206
|
+
"max_tokens": int(kwargs.get("max_tokens", 800)),
|
|
207
|
+
}
|
|
208
|
+
url = self.base_url + "/chat/completions"
|
|
209
|
+
headers = {"Content-Type": "application/json"}
|
|
210
|
+
if self.api_key:
|
|
211
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
212
|
+
data = _http_post_json(url, body, headers, self.timeout)
|
|
213
|
+
return (data.get("choices", [{}])[0].get("message", {}) or {}).get("content", "") or ""
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class AnthropicProvider(LLMClient):
|
|
217
|
+
"""Anthropic Messages API (claude-3.x)."""
|
|
218
|
+
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
model: str = "claude-3-5-haiku-latest",
|
|
222
|
+
api_key: str | None = None,
|
|
223
|
+
base_url: str = "https://api.anthropic.com",
|
|
224
|
+
timeout: float = 60.0,
|
|
225
|
+
) -> None:
|
|
226
|
+
self.model = model
|
|
227
|
+
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
228
|
+
self.base_url = (base_url or "https://api.anthropic.com").rstrip("/")
|
|
229
|
+
self.timeout = timeout
|
|
230
|
+
|
|
231
|
+
def complete(self, history: ChatHistory, **kwargs) -> str:
|
|
232
|
+
sys_prompt = history.system or ""
|
|
233
|
+
msgs: list[dict[str, str]] = []
|
|
234
|
+
for m in history.messages:
|
|
235
|
+
if m.role == "system":
|
|
236
|
+
sys_prompt += "\n" + m.content
|
|
237
|
+
continue
|
|
238
|
+
msgs.append({"role": m.role, "content": m.content})
|
|
239
|
+
body = {
|
|
240
|
+
"model": self.model,
|
|
241
|
+
"system": sys_prompt or "You are a helpful assistant.",
|
|
242
|
+
"messages": msgs,
|
|
243
|
+
"max_tokens": int(kwargs.get("max_tokens", 800)),
|
|
244
|
+
"temperature": float(kwargs.get("temperature", 0.3)),
|
|
245
|
+
}
|
|
246
|
+
url = self.base_url + "/v1/messages"
|
|
247
|
+
headers = {
|
|
248
|
+
"Content-Type": "application/json",
|
|
249
|
+
"x-api-key": self.api_key or "",
|
|
250
|
+
"anthropic-version": "2023-06-01",
|
|
251
|
+
}
|
|
252
|
+
data = _http_post_json(url, body, headers, self.timeout)
|
|
253
|
+
content = data.get("content") or []
|
|
254
|
+
parts = [c.get("text", "") for c in content if c.get("type") == "text"]
|
|
255
|
+
return "\n".join(parts).strip()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class OllamaProvider(LLMClient):
|
|
259
|
+
"""Ollama /api/chat - local LLMs with no API key."""
|
|
260
|
+
|
|
261
|
+
def __init__(
|
|
262
|
+
self,
|
|
263
|
+
model: str = "qwen2.5:7b",
|
|
264
|
+
base_url: str = "http://127.0.0.1:11434",
|
|
265
|
+
timeout: float = 120.0,
|
|
266
|
+
) -> None:
|
|
267
|
+
self.model = model
|
|
268
|
+
self.base_url = (base_url or "http://127.0.0.1:11434").rstrip("/")
|
|
269
|
+
self.timeout = timeout
|
|
270
|
+
|
|
271
|
+
def complete(self, history: ChatHistory, **kwargs) -> str:
|
|
272
|
+
msgs: list[dict[str, str]] = []
|
|
273
|
+
if history.system:
|
|
274
|
+
msgs.append({"role": "system", "content": history.system})
|
|
275
|
+
for m in history.messages:
|
|
276
|
+
msgs.append({"role": m.role, "content": m.content})
|
|
277
|
+
body = {
|
|
278
|
+
"model": self.model,
|
|
279
|
+
"messages": msgs,
|
|
280
|
+
"stream": False,
|
|
281
|
+
"options": {
|
|
282
|
+
"temperature": float(kwargs.get("temperature", 0.3)),
|
|
283
|
+
"num_predict": int(kwargs.get("max_tokens", 800)),
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
url = self.base_url + "/api/chat"
|
|
287
|
+
data = _http_post_json(url, body, {"Content-Type": "application/json"}, self.timeout)
|
|
288
|
+
return (data.get("message") or {}).get("content") or ""
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class RuleBasedProvider(LLMClient):
|
|
292
|
+
"""A deterministic, zero-network fallback.
|
|
293
|
+
|
|
294
|
+
Used when the user has not configured an LLM. The consolidator
|
|
295
|
+
already runs deterministic rules for noise filtering, so this
|
|
296
|
+
provider mostly returns a short placeholder so the LLM-call
|
|
297
|
+
site in the pipeline still has something to parse.
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
model = "rules"
|
|
301
|
+
|
|
302
|
+
def __init__(self, min_chars: int = 6) -> None:
|
|
303
|
+
self.min_chars = min_chars
|
|
304
|
+
|
|
305
|
+
def complete(self, history: ChatHistory, **kwargs) -> str:
|
|
306
|
+
last_user = ""
|
|
307
|
+
for m in reversed(history.messages):
|
|
308
|
+
if m.role == "user":
|
|
309
|
+
last_user = m.content
|
|
310
|
+
break
|
|
311
|
+
return f"(rules) {last_user[:120]}"
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def resolve_api_key(config: dict[str, Any]) -> str | None:
|
|
315
|
+
"""Look up the API key for a provider through the secret backend.
|
|
316
|
+
|
|
317
|
+
The settings JSON never contains the key itself — only an
|
|
318
|
+
``api_key_account`` field like ``"llm/openai/api_key"`` and a
|
|
319
|
+
boolean ``api_key_set``. This function returns the actual
|
|
320
|
+
secret material, reading from the configured local backend when necessary.
|
|
321
|
+
"""
|
|
322
|
+
if not isinstance(config, dict):
|
|
323
|
+
config = {}
|
|
324
|
+
ptype = (config.get("provider") or "echo").lower()
|
|
325
|
+
explicit = config.get("api_key")
|
|
326
|
+
if explicit:
|
|
327
|
+
return explicit
|
|
328
|
+
account = config.get("api_key_account")
|
|
329
|
+
if not account:
|
|
330
|
+
# Default account name for the provider, used as a sensible
|
|
331
|
+
# fallback so the user doesn't have to manage multiple.
|
|
332
|
+
account = f"llm/{ptype}/api_key"
|
|
333
|
+
try:
|
|
334
|
+
from ..security import get_secret
|
|
335
|
+
return get_secret(account)
|
|
336
|
+
except Exception:
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def build_provider(config: dict[str, Any]) -> LLMClient:
|
|
341
|
+
"""Build a provider from a settings dict (see UI schema).
|
|
342
|
+
|
|
343
|
+
Safety fallback: if the chosen provider requires an API key but
|
|
344
|
+
none has been configured, drop to the rule-based provider instead
|
|
345
|
+
of attempting a 60-second network timeout per batch. The user is
|
|
346
|
+
warned via the returned provider name + a one-line log so the UI
|
|
347
|
+
can also surface a "no key configured" badge.
|
|
348
|
+
"""
|
|
349
|
+
if not isinstance(config, dict):
|
|
350
|
+
config = {}
|
|
351
|
+
ptype = str(config.get("provider") or "echo").lower()
|
|
352
|
+
if ptype not in PROVIDERS:
|
|
353
|
+
match = next((k for k in PROVIDERS if k.lower() == ptype), None)
|
|
354
|
+
if match is not None:
|
|
355
|
+
ptype = match
|
|
356
|
+
spec = PROVIDERS.get(ptype, PROVIDERS["echo"])
|
|
357
|
+
model = config.get("model") or spec.default_model
|
|
358
|
+
api_key = resolve_api_key(config)
|
|
359
|
+
base_url = config.get("base_url") or spec.default_base_url
|
|
360
|
+
if spec.needs_api_key and not api_key:
|
|
361
|
+
log.warning(
|
|
362
|
+
"provider %s requires an API key but none is configured; "
|
|
363
|
+
"falling back to rule-based provider (no network calls).",
|
|
364
|
+
ptype,
|
|
365
|
+
)
|
|
366
|
+
return RuleBasedProvider()
|
|
367
|
+
if ptype == "MiniMax":
|
|
368
|
+
return OpenAICompatProvider(
|
|
369
|
+
model=model,
|
|
370
|
+
api_key=api_key,
|
|
371
|
+
base_url=base_url or "https://api.minimaxi.com/v1",
|
|
372
|
+
)
|
|
373
|
+
if ptype in ("openai", "openai_compat", "openai-compat"):
|
|
374
|
+
return OpenAICompatProvider(model=model, api_key=api_key, base_url=base_url or "https://api.openai.com/v1")
|
|
375
|
+
if ptype == "anthropic":
|
|
376
|
+
return AnthropicProvider(model=model, api_key=api_key, base_url=base_url or "https://api.anthropic.com")
|
|
377
|
+
if ptype == "ollama":
|
|
378
|
+
return OllamaProvider(model=model, base_url=base_url or "http://127.0.0.1:11434")
|
|
379
|
+
if ptype in ("rules", "echo"):
|
|
380
|
+
return RuleBasedProvider()
|
|
381
|
+
log.warning("unknown LLM provider %r, falling back to rules", ptype)
|
|
382
|
+
return RuleBasedProvider()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def default_config() -> dict[str, Any]:
|
|
386
|
+
"""Settings shape the UI uses as a starting point.
|
|
387
|
+
|
|
388
|
+
The ``api_key`` field is never persisted to the SQLite store.
|
|
389
|
+
The actual secret lives in the OS keychain under the account
|
|
390
|
+
name ``api_key_account``; the settings blob only carries a
|
|
391
|
+
boolean ``api_key_set`` hint so the UI can show a "key
|
|
392
|
+
configured" badge.
|
|
393
|
+
"""
|
|
394
|
+
return {
|
|
395
|
+
"provider": "echo",
|
|
396
|
+
"model": "rules",
|
|
397
|
+
"api_key_set": False,
|
|
398
|
+
"api_key_account": "llm/echo/api_key",
|
|
399
|
+
"base_url": "",
|
|
400
|
+
"schedule": {
|
|
401
|
+
"enabled": False,
|
|
402
|
+
"mode": "off", # off | realtime | hourly | daily | weekly | interval
|
|
403
|
+
"interval_minutes": 60, # for "every N minutes"
|
|
404
|
+
"hour": 3, # for daily
|
|
405
|
+
"minute": 0,
|
|
406
|
+
"weekday": 0, # 0=Mon, 6=Sun, for weekly
|
|
407
|
+
"after_ingest_idle_sec": 30, # realtime: wait this long after last ingest
|
|
408
|
+
},
|
|
409
|
+
"behaviour": {
|
|
410
|
+
"batch_size": 50,
|
|
411
|
+
"min_importance": 0.0,
|
|
412
|
+
# Defaults tuned for the v2 distillation policy: completeness over
|
|
413
|
+
# compactness. We let the LLM see ~4K of input chars (enough to absorb
|
|
414
|
+
# a whole cluster + a chunk of prior wiki context) and emit up to
|
|
415
|
+
# 4K output tokens for wiki bodies that must preserve every fact.
|
|
416
|
+
"max_text_chars": 4000,
|
|
417
|
+
"max_output_tokens": 4096,
|
|
418
|
+
"temperature": 0.3,
|
|
419
|
+
"enable_score": True, # re-score by LLM
|
|
420
|
+
"enable_filter": True, # drop noise
|
|
421
|
+
"enable_summarize": True, # condense near-dupes
|
|
422
|
+
"dry_run": False, # if true, do not mutate the store
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def validate_config(cfg: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
|
428
|
+
"""Normalize + validate. Returns (cleaned, warnings)."""
|
|
429
|
+
warnings: list[str] = []
|
|
430
|
+
if not isinstance(cfg, dict):
|
|
431
|
+
cfg = {}
|
|
432
|
+
ptype = str(cfg.get("provider") or "echo").lower()
|
|
433
|
+
if ptype not in PROVIDERS:
|
|
434
|
+
# Try case-insensitive fallback so providers can be entered as
|
|
435
|
+
# "MiniMax", "minimax", "MINIMAX" and still resolve.
|
|
436
|
+
match = next((k for k in PROVIDERS if k.lower() == ptype), None)
|
|
437
|
+
if match is not None:
|
|
438
|
+
ptype = match
|
|
439
|
+
else:
|
|
440
|
+
warnings.append(f"unknown provider {ptype!r}; using 'echo'")
|
|
441
|
+
ptype = "echo"
|
|
442
|
+
spec = PROVIDERS[ptype]
|
|
443
|
+
out = dict(cfg)
|
|
444
|
+
out["provider"] = ptype
|
|
445
|
+
# The api_key field is *never* stored in the settings blob; strip
|
|
446
|
+
# it on the way through. Callers should go through the keychain
|
|
447
|
+
# or send a one-off api_key to the test endpoint.
|
|
448
|
+
out.pop("api_key", None)
|
|
449
|
+
if "api_key_set" not in out:
|
|
450
|
+
out["api_key_set"] = False
|
|
451
|
+
if not out.get("model"):
|
|
452
|
+
out["model"] = spec.default_model
|
|
453
|
+
# api_key is no longer in this dict. The keychain is checked at
|
|
454
|
+
# build_provider() time. The settings table just carries
|
|
455
|
+
# api_key_set / api_key_account hints.
|
|
456
|
+
if not out.get("api_key_account"):
|
|
457
|
+
out["api_key_account"] = f"llm/{ptype}/api_key"
|
|
458
|
+
if spec.needs_api_key and not bool(out.get("api_key_set")):
|
|
459
|
+
env = (os.environ.get("LOOP_MEMORY_API_KEY")
|
|
460
|
+
or os.environ.get("OPENAI_API_KEY")
|
|
461
|
+
or os.environ.get("ANTHROPIC_API_KEY"))
|
|
462
|
+
if env:
|
|
463
|
+
out["api_key_set"] = True
|
|
464
|
+
# We do NOT store the env key into the keychain — that
|
|
465
|
+
# would surprise the user. We just note that an env-based
|
|
466
|
+
# fallback is available.
|
|
467
|
+
warnings.append("API key not set in keychain; falling back to env var.")
|
|
468
|
+
if spec.needs_base_url and not (out.get("base_url") or "").strip():
|
|
469
|
+
out["base_url"] = spec.default_base_url
|
|
470
|
+
if not isinstance(out.get("schedule"), dict):
|
|
471
|
+
out["schedule"] = default_config()["schedule"]
|
|
472
|
+
# Defensive deep-clean of the schedule dict. Earlier versions of
|
|
473
|
+
# the Settings UI sent the full config payload through
|
|
474
|
+
# ``POST /api/admin/llm/schedule`` — a flat-merge endpoint that
|
|
475
|
+
# nested ``schedule`` and ``behaviour`` under
|
|
476
|
+
# ``cfg.schedule.schedule`` / ``cfg.schedule.behaviour`` while
|
|
477
|
+
# leaving the top-level ``enabled`` / ``mode`` stale. Saved rows
|
|
478
|
+
# built up that way can still be in the store today. Rebuilding
|
|
479
|
+
# the schedule from a clean default + the known keys drops any
|
|
480
|
+
# nested pollution on the next save and prevents it from
|
|
481
|
+
# spreading to the scheduler or being shown back to the user.
|
|
482
|
+
_sched_keys = set(default_config()["schedule"].keys())
|
|
483
|
+
clean_sched = dict(default_config()["schedule"])
|
|
484
|
+
if isinstance(out.get("schedule"), dict):
|
|
485
|
+
for k in _sched_keys:
|
|
486
|
+
if k in out["schedule"]:
|
|
487
|
+
clean_sched[k] = out["schedule"][k]
|
|
488
|
+
out["schedule"] = clean_sched
|
|
489
|
+
# Same treatment for behaviour — drop stray top-level / nested
|
|
490
|
+
# keys (e.g. ``schedule`` mistakenly merged in) so behaviour
|
|
491
|
+
# only ever carries the canonical knobs.
|
|
492
|
+
_beh_keys = set(default_config()["behaviour"].keys())
|
|
493
|
+
clean_beh = dict(default_config()["behaviour"])
|
|
494
|
+
if isinstance(out.get("behaviour"), dict):
|
|
495
|
+
for k in _beh_keys:
|
|
496
|
+
if k in out["behaviour"]:
|
|
497
|
+
clean_beh[k] = out["behaviour"][k]
|
|
498
|
+
out["behaviour"] = clean_beh
|
|
499
|
+
beh = out["behaviour"]
|
|
500
|
+
try:
|
|
501
|
+
beh["batch_size"] = max(1, min(int(beh.get("batch_size") or 50), 500))
|
|
502
|
+
except Exception:
|
|
503
|
+
beh["batch_size"] = 50
|
|
504
|
+
try:
|
|
505
|
+
beh["max_output_tokens"] = max(64, min(int(beh.get("max_output_tokens") or 4096), 8192))
|
|
506
|
+
except Exception:
|
|
507
|
+
beh["max_output_tokens"] = 4096
|
|
508
|
+
try:
|
|
509
|
+
beh["temperature"] = max(0.0, min(float(beh.get("temperature") or 0.3), 2.0))
|
|
510
|
+
except Exception:
|
|
511
|
+
beh["temperature"] = 0.3
|
|
512
|
+
try:
|
|
513
|
+
beh["min_importance"] = max(0.0, min(float(beh.get("min_importance") or 0.0), 1.0))
|
|
514
|
+
except Exception:
|
|
515
|
+
beh["min_importance"] = 0.0
|
|
516
|
+
out["behaviour"] = beh
|
|
517
|
+
return out, warnings
|