ltcai 8.9.0 → 9.1.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.
Files changed (208) hide show
  1. package/README.md +81 -58
  2. package/auto_setup.py +7 -904
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +221 -238
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  13. package/docs/TRUST_MODEL.md +5 -2
  14. package/docs/WHY_LATTICE.md +15 -10
  15. package/docs/WORKFLOW_DESIGNER.md +22 -0
  16. package/docs/kg-schema.md +13 -2
  17. package/docs/mcp-tools.md +17 -6
  18. package/docs/privacy.md +19 -3
  19. package/docs/public-deploy.md +32 -3
  20. package/docs/security-model.md +46 -11
  21. package/lattice_brain/__init__.py +1 -1
  22. package/lattice_brain/archive.py +4 -14
  23. package/lattice_brain/context.py +66 -9
  24. package/lattice_brain/embeddings.py +38 -2
  25. package/lattice_brain/graph/_kg_common.py +27 -462
  26. package/lattice_brain/graph/_kg_constants.py +243 -0
  27. package/lattice_brain/graph/_kg_fsutil.py +293 -0
  28. package/lattice_brain/graph/discovery.py +0 -948
  29. package/lattice_brain/graph/discovery_index.py +1126 -0
  30. package/lattice_brain/graph/documents.py +44 -9
  31. package/lattice_brain/graph/ingest.py +47 -20
  32. package/lattice_brain/graph/provenance.py +13 -6
  33. package/lattice_brain/graph/retrieval.py +141 -610
  34. package/lattice_brain/graph/retrieval_docgen.py +243 -0
  35. package/lattice_brain/graph/retrieval_vector.py +460 -0
  36. package/lattice_brain/graph/store.py +6 -0
  37. package/lattice_brain/ingestion.py +69 -4
  38. package/lattice_brain/portability.py +29 -23
  39. package/lattice_brain/quality.py +98 -4
  40. package/lattice_brain/runtime/agent_runtime.py +169 -7
  41. package/lattice_brain/runtime/hooks.py +30 -13
  42. package/lattice_brain/runtime/multi_agent.py +27 -8
  43. package/lattice_brain/runtime/statuses.py +10 -0
  44. package/lattice_brain/utils.py +46 -0
  45. package/lattice_brain/workflow.py +1 -5
  46. package/latticeai/__init__.py +1 -1
  47. package/latticeai/api/admin.py +1 -1
  48. package/latticeai/api/agent_registry.py +10 -8
  49. package/latticeai/api/agents.py +22 -3
  50. package/latticeai/api/auth.py +78 -16
  51. package/latticeai/api/browser.py +303 -34
  52. package/latticeai/api/chat.py +355 -952
  53. package/latticeai/api/chat_agent_http.py +356 -0
  54. package/latticeai/api/chat_contracts.py +54 -0
  55. package/latticeai/api/chat_documents.py +256 -0
  56. package/latticeai/api/chat_helpers.py +250 -0
  57. package/latticeai/api/chat_history.py +99 -0
  58. package/latticeai/api/chat_intents.py +302 -0
  59. package/latticeai/api/chat_stream.py +115 -0
  60. package/latticeai/api/computer_use.py +211 -40
  61. package/latticeai/api/health.py +9 -3
  62. package/latticeai/api/hooks.py +17 -6
  63. package/latticeai/api/knowledge_graph.py +78 -14
  64. package/latticeai/api/local_files.py +7 -2
  65. package/latticeai/api/marketplace.py +11 -0
  66. package/latticeai/api/mcp.py +102 -23
  67. package/latticeai/api/models.py +72 -33
  68. package/latticeai/api/network.py +5 -5
  69. package/latticeai/api/permissions.py +70 -29
  70. package/latticeai/api/plugins.py +21 -7
  71. package/latticeai/api/portability.py +5 -5
  72. package/latticeai/api/realtime.py +33 -5
  73. package/latticeai/api/setup.py +2 -2
  74. package/latticeai/api/static_routes.py +123 -24
  75. package/latticeai/api/tools.py +97 -14
  76. package/latticeai/api/workflow_designer.py +37 -0
  77. package/latticeai/api/workspace.py +2 -1
  78. package/latticeai/app_factory.py +185 -405
  79. package/latticeai/core/agent.py +19 -9
  80. package/latticeai/core/agent_registry.py +1 -5
  81. package/latticeai/core/config.py +23 -3
  82. package/latticeai/core/context_builder.py +21 -3
  83. package/latticeai/core/invitations.py +1 -4
  84. package/latticeai/core/io_utils.py +45 -0
  85. package/latticeai/core/legacy_compatibility.py +24 -3
  86. package/latticeai/core/local_embeddings.py +2 -4
  87. package/latticeai/core/marketplace.py +33 -2
  88. package/latticeai/core/mcp_catalog.py +450 -0
  89. package/latticeai/core/mcp_registry.py +2 -441
  90. package/latticeai/core/plugins.py +15 -0
  91. package/latticeai/core/policy.py +1 -1
  92. package/latticeai/core/realtime.py +38 -15
  93. package/latticeai/core/sessions.py +7 -2
  94. package/latticeai/core/timeutil.py +10 -0
  95. package/latticeai/core/tool_registry.py +90 -18
  96. package/latticeai/core/users.py +5 -14
  97. package/latticeai/core/workspace_graph_trace.py +31 -5
  98. package/latticeai/core/workspace_memory.py +3 -1
  99. package/latticeai/core/workspace_os.py +18 -7
  100. package/latticeai/core/workspace_os_utils.py +2 -21
  101. package/latticeai/core/workspace_permissions.py +2 -1
  102. package/latticeai/core/workspace_plugins.py +1 -1
  103. package/latticeai/core/workspace_runs.py +14 -5
  104. package/latticeai/core/workspace_skills.py +1 -1
  105. package/latticeai/core/workspace_snapshots.py +2 -1
  106. package/latticeai/core/workspace_timeline.py +2 -1
  107. package/latticeai/integrations/telegram_bot.py +96 -40
  108. package/latticeai/models/model_providers.py +111 -0
  109. package/latticeai/models/router.py +189 -173
  110. package/latticeai/runtime/access_runtime.py +62 -4
  111. package/latticeai/runtime/audit_runtime.py +27 -16
  112. package/latticeai/runtime/automation_runtime.py +22 -7
  113. package/latticeai/runtime/brain_runtime.py +19 -7
  114. package/latticeai/runtime/chat_wiring.py +2 -0
  115. package/latticeai/runtime/config_runtime.py +58 -26
  116. package/latticeai/runtime/context_runtime.py +16 -4
  117. package/latticeai/runtime/history_runtime.py +163 -0
  118. package/latticeai/runtime/hooks_runtime.py +1 -1
  119. package/latticeai/runtime/model_wiring.py +33 -5
  120. package/latticeai/runtime/namespace_runtime.py +163 -0
  121. package/latticeai/runtime/network_config_runtime.py +56 -0
  122. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  123. package/latticeai/runtime/review_wiring.py +1 -1
  124. package/latticeai/runtime/router_registration.py +34 -1
  125. package/latticeai/runtime/security_runtime.py +110 -13
  126. package/latticeai/runtime/sso_config_runtime.py +128 -0
  127. package/latticeai/runtime/stages.py +27 -0
  128. package/latticeai/runtime/user_key_runtime.py +106 -0
  129. package/latticeai/server_app.py +5 -1
  130. package/latticeai/services/architecture_readiness.py +74 -5
  131. package/latticeai/services/brain_automation.py +3 -26
  132. package/latticeai/services/chat_service.py +205 -15
  133. package/latticeai/services/local_knowledge.py +423 -0
  134. package/latticeai/services/memory_service.py +268 -21
  135. package/latticeai/services/model_engines.py +48 -33
  136. package/latticeai/services/model_errors.py +17 -0
  137. package/latticeai/services/model_loading.py +28 -25
  138. package/latticeai/services/model_runtime.py +228 -162
  139. package/latticeai/services/p_reinforce.py +22 -3
  140. package/latticeai/services/platform_runtime.py +92 -24
  141. package/latticeai/services/product_readiness.py +19 -17
  142. package/latticeai/services/review_queue.py +76 -11
  143. package/latticeai/services/router_context.py +1 -0
  144. package/latticeai/services/run_executor.py +25 -9
  145. package/latticeai/services/search_service.py +203 -28
  146. package/latticeai/services/setup_detection.py +80 -0
  147. package/latticeai/services/tool_dispatch.py +28 -5
  148. package/latticeai/services/triggers.py +53 -4
  149. package/latticeai/services/upload_service.py +13 -2
  150. package/latticeai/setup/__init__.py +25 -0
  151. package/latticeai/setup/auto_setup.py +857 -0
  152. package/latticeai/setup/wizard.py +1264 -0
  153. package/latticeai/tools/__init__.py +278 -0
  154. package/{tools → latticeai/tools}/commands.py +67 -7
  155. package/{tools → latticeai/tools}/computer.py +1 -1
  156. package/{tools → latticeai/tools}/documents.py +1 -1
  157. package/{tools → latticeai/tools}/filesystem.py +3 -3
  158. package/latticeai/tools/knowledge.py +178 -0
  159. package/{tools → latticeai/tools}/local_files.py +7 -1
  160. package/{tools → latticeai/tools}/network.py +0 -1
  161. package/local_knowledge_api.py +4 -342
  162. package/package.json +22 -2
  163. package/scripts/brain_quality_eval.py +3 -1
  164. package/scripts/bump_version.py +3 -0
  165. package/scripts/capture_release_evidence.mjs +180 -0
  166. package/scripts/check_current_release_docs.mjs +142 -0
  167. package/scripts/check_i18n_literals.mjs +107 -31
  168. package/scripts/check_openapi_drift.mjs +56 -0
  169. package/scripts/export_openapi.py +67 -7
  170. package/scripts/i18n_literal_allowlist.json +22 -24
  171. package/scripts/run_integration_tests.mjs +72 -10
  172. package/scripts/validate_release_artifacts.py +49 -7
  173. package/scripts/wheel_smoke.py +5 -0
  174. package/setup_wizard.py +3 -1304
  175. package/src-tauri/Cargo.lock +1 -1
  176. package/src-tauri/Cargo.toml +1 -1
  177. package/src-tauri/tauri.conf.json +1 -1
  178. package/static/app/asset-manifest.json +11 -11
  179. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  180. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  181. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  182. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  183. package/static/app/assets/System-80lHW0Ux.js +1 -0
  184. package/static/app/assets/index-BiMofBTM.js +17 -0
  185. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  186. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  187. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  188. package/static/app/index.html +2 -2
  189. package/static/css/tokens.css +4 -2
  190. package/static/sw.js +1 -1
  191. package/tools/__init__.py +21 -269
  192. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  193. package/latticeai/runtime/app_context_runtime.py +0 -13
  194. package/latticeai/runtime/sso_runtime.py +0 -52
  195. package/latticeai/runtime/tail_wiring.py +0 -21
  196. package/scripts/com.pts.claudecode.discord.plist +0 -31
  197. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  198. package/scripts/start-pts-claudecode-discord.sh +0 -51
  199. package/static/app/assets/Act-fZokUnC0.js +0 -1
  200. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  201. package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
  202. package/static/app/assets/Library-C9kyFkSt.js +0 -1
  203. package/static/app/assets/System-VbChmX7r.js +0 -1
  204. package/static/app/assets/index-DCh5AoXt.css +0 -2
  205. package/static/app/assets/index-DPdcPoF0.js +0 -17
  206. package/static/app/assets/primitives-DFeanEV6.js +0 -1
  207. package/static/app/assets/textarea-CD8UNKIy.js +0 -1
  208. package/tools/knowledge.py +0 -95
