aicert-pro 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.
aicert_pro/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """aicert_pro - Pro baseline regression enforcement for aicert CLI."""
2
+
3
+ __version__ = "0.1.0"
aicert_pro/baseline.py ADDED
@@ -0,0 +1,209 @@
1
+ """Baseline regression enforcement for aicert-pro."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ # Default thresholds for regression detection
8
+ DEFAULT_MAX_STABILITY_DROP = 5
9
+ DEFAULT_MAX_COMPLIANCE_DROP = 2
10
+ DEFAULT_MAX_P95_LATENCY_REGRESSION_PCT = 25
11
+ DEFAULT_MAX_COST_REGRESSION_PCT = 20
12
+
13
+
14
+ def provider_key(provider: str, model: str, temperature: float | None) -> str:
15
+ """Generate unique provider key for baseline tracking.
16
+
17
+ Args:
18
+ provider: Provider name (e.g., "anthropic", "openai").
19
+ model: Model name (e.g., "claude-3-5-sonnet-20241022").
20
+ temperature: Temperature setting or None.
21
+
22
+ Returns:
23
+ Unique key string combining provider, model, and temperature.
24
+ """
25
+ temp_str = f"_{temperature}" if temperature is not None else ""
26
+ return f"{provider}/{model}{temp_str}"
27
+
28
+
29
+ def load_baseline(path: Path) -> dict:
30
+ """Load baseline data from JSON file.
31
+
32
+ Args:
33
+ path: Path to baseline JSON file.
34
+
35
+ Returns:
36
+ Baseline data dictionary.
37
+ """
38
+ with open(path, "r") as f:
39
+ return json.load(f)
40
+
41
+
42
+ def save_baseline(path: Path, data: dict) -> None:
43
+ """Save baseline data to JSON file.
44
+
45
+ Args:
46
+ path: Path to save baseline JSON file.
47
+ data: Baseline data dictionary.
48
+ """
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ with open(path, "w") as f:
51
+ json.dump(data, f, indent=2)
52
+
53
+
54
+ def baseline_path(project: str, baseline_dir: Path | None) -> Path:
55
+ """Get baseline file path for a project.
56
+
57
+ Args:
58
+ project: Project name.
59
+ baseline_dir: Directory to store baselines, or None for default.
60
+
61
+ Returns:
62
+ Path to baseline JSON file.
63
+ """
64
+ if baseline_dir is None:
65
+ baseline_dir = Path.home() / ".aicert" / "baselines"
66
+ baseline_dir.mkdir(parents=True, exist_ok=True)
67
+ return baseline_dir / f"{project}.json"
68
+
69
+
70
+ def save_from_run(run_dir: Path, baseline_dir: Path | None = None) -> dict:
71
+ """Save baseline metrics from a run directory.
72
+
73
+ Reads summary.json from the run directory and saves provider metrics
74
+ to the baseline store.
75
+
76
+ Args:
77
+ run_dir: Path to run directory containing summary.json.
78
+ baseline_dir: Directory to store baselines, or None for default.
79
+
80
+ Returns:
81
+ The saved baseline data dictionary.
82
+ """
83
+ summary_path = run_dir / "summary.json"
84
+ if not summary_path.exists():
85
+ raise FileNotFoundError(f"summary.json not found in {run_dir}")
86
+
87
+ with open(summary_path, "r") as f:
88
+ summary = json.load(f)
89
+
90
+ project = summary.get("project", "default")
91
+
92
+ # Extract provider metrics from summary
93
+ metrics = summary.get("metrics", {})
94
+ provider = summary.get("provider", "")
95
+ model = summary.get("model", "")
96
+ temperature = summary.get("temperature")
97
+
98
+ provider_key_str = provider_key(provider, model, temperature)
99
+
100
+ baseline_data = {
101
+ "project": project,
102
+ "provider_key": provider_key_str,
103
+ "created_at": summary.get("timestamp", ""),
104
+ "metrics": {
105
+ "stability": metrics.get("stability", 0),
106
+ "compliance": metrics.get("compliance", 0),
107
+ "p95_latency_ms": metrics.get("p95_latency_ms", 0),
108
+ "mean_latency_ms": metrics.get("mean_latency_ms", 0),
109
+ "mean_cost_usd": metrics.get("mean_cost_usd"),
110
+ "prompt_hash": summary.get("prompt_hash", ""),
111
+ "schema_hash": summary.get("schema_hash", ""),
112
+ }
113
+ }
114
+
115
+ # Remove None values
116
+ baseline_data["metrics"] = {k: v for k, v in baseline_data["metrics"].items() if v is not None}
117
+
118
+ path = baseline_path(project, baseline_dir)
119
+ save_baseline(path, baseline_data)
120
+
121
+ return baseline_data
122
+
123
+
124
+ def check_from_run(
125
+ run_dir: Path,
126
+ baseline_dir: Path | None = None,
127
+ max_stability_drop: int = DEFAULT_MAX_STABILITY_DROP,
128
+ max_compliance_drop: int = DEFAULT_MAX_COMPLIANCE_DROP,
129
+ max_p95_latency_regression_pct: int = DEFAULT_MAX_P95_LATENCY_REGRESSION_PCT,
130
+ max_cost_regression_pct: int = DEFAULT_MAX_COST_REGRESSION_PCT,
131
+ ) -> bool:
132
+ """Check current run against baseline for regressions.
133
+
134
+ Args:
135
+ run_dir: Path to run directory containing summary.json.
136
+ baseline_dir: Directory to store baselines, or None for default.
137
+ max_stability_drop: Maximum allowed stability drop (percentage points).
138
+ max_compliance_drop: Maximum allowed compliance drop (percentage points).
139
+ max_p95_latency_regression_pct: Maximum allowed P95 latency regression (%).
140
+ max_cost_regression_pct: Maximum allowed cost regression (%).
141
+
142
+ Returns:
143
+ True if all metrics pass baseline thresholds, False otherwise.
144
+ """
145
+ summary_path = run_dir / "summary.json"
146
+ if not summary_path.exists():
147
+ raise FileNotFoundError(f"summary.json not found in {run_dir}")
148
+
149
+ with open(summary_path, "r") as f:
150
+ summary = json.load(f)
151
+
152
+ project = summary.get("project", "default")
153
+ baseline_file = baseline_path(project, baseline_dir)
154
+
155
+ if not baseline_file.exists():
156
+ raise FileNotFoundError(f"Baseline not found for project '{project}'. Run 'baseline save' first.")
157
+
158
+ baseline = load_baseline(baseline_file)
159
+ baseline_metrics = baseline.get("metrics", {})
160
+ current_metrics = summary.get("metrics", {})
161
+
162
+ failures: list[str] = []
163
+
164
+ # Check stability drop
165
+ baseline_stability = baseline_metrics.get("stability", 0)
166
+ current_stability = current_metrics.get("stability", 0)
167
+ stability_drop = baseline_stability - current_stability
168
+ if stability_drop > max_stability_drop:
169
+ failures.append(
170
+ f"Stability dropped by {stability_drop:.1f} (max: {max_stability_drop})"
171
+ )
172
+
173
+ # Check compliance drop
174
+ baseline_compliance = baseline_metrics.get("compliance", 0)
175
+ current_compliance = current_metrics.get("compliance", 0)
176
+ compliance_drop = baseline_compliance - current_compliance
177
+ if compliance_drop > max_compliance_drop:
178
+ failures.append(
179
+ f"Compliance dropped by {compliance_drop:.1f} (max: {max_compliance_drop})"
180
+ )
181
+
182
+ # Check P95 latency regression
183
+ baseline_p95 = baseline_metrics.get("p95_latency_ms", 0)
184
+ current_p95 = current_metrics.get("p95_latency_ms", 0)
185
+ if baseline_p95 > 0 and current_p95 > 0:
186
+ p95_regression_pct = ((current_p95 - baseline_p95) / baseline_p95) * 100
187
+ if p95_regression_pct > max_p95_latency_regression_pct:
188
+ failures.append(
189
+ f"P95 latency regressed by {p95_regression_pct:.1f}% (max: {max_p95_latency_regression_pct}%)"
190
+ )
191
+
192
+ # Check cost regression
193
+ baseline_cost = baseline_metrics.get("mean_cost_usd")
194
+ current_cost = current_metrics.get("mean_cost_usd")
195
+ if baseline_cost is not None and current_cost is not None and baseline_cost > 0:
196
+ cost_regression_pct = ((current_cost - baseline_cost) / baseline_cost) * 100
197
+ if cost_regression_pct > max_cost_regression_pct:
198
+ failures.append(
199
+ f"Cost regressed by {cost_regression_pct:.1f}% (max: {max_cost_regression_pct}%)"
200
+ )
201
+
202
+ if failures:
203
+ print("Baseline regression detected:")
204
+ for failure in failures:
205
+ print(f" - {failure}")
206
+ return False
207
+
208
+ print("All metrics pass baseline thresholds.")
209
+ return True
aicert_pro/cli.py ADDED
@@ -0,0 +1,171 @@
1
+ """CLI for aicert-pro baseline regression enforcement."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from .baseline import (
12
+ baseline_path,
13
+ check_from_run,
14
+ load_baseline,
15
+ save_from_run,
16
+ )
17
+ from .license import LicenseError, require_pro
18
+
19
+ app = typer.Typer(
20
+ name="aicert-pro",
21
+ help="Pro baseline regression enforcement for aicert CLI.",
22
+ add_completion=False,
23
+ )
24
+
25
+
26
+ def run_aicert(config_path: Path) -> Path:
27
+ """Run aicert stability evaluation and return the run directory.
28
+
29
+ Args:
30
+ config_path: Path to aicert.yaml configuration file.
31
+
32
+ Returns:
33
+ Path to the run directory containing summary.json.
34
+
35
+ Exits:
36
+ With the same exit code if aicert fails.
37
+ """
38
+ cmd = [sys.executable, "-m", "aicert", "stability", str(config_path), "--ci"]
39
+ result = subprocess.run(cmd, capture_output=True, text=True)
40
+
41
+ if result.returncode != 0:
42
+ typer.echo(f"aicert failed:\n{result.stderr}", err=True)
43
+ raise typer.Exit(result.returncode)
44
+
45
+ # Try to extract run directory from stdout
46
+ # aicert outputs something like "Run complete. Results in: /path/to/run/dir"
47
+ for line in result.stdout.strip().split("\n"):
48
+ if "Run complete" in line or "Results in" in line:
49
+ # Extract path from line
50
+ parts = line.split()
51
+ for part in parts:
52
+ if part.startswith("/") or (len(part) > 1 and part[1] == ":"):
53
+ run_dir = Path(part)
54
+ if run_dir.exists():
55
+ return run_dir
56
+
57
+ # Fallback: assume default artifact location
58
+ # Default is typically ~/.aicert/runs/<timestamp>
59
+ from datetime import datetime
60
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
61
+ run_dir = Path.home() / ".aicert" / "runs" / timestamp
62
+
63
+ if not run_dir.exists():
64
+ raise typer.BadParameter(f"Could not determine run directory from aicert output. "
65
+ f"Please specify run directory directly.")
66
+
67
+ return run_dir
68
+
69
+
70
+ @app.command("license")
71
+ def license_verify() -> None:
72
+ """Verify pro license."""
73
+ require_pro()
74
+ typer.echo("License is valid.")
75
+
76
+
77
+ baseline_app = typer.Typer(help="Manage baselines for regression enforcement.")
78
+ app.add_typer(baseline_app, name="baseline")
79
+
80
+
81
+ @baseline_app.command("save")
82
+ def baseline_save(
83
+ path: Path = typer.Argument(
84
+ ...,
85
+ help="Path to run directory or .yaml config file",
86
+ ),
87
+ baseline_dir: Path | None = typer.Option(
88
+ None,
89
+ "--baseline-dir",
90
+ "-b",
91
+ help="Directory to store baselines",
92
+ ),
93
+ ) -> None:
94
+ """Save baseline metrics from a run directory or run aicert first."""
95
+ try:
96
+ require_pro()
97
+ except LicenseError:
98
+ raise typer.Exit(5)
99
+
100
+ # Determine if path is a config file or run directory
101
+ if path.suffix == ".yaml" or path.suffix == ".yml":
102
+ # Run aicert first
103
+ run_dir = run_aicert(path)
104
+ typer.echo(f"Evaluation complete. Run directory: {run_dir}")
105
+ else:
106
+ run_dir = path
107
+
108
+ data = save_from_run(run_dir, baseline_dir)
109
+ result_path = baseline_path(data["project"], baseline_dir)
110
+ typer.echo(f"Baseline saved to {result_path}")
111
+
112
+
113
+ @baseline_app.command("check")
114
+ def baseline_check(
115
+ path: Path = typer.Argument(
116
+ ...,
117
+ help="Path to run directory or .yaml config file",
118
+ ),
119
+ baseline_dir: Path | None = typer.Option(
120
+ None,
121
+ "--baseline-dir",
122
+ "-b",
123
+ help="Directory to store baselines",
124
+ ),
125
+ ) -> None:
126
+ """Check current run against baseline for regressions."""
127
+ try:
128
+ require_pro()
129
+ except LicenseError:
130
+ raise typer.Exit(5)
131
+
132
+ # Determine if path is a config file or run directory
133
+ if path.suffix == ".yaml" or path.suffix == ".yml":
134
+ # Run aicert first
135
+ run_dir = run_aicert(path)
136
+ typer.echo(f"Evaluation complete. Run directory: {run_dir}")
137
+ else:
138
+ run_dir = path
139
+
140
+ passed = check_from_run(run_dir, baseline_dir)
141
+ if not passed:
142
+ raise typer.Exit(2)
143
+ typer.echo("Baseline check passed.")
144
+
145
+
146
+ @baseline_app.command("show")
147
+ def baseline_show(
148
+ project: str = typer.Argument(..., help="Project name"),
149
+ baseline_dir: Path | None = typer.Option(
150
+ None,
151
+ "--baseline-dir",
152
+ "-b",
153
+ help="Directory to store baselines",
154
+ ),
155
+ ) -> None:
156
+ """Show baseline metrics for a project."""
157
+ require_pro()
158
+ path = baseline_path(project, baseline_dir)
159
+ if not path.exists():
160
+ raise typer.BadParameter(f"Baseline not found for project '{project}'")
161
+ data = load_baseline(path)
162
+ typer.echo(json.dumps(data, indent=2))
163
+
164
+
165
+ def main() -> None:
166
+ """Entry point for aicert-pro CLI."""
167
+ app()
168
+
169
+
170
+ if __name__ == "__main__":
171
+ app()
aicert_pro/license.py ADDED
@@ -0,0 +1,144 @@
1
+ """License verification for aicert-pro using Ed25519 signatures."""
2
+
3
+ import base64
4
+ import os
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timezone
8
+ from typing import Optional
9
+
10
+ from cryptography.hazmat.primitives.asymmetric import ed25519
11
+ from cryptography.hazmat.primitives import serialization
12
+ from cryptography.exceptions import InvalidSignature
13
+
14
+ # Embedded Ed25519 public key for license verification (placeholder)
15
+ # Replace with actual public key for production
16
+ LICENSE_PUBLIC_KEY = ed25519.Ed25519PublicKey.from_public_bytes(
17
+ base64.b64decode(
18
+ "ewWxgkhpOJpLGcQKky3gMuEKaBlvlEZjy9MJ6YYCpuY="
19
+ )
20
+ )
21
+
22
+ LICENSE_ENV_VAR = "AICERT_PRO_LICENSE"
23
+
24
+
25
+ class LicenseError(Exception):
26
+ """Base exception for license errors."""
27
+
28
+ exit_code = 5
29
+
30
+
31
+ class LicenseMissingError(LicenseError):
32
+ """Raised when license is not found."""
33
+
34
+ def __init__(self) -> None:
35
+ super().__init__("License not found. Set AICERT_PRO_LICENSE environment variable.")
36
+
37
+
38
+ class LicenseInvalidError(LicenseError):
39
+ """Raised when license signature is invalid."""
40
+
41
+ def __init__(self) -> None:
42
+ super().__init__("License signature is invalid.")
43
+
44
+
45
+ class LicenseExpiredError(LicenseError):
46
+ """Raised when license has expired."""
47
+
48
+ def __init__(self, expired_at: str) -> None:
49
+ super().__init__(f"License expired at {expired_at}.")
50
+
51
+
52
+ @dataclass
53
+ class LicenseData:
54
+ """License data parsed from base64-encoded JSON."""
55
+
56
+ license_id: str
57
+ owner: str
58
+ issued_at: str
59
+ expires_at: Optional[str] = None
60
+ features: Optional[list[str]] = None
61
+ signature: Optional[str] = None
62
+
63
+ @property
64
+ def is_expired(self) -> bool:
65
+ """Check if license is expired."""
66
+ if self.expires_at is None:
67
+ return False
68
+ try:
69
+ expires = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00"))
70
+ return datetime.now(timezone.utc) > expires
71
+ except ValueError:
72
+ return False
73
+
74
+
75
+ def verify_license(license_data: LicenseData) -> bool:
76
+ """Verify Ed25519 signature on license data.
77
+
78
+ Args:
79
+ license_data: Parsed license data including signature.
80
+
81
+ Returns:
82
+ True if signature is valid.
83
+
84
+ Raises:
85
+ LicenseInvalidError: If signature verification fails.
86
+ LicenseExpiredError: If license has expired.
87
+ """
88
+ if license_data.is_expired:
89
+ raise LicenseExpiredError(license_data.expires_at)
90
+
91
+ if license_data.signature is None:
92
+ raise LicenseInvalidError()
93
+
94
+ # Create signable payload (license data without signature)
95
+ payload = f"{license_data.license_id}:{license_data.owner}:{license_data.issued_at}"
96
+ if license_data.expires_at:
97
+ payload += f":{license_data.expires_at}"
98
+ if license_data.features:
99
+ payload += f":{','.join(license_data.features)}"
100
+
101
+ try:
102
+ signature_bytes = base64.b64decode(license_data.signature)
103
+ LICENSE_PUBLIC_KEY.verify(signature_bytes, payload.encode("utf-8"))
104
+ except (InvalidSignature, ValueError) as e:
105
+ raise LicenseInvalidError()
106
+
107
+ return True
108
+
109
+
110
+ def read_license_from_env() -> str:
111
+ """Read license string from environment variable.
112
+
113
+ Returns:
114
+ Base64-encoded license string.
115
+
116
+ Raises:
117
+ LicenseMissingError: If environment variable is not set.
118
+ """
119
+ license_str = os.environ.get(LICENSE_ENV_VAR)
120
+ if not license_str:
121
+ raise LicenseMissingError()
122
+ return license_str
123
+
124
+
125
+ def require_pro() -> LicenseData:
126
+ """Require and verify a valid pro license.
127
+
128
+ Returns:
129
+ Parsed and verified license data.
130
+
131
+ Exits:
132
+ Exit code 5 if license is missing or invalid.
133
+ """
134
+ try:
135
+ license_str = read_license_from_env()
136
+ import json
137
+ data = json.loads(base64.b64decode(license_str))
138
+ license_data = LicenseData(**data)
139
+ verify_license(license_data)
140
+ return license_data
141
+ except LicenseError:
142
+ raise
143
+ except Exception:
144
+ raise LicenseInvalidError()
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: aicert-pro
3
+ Version: 0.1.0
4
+ Summary: Pro baseline regression enforcement for aicert CLI
5
+ Author-email: aicert <dev@aicert.io>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: typer>=0.9.0
10
+ Requires-Dist: cryptography>=41.0.0
11
+ Requires-Dist: aicert>=0.1.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
14
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
15
+
16
+ # aicert-pro
17
+
18
+ Pro baseline regression enforcement for aicert CLI.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ pip install -e .
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### License Verification
29
+
30
+ ```bash
31
+ aicert-pro license verify
32
+ ```
33
+
34
+ Requires `AICERT_PRO_LICENSE` environment variable to be set with a valid license.
35
+
36
+ ### Baseline Management
37
+
38
+ Save baseline from a run:
39
+
40
+ ```bash
41
+ aicert-pro baseline save --from-run /path/to/run/dir
42
+ ```
43
+
44
+ Check current run against baseline:
45
+
46
+ ```bash
47
+ aicert-pro baseline check --from-run /path/to/run/dir
48
+ ```
49
+
50
+ Show baseline metrics:
51
+
52
+ ```bash
53
+ aicert-pro baseline show <project>
54
+ ```
55
+
56
+ ## License
57
+
58
+ MIT
@@ -0,0 +1,9 @@
1
+ aicert_pro/__init__.py,sha256=LZD_XDGLUwGfoPmJZf07LNJrS37CxMUwBzrnh6tPzQ4,94
2
+ aicert_pro/baseline.py,sha256=gvSAsTY80YQ6E8g8iyNSWXkF8Vhdg9PEmGBypjeK6ws,7470
3
+ aicert_pro/cli.py,sha256=IVGXoxl_7w5dMD9eSdLX2Ptpq3D3_R2yCxRGEM1-YmQ,4904
4
+ aicert_pro/license.py,sha256=NKlFVIj0LfEy_2Wnf4ew46iCOAPCs3z-U_GebmpsFrM,4078
5
+ aicert_pro-0.1.0.dist-info/METADATA,sha256=6yXQeJO2Xv3lGnjDxyt_ZFkz2czPFEUTs8Jiuar4xhk,1019
6
+ aicert_pro-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
7
+ aicert_pro-0.1.0.dist-info/entry_points.txt,sha256=fNBrg9QdIftLkfwU3yHxYUmMyUeJdJIo1-6FuEFJo3o,50
8
+ aicert_pro-0.1.0.dist-info/top_level.txt,sha256=VpQ9NFF4q5XG68g4DubEIEW6R5Du3t0-nOE7DQW1XZw,11
9
+ aicert_pro-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aicert-pro = aicert_pro.cli:app
@@ -0,0 +1 @@
1
+ aicert_pro