Skip to content

Commit bc5cf01

Browse files
feat: add models-list command
1 parent cc24c8e commit bc5cf01

5 files changed

Lines changed: 302 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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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(
19+
"--output",
20+
"--output-file",
21+
"-o",
22+
type=click.Path(),
23+
help="File path where the output will be written",
24+
)
25+
@service_command
26+
async def list_models(ctx, format, output):
27+
"""List available LLM models."""
28+
client = ServiceCommandBase.get_client(ctx)
29+
models = await client.agenthub.get_available_llm_models_async()
30+
31+
fmt = format or get_cli_context(ctx).output_format
32+
if fmt == "table" and not output:
33+
_render_rich_table(models)
34+
return None
35+
return models
36+
37+
38+
def _render_rich_table(models: Iterable[LlmModel]) -> None:
39+
"""Render models as a rich table with one column per vendor."""
40+
by_vendor: dict[str, list[str]] = {}
41+
for model in models:
42+
vendor = model.vendor or "Unknown"
43+
by_vendor.setdefault(vendor, []).append(model.model_name)
44+
45+
console = Console()
46+
if not by_vendor:
47+
console.print("Available LLM Models: none")
48+
return
49+
50+
for names in by_vendor.values():
51+
names.sort()
52+
53+
vendors = sorted(by_vendor.keys())
54+
55+
table = Table(title="Available LLM Models", show_lines=False)
56+
for vendor in vendors:
57+
table.add_column(vendor, style="cyan", no_wrap=True)
58+
59+
max_rows = max(len(by_vendor[v]) for v in vendors)
60+
for i in range(max_rows):
61+
row = [by_vendor[v][i] if i < len(by_vendor[v]) else "" for v in vendors]
62+
table.add_row(*row)
63+
64+
console.print(table)
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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_writes_through_plain_formatter(
143+
self, runner, mock_client, mock_env_vars, tmp_path
144+
):
145+
"""--output writes through format_output (not the rich path)."""
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+
def test_output_file_alias(
167+
self, runner, mock_client, mock_env_vars, tmp_path
168+
):
169+
"""`--output-file` works as an alias for `--output` (matches `run`)."""
170+
mock_client.agenthub.get_available_llm_models_async.return_value = (
171+
_make_models()
172+
)
173+
out_file = tmp_path / "models.json"
174+
175+
result = runner.invoke(
176+
cli,
177+
["list-models", "--format", "json", "--output-file", str(out_file)],
178+
)
179+
180+
assert result.exit_code == 0
181+
assert out_file.exists()
182+
payload = json.loads(out_file.read_text(encoding="utf-8"))
183+
assert len(payload) == 4
184+
185+
186+
class TestErrorPaths:
187+
def test_service_error(self, runner, mock_client, mock_env_vars):
188+
"""Exceptions from the service are surfaced as click errors."""
189+
mock_client.agenthub.get_available_llm_models_async.side_effect = RuntimeError(
190+
"boom"
191+
)
192+
193+
result = runner.invoke(cli, ["list-models"])
194+
195+
assert result.exit_code != 0
196+
assert "boom" in result.output
197+
198+
def test_missing_url(self, runner, monkeypatch):
199+
"""Missing UIPATH_URL surfaces an auth-configuration error."""
200+
monkeypatch.delenv("UIPATH_URL", raising=False)
201+
monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "mock_token")
202+
203+
result = runner.invoke(cli, ["list-models"])
204+
205+
assert result.exit_code != 0
206+
assert "UIPATH_URL not configured" in result.output
207+
208+
def test_missing_token(self, runner, monkeypatch):
209+
"""Missing UIPATH_ACCESS_TOKEN surfaces an auth-configuration error."""
210+
monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant")
211+
monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False)
212+
213+
result = runner.invoke(cli, ["list-models"])
214+
215+
assert result.exit_code != 0
216+
assert "Authentication required" in result.output
217+
218+
219+
class TestRegistration:
220+
def test_help_text(self, runner):
221+
"""--help surfaces the command description and options."""
222+
result = runner.invoke(cli, ["list-models", "--help"])
223+
224+
assert result.exit_code == 0
225+
assert "List available LLM models" in result.output
226+
assert "--format" in result.output
227+
assert "--output" in result.output
228+
assert "--output-file" in result.output
229+
230+
def test_registered_in_cli(self, runner):
231+
"""The command is wired into the top-level CLI group."""
232+
result = runner.invoke(cli, ["--help"])
233+
234+
assert result.exit_code == 0
235+
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)