@@ -9,6 +9,7 @@ import io
9
9
  import json
10
10
  import os
11
11
  import re
12
+ import threading
12
13
  import time
13
14
  from dataclasses import dataclass
14
15
  from pathlib import Path
@@ -21,6 +22,15 @@ from concurrent.futures import ThreadPoolExecutor
21
22
  from typing import AsyncIterator, Dict, Optional, Tuple, List
22
23
  from PIL import Image
23
24
 
25
+ # Cloud provider catalog data lives in .model_providers; re-exported here so
26
+ # ``from latticeai.models.router import OPENAI_COMPATIBLE_PROVIDERS`` (and the
27
+ # model_runtime re-export chain) resolve unchanged after the split.
28
+ from latticeai.models.model_providers import ( # noqa: F401
29
+ MODEL_SOURCE_BY_FAMILY,
30
+ OPENAI_COMPATIBLE_PROVIDERS,
31
+ PROVIDER_MODEL_CATALOG,
32
+ )
33
+
24
34
  try:
25
35
  from openai import AsyncOpenAI
26
36
  except Exception:
@@ -76,108 +86,6 @@ def normalize_branding(text: Optional[str]) -> str:
76
86
  normalized = pattern.sub(replacement, normalized)
77
87
  return normalized
78
88
 
