""" Fusion Ollama deployment tool (v2 - fixed) Features: 1. Auto-detect llama.cpp path 2. Convert HF model to GGUF format 3. Generate Modelfile 4. Create Ollama model 5. Support Thinking Dial control 6. Windows-compatible (shell=True for subprocess) Usage: python inference/ollama_deploy_v2.py --model_path ./output/fusion-8b --model_name fusion-8b Requirements: - llama.cpp (auto-detected or set LLAMA_CPP_DIR) - Ollama (https://ollama.com) Author: zhan1206 Project: Fusion - Hexagonal Open-source LLM License: Apache 2.0 """ import argparse import subprocess import os import json from pathlib import Path from typing import Optional import logging import sys logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) def find_llama_cpp() -> str: """ Auto-detect llama.cpp directory Returns: llama.cpp directory path Raises: FileNotFoundError: llama.cpp not found """ # 1. Check environment variable llama_cpp_dir = os.environ.get("LLAMA_CPP_DIR", "") if llama_cpp_dir and os.path.exists(llama_cpp_dir): convert_script = os.path.join(llama_cpp_dir, "convert-hf-to-gguf.py") if os.path.exists(convert_script): logger.info(f"Found llama.cpp from env: {llama_cpp_dir}") return llama_cpp_dir # 2. Check common paths possible_paths = [ "./llama.cpp", os.path.expanduser("~/llama.cpp"), os.path.join(os.path.dirname(__file__), "..", "llama.cpp"), os.path.join(os.path.dirname(__file__), "..", "..", "llama.cpp"), ] for path in possible_paths: path = os.path.abspath(path) convert_script = os.path.join(path, "convert-hf-to-gguf.py") if os.path.exists(convert_script): logger.info(f"Auto-detected llama.cpp: {path}") return path # 3. Not found raise FileNotFoundError( "llama.cpp not found. Set LLAMA_CPP_DIR or download to common path.\n" "Download: https://github.com/ggerganov/llama.cpp" ) def check_dependencies() -> bool: """ Check dependencies (auto-detect llama.cpp + Windows compatible) Returns: Whether dependencies are satisfied """ logger.info("Checking dependencies...") # 1. Check llama.cpp try: llama_cpp_dir = find_llama_cpp() convert_script = os.path.join(llama_cpp_dir, "convert-hf-to-gguf.py") logger.info(f"llama.cpp convert script found: {convert_script}") except FileNotFoundError as e: logger.error(f"llama.cpp not found: {e}") return False # 2. Check Ollama (Windows needs shell=True) try: result = subprocess.run( ["ollama", "--version"], capture_output=True, text=True, shell=True, # Windows needs shell=True timeout=10, ) if result.returncode == 0: logger.info(f"Ollama installed: {result.stdout.strip()}") else: logger.warning("Ollama not installed or not working") logger.warning("Please install from https://ollama.com") return False except FileNotFoundError: logger.warning("Ollama not installed") logger.warning("Please install from https://ollama.com") return False except subprocess.TimeoutExpired: logger.warning("Ollama check timeout") return False logger.info("All dependencies satisfied") return True def convert_to_gguf( model_path: str, output_path: str, quantize: str = "q4_k_m", ) -> str: """ Convert HuggingFace model to GGUF format Args: model_path: HuggingFace model path output_path: Output path quantize: Quantization level (q4_k_m, q5_k_m, q8_0, etc.) Returns: Converted GGUF file path """ logger.info("Converting to GGUF format...") llama_cpp_dir = find_llama_cpp() convert_script = os.path.join(llama_cpp_dir, "convert-hf-to-gguf.py") # Ensure output directory exists os.makedirs(os.path.dirname(output_path), exist_ok=True) # Conversion command cmd = [ sys.executable, # Use current Python interpreter convert_script, model_path, "--outtype", "f16", # Convert to f16 first "--outfile", output_path, ] logger.info(f"Running command: {' '.join(cmd)}") result = subprocess.run( cmd, capture_output=True, text=True, shell=True, # Windows needs shell=True timeout=600, # 10 minutes timeout ) if result.returncode != 0: logger.warning(f"Standard conversion failed: {result.stderr[:200]}") logger.info("Attempting fallback export for custom architecture...") # Fallback: Export model weights manually for custom architectures (e.g., SBLA) try: gguf_path = _fallback_export_gguf(model_path, output_path) if gguf_path: logger.info(f"Fallback export successful: {gguf_path}") return gguf_path except Exception as e2: logger.error(f"Fallback export also failed: {e2}") raise RuntimeError(f"GGUF conversion failed. The model uses custom architecture (SBLA/Thinking Dial) not recognized by llama.cpp. " f"Options: 1) Export weights manually, 2) Use a standard Transformer variant for deployment.") logger.info(f"GGUF conversion complete: {output_path}") # Quantization (optional) if quantize and quantize != "f16": logger.info(f"Quantizing model ({quantize})...") quantized_path = output_path.replace(".gguf", f"_{quantize}.gguf") quantize_cmd = [ os.path.join(llama_cpp_dir, "llama-quantize"), output_path, quantized_path, quantize, ] result = subprocess.run( quantize_cmd, capture_output=True, text=True, shell=True, timeout=300, ) if result.returncode != 0: logger.warning(f"Quantization failed: {result.stderr}") logger.warning("Using unquantized model") else: output_path = quantized_path logger.info(f"Quantization complete: {output_path}") return output_path def create_modelfile( model_path: str, modelfile_path: str, model_name: str, context_size: int = 32768, thinking_dial: bool = True, ): """ Create Ollama Modelfile Args: model_path: GGUF model path modelfile_path: Modelfile output path model_name: Model name context_size: Context window size thinking_dial: Whether to enable Thinking Dial """ logger.info("Creating Modelfile...") # Get absolute path of model file model_path_abs = os.path.abspath(model_path) # Modelfile content content = f"""# Fusion Model: {model_name} # Auto-generated by Fusion project # Project: https://github.com/zhan1206/fusion-llm FROM {model_path_abs} # Model parameters PARAMETER num_ctx {context_size} PARAMETER temperature 0.8 PARAMETER top_p 0.95 PARAMETER repeat_penalty 1.1 # System prompt SYSTEM \"\"\"You are a powerful AI assistant. You support dynamic reasoning intensity control: - Simple questions: direct answer - Complex questions: enable chain-of-thought reasoning Use <|think| depth=N|> to control reasoning depth (N=0-3). \"\"\" # Template (supports Thinking Dial) TEMPLATE \"\"\"{{{{ if .System }}}}<|im_start|>system {{{{ .System }}}}<|im_end|> {{{{ end }}}}{{{{ if .Prompt }}}}<|im_start|>user {{{{ .Prompt }}}}<|im_end|> {{{{ end }}}}<|im_start|>assistant \"\"\" """ # If Thinking Dial is enabled, add special token handling if thinking_dial: content += f""" # Thinking Dial examples (injected during training) # <|think_depth_0|> Simple question, direct answer # <|think_depth_3|> Complex question, detailed reasoning """ # Write file with open(modelfile_path, 'w', encoding='utf-8') as f: f.write(content) logger.info(f"Modelfile created: {modelfile_path}") def create_ollama_model(modelfile_path: str, model_name: str) -> bool: """ Create Ollama model using Modelfile Args: modelfile_path: Modelfile path model_name: Model name Returns: Whether creation succeeded """ logger.info(f"Creating Ollama model: {model_name}...") # Remove existing model subprocess.run( ["ollama", "rm", model_name], capture_output=True, shell=True, ) # Create model cmd = ["ollama", "create", model_name, "-f", modelfile_path] logger.info(f"Running command: {' '.join(cmd)}") result = subprocess.run( cmd, capture_output=True, text=True, shell=True, timeout=300, ) if result.returncode != 0: logger.error(f"Creation failed: {result.stderr}") return False logger.info(f"Ollama model created: {model_name}") logger.info(f"Run `ollama run {model_name}` to start using") return True def deploy( model_path: str, model_name: str, output_dir: str = "./ollama_output", quantize: str = "q4_k_m", context_size: int = 32768, thinking_dial: bool = True, ) -> bool: """ Complete deployment pipeline Args: model_path: HuggingFace model path model_name: Model name output_dir: Output directory quantize: Quantization level context_size: Context window thinking_dial: Whether to enable Thinking Dial Returns: Whether deployment succeeded """ logger.info("Starting Ollama deployment...") logger.info(f"Model path: {model_path}") logger.info(f"Model name: {model_name}") # 1. Check dependencies if not check_dependencies(): logger.error("Dependency check failed") return False # 2. Create output directory os.makedirs(output_dir, exist_ok=True) # 3. Convert to GGUF gguf_path = os.path.join(output_dir, f"{model_name}.gguf") try: gguf_path = convert_to_gguf( model_path=model_path, output_path=gguf_path, quantize=quantize, ) except RuntimeError as e: logger.error(f"GGUF conversion failed: {e}") return False # 4. Create Modelfile modelfile_path = os.path.join(output_dir, "Modelfile") create_modelfile( model_path=gguf_path, modelfile_path=modelfile_path, model_name=model_name, context_size=context_size, thinking_dial=thinking_dial, ) # 5. Create Ollama model if not create_ollama_model( modelfile_path=modelfile_path, model_name=model_name, ): logger.error("Ollama model creation failed") return False # 6. Generate usage example example_path = os.path.join(output_dir, "USAGE.md") generate_usage_example(model_name, example_path) logger.info("Deployment complete!") logger.info(f"Run: `ollama run {model_name}`") logger.info(f"Examples: see {example_path}") return True def generate_usage_example(model_name: str, output_path: str): """ Generate usage example document """ content = f"""# Fusion Model Usage Examples ## 1. Basic Usage ```bash # Start model ollama run {model_name} # Input question in interactive interface > Explain quantum entanglement ``` ## 2. Thinking Dial Control Fusion supports dynamic reasoning intensity control. Add control token before question: ```bash # depth=0: direct answer (casual chat, translation) > <|think_depth_0|> How's the weather today? # depth=1: simple reasoning > <|think_depth_1|> Calculate 123 * 456 # depth=2: medium reasoning > <|think_depth_2|> Prove Pythagorean theorem # depth=3: deep reasoning (chain-of-thought) > <|think_depth_3|> Solve this algorithm problem: ... ``` ## 3. REST API Ollama provides OpenAI-compatible API: ```bash # Start Ollama service ollama serve # Call API curl http://localhost:11434/api/generate -d {{{{ "model": "{model_name}", "prompt": "Explain machine learning", "stream": false }}}} ``` ## 4. Python Call ```python import ollama # Basic call response = ollama.generate( model="{model_name}", prompt="Explain quantum entanglement", ) print(response["response"]) # With Thinking Dial response = ollama.generate( model="{model_name}", prompt="<|think_depth_2|> Prove Pythagorean theorem", ) print(response["response"]) ``` ## 5. Parameter Tuning Adjust generation parameters in Ollama: ```bash # Temperature (creativity) ollama run {model_name} --temperature 0.9 # Context window ollama run {model_name} --num_ctx 16384 # Top-p sampling ollama run {model_name} --top_p 0.95 ``` --- **Tip**: See Ollama docs for more: https://ollama.com/docs """ with open(output_path, 'w', encoding='utf-8') as f: f.write(content) logger.info(f"Usage example generated: {output_path}") def main(): parser = argparse.ArgumentParser(description="Fusion Ollama One-Click Deployment (v2)") parser.add_argument("--model_path", type=str, required=True, help="HuggingFace model path") parser.add_argument("--model_name", type=str, required=True, help="Ollama model name (e.g., fusion-8b)") parser.add_argument("--output_dir", type=str, default="./ollama_output", help="Output directory") parser.add_argument("--quantize", type=str, default="q4_k_m", choices=["q4_k_m", "q5_k_m", "q8_0", "f16"], help="Quantization level") parser.add_argument("--context_size", type=int, default=32768, help="Context window size") parser.add_argument("--no_thinking_dial", action="store_false", dest="thinking_dial", help="Disable Thinking Dial") args = parser.parse_args() # Execute deployment success = deploy( model_path=args.model_path, model_name=args.model_name, output_dir=args.output_dir, quantize=args.quantize, context_size=args.context_size, thinking_dial=args.thinking_dial, ) if success: logger.info("Deployment successful!") else: logger.error("Deployment failed") if __name__ == "__main__": main() def _fallback_export_gguf(model_path: str, output_path: str) -> Optional[str]: """ Fallback: Export model weights for custom architectures that llama.cpp convert-hf-to-gguf.py cannot handle (e.g., SBLA, Thinking Dial). This exports a safetensors-format model that can be loaded by custom inference servers, or manually converted later. For Ollama deployment of custom architectures, you may need to: 1. Convert the model to a standard LLaMA-compatible format first 2. Strip SBLA/ThinkingDial layers (use standard attention + MLP) 3. Then convert the standard model to GGUF """ try: import safetensors.torch as st except ImportError: logger.warning("safetensors not installed. Install: pip install safetensors") return None import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from models.fusion_model import FusionModel, FusionConfig # Load model config = FusionConfig.from_pretrained(model_path) model = FusionModel(config) # Export path export_path = output_path.replace('.gguf', '.safetensors') # Load weights - handle sharded models (index.json + multiple safetensors) from pathlib import Path model_path_obj = Path(model_path) index_file = model_path_obj / "model.safetensors.index.json" if index_file.exists(): # Sharded model: load all shards and merge import json as _json with open(index_file, 'r') as f: index = _json.load(f) weight_map = index.get("weight_map", {}) shard_files = set(weight_map.values()) merged_state = {} for shard in shard_files: shard_path = model_path_obj / shard shard_state = st.load_file(str(shard_path)) merged_state.update(shard_state) st.save_file(merged_state, export_path) else: # Single-file model: load actual weights, don't save random init weight_files = list(model_path_obj.glob("*.safetensors")) + list(model_path_obj.glob("*.bin")) if not weight_files: logger.error("No model weight files found") return None # Load the actual weights import safetensors.torch as st import torch weight_file = weight_files[0] if weight_file.suffix == '.safetensors': state_dict = st.load_file(str(weight_file)) else: # .bin (PyTorch) state_dict = torch.load(str(weight_file), map_location='cpu') st.save_file(state_dict, export_path) logger.info(f"Exported model weights to: {export_path}") logger.info("NOTE: This is a safetensors export, not GGUF. For Ollama deployment,") logger.info(" convert this to GGUF using llama.cpp after ensuring architecture compatibility.") return export_path