From dae8e2edc5a18cc08177e4283a4f4d687ba09a62 Mon Sep 17 00:00:00 2001 From: Dongbumlee Date: Tue, 7 Apr 2026 14:44:25 -0700 Subject: [PATCH] feat: implement model list and agent list commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add services/discovery.py — list_models, list_agents - Lazy Azure imports, DefaultAzureCredential, --endpoint flag - 7 unit tests with mocked Azure SDK --- src/agentops/cli/app.py | 60 ++++++++++++-- src/agentops/services/discovery.py | 92 +++++++++++++++++++++ tests/unit/test_cli_commands.py | 16 ++-- tests/unit/test_discovery.py | 127 +++++++++++++++++++++++++++++ 4 files changed, 284 insertions(+), 11 deletions(-) create mode 100644 src/agentops/services/discovery.py create mode 100644 tests/unit/test_discovery.py diff --git a/src/agentops/cli/app.py b/src/agentops/cli/app.py index e3eb453..80a23ea 100644 --- a/src/agentops/cli/app.py +++ b/src/agentops/cli/app.py @@ -65,15 +65,63 @@ def cmd_trace_init() -> None: @model_app.command("list") -def cmd_model_list() -> None: - """List chat-capable models in Foundry project (planned).""" - _planned_command("agentops model list") +def cmd_model_list( + endpoint: str | None = typer.Option( + None, + "--endpoint", + help="Foundry project endpoint (default: AZURE_AI_FOUNDRY_PROJECT_ENDPOINT).", + ), +) -> None: + """List model deployments in the Foundry project.""" + from agentops.services.discovery import list_models + + try: + models = list_models(endpoint=endpoint) + except (ImportError, ValueError) as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) from exc + except Exception as exc: + typer.echo(f"Error: failed to list models: {exc}", err=True) + raise typer.Exit(code=1) from exc + + if not models: + typer.echo("No model deployments found.") + return + + typer.echo(f"Model deployments ({len(models)}):\n") + for m in models: + ver = f" version={m.model_version}" if m.model_version else "" + typer.echo(f" {m.name:<30} model={m.model_name}{ver}") @agent_app.command("list") -def cmd_agent_list() -> None: - """List agents in Foundry project (planned).""" - _planned_command("agentops agent list") +def cmd_agent_list( + endpoint: str | None = typer.Option( + None, + "--endpoint", + help="Foundry project endpoint (default: AZURE_AI_FOUNDRY_PROJECT_ENDPOINT).", + ), +) -> None: + """List agents in the Foundry project.""" + from agentops.services.discovery import list_agents + + try: + agents = list_agents(endpoint=endpoint) + except (ImportError, ValueError) as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) from exc + except Exception as exc: + typer.echo(f"Error: failed to list agents: {exc}", err=True) + raise typer.Exit(code=1) from exc + + if not agents: + typer.echo("No agents found.") + return + + typer.echo(f"Agents ({len(agents)}):\n") + for a in agents: + model = f" model={a.model}" if a.model else "" + typer.echo(f" {a.name:<30} id={a.agent_id}{model}") # --------------------------------------------------------------------------- diff --git a/src/agentops/services/discovery.py b/src/agentops/services/discovery.py new file mode 100644 index 0000000..336e7d2 --- /dev/null +++ b/src/agentops/services/discovery.py @@ -0,0 +1,92 @@ +"""Discovery services for listing Foundry models and agents.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import List, Optional + + +@dataclass(frozen=True) +class ModelDeploymentInfo: + """Summary of a model deployment in the Foundry project.""" + + name: str + model_name: str + model_version: str + deployment_type: str + + +@dataclass(frozen=True) +class AgentInfo: + """Summary of an agent in the Foundry project.""" + + name: str + agent_id: str + model: str + + +def _resolve_endpoint(endpoint: Optional[str] = None) -> str: + """Resolve the Foundry project endpoint from argument or env var.""" + resolved = endpoint or os.getenv("AZURE_AI_FOUNDRY_PROJECT_ENDPOINT", "") + if not resolved: + raise ValueError( + "Foundry project endpoint is required. Set it via:\n" + " --endpoint , or\n" + " AZURE_AI_FOUNDRY_PROJECT_ENDPOINT environment variable" + ) + return resolved.strip() + + +def _get_project_client(endpoint: str): + """Create an AIProjectClient with lazy Azure imports.""" + try: + from azure.ai.projects import AIProjectClient # noqa: WPS433 + from azure.identity import DefaultAzureCredential # noqa: WPS433 + except ImportError as exc: + raise ImportError( + "This command requires 'azure-ai-projects>=2.0.1' and 'azure-identity'.\n" + "Install with: pip install 'azure-ai-projects>=2.0.1' azure-identity" + ) from exc + + credential = DefaultAzureCredential(exclude_developer_cli_credential=True) + return AIProjectClient(endpoint=endpoint, credential=credential) + + +def list_models( + endpoint: Optional[str] = None, +) -> List[ModelDeploymentInfo]: + """List model deployments in the Foundry project.""" + resolved = _resolve_endpoint(endpoint) + client = _get_project_client(resolved) + + deployments: List[ModelDeploymentInfo] = [] + for d in client.deployments.list(): + deployments.append( + ModelDeploymentInfo( + name=d.name, + model_name=getattr(d, "model_name", "") or "", + model_version=getattr(d, "model_version", "") or "", + deployment_type=getattr(d, "type", "") or "", + ) + ) + return deployments + + +def list_agents( + endpoint: Optional[str] = None, +) -> List[AgentInfo]: + """List agents in the Foundry project.""" + resolved = _resolve_endpoint(endpoint) + client = _get_project_client(resolved) + + agents: List[AgentInfo] = [] + for a in client.agents.list(): + agents.append( + AgentInfo( + name=getattr(a, "name", "") or "", + agent_id=getattr(a, "id", "") or "", + model=getattr(a, "model", "") or "", + ) + ) + return agents diff --git a/tests/unit/test_cli_commands.py b/tests/unit/test_cli_commands.py index 4676f84..a8b867a 100644 --- a/tests/unit/test_cli_commands.py +++ b/tests/unit/test_cli_commands.py @@ -24,7 +24,10 @@ def test_eval_compare_rejects_wrong_run_count() -> None: result = runner.invoke(app, ["eval", "compare", "--runs", "only_one"]) assert result.exit_code == 1 - assert "at least two" in result.stdout.lower() or "at least two" in (result.stderr or "").lower() + assert ( + "at least two" in result.stdout.lower() + or "at least two" in (result.stderr or "").lower() + ) def test_trace_init_is_planned_stub() -> None: @@ -34,11 +37,14 @@ def test_trace_init_is_planned_stub() -> None: assert "planned but not implemented" in result.stdout.lower() -def test_model_list_is_planned_stub() -> None: - result = runner.invoke(app, ["model", "list"]) +def test_model_list_without_endpoint_fails() -> None: + """model list requires an endpoint — fails gracefully without one.""" + import os + from unittest.mock import patch - assert result.exit_code == 1 - assert "planned but not implemented" in result.stdout.lower() + with patch.dict(os.environ, {}, clear=True): + result = runner.invoke(app, ["model", "list"]) + assert result.exit_code == 1 def test_version_flag() -> None: diff --git a/tests/unit/test_discovery.py b/tests/unit/test_discovery.py new file mode 100644 index 0000000..224557c --- /dev/null +++ b/tests/unit/test_discovery.py @@ -0,0 +1,127 @@ +"""Tests for discovery services (model list, agent list).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from agentops.cli.app import app +from agentops.services.discovery import ( + list_agents, + list_models, +) + +runner = CliRunner() + + +class _FakeDeployment: + def __init__(self, name: str, model_name: str, model_version: str, dtype: str): + self.name = name + self.model_name = model_name + self.model_version = model_version + self.type = dtype + + +class _FakeAgent: + def __init__(self, name: str, agent_id: str, model: str = ""): + self.name = name + self.id = agent_id + self.model = model + + +def _mock_project_client(deployments=None, agents=None): + """Create a mock AIProjectClient.""" + client = MagicMock() + client.deployments.list.return_value = deployments or [] + client.agents.list.return_value = agents or [] + return client + + +# --------------------------------------------------------------------------- +# Service tests +# --------------------------------------------------------------------------- + + +class TestListModels: + @patch("agentops.services.discovery._get_project_client") + def test_lists_deployments(self, mock_get_client) -> None: + mock_get_client.return_value = _mock_project_client( + deployments=[ + _FakeDeployment("gpt-4.1", "gpt-4.1", "2025-04-14", "ModelDeployment"), + _FakeDeployment( + "embed-small", "text-embedding-3-small", "1", "ModelDeployment" + ), + ] + ) + result = list_models(endpoint="https://test.endpoint") + assert len(result) == 2 + assert result[0].name == "gpt-4.1" + assert result[0].model_version == "2025-04-14" + assert result[1].name == "embed-small" + + @patch("agentops.services.discovery._get_project_client") + def test_empty_list(self, mock_get_client) -> None: + mock_get_client.return_value = _mock_project_client(deployments=[]) + result = list_models(endpoint="https://test.endpoint") + assert result == [] + + def test_missing_endpoint(self) -> None: + with patch.dict("os.environ", {}, clear=True): + with pytest.raises(ValueError, match="endpoint is required"): + list_models(endpoint=None) + + +class TestListAgents: + @patch("agentops.services.discovery._get_project_client") + def test_lists_agents(self, mock_get_client) -> None: + mock_get_client.return_value = _mock_project_client( + agents=[ + _FakeAgent("my-agent", "my-agent", "gpt-4.1"), + _FakeAgent("test-bot", "test-bot"), + ] + ) + result = list_agents(endpoint="https://test.endpoint") + assert len(result) == 2 + assert result[0].name == "my-agent" + assert result[0].agent_id == "my-agent" + assert result[1].model == "" + + +# --------------------------------------------------------------------------- +# CLI tests +# --------------------------------------------------------------------------- + + +class TestModelListCLI: + @patch("agentops.services.discovery._get_project_client") + def test_lists_models(self, mock_get_client) -> None: + mock_get_client.return_value = _mock_project_client( + deployments=[ + _FakeDeployment("gpt-4.1", "gpt-4.1", "2025-04-14", "ModelDeployment"), + ] + ) + result = runner.invoke(app, ["model", "list", "--endpoint", "https://test"]) + assert result.exit_code == 0 + assert "gpt-4.1" in result.stdout + + @patch("agentops.services.discovery._get_project_client") + def test_empty(self, mock_get_client) -> None: + mock_get_client.return_value = _mock_project_client(deployments=[]) + result = runner.invoke(app, ["model", "list", "--endpoint", "https://test"]) + assert result.exit_code == 0 + assert "No model deployments" in result.stdout + + +class TestAgentListCLI: + @patch("agentops.services.discovery._get_project_client") + def test_lists_agents(self, mock_get_client) -> None: + mock_get_client.return_value = _mock_project_client( + agents=[ + _FakeAgent("agent-eval", "agent-eval", "gpt-4.1"), + ] + ) + result = runner.invoke(app, ["agent", "list", "--endpoint", "https://test"]) + assert result.exit_code == 0 + assert "agent-eval" in result.stdout