79
- OPENAI_COMPATIBLE_PROVIDERS = {
80
- "openai": {
81
- "env_key": "OPENAI_API_KEY",
82
- "base_url_env": "OPENAI_BASE_URL",
83
- "default_model": "gpt-4o-mini",
84
- },
85
- "openrouter": {
86
- "env_key": "OPENROUTER_API_KEY",
87
- "base_url": "https://openrouter.ai/api/v1",
88
- "default_model": "openai/gpt-4o-mini",
89
- },
90
- "groq": {
91
- "env_key": "GROQ_API_KEY",
92
- "base_url": "https://api.groq.com/openai/v1",
93
- "default_model": "meta-llama/llama-4-scout-17b-16e-instruct",
94
- },
95
- "together": {
96
- "env_key": "TOGETHER_API_KEY",
97
- "base_url": "https://api.together.xyz/v1",
98
- "default_model": "Qwen/Qwen3-VL-32B-Instruct",
99
- },
100
- "xai": {
101
- "env_key": "XAI_API_KEY",
102
- "base_url": "https://api.x.ai/v1",
103
- "default_model": "grok-beta",
104
- },
105
- "ollama": {
106
- "env_key": "OLLAMA_API_KEY",
107
- "base_url_env": "OLLAMA_BASE_URL",
108
- "base_url": "http://localhost:11434/v1",
109
- "default_model": "hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_K_M",
110
- "api_key_fallback": "ollama",
111
- },
112
- "vllm": {
113
- "env_key": "VLLM_API_KEY",
114
- "base_url_env": "VLLM_BASE_URL",
115
- "base_url": "http://localhost:8000/v1",
116
- "default_model": "Qwen/Qwen3-VL-8B-Instruct",
117
- "api_key_fallback": "vllm",
118
- },
119
- "lmstudio": {
120
- "env_key": "LMSTUDIO_API_KEY",
121
- "base_url_env": "LMSTUDIO_BASE_URL",
122
- "base_url": "http://localhost:1234/v1",
123
- "default_model": "local-model",
124
- "api_key_fallback": "lmstudio",
125
- },
126
- "llamacpp": {
127
- "env_key": "LLAMACPP_API_KEY",
128
- "base_url_env": "LLAMACPP_BASE_URL",
129
- "base_url": "http://localhost:8080/v1",
130
- "default_model": "llama.cpp-model",
131
- "api_key_fallback": "llamacpp",
132
- },
133
- }
134
-
135
- PROVIDER_MODEL_CATALOG = {
136
- "openai": [
137
- {"id": "gpt-5.5", "name": "GPT-5.5", "family": "GPT"},
138
- {"id": "gpt-5.4", "name": "GPT-5.4", "family": "GPT"},
139
- {"id": "gpt-5.4-mini", "name": "GPT-5.4 Mini", "family": "GPT"},
140
- {"id": "gpt-5.4-nano", "name": "GPT-5.4 Nano", "family": "GPT"},
141
- {"id": "gpt-4o-mini", "name": "GPT-4o Mini", "family": "GPT"},
142
- {"id": "gpt-4o", "name": "GPT-4o", "family": "GPT"},
143
- {"id": "gpt-4.1-mini", "name": "GPT-4.1 Mini", "family": "GPT"},
144
- {"id": "gpt-4.1", "name": "GPT-4.1", "family": "GPT"},
145
- ],
146
- "openrouter": [
147
- {"id": "openai/gpt-5.5", "name": "GPT-5.5 via OpenRouter", "family": "GPT"},
148
- {"id": "openai/gpt-4o-mini", "name": "GPT-4o Mini via OpenRouter", "family": "GPT"},
149
- {"id": "anthropic/claude-opus-4.7", "name": "Claude Opus 4.7 via OpenRouter", "family": "Claude"},
150
- {"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6 via OpenRouter", "family": "Claude"},
151
- {"id": "anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5 via OpenRouter", "family": "Claude"},
152
- {"id": "qwen/qwen3-vl-235b-a22b-instruct", "name": "Qwen3-VL 235B A22B via OpenRouter", "family": "Qwen"},
153
- {"id": "google/gemma-4-12b-it", "name": "Gemma 4 12B via OpenRouter", "family": "Gemma"},
154
- {"id": "x-ai/grok-2", "name": "Grok 2 via OpenRouter", "family": "Grok"},
155
- {"id": "meta-llama/llama-4-scout-17b-16e-instruct", "name": "Llama 4 Scout via OpenRouter", "family": "Llama"},
156
- {"id": "google/gemini-2.5-flash", "name": "Gemini 2.5 Flash via OpenRouter", "family": "Gemini"},
157
- ],
158
- "groq": [
159
- {"id": "meta-llama/llama-4-scout-17b-16e-instruct", "name": "Llama 4 Scout", "family": "Llama"},
160
- ],
161
- "together": [
162
- {"id": "Qwen/Qwen3-VL-32B-Instruct", "name": "Qwen3-VL 32B", "family": "Qwen"},
163
- {"id": "google/gemma-4-12b-it", "name": "Gemma 4 12B", "family": "Gemma"},
164
- {"id": "meta-llama/Llama-4-Scout-17B-16E-Instruct", "name": "Llama 4 Scout", "family": "Llama"},
165
- ],
166
- "xai": [
167
- {"id": "grok-beta", "name": "Grok Beta", "family": "Grok"},
168
- {"id": "grok-vision-beta", "name": "Grok Vision Beta", "family": "Grok"},
169
- ],
170
- }
171
-
172
- MODEL_SOURCE_BY_FAMILY = {
173
- "GPT": ("미국", "OpenAI"),
174
- "Claude": ("미국", "Anthropic"),
175
- "Qwen": ("중국", "Alibaba"),
176
- "Llama": ("미국", "Meta"),
177
- "Gemini": ("미국", "Google"),
178
- "Grok": ("미국", "xAI"),
179
- }
180
-
181
89
 
