coder-eval 0.8.2__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.
Files changed (127) hide show
  1. coder_eval/.gitattributes +2 -0
  2. coder_eval/__init__.py +3 -0
  3. coder_eval/agent.py +302 -0
  4. coder_eval/agents/__init__.py +41 -0
  5. coder_eval/agents/_logging.py +89 -0
  6. coder_eval/agents/antigravity_agent.py +811 -0
  7. coder_eval/agents/claude_code_agent.py +1701 -0
  8. coder_eval/agents/codex_agent.py +2055 -0
  9. coder_eval/agents/noop_agent.py +100 -0
  10. coder_eval/agents/registry.py +163 -0
  11. coder_eval/agents/watchdog.py +116 -0
  12. coder_eval/analysis.py +88 -0
  13. coder_eval/cli/__init__.py +87 -0
  14. coder_eval/cli/aggregate_command.py +153 -0
  15. coder_eval/cli/console.py +7 -0
  16. coder_eval/cli/evaluate_command.py +164 -0
  17. coder_eval/cli/plan_command.py +152 -0
  18. coder_eval/cli/report_command.py +121 -0
  19. coder_eval/cli/run_command.py +686 -0
  20. coder_eval/cli/run_helpers.py +137 -0
  21. coder_eval/cli/run_task_internal_command.py +210 -0
  22. coder_eval/cli/utils.py +37 -0
  23. coder_eval/config.py +212 -0
  24. coder_eval/criteria/__init__.py +163 -0
  25. coder_eval/criteria/_classification_aggregate.py +106 -0
  26. coder_eval/criteria/agent_judge.py +425 -0
  27. coder_eval/criteria/base.py +248 -0
  28. coder_eval/criteria/classification_match.py +107 -0
  29. coder_eval/criteria/command_executed.py +181 -0
  30. coder_eval/criteria/commands_efficiency.py +73 -0
  31. coder_eval/criteria/file_check.py +119 -0
  32. coder_eval/criteria/file_contains.py +85 -0
  33. coder_eval/criteria/file_exists.py +45 -0
  34. coder_eval/criteria/file_matches_regex.py +86 -0
  35. coder_eval/criteria/json_check.py +184 -0
  36. coder_eval/criteria/llm_judge.py +260 -0
  37. coder_eval/criteria/reference_comparison.py +130 -0
  38. coder_eval/criteria/run_command.py +220 -0
  39. coder_eval/criteria/skill_triggered.py +111 -0
  40. coder_eval/criteria/uipath_eval.py +223 -0
  41. coder_eval/errors/__init__.py +26 -0
  42. coder_eval/errors/agent.py +26 -0
  43. coder_eval/errors/budget.py +28 -0
  44. coder_eval/errors/categories.py +205 -0
  45. coder_eval/errors/categorization.py +240 -0
  46. coder_eval/errors/executor.py +124 -0
  47. coder_eval/errors/judge.py +12 -0
  48. coder_eval/errors/retry.py +211 -0
  49. coder_eval/errors/timeout.py +63 -0
  50. coder_eval/evaluation/__init__.py +10 -0
  51. coder_eval/evaluation/checker.py +260 -0
  52. coder_eval/evaluation/judge_anthropic.py +55 -0
  53. coder_eval/evaluation/judge_bedrock.py +114 -0
  54. coder_eval/evaluation/judge_context.py +497 -0
  55. coder_eval/evaluation/judge_models.py +53 -0
  56. coder_eval/evaluation/judge_persistence.py +291 -0
  57. coder_eval/evaluation/judge_usage.py +50 -0
  58. coder_eval/evaluation/sub_agent.py +231 -0
  59. coder_eval/evaluation/summaries.py +48 -0
  60. coder_eval/evaluation/verdict_tool.py +213 -0
  61. coder_eval/formatting.py +191 -0
  62. coder_eval/isolation/__init__.py +15 -0
  63. coder_eval/isolation/docker_runner.py +1253 -0
  64. coder_eval/logging_config.py +399 -0
  65. coder_eval/models/__init__.py +337 -0
  66. coder_eval/models/agent_config.py +373 -0
  67. coder_eval/models/container_paths.py +26 -0
  68. coder_eval/models/criteria.py +942 -0
  69. coder_eval/models/enums.py +121 -0
  70. coder_eval/models/experiment.py +314 -0
  71. coder_eval/models/judge.py +90 -0
  72. coder_eval/models/judge_defaults.py +11 -0
  73. coder_eval/models/limits.py +89 -0
  74. coder_eval/models/merge_strategy.py +132 -0
  75. coder_eval/models/mutations.py +95 -0
  76. coder_eval/models/results.py +787 -0
  77. coder_eval/models/routing.py +160 -0
  78. coder_eval/models/sandbox.py +371 -0
  79. coder_eval/models/tasks.py +631 -0
  80. coder_eval/models/telemetry.py +454 -0
  81. coder_eval/models/templates.py +108 -0
  82. coder_eval/orchestration/__init__.py +13 -0
  83. coder_eval/orchestration/batch.py +690 -0
  84. coder_eval/orchestration/config.py +111 -0
  85. coder_eval/orchestration/config_merge.py +431 -0
  86. coder_eval/orchestration/evaluation.py +94 -0
  87. coder_eval/orchestration/experiment.py +837 -0
  88. coder_eval/orchestration/overrides.py +206 -0
  89. coder_eval/orchestration/task_loader.py +437 -0
  90. coder_eval/orchestrator.py +2078 -0
  91. coder_eval/path_utils.py +79 -0
  92. coder_eval/plugins.py +81 -0
  93. coder_eval/pricing.py +169 -0
  94. coder_eval/py.typed +0 -0
  95. coder_eval/reports.py +1066 -0
  96. coder_eval/reports_experiment.py +761 -0
  97. coder_eval/reports_html.py +1659 -0
  98. coder_eval/reports_stats.py +250 -0
  99. coder_eval/resources/__init__.py +137 -0
  100. coder_eval/resources/default_experiment.yaml +85 -0
  101. coder_eval/resources/default_ignore_patterns.yaml +60 -0
  102. coder_eval/resources/tags.yaml +55 -0
  103. coder_eval/sandbox.py +1127 -0
  104. coder_eval/scoring/__init__.py +11 -0
  105. coder_eval/scoring/ast_similarity.py +38 -0
  106. coder_eval/scoring/complexity.py +115 -0
  107. coder_eval/scoring/quality.py +154 -0
  108. coder_eval/scoring/signature_similarity.py +57 -0
  109. coder_eval/scoring/similarity.py +103 -0
  110. coder_eval/scoring/token_similarity.py +44 -0
  111. coder_eval/simulation/__init__.py +27 -0
  112. coder_eval/simulation/termination.py +88 -0
  113. coder_eval/simulation/user_simulator.py +324 -0
  114. coder_eval/streaming/__init__.py +44 -0
  115. coder_eval/streaming/callbacks.py +57 -0
  116. coder_eval/streaming/collector.py +193 -0
  117. coder_eval/streaming/events.py +198 -0
  118. coder_eval/streaming/renderers.py +248 -0
  119. coder_eval/streaming/wire.py +115 -0
  120. coder_eval/telemetry.py +407 -0
  121. coder_eval/utils.py +517 -0
  122. coder_eval-0.8.2.dist-info/METADATA +242 -0
  123. coder_eval-0.8.2.dist-info/RECORD +127 -0
  124. coder_eval-0.8.2.dist-info/WHEEL +4 -0
  125. coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
  126. coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
  127. coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,137 @@
