Coverage for src/infra/io/config.py: 49%

1"""Configuration dataclass for mala.

2

3 Provides MalaConfig for centralized configuration management. This allows

4 programmatic users to construct configuration without relying on environment

5 variables, while CLI users can continue using env vars via from_env().

6

7 Environment Variables:

8 MALA_RUNS_DIR: Directory for run metadata files (default: ~/.config/mala/runs)

9 MALA_LOCK_DIR: Directory for file locks (default: /tmp/mala-locks)

10 CLAUDE_CONFIG_DIR: Claude SDK config directory (default: ~/.claude)

11 BRAINTRUST_API_KEY: Braintrust API key (required when braintrust_enabled=True)

12 MALA_REVIEW_TIMEOUT: Timeout in seconds for review-gate wait

13 MALA_CERBERUS_SPAWN_ARGS: Extra args for `review-gate spawn-code-review`

14 MALA_CERBERUS_WAIT_ARGS: Extra args for `review-gate wait`

15 MALA_CERBERUS_ENV: Extra env for review-gate (JSON dict or comma KEY=VALUE list)

16 MALA_MAX_DIFF_SIZE_KB: Max diff size for epic verification (KB)

17 MALA_MAX_EPIC_VERIFICATION_RETRIES: Max retries for epic verification loop

18 LLM_API_KEY: API key for LLM calls (fallback to ANTHROPIC_API_KEY)

19 LLM_BASE_URL: Base URL for LLM API

20"""

21

22 from __future__ import annotations

23

24 import json

25 import os

26 import shlex

27 from dataclasses import dataclass, field

28 from pathlib import Path

29

30 from src.infra.tools.env import USER_CONFIG_DIR

31

32

33 def parse_cerberus_args(raw: str | None, *, source: str) -> list[str]:

34 if not raw or not raw.strip():

35 return []

36 try:

37 return shlex.split(raw)

38 except ValueError as exc:

39 raise ValueError(f"{source}: {exc}") from exc

40

41

42 def parse_cerberus_env(raw: str | None, *, source: str) -> dict[str, str]:

43 if not raw or not raw.strip():

44 return {}

45

46 stripped = raw.strip()

47 if stripped.startswith("{"):

48 try:

49 data = json.loads(stripped)

50 except json.JSONDecodeError as exc:

51 raise ValueError(f"{source}: invalid JSON ({exc})") from exc

52 if not isinstance(data, dict):

53 raise ValueError(f"{source}: JSON must be an object")

54 return {str(key): str(value) for key, value in data.items()}

55

56 env: dict[str, str] = {}

57 for part in [item.strip() for item in raw.split(",") if item.strip()]:

58 if "=" not in part:

59 raise ValueError(f"{source}: invalid entry '{part}' (expected KEY=VALUE)")

60 key, value = part.split("=", 1)

61 key = key.strip()

62 if not key:

63 raise ValueError(f"{source}: invalid entry '{part}' (empty key)")

64 env[key] = value

65 return env

66

67

68 def _normalize_cerberus_env(env: dict[str, str]) -> tuple[tuple[str, str], ...]:

69 """Normalize env map into a stable, hashable tuple of key/value pairs."""

70 return tuple(sorted(env.items()))

71

72

73 def _find_cerberus_bin_path(claude_config_dir: Path) -> Path | None:

74 """Find the cerberus plugin bin directory from Claude's installed plugins.

75

76 Looks up the cerberus plugin installation path from Claude's

77 installed_plugins.json (v2 schema) and returns the path to its

78 bin/ directory. Falls back to known plugin locations if metadata is missing.

79

80 Args:

81 claude_config_dir: Path to Claude config directory (typically ~/.claude).

82

83 Returns:

84 Path to cerberus bin directory, or None if not found.

85 """

86 plugins_root = claude_config_dir / "plugins"

87 plugins_file = plugins_root / "installed_plugins.json"

88

89 def _iter_plugin_entries(data: object) -> list[tuple[str, object]]:

90 if isinstance(data, dict):

91 plugins = dict.get(data, "plugins")

92 if isinstance(plugins, dict):

93 return list(plugins.items())

94 return []

95

96 if plugins_file.exists():

97 try:

98 data = json.loads(plugins_file.read_text())

99 # Look for cerberus plugin (key format: "cerberus@cerberus" or similar)