182
90
  def source_metadata_for_model(provider: str, model: Dict[str, str], *, local_server: bool) -> Dict[str, str]:
183
91
  family = str(model.get("family") or "")
@@ -344,51 +252,64 @@ class LLMRouter:
344
252
  self._current: Optional[str] = None
345
253
  self._last_used: Dict[str, float] = {}
346
254
  self._max_local_models = max(1, int(os.getenv("LATTICEAI_MAX_LOCAL_MODELS", "1")))
255
+ # Guards the mutable model registry (_cache/_current/_last_used).
256
+ # Reentrant because the eviction path nests: _enforce_local_model_limit
257
+ # → unload_model → _release_memory. Never held across the heavy
258
+ # ``run_in_executor`` load (only the sync insert/read is guarded), so a
259
+ # long model load can't block a concurrent switch/unload from acquiring.
260
+ self._lock = threading.RLock()
347
261
 
348
262
  @property
349
263
  def current_model_id(self) -> Optional[str]:
350
- return self._current
264
+ with self._lock:
265
+ return self._current
351
266
 
352
267
  @property
353
268
  def loaded_model_ids(self) -> List[str]:
354
- return list(self._cache.keys())
269
+ with self._lock:
270
+ return list(self._cache.keys())
355
271
 
356
272
  def switch_model(self, model_id: str) -> None:
357
- if model_id not in self._cache:
358
- raise KeyError(model_id)
359
- self._current = model_id
360
- self._touch(model_id)
273
+ with self._lock:
274
+ if model_id not in self._cache:
275
+ raise KeyError(model_id)
276
+ self._current = model_id
277
+ self._touch(model_id)
361
278
 
362
279
  def unload_model(self, model_id: str) -> None:
363
- self._cache.pop(model_id, None)
364
- self._last_used.pop(model_id, None)
365
- if self._current == model_id:
366
- self._current = next(iter(self._cache), None)
367
- self._release_memory()
280
+ with self._lock:
281
+ self._cache.pop(model_id, None)
282
+ self._last_used.pop(model_id, None)
283
+ if self._current == model_id:
284
+ self._current = next(iter(self._cache), None)
285
+ self._release_memory()
368
286
 
