fluxloop-cli 0.1.0__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.
Potentially problematic release.
This version of fluxloop-cli might be problematic. Click here for more details.
- fluxloop_cli/__init__.py +9 -0
- fluxloop_cli/arg_binder.py +219 -0
- fluxloop_cli/commands/__init__.py +5 -0
- fluxloop_cli/commands/config.py +355 -0
- fluxloop_cli/commands/generate.py +304 -0
- fluxloop_cli/commands/init.py +225 -0
- fluxloop_cli/commands/parse.py +293 -0
- fluxloop_cli/commands/run.py +310 -0
- fluxloop_cli/commands/status.py +227 -0
- fluxloop_cli/config_loader.py +159 -0
- fluxloop_cli/constants.py +12 -0
- fluxloop_cli/input_generator.py +158 -0
- fluxloop_cli/llm_generator.py +417 -0
- fluxloop_cli/main.py +97 -0
- fluxloop_cli/project_paths.py +80 -0
- fluxloop_cli/runner.py +634 -0
- fluxloop_cli/target_loader.py +95 -0
- fluxloop_cli/templates.py +277 -0
- fluxloop_cli/validators.py +31 -0
- fluxloop_cli-0.1.0.dist-info/METADATA +86 -0
- fluxloop_cli-0.1.0.dist-info/RECORD +24 -0
- fluxloop_cli-0.1.0.dist-info/WHEEL +5 -0
- fluxloop_cli-0.1.0.dist-info/entry_points.txt +2 -0
- fluxloop_cli-0.1.0.dist-info/top_level.txt +1 -0
fluxloop_cli/__init__.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Argument binding utilities with replay support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable, Dict, Optional
|
|
9
|
+
|
|
10
|
+
from fluxloop.schemas import ExperimentConfig, ReplayArgsConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _AwaitableNone:
|
|
14
|
+
"""Simple awaitable that resolves to ``None``."""
|
|
15
|
+
|
|
16
|
+
def __await__(self): # type: ignore[override]
|
|
17
|
+
async def _noop() -> None:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
return _noop().__await__()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ArgBinder:
|
|
24
|
+
"""Bind call arguments using replay data when configured."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, experiment_config: ExperimentConfig) -> None:
|
|
27
|
+
self.config = experiment_config
|
|
28
|
+
self.replay_config: Optional[ReplayArgsConfig] = experiment_config.replay_args
|
|
29
|
+
self._recording: Optional[Dict[str, Any]] = None
|
|
30
|
+
|
|
31
|
+
if self.replay_config and self.replay_config.enabled:
|
|
32
|
+
self._load_recording()
|
|
33
|
+
|
|
34
|
+
def _load_recording(self) -> None:
|
|
35
|
+
replay = self.replay_config
|
|
36
|
+
assert replay is not None
|
|
37
|
+
|
|
38
|
+
if not replay.recording_file:
|
|
39
|
+
raise ValueError("replay_args.recording_file must be provided when replay is enabled")
|
|
40
|
+
|
|
41
|
+
file_path = Path(replay.recording_file)
|
|
42
|
+
if not file_path.is_absolute():
|
|
43
|
+
source_dir = self.config.get_source_dir()
|
|
44
|
+
if source_dir:
|
|
45
|
+
file_path = (source_dir / file_path).resolve()
|
|
46
|
+
else:
|
|
47
|
+
file_path = file_path.resolve()
|
|
48
|
+
|
|
49
|
+
if not file_path.exists():
|
|
50
|
+
raise FileNotFoundError(
|
|
51
|
+
f"Recording file not found: {file_path}. Make sure it is available locally."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
last_line: Optional[str] = None
|
|
55
|
+
with file_path.open("r", encoding="utf-8") as fp:
|
|
56
|
+
for line in fp:
|
|
57
|
+
if line.strip():
|
|
58
|
+
last_line = line
|
|
59
|
+
|
|
60
|
+
if not last_line:
|
|
61
|
+
raise ValueError(f"Recording file is empty: {file_path}")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
self._recording = json.loads(last_line)
|
|
65
|
+
except json.JSONDecodeError as exc:
|
|
66
|
+
raise ValueError(f"Invalid JSON in recording file {file_path}: {exc}")
|
|
67
|
+
|
|
68
|
+
recording_target = self._recording.get("target")
|
|
69
|
+
config_target = self._resolve_config_target()
|
|
70
|
+
if recording_target and recording_target != config_target:
|
|
71
|
+
print(
|
|
72
|
+
"⚠️ Recording target mismatch:"
|
|
73
|
+
f" recording={recording_target}, config={config_target}. Proceeding anyway."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def bind_call_args(
|
|
77
|
+
self,
|
|
78
|
+
func: Callable,
|
|
79
|
+
*,
|
|
80
|
+
runtime_input: str,
|
|
81
|
+
iteration: int = 0,
|
|
82
|
+
) -> Dict[str, Any]:
|
|
83
|
+
"""Construct kwargs for calling *func* based on replay or inspection."""
|
|
84
|
+
|
|
85
|
+
if self._recording:
|
|
86
|
+
kwargs = self._recording.get("kwargs", {}).copy()
|
|
87
|
+
|
|
88
|
+
replay = self.replay_config
|
|
89
|
+
assert replay is not None
|
|
90
|
+
|
|
91
|
+
if replay.override_param_path:
|
|
92
|
+
try:
|
|
93
|
+
self._set_by_path(kwargs, replay.override_param_path, runtime_input)
|
|
94
|
+
except (KeyError, TypeError):
|
|
95
|
+
# If path missing, fall back to plain binding
|
|
96
|
+
return self._bind_by_signature(func, runtime_input)
|
|
97
|
+
|
|
98
|
+
self._restore_callables(kwargs, replay)
|
|
99
|
+
self._ensure_no_unmapped_callables(kwargs, replay)
|
|
100
|
+
return kwargs
|
|
101
|
+
|
|
102
|
+
return self._bind_by_signature(func, runtime_input)
|
|
103
|
+
|
|
104
|
+
def _bind_by_signature(self, func: Callable, runtime_input: str) -> Dict[str, Any]:
|
|
105
|
+
signature = inspect.signature(func)
|
|
106
|
+
parameters = list(signature.parameters.values())
|
|
107
|
+
|
|
108
|
+
if parameters and parameters[0].name == "self":
|
|
109
|
+
parameters = parameters[1:]
|
|
110
|
+
|
|
111
|
+
candidate_names = [
|
|
112
|
+
"input",
|
|
113
|
+
"input_text",
|
|
114
|
+
"message",
|
|
115
|
+
"query",
|
|
116
|
+
"text",
|
|
117
|
+
"content",
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
for param in parameters:
|
|
121
|
+
if param.name in candidate_names:
|
|
122
|
+
return {param.name: runtime_input}
|
|
123
|
+
|
|
124
|
+
if parameters:
|
|
125
|
+
return {parameters[0].name: runtime_input}
|
|
126
|
+
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"Cannot determine where to bind runtime input for function '{func.__name__}'."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def _restore_callables(self, kwargs: Dict[str, Any], replay: ReplayArgsConfig) -> None:
|
|
132
|
+
for param_name, provider in replay.callable_providers.items():
|
|
133
|
+
if param_name not in kwargs:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
marker = kwargs[param_name]
|
|
137
|
+
if isinstance(marker, str) and marker.startswith("<"):
|
|
138
|
+
kwargs[param_name] = self._resolve_builtin_callable(provider, marker)
|
|
139
|
+
|
|
140
|
+
def _ensure_no_unmapped_callables(self, kwargs: Dict[str, Any], replay: ReplayArgsConfig) -> None:
|
|
141
|
+
callable_markers = {
|
|
142
|
+
key: value
|
|
143
|
+
for key, value in kwargs.items()
|
|
144
|
+
if isinstance(value, str) and value.startswith("<")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if not callable_markers:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
configured = set(replay.callable_providers.keys())
|
|
151
|
+
missing = [key for key in callable_markers if key not in configured]
|
|
152
|
+
if missing:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"Missing callable providers for recorded parameters: "
|
|
155
|
+
f"{', '.join(missing)}. Configure them under replay_args.callable_providers."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _resolve_builtin_callable(self, provider: str, marker: str) -> Callable:
|
|
159
|
+
is_async = marker.endswith(":async>")
|
|
160
|
+
|
|
161
|
+
if provider == "builtin:collector.send":
|
|
162
|
+
messages: list = []
|
|
163
|
+
|
|
164
|
+
def _record(args: Any, kwargs: Any) -> None:
|
|
165
|
+
messages.append((args, kwargs))
|
|
166
|
+
pretty = args[0] if len(args) == 1 and not kwargs else {"args": args, "kwargs": kwargs}
|
|
167
|
+
|
|
168
|
+
def send(*args: Any, **kwargs: Any) -> _AwaitableNone:
|
|
169
|
+
_record(args, kwargs)
|
|
170
|
+
return _AwaitableNone()
|
|
171
|
+
|
|
172
|
+
async def send_async(*args: Any, **kwargs: Any) -> None:
|
|
173
|
+
_record(args, kwargs)
|
|
174
|
+
|
|
175
|
+
send.messages = messages
|
|
176
|
+
send_async.messages = messages
|
|
177
|
+
send.__fluxloop_builtin__ = "collector.send"
|
|
178
|
+
send_async.__fluxloop_builtin__ = "collector.send:async"
|
|
179
|
+
return send_async if is_async else send
|
|
180
|
+
|
|
181
|
+
if provider == "builtin:collector.error":
|
|
182
|
+
errors: list = []
|
|
183
|
+
|
|
184
|
+
def _record_error(args: Any, kwargs: Any) -> None:
|
|
185
|
+
errors.append((args, kwargs))
|
|
186
|
+
pretty = args[0] if len(args) == 1 and not kwargs else {"args": args, "kwargs": kwargs}
|
|
187
|
+
print(f"[ERROR] {pretty}")
|
|
188
|
+
|
|
189
|
+
def send_error(*args: Any, **kwargs: Any) -> _AwaitableNone:
|
|
190
|
+
_record_error(args, kwargs)
|
|
191
|
+
return _AwaitableNone()
|
|
192
|
+
|
|
193
|
+
async def send_error_async(*args: Any, **kwargs: Any) -> None:
|
|
194
|
+
_record_error(args, kwargs)
|
|
195
|
+
|
|
196
|
+
send_error.errors = errors
|
|
197
|
+
send_error_async.errors = errors
|
|
198
|
+
send_error.__fluxloop_builtin__ = "collector.error"
|
|
199
|
+
send_error_async.__fluxloop_builtin__ = "collector.error:async"
|
|
200
|
+
return send_error_async if is_async else send_error
|
|
201
|
+
|
|
202
|
+
raise ValueError(
|
|
203
|
+
f"Unknown callable provider '{provider}'. Supported providers: "
|
|
204
|
+
"builtin:collector.send, builtin:collector.error."
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def _set_by_path(self, payload: Dict[str, Any], path: str, value: Any) -> None:
|
|
208
|
+
parts = path.split(".")
|
|
209
|
+
current = payload
|
|
210
|
+
for key in parts[:-1]:
|
|
211
|
+
current = current[key]
|
|
212
|
+
current[parts[-1]] = value
|
|
213
|
+
|
|
214
|
+
def _resolve_config_target(self) -> Optional[str]:
|
|
215
|
+
runner = self.config.runner
|
|
216
|
+
if runner.target:
|
|
217
|
+
return runner.target
|
|
218
|
+
return f"{runner.module_path}:{runner.function_name}"
|
|
219
|
+
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Config command for managing configuration.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
import yaml
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.syntax import Syntax
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from ..config_loader import load_experiment_config
|
|
16
|
+
from ..templates import create_env_file, create_gitignore, create_sample_agent
|
|
17
|
+
from ..constants import DEFAULT_CONFIG_PATH, DEFAULT_ROOT_DIR_NAME
|
|
18
|
+
from ..project_paths import (
|
|
19
|
+
resolve_config_path,
|
|
20
|
+
resolve_env_path,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
app = typer.Typer()
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def show(
|
|
29
|
+
config_file: Path = typer.Option(
|
|
30
|
+
DEFAULT_CONFIG_PATH,
|
|
31
|
+
"--file",
|
|
32
|
+
"-f",
|
|
33
|
+
help="Configuration file to show",
|
|
34
|
+
),
|
|
35
|
+
project: Optional[str] = typer.Option(
|
|
36
|
+
None,
|
|
37
|
+
"--project",
|
|
38
|
+
help="Project name under the FluxLoop root",
|
|
39
|
+
),
|
|
40
|
+
root: Path = typer.Option(
|
|
41
|
+
Path(DEFAULT_ROOT_DIR_NAME),
|
|
42
|
+
"--root",
|
|
43
|
+
help="FluxLoop root directory",
|
|
44
|
+
),
|
|
45
|
+
format: str = typer.Option(
|
|
46
|
+
"yaml",
|
|
47
|
+
"--format",
|
|
48
|
+
help="Output format (yaml, json)",
|
|
49
|
+
),
|
|
50
|
+
):
|
|
51
|
+
"""
|
|
52
|
+
Show current configuration.
|
|
53
|
+
"""
|
|
54
|
+
resolved_path = resolve_config_path(config_file, project, root)
|
|
55
|
+
if not resolved_path.exists():
|
|
56
|
+
console.print(f"[red]Error:[/red] Configuration file not found: {config_file}")
|
|
57
|
+
raise typer.Exit(1)
|
|
58
|
+
|
|
59
|
+
content = resolved_path.read_text()
|
|
60
|
+
|
|
61
|
+
if format == "json":
|
|
62
|
+
# Convert YAML to JSON
|
|
63
|
+
import json
|
|
64
|
+
data = yaml.safe_load(content)
|
|
65
|
+
content = json.dumps(data, indent=2)
|
|
66
|
+
lexer = "json"
|
|
67
|
+
else:
|
|
68
|
+
lexer = "yaml"
|
|
69
|
+
|
|
70
|
+
syntax = Syntax(content, lexer, theme="monokai", line_numbers=True)
|
|
71
|
+
console.print(syntax)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command()
|
|
75
|
+
def set(
|
|
76
|
+
key: str = typer.Argument(
|
|
77
|
+
...,
|
|
78
|
+
help="Configuration key to set (e.g., iterations, runner.timeout)",
|
|
79
|
+
),
|
|
80
|
+
value: str = typer.Argument(
|
|
81
|
+
...,
|
|
82
|
+
help="Value to set",
|
|
83
|
+
),
|
|
84
|
+
config_file: Path = typer.Option(
|
|
85
|
+
DEFAULT_CONFIG_PATH,
|
|
86
|
+
"--file",
|
|
87
|
+
"-f",
|
|
88
|
+
help="Configuration file to update",
|
|
89
|
+
),
|
|
90
|
+
project: Optional[str] = typer.Option(
|
|
91
|
+
None,
|
|
92
|
+
"--project",
|
|
93
|
+
help="Project name under the FluxLoop root",
|
|
94
|
+
),
|
|
95
|
+
root: Path = typer.Option(
|
|
96
|
+
Path(DEFAULT_ROOT_DIR_NAME),
|
|
97
|
+
"--root",
|
|
98
|
+
help="FluxLoop root directory",
|
|
99
|
+
),
|
|
100
|
+
):
|
|
101
|
+
"""
|
|
102
|
+
Set a configuration value.
|
|
103
|
+
|
|
104
|
+
Examples:
|
|
105
|
+
- fluxloop config set iterations 20
|
|
106
|
+
- fluxloop config set runner.timeout 300
|
|
107
|
+
"""
|
|
108
|
+
resolved_path = resolve_config_path(config_file, project, root)
|
|
109
|
+
if not resolved_path.exists():
|
|
110
|
+
console.print(f"[red]Error:[/red] Configuration file not found: {config_file}")
|
|
111
|
+
raise typer.Exit(1)
|
|
112
|
+
|
|
113
|
+
# Load configuration
|
|
114
|
+
with open(resolved_path) as f:
|
|
115
|
+
config = yaml.safe_load(f) or {}
|
|
116
|
+
|
|
117
|
+
# Parse key path
|
|
118
|
+
keys = key.split(".")
|
|
119
|
+
current = config
|
|
120
|
+
|
|
121
|
+
# Navigate to the key
|
|
122
|
+
for k in keys[:-1]:
|
|
123
|
+
if k not in current:
|
|
124
|
+
current[k] = {}
|
|
125
|
+
current = current[k]
|
|
126
|
+
|
|
127
|
+
# Set the value
|
|
128
|
+
final_key = keys[-1]
|
|
129
|
+
|
|
130
|
+
# Try to parse value as appropriate type
|
|
131
|
+
try:
|
|
132
|
+
# Try as number
|
|
133
|
+
if "." in value:
|
|
134
|
+
parsed_value = float(value)
|
|
135
|
+
else:
|
|
136
|
+
parsed_value = int(value)
|
|
137
|
+
except ValueError:
|
|
138
|
+
# Try as boolean
|
|
139
|
+
if value.lower() in ("true", "false"):
|
|
140
|
+
parsed_value = value.lower() == "true"
|
|
141
|
+
else:
|
|
142
|
+
# Keep as string
|
|
143
|
+
parsed_value = value
|
|
144
|
+
|
|
145
|
+
current[final_key] = parsed_value
|
|
146
|
+
|
|
147
|
+
# Save configuration
|
|
148
|
+
with open(resolved_path, "w") as f:
|
|
149
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
150
|
+
|
|
151
|
+
console.print(f"[green]✓[/green] Set {key} = {parsed_value}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.command()
|
|
155
|
+
def env(
|
|
156
|
+
show_values: bool = typer.Option(
|
|
157
|
+
False,
|
|
158
|
+
"--show-values",
|
|
159
|
+
"-s",
|
|
160
|
+
help="Show actual values (be careful with secrets)",
|
|
161
|
+
),
|
|
162
|
+
project: Optional[str] = typer.Option(
|
|
163
|
+
None,
|
|
164
|
+
"--project",
|
|
165
|
+
help="Project name under the FluxLoop root",
|
|
166
|
+
),
|
|
167
|
+
root: Path = typer.Option(
|
|
168
|
+
Path(DEFAULT_ROOT_DIR_NAME),
|
|
169
|
+
"--root",
|
|
170
|
+
help="FluxLoop root directory",
|
|
171
|
+
),
|
|
172
|
+
):
|
|
173
|
+
"""
|
|
174
|
+
Show environment variables used by FluxLoop.
|
|
175
|
+
"""
|
|
176
|
+
env_vars = [
|
|
177
|
+
("FLUXLOOP_COLLECTOR_URL", "Collector service URL", "http://localhost:8000"),
|
|
178
|
+
("FLUXLOOP_API_KEY", "API key for authentication", None),
|
|
179
|
+
("FLUXLOOP_ENABLED", "Enable/disable tracing", "true"),
|
|
180
|
+
("FLUXLOOP_DEBUG", "Enable debug mode", "false"),
|
|
181
|
+
("FLUXLOOP_SAMPLE_RATE", "Trace sampling rate (0-1)", "1.0"),
|
|
182
|
+
("FLUXLOOP_SERVICE_NAME", "Service name for traces", None),
|
|
183
|
+
("FLUXLOOP_ENVIRONMENT", "Environment (dev/staging/prod)", "development"),
|
|
184
|
+
("OPENAI_API_KEY", "OpenAI API key", None),
|
|
185
|
+
("ANTHROPIC_API_KEY", "Anthropic API key", None),
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
table = Table(title="FluxLoop Environment Variables")
|
|
189
|
+
table.add_column("Variable", style="cyan")
|
|
190
|
+
table.add_column("Description")
|
|
191
|
+
table.add_column("Current Value")
|
|
192
|
+
table.add_column("Default", style="dim")
|
|
193
|
+
|
|
194
|
+
for var_name, description, default in env_vars:
|
|
195
|
+
current = os.getenv(var_name)
|
|
196
|
+
|
|
197
|
+
if current:
|
|
198
|
+
if show_values or not var_name.endswith("_KEY"):
|
|
199
|
+
display_value = current
|
|
200
|
+
else:
|
|
201
|
+
# Mask API keys
|
|
202
|
+
display_value = "****" + current[-4:] if len(current) > 4 else "****"
|
|
203
|
+
display_value = f"[green]{display_value}[/green]"
|
|
204
|
+
else:
|
|
205
|
+
display_value = "[yellow]Not set[/yellow]"
|
|
206
|
+
|
|
207
|
+
table.add_row(
|
|
208
|
+
var_name,
|
|
209
|
+
description,
|
|
210
|
+
display_value,
|
|
211
|
+
default or "-"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
console.print(table)
|
|
215
|
+
|
|
216
|
+
# Check for .env file
|
|
217
|
+
env_file = resolve_env_path(Path(".env"), project, root)
|
|
218
|
+
if env_file.exists():
|
|
219
|
+
console.print(f"\n[dim]Loading from:[/dim] {env_file}")
|
|
220
|
+
else:
|
|
221
|
+
console.print("\n[yellow]No .env file found[/yellow]")
|
|
222
|
+
console.print("Create one with: [cyan]fluxloop init project[/cyan]")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@app.command()
|
|
226
|
+
def set_llm(
|
|
227
|
+
provider: str = typer.Argument(..., help="LLM provider identifier (e.g., openai, anthropic, gemini)"),
|
|
228
|
+
api_key: str = typer.Argument(..., help="API key or token for the provider"),
|
|
229
|
+
model: Optional[str] = typer.Option(None, "--model", "-m", help="Default model to use"),
|
|
230
|
+
overwrite_env: bool = typer.Option(False, "--overwrite-env", help="Overwrite existing key in .env"),
|
|
231
|
+
config_file: Path = typer.Option(DEFAULT_CONFIG_PATH, "--file", "-f", help="Configuration file to update"),
|
|
232
|
+
env_file: Path = typer.Option(Path(".env"), "--env-file", help="Path to environment file"),
|
|
233
|
+
project: Optional[str] = typer.Option(None, "--project", help="Project name under the FluxLoop root"),
|
|
234
|
+
root: Path = typer.Option(Path(DEFAULT_ROOT_DIR_NAME), "--root", help="FluxLoop root directory"),
|
|
235
|
+
):
|
|
236
|
+
"""Configure LLM provider credentials and defaults."""
|
|
237
|
+
|
|
238
|
+
supported_providers: Dict[str, Dict[str, str]] = {
|
|
239
|
+
"openai": {
|
|
240
|
+
"env_var": "OPENAI_API_KEY",
|
|
241
|
+
"model": "gpt-5",
|
|
242
|
+
},
|
|
243
|
+
"anthropic": {
|
|
244
|
+
"env_var": "ANTHROPIC_API_KEY",
|
|
245
|
+
"model": "claude-3-haiku-20240307",
|
|
246
|
+
},
|
|
247
|
+
"gemini": {
|
|
248
|
+
"env_var": "GEMINI_API_KEY",
|
|
249
|
+
"model": "gemini-2.5-flash",
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
normalized_provider = provider.lower()
|
|
254
|
+
if normalized_provider not in supported_providers:
|
|
255
|
+
available = ", ".join(sorted(supported_providers.keys()))
|
|
256
|
+
console.print(
|
|
257
|
+
f"[red]Error:[/red] Unsupported provider '{provider}'. Available: {available}"
|
|
258
|
+
)
|
|
259
|
+
raise typer.Exit(1)
|
|
260
|
+
|
|
261
|
+
provider_info = supported_providers[normalized_provider]
|
|
262
|
+
env_var = provider_info["env_var"]
|
|
263
|
+
|
|
264
|
+
# Update .env file
|
|
265
|
+
env_path = resolve_env_path(env_file, project, root)
|
|
266
|
+
env_contents: Dict[str, str] = {}
|
|
267
|
+
if env_path.exists():
|
|
268
|
+
for line in env_path.read_text().splitlines():
|
|
269
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
270
|
+
continue
|
|
271
|
+
key, value = line.split("=", 1)
|
|
272
|
+
env_contents[key.strip()] = value.strip()
|
|
273
|
+
|
|
274
|
+
if env_var in env_contents and not overwrite_env:
|
|
275
|
+
console.print(
|
|
276
|
+
f"[yellow]Warning:[/yellow] {env_var} already set. Use --overwrite-env to replace it."
|
|
277
|
+
)
|
|
278
|
+
else:
|
|
279
|
+
env_contents[env_var] = api_key
|
|
280
|
+
env_lines = [f"{key}={value}" for key, value in env_contents.items()]
|
|
281
|
+
env_path.write_text("\n".join(env_lines) + "\n")
|
|
282
|
+
console.print(f"[green]✓[/green] Saved {env_var} to {env_path}")
|
|
283
|
+
|
|
284
|
+
# Update configuration file
|
|
285
|
+
resolved_cfg = resolve_config_path(config_file, project, root)
|
|
286
|
+
with open(resolved_cfg) as f:
|
|
287
|
+
config = yaml.safe_load(f) or {}
|
|
288
|
+
|
|
289
|
+
input_generation = config.setdefault("input_generation", {})
|
|
290
|
+
|
|
291
|
+
llm_config = input_generation.setdefault("llm", {})
|
|
292
|
+
llm_config["enabled"] = True
|
|
293
|
+
llm_config["provider"] = normalized_provider
|
|
294
|
+
llm_config["model"] = model or llm_config.get("model", provider_info["model"])
|
|
295
|
+
|
|
296
|
+
with open(resolved_cfg, "w") as f:
|
|
297
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
298
|
+
|
|
299
|
+
display_config_path = resolved_cfg if project else config_file
|
|
300
|
+
console.print(
|
|
301
|
+
f"[green]✓[/green] Updated {display_config_path} with provider='{normalized_provider}' model='{llm_config['model']}'"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@app.command()
|
|
306
|
+
def validate(
|
|
307
|
+
config_file: Path = typer.Option(
|
|
308
|
+
DEFAULT_CONFIG_PATH,
|
|
309
|
+
"--file",
|
|
310
|
+
"-f",
|
|
311
|
+
help="Configuration file to validate",
|
|
312
|
+
),
|
|
313
|
+
project: Optional[str] = typer.Option(
|
|
314
|
+
None,
|
|
315
|
+
"--project",
|
|
316
|
+
help="Project name under the FluxLoop root",
|
|
317
|
+
),
|
|
318
|
+
root: Path = typer.Option(
|
|
319
|
+
Path(DEFAULT_ROOT_DIR_NAME),
|
|
320
|
+
"--root",
|
|
321
|
+
help="FluxLoop root directory",
|
|
322
|
+
),
|
|
323
|
+
):
|
|
324
|
+
"""
|
|
325
|
+
Validate configuration file.
|
|
326
|
+
"""
|
|
327
|
+
resolved_path = resolve_config_path(config_file, project, root)
|
|
328
|
+
if not resolved_path.exists():
|
|
329
|
+
console.print(f"[red]Error:[/red] Configuration file not found: {config_file}")
|
|
330
|
+
raise typer.Exit(1)
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
config = load_experiment_config(config_file)
|
|
334
|
+
|
|
335
|
+
# Show validation results
|
|
336
|
+
console.print("[green]✓[/green] Configuration is valid!\n")
|
|
337
|
+
|
|
338
|
+
# Show summary
|
|
339
|
+
table = Table(show_header=False)
|
|
340
|
+
table.add_column("Property", style="cyan")
|
|
341
|
+
table.add_column("Value")
|
|
342
|
+
|
|
343
|
+
table.add_row("Experiment Name", config.name)
|
|
344
|
+
table.add_row("Iterations", str(config.iterations))
|
|
345
|
+
table.add_row("Personas", str(len(config.personas)))
|
|
346
|
+
table.add_row("Variations", str(config.variation_count))
|
|
347
|
+
table.add_row("Total Runs", str(config.estimate_total_runs()))
|
|
348
|
+
table.add_row("Runner Module", config.runner.module_path)
|
|
349
|
+
table.add_row("Evaluators", str(len(config.evaluators)))
|
|
350
|
+
|
|
351
|
+
console.print(table)
|
|
352
|
+
|
|
353
|
+
except Exception as e:
|
|
354
|
+
console.print(f"[red]✗ Validation failed:[/red] {e}")
|
|
355
|
+
raise typer.Exit(1)
|