import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from llamacppctl.config import ConfigError, resolve_effective_config, builtin_defaults # noqa: E402 from llamacppctl.cli import build_parser, validate_args # noqa: E402 CONFIG_BASIC = """ [default] image = ghcr.io/ggml-org/llama.cpp:server-cuda hf_home = /srv/models model_path = qwen3/default.gguf container_name = test_default host_port = 8001 container_port = 8000 model_alias = default_llm gpu_device = 0 restart_policy = unless-stopped ctx_size = 262144 n_predict = 16384 temp = 0.65 top_p = 0.80 top_k = 20 min_p = 0.01 repeat_penalty = 1.05 main_gpu = 0 ngl = 999 fa = true kv_unified = true jinja = true reasoning = on no_context_shift = true cache_type_k = q4_0 cache_type_v = q4_0 batch_size = 1024 ubatch_size = 512 parallel = 1 cont_batching = true host = 0.0.0.0 health_endpoint = /health models_endpoint = /v1/models chat_endpoint = /v1/chat/completions timeout = 300 poll_interval = 2 [model.alt] container_name = test_alt host_port = 9001 model_alias = alt_llm [model.noname] host_port = 9002 [model.vision] container_name = test_vision mmproj = qwen3/mmproj.gguf [prompt.concise] system_prompt = Be brief. """ def _write_config(tmp_path: Path, content: str = CONFIG_BASIC) -> Path: p = tmp_path / "llama.cpp.config" p.write_text(content, encoding="utf-8") return p def _parse(argv): parser = build_parser() args = parser.parse_args(argv) validate_args(args, parser) args._resolved_system_prompt = None args._resolved_user_prompt = None return args def test_default_profile_resolves(tmp_path): cfg_path = _write_config(tmp_path) args = _parse(["--start", "--config", str(cfg_path)]) server_cfg, prompt_cfg = resolve_effective_config(args) assert server_cfg.container_name == "test_default" assert server_cfg.host_port == 8001 assert server_cfg.jinja is True assert server_cfg.reasoning == "on" def test_model_profile_overrides_default(tmp_path): cfg_path = _write_config(tmp_path) args = _parse(["--start", "--config", str(cfg_path), "--profile", "alt"]) server_cfg, _ = resolve_effective_config(args) assert server_cfg.container_name == "test_alt" assert server_cfg.host_port == 9001 assert server_cfg.model_alias == "alt_llm" # inherited from [default] assert server_cfg.image == "ghcr.io/ggml-org/llama.cpp:server-cuda" def test_missing_profile_raises(tmp_path): cfg_path = _write_config(tmp_path) args = _parse(["--start", "--config", str(cfg_path), "--profile", "does-not-exist"]) with pytest.raises(ConfigError): resolve_effective_config(args) def test_prompt_profile_provides_fallback_system_prompt(tmp_path): cfg_path = _write_config(tmp_path) args = _parse( ["--start", "--config", str(cfg_path), "--system-prompt-profile", "concise"] ) _, prompt_cfg = resolve_effective_config(args) assert prompt_cfg.system_prompt == "Be brief." def test_explicit_system_overrides_prompt_profile(tmp_path): cfg_path = _write_config(tmp_path) args = _parse( [ "--start", "--config", str(cfg_path), "--system-prompt-profile", "concise", "--system", "Explicit wins.", ] ) args._resolved_system_prompt = "Explicit wins." _, prompt_cfg = resolve_effective_config(args) assert prompt_cfg.system_prompt == "Explicit wins." def test_missing_container_name_raises(tmp_path): cfg_path = _write_config(tmp_path) args = _parse(["--start", "--config", str(cfg_path), "--profile", "noname"]) # [model.noname] has no container_name and [default] provides one, # so this should actually succeed by inheriting test_default. server_cfg, _ = resolve_effective_config(args) assert server_cfg.container_name == "test_default" def test_empty_container_name_via_cli_rejected_by_argparse(tmp_path): # cli.validate_args() already rejects a blank --container-name before # config resolution is ever reached (defense at the CLI layer). cfg_path = _write_config(tmp_path) with pytest.raises(SystemExit): _parse(["--start", "--config", str(cfg_path), "--container-name", " "]) def test_empty_container_name_from_config_raises(tmp_path): # container_name can only end up empty via the config file itself # (CLI-level blanks are already rejected by validate_args above). bad_config = CONFIG_BASIC.replace("container_name = test_default", "container_name =") cfg_path = _write_config(tmp_path, bad_config) args = _parse(["--start", "--config", str(cfg_path)]) with pytest.raises(ConfigError): resolve_effective_config(args) def test_cli_override_wins_over_config(tmp_path): cfg_path = _write_config(tmp_path) args = _parse( ["--start", "--config", str(cfg_path), "--host-port", "12345"] ) server_cfg, _ = resolve_effective_config(args) assert server_cfg.host_port == 12345 def test_invalid_boolean_raises(tmp_path): bad_config = CONFIG_BASIC.replace("jinja = true", "jinja = maybe") cfg_path = _write_config(tmp_path, bad_config) args = _parse(["--start", "--config", str(cfg_path)]) with pytest.raises(ConfigError): resolve_effective_config(args) def test_lock_file_derived_from_container_name(tmp_path): cfg_path = _write_config(tmp_path) args = _parse(["--start", "--config", str(cfg_path)]) server_cfg, _ = resolve_effective_config(args) assert str(server_cfg.lock_file) == "/tmp/llamacppctl.test_default.lock" def test_hf_home_expands_env_var(tmp_path, monkeypatch): monkeypatch.setenv("LLAMACPPCTL_TEST_HOME", "/data/models") cfg = CONFIG_BASIC.replace("hf_home = /srv/models", "hf_home = ${LLAMACPPCTL_TEST_HOME}/sub") cfg_path = _write_config(tmp_path, cfg) server_cfg, _ = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)])) assert str(server_cfg.hf_home) == "/data/models/sub" def test_hf_home_expands_tilde(tmp_path, monkeypatch): monkeypatch.setenv("HOME", "/home/tester") cfg = CONFIG_BASIC.replace("hf_home = /srv/models", "hf_home = ~/models") cfg_path = _write_config(tmp_path, cfg) server_cfg, _ = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)])) assert str(server_cfg.hf_home) == "/home/tester/models" def test_builtin_defaults_has_container_name(): defaults = builtin_defaults() assert defaults["container_name"] == "va_llm" def test_chat_params_default_and_defer_temperature(tmp_path): cfg_path = _write_config(tmp_path) args = _parse(["--start", "--config", str(cfg_path)]) _, prompt_cfg = resolve_effective_config(args) assert prompt_cfg.max_tokens == 2048 # built-in default assert prompt_cfg.temperature is None # empty chat_temperature => server temp def test_chat_params_from_config(tmp_path): cfg = CONFIG_BASIC.replace( "poll_interval = 2", "poll_interval = 2\nmax_tokens = 32768\nchat_temperature = 0.6", ) cfg_path = _write_config(tmp_path, cfg) args = _parse(["--start", "--config", str(cfg_path)]) _, prompt_cfg = resolve_effective_config(args) assert prompt_cfg.max_tokens == 32768 assert prompt_cfg.temperature == 0.6 def test_expose_and_api_key_default_and_config(tmp_path): cfg_path = _write_config(tmp_path) server_cfg, _ = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)])) assert server_cfg.expose is False # loopback-only default assert server_cfg.api_key == "" cfg = CONFIG_BASIC.replace( "host = 0.0.0.0", "host = 0.0.0.0\nexpose = true\napi_key = s3cr3t" ) cfg_path2 = _write_config(tmp_path, cfg) server_cfg, _ = resolve_effective_config(_parse(["--start", "--config", str(cfg_path2)])) assert server_cfg.expose is True assert server_cfg.api_key == "s3cr3t" def test_expose_cli_overrides_config(tmp_path): cfg = CONFIG_BASIC.replace("host = 0.0.0.0", "host = 0.0.0.0\nexpose = true") cfg_path = _write_config(tmp_path, cfg) server_cfg, _ = resolve_effective_config( _parse(["--start", "--config", str(cfg_path), "--no-expose"]) ) assert server_cfg.expose is False def test_chat_params_cli_overrides_config(tmp_path): cfg = CONFIG_BASIC.replace( "poll_interval = 2", "poll_interval = 2\nmax_tokens = 32768\nchat_temperature = 0.6", ) cfg_path = _write_config(tmp_path, cfg) args = _parse( ["--start", "--config", str(cfg_path), "--max-tokens", "512", "--chat-temp", "0.1"] ) _, prompt_cfg = resolve_effective_config(args) assert prompt_cfg.max_tokens == 512 assert prompt_cfg.temperature == 0.1 def test_stream_and_timeouts_default(tmp_path): cfg_path = _write_config(tmp_path) _, prompt_cfg = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)])) assert prompt_cfg.stream is False assert prompt_cfg.read_timeout == 600.0 # generous default for reasoning models assert prompt_cfg.connect_timeout == 10.0 def test_stream_and_read_timeout_from_config(tmp_path): cfg = CONFIG_BASIC.replace( "poll_interval = 2", "poll_interval = 2\nstream = true\nread_timeout = 900\nconnect_timeout = 5", ) cfg_path = _write_config(tmp_path, cfg) _, prompt_cfg = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)])) assert prompt_cfg.stream is True assert prompt_cfg.read_timeout == 900.0 assert prompt_cfg.connect_timeout == 5.0 def test_mmproj_empty_by_default(tmp_path): cfg_path = _write_config(tmp_path) server_cfg, _ = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)])) assert server_cfg.mmproj == "" assert server_cfg.mmproj_offload is True # llama.cpp offloads by default def test_mmproj_from_profile(tmp_path): cfg_path = _write_config(tmp_path) args = _parse(["--start", "--config", str(cfg_path), "--profile", "vision"]) server_cfg, _ = resolve_effective_config(args) assert server_cfg.mmproj == "qwen3/mmproj.gguf" assert server_cfg.container_name == "test_vision" def test_mmproj_cli_overrides_config(tmp_path): cfg_path = _write_config(tmp_path) args = _parse( [ "--start", "--config", str(cfg_path), "--profile", "vision", "--mmproj", "other/proj.gguf", "--no-mmproj-offload", ] ) server_cfg, _ = resolve_effective_config(args) assert server_cfg.mmproj == "other/proj.gguf" assert server_cfg.mmproj_offload is False def test_mmproj_offload_false_from_config(tmp_path): cfg = CONFIG_BASIC.replace( "[model.vision]\ncontainer_name = test_vision", "[model.vision]\ncontainer_name = test_vision\nmmproj_offload = false", ) cfg_path = _write_config(tmp_path, cfg) args = _parse(["--start", "--config", str(cfg_path), "--profile", "vision"]) server_cfg, _ = resolve_effective_config(args) assert server_cfg.mmproj_offload is False def test_blank_mmproj_via_cli_rejected(tmp_path): cfg_path = _write_config(tmp_path) with pytest.raises(SystemExit): _parse(["--start", "--config", str(cfg_path), "--mmproj", " "]) def test_stream_and_read_timeout_cli_overrides_config(tmp_path): cfg = CONFIG_BASIC.replace("poll_interval = 2", "poll_interval = 2\nread_timeout = 900") cfg_path = _write_config(tmp_path, cfg) # --stream forces streaming on; --read-timeout overrides the config value. args = _parse(["--chat", "-p", "hi", "--config", str(cfg_path), "--stream", "--read-timeout", "42"]) _, prompt_cfg = resolve_effective_config(args) assert prompt_cfg.stream is True assert prompt_cfg.read_timeout == 42.0