369
287
  def unload_all(self) -> None:
370
- self._cache.clear()
371
- self._last_used.clear()
372
- self._current = None
373
- self._release_memory()
288
+ with self._lock:
289
+ self._cache.clear()
290
+ self._last_used.clear()
291
+ self._current = None
292
+ self._release_memory()
374
293
 
375
294
  def unload_idle_models(self, idle_seconds: int) -> List[str]:
376
295
  if idle_seconds <= 0:
377
296
  return []
378
297
  now = time.monotonic()
379
298
  unloaded = []
380
- for model_id, last_used in list(self._last_used.items()):
381
- if now - last_used >= idle_seconds:
382
- self.unload_model(model_id)
383
- unloaded.append(model_id)
299
+ with self._lock:
300
+ for model_id, last_used in list(self._last_used.items()):
301
+ if now - last_used >= idle_seconds:
302
+ self.unload_model(model_id)
303
+ unloaded.append(model_id)
384
304
  return unloaded
385
305
 
386
306
  def model_memory_policy(self) -> Dict[str, object]:
387
- return {
388
- "max_local_models": self._max_local_models,
389
- "loaded_count": len(self._cache),
390
- "last_used": dict(self._last_used),
391
- }
307
+ with self._lock:
308
+ return {
309
+ "max_local_models": self._max_local_models,
310
+ "loaded_count": len(self._cache),
311
+ "last_used": dict(self._last_used),
312
+ }
392
313
 
393
314
  def _touch(self, model_id: Optional[str] = None) -> None:
394
315
  model_id = model_id or self._current
@@ -400,14 +321,15 @@ class LLMRouter:
400
321
  return cached is not None and not isinstance(cached, CloudModel)
401
322
 
402
323
  def _enforce_local_model_limit(self, incoming_key: str) -> None:
403
- local_ids = [model_id for model_id in self._cache if self._is_local_model(model_id)]
404
- while len(local_ids) >= self._max_local_models:
405
- victim = min(local_ids, key=lambda model_id: self._last_used.get(model_id, 0))
406
- if victim == incoming_key:
407
- break
408
- print(f"🧹 Unloading local model to stay within memory policy: {victim}")
409
- self.unload_model(victim)
324
+ with self._lock:
410
325
  local_ids = [model_id for model_id in self._cache if self._is_local_model(model_id)]
326
+ while len(local_ids) >= self._max_local_models:
327
+ victim = min(local_ids, key=lambda model_id: self._last_used.get(model_id, 0))
328
+ if victim == incoming_key:
329
+ break
330
+ print(f"🧹 Unloading local model to stay within memory policy: {victim}")
331
+ self.unload_model(victim)
332
+ local_ids = [model_id for model_id in self._cache if self._is_local_model(model_id)]
411
333
 
412
334
  def _release_memory(self) -> None:
413
335
  gc.collect()
@@ -434,10 +356,11 @@ class LLMRouter:
434
356
  raise RuntimeError("MLX is not available in this process. Run on Apple Silicon with Metal access.")
435
357
 
436
358
  cache_key = f"{model_id}_{draft_model_id}" if draft_model_id else model_id
437
- if cache_key in self._cache:
438
- self._current = cache_key
439
- self._touch(cache_key)
440
- return f"Cached: {cache_key}"
359
+ with self._lock:
360
+ if cache_key in self._cache:
361
+ self._current = cache_key
362
+ self._touch(cache_key)
363
+ return f"Cached: {cache_key}"
441
364
 
442
365
  self._enforce_local_model_limit(cache_key)
443
366
  print(f"⏳ Loading local model stack: {cache_key}...")
@@ -479,9 +402,10 @@ class LLMRouter:
479
402
  try:
480
403
  # Use the dedicated single-thread executor to ensure MLX GPU streams match during inference
481
404
  model, tokenizer, draft_model, loader_kind = await loop.run_in_executor(executor, _load)
482
- self._cache[cache_key] = (model, tokenizer, draft_model, loader_kind)
483
- self._current = cache_key
484
- self._touch(cache_key)
405
+ with self._lock:
406
+ self._cache[cache_key] = (model, tokenizer, draft_model, loader_kind)
407
+ self._current = cache_key
408
+ self._touch(cache_key)
485
409
  print(f"✅ Fully Loaded: {cache_key} ({loader_kind})")
486
410
  return f"Success: {cache_key} ({loader_kind})"
487
411
  except Exception as e:
@@ -507,9 +431,10 @@ class LLMRouter:
507
431
 
508
432
  cache_owner = owner or "global"
509
433
  cache_key = f"{provider}:{model}::{cache_owner}"
510
- self._cache[cache_key] = CloudModel(provider=provider, model=model, client=AsyncOpenAI(**client_kwargs), cache_key=cache_key)
511
- self._current = cache_key
512
- self._touch(cache_key)
434
+ with self._lock:
435
+ self._cache[cache_key] = CloudModel(provider=provider, model=model, client=AsyncOpenAI(**client_kwargs), cache_key=cache_key)
436
+ self._current = cache_key
437
+ self._touch(cache_key)
513
438
  return f"Cloud provider ready: {cache_key}"
514
439
 
515
440
  def detected_cloud_models(self) -> List[Dict[str, str]]:
@@ -556,7 +481,8 @@ class LLMRouter:
556
481
  return items
