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.
Files changed (62) hide show
  1. flexsds/__init__.py +4 -0
  2. flexsds/assets/ptit_logo.png +0 -0
  3. flexsds/cli/__init__.py +1 -0
  4. flexsds/cli/banner.py +84 -0
  5. flexsds/cli/main.py +330 -0
  6. flexsds/cli/output.py +69 -0
  7. flexsds/cli/task_runner.py +171 -0
  8. flexsds/cli/wizard.py +360 -0
  9. flexsds/client/__init__.py +4 -0
  10. flexsds/client/chat.py +137 -0
  11. flexsds/client/client.py +107 -0
  12. flexsds/client/monitor.py +95 -0
  13. flexsds/core/__init__.py +44 -0
  14. flexsds/core/compatibility.py +442 -0
  15. flexsds/core/controller.py +502 -0
  16. flexsds/core/cost_model.py +530 -0
  17. flexsds/core/credentials.py +111 -0
  18. flexsds/core/hardware.py +77 -0
  19. flexsds/core/tree_topology.py +296 -0
  20. flexsds/profiles/profile_sm75_t4.json +62 -0
  21. flexsds/profiles/profile_sm89_l4.json +194 -0
  22. flexsds/server/__init__.py +1 -0
  23. flexsds/server/colab_runner.py +62 -0
  24. flexsds/server/local_runner.py +208 -0
  25. flexsds/server/modal_app.py +50 -0
  26. flexsds/server/modal_runner.py +98 -0
  27. flexsds/server/vscode_cli.py +82 -0
  28. flexsds-0.2.0.dist-info/METADATA +204 -0
  29. flexsds-0.2.0.dist-info/RECORD +62 -0
  30. flexsds-0.2.0.dist-info/WHEEL +5 -0
  31. flexsds-0.2.0.dist-info/entry_points.txt +2 -0
  32. flexsds-0.2.0.dist-info/top_level.txt +4 -0
  33. pipeline/entity_eval.py +171 -0
  34. pipeline/examples/node1_extractor_input_m0_04_001.txt +9 -0
  35. pipeline/examples/node1_extractor_output_m0_04_001.json +90 -0
  36. pipeline/extractor.py +502 -0
  37. pipeline/invoice_models.py +163 -0
  38. pipeline/node2_questions.py +30 -0
  39. pipeline/openai_compatible.py +87 -0
  40. pipeline/prompts/README.md +18 -0
  41. pipeline/prompts/node1_extractor_system.md +92 -0
  42. pipeline/prompts/node3_report_user.md +5 -0
  43. pipeline/report_writer.py +143 -0
  44. pipeline/schemas/invoice_extraction.schema.json +469 -0
  45. pipeline/sql_guard.py +140 -0
  46. pipeline/sqlite/schema.sql +219 -0
  47. pipeline/text_to_sql.py +170 -0
  48. pipeline/validation.py +166 -0
  49. pipeline/w1_workload.py +139 -0
  50. proposer/__init__.py +31 -0
  51. proposer/adapter.py +153 -0
  52. proposer/controller.py +460 -0
  53. proposer/cost_model.py +216 -0
  54. proposer/tree_adaptive.py +466 -0
  55. server/__init__.py +3 -0
  56. server/adaptive_scheduler.py +215 -0
  57. server/budget_planner.py +287 -0
  58. server/lineage.py +290 -0
  59. server/lineage_diagnostics.py +38 -0
  60. server/lineage_executor.py +129 -0
  61. server/lineage_startup.py +47 -0
  62. 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
@@ -0,0 +1,4 @@
1
+ """FlexSDS client modules."""
2
+ from flexsds.client.client import FlexSDSClient, EndpointUnreachableError
3
+
4
+ __all__ = ["FlexSDSClient", "EndpointUnreachableError"]
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()
@@ -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