chore: import upstream snapshot with attribution
Book-CI / test (macos-latest) (push) Has been cancelled
Book-CI / test (ubuntu-latest) (push) Has been cancelled
Book-CI / test (windows-latest) (push) Has been cancelled
Release Fake Tag / publish (push) Has been cancelled
Deploy / deploy (macos-latest) (push) Has been cancelled
Deploy / deploy (ubuntu-latest) (push) Has been cancelled
Deploy / deploy (windows-latest) (push) Has been cancelled
Release to PyPI / Build & publish sglang-kt (push) Has been cancelled
Release to PyPI / Build kt-kernel (Python 3.11) (push) Has been cancelled
Release to PyPI / Build kt-kernel (Python 3.12) (push) Has been cancelled
Release to PyPI / Publish kt-kernel to PyPI (push) Has been cancelled
Book-CI / test (macos-latest) (push) Has been cancelled
Book-CI / test (ubuntu-latest) (push) Has been cancelled
Book-CI / test (windows-latest) (push) Has been cancelled
Release Fake Tag / publish (push) Has been cancelled
Deploy / deploy (macos-latest) (push) Has been cancelled
Deploy / deploy (ubuntu-latest) (push) Has been cancelled
Deploy / deploy (windows-latest) (push) Has been cancelled
Release to PyPI / Build & publish sglang-kt (push) Has been cancelled
Release to PyPI / Build kt-kernel (Python 3.11) (push) Has been cancelled
Release to PyPI / Build kt-kernel (Python 3.12) (push) Has been cancelled
Release to PyPI / Publish kt-kernel to PyPI (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
KTransformers CLI - A unified command-line interface for KTransformers.
|
||||
|
||||
This CLI provides a user-friendly interface to all KTransformers functionality,
|
||||
including model inference, fine-tuning, benchmarking, and more.
|
||||
"""
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
try:
|
||||
__version__ = version("kt-kernel")
|
||||
except PackageNotFoundError:
|
||||
_version_ns = {}
|
||||
_root_version_file = Path(__file__).resolve().parents[3] / "version.py"
|
||||
if _root_version_file.exists():
|
||||
exec(_root_version_file.read_text(encoding="utf-8"), _version_ns)
|
||||
__version__ = _version_ns.get("__version__", "0.6.1")
|
||||
else:
|
||||
__version__ = "0.6.1"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Command modules for kt-cli.
|
||||
"""
|
||||
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
Bench commands for kt-cli.
|
||||
|
||||
Runs benchmarks for performance testing.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from kt_kernel.cli.i18n import t
|
||||
from kt_kernel.cli.utils.console import (
|
||||
console,
|
||||
print_error,
|
||||
print_info,
|
||||
print_step,
|
||||
print_success,
|
||||
)
|
||||
|
||||
|
||||
class BenchType(str, Enum):
|
||||
"""Benchmark type."""
|
||||
|
||||
INFERENCE = "inference"
|
||||
MLA = "mla"
|
||||
MOE = "moe"
|
||||
LINEAR = "linear"
|
||||
ATTENTION = "attention"
|
||||
ALL = "all"
|
||||
|
||||
|
||||
def bench(
|
||||
type: BenchType = typer.Option(
|
||||
BenchType.ALL,
|
||||
"--type",
|
||||
"-t",
|
||||
help="Benchmark type",
|
||||
),
|
||||
model: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--model",
|
||||
"-m",
|
||||
help="Model to benchmark",
|
||||
),
|
||||
output: Optional[Path] = typer.Option(
|
||||
None,
|
||||
"--output",
|
||||
"-o",
|
||||
help="Output file for results (JSON)",
|
||||
),
|
||||
iterations: int = typer.Option(
|
||||
10,
|
||||
"--iterations",
|
||||
"-n",
|
||||
help="Number of iterations",
|
||||
),
|
||||
) -> None:
|
||||
"""Run full benchmark suite."""
|
||||
console.print()
|
||||
print_step(t("bench_starting"))
|
||||
print_info(t("bench_type", type=type.value))
|
||||
console.print()
|
||||
|
||||
if type == BenchType.ALL:
|
||||
_run_all_benchmarks(model, output, iterations)
|
||||
elif type == BenchType.INFERENCE:
|
||||
_run_inference_benchmark(model, output, iterations)
|
||||
elif type == BenchType.MLA:
|
||||
_run_component_benchmark("mla", output, iterations)
|
||||
elif type == BenchType.MOE:
|
||||
_run_component_benchmark("moe", output, iterations)
|
||||
elif type == BenchType.LINEAR:
|
||||
_run_component_benchmark("linear", output, iterations)
|
||||
elif type == BenchType.ATTENTION:
|
||||
_run_component_benchmark("attention", output, iterations)
|
||||
|
||||
console.print()
|
||||
print_success(t("bench_complete"))
|
||||
if output:
|
||||
console.print(f" Results saved to: {output}")
|
||||
console.print()
|
||||
|
||||
|
||||
def microbench(
|
||||
component: str = typer.Argument(
|
||||
"moe",
|
||||
help="Component to benchmark (moe, mla, linear, attention)",
|
||||
),
|
||||
batch_size: int = typer.Option(
|
||||
1,
|
||||
"--batch-size",
|
||||
"-b",
|
||||
help="Batch size",
|
||||
),
|
||||
seq_len: int = typer.Option(
|
||||
1,
|
||||
"--seq-len",
|
||||
"-s",
|
||||
help="Sequence length",
|
||||
),
|
||||
iterations: int = typer.Option(
|
||||
100,
|
||||
"--iterations",
|
||||
"-n",
|
||||
help="Number of iterations",
|
||||
),
|
||||
warmup: int = typer.Option(
|
||||
10,
|
||||
"--warmup",
|
||||
"-w",
|
||||
help="Warmup iterations",
|
||||
),
|
||||
output: Optional[Path] = typer.Option(
|
||||
None,
|
||||
"--output",
|
||||
"-o",
|
||||
help="Output file for results (JSON)",
|
||||
),
|
||||
) -> None:
|
||||
"""Run micro-benchmark for specific components."""
|
||||
console.print()
|
||||
console.print(f"[yellow]{t('feature_coming_soon')}[/yellow]")
|
||||
console.print()
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Try to find the benchmark script
|
||||
kt_kernel_path = _find_kt_kernel_path()
|
||||
|
||||
if kt_kernel_path is None:
|
||||
print_error("kt-kernel not found. Install with: kt install inference")
|
||||
raise typer.Exit(1)
|
||||
|
||||
bench_dir = kt_kernel_path / "bench"
|
||||
|
||||
# Map component to script
|
||||
component_scripts = {
|
||||
"moe": "bench_moe.py",
|
||||
"mla": "bench_mla.py",
|
||||
"linear": "bench_linear.py",
|
||||
"attention": "bench_attention.py",
|
||||
"mlp": "bench_mlp.py",
|
||||
}
|
||||
|
||||
script_name = component_scripts.get(component.lower())
|
||||
if script_name is None:
|
||||
print_error(f"Unknown component: {component}")
|
||||
console.print(f"Available: {', '.join(component_scripts.keys())}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
script_path = bench_dir / script_name
|
||||
if not script_path.exists():
|
||||
print_error(f"Benchmark script not found: {script_path}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Run benchmark
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(script_path),
|
||||
"--batch-size",
|
||||
str(batch_size),
|
||||
"--seq-len",
|
||||
str(seq_len),
|
||||
"--iterations",
|
||||
str(iterations),
|
||||
"--warmup",
|
||||
str(warmup),
|
||||
]
|
||||
|
||||
if output:
|
||||
cmd.extend(["--output", str(output)])
|
||||
|
||||
console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
|
||||
console.print()
|
||||
|
||||
try:
|
||||
process = subprocess.run(cmd)
|
||||
|
||||
if process.returncode == 0:
|
||||
console.print()
|
||||
print_success(t("bench_complete"))
|
||||
if output:
|
||||
console.print(f" Results saved to: {output}")
|
||||
else:
|
||||
print_error(f"Benchmark failed with exit code {process.returncode}")
|
||||
raise typer.Exit(process.returncode)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
print_error(f"Failed to run benchmark: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _find_kt_kernel_path() -> Optional[Path]:
|
||||
"""Find the kt-kernel installation path."""
|
||||
try:
|
||||
import kt_kernel
|
||||
|
||||
return Path(kt_kernel.__file__).parent.parent
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Check common locations
|
||||
possible_paths = [
|
||||
Path.home() / "Projects" / "ktransformers" / "kt-kernel",
|
||||
Path("/opt/ktransformers/kt-kernel"),
|
||||
Path.cwd() / "kt-kernel",
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if path.exists() and (path / "bench").exists():
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _run_all_benchmarks(model: Optional[str], output: Optional[Path], iterations: int) -> None:
|
||||
"""Run all benchmarks."""
|
||||
components = ["moe", "mla", "linear", "attention"]
|
||||
|
||||
for component in components:
|
||||
console.print(f"\n[bold]Running {component} benchmark...[/bold]")
|
||||
_run_component_benchmark(component, None, iterations)
|
||||
|
||||
|
||||
def _run_inference_benchmark(model: Optional[str], output: Optional[Path], iterations: int) -> None:
|
||||
"""Run inference benchmark."""
|
||||
if model is None:
|
||||
print_error("Model required for inference benchmark. Use --model flag.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
print_info(f"Running inference benchmark on {model}...")
|
||||
console.print()
|
||||
console.print("[dim]This will start the server and run test requests.[/dim]")
|
||||
console.print()
|
||||
|
||||
# TODO: Implement actual inference benchmarking
|
||||
print_error("Inference benchmarking not yet implemented.")
|
||||
|
||||
|
||||
def _run_component_benchmark(component: str, output: Optional[Path], iterations: int) -> None:
|
||||
"""Run a component benchmark."""
|
||||
kt_kernel_path = _find_kt_kernel_path()
|
||||
|
||||
if kt_kernel_path is None:
|
||||
print_error("kt-kernel not found.")
|
||||
return
|
||||
|
||||
bench_dir = kt_kernel_path / "bench"
|
||||
script_map = {
|
||||
"moe": "bench_moe.py",
|
||||
"mla": "bench_mla.py",
|
||||
"linear": "bench_linear.py",
|
||||
"attention": "bench_attention.py",
|
||||
}
|
||||
|
||||
script_name = script_map.get(component)
|
||||
if script_name is None:
|
||||
print_error(f"Unknown component: {component}")
|
||||
return
|
||||
|
||||
script_path = bench_dir / script_name
|
||||
if not script_path.exists():
|
||||
print_error(f"Script not found: {script_path}")
|
||||
return
|
||||
|
||||
cmd = [sys.executable, str(script_path), "--iterations", str(iterations)]
|
||||
|
||||
try:
|
||||
subprocess.run(cmd)
|
||||
except Exception as e:
|
||||
print_error(f"Benchmark failed: {e}")
|
||||
@@ -0,0 +1,572 @@
|
||||
"""
|
||||
Chat command for kt-cli.
|
||||
|
||||
Provides interactive chat interface with running model server.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Prompt, Confirm
|
||||
|
||||
from kt_kernel.cli.config.settings import get_settings
|
||||
from kt_kernel.cli.i18n import t
|
||||
from kt_kernel.cli.utils.console import (
|
||||
console,
|
||||
print_error,
|
||||
print_info,
|
||||
print_success,
|
||||
print_warning,
|
||||
)
|
||||
|
||||
# Try to import OpenAI SDK
|
||||
try:
|
||||
from openai import OpenAI
|
||||
|
||||
HAS_OPENAI = True
|
||||
except ImportError:
|
||||
HAS_OPENAI = False
|
||||
|
||||
|
||||
def chat(
|
||||
host: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--host",
|
||||
"-H",
|
||||
help="Server host address",
|
||||
),
|
||||
port: Optional[int] = typer.Option(
|
||||
None,
|
||||
"--port",
|
||||
"-p",
|
||||
help="Server port",
|
||||
),
|
||||
model: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--model",
|
||||
"-m",
|
||||
help="Model name (if server hosts multiple models)",
|
||||
),
|
||||
temperature: float = typer.Option(
|
||||
0.7,
|
||||
"--temperature",
|
||||
"-t",
|
||||
help="Sampling temperature (0.0 to 2.0)",
|
||||
),
|
||||
max_tokens: int = typer.Option(
|
||||
2048,
|
||||
"--max-tokens",
|
||||
help="Maximum tokens to generate",
|
||||
),
|
||||
system_prompt: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--system",
|
||||
"-s",
|
||||
help="System prompt",
|
||||
),
|
||||
save_history: bool = typer.Option(
|
||||
True,
|
||||
"--save-history/--no-save-history",
|
||||
help="Save conversation history",
|
||||
),
|
||||
history_file: Optional[Path] = typer.Option(
|
||||
None,
|
||||
"--history-file",
|
||||
help="Path to save conversation history",
|
||||
),
|
||||
stream: bool = typer.Option(
|
||||
True,
|
||||
"--stream/--no-stream",
|
||||
help="Enable streaming output",
|
||||
),
|
||||
) -> None:
|
||||
"""Start interactive chat with a running model server.
|
||||
|
||||
Examples:
|
||||
kt chat # Connect to default server
|
||||
kt chat --host 127.0.0.1 -p 8080 # Connect to specific server
|
||||
kt chat -t 0.9 --max-tokens 4096 # Adjust generation parameters
|
||||
"""
|
||||
if not HAS_OPENAI:
|
||||
print_error(t("chat_openai_required"))
|
||||
console.print()
|
||||
console.print(t("chat_install_hint"))
|
||||
console.print(" pip install openai")
|
||||
raise typer.Exit(1)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Resolve server connection
|
||||
final_host = host or settings.get("server.host", "127.0.0.1")
|
||||
final_port = port or settings.get("server.port", 30000)
|
||||
|
||||
# Construct base URL for OpenAI-compatible API
|
||||
base_url = f"http://{final_host}:{final_port}/v1"
|
||||
|
||||
console.print()
|
||||
console.print(
|
||||
Panel.fit(
|
||||
f"[bold cyan]{t('chat_title')}[/bold cyan]\n\n"
|
||||
f"{t('chat_server')}: [yellow]{final_host}:{final_port}[/yellow]\n"
|
||||
f"{t('chat_temperature')}: [cyan]{temperature}[/cyan] | {t('chat_max_tokens')}: [cyan]{max_tokens}[/cyan]\n\n"
|
||||
f"[dim]{t('chat_help_hint')}[/dim]",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
# Check for proxy environment variables
|
||||
proxy_vars = ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"]
|
||||
detected_proxies = {var: os.environ.get(var) for var in proxy_vars if os.environ.get(var)}
|
||||
|
||||
if detected_proxies:
|
||||
proxy_info = ", ".join(f"{k}={v}" for k, v in detected_proxies.items())
|
||||
console.print()
|
||||
print_warning(t("chat_proxy_detected"))
|
||||
console.print(f" [dim]{proxy_info}[/dim]")
|
||||
console.print()
|
||||
|
||||
use_proxy = Confirm.ask(t("chat_proxy_confirm"), default=False)
|
||||
|
||||
if not use_proxy:
|
||||
# Temporarily disable proxy for this connection
|
||||
for var in proxy_vars:
|
||||
if var in os.environ:
|
||||
del os.environ[var]
|
||||
print_info(t("chat_proxy_disabled"))
|
||||
console.print()
|
||||
|
||||
# Initialize OpenAI client
|
||||
try:
|
||||
client = OpenAI(
|
||||
base_url=base_url,
|
||||
api_key="EMPTY", # SGLang doesn't require API key
|
||||
)
|
||||
|
||||
# Test connection
|
||||
print_info(t("chat_connecting"))
|
||||
models = client.models.list()
|
||||
available_models = [m.id for m in models.data]
|
||||
|
||||
if not available_models:
|
||||
print_error(t("chat_no_models"))
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Select model
|
||||
if model:
|
||||
if model not in available_models:
|
||||
print_warning(t("chat_model_not_found", model=model, available=", ".join(available_models)))
|
||||
selected_model = available_models[0]
|
||||
else:
|
||||
selected_model = model
|
||||
else:
|
||||
selected_model = available_models[0]
|
||||
|
||||
print_success(t("chat_connected", model=selected_model))
|
||||
console.print()
|
||||
|
||||
# Load tokenizer for accurate token counting
|
||||
tokenizer = None
|
||||
try:
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
# selected_model is the model path
|
||||
tokenizer = AutoTokenizer.from_pretrained(selected_model, trust_remote_code=True)
|
||||
console.print(f"[dim]Loaded tokenizer from {selected_model}[/dim]")
|
||||
console.print()
|
||||
except Exception as e:
|
||||
console.print(f"[dim yellow]Warning: Could not load tokenizer, token counts will be estimated[/dim]")
|
||||
console.print()
|
||||
|
||||
except Exception as e:
|
||||
print_error(t("chat_connect_failed", error=str(e)))
|
||||
console.print()
|
||||
console.print(t("chat_server_not_running"))
|
||||
console.print(" kt run <model>")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Initialize conversation history
|
||||
messages = []
|
||||
|
||||
# Add system prompt if provided
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
|
||||
# Setup history file
|
||||
if save_history:
|
||||
if history_file is None:
|
||||
history_dir = settings.config_dir / "chat_history"
|
||||
history_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
history_file = history_dir / f"chat_{timestamp}.json"
|
||||
else:
|
||||
history_file = Path(history_file)
|
||||
history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Main chat loop
|
||||
try:
|
||||
while True:
|
||||
# Get user input - use console.input() for better CJK character support
|
||||
try:
|
||||
user_input = console.input(f"[bold green]{t('chat_user_prompt')}[/bold green]: ")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
console.print()
|
||||
print_info(t("chat_goodbye"))
|
||||
break
|
||||
|
||||
if not user_input.strip():
|
||||
continue
|
||||
|
||||
# Handle special commands
|
||||
if user_input.startswith("/"):
|
||||
if _handle_command(user_input, messages, temperature, max_tokens):
|
||||
continue
|
||||
else:
|
||||
break # Exit command
|
||||
|
||||
# Add user message to history
|
||||
messages.append({"role": "user", "content": user_input})
|
||||
|
||||
# Generate response
|
||||
console.print()
|
||||
console.print(f"[bold cyan]{t('chat_assistant_prompt')}[/bold cyan]")
|
||||
|
||||
try:
|
||||
if stream:
|
||||
# Streaming response
|
||||
response_content = _stream_response(
|
||||
client, selected_model, messages, temperature, max_tokens, tokenizer
|
||||
)
|
||||
else:
|
||||
# Non-streaming response
|
||||
response_content = _generate_response(
|
||||
client, selected_model, messages, temperature, max_tokens, tokenizer
|
||||
)
|
||||
|
||||
# Add assistant response to history
|
||||
messages.append({"role": "assistant", "content": response_content})
|
||||
|
||||
console.print()
|
||||
|
||||
except Exception as e:
|
||||
print_error(t("chat_generation_error", error=str(e)))
|
||||
# Remove the user message that caused the error
|
||||
messages.pop()
|
||||
continue
|
||||
|
||||
# Save history if enabled
|
||||
if save_history:
|
||||
_save_history(history_file, messages, selected_model)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print()
|
||||
console.print()
|
||||
print_info(t("chat_interrupted"))
|
||||
|
||||
# Final history save
|
||||
if save_history and messages:
|
||||
_save_history(history_file, messages, selected_model)
|
||||
console.print(f"[dim]{t('chat_history_saved', path=str(history_file))}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def _stream_response(
|
||||
client: "OpenAI",
|
||||
model: str,
|
||||
messages: list,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
tokenizer=None,
|
||||
) -> str:
|
||||
"""Generate streaming response and display in real-time."""
|
||||
import time
|
||||
|
||||
response_content = ""
|
||||
reasoning_content = ""
|
||||
|
||||
# Performance tracking
|
||||
first_token_time = None
|
||||
chunk_count = 0
|
||||
|
||||
try:
|
||||
# Start timing before sending request
|
||||
start_time = time.time()
|
||||
|
||||
stream = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
for chunk in stream:
|
||||
delta = chunk.choices[0].delta if chunk.choices else None
|
||||
if delta:
|
||||
reasoning_delta = getattr(delta, "reasoning_content", None)
|
||||
if reasoning_delta:
|
||||
if first_token_time is None:
|
||||
first_token_time = time.time()
|
||||
reasoning_content += reasoning_delta
|
||||
console.print(reasoning_delta, end="", style="dim")
|
||||
chunk_count += 1
|
||||
|
||||
if delta.content:
|
||||
if first_token_time is None:
|
||||
first_token_time = time.time()
|
||||
content = delta.content
|
||||
response_content += content
|
||||
console.print(content, end="")
|
||||
chunk_count += 1
|
||||
|
||||
console.print() # Newline after streaming
|
||||
|
||||
# Display performance metrics
|
||||
end_time = time.time()
|
||||
if first_token_time and chunk_count > 0:
|
||||
ttft = first_token_time - start_time
|
||||
total_time = end_time - start_time
|
||||
|
||||
# Calculate TPOT based on chunks
|
||||
if chunk_count > 1:
|
||||
generation_time = total_time - ttft
|
||||
tpot = generation_time / (chunk_count - 1)
|
||||
else:
|
||||
tpot = 0
|
||||
|
||||
# Calculate accurate token counts using tokenizer
|
||||
if tokenizer:
|
||||
input_tokens = _count_tokens_with_tokenizer(messages, tokenizer)
|
||||
output_tokens = _count_tokens_with_tokenizer(
|
||||
[{"role": "assistant", "content": response_content}], tokenizer
|
||||
)
|
||||
token_prefix = ""
|
||||
else:
|
||||
# Fallback to estimation
|
||||
input_tokens = _estimate_tokens(messages)
|
||||
output_tokens = _estimate_tokens([{"role": "assistant", "content": response_content}])
|
||||
token_prefix = "~"
|
||||
|
||||
# Build metrics display
|
||||
metrics = f"[dim]Total: {total_time*1000:.0f}ms | TTFT: {ttft*1000:.0f}ms"
|
||||
if tpot > 0:
|
||||
metrics += f" | TPOT: {tpot*1000:.1f}ms"
|
||||
metrics += f" | In: {token_prefix}{input_tokens} | Out: {token_prefix}{output_tokens}"
|
||||
metrics += "[/dim]"
|
||||
|
||||
console.print(metrics)
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Streaming error: {e}")
|
||||
|
||||
return response_content
|
||||
|
||||
|
||||
def _count_tokens_with_tokenizer(messages: list, tokenizer) -> int:
|
||||
"""Count tokens accurately using the model's tokenizer."""
|
||||
try:
|
||||
# Concatenate all message content
|
||||
text = ""
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
# Simple format: role + content
|
||||
text += f"{role}: {content}\n"
|
||||
|
||||
# Encode and count tokens - suppress any debug output from custom tokenizers
|
||||
import os
|
||||
import sys
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
|
||||
with open(os.devnull, "w") as devnull:
|
||||
with redirect_stdout(devnull), redirect_stderr(devnull):
|
||||
tokens = tokenizer.encode(text, add_special_tokens=True)
|
||||
return len(tokens)
|
||||
except Exception:
|
||||
# Fallback to estimation if tokenizer fails
|
||||
return _estimate_tokens(messages)
|
||||
|
||||
|
||||
def _estimate_tokens(messages: list) -> int:
|
||||
"""Estimate token count for messages (rough approximation)."""
|
||||
total_chars = 0
|
||||
for msg in messages:
|
||||
content = msg.get("content", "")
|
||||
total_chars += len(content)
|
||||
|
||||
# Rough estimation:
|
||||
# - English: ~4 chars per token
|
||||
# - Chinese: ~1.5 chars per token
|
||||
# Use 2.5 as average
|
||||
return max(1, int(total_chars / 2.5))
|
||||
|
||||
|
||||
def _generate_response(
|
||||
client: "OpenAI",
|
||||
model: str,
|
||||
messages: list,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
tokenizer=None,
|
||||
) -> str:
|
||||
"""Generate non-streaming response."""
|
||||
import time
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
end_time = time.time()
|
||||
total_time = end_time - start_time
|
||||
|
||||
content = response.choices[0].message.content
|
||||
|
||||
# Display as markdown
|
||||
md = Markdown(content)
|
||||
console.print(md)
|
||||
|
||||
# Calculate accurate token counts using tokenizer
|
||||
if tokenizer:
|
||||
input_tokens = _count_tokens_with_tokenizer(messages, tokenizer)
|
||||
output_tokens = _count_tokens_with_tokenizer([{"role": "assistant", "content": content}], tokenizer)
|
||||
token_prefix = ""
|
||||
else:
|
||||
# Fallback to API usage or estimation
|
||||
input_tokens = response.usage.prompt_tokens if response.usage else _estimate_tokens(messages)
|
||||
output_tokens = (
|
||||
response.usage.completion_tokens
|
||||
if response.usage
|
||||
else _estimate_tokens([{"role": "assistant", "content": content}])
|
||||
)
|
||||
token_prefix = "" if response.usage else "~"
|
||||
|
||||
# Display performance metrics
|
||||
console.print(
|
||||
f"[dim]Time: {total_time*1000:.0f}ms | "
|
||||
f"In: {token_prefix}{input_tokens} | Out: {token_prefix}{output_tokens}[/dim]"
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Generation error: {e}")
|
||||
|
||||
|
||||
def _handle_command(command: str, messages: list, temperature: float, max_tokens: int) -> bool:
|
||||
"""Handle special commands. Returns True to continue chat, False to exit."""
|
||||
cmd = command.lower().strip()
|
||||
|
||||
if cmd in ["/quit", "/exit", "/q"]:
|
||||
console.print()
|
||||
print_info(t("chat_goodbye"))
|
||||
return False
|
||||
|
||||
elif cmd in ["/help", "/h"]:
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold]{t('chat_help_title')}[/bold]\n\n{t('chat_help_content')}",
|
||||
title="Help",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
return True
|
||||
|
||||
elif cmd in ["/clear", "/c"]:
|
||||
messages.clear()
|
||||
console.print()
|
||||
print_success(t("chat_history_cleared"))
|
||||
console.print()
|
||||
return True
|
||||
|
||||
elif cmd in ["/history", "/hist"]:
|
||||
console.print()
|
||||
if not messages:
|
||||
print_info(t("chat_no_history"))
|
||||
else:
|
||||
console.print(
|
||||
Panel(
|
||||
_format_history(messages),
|
||||
title=t("chat_history_title", count=len(messages)),
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
return True
|
||||
|
||||
elif cmd in ["/info", "/i"]:
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
f"[bold]{t('chat_info_title')}[/bold]\n\n{t('chat_info_content', temperature=temperature, max_tokens=max_tokens, messages=len(messages))}",
|
||||
title="Info",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
return True
|
||||
|
||||
elif cmd in ["/retry", "/r"]:
|
||||
if len(messages) >= 2 and messages[-1]["role"] == "assistant":
|
||||
# Remove last assistant response
|
||||
messages.pop()
|
||||
print_info(t("chat_retrying"))
|
||||
console.print()
|
||||
else:
|
||||
print_warning(t("chat_no_retry"))
|
||||
console.print()
|
||||
return True
|
||||
|
||||
else:
|
||||
print_warning(t("chat_unknown_command", command=command))
|
||||
console.print(f"[dim]{t('chat_unknown_hint')}[/dim]")
|
||||
console.print()
|
||||
return True
|
||||
|
||||
|
||||
def _format_history(messages: list) -> str:
|
||||
"""Format conversation history for display."""
|
||||
lines = []
|
||||
for i, msg in enumerate(messages, 1):
|
||||
role = msg["role"].capitalize()
|
||||
content = msg["content"]
|
||||
|
||||
# Truncate long messages
|
||||
if len(content) > 200:
|
||||
content = content[:200] + "..."
|
||||
|
||||
lines.append(f"[bold]{i}. {role}:[/bold] {content}")
|
||||
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def _save_history(file_path: Path, messages: list, model: str) -> None:
|
||||
"""Save conversation history to file."""
|
||||
try:
|
||||
history_data = {
|
||||
"model": model,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(history_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
print_warning(f"Failed to save history: {e}")
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Config command for kt-cli.
|
||||
|
||||
Manages kt-cli configuration.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
import yaml
|
||||
from rich.syntax import Syntax
|
||||
|
||||
from kt_kernel.cli.config.settings import get_settings
|
||||
from kt_kernel.cli.i18n import t
|
||||
from kt_kernel.cli.utils.console import confirm, console, print_error, print_success
|
||||
|
||||
app = typer.Typer(help="Manage kt-cli configuration")
|
||||
|
||||
|
||||
@app.command(name="init")
|
||||
def init() -> None:
|
||||
"""Initialize or re-run the first-time setup wizard."""
|
||||
from kt_kernel.cli.main import _show_first_run_setup
|
||||
from kt_kernel.cli.config.settings import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
_show_first_run_setup(settings)
|
||||
|
||||
|
||||
@app.command(name="show")
|
||||
def show(
|
||||
key: Optional[str] = typer.Argument(None, help="Configuration key to show (e.g., server.port)"),
|
||||
) -> None:
|
||||
"""Show current configuration."""
|
||||
settings = get_settings()
|
||||
|
||||
if key:
|
||||
value = settings.get(key)
|
||||
if value is not None:
|
||||
if isinstance(value, (dict, list)):
|
||||
console.print(yaml.dump({key: value}, default_flow_style=False, allow_unicode=True))
|
||||
else:
|
||||
console.print(t("config_get_value", key=key, value=value))
|
||||
else:
|
||||
print_error(t("config_get_not_found", key=key))
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
console.print(f"\n[bold]{t('config_show_title')}[/bold]\n")
|
||||
console.print(f"[dim]{t('config_file_location', path=str(settings.config_path))}[/dim]\n")
|
||||
|
||||
config_yaml = yaml.dump(settings.get_all(), default_flow_style=False, allow_unicode=True)
|
||||
syntax = Syntax(config_yaml, "yaml", theme="monokai", line_numbers=False)
|
||||
console.print(syntax)
|
||||
|
||||
|
||||
@app.command(name="set")
|
||||
def set_config(
|
||||
key: str = typer.Argument(..., help="Configuration key (e.g., server.port)"),
|
||||
value: str = typer.Argument(..., help="Value to set"),
|
||||
) -> None:
|
||||
"""Set a configuration value."""
|
||||
settings = get_settings()
|
||||
|
||||
# Try to parse value as JSON/YAML for complex types
|
||||
parsed_value = _parse_value(value)
|
||||
|
||||
settings.set(key, parsed_value)
|
||||
print_success(t("config_set_success", key=key, value=parsed_value))
|
||||
|
||||
|
||||
@app.command(name="get")
|
||||
def get_config(
|
||||
key: str = typer.Argument(..., help="Configuration key (e.g., server.port)"),
|
||||
) -> None:
|
||||
"""Get a configuration value."""
|
||||
settings = get_settings()
|
||||
value = settings.get(key)
|
||||
|
||||
if value is not None:
|
||||
if isinstance(value, (dict, list)):
|
||||
console.print(yaml.dump(value, default_flow_style=False, allow_unicode=True))
|
||||
else:
|
||||
console.print(str(value))
|
||||
else:
|
||||
print_error(t("config_get_not_found", key=key))
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command(name="reset")
|
||||
def reset(
|
||||
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
||||
) -> None:
|
||||
"""Reset configuration to defaults."""
|
||||
if not yes:
|
||||
if not confirm(t("config_reset_confirm"), default=False):
|
||||
raise typer.Abort()
|
||||
|
||||
settings = get_settings()
|
||||
settings.reset()
|
||||
print_success(t("config_reset_success"))
|
||||
|
||||
|
||||
@app.command(name="path")
|
||||
def path() -> None:
|
||||
"""Show configuration file path."""
|
||||
settings = get_settings()
|
||||
console.print(str(settings.config_path))
|
||||
|
||||
|
||||
@app.command(name="model-path-list", deprecated=True, hidden=True)
|
||||
def model_path_list() -> None:
|
||||
"""[Deprecated] Use 'kt model path-list' instead."""
|
||||
console.print("[yellow]⚠ This command is deprecated. Use 'kt model path-list' instead.[/yellow]\n")
|
||||
import subprocess
|
||||
subprocess.run(["kt", "model", "path-list"])
|
||||
|
||||
|
||||
@app.command(name="model-path-add", deprecated=True, hidden=True)
|
||||
def model_path_add(
|
||||
path: str = typer.Argument(..., help="Path to add"),
|
||||
) -> None:
|
||||
"""[Deprecated] Use 'kt model path-add' instead."""
|
||||
console.print("[yellow]⚠ This command is deprecated. Use 'kt model path-add' instead.[/yellow]\n")
|
||||
import subprocess
|
||||
subprocess.run(["kt", "model", "path-add", path])
|
||||
|
||||
|
||||
@app.command(name="model-path-remove", deprecated=True, hidden=True)
|
||||
def model_path_remove(
|
||||
path: str = typer.Argument(..., help="Path to remove"),
|
||||
) -> None:
|
||||
"""[Deprecated] Use 'kt model path-remove' instead."""
|
||||
console.print("[yellow]⚠ This command is deprecated. Use 'kt model path-remove' instead.[/yellow]\n")
|
||||
import subprocess
|
||||
subprocess.run(["kt", "model", "path-remove", path])
|
||||
|
||||
|
||||
def _parse_value(value: str):
|
||||
"""Parse a string value into appropriate Python type."""
|
||||
# Try boolean
|
||||
if value.lower() in ("true", "yes", "on", "1"):
|
||||
return True
|
||||
if value.lower() in ("false", "no", "off", "0"):
|
||||
return False
|
||||
|
||||
# Try integer
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Try float
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Try YAML/JSON parsing for lists/dicts
|
||||
try:
|
||||
parsed = yaml.safe_load(value)
|
||||
if isinstance(parsed, (dict, list)):
|
||||
return parsed
|
||||
except yaml.YAMLError:
|
||||
pass
|
||||
|
||||
# Return as string
|
||||
return value
|
||||
@@ -0,0 +1,556 @@
|
||||
"""
|
||||
Doctor command for kt-cli.
|
||||
|
||||
Diagnoses environment issues and provides recommendations.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from rich.table import Table
|
||||
|
||||
from kt_kernel.cli.config.settings import get_settings
|
||||
from kt_kernel.cli.i18n import t
|
||||
from kt_kernel.cli.utils.console import console, print_error, print_info, print_success, print_warning
|
||||
from kt_kernel.cli.utils.environment import (
|
||||
check_docker,
|
||||
detect_available_ram_gb,
|
||||
detect_cpu_info,
|
||||
detect_cuda_version,
|
||||
detect_disk_space_gb,
|
||||
detect_env_managers,
|
||||
detect_gpus,
|
||||
detect_memory_info,
|
||||
detect_ram_gb,
|
||||
get_installed_package_version,
|
||||
)
|
||||
|
||||
|
||||
def _get_kt_kernel_info() -> dict:
|
||||
"""Get kt-kernel installation information."""
|
||||
info = {
|
||||
"installed": False,
|
||||
"version": None,
|
||||
"cpu_variant": None,
|
||||
"install_path": None,
|
||||
"available_variants": [],
|
||||
"extension_file": None,
|
||||
}
|
||||
|
||||
try:
|
||||
import kt_kernel
|
||||
|
||||
info["installed"] = True
|
||||
info["version"] = getattr(kt_kernel, "__version__", "unknown")
|
||||
info["cpu_variant"] = getattr(kt_kernel, "__cpu_variant__", "unknown")
|
||||
|
||||
# Get installation path
|
||||
info["install_path"] = os.path.dirname(kt_kernel.__file__)
|
||||
|
||||
# Find available .so files
|
||||
kt_kernel_dir = info["install_path"]
|
||||
so_files = glob.glob(os.path.join(kt_kernel_dir, "_kt_kernel_ext_*.so"))
|
||||
so_files.extend(glob.glob(os.path.join(kt_kernel_dir, "kt_kernel_ext*.so")))
|
||||
|
||||
# Parse variant names from filenames
|
||||
variants = set()
|
||||
for so_file in so_files:
|
||||
basename = os.path.basename(so_file)
|
||||
if "_kt_kernel_ext_" in basename:
|
||||
# Extract variant from _kt_kernel_ext_amx.cpython-311-x86_64-linux-gnu.so
|
||||
parts = basename.split("_")
|
||||
if len(parts) >= 4:
|
||||
variant = parts[3] # "amx" from "_kt_kernel_ext_amx..."
|
||||
if variant.startswith("avx"):
|
||||
# Normalize avx variants
|
||||
if variant in ["avx512", "avx512_bf16", "avx512_vbmi", "avx512_vnni", "avx512_base"]:
|
||||
variants.add("avx512")
|
||||
else:
|
||||
variants.add(variant)
|
||||
else:
|
||||
variants.add(variant)
|
||||
elif "kt_kernel_ext" in basename:
|
||||
variants.add("default")
|
||||
|
||||
info["available_variants"] = sorted(list(variants))
|
||||
|
||||
# Get current extension file
|
||||
if hasattr(kt_kernel, "kt_kernel_ext"):
|
||||
ext_module = kt_kernel.kt_kernel_ext
|
||||
info["extension_file"] = getattr(ext_module, "__file__", None)
|
||||
|
||||
except ImportError:
|
||||
info["installed"] = False
|
||||
except Exception as e:
|
||||
info["error"] = str(e)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def doctor(
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed diagnostics"),
|
||||
) -> None:
|
||||
"""Diagnose environment issues."""
|
||||
console.print(f"\n[bold]{t('doctor_title')}[/bold]\n")
|
||||
|
||||
issues_found = False
|
||||
checks = []
|
||||
|
||||
# 1. Python version
|
||||
python_version = platform.python_version()
|
||||
python_ok = _check_python_version(python_version)
|
||||
checks.append(
|
||||
{
|
||||
"name": t("doctor_check_python"),
|
||||
"status": "ok" if python_ok else "error",
|
||||
"value": python_version,
|
||||
"hint": "Python 3.10+ required" if not python_ok else None,
|
||||
}
|
||||
)
|
||||
if not python_ok:
|
||||
issues_found = True
|
||||
|
||||
# 2. CUDA availability
|
||||
cuda_version = detect_cuda_version()
|
||||
checks.append(
|
||||
{
|
||||
"name": t("doctor_check_cuda"),
|
||||
"status": "ok" if cuda_version else "warning",
|
||||
"value": cuda_version or t("version_cuda_not_found"),
|
||||
"hint": "CUDA is optional but recommended for GPU acceleration" if not cuda_version else None,
|
||||
}
|
||||
)
|
||||
|
||||
# 3. GPU detection
|
||||
gpus = detect_gpus()
|
||||
if gpus:
|
||||
gpu_names = ", ".join(g.name for g in gpus)
|
||||
total_vram = sum(g.vram_gb for g in gpus)
|
||||
checks.append(
|
||||
{
|
||||
"name": t("doctor_check_gpu"),
|
||||
"status": "ok",
|
||||
"value": t("doctor_gpu_found", count=len(gpus), names=gpu_names),
|
||||
"hint": f"Total VRAM: {total_vram}GB",
|
||||
}
|
||||
)
|
||||
else:
|
||||
checks.append(
|
||||
{
|
||||
"name": t("doctor_check_gpu"),
|
||||
"status": "warning",
|
||||
"value": t("doctor_gpu_not_found"),
|
||||
"hint": "GPU recommended for best performance",
|
||||
}
|
||||
)
|
||||
|
||||
# 4. CPU information
|
||||
cpu_info = detect_cpu_info()
|
||||
checks.append(
|
||||
{
|
||||
"name": t("doctor_check_cpu"),
|
||||
"status": "ok",
|
||||
"value": t("doctor_cpu_info", name=cpu_info.name, cores=cpu_info.cores, threads=cpu_info.threads),
|
||||
"hint": None,
|
||||
}
|
||||
)
|
||||
|
||||
# 5. CPU instruction sets (critical for kt-kernel)
|
||||
isa_list = cpu_info.instruction_sets
|
||||
# Check for recommended instruction sets
|
||||
recommended_isa = {"AVX2", "AVX512F", "AMX-INT8"}
|
||||
has_recommended = bool(set(isa_list) & recommended_isa)
|
||||
has_avx2 = "AVX2" in isa_list
|
||||
has_avx512 = any(isa.startswith("AVX512") for isa in isa_list)
|
||||
has_amx = any(isa.startswith("AMX") for isa in isa_list)
|
||||
|
||||
# Determine status and build display string
|
||||
if has_amx:
|
||||
isa_status = "ok"
|
||||
isa_hint = "AMX available - best performance for INT4/INT8"
|
||||
elif has_avx512:
|
||||
isa_status = "ok"
|
||||
isa_hint = "AVX512 available - good performance"
|
||||
elif has_avx2:
|
||||
isa_status = "warning"
|
||||
isa_hint = "AVX2 only - consider upgrading CPU for better performance"
|
||||
else:
|
||||
isa_status = "error"
|
||||
isa_hint = "AVX2 required for kt-kernel"
|
||||
|
||||
# Show top instruction sets (prioritize important ones)
|
||||
display_isa = isa_list[:8] if len(isa_list) > 8 else isa_list
|
||||
isa_display = ", ".join(display_isa)
|
||||
if len(isa_list) > 8:
|
||||
isa_display += f" (+{len(isa_list) - 8} more)"
|
||||
|
||||
checks.append(
|
||||
{
|
||||
"name": t("doctor_check_cpu_isa"),
|
||||
"status": isa_status,
|
||||
"value": isa_display if isa_display else "None detected",
|
||||
"hint": isa_hint,
|
||||
}
|
||||
)
|
||||
|
||||
# 6. NUMA topology
|
||||
numa_detail = []
|
||||
for node, cpus in sorted(cpu_info.numa_info.items()):
|
||||
if len(cpus) > 6:
|
||||
cpu_str = f"{cpus[0]}-{cpus[-1]}"
|
||||
else:
|
||||
cpu_str = ",".join(str(c) for c in cpus)
|
||||
numa_detail.append(f"{node}: {cpu_str}")
|
||||
|
||||
numa_value = t("doctor_numa_info", nodes=cpu_info.numa_nodes)
|
||||
if verbose and numa_detail:
|
||||
numa_value += " (" + "; ".join(numa_detail) + ")"
|
||||
|
||||
checks.append(
|
||||
{
|
||||
"name": t("doctor_check_numa"),
|
||||
"status": "ok",
|
||||
"value": numa_value,
|
||||
"hint": f"{cpu_info.threads // cpu_info.numa_nodes} threads per node" if cpu_info.numa_nodes > 1 else None,
|
||||
}
|
||||
)
|
||||
|
||||
# 6b. kt-kernel installation check
|
||||
kt_info = _get_kt_kernel_info()
|
||||
|
||||
if kt_info["installed"]:
|
||||
# Build display string for kt-kernel
|
||||
variant = kt_info["cpu_variant"]
|
||||
version = kt_info["version"]
|
||||
available_variants = kt_info["available_variants"]
|
||||
|
||||
# Determine status based on CPU variant
|
||||
if variant == "amx":
|
||||
kt_status = "ok"
|
||||
kt_hint = "AMX variant loaded - optimal performance"
|
||||
elif variant.startswith("avx512"):
|
||||
kt_status = "ok"
|
||||
kt_hint = "AVX512 variant loaded - good performance"
|
||||
elif variant == "avx2":
|
||||
kt_status = "warning"
|
||||
kt_hint = "AVX2 variant - consider upgrading CPU for AMX/AVX512"
|
||||
else:
|
||||
kt_status = "warning"
|
||||
kt_hint = f"Unknown variant: {variant}"
|
||||
|
||||
kt_value = f"v{version} ({variant.upper()})"
|
||||
if verbose and available_variants:
|
||||
kt_value += f" [dim] - available: {', '.join(available_variants)}[/dim]"
|
||||
|
||||
checks.append(
|
||||
{
|
||||
"name": "kt-kernel",
|
||||
"status": kt_status,
|
||||
"value": kt_value,
|
||||
"hint": kt_hint,
|
||||
}
|
||||
)
|
||||
|
||||
# Show extension file path in verbose mode
|
||||
if verbose and kt_info.get("extension_file"):
|
||||
ext_file = os.path.basename(kt_info["extension_file"])
|
||||
checks.append(
|
||||
{
|
||||
"name": " └─ Extension",
|
||||
"status": "ok",
|
||||
"value": ext_file,
|
||||
"hint": None,
|
||||
}
|
||||
)
|
||||
|
||||
# Show installation path in verbose mode
|
||||
if verbose and kt_info.get("install_path"):
|
||||
checks.append(
|
||||
{
|
||||
"name": " └─ Path",
|
||||
"status": "ok",
|
||||
"value": kt_info["install_path"],
|
||||
"hint": None,
|
||||
}
|
||||
)
|
||||
else:
|
||||
error_msg = kt_info.get("error", "Not installed")
|
||||
checks.append(
|
||||
{
|
||||
"name": "kt-kernel",
|
||||
"status": "error",
|
||||
"value": error_msg,
|
||||
"hint": "kt-kernel is required - run: pip install kt-kernel",
|
||||
}
|
||||
)
|
||||
issues_found = True
|
||||
|
||||
# 7. System memory (with frequency if available)
|
||||
mem_info = detect_memory_info()
|
||||
if mem_info.frequency_mhz and mem_info.type:
|
||||
mem_value = t(
|
||||
"doctor_memory_freq",
|
||||
available=f"{mem_info.available_gb}GB",
|
||||
total=f"{mem_info.total_gb}GB",
|
||||
freq=mem_info.frequency_mhz,
|
||||
type=mem_info.type,
|
||||
)
|
||||
else:
|
||||
mem_value = t("doctor_memory_info", available=f"{mem_info.available_gb}GB", total=f"{mem_info.total_gb}GB")
|
||||
|
||||
ram_ok = mem_info.total_gb >= 32
|
||||
checks.append(
|
||||
{
|
||||
"name": t("doctor_check_memory"),
|
||||
"status": "ok" if ram_ok else "warning",
|
||||
"value": mem_value,
|
||||
"hint": "32GB+ RAM recommended for large models" if not ram_ok else None,
|
||||
}
|
||||
)
|
||||
|
||||
# 8. Disk space - check all model paths
|
||||
settings = get_settings()
|
||||
model_paths = settings.get_model_paths()
|
||||
|
||||
# Check all configured model paths
|
||||
for i, disk_path in enumerate(model_paths):
|
||||
available_disk, total_disk = detect_disk_space_gb(str(disk_path))
|
||||
disk_ok = available_disk >= 100
|
||||
|
||||
# For multiple paths, add index to name
|
||||
path_label = f"Model Path {i+1}" if len(model_paths) > 1 else t("doctor_check_disk")
|
||||
|
||||
checks.append(
|
||||
{
|
||||
"name": path_label,
|
||||
"status": "ok" if disk_ok else "warning",
|
||||
"value": t("doctor_disk_info", available=f"{available_disk}GB", path=str(disk_path)),
|
||||
"hint": "100GB+ free space recommended for model storage" if not disk_ok else None,
|
||||
}
|
||||
)
|
||||
|
||||
# 6. Required packages
|
||||
packages = [
|
||||
("kt-kernel", ">=0.4.0", False), # name, version_req, required
|
||||
("sglang", ">=0.4.0", False),
|
||||
("torch", ">=2.4.0", True),
|
||||
("transformers", ">=4.45.0", True),
|
||||
]
|
||||
|
||||
package_issues = []
|
||||
for pkg_name, version_req, required in packages:
|
||||
version = get_installed_package_version(pkg_name)
|
||||
if version:
|
||||
package_issues.append((pkg_name, version, "ok"))
|
||||
elif required:
|
||||
package_issues.append((pkg_name, t("version_not_installed"), "error"))
|
||||
issues_found = True
|
||||
else:
|
||||
package_issues.append((pkg_name, t("version_not_installed"), "warning"))
|
||||
|
||||
if verbose:
|
||||
checks.append(
|
||||
{
|
||||
"name": t("doctor_check_packages"),
|
||||
"status": "ok" if not any(p[2] == "error" for p in package_issues) else "error",
|
||||
"value": f"{sum(1 for p in package_issues if p[2] == 'ok')}/{len(package_issues)} installed",
|
||||
"packages": package_issues,
|
||||
}
|
||||
)
|
||||
|
||||
# 7. SGLang installation source check
|
||||
from kt_kernel.cli.utils.sglang_checker import check_sglang_installation, check_sglang_kt_kernel_support
|
||||
|
||||
sglang_info = check_sglang_installation()
|
||||
|
||||
if sglang_info["installed"]:
|
||||
if sglang_info.get("is_kvcache_fork"):
|
||||
# Package name is sglang-kt — this is definitively the kvcache-ai fork
|
||||
if sglang_info["from_source"] and sglang_info["git_info"]:
|
||||
git_remote = sglang_info["git_info"].get("remote", "unknown")
|
||||
git_branch = sglang_info["git_info"].get("branch", "unknown")
|
||||
sglang_source_value = f"sglang-kt (Source: {git_remote}, branch: {git_branch})"
|
||||
elif sglang_info["editable"]:
|
||||
sglang_source_value = "sglang-kt (editable)"
|
||||
else:
|
||||
sglang_source_value = "sglang-kt"
|
||||
sglang_source_status = "ok"
|
||||
sglang_source_hint = None
|
||||
elif sglang_info["from_source"]:
|
||||
if sglang_info["git_info"]:
|
||||
git_remote = sglang_info["git_info"].get("remote", "unknown")
|
||||
git_branch = sglang_info["git_info"].get("branch", "unknown")
|
||||
sglang_source_value = f"Source (GitHub: {git_remote}, branch: {git_branch})"
|
||||
sglang_source_status = "ok"
|
||||
sglang_source_hint = None
|
||||
else:
|
||||
sglang_source_value = "Source (editable)"
|
||||
sglang_source_status = "ok"
|
||||
sglang_source_hint = None
|
||||
else:
|
||||
sglang_source_value = "PyPI sglang (not kvcache-ai fork)"
|
||||
sglang_source_status = "warning"
|
||||
sglang_source_hint = t("sglang_pypi_hint")
|
||||
else:
|
||||
sglang_source_value = "Not installed"
|
||||
sglang_source_status = "warning"
|
||||
sglang_source_hint = t("sglang_install_hint")
|
||||
|
||||
checks.append(
|
||||
{
|
||||
"name": "SGLang Source",
|
||||
"status": sglang_source_status,
|
||||
"value": sglang_source_value,
|
||||
"hint": sglang_source_hint,
|
||||
}
|
||||
)
|
||||
|
||||
# 7b. SGLang kt-kernel support check (only if SGLang is installed)
|
||||
kt_kernel_support = {"supported": True} # Default to True if not checked
|
||||
if sglang_info["installed"]:
|
||||
# Use cache=False to force re-check in doctor, but silent=True since we show in table
|
||||
kt_kernel_support = check_sglang_kt_kernel_support(use_cache=False, silent=True)
|
||||
|
||||
if kt_kernel_support["supported"]:
|
||||
kt_kernel_value = t("sglang_kt_kernel_supported")
|
||||
kt_kernel_status = "ok"
|
||||
kt_kernel_hint = None
|
||||
else:
|
||||
kt_kernel_value = t("sglang_kt_kernel_not_supported")
|
||||
kt_kernel_status = "error"
|
||||
kt_kernel_hint = "Reinstall SGLang: pip uninstall sglang -y && pip install sglang-kt (or run ./install.sh from ktransformers root)"
|
||||
issues_found = True
|
||||
|
||||
checks.append(
|
||||
{
|
||||
"name": "SGLang kt-kernel",
|
||||
"status": kt_kernel_status,
|
||||
"value": kt_kernel_value,
|
||||
"hint": kt_kernel_hint,
|
||||
}
|
||||
)
|
||||
|
||||
# 8. Potentially conflicting environment variables
|
||||
# Only surface a row when the variable is actually present; no noise otherwise.
|
||||
dsv4_submode = os.environ.get("SGLANG_DSV4_2604_SUBMODE")
|
||||
if dsv4_submode:
|
||||
checks.append(
|
||||
{
|
||||
"name": "Env: SGLANG_DSV4_2604_SUBMODE",
|
||||
"status": "warning" if dsv4_submode == "2604B" else "ok",
|
||||
"value": dsv4_submode,
|
||||
"hint": (
|
||||
"Intended for MXFP4 launches only. "
|
||||
"Causes a startup crash when kt-method is not MXFP4. Unset it if unused."
|
||||
if dsv4_submode == "2604B"
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# 9. Environment managers
|
||||
env_managers = detect_env_managers()
|
||||
docker = check_docker()
|
||||
env_list = [f"{m.name} {m.version}" for m in env_managers]
|
||||
if docker:
|
||||
env_list.append(f"docker {docker.version}")
|
||||
|
||||
checks.append(
|
||||
{
|
||||
"name": "Environment Managers",
|
||||
"status": "ok" if env_list else "warning",
|
||||
"value": ", ".join(env_list) if env_list else "None found",
|
||||
"hint": "conda or docker recommended for installation" if not env_list else None,
|
||||
}
|
||||
)
|
||||
|
||||
# Display results
|
||||
_display_results(checks, verbose)
|
||||
|
||||
# Show SGLang installation instructions if not installed
|
||||
if not sglang_info["installed"]:
|
||||
from kt_kernel.cli.utils.sglang_checker import print_sglang_install_instructions
|
||||
|
||||
console.print()
|
||||
print_sglang_install_instructions()
|
||||
# Show kt-kernel installation instructions if SGLang is installed but doesn't support kt-kernel
|
||||
elif sglang_info["installed"] and not kt_kernel_support.get("supported", True):
|
||||
from kt_kernel.cli.utils.sglang_checker import print_sglang_kt_kernel_instructions
|
||||
|
||||
console.print()
|
||||
print_sglang_kt_kernel_instructions()
|
||||
|
||||
# Summary
|
||||
console.print()
|
||||
if issues_found:
|
||||
print_warning(t("doctor_has_issues"))
|
||||
else:
|
||||
print_success(t("doctor_all_ok"))
|
||||
console.print()
|
||||
|
||||
|
||||
def _check_python_version(version: str) -> bool:
|
||||
"""Check if Python version meets requirements."""
|
||||
parts = version.split(".")
|
||||
try:
|
||||
major, minor = int(parts[0]), int(parts[1])
|
||||
return major >= 3 and minor >= 10
|
||||
except (IndexError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _display_results(checks: list[dict], verbose: bool) -> None:
|
||||
"""Display diagnostic results."""
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("Check", style="bold")
|
||||
table.add_column("Status", width=8)
|
||||
table.add_column("Value")
|
||||
if verbose:
|
||||
table.add_column("Notes", style="dim")
|
||||
|
||||
for check in checks:
|
||||
status = check["status"]
|
||||
if status == "ok":
|
||||
status_str = f"[green]{t('doctor_status_ok')}[/green]"
|
||||
elif status == "warning":
|
||||
status_str = f"[yellow]{t('doctor_status_warning')}[/yellow]"
|
||||
else:
|
||||
status_str = f"[red]{t('doctor_status_error')}[/red]"
|
||||
|
||||
if verbose:
|
||||
table.add_row(
|
||||
check["name"],
|
||||
status_str,
|
||||
check["value"],
|
||||
check.get("hint", ""),
|
||||
)
|
||||
else:
|
||||
table.add_row(
|
||||
check["name"],
|
||||
status_str,
|
||||
check["value"],
|
||||
)
|
||||
|
||||
# Show package details if verbose
|
||||
if verbose and "packages" in check:
|
||||
for pkg_name, pkg_version, pkg_status in check["packages"]:
|
||||
if pkg_status == "ok":
|
||||
pkg_status_str = "[green]✓[/green]"
|
||||
elif pkg_status == "warning":
|
||||
pkg_status_str = "[yellow]○[/yellow]"
|
||||
else:
|
||||
pkg_status_str = "[red]✗[/red]"
|
||||
|
||||
table.add_row(
|
||||
f" └─ {pkg_name}",
|
||||
pkg_status_str,
|
||||
pkg_version,
|
||||
"",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,530 @@
|
||||
"""
|
||||
Quant command for kt-cli.
|
||||
|
||||
Quantizes model weights for CPU inference.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from kt_kernel.cli.config.settings import get_settings
|
||||
from kt_kernel.cli.i18n import t
|
||||
from kt_kernel.cli.utils.console import (
|
||||
confirm,
|
||||
console,
|
||||
create_progress,
|
||||
print_error,
|
||||
print_info,
|
||||
print_step,
|
||||
print_success,
|
||||
print_warning,
|
||||
)
|
||||
from kt_kernel.cli.utils.environment import detect_cpu_info
|
||||
|
||||
|
||||
class QuantMethod(str, Enum):
|
||||
"""Quantization method."""
|
||||
|
||||
INT4 = "int4"
|
||||
INT8 = "int8"
|
||||
|
||||
|
||||
def quant(
|
||||
model: Optional[str] = typer.Argument(
|
||||
None,
|
||||
help="Model name or path to quantize",
|
||||
),
|
||||
method: Optional[QuantMethod] = typer.Option(
|
||||
None,
|
||||
"--method",
|
||||
"-m",
|
||||
help="Quantization method",
|
||||
),
|
||||
output: Optional[Path] = typer.Option(
|
||||
None,
|
||||
"--output",
|
||||
"-o",
|
||||
help="Output path for quantized weights",
|
||||
),
|
||||
input_type: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--input-type",
|
||||
"-i",
|
||||
help="Input weight type (fp8, fp16, bf16)",
|
||||
),
|
||||
cpu_threads: Optional[int] = typer.Option(
|
||||
None,
|
||||
"--cpu-threads",
|
||||
help="Number of CPU threads for quantization",
|
||||
),
|
||||
numa_nodes: Optional[int] = typer.Option(
|
||||
None,
|
||||
"--numa-nodes",
|
||||
help="Number of NUMA nodes",
|
||||
),
|
||||
no_merge: bool = typer.Option(
|
||||
False,
|
||||
"--no-merge",
|
||||
help="Don't merge safetensor files",
|
||||
),
|
||||
gpu: bool = typer.Option(
|
||||
False,
|
||||
"--gpu",
|
||||
help="Use GPU for conversion (faster)",
|
||||
),
|
||||
yes: bool = typer.Option(
|
||||
False,
|
||||
"--yes",
|
||||
"-y",
|
||||
help="Skip confirmation prompts",
|
||||
),
|
||||
) -> None:
|
||||
"""Quantize model weights for CPU inference.
|
||||
|
||||
If no model is specified, interactive mode will be activated.
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# Check if we should use interactive mode
|
||||
# Interactive mode triggers when: no model, or missing critical parameters
|
||||
needs_interactive = model is None or method is None or cpu_threads is None or numa_nodes is None
|
||||
is_interactive = False
|
||||
|
||||
if needs_interactive and sys.stdin.isatty():
|
||||
# Use interactive configuration (includes verification in Step 1.5)
|
||||
from kt_kernel.cli.utils.quant_interactive import interactive_quant_config
|
||||
|
||||
console.print()
|
||||
console.print(f"[bold cyan]═══ {t('quant_interactive_title')} ═══[/bold cyan]")
|
||||
console.print()
|
||||
console.print(f"[yellow]{t('quant_new_model_notice')}[/yellow]")
|
||||
console.print()
|
||||
|
||||
config = interactive_quant_config()
|
||||
if config is None:
|
||||
# User cancelled
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Extract configuration
|
||||
model_obj = config["model"]
|
||||
model = model_obj.id
|
||||
input_path = Path(model_obj.path)
|
||||
method = QuantMethod(config["method"])
|
||||
input_type = config["input_type"]
|
||||
cpu_threads = config["cpu_threads"]
|
||||
numa_nodes = config["numa_nodes"]
|
||||
output = config["output_path"]
|
||||
gpu = config["use_gpu"]
|
||||
is_interactive = True
|
||||
|
||||
console.print()
|
||||
print_success(t("quant_config_complete"))
|
||||
console.print()
|
||||
else:
|
||||
# Non-interactive mode - require model parameter
|
||||
if model is None:
|
||||
print_error("Model argument is required in non-interactive mode")
|
||||
console.print()
|
||||
console.print("Usage: kt quant <model>")
|
||||
console.print(" Or: kt quant (for interactive mode)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Set defaults for optional parameters
|
||||
method = method or QuantMethod.INT4
|
||||
input_type = input_type or "fp8"
|
||||
|
||||
console.print()
|
||||
|
||||
# Resolve input path
|
||||
input_path = _resolve_input_path(model, settings)
|
||||
if input_path is None:
|
||||
print_error(t("quant_input_not_found", path=model))
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Pre-quantization verification (only in non-interactive mode)
|
||||
# Interactive mode already did verification in interactive_quant_config()
|
||||
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
|
||||
from kt_kernel.cli.utils.model_verifier import pre_operation_verification
|
||||
|
||||
user_registry = UserModelRegistry()
|
||||
user_model_obj = user_registry.find_by_path(str(input_path))
|
||||
|
||||
if user_model_obj and user_model_obj.format == "safetensors":
|
||||
pre_operation_verification(user_model_obj, user_registry, operation_name="quantizing")
|
||||
|
||||
# Get user model info for both modes (needed later for registering quantized model)
|
||||
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
|
||||
|
||||
user_registry = UserModelRegistry()
|
||||
user_model_obj = user_registry.find_by_path(str(input_path))
|
||||
|
||||
# Validate that it's a MoE model (not AMX or GGUF)
|
||||
from kt_kernel.cli.commands.model import is_amx_weights
|
||||
|
||||
# Check if it's AMX (already quantized)
|
||||
is_amx, _ = is_amx_weights(str(input_path))
|
||||
if is_amx:
|
||||
print_error("Cannot quantize AMX models (already quantized)")
|
||||
console.print()
|
||||
console.print(f" The model at {input_path} is already in AMX format.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Check if it's a MoE model
|
||||
from kt_kernel.cli.utils.analyze_moe_model import analyze_moe_model
|
||||
|
||||
moe_result = None # Store for later use when registering quantized model
|
||||
try:
|
||||
moe_result = analyze_moe_model(str(input_path), use_cache=True)
|
||||
if not moe_result or not moe_result.get("is_moe"):
|
||||
print_error("Only MoE models can be quantized to AMX format")
|
||||
console.print()
|
||||
console.print(f" The model at {input_path} is not a MoE model.")
|
||||
console.print(" AMX quantization is designed for MoE models (e.g., DeepSeek-V3).")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
print_warning(f"Could not detect MoE information: {e}")
|
||||
console.print()
|
||||
if not yes:
|
||||
if not confirm("Continue quantization anyway?", default=False):
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Detect CPU configuration and resolve output path (only needed in non-interactive mode)
|
||||
if not is_interactive:
|
||||
print_info(t("quant_input_path", path=str(input_path)))
|
||||
|
||||
# Detect CPU configuration (needed for output path)
|
||||
cpu = detect_cpu_info()
|
||||
final_cpu_threads = cpu_threads or cpu.cores
|
||||
final_numa_nodes = numa_nodes or cpu.numa_nodes
|
||||
|
||||
# Resolve output path
|
||||
if output is None:
|
||||
# Priority: paths.weights > paths.models[0] > model's parent directory
|
||||
weights_dir = settings.weights_dir
|
||||
|
||||
if weights_dir and weights_dir.exists():
|
||||
# Use configured weights directory (highest priority)
|
||||
output = weights_dir / f"{input_path.name}-AMX{method.value.upper()}-NUMA{final_numa_nodes}"
|
||||
else:
|
||||
# Use first model storage path
|
||||
model_paths = settings.get_model_paths()
|
||||
if model_paths and model_paths[0].exists():
|
||||
output = model_paths[0] / f"{input_path.name}-AMX{method.value.upper()}-NUMA{final_numa_nodes}"
|
||||
else:
|
||||
# Fallback to model's parent directory
|
||||
output = input_path.parent / f"{input_path.name}-AMX{method.value.upper()}-NUMA{final_numa_nodes}"
|
||||
|
||||
print_info(t("quant_output_path", path=str(output)))
|
||||
print_info(t("quant_method", method=method.value.upper()))
|
||||
print_info(t("quant_cpu_threads", threads=final_cpu_threads))
|
||||
print_info(t("quant_numa_nodes", nodes=final_numa_nodes))
|
||||
|
||||
# Calculate space requirements
|
||||
console.print()
|
||||
console.print(f"[bold cyan]{t('quant_disk_analysis')}[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
# Calculate source model size
|
||||
try:
|
||||
total_bytes = sum(f.stat().st_size for f in input_path.glob("*.safetensors") if f.is_file())
|
||||
source_size_gb = total_bytes / (1024**3)
|
||||
except Exception:
|
||||
source_size_gb = 0.0
|
||||
|
||||
# Estimate quantized size
|
||||
input_bits = {"fp8": 8, "fp16": 16, "bf16": 16}
|
||||
quant_bits = {"int4": 4, "int8": 8}
|
||||
input_bit = input_bits.get(input_type, 16)
|
||||
quant_bit = quant_bits.get(method.value, 4)
|
||||
ratio = quant_bit / input_bit
|
||||
estimated_size_gb = source_size_gb * ratio
|
||||
|
||||
# Check available space
|
||||
import shutil
|
||||
|
||||
try:
|
||||
check_path = output.parent if not output.exists() else output
|
||||
while not check_path.exists() and check_path != check_path.parent:
|
||||
check_path = check_path.parent
|
||||
stat = shutil.disk_usage(check_path)
|
||||
available_gb = stat.free / (1024**3)
|
||||
except Exception:
|
||||
available_gb = 0.0
|
||||
|
||||
is_sufficient = available_gb >= (estimated_size_gb * 1.2)
|
||||
|
||||
console.print(f" {t('quant_source_size'):<26} {source_size_gb:.2f} GB")
|
||||
console.print(f" {t('quant_estimated_size'):<26} {estimated_size_gb:.2f} GB")
|
||||
console.print(f" {t('quant_available_space'):<26} {available_gb:.2f} GB")
|
||||
console.print()
|
||||
|
||||
if not is_sufficient:
|
||||
required_with_buffer = estimated_size_gb * 1.2
|
||||
print_warning(t("quant_insufficient_space"))
|
||||
console.print()
|
||||
console.print(f" {t('quant_required_space'):<26} {required_with_buffer:.2f} GB")
|
||||
console.print(f" {t('quant_available_space'):<26} {available_gb:.2f} GB")
|
||||
console.print(f" {t('quant_shortage'):<26} {required_with_buffer - available_gb:.2f} GB")
|
||||
console.print()
|
||||
console.print(f" {t('quant_may_fail')}")
|
||||
console.print()
|
||||
|
||||
if not yes:
|
||||
if not confirm(t("quant_continue_anyway"), default=False):
|
||||
raise typer.Abort()
|
||||
console.print()
|
||||
|
||||
# Check if output exists and generate unique name
|
||||
if output.exists():
|
||||
print_warning(t("quant_output_exists", path=str(output)))
|
||||
console.print()
|
||||
|
||||
# Generate unique name by adding suffix
|
||||
original_name = output.name
|
||||
parent_dir = output.parent
|
||||
counter = 2
|
||||
|
||||
while output.exists():
|
||||
new_name = f"{original_name}-{counter}"
|
||||
output = parent_dir / new_name
|
||||
counter += 1
|
||||
|
||||
print_success(t("quant_using_unique", path=str(output)))
|
||||
console.print()
|
||||
|
||||
# Confirm (only show if not using --yes flag)
|
||||
if not yes:
|
||||
console.print()
|
||||
print_warning(t("quant_time_warning"))
|
||||
console.print()
|
||||
|
||||
if not confirm(t("prompt_continue")):
|
||||
raise typer.Abort()
|
||||
else:
|
||||
# Interactive mode: cpu_threads and numa_nodes already set
|
||||
final_cpu_threads = cpu_threads
|
||||
final_numa_nodes = numa_nodes
|
||||
|
||||
# Find conversion script
|
||||
kt_kernel_path = _find_kt_kernel_path()
|
||||
if kt_kernel_path is None:
|
||||
print_error("kt-kernel not found. Install with: kt install inference")
|
||||
raise typer.Exit(1)
|
||||
|
||||
script_path = kt_kernel_path / "scripts" / "convert_cpu_weights.py"
|
||||
if not script_path.exists():
|
||||
print_error(f"Conversion script not found: {script_path}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Build command
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(script_path),
|
||||
"--input-path",
|
||||
str(input_path),
|
||||
"--input-type",
|
||||
input_type,
|
||||
"--output",
|
||||
str(output),
|
||||
"--quant-method",
|
||||
method.value,
|
||||
"--cpuinfer-threads",
|
||||
str(final_cpu_threads),
|
||||
"--threadpool-count",
|
||||
str(final_numa_nodes),
|
||||
]
|
||||
|
||||
if no_merge:
|
||||
cmd.append("--no-merge-safetensor")
|
||||
|
||||
if gpu:
|
||||
cmd.append("--gpu")
|
||||
|
||||
# Run quantization
|
||||
console.print()
|
||||
print_step(t("quant_starting"))
|
||||
console.print()
|
||||
console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
|
||||
console.print()
|
||||
console.print("[dim]" + "=" * 80 + "[/dim]")
|
||||
console.print()
|
||||
|
||||
try:
|
||||
# Run with real-time stdout/stderr output
|
||||
import os
|
||||
import time
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PYTHONUNBUFFERED"] = "1" # Disable Python output buffering
|
||||
|
||||
# Record start time
|
||||
start_time = time.time()
|
||||
|
||||
process = subprocess.run(
|
||||
cmd,
|
||||
stdout=None, # Inherit parent's stdout (real-time output)
|
||||
stderr=None, # Inherit parent's stderr (real-time output)
|
||||
env=env,
|
||||
)
|
||||
|
||||
# Calculate elapsed time
|
||||
elapsed_time = time.time() - start_time
|
||||
hours = int(elapsed_time // 3600)
|
||||
minutes = int((elapsed_time % 3600) // 60)
|
||||
seconds = int(elapsed_time % 60)
|
||||
|
||||
console.print()
|
||||
console.print("[dim]" + "=" * 80 + "[/dim]")
|
||||
console.print()
|
||||
|
||||
if process.returncode == 0:
|
||||
print_success(t("quant_complete"))
|
||||
console.print()
|
||||
|
||||
# Display elapsed time
|
||||
if hours > 0:
|
||||
time_str = f"{hours}h {minutes}m {seconds}s"
|
||||
elif minutes > 0:
|
||||
time_str = f"{minutes}m {seconds}s"
|
||||
else:
|
||||
time_str = f"{seconds}s"
|
||||
console.print(f" [cyan]{t('quant_time_elapsed')} {time_str}[/cyan]")
|
||||
console.print()
|
||||
console.print(f" Quantized weights saved to: {output}")
|
||||
console.print()
|
||||
|
||||
# Auto-register the quantized model
|
||||
try:
|
||||
from kt_kernel.cli.utils.user_model_registry import UserModel
|
||||
|
||||
# Generate model name from output path
|
||||
base_name = output.name
|
||||
suggested_name = user_registry.suggest_name(base_name)
|
||||
|
||||
# Determine MoE information and source model name
|
||||
if user_model_obj:
|
||||
is_moe_val = user_model_obj.is_moe
|
||||
num_experts = user_model_obj.moe_num_experts
|
||||
num_active = user_model_obj.moe_num_experts_per_tok
|
||||
repo_type_val = user_model_obj.repo_type
|
||||
repo_id_val = user_model_obj.repo_id
|
||||
source_model_name = user_model_obj.name # Store source model name
|
||||
elif moe_result:
|
||||
is_moe_val = moe_result.get("is_moe", True)
|
||||
num_experts = moe_result.get("num_experts")
|
||||
num_active = moe_result.get("num_experts_per_tok")
|
||||
repo_type_val = None
|
||||
repo_id_val = None
|
||||
source_model_name = input_path.name # Use folder name as fallback
|
||||
else:
|
||||
is_moe_val = None
|
||||
num_experts = None
|
||||
num_active = None
|
||||
repo_type_val = None
|
||||
repo_id_val = None
|
||||
source_model_name = input_path.name # Use folder name as fallback
|
||||
|
||||
# Create new model entry (AMX format uses "safetensors" format, detected by is_amx_weights())
|
||||
new_model = UserModel(
|
||||
name=suggested_name,
|
||||
path=str(output),
|
||||
format="safetensors", # AMX files are safetensors format
|
||||
repo_type=repo_type_val,
|
||||
repo_id=repo_id_val,
|
||||
sha256_status="not_checked", # AMX weights don't need verification
|
||||
# Inherit MoE information from source model
|
||||
is_moe=is_moe_val,
|
||||
moe_num_experts=num_experts,
|
||||
moe_num_experts_per_tok=num_active,
|
||||
# AMX quantization metadata
|
||||
amx_source_model=source_model_name,
|
||||
amx_quant_method=method.value, # "int4" or "int8"
|
||||
amx_numa_nodes=final_numa_nodes,
|
||||
)
|
||||
|
||||
user_registry.add_model(new_model)
|
||||
console.print()
|
||||
print_success(t("quant_registered", name=suggested_name))
|
||||
console.print()
|
||||
console.print(f" {t('quant_view_with')} [cyan]kt model list[/cyan]")
|
||||
console.print(f" {t('quant_use_with')} [cyan]kt run {suggested_name}[/cyan]")
|
||||
console.print()
|
||||
except Exception as e:
|
||||
# Non-fatal error - quantization succeeded but registration failed
|
||||
console.print()
|
||||
print_warning(t("quant_register_failed", error=str(e)))
|
||||
console.print()
|
||||
console.print(f" {t('quant_use_with')}")
|
||||
console.print(f" kt run {model} --weights-path {output}")
|
||||
console.print()
|
||||
else:
|
||||
print_error(f"Quantization failed with exit code {process.returncode}")
|
||||
raise typer.Exit(process.returncode)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
print_error(f"Failed to run quantization: {e}")
|
||||
raise typer.Exit(1)
|
||||
except KeyboardInterrupt:
|
||||
console.print()
|
||||
print_warning("Quantization interrupted.")
|
||||
raise typer.Exit(130)
|
||||
|
||||
|
||||
def _resolve_input_path(model: str, settings) -> Optional[Path]:
|
||||
"""Resolve the input model path."""
|
||||
# Check if it's already a path
|
||||
path = Path(model)
|
||||
if path.exists() and (path / "config.json").exists():
|
||||
return path
|
||||
|
||||
# Search in models directory
|
||||
from kt_kernel.cli.utils.model_registry import get_registry
|
||||
|
||||
registry = get_registry()
|
||||
matches = registry.search(model)
|
||||
|
||||
if matches:
|
||||
model_info = matches[0]
|
||||
# Try to find in all configured model directories
|
||||
model_paths = settings.get_model_paths()
|
||||
|
||||
for models_dir in model_paths:
|
||||
possible_paths = [
|
||||
models_dir / model_info.name,
|
||||
models_dir / model_info.name.lower(),
|
||||
models_dir / model_info.hf_repo.split("/")[-1],
|
||||
]
|
||||
|
||||
for p in possible_paths:
|
||||
if p.exists() and (p / "config.json").exists():
|
||||
return p
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_kt_kernel_path() -> Optional[Path]:
|
||||
"""Find the kt-kernel installation path."""
|
||||
try:
|
||||
import kt_kernel
|
||||
|
||||
return Path(kt_kernel.__file__).parent.parent
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Check common locations
|
||||
possible_paths = [
|
||||
Path.home() / "Projects" / "ktransformers" / "kt-kernel",
|
||||
Path.cwd().parent / "kt-kernel",
|
||||
Path.cwd() / "kt-kernel",
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if path.exists() and (path / "scripts").exists():
|
||||
return path
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,838 @@
|
||||
"""
|
||||
Run command for kt-cli.
|
||||
|
||||
Starts the model inference server using SGLang + kt-kernel.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
import typer
|
||||
|
||||
from kt_kernel.cli.config.settings import get_settings
|
||||
from kt_kernel.cli.i18n import t
|
||||
from kt_kernel.cli.utils.console import (
|
||||
confirm,
|
||||
console,
|
||||
print_api_info,
|
||||
print_error,
|
||||
print_info,
|
||||
print_server_info,
|
||||
print_step,
|
||||
print_success,
|
||||
print_warning,
|
||||
prompt_choice,
|
||||
)
|
||||
from kt_kernel.cli.utils.environment import detect_cpu_info, detect_gpus, detect_ram_gb
|
||||
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
|
||||
|
||||
|
||||
@click.command(
|
||||
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
||||
add_help_option=False, # We'll handle help manually to avoid conflicts
|
||||
)
|
||||
@click.argument("model", required=False, default=None)
|
||||
@click.option("--host", "-H", default=None, help="Server host address")
|
||||
@click.option("--port", "-p", type=int, default=None, help="Server port")
|
||||
@click.option("--gpu-experts", type=int, default=None, help="Number of GPU experts per layer")
|
||||
@click.option("--cpu-threads", type=int, default=None, help="Number of CPU inference threads")
|
||||
@click.option(
|
||||
"--numa-nodes",
|
||||
"numa_nodes",
|
||||
type=int,
|
||||
multiple=True,
|
||||
default=(),
|
||||
help="Number of KT threadpools, or explicit NUMA node IDs for each threadpool (e.g. --numa-nodes 2 or --numa-nodes 0 --numa-nodes 1)",
|
||||
)
|
||||
@click.option(
|
||||
"--tensor-parallel-size", "--tp", "tensor_parallel_size", type=int, default=None, help="Tensor parallel size"
|
||||
)
|
||||
@click.option("--model-path", type=click.Path(), default=None, help="Custom model path")
|
||||
@click.option("--weights-path", type=click.Path(), default=None, help="Custom quantized weights path")
|
||||
@click.option("--kt-method", default=None, help="KT quantization method")
|
||||
@click.option(
|
||||
"--kt-gpu-prefill-threshold", "kt_gpu_prefill_threshold", type=int, default=None, help="GPU prefill token threshold"
|
||||
)
|
||||
@click.option("--attention-backend", default=None, help="Attention backend")
|
||||
@click.option("--max-total-tokens", "max_total_tokens", type=int, default=None, help="Maximum total tokens")
|
||||
@click.option("--max-running-requests", "max_running_requests", type=int, default=None, help="Maximum running requests")
|
||||
@click.option("--chunked-prefill-size", "chunked_prefill_size", type=int, default=None, help="Chunked prefill size")
|
||||
@click.option("--mem-fraction-static", "mem_fraction_static", type=float, default=None, help="Memory fraction static")
|
||||
@click.option("--watchdog-timeout", "watchdog_timeout", type=int, default=None, help="Watchdog timeout")
|
||||
@click.option("--served-model-name", "served_model_name", default=None, help="Served model name")
|
||||
@click.option(
|
||||
"--disable-shared-experts-fusion",
|
||||
"disable_shared_experts_fusion",
|
||||
is_flag=True,
|
||||
default=None,
|
||||
help="Disable shared experts fusion",
|
||||
)
|
||||
@click.option(
|
||||
"--enable-shared-experts-fusion",
|
||||
"enable_shared_experts_fusion",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Enable shared experts fusion",
|
||||
)
|
||||
@click.option("--quantize", "-q", is_flag=True, default=False, help="Quantize model")
|
||||
@click.option("--advanced", is_flag=True, default=False, help="Show advanced options")
|
||||
@click.option("--dry-run", "dry_run", is_flag=True, default=False, help="Show command without executing")
|
||||
@click.pass_context
|
||||
def run(
|
||||
ctx: click.Context,
|
||||
model: Optional[str],
|
||||
host: Optional[str],
|
||||
port: Optional[int],
|
||||
gpu_experts: Optional[int],
|
||||
cpu_threads: Optional[int],
|
||||
numa_nodes: Optional[tuple[int, ...]],
|
||||
tensor_parallel_size: Optional[int],
|
||||
model_path: Optional[str],
|
||||
weights_path: Optional[str],
|
||||
kt_method: Optional[str],
|
||||
kt_gpu_prefill_threshold: Optional[int],
|
||||
attention_backend: Optional[str],
|
||||
max_total_tokens: Optional[int],
|
||||
max_running_requests: Optional[int],
|
||||
chunked_prefill_size: Optional[int],
|
||||
mem_fraction_static: Optional[float],
|
||||
watchdog_timeout: Optional[int],
|
||||
served_model_name: Optional[str],
|
||||
disable_shared_experts_fusion: Optional[bool],
|
||||
enable_shared_experts_fusion: bool,
|
||||
quantize: bool,
|
||||
advanced: bool,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Start model inference server.
|
||||
|
||||
\b
|
||||
Examples: kt run deepseek-v3 | kt run m2 --tensor-parallel-size 2 | kt run /path/to/model --gpu-experts 4
|
||||
|
||||
\b
|
||||
Custom Options: Pass any SGLang server option directly (e.g., kt run m2 --fp8-gemm-backend triton).
|
||||
Common: --fp8-gemm-backend, --tool-call-parser, --reasoning-parser, --dp-size, --enable-ma
|
||||
For full list: python -m sglang.launch_server --help
|
||||
"""
|
||||
# Handle --help manually since we disabled it
|
||||
# Check sys.argv for --help or -h since ctx.args may not be set yet
|
||||
if "--help" in sys.argv or "-h" in sys.argv:
|
||||
click.echo(ctx.get_help())
|
||||
return
|
||||
|
||||
# Handle disable/enable shared experts fusion flags
|
||||
if enable_shared_experts_fusion:
|
||||
disable_shared_experts_fusion = False
|
||||
|
||||
# Convert Path objects from click
|
||||
model_path_obj = Path(model_path) if model_path else None
|
||||
weights_path_obj = Path(weights_path) if weights_path else None
|
||||
|
||||
# Get extra args that weren't parsed (unknown options)
|
||||
# click stores these in ctx.args when ignore_unknown_options=True
|
||||
extra_cli_args = list(ctx.args) if ctx.args else []
|
||||
|
||||
# Remove --help from extra args if present (already handled)
|
||||
extra_cli_args = [arg for arg in extra_cli_args if arg not in ["--help", "-h"]]
|
||||
|
||||
# Call the actual run function implementation
|
||||
_run_impl(
|
||||
model=model,
|
||||
host=host,
|
||||
port=port,
|
||||
gpu_experts=gpu_experts,
|
||||
cpu_threads=cpu_threads,
|
||||
numa_nodes=numa_nodes,
|
||||
tensor_parallel_size=tensor_parallel_size,
|
||||
model_path=model_path_obj,
|
||||
weights_path=weights_path_obj,
|
||||
kt_method=kt_method,
|
||||
kt_gpu_prefill_threshold=kt_gpu_prefill_threshold,
|
||||
attention_backend=attention_backend,
|
||||
max_total_tokens=max_total_tokens,
|
||||
max_running_requests=max_running_requests,
|
||||
chunked_prefill_size=chunked_prefill_size,
|
||||
mem_fraction_static=mem_fraction_static,
|
||||
watchdog_timeout=watchdog_timeout,
|
||||
served_model_name=served_model_name,
|
||||
disable_shared_experts_fusion=disable_shared_experts_fusion,
|
||||
quantize=quantize,
|
||||
advanced=advanced,
|
||||
dry_run=dry_run,
|
||||
extra_cli_args=extra_cli_args,
|
||||
)
|
||||
|
||||
|
||||
def _run_impl(
|
||||
model: Optional[str],
|
||||
host: Optional[str],
|
||||
port: Optional[int],
|
||||
gpu_experts: Optional[int],
|
||||
cpu_threads: Optional[int],
|
||||
numa_nodes: Optional[tuple[int, ...]],
|
||||
tensor_parallel_size: Optional[int],
|
||||
model_path: Optional[Path],
|
||||
weights_path: Optional[Path],
|
||||
kt_method: Optional[str],
|
||||
kt_gpu_prefill_threshold: Optional[int],
|
||||
attention_backend: Optional[str],
|
||||
max_total_tokens: Optional[int],
|
||||
max_running_requests: Optional[int],
|
||||
chunked_prefill_size: Optional[int],
|
||||
mem_fraction_static: Optional[float],
|
||||
watchdog_timeout: Optional[int],
|
||||
served_model_name: Optional[str],
|
||||
disable_shared_experts_fusion: Optional[bool],
|
||||
quantize: bool,
|
||||
advanced: bool,
|
||||
dry_run: bool,
|
||||
extra_cli_args: list[str],
|
||||
) -> None:
|
||||
"""Actual implementation of run command."""
|
||||
# Check if SGLang is installed before proceeding
|
||||
from kt_kernel.cli.utils.sglang_checker import (
|
||||
check_sglang_installation,
|
||||
check_sglang_kt_kernel_support,
|
||||
print_sglang_install_instructions,
|
||||
print_sglang_kt_kernel_instructions,
|
||||
)
|
||||
|
||||
sglang_info = check_sglang_installation()
|
||||
if not sglang_info["installed"]:
|
||||
console.print()
|
||||
print_error(t("sglang_not_found"))
|
||||
console.print()
|
||||
print_sglang_install_instructions()
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Check if SGLang supports kt-kernel (has --kt-gpu-prefill-token-threshold parameter)
|
||||
kt_kernel_support = check_sglang_kt_kernel_support()
|
||||
if not kt_kernel_support["supported"]:
|
||||
console.print()
|
||||
print_error(t("sglang_kt_kernel_not_supported"))
|
||||
console.print()
|
||||
print_sglang_kt_kernel_instructions()
|
||||
raise typer.Exit(1)
|
||||
|
||||
settings = get_settings()
|
||||
user_registry = UserModelRegistry()
|
||||
|
||||
# Check if we should use interactive mode
|
||||
# Interactive mode triggers when:
|
||||
# 1. No model specified, OR
|
||||
# 2. Model specified but missing critical parameters (gpu_experts, tensor_parallel_size, etc.)
|
||||
use_interactive = False
|
||||
|
||||
if model is None:
|
||||
use_interactive = True
|
||||
elif (
|
||||
gpu_experts is None
|
||||
or tensor_parallel_size is None
|
||||
or cpu_threads is None
|
||||
or not numa_nodes
|
||||
or max_total_tokens is None
|
||||
):
|
||||
# Model specified but some parameters missing - use interactive
|
||||
use_interactive = True
|
||||
|
||||
if use_interactive and sys.stdin.isatty():
|
||||
# Use new interactive configuration flow
|
||||
from kt_kernel.cli.utils.run_interactive import interactive_run_config
|
||||
|
||||
console.print()
|
||||
console.print("[bold cyan]═══ Interactive Run Configuration ═══[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
config = interactive_run_config()
|
||||
if config is None:
|
||||
# User cancelled
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Extract configuration from new format
|
||||
user_model_obj = config["model"]
|
||||
model = user_model_obj.id
|
||||
resolved_model_path = Path(config["model_path"])
|
||||
resolved_weights_path = Path(config["weights_path"])
|
||||
|
||||
# Extract parameters
|
||||
gpu_experts = config["gpu_experts"]
|
||||
cpu_threads = config["cpu_threads"]
|
||||
if config.get("numa_nodes") is not None:
|
||||
numa_nodes = (int(config["numa_nodes"]),)
|
||||
else:
|
||||
numa_nodes = ()
|
||||
tensor_parallel_size = config["tp_size"]
|
||||
|
||||
# Get kt-method and other method-specific settings
|
||||
kt_method = config["kt_method"]
|
||||
|
||||
# KV cache settings (may be None for non-raw methods)
|
||||
max_total_tokens = config.get("kv_cache", 32768)
|
||||
chunked_prefill_size = config.get("chunk_prefill", 32768)
|
||||
kt_gpu_prefill_threshold = config.get("gpu_prefill_threshold", 500)
|
||||
|
||||
# Memory settings
|
||||
mem_fraction_static = config["mem_fraction_static"]
|
||||
|
||||
# Parser settings (optional)
|
||||
tool_call_parser = config.get("tool_call_parser")
|
||||
reasoning_parser = config.get("reasoning_parser")
|
||||
|
||||
# Server settings
|
||||
host = config.get("host", "0.0.0.0")
|
||||
port = config.get("port", 30000)
|
||||
|
||||
# Set CUDA_VISIBLE_DEVICES for selected GPUs
|
||||
selected_gpus = config["selected_gpus"]
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(str(gpu_id) for gpu_id in selected_gpus)
|
||||
|
||||
# Detect hardware for parameter resolution (needed for resolve() function later)
|
||||
gpus = detect_gpus()
|
||||
cpu = detect_cpu_info()
|
||||
|
||||
console.print()
|
||||
print_info(f"[green]✓[/green] Configuration complete")
|
||||
console.print()
|
||||
else:
|
||||
# Non-interactive mode - use traditional flow
|
||||
console.print()
|
||||
|
||||
# Initialize variables that may have been set by interactive mode
|
||||
# These will be None in non-interactive mode and will use defaults via resolve()
|
||||
|
||||
# If no model specified, show old interactive selection
|
||||
if model is None:
|
||||
model = _interactive_model_selection(user_registry, settings)
|
||||
if model is None:
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Detect hardware (needed for defaults)
|
||||
gpus = detect_gpus()
|
||||
cpu = detect_cpu_info()
|
||||
ram = detect_ram_gb()
|
||||
|
||||
if gpus:
|
||||
gpu_info = f"{gpus[0].name} ({gpus[0].vram_gb}GB VRAM)"
|
||||
if len(gpus) > 1:
|
||||
gpu_info += f" + {len(gpus) - 1} more"
|
||||
print_info(t("run_gpu_info", name=gpus[0].name, vram=gpus[0].vram_gb))
|
||||
else:
|
||||
print_warning(t("doctor_gpu_not_found"))
|
||||
gpu_info = "None"
|
||||
|
||||
print_info(t("run_cpu_info", name=cpu.name, cores=cpu.cores, numa=cpu.numa_nodes))
|
||||
print_info(t("run_ram_info", total=int(ram)))
|
||||
|
||||
# Step 2: Resolve model
|
||||
console.print()
|
||||
print_step(t("run_checking_model"))
|
||||
|
||||
user_model_obj = None
|
||||
resolved_model_path = model_path
|
||||
|
||||
# Check if model is a path
|
||||
if Path(model).exists():
|
||||
resolved_model_path = Path(model)
|
||||
print_info(t("run_model_path", path=str(resolved_model_path)))
|
||||
|
||||
# Try to find in user registry by path
|
||||
user_model_obj = user_registry.find_by_path(str(resolved_model_path))
|
||||
if user_model_obj:
|
||||
print_info(f"Using registered model: {user_model_obj.name}")
|
||||
else:
|
||||
print_warning("Using unregistered model path. Consider adding it with 'kt model add'")
|
||||
else:
|
||||
# Search in user registry by name
|
||||
user_model_obj = user_registry.get_model(model)
|
||||
|
||||
if not user_model_obj:
|
||||
print_error(t("run_model_not_found", name=model))
|
||||
console.print()
|
||||
|
||||
# Show available models
|
||||
all_models = user_registry.list_models()
|
||||
if all_models:
|
||||
console.print("Available registered models:")
|
||||
for m in all_models[:5]:
|
||||
console.print(f" - {m.name}")
|
||||
if len(all_models) > 5:
|
||||
console.print(f" ... and {len(all_models) - 5} more")
|
||||
else:
|
||||
console.print("No models registered yet.")
|
||||
|
||||
console.print()
|
||||
console.print(f"Add your model with: [cyan]kt model add /path/to/model[/cyan]")
|
||||
console.print(f"Or scan for models: [cyan]kt model scan[/cyan]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Use model path from registry
|
||||
resolved_model_path = Path(user_model_obj.path)
|
||||
|
||||
# Verify path exists
|
||||
if not resolved_model_path.exists():
|
||||
print_error(f"Model path does not exist: {resolved_model_path}")
|
||||
console.print()
|
||||
console.print(f"Run 'kt model refresh' to check all models")
|
||||
raise typer.Exit(1)
|
||||
|
||||
print_info(t("run_model_path", path=str(resolved_model_path)))
|
||||
|
||||
# Step 2.5: Pre-run verification (optional integrity check)
|
||||
if user_model_obj and user_model_obj.format == "safetensors":
|
||||
from kt_kernel.cli.utils.model_verifier import pre_operation_verification
|
||||
|
||||
pre_operation_verification(user_model_obj, user_registry, operation_name="running")
|
||||
|
||||
# Step 3: Check quantized weights (only if explicitly requested)
|
||||
resolved_weights_path = None
|
||||
|
||||
# Only use quantized weights if explicitly specified by user
|
||||
if weights_path is not None:
|
||||
# User explicitly specified weights path
|
||||
resolved_weights_path = weights_path
|
||||
if not resolved_weights_path.exists():
|
||||
print_error(t("run_weights_not_found"))
|
||||
console.print(f" Path: {resolved_weights_path}")
|
||||
raise typer.Exit(1)
|
||||
print_info(f"Using quantized weights: {resolved_weights_path}")
|
||||
elif quantize:
|
||||
# User requested quantization
|
||||
console.print()
|
||||
print_step(t("run_quantizing"))
|
||||
# TODO: Implement quantization
|
||||
print_warning("Quantization not yet implemented. Please run 'kt quant' manually.")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
# Default: use original precision model without quantization
|
||||
console.print()
|
||||
print_info("Using original precision model (no quantization)")
|
||||
|
||||
# Step 4: Build command
|
||||
# Helper to resolve parameter with fallback chain: CLI > config > default
|
||||
def resolve(cli_val, config_key, default):
|
||||
if cli_val is not None:
|
||||
return cli_val
|
||||
config_val = settings.get(config_key)
|
||||
return config_val if config_val is not None else default
|
||||
|
||||
# Server configuration
|
||||
final_host = resolve(host, "server.host", "0.0.0.0")
|
||||
final_port = resolve(port, "server.port", 30000)
|
||||
|
||||
# Tensor parallel size: CLI > config > auto-detect from GPUs
|
||||
final_tensor_parallel_size = resolve(
|
||||
tensor_parallel_size, "inference.tensor_parallel_size", len(gpus) if gpus else 1
|
||||
)
|
||||
|
||||
# CPU/GPU configuration with smart defaults
|
||||
total_threads = cpu.threads # Use logical threads instead of physical cores
|
||||
final_cpu_threads = resolve(cpu_threads, "inference.cpu_threads", int(total_threads * 0.8))
|
||||
final_numa_nodes = resolve(None, "inference.numa_nodes", cpu.numa_nodes)
|
||||
final_kt_numa_nodes = None
|
||||
|
||||
if numa_nodes:
|
||||
if len(numa_nodes) == 1:
|
||||
final_numa_nodes = numa_nodes[0]
|
||||
else:
|
||||
final_kt_numa_nodes = list(numa_nodes)
|
||||
final_numa_nodes = len(final_kt_numa_nodes)
|
||||
final_gpu_experts = resolve(gpu_experts, "inference.gpu_experts", 1)
|
||||
|
||||
# KT-kernel options
|
||||
final_kt_method = resolve(kt_method, "inference.kt_method", "AMXINT4")
|
||||
final_kt_gpu_prefill_threshold = resolve(kt_gpu_prefill_threshold, "inference.kt_gpu_prefill_token_threshold", 4096)
|
||||
|
||||
# SGLang options
|
||||
final_attention_backend = resolve(attention_backend, "inference.attention_backend", "flashinfer")
|
||||
final_max_total_tokens = resolve(max_total_tokens, "inference.max_total_tokens", 40000)
|
||||
final_max_running_requests = resolve(max_running_requests, "inference.max_running_requests", 32)
|
||||
final_chunked_prefill_size = resolve(chunked_prefill_size, "inference.chunked_prefill_size", 4096)
|
||||
final_mem_fraction_static = resolve(mem_fraction_static, "inference.mem_fraction_static", 0.98)
|
||||
final_watchdog_timeout = resolve(watchdog_timeout, "inference.watchdog_timeout", 3000)
|
||||
final_served_model_name = resolve(served_model_name, "inference.served_model_name", "")
|
||||
# Performance flags
|
||||
final_disable_shared_experts_fusion = resolve(
|
||||
disable_shared_experts_fusion, "inference.disable_shared_experts_fusion", True
|
||||
)
|
||||
|
||||
# Pass extra CLI parameters
|
||||
extra_params = {}
|
||||
|
||||
# Parser parameters (from interactive mode or None in non-interactive mode)
|
||||
final_tool_call_parser = None
|
||||
final_reasoning_parser = None
|
||||
if "tool_call_parser" in locals() and tool_call_parser:
|
||||
final_tool_call_parser = tool_call_parser
|
||||
if "reasoning_parser" in locals() and reasoning_parser:
|
||||
final_reasoning_parser = reasoning_parser
|
||||
|
||||
cmd = _build_sglang_command(
|
||||
model_path=resolved_model_path,
|
||||
weights_path=resolved_weights_path,
|
||||
host=final_host,
|
||||
port=final_port,
|
||||
gpu_experts=final_gpu_experts,
|
||||
cpu_threads=final_cpu_threads,
|
||||
numa_nodes=final_numa_nodes,
|
||||
tensor_parallel_size=final_tensor_parallel_size,
|
||||
kt_method=final_kt_method,
|
||||
kt_gpu_prefill_threshold=final_kt_gpu_prefill_threshold,
|
||||
attention_backend=final_attention_backend,
|
||||
max_total_tokens=final_max_total_tokens,
|
||||
max_running_requests=final_max_running_requests,
|
||||
chunked_prefill_size=final_chunked_prefill_size,
|
||||
mem_fraction_static=final_mem_fraction_static,
|
||||
watchdog_timeout=final_watchdog_timeout,
|
||||
served_model_name=final_served_model_name,
|
||||
disable_shared_experts_fusion=final_disable_shared_experts_fusion,
|
||||
kt_numa_nodes=final_kt_numa_nodes,
|
||||
tool_call_parser=final_tool_call_parser,
|
||||
reasoning_parser=final_reasoning_parser,
|
||||
settings=settings,
|
||||
extra_model_params=extra_params,
|
||||
extra_cli_args=extra_cli_args,
|
||||
)
|
||||
|
||||
# Prepare environment variables
|
||||
env = os.environ.copy()
|
||||
# Add environment variables from advanced.env
|
||||
env.update(settings.get_env_vars())
|
||||
# Add environment variables from inference.env
|
||||
inference_env = settings.get("inference.env", {})
|
||||
if isinstance(inference_env, dict):
|
||||
env.update({k: str(v) for k, v in inference_env.items()})
|
||||
|
||||
# Fail fast if a conflicting env var would crash sglang during model loading.
|
||||
# Check against the fully-assembled env dict (shell + kt config settings) so
|
||||
# nothing slips through regardless of where the variable was set.
|
||||
_check_conflicting_env_vars(final_kt_method, env)
|
||||
|
||||
# Step 5: Show configuration summary
|
||||
console.print()
|
||||
print_step("Configuration")
|
||||
|
||||
# Display model name
|
||||
model_display_name = user_model_obj.name if user_model_obj else resolved_model_path.name
|
||||
console.print(f" Model: [bold]{model_display_name}[/bold]")
|
||||
|
||||
console.print(f" Path: [dim]{resolved_model_path}[/dim]")
|
||||
|
||||
# Key parameters
|
||||
console.print()
|
||||
console.print(f" GPU Experts: [cyan]{final_gpu_experts}[/cyan] per layer")
|
||||
console.print(f" CPU Threads (kt-cpuinfer): [cyan]{final_cpu_threads}[/cyan]")
|
||||
console.print(f" NUMA Nodes (kt-threadpool-count): [cyan]{final_numa_nodes}[/cyan]")
|
||||
if final_kt_numa_nodes is not None:
|
||||
console.print(f" NUMA Nodes (binding): [cyan]{', '.join(map(str, final_kt_numa_nodes))}[/cyan]")
|
||||
console.print(f" Tensor Parallel: [cyan]{final_tensor_parallel_size}[/cyan]")
|
||||
console.print(f" Method: [cyan]{final_kt_method}[/cyan]")
|
||||
console.print(f" Attention: [cyan]{final_attention_backend}[/cyan]")
|
||||
|
||||
# Weights info
|
||||
if resolved_weights_path:
|
||||
console.print()
|
||||
console.print(f" Quantized weights: [yellow]{resolved_weights_path}[/yellow]")
|
||||
|
||||
console.print()
|
||||
console.print(f" Server: [green]http://{final_host}:{final_port}[/green]")
|
||||
console.print()
|
||||
|
||||
# Step 6: Show or execute
|
||||
if dry_run:
|
||||
console.print()
|
||||
console.print("[bold]Command:[/bold]")
|
||||
console.print()
|
||||
console.print(f" [dim]{' '.join(cmd)}[/dim]")
|
||||
console.print()
|
||||
return
|
||||
|
||||
# Execute with prepared environment variables
|
||||
# Don't print "Server started" or API info here - let sglang's logs speak for themselves
|
||||
# The actual startup takes time and these messages are misleading
|
||||
|
||||
# Print the command being executed
|
||||
console.print()
|
||||
console.print("[bold]Launching server with command:[/bold]")
|
||||
console.print()
|
||||
console.print(f" [dim]{' '.join(cmd)}[/dim]")
|
||||
console.print()
|
||||
|
||||
try:
|
||||
# Execute directly without intercepting output or signals
|
||||
# This allows direct output to terminal and Ctrl+C to work naturally
|
||||
process = subprocess.run(cmd, env=env)
|
||||
sys.exit(process.returncode)
|
||||
|
||||
except FileNotFoundError:
|
||||
from kt_kernel.cli.utils.sglang_checker import print_sglang_install_instructions
|
||||
|
||||
print_error(t("sglang_not_found"))
|
||||
console.print()
|
||||
print_sglang_install_instructions()
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
print_error(f"Failed to start server: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
# Dead code removed: _find_model_path() and _find_weights_path()
|
||||
# These functions were part of the old builtin model system
|
||||
|
||||
|
||||
def _check_conflicting_env_vars(kt_method: str, env: dict) -> None:
|
||||
"""Exit early if environment variables conflict with the chosen kt-method.
|
||||
|
||||
Receives the fully-assembled subprocess env dict (shell + kt config settings)
|
||||
so that variables injected via inference.env or advanced.env are also caught.
|
||||
Catches copy-paste mistakes such as keeping SGLANG_DSV4_2604_SUBMODE=2604B
|
||||
in the shell after switching from a MXFP4 launch to another method.
|
||||
"""
|
||||
dsv4_submode = env.get("SGLANG_DSV4_2604_SUBMODE", "")
|
||||
if dsv4_submode == "2604B" and (not kt_method or kt_method.upper() != "MXFP4"):
|
||||
print_error(
|
||||
f"SGLANG_DSV4_2604_SUBMODE=2604B is set but kt-method is "
|
||||
f"{kt_method!r} (not MXFP4). "
|
||||
f"This will raise a ValueError during model loading. "
|
||||
f"Either unset the variable (unset SGLANG_DSV4_2604_SUBMODE) "
|
||||
f"or switch to --kt-method MXFP4."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _build_sglang_command(
|
||||
model_path: Path,
|
||||
weights_path: Optional[Path],
|
||||
host: str,
|
||||
port: int,
|
||||
gpu_experts: int,
|
||||
cpu_threads: int,
|
||||
numa_nodes: int,
|
||||
tensor_parallel_size: int,
|
||||
kt_method: str,
|
||||
kt_gpu_prefill_threshold: int,
|
||||
attention_backend: str,
|
||||
max_total_tokens: int,
|
||||
max_running_requests: int,
|
||||
chunked_prefill_size: int,
|
||||
mem_fraction_static: float,
|
||||
watchdog_timeout: int,
|
||||
served_model_name: str,
|
||||
disable_shared_experts_fusion: bool,
|
||||
kt_numa_nodes: Optional[list[int]],
|
||||
tool_call_parser: Optional[str],
|
||||
reasoning_parser: Optional[str],
|
||||
settings,
|
||||
extra_model_params: Optional[dict] = None, # New parameter for additional params
|
||||
extra_cli_args: Optional[list[str]] = None, # Extra args from CLI to pass to sglang
|
||||
) -> list[str]:
|
||||
"""Build the SGLang launch command."""
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"sglang.launch_server",
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
str(port),
|
||||
"--model",
|
||||
str(model_path),
|
||||
]
|
||||
|
||||
# Add kt-kernel options
|
||||
# kt-kernel is needed for:
|
||||
# 1. Quantized models (when weights_path is provided)
|
||||
# 2. MoE models with CPU offloading (when kt-cpuinfer > 0 or kt-num-gpu-experts is configured)
|
||||
use_kt_kernel = False
|
||||
|
||||
# Check if we should use kt-kernel
|
||||
if weights_path:
|
||||
# Quantized model - always use kt-kernel
|
||||
use_kt_kernel = True
|
||||
elif cpu_threads > 0 or gpu_experts > 1:
|
||||
# CPU offloading configured - use kt-kernel
|
||||
use_kt_kernel = True
|
||||
|
||||
if use_kt_kernel:
|
||||
# Add kt-weight-path: use quantized weights if available, otherwise use model path
|
||||
weight_path_to_use = weights_path if weights_path else model_path
|
||||
|
||||
# Add kt-kernel configuration
|
||||
cmd.extend(
|
||||
[
|
||||
"--kt-weight-path",
|
||||
str(weight_path_to_use),
|
||||
"--kt-cpuinfer",
|
||||
str(cpu_threads),
|
||||
"--kt-threadpool-count",
|
||||
str(numa_nodes),
|
||||
"--kt-num-gpu-experts",
|
||||
str(gpu_experts),
|
||||
"--kt-method",
|
||||
kt_method,
|
||||
"--kt-gpu-prefill-token-threshold",
|
||||
str(kt_gpu_prefill_threshold),
|
||||
"--kt-enable-dynamic-expert-update", # Enable dynamic expert updates
|
||||
]
|
||||
)
|
||||
if kt_numa_nodes is not None:
|
||||
cmd.extend(["--kt-numa-nodes", *map(str, kt_numa_nodes)])
|
||||
|
||||
# Add SGLang options
|
||||
cmd.extend(
|
||||
[
|
||||
"--attention-backend",
|
||||
attention_backend,
|
||||
"--trust-remote-code",
|
||||
"--mem-fraction-static",
|
||||
str(mem_fraction_static),
|
||||
"--chunked-prefill-size",
|
||||
str(chunked_prefill_size),
|
||||
"--max-running-requests",
|
||||
str(max_running_requests),
|
||||
"--max-total-tokens",
|
||||
str(max_total_tokens),
|
||||
"--watchdog-timeout",
|
||||
str(watchdog_timeout),
|
||||
"--enable-mixed-chunk",
|
||||
"--tensor-parallel-size",
|
||||
str(tensor_parallel_size),
|
||||
"--enable-p2p-check",
|
||||
]
|
||||
)
|
||||
|
||||
# Add served model name if specified
|
||||
if served_model_name:
|
||||
cmd.extend(["--served-model-name", served_model_name])
|
||||
|
||||
# Add performance flags
|
||||
if disable_shared_experts_fusion:
|
||||
cmd.append("--disable-shared-experts-fusion")
|
||||
|
||||
# Add FP8 backend if using FP8 method
|
||||
if "FP8" in kt_method.upper():
|
||||
cmd.extend(["--fp8-gemm-backend", "triton"])
|
||||
|
||||
# Add parsers if specified
|
||||
if tool_call_parser:
|
||||
cmd.extend(["--tool-call-parser", tool_call_parser])
|
||||
if reasoning_parser:
|
||||
cmd.extend(["--reasoning-parser", reasoning_parser])
|
||||
|
||||
# Add any extra parameters from model defaults that weren't explicitly handled
|
||||
if extra_model_params:
|
||||
# List of parameters already handled above
|
||||
handled_params = {
|
||||
"kt-num-gpu-experts",
|
||||
"kt-cpuinfer",
|
||||
"kt-threadpool-count",
|
||||
"kt-numa-nodes",
|
||||
"kt-method",
|
||||
"kt-gpu-prefill-token-threshold",
|
||||
"attention-backend",
|
||||
"tensor-parallel-size",
|
||||
"max-total-tokens",
|
||||
"max-running-requests",
|
||||
"chunked-prefill-size",
|
||||
"mem-fraction-static",
|
||||
"watchdog-timeout",
|
||||
"served-model-name",
|
||||
"disable-shared-experts-fusion",
|
||||
}
|
||||
|
||||
for key, value in extra_model_params.items():
|
||||
if key not in handled_params:
|
||||
# Add unhandled parameters dynamically
|
||||
cmd.append(f"--{key}")
|
||||
if isinstance(value, bool):
|
||||
# Boolean flags don't need a value
|
||||
if not value:
|
||||
# For False boolean, skip the flag entirely
|
||||
cmd.pop() # Remove the flag we just added
|
||||
else:
|
||||
cmd.append(str(value))
|
||||
|
||||
# Add extra args from settings
|
||||
extra_args = settings.get("advanced.sglang_args", [])
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
|
||||
# Add extra CLI args (user-provided options not defined in kt CLI)
|
||||
if extra_cli_args:
|
||||
cmd.extend(extra_cli_args)
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
def _interactive_model_selection(user_registry, settings) -> Optional[str]:
|
||||
"""Show interactive model selection interface.
|
||||
|
||||
Returns:
|
||||
Selected model name or None if cancelled.
|
||||
"""
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Prompt
|
||||
|
||||
# Get all user models
|
||||
all_models = user_registry.list_models()
|
||||
|
||||
if not all_models:
|
||||
console.print()
|
||||
print_warning("No models registered.")
|
||||
console.print()
|
||||
console.print(f" Add models with: [cyan]kt model scan[/cyan]")
|
||||
console.print(f" Or manually: [cyan]kt model add /path/to/model[/cyan]")
|
||||
console.print()
|
||||
return None
|
||||
|
||||
console.print()
|
||||
console.print(
|
||||
Panel.fit(
|
||||
"Select a model to run",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
# Build choices list
|
||||
choices = []
|
||||
choice_map = {} # index -> model name
|
||||
|
||||
# Show all user models
|
||||
console.print(f"[bold green]Available Models:[/bold green]")
|
||||
console.print()
|
||||
|
||||
for i, model in enumerate(all_models, 1):
|
||||
# Check if path exists
|
||||
path_status = "✓" if model.path_exists() else "✗ Missing"
|
||||
console.print(f" [cyan][{i}][/cyan] [bold]{model.name}[/bold] [{path_status}]")
|
||||
console.print(f" [dim]{model.format} - {model.path}[/dim]")
|
||||
choices.append(str(i))
|
||||
choice_map[str(i)] = model.name
|
||||
|
||||
console.print()
|
||||
|
||||
# Add cancel option
|
||||
cancel_idx = str(len(choices) + 1)
|
||||
console.print(f" [cyan][{cancel_idx}][/cyan] [dim]Cancel[/dim]")
|
||||
choices.append(cancel_idx)
|
||||
console.print()
|
||||
|
||||
# Prompt for selection
|
||||
try:
|
||||
selection = Prompt.ask(
|
||||
"Select model",
|
||||
choices=choices,
|
||||
default="1" if choices else cancel_idx,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
console.print()
|
||||
return None
|
||||
|
||||
if selection == cancel_idx:
|
||||
return None
|
||||
|
||||
return choice_map.get(selection)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
SFT command for kt-cli.
|
||||
|
||||
Fine-tuning with LlamaFactory integration.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
from kt_kernel.cli.i18n import t
|
||||
from kt_kernel.cli.utils.console import console
|
||||
|
||||
app = typer.Typer(help="Fine-tuning with LlamaFactory (coming soon)")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def callback(ctx: typer.Context) -> None:
|
||||
"""Fine-tuning commands (coming soon)."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
console.print()
|
||||
console.print(f"[yellow]{t('feature_coming_soon')}[/yellow]")
|
||||
console.print()
|
||||
console.print("[dim]kt sft train - Train a model[/dim]")
|
||||
console.print("[dim]kt sft chat - Chat with a trained model[/dim]")
|
||||
console.print("[dim]kt sft export - Export a trained model[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
@app.command(name="train")
|
||||
def train() -> None:
|
||||
"""Train a model using LlamaFactory (coming soon)."""
|
||||
console.print()
|
||||
console.print(f"[yellow]{t('feature_coming_soon')}[/yellow]")
|
||||
console.print()
|
||||
raise typer.Exit(0)
|
||||
|
||||
|
||||
@app.command(name="chat")
|
||||
def chat() -> None:
|
||||
"""Chat with a trained model using LlamaFactory (coming soon)."""
|
||||
console.print()
|
||||
console.print(f"[yellow]{t('feature_coming_soon')}[/yellow]")
|
||||
console.print()
|
||||
raise typer.Exit(0)
|
||||
|
||||
|
||||
@app.command(name="export")
|
||||
def export() -> None:
|
||||
"""Export a trained model using LlamaFactory (coming soon)."""
|
||||
console.print()
|
||||
console.print(f"[yellow]{t('feature_coming_soon')}[/yellow]")
|
||||
console.print()
|
||||
raise typer.Exit(0)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Version command for kt-cli.
|
||||
|
||||
Displays version information for kt-cli and related packages.
|
||||
"""
|
||||
|
||||
import platform
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from kt_kernel.cli import __version__
|
||||
from kt_kernel.cli.i18n import t
|
||||
from kt_kernel.cli.utils.console import console, print_version_table
|
||||
from kt_kernel.cli.utils.environment import detect_cuda_version, get_installed_package_version
|
||||
|
||||
|
||||
def _get_sglang_info() -> str:
|
||||
"""Get sglang-kt version and installation source information."""
|
||||
from kt_kernel.cli.utils.sglang_checker import check_sglang_installation
|
||||
|
||||
info = check_sglang_installation()
|
||||
|
||||
if not info["installed"]:
|
||||
return t("version_not_installed")
|
||||
|
||||
# Get version from package metadata (prefer sglang-kt)
|
||||
version = get_installed_package_version("sglang-kt")
|
||||
if not version:
|
||||
version = get_installed_package_version("sglang")
|
||||
if not version:
|
||||
version = info.get("version") or "unknown"
|
||||
|
||||
# Determine source label
|
||||
if info.get("is_kvcache_fork"):
|
||||
if info["from_source"] and info.get("git_info"):
|
||||
git_remote = info["git_info"].get("remote", "")
|
||||
return f"{version} [dim](Source: {git_remote})[/dim]"
|
||||
elif info["editable"]:
|
||||
return f"{version} [dim](editable)[/dim]"
|
||||
else:
|
||||
return f"{version} [dim](sglang-kt)[/dim]"
|
||||
elif info["from_source"]:
|
||||
if info.get("git_info"):
|
||||
git_remote = info["git_info"].get("remote", "")
|
||||
return f"{version} [dim](Source: {git_remote})[/dim]"
|
||||
return f"{version} [dim](source)[/dim]"
|
||||
else:
|
||||
return f"{version} [dim](PyPI)[/dim]"
|
||||
|
||||
|
||||
def version(
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed version info"),
|
||||
) -> None:
|
||||
"""Show version information."""
|
||||
console.print(f"\n[bold]{t('version_info')}[/bold] v{__version__}\n")
|
||||
|
||||
# Basic info
|
||||
versions = {
|
||||
t("version_python"): platform.python_version(),
|
||||
t("version_platform"): f"{platform.system()} {platform.release()}",
|
||||
}
|
||||
|
||||
# CUDA version
|
||||
cuda_version = detect_cuda_version()
|
||||
versions[t("version_cuda")] = cuda_version or t("version_cuda_not_found")
|
||||
|
||||
print_version_table(versions)
|
||||
|
||||
# Always show key packages with installation source
|
||||
console.print("\n[bold]Packages:[/bold]\n")
|
||||
|
||||
sglang_info = _get_sglang_info()
|
||||
key_packages = {
|
||||
t("version_kt_kernel"): get_installed_package_version("kt-kernel") or t("version_not_installed"),
|
||||
t("version_sglang"): sglang_info,
|
||||
}
|
||||
|
||||
print_version_table(key_packages)
|
||||
|
||||
# Show SGLang installation hint if not installed
|
||||
if sglang_info == t("version_not_installed"):
|
||||
from kt_kernel.cli.utils.sglang_checker import print_sglang_install_instructions
|
||||
|
||||
console.print()
|
||||
print_sglang_install_instructions()
|
||||
|
||||
if verbose:
|
||||
console.print("\n[bold]Additional Packages:[/bold]\n")
|
||||
|
||||
package_versions = {
|
||||
t("version_ktransformers"): get_installed_package_version("ktransformers") or t("version_not_installed"),
|
||||
t("version_llamafactory"): get_installed_package_version("llamafactory") or t("version_not_installed"),
|
||||
"typer": get_installed_package_version("typer") or t("version_not_installed"),
|
||||
"rich": get_installed_package_version("rich") or t("version_not_installed"),
|
||||
"torch": get_installed_package_version("torch") or t("version_not_installed"),
|
||||
"transformers": get_installed_package_version("transformers") or t("version_not_installed"),
|
||||
}
|
||||
|
||||
print_version_table(package_versions)
|
||||
|
||||
console.print()
|
||||
@@ -0,0 +1 @@
|
||||
"""Shell completion scripts for kt-cli."""
|
||||
@@ -0,0 +1,153 @@
|
||||
#compdef kt
|
||||
# Zsh completion for kt command
|
||||
# This is a static completion script that doesn't require Python startup
|
||||
|
||||
_kt() {
|
||||
local -a commands
|
||||
commands=(
|
||||
'version:Show version information'
|
||||
'run:Start model inference server'
|
||||
'chat:Interactive chat with running model'
|
||||
'quant:Quantize model weights'
|
||||
'bench:Run full benchmark'
|
||||
'microbench:Run micro-benchmark'
|
||||
'doctor:Diagnose environment issues'
|
||||
'model:Manage models and storage paths'
|
||||
'config:Manage configuration'
|
||||
'sft:Fine-tuning with LlamaFactory'
|
||||
)
|
||||
|
||||
local -a run_opts
|
||||
run_opts=(
|
||||
'--host[Server host]:host:'
|
||||
'--port[Server port]:port:'
|
||||
'--gpu-experts[Number of GPU experts]:count:'
|
||||
'--cpu-threads[Number of CPU threads]:count:'
|
||||
'--tensor-parallel-size[Tensor parallel size]:size:'
|
||||
'--kt-method[KT method]:method:(AMXINT4 FP8 RAWINT4)'
|
||||
'--attention-backend[Attention backend]:backend:(triton flashinfer)'
|
||||
'--max-total-tokens[Maximum total tokens]:tokens:'
|
||||
'--dry-run[Show command without executing]'
|
||||
'--help[Show help message]'
|
||||
)
|
||||
|
||||
local -a chat_opts
|
||||
chat_opts=(
|
||||
'--host[Server host]:host:'
|
||||
'--port[Server port]:port:'
|
||||
'--model[Model name]:model:'
|
||||
'--temperature[Sampling temperature]:temp:'
|
||||
'--max-tokens[Maximum tokens]:tokens:'
|
||||
'--system[System prompt]:prompt:'
|
||||
'--save-history[Save conversation history]'
|
||||
'--no-save-history[Do not save history]'
|
||||
'--history-file[History file path]:path:_files'
|
||||
'--stream[Enable streaming output]'
|
||||
'--no-stream[Disable streaming output]'
|
||||
'--help[Show help message]'
|
||||
)
|
||||
|
||||
local -a model_cmds
|
||||
model_cmds=(
|
||||
'download:Download a model from HuggingFace'
|
||||
'list:List available models'
|
||||
'path-list:List all model storage paths'
|
||||
'path-add:Add a new model storage path'
|
||||
'path-remove:Remove a model storage path'
|
||||
'search:Search for models in the registry'
|
||||
)
|
||||
|
||||
local -a config_cmds
|
||||
config_cmds=(
|
||||
'show:Show all configuration'
|
||||
'get:Get configuration value'
|
||||
'set:Set configuration value'
|
||||
'reset:Reset to defaults'
|
||||
'path:Show configuration file path'
|
||||
'init:Re-run first-time setup wizard'
|
||||
)
|
||||
|
||||
local -a sft_cmds
|
||||
sft_cmds=(
|
||||
'train:Train model'
|
||||
'chat:Chat with model'
|
||||
'export:Export model'
|
||||
)
|
||||
|
||||
_arguments -C \
|
||||
'1: :->command' \
|
||||
'*::arg:->args'
|
||||
|
||||
case $state in
|
||||
command)
|
||||
_describe 'kt commands' commands
|
||||
_arguments \
|
||||
'--help[Show help message]' \
|
||||
'--version[Show version]'
|
||||
;;
|
||||
args)
|
||||
case $words[1] in
|
||||
run)
|
||||
_arguments $run_opts \
|
||||
'1:model:'
|
||||
;;
|
||||
chat)
|
||||
_arguments $chat_opts
|
||||
;;
|
||||
quant)
|
||||
_arguments \
|
||||
'--method[Quantization method]:method:' \
|
||||
'--output[Output directory]:path:_files -/' \
|
||||
'--help[Show help message]' \
|
||||
'1:model:_files -/'
|
||||
;;
|
||||
bench|microbench)
|
||||
_arguments \
|
||||
'--model[Model name or path]:model:' \
|
||||
'--config[Config file path]:path:_files' \
|
||||
'--help[Show help message]'
|
||||
;;
|
||||
doctor)
|
||||
_arguments \
|
||||
'--verbose[Verbose output]' \
|
||||
'--help[Show help message]'
|
||||
;;
|
||||
model)
|
||||
_arguments \
|
||||
'1: :->model_cmd' \
|
||||
'*::arg:->model_args'
|
||||
|
||||
case $state in
|
||||
model_cmd)
|
||||
_describe 'model commands' model_cmds
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
config)
|
||||
_arguments \
|
||||
'1: :->config_cmd' \
|
||||
'*::arg:->config_args'
|
||||
|
||||
case $state in
|
||||
config_cmd)
|
||||
_describe 'config commands' config_cmds
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
sft)
|
||||
_arguments \
|
||||
'1: :->sft_cmd' \
|
||||
'*::arg:->sft_args'
|
||||
|
||||
case $state in
|
||||
sft_cmd)
|
||||
_describe 'sft commands' sft_cmds
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
_kt "$@"
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
# Bash completion for kt command
|
||||
# This is a static completion script that doesn't require Python startup
|
||||
|
||||
_kt_completion() {
|
||||
local cur prev opts
|
||||
COMPREPLY=()
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
# Main commands
|
||||
local commands="version run chat quant edit bench microbench doctor model config sft"
|
||||
|
||||
# Global options
|
||||
local global_opts="--help --version"
|
||||
|
||||
# Handle subcommands
|
||||
case "${COMP_CWORD}" in
|
||||
1)
|
||||
# First argument: suggest commands and global options
|
||||
COMPREPLY=( $(compgen -W "${commands} ${global_opts}" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
# Handle specific command options
|
||||
case "${COMP_WORDS[1]}" in
|
||||
run)
|
||||
local run_opts="--host --port --gpu-experts --cpu-threads --tensor-parallel-size --kt-method --attention-backend --max-total-tokens --dry-run --help"
|
||||
COMPREPLY=( $(compgen -W "${run_opts}" -- ${cur}) )
|
||||
;;
|
||||
chat)
|
||||
local chat_opts="--host --port --model --temperature --max-tokens --system --save-history --no-save-history --history-file --stream --no-stream --help"
|
||||
COMPREPLY=( $(compgen -W "${chat_opts}" -- ${cur}) )
|
||||
;;
|
||||
quant)
|
||||
local quant_opts="--method --output --help"
|
||||
COMPREPLY=( $(compgen -W "${quant_opts}" -- ${cur}) )
|
||||
;;
|
||||
edit)
|
||||
local edit_opts="--help"
|
||||
COMPREPLY=( $(compgen -W "${edit_opts}" -- ${cur}) )
|
||||
;;
|
||||
bench|microbench)
|
||||
local bench_opts="--model --config --help"
|
||||
COMPREPLY=( $(compgen -W "${bench_opts}" -- ${cur}) )
|
||||
;;
|
||||
doctor)
|
||||
local doctor_opts="--verbose --help"
|
||||
COMPREPLY=( $(compgen -W "${doctor_opts}" -- ${cur}) )
|
||||
;;
|
||||
model)
|
||||
local model_cmds="download list path-list path-add path-remove search"
|
||||
local model_opts="--help"
|
||||
COMPREPLY=( $(compgen -W "${model_cmds} ${model_opts}" -- ${cur}) )
|
||||
;;
|
||||
config)
|
||||
local config_cmds="show get set reset path init model-path-list model-path-add model-path-remove"
|
||||
local config_opts="--help"
|
||||
COMPREPLY=( $(compgen -W "${config_cmds} ${config_opts}" -- ${cur}) )
|
||||
;;
|
||||
sft)
|
||||
local sft_cmds="train chat export"
|
||||
local sft_opts="--help"
|
||||
COMPREPLY=( $(compgen -W "${sft_cmds} ${sft_opts}" -- ${cur}) )
|
||||
;;
|
||||
version)
|
||||
COMPREPLY=( $(compgen -W "--help" -- ${cur}) )
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=()
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
complete -F _kt_completion kt
|
||||
@@ -0,0 +1,74 @@
|
||||
# Fish completion for kt command
|
||||
# This is a static completion script that doesn't require Python startup
|
||||
|
||||
# Main commands
|
||||
complete -c kt -f -n "__fish_use_subcommand" -a "version" -d "Show version information"
|
||||
complete -c kt -f -n "__fish_use_subcommand" -a "run" -d "Start model inference server"
|
||||
complete -c kt -f -n "__fish_use_subcommand" -a "chat" -d "Interactive chat with running model"
|
||||
complete -c kt -f -n "__fish_use_subcommand" -a "quant" -d "Quantize model weights"
|
||||
complete -c kt -f -n "__fish_use_subcommand" -a "bench" -d "Run full benchmark"
|
||||
complete -c kt -f -n "__fish_use_subcommand" -a "microbench" -d "Run micro-benchmark"
|
||||
complete -c kt -f -n "__fish_use_subcommand" -a "doctor" -d "Diagnose environment issues"
|
||||
complete -c kt -f -n "__fish_use_subcommand" -a "model" -d "Manage models and storage paths"
|
||||
complete -c kt -f -n "__fish_use_subcommand" -a "config" -d "Manage configuration"
|
||||
complete -c kt -f -n "__fish_use_subcommand" -a "sft" -d "Fine-tuning with LlamaFactory"
|
||||
|
||||
# Global options
|
||||
complete -c kt -l help -d "Show help message"
|
||||
complete -c kt -l version -d "Show version"
|
||||
|
||||
# Run command options
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from run" -l host -d "Server host"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from run" -l port -d "Server port"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from run" -l gpu-experts -d "Number of GPU experts"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from run" -l cpu-threads -d "Number of CPU threads"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from run" -l tensor-parallel-size -d "Tensor parallel size"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from run" -l kt-method -d "KT method"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from run" -l attention-backend -d "Attention backend"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from run" -l max-total-tokens -d "Maximum total tokens"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from run" -l dry-run -d "Show command without executing"
|
||||
|
||||
# Chat command options
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from chat" -l host -d "Server host"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from chat" -l port -d "Server port"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from chat" -l model -d "Model name"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from chat" -l temperature -d "Sampling temperature"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from chat" -l max-tokens -d "Maximum tokens"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from chat" -l system -d "System prompt"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from chat" -l save-history -d "Save conversation history"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from chat" -l no-save-history -d "Do not save history"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from chat" -l history-file -d "History file path"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from chat" -l stream -d "Enable streaming output"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from chat" -l no-stream -d "Disable streaming output"
|
||||
|
||||
# Quant command options
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from quant" -l method -d "Quantization method"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from quant" -l output -d "Output directory"
|
||||
|
||||
# Bench command options
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from bench microbench" -l model -d "Model name or path"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from bench microbench" -l config -d "Config file path"
|
||||
|
||||
# Doctor command options
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from doctor" -l verbose -d "Verbose output"
|
||||
|
||||
# Model subcommands
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from model; and not __fish_seen_subcommand_from download list path-list path-add path-remove search" -a "download" -d "Download a model from HuggingFace"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from model; and not __fish_seen_subcommand_from download list path-list path-add path-remove search" -a "list" -d "List available models"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from model; and not __fish_seen_subcommand_from download list path-list path-add path-remove search" -a "path-list" -d "List all model storage paths"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from model; and not __fish_seen_subcommand_from download list path-list path-add path-remove search" -a "path-add" -d "Add a new model storage path"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from model; and not __fish_seen_subcommand_from download list path-list path-add path-remove search" -a "path-remove" -d "Remove a model storage path"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from model; and not __fish_seen_subcommand_from download list path-list path-add path-remove search" -a "search" -d "Search for models in the registry"
|
||||
|
||||
# Config subcommands
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from show get set reset path init" -a "show" -d "Show all configuration"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from show get set reset path init" -a "get" -d "Get configuration value"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from show get set reset path init" -a "set" -d "Set configuration value"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from show get set reset path init" -a "reset" -d "Reset to defaults"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from show get set reset path init" -a "path" -d "Show configuration file path"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from show get set reset path init" -a "init" -d "Re-run first-time setup wizard"
|
||||
|
||||
# SFT subcommands
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from sft; and not __fish_seen_subcommand_from train chat export" -a "train" -d "Train model"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from sft; and not __fish_seen_subcommand_from train chat export" -a "chat" -d "Chat with model"
|
||||
complete -c kt -f -n "__fish_seen_subcommand_from sft; and not __fish_seen_subcommand_from train chat export" -a "export" -d "Export model"
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Configuration management for kt-cli.
|
||||
"""
|
||||
|
||||
from kt_kernel.cli.config.settings import Settings, get_settings
|
||||
|
||||
__all__ = ["Settings", "get_settings"]
|
||||
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
Configuration management for kt-cli.
|
||||
|
||||
Handles reading and writing configuration from ~/.ktransformers/config.yaml
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
# Default configuration directory
|
||||
DEFAULT_CONFIG_DIR = Path.home() / ".ktransformers"
|
||||
DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.yaml"
|
||||
DEFAULT_MODELS_DIR = DEFAULT_CONFIG_DIR / "models"
|
||||
DEFAULT_CACHE_DIR = DEFAULT_CONFIG_DIR / "cache"
|
||||
|
||||
# Default configuration values
|
||||
DEFAULT_CONFIG = {
|
||||
"general": {
|
||||
"language": "auto", # auto, en, zh
|
||||
"color": True,
|
||||
"verbose": False,
|
||||
},
|
||||
"paths": {
|
||||
"models": str(DEFAULT_MODELS_DIR),
|
||||
"cache": str(DEFAULT_CACHE_DIR),
|
||||
"weights": "", # Custom quantized weights path
|
||||
},
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 30000,
|
||||
},
|
||||
"inference": {
|
||||
# Inference parameters are model-specific and should not have defaults
|
||||
# They will be auto-detected or use model-specific optimizations
|
||||
# Environment variables (general optimizations)
|
||||
"env": {
|
||||
"PYTORCH_ALLOC_CONF": "expandable_segments:True",
|
||||
"SGLANG_ENABLE_JIT_DEEPGEMM": "0",
|
||||
},
|
||||
},
|
||||
"download": {
|
||||
"mirror": "", # HuggingFace mirror URL
|
||||
"resume": True,
|
||||
"verify": True,
|
||||
},
|
||||
"advanced": {
|
||||
# Environment variables to set when running
|
||||
"env": {},
|
||||
# Extra arguments to pass to sglang
|
||||
"sglang_args": [],
|
||||
# Extra arguments to pass to llamafactory
|
||||
"llamafactory_args": [],
|
||||
},
|
||||
"dependencies": {
|
||||
# SGLang installation source configuration
|
||||
"sglang": {
|
||||
"source": "github", # "pypi" or "github"
|
||||
"repo": "https://github.com/kvcache-ai/sglang",
|
||||
"branch": "main",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Configuration manager for kt-cli."""
|
||||
|
||||
def __init__(self, config_path: Optional[Path] = None):
|
||||
"""Initialize settings manager.
|
||||
|
||||
Args:
|
||||
config_path: Path to config file. Defaults to ~/.ktransformers/config.yaml
|
||||
"""
|
||||
self.config_path = config_path or DEFAULT_CONFIG_FILE
|
||||
self.config_dir = self.config_path.parent
|
||||
self._config: dict[str, Any] = {}
|
||||
self._load()
|
||||
|
||||
def _ensure_dirs(self) -> None:
|
||||
"""Ensure configuration directories exist."""
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Ensure all model paths exist
|
||||
model_paths = self.get_model_paths()
|
||||
for path in model_paths:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
Path(self.get("paths.cache", DEFAULT_CACHE_DIR)).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load configuration from file."""
|
||||
self._config = self._deep_copy(DEFAULT_CONFIG)
|
||||
|
||||
if self.config_path.exists():
|
||||
try:
|
||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
self._deep_merge(self._config, user_config)
|
||||
except (yaml.YAMLError, OSError) as e:
|
||||
# Log warning but continue with defaults
|
||||
print(f"Warning: Failed to load config: {e}")
|
||||
|
||||
self._ensure_dirs()
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Save configuration to file."""
|
||||
self._ensure_dirs()
|
||||
try:
|
||||
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(self._config, f, default_flow_style=False, allow_unicode=True)
|
||||
except OSError as e:
|
||||
raise RuntimeError(f"Failed to save config: {e}")
|
||||
|
||||
def _deep_copy(self, obj: Any) -> Any:
|
||||
"""Create a deep copy of a nested dict."""
|
||||
if isinstance(obj, dict):
|
||||
return {k: self._deep_copy(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [self._deep_copy(item) for item in obj]
|
||||
return obj
|
||||
|
||||
def _deep_merge(self, base: dict, override: dict) -> None:
|
||||
"""Deep merge override into base."""
|
||||
for key, value in override.items():
|
||||
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
||||
self._deep_merge(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a configuration value by dot-separated key.
|
||||
|
||||
Args:
|
||||
key: Dot-separated key path (e.g., "server.port")
|
||||
default: Default value if key not found
|
||||
|
||||
Returns:
|
||||
Configuration value or default
|
||||
"""
|
||||
parts = key.split(".")
|
||||
value = self._config
|
||||
|
||||
for part in parts:
|
||||
if isinstance(value, dict) and part in value:
|
||||
value = value[part]
|
||||
else:
|
||||
return default
|
||||
|
||||
return value
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Set a configuration value by dot-separated key.
|
||||
|
||||
Args:
|
||||
key: Dot-separated key path (e.g., "server.port")
|
||||
value: Value to set
|
||||
"""
|
||||
parts = key.split(".")
|
||||
config = self._config
|
||||
|
||||
# Navigate to parent
|
||||
for part in parts[:-1]:
|
||||
if part not in config:
|
||||
config[part] = {}
|
||||
config = config[part]
|
||||
|
||||
# Set value
|
||||
config[parts[-1]] = value
|
||||
self._save()
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete a configuration value.
|
||||
|
||||
Args:
|
||||
key: Dot-separated key path
|
||||
|
||||
Returns:
|
||||
True if key was deleted, False if not found
|
||||
"""
|
||||
parts = key.split(".")
|
||||
config = self._config
|
||||
|
||||
# Navigate to parent
|
||||
for part in parts[:-1]:
|
||||
if part not in config:
|
||||
return False
|
||||
config = config[part]
|
||||
|
||||
# Delete key
|
||||
if parts[-1] in config:
|
||||
del config[parts[-1]]
|
||||
self._save()
|
||||
return True
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset configuration to defaults."""
|
||||
self._config = self._deep_copy(DEFAULT_CONFIG)
|
||||
self._save()
|
||||
|
||||
def get_all(self) -> dict[str, Any]:
|
||||
"""Get all configuration values."""
|
||||
return self._deep_copy(self._config)
|
||||
|
||||
def get_env_vars(self) -> dict[str, str]:
|
||||
"""Get environment variables to set."""
|
||||
env_vars = {}
|
||||
|
||||
# Get from advanced.env
|
||||
advanced_env = self.get("advanced.env", {})
|
||||
if isinstance(advanced_env, dict):
|
||||
env_vars.update({k: str(v) for k, v in advanced_env.items()})
|
||||
|
||||
return env_vars
|
||||
|
||||
@property
|
||||
def models_dir(self) -> Path:
|
||||
"""Get the primary models directory path (for backward compatibility)."""
|
||||
paths = self.get_model_paths()
|
||||
return paths[0] if paths else Path(DEFAULT_MODELS_DIR)
|
||||
|
||||
def get_model_paths(self) -> list[Path]:
|
||||
"""Get all model directory paths.
|
||||
|
||||
Returns a list of Path objects. Supports both:
|
||||
- Single path: paths.models = "/path/to/models"
|
||||
- Multiple paths: paths.models = ["/path/1", "/path/2"]
|
||||
"""
|
||||
models_config = self.get("paths.models", DEFAULT_MODELS_DIR)
|
||||
|
||||
# Handle both string and list
|
||||
if isinstance(models_config, str):
|
||||
return [Path(models_config)]
|
||||
elif isinstance(models_config, list):
|
||||
return [Path(p) for p in models_config]
|
||||
else:
|
||||
return [Path(DEFAULT_MODELS_DIR)]
|
||||
|
||||
def add_model_path(self, path: str) -> None:
|
||||
"""Add a new model path to the configuration."""
|
||||
models_config = self.get("paths.models", DEFAULT_MODELS_DIR)
|
||||
|
||||
# Convert to list if it's a string
|
||||
if isinstance(models_config, str):
|
||||
paths = [models_config]
|
||||
elif isinstance(models_config, list):
|
||||
paths = list(models_config)
|
||||
else:
|
||||
paths = []
|
||||
|
||||
# Add new path if not already present
|
||||
if path not in paths:
|
||||
paths.append(path)
|
||||
self.set("paths.models", paths)
|
||||
|
||||
def remove_model_path(self, path: str) -> bool:
|
||||
"""Remove a model path from the configuration.
|
||||
|
||||
Returns True if path was removed, False if not found.
|
||||
"""
|
||||
models_config = self.get("paths.models", DEFAULT_MODELS_DIR)
|
||||
|
||||
if isinstance(models_config, str):
|
||||
# Can't remove if it's a single string
|
||||
if models_config == path:
|
||||
# Don't remove the last path
|
||||
return False
|
||||
return False
|
||||
elif isinstance(models_config, list):
|
||||
if path in models_config:
|
||||
paths = list(models_config)
|
||||
paths.remove(path)
|
||||
# Don't allow removing all paths
|
||||
if not paths:
|
||||
return False
|
||||
self.set("paths.models", paths if len(paths) > 1 else paths[0])
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def cache_dir(self) -> Path:
|
||||
"""Get the cache directory path."""
|
||||
return Path(self.get("paths.cache", DEFAULT_CACHE_DIR))
|
||||
|
||||
@property
|
||||
def weights_dir(self) -> Optional[Path]:
|
||||
"""Get the custom weights directory path."""
|
||||
weights = self.get("paths.weights", "")
|
||||
return Path(weights) if weights else None
|
||||
|
||||
|
||||
# Global settings instance
|
||||
_settings: Optional[Settings] = None
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Get the global settings instance."""
|
||||
global _settings
|
||||
if _settings is None:
|
||||
_settings = Settings()
|
||||
return _settings
|
||||
|
||||
|
||||
def reset_settings() -> None:
|
||||
"""Reset the global settings instance."""
|
||||
global _settings
|
||||
_settings = None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,547 @@
|
||||
"""
|
||||
Main entry point for kt-cli.
|
||||
|
||||
KTransformers CLI - A unified command-line interface for KTransformers.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
# Suppress numpy subnormal warnings
|
||||
warnings.filterwarnings("ignore", message="The value of the smallest subnormal")
|
||||
|
||||
import typer
|
||||
|
||||
from kt_kernel.cli import __version__
|
||||
from kt_kernel.cli.commands import bench, chat, config, doctor, model, quant, run, sft, version
|
||||
from kt_kernel.cli.i18n import t, set_lang, get_lang
|
||||
|
||||
|
||||
def _get_app_help() -> str:
|
||||
"""Get app help text based on current language."""
|
||||
lang = get_lang()
|
||||
if lang == "zh":
|
||||
return "KTransformers CLI - KTransformers 统一命令行界面"
|
||||
return "KTransformers CLI - A unified command-line interface for KTransformers."
|
||||
|
||||
|
||||
def _get_help(key: str) -> str:
|
||||
"""Get help text based on current language."""
|
||||
help_texts = {
|
||||
"version": {"en": "Show version information", "zh": "显示版本信息"},
|
||||
"run": {"en": "Start model inference server", "zh": "启动模型推理服务器"},
|
||||
"chat": {"en": "Interactive chat with running model", "zh": "与运行中的模型进行交互式聊天"},
|
||||
"quant": {"en": "Quantize model weights", "zh": "量化模型权重"},
|
||||
"edit": {"en": "Edit model information", "zh": "编辑模型信息"},
|
||||
"bench": {"en": "Run full benchmark", "zh": "运行完整基准测试"},
|
||||
"microbench": {"en": "Run micro-benchmark", "zh": "运行微基准测试"},
|
||||
"doctor": {"en": "Diagnose environment issues", "zh": "诊断环境问题"},
|
||||
"model": {"en": "Manage models and storage paths", "zh": "管理模型和存储路径"},
|
||||
"config": {"en": "Manage configuration", "zh": "管理配置"},
|
||||
"sft": {"en": "Fine-tuning with LlamaFactory", "zh": "使用 LlamaFactory 进行微调"},
|
||||
}
|
||||
lang = get_lang()
|
||||
return help_texts.get(key, {}).get(lang, help_texts.get(key, {}).get("en", key))
|
||||
|
||||
|
||||
# Create main app with dynamic help
|
||||
app = typer.Typer(
|
||||
name="kt",
|
||||
help="KTransformers CLI - A unified command-line interface for KTransformers.",
|
||||
no_args_is_help=False, # Handle no-args case manually to support first-run setup
|
||||
add_completion=False, # Use static completion scripts instead of dynamic completion
|
||||
rich_markup_mode="rich",
|
||||
)
|
||||
|
||||
|
||||
def _update_help_texts() -> None:
|
||||
"""Update all help texts based on current language setting."""
|
||||
# Update main app help
|
||||
app.info.help = _get_app_help()
|
||||
|
||||
# Update command help texts
|
||||
for cmd_info in app.registered_commands:
|
||||
# cmd_info is a CommandInfo object
|
||||
if hasattr(cmd_info, "name") and cmd_info.name:
|
||||
cmd_info.help = _get_help(cmd_info.name)
|
||||
|
||||
# Update sub-app help texts
|
||||
for group_info in app.registered_groups:
|
||||
if hasattr(group_info, "name") and group_info.name:
|
||||
group_info.help = _get_help(group_info.name)
|
||||
|
||||
|
||||
# Commands are registered later after tui_command is defined
|
||||
|
||||
|
||||
def check_first_run() -> None:
|
||||
"""Check if this is the first run and prompt for language setup."""
|
||||
import os
|
||||
|
||||
# Skip if not running in interactive terminal
|
||||
if not sys.stdin.isatty():
|
||||
return
|
||||
|
||||
from kt_kernel.cli.config.settings import DEFAULT_CONFIG_FILE
|
||||
|
||||
# Only check if config file exists - don't create it yet
|
||||
if not DEFAULT_CONFIG_FILE.exists():
|
||||
# First run - show welcome and language selection
|
||||
from kt_kernel.cli.config.settings import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
_show_first_run_setup(settings)
|
||||
else:
|
||||
# Config exists - check if initialized
|
||||
from kt_kernel.cli.config.settings import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.get("general._initialized"):
|
||||
_show_first_run_setup(settings)
|
||||
|
||||
|
||||
def _show_first_run_setup(settings) -> None:
|
||||
"""Show first-run setup wizard."""
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Prompt, Confirm
|
||||
from rich.spinner import Spinner
|
||||
from rich.live import Live
|
||||
|
||||
from kt_kernel.cli.utils.environment import scan_storage_locations, format_size_gb
|
||||
|
||||
console = Console()
|
||||
|
||||
# Welcome message
|
||||
console.print()
|
||||
console.print(
|
||||
Panel.fit(
|
||||
"[bold cyan]Welcome to KTransformers CLI! / 欢迎使用 KTransformers CLI![/bold cyan]\n\n"
|
||||
"Let's set up your preferences.\n"
|
||||
"让我们设置您的偏好。",
|
||||
title="kt-cli",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
# Language selection
|
||||
console.print("[bold]Select your preferred language / 选择您的首选语言:[/bold]")
|
||||
console.print()
|
||||
console.print(" [cyan][1][/cyan] English")
|
||||
console.print(" [cyan][2][/cyan] 中文 (Chinese)")
|
||||
console.print()
|
||||
|
||||
choice = Prompt.ask("Enter choice / 输入选择", choices=["1", "2"], default="1")
|
||||
lang = "en" if choice == "1" else "zh"
|
||||
|
||||
# Save language setting
|
||||
settings.set("general.language", lang)
|
||||
set_lang(lang)
|
||||
|
||||
# Confirmation message
|
||||
console.print()
|
||||
if lang == "zh":
|
||||
console.print("[green]✓[/green] 语言已设置为中文")
|
||||
else:
|
||||
console.print("[green]✓[/green] Language set to English")
|
||||
|
||||
# Model discovery section
|
||||
console.print()
|
||||
if lang == "zh":
|
||||
console.print("[bold]发现模型权重[/bold]")
|
||||
console.print()
|
||||
console.print("[dim]扫描系统中已有的模型权重文件,以便快速添加到模型列表。[/dim]")
|
||||
console.print()
|
||||
console.print(" [cyan][1][/cyan] 全局扫描 (自动扫描所有非系统路径)")
|
||||
console.print(" [cyan][2][/cyan] 手动指定路径 (可添加多个)")
|
||||
console.print(" [cyan][3][/cyan] 跳过 (稍后手动添加)")
|
||||
console.print()
|
||||
scan_choice = Prompt.ask("选择扫描方式", choices=["1", "2", "3"], default="1")
|
||||
else:
|
||||
console.print("[bold]Discover Model Weights[/bold]")
|
||||
console.print()
|
||||
console.print("[dim]Scan existing model weights on your system to quickly add them to the model list.[/dim]")
|
||||
console.print()
|
||||
console.print(" [cyan][1][/cyan] Global scan (auto-scan all non-system paths)")
|
||||
console.print(" [cyan][2][/cyan] Manual paths (add multiple paths)")
|
||||
console.print(" [cyan][3][/cyan] Skip (add manually later)")
|
||||
console.print()
|
||||
scan_choice = Prompt.ask("Select scan method", choices=["1", "2", "3"], default="1")
|
||||
|
||||
if scan_choice == "1":
|
||||
# Global scan
|
||||
from kt_kernel.cli.utils.model_discovery import discover_and_register_global, format_discovery_summary
|
||||
|
||||
console.print()
|
||||
try:
|
||||
total_found, new_found, registered = discover_and_register_global(
|
||||
min_size_gb=2.0, max_depth=6, show_progress=True, lang=lang
|
||||
)
|
||||
|
||||
format_discovery_summary(
|
||||
total_found=total_found,
|
||||
new_found=new_found,
|
||||
registered=registered,
|
||||
lang=lang,
|
||||
show_models=True,
|
||||
max_show=10,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Warning: Scan failed - {e}[/yellow]")
|
||||
|
||||
elif scan_choice == "2":
|
||||
# Manual path specification
|
||||
from kt_kernel.cli.utils.model_discovery import discover_and_register_path
|
||||
import os
|
||||
|
||||
discovered_paths = set() # Track paths discovered in this session
|
||||
total_registered = []
|
||||
|
||||
while True:
|
||||
console.print()
|
||||
if lang == "zh":
|
||||
path = Prompt.ask("输入要扫描的路径 (例如: /mnt/data/models)")
|
||||
else:
|
||||
path = Prompt.ask("Enter path to scan (e.g., /mnt/data/models)")
|
||||
|
||||
# Expand and validate path
|
||||
path = os.path.expanduser(path)
|
||||
|
||||
if not os.path.exists(path):
|
||||
if lang == "zh":
|
||||
console.print(f"[yellow]警告: 路径不存在: {path}[/yellow]")
|
||||
else:
|
||||
console.print(f"[yellow]Warning: Path does not exist: {path}[/yellow]")
|
||||
continue
|
||||
|
||||
if not os.path.isdir(path):
|
||||
if lang == "zh":
|
||||
console.print(f"[yellow]警告: 不是一个目录: {path}[/yellow]")
|
||||
else:
|
||||
console.print(f"[yellow]Warning: Not a directory: {path}[/yellow]")
|
||||
continue
|
||||
|
||||
# Scan this path
|
||||
console.print()
|
||||
try:
|
||||
total_found, new_found, registered = discover_and_register_path(
|
||||
path=path, min_size_gb=2.0, existing_paths=discovered_paths, show_progress=True, lang=lang
|
||||
)
|
||||
|
||||
# Update discovered paths
|
||||
for model in registered:
|
||||
discovered_paths.add(model.path)
|
||||
total_registered.extend(registered)
|
||||
|
||||
console.print()
|
||||
if lang == "zh":
|
||||
console.print(f"[green]✓[/green] 在此路径找到 {total_found} 个模型,其中 {new_found} 个为新模型")
|
||||
else:
|
||||
console.print(f"[green]✓[/green] Found {total_found} models in this path, {new_found} are new")
|
||||
|
||||
if new_found > 0:
|
||||
for model in registered[:5]:
|
||||
console.print(f" • {model.name} ({model.format})")
|
||||
|
||||
if len(registered) > 5:
|
||||
if lang == "zh":
|
||||
console.print(f" [dim]... 还有 {len(registered) - 5} 个新模型[/dim]")
|
||||
else:
|
||||
console.print(f" [dim]... and {len(registered) - 5} more new models[/dim]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error scanning path: {e}[/red]")
|
||||
|
||||
# Ask if continue
|
||||
console.print()
|
||||
if lang == "zh":
|
||||
continue_scan = Confirm.ask("是否继续添加其他路径?", default=False)
|
||||
else:
|
||||
continue_scan = Confirm.ask("Continue adding more paths?", default=False)
|
||||
|
||||
if not continue_scan:
|
||||
break
|
||||
|
||||
if total_registered:
|
||||
console.print()
|
||||
if lang == "zh":
|
||||
console.print(f"[green]✓[/green] 总共发现 {len(total_registered)} 个新模型")
|
||||
else:
|
||||
console.print(f"[green]✓[/green] Total {len(total_registered)} new models discovered")
|
||||
|
||||
# Model storage path selection
|
||||
console.print()
|
||||
console.print(f"[bold]{t('setup_model_path_title')}[/bold]")
|
||||
console.print()
|
||||
console.print(f"[dim]{t('setup_model_path_desc')}[/dim]")
|
||||
console.print()
|
||||
|
||||
# Scan for storage locations
|
||||
console.print(f"[dim]{t('setup_scanning_disks')}[/dim]")
|
||||
locations = scan_storage_locations(min_size_gb=50.0)
|
||||
console.print()
|
||||
|
||||
if locations:
|
||||
# Show storage location options
|
||||
for i, loc in enumerate(locations[:5], 1): # Show top 5 options
|
||||
available = format_size_gb(loc.available_gb)
|
||||
total = format_size_gb(loc.total_gb)
|
||||
|
||||
# Build the option string
|
||||
if i == 1:
|
||||
option_str = t("setup_disk_option_recommended", path=loc.path, available=available, total=total)
|
||||
else:
|
||||
option_str = t("setup_disk_option", path=loc.path, available=available, total=total)
|
||||
|
||||
console.print(f" [cyan][{i}][/cyan] {option_str}")
|
||||
|
||||
# Custom path option
|
||||
custom_idx = min(len(locations), 5) + 1
|
||||
console.print(f" [cyan][{custom_idx}][/cyan] {t('setup_custom_path')}")
|
||||
console.print()
|
||||
|
||||
valid_choices = [str(i) for i in range(1, custom_idx + 1)]
|
||||
path_choice = Prompt.ask(t("prompt_select"), choices=valid_choices, default="1")
|
||||
|
||||
if path_choice == str(custom_idx):
|
||||
# Custom path
|
||||
selected_path = _prompt_custom_path(console, settings)
|
||||
else:
|
||||
selected_path = locations[int(path_choice) - 1].path
|
||||
else:
|
||||
# No large storage found, ask for custom path
|
||||
console.print(f"[yellow]{t('setup_no_large_disk')}[/yellow]")
|
||||
console.print()
|
||||
selected_path = _prompt_custom_path(console, settings)
|
||||
|
||||
# Ensure the path exists
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
if not os.path.exists(selected_path):
|
||||
if Confirm.ask(t("setup_path_not_exist"), default=True):
|
||||
try:
|
||||
Path(selected_path).mkdir(parents=True, exist_ok=True)
|
||||
except (OSError, PermissionError) as e:
|
||||
console.print(f"[red]{t('error')}: {e}[/red]")
|
||||
# Fall back to default
|
||||
selected_path = str(Path.home() / ".ktransformers" / "models")
|
||||
Path(selected_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Check available space and warn if low
|
||||
from kt_kernel.cli.utils.environment import detect_disk_space_gb
|
||||
|
||||
available_gb, _ = detect_disk_space_gb(
|
||||
selected_path if os.path.exists(selected_path) else str(Path(selected_path).parent)
|
||||
)
|
||||
if available_gb < 100:
|
||||
console.print(f"[yellow]{t('setup_path_low_space')}[/yellow]")
|
||||
|
||||
# Save the path
|
||||
settings.set("paths.models", selected_path)
|
||||
settings.set("general._initialized", True)
|
||||
|
||||
console.print()
|
||||
console.print(f"[green]✓[/green] {t('setup_model_path_set', path=selected_path)}")
|
||||
console.print()
|
||||
|
||||
# Tips
|
||||
if lang == "zh":
|
||||
console.print("[dim]提示: 运行 'kt config show' 查看所有配置[/dim]")
|
||||
else:
|
||||
console.print("[dim]Tip: Run 'kt config show' to view all settings[/dim]")
|
||||
|
||||
console.print()
|
||||
|
||||
|
||||
def _prompt_custom_path(console, settings) -> str:
|
||||
"""Prompt user to enter a custom path."""
|
||||
from rich.prompt import Prompt
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
default_path = str(Path.home() / ".ktransformers" / "models")
|
||||
|
||||
while True:
|
||||
custom_path = Prompt.ask(t("setup_enter_custom_path"), default=default_path)
|
||||
|
||||
# Expand user home
|
||||
custom_path = os.path.expanduser(custom_path)
|
||||
|
||||
# Check if path exists or parent is writable
|
||||
if os.path.exists(custom_path):
|
||||
if os.access(custom_path, os.W_OK):
|
||||
return custom_path
|
||||
else:
|
||||
console.print(f"[red]{t('setup_path_no_write')}[/red]")
|
||||
else:
|
||||
# Check if we can create it (parent writable)
|
||||
parent = str(Path(custom_path).parent)
|
||||
while not os.path.exists(parent) and parent != "/":
|
||||
parent = str(Path(parent).parent)
|
||||
|
||||
if os.access(parent, os.W_OK):
|
||||
return custom_path
|
||||
else:
|
||||
console.print(f"[red]{t('setup_path_no_write')}[/red]")
|
||||
|
||||
|
||||
def _install_shell_completion() -> None:
|
||||
"""Install shell completion scripts to user directories.
|
||||
|
||||
Uses standard locations that are auto-loaded by shell completion systems:
|
||||
- Bash: ~/.local/share/bash-completion/completions/kt (auto-loaded by bash-completion 2.0+)
|
||||
- Zsh: ~/.zfunc/_kt (requires fpath setup, but commonly used)
|
||||
- Fish: ~/.config/fish/completions/kt.fish (auto-loaded)
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from kt_kernel.cli.config.settings import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Check if already installed
|
||||
if settings.get("general._completion_installed", False):
|
||||
return
|
||||
|
||||
# Detect current shell
|
||||
shell = os.environ.get("SHELL", "")
|
||||
shell_name = "zsh" if "zsh" in shell else "fish" if "fish" in shell else "bash"
|
||||
|
||||
try:
|
||||
cli_dir = Path(__file__).parent
|
||||
completions_dir = cli_dir / "completions"
|
||||
home = Path.home()
|
||||
|
||||
def install_completion(src_name: str, dest_dir: Path, dest_name: str) -> None:
|
||||
"""Install completion file from source to destination."""
|
||||
src_file = completions_dir / src_name
|
||||
if src_file.exists():
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src_file, dest_dir / dest_name)
|
||||
|
||||
if shell_name == "bash":
|
||||
install_completion(
|
||||
"kt-completion.bash", home / ".local" / "share" / "bash-completion" / "completions", "kt"
|
||||
)
|
||||
elif shell_name == "zsh":
|
||||
install_completion("_kt", home / ".zfunc", "_kt")
|
||||
elif shell_name == "fish":
|
||||
install_completion("kt.fish", home / ".config" / "fish" / "completions", "kt.fish")
|
||||
|
||||
# Mark as installed
|
||||
settings.set("general._completion_installed", True)
|
||||
|
||||
# For bash/zsh, completion will work in new terminals automatically
|
||||
# (bash-completion 2.0+ auto-loads from ~/.local/share/bash-completion/completions/)
|
||||
|
||||
except (OSError, IOError):
|
||||
# Silently ignore errors - completion is not critical
|
||||
pass
|
||||
|
||||
|
||||
def _apply_saved_language() -> None:
|
||||
"""Apply the saved language setting.
|
||||
|
||||
Priority:
|
||||
1. KT_LANG environment variable (if already set, don't override)
|
||||
2. Config file setting
|
||||
3. System locale (auto)
|
||||
"""
|
||||
import os
|
||||
|
||||
# Don't override if KT_LANG is already set by user
|
||||
if os.environ.get("KT_LANG"):
|
||||
return
|
||||
|
||||
from kt_kernel.cli.config.settings import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
lang = settings.get("general.language", "auto")
|
||||
|
||||
if lang != "auto":
|
||||
set_lang(lang)
|
||||
|
||||
|
||||
app.command(name="version", help="Show version information")(version.version)
|
||||
app.command(name="chat", help="Interactive chat with running model")(chat.chat)
|
||||
app.command(name="quant", help="Quantize model weights")(quant.quant)
|
||||
app.command(name="edit", help="Edit model information")(model.edit_model)
|
||||
app.command(name="bench", help="Run full benchmark")(bench.bench)
|
||||
app.command(name="microbench", help="Run micro-benchmark")(bench.microbench)
|
||||
app.command(name="doctor", help="Diagnose environment issues")(doctor.doctor)
|
||||
|
||||
# Register sub-apps
|
||||
app.add_typer(model.app, name="model", help="Manage models and storage paths")
|
||||
app.add_typer(config.app, name="config", help="Manage configuration")
|
||||
app.add_typer(sft.app, name="sft", help="Fine-tuning with LlamaFactory")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
# Apply saved language setting first (before anything else for correct help display)
|
||||
_apply_saved_language()
|
||||
|
||||
# Update help texts based on language
|
||||
_update_help_texts()
|
||||
|
||||
# Check for first run (but not for certain commands)
|
||||
# Skip first-run check for: --help, config commands, version
|
||||
args = sys.argv[1:] if len(sys.argv) > 1 else []
|
||||
skip_commands = ["--help", "-h", "config", "version", "--version", "--no-tui"]
|
||||
|
||||
should_check_first_run = True
|
||||
for arg in args:
|
||||
if arg in skip_commands:
|
||||
should_check_first_run = False
|
||||
break
|
||||
|
||||
# Handle no arguments case
|
||||
if not args:
|
||||
# Check if this is first run
|
||||
from kt_kernel.cli.config.settings import DEFAULT_CONFIG_FILE, get_settings
|
||||
|
||||
is_first_run = False
|
||||
if not DEFAULT_CONFIG_FILE.exists():
|
||||
is_first_run = True
|
||||
else:
|
||||
settings = get_settings()
|
||||
if not settings.get("general._initialized"):
|
||||
is_first_run = True
|
||||
|
||||
if is_first_run:
|
||||
# First run - start initialization
|
||||
_install_shell_completion()
|
||||
check_first_run()
|
||||
return
|
||||
else:
|
||||
# Not first run - show help
|
||||
app(["--help"])
|
||||
return
|
||||
|
||||
# Auto-install shell completion on first run
|
||||
if should_check_first_run:
|
||||
_install_shell_completion()
|
||||
|
||||
# Check first run before running commands
|
||||
if should_check_first_run:
|
||||
check_first_run()
|
||||
|
||||
# Handle "run" command specially to pass through unknown options
|
||||
if args and args[0] == "run":
|
||||
# Get args after "run"
|
||||
run_args = args[1:]
|
||||
# Use click command directly with ignore_unknown_options
|
||||
from kt_kernel.cli.commands import run as run_module
|
||||
|
||||
sys.exit(run_module.run.main(args=run_args, standalone_mode=False))
|
||||
|
||||
app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
# Inference dependencies for KTransformers
|
||||
# NOTE: sglang is installed separately from source (see install.py)
|
||||
|
||||
transformers>=4.45.0
|
||||
safetensors>=0.4.0
|
||||
huggingface-hub>=0.20.0
|
||||
@@ -0,0 +1,7 @@
|
||||
# SFT (Supervised Fine-Tuning) dependencies for KTransformers
|
||||
|
||||
llamafactory>=0.9.0
|
||||
peft>=0.12.0
|
||||
transformers>=4.45.0
|
||||
datasets>=2.14.0
|
||||
accelerate>=0.30.0
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Utility modules for kt-cli.
|
||||
"""
|
||||
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
快速分析 MoE 模型 - 基于 config.json
|
||||
(复用 sglang 的模型注册表和判断逻辑)
|
||||
"""
|
||||
import json
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
def _get_sglang_moe_architectures():
|
||||
"""
|
||||
从 sglang 的模型注册表获取所有 MoE 架构
|
||||
|
||||
复用 sglang 的代码,这样 sglang 更新后自动支持新模型
|
||||
"""
|
||||
try:
|
||||
import sys
|
||||
|
||||
# 添加 sglang 路径到 sys.path
|
||||
sglang_path = Path("/mnt/data2/ljq/sglang/python")
|
||||
if sglang_path.exists() and str(sglang_path) not in sys.path:
|
||||
sys.path.insert(0, str(sglang_path))
|
||||
|
||||
# 直接导入 sglang 的 ModelRegistry
|
||||
# 注意:这需要 sglang 及其依赖正确安装
|
||||
from sglang.srt.models.registry import ModelRegistry
|
||||
|
||||
# 获取所有支持的架构
|
||||
supported_archs = ModelRegistry.get_supported_archs()
|
||||
|
||||
# 过滤出 MoE 模型(名称包含 Moe)
|
||||
moe_archs = {arch for arch in supported_archs if "Moe" in arch or "moe" in arch.lower()}
|
||||
|
||||
# 手动添加一些不带 "Moe" 字样但是 MoE 模型的架构
|
||||
# DeepSeek V2/V3 系列
|
||||
deepseek_moe = {arch for arch in supported_archs if arch.startswith("Deepseek") or arch.startswith("deepseek")}
|
||||
moe_archs.update(deepseek_moe)
|
||||
|
||||
# DBRX 也是 MoE 模型
|
||||
dbrx_moe = {arch for arch in supported_archs if "DBRX" in arch or "dbrx" in arch.lower()}
|
||||
moe_archs.update(dbrx_moe)
|
||||
|
||||
# Grok 也是 MoE 模型
|
||||
grok_moe = {arch for arch in supported_archs if "Grok" in arch or "grok" in arch.lower()}
|
||||
moe_archs.update(grok_moe)
|
||||
|
||||
return moe_archs
|
||||
except Exception as e:
|
||||
# 如果 sglang 不可用,返回空集合
|
||||
# 这种情况下,后续会使用配置文件中的其他判断方法
|
||||
import warnings
|
||||
|
||||
warnings.warn(f"Failed to load MoE architectures from sglang: {e}. Using fallback detection methods.")
|
||||
return set()
|
||||
|
||||
|
||||
# 获取 MoE 架构列表(优先从 sglang 获取)
|
||||
MOE_ARCHITECTURES = _get_sglang_moe_architectures()
|
||||
|
||||
|
||||
def _get_cache_file():
|
||||
"""获取集中式缓存文件路径"""
|
||||
cache_dir = Path.home() / ".ktransformers" / "cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_dir / "moe_analysis_v2.json"
|
||||
|
||||
|
||||
def _load_all_cache():
|
||||
"""加载所有缓存数据"""
|
||||
cache_file = _get_cache_file()
|
||||
if not cache_file.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(cache_file, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_all_cache(cache_data):
|
||||
"""保存所有缓存数据"""
|
||||
cache_file = _get_cache_file()
|
||||
try:
|
||||
with open(cache_file, "w") as f:
|
||||
json.dump(cache_data, f, indent=2)
|
||||
except Exception as e:
|
||||
import warnings
|
||||
|
||||
warnings.warn(f"Failed to save MoE cache: {e}")
|
||||
|
||||
|
||||
def _compute_config_fingerprint(config_path: Path) -> Optional[str]:
|
||||
"""计算 config.json 指纹"""
|
||||
if not config_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
stat = config_path.stat()
|
||||
# 使用文件大小和修改时间作为指纹
|
||||
fingerprint_str = f"{config_path.name}:{stat.st_size}:{int(stat.st_mtime)}"
|
||||
return hashlib.md5(fingerprint_str.encode()).hexdigest()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _load_cache(model_path: Path) -> Optional[Dict[str, Any]]:
|
||||
"""加载指定模型的缓存"""
|
||||
model_path_str = str(model_path.resolve())
|
||||
all_cache = _load_all_cache()
|
||||
|
||||
if model_path_str not in all_cache:
|
||||
return None
|
||||
|
||||
try:
|
||||
cache_entry = all_cache[model_path_str]
|
||||
|
||||
# 验证缓存版本
|
||||
cache_version = cache_entry.get("cache_version", 0)
|
||||
if cache_version != 2:
|
||||
return None
|
||||
|
||||
# 验证 config.json 指纹
|
||||
config_path = model_path / "config.json"
|
||||
current_fingerprint = _compute_config_fingerprint(config_path)
|
||||
if cache_entry.get("fingerprint") != current_fingerprint:
|
||||
return None
|
||||
|
||||
return cache_entry.get("result")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _save_cache(model_path: Path, result: Dict[str, Any]):
|
||||
"""保存指定模型的缓存"""
|
||||
model_path_str = str(model_path.resolve())
|
||||
|
||||
try:
|
||||
config_path = model_path / "config.json"
|
||||
fingerprint = _compute_config_fingerprint(config_path)
|
||||
|
||||
all_cache = _load_all_cache()
|
||||
|
||||
all_cache[model_path_str] = {
|
||||
"fingerprint": fingerprint,
|
||||
"result": result,
|
||||
"cache_version": 2,
|
||||
"last_updated": __import__("datetime").datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
_save_all_cache(all_cache)
|
||||
except Exception as e:
|
||||
import warnings
|
||||
|
||||
warnings.warn(f"Failed to save MoE cache for {model_path}: {e}")
|
||||
|
||||
|
||||
def _load_config_json(model_path: Path) -> Optional[Dict[str, Any]]:
|
||||
"""读取 config.json 文件
|
||||
|
||||
参考 sglang 的 get_config() 实现
|
||||
"""
|
||||
config_path = model_path / "config.json"
|
||||
|
||||
if not config_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
return config
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_moe_model(config: Dict[str, Any]) -> bool:
|
||||
"""判断是否是 MoE 模型
|
||||
|
||||
参考 sglang 的模型注册表和架构识别方式
|
||||
"""
|
||||
# 方法1: 检查架构名称
|
||||
architectures = config.get("architectures", [])
|
||||
if any(arch in MOE_ARCHITECTURES for arch in architectures):
|
||||
return True
|
||||
|
||||
# 方法2: 检查是否有 MoE 相关字段(Mistral 格式)
|
||||
if config.get("moe"):
|
||||
return True
|
||||
|
||||
# 方法3: 检查是否有 num_experts 或其变体字段
|
||||
# 需要检查 text_config(对于某些多模态模型)
|
||||
text_config = config.get("text_config", config)
|
||||
|
||||
# 检查各种专家数量字段
|
||||
if (
|
||||
text_config.get("num_experts") or text_config.get("num_local_experts") or text_config.get("n_routed_experts")
|
||||
): # Kimi-K2 使用这个字段
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _extract_moe_params(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""从 config 中提取 MoE 参数
|
||||
|
||||
参考 sglang 的各种 MoE 模型实现
|
||||
"""
|
||||
# 处理嵌套的 text_config
|
||||
text_config = config.get("text_config", config)
|
||||
|
||||
# 提取基本参数
|
||||
result = {
|
||||
"architectures": config.get("architectures", []),
|
||||
"model_type": config.get("model_type", "unknown"),
|
||||
}
|
||||
|
||||
# 专家数量(不同模型字段名不同)
|
||||
num_experts = (
|
||||
text_config.get("num_experts") # Qwen2/3 MoE, DeepSeek V2
|
||||
or text_config.get("num_local_experts") # Mixtral
|
||||
or text_config.get("n_routed_experts") # Kimi-K2, DeepSeek V3
|
||||
or config.get("moe", {}).get("num_experts") # Mistral 格式
|
||||
)
|
||||
|
||||
# 每个 token 激活的专家数
|
||||
num_experts_per_tok = (
|
||||
text_config.get("num_experts_per_tok")
|
||||
or text_config.get("num_experts_per_token")
|
||||
or config.get("moe", {}).get("num_experts_per_tok")
|
||||
or 2 # 默认值
|
||||
)
|
||||
|
||||
# 层数
|
||||
num_hidden_layers = text_config.get("num_hidden_layers") or text_config.get("n_layer") or 0
|
||||
|
||||
# 隐藏层维度
|
||||
hidden_size = text_config.get("hidden_size") or text_config.get("d_model") or 0
|
||||
|
||||
# MoE 专家中间层大小
|
||||
moe_intermediate_size = (
|
||||
text_config.get("moe_intermediate_size")
|
||||
or text_config.get("intermediate_size") # 如果没有特殊的 moe_intermediate_size
|
||||
or 0
|
||||
)
|
||||
|
||||
# 共享专家中间层大小(Qwen2/3 MoE)
|
||||
shared_expert_intermediate_size = text_config.get("shared_expert_intermediate_size", 0)
|
||||
|
||||
result.update(
|
||||
{
|
||||
"num_experts": num_experts or 0,
|
||||
"num_experts_per_tok": num_experts_per_tok,
|
||||
"num_hidden_layers": num_hidden_layers,
|
||||
"hidden_size": hidden_size,
|
||||
"moe_intermediate_size": moe_intermediate_size,
|
||||
"shared_expert_intermediate_size": shared_expert_intermediate_size,
|
||||
}
|
||||
)
|
||||
|
||||
# 提取其他有用的参数
|
||||
result["num_attention_heads"] = text_config.get("num_attention_heads", 0)
|
||||
result["num_key_value_heads"] = text_config.get("num_key_value_heads", 0)
|
||||
result["vocab_size"] = text_config.get("vocab_size", 0)
|
||||
result["max_position_embeddings"] = text_config.get("max_position_embeddings", 0)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _estimate_model_size(model_path: Path) -> float:
|
||||
"""估算模型总大小(GB)
|
||||
|
||||
快速统计 safetensors 文件总大小
|
||||
"""
|
||||
try:
|
||||
total_size = 0
|
||||
for file_path in model_path.glob("*.safetensors"):
|
||||
total_size += file_path.stat().st_size
|
||||
return total_size / (1024**3)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def analyze_moe_model(model_path, use_cache=True):
|
||||
"""
|
||||
快速分析 MoE 模型 - 只读取 config.json
|
||||
|
||||
参数:
|
||||
model_path: 模型路径(字符串或Path对象)
|
||||
use_cache: 是否使用缓存(默认True)
|
||||
|
||||
返回:
|
||||
dict: {
|
||||
'is_moe': 是否是 MoE 模型,
|
||||
'num_experts': 专家总数,
|
||||
'num_experts_per_tok': 每个 token 激活的专家数,
|
||||
'num_hidden_layers': 层数,
|
||||
'hidden_size': 隐藏层维度,
|
||||
'moe_intermediate_size': MoE 专家中间层大小,
|
||||
'shared_expert_intermediate_size': 共享专家中间层大小,
|
||||
'architectures': 模型架构列表,
|
||||
'model_type': 模型类型,
|
||||
'total_size_gb': 模型总大小(估算,GB),
|
||||
'cached': 是否从缓存读取
|
||||
}
|
||||
如果不是 MoE 模型或失败,返回 None
|
||||
"""
|
||||
model_path = Path(model_path)
|
||||
|
||||
if not model_path.exists():
|
||||
return None
|
||||
|
||||
# 尝试加载缓存
|
||||
if use_cache:
|
||||
cached_result = _load_cache(model_path)
|
||||
if cached_result:
|
||||
cached_result["cached"] = True
|
||||
return cached_result
|
||||
|
||||
# 读取 config.json
|
||||
config = _load_config_json(model_path)
|
||||
if not config:
|
||||
return None
|
||||
|
||||
# 判断是否是 MoE 模型
|
||||
if not _is_moe_model(config):
|
||||
return None
|
||||
|
||||
# 提取 MoE 参数
|
||||
params = _extract_moe_params(config)
|
||||
|
||||
# 验证必要参数
|
||||
if params["num_experts"] == 0:
|
||||
return None
|
||||
|
||||
# 估算模型大小
|
||||
total_size_gb = _estimate_model_size(model_path)
|
||||
|
||||
# 组装结果
|
||||
result = {
|
||||
"is_moe": True,
|
||||
"num_experts": params["num_experts"],
|
||||
"num_experts_per_tok": params["num_experts_per_tok"],
|
||||
"num_hidden_layers": params["num_hidden_layers"],
|
||||
"hidden_size": params["hidden_size"],
|
||||
"moe_intermediate_size": params["moe_intermediate_size"],
|
||||
"shared_expert_intermediate_size": params["shared_expert_intermediate_size"],
|
||||
"architectures": params["architectures"],
|
||||
"model_type": params["model_type"],
|
||||
"total_size_gb": total_size_gb,
|
||||
"cached": False,
|
||||
# 额外参数
|
||||
"num_attention_heads": params.get("num_attention_heads", 0),
|
||||
"num_key_value_heads": params.get("num_key_value_heads", 0),
|
||||
"vocab_size": params.get("vocab_size", 0),
|
||||
}
|
||||
|
||||
# 保存缓存
|
||||
if use_cache:
|
||||
_save_cache(model_path, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def print_analysis(model_path):
|
||||
"""打印模型分析结果"""
|
||||
print(f"分析模型: {model_path}\n")
|
||||
|
||||
result = analyze_moe_model(model_path)
|
||||
|
||||
if result is None:
|
||||
print("不是 MoE 模型或分析失败")
|
||||
return
|
||||
|
||||
print("=" * 70)
|
||||
print("MoE 模型分析结果")
|
||||
if result.get("cached"):
|
||||
print("[使用缓存]")
|
||||
print("=" * 70)
|
||||
print(f"模型架构:")
|
||||
print(f" - 架构: {', '.join(result['architectures'])}")
|
||||
print(f" - 类型: {result['model_type']}")
|
||||
print()
|
||||
print(f"MoE 结构:")
|
||||
print(f" - 专家总数: {result['num_experts']}")
|
||||
print(f" - 激活专家数: {result['num_experts_per_tok']} experts/token")
|
||||
print(f" - 层数: {result['num_hidden_layers']}")
|
||||
print(f" - 隐藏维度: {result['hidden_size']}")
|
||||
print(f" - MoE 中间层: {result['moe_intermediate_size']}")
|
||||
if result["shared_expert_intermediate_size"] > 0:
|
||||
print(f" - 共享专家中间层: {result['shared_expert_intermediate_size']}")
|
||||
print()
|
||||
print(f"大小统计:")
|
||||
print(f" - 模型总大小: {result['total_size_gb']:.2f} GB")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
import sys
|
||||
|
||||
models = ["/mnt/data2/models/Qwen3-30B-A3B", "/mnt/data2/models/Qwen3-235B-A22B-Instruct-2507"]
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
models = [sys.argv[1]]
|
||||
|
||||
for model_path in models:
|
||||
print_analysis(model_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Console utilities for kt-cli.
|
||||
|
||||
Provides Rich-based console output helpers for consistent formatting.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
DownloadColumn,
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
TaskProgressColumn,
|
||||
TextColumn,
|
||||
TimeElapsedColumn,
|
||||
TimeRemainingColumn,
|
||||
TransferSpeedColumn,
|
||||
)
|
||||
from rich.prompt import Confirm, Prompt
|
||||
from rich.table import Table
|
||||
from rich.theme import Theme
|
||||
|
||||
from kt_kernel.cli.i18n import t
|
||||
|
||||
# Custom theme for kt-cli
|
||||
KT_THEME = Theme(
|
||||
{
|
||||
"info": "cyan",
|
||||
"warning": "yellow",
|
||||
"error": "bold red",
|
||||
"success": "bold green",
|
||||
"highlight": "bold magenta",
|
||||
"muted": "dim",
|
||||
}
|
||||
)
|
||||
|
||||
# Global console instance
|
||||
console = Console(theme=KT_THEME)
|
||||
|
||||
|
||||
def print_info(message: str, **kwargs) -> None:
|
||||
"""Print an info message."""
|
||||
console.print(f"[info]ℹ[/info] {message}", **kwargs)
|
||||
|
||||
|
||||
def print_success(message: str, **kwargs) -> None:
|
||||
"""Print a success message."""
|
||||
console.print(f"[success]✓[/success] {message}", **kwargs)
|
||||
|
||||
|
||||
def print_warning(message: str, **kwargs) -> None:
|
||||
"""Print a warning message."""
|
||||
console.print(f"[warning]⚠[/warning] {message}", **kwargs)
|
||||
|
||||
|
||||
def print_error(message: str, **kwargs) -> None:
|
||||
"""Print an error message."""
|
||||
console.print(f"[error]✗[/error] {message}", **kwargs)
|
||||
|
||||
|
||||
def print_step(message: str, **kwargs) -> None:
|
||||
"""Print a step indicator."""
|
||||
console.print(f"[highlight]→[/highlight] {message}", **kwargs)
|
||||
|
||||
|
||||
def print_header(title: str, subtitle: Optional[str] = None) -> None:
|
||||
"""Print a header panel."""
|
||||
content = f"[bold]{title}[/bold]"
|
||||
if subtitle:
|
||||
content += f"\n[muted]{subtitle}[/muted]"
|
||||
console.print(Panel(content, expand=False))
|
||||
|
||||
|
||||
def print_version_table(versions: dict[str, Optional[str]]) -> None:
|
||||
"""Print a version information table."""
|
||||
table = Table(show_header=False, box=None, padding=(0, 2))
|
||||
table.add_column("Component", style="bold")
|
||||
table.add_column("Version")
|
||||
|
||||
for name, version in versions.items():
|
||||
if version:
|
||||
table.add_row(name, f"[success]{version}[/success]")
|
||||
else:
|
||||
table.add_row(name, f"[muted]{t('version_not_installed')}[/muted]")
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def print_dependency_table(deps: list[dict]) -> None:
|
||||
"""Print a dependency status table."""
|
||||
table = Table(title=t("install_checking_deps"))
|
||||
table.add_column(t("version_info"), style="bold")
|
||||
table.add_column("Current")
|
||||
table.add_column("Required")
|
||||
table.add_column("Status")
|
||||
|
||||
for dep in deps:
|
||||
status = dep.get("status", "ok")
|
||||
if status == "ok":
|
||||
status_str = f"[success]{t('install_dep_ok')}[/success]"
|
||||
elif status == "outdated":
|
||||
status_str = f"[warning]{t('install_dep_outdated')}[/warning]"
|
||||
else:
|
||||
status_str = f"[error]{t('install_dep_missing')}[/error]"
|
||||
|
||||
table.add_row(
|
||||
dep["name"],
|
||||
dep.get("installed", "-"),
|
||||
dep.get("required", "-"),
|
||||
status_str,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def confirm(message: str, default: bool = True) -> bool:
|
||||
"""Ask for confirmation."""
|
||||
return Confirm.ask(message, default=default, console=console)
|
||||
|
||||
|
||||
def prompt_choice(message: str, choices: list[str], default: Optional[str] = None) -> str:
|
||||
"""Prompt for a choice from a list."""
|
||||
# Display numbered choices
|
||||
console.print(f"\n[bold]{message}[/bold]")
|
||||
for i, choice in enumerate(choices, 1):
|
||||
console.print(f" [highlight][{i}][/highlight] {choice}")
|
||||
|
||||
while True:
|
||||
response = Prompt.ask(
|
||||
"\n" + t("prompt_select"),
|
||||
console=console,
|
||||
default=str(choices.index(default) + 1) if default else None,
|
||||
)
|
||||
try:
|
||||
idx = int(response) - 1
|
||||
if 0 <= idx < len(choices):
|
||||
return choices[idx]
|
||||
except ValueError:
|
||||
# Check if response matches a choice directly
|
||||
if response in choices:
|
||||
return response
|
||||
|
||||
print_error(f"Please enter a number between 1 and {len(choices)}")
|
||||
|
||||
|
||||
def prompt_text(message: str, default: Optional[str] = None) -> str:
|
||||
"""Prompt for text input."""
|
||||
return Prompt.ask(message, console=console, default=default)
|
||||
|
||||
|
||||
def create_progress() -> Progress:
|
||||
"""Create a progress bar for general tasks."""
|
||||
return Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TaskProgressColumn(),
|
||||
TimeElapsedColumn(),
|
||||
console=console,
|
||||
)
|
||||
|
||||
|
||||
def create_download_progress() -> Progress:
|
||||
"""Create a progress bar for downloads."""
|
||||
return Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
DownloadColumn(),
|
||||
TransferSpeedColumn(),
|
||||
TimeRemainingColumn(),
|
||||
console=console,
|
||||
)
|
||||
|
||||
|
||||
def print_model_table(models: list[dict]) -> None:
|
||||
"""Print a table of models."""
|
||||
table = Table(title=t("download_list_title"))
|
||||
table.add_column("Name", style="bold")
|
||||
table.add_column("Repository")
|
||||
table.add_column("Type")
|
||||
table.add_column("Requirements")
|
||||
|
||||
for model in models:
|
||||
reqs = []
|
||||
if model.get("gpu_vram_gb"):
|
||||
reqs.append(f"GPU: {model['gpu_vram_gb']}GB")
|
||||
if model.get("cpu_ram_gb"):
|
||||
reqs.append(f"RAM: {model['cpu_ram_gb']}GB")
|
||||
|
||||
table.add_row(
|
||||
model.get("name", ""),
|
||||
model.get("hf_repo", ""),
|
||||
model.get("type", ""),
|
||||
", ".join(reqs) if reqs else "-",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def print_hardware_info(gpu_info: str, cpu_info: str, ram_info: str) -> None:
|
||||
"""Print hardware information."""
|
||||
table = Table(show_header=False, box=None)
|
||||
table.add_column("Icon", width=3)
|
||||
table.add_column("Info")
|
||||
|
||||
table.add_row("🖥️", gpu_info)
|
||||
table.add_row("💻", cpu_info)
|
||||
table.add_row("🧠", ram_info)
|
||||
|
||||
console.print(Panel(table, title="Hardware", expand=False))
|
||||
|
||||
|
||||
def print_server_info(
|
||||
mode: str, host: str, port: int, gpu_experts: int, cpu_threads: int
|
||||
) -> None:
|
||||
"""Print server startup information."""
|
||||
table = Table(show_header=False, box=None)
|
||||
table.add_column("Key", style="bold")
|
||||
table.add_column("Value")
|
||||
|
||||
table.add_row(t("run_server_mode").split(":")[0], mode)
|
||||
table.add_row("Host", host)
|
||||
table.add_row("Port", str(port))
|
||||
table.add_row(t("run_gpu_experts").split(":")[0], f"{gpu_experts}/layer")
|
||||
table.add_row(t("run_cpu_threads").split(":")[0], str(cpu_threads))
|
||||
|
||||
console.print(Panel(table, title=t("run_server_started"), expand=False, border_style="green"))
|
||||
|
||||
|
||||
def print_api_info(host: str, port: int) -> None:
|
||||
"""Print API endpoint information."""
|
||||
api_url = f"http://{host}:{port}"
|
||||
docs_url = f"http://{host}:{port}/docs"
|
||||
|
||||
console.print()
|
||||
console.print(f" {t('run_api_url', host=host, port=port)}")
|
||||
console.print(f" {t('run_docs_url', host=host, port=port)}")
|
||||
console.print()
|
||||
console.print(f" [muted]Test command:[/muted]")
|
||||
console.print(
|
||||
f" [dim]curl {api_url}/v1/chat/completions -H 'Content-Type: application/json' "
|
||||
f"-d '{{\"model\": \"default\", \"messages\": [{{\"role\": \"user\", \"content\": \"Hello\"}}]}}'[/dim]"
|
||||
)
|
||||
console.print()
|
||||
console.print(f" [muted]{t('run_stop_hint')}[/muted]")
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Debug utility to inspect saved run configurations.
|
||||
|
||||
Usage: python -m kt_kernel.cli.utils.debug_configs
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich import box
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def main():
|
||||
"""Show all saved configurations."""
|
||||
config_file = Path.home() / ".ktransformers" / "run_configs.yaml"
|
||||
|
||||
console.print()
|
||||
console.print(f"[bold]Configuration file:[/bold] {config_file}")
|
||||
console.print()
|
||||
|
||||
if not config_file.exists():
|
||||
console.print("[red]✗ Configuration file does not exist![/red]")
|
||||
console.print()
|
||||
console.print("No configurations have been saved yet.")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(config_file, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except Exception as e:
|
||||
console.print(f"[red]✗ Failed to load configuration file: {e}[/red]")
|
||||
return
|
||||
|
||||
console.print(f"[green]✓[/green] Configuration file loaded")
|
||||
console.print()
|
||||
|
||||
configs = data.get("configs", {})
|
||||
|
||||
if not configs:
|
||||
console.print("[yellow]No saved configurations found.[/yellow]")
|
||||
return
|
||||
|
||||
console.print(f"[bold]Found configurations for {len(configs)} model(s):[/bold]")
|
||||
console.print()
|
||||
|
||||
for model_id, model_configs in configs.items():
|
||||
console.print(f"[cyan]Model ID:[/cyan] {model_id}")
|
||||
console.print(f"[dim] {len(model_configs)} configuration(s)[/dim]")
|
||||
console.print()
|
||||
|
||||
if not model_configs:
|
||||
continue
|
||||
|
||||
# Display configs in a table
|
||||
table = Table(box=box.ROUNDED, show_header=True, header_style="bold cyan")
|
||||
table.add_column("#", justify="right", style="cyan")
|
||||
table.add_column("Name", style="white")
|
||||
table.add_column("Method", style="yellow")
|
||||
table.add_column("TP", justify="right", style="green")
|
||||
table.add_column("GPU Experts", justify="right", style="magenta")
|
||||
table.add_column("Created", style="dim")
|
||||
|
||||
for i, cfg in enumerate(model_configs, 1):
|
||||
method = cfg.get("inference_method", "?")
|
||||
kt_method = cfg.get("kt_method", "?")
|
||||
method_display = f"{method.upper()}"
|
||||
if method == "raw":
|
||||
method_display += f" ({cfg.get('raw_method', '?')})"
|
||||
elif method == "amx":
|
||||
method_display += f" ({kt_method})"
|
||||
|
||||
table.add_row(
|
||||
str(i),
|
||||
cfg.get("config_name", f"Config {i}"),
|
||||
method_display,
|
||||
str(cfg.get("tp_size", "?")),
|
||||
str(cfg.get("gpu_experts", "?")),
|
||||
cfg.get("created_at", "Unknown")[:19] if cfg.get("created_at") else "Unknown",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
# Also check user_models.yaml to show model names
|
||||
console.print("[bold]Checking model registry...[/bold]")
|
||||
console.print()
|
||||
|
||||
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
|
||||
|
||||
try:
|
||||
registry = UserModelRegistry()
|
||||
all_models = registry.list_models()
|
||||
|
||||
console.print(f"[green]✓[/green] Found {len(all_models)} registered model(s)")
|
||||
console.print()
|
||||
|
||||
# Map model IDs to names
|
||||
id_to_name = {m.id: m.name for m in all_models}
|
||||
|
||||
console.print("[bold]Model ID → Name mapping:[/bold]")
|
||||
console.print()
|
||||
|
||||
for model_id in configs.keys():
|
||||
model_name = id_to_name.get(model_id, "[red]Unknown (model not found in registry)[/red]")
|
||||
console.print(f" {model_id[:8]}... → {model_name}")
|
||||
|
||||
console.print()
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]⚠ Could not load model registry: {e}[/yellow]")
|
||||
console.print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Helper functions for interactive model download."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
import fnmatch
|
||||
|
||||
|
||||
def list_remote_files_hf(repo_id: str, use_mirror: bool = False) -> List[Dict[str, any]]:
|
||||
"""
|
||||
List files in a HuggingFace repository.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: 'path', 'size' (in bytes)
|
||||
"""
|
||||
from huggingface_hub import HfApi
|
||||
import os
|
||||
|
||||
# Set mirror if needed
|
||||
original_endpoint = os.environ.get("HF_ENDPOINT")
|
||||
if use_mirror and not original_endpoint:
|
||||
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
|
||||
|
||||
try:
|
||||
api = HfApi()
|
||||
files_info = api.list_repo_tree(repo_id=repo_id, recursive=True)
|
||||
|
||||
result = []
|
||||
for item in files_info:
|
||||
# Skip directories
|
||||
if hasattr(item, "type") and item.type == "directory":
|
||||
continue
|
||||
|
||||
# Get file info
|
||||
file_path = item.path if hasattr(item, "path") else str(item)
|
||||
file_size = item.size if hasattr(item, "size") else 0
|
||||
|
||||
result.append({"path": file_path, "size": file_size})
|
||||
|
||||
return result
|
||||
finally:
|
||||
# Restore original endpoint
|
||||
if use_mirror and not original_endpoint:
|
||||
os.environ.pop("HF_ENDPOINT", None)
|
||||
elif original_endpoint:
|
||||
os.environ["HF_ENDPOINT"] = original_endpoint
|
||||
|
||||
|
||||
def list_remote_files_ms(repo_id: str) -> List[Dict[str, any]]:
|
||||
"""
|
||||
List files in a ModelScope repository.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: 'path', 'size' (in bytes)
|
||||
"""
|
||||
from modelscope.hub.api import HubApi
|
||||
|
||||
api = HubApi()
|
||||
files_info = api.get_model_files(model_id=repo_id, recursive=True)
|
||||
|
||||
result = []
|
||||
for file_info in files_info:
|
||||
file_path = file_info.get("Name", file_info.get("Path", ""))
|
||||
file_size = file_info.get("Size", 0)
|
||||
|
||||
result.append({"path": file_path, "size": file_size})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def filter_files_by_pattern(files: List[Dict[str, any]], pattern: str) -> List[Dict[str, any]]:
|
||||
"""Filter files by glob pattern."""
|
||||
if pattern == "*":
|
||||
return files
|
||||
|
||||
filtered = []
|
||||
for file in files:
|
||||
# Check if filename matches pattern
|
||||
filename = Path(file["path"]).name
|
||||
full_path = file["path"]
|
||||
|
||||
if fnmatch.fnmatch(filename, pattern) or fnmatch.fnmatch(full_path, pattern):
|
||||
filtered.append(file)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def calculate_total_size(files: List[Dict[str, any]]) -> int:
|
||||
"""Calculate total size of files in bytes."""
|
||||
return sum(f["size"] for f in files)
|
||||
|
||||
|
||||
def format_file_list_table(files: List[Dict[str, any]], max_display: int = 10):
|
||||
"""Format file list as a table for display."""
|
||||
from rich.table import Table
|
||||
from kt_kernel.cli.utils.model_scanner import format_size
|
||||
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("File", style="cyan", overflow="fold")
|
||||
table.add_column("Size", justify="right")
|
||||
|
||||
# Show first max_display files
|
||||
for file in files[:max_display]:
|
||||
table.add_row(file["path"], format_size(file["size"]))
|
||||
|
||||
if len(files) > max_display:
|
||||
table.add_row(f"... and {len(files) - max_display} more files", "[dim]...[/dim]")
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def verify_repo_exists(repo_id: str, repo_type: str, use_mirror: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
Verify if a repository exists.
|
||||
|
||||
Returns:
|
||||
(exists: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
if repo_type == "huggingface":
|
||||
import os
|
||||
|
||||
original_endpoint = os.environ.get("HF_ENDPOINT")
|
||||
if use_mirror and not original_endpoint:
|
||||
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
|
||||
|
||||
from huggingface_hub import HfApi
|
||||
|
||||
try:
|
||||
api = HfApi()
|
||||
api.repo_info(repo_id=repo_id, repo_type="model")
|
||||
return True, "Repository found"
|
||||
finally:
|
||||
if use_mirror and not original_endpoint:
|
||||
os.environ.pop("HF_ENDPOINT", None)
|
||||
elif original_endpoint:
|
||||
os.environ["HF_ENDPOINT"] = original_endpoint
|
||||
|
||||
else: # modelscope
|
||||
from modelscope.hub.api import HubApi
|
||||
|
||||
api = HubApi()
|
||||
api.get_model(model_id=repo_id)
|
||||
return True, "Repository found"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Repository not found: {str(e)}"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Input validation utilities with retry mechanism.
|
||||
|
||||
Provides robust input validation with automatic retry on failure.
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Callable, Any
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def prompt_int_with_retry(
|
||||
message: str,
|
||||
default: Optional[int] = None,
|
||||
min_val: Optional[int] = None,
|
||||
max_val: Optional[int] = None,
|
||||
validator: Optional[Callable[[int], bool]] = None,
|
||||
validator_error_msg: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Prompt for integer input with validation and retry.
|
||||
|
||||
Args:
|
||||
message: Prompt message
|
||||
default: Default value (optional)
|
||||
min_val: Minimum allowed value (optional)
|
||||
max_val: Maximum allowed value (optional)
|
||||
validator: Custom validation function (optional)
|
||||
validator_error_msg: Error message for custom validator (optional)
|
||||
|
||||
Returns:
|
||||
Validated integer value
|
||||
"""
|
||||
while True:
|
||||
# Build prompt with default
|
||||
if default is not None:
|
||||
prompt_text = f"{message} [{default}]"
|
||||
else:
|
||||
prompt_text = message
|
||||
|
||||
# Get input
|
||||
user_input = Prompt.ask(prompt_text, default=str(default) if default is not None else None)
|
||||
|
||||
# Try to parse as integer
|
||||
try:
|
||||
value = int(user_input)
|
||||
except ValueError:
|
||||
console.print(f"[red]✗ Invalid input. Please enter a valid integer.[/red]")
|
||||
console.print()
|
||||
continue
|
||||
|
||||
# Validate range
|
||||
if min_val is not None and value < min_val:
|
||||
console.print(f"[red]✗ Value must be at least {min_val}[/red]")
|
||||
console.print()
|
||||
continue
|
||||
|
||||
if max_val is not None and value > max_val:
|
||||
console.print(f"[red]✗ Value must be at most {max_val}[/red]")
|
||||
console.print()
|
||||
continue
|
||||
|
||||
# Custom validation
|
||||
if validator is not None:
|
||||
if not validator(value):
|
||||
error_msg = validator_error_msg or "Invalid value"
|
||||
console.print(f"[red]✗ {error_msg}[/red]")
|
||||
console.print()
|
||||
continue
|
||||
|
||||
# All validations passed
|
||||
return value
|
||||
|
||||
|
||||
def prompt_float_with_retry(
|
||||
message: str,
|
||||
default: Optional[float] = None,
|
||||
min_val: Optional[float] = None,
|
||||
max_val: Optional[float] = None,
|
||||
) -> float:
|
||||
"""Prompt for float input with validation and retry.
|
||||
|
||||
Args:
|
||||
message: Prompt message
|
||||
default: Default value (optional)
|
||||
min_val: Minimum allowed value (optional)
|
||||
max_val: Maximum allowed value (optional)
|
||||
|
||||
Returns:
|
||||
Validated float value
|
||||
"""
|
||||
while True:
|
||||
# Build prompt with default
|
||||
if default is not None:
|
||||
prompt_text = f"{message} [{default}]"
|
||||
else:
|
||||
prompt_text = message
|
||||
|
||||
# Get input
|
||||
user_input = Prompt.ask(prompt_text, default=str(default) if default is not None else None)
|
||||
|
||||
# Try to parse as float
|
||||
try:
|
||||
value = float(user_input)
|
||||
except ValueError:
|
||||
console.print(f"[red]✗ Invalid input. Please enter a valid number.[/red]")
|
||||
console.print()
|
||||
continue
|
||||
|
||||
# Validate range
|
||||
if min_val is not None and value < min_val:
|
||||
console.print(f"[red]✗ Value must be at least {min_val}[/red]")
|
||||
console.print()
|
||||
continue
|
||||
|
||||
if max_val is not None and value > max_val:
|
||||
console.print(f"[red]✗ Value must be at most {max_val}[/red]")
|
||||
console.print()
|
||||
continue
|
||||
|
||||
# All validations passed
|
||||
return value
|
||||
|
||||
|
||||
def prompt_choice_with_retry(
|
||||
message: str,
|
||||
choices: List[str],
|
||||
default: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Prompt for choice input with validation and retry.
|
||||
|
||||
Args:
|
||||
message: Prompt message
|
||||
choices: List of valid choices
|
||||
default: Default choice (optional)
|
||||
|
||||
Returns:
|
||||
Selected choice
|
||||
"""
|
||||
while True:
|
||||
# Get input
|
||||
user_input = Prompt.ask(message, default=default)
|
||||
|
||||
# Validate choice
|
||||
if user_input not in choices:
|
||||
console.print(f"[red]✗ Invalid choice. Please select from: {', '.join(choices)}[/red]")
|
||||
console.print()
|
||||
continue
|
||||
|
||||
return user_input
|
||||
|
||||
|
||||
def prompt_int_list_with_retry(
|
||||
message: str,
|
||||
default: Optional[str] = None,
|
||||
min_val: Optional[int] = None,
|
||||
max_val: Optional[int] = None,
|
||||
validator: Optional[Callable[[List[int]], tuple[bool, Optional[str]]]] = None,
|
||||
) -> List[int]:
|
||||
"""Prompt for comma-separated integer list with validation and retry.
|
||||
|
||||
Args:
|
||||
message: Prompt message
|
||||
default: Default value as string (e.g., "0,1,2,3")
|
||||
min_val: Minimum allowed value for each integer (optional)
|
||||
max_val: Maximum allowed value for each integer (optional)
|
||||
validator: Custom validation function that returns (is_valid, error_message) (optional)
|
||||
|
||||
Returns:
|
||||
List of validated integers
|
||||
"""
|
||||
while True:
|
||||
# Get input
|
||||
user_input = Prompt.ask(message, default=default)
|
||||
|
||||
# Clean input: support Chinese comma and spaces
|
||||
user_input_cleaned = user_input.replace(",", ",").replace(" ", "")
|
||||
|
||||
# Try to parse as integers
|
||||
try:
|
||||
values = [int(x.strip()) for x in user_input_cleaned.split(",") if x.strip()]
|
||||
except ValueError:
|
||||
console.print(f"[red]✗ Invalid format. Please enter numbers separated by commas.[/red]")
|
||||
console.print()
|
||||
continue
|
||||
|
||||
# Validate each value's range
|
||||
invalid_values = []
|
||||
for value in values:
|
||||
if min_val is not None and value < min_val:
|
||||
invalid_values.append(value)
|
||||
elif max_val is not None and value > max_val:
|
||||
invalid_values.append(value)
|
||||
|
||||
if invalid_values:
|
||||
if min_val is not None and max_val is not None:
|
||||
console.print(f"[red]✗ Invalid value(s): {invalid_values}[/red]")
|
||||
console.print(f"[yellow]Valid range: {min_val}-{max_val}[/yellow]")
|
||||
elif min_val is not None:
|
||||
console.print(f"[red]✗ Value(s) must be at least {min_val}: {invalid_values}[/red]")
|
||||
elif max_val is not None:
|
||||
console.print(f"[red]✗ Value(s) must be at most {max_val}: {invalid_values}[/red]")
|
||||
console.print()
|
||||
continue
|
||||
|
||||
# Custom validation
|
||||
if validator is not None:
|
||||
is_valid, error_msg = validator(values)
|
||||
if not is_valid:
|
||||
console.print(f"[red]✗ {error_msg}[/red]")
|
||||
console.print()
|
||||
continue
|
||||
|
||||
# All validations passed
|
||||
return values
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
KV Cache Size Calculator for SGLang
|
||||
|
||||
This script calculates the KV cache size in GB for a given model and number of tokens.
|
||||
It follows the same logic as in sglang/srt/model_executor/model_runner.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import torch
|
||||
from transformers import AutoConfig
|
||||
|
||||
# Add sglang to path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "python"))
|
||||
|
||||
from sglang.srt.configs.model_config import ModelConfig, is_deepseek_nsa, get_nsa_index_head_dim
|
||||
from sglang.srt.mem_cache.memory_pool import NSATokenToKVPool
|
||||
|
||||
|
||||
def get_dtype_bytes(dtype_str: str) -> int:
|
||||
"""Get the number of bytes for a given dtype string."""
|
||||
dtype_map = {
|
||||
"float32": 4,
|
||||
"float16": 2,
|
||||
"bfloat16": 2,
|
||||
"float8_e4m3fn": 1,
|
||||
"float8_e5m2": 1,
|
||||
"auto": 2, # Usually defaults to bfloat16
|
||||
}
|
||||
return dtype_map.get(dtype_str, 2)
|
||||
|
||||
|
||||
def get_kv_size_gb(
|
||||
model_path: str,
|
||||
max_total_tokens: int,
|
||||
tp: int = 1,
|
||||
dtype: str = "auto",
|
||||
verbose: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
Calculate the KV cache size in GB for a given model and number of tokens.
|
||||
|
||||
Args:
|
||||
model_path: Path to the model
|
||||
max_total_tokens: Maximum number of tokens to cache
|
||||
tp: Tensor parallelism size
|
||||
dtype: Data type for KV cache (auto, float16, bfloat16, float8_e4m3fn, etc.)
|
||||
verbose: Whether to print detailed information
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing calculation details
|
||||
"""
|
||||
# Load model config
|
||||
model_config = ModelConfig(model_path, dtype=dtype)
|
||||
hf_config = model_config.hf_config
|
||||
|
||||
# Determine dtype bytes
|
||||
dtype_bytes = get_dtype_bytes(dtype)
|
||||
if dtype == "auto":
|
||||
# Auto dtype usually becomes bfloat16
|
||||
dtype_bytes = 2
|
||||
|
||||
# Number of layers
|
||||
num_layers = model_config.num_attention_layers
|
||||
|
||||
# Check if it's MLA (Multi-head Latent Attention) model
|
||||
is_mla = hasattr(model_config, "attention_arch") and model_config.attention_arch.name == "MLA"
|
||||
|
||||
result = {
|
||||
"model_path": model_path,
|
||||
"max_total_tokens": max_total_tokens,
|
||||
"tp": tp,
|
||||
"dtype": dtype,
|
||||
"dtype_bytes": dtype_bytes,
|
||||
"num_layers": num_layers,
|
||||
"is_mla": is_mla,
|
||||
}
|
||||
|
||||
if is_mla:
|
||||
# MLA models (DeepSeek-V2/V3, MiniCPM3, etc.)
|
||||
kv_lora_rank = model_config.kv_lora_rank
|
||||
qk_rope_head_dim = model_config.qk_rope_head_dim
|
||||
|
||||
# Calculate cell size (per token)
|
||||
cell_size = (kv_lora_rank + qk_rope_head_dim) * num_layers * dtype_bytes
|
||||
|
||||
result.update(
|
||||
{
|
||||
"kv_lora_rank": kv_lora_rank,
|
||||
"qk_rope_head_dim": qk_rope_head_dim,
|
||||
"cell_size_bytes": cell_size,
|
||||
}
|
||||
)
|
||||
|
||||
# Check if it's NSA (Native Sparse Attention) model
|
||||
if is_deepseek_nsa(hf_config):
|
||||
index_head_dim = get_nsa_index_head_dim(hf_config)
|
||||
indexer_size_per_token = index_head_dim + index_head_dim // NSATokenToKVPool.quant_block_size * 4
|
||||
indexer_dtype_bytes = torch._utils._element_size(NSATokenToKVPool.index_k_with_scale_buffer_dtype)
|
||||
indexer_cell_size = indexer_size_per_token * num_layers * indexer_dtype_bytes
|
||||
cell_size += indexer_cell_size
|
||||
|
||||
result.update(
|
||||
{
|
||||
"is_nsa": True,
|
||||
"index_head_dim": index_head_dim,
|
||||
"indexer_cell_size_bytes": indexer_cell_size,
|
||||
"total_cell_size_bytes": cell_size,
|
||||
}
|
||||
)
|
||||
else:
|
||||
result["is_nsa"] = False
|
||||
else:
|
||||
# Standard MHA models
|
||||
num_kv_heads = model_config.get_num_kv_heads(tp)
|
||||
head_dim = model_config.head_dim
|
||||
v_head_dim = model_config.v_head_dim
|
||||
|
||||
# Calculate cell size (per token)
|
||||
cell_size = num_kv_heads * (head_dim + v_head_dim) * num_layers * dtype_bytes
|
||||
|
||||
result.update(
|
||||
{
|
||||
"num_kv_heads": num_kv_heads,
|
||||
"head_dim": head_dim,
|
||||
"v_head_dim": v_head_dim,
|
||||
"cell_size_bytes": cell_size,
|
||||
}
|
||||
)
|
||||
|
||||
# Calculate total KV cache size
|
||||
total_size_bytes = max_total_tokens * cell_size
|
||||
total_size_gb = total_size_bytes / (1024**3)
|
||||
|
||||
# For MHA models with separate K and V buffers
|
||||
if not is_mla:
|
||||
k_size_bytes = max_total_tokens * num_kv_heads * head_dim * num_layers * dtype_bytes
|
||||
v_size_bytes = max_total_tokens * num_kv_heads * v_head_dim * num_layers * dtype_bytes
|
||||
k_size_gb = k_size_bytes / (1024**3)
|
||||
v_size_gb = v_size_bytes / (1024**3)
|
||||
|
||||
result.update(
|
||||
{
|
||||
"k_size_gb": k_size_gb,
|
||||
"v_size_gb": v_size_gb,
|
||||
}
|
||||
)
|
||||
|
||||
result.update(
|
||||
{
|
||||
"total_size_bytes": total_size_bytes,
|
||||
"total_size_gb": total_size_gb,
|
||||
}
|
||||
)
|
||||
|
||||
if verbose:
|
||||
print(f"Model: {model_path}")
|
||||
print(f"Tokens: {max_total_tokens}, TP: {tp}, Dtype: {dtype}")
|
||||
print(f"Architecture: {'MLA' if is_mla else 'MHA'}")
|
||||
print(f"Layers: {num_layers}")
|
||||
|
||||
if is_mla:
|
||||
print(f"KV LoRA Rank: {kv_lora_rank}, QK RoPE Head Dim: {qk_rope_head_dim}")
|
||||
if result.get("is_nsa"):
|
||||
print(f"NSA Index Head Dim: {index_head_dim}")
|
||||
print(
|
||||
f"Cell size: {cell_size} bytes (Main: {result['cell_size_bytes']}, Indexer: {result['indexer_cell_size_bytes']})"
|
||||
)
|
||||
else:
|
||||
print(f"Cell size: {cell_size} bytes")
|
||||
else:
|
||||
print(f"KV Heads: {num_kv_heads}, Head Dim: {head_dim}, V Head Dim: {v_head_dim}")
|
||||
print(f"Cell size: {cell_size} bytes")
|
||||
print(f"K size: {k_size_gb:.2f} GB, V size: {v_size_gb:.2f} GB")
|
||||
|
||||
print(f"Total KV Cache Size: {total_size_gb:.2f} GB")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Calculate KV cache size for a model")
|
||||
parser.add_argument("model_path", help="Path to the model")
|
||||
parser.add_argument("max_total_tokens", type=int, help="Maximum number of tokens")
|
||||
parser.add_argument("--tp", type=int, default=1, help="Tensor parallelism size")
|
||||
parser.add_argument("--dtype", type=str, default="auto", help="Data type (auto, float16, bfloat16, etc.)")
|
||||
parser.add_argument("--quiet", action="store_true", help="Suppress verbose output")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
result = get_kv_size_gb(
|
||||
args.model_path,
|
||||
args.max_total_tokens,
|
||||
tp=args.tp,
|
||||
dtype=args.dtype,
|
||||
verbose=not args.quiet,
|
||||
)
|
||||
|
||||
if args.quiet:
|
||||
print(f"{result['total_size_gb']:.2f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
Model Discovery Utilities
|
||||
|
||||
Shared functions for discovering and registering new models across different commands.
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from rich.console import Console
|
||||
|
||||
from kt_kernel.cli.utils.model_scanner import (
|
||||
discover_models,
|
||||
scan_directory_for_models,
|
||||
ScannedModel,
|
||||
)
|
||||
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry, UserModel
|
||||
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def discover_and_register_global(
|
||||
min_size_gb: float = 2.0, max_depth: int = 6, show_progress: bool = True, lang: str = "en"
|
||||
) -> Tuple[int, int, List[UserModel]]:
|
||||
"""
|
||||
Perform global model discovery and register new models.
|
||||
|
||||
Args:
|
||||
min_size_gb: Minimum model size in GB
|
||||
max_depth: Maximum search depth
|
||||
show_progress: Whether to show progress messages
|
||||
lang: Language for messages ("en" or "zh")
|
||||
|
||||
Returns:
|
||||
Tuple of (total_found, new_found, registered_models)
|
||||
"""
|
||||
registry = UserModelRegistry()
|
||||
|
||||
if show_progress:
|
||||
if lang == "zh":
|
||||
console.print("[dim]正在扫描系统中的模型权重,这可能需要30-60秒...[/dim]")
|
||||
else:
|
||||
console.print("[dim]Scanning system for model weights, this may take 30-60 seconds...[/dim]")
|
||||
|
||||
# Global scan
|
||||
all_models = discover_models(mount_points=None, min_size_gb=min_size_gb, max_depth=max_depth)
|
||||
|
||||
# Filter out existing models
|
||||
new_models = []
|
||||
for model in all_models:
|
||||
if not registry.find_by_path(model.path):
|
||||
new_models.append(model)
|
||||
|
||||
# Register new models
|
||||
registered = []
|
||||
for model in new_models:
|
||||
user_model = _create_and_register_model(registry, model)
|
||||
if user_model:
|
||||
registered.append(user_model)
|
||||
|
||||
return len(all_models), len(new_models), registered
|
||||
|
||||
|
||||
def discover_and_register_path(
|
||||
path: str,
|
||||
min_size_gb: float = 2.0,
|
||||
existing_paths: Optional[set] = None,
|
||||
show_progress: bool = True,
|
||||
lang: str = "en",
|
||||
) -> Tuple[int, int, List[UserModel]]:
|
||||
"""
|
||||
Discover models in a specific path and register new ones.
|
||||
|
||||
Args:
|
||||
path: Directory path to scan
|
||||
min_size_gb: Minimum model file size in GB
|
||||
existing_paths: Set of already discovered paths in this session (optional)
|
||||
show_progress: Whether to show progress messages
|
||||
lang: Language for messages ("en" or "zh")
|
||||
|
||||
Returns:
|
||||
Tuple of (total_found, new_found, registered_models)
|
||||
"""
|
||||
registry = UserModelRegistry()
|
||||
|
||||
if show_progress:
|
||||
if lang == "zh":
|
||||
console.print(f"[dim]正在扫描 {path}...[/dim]")
|
||||
else:
|
||||
console.print(f"[dim]Scanning {path}...[/dim]")
|
||||
|
||||
# Scan directory
|
||||
model_info = scan_directory_for_models(path, min_file_size_gb=min_size_gb)
|
||||
|
||||
if not model_info:
|
||||
return 0, 0, []
|
||||
|
||||
# Convert to ScannedModel and filter
|
||||
new_models = []
|
||||
for dir_path, (format_type, size_bytes, file_count, files) in model_info.items():
|
||||
# Check if already in registry
|
||||
if registry.find_by_path(dir_path):
|
||||
continue
|
||||
|
||||
# Check if already discovered in this session
|
||||
if existing_paths and dir_path in existing_paths:
|
||||
continue
|
||||
|
||||
model = ScannedModel(
|
||||
path=dir_path, format=format_type, size_bytes=size_bytes, file_count=file_count, files=files
|
||||
)
|
||||
new_models.append(model)
|
||||
|
||||
# Register new models
|
||||
registered = []
|
||||
for model in new_models:
|
||||
user_model = _create_and_register_model(registry, model)
|
||||
if user_model:
|
||||
registered.append(user_model)
|
||||
|
||||
return len(model_info), len(new_models), registered
|
||||
|
||||
|
||||
def _create_and_register_model(registry: UserModelRegistry, scanned_model: ScannedModel) -> Optional[UserModel]:
|
||||
"""
|
||||
Create a UserModel from ScannedModel and register it.
|
||||
|
||||
Handles name conflicts by suggesting a unique name (e.g., model-2, model-3).
|
||||
Automatically detects repo_id from README.md YAML frontmatter.
|
||||
Automatically detects and caches MoE information for safetensors models.
|
||||
|
||||
Args:
|
||||
registry: UserModelRegistry instance
|
||||
scanned_model: ScannedModel to register
|
||||
|
||||
Returns:
|
||||
Registered UserModel or None if failed
|
||||
"""
|
||||
# Use suggest_name to get a unique name (adds -2, -3, etc. if needed)
|
||||
unique_name = registry.suggest_name(scanned_model.folder_name)
|
||||
|
||||
user_model = UserModel(name=unique_name, path=scanned_model.path, format=scanned_model.format)
|
||||
|
||||
# Auto-detect repo_id from README.md (only YAML frontmatter)
|
||||
try:
|
||||
from kt_kernel.cli.utils.repo_detector import detect_repo_for_model
|
||||
|
||||
repo_info = detect_repo_for_model(scanned_model.path)
|
||||
if repo_info:
|
||||
repo_id, repo_type = repo_info
|
||||
user_model.repo_id = repo_id
|
||||
user_model.repo_type = repo_type
|
||||
except Exception:
|
||||
# Silently continue if detection fails
|
||||
pass
|
||||
|
||||
# Auto-detect MoE information for safetensors models
|
||||
if scanned_model.format == "safetensors":
|
||||
try:
|
||||
from kt_kernel.cli.utils.analyze_moe_model import analyze_moe_model
|
||||
|
||||
moe_result = analyze_moe_model(scanned_model.path, use_cache=True)
|
||||
if moe_result and moe_result.get("is_moe"):
|
||||
user_model.is_moe = True
|
||||
user_model.moe_num_experts = moe_result.get("num_experts")
|
||||
user_model.moe_num_experts_per_tok = moe_result.get("num_experts_per_tok")
|
||||
else:
|
||||
user_model.is_moe = False
|
||||
except Exception:
|
||||
# Silently continue if MoE detection fails
|
||||
# is_moe will remain None
|
||||
pass
|
||||
|
||||
try:
|
||||
registry.add_model(user_model)
|
||||
return user_model
|
||||
except Exception:
|
||||
# Should not happen since we used suggest_name, but handle gracefully
|
||||
return None
|
||||
|
||||
|
||||
def format_discovery_summary(
|
||||
total_found: int,
|
||||
new_found: int,
|
||||
registered: List[UserModel],
|
||||
lang: str = "en",
|
||||
show_models: bool = True,
|
||||
max_show: int = 10,
|
||||
) -> None:
|
||||
"""
|
||||
Print formatted discovery summary.
|
||||
|
||||
Args:
|
||||
total_found: Total models found
|
||||
new_found: New models found
|
||||
registered: List of registered UserModel objects
|
||||
lang: Language ("en" or "zh")
|
||||
show_models: Whether to show model list
|
||||
max_show: Maximum models to show
|
||||
"""
|
||||
console.print()
|
||||
|
||||
if new_found == 0:
|
||||
if total_found > 0:
|
||||
if lang == "zh":
|
||||
console.print(f"[green]✓[/green] 扫描完成:找到 {total_found} 个模型,所有模型均已在列表中")
|
||||
else:
|
||||
console.print(f"[green]✓[/green] Scan complete: found {total_found} models, all already in the list")
|
||||
else:
|
||||
if lang == "zh":
|
||||
console.print("[yellow]未找到模型[/yellow]")
|
||||
else:
|
||||
console.print("[yellow]No models found[/yellow]")
|
||||
return
|
||||
|
||||
# Show summary
|
||||
if lang == "zh":
|
||||
console.print(f"[green]✓[/green] 扫描完成:找到 {total_found} 个模型,其中 {new_found} 个为新模型")
|
||||
else:
|
||||
console.print(f"[green]✓[/green] Scan complete: found {total_found} models, {new_found} are new")
|
||||
|
||||
# Show registered count
|
||||
if len(registered) > 0:
|
||||
if lang == "zh":
|
||||
console.print(f"[green]✓[/green] 成功添加 {len(registered)} 个新模型到列表")
|
||||
else:
|
||||
console.print(f"[green]✓[/green] Successfully added {len(registered)} new models to list")
|
||||
|
||||
# Show model list
|
||||
if show_models and registered:
|
||||
console.print()
|
||||
if lang == "zh":
|
||||
console.print(f"[dim]新发现的模型(前{max_show}个):[/dim]")
|
||||
else:
|
||||
console.print(f"[dim]Newly discovered models (first {max_show}):[/dim]")
|
||||
|
||||
for i, model in enumerate(registered[:max_show], 1):
|
||||
# Get size from registry or estimate
|
||||
size_str = "?.? GB"
|
||||
# Try to find the ScannedModel to get size
|
||||
# For now just show name and path
|
||||
console.print(f" {i}. {model.name} ({model.format})")
|
||||
console.print(f" [dim]{model.path}[/dim]")
|
||||
|
||||
if len(registered) > max_show:
|
||||
remaining = len(registered) - max_show
|
||||
if lang == "zh":
|
||||
console.print(f" [dim]... 还有 {remaining} 个新模型[/dim]")
|
||||
else:
|
||||
console.print(f" [dim]... and {remaining} more new models[/dim]")
|
||||
@@ -0,0 +1,433 @@
|
||||
"""
|
||||
Model registry for kt-cli.
|
||||
|
||||
Provides a registry of supported models with fuzzy matching capabilities.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from kt_kernel.cli.config.settings import get_settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelInfo:
|
||||
"""Information about a supported model."""
|
||||
|
||||
name: str
|
||||
hf_repo: str
|
||||
aliases: list[str] = field(default_factory=list)
|
||||
type: str = "moe" # moe, dense
|
||||
gpu_vram_gb: float = 0
|
||||
cpu_ram_gb: float = 0
|
||||
default_params: dict = field(default_factory=dict)
|
||||
description: str = ""
|
||||
description_zh: str = ""
|
||||
max_tensor_parallel_size: Optional[int] = None # Maximum tensor parallel size for this model
|
||||
|
||||
|
||||
# Built-in model registry
|
||||
BUILTIN_MODELS: list[ModelInfo] = [
|
||||
ModelInfo(
|
||||
name="DeepSeek-V3-0324",
|
||||
hf_repo="deepseek-ai/DeepSeek-V3-0324",
|
||||
aliases=["deepseek-v3-0324", "deepseek-v3", "dsv3", "deepseek3", "v3-0324"],
|
||||
type="moe",
|
||||
default_params={
|
||||
"kt-num-gpu-experts": 1,
|
||||
"attention-backend": "triton",
|
||||
"disable-shared-experts-fusion": True,
|
||||
"kt-method": "AMXINT4",
|
||||
},
|
||||
description="DeepSeek V3-0324 685B MoE model (March 2025, improved benchmarks)",
|
||||
description_zh="DeepSeek V3-0324 685B MoE 模型(2025年3月,改进的基准测试)",
|
||||
),
|
||||
ModelInfo(
|
||||
name="DeepSeek-V3.2",
|
||||
hf_repo="deepseek-ai/DeepSeek-V3.2",
|
||||
aliases=["deepseek-v3.2", "dsv3.2", "deepseek3.2", "v3.2"],
|
||||
type="moe",
|
||||
default_params={
|
||||
"kt-method": "FP8",
|
||||
"kt-gpu-prefill-token-threshold": 4096,
|
||||
"attention-backend": "flashinfer",
|
||||
"fp8-gemm-backend": "triton",
|
||||
"max-total-tokens": 100000,
|
||||
"max-running-requests": 16,
|
||||
"chunked-prefill-size": 32768,
|
||||
"mem-fraction-static": 0.80,
|
||||
"watchdog-timeout": 3000,
|
||||
"served-model-name": "DeepSeek-V3.2",
|
||||
"disable-shared-experts-fusion": True,
|
||||
},
|
||||
description="DeepSeek V3.2 671B MoE model (latest)",
|
||||
description_zh="DeepSeek V3.2 671B MoE 模型(最新)",
|
||||
),
|
||||
ModelInfo(
|
||||
name="DeepSeek-R1-0528",
|
||||
hf_repo="deepseek-ai/DeepSeek-R1-0528",
|
||||
aliases=["deepseek-r1-0528", "deepseek-r1", "dsr1", "r1", "r1-0528"],
|
||||
type="moe",
|
||||
default_params={
|
||||
"kt-num-gpu-experts": 1,
|
||||
"attention-backend": "triton",
|
||||
"disable-shared-experts-fusion": True,
|
||||
"kt-method": "AMXINT4",
|
||||
},
|
||||
description="DeepSeek R1-0528 reasoning model (May 2025, improved reasoning depth)",
|
||||
description_zh="DeepSeek R1-0528 推理模型(2025年5月,改进的推理深度)",
|
||||
),
|
||||
ModelInfo(
|
||||
name="DeepSeek-V4-Flash",
|
||||
hf_repo="deepseek-ai/DeepSeek-V4-Flash",
|
||||
aliases=["deepseek-v4-flash", "deepseek-v4", "dsv4", "v4-flash", "v4"],
|
||||
type="moe",
|
||||
default_params={
|
||||
"kt-method": "MXFP4",
|
||||
"kt-gpu-prefill-token-threshold": 4096,
|
||||
"attention-backend": "flashinfer",
|
||||
"max-total-tokens": 100000,
|
||||
"max-running-requests": 16,
|
||||
"chunked-prefill-size": 32768,
|
||||
"mem-fraction-static": 0.80,
|
||||
"watchdog-timeout": 3000,
|
||||
"served-model-name": "DeepSeek-V4-Flash",
|
||||
"disable-shared-experts-fusion": True,
|
||||
},
|
||||
description="DeepSeek V4-Flash MoE model (native MXFP4 experts, MQA + sparse index attention)",
|
||||
description_zh="DeepSeek V4-Flash MoE 模型(原生 MXFP4 专家,MQA + 稀疏索引注意力)",
|
||||
),
|
||||
ModelInfo(
|
||||
name="Kimi-K2-Thinking",
|
||||
hf_repo="moonshotai/Kimi-K2-Thinking",
|
||||
aliases=["kimi-k2-thinking", "kimi-thinking", "k2-thinking", "kimi", "k2"],
|
||||
type="moe",
|
||||
default_params={
|
||||
"kt-method": "RAWINT4",
|
||||
"kt-gpu-prefill-token-threshold": 400,
|
||||
"attention-backend": "flashinfer",
|
||||
"max-total-tokens": 100000,
|
||||
"max-running-requests": 16,
|
||||
"chunked-prefill-size": 32768,
|
||||
"mem-fraction-static": 0.80,
|
||||
"watchdog-timeout": 3000,
|
||||
"served-model-name": "Kimi-K2-Thinking",
|
||||
"disable-shared-experts-fusion": True,
|
||||
},
|
||||
description="Moonshot Kimi K2 Thinking MoE model",
|
||||
description_zh="月之暗面 Kimi K2 Thinking MoE 模型",
|
||||
),
|
||||
ModelInfo(
|
||||
name="MiniMax-M2",
|
||||
hf_repo="MiniMaxAI/MiniMax-M2",
|
||||
aliases=["minimax-m2", "m2"],
|
||||
type="moe",
|
||||
default_params={
|
||||
"kt-method": "FP8",
|
||||
"kt-gpu-prefill-token-threshold": 4096,
|
||||
"attention-backend": "flashinfer",
|
||||
"fp8-gemm-backend": "triton",
|
||||
"max-total-tokens": 100000,
|
||||
"max-running-requests": 16,
|
||||
"chunked-prefill-size": 32768,
|
||||
"mem-fraction-static": 0.80,
|
||||
"watchdog-timeout": 3000,
|
||||
"served-model-name": "MiniMax-M2",
|
||||
"disable-shared-experts-fusion": True,
|
||||
"tool-call-parser": "minimax-m2",
|
||||
"reasoning-parser": "minimax-append-think",
|
||||
},
|
||||
description="MiniMax M2 MoE model",
|
||||
description_zh="MiniMax M2 MoE 模型",
|
||||
max_tensor_parallel_size=4, # M2 only supports up to 4-way tensor parallelism
|
||||
),
|
||||
ModelInfo(
|
||||
name="MiniMax-M2.1",
|
||||
hf_repo="MiniMaxAI/MiniMax-M2.1",
|
||||
aliases=["minimax-m2.1", "m2.1"],
|
||||
type="moe",
|
||||
default_params={
|
||||
"kt-method": "FP8",
|
||||
"kt-gpu-prefill-token-threshold": 4096,
|
||||
"attention-backend": "flashinfer",
|
||||
"fp8-gemm-backend": "triton",
|
||||
"max-total-tokens": 100000,
|
||||
"max-running-requests": 16,
|
||||
"chunked-prefill-size": 32768,
|
||||
"mem-fraction-static": 0.80,
|
||||
"watchdog-timeout": 3000,
|
||||
"served-model-name": "MiniMax-M2.1",
|
||||
"disable-shared-experts-fusion": True,
|
||||
"tool-call-parser": "minimax-m2",
|
||||
"reasoning-parser": "minimax-append-think",
|
||||
},
|
||||
description="MiniMax M2.1 MoE model (enhanced multi-language programming)",
|
||||
description_zh="MiniMax M2.1 MoE 模型(增强多语言编程能力)",
|
||||
max_tensor_parallel_size=4, # M2.1 only supports up to 4-way tensor parallelism
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class ModelRegistry:
|
||||
"""Registry of supported models with fuzzy matching."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the model registry."""
|
||||
self._models: dict[str, ModelInfo] = {}
|
||||
self._aliases: dict[str, str] = {}
|
||||
self._load_builtin_models()
|
||||
self._load_user_models()
|
||||
|
||||
def _load_builtin_models(self) -> None:
|
||||
"""Load built-in models."""
|
||||
for model in BUILTIN_MODELS:
|
||||
self._register(model)
|
||||
|
||||
def _load_user_models(self) -> None:
|
||||
"""Load user-defined models from config."""
|
||||
settings = get_settings()
|
||||
registry_file = settings.config_dir / "registry.yaml"
|
||||
|
||||
if registry_file.exists():
|
||||
try:
|
||||
with open(registry_file, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
for name, info in data.get("models", {}).items():
|
||||
model = ModelInfo(
|
||||
name=name,
|
||||
hf_repo=info.get("hf_repo", ""),
|
||||
aliases=info.get("aliases", []),
|
||||
type=info.get("type", "moe"),
|
||||
gpu_vram_gb=info.get("gpu_vram_gb", 0),
|
||||
cpu_ram_gb=info.get("cpu_ram_gb", 0),
|
||||
default_params=info.get("default_params", {}),
|
||||
description=info.get("description", ""),
|
||||
description_zh=info.get("description_zh", ""),
|
||||
max_tensor_parallel_size=info.get("max_tensor_parallel_size"),
|
||||
)
|
||||
self._register(model)
|
||||
except (yaml.YAMLError, OSError):
|
||||
pass
|
||||
|
||||
def _register(self, model: ModelInfo) -> None:
|
||||
"""Register a model."""
|
||||
self._models[model.name.lower()] = model
|
||||
|
||||
# Register aliases
|
||||
for alias in model.aliases:
|
||||
self._aliases[alias.lower()] = model.name.lower()
|
||||
|
||||
def get(self, name: str) -> Optional[ModelInfo]:
|
||||
"""Get a model by exact name or alias."""
|
||||
name_lower = name.lower()
|
||||
|
||||
# Check direct match
|
||||
if name_lower in self._models:
|
||||
return self._models[name_lower]
|
||||
|
||||
# Check aliases
|
||||
if name_lower in self._aliases:
|
||||
return self._models[self._aliases[name_lower]]
|
||||
|
||||
return None
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> list[ModelInfo]:
|
||||
"""Search for models using fuzzy matching.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
List of matching models, sorted by relevance
|
||||
"""
|
||||
query_lower = query.lower()
|
||||
results: list[tuple[float, ModelInfo]] = []
|
||||
|
||||
for model in self._models.values():
|
||||
score = self._match_score(query_lower, model)
|
||||
if score > 0:
|
||||
results.append((score, model))
|
||||
|
||||
# Sort by score descending
|
||||
results.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
return [model for _, model in results[:limit]]
|
||||
|
||||
def _match_score(self, query: str, model: ModelInfo) -> float:
|
||||
"""Calculate match score for a model.
|
||||
|
||||
Returns a score between 0 and 1, where 1 is an exact match.
|
||||
"""
|
||||
# Check exact match
|
||||
if query == model.name.lower():
|
||||
return 1.0
|
||||
|
||||
# Check alias exact match
|
||||
for alias in model.aliases:
|
||||
if query == alias.lower():
|
||||
return 0.95
|
||||
|
||||
# Check if query is contained in name
|
||||
if query in model.name.lower():
|
||||
return 0.8
|
||||
|
||||
# Check if query is contained in aliases
|
||||
for alias in model.aliases:
|
||||
if query in alias.lower():
|
||||
return 0.7
|
||||
|
||||
# Check if query is contained in hf_repo
|
||||
if query in model.hf_repo.lower():
|
||||
return 0.6
|
||||
|
||||
# Fuzzy matching - check if all query parts are present
|
||||
query_parts = re.split(r"[-_.\s]", query)
|
||||
name_lower = model.name.lower()
|
||||
|
||||
matches = sum(1 for part in query_parts if part and part in name_lower)
|
||||
if matches > 0:
|
||||
return 0.5 * (matches / len(query_parts))
|
||||
|
||||
return 0.0
|
||||
|
||||
def list_all(self) -> list[ModelInfo]:
|
||||
"""List all registered models."""
|
||||
return list(self._models.values())
|
||||
|
||||
def find_local_models(self, max_depth: int = 3) -> list[tuple[ModelInfo, Path]]:
|
||||
"""Find models that are downloaded locally in any configured model path.
|
||||
|
||||
Args:
|
||||
max_depth: Maximum depth to search within each model path (default: 3)
|
||||
|
||||
Returns:
|
||||
List of (ModelInfo, path) tuples for local models
|
||||
"""
|
||||
settings = get_settings()
|
||||
model_paths = settings.get_model_paths()
|
||||
results = []
|
||||
|
||||
for model in self._models.values():
|
||||
found = False
|
||||
# Search in all configured model directories
|
||||
for models_dir in model_paths:
|
||||
if not models_dir.exists():
|
||||
continue
|
||||
|
||||
# Generate possible names to search for
|
||||
possible_names = [
|
||||
model.name,
|
||||
model.name.lower(),
|
||||
model.hf_repo.split("/")[-1],
|
||||
model.hf_repo.replace("/", "--"),
|
||||
]
|
||||
|
||||
# Search recursively up to max_depth
|
||||
for depth in range(max_depth):
|
||||
# Build glob pattern for current depth
|
||||
# depth=0: direct children, depth=1: grandchildren, etc.
|
||||
glob_pattern = "*" if depth > 0 else ""
|
||||
for _ in range(depth):
|
||||
glob_pattern = "*/" + glob_pattern if glob_pattern else "*"
|
||||
|
||||
for name in possible_names:
|
||||
if depth == 0:
|
||||
# Direct children: models_dir / name
|
||||
search_paths = [models_dir / name]
|
||||
else:
|
||||
# Nested: use rglob to find directories matching the name
|
||||
search_paths = list(models_dir.rglob(name))
|
||||
|
||||
for path in search_paths:
|
||||
if path.exists() and (path / "config.json").exists():
|
||||
results.append((model, path))
|
||||
found = True
|
||||
break
|
||||
|
||||
if found:
|
||||
break
|
||||
|
||||
if found:
|
||||
break
|
||||
|
||||
if found:
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_registry: Optional[ModelRegistry] = None
|
||||
|
||||
|
||||
def get_registry() -> ModelRegistry:
|
||||
"""Get the global model registry instance."""
|
||||
global _registry
|
||||
if _registry is None:
|
||||
_registry = ModelRegistry()
|
||||
return _registry
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Model-specific parameter computation functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def compute_deepseek_v3_gpu_experts(tensor_parallel_size: int, vram_per_gpu_gb: float) -> int:
|
||||
per_gpu_gb = 16
|
||||
if vram_per_gpu_gb < per_gpu_gb:
|
||||
return int(0)
|
||||
total_vram = int(tensor_parallel_size * (vram_per_gpu_gb - per_gpu_gb))
|
||||
|
||||
return total_vram // 3
|
||||
|
||||
|
||||
def compute_deepseek_v4_gpu_experts(tensor_parallel_size: int, vram_per_gpu_gb: float) -> int:
|
||||
"""Compute kt-num-gpu-experts for DeepSeek-V4-Flash.
|
||||
|
||||
V4 uses MXFP4 experts (~0.5 bytes/param vs V3 FP8's 1 byte/param) so each GPU
|
||||
can hold ~2x more experts per VRAM unit than V3 at the same fragmentation.
|
||||
"""
|
||||
per_gpu_gb = 16
|
||||
if vram_per_gpu_gb < per_gpu_gb:
|
||||
return 0
|
||||
total_vram = int(tensor_parallel_size * (vram_per_gpu_gb - per_gpu_gb))
|
||||
return total_vram * 2 // 3
|
||||
|
||||
|
||||
def compute_kimi_k2_thinking_gpu_experts(tensor_parallel_size: int, vram_per_gpu_gb: float) -> int:
|
||||
"""Compute kt-num-gpu-experts for Kimi K2 Thinking."""
|
||||
per_gpu_gb = 16
|
||||
if vram_per_gpu_gb < per_gpu_gb:
|
||||
return int(0)
|
||||
total_vram = int(tensor_parallel_size * (vram_per_gpu_gb - per_gpu_gb))
|
||||
|
||||
return total_vram * 2 // 3
|
||||
|
||||
|
||||
def compute_minimax_m2_gpu_experts(tensor_parallel_size: int, vram_per_gpu_gb: float) -> int:
|
||||
"""Compute kt-num-gpu-experts for MiniMax M2/M2.1."""
|
||||
per_gpu_gb = 16
|
||||
if vram_per_gpu_gb < per_gpu_gb:
|
||||
return int(0)
|
||||
total_vram = int(tensor_parallel_size * (vram_per_gpu_gb - per_gpu_gb))
|
||||
|
||||
return total_vram // 1
|
||||
|
||||
|
||||
# Model name to computation function mapping
|
||||
MODEL_COMPUTE_FUNCTIONS: dict[str, Callable[[int, float], int]] = {
|
||||
"DeepSeek-V3-0324": compute_deepseek_v3_gpu_experts,
|
||||
"DeepSeek-V3.2": compute_deepseek_v3_gpu_experts, # Same as V3-0324
|
||||
"DeepSeek-R1-0528": compute_deepseek_v3_gpu_experts, # Same as V3-0324
|
||||
"DeepSeek-V4-Flash": compute_deepseek_v4_gpu_experts,
|
||||
"Kimi-K2-Thinking": compute_kimi_k2_thinking_gpu_experts,
|
||||
"MiniMax-M2": compute_minimax_m2_gpu_experts,
|
||||
"MiniMax-M2.1": compute_minimax_m2_gpu_experts, # Same as M2
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
"""
|
||||
Model Scanner
|
||||
|
||||
Scans directories for model files (safetensors, gguf) and identifies models
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Set, Tuple, Dict
|
||||
from collections import defaultdict
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScannedModel:
|
||||
"""Temporary structure for scanned model information"""
|
||||
|
||||
path: str # Absolute path to model directory
|
||||
format: str # "safetensors" | "gguf" | "mixed"
|
||||
size_bytes: int # Total size in bytes
|
||||
file_count: int # Number of model files
|
||||
files: List[str] # List of model file names
|
||||
|
||||
@property
|
||||
def size_gb(self) -> float:
|
||||
"""Get size in GB"""
|
||||
return self.size_bytes / (1024**3)
|
||||
|
||||
@property
|
||||
def folder_name(self) -> str:
|
||||
"""Get the folder name (default model name)"""
|
||||
return Path(self.path).name
|
||||
|
||||
|
||||
class ModelScanner:
|
||||
"""Scanner for discovering models in directory trees"""
|
||||
|
||||
def __init__(self, min_size_gb: float = 10.0):
|
||||
"""
|
||||
Initialize scanner
|
||||
|
||||
Args:
|
||||
min_size_gb: Minimum folder size in GB to be considered a model
|
||||
"""
|
||||
self.min_size_bytes = int(min_size_gb * 1024**3)
|
||||
|
||||
def scan_directory(
|
||||
self, base_path: Path, exclude_paths: Optional[Set[str]] = None
|
||||
) -> Tuple[List[ScannedModel], List[str]]:
|
||||
"""
|
||||
Scan directory tree for models
|
||||
|
||||
Args:
|
||||
base_path: Root directory to scan
|
||||
exclude_paths: Set of absolute paths to exclude from results
|
||||
|
||||
Returns:
|
||||
Tuple of (valid_models, warnings)
|
||||
- valid_models: List of ScannedModel instances
|
||||
- warnings: List of warning messages
|
||||
"""
|
||||
if not base_path.exists():
|
||||
raise ValueError(f"Path does not exist: {base_path}")
|
||||
|
||||
if not base_path.is_dir():
|
||||
raise ValueError(f"Path is not a directory: {base_path}")
|
||||
|
||||
exclude_paths = exclude_paths or set()
|
||||
results: List[ScannedModel] = []
|
||||
warnings: List[str] = []
|
||||
|
||||
# Walk the directory tree
|
||||
for root, dirs, files in os.walk(base_path):
|
||||
root_path = Path(root).resolve()
|
||||
|
||||
# Skip if already registered
|
||||
if str(root_path) in exclude_paths:
|
||||
dirs[:] = [] # Don't descend into this directory
|
||||
continue
|
||||
|
||||
# Check for model files
|
||||
safetensors_files = [f for f in files if f.endswith(".safetensors")]
|
||||
gguf_files = [f for f in files if f.endswith(".gguf")]
|
||||
|
||||
if not safetensors_files and not gguf_files:
|
||||
continue # No model files in this directory
|
||||
|
||||
# Calculate total size
|
||||
model_files = safetensors_files + gguf_files
|
||||
total_size = self._calculate_total_size(root_path, model_files)
|
||||
|
||||
# Check if size meets minimum threshold
|
||||
if total_size < self.min_size_bytes:
|
||||
continue # Too small, but keep scanning subdirectories
|
||||
|
||||
# Detect format
|
||||
if safetensors_files and gguf_files:
|
||||
# Mixed format - issue warning
|
||||
warnings.append(
|
||||
f"Mixed format detected in {root_path}: "
|
||||
f"{len(safetensors_files)} safetensors + {len(gguf_files)} gguf files. "
|
||||
"Please separate into different folders and re-scan."
|
||||
)
|
||||
dirs[:] = [] # Don't descend into mixed format directories
|
||||
continue
|
||||
|
||||
# Determine format
|
||||
format_type = "safetensors" if safetensors_files else "gguf"
|
||||
|
||||
# Create scanned model
|
||||
scanned = ScannedModel(
|
||||
path=str(root_path),
|
||||
format=format_type,
|
||||
size_bytes=total_size,
|
||||
file_count=len(model_files),
|
||||
files=model_files,
|
||||
)
|
||||
|
||||
results.append(scanned)
|
||||
|
||||
# Continue scanning subdirectories - they might also contain models
|
||||
# Each subdirectory will be independently checked for size >= 10GB
|
||||
|
||||
return results, warnings
|
||||
|
||||
def scan_single_path(self, path: Path) -> Optional[ScannedModel]:
|
||||
"""
|
||||
Scan a single path for model files
|
||||
|
||||
Args:
|
||||
path: Path to scan
|
||||
|
||||
Returns:
|
||||
ScannedModel instance or None if not a valid model
|
||||
"""
|
||||
if not path.exists() or not path.is_dir():
|
||||
return None
|
||||
|
||||
# Find model files
|
||||
safetensors_files = list(path.glob("*.safetensors"))
|
||||
gguf_files = list(path.glob("*.gguf"))
|
||||
|
||||
if not safetensors_files and not gguf_files:
|
||||
return None
|
||||
|
||||
# Check for mixed format
|
||||
if safetensors_files and gguf_files:
|
||||
raise ValueError(
|
||||
f"Mixed format detected: {len(safetensors_files)} safetensors + "
|
||||
f"{len(gguf_files)} gguf files. Please use a single format."
|
||||
)
|
||||
|
||||
# Calculate size
|
||||
model_files = [f.name for f in safetensors_files + gguf_files]
|
||||
total_size = self._calculate_total_size(path, model_files)
|
||||
|
||||
# Determine format
|
||||
format_type = "safetensors" if safetensors_files else "gguf"
|
||||
|
||||
return ScannedModel(
|
||||
path=str(path.resolve()),
|
||||
format=format_type,
|
||||
size_bytes=total_size,
|
||||
file_count=len(model_files),
|
||||
files=model_files,
|
||||
)
|
||||
|
||||
def _calculate_total_size(self, directory: Path, filenames: List[str]) -> int:
|
||||
"""
|
||||
Calculate total size of specified files in directory
|
||||
|
||||
Args:
|
||||
directory: Directory containing the files
|
||||
filenames: List of filenames to sum
|
||||
|
||||
Returns:
|
||||
Total size in bytes
|
||||
"""
|
||||
total = 0
|
||||
for filename in filenames:
|
||||
file_path = directory / filename
|
||||
if file_path.exists():
|
||||
try:
|
||||
total += file_path.stat().st_size
|
||||
except OSError:
|
||||
# File might be inaccessible, skip it
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
# Convenience functions
|
||||
|
||||
|
||||
def scan_directory(
|
||||
base_path: Path, min_size_gb: float = 10.0, exclude_paths: Optional[Set[str]] = None
|
||||
) -> Tuple[List[ScannedModel], List[str]]:
|
||||
"""
|
||||
Convenience function to scan a directory
|
||||
|
||||
Args:
|
||||
base_path: Root directory to scan
|
||||
min_size_gb: Minimum folder size in GB
|
||||
exclude_paths: Set of paths to exclude
|
||||
|
||||
Returns:
|
||||
Tuple of (models, warnings)
|
||||
"""
|
||||
scanner = ModelScanner(min_size_gb=min_size_gb)
|
||||
return scanner.scan_directory(base_path, exclude_paths)
|
||||
|
||||
|
||||
def scan_single_path(path: Path) -> Optional[ScannedModel]:
|
||||
"""
|
||||
Convenience function to scan a single path
|
||||
|
||||
Args:
|
||||
path: Path to scan
|
||||
|
||||
Returns:
|
||||
ScannedModel or None
|
||||
"""
|
||||
scanner = ModelScanner()
|
||||
return scanner.scan_single_path(path)
|
||||
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""
|
||||
Format size in bytes to human-readable string
|
||||
|
||||
Args:
|
||||
size_bytes: Size in bytes
|
||||
|
||||
Returns:
|
||||
Formatted string (e.g., "42.3 GB")
|
||||
"""
|
||||
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.1f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
return f"{size_bytes:.1f} PB"
|
||||
|
||||
|
||||
# ===== Fast Scanning with Find Command and Tree-based Root Detection =====
|
||||
|
||||
|
||||
def find_files_fast(mount_point: str, pattern: str, max_depth: int = 6, timeout: int = 30) -> List[str]:
|
||||
"""
|
||||
Use find command to quickly locate files
|
||||
|
||||
Args:
|
||||
mount_point: Starting directory
|
||||
pattern: File pattern (e.g., "config.json", "*.gguf")
|
||||
max_depth: Maximum directory depth (default: 6)
|
||||
timeout: Command timeout in seconds
|
||||
|
||||
Returns:
|
||||
List of absolute file paths
|
||||
"""
|
||||
try:
|
||||
# Use shell=False for better security and handling of special characters in paths
|
||||
cmd = ["find", mount_point, "-maxdepth", str(max_depth), "-name", pattern, "-type", "f"]
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Return results even if returncode is non-zero (due to permission errors)
|
||||
# As long as we got some output
|
||||
if result.stdout:
|
||||
return [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
|
||||
return []
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return []
|
||||
|
||||
|
||||
def is_valid_model_directory(directory: Path, min_size_gb: float = 10.0) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check if a directory is a valid model directory
|
||||
|
||||
Args:
|
||||
directory: Path to check
|
||||
min_size_gb: Minimum size in GB
|
||||
|
||||
Returns:
|
||||
(is_valid, model_type) where model_type is "safetensors", "gguf", or None
|
||||
"""
|
||||
if not directory.exists() or not directory.is_dir():
|
||||
return False, None
|
||||
|
||||
has_config = (directory / "config.json").exists()
|
||||
safetensors_files = list(directory.glob("*.safetensors"))
|
||||
gguf_files = list(directory.glob("*.gguf"))
|
||||
|
||||
# Determine model type
|
||||
model_type = None
|
||||
if (has_config and safetensors_files) or safetensors_files:
|
||||
model_type = "safetensors"
|
||||
elif gguf_files:
|
||||
model_type = "gguf"
|
||||
else:
|
||||
return False, None
|
||||
|
||||
# Check size - only count model files (fast!)
|
||||
total_size = 0
|
||||
if model_type == "safetensors":
|
||||
for f in safetensors_files:
|
||||
try:
|
||||
total_size += f.stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
else: # gguf
|
||||
for f in gguf_files:
|
||||
try:
|
||||
total_size += f.stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
size_gb = total_size / (1024**3)
|
||||
if size_gb < min_size_gb:
|
||||
return False, None
|
||||
|
||||
return True, model_type
|
||||
|
||||
|
||||
def scan_all_models_fast(mount_points: List[str], min_size_gb: float = 10.0, max_depth: int = 6) -> List[str]:
|
||||
"""
|
||||
Fast scan for all model paths using find command
|
||||
|
||||
Args:
|
||||
mount_points: List of mount points to scan
|
||||
min_size_gb: Minimum model size in GB
|
||||
max_depth: Maximum search depth (default: 6)
|
||||
|
||||
Returns:
|
||||
List of valid model directory paths
|
||||
"""
|
||||
model_paths = set()
|
||||
|
||||
for mount in mount_points:
|
||||
if not os.path.exists(mount):
|
||||
continue
|
||||
|
||||
# Find all config.json files
|
||||
config_files = find_files_fast(mount, "config.json", max_depth=max_depth)
|
||||
for config_path in config_files:
|
||||
model_dir = Path(config_path).parent
|
||||
is_valid, model_type = is_valid_model_directory(model_dir, min_size_gb)
|
||||
if is_valid:
|
||||
model_paths.add(str(model_dir.resolve()))
|
||||
|
||||
# Find all *.gguf files
|
||||
gguf_files = find_files_fast(mount, "*.gguf", max_depth=max_depth)
|
||||
for gguf_path in gguf_files:
|
||||
model_dir = Path(gguf_path).parent
|
||||
is_valid, model_type = is_valid_model_directory(model_dir, min_size_gb)
|
||||
if is_valid:
|
||||
model_paths.add(str(model_dir.resolve()))
|
||||
|
||||
return sorted(model_paths)
|
||||
|
||||
|
||||
def get_root_subdirs() -> List[str]:
|
||||
"""
|
||||
Get subdirectories of / that are worth scanning
|
||||
|
||||
Filters out system paths only
|
||||
|
||||
Returns:
|
||||
List of directories to scan
|
||||
"""
|
||||
# System paths to exclude
|
||||
excluded = {
|
||||
"dev",
|
||||
"proc",
|
||||
"sys",
|
||||
"run",
|
||||
"boot",
|
||||
"tmp",
|
||||
"usr",
|
||||
"lib",
|
||||
"lib64",
|
||||
"bin",
|
||||
"sbin",
|
||||
"etc",
|
||||
"opt",
|
||||
"var",
|
||||
"snap",
|
||||
}
|
||||
|
||||
scan_dirs = []
|
||||
|
||||
try:
|
||||
for entry in os.scandir("/"):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
|
||||
# Skip excluded paths
|
||||
if entry.name in excluded:
|
||||
continue
|
||||
|
||||
scan_dirs.append(entry.path)
|
||||
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
return sorted(scan_dirs)
|
||||
|
||||
|
||||
def scan_directory_for_models(directory: str, min_file_size_gb: float = 2.0) -> Dict[str, tuple]:
|
||||
"""
|
||||
Scan a directory for models using find command with size filter
|
||||
|
||||
Uses find -size +2G to only locate large model files (>=2GB)
|
||||
|
||||
Args:
|
||||
directory: Directory to scan
|
||||
min_file_size_gb: Minimum individual file size in GB (default: 2.0)
|
||||
|
||||
Returns:
|
||||
Dict mapping model_path -> (model_type, size_bytes, file_count, files)
|
||||
"""
|
||||
model_info = {}
|
||||
|
||||
# Convert GB to find's format (e.g., 2GB = +2G)
|
||||
if min_file_size_gb >= 1.0:
|
||||
size_filter = f"+{int(min_file_size_gb)}G"
|
||||
else:
|
||||
size_mb = int(min_file_size_gb * 1024)
|
||||
size_filter = f"+{size_mb}M"
|
||||
|
||||
# 1. Find *.gguf files >= 2GB
|
||||
gguf_cmd = ["find", directory, "-name", "*.gguf", "-type", "f", "-size", size_filter, "-printf", "%p\t%s\n"]
|
||||
result = subprocess.run(gguf_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=120)
|
||||
|
||||
# Group by directory
|
||||
gguf_dirs = defaultdict(list)
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
file_path, size_str = parts
|
||||
file_path_obj = Path(file_path)
|
||||
dir_path = str(file_path_obj.parent)
|
||||
gguf_dirs[dir_path].append((file_path_obj.name, int(size_str)))
|
||||
|
||||
# Add all gguf directories
|
||||
for dir_path, files in gguf_dirs.items():
|
||||
total_size = sum(size for _, size in files)
|
||||
model_info[dir_path] = ("gguf", total_size, len(files), [name for name, _ in files])
|
||||
|
||||
# 2. Find *.safetensors files >= 2GB
|
||||
safetensors_cmd = ["find", directory, "-name", "*.safetensors", "-type", "f", "-size", size_filter, "-printf", "%p\t%s\n"]
|
||||
result = subprocess.run(safetensors_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=120)
|
||||
|
||||
# Group by directory
|
||||
safetensors_dirs = defaultdict(list)
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
file_path, size_str = parts
|
||||
file_path_obj = Path(file_path)
|
||||
dir_path = str(file_path_obj.parent)
|
||||
safetensors_dirs[dir_path].append((file_path_obj.name, int(size_str)))
|
||||
|
||||
# 3. Check each safetensors directory for config.json
|
||||
for dir_path, files in safetensors_dirs.items():
|
||||
if os.path.exists(os.path.join(dir_path, "config.json")):
|
||||
total_size = sum(size for _, size in files)
|
||||
model_info[dir_path] = ("safetensors", total_size, len(files), [name for name, _ in files])
|
||||
|
||||
return model_info
|
||||
|
||||
|
||||
def scan_all_models_with_info(
|
||||
mount_points: Optional[List[str]] = None, min_size_gb: float = 10.0, max_depth: int = 6
|
||||
) -> Dict[str, tuple]:
|
||||
"""
|
||||
Fast scan with parallel directory scanning
|
||||
|
||||
Strategy:
|
||||
1. Use provided directories or auto-detect root subdirectories
|
||||
2. Scan each directory in parallel (one thread per directory)
|
||||
3. Use find -size +2G to find large model files (>=2GB)
|
||||
|
||||
Args:
|
||||
mount_points: Specific directories to scan, or None to auto-detect from / subdirs
|
||||
min_size_gb: Not used anymore (kept for API compatibility)
|
||||
max_depth: Not used anymore (kept for API compatibility)
|
||||
|
||||
Returns:
|
||||
Dict mapping model_path -> (model_type, size_bytes, file_count, files)
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
# Get directories to scan
|
||||
if mount_points is None:
|
||||
# Get root subdirectories (exclude system paths)
|
||||
scan_dirs = get_root_subdirs()
|
||||
else:
|
||||
scan_dirs = mount_points
|
||||
|
||||
if not scan_dirs:
|
||||
return {}
|
||||
|
||||
model_info = {}
|
||||
|
||||
# Scan each directory in parallel (max 8 concurrent)
|
||||
# Use 2GB threshold to find model files
|
||||
with ThreadPoolExecutor(max_workers=min(len(scan_dirs), 8)) as executor:
|
||||
futures = {executor.submit(scan_directory_for_models, d, 2.0): d for d in scan_dirs}
|
||||
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
dir_results = future.result()
|
||||
model_info.update(dir_results)
|
||||
except Exception as e:
|
||||
# Skip directories with errors
|
||||
pass
|
||||
|
||||
return model_info
|
||||
|
||||
|
||||
def find_model_roots_from_paths(model_paths: List[str]) -> Tuple[List[str], Dict[str, int]]:
|
||||
"""
|
||||
Find optimal root paths from model paths using tree-based algorithm
|
||||
|
||||
Algorithm:
|
||||
1. Build path tree with all intermediate paths
|
||||
2. DFS to calculate f(x) = subtree sum (number of models in subtree)
|
||||
3. Find roots where f(parent) = f(x) > max(f(children))
|
||||
|
||||
Args:
|
||||
model_paths: List of model directory paths
|
||||
|
||||
Returns:
|
||||
(root_paths, subtree_sizes) where:
|
||||
- root_paths: List of inferred root directories
|
||||
- subtree_sizes: Dict mapping each root to number of models
|
||||
"""
|
||||
if not model_paths:
|
||||
return [], {}
|
||||
|
||||
# 1. Build path set (including all intermediate paths)
|
||||
all_paths = set()
|
||||
model_set = set(model_paths)
|
||||
|
||||
for model_path in model_paths:
|
||||
path = Path(model_path)
|
||||
for i in range(1, len(path.parts) + 1):
|
||||
all_paths.add(str(Path(*path.parts[:i])))
|
||||
|
||||
# 2. Build parent-child relationships
|
||||
children_map = defaultdict(list)
|
||||
for path in all_paths:
|
||||
path_obj = Path(path)
|
||||
if len(path_obj.parts) > 1:
|
||||
parent = str(path_obj.parent)
|
||||
if parent in all_paths:
|
||||
children_map[parent].append(path)
|
||||
|
||||
# 3. DFS to calculate f(x) and max_child_f(x)
|
||||
f = {} # path -> subtree sum
|
||||
max_child_f = {} # path -> max(f(children))
|
||||
visited = set()
|
||||
|
||||
def dfs(path: str) -> int:
|
||||
if path in visited:
|
||||
return f[path]
|
||||
visited.add(path)
|
||||
|
||||
# Current node weight (1 if it's a model path, 0 otherwise)
|
||||
weight = 1 if path in model_set else 0
|
||||
|
||||
# Recursively calculate children
|
||||
children = children_map.get(path, [])
|
||||
if not children:
|
||||
# Leaf node
|
||||
f[path] = weight
|
||||
max_child_f[path] = 0
|
||||
return weight
|
||||
|
||||
# Calculate f values for all children
|
||||
children_f_values = [dfs(child) for child in children]
|
||||
|
||||
# Calculate f(x) and max_child_f(x)
|
||||
f[path] = weight + sum(children_f_values)
|
||||
max_child_f[path] = max(children_f_values) if children_f_values else 0
|
||||
|
||||
return f[path]
|
||||
|
||||
# Find top-level nodes (no parent in all_paths)
|
||||
top_nodes = []
|
||||
for path in all_paths:
|
||||
parent = str(Path(path).parent)
|
||||
if parent not in all_paths or parent == path:
|
||||
top_nodes.append(path)
|
||||
|
||||
# Execute DFS from all top nodes
|
||||
for top in top_nodes:
|
||||
dfs(top)
|
||||
|
||||
# 4. Find root nodes: f(parent) = f(x) >= max(f(children))
|
||||
# Note: Use >= instead of > to handle the case where a directory contains only one model
|
||||
candidate_roots = []
|
||||
for path in all_paths:
|
||||
# Skip model paths themselves (leaf nodes in model tree)
|
||||
if path in model_set:
|
||||
continue
|
||||
|
||||
parent = str(Path(path).parent)
|
||||
|
||||
# Check condition: f(parent) = f(x) and f(x) >= max(f(children))
|
||||
if parent in f and f.get(parent, 0) == f.get(path, 0):
|
||||
if f.get(path, 0) >= max_child_f.get(path, 0) and f.get(path, 0) > 0:
|
||||
candidate_roots.append(path)
|
||||
|
||||
# 5. Remove redundant roots (prefer deeper paths)
|
||||
# If a root is an ancestor of another root with the same f value, remove it
|
||||
roots = []
|
||||
candidate_roots_sorted = sorted(candidate_roots, key=lambda p: -len(Path(p).parts))
|
||||
|
||||
for root in candidate_roots_sorted:
|
||||
# Check if this root is a parent of any already selected root
|
||||
is_redundant = False
|
||||
for selected in roots:
|
||||
if selected.startswith(root + "/"):
|
||||
# selected is a child of root
|
||||
# Only keep root if it has more models (shouldn't happen by algorithm)
|
||||
if f.get(root, 0) == f.get(selected, 0):
|
||||
is_redundant = True
|
||||
break
|
||||
|
||||
if not is_redundant:
|
||||
# Also filter out very shallow paths (< 3 levels)
|
||||
if len(Path(root).parts) >= 3:
|
||||
roots.append(root)
|
||||
|
||||
# Build subtree sizes for roots
|
||||
subtree_sizes = {root: f.get(root, 0) for root in roots}
|
||||
|
||||
return sorted(roots), subtree_sizes
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelRootInfo:
|
||||
"""Information about a detected model root path"""
|
||||
|
||||
path: str
|
||||
model_count: int
|
||||
models: List[ScannedModel]
|
||||
|
||||
|
||||
def discover_models(
|
||||
mount_points: Optional[List[str]] = None, min_size_gb: float = 10.0, max_depth: int = 6
|
||||
) -> List[ScannedModel]:
|
||||
"""
|
||||
Discover all model directories on the system
|
||||
|
||||
Fast scan using find command to locate all models that meet the criteria
|
||||
|
||||
Args:
|
||||
mount_points: List of mount points to scan (None = auto-detect)
|
||||
min_size_gb: Minimum model size in GB (default: 10.0)
|
||||
max_depth: Maximum search depth (default: 6)
|
||||
|
||||
Returns:
|
||||
List of ScannedModel sorted by path
|
||||
"""
|
||||
# Auto-detect mount points if not provided
|
||||
if mount_points is None:
|
||||
mount_points = _get_mount_points()
|
||||
|
||||
# Fast scan with cached info (only scan once!)
|
||||
model_info = scan_all_models_with_info(mount_points, min_size_gb, max_depth)
|
||||
|
||||
if not model_info:
|
||||
return []
|
||||
|
||||
# Convert to ScannedModel objects
|
||||
results = []
|
||||
for model_path, (model_type, total_size, file_count, files) in model_info.items():
|
||||
results.append(
|
||||
ScannedModel(path=model_path, format=model_type, size_bytes=total_size, file_count=file_count, files=files)
|
||||
)
|
||||
|
||||
# Sort by path
|
||||
results.sort(key=lambda m: m.path)
|
||||
return results
|
||||
|
||||
|
||||
def _get_mount_points() -> List[str]:
|
||||
"""
|
||||
Get all valid mount points from /proc/mounts, filtering out system paths
|
||||
|
||||
Returns:
|
||||
List of mount point paths suitable for model storage
|
||||
(excludes root "/" to avoid scanning entire filesystem)
|
||||
"""
|
||||
mount_points = set()
|
||||
|
||||
# System paths to exclude (unlikely to contain model files)
|
||||
excluded_paths = [
|
||||
"/snap/",
|
||||
"/proc/",
|
||||
"/sys/",
|
||||
"/run/",
|
||||
"/boot",
|
||||
"/dev/",
|
||||
"/usr",
|
||||
"/lib",
|
||||
"/lib64",
|
||||
"/bin",
|
||||
"/sbin",
|
||||
"/etc",
|
||||
"/opt",
|
||||
"/var",
|
||||
"/tmp",
|
||||
]
|
||||
|
||||
try:
|
||||
with open("/proc/mounts", "r") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
|
||||
device, mount_point, fs_type = parts[0], parts[1], parts[2]
|
||||
|
||||
# Filter out pseudo filesystems
|
||||
pseudo_fs = {
|
||||
"proc",
|
||||
"sysfs",
|
||||
"devpts",
|
||||
"tmpfs",
|
||||
"devtmpfs",
|
||||
"cgroup",
|
||||
"cgroup2",
|
||||
"pstore",
|
||||
"bpf",
|
||||
"tracefs",
|
||||
"debugfs",
|
||||
"hugetlbfs",
|
||||
"mqueue",
|
||||
"configfs",
|
||||
"securityfs",
|
||||
"fuse.gvfsd-fuse",
|
||||
"fusectl",
|
||||
"squashfs",
|
||||
"overlay", # snap packages
|
||||
}
|
||||
|
||||
if fs_type in pseudo_fs:
|
||||
continue
|
||||
|
||||
# Skip root directory (too large to scan)
|
||||
if mount_point == "/":
|
||||
continue
|
||||
|
||||
# Filter out system paths
|
||||
if any(mount_point.startswith(x) for x in excluded_paths):
|
||||
continue
|
||||
|
||||
# Only include if it exists and is readable
|
||||
if os.path.exists(mount_point) and os.access(mount_point, os.R_OK):
|
||||
mount_points.add(mount_point)
|
||||
|
||||
# If no mount points found, add common data directories
|
||||
if not mount_points:
|
||||
# Add /home if it exists and is not already a separate mount point
|
||||
common_paths = ["/home", "/data", "/mnt"]
|
||||
for path in common_paths:
|
||||
if os.path.exists(path) and os.access(path, os.R_OK):
|
||||
mount_points.add(path)
|
||||
|
||||
except (FileNotFoundError, PermissionError):
|
||||
# Fallback to common paths
|
||||
mount_points = {"/home", "/mnt", "/data"}
|
||||
|
||||
return sorted(mount_points)
|
||||
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
Shared model table builders for consistent UI across commands.
|
||||
|
||||
Provides reusable table construction functions for displaying models
|
||||
in kt model list, kt quant, kt run, etc.
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from rich.table import Table
|
||||
from rich.console import Console
|
||||
import json
|
||||
|
||||
|
||||
def format_model_size(model_path: Path, format_type: str) -> str:
|
||||
"""Calculate and format model size."""
|
||||
from kt_kernel.cli.utils.model_scanner import format_size
|
||||
|
||||
try:
|
||||
if format_type == "safetensors":
|
||||
files = list(model_path.glob("*.safetensors"))
|
||||
elif format_type == "gguf":
|
||||
files = list(model_path.glob("*.gguf"))
|
||||
else:
|
||||
return "[dim]-[/dim]"
|
||||
|
||||
total_size = sum(f.stat().st_size for f in files if f.exists())
|
||||
return format_size(total_size)
|
||||
except Exception:
|
||||
return "[dim]-[/dim]"
|
||||
|
||||
|
||||
def format_repo_info(model) -> str:
|
||||
"""Format repository information."""
|
||||
if model.repo_id:
|
||||
repo_abbr = "hf" if model.repo_type == "huggingface" else "ms"
|
||||
return f"{repo_abbr}:{model.repo_id}"
|
||||
return "[dim]-[/dim]"
|
||||
|
||||
|
||||
def format_sha256_status(model, status_map: dict) -> str:
|
||||
"""Format SHA256 verification status."""
|
||||
return status_map.get(model.sha256_status or "not_checked", "[dim]?[/dim]")
|
||||
|
||||
|
||||
def build_moe_gpu_table(
|
||||
models: List, status_map: dict, show_index: bool = True, start_index: int = 1
|
||||
) -> Tuple[Table, List]:
|
||||
"""
|
||||
Build MoE GPU models table.
|
||||
|
||||
Args:
|
||||
models: List of MoE GPU model objects
|
||||
status_map: SHA256_STATUS_MAP for formatting status
|
||||
show_index: Whether to show # column for selection (default: True)
|
||||
start_index: Starting index number
|
||||
|
||||
Returns:
|
||||
Tuple of (Table object, list of models in display order)
|
||||
"""
|
||||
table = Table(show_header=True, header_style="bold", show_lines=False)
|
||||
|
||||
if show_index:
|
||||
table.add_column("#", justify="right", style="cyan", no_wrap=True)
|
||||
|
||||
table.add_column("Name", style="cyan", no_wrap=True)
|
||||
table.add_column("Path", style="dim", overflow="fold")
|
||||
table.add_column("Total", justify="right")
|
||||
table.add_column("Exps", justify="center", style="yellow")
|
||||
table.add_column("Act", justify="center", style="green")
|
||||
table.add_column("Repository", style="dim", overflow="fold")
|
||||
table.add_column("SHA256", justify="center")
|
||||
|
||||
displayed_models = []
|
||||
|
||||
for i, model in enumerate(models, start_index):
|
||||
displayed_models.append(model)
|
||||
|
||||
# Calculate size
|
||||
size_str = format_model_size(Path(model.path), "safetensors")
|
||||
|
||||
# MoE info
|
||||
num_experts = str(model.moe_num_experts) if model.moe_num_experts else "[dim]-[/dim]"
|
||||
num_active = str(model.moe_num_experts_per_tok) if model.moe_num_experts_per_tok else "[dim]-[/dim]"
|
||||
|
||||
# Repository and SHA256
|
||||
repo_str = format_repo_info(model)
|
||||
sha256_str = format_sha256_status(model, status_map)
|
||||
|
||||
row = []
|
||||
if show_index:
|
||||
row.append(str(i))
|
||||
|
||||
row.extend([model.name, model.path, size_str, num_experts, num_active, repo_str, sha256_str])
|
||||
|
||||
table.add_row(*row)
|
||||
|
||||
return table, displayed_models
|
||||
|
||||
|
||||
def build_amx_table(
|
||||
models: List,
|
||||
status_map: dict = None, # Kept for API compatibility but not used
|
||||
show_index: bool = True,
|
||||
start_index: int = 1,
|
||||
show_linked_gpus: bool = False,
|
||||
gpu_models: Optional[List] = None,
|
||||
) -> Tuple[Table, List]:
|
||||
"""
|
||||
Build AMX models table.
|
||||
|
||||
Note: AMX models are locally quantized, so no SHA256 verification column.
|
||||
|
||||
Args:
|
||||
models: List of AMX model objects
|
||||
status_map: (Unused - kept for API compatibility)
|
||||
show_index: Whether to show # column for selection (default: True)
|
||||
start_index: Starting index number
|
||||
show_linked_gpus: Whether to show sub-rows for linked GPU models
|
||||
gpu_models: List of GPU models (required if show_linked_gpus=True)
|
||||
|
||||
Returns:
|
||||
Tuple of (Table object, list of models in display order)
|
||||
"""
|
||||
table = Table(show_header=True, header_style="bold", show_lines=False)
|
||||
|
||||
if show_index:
|
||||
table.add_column("#", justify="right", style="cyan", no_wrap=True)
|
||||
|
||||
table.add_column("Name", style="cyan", no_wrap=True)
|
||||
table.add_column("Path", style="dim", overflow="fold")
|
||||
table.add_column("Total", justify="right")
|
||||
table.add_column("Method", justify="center", style="yellow")
|
||||
table.add_column("NUMA", justify="center", style="green")
|
||||
table.add_column("Source", style="dim", overflow="fold")
|
||||
|
||||
# Build reverse map if needed
|
||||
amx_used_by_gpu = {}
|
||||
if show_linked_gpus and gpu_models:
|
||||
for model in models:
|
||||
if model.gpu_model_ids:
|
||||
gpu_names = []
|
||||
for gpu_id in model.gpu_model_ids:
|
||||
for gpu_model in gpu_models:
|
||||
if gpu_model.id == gpu_id:
|
||||
gpu_names.append(gpu_model.name)
|
||||
break
|
||||
if gpu_names:
|
||||
amx_used_by_gpu[model.id] = gpu_names
|
||||
|
||||
displayed_models = []
|
||||
|
||||
for i, model in enumerate(models, start_index):
|
||||
displayed_models.append(model)
|
||||
|
||||
# Calculate size
|
||||
size_str = format_model_size(Path(model.path), "safetensors")
|
||||
|
||||
# Read metadata from config.json or UserModel fields
|
||||
method_from_config = None
|
||||
numa_from_config = None
|
||||
try:
|
||||
config_path = Path(model.path) / "config.json"
|
||||
if config_path.exists():
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
amx_quant = config.get("amx_quantization", {})
|
||||
if amx_quant.get("converted"):
|
||||
method_from_config = amx_quant.get("method")
|
||||
numa_from_config = amx_quant.get("numa_count")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Priority: UserModel fields > config.json > ?
|
||||
method_display = (
|
||||
model.amx_quant_method.upper()
|
||||
if model.amx_quant_method
|
||||
else method_from_config.upper() if method_from_config else "[dim]?[/dim]"
|
||||
)
|
||||
numa_display = (
|
||||
str(model.amx_numa_nodes)
|
||||
if model.amx_numa_nodes
|
||||
else str(numa_from_config) if numa_from_config else "[dim]?[/dim]"
|
||||
)
|
||||
source_display = model.amx_source_model or "[dim]-[/dim]"
|
||||
|
||||
row = []
|
||||
if show_index:
|
||||
row.append(str(i))
|
||||
|
||||
row.extend([model.name, model.path, size_str, method_display, numa_display, source_display])
|
||||
|
||||
table.add_row(*row)
|
||||
|
||||
# Add sub-row showing linked GPUs
|
||||
if show_linked_gpus and model.id in amx_used_by_gpu:
|
||||
gpu_list = amx_used_by_gpu[model.id]
|
||||
gpu_names_str = ", ".join([f"[dim]{name}[/dim]" for name in gpu_list])
|
||||
sub_row = []
|
||||
if show_index:
|
||||
sub_row.append("")
|
||||
sub_row.extend([f" [dim]↳ GPU: {gpu_names_str}[/dim]", "", "", "", "", ""])
|
||||
table.add_row(*sub_row, style="dim")
|
||||
|
||||
return table, displayed_models
|
||||
|
||||
|
||||
def build_gguf_table(
|
||||
models: List, status_map: dict, show_index: bool = True, start_index: int = 1
|
||||
) -> Tuple[Table, List]:
|
||||
"""
|
||||
Build GGUF models table.
|
||||
|
||||
Args:
|
||||
models: List of GGUF model objects
|
||||
status_map: SHA256_STATUS_MAP for formatting status
|
||||
show_index: Whether to show # column for selection (default: True)
|
||||
start_index: Starting index number
|
||||
|
||||
Returns:
|
||||
Tuple of (Table object, list of models in display order)
|
||||
"""
|
||||
table = Table(show_header=True, header_style="bold", show_lines=False)
|
||||
|
||||
if show_index:
|
||||
table.add_column("#", justify="right", style="cyan", no_wrap=True)
|
||||
|
||||
table.add_column("Name", style="cyan", no_wrap=True)
|
||||
table.add_column("Path", style="dim", overflow="fold")
|
||||
table.add_column("Total", justify="right")
|
||||
table.add_column("Repository", style="dim", overflow="fold")
|
||||
table.add_column("SHA256", justify="center")
|
||||
|
||||
displayed_models = []
|
||||
|
||||
for i, model in enumerate(models, start_index):
|
||||
displayed_models.append(model)
|
||||
|
||||
# Calculate size
|
||||
size_str = format_model_size(Path(model.path), "gguf")
|
||||
|
||||
# Repository and SHA256
|
||||
repo_str = format_repo_info(model)
|
||||
sha256_str = format_sha256_status(model, status_map)
|
||||
|
||||
row = []
|
||||
if show_index:
|
||||
row.append(str(i))
|
||||
|
||||
row.extend([model.name, model.path, size_str, repo_str, sha256_str])
|
||||
|
||||
table.add_row(*row)
|
||||
|
||||
return table, displayed_models
|
||||
@@ -0,0 +1,918 @@
|
||||
"""
|
||||
Model Verifier
|
||||
|
||||
SHA256 verification for model integrity
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import requests
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Literal, Tuple
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
|
||||
|
||||
def _compute_file_sha256(file_path: Path) -> Tuple[str, str, float]:
|
||||
"""
|
||||
Compute SHA256 for a single file (worker function for multiprocessing).
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
Tuple of (filename, sha256_hash, file_size_mb)
|
||||
"""
|
||||
sha256_hash = hashlib.sha256()
|
||||
file_size_mb = file_path.stat().st_size / (1024 * 1024)
|
||||
|
||||
# Read file in chunks to handle large files
|
||||
with open(file_path, "rb") as f:
|
||||
for byte_block in iter(lambda: f.read(8192 * 1024), b""): # 8MB chunks
|
||||
sha256_hash.update(byte_block)
|
||||
|
||||
return file_path.name, sha256_hash.hexdigest(), file_size_mb
|
||||
|
||||
|
||||
def check_huggingface_connectivity(timeout: int = 5) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if HuggingFace is accessible.
|
||||
|
||||
Args:
|
||||
timeout: Connection timeout in seconds
|
||||
|
||||
Returns:
|
||||
Tuple of (is_accessible, message)
|
||||
"""
|
||||
test_url = "https://huggingface.co"
|
||||
|
||||
try:
|
||||
response = requests.head(test_url, timeout=timeout, allow_redirects=True)
|
||||
if response.status_code < 500: # 2xx, 3xx, 4xx are all considered "accessible"
|
||||
return True, "HuggingFace is accessible"
|
||||
except requests.exceptions.Timeout:
|
||||
return False, f"Connection to {test_url} timed out"
|
||||
except requests.exceptions.ConnectionError:
|
||||
return False, f"Cannot connect to {test_url}"
|
||||
except requests.exceptions.RequestException as e:
|
||||
return False, f"Connection error: {str(e)}"
|
||||
|
||||
return False, "Unknown connection error"
|
||||
|
||||
|
||||
def verify_model_integrity(
|
||||
repo_type: Literal["huggingface", "modelscope"],
|
||||
repo_id: str,
|
||||
local_dir: Path,
|
||||
progress_callback=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify local model integrity against remote repository SHA256 hashes.
|
||||
|
||||
Verifies all important files:
|
||||
- *.safetensors (weights)
|
||||
- *.json (config files)
|
||||
- *.py (custom model code)
|
||||
|
||||
Args:
|
||||
repo_type: Type of repository ("huggingface" or "modelscope")
|
||||
repo_id: Repository ID (e.g., "deepseek-ai/DeepSeek-V3")
|
||||
local_dir: Local directory containing model files
|
||||
progress_callback: Optional callback function(message: str) for progress updates
|
||||
|
||||
Returns:
|
||||
Dictionary with verification results:
|
||||
{
|
||||
"status": "passed" | "failed" | "error",
|
||||
"files_checked": int,
|
||||
"files_passed": int,
|
||||
"files_failed": [list of filenames],
|
||||
"error_message": str (optional)
|
||||
}
|
||||
"""
|
||||
|
||||
def report_progress(msg: str):
|
||||
"""Helper to report progress"""
|
||||
if progress_callback:
|
||||
progress_callback(msg)
|
||||
|
||||
try:
|
||||
# Convert repo_type to platform format
|
||||
platform = "hf" if repo_type == "huggingface" else "ms"
|
||||
|
||||
# 1. Fetch official SHA256 hashes from remote
|
||||
report_progress("Fetching official SHA256 hashes from remote repository...")
|
||||
official_hashes = fetch_model_sha256(repo_id, platform)
|
||||
report_progress(f"✓ Fetched {len(official_hashes)} file hashes from remote")
|
||||
|
||||
if not official_hashes:
|
||||
return {
|
||||
"status": "error",
|
||||
"files_checked": 0,
|
||||
"files_passed": 0,
|
||||
"files_failed": [],
|
||||
"error_message": f"No verifiable files found in remote repository: {repo_id}",
|
||||
}
|
||||
|
||||
# 2. Calculate local SHA256 hashes with progress
|
||||
report_progress(f"Calculating SHA256 for local files...")
|
||||
|
||||
# Get all local files matching the patterns
|
||||
local_files = []
|
||||
for pattern in ["*.safetensors", "*.json", "*.py"]:
|
||||
local_files.extend([f for f in local_dir.glob(pattern) if f.is_file()])
|
||||
|
||||
if not local_files:
|
||||
return {
|
||||
"status": "error",
|
||||
"files_checked": 0,
|
||||
"files_passed": 0,
|
||||
"files_failed": [],
|
||||
"error_message": f"No verifiable files found in local directory: {local_dir}",
|
||||
}
|
||||
|
||||
# Calculate hashes for all files
|
||||
local_hashes = calculate_local_sha256(
|
||||
local_dir,
|
||||
file_pattern="*.safetensors", # Unused when files_list is provided
|
||||
progress_callback=report_progress,
|
||||
files_list=local_files,
|
||||
)
|
||||
report_progress(f"✓ Calculated {len(local_hashes)} local file hashes")
|
||||
|
||||
# 3. Compare hashes with progress
|
||||
report_progress(f"Comparing {len(official_hashes)} files...")
|
||||
files_failed = []
|
||||
files_missing = []
|
||||
files_passed = 0
|
||||
|
||||
for idx, (filename, official_hash) in enumerate(official_hashes.items(), 1):
|
||||
# Handle potential path separators in filename
|
||||
file_basename = Path(filename).name
|
||||
|
||||
# Try to find the file in local hashes
|
||||
local_hash = None
|
||||
for local_file, local_hash_value in local_hashes.items():
|
||||
if Path(local_file).name == file_basename:
|
||||
local_hash = local_hash_value
|
||||
break
|
||||
|
||||
if local_hash is None:
|
||||
files_missing.append(filename)
|
||||
report_progress(f" [{idx}/{len(official_hashes)}] ✗ {file_basename} - MISSING")
|
||||
elif local_hash.lower() != official_hash.lower():
|
||||
files_failed.append(f"{filename} (hash mismatch)")
|
||||
report_progress(f" [{idx}/{len(official_hashes)}] ✗ {file_basename} - HASH MISMATCH")
|
||||
else:
|
||||
files_passed += 1
|
||||
report_progress(f" [{idx}/{len(official_hashes)}] ✓ {file_basename}")
|
||||
|
||||
# 4. Return results
|
||||
total_checked = len(official_hashes)
|
||||
|
||||
if files_failed or files_missing:
|
||||
all_failed = files_failed + [f"{f} (missing)" for f in files_missing]
|
||||
return {
|
||||
"status": "failed",
|
||||
"files_checked": total_checked,
|
||||
"files_passed": files_passed,
|
||||
"files_failed": all_failed,
|
||||
"error_message": f"{len(all_failed)} file(s) failed verification",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "passed",
|
||||
"files_checked": total_checked,
|
||||
"files_passed": files_passed,
|
||||
"files_failed": [],
|
||||
}
|
||||
|
||||
except ImportError as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"files_checked": 0,
|
||||
"files_passed": 0,
|
||||
"files_failed": [],
|
||||
"error_message": f"Missing required package: {str(e)}. Install with: pip install huggingface-hub modelscope",
|
||||
"is_network_error": False,
|
||||
}
|
||||
except (
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.Timeout,
|
||||
requests.exceptions.RequestException,
|
||||
) as e:
|
||||
# Network-related errors - suggest mirror
|
||||
error_msg = f"Network error: {str(e)}"
|
||||
if repo_type == "huggingface":
|
||||
error_msg += "\n\nTry using HuggingFace mirror:\n export HF_ENDPOINT=https://hf-mirror.com"
|
||||
return {
|
||||
"status": "error",
|
||||
"files_checked": 0,
|
||||
"files_passed": 0,
|
||||
"files_failed": [],
|
||||
"error_message": error_msg,
|
||||
"is_network_error": True,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"files_checked": 0,
|
||||
"files_passed": 0,
|
||||
"files_failed": [],
|
||||
"error_message": f"Verification failed: {str(e)}",
|
||||
"is_network_error": False,
|
||||
}
|
||||
|
||||
|
||||
def calculate_local_sha256(
|
||||
local_dir: Path, file_pattern: str = "*.safetensors", progress_callback=None, files_list: list[Path] = None
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Calculate SHA256 hashes for files in a directory using parallel processing.
|
||||
|
||||
Args:
|
||||
local_dir: Directory to scan
|
||||
file_pattern: Glob pattern for files to hash (ignored if files_list is provided)
|
||||
progress_callback: Optional callback function(message: str) for progress updates
|
||||
files_list: Optional pre-filtered list of files to hash (overrides file_pattern)
|
||||
|
||||
Returns:
|
||||
Dictionary mapping filename to SHA256 hash
|
||||
"""
|
||||
result = {}
|
||||
|
||||
if not local_dir.exists():
|
||||
return result
|
||||
|
||||
# Get all files first to report total
|
||||
if files_list is not None:
|
||||
files_to_hash = files_list
|
||||
else:
|
||||
files_to_hash = [f for f in local_dir.glob(file_pattern) if f.is_file()]
|
||||
total_files = len(files_to_hash)
|
||||
|
||||
if total_files == 0:
|
||||
return result
|
||||
|
||||
# Use min(16, total_files) workers to avoid over-spawning processes
|
||||
max_workers = min(16, total_files)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(f" Using {max_workers} parallel workers for SHA256 calculation")
|
||||
|
||||
# Use ProcessPoolExecutor for CPU-intensive SHA256 computation
|
||||
completed_count = 0
|
||||
with ProcessPoolExecutor(max_workers=max_workers) as executor:
|
||||
# Submit all tasks
|
||||
future_to_file = {executor.submit(_compute_file_sha256, file_path): file_path for file_path in files_to_hash}
|
||||
|
||||
# Process results as they complete
|
||||
for future in as_completed(future_to_file):
|
||||
completed_count += 1
|
||||
try:
|
||||
filename, sha256_hash, file_size_mb = future.result()
|
||||
result[filename] = sha256_hash
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(f" [{completed_count}/{total_files}] ✓ {filename} ({file_size_mb:.1f} MB)")
|
||||
|
||||
except Exception as e:
|
||||
file_path = future_to_file[future]
|
||||
if progress_callback:
|
||||
progress_callback(f" [{completed_count}/{total_files}] ✗ {file_path.name} - Error: {str(e)}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def fetch_model_sha256(
|
||||
repo_id: str,
|
||||
platform: Literal["hf", "ms"],
|
||||
revision: str | None = None,
|
||||
use_mirror: bool = False,
|
||||
timeout: int | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
获取模型仓库中所有重要文件的 sha256 哈希值。
|
||||
|
||||
包括:
|
||||
- *.safetensors (权重文件)
|
||||
- *.json (配置文件:config.json, tokenizer_config.json 等)
|
||||
- *.py (自定义模型代码:modeling.py, configuration.py 等)
|
||||
|
||||
Args:
|
||||
repo_id: 仓库 ID,例如 "Qwen/Qwen3-30B-A3B"
|
||||
platform: 平台,"hf" (HuggingFace) 或 "ms" (ModelScope)
|
||||
revision: 版本/分支,默认 HuggingFace 为 "main",ModelScope 为 "master"
|
||||
use_mirror: 是否使用镜像(仅对 HuggingFace 有效)
|
||||
timeout: 网络请求超时时间(秒),None 表示不设置超时
|
||||
|
||||
Returns:
|
||||
dict: 文件名到 sha256 的映射,例如 {"model-00001-of-00016.safetensors": "abc123...", "config.json": "def456..."}
|
||||
"""
|
||||
if platform == "hf":
|
||||
# 先尝试直连,失败后自动使用镜像
|
||||
try:
|
||||
if use_mirror:
|
||||
return _fetch_from_huggingface(repo_id, revision or "main", use_mirror=True, timeout=timeout)
|
||||
else:
|
||||
return _fetch_from_huggingface(repo_id, revision or "main", use_mirror=False, timeout=timeout)
|
||||
except Exception as e:
|
||||
# 如果不是镜像模式且失败了,自动重试使用镜像
|
||||
if not use_mirror:
|
||||
return _fetch_from_huggingface(repo_id, revision or "main", use_mirror=True, timeout=timeout)
|
||||
else:
|
||||
raise e
|
||||
elif platform == "ms":
|
||||
return _fetch_from_modelscope(repo_id, revision or "master", timeout=timeout)
|
||||
else:
|
||||
raise ValueError(f"不支持的平台: {platform},请使用 'hf' 或 'ms'")
|
||||
|
||||
|
||||
def _fetch_from_huggingface(
|
||||
repo_id: str, revision: str, use_mirror: bool = False, timeout: int | None = None
|
||||
) -> dict[str, str]:
|
||||
"""从 HuggingFace 获取所有重要文件的 sha256
|
||||
|
||||
Args:
|
||||
repo_id: 仓库 ID
|
||||
revision: 版本/分支
|
||||
use_mirror: 是否使用镜像(hf-mirror.com)
|
||||
timeout: 网络请求超时时间(秒),None 表示不设置超时
|
||||
"""
|
||||
import os
|
||||
import socket
|
||||
|
||||
# 如果需要使用镜像,设置环境变量
|
||||
original_endpoint = os.environ.get("HF_ENDPOINT")
|
||||
if use_mirror and not original_endpoint:
|
||||
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
|
||||
|
||||
# Set socket timeout if specified
|
||||
original_timeout = socket.getdefaulttimeout()
|
||||
if timeout is not None:
|
||||
socket.setdefaulttimeout(timeout)
|
||||
|
||||
from huggingface_hub import HfApi, list_repo_files
|
||||
|
||||
try:
|
||||
api = HfApi()
|
||||
all_files = list_repo_files(repo_id=repo_id, revision=revision)
|
||||
|
||||
# 筛选重要文件:*.safetensors, *.json, *.py
|
||||
important_files = [f for f in all_files if f.endswith((".safetensors", ".json", ".py"))]
|
||||
|
||||
if not important_files:
|
||||
return {}
|
||||
|
||||
paths_info = api.get_paths_info(
|
||||
repo_id=repo_id,
|
||||
paths=important_files,
|
||||
revision=revision,
|
||||
)
|
||||
|
||||
result = {}
|
||||
for file_info in paths_info:
|
||||
if hasattr(file_info, "lfs") and file_info.lfs is not None:
|
||||
sha256 = file_info.lfs.sha256
|
||||
else:
|
||||
sha256 = getattr(file_info, "blob_id", None)
|
||||
result[file_info.path] = sha256
|
||||
|
||||
return result
|
||||
finally:
|
||||
# 恢复原始 socket timeout
|
||||
socket.setdefaulttimeout(original_timeout)
|
||||
|
||||
# 恢复原始环境变量
|
||||
if use_mirror and not original_endpoint:
|
||||
os.environ.pop("HF_ENDPOINT", None)
|
||||
elif original_endpoint:
|
||||
os.environ["HF_ENDPOINT"] = original_endpoint
|
||||
|
||||
|
||||
def _fetch_from_modelscope(repo_id: str, revision: str, timeout: int | None = None) -> dict[str, str]:
|
||||
"""从 ModelScope 获取所有重要文件的 sha256
|
||||
|
||||
Args:
|
||||
repo_id: 仓库 ID
|
||||
revision: 版本/分支
|
||||
timeout: 网络请求超时时间(秒),None 表示不设置超时
|
||||
"""
|
||||
import socket
|
||||
from modelscope.hub.api import HubApi
|
||||
|
||||
# Set socket timeout if specified
|
||||
original_timeout = socket.getdefaulttimeout()
|
||||
if timeout is not None:
|
||||
socket.setdefaulttimeout(timeout)
|
||||
|
||||
try:
|
||||
api = HubApi()
|
||||
files_info = api.get_model_files(model_id=repo_id, revision=revision)
|
||||
|
||||
result = {}
|
||||
for file_info in files_info:
|
||||
filename = file_info.get("Name", file_info.get("Path", ""))
|
||||
# 筛选重要文件:*.safetensors, *.json, *.py
|
||||
if filename.endswith((".safetensors", ".json", ".py")):
|
||||
sha256 = file_info.get("Sha256", file_info.get("sha256", None))
|
||||
result[filename] = sha256
|
||||
|
||||
return result
|
||||
finally:
|
||||
# 恢复原始 socket timeout
|
||||
socket.setdefaulttimeout(original_timeout)
|
||||
|
||||
|
||||
def verify_model_integrity_with_progress(
|
||||
repo_type: Literal["huggingface", "modelscope"],
|
||||
repo_id: str,
|
||||
local_dir: Path,
|
||||
progress_callback=None,
|
||||
verbose: bool = False,
|
||||
use_mirror: bool = False,
|
||||
files_to_verify: list[str] | None = None,
|
||||
timeout: int | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify model integrity with enhanced progress reporting for Rich Progress bars.
|
||||
|
||||
This is a wrapper around verify_model_integrity() that provides more detailed
|
||||
progress information suitable for progress bar display.
|
||||
|
||||
The progress_callback receives:
|
||||
- (message: str, total: int, current: int) for countable operations
|
||||
- (message: str) for status updates
|
||||
|
||||
Args:
|
||||
repo_type: Repository type ("huggingface" or "modelscope")
|
||||
repo_id: Repository ID
|
||||
local_dir: Local directory path
|
||||
progress_callback: Optional callback for progress updates
|
||||
verbose: If True, output detailed SHA256 comparison for each file
|
||||
use_mirror: If True, use HuggingFace mirror (hf-mirror.com)
|
||||
files_to_verify: Optional list of specific files to verify (for re-verification)
|
||||
timeout: Network request timeout in seconds (None = no timeout)
|
||||
"""
|
||||
|
||||
def report_progress(msg: str, total=None, current=None):
|
||||
"""Enhanced progress reporter"""
|
||||
if progress_callback:
|
||||
progress_callback(msg, total, current)
|
||||
|
||||
try:
|
||||
platform = "hf" if repo_type == "huggingface" else "ms"
|
||||
|
||||
# 1. Fetch official SHA256 hashes
|
||||
if files_to_verify:
|
||||
report_progress(f"Fetching SHA256 hashes for {len(files_to_verify)} files...")
|
||||
elif use_mirror and platform == "hf":
|
||||
report_progress("Fetching official SHA256 hashes from mirror (hf-mirror.com)...")
|
||||
else:
|
||||
report_progress("Fetching official SHA256 hashes from remote repository...")
|
||||
|
||||
official_hashes = fetch_model_sha256(repo_id, platform, use_mirror=use_mirror, timeout=timeout)
|
||||
|
||||
# Filter to only requested files if specified
|
||||
if files_to_verify:
|
||||
# Extract clean filenames from files_to_verify (remove markers like "(missing)")
|
||||
clean_filenames = set()
|
||||
for f in files_to_verify:
|
||||
clean_f = f.replace(" (missing)", "").replace(" (hash mismatch)", "").strip()
|
||||
# Ensure we only use the filename, not full path
|
||||
clean_filenames.add(Path(clean_f).name)
|
||||
|
||||
# Filter official_hashes to only include requested files
|
||||
# Compare using basename since official_hashes keys might have paths
|
||||
official_hashes = {k: v for k, v in official_hashes.items() if Path(k).name in clean_filenames}
|
||||
|
||||
report_progress(f"✓ Fetched {len(official_hashes)} file hashes from remote")
|
||||
|
||||
if not official_hashes:
|
||||
return {
|
||||
"status": "error",
|
||||
"files_checked": 0,
|
||||
"files_passed": 0,
|
||||
"files_failed": [],
|
||||
"error_message": f"No safetensors files found in remote repository: {repo_id}",
|
||||
}
|
||||
|
||||
# 2. Calculate local SHA256 hashes
|
||||
local_dir_path = Path(local_dir)
|
||||
|
||||
# Only hash the files we need to verify
|
||||
if files_to_verify:
|
||||
# Extract clean filenames (without markers)
|
||||
clean_filenames = set()
|
||||
for f in files_to_verify:
|
||||
clean_f = f.replace(" (missing)", "").replace(" (hash mismatch)", "").strip()
|
||||
# Ensure we only use the filename, not full path
|
||||
clean_filenames.add(Path(clean_f).name)
|
||||
|
||||
# Only hash files that match the clean filenames
|
||||
files_to_hash = [
|
||||
f for f in local_dir_path.glob("*.safetensors") if f.is_file() and f.name in clean_filenames
|
||||
]
|
||||
else:
|
||||
files_to_hash = [f for f in local_dir_path.glob("*.safetensors") if f.is_file()]
|
||||
|
||||
total_files = len(files_to_hash)
|
||||
|
||||
if files_to_verify:
|
||||
report_progress(f"Calculating SHA256 for {total_files} repaired files...", total=total_files, current=0)
|
||||
else:
|
||||
report_progress(f"Calculating SHA256 for local files...", total=total_files, current=0)
|
||||
|
||||
# Progress wrapper for hashing
|
||||
completed_count = [0] # Use list for mutable closure
|
||||
|
||||
def hash_progress_callback(msg: str):
|
||||
if "Using" in msg and "workers" in msg:
|
||||
report_progress(msg)
|
||||
elif "[" in msg and "/" in msg and "]" in msg:
|
||||
# Progress update like: [1/10] ✓ filename (123.4 MB)
|
||||
completed_count[0] += 1
|
||||
report_progress(msg, total=total_files, current=completed_count[0])
|
||||
|
||||
# Pass the pre-filtered files_to_hash list
|
||||
local_hashes = calculate_local_sha256(
|
||||
local_dir_path,
|
||||
"*.safetensors",
|
||||
progress_callback=hash_progress_callback,
|
||||
files_list=files_to_hash if files_to_verify else None,
|
||||
)
|
||||
report_progress(f"✓ Calculated {len(local_hashes)} local file hashes")
|
||||
|
||||
# 3. Compare hashes
|
||||
report_progress(f"Comparing {len(official_hashes)} files...", total=len(official_hashes), current=0)
|
||||
|
||||
files_failed = []
|
||||
files_missing = []
|
||||
files_passed = 0
|
||||
|
||||
for idx, (filename, official_hash) in enumerate(official_hashes.items(), 1):
|
||||
file_basename = Path(filename).name
|
||||
|
||||
# Find matching local file
|
||||
local_hash = None
|
||||
for local_file, local_hash_value in local_hashes.items():
|
||||
if Path(local_file).name == file_basename:
|
||||
local_hash = local_hash_value
|
||||
break
|
||||
|
||||
if local_hash is None:
|
||||
files_missing.append(filename)
|
||||
if verbose:
|
||||
report_progress(
|
||||
f"[{idx}/{len(official_hashes)}] ✗ {file_basename} (missing)\n Remote: {official_hash}\n Local: <missing>",
|
||||
total=len(official_hashes),
|
||||
current=idx,
|
||||
)
|
||||
else:
|
||||
report_progress(
|
||||
f"[{idx}/{len(official_hashes)}] ✗ {file_basename} (missing)",
|
||||
total=len(official_hashes),
|
||||
current=idx,
|
||||
)
|
||||
elif local_hash.lower() != official_hash.lower():
|
||||
files_failed.append(f"{filename} (hash mismatch)")
|
||||
if verbose:
|
||||
report_progress(
|
||||
f"[{idx}/{len(official_hashes)}] ✗ {file_basename} (hash mismatch)\n Remote: {official_hash}\n Local: {local_hash}",
|
||||
total=len(official_hashes),
|
||||
current=idx,
|
||||
)
|
||||
else:
|
||||
report_progress(
|
||||
f"[{idx}/{len(official_hashes)}] ✗ {file_basename} (hash mismatch)",
|
||||
total=len(official_hashes),
|
||||
current=idx,
|
||||
)
|
||||
else:
|
||||
files_passed += 1
|
||||
if verbose:
|
||||
report_progress(
|
||||
f"[{idx}/{len(official_hashes)}] ✓ {file_basename}\n Remote: {official_hash}\n Local: {local_hash}",
|
||||
total=len(official_hashes),
|
||||
current=idx,
|
||||
)
|
||||
else:
|
||||
report_progress(
|
||||
f"[{idx}/{len(official_hashes)}] ✓ {file_basename}", total=len(official_hashes), current=idx
|
||||
)
|
||||
|
||||
# 4. Return results
|
||||
total_checked = len(official_hashes)
|
||||
|
||||
if files_failed or files_missing:
|
||||
all_failed = files_failed + [f"{f} (missing)" for f in files_missing]
|
||||
return {
|
||||
"status": "failed",
|
||||
"files_checked": total_checked,
|
||||
"files_passed": files_passed,
|
||||
"files_failed": all_failed,
|
||||
"error_message": f"{len(all_failed)} file(s) failed verification",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "passed",
|
||||
"files_checked": total_checked,
|
||||
"files_passed": files_passed,
|
||||
"files_failed": [],
|
||||
}
|
||||
|
||||
except (
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.Timeout,
|
||||
requests.exceptions.RequestException,
|
||||
TimeoutError, # Socket timeout from socket.setdefaulttimeout()
|
||||
OSError, # Network-related OS errors
|
||||
) as e:
|
||||
error_msg = f"Network error: {str(e)}"
|
||||
if repo_type == "huggingface":
|
||||
error_msg += "\n\nTry using HuggingFace mirror:\n export HF_ENDPOINT=https://hf-mirror.com"
|
||||
return {
|
||||
"status": "error",
|
||||
"files_checked": 0,
|
||||
"files_passed": 0,
|
||||
"files_failed": [],
|
||||
"error_message": error_msg,
|
||||
"is_network_error": True,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"files_checked": 0,
|
||||
"files_passed": 0,
|
||||
"files_failed": [],
|
||||
"error_message": f"Verification failed: {str(e)}",
|
||||
"is_network_error": False,
|
||||
}
|
||||
|
||||
|
||||
def pre_operation_verification(user_model, user_registry, operation_name: str = "operation") -> None:
|
||||
"""Pre-operation verification of model integrity.
|
||||
|
||||
Can be used before running or quantizing models to ensure integrity.
|
||||
|
||||
Args:
|
||||
user_model: UserModel object to verify
|
||||
user_registry: UserModelRegistry instance
|
||||
operation_name: Name of the operation (e.g., "running", "quantizing")
|
||||
"""
|
||||
from rich.prompt import Prompt, Confirm
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, MofNCompleteColumn, TimeElapsedColumn
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
|
||||
from kt_kernel.cli.i18n import get_lang
|
||||
from kt_kernel.cli.utils.console import console, print_info, print_warning, print_error, print_success, print_step
|
||||
import typer
|
||||
|
||||
lang = get_lang()
|
||||
|
||||
# Check if already verified
|
||||
if user_model.sha256_status == "passed":
|
||||
console.print()
|
||||
print_info("Model integrity already verified ✓")
|
||||
console.print()
|
||||
return
|
||||
|
||||
# Model not verified yet
|
||||
console.print()
|
||||
console.print("[bold yellow]═══ Model Integrity Check ═══[/bold yellow]")
|
||||
console.print()
|
||||
|
||||
# Check if repo_id exists
|
||||
if not user_model.repo_id:
|
||||
# No repo_id - ask user to provide one
|
||||
console.print("[yellow]No repository ID configured for this model.[/yellow]")
|
||||
console.print()
|
||||
console.print("To verify model integrity, we need the repository ID (e.g., 'deepseek-ai/DeepSeek-V3')")
|
||||
console.print()
|
||||
|
||||
if not Confirm.ask("Would you like to configure repository ID now?", default=True):
|
||||
console.print()
|
||||
print_warning(f"Skipping verification. Model will be used for {operation_name} without integrity check.")
|
||||
console.print()
|
||||
return
|
||||
|
||||
# Ask for repo type
|
||||
console.print()
|
||||
console.print("Repository type:")
|
||||
console.print(" [cyan][1][/cyan] HuggingFace")
|
||||
console.print(" [cyan][2][/cyan] ModelScope")
|
||||
console.print()
|
||||
|
||||
repo_type_choice = Prompt.ask("Select repository type", choices=["1", "2"], default="1")
|
||||
repo_type = "huggingface" if repo_type_choice == "1" else "modelscope"
|
||||
|
||||
# Ask for repo_id
|
||||
console.print()
|
||||
repo_id = Prompt.ask("Enter repository ID (e.g., deepseek-ai/DeepSeek-V3)")
|
||||
|
||||
# Update model
|
||||
user_registry.update_model(user_model.name, {"repo_type": repo_type, "repo_id": repo_id})
|
||||
user_model.repo_type = repo_type
|
||||
user_model.repo_id = repo_id
|
||||
|
||||
console.print()
|
||||
print_success(f"Repository configured: {repo_type}:{repo_id}")
|
||||
console.print()
|
||||
|
||||
# Now ask if user wants to verify
|
||||
console.print("[dim]Model integrity verification is a one-time check that ensures your[/dim]")
|
||||
console.print("[dim]model weights are not corrupted. This helps prevent runtime errors.[/dim]")
|
||||
console.print()
|
||||
|
||||
if not Confirm.ask(f"Would you like to verify model integrity before {operation_name}?", default=True):
|
||||
console.print()
|
||||
print_warning(f"Skipping verification. Model will be used for {operation_name} without integrity check.")
|
||||
console.print()
|
||||
return
|
||||
|
||||
# Perform verification
|
||||
console.print()
|
||||
print_step("Verifying model integrity...")
|
||||
console.print()
|
||||
|
||||
# Check connectivity first
|
||||
use_mirror = False
|
||||
if user_model.repo_type == "huggingface":
|
||||
with console.status("[dim]Checking HuggingFace connectivity...[/dim]"):
|
||||
is_accessible, message = check_huggingface_connectivity(timeout=5)
|
||||
|
||||
if not is_accessible:
|
||||
print_warning("HuggingFace Connection Failed")
|
||||
console.print()
|
||||
console.print(f" {message}")
|
||||
console.print()
|
||||
console.print(" [yellow]Auto-switching to HuggingFace mirror:[/yellow] [cyan]hf-mirror.com[/cyan]")
|
||||
console.print()
|
||||
use_mirror = True
|
||||
|
||||
# Fetch remote hashes with timeout
|
||||
def fetch_with_timeout(repo_type, repo_id, use_mirror, timeout):
|
||||
"""Fetch hashes with timeout."""
|
||||
executor = ThreadPoolExecutor(max_workers=1)
|
||||
try:
|
||||
platform = "hf" if repo_type == "huggingface" else "ms"
|
||||
future = executor.submit(fetch_model_sha256, repo_id, platform, use_mirror=use_mirror, timeout=timeout)
|
||||
hashes = future.result(timeout=timeout)
|
||||
executor.shutdown(wait=False)
|
||||
return (hashes, False)
|
||||
except (FutureTimeoutError, Exception):
|
||||
executor.shutdown(wait=False)
|
||||
return (None, True)
|
||||
|
||||
# Try fetching hashes
|
||||
status = console.status("[dim]Fetching remote hashes...[/dim]")
|
||||
status.start()
|
||||
official_hashes, timed_out = fetch_with_timeout(user_model.repo_type, user_model.repo_id, use_mirror, 10)
|
||||
status.stop()
|
||||
|
||||
# Handle timeout with fallback
|
||||
if timed_out and user_model.repo_type == "huggingface" and not use_mirror:
|
||||
print_warning("HuggingFace Fetch Timeout (10s)")
|
||||
console.print()
|
||||
console.print(" [yellow]Trying HuggingFace mirror...[/yellow]")
|
||||
console.print()
|
||||
|
||||
status = console.status("[dim]Fetching remote hashes from mirror...[/dim]")
|
||||
status.start()
|
||||
official_hashes, timed_out = fetch_with_timeout(user_model.repo_type, user_model.repo_id, True, 10)
|
||||
status.stop()
|
||||
|
||||
if timed_out and user_model.repo_type == "huggingface":
|
||||
print_warning("HuggingFace Mirror Timeout (10s)")
|
||||
console.print()
|
||||
console.print(" [yellow]Fallback to ModelScope...[/yellow]")
|
||||
console.print()
|
||||
|
||||
status = console.status("[dim]Fetching remote hashes from ModelScope...[/dim]")
|
||||
status.start()
|
||||
official_hashes, timed_out = fetch_with_timeout("modelscope", user_model.repo_id, False, 10)
|
||||
status.stop()
|
||||
|
||||
if not official_hashes or timed_out:
|
||||
print_error("Failed to fetch remote hashes (network timeout)")
|
||||
console.print()
|
||||
console.print(" [yellow]Unable to verify model integrity due to network issues.[/yellow]")
|
||||
console.print()
|
||||
|
||||
if not Confirm.ask(f"Continue {operation_name} without verification?", default=False):
|
||||
raise typer.Exit(0)
|
||||
|
||||
console.print()
|
||||
return
|
||||
|
||||
console.print(f" [green]✓ Fetched {len(official_hashes)} file hashes[/green]")
|
||||
console.print()
|
||||
|
||||
# Calculate local hashes and compare
|
||||
local_dir = Path(user_model.path)
|
||||
files_to_hash = [f for f in local_dir.glob("*.safetensors") if f.is_file()]
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
MofNCompleteColumn(),
|
||||
TimeElapsedColumn(),
|
||||
console=console,
|
||||
) as progress:
|
||||
# Calculate local hashes
|
||||
task = progress.add_task("[yellow]Calculating local SHA256...", total=len(files_to_hash))
|
||||
|
||||
def hash_callback(msg):
|
||||
if "[" in msg and "/" in msg and "]" in msg and "✓" in msg:
|
||||
progress.advance(task)
|
||||
|
||||
local_hashes = calculate_local_sha256(local_dir, "*.safetensors", progress_callback=hash_callback)
|
||||
progress.remove_task(task)
|
||||
|
||||
console.print(f" [green]✓ Calculated {len(local_hashes)} local hashes[/green]")
|
||||
console.print()
|
||||
|
||||
# Compare hashes
|
||||
task = progress.add_task("[blue]Comparing hashes...", total=len(official_hashes))
|
||||
|
||||
files_failed = []
|
||||
files_missing = []
|
||||
files_passed = 0
|
||||
|
||||
for filename, official_hash in official_hashes.items():
|
||||
file_basename = Path(filename).name
|
||||
local_hash = None
|
||||
|
||||
for local_file, local_hash_value in local_hashes.items():
|
||||
if Path(local_file).name == file_basename:
|
||||
local_hash = local_hash_value
|
||||
break
|
||||
|
||||
if local_hash is None:
|
||||
files_missing.append(filename)
|
||||
elif local_hash.lower() != official_hash.lower():
|
||||
files_failed.append(f"{filename} (hash mismatch)")
|
||||
else:
|
||||
files_passed += 1
|
||||
|
||||
progress.advance(task)
|
||||
|
||||
progress.remove_task(task)
|
||||
|
||||
console.print()
|
||||
|
||||
# Check results
|
||||
if not files_failed and not files_missing:
|
||||
# Verification passed
|
||||
user_registry.update_model(user_model.name, {"sha256_status": "passed"})
|
||||
print_success("Model integrity verification PASSED ✓")
|
||||
console.print()
|
||||
console.print(f" All {files_passed} files verified successfully")
|
||||
console.print()
|
||||
else:
|
||||
# Verification failed
|
||||
user_registry.update_model(user_model.name, {"sha256_status": "failed"})
|
||||
print_error(f"Model integrity verification FAILED")
|
||||
console.print()
|
||||
console.print(f" ✓ Passed: [green]{files_passed}[/green]")
|
||||
console.print(f" ✗ Failed: [red]{len(files_failed) + len(files_missing)}[/red]")
|
||||
console.print()
|
||||
|
||||
if files_missing:
|
||||
console.print(f" [red]Missing files ({len(files_missing)}):[/red]")
|
||||
for f in files_missing[:5]:
|
||||
console.print(f" - {Path(f).name}")
|
||||
if len(files_missing) > 5:
|
||||
console.print(f" ... and {len(files_missing) - 5} more")
|
||||
console.print()
|
||||
|
||||
if files_failed:
|
||||
console.print(f" [red]Hash mismatch ({len(files_failed)}):[/red]")
|
||||
for f in files_failed[:5]:
|
||||
console.print(f" - {f}")
|
||||
if len(files_failed) > 5:
|
||||
console.print(f" ... and {len(files_failed) - 5} more")
|
||||
console.print()
|
||||
|
||||
console.print("[bold red]⚠ WARNING: Model weights may be corrupted![/bold red]")
|
||||
console.print()
|
||||
console.print("This could cause runtime errors or incorrect inference results.")
|
||||
console.print()
|
||||
|
||||
# Ask if user wants to repair
|
||||
if Confirm.ask("Would you like to repair (re-download) the corrupted files?", default=True):
|
||||
console.print()
|
||||
print_info("Please run: [cyan]kt model verify " + user_model.name + "[/cyan]")
|
||||
console.print()
|
||||
console.print("The verify command will guide you through the repair process.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Ask if user wants to continue anyway
|
||||
console.print()
|
||||
if not Confirm.ask(
|
||||
f"[yellow]Continue {operation_name} with potentially corrupted weights?[/yellow]", default=False
|
||||
):
|
||||
raise typer.Exit(0)
|
||||
|
||||
console.print()
|
||||
print_warning(f"Proceeding with {operation_name} using unverified weights at your own risk...")
|
||||
console.print()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Port availability checking utilities.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import sys
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
def is_port_available(host: str, port: int) -> bool:
|
||||
"""Check if a port is available on the given host.
|
||||
|
||||
Args:
|
||||
host: Host address (e.g., "0.0.0.0", "127.0.0.1")
|
||||
port: Port number to check
|
||||
|
||||
Returns:
|
||||
True if port is available, False if occupied
|
||||
"""
|
||||
try:
|
||||
bind_host = "" if host == "0.0.0.0" else host
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
if sys.platform != "win32":
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((bind_host, port))
|
||||
return True
|
||||
|
||||
except OSError:
|
||||
# If any error occurs, assume port is not available
|
||||
return False
|
||||
|
||||
|
||||
def find_available_port(host: str, start_port: int, max_attempts: int = 100) -> Tuple[bool, int]:
|
||||
"""Find an available port starting from start_port.
|
||||
|
||||
Args:
|
||||
host: Host address
|
||||
start_port: Starting port number to check
|
||||
max_attempts: Maximum number of ports to try
|
||||
|
||||
Returns:
|
||||
Tuple of (found, port_number)
|
||||
- found: True if an available port was found
|
||||
- port_number: The available port number (or start_port if not found)
|
||||
"""
|
||||
for port in range(start_port, start_port + max_attempts):
|
||||
if is_port_available(host, port):
|
||||
return True, port
|
||||
|
||||
return False, start_port
|
||||
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
Interactive configuration for kt quant command.
|
||||
|
||||
Provides rich, multi-step interactive configuration for model quantization.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Prompt, Confirm, IntPrompt
|
||||
from kt_kernel.cli.i18n import t
|
||||
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def select_model_to_quantize() -> Optional[Any]:
|
||||
"""Select model to quantize interactively."""
|
||||
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
|
||||
from kt_kernel.cli.commands.model import is_amx_weights, SHA256_STATUS_MAP
|
||||
from kt_kernel.cli.utils.model_table_builder import build_moe_gpu_table
|
||||
|
||||
registry = UserModelRegistry()
|
||||
all_models = registry.list_models()
|
||||
|
||||
# Filter MoE models only (safetensors, not AMX, is_moe=True)
|
||||
quant_models = []
|
||||
for model in all_models:
|
||||
if model.format == "safetensors":
|
||||
# Skip AMX models
|
||||
is_amx, _ = is_amx_weights(model.path)
|
||||
if is_amx:
|
||||
continue
|
||||
|
||||
# Only include MoE models
|
||||
if model.is_moe:
|
||||
quant_models.append(model)
|
||||
|
||||
if not quant_models:
|
||||
console.print(f"[yellow]{t('quant_no_moe_models')}[/yellow]")
|
||||
console.print()
|
||||
console.print(f" {t('quant_only_moe')}")
|
||||
console.print()
|
||||
console.print(f" {t('quant_add_models', command='kt model scan')}")
|
||||
console.print(f" {t('quant_add_models', command='kt model add <path>')}")
|
||||
return None
|
||||
|
||||
# Display models
|
||||
console.print()
|
||||
console.print(f"[bold green]{t('quant_moe_available')}[/bold green]")
|
||||
console.print()
|
||||
|
||||
# Use shared table builder
|
||||
table, displayed_models = build_moe_gpu_table(
|
||||
models=quant_models, status_map=SHA256_STATUS_MAP, show_index=True, start_index=1
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
choice = IntPrompt.ask(t("quant_select_model"), default=1, show_choices=False)
|
||||
|
||||
if choice < 1 or choice > len(displayed_models):
|
||||
console.print(f"[red]{t('quant_invalid_choice')}[/red]")
|
||||
return None
|
||||
|
||||
return displayed_models[choice - 1]
|
||||
|
||||
|
||||
def configure_quantization_method() -> Dict[str, str]:
|
||||
"""Select quantization method and input type."""
|
||||
console.print()
|
||||
console.print(Panel(f"[bold cyan]{t('quant_step2_method')}[/bold cyan]", expand=False))
|
||||
console.print()
|
||||
|
||||
# Method selection
|
||||
console.print(f"[bold]{t('quant_method_label')}[/bold]")
|
||||
console.print(f" [cyan][1][/cyan] {t('quant_int4_desc')}")
|
||||
console.print(f" [cyan][2][/cyan] {t('quant_int8_desc')}")
|
||||
console.print()
|
||||
|
||||
method_choice = Prompt.ask(t("quant_select_method"), choices=["1", "2"], default="1")
|
||||
method = "int4" if method_choice == "1" else "int8"
|
||||
|
||||
console.print()
|
||||
console.print(f"[bold]{t('quant_input_type_label')}[/bold]")
|
||||
console.print(f" [cyan][1][/cyan] {t('quant_fp8_desc')}")
|
||||
console.print(f" [cyan][2][/cyan] {t('quant_fp16_desc')}")
|
||||
console.print(f" [cyan][3][/cyan] {t('quant_bf16_desc')}")
|
||||
console.print()
|
||||
|
||||
input_choice = Prompt.ask(t("quant_select_input_type"), choices=["1", "2", "3"], default="1")
|
||||
input_type_map = {"1": "fp8", "2": "fp16", "3": "bf16"}
|
||||
input_type = input_type_map[input_choice]
|
||||
|
||||
return {"method": method, "input_type": input_type}
|
||||
|
||||
|
||||
def configure_cpu_params(max_cores: int, max_numa: int) -> Dict[str, Any]:
|
||||
"""Configure CPU parameters."""
|
||||
console.print()
|
||||
console.print(Panel(f"[bold cyan]{t('quant_step3_cpu')}[/bold cyan]", expand=False))
|
||||
console.print()
|
||||
|
||||
def clamp(value: int, min_val: int, max_val: int, default: int) -> int:
|
||||
"""Clamp value to range or return default if out of bounds."""
|
||||
if min_val <= value <= max_val:
|
||||
return max(min_val, min(value, max_val))
|
||||
return default
|
||||
|
||||
default_threads = int(max_cores * 0.8)
|
||||
cpu_threads = IntPrompt.ask(t("quant_cpu_threads_prompt", max=max_cores), default=default_threads)
|
||||
cpu_threads = clamp(cpu_threads, 1, max_cores, default_threads)
|
||||
|
||||
numa_nodes = IntPrompt.ask(t("quant_numa_nodes_prompt", max=max_numa), default=max_numa)
|
||||
numa_nodes = clamp(numa_nodes, 1, max_numa, max_numa)
|
||||
|
||||
# Ask about GPU usage
|
||||
console.print()
|
||||
console.print(f"[bold]{t('quant_use_gpu_label')}[/bold]")
|
||||
console.print(f" [dim]{t('quant_gpu_speedup')}[/dim]")
|
||||
console.print()
|
||||
use_gpu = Confirm.ask(t("quant_enable_gpu"), default=True)
|
||||
|
||||
return {"cpu_threads": cpu_threads, "numa_nodes": numa_nodes, "use_gpu": use_gpu}
|
||||
|
||||
|
||||
def configure_output_path(model: Any, method: str, numa_nodes: int) -> Path:
|
||||
"""Configure output path for quantized weights."""
|
||||
from kt_kernel.cli.config.settings import get_settings
|
||||
|
||||
console.print()
|
||||
console.print(Panel(f"[bold cyan]{t('quant_step4_output')}[/bold cyan]", expand=False))
|
||||
console.print()
|
||||
|
||||
# Generate default output path
|
||||
model_path = Path(model.path)
|
||||
method_upper = method.upper()
|
||||
settings = get_settings()
|
||||
|
||||
# Priority: paths.weights > paths.models[0] > model's parent directory
|
||||
weights_dir = settings.weights_dir
|
||||
if weights_dir and weights_dir.exists():
|
||||
# Use configured weights directory (highest priority)
|
||||
default_output = weights_dir / f"{model_path.name}-AMX{method_upper}-NUMA{numa_nodes}"
|
||||
else:
|
||||
# Use first model storage path
|
||||
model_paths = settings.get_model_paths()
|
||||
if model_paths and model_paths[0].exists():
|
||||
default_output = model_paths[0] / f"{model_path.name}-AMX{method_upper}-NUMA{numa_nodes}"
|
||||
else:
|
||||
# Fallback to model's parent directory
|
||||
default_output = model_path.parent / f"{model_path.name}-AMX{method_upper}-NUMA{numa_nodes}"
|
||||
|
||||
console.print(f"[dim]{t('quant_default_path')}[/dim]", default_output)
|
||||
console.print()
|
||||
|
||||
use_default = Confirm.ask(t("quant_use_default"), default=True)
|
||||
|
||||
if use_default:
|
||||
return default_output
|
||||
|
||||
custom_path = Prompt.ask(t("quant_custom_path"), default=str(default_output))
|
||||
|
||||
return Path(custom_path)
|
||||
|
||||
|
||||
def calculate_quantized_size(source_path: Path, input_type: str, quant_method: str) -> tuple[float, float]:
|
||||
"""
|
||||
Calculate source model size and estimated quantized size.
|
||||
|
||||
Args:
|
||||
source_path: Path to source model
|
||||
input_type: Input type (fp8, fp16, bf16)
|
||||
quant_method: Quantization method (int4, int8)
|
||||
|
||||
Returns:
|
||||
Tuple of (source_size_gb, estimated_quant_size_gb)
|
||||
"""
|
||||
# Calculate source model size
|
||||
try:
|
||||
total_bytes = sum(f.stat().st_size for f in source_path.glob("*.safetensors") if f.is_file())
|
||||
source_size_gb = total_bytes / (1024**3)
|
||||
except Exception:
|
||||
return 0.0, 0.0
|
||||
|
||||
# Bits mapping
|
||||
input_bits = {"fp8": 8, "fp16": 16, "bf16": 16}
|
||||
quant_bits = {"int4": 4, "int8": 8}
|
||||
|
||||
input_bit = input_bits.get(input_type, 16)
|
||||
quant_bit = quant_bits.get(quant_method, 4)
|
||||
|
||||
# Estimate: source_size * (quant_bits / input_bits)
|
||||
ratio = quant_bit / input_bit
|
||||
estimated_size_gb = source_size_gb * ratio
|
||||
|
||||
return source_size_gb, estimated_size_gb
|
||||
|
||||
|
||||
def check_disk_space(output_path: Path, required_size_gb: float) -> tuple[float, bool]:
|
||||
"""
|
||||
Check available disk space at output path.
|
||||
|
||||
Args:
|
||||
output_path: Target output path
|
||||
required_size_gb: Required space in GB
|
||||
|
||||
Returns:
|
||||
Tuple of (available_gb, is_sufficient)
|
||||
is_sufficient is True if available >= required * 1.2
|
||||
"""
|
||||
import shutil
|
||||
|
||||
try:
|
||||
# Get parent directory that exists
|
||||
check_path = output_path.parent if not output_path.exists() else output_path
|
||||
while not check_path.exists() and check_path != check_path.parent:
|
||||
check_path = check_path.parent
|
||||
|
||||
stat = shutil.disk_usage(check_path)
|
||||
available_gb = stat.free / (1024**3)
|
||||
|
||||
# Check if available space >= required * 1.2 (20% buffer)
|
||||
is_sufficient = available_gb >= (required_size_gb * 1.2)
|
||||
|
||||
return available_gb, is_sufficient
|
||||
except Exception:
|
||||
return 0.0, False
|
||||
|
||||
|
||||
def interactive_quant_config() -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Interactive configuration for kt quant.
|
||||
|
||||
Returns configuration dict or None if cancelled.
|
||||
"""
|
||||
from kt_kernel.cli.utils.environment import detect_cpu_info
|
||||
|
||||
# Get CPU info
|
||||
cpu_info = detect_cpu_info()
|
||||
|
||||
# Step 1: Select model
|
||||
model = select_model_to_quantize()
|
||||
if not model:
|
||||
return None
|
||||
|
||||
# Step 1.5: Pre-quantization verification (optional)
|
||||
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
|
||||
from kt_kernel.cli.utils.model_verifier import pre_operation_verification
|
||||
|
||||
user_registry = UserModelRegistry()
|
||||
user_model_obj = user_registry.find_by_path(model.path)
|
||||
|
||||
if user_model_obj and user_model_obj.format == "safetensors":
|
||||
pre_operation_verification(user_model_obj, user_registry, operation_name="quantizing")
|
||||
|
||||
# Step 2: Configure quantization method
|
||||
quant_config = configure_quantization_method()
|
||||
|
||||
# Step 3: Configure CPU parameters
|
||||
cpu_config = configure_cpu_params(cpu_info.threads, cpu_info.numa_nodes) # Use logical threads
|
||||
|
||||
# Step 4: Configure output path
|
||||
output_path = configure_output_path(model, quant_config["method"], cpu_config["numa_nodes"])
|
||||
|
||||
# Step 4.5: Check if output path already exists and generate unique name
|
||||
if output_path.exists():
|
||||
console.print()
|
||||
console.print(t("quant_output_exists_warn", path=str(output_path)))
|
||||
console.print()
|
||||
|
||||
# Generate unique name by adding suffix
|
||||
original_name = output_path.name
|
||||
parent_dir = output_path.parent
|
||||
counter = 2
|
||||
|
||||
while output_path.exists():
|
||||
new_name = f"{original_name}-{counter}"
|
||||
output_path = parent_dir / new_name
|
||||
counter += 1
|
||||
|
||||
console.print(t("quant_using_unique_name", path=str(output_path)))
|
||||
console.print()
|
||||
|
||||
# Step 5: Calculate space requirements and check availability
|
||||
console.print()
|
||||
console.print(Panel(f"[bold cyan]{t('quant_disk_analysis')}[/bold cyan]", expand=False))
|
||||
console.print()
|
||||
|
||||
source_size_gb, estimated_size_gb = calculate_quantized_size(
|
||||
Path(model.path), quant_config["input_type"], quant_config["method"]
|
||||
)
|
||||
|
||||
available_gb, is_sufficient = check_disk_space(output_path, estimated_size_gb)
|
||||
|
||||
console.print(f" {t('quant_source_size'):<26} [cyan]{source_size_gb:.2f} GB[/cyan]")
|
||||
console.print(f" {t('quant_estimated_size'):<26} [yellow]{estimated_size_gb:.2f} GB[/yellow]")
|
||||
console.print(
|
||||
f" {t('quant_available_space'):<26} [{'green' if is_sufficient else 'red'}]{available_gb:.2f} GB[/{'green' if is_sufficient else 'red'}]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
if not is_sufficient:
|
||||
required_with_buffer = estimated_size_gb * 1.2
|
||||
console.print(f"[bold red]⚠ {t('quant_insufficient_space')}[/bold red]")
|
||||
console.print()
|
||||
console.print(f" {t('quant_required_space'):<26} [yellow]{required_with_buffer:.2f} GB[/yellow]")
|
||||
console.print(f" {t('quant_available_space'):<26} [red]{available_gb:.2f} GB[/red]")
|
||||
console.print(f" {t('quant_shortage'):<26} [red]{required_with_buffer - available_gb:.2f} GB[/red]")
|
||||
console.print()
|
||||
console.print(f" {t('quant_may_fail')}")
|
||||
console.print()
|
||||
|
||||
if not Confirm.ask(f"[yellow]{t('quant_continue_anyway')}[/yellow]", default=False):
|
||||
console.print(f"[yellow]{t('quant_cancelled')}[/yellow]")
|
||||
return None
|
||||
console.print()
|
||||
|
||||
# Summary and confirmation
|
||||
console.print()
|
||||
console.print(Panel(f"[bold cyan]{t('quant_config_summary')}[/bold cyan]", expand=False))
|
||||
console.print()
|
||||
console.print(f" {t('quant_summary_model'):<15} {model.name}")
|
||||
console.print(f" {t('quant_summary_method'):<15} {quant_config['method'].upper()}")
|
||||
console.print(f" {t('quant_summary_input_type'):<15} {quant_config['input_type'].upper()}")
|
||||
console.print(f" {t('quant_summary_cpu_threads'):<15} {cpu_config['cpu_threads']}")
|
||||
console.print(f" {t('quant_summary_numa'):<15} {cpu_config['numa_nodes']}")
|
||||
console.print(f" {t('quant_summary_gpu'):<15} {t('yes') if cpu_config['use_gpu'] else t('no')}")
|
||||
console.print(f" {t('quant_summary_output'):<15} {output_path}")
|
||||
console.print()
|
||||
|
||||
if not Confirm.ask(f"[bold green]{t('quant_start_question')}[/bold green]", default=True):
|
||||
console.print(f"[yellow]{t('quant_cancelled')}[/yellow]")
|
||||
return None
|
||||
|
||||
return {
|
||||
"model": model,
|
||||
"method": quant_config["method"],
|
||||
"input_type": quant_config["input_type"],
|
||||
"cpu_threads": cpu_config["cpu_threads"],
|
||||
"numa_nodes": cpu_config["numa_nodes"],
|
||||
"use_gpu": cpu_config["use_gpu"],
|
||||
"output_path": output_path,
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
Repo Detector
|
||||
|
||||
Automatically detect repository information from model README.md files
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Tuple
|
||||
import yaml
|
||||
|
||||
|
||||
def parse_readme_frontmatter(readme_path: Path) -> Optional[Dict]:
|
||||
"""
|
||||
Parse YAML frontmatter from README.md
|
||||
|
||||
Args:
|
||||
readme_path: Path to README.md file
|
||||
|
||||
Returns:
|
||||
Dictionary of frontmatter data, or None if not found
|
||||
"""
|
||||
if not readme_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Match YAML frontmatter between --- markers
|
||||
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
yaml_content = match.group(1)
|
||||
|
||||
# Parse YAML
|
||||
try:
|
||||
data = yaml.safe_load(yaml_content)
|
||||
return data if isinstance(data, dict) else None
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
|
||||
def extract_repo_from_frontmatter(frontmatter: Dict) -> Optional[Tuple[str, str]]:
|
||||
"""
|
||||
Extract repo_id and repo_type from frontmatter
|
||||
|
||||
Args:
|
||||
frontmatter: Parsed YAML frontmatter dictionary
|
||||
|
||||
Returns:
|
||||
Tuple of (repo_id, repo_type) or None
|
||||
repo_type is either "huggingface" or "modelscope"
|
||||
"""
|
||||
if not frontmatter:
|
||||
return None
|
||||
|
||||
# Priority 1: Extract from license_link (most reliable)
|
||||
license_link = frontmatter.get("license_link")
|
||||
if license_link and isinstance(license_link, str):
|
||||
result = _extract_repo_from_url(license_link)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# Priority 2: Try to find repo_id from other fields
|
||||
repo_id = None
|
||||
|
||||
# Check base_model field
|
||||
base_model = frontmatter.get("base_model")
|
||||
if base_model:
|
||||
if isinstance(base_model, list) and len(base_model) > 0:
|
||||
# base_model is a list, take first item
|
||||
repo_id = base_model[0]
|
||||
elif isinstance(base_model, str):
|
||||
repo_id = base_model
|
||||
|
||||
# Check model-index field
|
||||
if not repo_id:
|
||||
model_index = frontmatter.get("model-index")
|
||||
if isinstance(model_index, list) and len(model_index) > 0:
|
||||
first_model = model_index[0]
|
||||
if isinstance(first_model, dict):
|
||||
repo_id = first_model.get("name")
|
||||
|
||||
# Check model_name field
|
||||
if not repo_id:
|
||||
repo_id = frontmatter.get("model_name")
|
||||
|
||||
if not repo_id or not isinstance(repo_id, str):
|
||||
return None
|
||||
|
||||
# Validate format: should be "namespace/model-name"
|
||||
if "/" not in repo_id:
|
||||
return None
|
||||
|
||||
parts = repo_id.split("/")
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
# Determine repo type
|
||||
repo_type = "huggingface" # Default
|
||||
|
||||
# Look for ModelScope indicators
|
||||
if "modelscope" in repo_id.lower():
|
||||
repo_type = "modelscope"
|
||||
|
||||
# Check tags
|
||||
tags = frontmatter.get("tags", [])
|
||||
if isinstance(tags, list):
|
||||
if "modelscope" in [str(t).lower() for t in tags]:
|
||||
repo_type = "modelscope"
|
||||
|
||||
return (repo_id, repo_type)
|
||||
|
||||
|
||||
def _extract_repo_from_url(url: str) -> Optional[Tuple[str, str]]:
|
||||
"""
|
||||
Extract repo_id and repo_type from a URL
|
||||
|
||||
Supports:
|
||||
- https://huggingface.co/Qwen/Qwen3-30B-A3B/blob/main/LICENSE
|
||||
- https://modelscope.cn/models/Qwen/Qwen3-30B-A3B
|
||||
|
||||
Args:
|
||||
url: URL string
|
||||
|
||||
Returns:
|
||||
Tuple of (repo_id, repo_type) or None
|
||||
"""
|
||||
# HuggingFace pattern: https://huggingface.co/{namespace}/{model}/...
|
||||
hf_match = re.match(r"https?://huggingface\.co/([^/]+)/([^/]+)", url)
|
||||
if hf_match:
|
||||
namespace = hf_match.group(1)
|
||||
model_name = hf_match.group(2)
|
||||
repo_id = f"{namespace}/{model_name}"
|
||||
return (repo_id, "huggingface")
|
||||
|
||||
# ModelScope pattern: https://modelscope.cn/models/{namespace}/{model}
|
||||
ms_match = re.match(r"https?://(?:www\.)?modelscope\.cn/models/([^/]+)/([^/]+)", url)
|
||||
if ms_match:
|
||||
namespace = ms_match.group(1)
|
||||
model_name = ms_match.group(2)
|
||||
repo_id = f"{namespace}/{model_name}"
|
||||
return (repo_id, "modelscope")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_repo_from_global_search(readme_path: Path) -> Optional[Tuple[str, str]]:
|
||||
"""
|
||||
Extract repo info by globally searching for URLs in README.md
|
||||
|
||||
Args:
|
||||
readme_path: Path to README.md file
|
||||
|
||||
Returns:
|
||||
Tuple of (repo_id, repo_type) or None if not found
|
||||
"""
|
||||
if not readme_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Find all HuggingFace URLs
|
||||
hf_pattern = r"https?://huggingface\.co/([^/\s]+)/([^/\s\)]+)"
|
||||
hf_matches = re.findall(hf_pattern, content)
|
||||
|
||||
# Find all ModelScope URLs
|
||||
ms_pattern = r"https?://(?:www\.)?modelscope\.cn/models/([^/\s]+)/([^/\s\)]+)"
|
||||
ms_matches = re.findall(ms_pattern, content)
|
||||
|
||||
# Collect all found repos with their types
|
||||
found_repos = []
|
||||
|
||||
for namespace, model_name in hf_matches:
|
||||
# Skip common non-repo paths
|
||||
if namespace.lower() in ["docs", "blog", "spaces", "datasets"]:
|
||||
continue
|
||||
if model_name.lower() in ["tree", "blob", "raw", "resolve", "discussions"]:
|
||||
continue
|
||||
|
||||
repo_id = f"{namespace}/{model_name}"
|
||||
found_repos.append((repo_id, "huggingface"))
|
||||
|
||||
for namespace, model_name in ms_matches:
|
||||
repo_id = f"{namespace}/{model_name}"
|
||||
found_repos.append((repo_id, "modelscope"))
|
||||
|
||||
if not found_repos:
|
||||
return None
|
||||
|
||||
# If multiple different repos found, use the last one
|
||||
# First, deduplicate
|
||||
seen = {}
|
||||
for repo_id, repo_type in found_repos:
|
||||
seen[repo_id] = repo_type # Will keep the last occurrence
|
||||
|
||||
# Get the last unique repo
|
||||
if seen:
|
||||
# Use the last item from found_repos that's unique
|
||||
last_unique = None
|
||||
for repo_id, repo_type in found_repos:
|
||||
if repo_id in seen:
|
||||
last_unique = (repo_id, repo_type)
|
||||
|
||||
return last_unique
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
|
||||
def detect_repo_for_model(model_path: str) -> Optional[Tuple[str, str]]:
|
||||
"""
|
||||
Detect repository information for a model
|
||||
|
||||
Strategy:
|
||||
Only extract from YAML frontmatter metadata in README.md
|
||||
(Removed global URL search to avoid false positives)
|
||||
|
||||
Args:
|
||||
model_path: Path to model directory
|
||||
|
||||
Returns:
|
||||
Tuple of (repo_id, repo_type) or None if not detected
|
||||
"""
|
||||
model_dir = Path(model_path)
|
||||
|
||||
if not model_dir.exists() or not model_dir.is_dir():
|
||||
return None
|
||||
|
||||
# Look for README.md
|
||||
readme_path = model_dir / "README.md"
|
||||
if not readme_path.exists():
|
||||
return None
|
||||
|
||||
# Only parse YAML frontmatter (no fallback to global search)
|
||||
frontmatter = parse_readme_frontmatter(readme_path)
|
||||
if frontmatter:
|
||||
return extract_repo_from_frontmatter(frontmatter)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def scan_models_for_repo(model_list) -> Dict:
|
||||
"""
|
||||
Scan a list of models and detect repo information
|
||||
|
||||
Args:
|
||||
model_list: List of UserModel objects
|
||||
|
||||
Returns:
|
||||
Dictionary with scan results:
|
||||
{
|
||||
'detected': [(model, repo_id, repo_type), ...],
|
||||
'not_detected': [model, ...],
|
||||
'skipped': [model, ...] # Already has repo_id
|
||||
}
|
||||
"""
|
||||
results = {"detected": [], "not_detected": [], "skipped": []}
|
||||
|
||||
for model in model_list:
|
||||
# Skip if already has repo_id
|
||||
if model.repo_id:
|
||||
results["skipped"].append(model)
|
||||
continue
|
||||
|
||||
# Only process safetensors and gguf models
|
||||
if model.format not in ["safetensors", "gguf"]:
|
||||
results["skipped"].append(model)
|
||||
continue
|
||||
|
||||
# Try to detect repo
|
||||
repo_info = detect_repo_for_model(model.path)
|
||||
|
||||
if repo_info:
|
||||
repo_id, repo_type = repo_info
|
||||
results["detected"].append((model, repo_id, repo_type))
|
||||
else:
|
||||
results["not_detected"].append(model)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def format_detection_report(results: Dict) -> str:
|
||||
"""
|
||||
Format scan results into a readable report
|
||||
|
||||
Args:
|
||||
results: Results from scan_models_for_repo()
|
||||
|
||||
Returns:
|
||||
Formatted string report
|
||||
"""
|
||||
lines = []
|
||||
|
||||
lines.append("=" * 80)
|
||||
lines.append("Auto-Detection Report")
|
||||
lines.append("=" * 80)
|
||||
lines.append("")
|
||||
|
||||
# Detected
|
||||
if results["detected"]:
|
||||
lines.append(f"✓ Detected repository information ({len(results['detected'])} models):")
|
||||
lines.append("")
|
||||
for model, repo_id, repo_type in results["detected"]:
|
||||
lines.append(f" • {model.name}")
|
||||
lines.append(f" Path: {model.path}")
|
||||
lines.append(f" Repo: {repo_id} ({repo_type})")
|
||||
lines.append("")
|
||||
|
||||
# Not detected
|
||||
if results["not_detected"]:
|
||||
lines.append(f"✗ No repository information found ({len(results['not_detected'])} models):")
|
||||
lines.append("")
|
||||
for model in results["not_detected"]:
|
||||
lines.append(f" • {model.name}")
|
||||
lines.append(f" Path: {model.path}")
|
||||
lines.append("")
|
||||
|
||||
# Skipped
|
||||
if results["skipped"]:
|
||||
lines.append(f"⊘ Skipped ({len(results['skipped'])} models):")
|
||||
lines.append(f" (Already have repo_id or not safetensors/gguf format)")
|
||||
lines.append("")
|
||||
|
||||
lines.append("=" * 80)
|
||||
lines.append(
|
||||
f"Summary: {len(results['detected'])} detected, "
|
||||
f"{len(results['not_detected'])} not detected, "
|
||||
f"{len(results['skipped'])} skipped"
|
||||
)
|
||||
lines.append("=" * 80)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def apply_detection_results(results: Dict, registry) -> int:
|
||||
"""
|
||||
Apply detected repo information to models in registry
|
||||
|
||||
Args:
|
||||
results: Results from scan_models_for_repo()
|
||||
registry: UserModelRegistry instance
|
||||
|
||||
Returns:
|
||||
Number of models updated
|
||||
"""
|
||||
updated_count = 0
|
||||
|
||||
for model, repo_id, repo_type in results["detected"]:
|
||||
success = registry.update_model(model.name, {"repo_id": repo_id, "repo_type": repo_type})
|
||||
|
||||
if success:
|
||||
updated_count += 1
|
||||
|
||||
return updated_count
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Configuration save/load for kt run command.
|
||||
|
||||
Manages saved run configurations bound to specific models.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime
|
||||
import yaml
|
||||
|
||||
|
||||
CONFIG_FILE = Path.home() / ".ktransformers" / "run_configs.yaml"
|
||||
|
||||
|
||||
class RunConfigManager:
|
||||
"""Manager for saved run configurations."""
|
||||
|
||||
def __init__(self):
|
||||
self.config_file = CONFIG_FILE
|
||||
self._ensure_config_file()
|
||||
|
||||
def _ensure_config_file(self):
|
||||
"""Ensure config file exists."""
|
||||
if not self.config_file.exists():
|
||||
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._save_data({"version": "1.0", "configs": {}})
|
||||
|
||||
def _load_data(self) -> Dict:
|
||||
"""Load raw config data."""
|
||||
try:
|
||||
with open(self.config_file, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {"version": "1.0", "configs": {}}
|
||||
except Exception:
|
||||
return {"version": "1.0", "configs": {}}
|
||||
|
||||
def _save_data(self, data: Dict):
|
||||
"""Save raw config data."""
|
||||
with open(self.config_file, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
|
||||
|
||||
def list_configs(self, model_id: str) -> List[Dict[str, Any]]:
|
||||
"""List all saved configs for a model.
|
||||
|
||||
Returns:
|
||||
List of config dicts with 'config_name' and other fields.
|
||||
"""
|
||||
data = self._load_data()
|
||||
configs = data.get("configs", {}).get(model_id, [])
|
||||
return configs if isinstance(configs, list) else []
|
||||
|
||||
def save_config(self, model_id: str, config: Dict[str, Any]):
|
||||
"""Save a configuration for a model.
|
||||
|
||||
Args:
|
||||
model_id: Model ID to bind config to
|
||||
config: Configuration dict with all run parameters
|
||||
"""
|
||||
data = self._load_data()
|
||||
|
||||
if "configs" not in data:
|
||||
data["configs"] = {}
|
||||
|
||||
if model_id not in data["configs"]:
|
||||
data["configs"][model_id] = []
|
||||
|
||||
# Add timestamp
|
||||
config["created_at"] = datetime.now().isoformat()
|
||||
|
||||
# Append config
|
||||
data["configs"][model_id].append(config)
|
||||
|
||||
self._save_data(data)
|
||||
|
||||
def delete_config(self, model_id: str, config_index: int) -> bool:
|
||||
"""Delete a saved configuration.
|
||||
|
||||
Args:
|
||||
model_id: Model ID
|
||||
config_index: Index of config to delete (0-based)
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
data = self._load_data()
|
||||
|
||||
if model_id not in data.get("configs", {}):
|
||||
return False
|
||||
|
||||
configs = data["configs"][model_id]
|
||||
if config_index < 0 or config_index >= len(configs):
|
||||
return False
|
||||
|
||||
configs.pop(config_index)
|
||||
self._save_data(data)
|
||||
return True
|
||||
|
||||
def get_config(self, model_id: str, config_index: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific saved configuration.
|
||||
|
||||
Args:
|
||||
model_id: Model ID
|
||||
config_index: Index of config to get (0-based)
|
||||
|
||||
Returns:
|
||||
Config dict or None if not found
|
||||
"""
|
||||
configs = self.list_configs(model_id)
|
||||
if config_index < 0 or config_index >= len(configs):
|
||||
return None
|
||||
return configs[config_index]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
SGLang installation checker and installation instructions provider.
|
||||
|
||||
This module provides utilities to:
|
||||
- Check if SGLang is installed and get its metadata
|
||||
- Provide installation instructions when SGLang is not found
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from kt_kernel.cli.i18n import t
|
||||
from kt_kernel.cli.utils.console import console
|
||||
|
||||
|
||||
def check_sglang_installation() -> dict:
|
||||
"""Check if SGLang is installed and get its metadata.
|
||||
|
||||
Returns:
|
||||
dict with keys:
|
||||
- installed: bool
|
||||
- version: str or None
|
||||
- location: str or None (installation path)
|
||||
- editable: bool (whether installed in editable mode)
|
||||
- git_info: dict or None (git remote and branch if available)
|
||||
- from_source: bool (whether installed from source repository)
|
||||
"""
|
||||
try:
|
||||
# Try to import sglang
|
||||
import sglang
|
||||
|
||||
version = getattr(sglang, "__version__", None)
|
||||
|
||||
# Use pip show to get detailed package information
|
||||
location = None
|
||||
editable = False
|
||||
git_info = None
|
||||
from_source = False
|
||||
is_kvcache_fork = False # True if installed as sglang-kt package
|
||||
|
||||
try:
|
||||
# Get pip show output (try sglang-kt first, then sglang)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "show", "sglang-kt"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
is_kvcache_fork = True # sglang-kt package name proves it's the fork
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "show", "sglang"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
pip_info = {}
|
||||
for line in result.stdout.split("\n"):
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
pip_info[key.strip()] = value.strip()
|
||||
|
||||
location = pip_info.get("Location")
|
||||
editable_location = pip_info.get("Editable project location")
|
||||
|
||||
if editable_location:
|
||||
editable = True
|
||||
location = editable_location
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
# Fallback to module location
|
||||
if hasattr(sglang, "__file__") and sglang.__file__:
|
||||
location = str(Path(sglang.__file__).parent.parent)
|
||||
|
||||
# Check if it's installed from source (has .git directory)
|
||||
if location:
|
||||
git_root = None
|
||||
check_path = Path(location)
|
||||
|
||||
# Check current directory and up to 2 parent directories
|
||||
for _ in range(3):
|
||||
git_dir = check_path / ".git"
|
||||
if git_dir.exists():
|
||||
git_root = check_path
|
||||
from_source = True
|
||||
break
|
||||
if check_path.parent == check_path: # Reached root
|
||||
break
|
||||
check_path = check_path.parent
|
||||
|
||||
if from_source and git_root:
|
||||
# Try to get git remote and branch info
|
||||
try:
|
||||
# Get remote URL
|
||||
result = subprocess.run(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
cwd=git_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
remote_url = result.stdout.strip() if result.returncode == 0 else None
|
||||
|
||||
# Extract org/repo from URL
|
||||
remote_short = None
|
||||
if remote_url:
|
||||
# Handle both https and git@ URLs
|
||||
if "github.com" in remote_url:
|
||||
parts = remote_url.rstrip("/").replace(".git", "").split("github.com")[-1]
|
||||
remote_short = parts.lstrip("/").lstrip(":")
|
||||
|
||||
# Get current branch
|
||||
result = subprocess.run(
|
||||
["git", "branch", "--show-current"],
|
||||
cwd=git_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
branch = result.stdout.strip() if result.returncode == 0 else None
|
||||
|
||||
if remote_url or branch:
|
||||
git_info = {
|
||||
"remote": remote_short or remote_url,
|
||||
"branch": branch,
|
||||
}
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"installed": True,
|
||||
"version": version,
|
||||
"location": location,
|
||||
"editable": editable,
|
||||
"git_info": git_info,
|
||||
"from_source": from_source,
|
||||
"is_kvcache_fork": is_kvcache_fork,
|
||||
}
|
||||
except ImportError:
|
||||
return {
|
||||
"installed": False,
|
||||
"version": None,
|
||||
"location": None,
|
||||
"editable": False,
|
||||
"git_info": None,
|
||||
"from_source": False,
|
||||
"is_kvcache_fork": False,
|
||||
}
|
||||
|
||||
|
||||
def get_sglang_install_instructions(lang: Optional[str] = None) -> str:
|
||||
"""Get SGLang installation instructions.
|
||||
|
||||
Args:
|
||||
lang: Language code ('en' or 'zh'). If None, uses current language setting.
|
||||
|
||||
Returns:
|
||||
Formatted installation instructions string.
|
||||
"""
|
||||
from kt_kernel.cli.i18n import get_lang
|
||||
|
||||
if lang is None:
|
||||
lang = get_lang()
|
||||
|
||||
if lang == "zh":
|
||||
return """
|
||||
[bold yellow]SGLang \u672a\u5b89\u88c5[/bold yellow]
|
||||
|
||||
\u8bf7\u9009\u62e9\u4ee5\u4e0b\u65b9\u5f0f\u4e4b\u4e00\u5b89\u88c5 SGLang (kvcache-ai \u5206\u652f):
|
||||
|
||||
[bold]\u65b9\u5f0f A - \u4e00\u952e\u5b89\u88c5 (\u63a8\u8350):[/bold]
|
||||
\u4ece ktransformers \u6839\u76ee\u5f55\u8fd0\u884c:
|
||||
[cyan]./install.sh[/cyan]
|
||||
|
||||
[bold]\u65b9\u5f0f B - pip \u5b89\u88c5:[/bold]
|
||||
[cyan]pip install sglang-kt[/cyan]
|
||||
|
||||
[bold]\u65b9\u5f0f C - \u4ece\u6e90\u7801\u5b89\u88c5:[/bold]
|
||||
git clone --recursive https://github.com/kvcache-ai/ktransformers.git
|
||||
cd ktransformers
|
||||
pip install "third_party/sglang/python[all]"
|
||||
|
||||
[dim]\u6ce8\u610f: \u8bf7\u786e\u4fdd\u5728\u6b63\u786e\u7684 Python \u73af\u5883\u4e2d\u6267\u884c\u4ee5\u4e0a\u547d\u4ee4[/dim]
|
||||
"""
|
||||
else:
|
||||
return """
|
||||
[bold yellow]SGLang is not installed[/bold yellow]
|
||||
|
||||
Install SGLang (kvcache-ai fork) using one of these methods:
|
||||
|
||||
[bold]Option A - One-click install (recommended):[/bold]
|
||||
From the ktransformers root directory, run:
|
||||
[cyan]./install.sh[/cyan]
|
||||
|
||||
[bold]Option B - pip install:[/bold]
|
||||
[cyan]pip install sglang-kt[/cyan]
|
||||
|
||||
[bold]Option C - From source:[/bold]
|
||||
git clone --recursive https://github.com/kvcache-ai/ktransformers.git
|
||||
cd ktransformers
|
||||
pip install "third_party/sglang/python[all]"
|
||||
|
||||
[dim]Note: Make sure to run these commands in the correct Python environment[/dim]
|
||||
"""
|
||||
|
||||
|
||||
def print_sglang_install_instructions() -> None:
|
||||
"""Print SGLang installation instructions to console."""
|
||||
instructions = get_sglang_install_instructions()
|
||||
console.print(instructions)
|
||||
|
||||
|
||||
def check_sglang_and_warn() -> bool:
|
||||
"""Check if SGLang is installed, print warning if not.
|
||||
|
||||
Returns:
|
||||
True if SGLang is installed, False otherwise.
|
||||
"""
|
||||
info = check_sglang_installation()
|
||||
|
||||
if not info["installed"]:
|
||||
print_sglang_install_instructions()
|
||||
return False
|
||||
|
||||
# Check if installed from PyPI (not recommended)
|
||||
if info["installed"] and not info["from_source"]:
|
||||
from kt_kernel.cli.utils.console import print_warning
|
||||
|
||||
print_warning(t("sglang_pypi_warning"))
|
||||
console.print()
|
||||
console.print("[dim]" + t("sglang_recommend_source") + "[/dim]")
|
||||
console.print()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _get_sglang_kt_kernel_cache_path() -> Path:
|
||||
"""Get the path to the sglang kt-kernel support cache file."""
|
||||
cache_dir = Path.home() / ".ktransformers" / "cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_dir / "sglang_kt_kernel_supported"
|
||||
|
||||
|
||||
def _is_sglang_kt_kernel_cache_valid() -> bool:
|
||||
"""Check if the sglang kt-kernel support cache is valid.
|
||||
|
||||
The cache is considered valid if:
|
||||
1. The cache file exists
|
||||
2. The cache file contains 'true' (indicating previous check passed)
|
||||
|
||||
Returns:
|
||||
True if cache is valid and indicates support, False otherwise.
|
||||
"""
|
||||
cache_path = _get_sglang_kt_kernel_cache_path()
|
||||
if cache_path.exists():
|
||||
try:
|
||||
content = cache_path.read_text().strip().lower()
|
||||
return content == "true"
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _save_sglang_kt_kernel_cache(supported: bool) -> None:
|
||||
"""Save the sglang kt-kernel support check result to cache."""
|
||||
cache_path = _get_sglang_kt_kernel_cache_path()
|
||||
try:
|
||||
cache_path.write_text("true" if supported else "false")
|
||||
except (OSError, IOError):
|
||||
pass # Ignore cache write errors
|
||||
|
||||
|
||||
def clear_sglang_kt_kernel_cache() -> None:
|
||||
"""Clear the sglang kt-kernel support cache, forcing a re-check on next run."""
|
||||
cache_path = _get_sglang_kt_kernel_cache_path()
|
||||
try:
|
||||
if cache_path.exists():
|
||||
cache_path.unlink()
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
|
||||
def check_sglang_kt_kernel_support(use_cache: bool = True, silent: bool = False) -> dict:
|
||||
"""Check if SGLang supports kt-kernel parameters (--kt-gpu-prefill-token-threshold).
|
||||
|
||||
This function runs `python -m sglang.launch_server --help` and checks if the
|
||||
output contains the `--kt-gpu-prefill-token-threshold` parameter. This parameter
|
||||
is only available in the kvcache-ai/sglang fork, not in the official sglang.
|
||||
|
||||
The result is cached after the first successful check to avoid repeated checks.
|
||||
|
||||
Args:
|
||||
use_cache: If True, use cached result if available. Default is True.
|
||||
silent: If True, don't print checking message. Default is False.
|
||||
|
||||
Returns:
|
||||
dict with keys:
|
||||
- supported: bool - True if kt-kernel parameters are supported
|
||||
- help_output: str or None - The help output from sglang.launch_server
|
||||
- error: str or None - Error message if check failed
|
||||
- from_cache: bool - True if result was from cache
|
||||
"""
|
||||
from kt_kernel.cli.utils.console import print_step
|
||||
|
||||
# Check cache first
|
||||
if use_cache and _is_sglang_kt_kernel_cache_valid():
|
||||
return {
|
||||
"supported": True,
|
||||
"help_output": None,
|
||||
"error": None,
|
||||
"from_cache": True,
|
||||
}
|
||||
|
||||
# Print checking message
|
||||
if not silent:
|
||||
print_step(t("sglang_checking_kt_kernel_support"))
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "sglang.launch_server", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=90, # Increased for slow CUDA init and module loading in some environments
|
||||
)
|
||||
|
||||
help_output = result.stdout + result.stderr
|
||||
|
||||
# Check if --kt-gpu-prefill-token-threshold is in the help output
|
||||
supported = "--kt-gpu-prefill-token-threshold" in help_output
|
||||
|
||||
# Save to cache if supported
|
||||
if supported:
|
||||
_save_sglang_kt_kernel_cache(True)
|
||||
|
||||
return {
|
||||
"supported": supported,
|
||||
"help_output": help_output,
|
||||
"error": None,
|
||||
"from_cache": False,
|
||||
}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"supported": False,
|
||||
"help_output": None,
|
||||
"error": "Timeout while checking sglang.launch_server --help",
|
||||
"from_cache": False,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
return {
|
||||
"supported": False,
|
||||
"help_output": None,
|
||||
"error": "Python interpreter not found",
|
||||
"from_cache": False,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"supported": False,
|
||||
"help_output": None,
|
||||
"error": str(e),
|
||||
"from_cache": False,
|
||||
}
|
||||
|
||||
|
||||
def print_sglang_kt_kernel_instructions() -> None:
|
||||
"""Print instructions for installing the kvcache-ai fork of SGLang with kt-kernel support."""
|
||||
from kt_kernel.cli.i18n import get_lang
|
||||
|
||||
lang = get_lang()
|
||||
|
||||
if lang == "zh":
|
||||
instructions = """
|
||||
[bold red]SGLang 不支持 kt-kernel[/bold red]
|
||||
|
||||
您当前安装的 SGLang 不包含 kt-kernel 支持。
|
||||
kt-kernel 需要使用 kvcache-ai 维护的 SGLang 分支。
|
||||
|
||||
[bold]请按以下步骤重新安装:[/bold]
|
||||
|
||||
[cyan]1. 卸载当前的 SGLang:[/cyan]
|
||||
pip uninstall sglang -y
|
||||
|
||||
[cyan]2. 安装 kvcache-ai 版本 (选择一种方式):[/cyan]
|
||||
|
||||
[bold]方式 A - 一键安装 (推荐):[/bold]
|
||||
从 ktransformers 根目录运行: ./install.sh
|
||||
|
||||
[bold]方式 B - pip 安装:[/bold]
|
||||
pip install sglang-kt
|
||||
|
||||
[dim]注意: 请确保在正确的 Python 环境中执行以上命令[/dim]
|
||||
"""
|
||||
else:
|
||||
instructions = """
|
||||
[bold red]SGLang does not support kt-kernel[/bold red]
|
||||
|
||||
Your current SGLang installation does not include kt-kernel support.
|
||||
kt-kernel requires the kvcache-ai maintained fork of SGLang.
|
||||
|
||||
[bold]Please reinstall SGLang:[/bold]
|
||||
|
||||
[cyan]1. Uninstall current SGLang:[/cyan]
|
||||
pip uninstall sglang -y
|
||||
|
||||
[cyan]2. Install the kvcache-ai fork (choose one):[/cyan]
|
||||
|
||||
[bold]Option A - One-click install (recommended):[/bold]
|
||||
From the ktransformers root directory, run: ./install.sh
|
||||
|
||||
[bold]Option B - pip install:[/bold]
|
||||
pip install sglang-kt
|
||||
|
||||
[dim]Note: Make sure to run these commands in the correct Python environment[/dim]
|
||||
"""
|
||||
console.print(instructions)
|
||||
@@ -0,0 +1,459 @@
|
||||
"""
|
||||
Tuna engine for auto-tuning GPU experts configuration.
|
||||
|
||||
Automatically finds the maximum viable num-gpu-experts through binary search
|
||||
by testing actual server launches with different configurations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from kt_kernel.cli.utils.console import console, print_error, print_info, print_warning
|
||||
|
||||
|
||||
def get_num_experts(model_path: Path) -> int:
|
||||
"""
|
||||
Get the number of experts per layer from model config.
|
||||
|
||||
Args:
|
||||
model_path: Path to the model directory
|
||||
|
||||
Returns:
|
||||
Number of experts per layer
|
||||
|
||||
Raises:
|
||||
ValueError: If config.json not found or num_experts field missing
|
||||
"""
|
||||
config_file = model_path / "config.json"
|
||||
|
||||
if not config_file.exists():
|
||||
raise ValueError(f"config.json not found in {model_path}")
|
||||
|
||||
try:
|
||||
config = json.loads(config_file.read_text())
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to parse config.json: {e}")
|
||||
|
||||
# Different models may use different field names
|
||||
possible_keys = [
|
||||
"num_experts_per_tok", # DeepSeek
|
||||
"num_local_experts", # Mixtral
|
||||
"n_routed_experts", # Qwen
|
||||
"num_experts", # Generic
|
||||
]
|
||||
|
||||
for key in possible_keys:
|
||||
if key in config:
|
||||
return config[key]
|
||||
|
||||
raise ValueError(f"Cannot find num_experts field in {config_file}. " f"Tried: {', '.join(possible_keys)}")
|
||||
|
||||
|
||||
def detect_oom(log_line: Optional[str]) -> bool:
|
||||
"""
|
||||
Detect OOM (Out Of Memory) errors from log output.
|
||||
|
||||
Args:
|
||||
log_line: A line from server output
|
||||
|
||||
Returns:
|
||||
True if OOM detected, False otherwise
|
||||
"""
|
||||
if log_line is None:
|
||||
return False
|
||||
|
||||
log_lower = log_line.lower()
|
||||
|
||||
oom_patterns = [
|
||||
"cuda out of memory",
|
||||
"out of memory",
|
||||
"outofmemoryerror",
|
||||
"oom",
|
||||
"failed to allocate",
|
||||
"cumemalloc failed",
|
||||
"cumemallocasync failed",
|
||||
"allocation failed",
|
||||
]
|
||||
|
||||
return any(pattern in log_lower for pattern in oom_patterns)
|
||||
|
||||
|
||||
def test_config(
|
||||
num_gpu_experts: int,
|
||||
model_path: Path,
|
||||
config: dict,
|
||||
verbose: bool = False,
|
||||
) -> tuple[bool, float]:
|
||||
"""
|
||||
Test if a configuration with given num_gpu_experts works.
|
||||
|
||||
Args:
|
||||
num_gpu_experts: Number of GPU experts to test
|
||||
model_path: Path to the model
|
||||
config: Configuration dict with all parameters
|
||||
verbose: Whether to show detailed logs
|
||||
|
||||
Returns:
|
||||
(success: bool, elapsed_time: float)
|
||||
- success: True if server starts and inference works
|
||||
- elapsed_time: Time taken for the test
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Use random port to avoid conflicts
|
||||
test_port = random.randint(30000, 40000)
|
||||
|
||||
# Build command
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"sglang.launch_server",
|
||||
"--model",
|
||||
str(model_path),
|
||||
"--port",
|
||||
str(test_port),
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--tensor-parallel-size",
|
||||
str(config["tensor_parallel_size"]),
|
||||
"--kt-num-gpu-experts",
|
||||
str(num_gpu_experts),
|
||||
"--max-total-tokens",
|
||||
str(config["max_total_tokens"]),
|
||||
]
|
||||
|
||||
# Add kt-kernel options
|
||||
if config.get("weights_path"):
|
||||
cmd.extend(["--kt-weight-path", str(config["weights_path"])])
|
||||
else:
|
||||
cmd.extend(["--kt-weight-path", str(model_path)])
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"--kt-cpuinfer",
|
||||
str(config.get("cpu_threads", 64)),
|
||||
"--kt-threadpool-count",
|
||||
str(config.get("numa_nodes", 2)),
|
||||
"--kt-method",
|
||||
config.get("kt_method", "AMXINT4"),
|
||||
"--kt-gpu-prefill-token-threshold",
|
||||
str(config.get("kt_gpu_prefill_threshold", 4096)),
|
||||
]
|
||||
)
|
||||
|
||||
# Add other SGLang options
|
||||
if config.get("attention_backend"):
|
||||
cmd.extend(["--attention-backend", config["attention_backend"]])
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"--trust-remote-code",
|
||||
"--mem-fraction-static",
|
||||
str(config.get("mem_fraction_static", 0.98)),
|
||||
"--chunked-prefill-size",
|
||||
str(config.get("chunked_prefill_size", 4096)),
|
||||
"--max-running-requests",
|
||||
str(config.get("max_running_requests", 1)), # Use 1 for faster testing
|
||||
"--watchdog-timeout",
|
||||
str(config.get("watchdog_timeout", 3000)),
|
||||
"--enable-mixed-chunk",
|
||||
"--enable-p2p-check",
|
||||
]
|
||||
)
|
||||
|
||||
# Add disable-shared-experts-fusion if specified
|
||||
if config.get("disable_shared_experts_fusion"):
|
||||
cmd.append("--disable-shared-experts-fusion")
|
||||
|
||||
# Add extra args
|
||||
if config.get("extra_args"):
|
||||
cmd.extend(config["extra_args"])
|
||||
|
||||
if verbose:
|
||||
console.print(f"[dim]Command: {' '.join(cmd)}[/dim]")
|
||||
|
||||
# Start process
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=config.get("env"),
|
||||
)
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print_error(f"Failed to start process: {e}")
|
||||
return False, time.time() - start_time
|
||||
|
||||
# Monitor process output
|
||||
timeout = 60 # Maximum 60 seconds to wait
|
||||
server_ready = False
|
||||
|
||||
try:
|
||||
while time.time() - start_time < timeout:
|
||||
# Check if process has output
|
||||
if process.poll() is not None:
|
||||
# Process exited
|
||||
if verbose:
|
||||
print_warning("Process exited early")
|
||||
return False, time.time() - start_time
|
||||
|
||||
# Read output line (non-blocking)
|
||||
try:
|
||||
line = process.stdout.readline()
|
||||
if not line:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
if verbose:
|
||||
console.print(f"[dim]{line.rstrip()}[/dim]")
|
||||
|
||||
# Fast OOM detection
|
||||
if detect_oom(line):
|
||||
if verbose:
|
||||
print_warning(f"OOM detected: {line.rstrip()}")
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
return False, time.time() - start_time
|
||||
|
||||
# Check for startup success
|
||||
if "Uvicorn running" in line or "Application startup complete" in line:
|
||||
server_ready = True
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print_warning(f"Error reading output: {e}")
|
||||
break
|
||||
|
||||
if not server_ready:
|
||||
# Timeout or failed to start
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
return False, time.time() - start_time
|
||||
|
||||
# Server is ready, test inference
|
||||
success = test_inference(test_port, verbose=verbose)
|
||||
|
||||
# Cleanup
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=2)
|
||||
|
||||
return success, time.time() - start_time
|
||||
|
||||
except KeyboardInterrupt:
|
||||
# User cancelled
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
raise
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print_error(f"Test failed with exception: {e}")
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except:
|
||||
try:
|
||||
process.kill()
|
||||
except:
|
||||
pass
|
||||
return False, time.time() - start_time
|
||||
|
||||
|
||||
def test_inference(port: int, verbose: bool = False) -> bool:
|
||||
"""
|
||||
Test if the server can handle a simple inference request.
|
||||
|
||||
Args:
|
||||
port: Server port
|
||||
verbose: Whether to show detailed logs
|
||||
|
||||
Returns:
|
||||
True if inference succeeds, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Wait a bit for server to be fully ready
|
||||
time.sleep(2)
|
||||
|
||||
# Try to import OpenAI client
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
if verbose:
|
||||
print_warning("OpenAI package not available, skipping inference test")
|
||||
return True # Assume success if we can't test
|
||||
|
||||
client = OpenAI(
|
||||
base_url=f"http://127.0.0.1:{port}/v1",
|
||||
api_key="test",
|
||||
)
|
||||
|
||||
# Send a simple test request
|
||||
response = client.chat.completions.create(
|
||||
model="test",
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
max_tokens=1,
|
||||
temperature=0,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
# Check if we got a valid response
|
||||
success = response.choices and len(response.choices) > 0 and response.choices[0].message.content is not None
|
||||
|
||||
if verbose:
|
||||
if success:
|
||||
print_info(f"Inference test passed: {response.choices[0].message.content}")
|
||||
else:
|
||||
print_warning("Inference test failed: no valid response")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print_warning(f"Inference test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def find_max_gpu_experts(
|
||||
model_path: Path,
|
||||
config: dict,
|
||||
verbose: bool = False,
|
||||
) -> int:
|
||||
"""
|
||||
Binary search to find the maximum viable num_gpu_experts.
|
||||
|
||||
Args:
|
||||
model_path: Path to the model
|
||||
config: Configuration dict
|
||||
verbose: Whether to show detailed logs
|
||||
|
||||
Returns:
|
||||
Maximum number of GPU experts that works
|
||||
"""
|
||||
# Get number of experts from model config
|
||||
try:
|
||||
num_experts = get_num_experts(model_path)
|
||||
except ValueError as e:
|
||||
print_error(str(e))
|
||||
raise
|
||||
|
||||
console.print()
|
||||
console.print(f"Binary search range: [0, {num_experts}]")
|
||||
console.print()
|
||||
|
||||
left, right = 0, num_experts
|
||||
result = 0
|
||||
iteration = 0
|
||||
total_iterations = math.ceil(math.log2(num_experts + 1))
|
||||
|
||||
while left <= right:
|
||||
iteration += 1
|
||||
mid = (left + right) // 2
|
||||
|
||||
console.print(f"[{iteration}/{total_iterations}] Testing gpu-experts={mid}... ", end="")
|
||||
|
||||
success, elapsed = test_config(mid, model_path, config, verbose=verbose)
|
||||
|
||||
if success:
|
||||
console.print(f"[green]✓ OK[/green] ({elapsed:.1f}s)")
|
||||
result = mid
|
||||
left = mid + 1
|
||||
else:
|
||||
console.print(f"[red]✗ FAILED[/red] ({elapsed:.1f}s)")
|
||||
right = mid - 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def run_tuna(
|
||||
model_path: Path,
|
||||
tensor_parallel_size: int,
|
||||
max_total_tokens: int,
|
||||
kt_method: str,
|
||||
verbose: bool = False,
|
||||
**kwargs,
|
||||
) -> int:
|
||||
"""
|
||||
Run tuna auto-tuning to find optimal num_gpu_experts.
|
||||
|
||||
Args:
|
||||
model_path: Path to the model
|
||||
tensor_parallel_size: Tensor parallel size
|
||||
max_total_tokens: Maximum total tokens
|
||||
kt_method: KT quantization method
|
||||
verbose: Whether to show detailed logs
|
||||
**kwargs: Additional configuration parameters
|
||||
|
||||
Returns:
|
||||
Optimal num_gpu_experts value
|
||||
|
||||
Raises:
|
||||
ValueError: If tuning fails completely
|
||||
"""
|
||||
# Prepare configuration
|
||||
config = {
|
||||
"tensor_parallel_size": tensor_parallel_size,
|
||||
"max_total_tokens": max_total_tokens,
|
||||
"kt_method": kt_method,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
# Run binary search
|
||||
try:
|
||||
result = find_max_gpu_experts(model_path, config, verbose=verbose)
|
||||
except KeyboardInterrupt:
|
||||
console.print()
|
||||
print_warning("Tuning cancelled by user")
|
||||
raise
|
||||
|
||||
console.print()
|
||||
|
||||
# Check if even 0 doesn't work
|
||||
if result == 0:
|
||||
console.print("[yellow]Testing if gpu-experts=0 is viable...[/yellow]")
|
||||
success, _ = test_config(0, model_path, config, verbose=verbose)
|
||||
|
||||
if not success:
|
||||
# Even 0 doesn't work
|
||||
console.print()
|
||||
print_error("Failed to start server even with all experts on CPU (gpu-experts=0)")
|
||||
console.print()
|
||||
console.print("[bold]Possible reasons:[/bold]")
|
||||
console.print(" • Insufficient GPU memory for base model layers")
|
||||
console.print(" • max-total-tokens is too large for available VRAM")
|
||||
console.print(" • Tensor parallel configuration issue")
|
||||
console.print()
|
||||
console.print("[bold]Suggestions:[/bold]")
|
||||
console.print(f" • Reduce --max-total-tokens (current: {max_total_tokens})")
|
||||
console.print(f" • Reduce --tensor-parallel-size (current: {tensor_parallel_size})")
|
||||
console.print(" • Use more GPUs or GPUs with more VRAM")
|
||||
console.print(" • Try a smaller model")
|
||||
console.print()
|
||||
raise ValueError("Minimum GPU memory requirements not met")
|
||||
else:
|
||||
# 0 works but nothing more
|
||||
console.print()
|
||||
print_warning("All experts will run on CPU (gpu-experts=0). " "Performance will be limited by CPU speed.")
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
User Model Registry
|
||||
|
||||
Manages user-registered models in ~/.ktransformers/user_models.yaml
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
import yaml
|
||||
|
||||
|
||||
# Constants
|
||||
USER_MODELS_FILE = Path.home() / ".ktransformers" / "user_models.yaml"
|
||||
REGISTRY_VERSION = "1.0"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserModel:
|
||||
"""Represents a user-registered model"""
|
||||
|
||||
name: str # User-editable name (default: folder name)
|
||||
path: str # Absolute path to model directory
|
||||
format: str # "safetensors" | "gguf"
|
||||
id: Optional[str] = None # Unique UUID for this model (auto-generated if None)
|
||||
repo_type: Optional[str] = None # "huggingface" | "modelscope" | None
|
||||
repo_id: Optional[str] = None # e.g., "deepseek-ai/DeepSeek-V3"
|
||||
sha256_status: str = "not_checked" # "not_checked" | "checking" | "passed" | "failed" | "no_repo"
|
||||
gpu_model_ids: Optional[List[str]] = None # For llamafile/AMX: list of GPU model UUIDs to run with
|
||||
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
last_verified: Optional[str] = None # ISO format datetime
|
||||
# MoE information (cached from analyze_moe_model)
|
||||
is_moe: Optional[bool] = None # True if MoE model, False if non-MoE, None if not analyzed
|
||||
moe_num_experts: Optional[int] = None # Total number of experts (for MoE models)
|
||||
moe_num_experts_per_tok: Optional[int] = None # Number of active experts per token (for MoE models)
|
||||
# AMX quantization metadata (for format == "amx")
|
||||
amx_source_model: Optional[str] = None # Name of the source MoE model that was quantized
|
||||
amx_quant_method: Optional[str] = None # "int4" | "int8"
|
||||
amx_numa_nodes: Optional[int] = None # Number of NUMA nodes used for quantization
|
||||
|
||||
def __post_init__(self):
|
||||
"""Ensure ID is set after initialization"""
|
||||
if self.id is None:
|
||||
import uuid
|
||||
|
||||
self.id = str(uuid.uuid4())
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for YAML serialization"""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "UserModel":
|
||||
"""Create from dictionary loaded from YAML"""
|
||||
return cls(**data)
|
||||
|
||||
def path_exists(self) -> bool:
|
||||
"""Check if model path still exists"""
|
||||
return Path(self.path).exists()
|
||||
|
||||
|
||||
class UserModelRegistry:
|
||||
"""Manages the user model registry"""
|
||||
|
||||
def __init__(self, registry_file: Optional[Path] = None):
|
||||
"""
|
||||
Initialize the registry
|
||||
|
||||
Args:
|
||||
registry_file: Path to the registry YAML file (default: USER_MODELS_FILE)
|
||||
"""
|
||||
self.registry_file = registry_file or USER_MODELS_FILE
|
||||
self.models: List[UserModel] = []
|
||||
self.version = REGISTRY_VERSION
|
||||
|
||||
# Ensure directory exists
|
||||
self.registry_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load existing registry
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
"""Load models from YAML file"""
|
||||
if not self.registry_file.exists():
|
||||
# Initialize empty registry
|
||||
self.models = []
|
||||
self.save() # Create the file
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.registry_file, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if not data:
|
||||
self.models = []
|
||||
return
|
||||
|
||||
# Load version
|
||||
self.version = data.get("version", REGISTRY_VERSION)
|
||||
|
||||
# Load models
|
||||
models_data = data.get("models", [])
|
||||
self.models = [UserModel.from_dict(m) for m in models_data]
|
||||
|
||||
# Migrate: ensure all models have UUIDs (for backward compatibility)
|
||||
needs_save = False
|
||||
for model in self.models:
|
||||
if model.id is None:
|
||||
import uuid
|
||||
|
||||
model.id = str(uuid.uuid4())
|
||||
needs_save = True
|
||||
|
||||
if needs_save:
|
||||
self.save()
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to load user model registry: {e}")
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save models to YAML file"""
|
||||
data = {"version": self.version, "models": [m.to_dict() for m in self.models]}
|
||||
|
||||
try:
|
||||
with open(self.registry_file, "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to save user model registry: {e}")
|
||||
|
||||
def add_model(self, model: UserModel) -> None:
|
||||
"""
|
||||
Add a model to the registry
|
||||
|
||||
Args:
|
||||
model: UserModel instance to add
|
||||
|
||||
Raises:
|
||||
ValueError: If a model with the same name already exists
|
||||
"""
|
||||
if self.check_name_conflict(model.name):
|
||||
raise ValueError(f"Model with name '{model.name}' already exists")
|
||||
|
||||
self.models.append(model)
|
||||
self.save()
|
||||
|
||||
def remove_model(self, name: str) -> bool:
|
||||
"""
|
||||
Remove a model from the registry
|
||||
|
||||
Args:
|
||||
name: Name of the model to remove
|
||||
|
||||
Returns:
|
||||
True if model was removed, False if not found
|
||||
"""
|
||||
original_count = len(self.models)
|
||||
self.models = [m for m in self.models if m.name != name]
|
||||
|
||||
if len(self.models) < original_count:
|
||||
self.save()
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_model(self, name: str, updates: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Update a model's attributes
|
||||
|
||||
Args:
|
||||
name: Name of the model to update
|
||||
updates: Dictionary of attributes to update
|
||||
|
||||
Returns:
|
||||
True if model was updated, False if not found
|
||||
"""
|
||||
model = self.get_model(name)
|
||||
if not model:
|
||||
return False
|
||||
|
||||
# Update attributes
|
||||
for key, value in updates.items():
|
||||
if hasattr(model, key):
|
||||
setattr(model, key, value)
|
||||
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def get_model(self, name: str) -> Optional[UserModel]:
|
||||
"""
|
||||
Get a model by name
|
||||
|
||||
Args:
|
||||
name: Name of the model
|
||||
|
||||
Returns:
|
||||
UserModel instance or None if not found
|
||||
"""
|
||||
for model in self.models:
|
||||
if model.name == name:
|
||||
return model
|
||||
return None
|
||||
|
||||
def get_model_by_id(self, model_id: str) -> Optional[UserModel]:
|
||||
"""
|
||||
Get a model by its unique ID
|
||||
|
||||
Args:
|
||||
model_id: UUID of the model
|
||||
|
||||
Returns:
|
||||
UserModel instance or None if not found
|
||||
"""
|
||||
for model in self.models:
|
||||
if model.id == model_id:
|
||||
return model
|
||||
return None
|
||||
|
||||
def list_models(self) -> List[UserModel]:
|
||||
"""
|
||||
List all models
|
||||
|
||||
Returns:
|
||||
List of all UserModel instances
|
||||
"""
|
||||
return self.models.copy()
|
||||
|
||||
def find_by_path(self, path: str) -> Optional[UserModel]:
|
||||
"""
|
||||
Find a model by its path
|
||||
|
||||
Args:
|
||||
path: Model directory path
|
||||
|
||||
Returns:
|
||||
UserModel instance or None if not found
|
||||
"""
|
||||
# Normalize paths for comparison
|
||||
search_path = str(Path(path).resolve())
|
||||
|
||||
for model in self.models:
|
||||
model_path = str(Path(model.path).resolve())
|
||||
if model_path == search_path:
|
||||
return model
|
||||
return None
|
||||
|
||||
def check_name_conflict(self, name: str, exclude_name: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Check if a name conflicts with existing models
|
||||
|
||||
Args:
|
||||
name: Name to check
|
||||
exclude_name: Optional name to exclude from check (for rename operations)
|
||||
|
||||
Returns:
|
||||
True if conflict exists, False otherwise
|
||||
"""
|
||||
for model in self.models:
|
||||
if model.name == name and model.name != exclude_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
def refresh_status(self) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Check all models and identify missing ones
|
||||
|
||||
Returns:
|
||||
Dictionary with 'valid' and 'missing' lists of model names
|
||||
"""
|
||||
valid = []
|
||||
missing = []
|
||||
|
||||
for model in self.models:
|
||||
if model.path_exists():
|
||||
valid.append(model.name)
|
||||
else:
|
||||
missing.append(model.name)
|
||||
|
||||
return {"valid": valid, "missing": missing}
|
||||
|
||||
def get_model_count(self) -> int:
|
||||
"""Get total number of registered models"""
|
||||
return len(self.models)
|
||||
|
||||
def suggest_name(self, base_name: str) -> str:
|
||||
"""
|
||||
Suggest a unique name based on base_name
|
||||
|
||||
Args:
|
||||
base_name: Base name to derive from
|
||||
|
||||
Returns:
|
||||
A unique name (may have suffix like -2, -3 etc.)
|
||||
"""
|
||||
if not self.check_name_conflict(base_name):
|
||||
return base_name
|
||||
|
||||
counter = 2
|
||||
while True:
|
||||
candidate = f"{base_name}-{counter}"
|
||||
if not self.check_name_conflict(candidate):
|
||||
return candidate
|
||||
counter += 1
|
||||
Reference in New Issue
Block a user