agentstack-cli 0.6.0rc1__py3-none-manylinux_2_34_aarch64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentstack_cli/__init__.py +164 -0
- agentstack_cli/api.py +160 -0
- agentstack_cli/async_typer.py +113 -0
- agentstack_cli/auth_manager.py +242 -0
- agentstack_cli/commands/__init__.py +3 -0
- agentstack_cli/commands/agent.py +1386 -0
- agentstack_cli/commands/build.py +222 -0
- agentstack_cli/commands/connector.py +301 -0
- agentstack_cli/commands/model.py +653 -0
- agentstack_cli/commands/platform/__init__.py +198 -0
- agentstack_cli/commands/platform/base_driver.py +217 -0
- agentstack_cli/commands/platform/lima_driver.py +277 -0
- agentstack_cli/commands/platform/wsl_driver.py +229 -0
- agentstack_cli/commands/self.py +213 -0
- agentstack_cli/commands/server.py +315 -0
- agentstack_cli/commands/user.py +87 -0
- agentstack_cli/configuration.py +79 -0
- agentstack_cli/console.py +25 -0
- agentstack_cli/data/.gitignore +2 -0
- agentstack_cli/data/helm-chart.tgz +0 -0
- agentstack_cli/data/lima-guestagent.Linux-aarch64.gz +0 -0
- agentstack_cli/data/limactl +0 -0
- agentstack_cli/utils.py +389 -0
- agentstack_cli-0.6.0rc1.dist-info/METADATA +107 -0
- agentstack_cli-0.6.0rc1.dist-info/RECORD +27 -0
- agentstack_cli-0.6.0rc1.dist-info/WHEEL +4 -0
- agentstack_cli-0.6.0rc1.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import typing
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import typer
|
|
14
|
+
from agentstack_sdk.platform import (
|
|
15
|
+
ModelCapability,
|
|
16
|
+
ModelProvider,
|
|
17
|
+
ModelProviderType,
|
|
18
|
+
SystemConfiguration,
|
|
19
|
+
)
|
|
20
|
+
from InquirerPy import inquirer
|
|
21
|
+
from InquirerPy.base.control import Choice
|
|
22
|
+
from InquirerPy.validator import EmptyInputValidator
|
|
23
|
+
from rich.table import Column
|
|
24
|
+
|
|
25
|
+
from agentstack_cli.api import openai_client
|
|
26
|
+
from agentstack_cli.async_typer import AsyncTyper, console, create_table
|
|
27
|
+
from agentstack_cli.configuration import Configuration
|
|
28
|
+
from agentstack_cli.utils import announce_server_action, confirm_server_action, run_command, verbosity
|
|
29
|
+
|
|
30
|
+
app = AsyncTyper()
|
|
31
|
+
configuration = Configuration()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ModelProviderError(Exception): ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@functools.cache
|
|
38
|
+
def _ollama_exe() -> str:
|
|
39
|
+
for exe in ("ollama", "ollama.exe", os.environ.get("LOCALAPPDATA", "") + "\\Programs\\Ollama\\ollama.exe"):
|
|
40
|
+
if shutil.which(exe):
|
|
41
|
+
return exe
|
|
42
|
+
raise RuntimeError("Ollama executable not found")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
RECOMMENDED_LLM_MODELS = [
|
|
46
|
+
f"{ModelProviderType.WATSONX}:ibm/granite-3-3-8b-instruct",
|
|
47
|
+
f"{ModelProviderType.OPENAI}:gpt-4o",
|
|
48
|
+
f"{ModelProviderType.ANTHROPIC}:claude-sonnet-4-20250514",
|
|
49
|
+
f"{ModelProviderType.CEREBRAS}:llama-3.3-70b",
|
|
50
|
+
f"{ModelProviderType.CHUTES}:deepseek-ai/DeepSeek-R1",
|
|
51
|
+
f"{ModelProviderType.COHERE}:command-r-plus",
|
|
52
|
+
f"{ModelProviderType.DEEPSEEK}:deepseek-reasoner",
|
|
53
|
+
f"{ModelProviderType.GEMINI}:models/gemini-2.5-pro",
|
|
54
|
+
f"{ModelProviderType.GITHUB}:openai/gpt-4o",
|
|
55
|
+
f"{ModelProviderType.GROQ}:meta-llama/llama-4-maverick-17b-128e-instruct",
|
|
56
|
+
f"{ModelProviderType.MISTRAL}:mistral-large-latest",
|
|
57
|
+
f"{ModelProviderType.MOONSHOT}:kimi-latest",
|
|
58
|
+
f"{ModelProviderType.NVIDIA}:deepseek-ai/deepseek-r1",
|
|
59
|
+
f"{ModelProviderType.OLLAMA}:granite3.3:8b",
|
|
60
|
+
f"{ModelProviderType.OPENROUTER}:deepseek/deepseek-r1-distill-llama-70b:free",
|
|
61
|
+
f"{ModelProviderType.TOGETHER}:deepseek-ai/DeepSeek-R1",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
RECOMMENDED_EMBEDDING_MODELS = [
|
|
65
|
+
f"{ModelProviderType.WATSONX}:ibm/granite-embedding-278m-multilingual",
|
|
66
|
+
f"{ModelProviderType.OPENAI}:text-embedding-3-small",
|
|
67
|
+
f"{ModelProviderType.COHERE}:embed-multilingual-v3.0",
|
|
68
|
+
f"{ModelProviderType.GEMINI}:models/gemini-embedding-001",
|
|
69
|
+
f"{ModelProviderType.MISTRAL}:mistral-embed",
|
|
70
|
+
f"{ModelProviderType.OLLAMA}:nomic-embed-text:latest",
|
|
71
|
+
f"{ModelProviderType.VOYAGE}:voyage-3.5",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
LLM_PROVIDERS = [
|
|
75
|
+
Choice(
|
|
76
|
+
name="Anthropic Claude".ljust(20),
|
|
77
|
+
value=(ModelProviderType.ANTHROPIC, "Anthropic Claude", "https://api.anthropic.com/v1"),
|
|
78
|
+
),
|
|
79
|
+
Choice(
|
|
80
|
+
name="Cerebras".ljust(20) + "🆓 has a free tier",
|
|
81
|
+
value=(ModelProviderType.CEREBRAS, "Cerebras", "https://api.cerebras.ai/v1"),
|
|
82
|
+
),
|
|
83
|
+
Choice(
|
|
84
|
+
name="Chutes".ljust(20) + "🆓 has a free tier",
|
|
85
|
+
value=(ModelProviderType.CHUTES, "Chutes", "https://llm.chutes.ai/v1"),
|
|
86
|
+
),
|
|
87
|
+
Choice(
|
|
88
|
+
name="Cohere".ljust(20) + "🆓 has a free tier",
|
|
89
|
+
value=(ModelProviderType.COHERE, "Cohere", "https://api.cohere.ai/compatibility/v1"),
|
|
90
|
+
),
|
|
91
|
+
Choice(name="DeepSeek", value=(ModelProviderType.DEEPSEEK, "DeepSeek", "https://api.deepseek.com/v1")),
|
|
92
|
+
Choice(
|
|
93
|
+
name="Google Gemini".ljust(20) + "🆓 has a free tier",
|
|
94
|
+
value=(ModelProviderType.GEMINI, "Google Gemini", "https://generativelanguage.googleapis.com/v1beta/openai"),
|
|
95
|
+
),
|
|
96
|
+
Choice(
|
|
97
|
+
name="GitHub Models".ljust(20) + "🆓 has a free tier",
|
|
98
|
+
value=(ModelProviderType.GITHUB, "GitHub Models", "https://models.github.ai/inference"),
|
|
99
|
+
),
|
|
100
|
+
Choice(
|
|
101
|
+
name="Groq".ljust(20) + "🆓 has a free tier",
|
|
102
|
+
value=(ModelProviderType.GROQ, "Groq", "https://api.groq.com/openai/v1"),
|
|
103
|
+
),
|
|
104
|
+
Choice(name="IBM watsonx".ljust(20), value=(ModelProviderType.WATSONX, "IBM watsonx", None)),
|
|
105
|
+
Choice(name="Jan".ljust(20) + "💻 local", value=(ModelProviderType.JAN, "Jan", "http://localhost:1337/v1")),
|
|
106
|
+
Choice(
|
|
107
|
+
name="Mistral".ljust(20) + "🆓 has a free tier",
|
|
108
|
+
value=(ModelProviderType.MISTRAL, "Mistral", "https://api.mistral.ai/v1"),
|
|
109
|
+
),
|
|
110
|
+
Choice(
|
|
111
|
+
name="Moonshot AI".ljust(20),
|
|
112
|
+
value=(ModelProviderType.MOONSHOT, "Moonshot AI", "https://api.moonshot.ai/v1"),
|
|
113
|
+
),
|
|
114
|
+
Choice(
|
|
115
|
+
name="NVIDIA NIM".ljust(20),
|
|
116
|
+
value=(ModelProviderType.NVIDIA, "NVIDIA NIM", "https://integrate.api.nvidia.com/v1"),
|
|
117
|
+
),
|
|
118
|
+
Choice(
|
|
119
|
+
name="Ollama".ljust(20) + "💻 local",
|
|
120
|
+
value=(ModelProviderType.OLLAMA, "Ollama", "http://localhost:11434/v1"),
|
|
121
|
+
),
|
|
122
|
+
Choice(
|
|
123
|
+
name="OpenAI".ljust(20),
|
|
124
|
+
value=(ModelProviderType.OPENAI, "OpenAI", "https://api.openai.com/v1"),
|
|
125
|
+
),
|
|
126
|
+
Choice(
|
|
127
|
+
name="OpenRouter".ljust(20) + "🆓 has some free models",
|
|
128
|
+
value=(ModelProviderType.OPENROUTER, "OpenRouter", "https://openrouter.ai/api/v1"),
|
|
129
|
+
),
|
|
130
|
+
Choice(
|
|
131
|
+
name="Perplexity".ljust(20),
|
|
132
|
+
value=(ModelProviderType.PERPLEXITY, "Perplexity", "https://api.perplexity.ai"),
|
|
133
|
+
),
|
|
134
|
+
Choice(
|
|
135
|
+
name="Together.ai".ljust(20) + "🆓 has a free tier",
|
|
136
|
+
value=(ModelProviderType.TOGETHER, "together.ai", "https://api.together.xyz/v1"),
|
|
137
|
+
),
|
|
138
|
+
Choice(
|
|
139
|
+
name="🛠️ Other (RITS, Amazon Bedrock, vLLM, ..., any OpenAI-compatible API)",
|
|
140
|
+
value=(ModelProviderType.OTHER, "Other", None),
|
|
141
|
+
),
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
EMBEDDING_PROVIDERS = [
|
|
145
|
+
Choice(
|
|
146
|
+
name="Cohere".ljust(20) + "🆓 has a free tier",
|
|
147
|
+
value=(ModelProviderType.COHERE, "Cohere", "https://api.cohere.ai/compatibility/v1"),
|
|
148
|
+
),
|
|
149
|
+
Choice(
|
|
150
|
+
name="Google Gemini".ljust(20) + "🆓 has a free tier",
|
|
151
|
+
value=(ModelProviderType.GEMINI, "Gemini", "https://generativelanguage.googleapis.com/v1beta/openai"),
|
|
152
|
+
),
|
|
153
|
+
Choice(
|
|
154
|
+
name="IBM watsonx".ljust(20),
|
|
155
|
+
value=(ModelProviderType.WATSONX, "IBM watsonx", None),
|
|
156
|
+
),
|
|
157
|
+
Choice(
|
|
158
|
+
name="Mistral".ljust(20) + "🆓 has a free tier",
|
|
159
|
+
value=(ModelProviderType.MISTRAL, "Mistral", "https://api.mistral.ai/v1"),
|
|
160
|
+
),
|
|
161
|
+
Choice(
|
|
162
|
+
name="Ollama".ljust(20) + "💻 local",
|
|
163
|
+
value=(ModelProviderType.OLLAMA, "Ollama", "http://localhost:11434/v1"),
|
|
164
|
+
),
|
|
165
|
+
Choice(
|
|
166
|
+
name="OpenAI".ljust(20),
|
|
167
|
+
value=(ModelProviderType.OPENAI, "OpenAI", "https://api.openai.com/v1"),
|
|
168
|
+
),
|
|
169
|
+
Choice(
|
|
170
|
+
name="Voyage".ljust(20),
|
|
171
|
+
value=(ModelProviderType.VOYAGE, "Voyage", "https://api.voyageai.com/v1"),
|
|
172
|
+
),
|
|
173
|
+
Choice(
|
|
174
|
+
name="🛠️ Other (Amazon Bedrock, vLLM, ..., any OpenAI-compatible API)",
|
|
175
|
+
value=(ModelProviderType.OTHER, "Other", None),
|
|
176
|
+
),
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def _add_provider(capability: ModelCapability, use_true_localhost: bool = False) -> ModelProvider:
|
|
181
|
+
provider_type: str
|
|
182
|
+
provider_name: str
|
|
183
|
+
base_url: str
|
|
184
|
+
watsonx_project_id, watsonx_space_id = None, None
|
|
185
|
+
choices = LLM_PROVIDERS if capability == ModelCapability.LLM else EMBEDDING_PROVIDERS
|
|
186
|
+
provider_type, provider_name, base_url = await inquirer.fuzzy( # type: ignore
|
|
187
|
+
message=f"Select {capability} provider (type to search):", choices=choices
|
|
188
|
+
).execute_async()
|
|
189
|
+
|
|
190
|
+
watsonx_project_or_space: str = ""
|
|
191
|
+
watsonx_project_or_space_id: str = ""
|
|
192
|
+
|
|
193
|
+
if provider_type == ModelProviderType.OTHER:
|
|
194
|
+
base_url: str = await inquirer.text( # type: ignore
|
|
195
|
+
message="Enter the base URL of your API (OpenAI-compatible):",
|
|
196
|
+
validate=lambda url: (url.startswith(("http://", "https://")) or "URL must start with http:// or https://"), # type: ignore
|
|
197
|
+
transformer=lambda url: url.rstrip("/"),
|
|
198
|
+
).execute_async()
|
|
199
|
+
if re.match(r"^https://[a-z0-9.-]+\.rits\.fmaas\.res\.ibm\.com/.*$", base_url):
|
|
200
|
+
provider_type = ModelProviderType.RITS
|
|
201
|
+
if not base_url.endswith("/v1"):
|
|
202
|
+
base_url = base_url.removesuffix("/") + "/v1"
|
|
203
|
+
|
|
204
|
+
if provider_type == ModelProviderType.WATSONX:
|
|
205
|
+
region: str = await inquirer.select( # type: ignore
|
|
206
|
+
message="Select IBM Cloud region:",
|
|
207
|
+
choices=[
|
|
208
|
+
Choice(name="us-south", value="us-south"),
|
|
209
|
+
Choice(name="ca-tor", value="ca-tor"),
|
|
210
|
+
Choice(name="eu-gb", value="eu-gb"),
|
|
211
|
+
Choice(name="eu-de", value="eu-de"),
|
|
212
|
+
Choice(name="jp-tok", value="jp-tok"),
|
|
213
|
+
Choice(name="au-syd", value="au-syd"),
|
|
214
|
+
],
|
|
215
|
+
).execute_async()
|
|
216
|
+
base_url: str = f"""https://{region}.ml.cloud.ibm.com"""
|
|
217
|
+
watsonx_project_or_space: str = await inquirer.select( # type:ignore
|
|
218
|
+
"Use a Project or a Space?", choices=["project", "space"]
|
|
219
|
+
).execute_async()
|
|
220
|
+
if (
|
|
221
|
+
not (watsonx_project_or_space_id := os.environ.get(f"WATSONX_{watsonx_project_or_space.upper()}_ID", ""))
|
|
222
|
+
or not await inquirer.confirm( # type:ignore
|
|
223
|
+
message=f"Use the {watsonx_project_or_space} id from environment variable 'WATSONX_{watsonx_project_or_space.upper()}_ID'?",
|
|
224
|
+
default=True,
|
|
225
|
+
).execute_async()
|
|
226
|
+
):
|
|
227
|
+
watsonx_project_or_space_id = await inquirer.text( # type:ignore
|
|
228
|
+
message=f"Enter the {watsonx_project_or_space} id:"
|
|
229
|
+
).execute_async()
|
|
230
|
+
|
|
231
|
+
watsonx_project_id = watsonx_project_or_space_id if watsonx_project_or_space == "project" else None
|
|
232
|
+
watsonx_space_id = watsonx_project_or_space_id if watsonx_project_or_space == "space" else None
|
|
233
|
+
|
|
234
|
+
if (api_key := os.environ.get(f"{provider_type.upper()}_API_KEY")) is None or not await inquirer.confirm( # type: ignore
|
|
235
|
+
message=f"Use the API key from environment variable '{provider_type.upper()}_API_KEY'?",
|
|
236
|
+
default=True,
|
|
237
|
+
).execute_async():
|
|
238
|
+
api_key: str = (
|
|
239
|
+
"dummy"
|
|
240
|
+
if provider_type in {ModelProviderType.OLLAMA, ModelProviderType.JAN}
|
|
241
|
+
else await inquirer.secret(message="Enter API key:", validate=EmptyInputValidator()).execute_async() # type: ignore
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
if provider_type == ModelProviderType.OLLAMA:
|
|
246
|
+
console.print()
|
|
247
|
+
console.hint(
|
|
248
|
+
"If you are struggling with ollama performance, try increasing the context "
|
|
249
|
+
+ "length in ollama UI settings or using an environment variable in the CLI: OLLAMA_CONTEXT_LENGTH=8192"
|
|
250
|
+
+ "\nMore information: https://github.com/ollama/ollama/blob/main/docs/faq.md#how-can-i-specify-the-context-window-size\n\n"
|
|
251
|
+
)
|
|
252
|
+
async with httpx.AsyncClient() as client:
|
|
253
|
+
response = (await client.get(f"{base_url}/models", timeout=30.0)).raise_for_status().json()
|
|
254
|
+
available_models = [m.get("id", "") for m in response.get("data", []) or []]
|
|
255
|
+
[recommended_llm_model] = [m for m in RECOMMENDED_LLM_MODELS if m.startswith(ModelProviderType.OLLAMA)]
|
|
256
|
+
[recommended_embedding_model] = [
|
|
257
|
+
m for m in RECOMMENDED_EMBEDDING_MODELS if m.startswith(ModelProviderType.OLLAMA)
|
|
258
|
+
]
|
|
259
|
+
recommended_llm_model = recommended_llm_model.removeprefix(f"{ModelProviderType.OLLAMA}:")
|
|
260
|
+
recommended_embedding_model = recommended_embedding_model.removeprefix(f"{ModelProviderType.OLLAMA}:")
|
|
261
|
+
|
|
262
|
+
if recommended_llm_model not in available_models:
|
|
263
|
+
message = f"Do you want to pull the recommended LLM model '{recommended_llm_model}'?"
|
|
264
|
+
if not available_models:
|
|
265
|
+
message = f"There are no locally available models in Ollama. {message}"
|
|
266
|
+
if await inquirer.confirm(message, default=True).execute_async(): # type: ignore
|
|
267
|
+
await run_command(
|
|
268
|
+
[_ollama_exe(), "pull", recommended_llm_model], "Pulling the selected model", check=True
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
if recommended_embedding_model not in available_models and (
|
|
272
|
+
await inquirer.confirm( # type: ignore
|
|
273
|
+
message=f"Do you want to pull the recommended embedding model '{recommended_embedding_model}'?",
|
|
274
|
+
default=True,
|
|
275
|
+
).execute_async()
|
|
276
|
+
):
|
|
277
|
+
await run_command(
|
|
278
|
+
[_ollama_exe(), "pull", recommended_embedding_model], "Pulling the selected model", check=True
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if not use_true_localhost:
|
|
282
|
+
base_url = re.sub(r"localhost|127\.0\.0\.1", "host.docker.internal", base_url)
|
|
283
|
+
|
|
284
|
+
with console.status("Saving configuration...", spinner="dots"):
|
|
285
|
+
return await ModelProvider.create(
|
|
286
|
+
name=provider_name,
|
|
287
|
+
type=ModelProviderType(provider_type),
|
|
288
|
+
base_url=base_url,
|
|
289
|
+
api_key=api_key,
|
|
290
|
+
watsonx_space_id=watsonx_space_id,
|
|
291
|
+
watsonx_project_id=watsonx_project_id,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
except httpx.HTTPError as e:
|
|
295
|
+
if hasattr(e, "response") and hasattr(e.response, "json"): # pyright: ignore [reportAttributeAccessIssue]
|
|
296
|
+
err = str(e.response.json().get("detail", str(e))) # pyright: ignore [reportAttributeAccessIssue]
|
|
297
|
+
else:
|
|
298
|
+
err = str(e)
|
|
299
|
+
match provider_type:
|
|
300
|
+
case ModelProviderType.OLLAMA:
|
|
301
|
+
err += "\n\n💡 [bright_cyan]HINT[/bright_cyan]: We could not connect to Ollama. Is it running?"
|
|
302
|
+
case ModelProviderType.JAN:
|
|
303
|
+
err += (
|
|
304
|
+
"\n\n💡 [bright_cyan]HINT[/bright_cyan]: We could not connect to Jan. Ensure that the server is running: "
|
|
305
|
+
"in the Jan application, click the [bold][<>][/bold] button and [bold]Start server[/bold]."
|
|
306
|
+
)
|
|
307
|
+
case ModelProviderType.OTHER:
|
|
308
|
+
err += (
|
|
309
|
+
"\n\n💡 [bright_cyan]HINT[/bright_cyan]: We could not connect to the API URL you have specified."
|
|
310
|
+
"Is it correct?"
|
|
311
|
+
)
|
|
312
|
+
case _:
|
|
313
|
+
err += f"\n\n💡 [bright_cyan]HINT[/bright_cyan]: {provider_type} may be down or API key is invalid"
|
|
314
|
+
raise ModelProviderError(err) from e
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
async def _select_default_model(capability: ModelCapability) -> str | None:
|
|
318
|
+
async with openai_client() as client:
|
|
319
|
+
models = (await client.models.list()).data
|
|
320
|
+
|
|
321
|
+
recommended_models = RECOMMENDED_LLM_MODELS if capability == ModelCapability.LLM else RECOMMENDED_EMBEDDING_MODELS
|
|
322
|
+
|
|
323
|
+
available_models = {m.id for m in models if capability in m.model_dump()["provider"]["capabilities"]}
|
|
324
|
+
if not available_models:
|
|
325
|
+
raise ModelProviderError(
|
|
326
|
+
f"[bold]No models are available[/bold]\n"
|
|
327
|
+
f"Configure at least one working {capability} provider using `agentstack model add` command."
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
recommended_model = [m for m in recommended_models if m in available_models]
|
|
331
|
+
recommended_model = recommended_model[0] if recommended_model else None
|
|
332
|
+
|
|
333
|
+
console.print(f"\n[bold]Configure default model for {capability}[/bold]:")
|
|
334
|
+
|
|
335
|
+
selected_model = (
|
|
336
|
+
recommended_model
|
|
337
|
+
if recommended_model
|
|
338
|
+
and await inquirer.confirm( # type: ignore
|
|
339
|
+
message=f"Do you want to use the recommended model as default: '{recommended_model}'?",
|
|
340
|
+
default=True,
|
|
341
|
+
).execute_async()
|
|
342
|
+
else (
|
|
343
|
+
await inquirer.fuzzy( # type: ignore
|
|
344
|
+
message="Select a model to be used as default (type to search):",
|
|
345
|
+
choices=sorted(available_models),
|
|
346
|
+
).execute_async()
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
assert selected_model, "No model selected"
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
with console.status("Checking if the model works...", spinner="dots"):
|
|
353
|
+
async with openai_client() as client:
|
|
354
|
+
if capability == ModelCapability.LLM:
|
|
355
|
+
test_response = await client.chat.completions.create(
|
|
356
|
+
model=selected_model,
|
|
357
|
+
# reasoning models need some tokens to think about this
|
|
358
|
+
max_completion_tokens=500 if not selected_model.startswith("mistral") else None,
|
|
359
|
+
messages=[
|
|
360
|
+
{
|
|
361
|
+
"role": "system",
|
|
362
|
+
"content": "Repeat each message back to the user, verbatim. Don't say anything else.",
|
|
363
|
+
},
|
|
364
|
+
{"role": "user", "content": "Hello!"},
|
|
365
|
+
],
|
|
366
|
+
)
|
|
367
|
+
if not test_response.choices or "Hello" not in (test_response.choices[0].message.content or ""):
|
|
368
|
+
raise ModelProviderError("Model did not provide a proper response.")
|
|
369
|
+
else:
|
|
370
|
+
test_response = await client.embeddings.create(model=selected_model, input="Hello!")
|
|
371
|
+
if not test_response.data or not test_response.data[0].embedding:
|
|
372
|
+
raise ModelProviderError("Model did not provide a proper response.")
|
|
373
|
+
return selected_model
|
|
374
|
+
except ModelProviderError:
|
|
375
|
+
raise
|
|
376
|
+
except Exception as ex:
|
|
377
|
+
raise ModelProviderError(f"Error during model test: {ex!s}") from ex
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@app.command("list")
|
|
381
|
+
async def list_models():
|
|
382
|
+
"""List all available models."""
|
|
383
|
+
announce_server_action("Listing models on")
|
|
384
|
+
async with configuration.use_platform_client():
|
|
385
|
+
config = await SystemConfiguration.get()
|
|
386
|
+
async with openai_client() as client:
|
|
387
|
+
models = (await client.models.list()).data
|
|
388
|
+
max_id_len = max(len(model.id) for model in models) if models else 0
|
|
389
|
+
max_col_len = max_id_len + len(" (default embedding)")
|
|
390
|
+
with create_table(
|
|
391
|
+
Column("Id", width=max_col_len),
|
|
392
|
+
Column("Owned by"),
|
|
393
|
+
Column("Created", ratio=1),
|
|
394
|
+
) as model_table:
|
|
395
|
+
for model in sorted(models, key=lambda m: m.id):
|
|
396
|
+
model_id = model.id.ljust(max_id_len)
|
|
397
|
+
if config.default_embedding_model == model.id:
|
|
398
|
+
model_id += " [blue][bold](default embedding)[/bold][/blue]"
|
|
399
|
+
if config.default_llm_model == model.id:
|
|
400
|
+
model_id += " [green][bold](default llm)[/bold][/green]"
|
|
401
|
+
model_table.add_row(
|
|
402
|
+
model_id, model.owned_by, datetime.fromtimestamp(model.created).strftime("%Y-%m-%d")
|
|
403
|
+
)
|
|
404
|
+
console.print(model_table)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
async def _reset_configuration(existing_providers: list[ModelProvider] | None = None):
|
|
408
|
+
if not existing_providers:
|
|
409
|
+
existing_providers = await ModelProvider.list()
|
|
410
|
+
for provider in existing_providers:
|
|
411
|
+
await provider.delete()
|
|
412
|
+
await SystemConfiguration.update(default_embedding_model=None, default_llm_model=None)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@app.command("setup", help="Interactive setup for LLM and embedding provider environment variables [Admin only]")
|
|
416
|
+
async def setup(
|
|
417
|
+
use_true_localhost: typing.Annotated[bool, typer.Option(hidden=True)] = False,
|
|
418
|
+
verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
|
|
419
|
+
):
|
|
420
|
+
"""Interactive setup for LLM and embedding provider environment variables"""
|
|
421
|
+
announce_server_action("Configuring model providers for")
|
|
422
|
+
|
|
423
|
+
with verbosity(verbose):
|
|
424
|
+
async with configuration.use_platform_client():
|
|
425
|
+
# Delete all existing providers
|
|
426
|
+
existing_providers = await ModelProvider.list()
|
|
427
|
+
if existing_providers:
|
|
428
|
+
console.warning("The following providers are already configured:\n")
|
|
429
|
+
_list_providers(existing_providers)
|
|
430
|
+
console.print()
|
|
431
|
+
if await inquirer.confirm( # type: ignore
|
|
432
|
+
message="Do you want to reset the configuration?", default=True
|
|
433
|
+
).execute_async():
|
|
434
|
+
with console.status("Resetting configuration...", spinner="dots"):
|
|
435
|
+
await _reset_configuration(existing_providers)
|
|
436
|
+
else:
|
|
437
|
+
console.print("[bold]Aborting[/bold] the setup.")
|
|
438
|
+
sys.exit(1)
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
console.print("[bold]Setting up LLM provider...[/bold]")
|
|
442
|
+
llm_provider = await _add_provider(ModelCapability.LLM, use_true_localhost=use_true_localhost)
|
|
443
|
+
default_llm_model = await _select_default_model(ModelCapability.LLM)
|
|
444
|
+
|
|
445
|
+
default_embedding_model = None
|
|
446
|
+
if (
|
|
447
|
+
ModelCapability.EMBEDDING in llm_provider.capabilities
|
|
448
|
+
and llm_provider.type
|
|
449
|
+
!= ModelProviderType.RITS # RITS does not support embeddings, but we treat it as OTHER
|
|
450
|
+
and (
|
|
451
|
+
llm_provider.type != ModelProviderType.OTHER # OTHER may not support embeddings, so we ask
|
|
452
|
+
or inquirer.confirm( # type: ignore
|
|
453
|
+
"Do you want to also set up an embedding model from the same provider?", default=True
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
):
|
|
457
|
+
default_embedding_model = await _select_default_model(ModelCapability.EMBEDDING)
|
|
458
|
+
elif await inquirer.confirm( # type: ignore
|
|
459
|
+
message="Do you want to configure an embedding provider? (recommended)", default=True
|
|
460
|
+
).execute_async():
|
|
461
|
+
console.print("[bold]Setting up embedding provider...[/bold]")
|
|
462
|
+
await _add_provider(capability=ModelCapability.EMBEDDING, use_true_localhost=use_true_localhost)
|
|
463
|
+
default_embedding_model = await _select_default_model(ModelCapability.EMBEDDING)
|
|
464
|
+
else:
|
|
465
|
+
console.hint("You can add an embedding provider later with: [green]agentstack model add[/green]")
|
|
466
|
+
|
|
467
|
+
with console.status("Saving configuration...", spinner="dots"):
|
|
468
|
+
await SystemConfiguration.update(
|
|
469
|
+
default_llm_model=default_llm_model,
|
|
470
|
+
default_embedding_model=default_embedding_model,
|
|
471
|
+
)
|
|
472
|
+
console.print(
|
|
473
|
+
"\n[bold green]You're all set![/bold green] "
|
|
474
|
+
"(You can re-run this setup anytime with [blue]agentstack model setup[/blue])"
|
|
475
|
+
)
|
|
476
|
+
except Exception:
|
|
477
|
+
await _reset_configuration()
|
|
478
|
+
raise
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@app.command("change | select | default", help="Change the default model [Admin only]")
|
|
482
|
+
async def select_default_model(
|
|
483
|
+
capability: typing.Annotated[
|
|
484
|
+
ModelCapability | None, typer.Argument(help="Which default model to change (llm/embedding)")
|
|
485
|
+
] = None,
|
|
486
|
+
model_id: typing.Annotated[str | None, typer.Argument(help="Model ID to be used as default")] = None,
|
|
487
|
+
yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
|
|
488
|
+
):
|
|
489
|
+
url = announce_server_action("Updating default model for")
|
|
490
|
+
await confirm_server_action("Proceed with updating default model on", url=url, yes=yes)
|
|
491
|
+
if not capability:
|
|
492
|
+
capability = await inquirer.select( # type: ignore
|
|
493
|
+
message="Which default model would you like to change?",
|
|
494
|
+
choices=[
|
|
495
|
+
Choice(name="llm", value=ModelCapability.LLM),
|
|
496
|
+
Choice(name="embedding", value=ModelCapability.EMBEDDING),
|
|
497
|
+
],
|
|
498
|
+
).execute_async()
|
|
499
|
+
|
|
500
|
+
assert capability
|
|
501
|
+
capability_name = str(getattr(capability, "value", capability)).lower()
|
|
502
|
+
await confirm_server_action(f"Proceed with updating the default {capability_name} model on", url=url, yes=yes)
|
|
503
|
+
async with configuration.use_platform_client():
|
|
504
|
+
model = model_id if model_id else await _select_default_model(capability)
|
|
505
|
+
conf = await SystemConfiguration.get()
|
|
506
|
+
default_llm_model = model if capability == ModelCapability.LLM else conf.default_llm_model
|
|
507
|
+
default_embedding_model = model if capability == ModelCapability.EMBEDDING else conf.default_embedding_model
|
|
508
|
+
with console.status("Saving configuration...", spinner="dots"):
|
|
509
|
+
await SystemConfiguration.update(
|
|
510
|
+
default_llm_model=default_llm_model,
|
|
511
|
+
default_embedding_model=default_embedding_model,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
model_provider_app = AsyncTyper()
|
|
516
|
+
app.add_typer(model_provider_app, name="provider")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _list_providers(providers: list[ModelProvider]):
|
|
520
|
+
with create_table(Column("Type"), Column("Name"), Column("Base URL", ratio=1)) as provider_table:
|
|
521
|
+
for provider in providers:
|
|
522
|
+
provider_table.add_row(provider.type, provider.name, str(provider.base_url))
|
|
523
|
+
console.print(provider_table)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@model_provider_app.command("list")
|
|
527
|
+
async def list_model_providers():
|
|
528
|
+
"""List all available model providers."""
|
|
529
|
+
announce_server_action("Listing model providers on")
|
|
530
|
+
async with configuration.use_platform_client():
|
|
531
|
+
providers = await ModelProvider.list()
|
|
532
|
+
_list_providers(providers)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@model_provider_app.command("add", help="Add a new model provider [Admin only]")
|
|
536
|
+
@app.command("add")
|
|
537
|
+
async def add_provider(
|
|
538
|
+
capability: typing.Annotated[
|
|
539
|
+
ModelCapability | None, typer.Argument(help="Which default model to change (llm/embedding)")
|
|
540
|
+
] = None,
|
|
541
|
+
):
|
|
542
|
+
"""Add a new model provider. [Admin only]"""
|
|
543
|
+
announce_server_action("Adding provider for")
|
|
544
|
+
if not capability:
|
|
545
|
+
capability = await inquirer.select( # type: ignore
|
|
546
|
+
message="Which default provider would you like to add?",
|
|
547
|
+
choices=[
|
|
548
|
+
Choice(name="llm", value=ModelCapability.LLM),
|
|
549
|
+
Choice(name="embedding", value=ModelCapability.EMBEDDING),
|
|
550
|
+
],
|
|
551
|
+
).execute_async()
|
|
552
|
+
|
|
553
|
+
assert capability
|
|
554
|
+
async with configuration.use_platform_client():
|
|
555
|
+
await _add_provider(capability)
|
|
556
|
+
|
|
557
|
+
conf = await SystemConfiguration.get()
|
|
558
|
+
default_model = conf.default_llm_model if capability == ModelCapability.LLM else conf.default_embedding_model
|
|
559
|
+
if not default_model:
|
|
560
|
+
default_model = await _select_default_model(capability)
|
|
561
|
+
default_llm = default_model if capability == ModelCapability.LLM else conf.default_llm_model
|
|
562
|
+
default_embedding = (
|
|
563
|
+
default_model if capability == ModelCapability.EMBEDDING else conf.default_embedding_model
|
|
564
|
+
)
|
|
565
|
+
with console.status("Saving configuration...", spinner="dots"):
|
|
566
|
+
await SystemConfiguration.update(
|
|
567
|
+
default_llm_model=default_llm, default_embedding_model=default_embedding
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _select_provider(providers: list[ModelProvider], search_path: str) -> ModelProvider:
|
|
572
|
+
search_path = search_path.lower()
|
|
573
|
+
provider_candidates = {p.id: p for p in providers if search_path in p.type.lower()}
|
|
574
|
+
provider_candidates.update({p.id: p for p in providers if search_path in str(p.base_url).lower()})
|
|
575
|
+
if len(provider_candidates) != 1:
|
|
576
|
+
provider_candidates = [f" - {c}" for c in provider_candidates]
|
|
577
|
+
remove_providers_detail = ":\n" + "\n".join(provider_candidates) if provider_candidates else ""
|
|
578
|
+
raise ValueError(f"{len(provider_candidates)} matching providers{remove_providers_detail}")
|
|
579
|
+
[selected_provider] = provider_candidates.values()
|
|
580
|
+
return selected_provider
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@model_provider_app.command("remove | rm | delete", help="Remove a model provider [Admin only]")
|
|
584
|
+
@app.command("remove | rm | delete")
|
|
585
|
+
async def remove_provider(
|
|
586
|
+
search_path: typing.Annotated[
|
|
587
|
+
str | None, typer.Argument(..., help="Provider type or part of the provider base url")
|
|
588
|
+
] = None,
|
|
589
|
+
yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
|
|
590
|
+
):
|
|
591
|
+
descriptor = search_path or "selected provider"
|
|
592
|
+
url = announce_server_action(f"Removing model provider '{descriptor}' from")
|
|
593
|
+
await confirm_server_action("Proceed with removing the selected model provider from", url=url, yes=yes)
|
|
594
|
+
async with configuration.use_platform_client():
|
|
595
|
+
conf = await SystemConfiguration.get()
|
|
596
|
+
|
|
597
|
+
async with configuration.use_platform_client():
|
|
598
|
+
providers = await ModelProvider.list()
|
|
599
|
+
|
|
600
|
+
if not search_path:
|
|
601
|
+
provider: ModelProvider = await inquirer.select( # type: ignore
|
|
602
|
+
message="Choose a provider to remove:",
|
|
603
|
+
choices=[Choice(name=f"{p.type} ({p.base_url})", value=p) for p in providers],
|
|
604
|
+
).execute_async()
|
|
605
|
+
else:
|
|
606
|
+
provider = _select_provider(providers, search_path)
|
|
607
|
+
|
|
608
|
+
await provider.delete()
|
|
609
|
+
|
|
610
|
+
default_llm = None if (conf.default_llm_model or "").startswith(provider.type) else conf.default_llm_model
|
|
611
|
+
default_embed = (
|
|
612
|
+
None if (conf.default_embedding_model or "").startswith(provider.type) else conf.default_embedding_model
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
try:
|
|
616
|
+
if (conf.default_llm_model or "").startswith(provider.type):
|
|
617
|
+
console.print("The provider was used as default llm model. Please select another one...")
|
|
618
|
+
default_llm = await _select_default_model(ModelCapability.LLM)
|
|
619
|
+
if (conf.default_embedding_model or "").startswith(provider.type):
|
|
620
|
+
console.print("The provider was used as default embedding model. Please select another one...")
|
|
621
|
+
default_embed = await _select_default_model(ModelCapability.EMBEDDING)
|
|
622
|
+
finally:
|
|
623
|
+
await SystemConfiguration.update(default_llm_model=default_llm, default_embedding_model=default_embed)
|
|
624
|
+
|
|
625
|
+
await list_model_providers()
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
async def ensure_llm_provider():
|
|
629
|
+
async with configuration.use_platform_client():
|
|
630
|
+
config = await SystemConfiguration.get()
|
|
631
|
+
async with openai_client() as client:
|
|
632
|
+
models = (await client.models.list()).data
|
|
633
|
+
models = {m.id for m in models}
|
|
634
|
+
|
|
635
|
+
inconsistent = False
|
|
636
|
+
if (config.default_embedding_model and config.default_embedding_model not in models) or (
|
|
637
|
+
config.default_llm_model and config.default_llm_model not in models
|
|
638
|
+
):
|
|
639
|
+
console.warning("Found inconsistent configuration: default model is not found in available models.")
|
|
640
|
+
inconsistent = True
|
|
641
|
+
|
|
642
|
+
if config.default_llm_model and not inconsistent:
|
|
643
|
+
return
|
|
644
|
+
|
|
645
|
+
console.print("[bold]Welcome to [red]Agent Stack[/red]![/bold]")
|
|
646
|
+
console.print("Let's start by configuring your LLM environment.\n")
|
|
647
|
+
try:
|
|
648
|
+
await setup()
|
|
649
|
+
except Exception:
|
|
650
|
+
console.error("Could not continue because the LLM environment is not properly set up.")
|
|
651
|
+
console.hint("Try re-entering your LLM API details with: [green]agentstack model setup[/green]")
|
|
652
|
+
raise
|
|
653
|
+
console.print()
|