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.
- package/README.md +81 -58
- package/auto_setup.py +7 -904
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +221 -238
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
- package/docs/DEVELOPMENT.md +53 -14
- package/docs/LEGACY_COMPATIBILITY.md +4 -4
- package/docs/ONBOARDING.md +10 -2
- package/docs/OPERATIONS.md +8 -4
- package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
- package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
- package/docs/TRUST_MODEL.md +5 -2
- package/docs/WHY_LATTICE.md +15 -10
- package/docs/WORKFLOW_DESIGNER.md +22 -0
- package/docs/kg-schema.md +13 -2
- package/docs/mcp-tools.md +17 -6
- package/docs/privacy.md +19 -3
- package/docs/public-deploy.md +32 -3
- package/docs/security-model.md +46 -11
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +4 -14
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/embeddings.py +38 -2
- package/lattice_brain/graph/_kg_common.py +27 -462
- package/lattice_brain/graph/_kg_constants.py +243 -0
- package/lattice_brain/graph/_kg_fsutil.py +293 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +1126 -0
- package/lattice_brain/graph/documents.py +44 -9
- package/lattice_brain/graph/ingest.py +47 -20
- package/lattice_brain/graph/provenance.py +13 -6
- package/lattice_brain/graph/retrieval.py +141 -610
- package/lattice_brain/graph/retrieval_docgen.py +243 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/store.py +6 -0
- package/lattice_brain/ingestion.py +69 -4
- package/lattice_brain/portability.py +29 -23
- package/lattice_brain/quality.py +98 -4
- package/lattice_brain/runtime/agent_runtime.py +169 -7
- package/lattice_brain/runtime/hooks.py +30 -13
- package/lattice_brain/runtime/multi_agent.py +27 -8
- package/lattice_brain/runtime/statuses.py +10 -0
- package/lattice_brain/utils.py +46 -0
- package/lattice_brain/workflow.py +1 -5
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agent_registry.py +10 -8
- package/latticeai/api/agents.py +22 -3
- package/latticeai/api/auth.py +78 -16
- package/latticeai/api/browser.py +303 -34
- package/latticeai/api/chat.py +355 -952
- package/latticeai/api/chat_agent_http.py +356 -0
- package/latticeai/api/chat_contracts.py +54 -0
- package/latticeai/api/chat_documents.py +256 -0
- package/latticeai/api/chat_helpers.py +250 -0
- package/latticeai/api/chat_history.py +99 -0
- package/latticeai/api/chat_intents.py +302 -0
- package/latticeai/api/chat_stream.py +115 -0
- package/latticeai/api/computer_use.py +211 -40
- package/latticeai/api/health.py +9 -3
- package/latticeai/api/hooks.py +17 -6
- package/latticeai/api/knowledge_graph.py +78 -14
- package/latticeai/api/local_files.py +7 -2
- package/latticeai/api/marketplace.py +11 -0
- package/latticeai/api/mcp.py +102 -23
- package/latticeai/api/models.py +72 -33
- package/latticeai/api/network.py +5 -5
- package/latticeai/api/permissions.py +70 -29
- package/latticeai/api/plugins.py +21 -7
- package/latticeai/api/portability.py +5 -5
- package/latticeai/api/realtime.py +33 -5
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +123 -24
- package/latticeai/api/tools.py +97 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +185 -405
- package/latticeai/core/agent.py +19 -9
- package/latticeai/core/agent_registry.py +1 -5
- package/latticeai/core/config.py +23 -3
- package/latticeai/core/context_builder.py +21 -3
- package/latticeai/core/invitations.py +1 -4
- package/latticeai/core/io_utils.py +45 -0
- package/latticeai/core/legacy_compatibility.py +24 -3
- package/latticeai/core/local_embeddings.py +2 -4
- package/latticeai/core/marketplace.py +33 -2
- package/latticeai/core/mcp_catalog.py +450 -0
- package/latticeai/core/mcp_registry.py +2 -441
- package/latticeai/core/plugins.py +15 -0
- package/latticeai/core/policy.py +1 -1
- package/latticeai/core/realtime.py +38 -15
- package/latticeai/core/sessions.py +7 -2
- package/latticeai/core/timeutil.py +10 -0
- package/latticeai/core/tool_registry.py +90 -18
- package/latticeai/core/users.py +5 -14
- package/latticeai/core/workspace_graph_trace.py +31 -5
- package/latticeai/core/workspace_memory.py +3 -1
- package/latticeai/core/workspace_os.py +18 -7
- package/latticeai/core/workspace_os_utils.py +2 -21
- package/latticeai/core/workspace_permissions.py +2 -1
- package/latticeai/core/workspace_plugins.py +1 -1
- package/latticeai/core/workspace_runs.py +14 -5
- package/latticeai/core/workspace_skills.py +1 -1
- package/latticeai/core/workspace_snapshots.py +2 -1
- package/latticeai/core/workspace_timeline.py +2 -1
- package/latticeai/integrations/telegram_bot.py +96 -40
- package/latticeai/models/model_providers.py +111 -0
- package/latticeai/models/router.py +189 -173
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/audit_runtime.py +27 -16
- package/latticeai/runtime/automation_runtime.py +22 -7
- package/latticeai/runtime/brain_runtime.py +19 -7
- package/latticeai/runtime/chat_wiring.py +2 -0
- package/latticeai/runtime/config_runtime.py +58 -26
- package/latticeai/runtime/context_runtime.py +16 -4
- package/latticeai/runtime/history_runtime.py +163 -0
- package/latticeai/runtime/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +163 -0
- package/latticeai/runtime/network_config_runtime.py +56 -0
- package/latticeai/runtime/platform_runtime_wiring.py +4 -3
- package/latticeai/runtime/review_wiring.py +1 -1
- package/latticeai/runtime/router_registration.py +34 -1
- package/latticeai/runtime/security_runtime.py +110 -13
- package/latticeai/runtime/sso_config_runtime.py +128 -0
- package/latticeai/runtime/stages.py +27 -0
- package/latticeai/runtime/user_key_runtime.py +106 -0
- package/latticeai/server_app.py +5 -1
- package/latticeai/services/architecture_readiness.py +74 -5
- package/latticeai/services/brain_automation.py +3 -26
- package/latticeai/services/chat_service.py +205 -15
- package/latticeai/services/local_knowledge.py +423 -0
- package/latticeai/services/memory_service.py +268 -21
- package/latticeai/services/model_engines.py +48 -33
- package/latticeai/services/model_errors.py +17 -0
- package/latticeai/services/model_loading.py +28 -25
- package/latticeai/services/model_runtime.py +228 -162
- package/latticeai/services/p_reinforce.py +22 -3
- package/latticeai/services/platform_runtime.py +92 -24
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +76 -11
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +25 -9
- package/latticeai/services/search_service.py +203 -28
- package/latticeai/services/setup_detection.py +80 -0
- package/latticeai/services/tool_dispatch.py +28 -5
- package/latticeai/services/triggers.py +53 -4
- package/latticeai/services/upload_service.py +13 -2
- package/latticeai/setup/__init__.py +25 -0
- package/latticeai/setup/auto_setup.py +857 -0
- package/latticeai/setup/wizard.py +1264 -0
- package/latticeai/tools/__init__.py +278 -0
- package/{tools → latticeai/tools}/commands.py +67 -7
- package/{tools → latticeai/tools}/computer.py +1 -1
- package/{tools → latticeai/tools}/documents.py +1 -1
- package/{tools → latticeai/tools}/filesystem.py +3 -3
- package/latticeai/tools/knowledge.py +178 -0
- package/{tools → latticeai/tools}/local_files.py +7 -1
- package/{tools → latticeai/tools}/network.py +0 -1
- package/local_knowledge_api.py +4 -342
- package/package.json +22 -2
- package/scripts/brain_quality_eval.py +3 -1
- package/scripts/bump_version.py +3 -0
- package/scripts/capture_release_evidence.mjs +180 -0
- package/scripts/check_current_release_docs.mjs +142 -0
- package/scripts/check_i18n_literals.mjs +107 -31
- package/scripts/check_openapi_drift.mjs +56 -0
- package/scripts/export_openapi.py +67 -7
- package/scripts/i18n_literal_allowlist.json +22 -24
- package/scripts/run_integration_tests.mjs +72 -10
- package/scripts/validate_release_artifacts.py +49 -7
- package/scripts/wheel_smoke.py +5 -0
- package/setup_wizard.py +3 -1304
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/Act-Bzz0bUyW.js +1 -0
- package/static/app/assets/Brain-Dj2J20YA.js +321 -0
- package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
- package/static/app/assets/Library-B03FP1Yx.js +1 -0
- package/static/app/assets/System-80lHW0Ux.js +1 -0
- package/static/app/assets/index-BiMofBTM.js +17 -0
- package/static/app/assets/index-Bmx9rzTc.css +2 -0
- package/static/app/assets/primitives-Q1A96_7v.js +1 -0
- package/static/app/assets/textarea-D13RtnTo.js +1 -0
- package/static/app/index.html +2 -2
- package/static/css/tokens.css +4 -2
- package/static/sw.js +1 -1
- package/tools/__init__.py +21 -269
- package/docs/CODE_REVIEW_2026-07-06.md +0 -764
- package/latticeai/runtime/app_context_runtime.py +0 -13
- package/latticeai/runtime/sso_runtime.py +0 -52
- package/latticeai/runtime/tail_wiring.py +0 -21
- package/scripts/com.pts.claudecode.discord.plist +0 -31
- package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
- package/scripts/start-pts-claudecode-discord.sh +0 -51
- package/static/app/assets/Act-fZokUnC0.js +0 -1
- package/static/app/assets/Brain-DtyuWubr.js +0 -321
- package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
- package/static/app/assets/Library-C9kyFkSt.js +0 -1
- package/static/app/assets/System-VbChmX7r.js +0 -1
- package/static/app/assets/index-DCh5AoXt.css +0 -2
- package/static/app/assets/index-DPdcPoF0.js +0 -17
- package/static/app/assets/primitives-DFeanEV6.js +0 -1
- package/static/app/assets/textarea-CD8UNKIy.js +0 -1
- 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
|
-
|
|
264
|
+
with self._lock:
|
|
265
|
+
return self._current
|
|
351
266
|
|
|
352
267
|
@property
|
|
353
268
|
def loaded_model_ids(self) -> List[str]:
|
|
354
|
-
|
|
269
|
+
with self._lock:
|
|
270
|
+
return list(self._cache.keys())
|
|
355
271
|
|
|
356
272
|
def switch_model(self, model_id: str) -> None:
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
self._current
|
|
367
|
-
|
|
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.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
438
|
-
self.
|
|
439
|
-
|
|
440
|
-
|
|
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.
|
|
483
|
-
|
|
484
|
-
|
|
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.
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
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
|
-
|
|
614
|
-
"""
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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.
|
|
630
|
-
|
|
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(
|
|
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
|
|
678
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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":
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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()
|