Skip to content

Commit 21066c6

Browse files
feat: add models-list command
1 parent 64399bf commit 21066c6

5 files changed

Lines changed: 276 additions & 2 deletions

File tree

packages/uipath/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath"
3-
version = "2.10.55"
3+
version = "2.10.56"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath/src/uipath/_cli/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"server": "cli_server",
4646
"register": "cli_register",
4747
"debug": "cli_debug",
48+
"list-models": "cli_list_models",
4849
"assets": "services.cli_assets",
4950
"buckets": "services.cli_buckets",
5051
"context-grounding": "services.cli_context_grounding",
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from collections.abc import Iterable
2+
3+
import click
4+
from rich.console import Console
5+
from rich.table import Table
6+
7+
from ..platform.agenthub import LlmModel
8+
from ._utils._context import get_cli_context
9+
from ._utils._service_base import ServiceCommandBase, service_command
10+
11+
12+
@click.command(name="list-models")
13+
@click.option(
14+
"--format",
15+
type=click.Choice(["json", "table", "csv"]),
16+
help="Output format (overrides global)",
17+
)
18+
@click.option("--output", "-o", type=click.Path(), help="Output file")
19+
@service_command
20+
async def list_models(ctx, format, output):
21+
"""List available LLM models."""
22+
client = ServiceCommandBase.get_client(ctx)
23+
models = await client.agenthub.get_available_llm_models_async()
24+
25+
fmt = format or get_cli_context(ctx).output_format
26+
if fmt == "table" and not output:
27+
_render_rich_table(models)
28+
return None
29+
return models
30+
31+
32+
def _render_rich_table(models: Iterable[LlmModel]) -> None:
33+
"""Render models as a rich table with one column per vendor."""
34+
by_vendor: dict[str, list[str]] = {}
35+
for model in models:
36+
vendor = model.vendor or "Unknown"
37+
by_vendor.setdefault(vendor, []).append(model.model_name)
38+
39+
console = Console()
40+
if not by_vendor:
41+
console.print("Available LLM Models: none")
42+
return
43+
44+
for names in by_vendor.values():
45+
names.sort()
46+
47+
vendors = sorted(by_vendor.keys())
48+
49+
table = Table(title="Available LLM Models", show_lines=False)
50+
for vendor in vendors:
51+
table.add_column(vendor, style="cyan", no_wrap=True)
52+
53+
max_rows = max(len(by_vendor[v]) for v in vendors)
54+
for i in range(max_rows):
55+
row = [by_vendor[v][i] if i < len(by_vendor[v]) else "" for v in vendors]
56+
table.add_row(*row)
57+
58+
console.print(table)
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
"""Integration tests for the `uipath list-models` CLI command.
2+
3+
The command renders a rich table grouped by vendor (one column per vendor)
4+
for human terminal use, and falls through to the shared `format_output`
5+
pipeline for `--format json|csv` and `--output <file>`.
6+
"""
7+
8+
import json
9+
from unittest.mock import AsyncMock, MagicMock, patch
10+
11+
import pytest
12+
from click.testing import CliRunner
13+
14+
from uipath._cli import cli
15+
from uipath.platform.agenthub import LlmModel
16+
17+
18+
@pytest.fixture
19+
def runner():
20+
"""Provide a Click CLI test runner."""
21+
return CliRunner()
22+
23+
24+
@pytest.fixture
25+
def mock_client():
26+
"""Provide a mocked UiPath client with an async agenthub service."""
27+
with patch("uipath.platform._uipath.UiPath") as mock:
28+
client_instance = MagicMock()
29+
mock.return_value = client_instance
30+
31+
client_instance.agenthub = MagicMock()
32+
client_instance.agenthub.get_available_llm_models_async = AsyncMock()
33+
34+
yield client_instance
35+
36+
37+
def _make_models() -> list[LlmModel]:
38+
"""Build a small list of LlmModel instances spanning multiple vendors."""
39+
return [
40+
LlmModel(model_name="gpt-4o-mini", vendor="OpenAi"),
41+
LlmModel(model_name="gpt-4.1", vendor="OpenAi"),
42+
LlmModel(model_name="claude-sonnet-4-5", vendor="Anthropic"),
43+
LlmModel(model_name="gemini-2.5-flash", vendor="VertexAi"),
44+
]
45+
46+
47+
class TestRichTable:
48+
def test_renders_each_model_and_vendor(self, runner, mock_client, mock_env_vars):
49+
"""All models and vendor headers appear in the rendered table."""
50+
mock_client.agenthub.get_available_llm_models_async.return_value = (
51+
_make_models()
52+
)
53+
54+
result = runner.invoke(cli, ["list-models"])
55+
56+
assert result.exit_code == 0
57+
for model in _make_models():
58+
assert model.model_name in result.output
59+
assert (model.vendor or "") in result.output
60+
mock_client.agenthub.get_available_llm_models_async.assert_awaited_once()
61+
62+
def test_table_title(self, runner, mock_client, mock_env_vars):
63+
"""The rich table renders its title for orientation."""
64+
mock_client.agenthub.get_available_llm_models_async.return_value = (
65+
_make_models()
66+
)
67+
68+
result = runner.invoke(cli, ["list-models"])
69+
70+
assert result.exit_code == 0
71+
assert "Available LLM Models" in result.output
72+
73+
def test_missing_vendor_grouped_under_unknown(
74+
self, runner, mock_client, mock_env_vars
75+
):
76+
"""A model with no vendor lands in an 'Unknown' column."""
77+
mock_client.agenthub.get_available_llm_models_async.return_value = [
78+
LlmModel(model_name="custom-model", vendor=None),
79+
]
80+
81+
result = runner.invoke(cli, ["list-models"])
82+
83+
assert result.exit_code == 0
84+
assert "custom-model" in result.output
85+
assert "Unknown" in result.output
86+
87+
def test_empty(self, runner, mock_client, mock_env_vars):
88+
"""An empty model list renders the title without rows or errors."""
89+
mock_client.agenthub.get_available_llm_models_async.return_value = []
90+
91+
result = runner.invoke(cli, ["list-models"])
92+
93+
assert result.exit_code == 0
94+
assert "Available LLM Models" in result.output
95+
96+
97+
class TestMachineReadableFormats:
98+
def test_json_format(self, runner, mock_client, mock_env_vars):
99+
"""--format json bypasses the rich table and emits parseable JSON."""
100+
mock_client.agenthub.get_available_llm_models_async.return_value = (
101+
_make_models()
102+
)
103+
104+
result = runner.invoke(cli, ["list-models", "--format", "json"])
105+
106+
assert result.exit_code == 0
107+
payload = json.loads(result.output)
108+
assert isinstance(payload, list)
109+
assert {m["model_name"] for m in payload} == {
110+
"gpt-4o-mini",
111+
"gpt-4.1",
112+
"claude-sonnet-4-5",
113+
"gemini-2.5-flash",
114+
}
115+
116+
def test_csv_format(self, runner, mock_client, mock_env_vars):
117+
"""--format csv emits a header row and one row per model."""
118+
mock_client.agenthub.get_available_llm_models_async.return_value = (
119+
_make_models()
120+
)
121+
122+
result = runner.invoke(cli, ["list-models", "--format", "csv"])
123+
124+
assert result.exit_code == 0
125+
lines = [line for line in result.output.splitlines() if line.strip()]
126+
assert "model_name" in lines[0]
127+
assert "vendor" in lines[0]
128+
assert any("gpt-4o-mini" in line for line in lines[1:])
129+
130+
def test_global_json_flag(self, runner, mock_client, mock_env_vars):
131+
"""The cli-group --format json is honored too."""
132+
mock_client.agenthub.get_available_llm_models_async.return_value = (
133+
_make_models()
134+
)
135+
136+
result = runner.invoke(cli, ["--format", "json", "list-models"])
137+
138+
assert result.exit_code == 0
139+
payload = json.loads(result.output)
140+
assert len(payload) == 4
141+
142+
def test_output_to_file_uses_plain_formatter(
143+
self, runner, mock_client, mock_env_vars, tmp_path
144+
):
145+
"""--output writes through the plain ASCII formatter (not rich ANSI)."""
146+
mock_client.agenthub.get_available_llm_models_async.return_value = (
147+
_make_models()
148+
)
149+
out_file = tmp_path / "models.json"
150+
151+
result = runner.invoke(
152+
cli,
153+
["list-models", "--format", "json", "--output", str(out_file)],
154+
)
155+
156+
assert result.exit_code == 0
157+
assert out_file.exists()
158+
payload = json.loads(out_file.read_text(encoding="utf-8"))
159+
assert {m["model_name"] for m in payload} == {
160+
"gpt-4o-mini",
161+
"gpt-4.1",
162+
"claude-sonnet-4-5",
163+
"gemini-2.5-flash",
164+
}
165+
166+
167+
class TestErrorPaths:
168+
def test_service_error(self, runner, mock_client, mock_env_vars):
169+
"""Exceptions from the service are surfaced as click errors."""
170+
mock_client.agenthub.get_available_llm_models_async.side_effect = (
171+
RuntimeError("boom")
172+
)
173+
174+
result = runner.invoke(cli, ["list-models"])
175+
176+
assert result.exit_code != 0
177+
assert "boom" in result.output
178+
179+
def test_missing_url(self, runner, monkeypatch):
180+
"""Missing UIPATH_URL surfaces an auth-configuration error."""
181+
monkeypatch.delenv("UIPATH_URL", raising=False)
182+
monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "mock_token")
183+
184+
result = runner.invoke(cli, ["list-models"])
185+
186+
assert result.exit_code != 0
187+
assert "UIPATH_URL not configured" in result.output
188+
189+
def test_missing_token(self, runner, monkeypatch):
190+
"""Missing UIPATH_ACCESS_TOKEN surfaces an auth-configuration error."""
191+
monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant")
192+
monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False)
193+
194+
result = runner.invoke(cli, ["list-models"])
195+
196+
assert result.exit_code != 0
197+
assert "Authentication required" in result.output
198+
199+
200+
class TestRegistration:
201+
def test_help_text(self, runner):
202+
"""--help surfaces the command description and options."""
203+
result = runner.invoke(cli, ["list-models", "--help"])
204+
205+
assert result.exit_code == 0
206+
assert "List available LLM models" in result.output
207+
assert "--format" in result.output
208+
assert "--output" in result.output
209+
210+
def test_registered_in_cli(self, runner):
211+
"""The command is wired into the top-level CLI group."""
212+
result = runner.invoke(cli, ["--help"])
213+
214+
assert result.exit_code == 0
215+
assert "list-models" in result.output

packages/uipath/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)