netlab 0.6.1__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.
- netlab/__init__.py +10 -0
- netlab/artifacts.py +152 -0
- netlab/autoresearch/__init__.py +1 -0
- netlab/autoresearch/analysis_loop.py +134 -0
- netlab/autoresearch/backend.py +270 -0
- netlab/autoresearch/cli.py +295 -0
- netlab/autoresearch/dcbb_config.py +272 -0
- netlab/autoresearch/dcbb_failures.py +274 -0
- netlab/autoresearch/experiment_log.py +278 -0
- netlab/autoresearch/generation_loop.py +217 -0
- netlab/autoresearch/hypothesis.py +236 -0
- netlab/autoresearch/hypothesis_manager.py +199 -0
- netlab/autoresearch/memory.py +315 -0
- netlab/autoresearch/metrics_report.py +115 -0
- netlab/autoresearch/objective.py +170 -0
- netlab/autoresearch/prompt.py +288 -0
- netlab/autoresearch/runner.py +490 -0
- netlab/autoresearch/scenario_generator.py +380 -0
- netlab/autoresearch/scenario_validation.py +244 -0
- netlab/autoresearch/structural_analysis.py +297 -0
- netlab/autoresearch/sweep.py +570 -0
- netlab/cli.py +421 -0
- netlab/comparison.py +299 -0
- netlab/experiment.py +218 -0
- netlab/log_config.py +44 -0
- netlab/metrics/__init__.py +0 -0
- netlab/metrics/aggregate.py +22 -0
- netlab/metrics/analysis.py +89 -0
- netlab/metrics/bac.py +245 -0
- netlab/metrics/batch.py +706 -0
- netlab/metrics/common.py +87 -0
- netlab/metrics/comparisons.py +78 -0
- netlab/metrics/costpower.py +131 -0
- netlab/metrics/distributions.py +42 -0
- netlab/metrics/iterops.py +64 -0
- netlab/metrics/latency.py +295 -0
- netlab/metrics/matrixdump.py +108 -0
- netlab/metrics/msd.py +43 -0
- netlab/metrics/paired.py +84 -0
- netlab/metrics/plot_bac_delta_vs_baseline.py +197 -0
- netlab/metrics/plot_cross_seed_bac.py +168 -0
- netlab/metrics/plot_cross_seed_iterops.py +139 -0
- netlab/metrics/plot_cross_seed_latency.py +151 -0
- netlab/metrics/plot_significance_heatmap.py +120 -0
- netlab/metrics/reporting.py +347 -0
- netlab/metrics/seed_data.py +98 -0
- netlab/metrics/sps.py +115 -0
- netlab/metrics/summary.py +825 -0
- netlab/metrics/validation.py +193 -0
- netlab/metrics_failure.py +199 -0
- netlab/pipeline.py +207 -0
- netlab/results.py +240 -0
- netlab/run_record.py +46 -0
- netlab/runtime.py +54 -0
- netlab/scenario.py +162 -0
- netlab/simulation.py +284 -0
- netlab/tasks.py +58 -0
- netlab/topology.py +83 -0
- netlab/visualize.py +569 -0
- netlab-0.6.1.dist-info/METADATA +274 -0
- netlab-0.6.1.dist-info/RECORD +65 -0
- netlab-0.6.1.dist-info/WHEEL +5 -0
- netlab-0.6.1.dist-info/entry_points.txt +2 -0
- netlab-0.6.1.dist-info/licenses/LICENSE +21 -0
- netlab-0.6.1.dist-info/top_level.txt +1 -0
netlab/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""NetLab: metrics and experiment tooling for NetGraph scenarios."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("netlab")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
__version__ = "0.0.0.dev" # Package metadata is unavailable before installation.
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
netlab/artifacts.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Atomic artifact publication and content-based provenance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from importlib.metadata import distribution
|
|
10
|
+
from importlib.util import find_spec
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from tempfile import NamedTemporaryFile
|
|
13
|
+
from typing import Any, Iterator
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@contextmanager
|
|
17
|
+
def atomic_path(path: Path) -> Iterator[Path]:
|
|
18
|
+
"""Publish a same-filesystem temporary file only after a successful write."""
|
|
19
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
with NamedTemporaryFile(
|
|
21
|
+
dir=path.parent, prefix=f".{path.name}.", delete=False
|
|
22
|
+
) as f:
|
|
23
|
+
temporary = Path(f.name)
|
|
24
|
+
try:
|
|
25
|
+
yield temporary
|
|
26
|
+
temporary.replace(path)
|
|
27
|
+
finally:
|
|
28
|
+
temporary.unlink(missing_ok=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def write_text_atomic(path: Path, content: str) -> None:
|
|
32
|
+
with atomic_path(path) as temporary:
|
|
33
|
+
temporary.write_text(content, encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write_json_atomic(path: Path, data: Any) -> None:
|
|
37
|
+
write_text_atomic(path, json.dumps(data, indent=2) + "\n")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_csv_atomic(path: Path, data: Any) -> None:
|
|
41
|
+
import pandas as pd
|
|
42
|
+
|
|
43
|
+
table = data if isinstance(data, (pd.DataFrame, pd.Series)) else pd.DataFrame(data)
|
|
44
|
+
with atomic_path(path) as temporary:
|
|
45
|
+
table.to_csv(temporary)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def sha256_file(path: Path) -> str:
|
|
49
|
+
with path.open("rb") as stream:
|
|
50
|
+
return hashlib.file_digest(stream, "sha256").hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fingerprint(data: Any) -> str:
|
|
54
|
+
return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def package_versions() -> dict[str, dict[str, Any]]:
|
|
58
|
+
"""Identify installed revisions, including current editable package contents."""
|
|
59
|
+
packages = {}
|
|
60
|
+
for name in ("netlab", "netlab-topogen", "ngraph", "netgraph-core"):
|
|
61
|
+
package = distribution(name)
|
|
62
|
+
record: dict[str, Any] = {"version": package.version}
|
|
63
|
+
direct_url = package.read_text("direct_url.json")
|
|
64
|
+
if direct_url:
|
|
65
|
+
record["source"] = json.loads(direct_url)
|
|
66
|
+
if record["source"].get("dir_info", {}).get("editable"):
|
|
67
|
+
spec = find_spec(name.replace("-", "_"))
|
|
68
|
+
if spec is None or not spec.submodule_search_locations:
|
|
69
|
+
raise ValueError(f"Cannot fingerprint editable package {name}")
|
|
70
|
+
files = {}
|
|
71
|
+
for location in spec.submodule_search_locations:
|
|
72
|
+
root = Path(location)
|
|
73
|
+
for path in sorted(root.rglob("*")):
|
|
74
|
+
if path.is_file() and path.suffix in {".py", ".so", ".pyd"}:
|
|
75
|
+
files[str(path.relative_to(root))] = sha256_file(path)
|
|
76
|
+
record["content_sha256"] = fingerprint(files)
|
|
77
|
+
packages[name] = record
|
|
78
|
+
return packages
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def package_provenance() -> dict[str, dict[str, Any]]:
|
|
82
|
+
"""Record verified Git or pip VCS identity without local URLs, and use null for unknown Git state."""
|
|
83
|
+
packages = package_versions()
|
|
84
|
+
netlab = packages["netlab"]
|
|
85
|
+
source = netlab.pop("source", {})
|
|
86
|
+
commit = None
|
|
87
|
+
dirty = None
|
|
88
|
+
root = Path(__file__).resolve().parents[1]
|
|
89
|
+
if (root / ".git").exists():
|
|
90
|
+
try:
|
|
91
|
+
status = subprocess.run(
|
|
92
|
+
[
|
|
93
|
+
"git",
|
|
94
|
+
"--no-optional-locks",
|
|
95
|
+
"status",
|
|
96
|
+
"--porcelain",
|
|
97
|
+
"--untracked-files=normal",
|
|
98
|
+
"--ignore-submodules=none",
|
|
99
|
+
],
|
|
100
|
+
cwd=root,
|
|
101
|
+
capture_output=True,
|
|
102
|
+
text=True,
|
|
103
|
+
check=True,
|
|
104
|
+
)
|
|
105
|
+
dirty = bool(status.stdout)
|
|
106
|
+
if not dirty:
|
|
107
|
+
commit = subprocess.run(
|
|
108
|
+
["git", "rev-parse", "HEAD"],
|
|
109
|
+
cwd=root,
|
|
110
|
+
capture_output=True,
|
|
111
|
+
text=True,
|
|
112
|
+
check=True,
|
|
113
|
+
).stdout.strip()
|
|
114
|
+
except (subprocess.CalledProcessError, OSError):
|
|
115
|
+
commit = dirty = None
|
|
116
|
+
elif installed_commit := source.get("vcs_info", {}).get("commit_id"):
|
|
117
|
+
commit = installed_commit
|
|
118
|
+
dirty = False
|
|
119
|
+
netlab.update(commit=commit, dirty=dirty)
|
|
120
|
+
return packages
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def ensure_run_identity(path: Path, inputs: Any) -> None:
|
|
124
|
+
"""Bind a resumable output directory to exactly one set of inputs."""
|
|
125
|
+
key = fingerprint(inputs)
|
|
126
|
+
if path.exists():
|
|
127
|
+
previous = json.loads(path.read_text(encoding="utf-8"))
|
|
128
|
+
if previous["key"] != key:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"Research inputs or dependencies changed: {path}. "
|
|
131
|
+
"Use a new output directory for this experiment."
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
write_json_atomic(path, {"key": key, "inputs": inputs})
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cache_path(artifact: Path) -> Path:
|
|
138
|
+
return artifact.with_suffix(artifact.suffix + ".cache.json")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cache_matches(artifact: Path, key: str) -> bool:
|
|
142
|
+
try:
|
|
143
|
+
manifest = json.loads(cache_path(artifact).read_text(encoding="utf-8"))
|
|
144
|
+
return manifest["key"] == key and manifest["sha256"] == sha256_file(artifact)
|
|
145
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def record_cache(artifact: Path, key: str) -> None:
|
|
150
|
+
write_json_atomic(
|
|
151
|
+
cache_path(artifact), {"key": key, "sha256": sha256_file(artifact)}
|
|
152
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# LLM-assisted scenario generation and parameter searches.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Compute simulation metrics and request an LLM interpretation and next experiment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .backend import LLMBackend
|
|
8
|
+
from .metrics_report import build_metrics_report
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class AnalysisResult:
|
|
13
|
+
"""Output of the analysis loop."""
|
|
14
|
+
|
|
15
|
+
metrics_report: str
|
|
16
|
+
interpretation: str # LLM-generated explanation
|
|
17
|
+
next_hypothesis: str # LLM-generated suggestion for next experiment
|
|
18
|
+
iterations_used: int = 0
|
|
19
|
+
complete: bool = False
|
|
20
|
+
|
|
21
|
+
def summary(self) -> str:
|
|
22
|
+
lines = self.interpretation.strip().splitlines()
|
|
23
|
+
n_lines = len(lines)
|
|
24
|
+
preview = lines[0][:80] if lines else "(empty)"
|
|
25
|
+
return f"{n_lines} lines, {self.iterations_used} iterations: {preview}..."
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_ANALYSIS_SYSTEM_PROMPT = """\
|
|
29
|
+
You are a network reliability engineer analyzing simulation results.
|
|
30
|
+
|
|
31
|
+
Use the supplied metrics report to explain the topology's behavior under
|
|
32
|
+
failure. Distinguish measured results from proposed explanations. Flag
|
|
33
|
+
missing or inconsistent evidence, and do not invent measurements.
|
|
34
|
+
|
|
35
|
+
Keep the analysis concise. Support claims about topology and failure modes
|
|
36
|
+
with the scenario description and reported metrics.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
_ANALYSIS_PROMPT = """\
|
|
40
|
+
Hypothesis being tested:
|
|
41
|
+
{hypothesis}
|
|
42
|
+
|
|
43
|
+
{metrics_report}
|
|
44
|
+
|
|
45
|
+
Explain these results. For each failure mode:
|
|
46
|
+
1. Why does BAC have this value? What structural property causes it?
|
|
47
|
+
2. Does latency degrade under failure, or just bandwidth?
|
|
48
|
+
3. Are both directions affected equally?
|
|
49
|
+
|
|
50
|
+
Then summarize: what are the key design strengths and weaknesses of this topology?
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_NEXT_HYPOTHESIS_PROMPT = """\
|
|
54
|
+
Based on this analysis:
|
|
55
|
+
|
|
56
|
+
{interpretation}
|
|
57
|
+
|
|
58
|
+
Original hypothesis:
|
|
59
|
+
{hypothesis}
|
|
60
|
+
|
|
61
|
+
Metrics summary:
|
|
62
|
+
{metrics_summary}
|
|
63
|
+
|
|
64
|
+
Propose the next topology experiment. Consider:
|
|
65
|
+
- What structural variation might improve resilience?
|
|
66
|
+
- What tradeoff hasn't been explored (cost vs redundancy, latency vs bandwidth)?
|
|
67
|
+
- What aspect of the results is surprising or unexplained?
|
|
68
|
+
|
|
69
|
+
Respond with a clear, actionable topology description that can be
|
|
70
|
+
directly used to generate an ngraph scenario. Be specific about
|
|
71
|
+
node counts, link capacities, and failure modes to test.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run_analysis_loop(
|
|
76
|
+
results: dict,
|
|
77
|
+
hypothesis: str,
|
|
78
|
+
backend: LLMBackend,
|
|
79
|
+
max_iterations: int = 3,
|
|
80
|
+
) -> AnalysisResult:
|
|
81
|
+
"""Build a metrics report, interpret it, and propose the next experiment.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
results: NetGraph simulation results.
|
|
85
|
+
hypothesis: Description of the experiment being analyzed.
|
|
86
|
+
backend: LLM backend for interpretation.
|
|
87
|
+
max_iterations: Maximum attempts to obtain a nonempty interpretation.
|
|
88
|
+
"""
|
|
89
|
+
if max_iterations < 1:
|
|
90
|
+
raise ValueError("max_iterations must be positive")
|
|
91
|
+
metrics_report = build_metrics_report(results)
|
|
92
|
+
|
|
93
|
+
interpretation = ""
|
|
94
|
+
attempts_used = 0
|
|
95
|
+
for _attempt in range(1, max_iterations + 1):
|
|
96
|
+
attempts_used += 1
|
|
97
|
+
prompt = _ANALYSIS_PROMPT.format(
|
|
98
|
+
hypothesis=hypothesis,
|
|
99
|
+
metrics_report=metrics_report,
|
|
100
|
+
)
|
|
101
|
+
response = backend.generate(prompt, system=_ANALYSIS_SYSTEM_PROMPT)
|
|
102
|
+
interpretation = response.strip()
|
|
103
|
+
if interpretation:
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
if not interpretation:
|
|
107
|
+
return AnalysisResult(
|
|
108
|
+
metrics_report=metrics_report,
|
|
109
|
+
interpretation="(LLM produced no interpretation)",
|
|
110
|
+
next_hypothesis="",
|
|
111
|
+
iterations_used=max_iterations,
|
|
112
|
+
complete=False,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Limit the next-experiment prompt to a short metrics excerpt.
|
|
116
|
+
metrics_lines = metrics_report.splitlines()
|
|
117
|
+
metrics_summary = "\n".join(metrics_lines[:20])
|
|
118
|
+
|
|
119
|
+
next_prompt = _NEXT_HYPOTHESIS_PROMPT.format(
|
|
120
|
+
interpretation=interpretation,
|
|
121
|
+
hypothesis=hypothesis,
|
|
122
|
+
metrics_summary=metrics_summary,
|
|
123
|
+
)
|
|
124
|
+
next_hypothesis = backend.generate(
|
|
125
|
+
next_prompt, system=_ANALYSIS_SYSTEM_PROMPT
|
|
126
|
+
).strip()
|
|
127
|
+
|
|
128
|
+
return AnalysisResult(
|
|
129
|
+
metrics_report=metrics_report,
|
|
130
|
+
interpretation=interpretation,
|
|
131
|
+
next_hypothesis=next_hypothesis,
|
|
132
|
+
iterations_used=attempts_used,
|
|
133
|
+
complete=bool(next_hypothesis),
|
|
134
|
+
)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""LLM backends for scripted responses, CLI tools, and HTTP APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from http.client import HTTPException
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from netlab.runtime import require_executable
|
|
16
|
+
|
|
17
|
+
SUPPORTED_BACKENDS = ("mock", "claude-cli", "codex-cli", "openai")
|
|
18
|
+
DEFAULT_CLAUDE_MODEL = "opus"
|
|
19
|
+
DEFAULT_CODEX_MODEL = ""
|
|
20
|
+
DEFAULT_OPENAI_MODEL = "gpt-4"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LLMBackend(ABC):
|
|
24
|
+
"""Interface for one text-generation request."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def generate(self, prompt: str, system: str = "") -> str:
|
|
28
|
+
"""Return generated text; raise on failure."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MockBackend(LLMBackend):
|
|
32
|
+
"""Backend that returns scripted responses in order. For testing."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, responses: list[str]) -> None:
|
|
35
|
+
self._responses = list(responses)
|
|
36
|
+
self._index = 0
|
|
37
|
+
self.calls: list[tuple[str, str]] = []
|
|
38
|
+
|
|
39
|
+
def generate(self, prompt: str, system: str = "") -> str:
|
|
40
|
+
self.calls.append((prompt, system))
|
|
41
|
+
if self._index >= len(self._responses):
|
|
42
|
+
raise StopIteration("MockBackend: no more scripted responses")
|
|
43
|
+
response = self._responses[self._index]
|
|
44
|
+
self._index += 1
|
|
45
|
+
return response
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ClaudeCLIBackend(LLMBackend):
|
|
49
|
+
"""Backend that calls the `claude` CLI tool via subprocess."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
model: str = DEFAULT_CLAUDE_MODEL,
|
|
54
|
+
command: str | None = None,
|
|
55
|
+
timeout: float = 120,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.model = model
|
|
58
|
+
self.command = command
|
|
59
|
+
self.timeout = timeout
|
|
60
|
+
|
|
61
|
+
def generate(self, prompt: str, system: str = "") -> str:
|
|
62
|
+
# Claude Code headless mode uses -p/--print with the prompt as its value.
|
|
63
|
+
executable = require_executable(
|
|
64
|
+
"claude",
|
|
65
|
+
explicit=self.command,
|
|
66
|
+
env_var="CLAUDE_BIN",
|
|
67
|
+
display_name="Claude CLI",
|
|
68
|
+
)
|
|
69
|
+
cmd = [executable, "-p", prompt]
|
|
70
|
+
if self.model:
|
|
71
|
+
cmd.extend(["--model", self.model])
|
|
72
|
+
if system:
|
|
73
|
+
cmd.extend(["--system-prompt", system])
|
|
74
|
+
try:
|
|
75
|
+
result = subprocess.run(
|
|
76
|
+
cmd,
|
|
77
|
+
capture_output=True,
|
|
78
|
+
text=True,
|
|
79
|
+
check=False,
|
|
80
|
+
timeout=self.timeout,
|
|
81
|
+
)
|
|
82
|
+
except subprocess.TimeoutExpired as exc:
|
|
83
|
+
raise RuntimeError(f"Claude CLI timed out after {self.timeout}s") from exc
|
|
84
|
+
except FileNotFoundError as exc:
|
|
85
|
+
raise RuntimeError(
|
|
86
|
+
f"Claude CLI executable not found: {executable}"
|
|
87
|
+
) from exc
|
|
88
|
+
if result.returncode != 0:
|
|
89
|
+
raise RuntimeError(
|
|
90
|
+
f"claude CLI failed (exit {result.returncode}): {result.stderr}"
|
|
91
|
+
)
|
|
92
|
+
return result.stdout
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class CodexCLIBackend(LLMBackend):
|
|
96
|
+
"""Generate text with ``codex exec`` in ephemeral, read-only mode.
|
|
97
|
+
|
|
98
|
+
Read the response from the file written by ``--output-last-message``.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
model: str = DEFAULT_CODEX_MODEL,
|
|
104
|
+
command: str | None = None,
|
|
105
|
+
timeout: float = 120,
|
|
106
|
+
) -> None:
|
|
107
|
+
self.model = model
|
|
108
|
+
self.command = command
|
|
109
|
+
self.timeout = timeout
|
|
110
|
+
|
|
111
|
+
def generate(self, prompt: str, system: str = "") -> str:
|
|
112
|
+
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
|
113
|
+
|
|
114
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tmp:
|
|
115
|
+
output_path = tmp.name
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
executable = require_executable(
|
|
119
|
+
"codex",
|
|
120
|
+
explicit=self.command,
|
|
121
|
+
env_var="CODEX_BIN",
|
|
122
|
+
display_name="Codex CLI",
|
|
123
|
+
)
|
|
124
|
+
cmd = [
|
|
125
|
+
executable,
|
|
126
|
+
"exec",
|
|
127
|
+
"--ephemeral",
|
|
128
|
+
"--sandbox",
|
|
129
|
+
"read-only",
|
|
130
|
+
"--skip-git-repo-check",
|
|
131
|
+
"-o",
|
|
132
|
+
output_path,
|
|
133
|
+
]
|
|
134
|
+
if self.model:
|
|
135
|
+
cmd.extend(["-m", self.model])
|
|
136
|
+
cmd.append(full_prompt)
|
|
137
|
+
try:
|
|
138
|
+
result = subprocess.run(
|
|
139
|
+
cmd,
|
|
140
|
+
capture_output=True,
|
|
141
|
+
text=True,
|
|
142
|
+
check=False,
|
|
143
|
+
timeout=self.timeout,
|
|
144
|
+
)
|
|
145
|
+
except subprocess.TimeoutExpired as exc:
|
|
146
|
+
raise RuntimeError(
|
|
147
|
+
f"Codex CLI timed out after {self.timeout}s"
|
|
148
|
+
) from exc
|
|
149
|
+
except FileNotFoundError as exc:
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
f"Codex CLI executable not found: {executable}"
|
|
152
|
+
) from exc
|
|
153
|
+
if result.returncode != 0:
|
|
154
|
+
raise RuntimeError(
|
|
155
|
+
f"codex CLI failed (exit {result.returncode}): {result.stderr}"
|
|
156
|
+
)
|
|
157
|
+
output = Path(output_path).read_text(encoding="utf-8")
|
|
158
|
+
return output
|
|
159
|
+
finally:
|
|
160
|
+
Path(output_path).unlink(missing_ok=True)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class OpenAICompatibleBackend(LLMBackend):
|
|
164
|
+
"""Call an OpenAI-compatible HTTP API, retrying 429 and server errors."""
|
|
165
|
+
|
|
166
|
+
# Tests can override retry limits and delay.
|
|
167
|
+
max_retries: int = 3
|
|
168
|
+
base_delay: float = 2.0 # seconds
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
base_url: str,
|
|
173
|
+
model: str,
|
|
174
|
+
api_key: str = "",
|
|
175
|
+
timeout: int = 120,
|
|
176
|
+
) -> None:
|
|
177
|
+
self.base_url = base_url.rstrip("/")
|
|
178
|
+
self.model = model
|
|
179
|
+
self.api_key = api_key
|
|
180
|
+
self.timeout = timeout
|
|
181
|
+
|
|
182
|
+
def generate(self, prompt: str, system: str = "") -> str:
|
|
183
|
+
# base_url may or may not include /v1 — normalize
|
|
184
|
+
base = self.base_url.rstrip("/")
|
|
185
|
+
if base.endswith("/v1"):
|
|
186
|
+
url = f"{base}/chat/completions"
|
|
187
|
+
else:
|
|
188
|
+
url = f"{base}/v1/chat/completions"
|
|
189
|
+
messages: list[dict[str, str]] = []
|
|
190
|
+
if system:
|
|
191
|
+
messages.append({"role": "system", "content": system})
|
|
192
|
+
messages.append({"role": "user", "content": prompt})
|
|
193
|
+
|
|
194
|
+
body = json.dumps({"model": self.model, "messages": messages}).encode()
|
|
195
|
+
|
|
196
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
197
|
+
if self.api_key:
|
|
198
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
199
|
+
|
|
200
|
+
last_exc: BaseException | None = None
|
|
201
|
+
for attempt in range(self.max_retries + 1):
|
|
202
|
+
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
203
|
+
try:
|
|
204
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
205
|
+
data = json.loads(resp.read().decode())
|
|
206
|
+
content = data["choices"][0]["message"]["content"]
|
|
207
|
+
if not isinstance(content, str):
|
|
208
|
+
raise RuntimeError("Backend response must contain text content")
|
|
209
|
+
return content
|
|
210
|
+
except urllib.error.HTTPError as exc:
|
|
211
|
+
status = exc.code
|
|
212
|
+
if status == 429:
|
|
213
|
+
retry_after = (
|
|
214
|
+
exc.headers.get("Retry-After") if exc.headers else None
|
|
215
|
+
)
|
|
216
|
+
try:
|
|
217
|
+
delay = (
|
|
218
|
+
min(60.0, max(0.0, float(retry_after)))
|
|
219
|
+
if retry_after is not None
|
|
220
|
+
else self.base_delay * (2**attempt)
|
|
221
|
+
)
|
|
222
|
+
except ValueError:
|
|
223
|
+
delay = self.base_delay * (2**attempt)
|
|
224
|
+
last_exc = exc
|
|
225
|
+
if attempt < self.max_retries:
|
|
226
|
+
time.sleep(delay)
|
|
227
|
+
continue
|
|
228
|
+
raise RuntimeError(
|
|
229
|
+
f"HTTP {status} after {self.max_retries + 1} attempts"
|
|
230
|
+
) from exc
|
|
231
|
+
elif status >= 500:
|
|
232
|
+
delay = self.base_delay * (2**attempt)
|
|
233
|
+
last_exc = exc
|
|
234
|
+
if attempt < self.max_retries:
|
|
235
|
+
time.sleep(delay)
|
|
236
|
+
continue
|
|
237
|
+
raise RuntimeError(
|
|
238
|
+
f"HTTP {status} after {self.max_retries + 1} attempts"
|
|
239
|
+
) from exc
|
|
240
|
+
else:
|
|
241
|
+
# 4xx (except 429): fail immediately
|
|
242
|
+
raise RuntimeError(f"HTTP {status}: {exc.reason}") from exc
|
|
243
|
+
except urllib.error.URLError as exc:
|
|
244
|
+
# Connection refused, DNS failure, etc.
|
|
245
|
+
last_exc = exc
|
|
246
|
+
if attempt < self.max_retries:
|
|
247
|
+
delay = self.base_delay * (2**attempt)
|
|
248
|
+
time.sleep(delay)
|
|
249
|
+
continue
|
|
250
|
+
raise RuntimeError(
|
|
251
|
+
f"connection error after {self.max_retries + 1} attempts: {exc.reason}"
|
|
252
|
+
) from exc
|
|
253
|
+
except (TimeoutError, OSError, HTTPException) as exc:
|
|
254
|
+
# Socket timeout or low-level OS error
|
|
255
|
+
last_exc = exc
|
|
256
|
+
msg = str(exc).lower()
|
|
257
|
+
if "timed out" in msg or isinstance(exc, TimeoutError):
|
|
258
|
+
raise RuntimeError(f"timeout after {self.timeout}s: {exc}") from exc
|
|
259
|
+
# Other OS errors: retry
|
|
260
|
+
if attempt < self.max_retries:
|
|
261
|
+
delay = self.base_delay * (2**attempt)
|
|
262
|
+
time.sleep(delay)
|
|
263
|
+
continue
|
|
264
|
+
raise RuntimeError(
|
|
265
|
+
f"connection error after {self.max_retries + 1} attempts: {exc}"
|
|
266
|
+
) from exc
|
|
267
|
+
|
|
268
|
+
raise RuntimeError(
|
|
269
|
+
f"Unexpected retry exhaustion: {last_exc}"
|
|
270
|
+
) # pragma: no cover
|