Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 54 additions & 6 deletions src/agentops/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")


# ---------------------------------------------------------------------------
Expand Down
92 changes: 92 additions & 0 deletions src/agentops/services/discovery.py
Original file line number Diff line number Diff line change
@@ -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 <url>, 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
16 changes: 11 additions & 5 deletions tests/unit/test_cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
127 changes: 127 additions & 0 deletions tests/unit/test_discovery.py
Original file line number Diff line number Diff line change
@@ -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