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 CHANGED
@@ -4,13 +4,34 @@
4
4
 
5
5
  Apple Silicon MLX 로컬 추론 · OpenAI/Groq/OpenRouter 클라우드 모델 · Graph RAG · 멀티스텝 에이전트 워크플로
6
6
 
7
- [![PyPI](https://img.shields.io/pypi/v/ltcai)](https://pypi.org/project/ltcai/)
8
- [![npm](https://img.shields.io/npm/v/ltcai)](https://www.npmjs.com/package/ltcai)
9
- [![VS Code](https://img.shields.io/visual-studio-marketplace/v/parktaesoo.ltcai)](https://marketplace.visualstudio.com/items?itemName=parktaesoo.ltcai)
10
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
+ [![PyPI](https://img.shields.io/pypi/v/ltcai?label=pypi)](https://pypi.org/project/ltcai/)
8
+ [![npm](https://img.shields.io/npm/v/ltcai?label=npm)](https://www.npmjs.com/package/ltcai)
9
+ [![VS Code Marketplace](https://vsmarketplacebadges.dev/version/parktaesoo.ltcai.svg)](https://marketplace.visualstudio.com/items?itemName=parktaesoo.ltcai)
10
+ [![Open VSX](https://img.shields.io/open-vsx/v/parktaesoo/ltcai?label=Open%20VSX)](https://open-vsx.org/extension/parktaesoo/ltcai)
11
+ [![License](https://img.shields.io/github/license/TaeSooPark-PTS/LatticeAI)](./LICENSE)
11
12
 
12
- <!-- 스크린샷 / GIF docs/demo.gif 또는 스크린샷으로 교체하세요 -->
13
- <!-- ![Lattice AI demo](docs/demo.gif) -->
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.11** — 자세한 변경 이력은 [docs/CHANGELOG.md](docs/CHANGELOG.md) 참고.
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)
@@ -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
- "id": row["id"],
600
- "from": row["from_node"],
601
- "to": row["to_node"],
602
- "type": row["type"],
603
- "weight": row["weight"],
604
- "metadata": _safe_loads(row["metadata_json"]),
605
- }
606
- for row in conn.execute(
607
- "SELECT id, from_node, to_node, type, weight, metadata_json FROM edges ORDER BY created_at DESC LIMIT ?",
608
- (limit * 3,),
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
- if row["from_node"] in node_ids and row["to_node"] in node_ids
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): {model_id}...")
285
- model, tokenizer = vlm_load(model_id)
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): {model_id}...")
288
- model, tokenizer = lm_load(model_id)
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 draft_model_id:
293
- print(f"🔄 Loading Assistant (VLM Mode): {draft_model_id}...")
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(draft_model_id)
348
+ draft_model, _ = vlm_load(target_draft_model_id)
296
349
  else:
297
- draft_model, _ = lm_load(draft_model_id)
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
- response = await cloud.client.chat.completions.create(
449
- model=cloud.model,
450
- messages=[
451
- {"role": "system", "content": system},
452
- {"role": "user", "content": message},
453
- ],
454
- max_tokens=max_tokens,
455
- temperature=temperature,
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
- stream = await cloud.client.chat.completions.create(
512
- model=cloud.model,
513
- messages=[
514
- {"role": "system", "content": system},
515
- {"role": "user", "content": message},
516
- ],
517
- max_tokens=max_tokens,
518
- temperature=temperature,
519
- stream=True,
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