100 for key, installs in _iter_plugin_entries(data):

101 if "cerberus" in str(key).lower() and isinstance(installs, list):

102 for install in installs:

103 if not isinstance(install, dict):

104 continue

105 install_path = dict.get(install, "installPath")

106 if install_path:

107 bin_path = Path(install_path) / "bin"

108 if bin_path.exists():

109 return bin_path

110 except (json.JSONDecodeError, KeyError, TypeError):

111 pass

112

113 # Fallback to known locations if installed_plugins.json is missing or stale.

114 marketplace_bin = plugins_root / "marketplaces" / "cerberus" / "bin"

115 if marketplace_bin.exists():

116 return marketplace_bin

117

118 cache_root = plugins_root / "cache" / "cerberus" / "cerberus"

119 if cache_root.exists():

120 candidates = sorted(

121 (path for path in cache_root.iterdir() if path.is_dir()),

122 key=lambda path: path.stat().st_mtime,

123 reverse=True,

124 )

125 for candidate in candidates:

126 bin_path = candidate / "bin"

127 if bin_path.exists():

128 return bin_path

129

130 return None

131

132

133 def _safe_int(value: str | None, default: int) -> int:

134 """Safely parse an integer with fallback to default."""

135 if value is None:

136 return default

137 try:

138 return int(value)

139 except ValueError:

140 return default

141

142

143 class ConfigurationError(Exception):

144 """Raised when configuration validation fails."""

145

146 def __init__(self, errors: list[str]) -> None:

147 self.errors = errors

148 message = "Configuration validation failed:\n" + "\n".join(

149 f" - {e}" for e in errors

150 )

151 super().__init__(message)

152

153

154@dataclass(frozen=True)

155 class MalaConfig:

156 """Centralized configuration for mala orchestrator.

157

158 This dataclass consolidates all configuration that was previously scattered

159 across environment variable accesses. It can be constructed programmatically

160 or loaded from environment variables using from_env().

161

162 Attributes:

163 runs_dir: Directory where run metadata files are stored.

164 Env: MALA_RUNS_DIR (default: ~/.config/mala/runs)

165 lock_dir: Directory for file locks during parallel processing.

166 Env: MALA_LOCK_DIR (default: /tmp/mala-locks)

167 claude_config_dir: Claude SDK configuration directory.

168 Env: CLAUDE_CONFIG_DIR (default: ~/.claude)

169 braintrust_api_key: Braintrust API key for tracing.

170 Env: BRAINTRUST_API_KEY (required when braintrust_enabled=True)

171 braintrust_enabled: Whether Braintrust tracing is enabled.

172 Derived from braintrust_api_key presence.

173 review_enabled: Whether automated code review is enabled.

174 Defaults to True.

175 review_timeout: Timeout in seconds for review operations.

176 Defaults to 300.

177 cerberus_spawn_args: Extra args for `review-gate spawn-code-review`.

178 Defaults to empty (no extra args).

179 cerberus_wait_args: Extra args for `review-gate wait`.

180 Defaults to empty (no extra args).

181 cerberus_env: Extra environment variables for review-gate.

182 Defaults to empty (no extra env).

183 track_review_issues: Whether to create beads issues for P2/P3 review findings.

184 Env: MALA_TRACK_REVIEW_ISSUES (default: True)

185 llm_api_key: API key for LLM calls (epic verification).

186 Env: LLM_API_KEY (falls back to ANTHROPIC_API_KEY if not set)

187 llm_base_url: Base URL for LLM API requests.

188 Env: LLM_BASE_URL (for proxy/routing)

189 max_epic_verification_retries: Maximum retries for epic verification loop.

190 Env: MALA_MAX_EPIC_VERIFICATION_RETRIES (default: 3)

191

192 Example:

193 # Programmatic construction (no env vars needed):

194 config = MalaConfig(

195 runs_dir=Path("/custom/runs"),

196 lock_dir=Path("/custom/locks"),

197 claude_config_dir=Path("/custom/claude"),

198 )

199

200 # Load from environment:

201 config = MalaConfig.from_env()

202 """

203

204 # Paths

205 runs_dir: Path = field(

206 default_factory=lambda: Path.home() / ".config" / "mala" / "runs"

207 )

208 lock_dir: Path = field(default_factory=lambda: Path("/tmp/mala-locks"))