1
+ """Helper functions for the run command."""
2
+
3
+ import random
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from ..config import settings
9
+ from ..models import RunSummary
10
+ from ..path_utils import TASK_LOG_FILENAME, generate_run_id
11
+ from .console import console
12
+
13
+
14
+ # Resolve tasks/ relative to project root — this is a repo-only feature.
15
+ # When running from a wheel install, users must provide explicit task file paths.
16
+ _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
17
+ DEFAULT_TASKS_DIR = _PROJECT_ROOT / "tasks"
18
+
19
+
20
+ def discover_default_tasks() -> list[Path]:
21
+ """Recursively find all .yaml task files under the default tasks/ directory.
22
+
23
+ This feature requires a source checkout — the tasks/ directory is not
24
+ shipped in the wheel. When running from an installed package, provide
25
+ explicit task file paths instead.
26
+
27
+ Returns:
28
+ Sorted list of task file paths.
29
+
30
+ Raises:
31
+ typer.Exit: If the tasks/ directory doesn't exist or contains no YAML files.
32
+ """
33
+ if not DEFAULT_TASKS_DIR.is_dir():
34
+ console.print(f"[red]Default tasks directory not found: {DEFAULT_TASKS_DIR}[/red]")
35
+ console.print(
36
+ "[yellow]Hint: Zero-argument task discovery requires a source checkout. "
37
+ + "Provide explicit task file paths instead: coder-eval run task1.yaml task2.yaml[/yellow]"
38
+ )
39
+ raise typer.Exit(1)
40
+
41
+ task_files = list(DEFAULT_TASKS_DIR.rglob("*.yaml"))
42
+ if not task_files:
43
+ console.print(f"[red]No .yaml files found in {DEFAULT_TASKS_DIR}[/red]")
44
+ raise typer.Exit(1)
45
+
46
+ random.shuffle(task_files)
47
+ console.print(f"[dim]Discovered {len(task_files)} task(s) from {DEFAULT_TASKS_DIR}[/dim]")
48
+ return task_files
49
+
50
+
51
+ def prepare_run_directory(run_dir: Path | None) -> Path:
52
+ """Create and prepare the run directory.
53
+
54
+ Args:
55
+ run_dir: Custom run directory path, or None to auto-generate
56
+
57
+ Returns:
58
+ Path to the prepared run directory
59
+ """
60
+ if run_dir is None:
61
+ run_id = generate_run_id()
62
+ run_dir = settings.runs_dir / run_id
63
+ run_dir.mkdir(parents=True, exist_ok=True)
64
+
65
+ console.print(f"[bold]Run directory:[/bold] {run_dir}\n")
66
+ return run_dir
67
+
68
+
69
+ def expand_task_files(task_files: list[Path]) -> list[Path]:
70
+ """Expand glob patterns and collect all task files.
71
+
72
+ Args:
73
+ task_files: List of task file paths or glob patterns
74
+
75
+ Returns:
76
+ List of resolved task file paths
77
+
78
+ Raises:
79
+ typer.Exit: If no task files are found
80
+ """
81
+ all_task_files = []
82
+ for pattern in task_files:
83
+ if pattern.is_file():
84
+ all_task_files.append(pattern)
85
+ else:
86
+ # Try as glob pattern (supports ** for recursive matching)
87
+ if pattern.is_absolute():
88
+ all_task_files.extend(Path(pattern.anchor).glob(str(pattern.relative_to(pattern.anchor))))
89
+ else:
90
+ all_task_files.extend(Path().glob(str(pattern)))
91
+
92
+ if not all_task_files:
93
+ console.print("[red]No task files found![/red]")
94
+ raise typer.Exit(1)
95
+
96
+ random.shuffle(all_task_files)
97
+ return all_task_files
98
+
99
+
100
+ def print_execution_mode(task_count: int, max_parallel: int) -> None:
101
+ """Print execution mode information.
102
+
103
+ Args:
104
+ task_count: Number of tasks to execute
105
+ max_parallel: Maximum parallel tasks
106
+ """
107
+ console.print(f"\n[bold]Running {task_count} task(s)[/bold]")
108
+ if max_parallel > 1:
109
+ console.print(f"[dim]Max parallel tasks: {max_parallel}[/dim]\n")
110
+ else:
111
+ console.print("[dim]Mode: Sequential[/dim]\n")
112
+
113
+
114
+ def print_execution_summary(run_dir: Path, summary: RunSummary) -> None:
115
+ """Print execution summary with results and log file locations.
116
+
117
+ Args:
118
+ run_dir: Path to the run directory
119
+ summary: Run execution summary
120
+ """
121
+ console.print(f"\n[bold green]Run complete:[/bold green] {run_dir}")
122
+ console.print(f"[bold]Results:[/bold] {summary.tasks_succeeded}/{summary.tasks_run} succeeded")
123
+ console.print(f"[dim]View report: open {run_dir / 'experiment.md'}[/dim]")
124
+ console.print(f"[dim]View report: uv run coder-eval report {run_dir}[/dim]")
125
+
126
+ # Print log file locations
127
+ console.print("\n[bold]Log Files:[/bold]")
128
+ run_log_path = run_dir / "experiment.log"
129
+ if run_log_path.exists():
130
+ console.print(f" Experiment log: {run_log_path}")
131
+
132
+ # Print task logs (find all task.log files under variant directories)
133
+ for task_log in sorted(run_dir.glob(f"**/{TASK_LOG_FILENAME}")):
134
+ # Dataset fan-out task_ids contain slashes, so render the full
135
+ # relative path rather than indexing fixed parent segments.
136
+ rel = task_log.parent.relative_to(run_dir).as_posix()
137
+ console.print(f" Task log ({rel}): {task_log}")
@@ -0,0 +1,210 @@
1
+ """Internal CLI subcommand executed inside the Docker container.
2
+
3
+ Not part of the public CLI surface -- the host's :class:`DockerRunner`
4
+ invokes it via ``docker run``. It loads the staged task + context from
5
+ ``/work/input``, runs one full evaluation cycle in-process (driver=tempdir),
6
+ and writes ``task.json`` + ``task.html`` to ``/work/output``.
7
+
8
+ The container always exits 0 once ``task.json`` is written, even if the
9
+ task itself failed -- criterion failures are signaled via the final_status
10
+ field, not the container exit code. Setup failures (missing input,
11
+ malformed YAML) exit non-zero before producing task.json so the host can
12
+ distinguish them from task-level failures.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import contextlib
19
+ import json
20
+ import logging
21
+ from pathlib import Path
22
+
23
+ import typer
24
+
25
+ from coder_eval.config import settings
26
+ from coder_eval.isolation.docker_runner import (
27
+ HEARTBEAT_FILENAME,
28
+ HEARTBEAT_STALE_SECONDS,
29
+ )
30
+ from coder_eval.logging_config import setup_logging
31
+ from coder_eval.models import (
32
+ CONTAINER_INPUT_DIR,
33
+ CONTAINER_OUTPUT_DIR,
34
+ CONTAINER_TASK_DIR,
35
+ ConfigLineageEntry,
36
+ PreservationMode,
37
+ )
38
+ from coder_eval.orchestration.task_loader import load_task
39
+
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ def heartbeat_is_alive(current: str, last_counter: str, current_mtime: float, last_mtime: float) -> bool:
45
+ """True when the heartbeat shows a fresh signal of life.
46
+
47
+ Counter advance OR mtime advance counts as alive. The mtime arm covers the empty-counter
48
+ startup race (host touch + delayed first write → both reads ""; mtime advanced); the counter
49
+ arm covers bind-mount mtime latency (macOS gRPC-FUSE/VirtioFS) where mtime lags the write.
50
+ """
51
+ return bool(current and current != last_counter) or current_mtime > last_mtime
52
+
53
+
54
+ def run_task_internal_command(
55
+ input_dir: Path = typer.Option( # noqa: B008
56
+ Path(CONTAINER_INPUT_DIR),
57
+ "--input",
58
+ help="Directory containing task.yaml and context.json (bind-mounted by host).",
59
+ ),
60
+ output_dir: Path = typer.Option( # noqa: B008
61
+ Path(CONTAINER_OUTPUT_DIR),
62
+ "--output",
63
+ help="Directory to write task.json/task.html into (bind-mounted by host).",
64
+ ),
65
+ task_dir: Path = typer.Option( # noqa: B008
66
+ Path(CONTAINER_TASK_DIR),
67
+ "--task-dir",
68
+ help="Original task directory mount (used to resolve relative template paths).",
69
+ ),
70
+ verbose: bool = typer.Option(
71
+ False,
72
+ "--verbose",
73
+ "-v",
74
+ help="Enable verbose (DEBUG level) logging",
75
+ ),
76
+ ) -> None:
77
+ """Run a single staged task inside the container."""
78
+ # Use the same logging path as the host CLI so LOG_LEVEL from the
79
+ # forwarded env is honoured. Without this, root stays at INFO and the
80
+ # DEBUG-level task_log_handler attached by Orchestrator never sees the
81
+ # agent's per-tool-call DEBUG records.
82
+ log_level = "DEBUG" if verbose else settings.log_level
83
+ setup_logging(level=log_level)
84
+
85
+ # Start the host-heartbeat watchdog: if the host process dies
86
+ # ungracefully (SIGKILL, Claude-Code Escape, crash) before it can
87
+ # `docker kill` us, the heartbeat file in output_dir goes stale and
88
+ # we self-exit -- otherwise the container would keep burning LLM
89
+ # budget orphaned. Daemon thread so it doesn't block normal shutdown.
90
+ import os as _os
91
+ import threading
92
+ import time
93
+
94
+ def _watch_host_heartbeat() -> None:
95
+ heartbeat = output_dir / HEARTBEAT_FILENAME
96
+ # Grace period for the host to write the first counter value.
97
+ time.sleep(HEARTBEAT_STALE_SECONDS)
98
+ last_counter = ""
99
+ last_mtime = 0.0
100
+ last_change = time.monotonic()
101
+ while True:
102
+ try:
103
+ current = heartbeat.read_text(encoding="utf-8")
104
+ except (FileNotFoundError, OSError):
105
+ current = ""
106
+ try:
107
+ current_mtime = heartbeat.stat().st_mtime
108
+ except (FileNotFoundError, OSError):
109
+ current_mtime = 0.0
110
+ now = time.monotonic()
111
+ if heartbeat_is_alive(current, last_counter, current_mtime, last_mtime):
112
+ last_counter = current
113
+ last_mtime = current_mtime
114
+ last_change = now
115
+ if now - last_change > HEARTBEAT_STALE_SECONDS:
116
+ logger.error(
117
+ "Host heartbeat stale (>%ss); exiting to reap orphan container.",
118
+ HEARTBEAT_STALE_SECONDS,
119
+ )
120
+ # os._exit skips atexit and IO flushing, so the error line
121
+ # above would routinely be lost -- making a genuine
122
+ # stale-heartbeat suicide indistinguishable from an external
123
+ # SIGKILL in the archived logs. Flush best-effort first;
124
+ # never let a flush failure stop the exit.
125
+ import sys as _sys
126
+
127
+ for _handler in logging.getLogger().handlers:
128
+ with contextlib.suppress(Exception):
129
+ _handler.flush()
130
+ with contextlib.suppress(Exception):
131
+ _sys.stdout.flush()
132
+ with contextlib.suppress(Exception):
133
+ _sys.stderr.flush()
134
+ _os._exit(137)
135
+ time.sleep(HEARTBEAT_STALE_SECONDS / 4)
136
+
137
+ threading.Thread(target=_watch_host_heartbeat, daemon=True).start()
138
+
139
+ task_yaml = input_dir / "task.yaml"
140
+ context_json = input_dir / "context.json"
141
+ if not task_yaml.exists():
142
+ typer.echo(f"FATAL: missing {task_yaml}", err=True)
143
+ raise typer.Exit(2)
144
+ if not context_json.exists():
145
+ typer.echo(f"FATAL: missing {context_json}", err=True)
146
+ raise typer.Exit(2)
147
+
148
+ context = json.loads(context_json.read_text(encoding="utf-8"))
149
+ variant_id: str = context["variant_id"]
150
+ replicate_index: int = context.get("replicate_index", 0)
151
+ # The host resolves the driver-derived default before dispatch; the container
152
+ # obeys it verbatim. This command only ever runs inside the docker driver, so
153
+ # a missing key falls back to the docker default (DIRECT_WRITE) — a deliberate
154
+ # default, not version back-compat.
155
+ preservation_mode = PreservationMode(context.get("preservation_mode", PreservationMode.DIRECT_WRITE.value))
156
+ # Docker WORKDIR alignment: the host resolves the concrete WORKDIR
157
+ # (config value / "auto" -> `docker inspect` / fallback) and forwards it here.
158
+ # Absent -> None -> standard run_dir/artifacts workspace.
159
+ workspace_dir_raw = context.get("workspace_dir")
160
+ workspace_dir = Path(workspace_dir_raw) if workspace_dir_raw else None
161
+ config_lineage = {k: ConfigLineageEntry.model_validate(v) for k, v in (context.get("config_lineage") or {}).items()}
162
+ # Prefer the host's raw source_yaml so task.json's audit trail matches
163
+ # the in-process driver. Fall back to the staged (post-override) YAML
164
+ # for older host versions that didn't forward it.
165
+ host_source_yaml: str | None = context.get("source_yaml")
166
+
167
+ # Load the post-override spec from the staged YAML. We then point
168
+ # `task_file` at a path *under the symmetric task_dir mount* so the
169
+ # Orchestrator's `task_file.parent` reasoning -- specifically the
170
+ # `TASK_DIR` env exposed to `run_command` criteria -- resolves to the
171
+ # original host task directory rather than `/work/input/`.
172
+ task, source_yaml = load_task(task_yaml)
173
+ if host_source_yaml is not None:
174
+ source_yaml = host_source_yaml
175
+ # The path below is never re-read; it only seeds Orchestrator's TASK_DIR.
176
+ runtime_task_file = task_dir / "task.yaml" if task_dir.is_dir() else task_yaml
177
+
178
+ # Force driver back to tempdir for the actual in-container run.
179
+ # We're already inside the container; another nested docker would be
180
+ # both wrong and impossible (no docker CLI in image).
181
+ if task.sandbox.driver == "docker":
182
+ task = task.model_copy(update={"sandbox": task.sandbox.model_copy(update={"driver": "tempdir"})})
183
+
184
+ output_dir.mkdir(parents=True, exist_ok=True)
185
+
186
+ # Late import: orchestrator pulls in heavy deps (anthropic SDK etc.)
187
+ # that we don't want to load just to print --help.
188
+ from coder_eval.orchestrator import Orchestrator
189
+
190
+ orchestrator = Orchestrator(
191
+ task=task,
192
+ run_dir=output_dir,
193
+ preservation_mode=preservation_mode,
194
+ task_file=runtime_task_file,
195
+ variant_id=variant_id,
196
+ source_yaml=source_yaml,
197
+ config_lineage=config_lineage,
198
+ replicate_index=replicate_index,
199
+ workspace_dir=workspace_dir,
200
+ )
201
+
202
+ # Install the stdout-NDJSON stream callback so per-tool-call events
203
+ # reach the host. Late import keeps the streaming module out of the
204
+ # default --help path.
205
+ from coder_eval.streaming.wire import StdoutNDJsonCallback
206
+
207
+ orchestrator.stream_callback = StdoutNDJsonCallback()
208
+
209
+ asyncio.run(orchestrator.run())
210
+ # Orchestrator.run() writes task.json to run_dir (== output_dir). Done.
@@ -0,0 +1,37 @@
1
+ """Shared utility functions for CLI commands."""
2
+
3
+ import shutil
4
+
5
+ from ..config import settings
6
+ from .console import console
7
+
8
+
9
+ def check_tools() -> None:
10
+ """Check that required tools are available."""
11
+ console.print("[bold]Checking required tools...[/bold]")
12
+
13
+ tools = {
14
+ "claude": "Claude Code CLI",
15
+ "uv": "UV package manager",
16
+ }
17
+
18
+ all_found = True
19
+ for cmd, name in tools.items():
20
+ if shutil.which(cmd):
21
+ console.print(f" [green]✓[/green] {name} ({cmd})")
22
+ else:
23
+ console.print(f" [red]✗[/red] {name} ({cmd}) not found")
24
+ all_found = False
25
+
26
+ if not all_found:
27
+ console.print("[yellow]Warning: Some tools are missing[/yellow]")
28
+
29
+
30
+ def check_api_keys() -> None:
31
+ """Check that API keys are configured."""
32
+ console.print("\n[bold]Checking API keys...[/bold]")
33
+
34
+ if settings.anthropic_api_key:
35
+ console.print(" [green]✓[/green] ANTHROPIC_API_KEY is set")
36
+ else:
37
+ console.print(" [yellow]⚠[/yellow] ANTHROPIC_API_KEY not set")
coder_eval/config.py ADDED
@@ -0,0 +1,212 @@
1
+ """Configuration management using pydantic-settings."""
2
+
3
+ # by-design model-hub ↔ config type-level cycle; runtime imports are lazy per CE017
4
+ # pyright: reportImportCycles=false
5
+
6
+ import base64
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from dotenv import dotenv_values, load_dotenv
12
+ from pydantic import AliasChoices, Field
13
+ from pydantic_settings import BaseSettings, SettingsConfigDict
14
+
15
+ from coder_eval.models import AgentKind, ApiBackend
16
+
17
+
18
+ # Application Insights connection string baked into the application so a fresh
19
+ # install reports usage telemetry to the shared coder-eval resource with no
20
+ # configuration. An explicitly-set connection string (env or .env, via any of the
21
+ # field's aliases below) takes precedence — pydantic-settings always prefers an
22
+ # env value over a field default.
23
+ #
24
+ # This is an INGESTION-ONLY connection string (InstrumentationKey + IngestionEndpoint):
25
+ # it can only WRITE telemetry to the resource, never read/query/manage it — the same
26
+ # class of value embedded in every distributed telemetry client (VS Code, Azure CLI,
27
+ # gh, the UiPath CLI). Approved by security for embedding. It is base64-wrapped ONLY
28
+ # to avoid tripping naive secret scanners / push-protection and to mark it as an
29
+ # intentional, reviewed default — NOT for secrecy (base64 is trivially reversible).
30
+ # Residual risk is telemetry spoofing / ingestion-cost abuse, bounded by the resource
31
+ # being dedicated to coder-eval usage telemetry.
32
+ _DEFAULT_TELEMETRY_CONNECTION_STRING = base64.b64decode(
33
+ "SW5zdHJ1bWVudGF0aW9uS2V5PTgxZDBkOGI1LTg1ZjktNDMxNS1iYjJlLTg4ODg0Y2ZkYTVhNztJbmdlc3Rpb25FbmRwb2ludD1odHRwczovL3dlc3R1czItMi5pbi5hcHBsaWNhdGlvbmluc2lnaHRzLmF6dXJlLmNvbS87TGl2ZUVuZHBvaW50PWh0dHBzOi8vd2VzdHVzMi5saXZlZGlhZ25vc3RpY3MubW9uaXRvci5henVyZS5jb20vO0FwcGxpY2F0aW9uSWQ9MDRjN2U3ZjItYjg0OC00ZjhlLTkxNzMtZjI3NmE1YTAwMzk0"
34
+ ).decode("utf-8")
35
+
36
+
37
+ # Load .env file with override so .env values always win over shell environment
38
+ load_dotenv(override=True)
39
+
40
+ # For certain keys, we want .env values to take precedence over shell environment
41
+ # because the shell may have outdated/different credentials
42
+ env_values = dotenv_values(".env")
43
+ for key in [
44
+ "ANTHROPIC_API_KEY",
45
+ ]:
46
+ value = env_values.get(key)
47
+ if value:
48
+ os.environ[key] = value
49
+
50
+
51
+ # Removed layer-5 `.env` knobs → their `-D` override paths. pydantic-settings
52
+ # silently ignores unknown env vars, so without an explicit guard a stale knob
53
+ # would silently stop having any effect — fail loud with a migration hint instead.
54
+ _REMOVED_DEFAULT_KNOBS = {
55
+ "DEFAULT_AGENT_MODEL": "agent.model",
56
+ "DEFAULT_PERMISSION_MODE": "agent.permission_mode",
57
+ "DEFAULT_MAX_TURNS": "run_limits.max_turns",
58
+ }
59
+
60
+
61
+ def _reject_removed_default_knobs() -> None:
62
+ """Fail loud on stale removed DEFAULT_* env knobs (see _REMOVED_DEFAULT_KNOBS).
63
+
64
+ An os.environ-only check suffices: load_dotenv(override=True) at module
65
+ import folds the .env file into os.environ before Settings is constructed
66
+ (if that load_dotenv were ever removed, this guard would silently narrow to
67
+ shell-env-only).
68
+
69
+ Called from Settings.__init__ BEFORE pydantic validation so the plain
70
+ ValueError propagates as-is — a pydantic ValidationError would echo the
71
+ full input dict (including API keys) into the error message.
72
+ """
73
+ stale = [name for name in _REMOVED_DEFAULT_KNOBS if os.environ.get(name)]
74
+ if stale:
75
+ hints = " ".join(
76
+ f"{name} was removed — set the baseline in experiments/default.yaml"
77
+ + f" ({_REMOVED_DEFAULT_KNOBS[name]}) or override per-run with -D {_REMOVED_DEFAULT_KNOBS[name]}=…"
78
+ + (" / --model." if name == "DEFAULT_AGENT_MODEL" else ".")
79
+ for name in stale
80
+ )
81
+ raise ValueError(f"{hints} Remove the variable(s) from your .env / shell environment.")
82
+
83
+
84
+ class Settings(BaseSettings):
85
+ """Application settings loaded from environment variables."""
86
+
87
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
88
+ _reject_removed_default_knobs()
89
+ super().__init__(*args, **kwargs)
90
+
91
+ # API Keys (for Claude Code agent only)
92
+ anthropic_api_key: str | None = None
93
+
94
+ # Paths
95
+ runs_dir: Path = Path("runs") # Base directory for timestamped runs
96
+
97
+ # API Backend routing
98
+ api_backend: ApiBackend = ApiBackend.DIRECT
99
+
100
+ # AWS Bedrock settings (used when api_backend == "bedrock")
101
+ aws_bearer_token_bedrock: str | None = None
102
+ aws_region: str | None = None
103
+ bedrock_model: str | None = None # Cross-region model ID
104
+ bedrock_small_model: str | None = None # Cross-region small model ID
105
+
106
+ # Codex settings (CodexAgent). CODEX_MODEL is the fallback model/deployment
107
+ # used when a task doesn't pin agent.model; CODEX_BASE_URL routes to a custom
108
+ # OpenAI-/responses-compatible endpoint (incl. Azure OpenAI). For Azure also
109
+ # set CODEX_API_VERSION (the required ``api-version`` query param) and use the
110
+ # deployment name as the model. CODEX_BASE_URL / CODEX_API_VERSION /
111
+ # CODEX_API_KEY are read directly via os.getenv in the agent, not mirrored here.
112
+ codex_model: str | None = None
113
+
114
+ # Antigravity settings (AntigravityAgent — Google's Gemini coding harness).
115
+ # GEMINI_API_KEY authenticates the local harness (read from .env here so the
116
+ # export loop below re-publishes it to os.environ, where the google-antigravity
117
+ # SDK looks for it). ANTIGRAVITY_MODEL is the fallback Gemini model used when a
118
+ # task doesn't pin agent.model.
119
+ gemini_api_key: str | None = None
120
+ antigravity_model: str | None = None
121
+
122
+ # Logging
123
+ log_level: str = "INFO" # Default log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
124
+ log_to_file: bool = False # Whether to enable file logging
125
+
126
+ # Usage telemetry (OpenTelemetry → Azure Application Insights customEvents).
127
+ # On by default via the baked-in connection string; see coder_eval/telemetry.py.
128
+ # telemetry_enabled (TELEMETRY_ENABLED) is the single canonical disable gate.
129
+ telemetry_enabled: bool = True
130
+ # Defaults to the embedded coder-eval resource; any set value (env or .env, via
131
+ # the aliases below) overrides it — pydantic-settings prefers env over default.
132
+ telemetry_connection_string: str | None = Field(
133
+ default=_DEFAULT_TELEMETRY_CONNECTION_STRING,
134
+ validation_alias=AliasChoices(
135
+ "telemetry_connection_string",
136
+ "applicationinsights_connection_string",
137
+ "uipath_ai_connection_string",
138
+ ),
139
+ )
140
+ # Caller-settable origin stamp (TELEMETRY_SOURCE), emitted as the `Source`
141
+ # dimension on every event. Lets downstream pipelines tag themselves (e.g.
142
+ # `nightly-vm` / `skill-eval`) so internal runs are distinguishable from
143
+ # anonymous local ones — `IsCI` alone can't, and the framework's own CI is
144
+ # muted. Defaults to "coder-eval" for a plain local install.
145
+ telemetry_source: str = "coder-eval"
146
+
147
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore")
148
+
149
+ def _validate_bedrock_settings(self) -> None:
150
+ """Validate that required AWS Bedrock settings are present.
151
+
152
+ Raises:
153
+ ValueError: If required Bedrock settings are missing
154
+ """
155
+ missing = []
156
+ if not self.aws_bearer_token_bedrock:
157
+ missing.append("AWS_BEARER_TOKEN_BEDROCK")
158
+ if not self.aws_region:
159
+ missing.append("AWS_REGION")
160
+ # BEDROCK_MODEL is the route-level model source. Without it, an
161
+ # invocation that doesn't override via --model / task.agent.model
162
+ # would send model=None to the SDK and Bedrock would return an
163
+ # opaque 400. Fail fast at startup with a clear error.
164
+ if not self.bedrock_model:
165
+ missing.append("BEDROCK_MODEL")
166
+ if missing:
167
+ raise ValueError(
168
+ f"Bedrock routing is enabled but missing required settings: {', '.join(missing)}."
169
+ + " Please set them in your .env file."
170
+ )
171
+
172
+ def validate_api_keys(self, agent_type: str) -> None:
173
+ """Validate that required API keys are present.
174
+
175
+ Args:
176
+ agent_type: The type of agent being used
177
+
178
+ Raises:
179
+ ValueError: If required API key is missing
180
+ """
181
+ # The no-op agent (agent: {type: none}) makes no model API call, so it
182
+ # needs no credentials — not even the backend (Bedrock) settings.
183
+ if agent_type == AgentKind.NONE.value:
184
+ return
185
+
186
+ if self.api_backend == ApiBackend.BEDROCK:
187
+ self._validate_bedrock_settings()
188
+
189
+ # Claude Code agent can use either:
190
+ # 1. ANTHROPIC_API_KEY environment variable
191
+ # 2. Cached CLI authentication from 'claude-code login' (subscription account)
192
+ # We don't validate the API key here because the SDK handles auth and fails clearly if missing.
193
+ if agent_type == AgentKind.CLAUDE_CODE.value:
194
+ return
195
+
196
+
197
+ # Global settings instance
198
+ settings = Settings()
199
+
200
+ # Export settings to environment variables for external libraries (the Anthropic SDK,
201
+ # boto3/Bedrock) that use os.getenv() instead of reading from the Settings object.
202
+ # Only export non-None values and convert non-string types to strings.
203
+ for key, value in settings.model_dump().items():
204
+ if value is not None:
205
+ env_key = key.upper()
206
+ # Convert Path objects and other types to strings
207
+ if isinstance(value, Path):
208
+ os.environ[env_key] = str(value)
209
+ elif isinstance(value, bool):
210
+ os.environ[env_key] = str(value).lower()
211
+ else:
212
+ os.environ[env_key] = str(value)