ltcai 0.3.1 → 0.4.0

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.
@@ -36,6 +36,8 @@ from fastapi import APIRouter, HTTPException, Query, Request
36
36
  from fastapi.responses import Response, StreamingResponse
37
37
  from pydantic import BaseModel
38
38
 
39
+ from ..core import timezones
40
+
39
41
  logger = logging.getLogger(__name__)
40
42
 
41
43
 
@@ -304,11 +306,13 @@ def create_security_router(
304
306
  report = build_sensitivity_report(history) or {}
305
307
  summary = report.get("summary", {})
306
308
  sev = summary.get("severity_counts", {}) or {}
307
- today = datetime.utcnow().date().isoformat()
309
+ # item 7: audit timestamp(로컬/설정 시간대)와 동일한 기준으로 "오늘"을 계산한다.
310
+ today = timezones.today_str()
308
311
  today_events = [e for e in events if str(e.get("timestamp", ""))[:10] == today]
309
312
 
310
313
  return {
311
- "generated_at": datetime.utcnow().isoformat() + "Z",
314
+ "generated_at": timezones.now_iso(),
315
+ "timezone": timezones.tz_name(),
312
316
  "cards": {
313
317
  "events_today": len(today_events),
314
318
  "high_risk_events": int(sev.get("high", 0)),
@@ -0,0 +1,453 @@
1
+ """Agent Runtime — the Discover→Plan→Implement→Verify state machine.
2
+
3
+ This module is the deep one: a small interface (``AgentDeps`` ports +
4
+ ``AgentRuntime.run_to_completion``) over the whole multi-role agent loop
5
+ (planner → executor → critic → rollback → memory). It carries no FastAPI,
6
+ no globals, and no I/O of its own — every collaborator is injected through
7
+ ``AgentDeps``.
8
+
9
+ Two adapters justify the seam:
10
+
11
+ * production wires ``AgentDeps`` from server.py's ``LLMRouter``, governance
12
+ map, audit log, and prompts;
13
+ * tests pass fake ports (an LLM that returns canned JSON, a recording tool
14
+ executor) and drive a full PLAN→EXECUTE→VERIFY→DONE cycle without a server.
15
+
16
+ HTTP concerns — request parsing, chat-history persistence, response shaping,
17
+ scheduling the background memory update — stay in server.py. This module
18
+ only owns the state machine.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import re
25
+ import subprocess
26
+ from dataclasses import dataclass
27
+ from enum import Enum
28
+ from pathlib import Path
29
+ from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional
30
+
31
+ from tools import ToolError
32
+
33
+
34
+ class AgentState(str, Enum):
35
+ IDLE = "IDLE"
36
+ PLANNING = "PLANNING"
37
+ WAITING_APPROVAL = "WAITING_APPROVAL"
38
+ EXECUTING = "EXECUTING"
39
+ VERIFYING = "VERIFYING"
40
+ FAILED = "FAILED"
41
+ ROLLBACK = "ROLLBACK"
42
+ DONE = "DONE"
43
+
44
+
45
+ # Terminal states — the agent loop exits when reaching one of these
46
+ AGENT_TERMINAL_STATES: FrozenSet[AgentState] = frozenset({AgentState.DONE, AgentState.FAILED})
47
+
48
+
49
+ class AgentRunContext:
50
+ """Mutable state carrier passed through all agent phases."""
51
+ __slots__ = ("state", "plan", "transcript", "retry_count",
52
+ "state_history", "corrections", "final_message", "rollback_log",
53
+ "executing_model", "reviewing_model")
54
+
55
+ def __init__(self) -> None:
56
+ self.state: AgentState = AgentState.IDLE
57
+ self.plan: dict = {}
58
+ self.transcript: list = []
59
+ self.retry_count: int = 0
60
+ self.state_history: list = []
61
+ self.corrections: list = []
62
+ self.final_message: str = ""
63
+ self.rollback_log: list = []
64
+ self.executing_model: Optional[str] = None
65
+ self.reviewing_model: Optional[str] = None
66
+
67
+
68
+ def extract_action(raw: str) -> Dict:
69
+ """Parse one JSON action object out of an LLM response (tolerant of fences/prose)."""
70
+ text = raw.strip()
71
+ fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL)
72
+ if fenced:
73
+ text = fenced.group(1).strip()
74
+ elif not text.startswith("{"):
75
+ start = text.find("{")
76
+ end = text.rfind("}")
77
+ if start >= 0 and end > start:
78
+ text = text[start : end + 1]
79
+
80
+ try:
81
+ action = json.loads(text)
82
+ except json.JSONDecodeError as exc:
83
+ raise ValueError(f"Agent did not return valid JSON: {exc}") from exc
84
+
85
+ if not isinstance(action, dict) or "action" not in action:
86
+ raise ValueError("Agent JSON must include an action field.")
87
+ return action
88
+
89
+
90
+ @dataclass
91
+ class AgentDeps:
92
+ """The ports an :class:`AgentRuntime` needs from the outside world.
93
+
94
+ Everything the state machine touches is here, so the loop can be exercised
95
+ against fakes. See module docstring for the two-adapter rationale.
96
+ """
97
+
98
+ # ── LLM port ─────────────────────────────────────────────────────
99
+ # generate_as(model_id, message, context, max_tokens, temperature) -> str
100
+ generate_as: Callable[..., Awaitable[Any]]
101
+ # generate(message, context, max_tokens, temperature) -> str
102
+ generate: Callable[..., Awaitable[Any]]
103
+
104
+ # ── tool port ────────────────────────────────────────────────────
105
+ execute_tool: Callable[[str, dict], dict]
106
+ policy_for: Callable[[str, dict], dict] # name, args -> governance policy
107
+ risk_level: Callable[[dict], str] # policy -> "low"|"medium"|"high"
108
+ check_role: Callable[[str, str], None] # tool_name, user -> raises if not allowed
109
+ tool_governance: Dict[str, dict] # name -> policy (for auto_approve set)
110
+ file_create_actions: FrozenSet[str]
111
+
112
+ # ── context / memory / audit ports ───────────────────────────────
113
+ recent_chat_context: Callable[..., str] # (conversation_id=...) -> str
114
+ clear_history: Callable[[int], dict]
115
+ knowledge_save: Callable[..., Any]
116
+ audit: Callable[..., None] # (event, **kw) -> None
117
+
118
+ # ── prompts + config ─────────────────────────────────────────────
119
+ planner_prompt: str
120
+ executor_prompt: str
121
+ critic_prompt: str
122
+ memory_updater_prompt: str
123
+ agent_root: Path
124
+
125
+
126
+ class AgentRuntime:
127
+ """Drives the agent state machine over injected :class:`AgentDeps`."""
128
+
129
+ def __init__(self, deps: AgentDeps) -> None:
130
+ self.deps = deps
131
+
132
+ # ── PLAN ─────────────────────────────────────────────────────────
133
+ async def plan(
134
+ self, ctx: AgentRunContext, req: Any, lang_hint: str, current_user: str,
135
+ model_id: Optional[str] = None,
136
+ ) -> None:
137
+ """PLAN: Planner role produces a structured plan JSON."""
138
+ d = self.deps
139
+ context = (
140
+ f"{d.planner_prompt}\n\n"
141
+ f"[LANGUAGE HINT: {lang_hint}]\n"
142
+ f"Workspace root: {d.agent_root}\n\n"
143
+ f"User request: {req.message}"
144
+ )
145
+ raw = await d.generate_as(
146
+ model_id,
147
+ message="Produce a JSON execution plan for this request.",
148
+ context=context, max_tokens=1024, temperature=0.1,
149
+ )
150
+ try:
151
+ plan = extract_action(str(raw))
152
+ except ValueError:
153
+ plan = {
154
+ "action": "plan", "state": "PLAN",
155
+ "goal": req.message, "steps": [],
156
+ "requires_approval": False, "rollback_strategy": "none", "estimated_steps": 1,
157
+ }
158
+ ctx.plan = plan
159
+ ctx.transcript.append({
160
+ "state": AgentState.PLANNING.value,
161
+ "goal": plan.get("goal", req.message),
162
+ "steps": plan.get("steps", []),
163
+ "requires_approval": plan.get("requires_approval", False),
164
+ "rollback_strategy": plan.get("rollback_strategy", "none"),
165
+ "estimated_steps": plan.get("estimated_steps", 1),
166
+ })
167
+ ctx.state = AgentState.WAITING_APPROVAL
168
+
169
+ # ── APPROVAL ─────────────────────────────────────────────────────
170
+ def approve(self, ctx: AgentRunContext, current_user: str) -> None:
171
+ """APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
172
+ d = self.deps
173
+ auto_approve_tools = {name for name, p in d.tool_governance.items() if p["auto_approve"]}
174
+ steps = ctx.plan.get("steps", [])
175
+ non_auto = [s.get("action") for s in steps if s.get("action") not in auto_approve_tools]
176
+ requires = ctx.plan.get("requires_approval", False) or bool(non_auto)
177
+
178
+ ctx.transcript.append({
179
+ "state": AgentState.WAITING_APPROVAL.value,
180
+ "requires_approval": requires,
181
+ "non_auto_approve_steps": non_auto,
182
+ "decision": "auto_approved",
183
+ })
184
+ d.audit(
185
+ "agent_approval", user_email=current_user,
186
+ requires_approval=requires, non_auto_steps=non_auto, decision="auto_approved",
187
+ )
188
+ ctx.state = AgentState.EXECUTING
189
+
190
+ # ── EXECUTE ──────────────────────────────────────────────────────
191
+ async def execute(
192
+ self, ctx: AgentRunContext, req: Any, lang_hint: str,
193
+ current_user: str, max_steps: int, model_id: Optional[str] = None,
194
+ ) -> None:
195
+ """EXECUTE: Executor role calls tools one at a time until final or budget exhausted."""
196
+ d = self.deps
197
+ exec_count = sum(1 for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value)
198
+ budget = max(1, max_steps - exec_count)
199
+
200
+ for _ in range(budget):
201
+ corrections_hint = (
202
+ "\n\nCritic corrections from previous attempt:\n"
203
+ + "\n".join(f"- {c}" for c in ctx.corrections)
204
+ ) if ctx.corrections else ""
205
+
206
+ context = (
207
+ f"{d.executor_prompt}\n\n"
208
+ f"[LANGUAGE HINT: {lang_hint}]\n"
209
+ f"Workspace root: {d.agent_root}\n\n"
210
+ f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
211
+ f"Recent conversation:\n{d.recent_chat_context(conversation_id=req.conversation_id) or '(none)'}\n\n"
212
+ f"User request: {req.message}{corrections_hint}\n\n"
213
+ f"Execution transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
214
+ )
215
+ raw = await d.generate_as(
216
+ model_id,
217
+ message="Execute the next step.",
218
+ context=context, max_tokens=4096, temperature=req.temperature,
219
+ )
220
+ try:
221
+ action = extract_action(str(raw))
222
+ except ValueError as exc:
223
+ ctx.transcript.append({
224
+ "state": AgentState.EXECUTING.value, "action": "parse_error",
225
+ "raw": str(raw)[:400], "error": str(exc),
226
+ })
227
+ break
228
+
229
+ name = action.get("action")
230
+ thoughts = str(action.get("thoughts") or "")[:600]
231
+ args = action.get("args") or {}
232
+
233
+ if name == "final":
234
+ ctx.final_message = action.get("message", "작업을 완료했습니다.")
235
+ ctx.transcript.append({
236
+ "state": AgentState.EXECUTING.value, "action": "final", "thoughts": thoughts,
237
+ })
238
+ ctx.state = AgentState.VERIFYING
239
+ return
240
+
241
+ # Loop guard
242
+ exec_steps = [s for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value]
243
+ last = exec_steps[-1] if exec_steps else None
244
+ if (
245
+ name in d.file_create_actions and last
246
+ and last.get("action") == name
247
+ and (last.get("args") or {}) == args
248
+ and "result" in last
249
+ ):
250
+ ctx.transcript.append({
251
+ "state": AgentState.EXECUTING.value, "action": name,
252
+ "error": "LOOP_DETECTED: identical action+args repeated — halted.",
253
+ })
254
+ break
255
+
256
+ if name == "clear_history":
257
+ result = d.clear_history(args.get("keep_last", 0))
258
+ ctx.transcript.append({
259
+ "state": AgentState.EXECUTING.value, "action": name,
260
+ "thoughts": thoughts, "args": args, "result": result,
261
+ })
262
+ continue
263
+
264
+ policy = d.policy_for(name, args)
265
+ risk = d.risk_level(policy)
266
+
267
+ if policy["risk"] == "destructive":
268
+ ctx.transcript.append({
269
+ "state": AgentState.EXECUTING.value, "action": name,
270
+ "thoughts": thoughts, "args": args, "risk": risk,
271
+ "governance": dict(policy),
272
+ "error": f"BLOCKED: destructive action '{name}' not permitted in agent mode.",
273
+ })
274
+ d.audit(
275
+ "agent_blocked", user_email=current_user, source=getattr(req, "source", None) or "agent",
276
+ action=name, reason="destructive", governance=dict(policy),
277
+ )
278
+ continue
279
+
280
+ if not policy["auto_approve"]:
281
+ d.audit(
282
+ "agent_exec", user_email=current_user, source=getattr(req, "source", None) or "agent",
283
+ state=AgentState.EXECUTING.value, action=name, risk=risk,
284
+ shell=policy["shell"], network=policy["network"],
285
+ destructive=policy["destructive"], sandbox=policy["sandbox"],
286
+ rollback=policy["rollback"],
287
+ args={k: v for k, v in args.items() if k != "content"},
288
+ )
289
+
290
+ try:
291
+ d.check_role(name, current_user)
292
+ result = d.execute_tool(name, args)
293
+ ctx.transcript.append({
294
+ "state": AgentState.EXECUTING.value, "action": name,
295
+ "thoughts": thoughts, "args": args,
296
+ "risk": risk, "governance": dict(policy), "result": result,
297
+ })
298
+ except (ToolError, KeyError, TypeError) as exc:
299
+ ctx.transcript.append({
300
+ "state": AgentState.EXECUTING.value, "action": name,
301
+ "thoughts": thoughts, "args": args,
302
+ "risk": risk, "governance": dict(policy), "error": str(exc),
303
+ })
304
+
305
+ ctx.state = AgentState.VERIFYING
306
+
307
+ # ── VERIFY ───────────────────────────────────────────────────────
308
+ async def verify(
309
+ self, ctx: AgentRunContext, req: Any, lang_hint: str, current_user: str,
310
+ max_retry: int = 3, model_id: Optional[str] = None,
311
+ ) -> None:
312
+ """VERIFYING: Critic role evaluates transcript → DONE / EXECUTING (retry) / ROLLBACK / FAILED."""
313
+ d = self.deps
314
+ context = (
315
+ f"{d.critic_prompt}\n\n"
316
+ f"[LANGUAGE HINT: {lang_hint}]\n\n"
317
+ f"Original request: {req.message}\n"
318
+ f"Plan goal: {ctx.plan.get('goal', req.message)}\n\n"
319
+ f"Full transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
320
+ )
321
+ raw = await d.generate_as(
322
+ model_id,
323
+ message="Review the execution transcript and return your verdict JSON.",
324
+ context=context, max_tokens=512, temperature=0.1,
325
+ )
326
+ try:
327
+ verdict = extract_action(str(raw))
328
+ except ValueError:
329
+ verdict = {"action": "verdict", "verdict": "PASS", "next_state": "DONE",
330
+ "reason": "Critic parse failed — assuming pass.", "corrections": [], "confidence": 0.7}
331
+
332
+ ctx.corrections = verdict.get("corrections", [])
333
+ # Normalize legacy verdict next_state strings to current AgentState names
334
+ raw_next = verdict.get("next_state", "DONE")
335
+ next_s = {"COMPLETE": "DONE", "RETRY": "EXECUTING"}.get(raw_next, raw_next)
336
+
337
+ ctx.transcript.append({
338
+ "state": AgentState.VERIFYING.value,
339
+ "verdict": verdict.get("verdict", "PASS"),
340
+ "reason": verdict.get("reason", ""),
341
+ "corrections": ctx.corrections,
342
+ "confidence": verdict.get("confidence", 0.9),
343
+ "next_state": next_s,
344
+ })
345
+
346
+ if verdict.get("verdict") == "PASS" or next_s == "DONE":
347
+ if not ctx.final_message:
348
+ ctx.final_message = verdict.get("reason", "작업이 완료되었습니다.")
349
+ ctx.state = AgentState.DONE
350
+ elif next_s == "ROLLBACK":
351
+ ctx.state = AgentState.ROLLBACK
352
+ elif next_s == "EXECUTING":
353
+ if ctx.retry_count >= max_retry:
354
+ ctx.final_message = "처리 중 문제가 발생했습니다. 다시 시도해 주세요."
355
+ ctx.state = AgentState.FAILED
356
+ else:
357
+ ctx.retry_count += 1
358
+ ctx.transcript.append({
359
+ "state": AgentState.EXECUTING.value,
360
+ "retry_attempt": ctx.retry_count,
361
+ "corrections": ctx.corrections,
362
+ })
363
+ ctx.state = AgentState.EXECUTING
364
+ else:
365
+ ctx.final_message = verdict.get("reason", "검증자가 인식되지 않은 다음 상태를 반환했습니다.")
366
+ ctx.state = AgentState.FAILED
367
+
368
+ # ── ROLLBACK ─────────────────────────────────────────────────────
369
+ def rollback(self, ctx: AgentRunContext, current_user: str) -> None:
370
+ """ROLLBACK: attempt git checkout for each edited file, then FAILED."""
371
+ d = self.deps
372
+ rolled: List[dict] = []
373
+ for step in ctx.transcript:
374
+ if step.get("state") != AgentState.EXECUTING.value:
375
+ continue
376
+ gov = step.get("governance", {})
377
+ if gov.get("rollback") != "git":
378
+ continue
379
+ result = step.get("result", {})
380
+ if not (isinstance(result, dict) and result.get("success")):
381
+ continue
382
+ path = result.get("path") or (step.get("args") or {}).get("path", "")
383
+ if not path:
384
+ continue
385
+ try:
386
+ r = subprocess.run(
387
+ ["git", "checkout", "--", path], cwd=str(d.agent_root),
388
+ capture_output=True, text=True, timeout=10,
389
+ )
390
+ rolled.append({"path": path, "ok": r.returncode == 0, "stderr": r.stderr[:200]})
391
+ except Exception as exc:
392
+ rolled.append({"path": path, "ok": False, "error": str(exc)})
393
+
394
+ ctx.transcript.append({"state": AgentState.ROLLBACK.value, "rolled_back": rolled})
395
+ recovered = [r["path"] for r in rolled if r.get("ok")]
396
+ ctx.final_message = (
397
+ f"실행 실패로 롤백했습니다. 복구 파일: {recovered}"
398
+ if recovered
399
+ else "롤백을 시도했으나 복구할 파일이 없거나 git이 초기화되지 않았습니다."
400
+ )
401
+ d.audit("agent_rollback", user_email=current_user, rolled_back=rolled)
402
+ # Rollback is a recovery from a failed verification — terminal state is FAILED
403
+ ctx.state = AgentState.FAILED
404
+
405
+ # ── MEMORY ───────────────────────────────────────────────────────
406
+ async def memory_update(self, ctx: AgentRunContext, req: Any, current_user: str) -> None:
407
+ """Background: Memory Updater role extracts learnings after DONE."""
408
+ d = self.deps
409
+ context = (
410
+ f"{d.memory_updater_prompt}\n\n"
411
+ f"Completed task: {req.message}\n\n"
412
+ f"Last 5 transcript steps:\n{json.dumps(ctx.transcript[-5:], ensure_ascii=False)}"
413
+ )
414
+ try:
415
+ raw = await d.generate(
416
+ message="Extract learnings from this completed task.",
417
+ context=context, max_tokens=256, temperature=0.1,
418
+ )
419
+ mem = extract_action(str(raw))
420
+ if mem.get("save_to_knowledge") and mem.get("learnings"):
421
+ d.knowledge_save(
422
+ "\n".join(mem["learnings"]),
423
+ folder="30_Projects",
424
+ title=f"Agent: {req.message[:60]}",
425
+ )
426
+ except Exception:
427
+ pass
428
+
429
+ # ── DRIVE LOOP ───────────────────────────────────────────────────
430
+ async def run_to_completion(
431
+ self, ctx: AgentRunContext, req: Any, lang_hint: str,
432
+ current_user: str, max_steps: int, max_retry: int,
433
+ ) -> None:
434
+ """Run EXECUTING → VERIFYING → ROLLBACK loop until a terminal state."""
435
+ while ctx.state not in AGENT_TERMINAL_STATES:
436
+ ctx.state_history.append(ctx.state.value)
437
+ if len(ctx.state_history) > 200:
438
+ ctx.final_message = "에이전트 상태 머신이 최대 반복(200)에 도달해 중단했습니다."
439
+ ctx.state = AgentState.FAILED
440
+ break
441
+
442
+ if ctx.state == AgentState.EXECUTING:
443
+ await self.execute(ctx, req, lang_hint, current_user, max_steps,
444
+ model_id=ctx.executing_model)
445
+ elif ctx.state == AgentState.VERIFYING:
446
+ await self.verify(ctx, req, lang_hint, current_user, max_retry,
447
+ model_id=ctx.reviewing_model)
448
+ elif ctx.state == AgentState.ROLLBACK:
449
+ self.rollback(ctx, current_user)
450
+ else:
451
+ ctx.state = AgentState.FAILED
452
+
453
+ ctx.state_history.append(ctx.state.value)
@@ -9,6 +9,8 @@ from datetime import datetime
9
9
  from pathlib import Path
10
10
  from typing import Any, Callable, Dict, List, Optional
11
11
 
12
+ from . import timezones
13
+
12
14
  _history_lock = threading.Lock()
13
15
 
14
16
  SENSITIVE_PATTERNS = [
@@ -41,7 +43,8 @@ def append_audit_event(audit_file: Path, event_type: str, **payload) -> None:
41
43
  try:
42
44
  event = {
43
45
  "event_type": event_type,
44
- "timestamp": datetime.now().isoformat(),
46
+ # item 7: 대시보드 "오늘" 계산과 동일한 시간대 기준으로 기록한다.
47
+ "timestamp": timezones.now_iso(),
45
48
  **payload,
46
49
  }
47
50
  with _history_lock:
@@ -0,0 +1,178 @@
1
+ """App-level configuration as a single deep module.
2
+
3
+ All environment parsing for Lattice AI's *application* settings (mode, host,
4
+ port, feature flags, SSO, auth gating, integrations) lives here behind one
5
+ interface: ``Config.from_env``. Callers read typed attributes off a frozen
6
+ ``Config`` instance instead of reaching for ``os.getenv`` in 40 places.
7
+
8
+ The ``env`` mapping passed to ``from_env`` is the seam:
9
+
10
+ * production passes ``os.environ`` (the default);
11
+ * tests pass a plain ``dict`` and get a fully-formed ``Config`` with no
12
+ monkeypatching of the process environment.
13
+
14
+ Per-request provider credentials (``OPENAI_API_KEY``, ``LMSTUDIO_API_KEY`` …)
15
+ are intentionally *not* here — those belong to the LLM Router's provider
16
+ concept and are read dynamically at call time.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import sys
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import List, Mapping, Optional
25
+
26
+ from latticeai.core.security import host_is_loopback
27
+
28
+
29
+ def _value(env: Mapping[str, str], key: str, default: str = "") -> str:
30
+ """Mirror the legacy ``env_value``: ``getenv(key) or default or ""`` (no strip)."""
31
+ return env.get(key) or default or ""
32
+
33
+
34
+ def _str(env: Mapping[str, str], key: str, default: str = "") -> str:
35
+ """Mirror ``os.getenv(key, default)``: default only when the key is absent."""
36
+ raw = env.get(key)
37
+ return raw if raw is not None else default
38
+
39
+
40
+ def _bool(env: Mapping[str, str], key: str, default: bool = False) -> bool:
41
+ raw = env.get(key)
42
+ if raw is None:
43
+ return default
44
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
45
+
46
+
47
+ def _int(env: Mapping[str, str], key: str, default: int) -> int:
48
+ raw = env.get(key)
49
+ if raw is None or not str(raw).strip():
50
+ return default
51
+ try:
52
+ return int(str(raw).strip())
53
+ except ValueError:
54
+ return default
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Config:
59
+ """Everything a caller must know about app-level settings.
60
+
61
+ Construct once at startup via :meth:`from_env`; pass the values onward
62
+ rather than re-reading the environment.
63
+ """
64
+
65
+ # ── mode / network ──────────────────────────────────────────────
66
+ app_mode: str
67
+ is_public: bool
68
+ host: str
69
+ port: int
70
+ network_exposed: bool
71
+
72
+ # ── feature flags ───────────────────────────────────────────────
73
+ enable_telegram: bool
74
+ enable_graph: bool
75
+ autoload_models: bool
76
+ model_idle_unload_seconds: int
77
+ allow_local_models: bool
78
+
79
+ # ── auth / security ─────────────────────────────────────────────
80
+ require_auth: bool
81
+ allow_plaintext_api_keys: bool
82
+ cors_allow_network: bool
83
+ cors_extra_origins: List[str]
84
+ rate_limit_enabled: bool
85
+ open_registration: bool
86
+ invite_code: str
87
+ invite_gate_enabled: bool
88
+ admin_emails: List[str]
89
+
90
+ # ── models ──────────────────────────────────────────────────────
91
+ public_model: str
92
+ local_model: str
93
+ local_draft_model: str
94
+ auto_read_chat_paths: bool
95
+
96
+ # ── SSO / OIDC ──────────────────────────────────────────────────
97
+ sso_discovery_url: str
98
+ sso_client_id: str
99
+ sso_client_secret: str
100
+ sso_redirect_uri: str
101
+ sso_provider_name: str
102
+
103
+ # ── integrations ────────────────────────────────────────────────
104
+ discord_permission_webhook: str
105
+ discord_bot_token: str
106
+ discord_permission_channel: str
107
+ permission_monitor_secret: str
108
+
109
+ # ── paths ───────────────────────────────────────────────────────
110
+ data_dir: Path
111
+ static_dir: Path
112
+
113
+ @classmethod
114
+ def from_env(cls, env: Optional[Mapping[str, str]] = None, *, base_dir: Optional[Path] = None) -> "Config":
115
+ if env is None:
116
+ import os
117
+ env = os.environ
118
+ if base_dir is None:
119
+ base_dir = Path(__file__).resolve().parent.parent.parent
120
+
121
+ app_mode = _value(env, "LATTICEAI_MODE", "local").lower()
122
+ if app_mode not in {"local", "public"}:
123
+ app_mode = "local"
124
+ is_public = app_mode == "public"
125
+
126
+ host = _value(env, "LATTICEAI_HOST", "127.0.0.1")
127
+ port = _int(env, "LATTICEAI_PORT", 4825)
128
+ network_exposed = not host_is_loopback(host)
129
+
130
+ cors_extra = [item.strip() for item in _value(env, "LATTICEAI_CORS_ALLOWED_ORIGINS", "").split(",") if item.strip()]
131
+ admin_emails = [item.strip().lower() for item in _value(env, "LATTICEAI_ADMIN_EMAILS", "").split(",") if item.strip()]
132
+
133
+ public_model = _value(env, "LATTICEAI_PUBLIC_MODEL", _value(env, "LATTICEAI_DEFAULT_MODEL", "openai:gpt-4o-mini"))
134
+ local_model = _value(env, "LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-26b-a4b-it-4bit")
135
+
136
+ data_dir = Path(_value(env, "LATTICEAI_DATA_DIR", str(Path.home() / ".ltcai")))
137
+ static_dir = Path(_value(env, "LATTICEAI_STATIC_DIR", str(base_dir / "static")))
138
+ if not static_dir.exists():
139
+ packaged_static = Path(sys.prefix) / "static"
140
+ if packaged_static.exists():
141
+ static_dir = packaged_static
142
+
143
+ return cls(
144
+ app_mode=app_mode,
145
+ is_public=is_public,
146
+ host=host,
147
+ port=port,
148
+ network_exposed=network_exposed,
149
+ enable_telegram=_bool(env, "LATTICEAI_ENABLE_TELEGRAM", default=not is_public),
150
+ enable_graph=_bool(env, "LATTICEAI_ENABLE_GRAPH", default=True),
151
+ autoload_models=_bool(env, "LATTICEAI_AUTOLOAD_MODELS", default=is_public),
152
+ model_idle_unload_seconds=_int(env, "LATTICEAI_MODEL_IDLE_UNLOAD_SECONDS", 0),
153
+ allow_local_models=_bool(env, "LATTICEAI_ALLOW_LOCAL_MODELS", default=not is_public),
154
+ require_auth=_bool(env, "LATTICEAI_REQUIRE_AUTH", default=is_public or network_exposed),
155
+ allow_plaintext_api_keys=_bool(env, "LATTICEAI_ALLOW_PLAINTEXT_API_KEYS", default=False),
156
+ cors_allow_network=_bool(env, "LATTICEAI_CORS_ALLOW_NETWORK", default=False),
157
+ cors_extra_origins=cors_extra,
158
+ rate_limit_enabled=_str(env, "LATTICEAI_RATE_LIMIT", "1") != "0",
159
+ open_registration=_bool(env, "LATTICEAI_OPEN_REGISTRATION", default=not network_exposed and not is_public),
160
+ invite_code=_value(env, "LATTICEAI_INVITE_CODE", "gemma-lattice-ai"),
161
+ invite_gate_enabled=_bool(env, "LATTICEAI_INVITE_GATE_ENABLED", default=False),
162
+ admin_emails=admin_emails,
163
+ public_model=public_model,
164
+ local_model=local_model,
165
+ local_draft_model=_value(env, "LATTICEAI_LOCAL_DRAFT_MODEL", ""),
166
+ auto_read_chat_paths=_bool(env, "LATTICEAI_AUTO_READ_CHAT_PATHS", default=False),
167
+ sso_discovery_url=_value(env, "OIDC_DISCOVERY_URL", ""),
168
+ sso_client_id=_value(env, "OIDC_CLIENT_ID", ""),
169
+ sso_client_secret=_value(env, "OIDC_CLIENT_SECRET", ""),
170
+ sso_redirect_uri=_value(env, "OIDC_REDIRECT_URI", "http://localhost:4825/auth/sso/callback"),
171
+ sso_provider_name=_value(env, "OIDC_PROVIDER_NAME", "SSO"),
172
+ discord_permission_webhook=_value(env, "LATTICEAI_DISCORD_PERMISSION_WEBHOOK", ""),
173
+ discord_bot_token=_value(env, "LATTICEAI_DISCORD_BOT_TOKEN", ""),
174
+ discord_permission_channel=_value(env, "LATTICEAI_DISCORD_PERMISSION_CHANNEL", ""),
175
+ permission_monitor_secret=_value(env, "LATTICEAI_PERMISSION_SECRET", ""),
176
+ data_dir=data_dir,
177
+ static_dir=static_dir,
178
+ )