209 claude_config_dir: Path = field(default_factory=lambda: Path.home() / ".claude")

210

211 # API keys (optional)

212 braintrust_api_key: str | None = None

213

214 # Feature flags (derived from API key presence)

215 braintrust_enabled: bool = field(default=False)

216

217 # Review settings

218 review_enabled: bool = field(default=True)

219 review_timeout: int = field(default=1200)

220 cerberus_bin_path: Path | None = None # Path to cerberus bin/ directory

221 cerberus_spawn_args: tuple[str, ...] = field(default_factory=tuple)

222 cerberus_wait_args: tuple[str, ...] = field(default_factory=tuple)

223 cerberus_env: tuple[tuple[str, str], ...] = field(default_factory=tuple)

224 track_review_issues: bool = field(default=True) # Create beads issues for P2/P3

225

226 # LLM configuration (for epic verification and other direct API calls)

227 llm_api_key: str | None = (

228 None # API key for LLM calls (falls back to ANTHROPIC_API_KEY)

229 )

230 llm_base_url: str | None = None # Base URL for LLM API (for proxy/routing)

231

232 # Epic verification retry configuration

233 max_epic_verification_retries: int = field(default=3)

234

235 # Deadlock detection

236 deadlock_detection_enabled: bool = field(default=True)

237

238 def __post_init__(self) -> None:

239 """Derive feature flags from API key presence.

240

241 Since the dataclass is frozen, we use object.__setattr__ to set

242 derived fields after initialization.

243 """

244 # Normalize Cerberus overrides for immutability/consistency

245 if isinstance(self.cerberus_spawn_args, list):

246 object.__setattr__(

247 self, "cerberus_spawn_args", tuple(self.cerberus_spawn_args)

248 )

249 if isinstance(self.cerberus_wait_args, list):

250 object.__setattr__(

251 self, "cerberus_wait_args", tuple(self.cerberus_wait_args)

252 )

253 if isinstance(self.cerberus_env, dict):

254 object.__setattr__(

255 self, "cerberus_env", _normalize_cerberus_env(self.cerberus_env)

256 )

257 elif isinstance(self.cerberus_env, list):

258 object.__setattr__(self, "cerberus_env", tuple(self.cerberus_env))

259

260 # Derive braintrust_enabled from api key presence if not explicitly set

261 if not self.braintrust_enabled and self.braintrust_api_key:

262 object.__setattr__(self, "braintrust_enabled", True)

263

264 @classmethod

265 def from_env(cls, *, validate: bool = True) -> MalaConfig:

266 """Create MalaConfig by loading from environment variables with validation.

267

268 Reads the following environment variables:

269 - MALA_RUNS_DIR: Run metadata directory (optional)

270 - MALA_LOCK_DIR: Lock files directory (optional)

271 - CLAUDE_CONFIG_DIR: Claude SDK config directory (optional)

272 - BRAINTRUST_API_KEY: Braintrust API key (optional)

273 - MALA_REVIEW_TIMEOUT: Review timeout in seconds (optional)

274 - MALA_TRACK_REVIEW_ISSUES: Create beads issues for P2/P3 findings (optional)

275 - MALA_CERBERUS_SPAWN_ARGS: Extra args for review-gate spawn (optional)

276 - MALA_CERBERUS_WAIT_ARGS: Extra args for review-gate wait (optional)

277 - MALA_CERBERUS_ENV: Extra env for review-gate (optional)

278 - MALA_MAX_DIFF_SIZE_KB: Max diff size for epic verification (optional)

279 - MALA_MAX_EPIC_VERIFICATION_RETRIES: Max epic verification retries (optional)

280 - LLM_API_KEY: API key for LLM calls (optional)

281 - LLM_BASE_URL: Base URL for LLM API (optional)

282

283 Args:

284 validate: If True (default), run validation and raise ConfigurationError

285 on any errors. Set to False to skip validation.

286

287 Returns:

288 MalaConfig instance with values from environment or defaults.

289

290 Raises:

291 ConfigurationError: If validate=True and configuration is invalid.

292

293 Example:

294 # Set environment variables first

295 os.environ["BRAINTRUST_API_KEY"] = "my-key"

296

297 # Load configuration (validates by default)

298 config = MalaConfig.from_env()

299 assert config.braintrust_enabled is True

300

301 # Skip validation if needed

302 config = MalaConfig.from_env(validate=False)

303 """