557
482
 
558
483
  def _is_cloud_current(self) -> bool:
559
- return bool(self._current and isinstance(self._cache.get(self._current), CloudModel))
484
+ with self._lock:
485
+ return bool(self._current and isinstance(self._cache.get(self._current), CloudModel))
560
486
 
561
487
  def _local_server_error_hint(self, cloud: CloudModel, error: Exception) -> str:
562
488
  raw = str(error)
@@ -610,28 +536,62 @@ class LLMRouter:
610
536
  loader_kind = str(cached[3]) if len(cached) > 3 else "mlx_vlm"
611
537
  return model, tokenizer, draft_model, loader_kind
612
538
 
613
- async def generate_as(self, model_id: str | None, message: str, context: Optional[str] = None, max_tokens: int = 4096, temperature: float = 0.2) -> str:
614
- """Generate using a specific model, temporarily switching if needed. Falls back to current model if model_id is None or not loaded."""
615
- if not model_id or model_id == self._current:
616
- return await self.generate(message, context, max_tokens, temperature)
617
- if model_id not in self._cache:
618
- raise ValueError(f"Model '{model_id}' is not loaded. Load it first via /models/load.")
619
- prev = self._current
620
- self._current = model_id
621
- try:
622
- return await self.generate(message, context, max_tokens, temperature)
623
- finally:
624
- self._current = prev
625
-
626
- async def generate(self, message: str, context: Optional[str] = None, max_tokens: int = 4096, temperature: float = 0.2, image_data: Optional[str] = None) -> str:
627
- if not self._current:
539
+ def _model_snapshot(self, model_id: Optional[str] = None) -> tuple[Optional[str], object | None]:
540
+ """Return an immutable request-scoped view of a loaded model.
541
+
542
+ Generation must never change ``_current``: that value is a UI/default
543
+ preference shared by every request. Capturing the cache entry while
544
+ holding the registry lock prevents concurrent requests from selecting
545
+ or restoring each other's models.
546
+ """
547
+ with self._lock:
548
+ selected = model_id or self._current
549
+ if not selected:
550
+ return None, None
551
+ cached = self._cache.get(selected)
552
+ if cached is None:
553
+ raise ValueError(f"Model '{selected}' is not loaded. Load it first via /models/load.")
554
+ self._touch(selected)
555
+ return selected, cached
556
+
557
+ async def generate_as(
558
+ self,
559
+ model_id: str | None,
560
+ message: str,
561
+ context: Optional[str] = None,
562
+ max_tokens: int = 4096,
563
+ temperature: float = 0.2,
564
+ image_data: Optional[str] = None,
565
+ ) -> str:
566
+ """Generate with a request-scoped model without changing the default."""
567
+ _selected, cached = self._model_snapshot(model_id)
568
+ if cached is None:
628
569
  return "No model."
629
- self._touch()
630
- cached = self._cache[self._current]
570
+ return await self._generate_cached(cached, message, context, max_tokens, temperature, image_data)
571
+
572
+ async def generate(
573
+ self,
574
+ message: str,
575
+ context: Optional[str] = None,
576
+ max_tokens: int = 4096,
577
+ temperature: float = 0.2,
578
+ image_data: Optional[str] = None,
579
+ ) -> str:
580
+ return await self.generate_as(None, message, context, max_tokens, temperature, image_data)
581
+
582
+ async def _generate_cached(
583
+ self,
584
+ cached: object,
585
+ message: str,
586
+ context: Optional[str],
587
+ max_tokens: int,
588
+ temperature: float,
589
+ image_data: Optional[str],
590
+ ) -> str:
631
591
  if isinstance(cached, CloudModel):
632
592
  return await self._cloud_generate(cached, message, context, max_tokens, temperature)
633
593
 
634
- model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(self._cache[self._current])
594
+ model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
635
595
  use_vlm = loader_kind == "mlx_vlm"
