Files
2026-07-13 13:22:34 +08:00

715 lines
25 KiB
Python

import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from mlflow.assistant.config import AssistantConfig, ProjectConfig
from mlflow.assistant.config import ProviderConfig as AssistantProviderConfig
from mlflow.assistant.providers import OllamaProvider
from mlflow.assistant.providers.base import (
AssistantProvider,
CLINotInstalledError,
NotAuthenticatedError,
ProviderConfig,
ProviderNotConfiguredError,
)
from mlflow.assistant.types import Event, Message, ToolUseBlock
from mlflow.server.assistant.api import (
PermissionDecision,
_require_localhost,
assistant_router,
)
from mlflow.server.assistant.session import SESSION_DIR, SessionManager, save_process_pid
from mlflow.utils.os import is_windows
class MockProvider(AssistantProvider):
"""Mock provider for testing."""
@property
def name(self) -> str:
return "mock_provider"
@property
def display_name(self) -> str:
return "Mock Provider"
@property
def description(self) -> str:
return "Mock provider for testing"
@property
def config_path(self) -> Path:
return Path.home() / ".mlflow" / "assistant" / "mock-config.json"
def is_available(self) -> bool:
return True
def load_config(self) -> ProviderConfig:
return ProviderConfig()
def check_connection(self, echo=print) -> None:
pass
def resolve_skills_path(self, base_directory: Path) -> Path:
return base_directory / ".mock" / "skills"
def list_models(self, base_url: str | None = None, api_key: str | None = None) -> list[str]:
raise NotImplementedError
async def astream(
self,
prompt: str,
tracking_uri: str,
session_id: str | None = None,
mlflow_session_id: str | None = None,
cwd: Path | None = None,
context: dict[str, Any] | None = None,
):
yield Event.from_message(message=Message(role="user", content="Hello from mock"))
yield Event.from_result(result="complete", session_id="mock-session-123")
@pytest.fixture(autouse=True)
def isolated_config(tmp_path, monkeypatch):
"""Redirect config to tmp_path to avoid modifying real user config."""
import mlflow.assistant.config as config_module
config_home = tmp_path / ".mlflow" / "assistant"
config_path = config_home / "config.json"
monkeypatch.setattr(config_module, "MLFLOW_ASSISTANT_HOME", config_home)
monkeypatch.setattr(config_module, "CONFIG_PATH", config_path)
return config_home
@pytest.fixture(autouse=True)
def clear_sessions():
"""Clear session storage before each test."""
if SESSION_DIR.exists():
shutil.rmtree(SESSION_DIR)
yield
if SESSION_DIR.exists():
shutil.rmtree(SESSION_DIR)
@pytest.fixture
def client():
"""Create test client with mock provider and bypassed localhost check."""
app = FastAPI()
app.include_router(assistant_router)
# Override localhost dependency to allow TestClient requests
async def mock_require_localhost():
pass
app.dependency_overrides[_require_localhost] = mock_require_localhost
mock_provider = MockProvider()
with (
patch("mlflow.server.assistant.api.list_providers", return_value=[mock_provider]),
patch("mlflow.server.assistant.api._get_selected_provider", return_value=mock_provider),
):
yield TestClient(app)
def test_message(client):
response = client.post(
"/ajax-api/3.0/mlflow/assistant/message",
json={
"message": "Hello",
"context": {"trace_id": "tr-123", "experiment_id": "exp-456"},
},
)
assert response.status_code == 200
data = response.json()
session_id = data["session_id"]
assert session_id is not None
assert data["stream_url"] == f"/ajax-api/3.0/mlflow/assistant/stream/{data['session_id']}"
# continue the conversation
response = client.post(
"/ajax-api/3.0/mlflow/assistant/message",
json={"message": "Second message", "session_id": session_id},
)
assert response.status_code == 200
assert response.json()["session_id"] == session_id
def test_stream_not_found_for_invalid_session(client):
response = client.get("/ajax-api/3.0/mlflow/assistant/sessions/invalid-session-id/stream")
assert response.status_code == 404
assert "Session not found" in response.json()["detail"]
def test_stream_bad_request_when_no_pending_message(client):
# Create session and consume the pending message
r = client.post("/ajax-api/3.0/mlflow/assistant/message", json={"message": "Hi"})
session_id = r.json()["session_id"]
client.get(f"/ajax-api/3.0/mlflow/assistant/sessions/{session_id}/stream")
# Try to stream again without a new message
response = client.get(f"/ajax-api/3.0/mlflow/assistant/sessions/{session_id}/stream")
assert response.status_code == 400
assert "No pending message" in response.json()["detail"]
def test_stream_returns_sse_events(client):
r = client.post("/ajax-api/3.0/mlflow/assistant/message", json={"message": "Hi"})
session_id = r.json()["session_id"]
response = client.get(f"/ajax-api/3.0/mlflow/assistant/sessions/{session_id}/stream")
assert response.status_code == 200
assert "text/event-stream" in response.headers["content-type"]
content = response.text
assert "event: message" in content
assert "event: done" in content
assert "Hello from mock" in content
def test_health_check_returns_ok_when_healthy(client):
response = client.get("/ajax-api/3.0/mlflow/assistant/providers/mock_provider/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_health_check_returns_404_for_unknown_provider(client):
response = client.get("/ajax-api/3.0/mlflow/assistant/providers/unknown_provider/health")
assert response.status_code == 404
assert "not found" in response.json()["detail"]
def test_health_check_returns_412_when_cli_not_installed():
app = FastAPI()
app.include_router(assistant_router)
async def mock_require_localhost():
pass
app.dependency_overrides[_require_localhost] = mock_require_localhost
class CLINotInstalledProvider(MockProvider):
def check_connection(self, echo=None):
raise CLINotInstalledError("CLI not installed")
provider = CLINotInstalledProvider()
with patch("mlflow.server.assistant.api.list_providers", return_value=[provider]):
client = TestClient(app)
response = client.get("/ajax-api/3.0/mlflow/assistant/providers/mock_provider/health")
assert response.status_code == 412
assert "CLI not installed" in response.json()["detail"]
def test_health_check_returns_401_when_not_authenticated():
app = FastAPI()
app.include_router(assistant_router)
async def mock_require_localhost():
pass
app.dependency_overrides[_require_localhost] = mock_require_localhost
class NotAuthenticatedProvider(MockProvider):
def check_connection(self, echo=None):
raise NotAuthenticatedError("Not authenticated")
provider = NotAuthenticatedProvider()
with patch("mlflow.server.assistant.api.list_providers", return_value=[provider]):
client = TestClient(app)
response = client.get("/ajax-api/3.0/mlflow/assistant/providers/mock_provider/health")
assert response.status_code == 401
assert "Not authenticated" in response.json()["detail"]
def test_get_config_returns_empty_config(client):
response = client.get("/ajax-api/3.0/mlflow/assistant/config")
assert response.status_code == 200
data = response.json()
assert data["providers"] == {}
assert data["projects"] == {}
def test_get_config_returns_existing_config(client, tmp_path):
# Set up existing config by saving it first
project_dir = tmp_path / "project"
project_dir.mkdir()
config = AssistantConfig(
providers={"claude_code": AssistantProviderConfig(model="default", selected=True)},
projects={"exp-123": ProjectConfig(type="local", location=str(project_dir))},
)
config.save()
response = client.get("/ajax-api/3.0/mlflow/assistant/config")
assert response.status_code == 200
data = response.json()
assert data["providers"]["claude_code"]["model"] == "default"
assert data["providers"]["claude_code"]["selected"] is True
assert data["projects"]["exp-123"]["location"] == str(project_dir)
def test_update_config_sets_provider(client):
response = client.put(
"/ajax-api/3.0/mlflow/assistant/config",
json={"providers": {"claude_code": {"model": "opus", "selected": True}}},
)
assert response.status_code == 200
data = response.json()
assert data["providers"]["claude_code"]["selected"] is True
def test_update_config_sets_project(client, tmp_path):
project_dir = tmp_path / "my_project"
project_dir.mkdir()
response = client.put(
"/ajax-api/3.0/mlflow/assistant/config",
json={"projects": {"exp-456": {"type": "local", "location": str(project_dir)}}},
)
assert response.status_code == 200
data = response.json()
assert data["projects"]["exp-456"]["location"] == str(project_dir)
def test_update_config_expand_user_home(client, tmp_path):
# Create a directory under a "fake home" structure to test ~ expansion
fake_home = tmp_path / "home" / "user"
project_dir = fake_home / "my_project"
project_dir.mkdir(parents=True)
with patch("mlflow.server.assistant.api.Path.expanduser") as mock_expanduser:
# Make expanduser return our tmp_path directory
mock_expanduser.return_value = project_dir
response = client.put(
"/ajax-api/3.0/mlflow/assistant/config",
json={"projects": {"exp-456": {"type": "local", "location": "~/my_project"}}},
)
assert response.status_code == 200
data = response.json()
assert data["projects"]["exp-456"]["location"] == str(project_dir)
@pytest.mark.asyncio
async def test_localhost_allows_ipv4():
mock_request = MagicMock()
mock_request.client.host = "127.0.0.1"
await _require_localhost(mock_request)
@pytest.mark.asyncio
async def test_localhost_allows_ipv6():
mock_request = MagicMock()
mock_request.client.host = "::1"
await _require_localhost(mock_request)
@pytest.mark.asyncio
async def test_localhost_blocks_external_ip():
mock_request = MagicMock()
mock_request.client.host = "192.168.1.100"
with pytest.raises(HTTPException, match="same host"):
await _require_localhost(mock_request)
@pytest.mark.asyncio
async def test_localhost_blocks_external_hostname():
mock_request = MagicMock()
mock_request.client.host = "external.example.com"
with pytest.raises(HTTPException, match="same host"):
await _require_localhost(mock_request)
@pytest.mark.asyncio
async def test_localhost_blocks_when_no_client():
mock_request = MagicMock()
mock_request.client = None
with pytest.raises(HTTPException, match="same host"):
await _require_localhost(mock_request)
def test_validate_session_id_accepts_valid_uuid():
valid_uuid = "f5f28c66-5ec6-46a1-9a2e-ca55fb64bf47"
SessionManager.validate_session_id(valid_uuid) # Should not raise
def test_validate_session_id_rejects_invalid_format():
with pytest.raises(ValueError, match="Invalid session ID format"):
SessionManager.validate_session_id("invalid-session-id")
def test_validate_session_id_rejects_path_traversal():
with pytest.raises(ValueError, match="Invalid session ID format"):
SessionManager.validate_session_id("../../../etc/passwd")
def _is_process_running(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except (OSError, ValueError): # ValueError is raised on Windows
return False
def test_patch_session_cancel_with_process(client):
r = client.post("/ajax-api/3.0/mlflow/assistant/message", json={"message": "Hi"})
session_id = r.json()["session_id"]
# Start a real subprocess and register it with the session
with subprocess.Popen([sys.executable, "-c", "import time; time.sleep(10)"]) as proc:
save_process_pid(session_id, proc.pid)
assert _is_process_running(proc.pid)
response = client.patch(
f"/ajax-api/3.0/mlflow/assistant/sessions/{session_id}",
json={"status": "cancelled"},
)
assert response.status_code == 200
data = response.json()
assert "terminated" in data["message"]
# Wait for the process to actually terminate
proc.wait(timeout=5)
assert proc.returncode is not None
# On non-Windows, verify the process is no longer running via PID check.
# Skip on Windows because PIDs are reused more aggressively.
if not is_windows():
assert not _is_process_running(proc.pid)
class _DeferredProvider(MockProvider):
"""Pauses at the prompt on the first turn; resumes from the decision in context."""
async def astream(
self,
prompt,
tracking_uri,
session_id=None,
mlflow_session_id=None,
cwd=None,
context=None,
):
decisions = (context or {}).get("tool_decisions") or {}
if not decisions:
# First turn: surface the tool, emit the prompt, and end the turn.
yield Event.from_message(
Message(
role="assistant",
content=[ToolUseBlock(id="t1", name="Bash", input={"command": "echo hi"})],
)
)
yield Event.from_permission_request("t1", "Bash", {"command": "echo hi"})
yield Event.from_result(result=None, session_id="prov-paused")
return
# Resume: apply the delivered decision.
allowed = decisions.get("t1") == "allow"
yield Event.from_message(Message(role="assistant", content="ran" if allowed else "denied"))
yield Event.from_result(result=None, session_id="prov-done")
@pytest.mark.parametrize(("decision", "expected_text"), [("allow", "ran"), ("deny", "denied")])
@pytest.mark.asyncio
async def test_stream_pauses_then_resumes(decision, expected_text):
"""The turn ENDS at the permission prompt (no hang, no cross-request state);
a resume request then drives a fresh stream to completion.
"""
from mlflow.server.assistant.api import resolve_permission, stream_response
session_id = "f5f28c66-5ec6-46a1-9a2e-ca55fb64bf47"
session = SessionManager.create()
session.set_pending_message(role="user", content="hi")
SessionManager.save(session_id, session)
mock_request = MagicMock()
mock_request.base_url = "http://localhost:5000/"
provider = _DeferredProvider()
# First turn: the stream completes immediately at the prompt (no await).
with patch("mlflow.server.assistant.api._get_selected_provider", return_value=provider):
response = await stream_response(mock_request, session_id)
first = "".join([c async for c in response.body_iterator])
assert "permission_request" in first
assert "event: done" in first
# Deliver the decision, then a fresh stream resumes to completion.
res = await resolve_permission(
session_id, PermissionDecision(request_id="t1", decision=decision)
)
assert res.session_id == session_id
with patch("mlflow.server.assistant.api._get_selected_provider", return_value=provider):
response2 = await stream_response(mock_request, session_id)
second = "".join([c async for c in response2.body_iterator])
assert expected_text in second
assert "event: done" in second
class _CaptureProvider(MockProvider):
"""Records the prompt and context astream is called with, then completes."""
def __init__(self):
self.captured: dict[str, Any] = {}
async def astream(
self,
prompt,
tracking_uri,
session_id=None,
mlflow_session_id=None,
cwd=None,
context=None,
):
self.captured = {"prompt": prompt, "context": context or {}}
yield Event.from_result(result=None, session_id="prov-done")
@pytest.mark.asyncio
async def test_stream_prefers_new_message_over_stale_tool_decision():
"""A pending message and a stale decision can coexist if a resume stream never
consumed the decision and the user typed again. The new message must win: the
provider sees the prompt and NOT the stale tool_decisions (which would otherwise
resume the abandoned turn and silently drop the message).
"""
from mlflow.server.assistant.api import stream_response
session_id = "f5f28c66-5ec6-46a1-9a2e-ca55fb64bf47"
session = SessionManager.create()
session.set_pending_message(role="user", content="what is 2+2")
session.pending_tool_decisions = {"t1": "allow"}
SessionManager.save(session_id, session)
mock_request = MagicMock()
mock_request.base_url = "http://localhost:5000/"
provider = _CaptureProvider()
with patch("mlflow.server.assistant.api._get_selected_provider", return_value=provider):
response = await stream_response(mock_request, session_id)
_ = "".join([c async for c in response.body_iterator])
assert provider.captured["prompt"] == "what is 2+2"
assert "tool_decisions" not in provider.captured["context"]
@pytest.mark.asyncio
async def test_stream_forwards_tool_decision_when_no_pending_message():
"""A genuine resume (decision delivered, no new message) still forwards the
tool_decisions so the provider can continue the paused turn.
"""
from mlflow.server.assistant.api import stream_response
session_id = "f5f28c66-5ec6-46a1-9a2e-ca55fb64bf47"
session = SessionManager.create()
session.pending_tool_decisions = {"t1": "allow"}
SessionManager.save(session_id, session)
mock_request = MagicMock()
mock_request.base_url = "http://localhost:5000/"
provider = _CaptureProvider()
with patch("mlflow.server.assistant.api._get_selected_provider", return_value=provider):
response = await stream_response(mock_request, session_id)
_ = "".join([c async for c in response.body_iterator])
assert provider.captured["prompt"] == ""
assert provider.captured["context"]["tool_decisions"] == {"t1": "allow"}
def test_resolve_permission_rejects_invalid_session(client):
response = client.post(
"/ajax-api/3.0/mlflow/assistant/sessions/not-a-uuid/permission",
json={"request_id": "t1", "decision": "allow"},
)
assert response.status_code == 400
def test_resolve_permission_returns_404_for_unknown_session(client):
# Well-formed UUID, but no such session exists.
response = client.post(
"/ajax-api/3.0/mlflow/assistant/sessions/f5f28c66-5ec6-46a1-9a2e-ca55fb64bf47/permission",
json={"request_id": "t1", "decision": "allow"},
)
assert response.status_code == 404
def test_install_skills_success(client):
with patch(
"mlflow.server.assistant.api.install_skills", return_value=["skill1", "skill2"]
) as mock_install:
response = client.post(
"/ajax-api/3.0/mlflow/assistant/skills/install",
json={"type": "custom", "custom_path": "/tmp/test-skills"},
)
assert response.status_code == 200
data = response.json()
assert data["installed_skills"] == ["skill1", "skill2"]
expected_path = os.path.join(os.sep, "tmp", "test-skills")
assert data["skills_directory"] == expected_path
mock_install.assert_called_once_with(Path(expected_path))
def test_install_skills_skips_when_already_installed(client):
with (
patch("mlflow.server.assistant.api.Path.exists", return_value=True),
patch(
"mlflow.server.assistant.api.list_installed_skills",
return_value=["existing_skill"],
) as mock_list,
patch("mlflow.server.assistant.api.install_skills") as mock_install,
):
response = client.post(
"/ajax-api/3.0/mlflow/assistant/skills/install",
json={"type": "custom", "custom_path": "/tmp/test-skills"},
)
assert response.status_code == 200
data = response.json()
assert data["installed_skills"] == ["existing_skill"]
mock_install.assert_not_called()
mock_list.assert_called_once()
def test_update_config_partial_update_preserves_selected_provider(client):
ollama = OllamaProvider.OLLAMA_PROVIDER_NAME
# Pre-populate config: claude_code selected, ollama exists but not selected
config = AssistantConfig(
providers={
"claude_code": AssistantProviderConfig(model="opus", selected=True),
ollama: AssistantProviderConfig(
model="llama3", selected=False, base_url="http://localhost:11434"
),
}
)
config.save()
# Partially update ollama base_url without a selected flag
response = client.put(
"/ajax-api/3.0/mlflow/assistant/config",
json={"providers": {ollama: {"base_url": "http://localhost:12345"}}},
)
assert response.status_code == 200
data = response.json()
assert data["providers"]["claude_code"]["selected"] is True
assert data["providers"][ollama]["selected"] is False
assert data["providers"][ollama]["base_url"] == "http://localhost:12345"
def test_list_ollama_models_returns_model_list(client):
mock_provider = MockProvider()
mock_provider.list_models = MagicMock(return_value=["llama3"])
with patch("mlflow.server.assistant.api.list_providers", return_value=[mock_provider]):
response = client.get(
"/ajax-api/3.0/mlflow/assistant/providers/mock_provider/models",
params={"base_url": "http://localhost:11434"},
)
assert response.status_code == 200
assert response.json() == {"models": ["llama3"]}
mock_provider.list_models.assert_called_once_with("http://localhost:11434", None)
def test_list_models_reads_api_key_from_header_not_query(client):
"""api_key must travel as the X-API-Key header so it stays out of access
logs, browser history, and referer headers. This test pins that
contract and verifies the value reaches the provider unchanged.
"""
mock_provider = MockProvider()
mock_provider.list_models = MagicMock(return_value=["llama3"])
with patch("mlflow.server.assistant.api.list_providers", return_value=[mock_provider]):
response = client.get(
"/ajax-api/3.0/mlflow/assistant/providers/mock_provider/models",
params={"base_url": "http://localhost:11434"},
headers={"X-API-Key": "sk-test-secret"},
)
assert response.status_code == 200
assert response.json() == {"models": ["llama3"]}
mock_provider.list_models.assert_called_once_with("http://localhost:11434", "sk-test-secret")
def test_list_models_ignores_api_key_query_param(client):
"""Defense in depth: even if a caller passes api_key as a query param,
the endpoint must not forward it to the provider — that would re-enable
the access-log leak the header migration was meant to prevent.
"""
mock_provider = MockProvider()
mock_provider.list_models = MagicMock(return_value=["llama3"])
with patch("mlflow.server.assistant.api.list_providers", return_value=[mock_provider]):
response = client.get(
"/ajax-api/3.0/mlflow/assistant/providers/mock_provider/models",
params={"base_url": "http://localhost:11434", "api_key": "sk-leaked"},
)
assert response.status_code == 200
mock_provider.list_models.assert_called_once_with("http://localhost:11434", None)
def test_list_ollama_models_returns_412_when_not_installed(client):
class MissingDependencyProvider(MockProvider):
def list_models(self, base_url: str | None = None, api_key: str | None = None) -> list[str]:
raise CLINotInstalledError("ollama package missing")
with patch(
"mlflow.server.assistant.api.list_providers",
return_value=[MissingDependencyProvider()],
):
response = client.get("/ajax-api/3.0/mlflow/assistant/providers/mock_provider/models")
assert response.status_code == 412
assert "ollama" in response.json()["detail"].lower()
def test_list_ollama_models_returns_503_on_connection_failure(client):
class UnreachableProvider(MockProvider):
def list_models(self, base_url: str | None = None, api_key: str | None = None) -> list[str]:
raise ProviderNotConfiguredError("Cannot connect to Ollama server")
with patch("mlflow.server.assistant.api.list_providers", return_value=[UnreachableProvider()]):
response = client.get(
"/ajax-api/3.0/mlflow/assistant/providers/mock_provider/models",
params={"base_url": "http://localhost:11434"},
)
assert response.status_code == 503
assert "Cannot connect" in response.json()["detail"]
def test_list_provider_models_returns_404_for_unsupported_provider(client):
class UnsupportedProvider(MockProvider):
@property
def name(self) -> str:
return "unsupported_provider"
with patch(
"mlflow.server.assistant.api.list_providers",
return_value=[UnsupportedProvider()],
):
response = client.get(
"/ajax-api/3.0/mlflow/assistant/providers/unsupported_provider/models"
)
assert response.status_code == 404
assert "not supported" in response.json()["detail"]