diff --git a/agent-mcp-servers/vlm-mcp/vlm_mcp_server/__main__.py b/agent-mcp-servers/vlm-mcp/vlm_mcp_server/__main__.py index fbaf5a84..6b1704d1 100644 --- a/agent-mcp-servers/vlm-mcp/vlm_mcp_server/__main__.py +++ b/agent-mcp-servers/vlm-mcp/vlm_mcp_server/__main__.py @@ -70,7 +70,7 @@ load_models_config_from_dict, make_vlm, ) -from xr_ai_models.config import KIND_OPENAI_COMPAT +from xr_ai_models.config import KIND_OPENAI_COMPAT, parse_bool from xr_ai_models.protocols import VLMService @@ -89,7 +89,7 @@ def _make_vlm_from_cfg(cfg: dict[str, Any]) -> tuple[VLMService, float]: models_block: dict[str, Any] | None = cfg.get("models") vlm_server: str | None = cfg.get("vlm_server") vlm_request_timeout_s = float(cfg.get("vlm_request_timeout_s", 60.0)) - enable_thinking = bool(cfg.get("enable_thinking", False)) + enable_thinking = parse_bool(cfg.get("enable_thinking", False), "enable_thinking") if models_block: vlm_entry = dict(models_block.get("vlm") or {}) diff --git a/agent-sdk/xr-ai-models/xr_ai_models/config.py b/agent-sdk/xr-ai-models/xr_ai_models/config.py index ab7d4870..eb22329d 100644 --- a/agent-sdk/xr-ai-models/xr_ai_models/config.py +++ b/agent-sdk/xr-ai-models/xr_ai_models/config.py @@ -25,6 +25,25 @@ KIND_OPENAI_COMPAT: ModelKind = "openai_compat" +_TRUE_BOOL_STRINGS = {"1", "true", "yes", "on"} +_FALSE_BOOL_STRINGS = {"0", "false", "no", "off"} + + +def parse_bool(value: object, key: str) -> bool: + """Parse a config boolean without treating every non-empty string as true.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in _TRUE_BOOL_STRINGS: + return True + if normalized in _FALSE_BOOL_STRINGS: + return False + raise ValueError( + f"{key} must be a boolean or one of " + f"{sorted(_TRUE_BOOL_STRINGS | _FALSE_BOOL_STRINGS)} (got {value!r})" + ) + @dataclass(frozen=True) class LLMSpec: @@ -171,7 +190,7 @@ def _construct(category: Category, body: dict[str, Any]) -> Spec: "base_url": _require_str(body, "base_url"), # Remote endpoints (e.g. hosted NIM) have no local /health route; set # ``health_check: false`` so the worker readiness gate doesn't block. - "health_check": bool(body.get("health_check", True)), + "health_check": parse_bool(body.get("health_check", True), "health_check"), } if "api_key_env" in body: common["api_key_env"] = body["api_key_env"] diff --git a/ai-services/llm/llama_nemotron/llama_nemotron_llm_server/__main__.py b/ai-services/llm/llama_nemotron/llama_nemotron_llm_server/__main__.py index fc98cf47..140862e3 100644 --- a/ai-services/llm/llama_nemotron/llama_nemotron_llm_server/__main__.py +++ b/ai-services/llm/llama_nemotron/llama_nemotron_llm_server/__main__.py @@ -38,6 +38,7 @@ from xr_ai_vllm import ( DEFAULT_IMAGE, load_config, + parse_bool, resolve_model_cache, serve, setup_hf_env, @@ -74,9 +75,12 @@ def run() -> None: tp_size = int(cfg.get("tensor_parallel_size", _DEFAULT_TP)) max_ctx = int(cfg.get("max_model_len", _DEFAULT_CTX)) gpu_mem = float(cfg.get("gpu_memory_utilization", _DEFAULT_GPU_MEM)) - enforce_eager = bool(cfg.get("enforce_eager", _DEFAULT_EAGER)) + enforce_eager = parse_bool(cfg.get("enforce_eager", _DEFAULT_EAGER), "enforce_eager") tool_call_parser = cfg.get("tool_call_parser", _DEFAULT_TOOL_CALL_PARSER) - enable_tool_choice = bool(cfg.get("enable_tool_choice", _DEFAULT_ENABLE_TOOL_CHOICE)) + enable_tool_choice = parse_bool( + cfg.get("enable_tool_choice", _DEFAULT_ENABLE_TOOL_CHOICE), + "enable_tool_choice", + ) backend = cfg.get("vllm_backend", "pip") image = cfg.get("vllm_image", DEFAULT_IMAGE) diff --git a/ai-services/llm/nemotron3_nano/nemotron3_nano_llm_server/__main__.py b/ai-services/llm/nemotron3_nano/nemotron3_nano_llm_server/__main__.py index e01f04d4..0a08898c 100644 --- a/ai-services/llm/nemotron3_nano/nemotron3_nano_llm_server/__main__.py +++ b/ai-services/llm/nemotron3_nano/nemotron3_nano_llm_server/__main__.py @@ -40,6 +40,7 @@ DEFAULT_IMAGE, gpu_compute_major, load_config, + parse_bool, resolve_model_cache, serve, setup_hf_env, @@ -100,7 +101,7 @@ def run() -> None: tp_size = int(cfg.get("tensor_parallel_size", _DEFAULT_TP)) max_ctx = int(cfg.get("max_model_len", _DEFAULT_CTX)) gpu_mem = float(cfg.get("gpu_memory_utilization", _DEFAULT_GPU_MEM)) - enforce_eager = bool(cfg.get("enforce_eager", _DEFAULT_EAGER)) + enforce_eager = parse_bool(cfg.get("enforce_eager", _DEFAULT_EAGER), "enforce_eager") parser_url = cfg.get("parser_url", _PARSER_URL_DEFAULT) backend = cfg.get("vllm_backend", "pip") image = cfg.get("vllm_image", DEFAULT_IMAGE) diff --git a/ai-services/llm/nemotron_omni/nemotron_omni_llm_server/__main__.py b/ai-services/llm/nemotron_omni/nemotron_omni_llm_server/__main__.py index 3c06c130..7e97fa66 100644 --- a/ai-services/llm/nemotron_omni/nemotron_omni_llm_server/__main__.py +++ b/ai-services/llm/nemotron_omni/nemotron_omni_llm_server/__main__.py @@ -47,6 +47,7 @@ DEFAULT_IMAGE, gpu_compute_major, load_config, + parse_bool, resolve_model_cache, serve, setup_hf_env, @@ -81,7 +82,8 @@ def run() -> None: # nvidia-smi queries the right device. cuda_devices = setup_hf_env(cfg, model_cache) - if cfg.get("use_bf16", False): + use_bf16 = parse_bool(cfg.get("use_bf16", False), "use_bf16") + if use_bf16: model = cfg.get("model_bf16", _MODEL_BF16) use_kv_fp8 = False logger.info("use_bf16=true → {}", model) @@ -104,7 +106,7 @@ def run() -> None: tp_size = int(cfg.get("tensor_parallel_size", _DEFAULT_TP)) max_ctx = int(cfg.get("max_model_len", _DEFAULT_CTX)) gpu_mem = float(cfg.get("gpu_memory_utilization", _DEFAULT_GPU_MEM)) - enforce_eager = bool(cfg.get("enforce_eager", _DEFAULT_EAGER)) + enforce_eager = parse_bool(cfg.get("enforce_eager", _DEFAULT_EAGER), "enforce_eager") prune_rate = float(cfg.get("video_pruning_rate", _DEFAULT_PRUNE)) video_fps = int(cfg.get("video_fps", _DEFAULT_FPS)) video_frames = int(cfg.get("video_num_frames", _DEFAULT_FRAMES)) diff --git a/ai-services/tts/piper/piper_tts_server/__main__.py b/ai-services/tts/piper/piper_tts_server/__main__.py index a65ed84b..1bd5c02e 100644 --- a/ai-services/tts/piper/piper_tts_server/__main__.py +++ b/ai-services/tts/piper/piper_tts_server/__main__.py @@ -47,6 +47,24 @@ # Callers/tests can treat this as retry-or-skip rather than a hard failure. _EXIT_VOICE_UNAVAILABLE = 3 +_TRUE_BOOL_STRINGS = {"1", "true", "yes", "on"} +_FALSE_BOOL_STRINGS = {"0", "false", "no", "off"} + + +def _parse_bool(value: object, key: str) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in _TRUE_BOOL_STRINGS: + return True + if normalized in _FALSE_BOOL_STRINGS: + return False + raise ValueError( + f"{key} must be a boolean or one of " + f"{sorted(_TRUE_BOOL_STRINGS | _FALSE_BOOL_STRINGS)} (got {value!r})" + ) + def _resolve_model_cache(cfg: dict, yaml_dir: Path) -> Path: raw = cfg.get("model_cache", "../models") @@ -202,7 +220,7 @@ def _build_app(cfg: dict, model_cache: Path): from pydantic import BaseModel voice_name = cfg["voice"] - use_cuda = bool(cfg.get("use_cuda", False)) + use_cuda = _parse_bool(cfg.get("use_cuda", False), "use_cuda") backend = _PiperBackend(voice_name, model_cache, use_cuda) app = FastAPI(title="Piper TTS Server", version="0.1.0") diff --git a/ai-services/vlm-server/vlm_server/__main__.py b/ai-services/vlm-server/vlm_server/__main__.py index eedfbec3..41186d64 100644 --- a/ai-services/vlm-server/vlm_server/__main__.py +++ b/ai-services/vlm-server/vlm_server/__main__.py @@ -45,6 +45,7 @@ from xr_ai_vllm import ( DEFAULT_IMAGE, load_config, + parse_bool, resolve_model_cache, serve, setup_hf_env, @@ -81,7 +82,7 @@ def run() -> None: tp_size = int(cfg.get("tensor_parallel_size", _DEFAULT_TP)) max_ctx = int(cfg.get("max_model_len", _DEFAULT_CTX)) gpu_mem = float(cfg.get("gpu_memory_utilization", _DEFAULT_GPU_MEM)) - enforce_eager = bool(cfg.get("enforce_eager", _DEFAULT_EAGER)) + enforce_eager = parse_bool(cfg.get("enforce_eager", _DEFAULT_EAGER), "enforce_eager") max_images = int(cfg.get("max_images_per_prompt", _DEFAULT_MAX_IMAGES)) max_videos = int(cfg.get("max_videos_per_prompt", _DEFAULT_MAX_VIDEOS)) backend = cfg.get("vllm_backend", "pip") diff --git a/tests/test_models_config.py b/tests/test_models_config.py index 1b8fff8a..fd6e66e0 100644 --- a/tests/test_models_config.py +++ b/tests/test_models_config.py @@ -128,6 +128,30 @@ def test_health_check_defaults_true_and_parses_false(tmp_path) -> None: assert nim.base_url == "https://integrate.api.nvidia.com" +def test_health_check_parses_quoted_false(tmp_path) -> None: + cfg = load_models_config(_write(tmp_path, """ +remote_llm: + kind: openai_compat + category: llm + base_url: https://integrate.api.nvidia.com + model_name: meta/llama-3.1-8b-instruct + health_check: "false" +""")) + assert cfg.llm("remote_llm").health_check is False + + +def test_health_check_rejects_unknown_string(tmp_path) -> None: + with pytest.raises(ValueError, match="health_check"): + load_models_config(_write(tmp_path, """ +remote_llm: + kind: openai_compat + category: llm + base_url: https://integrate.api.nvidia.com + model_name: meta/llama-3.1-8b-instruct + health_check: maybe +""")) + + def test_vlm_preset(tmp_path) -> None: cfg = load_models_config(_write(tmp_path, """ vlm: diff --git a/tests/test_piper_tts.py b/tests/test_piper_tts.py index 97935900..5892f784 100644 --- a/tests/test_piper_tts.py +++ b/tests/test_piper_tts.py @@ -20,6 +20,7 @@ from __future__ import annotations import asyncio +import importlib.util import json import os import shutil @@ -50,6 +51,27 @@ _EXIT_VOICE_UNAVAILABLE = 3 +def _load_piper_main_module(): + spec = importlib.util.spec_from_file_location( + "piper_tts_server_main", + _PIPER_PROJECT / "piper_tts_server" / "__main__.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +async def test_piper_config_parses_quoted_use_cuda_false() -> None: + mod = _load_piper_main_module() + assert mod._parse_bool("false", "use_cuda") is False + + +async def test_piper_config_rejects_unknown_use_cuda_string() -> None: + mod = _load_piper_main_module() + with pytest.raises(ValueError, match="use_cuda"): + mod._parse_bool("sometimes", "use_cuda") + + class _ServerExited(Exception): """Raised when piper_tts_server exits before binding its port. diff --git a/tests/test_vllm_lifecycle.py b/tests/test_vllm_lifecycle.py index 04909aba..353a7182 100644 --- a/tests/test_vllm_lifecycle.py +++ b/tests/test_vllm_lifecycle.py @@ -1,16 +1,41 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Unit tests for xr_ai_vllm._lifecycle pure helpers.""" +"""Unit tests for xr_ai_vllm pure helpers.""" from __future__ import annotations from unittest.mock import MagicMock, patch import pytest +from xr_ai_vllm import parse_bool from xr_ai_vllm._lifecycle import health_ok, health_url, wait_until_healthy +class TestParseBool: + @pytest.mark.parametrize( + ("value", "expected"), + [ + (True, True), + (False, False), + ("true", True), + ("YES", True), + ("1", True), + ("on", True), + ("false", False), + ("No", False), + ("0", False), + (" off ", False), + ], + ) + def test_accepts_booleans_and_known_strings(self, value, expected): + assert parse_bool(value, "enforce_eager") is expected + + def test_rejects_unknown_string(self): + with pytest.raises(ValueError, match="enforce_eager"): + parse_bool("sometimes", "enforce_eager") + + class TestHealthUrl: def test_always_probes_localhost(self): # The host param is intentionally ignored — always 127.0.0.1. diff --git a/tests/test_vlm_mcp.py b/tests/test_vlm_mcp.py index a4c5ab52..15e03e1e 100644 --- a/tests/test_vlm_mcp.py +++ b/tests/test_vlm_mcp.py @@ -282,6 +282,41 @@ async def test_make_vlm_from_cfg_new_models_block(mock_vlm, png_path: Path): assert payload["chat_template_kwargs"] == {"enable_thinking": False} +async def test_make_vlm_from_cfg_parses_quoted_enable_thinking_false(mock_vlm, png_path: Path): + server, base_url = mock_vlm + server.answer = "quoted false" + + vlm, _ = _make_vlm_from_cfg({ + "models": { + "vlm": { + "kind": "preset:cosmos_vlm", + "base_url": base_url, + }, + }, + "enable_thinking": "false", + }) + mcp = build_mcp(vlm) + try: + result = await mcp.call_tool( + "ask_image", + {"question": "cosmos q", "image_path": str(png_path)}, + ) + finally: + await vlm.close() + + assert result.structured_content["result"] == "quoted false" + payload = server.requests[0] + assert payload["chat_template_kwargs"] == {"enable_thinking": False} + + +async def test_make_vlm_from_cfg_rejects_unknown_enable_thinking_string(): + with pytest.raises(ValueError, match="enable_thinking"): + _make_vlm_from_cfg({ + "vlm_server": "http://localhost:8100", + "enable_thinking": "sometimes", + }) + + async def test_make_vlm_from_cfg_missing_required_keys_raises(): """Neither models: nor vlm_server: present → ValueError.""" with pytest.raises(ValueError, match="must specify either"): diff --git a/tests/test_xr_ai_voicegate.py b/tests/test_xr_ai_voicegate.py index 1b4540f8..494329cb 100644 --- a/tests/test_xr_ai_voicegate.py +++ b/tests/test_xr_ai_voicegate.py @@ -799,6 +799,21 @@ def test_load_voice_gate_config_null_phrases_normalizes_to_empty(tmp_path: pathl assert cfg.followup_grace_s == 3.0 +def test_load_voice_gate_config_parses_quoted_listening_chime_false(tmp_path: pathlib.Path): + p = tmp_path / "voice_gate.yaml" + p.write_text('magic_phrases: agent\nlistening_chime: "false"\n') + cfg = load_voice_gate_config(p) + assert cfg.magic_phrases == ("agent",) + assert cfg.listening_chime is False + + +def test_load_voice_gate_config_rejects_unknown_listening_chime_string(tmp_path: pathlib.Path): + p = tmp_path / "voice_gate.yaml" + p.write_text("listening_chime: sometimes\n") + with pytest.raises(ValueError, match="listening_chime"): + load_voice_gate_config(p) + + def test_load_voice_gate_config_strips_whitespace_and_drops_empty(tmp_path: pathlib.Path): """Case 39: phrase entries are stripped, and empty entries (after strip) are dropped — same normalization the inline parser ran.""" diff --git a/utils/xr-ai-vllm/xr_ai_vllm/__init__.py b/utils/xr-ai-vllm/xr_ai_vllm/__init__.py index 3a3e31af..227d54db 100644 --- a/utils/xr-ai-vllm/xr_ai_vllm/__init__.py +++ b/utils/xr-ai-vllm/xr_ai_vllm/__init__.py @@ -50,6 +50,7 @@ from ._config import ( gpu_compute_major, load_config, + parse_bool, resolve_model_cache, setup_hf_env, ) @@ -237,6 +238,7 @@ def stop_persistent_servers( "DEFAULT_IMAGE", "resolve_model_cache", "load_config", + "parse_bool", "setup_hf_env", "gpu_compute_major", ] diff --git a/utils/xr-ai-vllm/xr_ai_vllm/_config.py b/utils/xr-ai-vllm/xr_ai_vllm/_config.py index a84b4b6b..a2296db3 100644 --- a/utils/xr-ai-vllm/xr_ai_vllm/_config.py +++ b/utils/xr-ai-vllm/xr_ai_vllm/_config.py @@ -19,6 +19,25 @@ log = logging.getLogger(__name__) +_TRUE_BOOL_STRINGS = {"1", "true", "yes", "on"} +_FALSE_BOOL_STRINGS = {"0", "false", "no", "off"} + + +def parse_bool(value: object, key: str) -> bool: + """Parse a config boolean without treating every non-empty string as true.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in _TRUE_BOOL_STRINGS: + return True + if normalized in _FALSE_BOOL_STRINGS: + return False + raise ValueError( + f"{key} must be a boolean or one of " + f"{sorted(_TRUE_BOOL_STRINGS | _FALSE_BOOL_STRINGS)} (got {value!r})" + ) + def resolve_model_cache(cfg: dict, yaml_dir: Path, *, default: str) -> Path: """Resolve ``model_cache`` (relative to the YAML dir) and ensure it exists.""" diff --git a/utils/xr-ai-voicegate/xr_ai_voicegate/config.py b/utils/xr-ai-voicegate/xr_ai_voicegate/config.py index 268607da..21650e8e 100644 --- a/utils/xr-ai-voicegate/xr_ai_voicegate/config.py +++ b/utils/xr-ai-voicegate/xr_ai_voicegate/config.py @@ -11,6 +11,24 @@ import yaml +_TRUE_BOOL_STRINGS = {"1", "true", "yes", "on"} +_FALSE_BOOL_STRINGS = {"0", "false", "no", "off"} + + +def _parse_bool(value: object, key: str) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in _TRUE_BOOL_STRINGS: + return True + if normalized in _FALSE_BOOL_STRINGS: + return False + raise ValueError( + f"{key} must be a boolean or one of " + f"{sorted(_TRUE_BOOL_STRINGS | _FALSE_BOOL_STRINGS)} (got {value!r})" + ) + @dataclass(frozen=True) class VoiceGateConfig: @@ -57,7 +75,7 @@ def load_voice_gate_config(path: pathlib.Path) -> VoiceGateConfig: return VoiceGateConfig( magic_phrases = phrases, followup_grace_s = float(raw.get("followup_grace_s", 5.0)), - listening_chime = bool(raw.get("listening_chime", True)), + listening_chime = _parse_bool(raw.get("listening_chime", True), "listening_chime"), )