ltcai 11.2.0 → 11.4.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 +46 -53
- package/docs/CHANGELOG.md +61 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/MULTI_AGENT_RUNTIME.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +6 -2
- package/docs/PERMISSION_MODE.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +2 -2
- package/docs/v11.3.0_PLAN.md +202 -0
- package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common/__init__.py +287 -0
- package/lattice_brain/graph/_kg_common/extraction.py +516 -0
- package/lattice_brain/graph/_kg_common/relations.py +161 -0
- package/lattice_brain/graph/_kg_common/text.py +479 -0
- package/lattice_brain/graph/discovery_index/__init__.py +35 -0
- package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
- package/lattice_brain/graph/discovery_index/extract.py +137 -0
- package/lattice_brain/graph/discovery_index/scan.py +411 -0
- package/lattice_brain/graph/discovery_index/upsert.py +495 -0
- package/lattice_brain/graph/projection/__init__.py +42 -0
- package/lattice_brain/graph/projection/curation.py +500 -0
- package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +15 -477
- package/lattice_brain/graph/retrieval/__init__.py +54 -0
- package/lattice_brain/graph/retrieval/context.py +197 -0
- package/lattice_brain/graph/retrieval/graph_view.py +319 -0
- package/lattice_brain/graph/retrieval/hybrid.py +488 -0
- package/lattice_brain/graph/retrieval/maintenance.py +121 -0
- package/lattice_brain/graph/retrieval/signals.py +95 -0
- package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
- package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
- package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
- package/lattice_brain/graph/retrieval_vector/search.py +560 -0
- package/lattice_brain/graph/retrieval_vector/status.py +374 -0
- package/lattice_brain/ingestion/__init__.py +130 -0
- package/lattice_brain/ingestion/_contract.py +90 -0
- package/lattice_brain/ingestion/constants.py +127 -0
- package/lattice_brain/ingestion/folder_scan.py +57 -0
- package/lattice_brain/ingestion/folders.py +258 -0
- package/lattice_brain/ingestion/hashing.py +26 -0
- package/lattice_brain/ingestion/jobs_api.py +107 -0
- package/lattice_brain/ingestion/models.py +80 -0
- package/lattice_brain/ingestion/pipeline.py +486 -0
- package/lattice_brain/ingestion/quality.py +209 -0
- package/lattice_brain/ingestion/routing.py +295 -0
- package/lattice_brain/multimodal/__init__.py +164 -0
- package/lattice_brain/multimodal/audio.py +77 -0
- package/lattice_brain/multimodal/common.py +118 -0
- package/lattice_brain/multimodal/images.py +498 -0
- package/lattice_brain/multimodal/ports.py +169 -0
- package/lattice_brain/multimodal/video.py +410 -0
- package/lattice_brain/portability/__init__.py +90 -0
- package/lattice_brain/portability/_contract.py +42 -0
- package/lattice_brain/portability/backups.py +338 -0
- package/lattice_brain/portability/bundles.py +136 -0
- package/lattice_brain/portability/constants.py +93 -0
- package/lattice_brain/portability/fsops.py +138 -0
- package/lattice_brain/portability/service.py +41 -0
- package/lattice_brain/{portability.py → portability/sharing.py} +44 -677
- package/lattice_brain/runtime/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chronicle.py +63 -0
- package/latticeai/core/agent/__init__.py +93 -0
- package/latticeai/core/agent/_contract.py +79 -0
- package/latticeai/core/agent/context.py +57 -0
- package/latticeai/core/agent/deps.py +125 -0
- package/latticeai/core/agent/execution.py +622 -0
- package/latticeai/core/agent/planning.py +145 -0
- package/latticeai/core/agent/recovery.py +157 -0
- package/latticeai/core/agent/runtime.py +210 -0
- package/latticeai/core/agent/verification.py +231 -0
- package/latticeai/core/embedding_providers/__init__.py +151 -0
- package/latticeai/core/embedding_providers/base.py +199 -0
- package/latticeai/core/embedding_providers/captions.py +162 -0
- package/latticeai/core/embedding_providers/profiles.py +126 -0
- package/latticeai/core/embedding_providers/text.py +350 -0
- package/latticeai/core/embedding_providers/vision.py +352 -0
- package/latticeai/core/file_generation/__init__.py +115 -0
- package/latticeai/core/file_generation/bundles.py +76 -0
- package/latticeai/core/file_generation/extraction.py +154 -0
- package/latticeai/core/file_generation/inference.py +235 -0
- package/latticeai/core/file_generation/orchestration.py +152 -0
- package/latticeai/core/file_generation/prompting.py +117 -0
- package/latticeai/core/file_generation/repair.py +114 -0
- package/latticeai/core/file_generation/sanitize.py +61 -0
- package/latticeai/core/file_generation/validation.py +201 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +9 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/integrations/telegram_bot/__init__.py +123 -0
- package/latticeai/integrations/telegram_bot/__main__.py +17 -0
- package/latticeai/integrations/telegram_bot/config.py +86 -0
- package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
- package/latticeai/integrations/telegram_bot/flows.py +478 -0
- package/latticeai/integrations/telegram_bot/helpers.py +322 -0
- package/latticeai/integrations/telegram_bot/screens.py +394 -0
- package/latticeai/models/router/__init__.py +88 -0
- package/latticeai/models/router/_contract.py +66 -0
- package/latticeai/models/router/branding.py +56 -0
- package/latticeai/models/router/catalog.py +69 -0
- package/latticeai/models/router/documents.py +199 -0
- package/latticeai/models/router/errors.py +37 -0
- package/latticeai/models/router/generation.py +258 -0
- package/latticeai/models/router/loading.py +291 -0
- package/latticeai/models/router/local_models.py +85 -0
- package/latticeai/models/router/registry.py +147 -0
- package/latticeai/runtime/build_phases/__init__.py +82 -0
- package/latticeai/runtime/build_phases/features.py +407 -0
- package/latticeai/runtime/build_phases/foundation.py +555 -0
- package/latticeai/runtime/build_phases/web.py +492 -0
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +48 -19
- package/latticeai/services/brain_intelligence/__init__.py +58 -0
- package/latticeai/services/brain_intelligence/_contract.py +71 -0
- package/latticeai/services/brain_intelligence/consistency.py +193 -0
- package/latticeai/services/brain_intelligence/constants.py +47 -0
- package/latticeai/services/brain_intelligence/digest.py +258 -0
- package/latticeai/services/brain_intelligence/health.py +331 -0
- package/latticeai/services/brain_intelligence/proposals.py +264 -0
- package/latticeai/services/brain_intelligence/sampling.py +84 -0
- package/latticeai/services/brain_intelligence/service.py +48 -0
- package/latticeai/services/chronicle.py +557 -0
- package/latticeai/services/memory_service/__init__.py +52 -0
- package/latticeai/services/memory_service/_contract.py +100 -0
- package/latticeai/services/memory_service/brief.py +431 -0
- package/latticeai/services/memory_service/constants.py +57 -0
- package/latticeai/services/memory_service/maintenance.py +138 -0
- package/latticeai/services/memory_service/manager.py +186 -0
- package/latticeai/services/memory_service/proof.py +136 -0
- package/latticeai/services/memory_service/recall.py +225 -0
- package/latticeai/services/memory_service/service.py +48 -0
- package/latticeai/services/memory_service/stores.py +110 -0
- package/latticeai/services/model_runtime/__init__.py +322 -0
- package/latticeai/services/model_runtime/cloud.py +87 -0
- package/latticeai/services/model_runtime/download.py +282 -0
- package/latticeai/services/model_runtime/engines.py +341 -0
- package/latticeai/services/model_runtime/loading.py +178 -0
- package/latticeai/services/model_runtime/service.py +129 -0
- package/latticeai/services/model_runtime/state.py +131 -0
- package/latticeai/services/model_runtime/status.py +255 -0
- package/latticeai/services/product_readiness.py +15 -7
- package/latticeai/setup/wizard/__init__.py +126 -0
- package/latticeai/setup/wizard/catalog.py +172 -0
- package/latticeai/setup/wizard/detect.py +323 -0
- package/latticeai/setup/wizard/install.py +348 -0
- package/latticeai/setup/wizard/paths.py +168 -0
- package/latticeai/setup/wizard/plans.py +74 -0
- package/latticeai/setup/wizard/recommend.py +320 -0
- package/package.json +6 -2
- package/scripts/bump_version.py +14 -0
- package/scripts/capture_release_evidence.mjs +33 -21
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +41 -4
- package/scripts/check_max_file_lines.mjs +102 -0
- package/scripts/check_release_evidence_bound.mjs +30 -15
- package/scripts/check_screenshot_pixel_delta.py +34 -4
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/generate_rust_parity_fixtures.py +562 -0
- package/scripts/lib/mock_server_fingerprint.mjs +94 -0
- package/scripts/release_screen_claims.json +31 -2
- package/src-tauri/Cargo.lock +361 -3
- package/src-tauri/Cargo.toml +6 -1
- package/src-tauri/src/backend.rs +349 -0
- package/src-tauri/src/folder.rs +33 -0
- package/src-tauri/src/main.rs +97 -399
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +41 -37
- package/static/app/assets/Act-yYpYnn0v.js +1 -0
- package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
- package/static/app/assets/{Brain-tuhI4sOC.js → Brain-C1HBN0Wf.js} +2 -2
- package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
- package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
- package/static/app/assets/Capture-CFIRsFNE.js +1 -0
- package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
- package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
- package/static/app/assets/Library-DwO3yZST.js +1 -0
- package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-Jn1GK0-S.js} +1 -1
- package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
- package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
- package/static/app/assets/System-DW8F-2xL.js +1 -0
- package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
- package/static/app/assets/{bot-Cia42c2h.js → bot-IM_E_Y12.js} +1 -1
- package/static/app/assets/brain-Ci1CkWjM.js +1 -0
- package/static/app/assets/{button-2j2Ijzgq.js → button-COwyqfHM.js} +1 -1
- package/static/app/assets/circle-check-DfInj-qD.js +1 -0
- package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-DEM4A1Y5.js} +1 -1
- package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-C9djDuLd.js} +1 -1
- package/static/app/assets/{cpu-k4awryFq.js → cpu-DFdo1gw-.js} +1 -1
- package/static/app/assets/{download-DFbLJ_ig.js → download-SnJL6oqk.js} +1 -1
- package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CqZeDkjE.js} +1 -1
- package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-j1jJXYYf.js} +1 -1
- package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
- package/static/app/assets/index-_u5iUHDr.js +10 -0
- package/static/app/assets/input-B0lPdRQZ.js +1 -0
- package/static/app/assets/link-2-CoFbooHS.js +1 -0
- package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-BsyLxtao.js} +1 -1
- package/static/app/assets/primitives-DEbN-d6p.js +1 -0
- package/static/app/assets/search-BybIWPNd.js +1 -0
- package/static/app/assets/{share-2-BH1M-WNi.js → share-2-CVtZ_ewX.js} +1 -1
- package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-CBi2GNWM.js} +1 -1
- package/static/app/assets/{textarea-CCWbUfFB.js → textarea-DNMpB5ih.js} +1 -1
- package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-C83t3GXF.js} +1 -1
- package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
- package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-Dcp1OChy.js} +1 -1
- package/static/app/assets/utils-BlZr7Pd4.js +4 -0
- package/static/app/assets/workspace-jJY4RuAV.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/lattice_brain/graph/_kg_common.py +0 -1331
- package/lattice_brain/graph/discovery_index.py +0 -1141
- package/lattice_brain/graph/retrieval.py +0 -1120
- package/lattice_brain/graph/retrieval_vector.py +0 -1293
- package/lattice_brain/ingestion.py +0 -1525
- package/lattice_brain/multimodal.py +0 -1258
- package/latticeai/core/agent.py +0 -1465
- package/latticeai/core/embedding_providers.py +0 -1196
- package/latticeai/core/file_generation.py +0 -1047
- package/latticeai/integrations/telegram_bot.py +0 -1390
- package/latticeai/models/router.py +0 -1007
- package/latticeai/runtime/build_phases.py +0 -1450
- package/latticeai/services/brain_intelligence.py +0 -1083
- package/latticeai/services/memory_service.py +0 -1177
- package/latticeai/services/model_runtime.py +0 -1281
- package/latticeai/setup/wizard.py +0 -1310
- package/static/app/assets/Act-AWf0SAKp.js +0 -1
- package/static/app/assets/AdminConsole-D0u8Tiyj.js +0 -1
- package/static/app/assets/BrainHome-Ts7G_Ila.js +0 -2
- package/static/app/assets/BrainSignals-jMYgQ2Ar.js +0 -1
- package/static/app/assets/Capture-CqOSzyPr.js +0 -1
- package/static/app/assets/CommandPalette-DC0Bzh-I.js +0 -1
- package/static/app/assets/Library-CX-bbhmK.js +0 -1
- package/static/app/assets/ProductFlow-BHA2cfKI.js +0 -1
- package/static/app/assets/ReviewCard-BUhCKRNM.js +0 -3
- package/static/app/assets/System-Bu2t5hn1.js +0 -1
- package/static/app/assets/arrow-left-Dzwa5zRb.js +0 -1
- package/static/app/assets/brain-DJMoqrwx.js +0 -1
- package/static/app/assets/index-BpYkzcVm.js +0 -10
- package/static/app/assets/input-DSlJJxRs.js +0 -1
- package/static/app/assets/primitives-BCx6TvfG.js +0 -1
- package/static/app/assets/search-Cgy8cCFJ.js +0 -1
- package/static/app/assets/utils-zqPZJxdx.js +0 -4
- package/static/app/assets/workspace-DXTihhfU.js +0 -1
|
@@ -1,1007 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
LLM Router — mlx-vlm 기반 Gemma 4 최적화 및 추측 디코딩(Speculative Decoding) 코어
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import asyncio
|
|
6
|
-
import base64
|
|
7
|
-
import gc
|
|
8
|
-
import io
|
|
9
|
-
import json
|
|
10
|
-
import os
|
|
11
|
-
import re
|
|
12
|
-
import threading
|
|
13
|
-
import time
|
|
14
|
-
from dataclasses import dataclass
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
|
|
17
|
-
# Default Gemma 4 assistant drafting to MTP without overriding an operator's
|
|
18
|
-
# explicit MLX runtime choice.
|
|
19
|
-
os.environ.setdefault("MLX_VLM_DRAFT_KIND", "mtp")
|
|
20
|
-
|
|
21
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
22
|
-
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple
|
|
23
|
-
|
|
24
|
-
from PIL import Image
|
|
25
|
-
|
|
26
|
-
from latticeai.core.quiet import quiet
|
|
27
|
-
|
|
28
|
-
# Cloud provider catalog data lives in .model_providers; re-exported here so
|
|
29
|
-
# ``from latticeai.models.router import OPENAI_COMPATIBLE_PROVIDERS`` (and the
|
|
30
|
-
# model_runtime re-export chain) resolve unchanged after the split.
|
|
31
|
-
from latticeai.models.model_providers import ( # noqa: F401
|
|
32
|
-
MODEL_SOURCE_BY_FAMILY,
|
|
33
|
-
OPENAI_COMPATIBLE_PROVIDERS,
|
|
34
|
-
PROVIDER_MODEL_CATALOG,
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
# Optional dependencies. Each is aliased on import and then re-exported as
|
|
38
|
-
# `Any`, so "installed" and "absent" are the same declared type and every
|
|
39
|
-
# call site keeps its historical name.
|
|
40
|
-
try:
|
|
41
|
-
from openai import AsyncOpenAI as _AsyncOpenAI
|
|
42
|
-
except Exception:
|
|
43
|
-
_AsyncOpenAI = None # type: ignore[assignment,misc]
|
|
44
|
-
AsyncOpenAI: Any = _AsyncOpenAI
|
|
45
|
-
|
|
46
|
-
# 추론 전용 싱글 스레드 워커 (GPU 스트림 보호용)
|
|
47
|
-
executor = ThreadPoolExecutor(max_workers=1)
|
|
48
|
-
|
|
49
|
-
try:
|
|
50
|
-
import mlx.core as _mx
|
|
51
|
-
except Exception as e:
|
|
52
|
-
_mx = None # type: ignore[assignment]
|
|
53
|
-
print(f"⚠️ MLX core unavailable: {e}")
|
|
54
|
-
mx: Any = _mx
|
|
55
|
-
|
|
56
|
-
try:
|
|
57
|
-
from mlx_vlm import load as _vlm_load
|
|
58
|
-
VLM_AVAILABLE = True
|
|
59
|
-
print("✅ MLX-VLM is ready for multimodal models.")
|
|
60
|
-
except Exception as e:
|
|
61
|
-
_vlm_load = None # type: ignore[assignment]
|
|
62
|
-
VLM_AVAILABLE = False
|
|
63
|
-
print(f"⚠️ MLX-VLM unavailable: {e}")
|
|
64
|
-
vlm_load: Any = _vlm_load
|
|
65
|
-
|
|
66
|
-
try:
|
|
67
|
-
from mlx_lm import load as _lm_load
|
|
68
|
-
LM_AVAILABLE = True
|
|
69
|
-
print("✅ MLX-LM is ready for text fallback models.")
|
|
70
|
-
except Exception as e:
|
|
71
|
-
_lm_load = None # type: ignore[assignment]
|
|
72
|
-
LM_AVAILABLE = False
|
|
73
|
-
print(f"⚠️ MLX-LM unavailable: {e}")
|
|
74
|
-
lm_load: Any = _lm_load
|
|
75
|
-
|
|
76
|
-
BRAND_NAME = "Lattice AI"
|
|
77
|
-
LEGACY_BRAND_PATTERNS = [
|
|
78
|
-
(re.compile(r"\bconnect\s+ai\b", re.IGNORECASE), BRAND_NAME),
|
|
79
|
-
(re.compile(r"\bconnect-ai\b", re.IGNORECASE), BRAND_NAME),
|
|
80
|
-
(re.compile(r"\bconnectai\b", re.IGNORECASE), BRAND_NAME),
|
|
81
|
-
(re.compile(r"커넥트\s*AI", re.IGNORECASE), BRAND_NAME),
|
|
82
|
-
]
|
|
83
|
-
|
|
84
|
-
SYSTEM_PROMPT = """You are Lattice AI, a powerful local AI assistant running on Apple Silicon.
|
|
85
|
-
Your product name and identity are Lattice AI.
|
|
86
|
-
Never identify yourself as Connect AI, ConnectAI, connect-ai, or 커넥트 AI.
|
|
87
|
-
If context or old chat history mentions those names, treat them only as legacy aliases for Lattice AI.
|
|
88
|
-
You are a Vision-Language Model (VLM). If an image is provided, analyze it.
|
|
89
|
-
Be concise and respond in the user's language."""
|
|
90
|
-
|
|
91
|
-
# Appended ONLY when retrieved context exists (review 2026-07-25 Wave 2.3):
|
|
92
|
-
# grounded answers should cite their sources and admit gaps. Advisory prompt
|
|
93
|
-
# guidance — grounding assessment stays annotation-only and never blocks.
|
|
94
|
-
CITATION_INSTRUCTION = """The Context section above contains retrieved sources.
|
|
95
|
-
Ground your claims in those sources and cite them inline as [1], [2], ... matching the order they appear in the Context.
|
|
96
|
-
If the context does not cover the question, say so instead of inventing sources.
|
|
97
|
-
Never cite a source that is not in the Context."""
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def _compose_system(base: str, context: str) -> str:
|
|
101
|
-
"""Compose the system prompt with optional retrieved context.
|
|
102
|
-
|
|
103
|
-
Byte-compatible with the historical prompt when ``context`` is empty:
|
|
104
|
-
the return value is exactly ``base``. When context exists, the Context
|
|
105
|
-
block plus :data:`CITATION_INSTRUCTION` are appended.
|
|
106
|
-
"""
|
|
107
|
-
if not context:
|
|
108
|
-
return base
|
|
109
|
-
return f"{base}\n\nContext:\n{context}\n\n{CITATION_INSTRUCTION}"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def normalize_branding(text: Optional[str]) -> str:
|
|
113
|
-
if not text:
|
|
114
|
-
return ""
|
|
115
|
-
normalized = str(text)
|
|
116
|
-
for pattern, replacement in LEGACY_BRAND_PATTERNS:
|
|
117
|
-
normalized = pattern.sub(replacement, normalized)
|
|
118
|
-
return normalized
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
class ModelStreamError(RuntimeError):
|
|
122
|
-
"""A backend failed mid-stream. This is an error, never model output.
|
|
123
|
-
|
|
124
|
-
Streaming backends used to hand their failure to the caller as a chunk of
|
|
125
|
-
text (``"⚠️ Error: ..."``), which every consumer then treated as the
|
|
126
|
-
model's answer: it was echoed to the client as content and persisted as a
|
|
127
|
-
successful turn. The failure now travels as this typed exception instead.
|
|
128
|
-
|
|
129
|
-
The MLX generators run on a worker thread that cannot raise into the
|
|
130
|
-
consuming coroutine, so the thread puts an instance on the chunk queue and
|
|
131
|
-
:meth:`LLMRouter._drain_stream_queue` re-raises it. The SSE endpoints
|
|
132
|
-
(``latticeai.api.chat_stream.stream_chat`` and the document stream in
|
|
133
|
-
``latticeai.api.chat_documents``) already wrap their ``async for`` in
|
|
134
|
-
``except Exception`` and emit an ``error`` frame plus a ``[stream_error]``
|
|
135
|
-
marker on the persisted answer, so the stream framing is unchanged.
|
|
136
|
-
"""
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _stream_failure(stage: str, exc: BaseException) -> ModelStreamError:
|
|
140
|
-
"""Envelope a backend exception for transport across the chunk queue.
|
|
141
|
-
|
|
142
|
-
``raise ... from exc`` is unavailable on the worker thread (nothing there
|
|
143
|
-
consumes the traceback), so the cause is attached explicitly and stays
|
|
144
|
-
visible in logs when the consumer re-raises.
|
|
145
|
-
"""
|
|
146
|
-
error = ModelStreamError(f"{stage}: {exc}")
|
|
147
|
-
error.__cause__ = exc
|
|
148
|
-
return error
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
# Returns a display payload whose `source_display_order` value is a list,
|
|
152
|
-
# so the value type is Any rather than str.
|
|
153
|
-
def source_metadata_for_model(
|
|
154
|
-
provider: str, model: Dict[str, Any], *, local_server: bool
|
|
155
|
-
) -> Dict[str, Any]:
|
|
156
|
-
family = str(model.get("family") or "")
|
|
157
|
-
country, company = MODEL_SOURCE_BY_FAMILY.get(family, ("미상", provider.title()))
|
|
158
|
-
if local_server:
|
|
159
|
-
execution_method = "내 컴퓨터에서만 실행"
|
|
160
|
-
internet_requirement = "모델을 다운로드할 때만 인터넷 필요; 실행 중에는 필요 없음"
|
|
161
|
-
else:
|
|
162
|
-
execution_method = "인터넷 연결 후 사용"
|
|
163
|
-
internet_requirement = "내 파일이 인터넷으로 전송될 수 있음"
|
|
164
|
-
return {
|
|
165
|
-
"source_country": country,
|
|
166
|
-
"source_company": company,
|
|
167
|
-
"execution_method": execution_method,
|
|
168
|
-
"internet_requirement": internet_requirement,
|
|
169
|
-
"model_name": model.get("name") or model.get("id") or "",
|
|
170
|
-
"source_display_order": [
|
|
171
|
-
"source_country",
|
|
172
|
-
"source_company",
|
|
173
|
-
"execution_method",
|
|
174
|
-
"internet_requirement",
|
|
175
|
-
"model_name",
|
|
176
|
-
],
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
@dataclass
|
|
180
|
-
class CloudModel:
|
|
181
|
-
provider: str
|
|
182
|
-
model: str
|
|
183
|
-
client: Any # AsyncOpenAI when the optional dependency is installed
|
|
184
|
-
cache_key: str
|
|
185
|
-
|
|
186
|
-
def parse_model_ref(model_id: str) -> tuple[str, str]:
|
|
187
|
-
"""Return (provider, model). Unprefixed refs stay local MLX."""
|
|
188
|
-
if model_id.startswith("cloud:"):
|
|
189
|
-
_, provider, model = model_id.split(":", 2)
|
|
190
|
-
return provider, model
|
|
191
|
-
if ":" in model_id:
|
|
192
|
-
provider, model = model_id.split(":", 1)
|
|
193
|
-
if provider in OPENAI_COMPATIBLE_PROVIDERS:
|
|
194
|
-
return provider, model
|
|
195
|
-
if provider in {"local_mlx", "mlx"}:
|
|
196
|
-
return "local_mlx", model
|
|
197
|
-
if model_id.startswith("local_mlx:"):
|
|
198
|
-
return "local_mlx", model_id.split(":", 1)[1] # pragma: no cover — dead: a "local_mlx:" ref always has a ":" and returned above
|
|
199
|
-
return "local_mlx", model_id
|
|
200
|
-
|
|
201
|
-
HF_MODELS_ROOT = Path.home() / ".ltcai" / "hf-models"
|
|
202
|
-
|
|
203
|
-
def hf_model_dir(repo_id: str) -> Path:
|
|
204
|
-
return HF_MODELS_ROOT / repo_id.replace("/", "__")
|
|
205
|
-
|
|
206
|
-
def hf_cache_model_dir(repo_id: str) -> Optional[Path]:
|
|
207
|
-
"""Return a usable Hugging Face cache snapshot for an already-downloaded model."""
|
|
208
|
-
cache_root = Path.home() / ".cache" / "huggingface" / "hub" / f"models--{repo_id.replace('/', '--')}"
|
|
209
|
-
snapshots = cache_root / "snapshots"
|
|
210
|
-
if not snapshots.exists():
|
|
211
|
-
return None
|
|
212
|
-
candidates = sorted(
|
|
213
|
-
(item for item in snapshots.iterdir() if item.is_dir()),
|
|
214
|
-
key=lambda item: item.stat().st_mtime,
|
|
215
|
-
reverse=True,
|
|
216
|
-
)
|
|
217
|
-
for snapshot in candidates:
|
|
218
|
-
if _looks_like_hf_model_dir(snapshot):
|
|
219
|
-
return snapshot
|
|
220
|
-
return None
|
|
221
|
-
|
|
222
|
-
def _looks_like_hf_model_dir(path: Path) -> bool:
|
|
223
|
-
if not path.exists() or not path.is_dir():
|
|
224
|
-
return False
|
|
225
|
-
has_config = (path / "config.json").exists()
|
|
226
|
-
has_weights = any(path.glob("*.safetensors")) or any(path.glob("*.bin"))
|
|
227
|
-
has_tokenizer = (
|
|
228
|
-
(path / "tokenizer.json").exists()
|
|
229
|
-
or (path / "tokenizer.model").exists()
|
|
230
|
-
or (path / "tokenizer_config.json").exists()
|
|
231
|
-
)
|
|
232
|
-
return has_config and has_weights and has_tokenizer
|
|
233
|
-
|
|
234
|
-
def _resolve_local_hf_model(model_id: str) -> str:
|
|
235
|
-
explicit_path = Path(model_id).expanduser()
|
|
236
|
-
if explicit_path.exists():
|
|
237
|
-
return str(explicit_path)
|
|
238
|
-
local_dir = hf_model_dir(model_id)
|
|
239
|
-
if _looks_like_hf_model_dir(local_dir):
|
|
240
|
-
return str(local_dir)
|
|
241
|
-
cached_dir = hf_cache_model_dir(model_id)
|
|
242
|
-
if cached_dir is not None:
|
|
243
|
-
return str(cached_dir)
|
|
244
|
-
return model_id
|
|
245
|
-
|
|
246
|
-
def _is_gemma4_model_id(model_id: str) -> bool:
|
|
247
|
-
raw = str(model_id or "").lower()
|
|
248
|
-
return bool(re.search(r"gemma[-_/ ]?4|gemma4", raw))
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def _local_model_type(path_or_model_id: str) -> Optional[str]:
|
|
252
|
-
raw = str(path_or_model_id or "").strip()
|
|
253
|
-
candidates = []
|
|
254
|
-
explicit = Path(raw).expanduser()
|
|
255
|
-
if raw and explicit.exists():
|
|
256
|
-
candidates.append(explicit / "config.json")
|
|
257
|
-
candidates.append(hf_model_dir(raw) / "config.json")
|
|
258
|
-
for config_path in candidates:
|
|
259
|
-
try:
|
|
260
|
-
if config_path.exists():
|
|
261
|
-
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
262
|
-
model_type = str(data.get("model_type") or "").strip().lower()
|
|
263
|
-
if model_type:
|
|
264
|
-
return model_type
|
|
265
|
-
except Exception as e:
|
|
266
|
-
print(f"⚠️ Model config read skipped for {config_path}: {e}")
|
|
267
|
-
return None
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def ensure_mlx_runtime() -> None:
|
|
271
|
-
global mx, vlm_load, lm_load, VLM_AVAILABLE, LM_AVAILABLE
|
|
272
|
-
if mx is not None and (vlm_load is not None or lm_load is not None):
|
|
273
|
-
return
|
|
274
|
-
errors = []
|
|
275
|
-
try:
|
|
276
|
-
import mlx.core as mlx_core
|
|
277
|
-
mx = mlx_core
|
|
278
|
-
mx.set_default_device(mx.gpu)
|
|
279
|
-
except Exception as e:
|
|
280
|
-
errors.append(f"mlx: {e}")
|
|
281
|
-
mx = None
|
|
282
|
-
|
|
283
|
-
try:
|
|
284
|
-
from mlx_vlm import load as mlx_vlm_load
|
|
285
|
-
vlm_load = mlx_vlm_load
|
|
286
|
-
VLM_AVAILABLE = True
|
|
287
|
-
except Exception as e:
|
|
288
|
-
vlm_load = None
|
|
289
|
-
VLM_AVAILABLE = False
|
|
290
|
-
errors.append(f"mlx-vlm: {e}")
|
|
291
|
-
|
|
292
|
-
try:
|
|
293
|
-
from mlx_lm import load as mlx_lm_load
|
|
294
|
-
lm_load = mlx_lm_load
|
|
295
|
-
LM_AVAILABLE = True
|
|
296
|
-
except Exception as e:
|
|
297
|
-
lm_load = None
|
|
298
|
-
LM_AVAILABLE = False
|
|
299
|
-
errors.append(f"mlx-lm: {e}")
|
|
300
|
-
|
|
301
|
-
if mx is None or (vlm_load is None and lm_load is None):
|
|
302
|
-
raise RuntimeError(f"MLX runtime is not available after install: {'; '.join(errors)}")
|
|
303
|
-
|
|
304
|
-
def _mlx_sampler(temperature: float):
|
|
305
|
-
"""Build an MLX sampler callable for the given temperature.
|
|
306
|
-
|
|
307
|
-
Lattice v2.2 keeps local execution on MLX-VLM only. Returning ``None`` lets
|
|
308
|
-
MLX-VLM use its bundled default sampler without pulling another generation
|
|
309
|
-
package into the runtime contract.
|
|
310
|
-
"""
|
|
311
|
-
_ = temperature
|
|
312
|
-
return
|
|
313
|
-
|
|
314
|
-
class LLMRouter:
|
|
315
|
-
def __init__(self):
|
|
316
|
-
# A local entry is (model, tokenizer, draft_model, loader_kind); a
|
|
317
|
-
# cloud entry is a CloudModel. `_unpack_local_cache` splits them.
|
|
318
|
-
self._cache: Dict[str, Any] = {}
|
|
319
|
-
self._current: Optional[str] = None
|
|
320
|
-
self._last_used: Dict[str, float] = {}
|
|
321
|
-
self._max_local_models = max(1, int(os.getenv("LATTICEAI_MAX_LOCAL_MODELS", "1")))
|
|
322
|
-
# Guards the mutable model registry (_cache/_current/_last_used).
|
|
323
|
-
# Reentrant because the eviction path nests: _enforce_local_model_limit
|
|
324
|
-
# → unload_model → _release_memory. Never held across the heavy
|
|
325
|
-
# ``run_in_executor`` load (only the sync insert/read is guarded), so a
|
|
326
|
-
# long model load can't block a concurrent switch/unload from acquiring.
|
|
327
|
-
self._lock = threading.RLock()
|
|
328
|
-
|
|
329
|
-
@property
|
|
330
|
-
def current_model_id(self) -> Optional[str]:
|
|
331
|
-
with self._lock:
|
|
332
|
-
return self._current
|
|
333
|
-
|
|
334
|
-
@property
|
|
335
|
-
def loaded_model_ids(self) -> List[str]:
|
|
336
|
-
with self._lock:
|
|
337
|
-
return list(self._cache.keys())
|
|
338
|
-
|
|
339
|
-
def switch_model(self, model_id: str) -> None:
|
|
340
|
-
with self._lock:
|
|
341
|
-
if model_id not in self._cache:
|
|
342
|
-
raise KeyError(model_id)
|
|
343
|
-
self._current = model_id
|
|
344
|
-
self._touch(model_id)
|
|
345
|
-
|
|
346
|
-
def unload_model(self, model_id: str) -> None:
|
|
347
|
-
with self._lock:
|
|
348
|
-
self._cache.pop(model_id, None)
|
|
349
|
-
self._last_used.pop(model_id, None)
|
|
350
|
-
if self._current == model_id:
|
|
351
|
-
self._current = next(iter(self._cache), None)
|
|
352
|
-
self._release_memory()
|
|
353
|
-
|
|
354
|
-
def unload_all(self) -> None:
|
|
355
|
-
with self._lock:
|
|
356
|
-
self._cache.clear()
|
|
357
|
-
self._last_used.clear()
|
|
358
|
-
self._current = None
|
|
359
|
-
self._release_memory()
|
|
360
|
-
|
|
361
|
-
def unload_idle_models(self, idle_seconds: int) -> List[str]:
|
|
362
|
-
if idle_seconds <= 0:
|
|
363
|
-
return []
|
|
364
|
-
now = time.monotonic()
|
|
365
|
-
unloaded = []
|
|
366
|
-
with self._lock:
|
|
367
|
-
for model_id, last_used in list(self._last_used.items()):
|
|
368
|
-
if now - last_used >= idle_seconds:
|
|
369
|
-
self.unload_model(model_id)
|
|
370
|
-
unloaded.append(model_id)
|
|
371
|
-
return unloaded
|
|
372
|
-
|
|
373
|
-
def model_memory_policy(self) -> Dict[str, object]:
|
|
374
|
-
with self._lock:
|
|
375
|
-
return {
|
|
376
|
-
"max_local_models": self._max_local_models,
|
|
377
|
-
"loaded_count": len(self._cache),
|
|
378
|
-
"last_used": dict(self._last_used),
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
def _touch(self, model_id: Optional[str] = None) -> None:
|
|
382
|
-
model_id = model_id or self._current
|
|
383
|
-
if model_id:
|
|
384
|
-
self._last_used[model_id] = time.monotonic()
|
|
385
|
-
|
|
386
|
-
def _is_local_model(self, model_id: str) -> bool:
|
|
387
|
-
cached = self._cache.get(model_id)
|
|
388
|
-
return cached is not None and not isinstance(cached, CloudModel)
|
|
389
|
-
|
|
390
|
-
def _enforce_local_model_limit(self, incoming_key: str) -> None:
|
|
391
|
-
with self._lock:
|
|
392
|
-
local_ids = [model_id for model_id in self._cache if self._is_local_model(model_id)]
|
|
393
|
-
while len(local_ids) >= self._max_local_models:
|
|
394
|
-
victim = min(local_ids, key=lambda model_id: self._last_used.get(model_id, 0))
|
|
395
|
-
if victim == incoming_key:
|
|
396
|
-
break
|
|
397
|
-
print(f"🧹 Unloading local model to stay within memory policy: {victim}")
|
|
398
|
-
self.unload_model(victim)
|
|
399
|
-
local_ids = [model_id for model_id in self._cache if self._is_local_model(model_id)]
|
|
400
|
-
|
|
401
|
-
def _release_memory(self) -> None:
|
|
402
|
-
gc.collect()
|
|
403
|
-
if mx is not None and hasattr(mx, "clear_cache"):
|
|
404
|
-
try:
|
|
405
|
-
mx.clear_cache()
|
|
406
|
-
except Exception as e:
|
|
407
|
-
print(f"⚠️ MLX cache clear skipped: {e}")
|
|
408
|
-
|
|
409
|
-
async def load_model(
|
|
410
|
-
self,
|
|
411
|
-
model_id: str,
|
|
412
|
-
adapter_path: Optional[str] = None,
|
|
413
|
-
draft_model_id: Optional[str] = None,
|
|
414
|
-
api_key_override: Optional[str] = None,
|
|
415
|
-
owner: Optional[str] = None,
|
|
416
|
-
) -> str:
|
|
417
|
-
provider, provider_model = parse_model_ref(model_id)
|
|
418
|
-
if provider != "local_mlx":
|
|
419
|
-
return self._load_cloud_model(provider, provider_model, api_key_override=api_key_override, owner=owner)
|
|
420
|
-
|
|
421
|
-
ensure_mlx_runtime()
|
|
422
|
-
if mx is None or (vlm_load is None and lm_load is None):
|
|
423
|
-
raise RuntimeError("MLX is not available in this process. Run on Apple Silicon with Metal access.")
|
|
424
|
-
|
|
425
|
-
cache_key = f"{model_id}_{draft_model_id}" if draft_model_id else model_id
|
|
426
|
-
with self._lock:
|
|
427
|
-
if cache_key in self._cache:
|
|
428
|
-
self._current = cache_key
|
|
429
|
-
self._touch(cache_key)
|
|
430
|
-
return f"Cached: {cache_key}"
|
|
431
|
-
|
|
432
|
-
self._enforce_local_model_limit(cache_key)
|
|
433
|
-
print(f"⏳ Loading local model stack: {cache_key}...")
|
|
434
|
-
loop = asyncio.get_event_loop()
|
|
435
|
-
target_model_id = _resolve_local_hf_model(model_id)
|
|
436
|
-
target_draft_model_id = _resolve_local_hf_model(draft_model_id) if draft_model_id else None
|
|
437
|
-
|
|
438
|
-
def _load():
|
|
439
|
-
mx.set_default_device(mx.gpu)
|
|
440
|
-
is_gemma4 = _is_gemma4_model_id(model_id)
|
|
441
|
-
model_type = _local_model_type(target_model_id) or _local_model_type(model_id)
|
|
442
|
-
loader_kind = "mlx_vlm"
|
|
443
|
-
|
|
444
|
-
try:
|
|
445
|
-
if vlm_load is None:
|
|
446
|
-
raise RuntimeError("MLX-VLM is not installed.")
|
|
447
|
-
print(f"🔄 Loading Target (VLM Mode): {target_model_id}...")
|
|
448
|
-
model, tokenizer = vlm_load(target_model_id)
|
|
449
|
-
except Exception as vlm_error:
|
|
450
|
-
if not (is_gemma4 and model_type != "gemma4_unified" and lm_load is not None):
|
|
451
|
-
raise
|
|
452
|
-
print(f"⚠️ Gemma 4 MLX-VLM load failed; retrying MLX-LM text path: {vlm_error}")
|
|
453
|
-
print(f"🔄 Loading Target (LM Mode): {target_model_id}...")
|
|
454
|
-
model, tokenizer = lm_load(target_model_id)
|
|
455
|
-
loader_kind = "mlx_lm"
|
|
456
|
-
|
|
457
|
-
draft_model = None
|
|
458
|
-
if target_draft_model_id:
|
|
459
|
-
if loader_kind == "mlx_vlm":
|
|
460
|
-
print(f"🔄 Loading Assistant (VLM Mode): {target_draft_model_id}...")
|
|
461
|
-
draft_model, _ = vlm_load(target_draft_model_id)
|
|
462
|
-
elif lm_load is not None:
|
|
463
|
-
print(f"🔄 Loading Assistant (LM Mode): {target_draft_model_id}...")
|
|
464
|
-
draft_model, _ = lm_load(target_draft_model_id)
|
|
465
|
-
print("✅ Assistant Ready.")
|
|
466
|
-
|
|
467
|
-
return model, tokenizer, draft_model, loader_kind
|
|
468
|
-
|
|
469
|
-
try:
|
|
470
|
-
# Use the dedicated single-thread executor to ensure MLX GPU streams match during inference
|
|
471
|
-
model, tokenizer, draft_model, loader_kind = await loop.run_in_executor(executor, _load)
|
|
472
|
-
with self._lock:
|
|
473
|
-
self._cache[cache_key] = (model, tokenizer, draft_model, loader_kind)
|
|
474
|
-
self._current = cache_key
|
|
475
|
-
self._touch(cache_key)
|
|
476
|
-
print(f"✅ Fully Loaded: {cache_key} ({loader_kind})")
|
|
477
|
-
return f"Success: {cache_key} ({loader_kind})"
|
|
478
|
-
except Exception as e:
|
|
479
|
-
print(f"❌ Load Error: {e}")
|
|
480
|
-
raise e
|
|
481
|
-
|
|
482
|
-
def _load_cloud_model(self, provider: str, model: str, api_key_override: Optional[str] = None, owner: Optional[str] = None) -> str:
|
|
483
|
-
if AsyncOpenAI is None:
|
|
484
|
-
raise RuntimeError("openai package is not installed. Add it to requirements.txt and install dependencies.")
|
|
485
|
-
config = OPENAI_COMPATIBLE_PROVIDERS.get(provider)
|
|
486
|
-
if not config:
|
|
487
|
-
raise RuntimeError(f"Unsupported cloud provider: {provider}")
|
|
488
|
-
|
|
489
|
-
api_key = api_key_override or os.getenv(config["env_key"]) or config.get("api_key_fallback")
|
|
490
|
-
if not api_key:
|
|
491
|
-
raise RuntimeError(f"Missing API key env var: {config['env_key']}")
|
|
492
|
-
|
|
493
|
-
base_url = os.getenv(config.get("base_url_env", "")) if config.get("base_url_env") else None
|
|
494
|
-
base_url = base_url or config.get("base_url")
|
|
495
|
-
# base_url is passed only when configured: an explicit None is not
|
|
496
|
-
# the same as omitting the argument.
|
|
497
|
-
client = (
|
|
498
|
-
AsyncOpenAI(api_key=api_key, base_url=base_url)
|
|
499
|
-
if base_url
|
|
500
|
-
else AsyncOpenAI(api_key=api_key)
|
|
501
|
-
)
|
|
502
|
-
|
|
503
|
-
cache_owner = owner or "global"
|
|
504
|
-
cache_key = f"{provider}:{model}::{cache_owner}"
|
|
505
|
-
with self._lock:
|
|
506
|
-
self._cache[cache_key] = CloudModel(
|
|
507
|
-
provider=provider, model=model, client=client, cache_key=cache_key
|
|
508
|
-
)
|
|
509
|
-
self._current = cache_key
|
|
510
|
-
self._touch(cache_key)
|
|
511
|
-
return f"Cloud provider ready: {cache_key}"
|
|
512
|
-
|
|
513
|
-
def detected_cloud_models(self) -> List[Dict[str, str]]:
|
|
514
|
-
local_server_providers = {"ollama", "vllm", "lmstudio", "llamacpp"}
|
|
515
|
-
items: List[Dict[str, Any]] = []
|
|
516
|
-
for provider, config in OPENAI_COMPATIBLE_PROVIDERS.items():
|
|
517
|
-
has_key = bool(os.getenv(config["env_key"]) or config.get("api_key_fallback"))
|
|
518
|
-
provider_models = PROVIDER_MODEL_CATALOG.get(provider) or [{
|
|
519
|
-
"id": config["default_model"],
|
|
520
|
-
"name": f"{provider.title()} · {config['default_model']}",
|
|
521
|
-
"family": provider.title(),
|
|
522
|
-
}]
|
|
523
|
-
for model in provider_models:
|
|
524
|
-
model_id = model["id"]
|
|
525
|
-
local_server = provider in local_server_providers
|
|
526
|
-
items.append({
|
|
527
|
-
"id": f"{provider}:{model_id}",
|
|
528
|
-
"name": model.get("name") or f"{provider.title()} · {model_id}",
|
|
529
|
-
"provider": provider,
|
|
530
|
-
"family": model.get("family"),
|
|
531
|
-
"tag": "local-server" if local_server else "cloud",
|
|
532
|
-
"available": has_key,
|
|
533
|
-
"requires": config["env_key"] if not has_key else None,
|
|
534
|
-
**source_metadata_for_model(provider, model, local_server=local_server),
|
|
535
|
-
})
|
|
536
|
-
custom = os.getenv("LATTICEAI_CLOUD_MODELS") or ""
|
|
537
|
-
for raw in [item.strip() for item in custom.split(",") if item.strip()]:
|
|
538
|
-
provider, custom_model = parse_model_ref(raw)
|
|
539
|
-
if provider != "local_mlx" and provider in OPENAI_COMPATIBLE_PROVIDERS:
|
|
540
|
-
config = OPENAI_COMPATIBLE_PROVIDERS[provider]
|
|
541
|
-
items.append({
|
|
542
|
-
"id": f"{provider}:{custom_model}",
|
|
543
|
-
"name": f"{provider.title()} · {custom_model}",
|
|
544
|
-
"provider": provider,
|
|
545
|
-
"tag": "cloud",
|
|
546
|
-
"available": bool(os.getenv(config["env_key"]) or config.get("api_key_fallback")),
|
|
547
|
-
"requires": None,
|
|
548
|
-
**source_metadata_for_model(
|
|
549
|
-
provider,
|
|
550
|
-
{
|
|
551
|
-
"id": custom_model,
|
|
552
|
-
"name": f"{provider.title()} · {custom_model}",
|
|
553
|
-
"family": provider.title(),
|
|
554
|
-
},
|
|
555
|
-
local_server=provider in local_server_providers,
|
|
556
|
-
),
|
|
557
|
-
})
|
|
558
|
-
return items
|
|
559
|
-
|
|
560
|
-
def _is_cloud_current(self) -> bool:
|
|
561
|
-
with self._lock:
|
|
562
|
-
return bool(self._current and isinstance(self._cache.get(self._current), CloudModel))
|
|
563
|
-
|
|
564
|
-
def _local_server_error_hint(self, cloud: CloudModel, error: Exception) -> str:
|
|
565
|
-
raw = str(error)
|
|
566
|
-
if cloud.provider == "lmstudio":
|
|
567
|
-
base_url = os.getenv("LMSTUDIO_BASE_URL") or OPENAI_COMPATIBLE_PROVIDERS["lmstudio"]["base_url"]
|
|
568
|
-
return (
|
|
569
|
-
f"LM Studio 연결 실패: {raw}\n\n"
|
|
570
|
-
f"- LM Studio의 Developer/Local Server를 켜고 모델을 로드했는지 확인하세요.\n"
|
|
571
|
-
f"- Lattice가 보는 주소는 {base_url} 입니다. 포트가 다르면 LMSTUDIO_BASE_URL을 맞춰주세요.\n"
|
|
572
|
-
f"- 모델 선택창에는 LM Studio /v1/models에서 감지된 모델만 표시됩니다."
|
|
573
|
-
)
|
|
574
|
-
return raw
|
|
575
|
-
|
|
576
|
-
def _build_prompt(self, message: str, context: Optional[str], tokenizer) -> str:
|
|
577
|
-
context = normalize_branding(context)
|
|
578
|
-
system = _compose_system(SYSTEM_PROMPT, context)
|
|
579
|
-
if hasattr(tokenizer, "apply_chat_template"):
|
|
580
|
-
try:
|
|
581
|
-
msgs = [{"role": "system", "content": system}, {"role": "user", "content": message}]
|
|
582
|
-
return tokenizer.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
|
|
583
|
-
except Exception:
|
|
584
|
-
quiet()
|
|
585
|
-
return f"<|im_start|>system\n{system}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
|
|
586
|
-
|
|
587
|
-
def _build_vlm_prompt(self, model, processor, message: str, context: Optional[str], num_images: int) -> str:
|
|
588
|
-
context = normalize_branding(context)
|
|
589
|
-
system = _compose_system(SYSTEM_PROMPT, context)
|
|
590
|
-
try:
|
|
591
|
-
from mlx_vlm import apply_chat_template
|
|
592
|
-
|
|
593
|
-
return apply_chat_template(
|
|
594
|
-
processor,
|
|
595
|
-
model.config,
|
|
596
|
-
[
|
|
597
|
-
{"role": "system", "content": system},
|
|
598
|
-
{"role": "user", "content": message},
|
|
599
|
-
],
|
|
600
|
-
add_generation_prompt=True,
|
|
601
|
-
num_images=num_images,
|
|
602
|
-
)
|
|
603
|
-
except Exception as e:
|
|
604
|
-
print(f"⚠️ VLM chat template fallback: {e}")
|
|
605
|
-
return self._build_prompt(message, context, processor)
|
|
606
|
-
|
|
607
|
-
def _unpack_local_cache(self, cached: Any) -> Tuple[Any, Any, Any, str]:
|
|
608
|
-
model, tokenizer, draft_model = cached[:3]
|
|
609
|
-
loader_kind = str(cached[3]) if len(cached) > 3 else "mlx_vlm"
|
|
610
|
-
return model, tokenizer, draft_model, loader_kind
|
|
611
|
-
|
|
612
|
-
def _model_snapshot(self, model_id: Optional[str] = None) -> tuple[Optional[str], object | None]:
|
|
613
|
-
"""Return an immutable request-scoped view of a loaded model.
|
|
614
|
-
|
|
615
|
-
Generation must never change ``_current``: that value is a UI/default
|
|
616
|
-
preference shared by every request. Capturing the cache entry while
|
|
617
|
-
holding the registry lock prevents concurrent requests from selecting
|
|
618
|
-
or restoring each other's models.
|
|
619
|
-
"""
|
|
620
|
-
with self._lock:
|
|
621
|
-
selected = model_id or self._current
|
|
622
|
-
if not selected:
|
|
623
|
-
return None, None
|
|
624
|
-
cached = self._cache.get(selected)
|
|
625
|
-
if cached is None:
|
|
626
|
-
raise ValueError(f"Model '{selected}' is not loaded. Load it first via /models/load.")
|
|
627
|
-
self._touch(selected)
|
|
628
|
-
return selected, cached
|
|
629
|
-
|
|
630
|
-
async def generate_as(
|
|
631
|
-
self,
|
|
632
|
-
model_id: str | None,
|
|
633
|
-
message: str,
|
|
634
|
-
context: Optional[str] = None,
|
|
635
|
-
max_tokens: int = 4096,
|
|
636
|
-
temperature: float = 0.2,
|
|
637
|
-
image_data: Optional[str] = None,
|
|
638
|
-
) -> str:
|
|
639
|
-
"""Generate with a request-scoped model without changing the default."""
|
|
640
|
-
_selected, cached = self._model_snapshot(model_id)
|
|
641
|
-
if cached is None:
|
|
642
|
-
return "No model."
|
|
643
|
-
return await self._generate_cached(cached, message, context, max_tokens, temperature, image_data)
|
|
644
|
-
|
|
645
|
-
async def generate(
|
|
646
|
-
self,
|
|
647
|
-
message: str,
|
|
648
|
-
context: Optional[str] = None,
|
|
649
|
-
max_tokens: int = 4096,
|
|
650
|
-
temperature: float = 0.2,
|
|
651
|
-
image_data: Optional[str] = None,
|
|
652
|
-
) -> str:
|
|
653
|
-
return await self.generate_as(None, message, context, max_tokens, temperature, image_data)
|
|
654
|
-
|
|
655
|
-
async def _generate_cached(
|
|
656
|
-
self,
|
|
657
|
-
cached: object,
|
|
658
|
-
message: str,
|
|
659
|
-
context: Optional[str],
|
|
660
|
-
max_tokens: int,
|
|
661
|
-
temperature: float,
|
|
662
|
-
image_data: Optional[str],
|
|
663
|
-
) -> str:
|
|
664
|
-
if isinstance(cached, CloudModel):
|
|
665
|
-
return await self._cloud_generate(cached, message, context, max_tokens, temperature)
|
|
666
|
-
|
|
667
|
-
model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
|
|
668
|
-
use_vlm = loader_kind == "mlx_vlm"
|
|
669
|
-
prompt = (
|
|
670
|
-
self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
|
|
671
|
-
if use_vlm
|
|
672
|
-
else self._build_prompt(message, context, tokenizer)
|
|
673
|
-
)
|
|
674
|
-
|
|
675
|
-
loop = asyncio.get_event_loop()
|
|
676
|
-
|
|
677
|
-
def _gen():
|
|
678
|
-
import mlx.core as mx # type: ignore[no-redef]
|
|
679
|
-
|
|
680
|
-
mx.set_default_device(mx.gpu) # type: ignore[arg-type]
|
|
681
|
-
if use_vlm:
|
|
682
|
-
from mlx_vlm import generate as vlm_gen
|
|
683
|
-
return vlm_gen(model, tokenizer, prompt=prompt, image=self._prep_image(image_data) if image_data else None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
|
|
684
|
-
from mlx_lm import generate as lm_gen
|
|
685
|
-
return lm_gen(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
|
|
686
|
-
result = await loop.run_in_executor(executor, _gen)
|
|
687
|
-
# mlx-vlm might return a GenerationResult object; extract the text
|
|
688
|
-
if hasattr(result, "text"):
|
|
689
|
-
return normalize_branding(result.text)
|
|
690
|
-
return normalize_branding(str(result))
|
|
691
|
-
|
|
692
|
-
async def _cloud_generate(self, cloud: CloudModel, message: str, context: Optional[str], max_tokens: int, temperature: float) -> str:
|
|
693
|
-
context = normalize_branding(context)
|
|
694
|
-
system = _compose_system(SYSTEM_PROMPT, context)
|
|
695
|
-
try:
|
|
696
|
-
response = await cloud.client.chat.completions.create(
|
|
697
|
-
model=cloud.model,
|
|
698
|
-
messages=[
|
|
699
|
-
{"role": "system", "content": system},
|
|
700
|
-
{"role": "user", "content": message},
|
|
701
|
-
],
|
|
702
|
-
max_tokens=max_tokens,
|
|
703
|
-
temperature=temperature,
|
|
704
|
-
)
|
|
705
|
-
except Exception as e:
|
|
706
|
-
raise RuntimeError(self._local_server_error_hint(cloud, e)) from e
|
|
707
|
-
return normalize_branding(response.choices[0].message.content or "")
|
|
708
|
-
|
|
709
|
-
async def stream_generate_as(
|
|
710
|
-
self,
|
|
711
|
-
model_id: str | None,
|
|
712
|
-
message: str,
|
|
713
|
-
context: Optional[str] = None,
|
|
714
|
-
max_tokens: int = 4096,
|
|
715
|
-
temperature: float = 0.2,
|
|
716
|
-
image_data: Optional[str] = None,
|
|
717
|
-
) -> AsyncIterator[str]:
|
|
718
|
-
"""Stream with a request-scoped model without changing the default."""
|
|
719
|
-
_selected, cached = self._model_snapshot(model_id)
|
|
720
|
-
if cached is None:
|
|
721
|
-
yield "No model."
|
|
722
|
-
return
|
|
723
|
-
if isinstance(cached, CloudModel):
|
|
724
|
-
async for chunk in self._cloud_stream_generate(cached, message, context, max_tokens, temperature):
|
|
725
|
-
yield chunk
|
|
726
|
-
return
|
|
727
|
-
|
|
728
|
-
model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
|
|
729
|
-
use_vlm = loader_kind == "mlx_vlm"
|
|
730
|
-
prompt = (
|
|
731
|
-
self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
|
|
732
|
-
if use_vlm
|
|
733
|
-
else self._build_prompt(message, context, tokenizer)
|
|
734
|
-
)
|
|
735
|
-
loop = asyncio.get_event_loop()
|
|
736
|
-
queue: "asyncio.Queue[Any]" = asyncio.Queue()
|
|
737
|
-
|
|
738
|
-
def _stream():
|
|
739
|
-
import mlx.core as mx # type: ignore[no-redef]
|
|
740
|
-
|
|
741
|
-
mx.set_default_device(mx.gpu) # type: ignore[arg-type]
|
|
742
|
-
try:
|
|
743
|
-
if use_vlm:
|
|
744
|
-
from mlx_vlm import stream_generate as vlm_stream
|
|
745
|
-
gen = vlm_stream(model, tokenizer, prompt=prompt, image=self._prep_image(image_data) if image_data else None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
|
|
746
|
-
else:
|
|
747
|
-
from mlx_lm import stream_generate as lm_stream
|
|
748
|
-
gen = lm_stream(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
|
|
749
|
-
|
|
750
|
-
for chunk in gen:
|
|
751
|
-
text = chunk.text if hasattr(chunk, "text") else (chunk[0] if isinstance(chunk, tuple) else str(chunk))
|
|
752
|
-
loop.call_soon_threadsafe(queue.put_nowait, text)
|
|
753
|
-
except Exception as exc:
|
|
754
|
-
loop.call_soon_threadsafe(
|
|
755
|
-
queue.put_nowait, _stream_failure("MLX chat stream failed", exc)
|
|
756
|
-
)
|
|
757
|
-
finally:
|
|
758
|
-
loop.call_soon_threadsafe(queue.put_nowait, None)
|
|
759
|
-
|
|
760
|
-
loop.run_in_executor(executor, _stream)
|
|
761
|
-
async for chunk in self._drain_stream_queue(queue):
|
|
762
|
-
yield chunk
|
|
763
|
-
|
|
764
|
-
@staticmethod
|
|
765
|
-
async def _drain_stream_queue(queue: "asyncio.Queue[Any]") -> AsyncIterator[str]:
|
|
766
|
-
"""Yield worker-thread chunks until the terminator; raise failures.
|
|
767
|
-
|
|
768
|
-
``None`` terminates the stream. A :class:`ModelStreamError` on the
|
|
769
|
-
queue is a backend failure envelope, not model text, so it is raised
|
|
770
|
-
into the consuming coroutine — callers must never be able to mistake
|
|
771
|
-
it for an answer.
|
|
772
|
-
"""
|
|
773
|
-
while True:
|
|
774
|
-
chunk = await queue.get()
|
|
775
|
-
if chunk is None:
|
|
776
|
-
return
|
|
777
|
-
if isinstance(chunk, ModelStreamError):
|
|
778
|
-
raise chunk
|
|
779
|
-
yield normalize_branding(chunk)
|
|
780
|
-
|
|
781
|
-
async def stream_generate(
|
|
782
|
-
self,
|
|
783
|
-
message: str,
|
|
784
|
-
context: Optional[str] = None,
|
|
785
|
-
max_tokens: int = 4096,
|
|
786
|
-
temperature: float = 0.2,
|
|
787
|
-
image_data: Optional[str] = None,
|
|
788
|
-
) -> AsyncIterator[str]:
|
|
789
|
-
async for chunk in self.stream_generate_as(
|
|
790
|
-
None, message, context, max_tokens, temperature, image_data
|
|
791
|
-
):
|
|
792
|
-
yield chunk
|
|
793
|
-
|
|
794
|
-
async def _cloud_stream_generate(self, cloud: CloudModel, message: str, context: Optional[str], max_tokens: int, temperature: float) -> AsyncIterator[str]:
|
|
795
|
-
context = normalize_branding(context)
|
|
796
|
-
system = _compose_system(SYSTEM_PROMPT, context)
|
|
797
|
-
try:
|
|
798
|
-
stream = await cloud.client.chat.completions.create(
|
|
799
|
-
model=cloud.model,
|
|
800
|
-
messages=[
|
|
801
|
-
{"role": "system", "content": system},
|
|
802
|
-
{"role": "user", "content": message},
|
|
803
|
-
],
|
|
804
|
-
max_tokens=max_tokens,
|
|
805
|
-
temperature=temperature,
|
|
806
|
-
stream=True,
|
|
807
|
-
)
|
|
808
|
-
except Exception as exc:
|
|
809
|
-
# Same invariant as the MLX path: a backend that never produced a
|
|
810
|
-
# token failed, and that is an error — not the model's answer.
|
|
811
|
-
raise ModelStreamError(self._local_server_error_hint(cloud, exc)) from exc
|
|
812
|
-
async for event in stream:
|
|
813
|
-
if not event.choices:
|
|
814
|
-
continue
|
|
815
|
-
delta = event.choices[0].delta.content
|
|
816
|
-
if delta:
|
|
817
|
-
yield normalize_branding(delta)
|
|
818
|
-
|
|
819
|
-
def _prep_image(self, image_data: Optional[str]) -> Optional[Image.Image]:
|
|
820
|
-
if not image_data:
|
|
821
|
-
return None
|
|
822
|
-
try:
|
|
823
|
-
image = Image.open(io.BytesIO(base64.b64decode(image_data))).convert("RGB")
|
|
824
|
-
print(f"🖼️ VLM image decoded: {image.width}x{image.height}")
|
|
825
|
-
return image
|
|
826
|
-
except Exception as e:
|
|
827
|
-
print(f"⚠️ VLM image decode failed: {e}")
|
|
828
|
-
return None
|
|
829
|
-
|
|
830
|
-
# ── Document Generation Pipeline ──────────────────────────────────────
|
|
831
|
-
|
|
832
|
-
async def generate_document(
|
|
833
|
-
self,
|
|
834
|
-
message: str,
|
|
835
|
-
system_prompt: str,
|
|
836
|
-
*,
|
|
837
|
-
max_tokens: int = 8192,
|
|
838
|
-
temperature: float = 0.3,
|
|
839
|
-
) -> str:
|
|
840
|
-
"""Generate a document using a specialized system prompt with graph context."""
|
|
841
|
-
return await self.generate_document_as(
|
|
842
|
-
None,
|
|
843
|
-
message,
|
|
844
|
-
system_prompt,
|
|
845
|
-
max_tokens=max_tokens,
|
|
846
|
-
temperature=temperature,
|
|
847
|
-
)
|
|
848
|
-
|
|
849
|
-
async def generate_document_as(
|
|
850
|
-
self,
|
|
851
|
-
model_id: str | None,
|
|
852
|
-
message: str,
|
|
853
|
-
system_prompt: str,
|
|
854
|
-
*,
|
|
855
|
-
max_tokens: int = 8192,
|
|
856
|
-
temperature: float = 0.3,
|
|
857
|
-
) -> str:
|
|
858
|
-
"""Generate a document with a request-scoped model."""
|
|
859
|
-
_selected, cached = self._model_snapshot(model_id)
|
|
860
|
-
if cached is None:
|
|
861
|
-
return "No model loaded."
|
|
862
|
-
|
|
863
|
-
if isinstance(cached, CloudModel):
|
|
864
|
-
return await self._cloud_generate_document(cached, message, system_prompt, max_tokens, temperature)
|
|
865
|
-
|
|
866
|
-
model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
|
|
867
|
-
if hasattr(tokenizer, "apply_chat_template"):
|
|
868
|
-
try:
|
|
869
|
-
msgs = [
|
|
870
|
-
{"role": "system", "content": system_prompt},
|
|
871
|
-
{"role": "user", "content": message},
|
|
872
|
-
]
|
|
873
|
-
prompt = tokenizer.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
|
|
874
|
-
except Exception:
|
|
875
|
-
prompt = f"<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
|
|
876
|
-
else:
|
|
877
|
-
prompt = f"<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
|
|
878
|
-
|
|
879
|
-
loop = asyncio.get_event_loop()
|
|
880
|
-
def _gen():
|
|
881
|
-
import mlx.core as mx # type: ignore[no-redef]
|
|
882
|
-
|
|
883
|
-
mx.set_default_device(mx.gpu) # type: ignore[arg-type]
|
|
884
|
-
if loader_kind == "mlx_vlm":
|
|
885
|
-
from mlx_vlm import generate as vlm_gen
|
|
886
|
-
return vlm_gen(model, tokenizer, prompt=prompt, image=None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
|
|
887
|
-
from mlx_lm import generate as lm_gen
|
|
888
|
-
return lm_gen(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
|
|
889
|
-
result = await loop.run_in_executor(executor, _gen)
|
|
890
|
-
if hasattr(result, "text"):
|
|
891
|
-
return normalize_branding(result.text)
|
|
892
|
-
return normalize_branding(str(result))
|
|
893
|
-
|
|
894
|
-
async def _cloud_generate_document(self, cloud: CloudModel, message: str, system_prompt: str, max_tokens: int, temperature: float) -> str:
|
|
895
|
-
try:
|
|
896
|
-
response = await cloud.client.chat.completions.create(
|
|
897
|
-
model=cloud.model,
|
|
898
|
-
messages=[
|
|
899
|
-
{"role": "system", "content": system_prompt},
|
|
900
|
-
{"role": "user", "content": message},
|
|
901
|
-
],
|
|
902
|
-
max_tokens=max_tokens,
|
|
903
|
-
temperature=temperature,
|
|
904
|
-
)
|
|
905
|
-
except Exception as e:
|
|
906
|
-
raise RuntimeError(self._local_server_error_hint(cloud, e)) from e
|
|
907
|
-
return normalize_branding(response.choices[0].message.content or "")
|
|
908
|
-
|
|
909
|
-
async def stream_generate_document(
|
|
910
|
-
self,
|
|
911
|
-
message: str,
|
|
912
|
-
system_prompt: str,
|
|
913
|
-
*,
|
|
914
|
-
max_tokens: int = 8192,
|
|
915
|
-
temperature: float = 0.3,
|
|
916
|
-
) -> AsyncIterator[str]:
|
|
917
|
-
"""Stream document generation with specialized system prompt."""
|
|
918
|
-
async for chunk in self.stream_generate_document_as(
|
|
919
|
-
None,
|
|
920
|
-
message,
|
|
921
|
-
system_prompt,
|
|
922
|
-
max_tokens=max_tokens,
|
|
923
|
-
temperature=temperature,
|
|
924
|
-
):
|
|
925
|
-
yield chunk
|
|
926
|
-
|
|
927
|
-
async def stream_generate_document_as(
|
|
928
|
-
self,
|
|
929
|
-
model_id: str | None,
|
|
930
|
-
message: str,
|
|
931
|
-
system_prompt: str,
|
|
932
|
-
*,
|
|
933
|
-
max_tokens: int = 8192,
|
|
934
|
-
temperature: float = 0.3,
|
|
935
|
-
) -> AsyncIterator[str]:
|
|
936
|
-
"""Stream a document with a request-scoped model."""
|
|
937
|
-
_selected, cached = self._model_snapshot(model_id)
|
|
938
|
-
if cached is None:
|
|
939
|
-
yield "No model loaded."
|
|
940
|
-
return
|
|
941
|
-
|
|
942
|
-
if isinstance(cached, CloudModel):
|
|
943
|
-
async for chunk in self._cloud_stream_document(cached, message, system_prompt, max_tokens, temperature):
|
|
944
|
-
yield chunk
|
|
945
|
-
return
|
|
946
|
-
|
|
947
|
-
model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
|
|
948
|
-
if hasattr(tokenizer, "apply_chat_template"):
|
|
949
|
-
try:
|
|
950
|
-
msgs = [
|
|
951
|
-
{"role": "system", "content": system_prompt},
|
|
952
|
-
{"role": "user", "content": message},
|
|
953
|
-
]
|
|
954
|
-
prompt = tokenizer.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
|
|
955
|
-
except Exception:
|
|
956
|
-
prompt = f"<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
|
|
957
|
-
else:
|
|
958
|
-
prompt = f"<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
|
|
959
|
-
|
|
960
|
-
loop = asyncio.get_event_loop()
|
|
961
|
-
queue: "asyncio.Queue[Any]" = asyncio.Queue()
|
|
962
|
-
|
|
963
|
-
def _stream():
|
|
964
|
-
import mlx.core as mx # type: ignore[no-redef]
|
|
965
|
-
|
|
966
|
-
mx.set_default_device(mx.gpu) # type: ignore[arg-type]
|
|
967
|
-
try:
|
|
968
|
-
if loader_kind == "mlx_vlm":
|
|
969
|
-
from mlx_vlm import stream_generate as vlm_stream
|
|
970
|
-
gen = vlm_stream(model, tokenizer, prompt=prompt, image=None, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model, draft_kind="mtp")
|
|
971
|
-
else:
|
|
972
|
-
from mlx_lm import stream_generate as lm_stream
|
|
973
|
-
gen = lm_stream(model, tokenizer, prompt=prompt, max_tokens=max_tokens, sampler=_mlx_sampler(temperature), draft_model=draft_model)
|
|
974
|
-
for chunk in gen:
|
|
975
|
-
text = chunk.text if hasattr(chunk, "text") else (chunk[0] if isinstance(chunk, tuple) else str(chunk))
|
|
976
|
-
loop.call_soon_threadsafe(queue.put_nowait, text)
|
|
977
|
-
except Exception as exc:
|
|
978
|
-
loop.call_soon_threadsafe(
|
|
979
|
-
queue.put_nowait, _stream_failure("MLX document stream failed", exc)
|
|
980
|
-
)
|
|
981
|
-
finally:
|
|
982
|
-
loop.call_soon_threadsafe(queue.put_nowait, None)
|
|
983
|
-
|
|
984
|
-
loop.run_in_executor(executor, _stream)
|
|
985
|
-
async for chunk in self._drain_stream_queue(queue):
|
|
986
|
-
yield chunk
|
|
987
|
-
|
|
988
|
-
async def _cloud_stream_document(self, cloud: CloudModel, message: str, system_prompt: str, max_tokens: int, temperature: float) -> AsyncIterator[str]:
|
|
989
|
-
try:
|
|
990
|
-
stream = await cloud.client.chat.completions.create(
|
|
991
|
-
model=cloud.model,
|
|
992
|
-
messages=[
|
|
993
|
-
{"role": "system", "content": system_prompt},
|
|
994
|
-
{"role": "user", "content": message},
|
|
995
|
-
],
|
|
996
|
-
max_tokens=max_tokens,
|
|
997
|
-
temperature=temperature,
|
|
998
|
-
stream=True,
|
|
999
|
-
)
|
|
1000
|
-
except Exception as exc:
|
|
1001
|
-
raise ModelStreamError(self._local_server_error_hint(cloud, exc)) from exc
|
|
1002
|
-
async for event in stream:
|
|
1003
|
-
if not event.choices:
|
|
1004
|
-
continue
|
|
1005
|
-
delta = event.choices[0].delta.content
|
|
1006
|
-
if delta:
|
|
1007
|
-
yield normalize_branding(delta)
|