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/agents/llm_prompt.py +85 -14
- sandboxy/api/app.py +2 -1
- sandboxy/api/routes/local.py +216 -20
- sandboxy/api/routes/providers.py +369 -0
- sandboxy/cli/main.py +663 -31
- sandboxy/mlflow/__init__.py +38 -0
- sandboxy/mlflow/artifacts.py +184 -0
- sandboxy/mlflow/config.py +90 -0
- sandboxy/mlflow/exporter.py +445 -0
- sandboxy/mlflow/metrics.py +115 -0
- sandboxy/mlflow/tags.py +140 -0
- sandboxy/mlflow/tracing.py +126 -0
- sandboxy/providers/__init__.py +37 -3
- sandboxy/providers/config.py +243 -0
- sandboxy/providers/local.py +498 -0
- sandboxy/providers/registry.py +107 -13
- sandboxy/scenarios/loader.py +44 -2
- sandboxy/scenarios/runner.py +57 -2
- sandboxy/scenarios/unified.py +27 -3
- sandboxy/tools/yaml_tools.py +18 -0
- sandboxy/ui/dist/assets/index-CLxxjJuD.js +367 -0
- sandboxy/ui/dist/assets/index-DBB7ehs6.css +1 -0
- sandboxy/ui/dist/index.html +2 -2
- {sandboxy-0.0.3.dist-info → sandboxy-0.0.5.dist-info}/METADATA +103 -27
- {sandboxy-0.0.3.dist-info → sandboxy-0.0.5.dist-info}/RECORD +28 -18
- sandboxy/ui/dist/assets/index-CgAkYWrJ.css +0 -1
- sandboxy/ui/dist/assets/index-D4zoGFcr.js +0 -347
- {sandboxy-0.0.3.dist-info → sandboxy-0.0.5.dist-info}/WHEEL +0 -0
- {sandboxy-0.0.3.dist-info → sandboxy-0.0.5.dist-info}/entry_points.txt +0 -0
- {sandboxy-0.0.3.dist-info → sandboxy-0.0.5.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
532
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
586
|
-
|
|
770
|
+
# Build list of models to run
|
|
771
|
+
models_to_run: list[str] = []
|
|
587
772
|
|
|
588
773
|
if model:
|
|
589
|
-
|
|
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"
|
|
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
|
-
|
|
636
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
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()
|