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/__init__.py +3 -0
- aicert/__main__.py +6 -0
- aicert/artifacts.py +104 -0
- aicert/cli.py +1423 -0
- aicert/config.py +193 -0
- aicert/doctor.py +366 -0
- aicert/hashing.py +28 -0
- aicert/metrics.py +305 -0
- aicert/providers/__init__.py +13 -0
- aicert/providers/anthropic.py +182 -0
- aicert/providers/base.py +36 -0
- aicert/providers/openai.py +153 -0
- aicert/providers/openai_compatible.py +152 -0
- aicert/runner.py +620 -0
- aicert/templating.py +83 -0
- aicert/validation.py +322 -0
- aicert-0.1.0.dist-info/METADATA +306 -0
- aicert-0.1.0.dist-info/RECORD +22 -0
- aicert-0.1.0.dist-info/WHEEL +5 -0
- aicert-0.1.0.dist-info/entry_points.txt +2 -0
- aicert-0.1.0.dist-info/licenses/LICENSE +21 -0
- aicert-0.1.0.dist-info/top_level.txt +1 -0
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())
|