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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:30:03 +08:00
commit ec436095dd
1232 changed files with 404407 additions and 0 deletions
+21
View File
@@ -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.
"""
+274
View File
@@ -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}")
+572
View File
@@ -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}")
+167
View File
@@ -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
+556
View File
@@ -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
+530
View File
@@ -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
+838
View File
@@ -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)
+52
View File
@@ -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)
+102
View File
@@ -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."""
+153
View File
@@ -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
+74
View File
@@ -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"
+7
View File
@@ -0,0 +1,7 @@
"""
Configuration management for kt-cli.
"""
from kt_kernel.cli.config.settings import Settings, get_settings
__all__ = ["Settings", "get_settings"]
+311
View File
@@ -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
+547
View File
@@ -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
+3
View File
@@ -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()
+249
View File
@@ -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]")
+118
View File
@@ -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
}
+789
View File
@@ -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,
}
+364
View File
@@ -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
+111
View File
@@ -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)
+459
View File
@@ -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