304 # Get path values from environment with defaults

305 runs_dir = Path(

306 os.environ.get(

307 "MALA_RUNS_DIR", str(Path.home() / ".config" / "mala" / "runs")

308 )

309 )

310 lock_dir = Path(os.environ.get("MALA_LOCK_DIR", "/tmp/mala-locks"))

311 claude_config_dir = Path(

312 os.environ.get("CLAUDE_CONFIG_DIR", str(Path.home() / ".claude"))

313 )

314

315 # Get optional API keys (treat empty strings as None)

316 braintrust_api_key = os.environ.get("BRAINTRUST_API_KEY") or None

317

318 review_timeout = None

319 review_timeout_raw = os.environ.get("MALA_REVIEW_TIMEOUT")

320 parse_errors: list[str] = []

321 if review_timeout_raw:

322 try:

323 review_timeout = int(review_timeout_raw)

324 except ValueError:

325 parse_errors.append(

326 f"MALA_REVIEW_TIMEOUT: invalid integer '{review_timeout_raw}'"

327 )

328 review_timeout = None

329

330 # Parse Cerberus override settings

331 try:

332 cerberus_spawn_args = parse_cerberus_args(

333 os.environ.get("MALA_CERBERUS_SPAWN_ARGS"),

334 source="MALA_CERBERUS_SPAWN_ARGS",

335 )

336 except ValueError as exc:

337 parse_errors.append(str(exc))

338 cerberus_spawn_args = []

339

340 try:

341 cerberus_wait_args = parse_cerberus_args(

342 os.environ.get("MALA_CERBERUS_WAIT_ARGS"),

343 source="MALA_CERBERUS_WAIT_ARGS",

344 )

345 except ValueError as exc:

346 parse_errors.append(str(exc))

347 cerberus_wait_args = []

348

349 try:

350 cerberus_env = parse_cerberus_env(

351 os.environ.get("MALA_CERBERUS_ENV"),

352 source="MALA_CERBERUS_ENV",

353 )

354 except ValueError as exc:

355 parse_errors.append(str(exc))

356 cerberus_env = {}

357

358 # Auto-detect cerberus bin path from Claude plugins

359 cerberus_bin_path = _find_cerberus_bin_path(claude_config_dir)

360

361 # Parse track_review_issues flag (defaults to True)

362 track_review_issues_raw = os.environ.get("MALA_TRACK_REVIEW_ISSUES", "").lower()

363 track_review_issues = track_review_issues_raw not in ("0", "false", "no", "off")

364

365 # Get LLM configuration (for epic verification and other direct API calls)

366 # Falls back to ANTHROPIC_API_KEY if LLM_API_KEY is not set

367 llm_api_key = (

368 os.environ.get("LLM_API_KEY") or os.environ.get("ANTHROPIC_API_KEY") or None

369 )

370 llm_base_url = os.environ.get("LLM_BASE_URL") or None

371

372 # Parse max_epic_verification_retries

373 max_epic_verification_retries = _safe_int(

374 os.environ.get("MALA_MAX_EPIC_VERIFICATION_RETRIES"), 3

375 )

376

377 config = cls(

378 runs_dir=runs_dir,

379 lock_dir=lock_dir,

380 claude_config_dir=claude_config_dir,

381 braintrust_api_key=braintrust_api_key,

382 review_timeout=review_timeout if review_timeout is not None else 1200,

383 cerberus_bin_path=cerberus_bin_path,

384 cerberus_spawn_args=tuple(cerberus_spawn_args),

385 cerberus_wait_args=tuple(cerberus_wait_args),

386 cerberus_env=_normalize_cerberus_env(cerberus_env),

387 track_review_issues=track_review_issues,

388 llm_api_key=llm_api_key,

389 llm_base_url=llm_base_url,

390 max_epic_verification_retries=max_epic_verification_retries,

391 )

392

393 if validate:

394 errors = config.validate()

395 errors.extend(parse_errors)

396 if errors:

397 raise ConfigurationError(errors)

398 elif parse_errors:

399 raise ConfigurationError(parse_errors)

400

401 return config

402

403 def validate(self) -> list[str]:

