flexsds 0.2.0__py3-none-any.whl
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.
- flexsds/__init__.py +4 -0
- flexsds/assets/ptit_logo.png +0 -0
- flexsds/cli/__init__.py +1 -0
- flexsds/cli/banner.py +84 -0
- flexsds/cli/main.py +330 -0
- flexsds/cli/output.py +69 -0
- flexsds/cli/task_runner.py +171 -0
- flexsds/cli/wizard.py +360 -0
- flexsds/client/__init__.py +4 -0
- flexsds/client/chat.py +137 -0
- flexsds/client/client.py +107 -0
- flexsds/client/monitor.py +95 -0
- flexsds/core/__init__.py +44 -0
- flexsds/core/compatibility.py +442 -0
- flexsds/core/controller.py +502 -0
- flexsds/core/cost_model.py +530 -0
- flexsds/core/credentials.py +111 -0
- flexsds/core/hardware.py +77 -0
- flexsds/core/tree_topology.py +296 -0
- flexsds/profiles/profile_sm75_t4.json +62 -0
- flexsds/profiles/profile_sm89_l4.json +194 -0
- flexsds/server/__init__.py +1 -0
- flexsds/server/colab_runner.py +62 -0
- flexsds/server/local_runner.py +208 -0
- flexsds/server/modal_app.py +50 -0
- flexsds/server/modal_runner.py +98 -0
- flexsds/server/vscode_cli.py +82 -0
- flexsds-0.2.0.dist-info/METADATA +204 -0
- flexsds-0.2.0.dist-info/RECORD +62 -0
- flexsds-0.2.0.dist-info/WHEEL +5 -0
- flexsds-0.2.0.dist-info/entry_points.txt +2 -0
- flexsds-0.2.0.dist-info/top_level.txt +4 -0
- pipeline/entity_eval.py +171 -0
- pipeline/examples/node1_extractor_input_m0_04_001.txt +9 -0
- pipeline/examples/node1_extractor_output_m0_04_001.json +90 -0
- pipeline/extractor.py +502 -0
- pipeline/invoice_models.py +163 -0
- pipeline/node2_questions.py +30 -0
- pipeline/openai_compatible.py +87 -0
- pipeline/prompts/README.md +18 -0
- pipeline/prompts/node1_extractor_system.md +92 -0
- pipeline/prompts/node3_report_user.md +5 -0
- pipeline/report_writer.py +143 -0
- pipeline/schemas/invoice_extraction.schema.json +469 -0
- pipeline/sql_guard.py +140 -0
- pipeline/sqlite/schema.sql +219 -0
- pipeline/text_to_sql.py +170 -0
- pipeline/validation.py +166 -0
- pipeline/w1_workload.py +139 -0
- proposer/__init__.py +31 -0
- proposer/adapter.py +153 -0
- proposer/controller.py +460 -0
- proposer/cost_model.py +216 -0
- proposer/tree_adaptive.py +466 -0
- server/__init__.py +3 -0
- server/adaptive_scheduler.py +215 -0
- server/budget_planner.py +287 -0
- server/lineage.py +290 -0
- server/lineage_diagnostics.py +38 -0
- server/lineage_executor.py +129 -0
- server/lineage_startup.py +47 -0
- server/t4_rowwise_attention.py +262 -0
flexsds/cli/wizard.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Interactive serving wizard for FlexSDS with arrow-key navigation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import termios
|
|
12
|
+
import tty
|
|
13
|
+
HAS_TERMIOS = True
|
|
14
|
+
except ImportError:
|
|
15
|
+
HAS_TERMIOS = False
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from flexsds.cli.output import print_output as print
|
|
19
|
+
from flexsds.core.hardware import detect_hardware, HardwareInfo
|
|
20
|
+
from flexsds.core.compatibility import (
|
|
21
|
+
evaluate_serving_preflight,
|
|
22
|
+
ServingBlockedError,
|
|
23
|
+
)
|
|
24
|
+
from flexsds.core.cost_model import load_profile_for_hardware
|
|
25
|
+
from flexsds.server.local_runner import LocalServingRunner, finish_launch
|
|
26
|
+
from flexsds.server.colab_runner import ColabServingRunner
|
|
27
|
+
from flexsds.server.modal_runner import ModalServingRunner
|
|
28
|
+
except ModuleNotFoundError:
|
|
29
|
+
from src.flexsds.cli.output import print_output as print
|
|
30
|
+
from src.flexsds.core.hardware import detect_hardware, HardwareInfo
|
|
31
|
+
from src.flexsds.core.compatibility import (
|
|
32
|
+
evaluate_serving_preflight,
|
|
33
|
+
ServingBlockedError,
|
|
34
|
+
)
|
|
35
|
+
from src.flexsds.core.cost_model import load_profile_for_hardware
|
|
36
|
+
from src.flexsds.server.local_runner import LocalServingRunner, finish_launch
|
|
37
|
+
from src.flexsds.server.colab_runner import ColabServingRunner
|
|
38
|
+
from src.flexsds.server.modal_runner import ModalServingRunner
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
PLATFORM_CHOICES = [
|
|
42
|
+
"Modal.com (Serverless GPU đám mây)",
|
|
43
|
+
"Google Colab (GPU miễn phí T4/L4)",
|
|
44
|
+
"Tự dùng GPU nội bộ (Local vLLM)",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def strip_ansi(s: str) -> str:
|
|
49
|
+
"""Removes ANSI escape codes to calculate visible string length."""
|
|
50
|
+
import re
|
|
51
|
+
return re.sub(r"\033\[[0-9;]*m", "", s)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def render_box(title: str, items: list[tuple[str, str, str]], min_width: int = 56) -> str:
|
|
55
|
+
"""Renders a self-adjusting border box with perfectly aligned borders.
|
|
56
|
+
|
|
57
|
+
items: list of (label, value, value_ansi_color)
|
|
58
|
+
"""
|
|
59
|
+
max_label_w = max(len(strip_ansi(label)) for label, _, _ in items) if items else 12
|
|
60
|
+
label_w = max(max_label_w, 14)
|
|
61
|
+
|
|
62
|
+
max_content_w = len(title) + 6
|
|
63
|
+
for label, val, _ in items:
|
|
64
|
+
line_w = label_w + len(strip_ansi(val)) + 5
|
|
65
|
+
if line_w > max_content_w:
|
|
66
|
+
max_content_w = line_w
|
|
67
|
+
|
|
68
|
+
inner_w = max(max_content_w, min_width)
|
|
69
|
+
|
|
70
|
+
title_strip = strip_ansi(title)
|
|
71
|
+
top_left = "─── "
|
|
72
|
+
remaining = inner_w - len(top_left) - len(title_strip) - 1
|
|
73
|
+
top_right = "─" * max(2, remaining)
|
|
74
|
+
actual_inner = len(top_left) + len(title_strip) + 1 + len(top_right)
|
|
75
|
+
|
|
76
|
+
top = f"\033[90m┌{top_left}\033[1;37m{title_strip}\033[0m\033[90m {top_right}┐\033[0m"
|
|
77
|
+
bottom = "\033[90m└" + "─" * actual_inner + "┘\033[0m"
|
|
78
|
+
|
|
79
|
+
lines = [top]
|
|
80
|
+
for label, val, color in items:
|
|
81
|
+
clean_label = strip_ansi(label)
|
|
82
|
+
clean_val = strip_ansi(val)
|
|
83
|
+
prefix = f" {clean_label:<{label_w}} : "
|
|
84
|
+
used = len(prefix) + len(clean_val)
|
|
85
|
+
pad = " " * max(1, actual_inner - used)
|
|
86
|
+
row = f"\033[90m│\033[0m{prefix}{color}{clean_val}\033[0m{pad}\033[90m│\033[0m"
|
|
87
|
+
lines.append(row)
|
|
88
|
+
|
|
89
|
+
lines.append(bottom)
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def prompt_choice(question: str, choices: list[str], default_idx: int = 0) -> str:
|
|
94
|
+
"""Prompt user to select an option using arrow keys and Enter."""
|
|
95
|
+
if not sys.stdin.isatty() or not HAS_TERMIOS:
|
|
96
|
+
print(f"\033[32m✔\033[0m \033[1m{question}\033[0m \033[36m{choices[default_idx]}\033[0m")
|
|
97
|
+
return choices[default_idx]
|
|
98
|
+
|
|
99
|
+
current = default_idx
|
|
100
|
+
n = len(choices)
|
|
101
|
+
|
|
102
|
+
print(f"\n\033[1;37m? {question}\033[0m\n", end="")
|
|
103
|
+
print("\033[?25l", end="") # Hide cursor
|
|
104
|
+
sys.stdout.flush()
|
|
105
|
+
|
|
106
|
+
def render(first: bool = False) -> None:
|
|
107
|
+
if not first:
|
|
108
|
+
print(f"\033[{n}A", end="")
|
|
109
|
+
for i, choice in enumerate(choices):
|
|
110
|
+
print("\r\033[K", end="")
|
|
111
|
+
if i == current:
|
|
112
|
+
print(f" \033[1;37m> {choice}\033[0m\n", end="")
|
|
113
|
+
else:
|
|
114
|
+
print(f" \033[90m{choice}\033[0m\n", end="")
|
|
115
|
+
sys.stdout.flush()
|
|
116
|
+
|
|
117
|
+
render(first=True)
|
|
118
|
+
|
|
119
|
+
fd = sys.stdin.fileno()
|
|
120
|
+
old_settings = termios.tcgetattr(fd)
|
|
121
|
+
try:
|
|
122
|
+
tty.setcbreak(fd)
|
|
123
|
+
while True:
|
|
124
|
+
ch = sys.stdin.read(1)
|
|
125
|
+
if ch == "\x1b":
|
|
126
|
+
ch2 = sys.stdin.read(1)
|
|
127
|
+
if ch2 == "[":
|
|
128
|
+
ch3 = sys.stdin.read(1)
|
|
129
|
+
if ch3 == "A": # Up arrow
|
|
130
|
+
current = (current - 1) % n
|
|
131
|
+
render()
|
|
132
|
+
elif ch3 == "B": # Down arrow
|
|
133
|
+
current = (current + 1) % n
|
|
134
|
+
render()
|
|
135
|
+
elif ch in ("k", "K"):
|
|
136
|
+
current = (current - 1) % n
|
|
137
|
+
render()
|
|
138
|
+
elif ch in ("j", "J"):
|
|
139
|
+
current = (current + 1) % n
|
|
140
|
+
render()
|
|
141
|
+
elif ch.isdigit():
|
|
142
|
+
idx = int(ch) - 1
|
|
143
|
+
if 0 <= idx < n:
|
|
144
|
+
current = idx
|
|
145
|
+
render()
|
|
146
|
+
break
|
|
147
|
+
elif ch in ("\r", "\n", " "):
|
|
148
|
+
break
|
|
149
|
+
elif ch == "\x03": # Ctrl+C
|
|
150
|
+
print("\033[?25h\n", end="")
|
|
151
|
+
sys.exit(0)
|
|
152
|
+
finally:
|
|
153
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
154
|
+
print("\033[?25h", end="") # Show cursor
|
|
155
|
+
sys.stdout.flush()
|
|
156
|
+
|
|
157
|
+
# Collapse into clean single confirmed line
|
|
158
|
+
print(f"\033[{n+1}A\r\033[K\033[32m✔\033[0m \033[1m{question}\033[0m \033[36m{choices[current]}\033[0m\n", end="")
|
|
159
|
+
for _ in range(n):
|
|
160
|
+
print("\033[K\n", end="")
|
|
161
|
+
print(f"\033[{n}A", end="")
|
|
162
|
+
sys.stdout.flush()
|
|
163
|
+
|
|
164
|
+
return choices[current]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def prompt_string(question: str, default_val: str = "") -> str:
|
|
168
|
+
"""Prompt for text input with a default fallback."""
|
|
169
|
+
if not sys.stdin.isatty():
|
|
170
|
+
print(f"\033[32m✔\033[0m \033[1m{question}\033[0m \033[36m{default_val}\033[0m")
|
|
171
|
+
return default_val
|
|
172
|
+
|
|
173
|
+
hint = f" \033[90m[{default_val}]\033[0m" if default_val else ""
|
|
174
|
+
try:
|
|
175
|
+
print(f"\n\033[1;37m? {question}\033[0m{hint}: ", end="")
|
|
176
|
+
sys.stdout.flush()
|
|
177
|
+
user_input = sys.stdin.readline().strip()
|
|
178
|
+
val = user_input if user_input else default_val
|
|
179
|
+
print(f"\033[1A\r\033[K\033[32m✔\033[0m \033[1m{question}\033[0m \033[36m{val}\033[0m\n", end="")
|
|
180
|
+
sys.stdout.flush()
|
|
181
|
+
return val
|
|
182
|
+
except (EOFError, KeyboardInterrupt):
|
|
183
|
+
print("\n", end="")
|
|
184
|
+
sys.exit(0)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def prompt_bool(question: str, default_val: bool = True) -> bool:
|
|
188
|
+
"""Prompt for Yes/No using arrow-key selection."""
|
|
189
|
+
choices = ["Yes", "No"] if default_val else ["No", "Yes"]
|
|
190
|
+
selected = prompt_choice(question, choices, default_idx=0)
|
|
191
|
+
return selected == "Yes"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def run_interactive_wizard(hw_info: HardwareInfo) -> Dict[str, Any]:
|
|
195
|
+
"""Guides the user through setting up and launching FlexSDS serving."""
|
|
196
|
+
print("\n\033[1;37m--- FlexSDS Serving Wizard ---\033[0m")
|
|
197
|
+
|
|
198
|
+
# Step 1: Select Action
|
|
199
|
+
action = prompt_choice(
|
|
200
|
+
"Select action:",
|
|
201
|
+
["Launch serving", "Detect hardware", "Benchmark & profile GPU"],
|
|
202
|
+
default_idx=0,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if action == "Detect hardware":
|
|
206
|
+
print(f"\n\033[32m✔ Hardware specs: {hw_info.summary()}\033[0m")
|
|
207
|
+
return {"action": "detect"}
|
|
208
|
+
|
|
209
|
+
if action == "Benchmark & profile GPU":
|
|
210
|
+
cost_model, profile_name = load_profile_for_hardware(hw_info)
|
|
211
|
+
print(f"\n\033[32m✔ Hardware profile loaded: {profile_name}\033[0m")
|
|
212
|
+
return {"action": "profile"}
|
|
213
|
+
|
|
214
|
+
# Step 2: Target & Draft Model Configuration
|
|
215
|
+
model_mode = prompt_choice(
|
|
216
|
+
"Model configuration:",
|
|
217
|
+
[
|
|
218
|
+
"Default: Qwen2.5-7B-AWQ + EAGLE-3.1",
|
|
219
|
+
"Custom Hugging Face model",
|
|
220
|
+
],
|
|
221
|
+
default_idx=0,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if "Default" in model_mode:
|
|
225
|
+
target_model = "Qwen/Qwen2.5-7B-Instruct-AWQ"
|
|
226
|
+
draft_model = "duckhoi/qwen2.5-7b-eagle3.1-speculator"
|
|
227
|
+
else:
|
|
228
|
+
target_model = prompt_string(
|
|
229
|
+
"Target model ID or path:",
|
|
230
|
+
default_val="Qwen/Qwen2.5-7B-Instruct-AWQ",
|
|
231
|
+
)
|
|
232
|
+
draft_model = prompt_string(
|
|
233
|
+
"Draft speculator ID or path:",
|
|
234
|
+
default_val="duckhoi/qwen2.5-7b-eagle3.1-speculator",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
platform_choice = prompt_choice("Serving platform:", PLATFORM_CHOICES, default_idx=0)
|
|
238
|
+
backend = "modal" if "Modal" in platform_choice else ("colab" if "Colab" in platform_choice else "local")
|
|
239
|
+
target_gpu = "L4"
|
|
240
|
+
if backend == "modal":
|
|
241
|
+
target_gpu = prompt_choice("Modal GPU:", ["L4", "A10G", "T4"], default_idx=0)
|
|
242
|
+
elif backend == "colab":
|
|
243
|
+
target_gpu = "T4"
|
|
244
|
+
preflight = evaluate_serving_preflight(target_model, draft_model, hw_info, backend, target_gpu)
|
|
245
|
+
print(preflight.report.summary())
|
|
246
|
+
preflight.require_safe()
|
|
247
|
+
report = preflight.report
|
|
248
|
+
|
|
249
|
+
# Compatibility Report Box (dynamically auto-resizes)
|
|
250
|
+
status_color = "\033[32m" if not report.oom_risk else "\033[31m"
|
|
251
|
+
status_text = "OK" if not report.oom_risk else "OOM RISK"
|
|
252
|
+
vram_detail = f"(~{report.total_vram_needed_mb/1024:.1f} GB needed / {report.free_vram_mb/1024:.1f} GB free)"
|
|
253
|
+
vocab_text = "Shared" if not report.use_heterogeneous_vocab else "Heterogeneous"
|
|
254
|
+
|
|
255
|
+
box_items = [
|
|
256
|
+
("VRAM Status", f"{status_text} {vram_detail}", status_color),
|
|
257
|
+
("Speculation", report.draft_method, "\033[36m"),
|
|
258
|
+
("Vocabulary", vocab_text, "\033[32m"),
|
|
259
|
+
]
|
|
260
|
+
for warn in report.warnings:
|
|
261
|
+
box_items.append(("Warning", warn, "\033[33m"))
|
|
262
|
+
|
|
263
|
+
print("\n" + render_box("Compatibility Report", box_items))
|
|
264
|
+
|
|
265
|
+
colab_tunnel_url = ""
|
|
266
|
+
modal_deploy = False
|
|
267
|
+
if backend == "modal":
|
|
268
|
+
if not ModalServingRunner().check_credentials():
|
|
269
|
+
print("Modal credentials missing; use flexsds auth login securely before launch.")
|
|
270
|
+
mode_choice = prompt_choice("Modal deployment mode:",
|
|
271
|
+
["Development (modal serve)", "Production (modal deploy)"], default_idx=0)
|
|
272
|
+
modal_deploy = "Production" in mode_choice
|
|
273
|
+
|
|
274
|
+
elif "Google Colab" in platform_choice:
|
|
275
|
+
backend = "colab"
|
|
276
|
+
gpu_choice = prompt_choice("Colab GPU:", ["T4", "A100 / L4"], default_idx=0)
|
|
277
|
+
target_gpu = "T4" if "T4" in gpu_choice else "L4"
|
|
278
|
+
|
|
279
|
+
colab_runner = ColabServingRunner()
|
|
280
|
+
instructions = colab_runner.get_colab_instructions(target_gpu=target_gpu)
|
|
281
|
+
print("\n\033[1;37mGoogle Colab Instructions:\033[0m")
|
|
282
|
+
for step in instructions["steps"]:
|
|
283
|
+
print(f" \033[90m{step}\033[0m")
|
|
284
|
+
print(f" Notebook URL: \033[34m{instructions['notebook_url']}\033[0m\n")
|
|
285
|
+
|
|
286
|
+
colab_tunnel_url = prompt_string("Paste Cloudflare tunnel URL (Enter to skip):", "")
|
|
287
|
+
if colab_tunnel_url:
|
|
288
|
+
print("\033[90mVerifying tunnel connection...\033[0m")
|
|
289
|
+
if colab_runner.verify_tunnel_health(colab_tunnel_url):
|
|
290
|
+
print("\033[32m✔ Cloudflare tunnel is reachable.\033[0m")
|
|
291
|
+
else:
|
|
292
|
+
print("\033[33m⚠ Endpoint not responding yet (model weights may still be loading).\033[0m")
|
|
293
|
+
|
|
294
|
+
else:
|
|
295
|
+
backend = "local"
|
|
296
|
+
if not hw_info.is_cuda_available:
|
|
297
|
+
print("\n\033[33m⚠ No NVIDIA CUDA GPU detected on this system.\033[0m")
|
|
298
|
+
print(" Modal or Google Colab is recommended for hardware acceleration.")
|
|
299
|
+
else:
|
|
300
|
+
print(f"\n\033[32m✔ Local GPU: {hw_info.device_name} ({hw_info.free_vram_gb} GB free)\033[0m")
|
|
301
|
+
|
|
302
|
+
# Step 5: HardwareAdaptive Optimization Engine
|
|
303
|
+
enable_adaptive = prompt_bool(
|
|
304
|
+
"Enable HardwareAdaptive engine?",
|
|
305
|
+
default_val=True,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Step 6: Profile Mapping
|
|
309
|
+
cost_model, profile_name = load_profile_for_hardware(hw_info)
|
|
310
|
+
print(f"\033[32m✔ Linked hardware profile: {profile_name}\033[0m")
|
|
311
|
+
|
|
312
|
+
# Save serving configuration
|
|
313
|
+
config: Dict[str, Any] = {
|
|
314
|
+
"flexsds_version": "0.2.0-alpha",
|
|
315
|
+
"action": "serve",
|
|
316
|
+
"backend": backend,
|
|
317
|
+
"target_model": target_model,
|
|
318
|
+
"draft_model": draft_model,
|
|
319
|
+
"draft_method": report.draft_method,
|
|
320
|
+
"use_heterogeneous_vocab": report.use_heterogeneous_vocab,
|
|
321
|
+
"enable_adaptive": enable_adaptive,
|
|
322
|
+
"profile_applied": profile_name,
|
|
323
|
+
"target_gpu": target_gpu,
|
|
324
|
+
"colab_tunnel_url": colab_tunnel_url,
|
|
325
|
+
"modal_deploy": modal_deploy,
|
|
326
|
+
"hardware": {
|
|
327
|
+
"device_name": hw_info.device_name,
|
|
328
|
+
"sm_architecture": hw_info.sm_architecture,
|
|
329
|
+
"free_vram_mb": hw_info.free_vram_mb,
|
|
330
|
+
},
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
config_file = Path("serving_config.json")
|
|
334
|
+
with open(config_file, "w", encoding="utf-8") as f:
|
|
335
|
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
336
|
+
print(f"\033[32m✔ Configuration saved: {config_file.name}\033[0m")
|
|
337
|
+
|
|
338
|
+
# Step 7: Launch Server
|
|
339
|
+
should_launch = prompt_bool("Launch server now?", default_val=True)
|
|
340
|
+
if should_launch:
|
|
341
|
+
if backend == "local":
|
|
342
|
+
local_runner = LocalServingRunner()
|
|
343
|
+
run_in_bg = prompt_bool("Run server in background?", default_val=False)
|
|
344
|
+
proc = local_runner.launch(config, background=run_in_bg)
|
|
345
|
+
config["exit_code"] = finish_launch(proc, background=run_in_bg)
|
|
346
|
+
return config
|
|
347
|
+
elif backend == "modal":
|
|
348
|
+
modal_runner = ModalServingRunner()
|
|
349
|
+
proc = modal_runner.launch(config, deploy=modal_deploy)
|
|
350
|
+
config["exit_code"] = finish_launch(proc)
|
|
351
|
+
return config
|
|
352
|
+
elif backend == "colab":
|
|
353
|
+
endpoint = colab_tunnel_url or "http://localhost:8000/v1"
|
|
354
|
+
print("\n\033[1;32mColab guidance only; no server or endpoint created.\033[0m")
|
|
355
|
+
print(f" \033[1;37mGợi ý:\033[0m Chạy lệnh: \033[36mflexsds chat --endpoint {endpoint}\033[0m để bắt đầu trò chuyện!\n")
|
|
356
|
+
else:
|
|
357
|
+
print("\n\033[1;32mConfiguration saved; no server launched or readiness established.\033[0m")
|
|
358
|
+
print(" \033[1;37mGợi ý:\033[0m Chạy \033[36mflexsds chat\033[0m để kết nối máy chủ khi đã bật.\n")
|
|
359
|
+
|
|
360
|
+
return config
|
flexsds/client/chat.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Interactive terminal chat session with real-time streaming and performance box."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from flexsds.cli.output import print_output as print, write_output
|
|
8
|
+
except ModuleNotFoundError:
|
|
9
|
+
from src.flexsds.cli.output import print_output as print, write_output
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from flexsds.client.client import FlexSDSClient, EndpointUnreachableError
|
|
16
|
+
from flexsds.cli.wizard import render_box
|
|
17
|
+
except ModuleNotFoundError:
|
|
18
|
+
from src.flexsds.client.client import FlexSDSClient, EndpointUnreachableError
|
|
19
|
+
from src.flexsds.cli.wizard import render_box
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class PerformanceMetrics:
|
|
24
|
+
"""Performance measurements for one generation turn."""
|
|
25
|
+
total_chunks: int
|
|
26
|
+
elapsed_seconds: float
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def chunks_per_second(self) -> float:
|
|
30
|
+
if self.elapsed_seconds <= 0 or self.total_chunks == 0:
|
|
31
|
+
return 0.0
|
|
32
|
+
return round(self.total_chunks / self.elapsed_seconds, 2)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def ms_per_chunk(self) -> float:
|
|
36
|
+
"""Elapsed milliseconds per SSE content chunk; not TPOT."""
|
|
37
|
+
if self.total_chunks == 0 or self.elapsed_seconds <= 0:
|
|
38
|
+
return 0.0
|
|
39
|
+
return round((self.elapsed_seconds / self.total_chunks) * 1000.0, 2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class InteractiveChatSession:
|
|
43
|
+
"""Manages multi-turn streaming chat on the terminal."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
client: FlexSDSClient,
|
|
48
|
+
model: Optional[str] = None,
|
|
49
|
+
temperature: float = 0.0,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.client = client
|
|
52
|
+
self.model = model or "qwen2.5-7b-adaptive"
|
|
53
|
+
self.temperature = temperature
|
|
54
|
+
self.history: List[Dict[str, str]] = []
|
|
55
|
+
|
|
56
|
+
def start(self) -> int:
|
|
57
|
+
"""Runs the interactive chat loop."""
|
|
58
|
+
if not self.client.check_health():
|
|
59
|
+
print(f"\n\033[31m[Lỗi kết nối]\033[0m Không thể kết nối tới máy chủ tại: {self.client.base_url}")
|
|
60
|
+
print(" Vui lòng đảm bảo máy chủ đã được bật bằng lệnh: flexsds serve")
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
print(f"\n\033[1;32m✔ Đã kết nối tới FlexSDS Serving Endpoint:\033[0m \033[36m{self.client.base_url}\033[0m")
|
|
64
|
+
print(f" Mô hình: \033[1;37m{self.model}\033[0m | Đầu cơ: \033[33mEAGLE-3.1 (Adaptive k động)\033[0m")
|
|
65
|
+
print(" \033[90m(Gõ tin nhắn để trò chuyện, '/exit' để thoát, '/clear' để xóa ngữ cảnh)\033[0m\n")
|
|
66
|
+
|
|
67
|
+
while True:
|
|
68
|
+
try:
|
|
69
|
+
user_input = input("\033[1;32mYou >\033[0m ").strip()
|
|
70
|
+
except (KeyboardInterrupt, EOFError):
|
|
71
|
+
print("\n\033[90mPhiên trò chuyện kết thúc.\033[0m")
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
if not user_input:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
if user_input.lower() in ("/exit", "/quit", "exit", "quit"):
|
|
78
|
+
print("\033[90mPhiên trò chuyện kết thúc.\033[0m")
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
if user_input.lower() == "/clear":
|
|
82
|
+
self.history.clear()
|
|
83
|
+
print("\033[32m✔ Đã xóa lịch sử ngữ cảnh trò chuyện.\033[0m\n")
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
self.history.append({"role": "user", "content": user_input})
|
|
87
|
+
write_output("\033[1;36mFlexSDS >\033[0m ")
|
|
88
|
+
sys.stdout.flush()
|
|
89
|
+
|
|
90
|
+
start_t = time.perf_counter()
|
|
91
|
+
chunk_count = 0
|
|
92
|
+
full_response: List[str] = []
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
for token in self.client.stream_chat(
|
|
96
|
+
self.history,
|
|
97
|
+
model=self.model,
|
|
98
|
+
temperature=self.temperature,
|
|
99
|
+
):
|
|
100
|
+
write_output(token)
|
|
101
|
+
sys.stdout.flush()
|
|
102
|
+
full_response.append(token)
|
|
103
|
+
chunk_count += 1
|
|
104
|
+
write_output("\n")
|
|
105
|
+
sys.stdout.flush()
|
|
106
|
+
except KeyboardInterrupt:
|
|
107
|
+
write_output("\n\033[90m(Hủy lượt trả lời hiện tại)\033[0m\n")
|
|
108
|
+
sys.stdout.flush()
|
|
109
|
+
continue
|
|
110
|
+
except EndpointUnreachableError as e:
|
|
111
|
+
print(f"\n\033[31m[Lỗi đường truyền]\033[0m Mất kết nối tới server: {e}")
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
elapsed = max(time.perf_counter() - start_t, 0.001)
|
|
115
|
+
metrics = PerformanceMetrics(total_chunks=chunk_count, elapsed_seconds=elapsed)
|
|
116
|
+
self.history.append({"role": "assistant", "content": "".join(full_response)})
|
|
117
|
+
|
|
118
|
+
# Render box thống kê hiệu năng
|
|
119
|
+
items = [
|
|
120
|
+
("SSE content chunks", f"{metrics.total_chunks} chunks", "\033[1;37m"),
|
|
121
|
+
("Thời gian xử lý", f"{metrics.elapsed_seconds:.2f} s", "\033[36m"),
|
|
122
|
+
("Tốc độ trung bình", f"{metrics.chunks_per_second} chunks/s", "\033[32m"),
|
|
123
|
+
("Elapsed per chunk", f"{metrics.ms_per_chunk} ms/chunk", "\033[33m"),
|
|
124
|
+
]
|
|
125
|
+
box = render_box("Thống kê suy luận", items, min_width=44)
|
|
126
|
+
print(f"\n{box}\n")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_interactive_chat(
|
|
130
|
+
endpoint: str = "http://localhost:8000/v1",
|
|
131
|
+
model: Optional[str] = None,
|
|
132
|
+
temperature: float = 0.0,
|
|
133
|
+
) -> int:
|
|
134
|
+
"""Entry point for CLI flexsds chat command."""
|
|
135
|
+
client = FlexSDSClient(base_url=endpoint)
|
|
136
|
+
session = InteractiveChatSession(client, model=model, temperature=temperature)
|
|
137
|
+
return session.start()
|
flexsds/client/client.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Lightweight OpenAI-compatible HTTP client with streaming SSE support."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EndpointUnreachableError(Exception):
|
|
11
|
+
"""Raised when the serving endpoint cannot be reached."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FlexSDSClient:
|
|
15
|
+
"""Client for OpenAI-compatible vLLM serving endpoints."""
|
|
16
|
+
|
|
17
|
+
base_url: str
|
|
18
|
+
api_key: str
|
|
19
|
+
|
|
20
|
+
def __init__(self, base_url: str = "http://localhost:8000/v1", api_key: Optional[str] = None) -> None:
|
|
21
|
+
self.base_url = base_url.rstrip("/")
|
|
22
|
+
self.api_key = api_key or "EMPTY"
|
|
23
|
+
|
|
24
|
+
def _make_request(
|
|
25
|
+
self,
|
|
26
|
+
endpoint: str,
|
|
27
|
+
data: Optional[Dict[str, Any]] = None,
|
|
28
|
+
timeout: Optional[int] = 10,
|
|
29
|
+
) -> urllib.request.Request:
|
|
30
|
+
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
|
31
|
+
headers = {
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
"User-Agent": "FlexSDS-Client/0.2.0",
|
|
34
|
+
}
|
|
35
|
+
if self.api_key and self.api_key != "EMPTY":
|
|
36
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
37
|
+
|
|
38
|
+
body = json.dumps(data).encode("utf-8") if data is not None else None
|
|
39
|
+
return urllib.request.Request(url, data=body, headers=headers)
|
|
40
|
+
|
|
41
|
+
def check_health(self, timeout: int = 3) -> bool:
|
|
42
|
+
"""Kiểm tra xem máy chủ có phản hồi không (qua /models hoặc /health)."""
|
|
43
|
+
# Thử endpoint models trước
|
|
44
|
+
try:
|
|
45
|
+
req = self._make_request("models", timeout=timeout)
|
|
46
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
47
|
+
return resp.status == 200
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
# Thử endpoint root health
|
|
52
|
+
try:
|
|
53
|
+
root_url = self.base_url.rsplit("/v1", 1)[0]
|
|
54
|
+
req = urllib.request.Request(f"{root_url}/health", headers={"User-Agent": "FlexSDS-Client/0.2.0"})
|
|
55
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
56
|
+
return resp.status == 200
|
|
57
|
+
except Exception:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def get_models(self) -> List[str]:
|
|
61
|
+
"""Lấy danh sách các mô hình đang được phục vụ trên máy chủ."""
|
|
62
|
+
req = self._make_request("models")
|
|
63
|
+
try:
|
|
64
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
65
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
66
|
+
return [m["id"] for m in data.get("data", [])]
|
|
67
|
+
except Exception as e:
|
|
68
|
+
raise EndpointUnreachableError(f"Cannot reach server at {self.base_url}: {e}") from e
|
|
69
|
+
|
|
70
|
+
def stream_chat(
|
|
71
|
+
self,
|
|
72
|
+
messages: List[Dict[str, str]],
|
|
73
|
+
model: str = "qwen2.5-7b-adaptive",
|
|
74
|
+
temperature: float = 0.0,
|
|
75
|
+
) -> Iterator[str]:
|
|
76
|
+
"""Gửi yêu cầu trò chuyện và trả về từng token văn bản (streaming SSE)."""
|
|
77
|
+
payload = {
|
|
78
|
+
"model": model,
|
|
79
|
+
"messages": messages,
|
|
80
|
+
"temperature": temperature,
|
|
81
|
+
"stream": True,
|
|
82
|
+
}
|
|
83
|
+
req = self._make_request("chat/completions", data=payload)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
resp = urllib.request.urlopen(req)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
raise EndpointUnreachableError(f"Failed to connect to {self.base_url}: {e}") from e
|
|
89
|
+
|
|
90
|
+
with resp:
|
|
91
|
+
for raw_line in resp:
|
|
92
|
+
line = raw_line.decode("utf-8").strip()
|
|
93
|
+
if not line or not line.startswith("data: "):
|
|
94
|
+
continue
|
|
95
|
+
chunk_str = line[6:].strip()
|
|
96
|
+
if chunk_str == "[DONE]":
|
|
97
|
+
break
|
|
98
|
+
try:
|
|
99
|
+
chunk = json.loads(chunk_str)
|
|
100
|
+
choices = chunk.get("choices", [])
|
|
101
|
+
if choices:
|
|
102
|
+
delta = choices[0].get("delta", {})
|
|
103
|
+
content = delta.get("content")
|
|
104
|
+
if content:
|
|
105
|
+
yield content
|
|
106
|
+
except json.JSONDecodeError:
|
|
107
|
+
continue
|