636
596
  prompt = (
637
597
  self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
@@ -674,18 +634,26 @@ class LLMRouter:
674
634
  raise RuntimeError(self._local_server_error_hint(cloud, e)) from e
675
635
  return normalize_branding(response.choices[0].message.content or "")
676
636
 
677
- async def stream_generate(self, message: str, context: Optional[str] = None, max_tokens: int = 4096, temperature: float = 0.2, image_data: Optional[str] = None) -> AsyncIterator[str]:
678
- if not self._current:
637
+ async def stream_generate_as(
638
+ self,
639
+ model_id: str | None,
640
+ message: str,
641
+ context: Optional[str] = None,
642
+ max_tokens: int = 4096,
643
+ temperature: float = 0.2,
644
+ image_data: Optional[str] = None,
645
+ ) -> AsyncIterator[str]:
646
+ """Stream with a request-scoped model without changing the default."""
647
+ _selected, cached = self._model_snapshot(model_id)
648
+ if cached is None:
679
649
  yield "No model."
680
650
  return
681
- self._touch()
682
- cached = self._cache[self._current]
683
651
  if isinstance(cached, CloudModel):
684
652
  async for chunk in self._cloud_stream_generate(cached, message, context, max_tokens, temperature):
685
653
  yield chunk
686
654
  return
687
655
 
688
- model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(self._cache[self._current])
656
+ model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
689
657
  use_vlm = loader_kind == "mlx_vlm"
690
658
  prompt = (
691
659
  self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
@@ -721,6 +689,19 @@ class LLMRouter:
721
689
  break
722
690
  yield normalize_branding(chunk)
723
691
 
692
+ async def stream_generate(
693
+ self,
694
+ message: str,
695
+ context: Optional[str] = None,
696
+ max_tokens: int = 4096,
697
+ temperature: float = 0.2,
698
+ image_data: Optional[str] = None,
699
+ ) -> AsyncIterator[str]:
700
+ async for chunk in self.stream_generate_as(
701
+ None, message, context, max_tokens, temperature, image_data
702
+ ):
703
+ yield chunk
704
+
724
705
  async def _cloud_stream_generate(self, cloud: CloudModel, message: str, context: Optional[str], max_tokens: int, temperature: float) -> AsyncIterator[str]:
725
706
  system = SYSTEM_PROMPT
726
707
  context = normalize_branding(context)
@@ -769,10 +750,27 @@ class LLMRouter:
769
750
  temperature: float = 0.3,
770
751
  ) -> str:
771
752
  """Generate a document using a specialized system prompt with graph context."""
772
- if not self._current:
753
+ return await self.generate_document_as(
754
+ None,
755
+ message,
756
+ system_prompt,
757
+ max_tokens=max_tokens,
758
+ temperature=temperature,
759
+ )
760
+
761
+ async def generate_document_as(
762
+ self,
763
+ model_id: str | None,
764
+ message: str,
765
+ system_prompt: str,
766
+ *,
767
+ max_tokens: int = 8192,
768
+ temperature: float = 0.3,
769
+ ) -> str:
770
+ """Generate a document with a request-scoped model."""
771
+ _selected, cached = self._model_snapshot(model_id)
772
+ if cached is None:
773
773
  return "No model loaded."
774
- self._touch()
775
- cached = self._cache[self._current]
776
774
 
777
775
  if isinstance(cached, CloudModel):
778
776
  return await self._cloud_generate_document(cached, message, system_prompt, max_tokens, temperature)
@@ -828,11 +826,29 @@ class LLMRouter:
828
826
  temperature: float = 0.3,
829
827
  ) -> AsyncIterator[str]:
830
828
  """Stream document generation with specialized system prompt."""
831
- if not self._current:
829
+ async for chunk in self.stream_generate_document_as(
830
+ None,
831
+ message,
832
+ system_prompt,
833
+ max_tokens=max_tokens,
834
+ temperature=temperature,
835
+ ):
836
+ yield chunk
837
+
838
+ async def stream_generate_document_as(
839
+ self,
840
+ model_id: str | None,
841
+ message: str,
842
+ system_prompt: str,
843
+ *,
844
+ max_tokens: int = 8192,
845
+ temperature: float = 0.3,
846
+ ) -> AsyncIterator[str]:
847
+ """Stream a document with a request-scoped model."""
848
+ _selected, cached = self._model_snapshot(model_id)
849
+ if cached is None:
832
850
  yield "No model loaded."
833
851
  return
834
- self._touch()
835
- cached = self._cache[self._current]
836
852
 
837
853
  if isinstance(cached, CloudModel):
838
854
  async for chunk in self._cloud_stream_document(cached, message, system_prompt, max_tokens, temperature):
