ltcai 0.1.11 → 0.1.16
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 +49 -8
- package/docs/CHANGELOG.md +80 -0
- package/knowledge_graph.py +123 -15
- package/llm_router.py +100 -28
- package/ltcai_cli.py +137 -4
- package/package.json +14 -2
- package/server.py +759 -68
- package/static/account.html +53 -51
- package/static/admin.html +50 -46
- package/static/chat.html +124 -96
- package/static/graph.html +1231 -337
- package/static/manifest.json +2 -2
package/README.md
CHANGED
|
@@ -4,13 +4,34 @@
|
|
|
4
4
|
|
|
5
5
|
Apple Silicon MLX 로컬 추론 · OpenAI/Groq/OpenRouter 클라우드 모델 · Graph RAG · 멀티스텝 에이전트 워크플로
|
|
6
6
|
|
|
7
|
-
[](https://pypi.org/project/ltcai/)
|
|
8
|
-
[](https://www.npmjs.com/package/ltcai)
|
|
9
|
-
[](https://pypi.org/project/ltcai/)
|
|
8
|
+
[](https://www.npmjs.com/package/ltcai)
|
|
9
|
+
[](https://marketplace.visualstudio.com/items?itemName=parktaesoo.ltcai)
|
|
10
|
+
[](https://open-vsx.org/extension/parktaesoo/ltcai)
|
|
11
|
+
[](./LICENSE)
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
Lattice AI는 개인 개발자가 로컬 모델, 클라우드 모델, 에이전트 툴링, 코드 에디터 연동을 하나의 워크스페이스로 운영할 수 있게 만든 서버입니다.
|
|
14
|
+
|
|
15
|
+
### 현재 배포 버전
|
|
16
|
+
|
|
17
|
+
- `PyPI`: `ltcai==0.1.16`
|
|
18
|
+
- `npm`: `ltcai@0.1.16`
|
|
19
|
+
- `VS Code Marketplace`: `parktaesoo.ltcai@0.1.16`
|
|
20
|
+
- `Open VSX`: `parktaesoo.ltcai@0.1.16`
|
|
21
|
+
|
|
22
|
+
### 왜 Lattice AI인가
|
|
23
|
+
|
|
24
|
+
- **하나의 서버, 여러 인터페이스**: 웹 UI, VS Code/Cursor 확장, Telegram 봇, MCP 도구를 한 번에 연결합니다.
|
|
25
|
+
- **로컬 우선 + 클라우드 선택**: Apple Silicon MLX 로컬 모델과 OpenAI 호환 클라우드 모델을 같은 UX로 다룹니다.
|
|
26
|
+
- **실전형 에이전트 워크플로**: 파일 편집, grep, todo, 터미널 도구를 묶어 멀티스텝 작업을 수행합니다.
|
|
27
|
+
|
|
28
|
+
### 빠른 링크
|
|
29
|
+
|
|
30
|
+
- [설치 & 첫 실행](#설치--첫-실행-30초)
|
|
31
|
+
- [퍼블릭 배포 가이드](./docs/public-deploy.md)
|
|
32
|
+
- [보안 모델](./docs/security-model.md)
|
|
33
|
+
- [아키텍처](./docs/architecture.md)
|
|
34
|
+
- [변경 이력](./docs/CHANGELOG.md)
|
|
14
35
|
|
|
15
36
|
---
|
|
16
37
|
|
|
@@ -26,11 +47,22 @@ pip install "ltcai[local]"
|
|
|
26
47
|
# npm (자동 Python 환경 구성)
|
|
27
48
|
npm install -g ltcai
|
|
28
49
|
|
|
29
|
-
# 서버 실행
|
|
50
|
+
# 서버 실행 (로컬)
|
|
30
51
|
LTCAI
|
|
31
52
|
# → http://localhost:4825
|
|
53
|
+
|
|
54
|
+
# 외부에서 접속 가능하게 실행 (Cloudflare 터널 자동 개설)
|
|
55
|
+
LTCAI --tunnel
|
|
56
|
+
# → http://localhost:4825
|
|
57
|
+
# → https://xxxx.trycloudflare.com ← 어디서든 접속 가능한 공개 URL
|
|
32
58
|
```
|
|
33
59
|
|
|
60
|
+
**`--tunnel` 동작 방식:**
|
|
61
|
+
- cloudflared가 없으면 자동 다운로드 (계정 불필요)
|
|
62
|
+
- 서버를 `0.0.0.0`에 바인딩하고 Cloudflare 무료 터널로 HTTPS 공개 URL 발급
|
|
63
|
+
- `LATTICEAI_TELEGRAM_BOT_TOKEN` + `LATTICEAI_TELEGRAM_CHAT_ID` 환경변수가 있으면 시작 시 Telegram으로 URL 자동 전송
|
|
64
|
+
- 서버 종료 시 터널도 함께 종료
|
|
65
|
+
|
|
34
66
|
**설치 확인:**
|
|
35
67
|
|
|
36
68
|
```
|
|
@@ -214,6 +246,15 @@ docker run --rm -p 4825:4825 \
|
|
|
214
246
|
| VS Code / Cursor | [marketplace.visualstudio.com](https://marketplace.visualstudio.com/items?itemName=parktaesoo.ltcai) |
|
|
215
247
|
| Antigravity / VSCodium | [open-vsx.org](https://open-vsx.org/extension/parktaesoo/ltcai) |
|
|
216
248
|
|
|
249
|
+
### 릴리스 체크
|
|
250
|
+
|
|
251
|
+
`0.1.16 릴리스는 아래 네 채널을 동일 버전으로 맞춥니다.
|
|
252
|
+
|
|
253
|
+
- `npm`
|
|
254
|
+
- `PyPI`
|
|
255
|
+
- `VS Code Marketplace`
|
|
256
|
+
- `Open VSX`
|
|
257
|
+
|
|
217
258
|
### 수동 설치 (VSIX)
|
|
218
259
|
|
|
219
260
|
```bash
|
|
@@ -318,7 +359,7 @@ launchctl load ~/Library/LaunchAgents/com.ltcai.plist
|
|
|
318
359
|
|
|
319
360
|
## 릴리스 노트
|
|
320
361
|
|
|
321
|
-
현재 버전: **0.1.
|
|
362
|
+
현재 버전: **0.1.16** — 자세한 변경 이력은 [docs/CHANGELOG.md](docs/CHANGELOG.md) 참고.
|
|
322
363
|
|
|
323
364
|
## 라이선스
|
|
324
365
|
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.16] - 2026-05-22
|
|
4
|
+
|
|
5
|
+
### First-user admin bootstrap
|
|
6
|
+
|
|
7
|
+
- 서버를 처음 설치하고 가입하는 첫 번째 사용자가 자동으로 **admin** 권한 획득
|
|
8
|
+
- 이후 가입자는 기존과 동일하게 `user` 역할
|
|
9
|
+
- `/register` 응답에 `role` 필드 추가 — 클라이언트가 첫 가입 여부 확인 가능
|
|
10
|
+
|
|
11
|
+
### Release
|
|
12
|
+
- 배포 버전을 `0.1.16`으로 상향
|
|
13
|
+
|
|
14
|
+
## [0.1.15] - 2026-05-22
|
|
15
|
+
|
|
16
|
+
### Security hardening
|
|
17
|
+
|
|
18
|
+
- `LTCAI --tunnel` 실행 시 `LATTICEAI_REQUIRE_AUTH=true` 자동 강제 — 터널로 공개된 서버에 로그인 없이 접근 불가
|
|
19
|
+
- `/register` IP당 시간당 5회 rate limit
|
|
20
|
+
- `/login` IP당 5분당 10회 rate limit (brute force 방지)
|
|
21
|
+
- Cloudflare 터널 통과 시 `CF-Connecting-IP` 헤더로 실제 클라이언트 IP 추출
|
|
22
|
+
- `LATTICEAI_OPEN_REGISTRATION=false` 설정 시 회원가입 완전 차단 (관리자 직접 추가만 허용)
|
|
23
|
+
|
|
24
|
+
### Release
|
|
25
|
+
- 배포 버전을 `0.1.15`로 상향
|
|
26
|
+
|
|
27
|
+
## [0.1.14] - 2026-05-22
|
|
28
|
+
|
|
29
|
+
### `--tunnel` flag — 누구나 자기 PC를 서버로
|
|
30
|
+
|
|
31
|
+
- `LTCAI --tunnel` 한 줄로 Cloudflare 무료 터널 자동 개설
|
|
32
|
+
- cloudflared 바이너리가 없으면 GitHub에서 자동 다운로드 (`~/.latticeai/bin/`)
|
|
33
|
+
- macOS arm64/amd64, Linux arm64/amd64, Windows amd64 지원
|
|
34
|
+
- 터널 URL을 배너에 출력 + `LATTICEAI_TELEGRAM_BOT_TOKEN` / `LATTICEAI_TELEGRAM_CHAT_ID` 설정 시 Telegram 자동 알림
|
|
35
|
+
- `--tunnel` 지정 시 host 자동으로 `0.0.0.0`, CORS 네트워크 허용으로 전환
|
|
36
|
+
|
|
37
|
+
### Release
|
|
38
|
+
- 배포 버전을 `0.1.14`로 상향
|
|
39
|
+
- 대상 채널: `npm`, `PyPI`, `VS Code Marketplace`, `Open VSX`
|
|
40
|
+
|
|
41
|
+
## [0.1.13] - 2026-05-22
|
|
42
|
+
|
|
43
|
+
### Code quality & efficiency
|
|
44
|
+
|
|
45
|
+
- `HF_MODELS_ROOT` / `hf_model_dir` 중복 정의 제거 — `llm_router.py` 단일 소스로 통합, `server.py`에서 import
|
|
46
|
+
- `_looks_like_hf_model_dir` 가중치 파일 체크를 `.safetensors` / `.bin`으로 일치 — `.gguf`를 MLX 경로에서 잘못 허용하던 버그 수정
|
|
47
|
+
- `vllm_executable()` `shutil.which` 이중 호출 → 변수 캐시
|
|
48
|
+
- `ensure_lmstudio_model()` `_find_lmstudio_model_key` 이중 호출 → `found_key` 변수로 캐시
|
|
49
|
+
- `engine_support_status` 3단계 중첩 조건 → `is_apple_silicon` 플래그로 평탄화
|
|
50
|
+
- `ensure_llamacpp_server` 동일 프로세스 이중 `terminate()` 블록 → 단일 블록 (vllm 패턴과 통일)
|
|
51
|
+
- `ensure_vllm_server` 37줄 중첩 삼항 커맨드 빌더 → `if/elif/else` + `_host_args` 공통화
|
|
52
|
+
- `except: pass` → `except Exception: pass` (KeyboardInterrupt 노출)
|
|
53
|
+
- `knowledge_graph.py` 엣지 순회 루프 두 번 (`degree_map` + `topic_metrics`) → 단일 루프로 병합
|
|
54
|
+
|
|
55
|
+
### Performance & correctness
|
|
56
|
+
|
|
57
|
+
- `get_lmstudio_models()` TTL 캐시(10초) 추가 — `/health`, `/engines`, `/models` 매 요청마다 LM Studio HTTP 프로브하던 문제 해결, 서버 미응답 시 마지막 캐시 반환
|
|
58
|
+
- `/health`, `/engines`, `/models` 엔드포인트에서 `engine_status()` 호출을 `asyncio.to_thread()`로 오프로드 — LM Studio 최대 45초, ollama subprocess 블로킹이 이벤트 루프를 점유하던 문제 해결
|
|
59
|
+
- 앱 종료 시 `LOCAL_SERVER_PROCESSES` (vLLM, llama.cpp) 자식 프로세스 정리 — GPU 메모리 고아 프로세스 누수 수정
|
|
60
|
+
|
|
61
|
+
### Release
|
|
62
|
+
- 배포 버전을 `0.1.13`으로 상향
|
|
63
|
+
- 대상 채널: `npm`, `PyPI`, `VS Code Marketplace`, `Open VSX`
|
|
64
|
+
|
|
65
|
+
## [0.1.12] - 2026-05-22
|
|
66
|
+
|
|
67
|
+
### Local engine install / load flow
|
|
68
|
+
- `vLLM` 설치 경로를 macOS용 `Python 3.12 + vllm-metal` 흐름으로 교체
|
|
69
|
+
- `LM Studio` 번들 `lms` CLI와 native API를 사용해 서버 시작, 모델 다운로드, 모델 로드를 자동화
|
|
70
|
+
- `llama.cpp`는 선택한 GGUF를 alias와 함께 OpenAI 호환 서버로 직접 로드하도록 정리
|
|
71
|
+
- 모델 패널의 `설치` / `다운로드 후 자동 로드` 흐름이 실제 `prepare_and_load_model()` 경로로 수렴되도록 정리
|
|
72
|
+
|
|
73
|
+
### Verified
|
|
74
|
+
- 최소 테스트 모델 기준 실사용 검증 완료
|
|
75
|
+
- `vLLM`: `Qwen/Qwen2.5-0.5B-Instruct-AWQ`
|
|
76
|
+
- `LM Studio`: `https://huggingface.co/lmstudio-community/Qwen2.5-0.5B-Instruct-GGUF`
|
|
77
|
+
- `llama.cpp`: `lmstudio-community/Qwen2.5-0.5B-Instruct-GGUF`
|
|
78
|
+
|
|
79
|
+
### Release
|
|
80
|
+
- 배포 버전을 `0.1.12`로 상향
|
|
81
|
+
- 대상 채널: `npm`, `PyPI`, `VS Code Marketplace`, `Open VSX`
|
|
82
|
+
|
|
3
83
|
## [0.1.11] - 2026-05-21
|
|
4
84
|
|
|
5
85
|
### Agent state machine (renamed + cleaned up)
|
package/knowledge_graph.py
CHANGED
|
@@ -9,6 +9,7 @@ the ingestion contract.
|
|
|
9
9
|
import hashlib
|
|
10
10
|
import json
|
|
11
11
|
import logging
|
|
12
|
+
import math
|
|
12
13
|
import re
|
|
13
14
|
import shutil
|
|
14
15
|
import sqlite3
|
|
@@ -25,6 +26,25 @@ def _now() -> str:
|
|
|
25
26
|
return datetime.now().isoformat()
|
|
26
27
|
|
|
27
28
|
|
|
29
|
+
def _parse_iso(raw: Optional[str]) -> Optional[datetime]:
|
|
30
|
+
if not raw:
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
return datetime.fromisoformat(str(raw))
|
|
34
|
+
except (TypeError, ValueError):
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _recency_score(updated_at: Optional[str], *, now: Optional[datetime] = None, half_life_days: float = 14.0) -> float:
|
|
39
|
+
stamp = _parse_iso(updated_at)
|
|
40
|
+
if not stamp:
|
|
41
|
+
return 0.0
|
|
42
|
+
now = now or datetime.now()
|
|
43
|
+
age_days = max(0.0, (now - stamp).total_seconds() / 86400.0)
|
|
44
|
+
decay = math.log(2) / max(0.1, half_life_days)
|
|
45
|
+
return math.exp(-decay * age_days)
|
|
46
|
+
|
|
47
|
+
|
|
28
48
|
def _json(data: Optional[Dict[str, Any]]) -> str:
|
|
29
49
|
return json.dumps(data or {}, ensure_ascii=False, sort_keys=True)
|
|
30
50
|
|
|
@@ -587,28 +607,115 @@ class KnowledgeGraphStore:
|
|
|
587
607
|
"title": row["title"],
|
|
588
608
|
"summary": row["summary"],
|
|
589
609
|
"metadata": _safe_loads(row["metadata_json"]),
|
|
610
|
+
"updated_at": row["updated_at"],
|
|
590
611
|
}
|
|
591
612
|
for row in conn.execute(
|
|
592
|
-
"SELECT id, type, title, summary, metadata_json FROM nodes WHERE type != 'Chunk' ORDER BY updated_at DESC LIMIT ?",
|
|
613
|
+
"SELECT id, type, title, summary, metadata_json, updated_at FROM nodes WHERE type != 'Chunk' ORDER BY updated_at DESC LIMIT ?",
|
|
593
614
|
(limit,),
|
|
594
615
|
)
|
|
595
616
|
]
|
|
596
617
|
node_ids = {node["id"] for node in nodes}
|
|
597
|
-
edges = [
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
"
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
618
|
+
edges: List[Dict[str, Any]] = []
|
|
619
|
+
if node_ids:
|
|
620
|
+
edge_rows = conn.execute(
|
|
621
|
+
"""
|
|
622
|
+
SELECT id, from_node, to_node, type, weight, metadata_json
|
|
623
|
+
FROM edges
|
|
624
|
+
WHERE from_node IN (
|
|
625
|
+
SELECT id
|
|
626
|
+
FROM nodes
|
|
627
|
+
WHERE type != 'Chunk'
|
|
628
|
+
ORDER BY updated_at DESC
|
|
629
|
+
LIMIT ?
|
|
630
|
+
)
|
|
631
|
+
AND to_node IN (
|
|
632
|
+
SELECT id
|
|
633
|
+
FROM nodes
|
|
634
|
+
WHERE type != 'Chunk'
|
|
635
|
+
ORDER BY updated_at DESC
|
|
636
|
+
LIMIT ?
|
|
637
|
+
)
|
|
638
|
+
ORDER BY created_at DESC
|
|
639
|
+
""",
|
|
640
|
+
(limit, limit),
|
|
641
|
+
).fetchall()
|
|
642
|
+
edges = [
|
|
643
|
+
{
|
|
644
|
+
"id": row["id"],
|
|
645
|
+
"from": row["from_node"],
|
|
646
|
+
"to": row["to_node"],
|
|
647
|
+
"type": row["type"],
|
|
648
|
+
"weight": row["weight"],
|
|
649
|
+
"metadata": _safe_loads(row["metadata_json"]),
|
|
650
|
+
}
|
|
651
|
+
for row in edge_rows
|
|
652
|
+
]
|
|
653
|
+
|
|
654
|
+
degree_map: Dict[str, int] = {}
|
|
655
|
+
now = datetime.now()
|
|
656
|
+
node_by_id = {node["id"]: node for node in nodes}
|
|
657
|
+
topic_metrics: Dict[str, Dict[str, Any]] = {}
|
|
658
|
+
|
|
659
|
+
for edge in edges:
|
|
660
|
+
degree_map[edge["from"]] = degree_map.get(edge["from"], 0) + 1
|
|
661
|
+
degree_map[edge["to"]] = degree_map.get(edge["to"], 0) + 1
|
|
662
|
+
from_node = node_by_id.get(edge["from"])
|
|
663
|
+
to_node = node_by_id.get(edge["to"])
|
|
664
|
+
if not from_node or not to_node:
|
|
665
|
+
continue
|
|
666
|
+
for topic_node, other_node in ((from_node, to_node), (to_node, from_node)):
|
|
667
|
+
if topic_node["type"] != "Topic":
|
|
668
|
+
continue
|
|
669
|
+
metrics = topic_metrics.setdefault(topic_node["id"], {
|
|
670
|
+
"mention_count": 0.0,
|
|
671
|
+
"conversation_ids": set(),
|
|
672
|
+
})
|
|
673
|
+
if edge["type"] in {"mentions", "discusses"}:
|
|
674
|
+
metrics["mention_count"] += max(0.5, float(edge.get("weight") or 1.0))
|
|
675
|
+
other_meta = other_node.get("metadata") or {}
|
|
676
|
+
conversation_id = other_meta.get("conversation_id")
|
|
677
|
+
if other_node["type"] == "Conversation":
|
|
678
|
+
conversation_id = other_node["id"]
|
|
679
|
+
if conversation_id:
|
|
680
|
+
metrics["conversation_ids"].add(str(conversation_id))
|
|
681
|
+
|
|
682
|
+
type_max_raw: Dict[str, float] = {}
|
|
683
|
+
for node in nodes:
|
|
684
|
+
degree = degree_map.get(node["id"], 0)
|
|
685
|
+
recency = _recency_score(node.get("updated_at"), now=now)
|
|
686
|
+
metrics = {
|
|
687
|
+
"degree": degree,
|
|
688
|
+
"recency_score": round(recency, 4),
|
|
689
|
+
}
|
|
690
|
+
if node["type"] == "Topic":
|
|
691
|
+
topic_stat = topic_metrics.get(node["id"], {})
|
|
692
|
+
mention_count = float(topic_stat.get("mention_count") or 0.0)
|
|
693
|
+
conversation_count = len(topic_stat.get("conversation_ids") or ())
|
|
694
|
+
raw_importance = (
|
|
695
|
+
math.log1p(mention_count) * 2.8
|
|
696
|
+
+ math.log1p(conversation_count) * 2.2
|
|
697
|
+
+ recency * 1.4
|
|
698
|
+
+ math.sqrt(max(0, degree)) * 0.45
|
|
609
699
|
)
|
|
610
|
-
|
|
611
|
-
|
|
700
|
+
metrics.update({
|
|
701
|
+
"mention_count": round(mention_count, 2),
|
|
702
|
+
"conversation_count": conversation_count,
|
|
703
|
+
})
|
|
704
|
+
else:
|
|
705
|
+
raw_importance = math.log1p(max(0, degree)) * 1.4 + recency * 0.9
|
|
706
|
+
|
|
707
|
+
metrics["importance_raw"] = round(raw_importance, 4)
|
|
708
|
+
node["importance"] = round(raw_importance, 4)
|
|
709
|
+
node["_raw_importance"] = raw_importance
|
|
710
|
+
node["metadata"] = {**(node.get("metadata") or {}), "graph_metrics": metrics}
|
|
711
|
+
type_max_raw[node["type"]] = max(type_max_raw.get(node["type"], 0.0), raw_importance)
|
|
712
|
+
|
|
713
|
+
for node in nodes:
|
|
714
|
+
max_raw = max(type_max_raw.get(node["type"], 0.0), 0.0001)
|
|
715
|
+
importance_norm = min(1.0, (node.get("_raw_importance") or 0.0) / max_raw)
|
|
716
|
+
node["importance_norm"] = round(importance_norm, 4)
|
|
717
|
+
node["metadata"]["graph_metrics"]["importance_norm"] = node["importance_norm"]
|
|
718
|
+
node.pop("_raw_importance", None)
|
|
612
719
|
return {"nodes": nodes, "edges": edges}
|
|
613
720
|
|
|
614
721
|
def search(self, query: str, limit: int = 30) -> Dict[str, Any]:
|
|
@@ -669,6 +776,7 @@ class KnowledgeGraphStore:
|
|
|
669
776
|
"title": row["title"],
|
|
670
777
|
"summary": row["summary"],
|
|
671
778
|
"metadata": _safe_loads(row["metadata_json"]),
|
|
779
|
+
"updated_at": row["updated_at"],
|
|
672
780
|
}
|
|
673
781
|
for row in rows
|
|
674
782
|
],
|
package/llm_router.py
CHANGED
|
@@ -10,6 +10,7 @@ import os
|
|
|
10
10
|
import re
|
|
11
11
|
import time
|
|
12
12
|
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
13
14
|
|
|
14
15
|
# Set MLX_VLM_DRAFT_KIND to 'mtp' to enable the Gemma 4 assistant MTP drafter.
|
|
15
16
|
os.environ["MLX_VLM_DRAFT_KIND"] = "mtp"
|
|
@@ -167,10 +168,59 @@ def parse_model_ref(model_id: str) -> tuple[str, str]:
|
|
|
167
168
|
provider, model = model_id.split(":", 1)
|
|
168
169
|
if provider in OPENAI_COMPATIBLE_PROVIDERS:
|
|
169
170
|
return provider, model
|
|
171
|
+
if provider in {"local_mlx", "mlx"}:
|
|
172
|
+
return "local_mlx", model
|
|
170
173
|
if model_id.startswith("local_mlx:"):
|
|
171
174
|
return "local_mlx", model_id.split(":", 1)[1]
|
|
172
175
|
return "local_mlx", model_id
|
|
173
176
|
|
|
177
|
+
HF_MODELS_ROOT = Path.home() / ".latticeai" / "hf-models"
|
|
178
|
+
|
|
179
|
+
def hf_model_dir(repo_id: str) -> Path:
|
|
180
|
+
return HF_MODELS_ROOT / repo_id.replace("/", "__")
|
|
181
|
+
|
|
182
|
+
def _looks_like_hf_model_dir(path: Path) -> bool:
|
|
183
|
+
if not path.exists() or not path.is_dir():
|
|
184
|
+
return False
|
|
185
|
+
has_config = (path / "config.json").exists()
|
|
186
|
+
has_weights = any(path.glob("*.safetensors")) or any(path.glob("*.bin"))
|
|
187
|
+
has_tokenizer = (
|
|
188
|
+
(path / "tokenizer.json").exists()
|
|
189
|
+
or (path / "tokenizer.model").exists()
|
|
190
|
+
or (path / "tokenizer_config.json").exists()
|
|
191
|
+
)
|
|
192
|
+
return has_config and has_weights and has_tokenizer
|
|
193
|
+
|
|
194
|
+
def _resolve_local_hf_model(model_id: str) -> str:
|
|
195
|
+
explicit_path = Path(model_id).expanduser()
|
|
196
|
+
if explicit_path.exists():
|
|
197
|
+
return str(explicit_path)
|
|
198
|
+
local_dir = hf_model_dir(model_id)
|
|
199
|
+
if _looks_like_hf_model_dir(local_dir):
|
|
200
|
+
return str(local_dir)
|
|
201
|
+
return model_id
|
|
202
|
+
|
|
203
|
+
def ensure_mlx_runtime() -> None:
|
|
204
|
+
global mx, lm_load, vlm_load, VLM_AVAILABLE
|
|
205
|
+
if mx is not None and lm_load is not None:
|
|
206
|
+
return
|
|
207
|
+
try:
|
|
208
|
+
import mlx.core as mlx_core
|
|
209
|
+
from mlx_lm import load as mlx_lm_load
|
|
210
|
+
|
|
211
|
+
mx = mlx_core
|
|
212
|
+
lm_load = mlx_lm_load
|
|
213
|
+
try:
|
|
214
|
+
from mlx_vlm import load as mlx_vlm_load
|
|
215
|
+
vlm_load = mlx_vlm_load
|
|
216
|
+
VLM_AVAILABLE = True
|
|
217
|
+
except Exception:
|
|
218
|
+
vlm_load = None
|
|
219
|
+
VLM_AVAILABLE = False
|
|
220
|
+
mx.set_default_device(mx.gpu)
|
|
221
|
+
except Exception as e:
|
|
222
|
+
raise RuntimeError(f"MLX runtime is not available after install: {e}") from e
|
|
223
|
+
|
|
174
224
|
class LLMRouter:
|
|
175
225
|
def __init__(self):
|
|
176
226
|
self._cache: Dict[str, Tuple] = {}
|
|
@@ -262,6 +312,7 @@ class LLMRouter:
|
|
|
262
312
|
if provider != "local_mlx":
|
|
263
313
|
return self._load_cloud_model(provider, provider_model, api_key_override=api_key_override, owner=owner)
|
|
264
314
|
|
|
315
|
+
ensure_mlx_runtime()
|
|
265
316
|
if mx is None or lm_load is None:
|
|
266
317
|
raise RuntimeError("MLX is not available in this process. Run on Apple Silicon with Metal access.")
|
|
267
318
|
|
|
@@ -274,6 +325,8 @@ class LLMRouter:
|
|
|
274
325
|
self._enforce_local_model_limit(cache_key)
|
|
275
326
|
print(f"⏳ Loading Gemma 4 Stack: {cache_key}...")
|
|
276
327
|
loop = asyncio.get_event_loop()
|
|
328
|
+
target_model_id = _resolve_local_hf_model(model_id)
|
|
329
|
+
target_draft_model_id = _resolve_local_hf_model(draft_model_id) if draft_model_id else None
|
|
277
330
|
|
|
278
331
|
def _load():
|
|
279
332
|
mx.set_default_device(mx.gpu)
|
|
@@ -281,20 +334,20 @@ class LLMRouter:
|
|
|
281
334
|
|
|
282
335
|
# 1. Target 로드 (Gemma 4는 항상 vlm_load 사용)
|
|
283
336
|
if is_gemma4 and VLM_AVAILABLE:
|
|
284
|
-
print(f"🔄 Loading Target (VLM Mode): {
|
|
285
|
-
model, tokenizer = vlm_load(
|
|
337
|
+
print(f"🔄 Loading Target (VLM Mode): {target_model_id}...")
|
|
338
|
+
model, tokenizer = vlm_load(target_model_id)
|
|
286
339
|
else:
|
|
287
|
-
print(f"🔄 Loading Target (LM Mode): {
|
|
288
|
-
model, tokenizer = lm_load(
|
|
340
|
+
print(f"🔄 Loading Target (LM Mode): {target_model_id}...")
|
|
341
|
+
model, tokenizer = lm_load(target_model_id)
|
|
289
342
|
|
|
290
343
|
# 2. Draft 로드 (Gemma 4는 항상 vlm_load 사용)
|
|
291
344
|
draft_model = None
|
|
292
|
-
if
|
|
293
|
-
print(f"🔄 Loading Assistant (VLM Mode): {
|
|
345
|
+
if target_draft_model_id:
|
|
346
|
+
print(f"🔄 Loading Assistant (VLM Mode): {target_draft_model_id}...")
|
|
294
347
|
if is_gemma4 and VLM_AVAILABLE:
|
|
295
|
-
draft_model, _ = vlm_load(
|
|
348
|
+
draft_model, _ = vlm_load(target_draft_model_id)
|
|
296
349
|
else:
|
|
297
|
-
draft_model, _ = lm_load(
|
|
350
|
+
draft_model, _ = lm_load(target_draft_model_id)
|
|
298
351
|
print(f"✅ Assistant Ready.")
|
|
299
352
|
|
|
300
353
|
return model, tokenizer, draft_model
|
|
@@ -374,6 +427,18 @@ class LLMRouter:
|
|
|
374
427
|
def _is_cloud_current(self) -> bool:
|
|
375
428
|
return bool(self._current and isinstance(self._cache.get(self._current), CloudModel))
|
|
376
429
|
|
|
430
|
+
def _local_server_error_hint(self, cloud: CloudModel, error: Exception) -> str:
|
|
431
|
+
raw = str(error)
|
|
432
|
+
if cloud.provider == "lmstudio":
|
|
433
|
+
base_url = os.getenv("LMSTUDIO_BASE_URL") or OPENAI_COMPATIBLE_PROVIDERS["lmstudio"]["base_url"]
|
|
434
|
+
return (
|
|
435
|
+
f"LM Studio 연결 실패: {raw}\n\n"
|
|
436
|
+
f"- LM Studio의 Developer/Local Server를 켜고 모델을 로드했는지 확인하세요.\n"
|
|
437
|
+
f"- Lattice가 보는 주소는 {base_url} 입니다. 포트가 다르면 LMSTUDIO_BASE_URL을 맞춰주세요.\n"
|
|
438
|
+
f"- 모델 선택창에는 LM Studio /v1/models에서 감지된 모델만 표시됩니다."
|
|
439
|
+
)
|
|
440
|
+
return raw
|
|
441
|
+
|
|
377
442
|
def _build_prompt(self, message: str, context: Optional[str], tokenizer) -> str:
|
|
378
443
|
system = SYSTEM_PROMPT
|
|
379
444
|
context = normalize_branding(context)
|
|
@@ -382,7 +447,7 @@ class LLMRouter:
|
|
|
382
447
|
try:
|
|
383
448
|
msgs = [{"role": "system", "content": system}, {"role": "user", "content": message}]
|
|
384
449
|
return tokenizer.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
|
|
385
|
-
except: pass
|
|
450
|
+
except Exception: pass
|
|
386
451
|
return f"<|im_start|>system\n{system}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
|
|
387
452
|
|
|
388
453
|
def _build_vlm_prompt(self, model, processor, message: str, context: Optional[str], num_images: int) -> str:
|
|
@@ -445,15 +510,18 @@ class LLMRouter:
|
|
|
445
510
|
context = normalize_branding(context)
|
|
446
511
|
if context:
|
|
447
512
|
system += f"\n\nContext:\n{context}"
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
513
|
+
try:
|
|
514
|
+
response = await cloud.client.chat.completions.create(
|
|
515
|
+
model=cloud.model,
|
|
516
|
+
messages=[
|
|
517
|
+
{"role": "system", "content": system},
|
|
518
|
+
{"role": "user", "content": message},
|
|
519
|
+
],
|
|
520
|
+
max_tokens=max_tokens,
|
|
521
|
+
temperature=temperature,
|
|
522
|
+
)
|
|
523
|
+
except Exception as e:
|
|
524
|
+
raise RuntimeError(self._local_server_error_hint(cloud, e)) from e
|
|
457
525
|
return normalize_branding(response.choices[0].message.content or "")
|
|
458
526
|
|
|
459
527
|
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]:
|
|
@@ -508,16 +576,20 @@ class LLMRouter:
|
|
|
508
576
|
context = normalize_branding(context)
|
|
509
577
|
if context:
|
|
510
578
|
system += f"\n\nContext:\n{context}"
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
579
|
+
try:
|
|
580
|
+
stream = await cloud.client.chat.completions.create(
|
|
581
|
+
model=cloud.model,
|
|
582
|
+
messages=[
|
|
583
|
+
{"role": "system", "content": system},
|
|
584
|
+
{"role": "user", "content": message},
|
|
585
|
+
],
|
|
586
|
+
max_tokens=max_tokens,
|
|
587
|
+
temperature=temperature,
|
|
588
|
+
stream=True,
|
|
589
|
+
)
|
|
590
|
+
except Exception as e:
|
|
591
|
+
yield f"⚠️ {self._local_server_error_hint(cloud, e)}"
|
|
592
|
+
return
|
|
521
593
|
async for event in stream:
|
|
522
594
|
if not event.choices:
|
|
523
595
|
continue
|