aicert 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/config.py ADDED
@@ -0,0 +1,193 @@
1
+ """Configuration models for aicert."""
2
+
3
+ from pathlib import Path
4
+ from typing import Literal, Optional
5
+
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+
9
+ class ChaosConfig(BaseModel):
10
+ """Configuration for FakeAdapter chaos mode.
11
+
12
+ All probability values should be between 0 and 1.
13
+ """
14
+
15
+ seed: int = Field(default=1337, description="Random seed for reproducible chaos")
16
+ p_invalid_json: float = Field(default=0.0, ge=0, le=1, description="Probability of returning invalid JSON")
17
+ p_wrong_schema: float = Field(default=0.0, ge=0, le=1, description="Probability of JSON with wrong schema")
18
+ p_extra_keys: float = Field(default=0.0, ge=0, le=1, description="Probability of JSON with extra keys")
19
+ p_wrapped_json: float = Field(default=0.0, ge=0, le=1, description="Probability of JSON wrapped in markdown fence")
20
+ p_non_json: float = Field(default=0.0, ge=0, le=1, description="Probability of non-JSON response")
21
+ p_timeout: float = Field(default=0.0, ge=0, le=1, description="Probability of timeout")
22
+ p_http_429: float = Field(default=0.0, ge=0, le=1, description="Probability of HTTP 429 error")
23
+ p_http_500: float = Field(default=0.0, ge=0, le=1, description="Probability of HTTP 500 error")
24
+
25
+
26
+ class ProviderConfig(BaseModel):
27
+ """Configuration for an LLM provider."""
28
+
29
+ id: str = Field(..., description="Provider identifier")
30
+ provider: Literal["openai", "anthropic", "openai_compatible", "fake"] = Field(
31
+ ..., description="Provider type"
32
+ )
33
+ model: str = Field(..., description="Model identifier")
34
+ temperature: float = Field(..., description="Temperature for sampling")
35
+ base_url: Optional[str] = Field(
36
+ None, description="Base URL for openai_compatible provider"
37
+ )
38
+ chaos: Optional[ChaosConfig] = Field(
39
+ None, description="Chaos mode configuration (fake provider only)"
40
+ )
41
+
42
+
43
+ class ValidationConfig(BaseModel):
44
+ """Configuration for output validation."""
45
+
46
+ extract_json: bool = Field(default=True, description="Extract JSON from response")
47
+ allow_extra_keys: bool = Field(
48
+ default=False, description="Allow extra keys in JSON output"
49
+ )
50
+
51
+
52
+ class ThresholdsConfig(BaseModel):
53
+ """Configuration for pass/fail thresholds."""
54
+
55
+ min_stability: int = Field(default=85, description="Minimum stability percentage")
56
+ min_compliance: int = Field(default=95, description="Minimum compliance percentage")
57
+ max_cost_usd: Optional[float] = Field(None, description="Maximum cost in USD")
58
+ p95_latency_ms: Optional[int] = Field(None, description="P95 latency in milliseconds")
59
+
60
+
61
+ class CIConfig(BaseModel):
62
+ """Configuration for CI mode."""
63
+
64
+ runs: int = Field(default=10, description="Number of runs in CI mode")
65
+ save_on_fail: bool = Field(
66
+ default=True, description="Save results on test failure"
67
+ )
68
+
69
+
70
+ class Config(BaseModel):
71
+ """Main configuration for aicert."""
72
+
73
+ project: str = Field(..., description="Project name")
74
+
75
+ providers: list[ProviderConfig] = Field(
76
+ ..., description="List of LLM provider configurations"
77
+ )
78
+
79
+ prompt_file: str = Field(..., description="Path to prompt file")
80
+ cases_file: str = Field(..., description="Path to test cases file (JSONL)")
81
+ schema_file: str = Field(..., description="Path to JSON schema file")
82
+
83
+ runs: int = Field(default=50, description="Number of test runs")
84
+ concurrency: int = Field(default=10, description="Number of concurrent requests")
85
+ timeout_s: int = Field(default=30, description="Timeout for requests in seconds")
86
+
87
+ validation: ValidationConfig = Field(
88
+ default_factory=ValidationConfig, description="Validation settings"
89
+ )
90
+
91
+ thresholds: ThresholdsConfig = Field(
92
+ default_factory=ThresholdsConfig, description="Pass/fail thresholds"
93
+ )
94
+
95
+ ci: CIConfig = Field(default_factory=CIConfig, description="CI mode settings")
96
+
97
+ @field_validator("providers", mode="before")
98
+ @classmethod
99
+ def ensure_providers_list(cls, v):
100
+ """Ensure providers is a list."""
101
+ if isinstance(v, dict):
102
+ return [v]
103
+ return v
104
+
105
+ @property
106
+ def primary_provider(self) -> ProviderConfig:
107
+ """Get the primary provider (first one)."""
108
+ return self.providers[0]
109
+
110
+
111
+ class ConfigLoadError(Exception):
112
+ """Error raised when configuration loading fails."""
113
+
114
+ def __init__(self, message: str, config_path: Optional[str] = None, hint: Optional[str] = None):
115
+ self.message = message
116
+ self.config_path = config_path
117
+ self.hint = hint
118
+ super().__init__(self._format_message())
119
+
120
+ def _format_message(self) -> str:
121
+ """Format the error message with context."""
122
+ parts = []
123
+ if self.config_path:
124
+ parts.append(f"[bold red]Config file: {self.config_path}[/bold red]")
125
+ parts.append(f"[bold red]Error:[/bold red] {self.message}")
126
+ if self.hint:
127
+ parts.append(f"[bold yellow]Hint:[/bold yellow] {self.hint}")
128
+ return "\n".join(parts)
129
+
130
+
131
+ def load_config(path: str) -> Config:
132
+ """Load configuration from YAML file.
133
+
134
+ Args:
135
+ path: Path to the YAML config file.
136
+
137
+ Returns:
138
+ Config object with validated settings.
139
+
140
+ Raises:
141
+ ConfigLoadError: If the file cannot be loaded or validation fails.
142
+ FileNotFoundError: If referenced files don't exist.
143
+ """
144
+ import yaml
145
+
146
+ config_path = Path(path)
147
+ config_dir = config_path.parent
148
+
149
+ try:
150
+ with open(config_path, "r") as f:
151
+ config_data = yaml.safe_load(f)
152
+ except FileNotFoundError:
153
+ raise ConfigLoadError(
154
+ message=f"Config file not found: {path}",
155
+ config_path=str(config_path.resolve()),
156
+ hint="Make sure the path is correct and the file exists."
157
+ )
158
+ except yaml.YAMLError as e:
159
+ raise ConfigLoadError(
160
+ message=f"Invalid YAML in config file: {e}",
161
+ config_path=str(config_path.resolve()),
162
+ hint="Check for syntax errors like incorrect indentation or missing colons."
163
+ )
164
+
165
+ try:
166
+ config = Config(**config_data)
167
+ except Exception as e:
168
+ raise ConfigLoadError(
169
+ message=f"Configuration validation failed: {e}",
170
+ config_path=str(config_path.resolve()),
171
+ hint="Check that all required fields are present and have the correct types."
172
+ )
173
+
174
+ # Validate referenced files exist, resolving relative to config directory
175
+ errors: list[str] = []
176
+
177
+ for field_name in ["prompt_file", "cases_file", "schema_file"]:
178
+ file_path = getattr(config, field_name)
179
+ resolved_path = config_dir / file_path
180
+ if not resolved_path.exists():
181
+ errors.append(
182
+ f"{field_name}: '{file_path}' not found "
183
+ f"(resolved to: {resolved_path})"
184
+ )
185
+
186
+ if errors:
187
+ raise ConfigLoadError(
188
+ message="Referenced files not found:\n - " + "\n - ".join(errors),
189
+ config_path=str(config_path.resolve()),
190
+ hint="Make sure all file paths are correct and files exist. Paths are resolved relative to the config file directory."
191
+ )
192
+
193
+ return config
aicert/doctor.py ADDED
@@ -0,0 +1,366 @@
1
+ """Doctor command for validating aicert installation and configuration."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional, Tuple
8
+
9
+ import httpx
10
+ from rich.console import Console
11
+
12
+ from aicert.config import Config, ConfigLoadError, ProviderConfig, load_config
13
+ from aicert.templating import build_schema_hint, render_prompt
14
+ from aicert.validation import load_json_schema, validate_output
15
+
16
+ console = Console()
17
+
18
+
19
+ class DoctorCheck:
20
+ """Represents a single doctor check with result."""
21
+
22
+ def __init__(self, name: str):
23
+ self.name = name
24
+ self.passed = False
25
+ self.error: Optional[str] = None
26
+ self.details: List[str] = []
27
+
28
+ def pass_check(self, details: Optional[List[str]] = None) -> None:
29
+ """Mark check as passed."""
30
+ self.passed = True
31
+ if details:
32
+ self.details = details
33
+
34
+ def fail_check(self, error: str) -> None:
35
+ """Mark check as failed."""
36
+ self.passed = False
37
+ self.error = error
38
+
39
+ def add_detail(self, detail: str) -> None:
40
+ """Add a detail message."""
41
+ self.details.append(detail)
42
+
43
+
44
+ def load_cases(cases_file: str) -> Tuple[List[Dict[str, Any]], List[str]]:
45
+ """Load test cases from JSONL file.
46
+
47
+ Returns:
48
+ Tuple of (cases, errors)
49
+ """
50
+ cases = []
51
+ errors = []
52
+
53
+ with open(cases_file, "r") as f:
54
+ for line_num, line in enumerate(f, 1):
55
+ line = line.strip()
56
+ if not line:
57
+ continue
58
+ try:
59
+ case = json.loads(line)
60
+ cases.append(case)
61
+ # Check for required 'id' field
62
+ if "id" not in case and "name" not in case:
63
+ errors.append(f"Line {line_num}: Missing 'id' or 'name' field")
64
+ except json.JSONDecodeError as e:
65
+ errors.append(f"Line {line_num}: Invalid JSON - {e}")
66
+
67
+ return cases, errors
68
+
69
+
70
+ def check_provider_env(provider: ProviderConfig) -> Tuple[str, Optional[str]]:
71
+ """Check provider environment readiness.
72
+
73
+ Returns:
74
+ Tuple of (status, message)
75
+ Status: "OK" | "MISSING_ENV" | "MISCONFIG"
76
+ """
77
+ if provider.provider == "fake":
78
+ return "OK", "No environment variables required for fake provider"
79
+
80
+ if provider.provider == "openai":
81
+ api_key = os.environ.get("OPENAI_API_KEY")
82
+ if not api_key:
83
+ return "MISSING_ENV", "OPENAI_API_KEY not set"
84
+ return "OK", "OPENAI_API_KEY is set"
85
+
86
+ if provider.provider == "anthropic":
87
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
88
+ if not api_key:
89
+ return "MISSING_ENV", "ANTHROPIC_API_KEY not set"
90
+ return "OK", "ANTHROPIC_API_KEY is set"
91
+
92
+ if provider.provider == "openai_compatible":
93
+ base_url = provider.base_url
94
+ if not base_url:
95
+ return "MISCONFIG", "base_url not configured"
96
+
97
+ api_key_env = os.environ.get("OPENAI_COMPAT_API_KEY")
98
+ if api_key_env:
99
+ return "OK", f"base_url={base_url}, OPENAI_COMPAT_API_KEY is set"
100
+ else:
101
+ return "OK", f"base_url={base_url}, OPENAI_COMPAT_API_KEY not set (optional)"
102
+
103
+ return "MISCONFIG", f"Unknown provider type: {provider.provider}"
104
+
105
+
106
+ async def check_connectivity(provider: ProviderConfig) -> Tuple[bool, str]:
107
+ """Check connectivity to openai_compatible provider.
108
+
109
+ Returns:
110
+ Tuple of (success, message)
111
+ """
112
+ base_url = provider.base_url
113
+ if not base_url:
114
+ return False, "No base_url configured"
115
+
116
+ # Try /models endpoint first, then fallback to base URL
117
+ test_urls = [
118
+ f"{base_url.rstrip('/')}/models",
119
+ base_url.rstrip('/'),
120
+ ]
121
+
122
+ for url in test_urls:
123
+ try:
124
+ async with httpx.AsyncClient(timeout=3.0) as client:
125
+ response = await client.get(url, follow_redirects=True)
126
+ if response.status_code < 500:
127
+ return True, f"Connected to {url} (status {response.status_code})"
128
+ except httpx.TimeoutException:
129
+ continue
130
+ except Exception as e:
131
+ continue
132
+
133
+ return False, f"Could not connect to {base_url} (tried {len(test_urls)} endpoints)"
134
+
135
+
136
+ def run_doctor(
137
+ config_path: str,
138
+ check_connectivity_flag: bool = False,
139
+ ) -> Tuple[int, int]:
140
+ """Run all doctor checks.
141
+
142
+ Args:
143
+ config_path: Path to configuration file.
144
+ check_connectivity_flag: Whether to check connectivity for openai_compatible providers.
145
+
146
+ Returns:
147
+ Tuple of (exit_code, failed_checks_count)
148
+ """
149
+ checks: List[DoctorCheck] = []
150
+ failed_count = 0
151
+
152
+ # === A. Load Config ===
153
+ check = DoctorCheck("Config")
154
+ checks.append(check)
155
+ try:
156
+ config = load_config(config_path)
157
+ check.pass_check([f"Config path: {config_path}", f"Project: {config.project}"])
158
+ except ConfigLoadError:
159
+ # Re-raise for CLI to handle exit code
160
+ raise
161
+ except Exception as e:
162
+ check.fail_check(f"Failed to load config: {e}")
163
+ failed_count += 1
164
+ # Can't continue without config
165
+ return 1, failed_count
166
+
167
+ # === B. Validate Files ===
168
+ config_dir = Path(config_path).parent
169
+
170
+ # B1. Prompt file
171
+ check = DoctorCheck("Files")
172
+ checks.append(check)
173
+ prompt_errors = []
174
+
175
+ prompt_file = config_dir / config.prompt_file
176
+ try:
177
+ with open(prompt_file, "r") as f:
178
+ prompt_content = f.read()
179
+ check.add_detail(f"prompt_file: {config.prompt_file} (readable, {len(prompt_content)} chars)")
180
+ except Exception as e:
181
+ prompt_errors.append(f"prompt_file: {e}")
182
+
183
+ # B2. Cases file
184
+ cases_file = config_dir / config.cases_file
185
+ cases: List[Dict[str, Any]] = []
186
+ try:
187
+ cases, case_errors = load_cases(str(cases_file))
188
+ if case_errors:
189
+ prompt_errors.extend(case_errors)
190
+ else:
191
+ check.add_detail(f"cases_file: {config.cases_file} ({len(cases)} cases)")
192
+ except Exception as e:
193
+ prompt_errors.append(f"cases_file: {e}")
194
+
195
+ # B3. Schema file
196
+ schema_file = config_dir / config.schema_file
197
+ schema: Dict[str, Any] = {}
198
+ try:
199
+ schema = load_json_schema(str(schema_file))
200
+ check.add_detail(f"schema_file: {config.schema_file} (valid JSON schema)")
201
+ except Exception as e:
202
+ prompt_errors.append(f"schema_file: {e}")
203
+
204
+ if prompt_errors:
205
+ check.fail_check("\n ".join(prompt_errors))
206
+ failed_count += 1
207
+ else:
208
+ check.pass_check()
209
+
210
+ # === C. Template Render Validation ===
211
+ check = DoctorCheck("Template")
212
+ checks.append(check)
213
+ template_errors = []
214
+
215
+ if cases and schema:
216
+ schema_hint = build_schema_hint(schema)
217
+ # Test first 1-3 cases
218
+ test_cases = cases[:3]
219
+ for case in test_cases:
220
+ case_id = case.get("id") or case.get("name", "unknown")
221
+ prompt_template = case.get("prompt", "")
222
+ variables = case.get("variables", {})
223
+ try:
224
+ rendered = render_prompt(prompt_template, variables, schema_hint, case_id)
225
+ check.add_detail(f"Case '{case_id}': rendered successfully")
226
+ except ValueError as e:
227
+ template_errors.append(f"Case '{case_id}': {e}")
228
+
229
+ if template_errors:
230
+ check.fail_check("\n ".join(template_errors))
231
+ failed_count += 1
232
+ else:
233
+ if cases:
234
+ check.pass_check([f"Rendered {min(3, len(cases))} case(s) successfully"])
235
+ else:
236
+ check.pass_check(["No cases to test"])
237
+
238
+ # === D. Validation Pipeline Sanity ===
239
+ check = DoctorCheck("Validation")
240
+ checks.append(check)
241
+
242
+ # Use FakeAdapter with deterministic output to test validation
243
+ try:
244
+ from aicert.runner import FakeAdapter
245
+ import asyncio
246
+
247
+ async def test_validation():
248
+ adapter = FakeAdapter(latency_ms=1)
249
+ result = await adapter.generate("Test prompt")
250
+ content = result["choices"][0]["message"]["content"]
251
+ return content
252
+
253
+ sample_output = asyncio.run(test_validation())
254
+
255
+ # Test validation with schema
256
+ validation_result = validate_output(
257
+ text=sample_output,
258
+ schema=schema,
259
+ extract_json=config.validation.extract_json,
260
+ allow_extra_keys=config.validation.allow_extra_keys,
261
+ )
262
+
263
+ if validation_result.ok_json and validation_result.ok_schema:
264
+ check.pass_check(["Validation pipeline working correctly"])
265
+ elif validation_result.ok_json:
266
+ check.fail_check(f"Schema validation failed: {validation_result.error}")
267
+ failed_count += 1
268
+ else:
269
+ check.fail_check(f"JSON parsing failed: {validation_result.error}")
270
+ failed_count += 1
271
+ except Exception as e:
272
+ check.fail_check(f"Validation pipeline error: {e}")
273
+ failed_count += 1
274
+
275
+ # === E. Provider Readiness ===
276
+ check = DoctorCheck("Providers")
277
+ checks.append(check)
278
+ provider_status = []
279
+ provider_issues = []
280
+
281
+ for provider in config.providers:
282
+ status, message = check_provider_env(provider)
283
+ provider_status.append(f"{provider.id} ({provider.provider}): {status}")
284
+ if status != "OK":
285
+ provider_issues.append(f"{provider.id}: {message}")
286
+
287
+ if provider_issues:
288
+ check.fail_check("\n ".join(provider_issues))
289
+ failed_count += 1
290
+ else:
291
+ check.pass_check(provider_status)
292
+
293
+ # === E2. Connectivity Check (optional) ===
294
+ if check_connectivity_flag:
295
+ check = DoctorCheck("Connectivity")
296
+ checks.append(check)
297
+ connectivity_results = []
298
+ connectivity_issues = []
299
+
300
+ for provider in config.providers:
301
+ if provider.provider == "openai_compatible":
302
+ success, message = asyncio.run(check_connectivity(provider))
303
+ if success:
304
+ connectivity_results.append(f"{provider.id}: {message}")
305
+ else:
306
+ connectivity_issues.append(f"{provider.id}: {message}")
307
+
308
+ if connectivity_issues:
309
+ # Don't fail doctor for connectivity issues, just warn
310
+ connectivity_results.extend([f"[warn] {x}" for x in connectivity_issues])
311
+ check.pass_check(connectivity_results)
312
+ else:
313
+ check.pass_check(connectivity_results if connectivity_results else ["No openai_compatible providers"])
314
+
315
+ # === Print Summary ===
316
+ console.print("\n[bold]Doctor Summary[/bold]")
317
+ console.print("-" * 50)
318
+
319
+ for check in checks:
320
+ if check.passed:
321
+ icon = "✅"
322
+ console.print(f" {icon} {check.name}")
323
+ for detail in check.details:
324
+ console.print(f" {detail}")
325
+ else:
326
+ icon = "❌"
327
+ console.print(f" {icon} {check.name}")
328
+ error = check.error or "Unknown error"
329
+ for line in error.split("\n"):
330
+ console.print(f" {line}")
331
+
332
+ console.print("-" * 50)
333
+
334
+ # Final verdict
335
+ total_failed = len([c for c in checks if not c.passed])
336
+ if total_failed == 0:
337
+ console.print("[bold green]Doctor: OK[/bold green]")
338
+ return 0, 0
339
+ else:
340
+ console.print(f"[bold red]Doctor: Issues found ({total_failed})[/bold red]")
341
+ return 1, total_failed
342
+
343
+
344
+ def print_dry_run_plan(config: Config, cases: List[Dict[str, Any]]) -> None:
345
+ """Print the dry-run execution plan."""
346
+ console.print("\n[bold]Dry Run Plan[/bold]")
347
+ console.print("-" * 50)
348
+
349
+ providers_count = len(config.providers)
350
+ cases_count = len(cases)
351
+ runs = config.runs
352
+ total_requests = providers_count * cases_count * runs
353
+
354
+ console.print(f" Providers: {providers_count}")
355
+ for p in config.providers:
356
+ console.print(f" - {p.id}: {p.provider}/{p.model}")
357
+ console.print(f" Cases: {cases_count}")
358
+ console.print(f" Runs per case: {runs}")
359
+ console.print(f" [bold]Total requests: {total_requests}[/bold]")
360
+ console.print(f" Concurrency: {config.concurrency}")
361
+ console.print(f" Timeout: {config.timeout_s}s")
362
+ console.print(f" Validation:")
363
+ console.print(f" - extract_json: {config.validation.extract_json}")
364
+ console.print(f" - allow_extra_keys: {config.validation.allow_extra_keys}")
365
+
366
+ console.print("-" * 50)
aicert/hashing.py ADDED
@@ -0,0 +1,28 @@
1
+ """Hashing helpers for aicert."""
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+
6
+
7
+ def sha256_bytes(b: bytes) -> str:
8
+ """Compute SHA-256 hash of bytes and return as 'sha256:<hex>' format.
9
+
10
+ Args:
11
+ b: Bytes to hash.
12
+
13
+ Returns:
14
+ String in format "sha256:<hex>" where <hex> is the lowercase SHA-256 hex digest.
15
+ """
16
+ return f"sha256:{hashlib.sha256(b).hexdigest()}"
17
+
18
+
19
+ def sha256_file(path: Path) -> str:
20
+ """Compute SHA-256 hash of a file and return as 'sha256:<hex>' format.
21
+
22
+ Args:
23
+ path: Path to the file to hash.
24
+
25
+ Returns:
26
+ String in format "sha256:<hex>" where <hex> is the lowercase SHA-256 hex digest.
27
+ """
28
+ return sha256_bytes(path.read_bytes())