404 """Validate configuration and return list of errors.

405

406 Checks:

407 - Feature flags have required API keys

408 - Paths are absolute

409

410 Note: Parent directories are not checked since ensure_directories()

411 creates them with parents=True. This allows first-run on fresh machines.

412

413 Returns:

414 List of error messages. Empty list if configuration is valid.

415

416 Example:

417 config = MalaConfig(braintrust_enabled=True) # Missing API key

418 errors = config.validate()

419 # errors = ["braintrust_enabled=True requires BRAINTRUST_API_KEY"]

420 """

421 errors: list[str] = []

422

423 # Check required API keys for enabled features

424 if self.braintrust_enabled and not self.braintrust_api_key:

425 errors.append(

426 "braintrust_enabled=True requires BRAINTRUST_API_KEY to be set"

427 )

428

429 # Validate paths are absolute (recommended for deterministic behavior)

430 if not self.runs_dir.is_absolute():

431 errors.append(f"runs_dir should be an absolute path, got: {self.runs_dir}")

432 if not self.lock_dir.is_absolute():

433 errors.append(f"lock_dir should be an absolute path, got: {self.lock_dir}")

434 if not self.claude_config_dir.is_absolute():

435 errors.append(

436 f"claude_config_dir should be an absolute path, got: {self.claude_config_dir}"

437 )

438 if self.review_timeout < 0:

439 errors.append(f"review_timeout must be >= 0, got: {self.review_timeout}")

440

441 return errors

442

443 def ensure_directories(self) -> None:

444 """Create configuration directories if they don't exist.

445

446 Creates runs_dir and lock_dir with parents=True.

447 Does not create claude_config_dir (managed by Claude SDK).

448 """

449 self.runs_dir.mkdir(parents=True, exist_ok=True)

450 self.lock_dir.mkdir(parents=True, exist_ok=True)

451

452

453@dataclass(frozen=True)

454 class CLIOverrides:

455 """CLI override values that modify MalaConfig.

456

457 This represents the raw string values from CLI arguments that will be

458 parsed and merged with MalaConfig to produce a ResolvedConfig.

459

460 Attributes:

461 cerberus_spawn_args: Raw string of extra args for review-gate spawn.

462 cerberus_wait_args: Raw string of extra args for review-gate wait.

463 cerberus_env: Raw string of extra env vars (JSON or KEY=VALUE,KEY=VALUE).

464 review_timeout: Override for review timeout in seconds.

465 max_epic_verification_retries: Override for max epic verification retries.

466 no_braintrust: Whether --no-braintrust flag was passed.

467 disable_review: Whether 'review' is in --disable-validations.

468 """

469

470 cerberus_spawn_args: str | None = None

471 cerberus_wait_args: str | None = None

472 cerberus_env: str | None = None

473 review_timeout: int | None = None

474 max_epic_verification_retries: int | None = None

475 no_braintrust: bool = False

476 disable_review: bool = False

477

478

479@dataclass(frozen=True)

480 class ResolvedConfig:

481 """Fully resolved configuration combining MalaConfig and CLI overrides.

482

483 This is the final configuration object used by the orchestrator. It contains

484 all fields from MalaConfig plus derived fields computed from the combination

485 of base config and CLI overrides.

486

487 Attributes:

488 runs_dir: Directory where run metadata files are stored.

489 lock_dir: Directory for file locks during parallel processing.

490 claude_config_dir: Claude SDK configuration directory.

491 braintrust_api_key: Braintrust API key for tracing.

492 braintrust_enabled: Whether Braintrust tracing is enabled.

493 review_enabled: Whether automated code review is enabled.

494 review_timeout: Timeout in seconds for review operations.

495 cerberus_bin_path: Path to cerberus bin/ directory.

496 cerberus_spawn_args: Parsed extra args for review-gate spawn.

497 cerberus_wait_args: Parsed extra args for review-gate wait.

498 cerberus_env: Parsed extra environment variables for review-gate.

499 track_review_issues: Whether to create beads issues for P2/P3.

500 llm_api_key: API key for LLM calls.

501 llm_base_url: Base URL for LLM API.

502 max_epic_verification_retries: Maximum retries for epic verification loop.

503 braintrust_disabled_reason: Reason braintrust is disabled, if applicable.

504 """

505

506 # Paths

507 runs_dir: Path

508 lock_dir: Path

509 claude_config_dir: Path

510

511 # API keys

512 braintrust_api_key: str | None

513

514 # Feature flags

515 braintrust_enabled: bool

