feat: add Ollama integration — deploy GGUF models in one command (v0.18.0)

New commands:
- `soup deploy ollama` — deploy GGUF to local Ollama with auto-template detection
- `soup deploy ollama --list` / `--remove` — manage Soup-deployed models
- `soup export --deploy ollama` — export + auto-deploy in one step

New files:
- soup_cli/utils/ollama.py — detect, deploy, list, remove, Modelfile generation
- soup_cli/commands/deploy.py — Typer command group with Rich panels
- tests/test_deploy_ollama.py — 78 tests covering all paths

Security hardening:
- GGUF path traversal protection + .gguf extension validation
- Model name validation (no path separators, null bytes)
- Modelfile parameter key allowlist prevents directive injection
- Parameter value newline/null sanitization
- Subprocess calls use list args (no shell injection)
- Warning panel before overwriting existing Ollama models

1449 tests, 57 test files, all passing.
This commit is contained in:
Alpamys 2026-04-01 13:47:40 +05:00
parent 986f8cb26c
commit f98519ef87
10 changed files with 1402 additions and 12 deletions

View File

@ -1,12 +1,12 @@
# Soup CLI — Project CLAUDE.md
Soup is a CLI-first LLM fine-tuning tool (v0.17.3). Python 3.9+, MIT license.
Soup is a CLI-first LLM fine-tuning tool (v0.18.0). Python 3.9+, MIT license.
## Build & Development
```bash
pip install -e ".[dev]" # Install editable + test deps
pytest tests/ -v --tb=short # Run all tests (1348 tests)
pytest tests/ -v --tb=short # Run all tests (1449 tests)
ruff check soup_cli/ tests/ # Lint (must pass before commit)
ruff check --fix soup_cli/ tests/ # Auto-fix lint issues
```
@ -16,7 +16,7 @@ ruff check --fix soup_cli/ tests/ # Auto-fix lint issues
```
soup_cli/
cli.py # Entry point, Typer app, all command registration
__init__.py # __version__ = "0.17.3"
__init__.py # __version__ = "0.18.0"
config/
schema.py # Pydantic models (SoupConfig, DataConfig, TrainingConfig, LoraConfig)
loader.py # YAML -> SoupConfig, load_config_from_string()
@ -47,6 +47,7 @@ soup_cli/
tracker.py # SQLite at ~/.soup/experiments.db (runs, metrics, eval_results)
commands/
train.py # soup train (routes to SFT/DPO/GRPO/PPO/Reward/KTO/ORPO/SimPO/IPO)
deploy.py # soup deploy ollama (deploy GGUF to Ollama)
init.py # soup init (interactive wizard + 10 templates)
chat.py # soup chat (terminal REPL)
serve.py # soup serve (OpenAI-compatible API, transformers/vllm backends)
@ -82,6 +83,7 @@ soup_cli/
long_context.py # RoPE scaling for 128k+ context fine-tuning
quality.py # Perplexity + coherence scoring for data quality filters
sglang.py # SGLang runtime backend (high-throughput serving)
ollama.py # Ollama integration (detect, deploy, list, remove, Modelfile gen)
constants.py # APP_NAME, paths, default chat template
tests/ # 56 test files, 1348 tests
examples/
@ -98,6 +100,10 @@ soup infer # Batch inference (--model, --input, --output)
soup chat # Terminal chat with model
soup serve # OpenAI-compatible inference server (--backend transformers|vllm|sglang, --speculative-decoding)
soup export # Convert to GGUF/ONNX/TensorRT for deployment
soup export --deploy ollama # Export GGUF + auto-deploy to Ollama
soup deploy ollama # Deploy GGUF model to local Ollama instance
soup deploy ollama --list # List Soup-deployed models in Ollama
soup deploy ollama --remove # Remove model from Ollama
soup merge # Merge LoRA adapter with base model
soup push # Upload to HuggingFace Hub
soup eval # Run benchmarks (mmlu, gsm8k, etc.)
@ -192,6 +198,12 @@ soup version # Show version (--full for details)
- **Audio model**: trust_remote_code warning panel before loading audio models (v0.17.0)
- **Audio paths**: path traversal protection — resolved paths confined to audio_dir (v0.17.0)
- **SGLang**: trust_remote_code warning panel before runtime creation (v0.17.0)
- **Ollama deploy**: GGUF path traversal protection + `.gguf` extension validation (v0.18.0)
- **Ollama deploy**: model name validation — no path separators or null bytes (v0.18.0)
- **Ollama deploy**: subprocess calls use list args (no shell injection) (v0.18.0)
- **Ollama deploy**: Modelfile parameter key allowlist prevents directive injection (v0.18.0)
- **Ollama deploy**: parameter value newline/null sanitization prevents Modelfile injection (v0.18.0)
- **Ollama deploy**: warning panel before `ollama create` (overwrites existing model) (v0.18.0)
## Code Conventions
@ -261,7 +273,7 @@ soup version # Show version (--full for details)
15. **Tag**: `git tag v0.X.Y && git push origin v0.X.Y`
16. **Release**: `gh release create v0.X.Y` with changelog (What's New, Install/Upgrade)
## Tests (56 test files, 1348 tests)
## Tests (57 test files, 1449 tests)
| File | Covers |
|------|--------|
@ -320,3 +332,4 @@ soup version # Show version (--full for details)
| test_quality_filter.py | Perplexity + coherence scoring, `soup data filter` |
| test_audio.py | Audio modality config, format, template, routing, loader |
| test_sglang_serve.py | SGLang backend detection, runtime creation, serve --backend |
| test_deploy_ollama.py | Ollama deploy, Modelfile gen, template mapping, security validation |

View File

@ -683,13 +683,44 @@ soup export --model ./output --format tensorrt
soup export --model ./output --format tensorrt --output ./model_trt
```
After export, use with Ollama:
After export, use with Ollama manually or auto-deploy:
```bash
# Manual (3-step)
echo 'FROM ./my-model.q4_k_m.gguf' > Modelfile
ollama create my-model -f Modelfile
ollama run my-model
# Auto-deploy (1-step)
soup export --model ./output --format gguf --deploy ollama --deploy-name my-model
```
### Deploy to Ollama
Deploy a GGUF model directly to your local [Ollama](https://ollama.com/) instance:
```bash
# Deploy a GGUF model
soup deploy ollama --model ./output/model.q4_k_m.gguf --name soup-my-model
# Deploy with system prompt and parameters
soup deploy ollama --model ./model.gguf --name soup-chat \
--system "You are a helpful assistant." \
--template chatml \
--parameter temperature=0.7 \
--parameter top_p=0.9
# Export + deploy in one command
soup export --model ./output --format gguf --deploy ollama
# List Soup-deployed models
soup deploy ollama --list
# Remove a model
soup deploy ollama --remove soup-my-model
```
Auto-detected chat templates: `chatml`, `llama`, `mistral`, `vicuna`, `zephyr` (or `auto` to infer from soup.yaml).
## Resume Training
Resume a training run from a checkpoint:
@ -1132,8 +1163,12 @@ soup chat --model ./output Interactive chat
soup push --model ./output --repo user/name Upload to HuggingFace
soup merge --adapter ./output Merge LoRA with base model
soup export --model ./output --format gguf Export to GGUF (Ollama)
soup export --model ./output --deploy ollama Export GGUF + auto-deploy to Ollama
soup export --model ./output --format onnx Export to ONNX
soup export --model ./output --format tensorrt Export to TensorRT-LLM
soup deploy ollama --model m.gguf --name x Deploy GGUF to Ollama
soup deploy ollama --list List Soup-deployed models
soup deploy ollama --remove <name> Remove model from Ollama
soup eval --model ./output --benchmarks mmlu Evaluate on benchmarks
soup serve --model ./output --port 8000 OpenAI-compatible API server
soup serve --model ./output --backend vllm vLLM backend (2-4x throughput)

View File

@ -4,14 +4,14 @@
We provide security updates for the following versions:
- **Latest minor version:** Active support (e.g., v0.17.x)
- **Latest minor version:** Active support (e.g., v0.18.x)
- **Previous minor versions:** Bug-fix support only
- **Versions older than 3 minor versions:** No support
Example:
- v0.17.0-0.17.x → Full support (latest)
- v0.16.0-0.16.x → Bug-fix support only
- v0.15.x and below → No support
- v0.18.0-0.18.x → Full support (latest)
- v0.17.0-0.17.x → Bug-fix support only
- v0.16.x and below → No support
## Reporting a Vulnerability
@ -120,6 +120,7 @@ No known critical vulnerabilities in current releases.
- **v0.15.0**: `rope_scaling_type` Literal constraint, `max_length` bounds (ge=64, le=1048576), FSDP config key allowlist, Liger Kernel exception handling narrowed
- **v0.16.0**: `embedding_loss` Literal constraint, `embedding_margin` gt=0 validation, ONNX export without trust_remote_code (with warning), TensorRT export subprocess list args (no shell injection), speculative decoding SSRF-protected (URL blocked) with warning panel, vLLM speculative model URL validation
- **v0.17.0**: Server data generation provider SSRF validation (scheme whitelist + localhost-only HTTP), audio model trust_remote_code warning panel, audio file path traversal protection (resolved paths confined to audio_dir), SGLang backend trust_remote_code warning panel
- **v0.18.0**: Ollama deploy GGUF path traversal protection + `.gguf` extension validation, model name validation (no path separators/null bytes), subprocess list args (no shell injection), Modelfile parameter key allowlist + value newline/null sanitization, overwrite warning panel
## Security Scanning
@ -156,6 +157,6 @@ This Security Policy is provided under the MIT license, same as the Soup project
---
**Last Updated**: March 2026
**Last Updated**: April 2026
For the latest version of this policy, visit: https://github.com/MakazhanAlpamys/Soup/blob/main/SECURITY.md

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "soup-cli"
version = "0.17.3"
version = "0.18.0"
description = "Fine-tune LLMs in one command. No SSH, no config hell."
readme = "README.md"
license = "MIT"

View File

@ -1,3 +1,3 @@
"""Soup CLI — Fine-tune LLMs in one command."""
__version__ = "0.17.3"
__version__ = "0.18.0"

View File

@ -9,6 +9,7 @@ from soup_cli import __version__
from soup_cli.commands import (
chat,
data,
deploy,
diff,
eval,
export,
@ -53,6 +54,10 @@ app.add_typer(
data.app, name="data",
help="Dataset tools: inspect, convert, merge, dedup, validate, stats.",
)
app.add_typer(
deploy.app, name="deploy",
help="Deploy models: Ollama integration (deploy, list, remove).",
)
app.add_typer(runs.app, name="runs", help="Experiment tracking: list, show, compare runs.")
app.command(name="eval")(eval.eval_model)
app.command()(serve.serve)

253
soup_cli/commands/deploy.py Normal file
View File

@ -0,0 +1,253 @@
"""soup deploy — deploy models to inference runtimes (Ollama)."""
from pathlib import Path
from typing import List, Optional
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
console = Console()
app = typer.Typer(no_args_is_help=True)
@app.command()
def ollama(
model: Optional[str] = typer.Option(
None,
"--model",
"-m",
help="Path to GGUF model file",
),
name: Optional[str] = typer.Option(
None,
"--name",
"-n",
help="Ollama model name (e.g. soup-my-model)",
),
system: Optional[str] = typer.Option(
None,
"--system",
"-s",
help="System prompt for the model",
),
template: str = typer.Option(
"auto",
"--template",
"-t",
help="Chat template: auto, chatml, llama, mistral, vicuna, zephyr",
),
parameter: Optional[List[str]] = typer.Option(
None,
"--parameter",
"-p",
help="Ollama parameter (repeatable): temperature=0.7, top_p=0.9, etc.",
),
list_models: bool = typer.Option(
False,
"--list",
"-l",
help="List Soup-deployed models in Ollama",
),
remove: Optional[str] = typer.Option(
None,
"--remove",
"-r",
help="Remove a model from Ollama by name",
),
yes: bool = typer.Option(
False,
"--yes",
"-y",
help="Skip confirmation prompt",
),
):
"""Deploy a GGUF model to local Ollama instance."""
from soup_cli.utils.ollama import (
OLLAMA_TEMPLATES,
create_modelfile,
deploy_to_ollama,
detect_ollama,
list_soup_models,
remove_model,
validate_gguf_path,
validate_model_name,
)
# --- List mode ---
if list_models:
version = detect_ollama()
if not version:
console.print("[red]Ollama not found.[/] Install from https://ollama.com")
raise typer.Exit(1)
models = list_soup_models()
if not models:
console.print("[yellow]No Soup-deployed models found in Ollama.[/]")
console.print("[dim]Deploy a model with: soup deploy ollama --model <gguf>[/]")
raise typer.Exit(0)
table = Table(title="Soup Models in Ollama")
table.add_column("Name", style="bold cyan")
table.add_column("Size", style="green")
for entry in models:
table.add_row(entry["name"], entry["size"])
console.print(table)
raise typer.Exit(0)
# --- Remove mode ---
if remove:
version = detect_ollama()
if not version:
console.print("[red]Ollama not found.[/] Install from https://ollama.com")
raise typer.Exit(1)
if not yes:
confirm = typer.confirm(f"Remove model '{remove}' from Ollama?")
if not confirm:
raise typer.Exit(0)
success, message = remove_model(remove)
if success:
console.print(f"[green]{message}[/]")
else:
console.print(f"[red]{message}[/]")
raise typer.Exit(1)
raise typer.Exit(0)
# --- Deploy mode: require --model and --name ---
if not model:
console.print("[red]--model is required for deploy.[/]")
console.print("[dim]Usage: soup deploy ollama --model <gguf> --name <name>[/]")
raise typer.Exit(1)
if not name:
console.print("[red]--name is required for deploy.[/]")
console.print("[dim]Usage: soup deploy ollama --model <gguf> --name <name>[/]")
raise typer.Exit(1)
# Validate model name
valid_name, name_err = validate_model_name(name)
if not valid_name:
console.print(f"[red]Invalid model name:[/] {name_err}")
raise typer.Exit(1)
# Validate GGUF path
gguf_path = Path(model)
valid_path, path_err = validate_gguf_path(gguf_path)
if not valid_path:
console.print(f"[red]{path_err}[/]")
raise typer.Exit(1)
# Check Ollama is installed
version = detect_ollama()
if not version:
console.print(
"[red]Ollama not found.[/]\n"
"Install from: [bold]https://ollama.com[/]"
)
raise typer.Exit(1)
# Resolve template
resolved_template = None
if template == "auto":
# Try to infer from soup.yaml in cwd
resolved_template = _auto_detect_template()
if not resolved_template:
resolved_template = "chatml" # Default fallback
elif template in OLLAMA_TEMPLATES:
resolved_template = template
else:
console.print(
f"[red]Unknown template: {template}[/]\n"
f"Available: auto, {', '.join(OLLAMA_TEMPLATES.keys())}"
)
raise typer.Exit(1)
# Parse parameters
params = {}
if parameter:
for param_str in parameter:
if "=" not in param_str:
console.print(f"[red]Invalid parameter format: {param_str}[/]")
console.print("[dim]Expected format: key=value (e.g. temperature=0.7)[/]")
raise typer.Exit(1)
key, value = param_str.split("=", 1)
params[key.strip()] = value.strip()
# Show deploy plan
console.print(
Panel(
f"Model: [bold]{name}[/]\n"
f"GGUF: [bold]{gguf_path}[/]\n"
f"Template: [bold]{resolved_template}[/]"
+ (f"\nSystem: [bold]{system}[/]" if system else "")
+ (f"\nParams: [bold]{params}[/]" if params else ""),
title="Deploy to Ollama",
)
)
# Confirmation — warn that this overwrites an existing model
if not yes:
console.print(
"[yellow]Warning:[/] This will overwrite any existing Ollama model "
f"named '{name}'."
)
confirm = typer.confirm("Proceed?")
if not confirm:
raise typer.Exit(0)
# Generate Modelfile
console.print(f"[green]\u2713[/] Ollama v{version} detected")
try:
modelfile = create_modelfile(
gguf_path=gguf_path,
template=resolved_template,
system_prompt=system,
parameters=params,
)
except ValueError as exc:
console.print(f"[red]Invalid parameter:[/] {exc}")
raise typer.Exit(1)
console.print("[green]\u2713[/] Modelfile generated")
# Deploy
console.print("[dim]Creating model in Ollama...[/]")
success, message = deploy_to_ollama(name, modelfile)
if not success:
console.print(f"[red]Deploy failed:[/] {message}")
raise typer.Exit(1)
console.print(f"[green]\u2713[/] Model created: [bold]{name}[/]")
console.print(
Panel(
f"Run: [bold]ollama run {name}[/]",
title="[bold green]Deploy Complete![/]",
)
)
def _auto_detect_template() -> Optional[str]:
"""Try to infer chat template from soup.yaml in cwd."""
from soup_cli.utils.ollama import infer_chat_template
config_path = Path("soup.yaml")
if not config_path.exists():
return None
try:
import yaml
with open(config_path, encoding="utf-8") as fh:
config = yaml.safe_load(fh)
if not isinstance(config, dict):
return None
data_section = config.get("data", {})
if isinstance(data_section, dict):
fmt = data_section.get("format")
return infer_chat_template(fmt)
except (yaml.YAMLError, OSError, KeyError, ImportError):
return None
return None

View File

@ -61,6 +61,16 @@ def export(
"--onnx-task",
help="ONNX export task: text-generation (causal LM) or feature-extraction (embedding)",
),
deploy: Optional[str] = typer.Option(
None,
"--deploy",
help="Auto-deploy after export. Currently supported: ollama",
),
deploy_name: Optional[str] = typer.Option(
None,
"--deploy-name",
help="Model name for deployment (used with --deploy)",
),
):
"""Export a model to GGUF, ONNX, or TensorRT-LLM format."""
model_path = Path(model)
@ -191,6 +201,10 @@ def export(
)
)
# --- Auto-deploy to Ollama if requested ---
if deploy:
_auto_deploy_ollama(output_path, model_name, deploy, deploy_name)
def _detect_base_model(adapter_config_path: Path) -> Optional[str]:
"""Read base_model_name_or_path from adapter_config.json."""
@ -552,6 +566,55 @@ def _export_tensorrt(model_path: Path, output: Optional[str], base: Optional[str
)
def _auto_deploy_ollama(
output_path: Path, model_name: str, deploy_target: str, deploy_name: Optional[str]
):
"""Auto-deploy a GGUF file to Ollama after export."""
if deploy_target != "ollama":
console.print(
f"[red]Unsupported deploy target: {deploy_target}[/]\n"
"Supported: ollama"
)
raise typer.Exit(1)
from soup_cli.utils.ollama import (
create_modelfile,
deploy_to_ollama,
detect_ollama,
validate_model_name,
)
ollama_name = deploy_name or f"soup-{model_name}"
valid, err = validate_model_name(ollama_name)
if not valid:
console.print(f"[red]Invalid deploy name:[/] {err}")
raise typer.Exit(1)
version = detect_ollama()
if not version:
console.print(
"[red]Ollama not found — skipping deploy.[/]\n"
"Install from: [bold]https://ollama.com[/]"
)
raise typer.Exit(1)
console.print(f"\n[green]✓[/] Ollama v{version} detected — deploying as [bold]{ollama_name}[/]")
console.print(
"[yellow]Warning:[/] This will overwrite any existing Ollama model "
f"named '{ollama_name}'."
)
modelfile = create_modelfile(gguf_path=output_path, template="chatml")
success, message = deploy_to_ollama(ollama_name, modelfile)
if not success:
console.print(f"[red]Deploy failed:[/] {message}")
raise typer.Exit(1)
console.print(f"[green]✓[/] Deployed to Ollama: [bold]{ollama_name}[/]")
console.print(f"Run: [bold]ollama run {ollama_name}[/]")
def _format_size(size_bytes: int) -> str:
"""Format bytes into human-readable string."""
value: float = float(size_bytes)

292
soup_cli/utils/ollama.py Normal file
View File

@ -0,0 +1,292 @@
"""Ollama integration utilities — detect, deploy, list, remove models."""
import re
import subprocess
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# Ollama TEMPLATE blocks for common chat formats
OLLAMA_TEMPLATES: Dict[str, str] = {
"chatml": (
'{{ if .System }}<|im_start|>system\n'
'{{ .System }}<|im_end|>\n'
'{{ end }}{{ range .Messages }}'
'{{ if eq .Role "user" }}<|im_start|>user\n'
'{{ .Content }}<|im_end|>\n'
'{{ else if eq .Role "assistant" }}<|im_start|>assistant\n'
'{{ .Content }}<|im_end|>\n'
'{{ end }}{{ end }}<|im_start|>assistant\n'
),
"llama": (
'<|begin_of_text|>'
'{{ if .System }}<|start_header_id|>system<|end_header_id|>\n\n'
'{{ .System }}<|eot_id|>{{ end }}'
'{{ range .Messages }}'
'{{ if eq .Role "user" }}<|start_header_id|>user<|end_header_id|>\n\n'
'{{ .Content }}<|eot_id|>'
'{{ else if eq .Role "assistant" }}<|start_header_id|>assistant<|end_header_id|>\n\n'
'{{ .Content }}<|eot_id|>'
'{{ end }}{{ end }}'
'<|start_header_id|>assistant<|end_header_id|>\n\n'
),
"mistral": (
'{{ if .System }}[INST] {{ .System }} [/INST]\n{{ end }}'
'{{ range .Messages }}'
'{{ if eq .Role "user" }}[INST] {{ .Content }} [/INST]\n'
'{{ else if eq .Role "assistant" }}{{ .Content }}</s>\n'
'{{ end }}{{ end }}'
),
"vicuna": (
'{{ if .System }}{{ .System }}\n\n{{ end }}'
'{{ range .Messages }}'
'{{ if eq .Role "user" }}USER: {{ .Content }}\n'
'{{ else if eq .Role "assistant" }}ASSISTANT: {{ .Content }}</s>\n'
'{{ end }}{{ end }}ASSISTANT:'
),
"zephyr": (
'{{ if .System }}<|system|>\n{{ .System }}</s>\n{{ end }}'
'{{ range .Messages }}'
'{{ if eq .Role "user" }}<|user|>\n{{ .Content }}</s>\n'
'{{ else if eq .Role "assistant" }}<|assistant|>\n{{ .Content }}</s>\n'
'{{ end }}{{ end }}<|assistant|>\n'
),
}
# Map Soup data formats to Ollama template names
FORMAT_TO_TEMPLATE: Dict[str, str] = {
"chatml": "chatml",
"alpaca": "llama",
"llama": "llama",
"mistral": "mistral",
"vicuna": "vicuna",
"zephyr": "zephyr",
"sharegpt": "chatml",
}
# Valid model name pattern: alphanumeric, hyphens, underscores, colons (for tags)
_MODEL_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._:-]*$")
# Soup-deployed model prefix
SOUP_MODEL_PREFIX = "soup-"
# Allowed Ollama PARAMETER keys (prevents injection of arbitrary directives)
ALLOWED_OLLAMA_PARAMS = frozenset({
"temperature", "top_p", "top_k", "num_ctx", "num_predict",
"stop", "repeat_penalty", "repeat_last_n", "seed", "mirostat",
"mirostat_tau", "mirostat_eta", "num_gpu", "num_thread",
"tfs_z", "typical_p", "penalize_newline",
})
def validate_model_name(name: str) -> Tuple[bool, str]:
"""Validate Ollama model name.
Returns (is_valid, error_message).
"""
if not name:
return False, "Model name cannot be empty"
if len(name) > 128:
return False, "Model name too long (max 128 characters)"
# Block path separators and null bytes
if "/" in name or "\\" in name or "\0" in name:
return False, "Model name must not contain path separators or null bytes"
if not _MODEL_NAME_RE.match(name):
return False, (
"Model name must start with alphanumeric and contain only "
"alphanumeric, hyphens, underscores, dots, or colons"
)
return True, ""
def validate_gguf_path(gguf_path: Path) -> Tuple[bool, str]:
"""Validate GGUF file path — must exist and stay under cwd.
Returns (is_valid, error_message).
"""
if not gguf_path.exists():
return False, f"GGUF file not found: {gguf_path}"
if not gguf_path.is_file():
return False, f"GGUF path is not a file: {gguf_path}"
if gguf_path.suffix.lower() != ".gguf":
return False, "File must have a .gguf extension"
# Path traversal protection: resolve and check it's under cwd
resolved = gguf_path.resolve()
cwd = Path.cwd().resolve()
try:
resolved.relative_to(cwd)
except ValueError:
return False, "GGUF path must be under the current working directory"
return True, ""
def detect_ollama() -> Optional[str]:
"""Check if Ollama is installed, return version string or None."""
try:
result = subprocess.run(
["ollama", "--version"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
output = result.stdout.strip() or result.stderr.strip()
# Extract version from output like "ollama version is 0.6.2"
match = re.search(r"(\d+\.\d+\.\d+)", output)
if match:
return match.group(1)
return output if output else "unknown"
return None
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return None
def infer_chat_template(config_format: Optional[str]) -> Optional[str]:
"""Map a Soup config format to an Ollama template name.
Returns template name (key into OLLAMA_TEMPLATES) or None if unknown.
"""
if not config_format:
return None
return FORMAT_TO_TEMPLATE.get(config_format.lower())
def create_modelfile(
gguf_path: Path,
template: Optional[str] = None,
system_prompt: Optional[str] = None,
parameters: Optional[Dict[str, str]] = None,
) -> str:
"""Generate Ollama Modelfile content.
Args:
gguf_path: Path to the GGUF model file.
template: Template name (key in OLLAMA_TEMPLATES) or raw template string.
system_prompt: Optional system prompt.
parameters: Optional dict of PARAMETER key=value pairs.
Returns:
Modelfile content as string.
"""
lines = [f"FROM {gguf_path}"]
# Template
if template:
template_content = OLLAMA_TEMPLATES.get(template, template)
lines.append(f'TEMPLATE """{template_content}"""')
# System prompt
if system_prompt:
# Escape double quotes and strip newlines in system prompt
escaped = system_prompt.replace('"', '\\"')
escaped = escaped.replace("\n", " ").replace("\r", "")
lines.append(f'SYSTEM "{escaped}"')
# Parameters — validated against allowlist, no control chars
if parameters:
for key, value in sorted(parameters.items()):
if key not in ALLOWED_OLLAMA_PARAMS:
raise ValueError(
f"Unknown Ollama parameter: {key!r}. "
f"Allowed: {', '.join(sorted(ALLOWED_OLLAMA_PARAMS))}"
)
if "\n" in value or "\r" in value or "\0" in value:
raise ValueError(
f"Parameter value for {key!r} contains "
"illegal characters (newline or null)"
)
lines.append(f"PARAMETER {key} {value}")
return "\n\n".join(lines) + "\n"
def deploy_to_ollama(name: str, modelfile_content: str) -> Tuple[bool, str]:
"""Deploy a model to Ollama via `ollama create`.
Args:
name: Model name for Ollama.
modelfile_content: Full Modelfile content.
Returns:
(success, message) tuple.
"""
import tempfile
with tempfile.NamedTemporaryFile(
mode="w", suffix=".Modelfile", delete=False, encoding="utf-8"
) as tmp:
tmp.write(modelfile_content)
tmp_path = tmp.name
try:
result = subprocess.run(
["ollama", "create", name, "-f", tmp_path],
capture_output=True,
text=True,
timeout=300,
)
if result.returncode == 0:
return True, result.stdout.strip() or "Model created successfully"
return False, result.stderr.strip() or "ollama create failed"
except FileNotFoundError:
return False, "Ollama not found. Install from https://ollama.com"
except subprocess.TimeoutExpired:
return False, "ollama create timed out (5 minutes)"
except OSError as exc:
return False, f"Failed to run ollama: {exc}"
finally:
Path(tmp_path).unlink(missing_ok=True)
def list_soup_models() -> List[Dict[str, str]]:
"""List Ollama models deployed by Soup (prefixed with soup-).
Returns list of dicts with 'name' and 'size' keys.
"""
try:
result = subprocess.run(
["ollama", "list"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
return []
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return []
models = []
for line in result.stdout.strip().splitlines()[1:]: # Skip header
parts = line.split()
if not parts:
continue
model_name = parts[0]
# Filter to soup- prefixed models
if model_name.startswith(SOUP_MODEL_PREFIX) or (
":" in model_name and model_name.split(":")[0].startswith(SOUP_MODEL_PREFIX)
):
size = parts[2] + " " + parts[3] if len(parts) >= 4 else "unknown"
models.append({"name": model_name, "size": size})
return models
def remove_model(name: str) -> Tuple[bool, str]:
"""Remove a model from Ollama.
Returns (success, message) tuple.
"""
try:
result = subprocess.run(
["ollama", "rm", name],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
return True, result.stdout.strip() or f"Model '{name}' removed"
return False, result.stderr.strip() or f"Failed to remove '{name}'"
except FileNotFoundError:
return False, "Ollama not found. Install from https://ollama.com"
except subprocess.TimeoutExpired:
return False, "ollama rm timed out"
except OSError as exc:
return False, f"Failed to run ollama: {exc}"

728
tests/test_deploy_ollama.py Normal file
View File

@ -0,0 +1,728 @@
"""Tests for soup deploy ollama command and Ollama utilities."""
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import click
import pytest
from typer.testing import CliRunner
from soup_cli.cli import app
from soup_cli.utils.ollama import (
ALLOWED_OLLAMA_PARAMS,
FORMAT_TO_TEMPLATE,
OLLAMA_TEMPLATES,
SOUP_MODEL_PREFIX,
create_modelfile,
deploy_to_ollama,
detect_ollama,
infer_chat_template,
list_soup_models,
remove_model,
validate_gguf_path,
validate_model_name,
)
runner = CliRunner()
# Patch targets — lazy imports in deploy.py resolve to soup_cli.utils.ollama
_OLLAMA = "soup_cli.utils.ollama"
# ─── validate_model_name ───
def test_validate_model_name_valid():
valid, err = validate_model_name("my-model")
assert valid is True
assert err == ""
def test_validate_model_name_with_colon():
valid, err = validate_model_name("soup-model:latest")
assert valid is True
def test_validate_model_name_with_dots():
valid, err = validate_model_name("my.model.v2")
assert valid is True
def test_validate_model_name_empty():
valid, err = validate_model_name("")
assert valid is False
assert "empty" in err.lower()
def test_validate_model_name_too_long():
valid, err = validate_model_name("a" * 129)
assert valid is False
assert "long" in err.lower()
def test_validate_model_name_with_slash():
valid, err = validate_model_name("bad/name")
assert valid is False
assert "path separator" in err.lower()
def test_validate_model_name_with_backslash():
valid, err = validate_model_name("bad\\name")
assert valid is False
assert "path separator" in err.lower()
def test_validate_model_name_with_null():
valid, err = validate_model_name("bad\0name")
assert valid is False
assert "null" in err.lower()
def test_validate_model_name_starts_with_hyphen():
valid, err = validate_model_name("-bad")
assert valid is False
assert "alphanumeric" in err.lower()
# ─── validate_gguf_path ───
def test_validate_gguf_path_exists(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
gguf = tmp_path / "model.gguf"
gguf.write_bytes(b"fake gguf")
valid, err = validate_gguf_path(gguf)
assert valid is True
assert err == ""
def test_validate_gguf_path_not_exists(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
valid, err = validate_gguf_path(tmp_path / "missing.gguf")
assert valid is False
assert "not found" in err.lower()
def test_validate_gguf_path_is_directory(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
valid, err = validate_gguf_path(tmp_path)
assert valid is False
assert "not a file" in err.lower()
def test_validate_gguf_path_bad_extension(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
bad = tmp_path / "model.bin"
bad.write_bytes(b"fake")
valid, err = validate_gguf_path(bad)
assert valid is False
assert ".gguf" in err.lower()
def test_validate_gguf_path_traversal(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
outside = tmp_path.parent / "outside.gguf"
outside.write_bytes(b"fake")
try:
valid, err = validate_gguf_path(outside)
assert valid is False
assert "current working directory" in err.lower()
finally:
outside.unlink(missing_ok=True)
# ─── detect_ollama ───
@patch(f"{_OLLAMA}.subprocess.run")
def test_detect_ollama_installed(mock_run):
mock_run.return_value = MagicMock(
returncode=0, stdout="ollama version is 0.6.2", stderr=""
)
version = detect_ollama()
assert version == "0.6.2"
@patch(f"{_OLLAMA}.subprocess.run")
def test_detect_ollama_not_installed(mock_run):
mock_run.side_effect = FileNotFoundError
assert detect_ollama() is None
@patch(f"{_OLLAMA}.subprocess.run")
def test_detect_ollama_timeout(mock_run):
mock_run.side_effect = subprocess.TimeoutExpired(cmd="ollama", timeout=10)
assert detect_ollama() is None
@patch(f"{_OLLAMA}.subprocess.run")
def test_detect_ollama_nonzero(mock_run):
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
assert detect_ollama() is None
@patch(f"{_OLLAMA}.subprocess.run")
def test_detect_ollama_no_version_match(mock_run):
mock_run.return_value = MagicMock(
returncode=0, stdout="ollama unknown", stderr=""
)
version = detect_ollama()
assert version == "ollama unknown"
# ─── infer_chat_template ───
def test_infer_chatml():
assert infer_chat_template("chatml") == "chatml"
def test_infer_alpaca():
assert infer_chat_template("alpaca") == "llama"
def test_infer_sharegpt():
assert infer_chat_template("sharegpt") == "chatml"
def test_infer_mistral():
assert infer_chat_template("mistral") == "mistral"
def test_infer_unknown():
assert infer_chat_template("xyz") is None
def test_infer_none():
assert infer_chat_template(None) is None
def test_infer_case_insensitive():
assert infer_chat_template("ChatML") == "chatml"
# ─── create_modelfile ───
def test_create_modelfile_basic():
result = create_modelfile(Path("model.gguf"))
assert "FROM" in result
assert "model.gguf" in result
assert "TEMPLATE" not in result
def test_create_modelfile_with_template():
result = create_modelfile(Path("model.gguf"), template="chatml")
assert "model.gguf" in result
assert "TEMPLATE" in result
assert "im_start" in result
def test_create_modelfile_with_system():
result = create_modelfile(Path("m.gguf"), system_prompt="You are helpful.")
assert 'SYSTEM "You are helpful."' in result
def test_create_modelfile_with_system_quotes():
result = create_modelfile(Path("m.gguf"), system_prompt='Say "hello"')
assert 'SYSTEM "Say \\"hello\\""' in result
def test_create_modelfile_with_params():
result = create_modelfile(
Path("m.gguf"), parameters={"temperature": "0.7", "top_p": "0.9"}
)
assert "PARAMETER temperature 0.7" in result
assert "PARAMETER top_p 0.9" in result
def test_create_modelfile_full():
result = create_modelfile(
Path("out") / "model.gguf",
template="llama",
system_prompt="Be concise.",
parameters={"temperature": "0.5"},
)
assert "FROM" in result
assert "model.gguf" in result
assert "TEMPLATE" in result
assert "begin_of_text" in result
assert 'SYSTEM "Be concise."' in result
assert "PARAMETER temperature 0.5" in result
def test_create_modelfile_custom_template():
custom = "{{ .Prompt }}"
result = create_modelfile(Path("m.gguf"), template=custom)
assert custom in result
def test_create_modelfile_rejects_unknown_param():
with pytest.raises(ValueError, match="Unknown Ollama parameter"):
create_modelfile(Path("m.gguf"), parameters={"evil_key": "value"})
def test_create_modelfile_rejects_newline_in_param_value():
with pytest.raises(ValueError, match="illegal characters"):
create_modelfile(
Path("m.gguf"), parameters={"temperature": "0.7\nSYSTEM injected"}
)
def test_create_modelfile_allows_valid_params():
result = create_modelfile(
Path("m.gguf"),
parameters={"temperature": "0.7", "top_k": "40", "seed": "42"},
)
assert "PARAMETER temperature 0.7" in result
assert "PARAMETER top_k 40" in result
assert "PARAMETER seed 42" in result
# ─── deploy_to_ollama ───
@patch(f"{_OLLAMA}.subprocess.run")
def test_deploy_to_ollama_success(mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout="success")
success, msg = deploy_to_ollama("test-model", "FROM m.gguf\n")
assert success is True
assert "success" in msg
@patch(f"{_OLLAMA}.subprocess.run")
def test_deploy_to_ollama_failure(mock_run):
mock_run.return_value = MagicMock(returncode=1, stderr="error creating model")
success, msg = deploy_to_ollama("test-model", "FROM m.gguf\n")
assert success is False
assert "error" in msg.lower()
@patch(f"{_OLLAMA}.subprocess.run")
def test_deploy_to_ollama_not_found(mock_run):
mock_run.side_effect = FileNotFoundError
success, msg = deploy_to_ollama("test-model", "FROM m.gguf\n")
assert success is False
assert "not found" in msg.lower()
@patch(f"{_OLLAMA}.subprocess.run")
def test_deploy_to_ollama_timeout(mock_run):
mock_run.side_effect = subprocess.TimeoutExpired(cmd="ollama", timeout=300)
success, msg = deploy_to_ollama("test-model", "FROM m.gguf\n")
assert success is False
assert "timed out" in msg.lower()
# ─── list_soup_models ───
@patch(f"{_OLLAMA}.subprocess.run")
def test_list_soup_models_found(mock_run):
mock_run.return_value = MagicMock(
returncode=0,
stdout=(
"NAME ID SIZE MODIFIED\n"
"soup-my-model:latest abc123def456 4.1 GB 2 hours ago\n"
"llama3.1:latest xyz789abc123 8.0 GB 3 days ago\n"
"soup-code:latest def456ghi789 3.2 GB 1 day ago\n"
),
)
models = list_soup_models()
assert len(models) == 2
assert models[0]["name"] == "soup-my-model:latest"
assert models[1]["name"] == "soup-code:latest"
@patch(f"{_OLLAMA}.subprocess.run")
def test_list_soup_models_empty(mock_run):
mock_run.return_value = MagicMock(
returncode=0,
stdout="NAME ID SIZE MODIFIED\n",
)
assert list_soup_models() == []
@patch(f"{_OLLAMA}.subprocess.run")
def test_list_soup_models_ollama_not_found(mock_run):
mock_run.side_effect = FileNotFoundError
assert list_soup_models() == []
# ─── remove_model ───
@patch(f"{_OLLAMA}.subprocess.run")
def test_remove_model_success(mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout="deleted 'soup-test'")
success, msg = remove_model("soup-test")
assert success is True
@patch(f"{_OLLAMA}.subprocess.run")
def test_remove_model_failure(mock_run):
mock_run.return_value = MagicMock(returncode=1, stderr="model not found")
success, msg = remove_model("soup-test")
assert success is False
assert "not found" in msg.lower()
@patch(f"{_OLLAMA}.subprocess.run")
def test_remove_model_not_installed(mock_run):
mock_run.side_effect = FileNotFoundError
success, msg = remove_model("soup-test")
assert success is False
assert "not found" in msg.lower()
# ─── Constants ───
def test_ollama_templates_keys():
assert "chatml" in OLLAMA_TEMPLATES
assert "llama" in OLLAMA_TEMPLATES
assert "mistral" in OLLAMA_TEMPLATES
assert "vicuna" in OLLAMA_TEMPLATES
assert "zephyr" in OLLAMA_TEMPLATES
def test_format_to_template_mapping():
assert FORMAT_TO_TEMPLATE["chatml"] == "chatml"
assert FORMAT_TO_TEMPLATE["alpaca"] == "llama"
assert FORMAT_TO_TEMPLATE["sharegpt"] == "chatml"
def test_soup_model_prefix():
assert SOUP_MODEL_PREFIX == "soup-"
def test_allowed_ollama_params():
assert "temperature" in ALLOWED_OLLAMA_PARAMS
assert "top_p" in ALLOWED_OLLAMA_PARAMS
assert "seed" in ALLOWED_OLLAMA_PARAMS
assert len(ALLOWED_OLLAMA_PARAMS) >= 10
# ─── CLI: soup deploy ollama --help ───
def test_deploy_ollama_help():
result = runner.invoke(app, ["deploy", "ollama", "--help"])
assert result.exit_code == 0
assert "ollama" in result.output.lower()
assert "--model" in result.output
assert "--name" in result.output
def test_deploy_help():
result = runner.invoke(app, ["deploy", "--help"])
assert result.exit_code == 0
assert "ollama" in result.output.lower()
# ─── CLI: soup deploy ollama --list ───
@patch(f"{_OLLAMA}.detect_ollama", return_value="0.6.2")
@patch(f"{_OLLAMA}.list_soup_models", return_value=[])
def test_deploy_list_empty(mock_list, mock_detect):
result = runner.invoke(app, ["deploy", "ollama", "--list"])
assert result.exit_code == 0
assert "no soup-deployed" in result.output.lower()
@patch(f"{_OLLAMA}.detect_ollama", return_value="0.6.2")
@patch(
f"{_OLLAMA}.list_soup_models",
return_value=[{"name": "soup-test:latest", "size": "4.1 GB"}],
)
def test_deploy_list_with_models(mock_list, mock_detect):
result = runner.invoke(app, ["deploy", "ollama", "--list"])
assert result.exit_code == 0
assert "soup-test" in result.output
@patch(f"{_OLLAMA}.detect_ollama", return_value=None)
def test_deploy_list_no_ollama(mock_detect):
result = runner.invoke(app, ["deploy", "ollama", "--list"])
assert result.exit_code == 1
assert "not found" in result.output.lower()
# ─── CLI: soup deploy ollama --remove ───
@patch(f"{_OLLAMA}.detect_ollama", return_value="0.6.2")
@patch(f"{_OLLAMA}.remove_model", return_value=(True, "deleted"))
def test_deploy_remove_success(mock_rm, mock_detect):
result = runner.invoke(app, ["deploy", "ollama", "--remove", "soup-test", "--yes"])
assert result.exit_code == 0
assert "deleted" in result.output.lower()
@patch(f"{_OLLAMA}.detect_ollama", return_value="0.6.2")
@patch(f"{_OLLAMA}.remove_model", return_value=(False, "not found"))
def test_deploy_remove_failure(mock_rm, mock_detect):
result = runner.invoke(app, ["deploy", "ollama", "--remove", "soup-bad", "--yes"])
assert result.exit_code == 1
assert "not found" in result.output.lower()
@patch(f"{_OLLAMA}.detect_ollama", return_value=None)
def test_deploy_remove_no_ollama(mock_detect):
result = runner.invoke(app, ["deploy", "ollama", "--remove", "soup-test", "--yes"])
assert result.exit_code == 1
# ─── CLI: soup deploy ollama (deploy mode) ───
def test_deploy_missing_model():
result = runner.invoke(app, ["deploy", "ollama"])
assert result.exit_code == 1
assert "--model" in result.output
def test_deploy_missing_name(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
gguf = tmp_path / "model.gguf"
gguf.write_bytes(b"fake")
result = runner.invoke(app, ["deploy", "ollama", "--model", str(gguf)])
assert result.exit_code == 1
assert "--name" in result.output
def test_deploy_invalid_name(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
gguf = tmp_path / "model.gguf"
gguf.write_bytes(b"fake")
result = runner.invoke(
app, ["deploy", "ollama", "--model", str(gguf), "--name", "bad/name"]
)
assert result.exit_code == 1
assert "invalid" in result.output.lower()
def test_deploy_gguf_not_found(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
result = runner.invoke(
app, ["deploy", "ollama", "--model", "missing.gguf", "--name", "soup-test"]
)
assert result.exit_code == 1
assert "not found" in result.output.lower()
@patch(f"{_OLLAMA}.detect_ollama", return_value=None)
def test_deploy_ollama_not_installed(mock_detect, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
gguf = tmp_path / "model.gguf"
gguf.write_bytes(b"fake")
result = runner.invoke(
app,
["deploy", "ollama", "--model", str(gguf), "--name", "soup-test"],
)
assert result.exit_code == 1
assert "not found" in result.output.lower()
@patch(f"{_OLLAMA}.detect_ollama", return_value="0.6.2")
def test_deploy_invalid_template(mock_detect, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
gguf = tmp_path / "model.gguf"
gguf.write_bytes(b"fake")
result = runner.invoke(
app,
[
"deploy", "ollama",
"--model", str(gguf),
"--name", "soup-test",
"--template", "nonexistent",
],
)
assert result.exit_code == 1
assert "unknown template" in result.output.lower()
@patch(f"{_OLLAMA}.detect_ollama", return_value="0.6.2")
def test_deploy_bad_parameter_format(mock_detect, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
gguf = tmp_path / "model.gguf"
gguf.write_bytes(b"fake")
result = runner.invoke(
app,
[
"deploy", "ollama",
"--model", str(gguf),
"--name", "soup-test",
"--template", "chatml",
"--parameter", "bad_no_equals",
"--yes",
],
)
assert result.exit_code == 1
assert "invalid parameter" in result.output.lower()
@patch(f"{_OLLAMA}.detect_ollama", return_value="0.6.2")
def test_deploy_unknown_parameter_key(mock_detect, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
gguf = tmp_path / "model.gguf"
gguf.write_bytes(b"fake")
result = runner.invoke(
app,
[
"deploy", "ollama",
"--model", str(gguf),
"--name", "soup-test",
"--template", "chatml",
"--parameter", "evil_key=value",
"--yes",
],
)
assert result.exit_code == 1
assert "invalid parameter" in result.output.lower()
@patch(f"{_OLLAMA}.deploy_to_ollama", return_value=(True, "created"))
@patch(f"{_OLLAMA}.detect_ollama", return_value="0.6.2")
def test_deploy_full_success(mock_detect, mock_deploy, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
gguf = tmp_path / "model.gguf"
gguf.write_bytes(b"fake gguf data")
result = runner.invoke(
app,
[
"deploy", "ollama",
"--model", str(gguf),
"--name", "soup-test",
"--template", "chatml",
"--system", "You are helpful.",
"--parameter", "temperature=0.7",
"--yes",
],
)
assert result.exit_code == 0
assert "soup-test" in result.output
assert "deploy complete" in result.output.lower()
@patch(f"{_OLLAMA}.deploy_to_ollama", return_value=(False, "disk full"))
@patch(f"{_OLLAMA}.detect_ollama", return_value="0.6.2")
def test_deploy_create_fails(mock_detect, mock_deploy, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
gguf = tmp_path / "model.gguf"
gguf.write_bytes(b"fake gguf data")
result = runner.invoke(
app,
[
"deploy", "ollama",
"--model", str(gguf),
"--name", "soup-test",
"--template", "chatml",
"--yes",
],
)
assert result.exit_code == 1
assert "disk full" in result.output.lower()
# ─── CLI: soup export --deploy ───
def test_export_deploy_flag_in_help():
result = runner.invoke(app, ["export", "--help"])
assert result.exit_code == 0
assert "--deploy" in result.output
# ─── _auto_detect_template ───
def test_auto_detect_from_soup_yaml(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "soup.yaml").write_text(
"base: test\ndata:\n train: data.jsonl\n format: chatml\n",
encoding="utf-8",
)
from soup_cli.commands.deploy import _auto_detect_template
result = _auto_detect_template()
assert result == "chatml"
def test_auto_detect_no_soup_yaml(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
from soup_cli.commands.deploy import _auto_detect_template
result = _auto_detect_template()
assert result is None
def test_auto_detect_invalid_yaml(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "soup.yaml").write_text(":::invalid", encoding="utf-8")
from soup_cli.commands.deploy import _auto_detect_template
result = _auto_detect_template()
assert result is None
def test_auto_detect_no_format(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "soup.yaml").write_text(
"base: test\ndata:\n train: data.jsonl\n",
encoding="utf-8",
)
from soup_cli.commands.deploy import _auto_detect_template
result = _auto_detect_template()
assert result is None
# ─── Export _auto_deploy_ollama ───
def test_export_deploy_unsupported_target():
"""_auto_deploy_ollama rejects non-ollama targets."""
from soup_cli.commands.export import _auto_deploy_ollama
with pytest.raises((SystemExit, click.exceptions.Exit)):
_auto_deploy_ollama(Path("m.gguf"), "model", "kubernetes", None)
@patch(f"{_OLLAMA}.detect_ollama", return_value=None)
def test_export_deploy_ollama_not_found(mock_detect):
"""_auto_deploy_ollama exits if Ollama not installed."""
from soup_cli.commands.export import _auto_deploy_ollama
with pytest.raises((SystemExit, click.exceptions.Exit)):
_auto_deploy_ollama(Path("m.gguf"), "model", "ollama", None)
@patch(f"{_OLLAMA}.deploy_to_ollama", return_value=(True, "ok"))
@patch(f"{_OLLAMA}.detect_ollama", return_value="0.6.2")
def test_export_deploy_ollama_success(mock_detect, mock_deploy):
"""_auto_deploy_ollama succeeds with valid inputs."""
from soup_cli.commands.export import _auto_deploy_ollama
# Should not raise
_auto_deploy_ollama(Path("model.gguf"), "mymodel", "ollama", "soup-mymodel")
@patch(f"{_OLLAMA}.deploy_to_ollama", return_value=(False, "fail"))
@patch(f"{_OLLAMA}.detect_ollama", return_value="0.6.2")
def test_export_deploy_ollama_create_fails(mock_detect, mock_deploy):
"""_auto_deploy_ollama exits on deploy failure."""
from soup_cli.commands.export import _auto_deploy_ollama
with pytest.raises((SystemExit, click.exceptions.Exit)):
_auto_deploy_ollama(Path("m.gguf"), "model", "ollama", "soup-model")
def test_export_deploy_invalid_name():
"""_auto_deploy_ollama rejects invalid model name."""
from soup_cli.commands.export import _auto_deploy_ollama
with pytest.raises((SystemExit, click.exceptions.Exit)):
_auto_deploy_ollama(Path("m.gguf"), "model", "ollama", "bad/name")