sandboxy 0.0.3__py3-none-any.whl → 0.0.5__py3-none-any.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.
sandboxy/cli/main.py CHANGED
@@ -98,6 +98,137 @@ def _load_variables_from_env() -> dict:
98
98
  return {}
99
99
 
100
100
 
101
+ def _export_to_mlflow(
102
+ result: Any,
103
+ spec: Any,
104
+ scenario_path: Path,
105
+ mlflow_export: bool,
106
+ no_mlflow: bool,
107
+ mlflow_tracking_uri: str | None,
108
+ mlflow_experiment: str | None,
109
+ agent_name: str = "default",
110
+ ) -> None:
111
+ """Export scenario result to MLflow if enabled.
112
+
113
+ Args:
114
+ result: ScenarioResult from runner
115
+ spec: ScenarioSpec
116
+ scenario_path: Path to scenario file
117
+ mlflow_export: --mlflow-export flag
118
+ no_mlflow: --no-mlflow flag
119
+ mlflow_tracking_uri: --mlflow-tracking-uri value
120
+ mlflow_experiment: --mlflow-experiment value
121
+ agent_name: Agent configuration name
122
+ """
123
+ from sandboxy.mlflow.config import MLflowConfig
124
+
125
+ # Get YAML config from spec
126
+ yaml_config = None
127
+ if spec.mlflow:
128
+ yaml_config = {
129
+ "enabled": spec.mlflow.enabled,
130
+ "experiment": spec.mlflow.experiment,
131
+ "tracking_uri": spec.mlflow.tracking_uri,
132
+ "tags": spec.mlflow.tags,
133
+ }
134
+
135
+ # Resolve config with precedence
136
+ config = MLflowConfig.resolve(
137
+ cli_export=mlflow_export,
138
+ cli_no_mlflow=no_mlflow,
139
+ cli_tracking_uri=mlflow_tracking_uri,
140
+ cli_experiment=mlflow_experiment,
141
+ yaml_config=yaml_config,
142
+ scenario_name=spec.name,
143
+ )
144
+
145
+ if not config.enabled:
146
+ return
147
+
148
+ # Import and use exporter
149
+ try:
150
+ from sandboxy.mlflow.exporter import MLflowExporter
151
+
152
+ exporter = MLflowExporter(config)
153
+
154
+ # Convert ScenarioResult to RunResult-like for exporter
155
+ # ScenarioResult has different structure, create adapter
156
+ run_id = exporter.export(
157
+ result=_adapt_scenario_result(result),
158
+ scenario_path=scenario_path,
159
+ scenario_name=spec.name,
160
+ scenario_id=spec.id,
161
+ agent_name=agent_name,
162
+ )
163
+
164
+ if run_id:
165
+ click.echo(f"\nExported to MLflow: run_id={run_id}")
166
+
167
+ except ImportError:
168
+ click.echo(
169
+ "\nMLflow not installed. Install with: pip install sandboxy[mlflow]",
170
+ err=True,
171
+ )
172
+ except Exception as e:
173
+ click.echo(f"\nWarning: MLflow export failed: {e}", err=True)
174
+
175
+
176
+ def _adapt_scenario_result(result: Any) -> Any:
177
+ """Adapt ScenarioResult to RunResult-like interface for MLflowExporter.
178
+
179
+ The exporter expects RunResult fields, but ScenarioRunner returns ScenarioResult.
180
+ This creates an adapter object.
181
+ """
182
+ from dataclasses import dataclass, field
183
+
184
+ @dataclass
185
+ class GoalResultAdapter:
186
+ name: str
187
+ score: float
188
+ passed: bool = True
189
+
190
+ @dataclass
191
+ class EvaluationAdapter:
192
+ goals: list[GoalResultAdapter] = field(default_factory=list)
193
+ total_score: float = 0.0
194
+ max_score: float = 0.0
195
+ percentage: float = 0.0
196
+
197
+ @dataclass
198
+ class RunResultAdapter:
199
+ model: str = ""
200
+ error: str | None = None
201
+ latency_ms: int = 0
202
+ input_tokens: int = 0
203
+ output_tokens: int = 0
204
+ evaluation: EvaluationAdapter | None = None
205
+
206
+ # Extract data from ScenarioResult
207
+ adapter = RunResultAdapter(
208
+ model=getattr(result, "agent_id", "unknown"),
209
+ error=None,
210
+ )
211
+
212
+ # Build evaluation from goals
213
+ goals = []
214
+ total = 0.0
215
+ for goal_name in getattr(result, "goals_achieved", []):
216
+ goals.append(GoalResultAdapter(name=goal_name, score=1.0, passed=True))
217
+ total += 1.0
218
+
219
+ score = getattr(result, "score", 0.0)
220
+ max_score = max(score, len(goals)) if goals else score
221
+
222
+ adapter.evaluation = EvaluationAdapter(
223
+ goals=goals,
224
+ total_score=score,
225
+ max_score=max_score,
226
+ percentage=(score / max_score * 100) if max_score > 0 else 0.0,
227
+ )
228
+
229
+ return adapter
230
+
231
+
101
232
  @main.command()
