ltcai 8.9.0 → 9.0.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 +28 -39
- package/auto_setup.py +11 -62
- package/docs/CHANGELOG.md +76 -237
- package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
- package/docs/DEVELOPMENT.md +8 -8
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +2 -2
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +4 -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 +297 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +972 -0
- package/lattice_brain/graph/retrieval.py +0 -570
- package/lattice_brain/graph/retrieval_docgen.py +210 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/store.py +6 -0
- package/lattice_brain/ingestion.py +68 -0
- package/lattice_brain/portability.py +1 -9
- package/lattice_brain/quality.py +98 -4
- package/lattice_brain/runtime/agent_runtime.py +166 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/utils.py +28 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +340 -407
- package/latticeai/api/chat_helpers.py +227 -0
- package/latticeai/api/computer_use.py +149 -31
- package/latticeai/api/marketplace.py +11 -0
- package/latticeai/api/permissions.py +3 -3
- package/latticeai/api/tools.py +1 -0
- package/latticeai/app_factory.py +82 -360
- package/latticeai/core/io_utils.py +37 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- 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/users.py +4 -9
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/core/workspace_os_utils.py +3 -17
- package/latticeai/integrations/telegram_bot.py +7 -2
- package/latticeai/models/model_providers.py +111 -0
- package/latticeai/models/router.py +58 -136
- package/latticeai/runtime/audit_runtime.py +27 -16
- package/latticeai/runtime/automation_runtime.py +9 -0
- package/latticeai/runtime/history_runtime.py +163 -0
- package/latticeai/runtime/namespace_runtime.py +173 -0
- package/latticeai/runtime/network_config_runtime.py +56 -0
- package/latticeai/runtime/sso_config_runtime.py +128 -0
- package/latticeai/runtime/user_key_runtime.py +106 -0
- package/latticeai/services/architecture_readiness.py +2 -2
- package/latticeai/services/memory_service.py +213 -0
- package/latticeai/services/platform_runtime.py +9 -1
- package/latticeai/services/product_readiness.py +11 -11
- package/latticeai/services/review_queue.py +64 -11
- package/latticeai/services/run_executor.py +21 -0
- package/latticeai/services/setup_detection.py +80 -0
- package/latticeai/services/tool_dispatch.py +1 -1
- package/package.json +1 -1
- package/setup_wizard.py +11 -55
- 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-fZokUnC0.js → Act-21lIXx2E.js} +1 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
- package/static/app/assets/{Capture-D5KV3Cu7.js → Capture-BA7Z2Q1u.js} +1 -1
- package/static/app/assets/{Library-C9kyFkSt.js → Library-bFMtyni3.js} +1 -1
- package/static/app/assets/System-K6krGCqn.js +1 -0
- package/static/app/assets/index-C4R3ws30.js +17 -0
- package/static/app/assets/index-ChSeOB02.css +2 -0
- package/static/app/assets/primitives-sQU3it5I.js +1 -0
- package/static/app/assets/{textarea-CD8UNKIy.js → textarea-DK3Fd_lR.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/css/tokens.css +4 -2
- package/static/sw.js +1 -1
- package/tools/local_files.py +6 -0
- package/latticeai/runtime/sso_runtime.py +0 -52
- package/static/app/assets/Brain-DtyuWubr.js +0 -321
- 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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Cloud LLM provider catalog — static data split out of the router.
|
|
2
|
+
|
|
3
|
+
Pure config dicts (OpenAI-compatible providers, per-provider model
|
|
4
|
+
catalog, model→source family map). router.py re-exports these, so
|
|
5
|
+
``from latticeai.models.router import OPENAI_COMPATIBLE_PROVIDERS`` and the
|
|
6
|
+
model_runtime re-export chain are unaffected.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
OPENAI_COMPATIBLE_PROVIDERS = {
|
|
12
|
+
"openai": {
|
|
13
|
+
"env_key": "OPENAI_API_KEY",
|
|
14
|
+
"base_url_env": "OPENAI_BASE_URL",
|
|
15
|
+
"default_model": "gpt-4o-mini",
|
|
16
|
+
},
|
|
17
|
+
"openrouter": {
|
|
18
|
+
"env_key": "OPENROUTER_API_KEY",
|
|
19
|
+
"base_url": "https://openrouter.ai/api/v1",
|
|
20
|
+
"default_model": "openai/gpt-4o-mini",
|
|
21
|
+
},
|
|
22
|
+
"groq": {
|
|
23
|
+
"env_key": "GROQ_API_KEY",
|
|
24
|
+
"base_url": "https://api.groq.com/openai/v1",
|
|
25
|
+
"default_model": "meta-llama/llama-4-scout-17b-16e-instruct",
|
|
26
|
+
},
|
|
27
|
+
"together": {
|
|
28
|
+
"env_key": "TOGETHER_API_KEY",
|
|
29
|
+
"base_url": "https://api.together.xyz/v1",
|
|
30
|
+
"default_model": "Qwen/Qwen3-VL-32B-Instruct",
|
|
31
|
+
},
|
|
32
|
+
"xai": {
|
|
33
|
+
"env_key": "XAI_API_KEY",
|
|
34
|
+
"base_url": "https://api.x.ai/v1",
|
|
35
|
+
"default_model": "grok-beta",
|
|
36
|
+
},
|
|
37
|
+
"ollama": {
|
|
38
|
+
"env_key": "OLLAMA_API_KEY",
|
|
39
|
+
"base_url_env": "OLLAMA_BASE_URL",
|
|
40
|
+
"base_url": "http://localhost:11434/v1",
|
|
41
|
+
"default_model": "hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_K_M",
|
|
42
|
+
"api_key_fallback": "ollama",
|
|
43
|
+
},
|
|
44
|
+
"vllm": {
|
|
45
|
+
"env_key": "VLLM_API_KEY",
|
|
46
|
+
"base_url_env": "VLLM_BASE_URL",
|
|
47
|
+
"base_url": "http://localhost:8000/v1",
|
|
48
|
+
"default_model": "Qwen/Qwen3-VL-8B-Instruct",
|
|
49
|
+
"api_key_fallback": "vllm",
|
|
50
|
+
},
|
|
51
|
+
"lmstudio": {
|
|
52
|
+
"env_key": "LMSTUDIO_API_KEY",
|
|
53
|
+
"base_url_env": "LMSTUDIO_BASE_URL",
|
|
54
|
+
"base_url": "http://localhost:1234/v1",
|
|
55
|
+
"default_model": "local-model",
|
|
56
|
+
"api_key_fallback": "lmstudio",
|
|
57
|
+
},
|
|
58
|
+
"llamacpp": {
|
|
59
|
+
"env_key": "LLAMACPP_API_KEY",
|
|
60
|
+
"base_url_env": "LLAMACPP_BASE_URL",
|
|
61
|
+
"base_url": "http://localhost:8080/v1",
|
|
62
|
+
"default_model": "llama.cpp-model",
|
|
63
|
+
"api_key_fallback": "llamacpp",
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
PROVIDER_MODEL_CATALOG = {
|
|
68
|
+
"openai": [
|
|
69
|
+
{"id": "gpt-5.5", "name": "GPT-5.5", "family": "GPT"},
|
|
70
|
+
{"id": "gpt-5.4", "name": "GPT-5.4", "family": "GPT"},
|
|
71
|
+
{"id": "gpt-5.4-mini", "name": "GPT-5.4 Mini", "family": "GPT"},
|
|
72
|
+
{"id": "gpt-5.4-nano", "name": "GPT-5.4 Nano", "family": "GPT"},
|
|
73
|
+
{"id": "gpt-4o-mini", "name": "GPT-4o Mini", "family": "GPT"},
|
|
74
|
+
{"id": "gpt-4o", "name": "GPT-4o", "family": "GPT"},
|
|
75
|
+
{"id": "gpt-4.1-mini", "name": "GPT-4.1 Mini", "family": "GPT"},
|
|
76
|
+
{"id": "gpt-4.1", "name": "GPT-4.1", "family": "GPT"},
|
|
77
|
+
],
|
|
78
|
+
"openrouter": [
|
|
79
|
+
{"id": "openai/gpt-5.5", "name": "GPT-5.5 via OpenRouter", "family": "GPT"},
|
|
80
|
+
{"id": "openai/gpt-4o-mini", "name": "GPT-4o Mini via OpenRouter", "family": "GPT"},
|
|
81
|
+
{"id": "anthropic/claude-opus-4.7", "name": "Claude Opus 4.7 via OpenRouter", "family": "Claude"},
|
|
82
|
+
{"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6 via OpenRouter", "family": "Claude"},
|
|
83
|
+
{"id": "anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5 via OpenRouter", "family": "Claude"},
|
|
84
|
+
{"id": "qwen/qwen3-vl-235b-a22b-instruct", "name": "Qwen3-VL 235B A22B via OpenRouter", "family": "Qwen"},
|
|
85
|
+
{"id": "google/gemma-4-12b-it", "name": "Gemma 4 12B via OpenRouter", "family": "Gemma"},
|
|
86
|
+
{"id": "x-ai/grok-2", "name": "Grok 2 via OpenRouter", "family": "Grok"},
|
|
87
|
+
{"id": "meta-llama/llama-4-scout-17b-16e-instruct", "name": "Llama 4 Scout via OpenRouter", "family": "Llama"},
|
|
88
|
+
{"id": "google/gemini-2.5-flash", "name": "Gemini 2.5 Flash via OpenRouter", "family": "Gemini"},
|
|
89
|
+
],
|
|
90
|
+
"groq": [
|
|
91
|
+
{"id": "meta-llama/llama-4-scout-17b-16e-instruct", "name": "Llama 4 Scout", "family": "Llama"},
|
|
92
|
+
],
|
|
93
|
+
"together": [
|
|
94
|
+
{"id": "Qwen/Qwen3-VL-32B-Instruct", "name": "Qwen3-VL 32B", "family": "Qwen"},
|
|
95
|
+
{"id": "google/gemma-4-12b-it", "name": "Gemma 4 12B", "family": "Gemma"},
|
|
96
|
+
{"id": "meta-llama/Llama-4-Scout-17B-16E-Instruct", "name": "Llama 4 Scout", "family": "Llama"},
|
|
97
|
+
],
|
|
98
|
+
"xai": [
|
|
99
|
+
{"id": "grok-beta", "name": "Grok Beta", "family": "Grok"},
|
|
100
|
+
{"id": "grok-vision-beta", "name": "Grok Vision Beta", "family": "Grok"},
|
|
101
|
+
],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
MODEL_SOURCE_BY_FAMILY = {
|
|
105
|
+
"GPT": ("미국", "OpenAI"),
|
|
106
|
+
"Claude": ("미국", "Anthropic"),
|
|
107
|
+
"Qwen": ("중국", "Alibaba"),
|
|
108
|
+
"Llama": ("미국", "Meta"),
|
|
109
|
+
"Gemini": ("미국", "Google"),
|
|
110
|
+
"Grok": ("미국", "xAI"),
|
|
111
|
+
}
|
|
@@ -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,6 +252,12 @@ 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]:
|
|
@@ -354,33 +268,37 @@ class LLMRouter:
|
|
|
354
268
|
return list(self._cache.keys())
|
|
355
269
|
|
|
356
270
|
def switch_model(self, model_id: str) -> None:
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
271
|
+
with self._lock:
|
|
272
|
+
if model_id not in self._cache:
|
|
273
|
+
raise KeyError(model_id)
|
|
274
|
+
self._current = model_id
|
|
275
|
+
self._touch(model_id)
|
|
361
276
|
|
|
362
277
|
def unload_model(self, model_id: str) -> None:
|
|
363
|
-
self.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
self._current
|
|
367
|
-
|
|
278
|
+
with self._lock:
|
|
279
|
+
self._cache.pop(model_id, None)
|
|
280
|
+
self._last_used.pop(model_id, None)
|
|
281
|
+
if self._current == model_id:
|
|
282
|
+
self._current = next(iter(self._cache), None)
|
|
283
|
+
self._release_memory()
|
|
368
284
|
|
|
369
285
|
def unload_all(self) -> None:
|
|
370
|
-
self.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
286
|
+
with self._lock:
|
|
287
|
+
self._cache.clear()
|
|
288
|
+
self._last_used.clear()
|
|
289
|
+
self._current = None
|
|
290
|
+
self._release_memory()
|
|
374
291
|
|
|
375
292
|
def unload_idle_models(self, idle_seconds: int) -> List[str]:
|
|
376
293
|
if idle_seconds <= 0:
|
|
377
294
|
return []
|
|
378
295
|
now = time.monotonic()
|
|
379
296
|
unloaded = []
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
297
|
+
with self._lock:
|
|
298
|
+
for model_id, last_used in list(self._last_used.items()):
|
|
299
|
+
if now - last_used >= idle_seconds:
|
|
300
|
+
self.unload_model(model_id)
|
|
301
|
+
unloaded.append(model_id)
|
|
384
302
|
return unloaded
|
|
385
303
|
|
|
386
304
|
def model_memory_policy(self) -> Dict[str, object]:
|
|
@@ -400,14 +318,15 @@ class LLMRouter:
|
|
|
400
318
|
return cached is not None and not isinstance(cached, CloudModel)
|
|
401
319
|
|
|
402
320
|
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)
|
|
321
|
+
with self._lock:
|
|
410
322
|
local_ids = [model_id for model_id in self._cache if self._is_local_model(model_id)]
|
|
323
|
+
while len(local_ids) >= self._max_local_models:
|
|
324
|
+
victim = min(local_ids, key=lambda model_id: self._last_used.get(model_id, 0))
|
|
325
|
+
if victim == incoming_key:
|
|
326
|
+
break
|
|
327
|
+
print(f"🧹 Unloading local model to stay within memory policy: {victim}")
|
|
328
|
+
self.unload_model(victim)
|
|
329
|
+
local_ids = [model_id for model_id in self._cache if self._is_local_model(model_id)]
|
|
411
330
|
|
|
412
331
|
def _release_memory(self) -> None:
|
|
413
332
|
gc.collect()
|
|
@@ -434,10 +353,11 @@ class LLMRouter:
|
|
|
434
353
|
raise RuntimeError("MLX is not available in this process. Run on Apple Silicon with Metal access.")
|
|
435
354
|
|
|
436
355
|
cache_key = f"{model_id}_{draft_model_id}" if draft_model_id else model_id
|
|
437
|
-
|
|
438
|
-
self.
|
|
439
|
-
|
|
440
|
-
|
|
356
|
+
with self._lock:
|
|
357
|
+
if cache_key in self._cache:
|
|
358
|
+
self._current = cache_key
|
|
359
|
+
self._touch(cache_key)
|
|
360
|
+
return f"Cached: {cache_key}"
|
|
441
361
|
|
|
442
362
|
self._enforce_local_model_limit(cache_key)
|
|
443
363
|
print(f"⏳ Loading local model stack: {cache_key}...")
|
|
@@ -479,9 +399,10 @@ class LLMRouter:
|
|
|
479
399
|
try:
|
|
480
400
|
# Use the dedicated single-thread executor to ensure MLX GPU streams match during inference
|
|
481
401
|
model, tokenizer, draft_model, loader_kind = await loop.run_in_executor(executor, _load)
|
|
482
|
-
self.
|
|
483
|
-
|
|
484
|
-
|
|
402
|
+
with self._lock:
|
|
403
|
+
self._cache[cache_key] = (model, tokenizer, draft_model, loader_kind)
|
|
404
|
+
self._current = cache_key
|
|
405
|
+
self._touch(cache_key)
|
|
485
406
|
print(f"✅ Fully Loaded: {cache_key} ({loader_kind})")
|
|
486
407
|
return f"Success: {cache_key} ({loader_kind})"
|
|
487
408
|
except Exception as e:
|
|
@@ -507,9 +428,10 @@ class LLMRouter:
|
|
|
507
428
|
|
|
508
429
|
cache_owner = owner or "global"
|
|
509
430
|
cache_key = f"{provider}:{model}::{cache_owner}"
|
|
510
|
-
self.
|
|
511
|
-
|
|
512
|
-
|
|
431
|
+
with self._lock:
|
|
432
|
+
self._cache[cache_key] = CloudModel(provider=provider, model=model, client=AsyncOpenAI(**client_kwargs), cache_key=cache_key)
|
|
433
|
+
self._current = cache_key
|
|
434
|
+
self._touch(cache_key)
|
|
513
435
|
return f"Cloud provider ready: {cache_key}"
|
|
514
436
|
|
|
515
437
|
def detected_cloud_models(self) -> List[Dict[str, str]]:
|
|
@@ -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()
|
|
@@ -40,12 +40,21 @@ def build_automation_runtime(
|
|
|
40
40
|
review_sink=review_queue,
|
|
41
41
|
tz_name=tz_name,
|
|
42
42
|
)
|
|
43
|
+
def _memory_ingest(**kwargs):
|
|
44
|
+
try:
|
|
45
|
+
# Delegate to store's upsert (enriches memories + KG automatically via internal graph.ingest_event)
|
|
46
|
+
return store.upsert_memory(**kwargs)
|
|
47
|
+
except Exception:
|
|
48
|
+
return None
|
|
49
|
+
|
|
43
50
|
agent_runtime = AgentRuntime(
|
|
44
51
|
store=store,
|
|
45
52
|
orchestrator_factory=platform.build_orchestrator,
|
|
46
53
|
workspace_graph=workspace_graph,
|
|
47
54
|
append_audit_event=append_audit_event,
|
|
48
55
|
hooks=hooks,
|
|
56
|
+
memory_ingest=_memory_ingest,
|
|
57
|
+
review_sink=review_queue,
|
|
49
58
|
)
|
|
50
59
|
run_executor = RunExecutor(
|
|
51
60
|
store=store,
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Conversation-history query/clear seam extracted from the app factory.
|
|
2
|
+
|
|
3
|
+
The read and clear helpers (scope resolution, ``get_history``, conversation
|
|
4
|
+
grouping, and the clear operations) used to be defined inline in
|
|
5
|
+
``app_factory._build``. They are behaviour-preserving closures over the durable
|
|
6
|
+
conversation store and the workspace service; moving them here keeps the factory
|
|
7
|
+
a wiring path. Names are returned unchanged for the legacy ``server_app``
|
|
8
|
+
compatibility namespace.
|
|
9
|
+
|
|
10
|
+
Note: ``save_to_history`` (the *write* path) stays in the factory — it is bound
|
|
11
|
+
up with redaction, audit, and the ingestion pipeline that are assembled around
|
|
12
|
+
it — so only the query/clear side moves here.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_history_query_runtime(
|
|
22
|
+
*,
|
|
23
|
+
conversations: Any,
|
|
24
|
+
workspace_service: Any,
|
|
25
|
+
require_auth: bool,
|
|
26
|
+
logging: Any,
|
|
27
|
+
) -> Dict[str, Any]:
|
|
28
|
+
"""Return the history scope/query/clear helpers as a name → callable dict."""
|
|
29
|
+
|
|
30
|
+
def _history_allowed_workspaces_for(user_email: Optional[str]):
|
|
31
|
+
if not require_auth or not user_email:
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
return set(workspace_service.readable_workspaces(user_email))
|
|
35
|
+
except Exception as exc:
|
|
36
|
+
logging.warning("history workspace scope resolution failed for %s: %s", user_email, exc)
|
|
37
|
+
return set()
|
|
38
|
+
|
|
39
|
+
def _history_include_legacy_global(user_email: Optional[str]) -> bool:
|
|
40
|
+
return not require_auth or not user_email
|
|
41
|
+
|
|
42
|
+
def get_history(
|
|
43
|
+
user_email: Optional[str] = None,
|
|
44
|
+
allowed_workspaces=None,
|
|
45
|
+
include_legacy_global: Optional[bool] = None,
|
|
46
|
+
):
|
|
47
|
+
try:
|
|
48
|
+
if allowed_workspaces is None and user_email:
|
|
49
|
+
allowed_workspaces = _history_allowed_workspaces_for(user_email)
|
|
50
|
+
if include_legacy_global is None:
|
|
51
|
+
include_legacy_global = _history_include_legacy_global(user_email)
|
|
52
|
+
return conversations.history(
|
|
53
|
+
user_email=user_email,
|
|
54
|
+
allowed_workspaces=allowed_workspaces,
|
|
55
|
+
include_legacy_global=include_legacy_global,
|
|
56
|
+
)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logging.warning("get_history failed: %s", e)
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
def conversation_title(item: Dict) -> str:
|
|
62
|
+
content = str(item.get("content") or "").strip()
|
|
63
|
+
content = re.sub(r"\s+", " ", content)
|
|
64
|
+
return content[:48] or "새 대화"
|
|
65
|
+
|
|
66
|
+
def group_history_conversations(history: Optional[List[Dict]] = None) -> List[Dict]:
|
|
67
|
+
history = history if history is not None else get_history()
|
|
68
|
+
grouped: Dict[str, Dict] = {}
|
|
69
|
+
order: List[str] = []
|
|
70
|
+
|
|
71
|
+
for index, item in enumerate(history):
|
|
72
|
+
conv_id = item.get("conversation_id")
|
|
73
|
+
if not conv_id:
|
|
74
|
+
conv_id = "legacy-previous-history"
|
|
75
|
+
|
|
76
|
+
if conv_id not in grouped:
|
|
77
|
+
grouped[conv_id] = {
|
|
78
|
+
"id": conv_id,
|
|
79
|
+
"title": "이전 대화 기록" if conv_id == "legacy-previous-history" else conversation_title(item),
|
|
80
|
+
"created_at": item.get("timestamp"),
|
|
81
|
+
"updated_at": item.get("timestamp"),
|
|
82
|
+
"message_count": 0,
|
|
83
|
+
"last_message": "",
|
|
84
|
+
"source": item.get("source"),
|
|
85
|
+
}
|
|
86
|
+
order.append(conv_id)
|
|
87
|
+
|
|
88
|
+
conv = grouped[conv_id]
|
|
89
|
+
conv["message_count"] += 1
|
|
90
|
+
conv["updated_at"] = item.get("timestamp") or conv.get("updated_at")
|
|
91
|
+
conv["last_message"] = conversation_title(item)
|
|
92
|
+
if conv_id != "legacy-previous-history" and item.get("role") == "user" and (not conv.get("title") or conv["title"] == "새 대화"):
|
|
93
|
+
conv["title"] = conversation_title(item)
|
|
94
|
+
|
|
95
|
+
return sorted((grouped[key] for key in order), key=lambda item: item.get("updated_at") or "", reverse=True)
|
|
96
|
+
|
|
97
|
+
def get_conversation_messages(
|
|
98
|
+
conversation_id: str,
|
|
99
|
+
*,
|
|
100
|
+
user_email: Optional[str] = None,
|
|
101
|
+
allowed_workspaces=None,
|
|
102
|
+
include_legacy_global: Optional[bool] = None,
|
|
103
|
+
) -> List[Dict]:
|
|
104
|
+
history = get_history(
|
|
105
|
+
user_email=user_email,
|
|
106
|
+
allowed_workspaces=allowed_workspaces,
|
|
107
|
+
include_legacy_global=include_legacy_global,
|
|
108
|
+
)
|
|
109
|
+
if conversation_id == "legacy-previous-history":
|
|
110
|
+
return [item for item in history if not item.get("conversation_id")]
|
|
111
|
+
return [item for item in history if item.get("conversation_id") == conversation_id]
|
|
112
|
+
|
|
113
|
+
def clear_history(
|
|
114
|
+
keep_last: int = 0,
|
|
115
|
+
*,
|
|
116
|
+
user_email: Optional[str] = None,
|
|
117
|
+
allowed_workspaces=None,
|
|
118
|
+
include_legacy_global: Optional[bool] = None,
|
|
119
|
+
) -> Dict:
|
|
120
|
+
if allowed_workspaces is None and user_email:
|
|
121
|
+
allowed_workspaces = _history_allowed_workspaces_for(user_email)
|
|
122
|
+
if include_legacy_global is None:
|
|
123
|
+
include_legacy_global = _history_include_legacy_global(user_email)
|
|
124
|
+
return conversations.clear_all(
|
|
125
|
+
keep_last=keep_last,
|
|
126
|
+
user_email=user_email,
|
|
127
|
+
allowed_workspaces=allowed_workspaces,
|
|
128
|
+
include_legacy_global=include_legacy_global,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def clear_conversation(
|
|
132
|
+
conversation_id: str,
|
|
133
|
+
started_at: Optional[str] = None,
|
|
134
|
+
*,
|
|
135
|
+
user_email: Optional[str] = None,
|
|
136
|
+
allowed_workspaces=None,
|
|
137
|
+
include_legacy_global: Optional[bool] = None,
|
|
138
|
+
) -> Dict:
|
|
139
|
+
if allowed_workspaces is None and user_email:
|
|
140
|
+
allowed_workspaces = _history_allowed_workspaces_for(user_email)
|
|
141
|
+
if include_legacy_global is None:
|
|
142
|
+
include_legacy_global = _history_include_legacy_global(user_email)
|
|
143
|
+
return conversations.clear_conversation(
|
|
144
|
+
conversation_id,
|
|
145
|
+
started_at=started_at,
|
|
146
|
+
user_email=user_email,
|
|
147
|
+
allowed_workspaces=allowed_workspaces,
|
|
148
|
+
include_legacy_global=include_legacy_global,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"_history_allowed_workspaces_for": _history_allowed_workspaces_for,
|
|
153
|
+
"_history_include_legacy_global": _history_include_legacy_global,
|
|
154
|
+
"get_history": get_history,
|
|
155
|
+
"conversation_title": conversation_title,
|
|
156
|
+
"group_history_conversations": group_history_conversations,
|
|
157
|
+
"get_conversation_messages": get_conversation_messages,
|
|
158
|
+
"clear_history": clear_history,
|
|
159
|
+
"clear_conversation": clear_conversation,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
__all__ = ["build_history_query_runtime"]
|