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.
Files changed (84) hide show
  1. loop_memory/__init__.py +62 -0
  2. loop_memory/backends/__init__.py +13 -0
  3. loop_memory/backends/embedding.py +82 -0
  4. loop_memory/backends/sentence_embedder.py +30 -0
  5. loop_memory/backends/vector_store.py +139 -0
  6. loop_memory/cli/__init__.py +0 -0
  7. loop_memory/cli/_common.py +68 -0
  8. loop_memory/cli/commands/__init__.py +13 -0
  9. loop_memory/cli/commands/cognitive.py +205 -0
  10. loop_memory/cli/commands/diag.py +346 -0
  11. loop_memory/cli/commands/graph.py +21 -0
  12. loop_memory/cli/commands/hooks.py +212 -0
  13. loop_memory/cli/commands/read.py +362 -0
  14. loop_memory/cli/commands/serve.py +147 -0
  15. loop_memory/cli/commands/write.py +138 -0
  16. loop_memory/cli/main.py +115 -0
  17. loop_memory/engine/__init__.py +0 -0
  18. loop_memory/engine/loop.py +247 -0
  19. loop_memory/engine/reflect.py +89 -0
  20. loop_memory/examples/__init__.py +0 -0
  21. loop_memory/examples/demo.py +39 -0
  22. loop_memory/export/__init__.py +39 -0
  23. loop_memory/export/memory_md.py +629 -0
  24. loop_memory/graph/__init__.py +0 -0
  25. loop_memory/graph/build.py +259 -0
  26. loop_memory/graph/extract.py +197 -0
  27. loop_memory/ingest/__init__.py +0 -0
  28. loop_memory/ingest/loader.py +782 -0
  29. loop_memory/ingest/pipeline.py +458 -0
  30. loop_memory/jobs/__init__.py +0 -0
  31. loop_memory/jobs/cognitive.py +353 -0
  32. loop_memory/jobs/compact.py +371 -0
  33. loop_memory/jobs/consolidate.py +95 -0
  34. loop_memory/jobs/contradiction.py +281 -0
  35. loop_memory/jobs/evolution.py +2021 -0
  36. loop_memory/jobs/graph.py +395 -0
  37. loop_memory/jobs/llm_compact_pass.py +24 -0
  38. loop_memory/jobs/llm_consolidate.py +980 -0
  39. loop_memory/jobs/scheduler.py +495 -0
  40. loop_memory/llm/__init__.py +0 -0
  41. loop_memory/llm/base.py +80 -0
  42. loop_memory/llm/openai_adapter.py +31 -0
  43. loop_memory/llm/providers.py +517 -0
  44. loop_memory/mcp/__init__.py +804 -0
  45. loop_memory/memory/__init__.py +0 -0
  46. loop_memory/memory/types.py +199 -0
  47. loop_memory/privacy/__init__.py +22 -0
  48. loop_memory/privacy/private.py +46 -0
  49. loop_memory/privacy/redact.py +188 -0
  50. loop_memory/py.typed +0 -0
  51. loop_memory/sdk.py +875 -0
  52. loop_memory/sdk_extensions.py +384 -0
  53. loop_memory/security/__init__.py +20 -0
  54. loop_memory/security/secrets.py +464 -0
  55. loop_memory/serve/__init__.py +0 -0
  56. loop_memory/serve/app.py +506 -0
  57. loop_memory/serve/handlers.py +316 -0
  58. loop_memory/serve/routes/_shared.py +59 -0
  59. loop_memory/serve/routes/admin.py +970 -0
  60. loop_memory/serve/routes/cognitive.py +64 -0
  61. loop_memory/serve/routes/export.py +65 -0
  62. loop_memory/serve/routes/graph.py +101 -0
  63. loop_memory/serve/routes/insights.py +702 -0
  64. loop_memory/serve/routes/memories.py +435 -0
  65. loop_memory/serve/routes/sessions.py +75 -0
  66. loop_memory/serve/routes/system.py +493 -0
  67. loop_memory/serve/routes/wiki.py +812 -0
  68. loop_memory/serve/static/__init__.py +0 -0
  69. loop_memory/serve/static/index.html +15 -0
  70. loop_memory/serve/watcher.py +451 -0
  71. loop_memory/storage/__init__.py +5 -0
  72. loop_memory/storage/retrieval.py +365 -0
  73. loop_memory/storage/sqlite_store.py +3627 -0
  74. loop_memory/wiki/__init__.py +41 -0
  75. loop_memory/wiki/backfill.py +143 -0
  76. loop_memory/wiki/classifier.py +238 -0
  77. loop_memory/wiki/prompts.py +295 -0
  78. loop_memory/wiki/scope.py +227 -0
  79. loop_memory-0.4.0.dist-info/METADATA +627 -0
  80. loop_memory-0.4.0.dist-info/RECORD +84 -0
  81. loop_memory-0.4.0.dist-info/WHEEL +5 -0
  82. loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
  83. loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
  84. 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