102
233
  @click.option("--with-examples", is_flag=True, help="Include example scenarios and tools")
103
234
  @click.option(
@@ -528,22 +659,54 @@ def info(module_path: str) -> None:
528
659
  @click.option(
529
660
  "--model",
530
661
  "-m",
531
- help="Model to use (e.g., openai/gpt-4o, anthropic/claude-3.5-sonnet)",
532
- default=None,
662
+ multiple=True,
663
+ help="Model(s) to use. Can specify multiple: -m gpt-4o -m claude-3.5-sonnet",
533
664
  )
534
665
  @click.option("--agent-id", "-a", help="Agent ID from config files", default=None)
535
666
  @click.option("--output", "-o", help="Output file for results JSON", default=None)
536
667
  @click.option("--pretty", "-p", is_flag=True, help="Pretty print output")
537
668
  @click.option("--max-turns", type=int, default=20, help="Maximum conversation turns")
538
669
  @click.option("--var", "-v", multiple=True, help="Variable in name=value format")
670
+ @click.option(
671
+ "--mlflow-export",
672
+ is_flag=True,
673
+ help="Export run results to MLflow tracking server",
674
+ )
675
+ @click.option(
676
+ "--no-mlflow",
677
+ is_flag=True,
678
+ help="Disable MLflow export (overrides YAML config)",
679
+ )
680
+ @click.option(
681
+ "--mlflow-tracking-uri",
682
+ type=str,
683
+ default=None,
684
+ help="MLflow tracking server URI (overrides MLFLOW_TRACKING_URI env)",
685
+ )
686
+ @click.option(
687
+ "--mlflow-experiment",
688
+ type=str,
689
+ default=None,
690
+ help="MLflow experiment name (defaults to scenario name)",
691
+ )
692
+ @click.option(
693
+ "--mlflow-no-tracing",
694
+ is_flag=True,
695
+ help="Disable LLM call tracing (only log summary metrics)",
696
+ )
539
697
  def scenario(
540
698
  scenario_path: str,
541
- model: str | None,
699
+ model: tuple[str, ...],
542
700
  agent_id: str | None,
543
701
  output: str | None,
544
702
  pretty: bool,
545
703
  max_turns: int,
546
704
  var: tuple[str, ...],
705
+ mlflow_export: bool,
706
+ no_mlflow: bool,
707
+ mlflow_tracking_uri: str | None,
708
+ mlflow_experiment: str | None,
709
+ mlflow_no_tracing: bool,
547
710
  ) -> None:
548
711
  """Run a scenario with YAML-defined tools.
549
712
 
@@ -554,8 +717,10 @@ def scenario(
554
717
 
555
718
  Examples:
556
719
  sandboxy scenario scenarios/trolley.yml -m openai/gpt-4o
557
- sandboxy scenario scenarios/trolley.yml -m anthropic/claude-3.5-sonnet -p
720
+ sandboxy scenario scenarios/trolley.yml -m gpt-4o -m claude-3.5-sonnet # multiple models
558
721
  sandboxy scenario scenarios/surgeon.yml -v patient="John Smith" -v condition="critical"
722
+ sandboxy scenario scenarios/test.yml -m gpt-4o --mlflow-export
723
+ sandboxy scenario scenarios/test.yml -m gpt-4o -m gpt-4o-mini --mlflow-export # compare models
559
724
  """
560
725
  from sandboxy.agents.base import AgentConfig
561
726
  from sandboxy.agents.llm_prompt import LlmPromptAgent
@@ -567,6 +732,26 @@ def scenario(
567
732
  click.echo(f"Error loading scenario: {e}", err=True)
568
733
  sys.exit(1)
569
734
 
735
+ # Build MLflow config if export requested
736
+ mlflow_config = None
737
+ if mlflow_export and not no_mlflow:
738
+ try:
739
+ from sandboxy.mlflow import MLflowConfig
740
+
741
+ mlflow_config = MLflowConfig.resolve(
742
+ cli_export=True,
743
+ cli_tracking_uri=mlflow_tracking_uri,
744
+ cli_experiment=mlflow_experiment,
745
+ cli_tracing=not mlflow_no_tracing,
746
+ yaml_config=spec.mlflow.model_dump() if spec.mlflow else None,
747
+ scenario_name=spec.name,
748
+ )
749
+ click.echo(f"MLflow enabled → experiment: {mlflow_config.experiment}")
750
+ if mlflow_config.tracing:
751
+ click.echo(" Tracing: ON (LLM calls will be captured)")
752
+ except ImportError:
753
+ pass # MLflow not installed
754
+
570
755
  # Parse and apply variables
571
756
  variables: dict[str, Any] = {}
572
757
  for v in var:
@@ -582,27 +767,17 @@ def scenario(
582
767
  spec = apply_scenario_variables(spec, variables)
583
768
  click.echo(f"Variables: {variables}")
584
769
 
585
- # Determine which agent to use
586
- agent = None
770
+ # Build list of models to run
771
+ models_to_run: list[str] = []
587
772
 
588
773
  if model:
589
- # Create ad-hoc agent from model string
590
- config = AgentConfig(
591
- id=model,
592
- name=model.split("/")[-1] if "/" in model else model,
593
- kind="llm-prompt",
594
- model=model,
595
- system_prompt="",
596
- tools=[],
597
- params={"temperature": 0.7, "max_tokens": 4096},
598
- impl={},
599
- )
600
- agent = LlmPromptAgent(config)
774
+ models_to_run = list(model)
601
775
  elif agent_id:
602
776
  # Load from agent config files
603
777
  loader = AgentLoader(DEFAULT_AGENT_DIRS)
604
778
  try:
605
779
  agent = loader.load(agent_id)
780
+ models_to_run = [agent.config.model]
606
781
  except ValueError as e:
607
782
  click.echo(f"Error loading agent: {e}", err=True)
608
783
  sys.exit(1)
@@ -611,6 +786,7 @@ def scenario(
611
786
  loader = AgentLoader(DEFAULT_AGENT_DIRS)
612
787
  try:
613
788
  agent = loader.load_default()
789
+ models_to_run = [agent.config.model]
614
790
  except ValueError:
615
791
  click.echo("No model specified. Use -m to specify a model:", err=True)
616
792
  click.echo("", err=True)
@@ -623,25 +799,110 @@ def scenario(
623
799
  )
624
800
  sys.exit(1)
625
801
 
626
- # Apply scenario's system prompt to agent
627
- if spec.system_prompt:
628
- agent.config.system_prompt = spec.system_prompt
629
-
630
802
  click.echo(f"Running scenario: {spec.name}")
631
- click.echo(f"Using model: {agent.config.model}")
803
+ click.echo(f"Models: {', '.join(models_to_run)}")
632
804
  click.echo(f"Tools loaded: {len(spec.tools) + len(spec.tools_from)} source(s)")
805
+ if len(models_to_run) > 1:
806
+ click.echo("Running models in parallel...")
633
807
  click.echo("")
634
808
 
635
- runner = ScenarioRunner(scenario=spec, agent=agent)
636
- result = runner.run(max_turns=max_turns)
809
+ def run_single_model(model_id: str) -> dict[str, Any]:
810
+ """Run scenario with a single model, with MLflow tracing if enabled."""
811
+ agent_config = AgentConfig(
812
+ id=model_id,
813
+ name=model_id.split("/")[-1] if "/" in model_id else model_id,
814
+ kind="llm-prompt",
815
+ model=model_id,
816
+ system_prompt=spec.system_prompt or "",
817
+ tools=[],
818
+ params={"temperature": 0.7, "max_tokens": 4096},
819
+ impl={},
820
+ )
821
+ agent = LlmPromptAgent(agent_config)
822
+
823
+ # If MLflow enabled, wrap execution in run context so traces are connected
824
+ if mlflow_config and mlflow_config.enabled:
825
+ from sandboxy.mlflow import MLflowExporter, mlflow_run_context
826
+ from sandboxy.mlflow.tracing import enable_tracing
827
+
828
+ # Enable tracing before the run starts
829
+ if mlflow_config.tracing:
830
+ enable_tracing(
831
+ tracking_uri=mlflow_config.tracking_uri,
832
+ experiment_name=mlflow_config.experiment,
833
+ )
834
+
835
+ # Start run, execute scenario, then log metrics - all connected
836
+ with mlflow_run_context(mlflow_config, run_name=model_id) as run_id:
837
+ runner = ScenarioRunner(scenario=spec, agent=agent)
838
+ result = runner.run(max_turns=max_turns)
839
+
840
+ # Log metrics to the active run (traces are already attached)
841
+ if run_id:
842
+ exporter = MLflowExporter(mlflow_config)
843
+ exporter.log_to_active_run(
844
+ result=result,
845
+ scenario_path=Path(scenario_path),
846
+ scenario_name=spec.name,
847
+ scenario_id=spec.id,
848
+ agent_name=agent.config.name,
849
+ )
850
+
851
+ return {"model": model_id, "result": result, "agent_name": agent.config.name}
852
+
853
+ # No MLflow - just run scenario
854
+ runner = ScenarioRunner(scenario=spec, agent=agent)
855
+ result = runner.run(max_turns=max_turns)
856
+ return {"model": model_id, "result": result, "agent_name": agent.config.name}
857
+
858
+ # Run models in parallel if multiple, otherwise just run single
859
+ results: list[Any] = []
860
+ if len(models_to_run) == 1:
861
+ results = [run_single_model(models_to_run[0])]
862
+ else:
863
+ from concurrent.futures import ThreadPoolExecutor, as_completed
864
+
865
+ with ThreadPoolExecutor(max_workers=len(models_to_run)) as executor:
866
+ futures = {executor.submit(run_single_model, m): m for m in models_to_run}
867
+ for future in as_completed(futures):
868
+ model_id = futures[future]
869
+ try:
870
+ result_data = future.result()
871
+ results.append(result_data)
872
+ click.echo(f"✓ Completed: {model_id}")
873
+ except Exception as e:
874
+ click.echo(f"✗ Failed: {model_id} - {e}", err=True)
875
+ click.echo("")
637
876
 
638
- if output:
639
- Path(output).write_text(result.to_json(indent=2))
640
- click.echo(f"\nResults saved to: {output}")
641
- elif pretty:
642
- click.echo(result.pretty())
877
+ # Output results
878
+ if len(results) == 1:
879
+ result = results[0]["result"]
880
+ if output:
881
+ Path(output).write_text(result.to_json(indent=2))
882
+ click.echo(f"\nResults saved to: {output}")
883
+ elif pretty:
884
+ click.echo(result.pretty())
885
+ else:
886
+ click.echo(result.to_json(indent=2))
643
887
  else:
644
- click.echo(result.to_json(indent=2))
888
+ # Multiple models - show summary
889
+ # Get max_score from spec (scoring config or sum of goal points)
890
+ max_score = spec.scoring.get("max_score", 0) if spec.scoring else 0
891
+ if not max_score and spec.goals:
892
+ max_score = sum(g.points for g in spec.goals)
893
+
894
+ click.echo("=== Results Summary ===")
895
+ for r in results:
896
+ model_name = r["model"]
897
+ res = r["result"]
898
+ score = getattr(res, "score", 0) or 0
899
+ pct = (score / max_score * 100) if max_score > 0 else 0
900
+ click.echo(f" {model_name}: {score:.1f}/{max_score:.1f} ({pct:.0f}%)")
901
+
902
+ if output:
903
+ all_results = [{"model": r["model"], "result": r["result"].to_dict()} for r in results]
904
+ Path(output).write_text(json.dumps(all_results, indent=2))
905
+ click.echo(f"\nResults saved to: {output}")
645
906
 
646
907
 
647
908
  @main.command()
@@ -1333,5 +1594,376 @@ def mcp_list_servers() -> None:
1333
1594
  click.echo("More servers: https://github.com/modelcontextprotocol/servers")
1334
1595
 
1335
1596
 
1597
+ # =============================================================================
1598
+ # PROVIDERS COMMAND GROUP
1599
+ # =============================================================================
1600
+
1601
+
1602
+ @main.group()
1603
+ def providers() -> None:
1604
+ """Manage local model providers (Ollama, LM Studio, vLLM, etc.)."""
1605
+ pass
1606
+
1607
+
1608
+ @providers.command("list")
1609
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
1610
+ def providers_list(as_json: bool) -> None:
1611
+ """List all configured local providers.
1612
+
1613
+ Shows provider name, type, URL, connection status, and model count.
1614
+
1615
+ Examples:
1616
+ sandboxy providers list
1617
+ sandboxy providers list --json
1618
+ """
1619
+ import asyncio
1620
+
1621
+ from sandboxy.providers.config import load_providers_config
1622
+ from sandboxy.providers.local import LocalProvider
1623
+
1624
+ config = load_providers_config()
1625
+
1626
+ if not config.providers:
1627
+ if as_json:
1628
+ click.echo(json.dumps({"providers": []}))
1629
+ else:
1630
+ click.echo("No local providers configured.")
1631
+ click.echo("")
1632
+ click.echo("Add a provider with:")
1633
+ click.echo(" sandboxy providers add ollama --url http://localhost:11434/v1")
1634
+ return
1635
+
1636
+ async def get_statuses():
1637
+ results = []
1638
+ for pconfig in config.providers:
1639
+ provider = LocalProvider(pconfig)
1640
+ try:
1641
+ status = await provider.test_connection()
1642
+ results.append(
1643
+ {
1644
+ "name": pconfig.name,
1645
+ "type": pconfig.type,
1646
+ "base_url": pconfig.base_url,
1647
+ "enabled": pconfig.enabled,
1648
+ "status": status.status.value,
1649
+ "model_count": len(status.available_models),
1650
+ }
1651
+ )
1652
+ except Exception as e:
1653
+ results.append(
1654
+ {
1655
+ "name": pconfig.name,
1656
+ "type": pconfig.type,
1657
+ "base_url": pconfig.base_url,
1658
+ "enabled": pconfig.enabled,
1659
+ "status": "error",
1660
+ "model_count": 0,
1661
+ "error": str(e),
1662
+ }
1663
+ )
1664
+ finally:
1665
+ await provider.close()
1666
+ return results
1667
+
1668
+ statuses = asyncio.run(get_statuses())
1669
+
1670
+ if as_json:
1671
+ click.echo(json.dumps({"providers": statuses}, indent=2))
1672
+ return
1673
+
1674
+ # Table output
1675
+ click.echo(f"{'NAME':<15} {'TYPE':<18} {'URL':<35} {'STATUS':<12} {'MODELS':<6}")
1676
+ for s in statuses:
1677
+ status_display = s["status"]
1678
+ if s["status"] == "connected":
1679
+ status_display = click.style("connected", fg="green")
1680
+ elif s["status"] in ("disconnected", "error"):
1681
+ status_display = click.style(s["status"], fg="red")
1682
+
1683
+ click.echo(
1684
+ f"{s['name']:<15} {s['type']:<18} {s['base_url']:<35} {status_display:<12} {s['model_count']:<6}"
1685
+ )
1686
+
1687
+
1688
+ @providers.command("add")
1689
+ @click.argument("name")
1690
+ @click.option(
1691
+ "--type",
1692
+ "provider_type",
1693
+ type=click.Choice(["ollama", "lmstudio", "vllm", "openai-compatible"]),
1694
+ default="openai-compatible",
1695
+ help="Provider type",
1696
+ )
1697
+ @click.option("--url", required=True, help="Base URL for the provider API")
1698
+ @click.option("--api-key", help="Optional API key for authentication")
1699
+ @click.option("--model", "models", multiple=True, help="Manually specify model (can be repeated)")
1700
+ @click.option("--no-test", is_flag=True, help="Skip connection test")
1701
+ def providers_add(
1702
+ name: str,
1703
+ provider_type: str,
1704
+ url: str,
1705
+ api_key: str | None,
1706
+ models: tuple[str, ...],
1707
+ no_test: bool,
1708
+ ) -> None:
1709
+ """Add a new local model provider.
1710
+
1711
+ Examples:
1712
+ sandboxy providers add ollama --url http://localhost:11434/v1
1713
+ sandboxy providers add my-vllm --type vllm --url http://gpu:8000/v1 --api-key $KEY
1714
+ sandboxy providers add custom --url http://localhost:8080/v1 --model llama3 --model mistral
1715
+ """
1716
+ import asyncio
1717
+
1718
+ from sandboxy.providers.config import (
1719
+ LocalProviderConfig,
1720
+ load_providers_config,
1721
+ save_providers_config,
1722
+ )
1723
+ from sandboxy.providers.local import LocalProvider
1724
+ from sandboxy.providers.registry import reload_local_providers
1725
+
1726
+ # Load existing config
1727
+ config = load_providers_config()
1728
+
1729
+ # Check for duplicate name
1730
+ if config.get_provider(name):
1731
+ click.echo(f"Error: Provider '{name}' already exists", err=True)
1732
+ sys.exit(1)
1733
+
1734
+ # Create provider config
1735
+ try:
1736
+ provider_config = LocalProviderConfig(
1737
+ name=name,
1738
+ type=provider_type,
1739
+ base_url=url,
1740
+ api_key=api_key,
1741
+ models=list(models),
1742
+ )
1743
+ except ValueError as e:
1744
+ click.echo(f"Error: {e}", err=True)
1745
+ sys.exit(1)
1746
+
1747
+ # Test connection unless skipped
1748
+ discovered_models: list[str] = []
1749
+ if not no_test:
1750
+ click.echo(f"Testing connection to {url}...")
1751
+
1752
+ async def test():
1753
+ provider = LocalProvider(provider_config)
1754
+ try:
1755
+ status = await provider.test_connection()
1756
+ return status
1757
+ finally:
1758
+ await provider.close()
1759
+
1760
+ try:
1761
+ status = asyncio.run(test())
1762
+ if status.status.value == "connected":
1763
+ click.echo(click.style("✓ Connected", fg="green"))
1764
+ discovered_models = status.available_models
1765
+ if discovered_models:
1766
+ click.echo(f"✓ Found {len(discovered_models)} models")
1767
+ else:
1768
+ click.echo(click.style(f"✗ Connection failed: {status.error_message}", fg="red"))
1769
+ click.echo("")
1770
+ click.echo("Provider will be added but may not work until server is running.")
1771
+ click.echo("Use --no-test to skip this check.")
1772
+ sys.exit(2)
1773
+ except Exception as e:
1774
+ click.echo(click.style(f"✗ Connection failed: {e}", fg="red"))
1775
+ sys.exit(2)
1776
+
1777
+ # Add and save
1778
+ config.add_provider(provider_config)
1779
+ save_providers_config(config)
1780
+
1781
+ # Reload providers in registry
1782
+ reload_local_providers()
1783
+
1784
+ click.echo(f"Added provider '{name}'")
1785
+ if discovered_models:
1786
+ model_list = ", ".join(discovered_models[:5])
1787
+ if len(discovered_models) > 5:
1788
+ model_list += f", ... ({len(discovered_models) - 5} more)"
1789
+ click.echo(f"Found {len(discovered_models)} models: {model_list}")
1790
+ elif models:
1791
+ click.echo(f"Configured {len(models)} model(s): {', '.join(models)}")
1792
+
1793
+
1794
+ @providers.command("remove")
1795
+ @click.argument("name")
1796
+ def providers_remove(name: str) -> None:
1797
+ """Remove a configured provider.
1798
+
1799
+ Examples:
1800
+ sandboxy providers remove ollama
1801
+ """
1802
+ from sandboxy.providers.config import load_providers_config, save_providers_config
1803
+ from sandboxy.providers.registry import reload_local_providers
1804
+
1805
+ config = load_providers_config()
1806
+
1807
+ if not config.remove_provider(name):
1808
+ click.echo(f"Error: Provider '{name}' not found", err=True)
1809
+ sys.exit(1)
1810
+
1811
+ save_providers_config(config)
1812
+ reload_local_providers()
1813
+
1814
+ click.echo(f"Removed provider '{name}'")
1815
+
1816
+
1817
+ @providers.command("test")
1818
+ @click.argument("name")
1819
+ def providers_test(name: str) -> None:
1820
+ """Test connection to a provider.
1821
+
1822
+ Examples:
1823
+ sandboxy providers test ollama
1824
+ """
1825
+ import asyncio
1826
+
1827
+ from sandboxy.providers.config import load_providers_config
1828
+ from sandboxy.providers.local import LocalProvider
1829
+
1830
+ config = load_providers_config()
1831
+ provider_config = config.get_provider(name)
1832
+
1833
+ if not provider_config:
1834
+ click.echo(f"Error: Provider '{name}' not found", err=True)
1835
+ sys.exit(1)
1836
+
1837
+ click.echo(f"Testing connection to {name} ({provider_config.base_url})...")
1838
+
1839
+ async def test():
1840
+ provider = LocalProvider(provider_config)
1841
+ try:
1842
+ return await provider.test_connection()
1843
+ finally:
1844
+ await provider.close()
1845
+
1846
+ try:
1847
+ status = asyncio.run(test())
1848
+
1849
+ if status.status.value == "connected":
1850
+ click.echo(click.style(f"✓ Connected in {status.latency_ms}ms", fg="green"))
1851
+ if status.available_models:
1852
+ click.echo(
1853
+ f"✓ Found {len(status.available_models)} models: {', '.join(status.available_models)}"
1854
+ )
1855
+ else:
1856
+ click.echo(click.style(f"✗ Connection failed: {status.error_message}", fg="red"))
1857
+
1858
+ # Provide helpful suggestions based on provider type
1859
+ if provider_config.type == "ollama":
1860
+ click.echo("")
1861
+ click.echo(" Suggestion: Ensure Ollama is running with: ollama serve")
1862
+ elif provider_config.type == "vllm":
1863
+ click.echo("")
1864
+ click.echo(
1865
+ " Suggestion: Start vLLM with: python -m vllm.entrypoints.openai.api_server"
1866
+ )
1867
+ elif provider_config.type == "lmstudio":
1868
+ click.echo("")
1869
+ click.echo(" Suggestion: Start the server in LM Studio and load a model")
1870
+
1871
+ sys.exit(1)
1872
+
1873
+ except Exception as e:
1874
+ click.echo(click.style(f"✗ Error: {e}", fg="red"))
1875
+ sys.exit(1)
1876
+
1877
+
1878
+ @providers.command("models")
1879
+ @click.argument("name", required=False)
1880
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
1881
+ def providers_models(name: str | None, as_json: bool) -> None:
1882
+ """List models from configured providers.
1883
+
1884
+ If NAME is provided, shows models only from that provider.
1885
+ Otherwise, shows models from all providers.
1886
+
1887
+ Examples:
1888
+ sandboxy providers models
1889
+ sandboxy providers models ollama
1890
+ sandboxy providers models --json
1891
+ """
1892
+ import asyncio
1893
+
1894
+ from sandboxy.providers.config import load_providers_config
1895
+ from sandboxy.providers.local import LocalProvider
1896
+
1897
+ config = load_providers_config()
1898
+
1899
+ if name:
1900
+ provider_config = config.get_provider(name)
1901
+ if not provider_config:
1902
+ click.echo(f"Error: Provider '{name}' not found", err=True)
1903
+ sys.exit(1)
1904
+ providers_to_check = [provider_config]
1905
+ else:
1906
+ providers_to_check = [p for p in config.providers if p.enabled]
1907
+
1908
+ if not providers_to_check:
1909
+ if as_json:
1910
+ click.echo(json.dumps({"models": []}))
1911
+ else:
1912
+ click.echo("No providers configured.")
1913
+ return
1914
+
1915
+ async def get_models():
1916
+ all_models = []
1917
+ for pconfig in providers_to_check:
1918
+ provider = LocalProvider(pconfig)
1919
+ try:
1920
+ models = await provider.refresh_models()
1921
+ for m in models:
1922
+ all_models.append(
1923
+ {
1924
+ "provider": pconfig.name,
1925
+ "id": m.id,
1926
+ "name": m.name,
1927
+ "context_length": m.context_length,
1928
+ "supports_tools": m.supports_tools,
1929
+ }
1930
+ )
1931
+ except Exception:
1932
+ # Provider unreachable, use configured models if any
1933
+ for model_id in pconfig.models:
1934
+ all_models.append(
1935
+ {
1936
+ "provider": pconfig.name,
1937
+ "id": model_id,
1938
+ "name": model_id,
1939
+ "context_length": 0,
1940
+ "supports_tools": False,
1941
+ }
1942
+ )
1943
+ finally:
1944
+ await provider.close()
1945
+ return all_models
1946
+
1947
+ models = asyncio.run(get_models())
1948
+
1949
+ if as_json:
1950
+ output = {"models": models}
1951
+ if name:
1952
+ output["provider"] = name
1953
+ click.echo(json.dumps(output, indent=2))
1954
+ return
1955
+
1956
+ if not models:
1957
+ click.echo("No models found. Is the provider running?")
1958
+ return
1959
+
1960
+ # Table output
1961
+ click.echo(f"{'PROVIDER':<15} {'MODEL':<40} {'TOOLS':<6} {'CONTEXT':<10}")
1962
+ for m in models:
1963
+ tools = "✓" if m["supports_tools"] else "✗"
1964
+ ctx = str(m["context_length"]) if m["context_length"] > 0 else "?"
1965
+ click.echo(f"{m['provider']:<15} {m['id']:<40} {tools:<6} {ctx:<10}")
1966
+
1967
+
1336
1968
  if __name__ == "__main__":
1337
1969
  main()