agentstack-cli 0.6.0rc1__py3-none-manylinux_2_34_x86_64.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.
@@ -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()