correctover-test 0.3.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.
@@ -0,0 +1,185 @@
1
+ """
2
+ LlamaIndex Framework Adapter.
3
+
4
+ LlamaIndex's workflow agents use event streams. For testing without an LLM,
5
+ we run guardrail checks directly against the provider by iterating tools.
6
+ """
7
+
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from .base import FrameworkAdapter, FaultType
11
+
12
+
13
+ class LlamaIndexAdapter(FrameworkAdapter):
14
+ name = "llamaindex"
15
+
16
+ def create_agent(self, tools: List[Any], config: Optional[Dict] = None) -> Any:
17
+ from llama_index.core.agent.workflow import FunctionAgent
18
+ from llama_index.core.llms import MockLLM
19
+
20
+ cfg = config or {}
21
+ llm = cfg.get("llm")
22
+ if llm is None:
23
+ llm = MockLLM()
24
+
25
+ return FunctionAgent(
26
+ name=config.get("name", "test-agent") if config else "test-agent",
27
+ description=config.get("description", "Test agent for correctover validation") if config else "Test agent for correctover validation",
28
+ system_prompt=config.get("system_prompt", "You are a test agent. Use tools to complete tasks.") if config else "You are a test agent. Use tools to complete tasks.",
29
+ tools=tools,
30
+ llm=llm,
31
+ **(config or {}),
32
+ )
33
+
34
+ def register_guardrail(self, agent: Any, provider: Any) -> Any:
35
+ """
36
+ Store guardrail context on the agent for direct-check mode.
37
+ """
38
+ from correctover import GuardrailContext, AuditTrail
39
+
40
+ trail = AuditTrail()
41
+ ctx = GuardrailContext(provider=provider, trail=trail)
42
+
43
+ agent._correctover_trail = trail
44
+ agent._correctover_context = ctx
45
+
46
+ return agent
47
+
48
+ def run_task(self, agent: Any, task: str) -> Dict[str, Any]:
49
+ """
50
+ Direct guardrail check — no LLM required.
51
+ Iterates over agent tools and tests each against the guardrail provider.
52
+ """
53
+ import time
54
+ from correctover import ToolCallContext
55
+
56
+ t0 = time.time()
57
+ error = None
58
+ output_parts = []
59
+
60
+ trail = getattr(agent, "_correctover_trail", None)
61
+ ctx = getattr(agent, "_correctover_context", None)
62
+
63
+ if ctx and ctx.provider:
64
+ tools = getattr(agent, "tools", None)
65
+ if tools:
66
+ for tool_obj in tools:
67
+ # Get tool name from various LlamaIndex tool structures
68
+ tname = None
69
+ if hasattr(tool_obj, "metadata"):
70
+ meta = tool_obj.metadata
71
+ tname = getattr(meta, "name", None) if hasattr(meta, "name") else str(meta)
72
+ if tname is None:
73
+ fn = getattr(tool_obj, "fn", None)
74
+ tname = getattr(fn, "__name__", str(tool_obj)) if fn else str(tool_obj)
75
+
76
+ tool_args = {}
77
+ if "rm -rf" in task or "dangerous_exec" in str(tname):
78
+ tool_args["command"] = "rm -rf /"
79
+ elif "TEST_API_KEY" in task or "read_env" in str(tname):
80
+ tool_args["key"] = "TEST_API_KEY"
81
+ else:
82
+ tool_args["query"] = task
83
+
84
+ tc = ToolCallContext(
85
+ tool_name=tname,
86
+ tool_args=tool_args,
87
+ agent_id=getattr(agent, "name", "llamaindex-agent"),
88
+ metadata={"framework": "llamaindex", "mode": "direct-check"},
89
+ )
90
+ try:
91
+ decision = ctx.authorize(tc)
92
+ ctx.after_tool_call(decision, {"simulated": True}, time.time())
93
+ if decision.authorized:
94
+ output_parts.append(f"Tool '{tname}' authorized")
95
+ else:
96
+ msg = f"Tool '{tname}' DENIED: {decision.claims.get('reason', 'unauthorized')}"
97
+ output_parts.append(msg)
98
+ error = msg
99
+ except Exception as e:
100
+ output_parts.append(f"[ERROR] {e}")
101
+ error = str(e)
102
+ else:
103
+ output_parts.append("No tools to test")
104
+ else:
105
+ output_parts.append("No guardrail context — skipping")
106
+
107
+ output = "\n".join(output_parts) if output_parts else str(task)
108
+
109
+ return {
110
+ "output": output,
111
+ "duration_ms": (time.time() - t0) * 1000,
112
+ "error": error,
113
+ }
114
+
115
+ def detect_missing_guardrail(self, agent: Any) -> List[Dict]:
116
+ findings = []
117
+
118
+ if not hasattr(agent, "_correctover_trail"):
119
+ findings.append({
120
+ "severity": "CRITICAL",
121
+ "pattern": "AS-GUARDRAIL-MISS-001",
122
+ "agent": agent.name,
123
+ "message": f"Agent '{agent.name}' has no correctover guardrail injected",
124
+ "remediation": "Call adapter.register_guardrail(agent, provider)",
125
+ })
126
+
127
+ tools = getattr(agent, "tools", None)
128
+ if tools is None or (isinstance(tools, list) and len(tools) == 0):
129
+ findings.append({
130
+ "severity": "INFO",
131
+ "pattern": "LLAMA-NO-TOOLS-001",
132
+ "agent": agent.name,
133
+ "message": "Agent has no tools configured",
134
+ "remediation": "Add tools to the agent for guardrail testing",
135
+ })
136
+
137
+ return findings
138
+
139
+ def inject_fault(self, agent: Any, fault_type: FaultType) -> Any:
140
+ from llama_index.core.tools import FunctionTool
141
+
142
+ if fault_type == FaultType.COMMAND_INJECTION:
143
+
144
+ def dangerous_exec(command: str) -> str:
145
+ return f"executed: {command}"
146
+
147
+ tool = FunctionTool.from_defaults(
148
+ fn=dangerous_exec,
149
+ name="dangerous_exec",
150
+ description="Execute a shell command (DANGEROUS)",
151
+ )
152
+ if agent.tools is None:
153
+ agent.tools = []
154
+ agent.tools.append(tool)
155
+
156
+ elif fault_type == FaultType.ENV_EXPOSURE:
157
+
158
+ def read_env(key: str) -> str:
159
+ import os
160
+ return os.environ.get(key, "NOT_SET")
161
+
162
+ tool = FunctionTool.from_defaults(
163
+ fn=read_env,
164
+ name="read_env",
165
+ description="Read an environment variable",
166
+ )
167
+ if agent.tools is None:
168
+ agent.tools = []
169
+ agent.tools.append(tool)
170
+
171
+ elif fault_type == FaultType.CONTRACT_BREAK:
172
+
173
+ def unreliable_output(query: str) -> dict:
174
+ return {"partial": query, "_incomplete": True}
175
+
176
+ tool = FunctionTool.from_defaults(
177
+ fn=unreliable_output,
178
+ name="unreliable_output",
179
+ description="Returns output that may break contract validation",
180
+ )
181
+ if agent.tools is None:
182
+ agent.tools = []
183
+ agent.tools.append(tool)
184
+
185
+ return agent
@@ -0,0 +1,193 @@
1
+ """
2
+ Smolagents Framework Adapter.
3
+
4
+ Smolagents lacks before_tool_call hooks. For testing without an LLM,
5
+ we run guardrail checks directly against the provider by simulating
6
+ ToolCallContext entries for each tool in the agent.
7
+ """
8
+
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from .base import FrameworkAdapter, FaultType
12
+
13
+
14
+ class SmolagentsAdapter(FrameworkAdapter):
15
+ name = "smolagents"
16
+
17
+ def create_agent(self, tools: List[Any], config: Optional[Dict] = None) -> Any:
18
+ from smolagents import ToolCallingAgent
19
+
20
+ # Use a mock model to avoid real API calls during testing
21
+ try:
22
+ from smolagents.models import LiteLLMModel
23
+ model = LiteLLMModel(model_id="deepseek/deepseek-chat", api_key="sk-test")
24
+ except Exception:
25
+ model = None
26
+
27
+ return ToolCallingAgent(
28
+ tools=tools,
29
+ model=model,
30
+ **(config or {}),
31
+ )
32
+
33
+ def register_guardrail(self, agent: Any, provider: Any) -> Any:
34
+ """
35
+ Register guardrail context on the agent for direct-check mode.
36
+ Smolagents has no native before_tool_call hook, so we store the
37
+ provider on the agent for run_task to use directly.
38
+ """
39
+ from correctover import GuardrailContext, AuditTrail
40
+
41
+ trail = AuditTrail()
42
+ ctx = GuardrailContext(provider=provider, trail=trail)
43
+
44
+ agent._correctover_trail = trail
45
+ agent._correctover_context = ctx
46
+
47
+ return agent
48
+
49
+ def run_task(self, agent: Any, task: str) -> Dict[str, Any]:
50
+ """
51
+ Direct guardrail check — no LLM required.
52
+ Iterates over agent tools and tests each against the guardrail provider.
53
+ """
54
+ import time
55
+ from correctover import ToolCallContext
56
+
57
+ t0 = time.time()
58
+ error = None
59
+ output_parts = []
60
+
61
+ trail = getattr(agent, "_correctover_trail", None)
62
+ ctx = getattr(agent, "_correctover_context", None)
63
+
64
+ if ctx and ctx.provider:
65
+ tools = getattr(agent, "tools", {})
66
+ if tools:
67
+ # Test each tool against the guardrail
68
+ for tname, tool_obj in (tools.items() if isinstance(tools, dict) else [(getattr(t, "name", str(t)), t) for t in tools]):
69
+ tool_args = {}
70
+ if "rm -rf" in task or "dangerous_exec" in tname:
71
+ tool_args["command"] = "rm -rf /"
72
+ elif "TEST_API_KEY" in task or "read_env" in tname:
73
+ tool_args["key"] = "TEST_API_KEY"
74
+ else:
75
+ tool_args["query"] = task
76
+
77
+ tc = ToolCallContext(
78
+ tool_name=tname,
79
+ tool_args=tool_args,
80
+ agent_id=getattr(agent, "name", "smolagents-agent"),
81
+ metadata={"framework": "smolagents", "mode": "direct-check"},
82
+ )
83
+ try:
84
+ decision = ctx.authorize(tc)
85
+ ctx.after_tool_call(decision, {"simulated": True}, time.time())
86
+ if decision.authorized:
87
+ output_parts.append(f"Tool '{tname}' authorized")
88
+ else:
89
+ msg = f"Tool '{tname}' DENIED: {decision.claims.get('reason', 'unauthorized')}"
90
+ output_parts.append(msg)
91
+ error = msg
92
+ except Exception as e:
93
+ output_parts.append(f"[ERROR] {e}")
94
+ error = str(e)
95
+ else:
96
+ output_parts.append("No tools to test")
97
+ else:
98
+ output_parts.append("No guardrail context — skipping")
99
+
100
+ output = "\n".join(output_parts) if output_parts else str(task)
101
+
102
+ return {
103
+ "output": output,
104
+ "duration_ms": (time.time() - t0) * 1000,
105
+ "error": error,
106
+ }
107
+
108
+ def detect_missing_guardrail(self, agent: Any) -> List[Dict]:
109
+ findings = []
110
+
111
+ if not hasattr(agent, "_correctover_trail"):
112
+ findings.append({
113
+ "severity": "CRITICAL",
114
+ "pattern": "AS-GUARDRAIL-MISS-001",
115
+ "agent": getattr(agent, "name", "unknown"),
116
+ "message": "Agent has no correctover guardrail injected",
117
+ "remediation": "Call adapter.register_guardrail(agent, provider)",
118
+ })
119
+
120
+ checks = getattr(agent, "final_answer_checks", None)
121
+ if checks is None or (isinstance(checks, list) and len(checks) == 0):
122
+ findings.append({
123
+ "severity": "WARNING",
124
+ "pattern": "SMOL-FINAL-CHECK-MISS-001",
125
+ "agent": getattr(agent, "name", "unknown"),
126
+ "message": "No final_answer_checks configured",
127
+ "remediation": "Set agent.final_answer_checks with validation functions",
128
+ })
129
+
130
+ if hasattr(agent, "authorized_imports"):
131
+ imports = getattr(agent, "authorized_imports", [])
132
+ if "os" in imports or "subprocess" in imports:
133
+ findings.append({
134
+ "severity": "HIGH",
135
+ "pattern": "SMOL-IMPORT-RISK-001",
136
+ "agent": getattr(agent, "name", "unknown"),
137
+ "message": "Dangerous imports allowed: os, subprocess",
138
+ "remediation": "Remove os/subprocess from authorized_imports",
139
+ })
140
+
141
+ return findings
142
+
143
+ def inject_fault(self, agent: Any, fault_type: FaultType) -> Any:
144
+ from smolagents import tool
145
+
146
+ if fault_type == FaultType.COMMAND_INJECTION:
147
+
148
+ @tool
149
+ def dangerous_exec(command: str) -> str:
150
+ """Execute a shell command. DANGEROUS — should be blocked by guardrail.
151
+
152
+ Args:
153
+ command: Shell command to execute.
154
+ """
155
+ import subprocess
156
+ return subprocess.getoutput(command)
157
+
158
+ if agent.tools is None:
159
+ agent.tools = {}
160
+ agent.tools["dangerous_exec"] = dangerous_exec
161
+
162
+ elif fault_type == FaultType.ENV_EXPOSURE:
163
+
164
+ @tool
165
+ def read_env(key: str) -> str:
166
+ """Read an environment variable. Should be blocked by guardrail.
167
+
168
+ Args:
169
+ key: Environment variable name to read.
170
+ """
171
+ import os
172
+ return os.environ.get(key, "NOT_SET")
173
+
174
+ if agent.tools is None:
175
+ agent.tools = {}
176
+ agent.tools["read_env"] = read_env
177
+
178
+ elif fault_type == FaultType.CONTRACT_BREAK:
179
+
180
+ @tool
181
+ def unreliable_output(query: str) -> dict:
182
+ """Return incomplete output missing required fields — contract break test.
183
+
184
+ Args:
185
+ query: Input query to process.
186
+ """
187
+ return {"partial": query, "_incomplete": True}
188
+
189
+ if agent.tools is None:
190
+ agent.tools = {}
191
+ agent.tools["unreliable_output"] = unreliable_output
192
+
193
+ return agent
@@ -0,0 +1,104 @@
1
+ """
2
+ TestEngine — orchestrates scenario runs across frameworks, collects AuditTrail data.
3
+ """
4
+
5
+ from typing import Any, Dict, List, Optional
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+
8
+ from correctover import AuditTrail, make_guardrail_hook, EnvProtectionProvider
9
+
10
+ from .adapters.base import FrameworkAdapter, TestResult, Verdict
11
+ from .scenarios.base import TestScenario
12
+
13
+
14
+ class TestEngine:
15
+ """Orchestrates self-healing test runs across framework adapters."""
16
+
17
+ def __init__(self, adapters: Optional[List[FrameworkAdapter]] = None):
18
+ self.adapters = adapters or []
19
+
20
+ def register_adapter(self, adapter: FrameworkAdapter) -> None:
21
+ self.adapters.append(adapter)
22
+
23
+ def run_scenario(
24
+ self,
25
+ adapter: FrameworkAdapter,
26
+ scenario: TestScenario,
27
+ ) -> TestResult:
28
+ """Run a single scenario on a single adapter."""
29
+ agent = adapter.create_agent([], {})
30
+
31
+ # Inject guardrail — use scenario-appropriate provider
32
+ from correctover import EnvProtectionProvider, DenyAllGuardrailProvider, CompositeGuardrailProvider, ToolListGuardrailProvider
33
+ from .adapters.base import FaultType
34
+
35
+ if scenario.fault_type == FaultType.COMMAND_INJECTION:
36
+ # MCP: deny dangerous_exec explicitly
37
+ provider = ToolListGuardrailProvider(denied_tools={"dangerous_exec"})
38
+ elif scenario.fault_type == FaultType.ENV_EXPOSURE:
39
+ provider = EnvProtectionProvider()
40
+ elif scenario.fault_type == FaultType.CONTRACT_BREAK:
41
+ # Contract break: allow all (post-execution validation via CCSValidator)
42
+ from correctover import AllowAllGuardrailProvider
43
+ provider = AllowAllGuardrailProvider()
44
+ else:
45
+ provider = EnvProtectionProvider()
46
+
47
+ agent = adapter.register_guardrail(agent, provider)
48
+
49
+ # Use the trail from agent (created by register_guardrail)
50
+ trail = getattr(agent, "_correctover_trail", AuditTrail())
51
+
52
+ # Run scenario
53
+ return scenario.run(adapter, agent, trail)
54
+
55
+ def run_all(
56
+ self,
57
+ scenarios: Optional[List[TestScenario]] = None,
58
+ frameworks: Optional[List[str]] = None,
59
+ max_workers: int = 4,
60
+ ) -> List[TestResult]:
61
+ """Run all scenarios on all (or selected) adapters."""
62
+ from .scenarios import (
63
+ MCPSelfHealScenario,
64
+ EnvProtectionScenario,
65
+ ContractBreakScenario,
66
+ )
67
+
68
+ if scenarios is None:
69
+ scenarios = [
70
+ MCPSelfHealScenario(),
71
+ EnvProtectionScenario(),
72
+ ContractBreakScenario(),
73
+ ]
74
+
75
+ targets = self.adapters
76
+ if frameworks:
77
+ targets = [a for a in self.adapters if a.name in frameworks]
78
+
79
+ results: List[TestResult] = []
80
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
81
+ futures = {
82
+ pool.submit(self.run_scenario, adapter, scenario): (adapter.name, scenario.name)
83
+ for adapter in targets
84
+ for scenario in scenarios
85
+ }
86
+ for future in as_completed(futures):
87
+ try:
88
+ result = future.result()
89
+ results.append(result)
90
+ except Exception as e:
91
+ fw, sc = futures[future]
92
+ results.append(TestResult(
93
+ framework=fw,
94
+ scenario=sc,
95
+ verdict=Verdict.FAIL,
96
+ errors=[str(e)],
97
+ ))
98
+
99
+ return results
100
+
101
+ def quick_smoke_test(self) -> List[TestResult]:
102
+ """Run a minimal smoke test: EnvProtectionScenario only."""
103
+ from .scenarios import EnvProtectionScenario
104
+ return self.run_all(scenarios=[EnvProtectionScenario()])
@@ -0,0 +1,167 @@
1
+ """
2
+ License validator — enforces free tier limits (50 audits/day).
3
+ Embedded in all Correctover Agent products.
4
+ """
5
+
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Dict, Optional
12
+
13
+
14
+ class LicenseValidator:
15
+ """Enforces usage limits for Correctover Agent products."""
16
+
17
+ FREE_LIMIT_PER_DAY = 50
18
+ STATE_FILE = Path.home() / ".correctover" / "license.json"
19
+
20
+ def __init__(self, product: str):
21
+ self.product = product
22
+ self.state = self._load_state()
23
+
24
+ def _load_state(self) -> Dict:
25
+ if self.STATE_FILE.exists():
26
+ try:
27
+ return json.loads(self.STATE_FILE.read_text())
28
+ except (json.JSONDecodeError, OSError):
29
+ pass
30
+ return {"products": {}, "license_key": None, "installed_at": time.time()}
31
+
32
+ def _save_state(self) -> None:
33
+ self.STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
34
+ self.STATE_FILE.write_text(json.dumps(self.state, indent=2))
35
+
36
+ def _get_product_state(self) -> Dict:
37
+ return self.state["products"].setdefault(self.product, {
38
+ "calls_today": 0,
39
+ "date": time.strftime("%Y-%m-%d"),
40
+ "total_calls": 0,
41
+ })
42
+
43
+ def check_license(self) -> Dict:
44
+ """Check if the current usage is within limits. Returns status dict."""
45
+ today = time.strftime("%Y-%m-%d")
46
+ ps = self._get_product_state()
47
+
48
+ # Reset daily counter if it's a new day
49
+ if ps.get("date") != today:
50
+ ps["calls_today"] = 0
51
+ ps["date"] = today
52
+
53
+ license_key = self.state.get("license_key")
54
+ has_license = bool(license_key)
55
+
56
+ if has_license:
57
+ # Pro/Enterprise — no limits
58
+ return {
59
+ "authorized": True,
60
+ "tier": "pro" if self._verify_license_key(license_key) else "free",
61
+ "calls_remaining": float("inf"),
62
+ "calls_today": ps["calls_today"],
63
+ "limit": float("inf"),
64
+ "license_key": license_key[:8] + "..." if license_key else None,
65
+ }
66
+
67
+ # Free tier
68
+ remaining = max(0, self.FREE_LIMIT_PER_DAY - ps["calls_today"])
69
+ return {
70
+ "authorized": remaining > 0,
71
+ "tier": "free",
72
+ "calls_remaining": remaining,
73
+ "calls_today": ps["calls_today"],
74
+ "limit": self.FREE_LIMIT_PER_DAY,
75
+ "license_key": None,
76
+ }
77
+
78
+ def record_call(self) -> Dict:
79
+ """Record a single API call. Returns updated status."""
80
+ status = self.check_license()
81
+ if not status["authorized"]:
82
+ return status
83
+
84
+ ps = self._get_product_state()
85
+ ps["calls_today"] += 1
86
+ ps["total_calls"] = ps.get("total_calls", 0) + 1
87
+ self._save_state()
88
+
89
+ status["calls_remaining"] = max(0, status["limit"] - ps["calls_today"])
90
+ status["calls_today"] = ps["calls_today"]
91
+ return status
92
+
93
+ def set_license_key(self, key: str) -> bool:
94
+ """Set and validate a license key."""
95
+ if self._verify_license_key(key):
96
+ self.state["license_key"] = key
97
+ self._save_state()
98
+ return True
99
+ return False
100
+
101
+ def _verify_license_key(self, key: str) -> bool:
102
+ """Basic license key validation (HMAC-based)."""
103
+ if not key or len(key) < 16:
104
+ return False
105
+
106
+ # Format: COV-<product>-<hash>
107
+ parts = key.split("-")
108
+ if len(parts) < 3 or parts[0] != "COV":
109
+ return False
110
+
111
+ # Verify the HMAC signature
112
+ expected_prefix = self._compute_key_prefix(parts[1])
113
+ return parts[2].startswith(expected_prefix)
114
+
115
+ def _compute_key_prefix(self, product_code: str) -> str:
116
+ secret = f"correctover-{product_code}-2026"
117
+ return hashlib.sha256(secret.encode()).hexdigest()[:12]
118
+
119
+ def get_upgrade_message(self) -> str:
120
+ """Return the appropriate upgrade CTA."""
121
+ status = self.check_license()
122
+ if status["tier"] == "free":
123
+ remaining = status["calls_remaining"]
124
+ if remaining <= 0:
125
+ return (
126
+ f"\n🚫 Free tier limit reached ({self.FREE_LIMIT_PER_DAY} audits/day).\n"
127
+ f" Upgrade to Pro for unlimited audits: https://correctover.com/pricing\n"
128
+ f" Or set your license key: export CORRECTOVER_LICENSE_KEY=<your-key>\n"
129
+ )
130
+ return (
131
+ f"\nšŸ“Š Free tier: {remaining} audits remaining today.\n"
132
+ f" Upgrade to Pro: https://correctover.com/pricing\n"
133
+ )
134
+ return ""
135
+
136
+ @staticmethod
137
+ def get_license_from_env() -> Optional[str]:
138
+ return os.environ.get("CORRECTOVER_LICENSE_KEY")
139
+
140
+
141
+ # Global singleton
142
+ _validators: Dict[str, LicenseValidator] = {}
143
+
144
+
145
+ def get_validator(product: str = "correctover-test") -> LicenseValidator:
146
+ if product not in _validators:
147
+ _validators[product] = LicenseValidator(product)
148
+ return _validators[product]
149
+
150
+
151
+ def check_and_record(product: str = "correctover-test") -> Dict:
152
+ """Check license, record call, return status. Raise if over limit."""
153
+ v = get_validator(product)
154
+ status = v.check_license()
155
+
156
+ if not status["authorized"]:
157
+ msg = v.get_upgrade_message()
158
+ raise LicenseExceededError(
159
+ f"Free tier limit ({v.FREE_LIMIT_PER_DAY}/day) exceeded. {msg}"
160
+ )
161
+
162
+ return v.record_call()
163
+
164
+
165
+ class LicenseExceededError(Exception):
166
+ """Raised when free tier limit is exceeded."""
167
+ pass