@@ -20,9 +20,23 @@ def build_access_runtime(
20
20
  from latticeai.core.policy import normalize_role, require_capability
21
21
  from latticeai.core.users import normalize_email
22
22
 
23
+ # The historical loopback/no-auth profile represents its single human as
24
+ # an empty identity. Keep that storage/workspace compatibility contract,
25
+ # but project the identity as an owner at authorization boundaries. Never
26
+ # extend this trust to public or non-loopback bindings, even if an invalid
27
+ # caller constructs this runtime with ``require_auth=False`` directly.
28
+ externally_reachable = bool(
29
+ getattr(config, "is_public", False)
30
+ or getattr(config, "network_exposed", False)
31
+ )
32
+ trusted_local_owner = not require_auth and not externally_reachable
33
+ effective_require_auth = bool(require_auth or externally_reachable)
34
+
23
35
  def get_user_role(email: str, users: Optional[Dict] = None) -> str:
24
36
  users = users or load_users()
25
37
  identity = str(email or "")
38
+ if trusted_local_owner and not identity:
39
+ return "owner"
26
40
  normalized_email = normalize_email(identity)
27
41
  user = users.get(normalized_email) or users.get(identity) or next(
28
42
  (
@@ -46,25 +60,69 @@ def build_access_runtime(
46
60
  return auth[7:].strip()
47
61
  return request.cookies.get("session_token")
48
62
 
63
+ def active_session_email(identity: Optional[str], users: Dict) -> Optional[str]:
64
+ """Resolve a session identity only while its account remains active."""
65
+ if not identity:
66
+ return None
67
+ raw_identity = str(identity)
68
+ normalized_email = normalize_email(raw_identity)
69
+ matched_key: Optional[str] = None
70
+ user = users.get(normalized_email)
71
+ if isinstance(user, dict):
72
+ matched_key = normalized_email
73
+ else:
74
+ user = users.get(raw_identity)
75
+ if isinstance(user, dict):
76
+ matched_key = raw_identity
77
+ else:
78
+ for key, item in users.items():
79
+ if isinstance(item, dict) and item.get("id") == raw_identity:
80
+ matched_key = str(key)
81
+ user = item
82
+ break
83
+ # A session for a deleted account, malformed account record, or an
84
+ # explicitly disabled account is invalid immediately. This check is
85
+ # intentionally performed on every request so stale bearer/cookie
86
+ # tokens cannot retain user or administrator access.
87
+ if not matched_key or not isinstance(user, dict) or bool(user.get("disabled", False)):
88
+ return None
89
+ return normalize_email(matched_key)
90
+
49
91
  def get_current_user(request: request_type) -> Optional[str]:
50
92
  token = extract_bearer_token(request)
51
93
  if token:
52
- return get_session_email(token)
94
+ try:
95
+ return active_session_email(get_session_email(token), load_users())
96
+ except Exception:
97
+ # Account-store failures must fail closed rather than turning a
98
+ # stale session into an authenticated identity.
99
+ return None
53
100
  return None
54
101
 
55
102
  def require_user(request: request_type) -> str:
56
103
  email = get_current_user(request)
57
- if require_auth and not email:
104
+ if email:
105
+ # Optional authentication remains meaningful in local mode: a
106
+ # valid session keeps its real account identity instead of being
107
+ # collapsed into the anonymous Local User fallback.
108
+ return email
109
+ if trusted_local_owner:
110
+ # A no-auth loopback caller is the trusted, anonymous local owner.
111
+ # Returning the legacy empty identity keeps ownerless workspaces,
112
+ # shared local vaults, and Local User profile behavior compatible;
113
+ # get_user_role() supplies the explicit owner authorization role.
114
+ return ""
115
+ if effective_require_auth and not email:
58
116
  raise http_exception(status_code=401, detail="인증이 필요합니다.")
59
117
  return email or ""
60
118
 
61
119
  def require_admin(request: request_type) -> tuple[str, Dict]:
62
120
  users = load_users()
63
- if not require_auth:
121
+ if trusted_local_owner:
64
122
  return "", users
65
123
  token = extract_bearer_token(request)
66
124
  if token:
67
- email = get_session_email(token)
125
+ email = active_session_email(get_session_email(token), users)
68
126
  if email:
69
127
  role = get_user_role(email, users)
70
128
  try:
@@ -5,10 +5,11 @@ Returns dict of get_audit_log / append_audit_event for legacy namespace.
5
5
  from __future__ import annotations
6
6
 
7
7
  import json
8
- from datetime import datetime
9
8
  from pathlib import Path
10
9
  from typing import Any, Callable, Dict, List, Optional
11
10
 
11
+ from latticeai.core import timezones
12
+
12
13
 
13
14
  def build_audit_runtime(
14
15
  *,
@@ -17,18 +18,33 @@ def build_audit_runtime(
17
18
  redact_fn: Optional[Callable[[str], str]] = None,
18
19
  ) -> Dict[str, Any]:
19
20
  _audit_lock: Any = __import__("threading").Lock()
21
+ audit_jsonl = audit_file.with_suffix(audit_file.suffix + ".jsonl")
20
22
 
21
23
  def _read_audit() -> List[Dict[str, Any]]:
24
+ events: List[Dict[str, Any]] = []
22
25
  if not audit_file.exists():
23
- return []
24
- try:
25
- data = json.loads(audit_file.read_text(encoding="utf-8"))
26
- return data if isinstance(data, list) else []
27
- except Exception:
28
- return []
26
+ data = []
27
+ else:
28
+ try:
29
+ data = json.loads(audit_file.read_text(encoding="utf-8"))
30
+ except Exception:
31
+ data = []
32
+ if isinstance(data, list):
33
+ events.extend(item for item in data if isinstance(item, dict))
34
+ if audit_jsonl.exists():
35
+ try:
36
+ for line in audit_jsonl.read_text(encoding="utf-8").splitlines():
37
+ if not line.strip():
38
+ continue
39
+ item = json.loads(line)
40
+ if isinstance(item, dict):
41
+ events.append(item)
42
+ except Exception:
43
+ pass
44
+ return events[-5000:]
29
45
 
30
46
  def _append(event_type: str, **payload: Any) -> None:
31
- entry = {"event_type": event_type, "timestamp": datetime.now().isoformat()}
47
+ entry = {"event_type": event_type, "timestamp": timezones.now_iso()}
32
48
  if payload:
33
49
  entry.update(payload)
34
50
  if redact_fn:
@@ -39,15 +55,10 @@ def build_audit_runtime(
39
55
  except Exception:
40
56
  pass
41
57
  with _audit_lock:
42
- events = _read_audit()
43
- events.append(entry)
44
- # keep last N to bound size (legacy behavior)
45
- if len(events) > 5000:
46
- events = events[-5000:]
47
58
  audit_file.parent.mkdir(parents=True, exist_ok=True)
48
- tmp = audit_file.with_suffix(".tmp")
49
- tmp.write_text(json.dumps(events, ensure_ascii=False, indent=2), encoding="utf-8")
50
- tmp.replace(audit_file)
59
+ with audit_jsonl.open("a", encoding="utf-8") as fh:
60
+ fh.write(json.dumps(entry, ensure_ascii=False, default=str))
61
+ fh.write("\n")
51
62
 
52
63
  def get_audit_log() -> List[Dict[str, Any]]:
53
64
  return _read_audit()