516

517 # Review settings

518 review_enabled: bool

519 review_timeout: int

520 cerberus_bin_path: Path | None

521 cerberus_spawn_args: tuple[str, ...]

522 cerberus_wait_args: tuple[str, ...]

523 cerberus_env: tuple[tuple[str, str], ...]

524 track_review_issues: bool

525

526 # LLM configuration

527 llm_api_key: str | None

528 llm_base_url: str | None

529

530 # Epic verification

531 max_epic_verification_retries: int

532

533 # Derived disabled reasons

534 braintrust_disabled_reason: str | None

535

536

537 def build_resolved_config(

538 base_config: MalaConfig,

539 cli_overrides: CLIOverrides | None = None,

540) -> ResolvedConfig:

541 """Build a ResolvedConfig by merging MalaConfig with CLI overrides.

542

543 Takes a base MalaConfig (typically from environment) and applies CLI

544 overrides, parsing string values and computing derived fields.

545

546 Args:

547 base_config: Base configuration from MalaConfig.from_env() or constructed.

548 cli_overrides: Optional CLI overrides to apply on top of base config.

549

550 Returns:

551 A frozen ResolvedConfig with all values resolved and derived fields computed.

552

553 Raises:

554 ValueError: If CLI override values cannot be parsed.

555

556 Example:

557 config = MalaConfig.from_env()

558 overrides = CLIOverrides(

559 cerberus_spawn_args="--mode fast",

560 )

561 resolved = build_resolved_config(config, overrides)

562 """

563 overrides = cli_overrides or CLIOverrides()

564

565 # Parse CLI override strings, falling back to base config values

566 if overrides.cerberus_spawn_args is not None:

567 spawn_args = tuple(

568 parse_cerberus_args(overrides.cerberus_spawn_args, source="CLI")

569 )

570 else:

571 spawn_args = base_config.cerberus_spawn_args

572

573 if overrides.cerberus_wait_args is not None:

574 wait_args = tuple(

575 parse_cerberus_args(overrides.cerberus_wait_args, source="CLI")

576 )

577 else:

578 wait_args = base_config.cerberus_wait_args

579

580 if overrides.cerberus_env is not None:

581 env = _normalize_cerberus_env(

582 parse_cerberus_env(overrides.cerberus_env, source="CLI")

583 )

584 else:

585 env = base_config.cerberus_env

586

587 # Apply timeout override

588 review_timeout = (

589 overrides.review_timeout

590 if overrides.review_timeout is not None

591 else base_config.review_timeout

592 )

593

594 # Apply max_epic_verification_retries override

595 max_epic_verification_retries = (

596 overrides.max_epic_verification_retries

597 if overrides.max_epic_verification_retries is not None

598 else base_config.max_epic_verification_retries

599 )

600

601 # Determine if features are enabled after CLI overrides

602 braintrust_enabled = base_config.braintrust_enabled and not overrides.no_braintrust

603 review_enabled = base_config.review_enabled and not overrides.disable_review

604

605 # Compute disabled reasons

606 braintrust_disabled_reason: str | None = None

607 if not braintrust_enabled:

608 if overrides.no_braintrust:

609 braintrust_disabled_reason = "--no-braintrust"

610 elif not base_config.braintrust_api_key:

611 braintrust_disabled_reason = (

612 f"add BRAINTRUST_API_KEY to {USER_CONFIG_DIR}/.env"

613 )

614 else:

615 braintrust_disabled_reason = "disabled by config"

616

617 return ResolvedConfig(

618 runs_dir=base_config.runs_dir,

619 lock_dir=base_config.lock_dir,

620 claude_config_dir=base_config.claude_config_dir,

621 braintrust_api_key=base_config.braintrust_api_key,

622 braintrust_enabled=braintrust_enabled,

623 review_enabled=review_enabled,

624 review_timeout=review_timeout,

625 cerberus_bin_path=base_config.cerberus_bin_path,

626 cerberus_spawn_args=spawn_args,

627 cerberus_wait_args=wait_args,

628 cerberus_env=env,

629 track_review_issues=base_config.track_review_issues,

630 llm_api_key=base_config.llm_api_key,

631 llm_base_url=base_config.llm_base_url,

632 max_epic_verification_retries=max_epic_verification_retries,

633 braintrust_disabled_reason=braintrust_disabled_reason,

634 )