cortexops 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {cortexops-0.1.0 → cortexops-0.2.0}/.gitignore +33 -33
- {cortexops-0.1.0 → cortexops-0.2.0}/PKG-INFO +5 -6
- {cortexops-0.1.0 → cortexops-0.2.0}/cortexops/__init__.py +9 -3
- cortexops-0.2.0/cortexops/auth.py +149 -0
- {cortexops-0.1.0 → cortexops-0.2.0}/cortexops/cli.py +40 -2
- {cortexops-0.1.0 → cortexops-0.2.0}/cortexops/client.py +0 -1
- {cortexops-0.1.0 → cortexops-0.2.0}/cortexops/judge.py +0 -1
- {cortexops-0.1.0/cortexops → cortexops-0.2.0}/cortexops/metrics.py +1 -2
- {cortexops-0.1.0/cortexops → cortexops-0.2.0}/cortexops/models.py +3 -0
- {cortexops-0.1.0/cortexops → cortexops-0.2.0}/cortexops/tracer.py +99 -31
- {cortexops-0.1.0 → cortexops-0.2.0}/pyproject.toml +7 -8
- cortexops-0.2.0/tests/conftest.py +8 -0
- {cortexops-0.1.0 → cortexops-0.2.0}/tests/test_enhancements.py +3 -1
- cortexops-0.1.0/cortexops/cortexops/__init__.py +0 -58
- cortexops-0.1.0/cortexops/cortexops/cli.py +0 -195
- cortexops-0.1.0/cortexops/cortexops/client.py +0 -84
- cortexops-0.1.0/cortexops/cortexops/judge.py +0 -155
- cortexops-0.1.0/cortexops/eval.py +0 -216
- cortexops-0.1.0/cortexops/metrics.py +0 -184
- cortexops-0.1.0/cortexops/models.py +0 -141
- cortexops-0.1.0/cortexops/tests/test_enhancements.py +0 -222
- cortexops-0.1.0/cortexops/tracer.py +0 -210
- cortexops-0.1.0/tests/__init__.py +0 -0
- cortexops-0.1.0/tests/test_cortexops.py +0 -211
- {cortexops-0.1.0 → cortexops-0.2.0}/LICENSE +0 -0
- {cortexops-0.1.0 → cortexops-0.2.0}/README.md +0 -0
- {cortexops-0.1.0 → cortexops-0.2.0}/cortexops/LICENSE +0 -0
- {cortexops-0.1.0 → cortexops-0.2.0}/cortexops/README.md +0 -0
- {cortexops-0.1.0/cortexops → cortexops-0.2.0}/cortexops/eval.py +0 -0
- {cortexops-0.1.0 → cortexops-0.2.0}/cortexops/pyproject.toml +0 -0
- {cortexops-0.1.0/cortexops → cortexops-0.2.0}/tests/__init__.py +0 -0
- {cortexops-0.1.0/cortexops → cortexops-0.2.0}/tests/test_cortexops.py +0 -0
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
# Python
|
|
2
|
-
__pycache__/
|
|
3
|
-
*.py[cod]
|
|
4
|
-
*.pyo
|
|
5
|
-
.venv/
|
|
6
|
-
venv/
|
|
7
|
-
.env
|
|
8
|
-
*.egg-info/
|
|
9
|
-
dist/
|
|
10
|
-
build/
|
|
11
|
-
PKG-INFO
|
|
12
|
-
*.whl
|
|
13
|
-
*.tar.gz
|
|
14
|
-
|
|
15
|
-
# Test / lint caches
|
|
16
|
-
.pytest_cache/
|
|
17
|
-
.ruff_cache/
|
|
18
|
-
.mypy_cache/
|
|
19
|
-
|
|
20
|
-
# Package managers
|
|
21
|
-
uv.lock
|
|
22
|
-
.python-version
|
|
23
|
-
|
|
24
|
-
# Database
|
|
25
|
-
*.db
|
|
26
|
-
*.sqlite
|
|
27
|
-
|
|
28
|
-
# IDE
|
|
29
|
-
.vscode/
|
|
30
|
-
.idea/
|
|
31
|
-
|
|
32
|
-
# OS
|
|
33
|
-
.DS_Store
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
.venv/
|
|
6
|
+
venv/
|
|
7
|
+
.env
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
PKG-INFO
|
|
12
|
+
*.whl
|
|
13
|
+
*.tar.gz
|
|
14
|
+
|
|
15
|
+
# Test / lint caches
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
|
|
20
|
+
# Package managers
|
|
21
|
+
uv.lock
|
|
22
|
+
.python-version
|
|
23
|
+
|
|
24
|
+
# Database
|
|
25
|
+
*.db
|
|
26
|
+
*.sqlite
|
|
27
|
+
|
|
28
|
+
# IDE
|
|
29
|
+
.vscode/
|
|
30
|
+
.idea/
|
|
31
|
+
|
|
32
|
+
# OS
|
|
33
|
+
.DS_Store
|
|
34
34
|
Thumbs.db
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cortexops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Reliability infrastructure for AI agents — evaluation, observability, and regression testing
|
|
5
|
-
Project-URL: Homepage, https://
|
|
5
|
+
Project-URL: Homepage, https://getcortexops.com
|
|
6
6
|
Project-URL: Repository, https://github.com/ashishodu2023/cortexops
|
|
7
|
-
Project-URL: Documentation, https://docs.
|
|
7
|
+
Project-URL: Documentation, https://docs.getcortexops.com
|
|
8
8
|
Project-URL: Bug Tracker, https://github.com/ashishodu2023/cortexops/issues
|
|
9
9
|
Project-URL: Changelog, https://github.com/ashishodu2023/cortexops/releases
|
|
10
|
-
Author-email: Ashish <
|
|
10
|
+
Author-email: Ashish <ashish@getcortexops.com>
|
|
11
11
|
License: MIT License
|
|
12
12
|
|
|
13
13
|
Copyright (c) 2025 CortexOps Contributors
|
|
@@ -31,7 +31,7 @@ License: MIT License
|
|
|
31
31
|
SOFTWARE.
|
|
32
32
|
License-File: LICENSE
|
|
33
33
|
Keywords: agents,ai,autogen,crewai,evaluation,langgraph,llm,observability,testing
|
|
34
|
-
Classifier: Development Status ::
|
|
34
|
+
Classifier: Development Status :: 4 - Beta
|
|
35
35
|
Classifier: Intended Audience :: Developers
|
|
36
36
|
Classifier: License :: OSI Approved :: MIT License
|
|
37
37
|
Classifier: Operating System :: OS Independent
|
|
@@ -46,7 +46,6 @@ Classifier: Typing :: Typed
|
|
|
46
46
|
Requires-Python: >=3.10
|
|
47
47
|
Requires-Dist: pydantic>=2.0
|
|
48
48
|
Requires-Dist: pyyaml>=6.0
|
|
49
|
-
Requires-Dist: setuptools>=82.0.1
|
|
50
49
|
Provides-Extra: all
|
|
51
50
|
Requires-Dist: httpx>=0.27; extra == 'all'
|
|
52
51
|
Provides-Extra: dev
|
|
@@ -27,13 +27,14 @@ from .models import (
|
|
|
27
27
|
EvalSummary,
|
|
28
28
|
FailureKind,
|
|
29
29
|
RunStatus,
|
|
30
|
+
ToolCall,
|
|
30
31
|
Trace,
|
|
31
32
|
TraceNode,
|
|
32
|
-
ToolCall,
|
|
33
33
|
)
|
|
34
|
+
from .auth import cmd_login, cmd_logout, cmd_whoami, save_credentials, load_credentials
|
|
34
35
|
from .tracer import CortexTracer
|
|
35
36
|
|
|
36
|
-
__version__ = "0.
|
|
37
|
+
__version__ = "0.2.0"
|
|
37
38
|
|
|
38
39
|
__all__ = [
|
|
39
40
|
"CortexTracer",
|
|
@@ -55,4 +56,9 @@ __all__ = [
|
|
|
55
56
|
"CaseResult",
|
|
56
57
|
"FailureKind",
|
|
57
58
|
"RunStatus",
|
|
58
|
-
|
|
59
|
+
"cmd_login",
|
|
60
|
+
"cmd_logout",
|
|
61
|
+
"cmd_whoami",
|
|
62
|
+
"save_credentials",
|
|
63
|
+
"load_credentials",
|
|
64
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cortexops login / logout / whoami — credential management.
|
|
3
|
+
Stores API key in ~/.cortexops/credentials (JSON).
|
|
4
|
+
Called by CLI and importable for programmatic use.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
_CREDENTIALS_DIR = Path.home() / ".cortexops"
|
|
14
|
+
_CREDENTIALS_FILE = _CREDENTIALS_DIR / "credentials"
|
|
15
|
+
_DEFAULT_API_URL = "https://api.getcortexops.com"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def save_credentials(api_key: str, project: str, api_url: str = _DEFAULT_API_URL) -> None:
|
|
19
|
+
"""Persist credentials to ~/.cortexops/credentials."""
|
|
20
|
+
_CREDENTIALS_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
21
|
+
creds = {"api_key": api_key, "project": project, "api_url": api_url}
|
|
22
|
+
_CREDENTIALS_FILE.write_text(json.dumps(creds, indent=2))
|
|
23
|
+
_CREDENTIALS_FILE.chmod(0o600) # owner read/write only — no secrets leak
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_credentials() -> dict | None:
|
|
27
|
+
"""Load credentials from ~/.cortexops/credentials. Returns None if not found."""
|
|
28
|
+
if not _CREDENTIALS_FILE.exists():
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
return json.loads(_CREDENTIALS_FILE.read_text())
|
|
32
|
+
except Exception:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def clear_credentials() -> None:
|
|
37
|
+
"""Remove stored credentials."""
|
|
38
|
+
if _CREDENTIALS_FILE.exists():
|
|
39
|
+
_CREDENTIALS_FILE.unlink()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def verify_key(api_key: str, api_url: str = _DEFAULT_API_URL) -> dict | None:
|
|
43
|
+
"""
|
|
44
|
+
Call GET /health with the key to verify it is valid.
|
|
45
|
+
Returns {"project": ..., "environment": ...} on success, None on failure.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
import httpx
|
|
49
|
+
r = httpx.get(
|
|
50
|
+
f"{api_url.rstrip('/')}/health",
|
|
51
|
+
headers={"X-API-Key": api_key},
|
|
52
|
+
timeout=8.0,
|
|
53
|
+
)
|
|
54
|
+
if r.status_code == 200:
|
|
55
|
+
return r.json()
|
|
56
|
+
return None
|
|
57
|
+
except Exception:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cmd_login(api_key: str | None = None, project: str | None = None,
|
|
62
|
+
api_url: str = _DEFAULT_API_URL) -> int:
|
|
63
|
+
"""
|
|
64
|
+
Interactive login flow. Called by `cortexops login`.
|
|
65
|
+
|
|
66
|
+
If api_key is not provided, prompts interactively.
|
|
67
|
+
Returns 0 on success, 1 on failure.
|
|
68
|
+
"""
|
|
69
|
+
print("CortexOps Login")
|
|
70
|
+
print("Get your API key at https://getcortexops.com/#pricing\n")
|
|
71
|
+
|
|
72
|
+
if not api_key:
|
|
73
|
+
try:
|
|
74
|
+
import getpass
|
|
75
|
+
api_key = getpass.getpass("API key (cxo-...): ").strip()
|
|
76
|
+
except KeyboardInterrupt:
|
|
77
|
+
print("\nCancelled.")
|
|
78
|
+
return 1
|
|
79
|
+
|
|
80
|
+
if not api_key.startswith("cxo-"):
|
|
81
|
+
print("Error: API key must start with 'cxo-'", file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
if not project:
|
|
85
|
+
project = input("Default project name: ").strip() or "my-agent"
|
|
86
|
+
|
|
87
|
+
print(f"\nVerifying key against {api_url}...")
|
|
88
|
+
info = verify_key(api_key, api_url)
|
|
89
|
+
if info is None:
|
|
90
|
+
print(
|
|
91
|
+
"Warning: Could not verify key (API may be unreachable).\n"
|
|
92
|
+
"Saving credentials anyway — verify manually with: cortexops whoami",
|
|
93
|
+
file=sys.stderr,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
save_credentials(api_key, project, api_url)
|
|
97
|
+
masked = api_key[:8] + "..." + api_key[-4:]
|
|
98
|
+
print("\n✓ Logged in")
|
|
99
|
+
print(f" Key: {masked}")
|
|
100
|
+
print(f" Project: {project}")
|
|
101
|
+
print(" Stored: ~/.cortexops/credentials")
|
|
102
|
+
print("\nYou can now use CortexTracer without passing api_key:")
|
|
103
|
+
print(f" tracer = CortexTracer(project=\"{project}\")")
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_logout() -> int:
|
|
108
|
+
"""Called by `cortexops logout`."""
|
|
109
|
+
creds = load_credentials()
|
|
110
|
+
if not creds:
|
|
111
|
+
print("Not logged in.")
|
|
112
|
+
return 0
|
|
113
|
+
clear_credentials()
|
|
114
|
+
print("✓ Logged out — credentials removed from ~/.cortexops/credentials")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_whoami(api_url: str | None = None) -> int:
|
|
119
|
+
"""Called by `cortexops whoami`."""
|
|
120
|
+
# Check env var first
|
|
121
|
+
env_key = os.getenv("CORTEXOPS_API_KEY")
|
|
122
|
+
file_creds = load_credentials()
|
|
123
|
+
|
|
124
|
+
if not env_key and not file_creds:
|
|
125
|
+
print("Not logged in.\nRun: cortexops login", file=sys.stderr)
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
if env_key:
|
|
129
|
+
print("API key source : CORTEXOPS_API_KEY (env)")
|
|
130
|
+
masked = env_key[:8] + "..." + env_key[-4:]
|
|
131
|
+
print(f"Key : {masked}")
|
|
132
|
+
url = api_url or os.getenv("CORTEXOPS_API_URL", _DEFAULT_API_URL)
|
|
133
|
+
else:
|
|
134
|
+
masked = file_creds["api_key"][:8] + "..." + file_creds["api_key"][-4:]
|
|
135
|
+
print("API key source : ~/.cortexops/credentials")
|
|
136
|
+
print(f"Key : {masked}")
|
|
137
|
+
print(f"Project : {file_creds.get('project', '—')}")
|
|
138
|
+
url = api_url or file_creds.get("api_url", _DEFAULT_API_URL)
|
|
139
|
+
env_key = file_creds["api_key"]
|
|
140
|
+
|
|
141
|
+
print(f"API URL : {url}")
|
|
142
|
+
print("\nVerifying...")
|
|
143
|
+
info = verify_key(env_key, url)
|
|
144
|
+
if info:
|
|
145
|
+
print(f"✓ Key is valid (API status: {info.get('status', 'ok')})")
|
|
146
|
+
else:
|
|
147
|
+
print("✗ Key verification failed — API unreachable or key invalid")
|
|
148
|
+
return 1
|
|
149
|
+
return 0
|
|
@@ -76,7 +76,8 @@ def cmd_eval_diff(args: argparse.Namespace) -> int:
|
|
|
76
76
|
regressions = diff.get("regressions", [])
|
|
77
77
|
improvements = diff.get("improvements", [])
|
|
78
78
|
|
|
79
|
-
sign
|
|
79
|
+
def sign(v):
|
|
80
|
+
return f"+{v:.1%}" if v >= 0 else f"{v:.1%}"
|
|
80
81
|
print(f"Diff: {args.run_a[:8]} → {args.run_b[:8]}")
|
|
81
82
|
print(f" Task completion : {sign(delta_tc)}")
|
|
82
83
|
print(f" Tool accuracy : {sign(delta_tool / 100)}")
|
|
@@ -137,6 +138,29 @@ def _load_agent(agent_path: str):
|
|
|
137
138
|
return getattr(module, attr)
|
|
138
139
|
|
|
139
140
|
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_login(args: argparse.Namespace) -> int:
|
|
144
|
+
"""cortexops login"""
|
|
145
|
+
from cortexops.auth import cmd_login as _login
|
|
146
|
+
return _login(
|
|
147
|
+
api_key=getattr(args, 'api_key', None),
|
|
148
|
+
project=getattr(args, 'project', None),
|
|
149
|
+
api_url=getattr(args, 'base_url', 'https://api.getcortexops.com'),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cmd_logout(args: argparse.Namespace) -> int:
|
|
154
|
+
"""cortexops logout"""
|
|
155
|
+
from cortexops.auth import cmd_logout as _logout
|
|
156
|
+
return _logout()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_whoami(args: argparse.Namespace) -> int:
|
|
160
|
+
"""cortexops whoami"""
|
|
161
|
+
from cortexops.auth import cmd_whoami as _whoami
|
|
162
|
+
return _whoami(api_url=getattr(args, 'base_url', None))
|
|
163
|
+
|
|
140
164
|
def main() -> None:
|
|
141
165
|
parser = argparse.ArgumentParser(
|
|
142
166
|
prog="cortexops",
|
|
@@ -169,12 +193,26 @@ def main() -> None:
|
|
|
169
193
|
fail_p.add_argument("--api-key", default=None)
|
|
170
194
|
fail_p.add_argument("--base-url", default="https://api.cortexops.ai")
|
|
171
195
|
|
|
196
|
+
|
|
197
|
+
# ── login / logout / whoami ───────────────────────────────────────────
|
|
198
|
+
login_p = sub.add_parser("login", help="Save API key to ~/.cortexops/credentials")
|
|
199
|
+
login_p.add_argument("--api-key", default=None, help="cxo-... key (prompted if omitted)")
|
|
200
|
+
login_p.add_argument("--project", "-p", default=None, help="Default project name")
|
|
201
|
+
login_p.add_argument("--base-url", default="https://api.getcortexops.com")
|
|
202
|
+
|
|
203
|
+
sub.add_parser("logout", help="Remove stored credentials")
|
|
204
|
+
whoami_p = sub.add_parser("whoami", help="Show current credentials and verify key")
|
|
205
|
+
whoami_p.add_argument("--base-url", default=None)
|
|
206
|
+
|
|
172
207
|
# ── version ───────────────────────────────────────────────────────────
|
|
173
208
|
sub.add_parser("version", help="Print version and exit")
|
|
174
209
|
|
|
175
210
|
args = parser.parse_args()
|
|
176
211
|
|
|
177
212
|
handlers = {
|
|
213
|
+
("login", None): cmd_login,
|
|
214
|
+
("logout", None): cmd_logout,
|
|
215
|
+
("whoami", None): cmd_whoami,
|
|
178
216
|
("eval", "run"): cmd_eval_run,
|
|
179
217
|
("eval", "diff"): cmd_eval_diff,
|
|
180
218
|
("failures", None): cmd_failures,
|
|
@@ -192,4 +230,4 @@ def main() -> None:
|
|
|
192
230
|
|
|
193
231
|
|
|
194
232
|
if __name__ == "__main__":
|
|
195
|
-
main()
|
|
233
|
+
main()
|
|
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
|
-
from typing import Any
|
|
6
5
|
|
|
7
6
|
from .models import CaseResult, EvalCase, FailureKind, Trace
|
|
8
7
|
|
|
@@ -139,7 +138,7 @@ class HallucinationMetric(Metric):
|
|
|
139
138
|
return 100.0, None, None
|
|
140
139
|
|
|
141
140
|
|
|
142
|
-
def compute_case_result(case: EvalCase, trace: Trace, extra_metrics:
|
|
141
|
+
def compute_case_result(case: EvalCase, trace: Trace, extra_metrics: list[Metric] | None = None) -> CaseResult:
|
|
143
142
|
metrics: list[Metric] = [
|
|
144
143
|
TaskCompletionMetric(),
|
|
145
144
|
ToolAccuracyMetric(),
|
|
@@ -1,21 +1,74 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import time
|
|
4
5
|
import uuid
|
|
6
|
+
from collections.abc import Callable
|
|
5
7
|
from contextlib import contextmanager
|
|
6
|
-
from
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .models import FailureKind, RunStatus, ToolCall, ToolCallStatus, Trace, TraceNode
|
|
12
|
+
|
|
13
|
+
# ── Key resolution order ───────────────────────────────────────────────────
|
|
14
|
+
# 1. Explicit api_key argument
|
|
15
|
+
# 2. CORTEXOPS_API_KEY environment variable
|
|
16
|
+
# 3. ~/.cortexops/credentials file (written by `cortexops login`)
|
|
17
|
+
# 4. None → local-only mode, no hosted tracing
|
|
18
|
+
|
|
19
|
+
_CREDENTIALS_FILE = Path.home() / ".cortexops" / "credentials"
|
|
20
|
+
_DEFAULT_API_URL = "https://api.getcortexops.com"
|
|
21
|
+
_ENV_KEY = "CORTEXOPS_API_KEY"
|
|
22
|
+
_ENV_URL = "CORTEXOPS_API_URL"
|
|
23
|
+
_ENV_PROJECT = "CORTEXOPS_PROJECT"
|
|
24
|
+
_ENV_ENV = "CORTEXOPS_ENVIRONMENT"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_api_key(explicit: str | None) -> str | None:
|
|
28
|
+
"""Resolve API key from multiple sources in priority order."""
|
|
29
|
+
if explicit:
|
|
30
|
+
return explicit
|
|
31
|
+
# Environment variable
|
|
32
|
+
if env_key := os.getenv(_ENV_KEY):
|
|
33
|
+
return env_key
|
|
34
|
+
# Credentials file written by `cortexops login`
|
|
35
|
+
if _CREDENTIALS_FILE.exists():
|
|
36
|
+
try:
|
|
37
|
+
import json
|
|
38
|
+
creds = json.loads(_CREDENTIALS_FILE.read_text())
|
|
39
|
+
return creds.get("api_key")
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
return None
|
|
7
43
|
|
|
8
|
-
|
|
44
|
+
|
|
45
|
+
def _resolve_api_url(explicit: str) -> str:
|
|
46
|
+
"""Resolve API URL — explicit arg > env var > default."""
|
|
47
|
+
if explicit != _DEFAULT_API_URL:
|
|
48
|
+
return explicit.rstrip("/")
|
|
49
|
+
return os.getenv(_ENV_URL, _DEFAULT_API_URL).rstrip("/")
|
|
9
50
|
|
|
10
51
|
|
|
11
52
|
class CortexTracer:
|
|
12
53
|
"""Instruments AI agents with zero-refactor tracing.
|
|
13
54
|
|
|
14
|
-
|
|
15
|
-
|
|
55
|
+
API key resolution order (most to least specific):
|
|
56
|
+
1. api_key argument
|
|
57
|
+
2. CORTEXOPS_API_KEY environment variable
|
|
58
|
+
3. ~/.cortexops/credentials (written by `cortexops login`)
|
|
59
|
+
4. None — local-only mode, traces stored in memory only
|
|
16
60
|
|
|
17
61
|
Usage:
|
|
62
|
+
# Explicit key
|
|
63
|
+
tracer = CortexTracer(project="payments-agent", api_key="cxo-...")
|
|
64
|
+
|
|
65
|
+
# From environment variable (recommended for CI)
|
|
66
|
+
# export CORTEXOPS_API_KEY=cxo-...
|
|
18
67
|
tracer = CortexTracer(project="payments-agent")
|
|
68
|
+
|
|
69
|
+
# After `cortexops login` (recommended for local dev)
|
|
70
|
+
tracer = CortexTracer(project="payments-agent")
|
|
71
|
+
|
|
19
72
|
graph = tracer.wrap(your_langgraph_app)
|
|
20
73
|
result = graph.invoke({"messages": [...]})
|
|
21
74
|
trace = tracer.last_trace()
|
|
@@ -23,30 +76,52 @@ class CortexTracer:
|
|
|
23
76
|
|
|
24
77
|
def __init__(
|
|
25
78
|
self,
|
|
26
|
-
project: str,
|
|
79
|
+
project: str | None = None,
|
|
27
80
|
api_key: str | None = None,
|
|
28
|
-
|
|
81
|
+
api_url: str = _DEFAULT_API_URL,
|
|
82
|
+
environment: str | None = None,
|
|
29
83
|
sample_rate: float = 1.0,
|
|
30
84
|
local_store: bool = True,
|
|
31
85
|
) -> None:
|
|
32
|
-
|
|
33
|
-
self.
|
|
34
|
-
|
|
86
|
+
# Project: arg > env var
|
|
87
|
+
self.project = project or os.getenv(_ENV_PROJECT) or "default"
|
|
88
|
+
# Key: auto-resolved from all sources
|
|
89
|
+
self.api_key = _resolve_api_key(api_key)
|
|
90
|
+
self.api_url = _resolve_api_url(api_url)
|
|
91
|
+
# Environment: arg > env var > "development"
|
|
92
|
+
self.environment = environment or os.getenv(_ENV_ENV, "development")
|
|
35
93
|
self.sample_rate = sample_rate
|
|
36
94
|
self.local_store = local_store
|
|
37
95
|
self._traces: list[Trace] = []
|
|
38
96
|
self._current_trace: Trace | None = None
|
|
39
97
|
|
|
98
|
+
# Inform user where key came from — only in development
|
|
99
|
+
if self.environment == "development" and self.api_key:
|
|
100
|
+
source = "argument"
|
|
101
|
+
if not api_key:
|
|
102
|
+
if os.getenv(_ENV_KEY):
|
|
103
|
+
source = f"env:{_ENV_KEY}"
|
|
104
|
+
elif _CREDENTIALS_FILE.exists():
|
|
105
|
+
source = "~/.cortexops/credentials"
|
|
106
|
+
if source != "argument":
|
|
107
|
+
import logging
|
|
108
|
+
logging.getLogger(__name__).debug(
|
|
109
|
+
"CortexTracer: api_key loaded from %s", source
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def is_hosted(self) -> bool:
|
|
114
|
+
"""True if traces will be shipped to the hosted API."""
|
|
115
|
+
return bool(self.api_key)
|
|
116
|
+
|
|
40
117
|
def wrap(self, agent: Any) -> Any:
|
|
41
118
|
"""Auto-detect agent type and return an instrumented wrapper."""
|
|
42
119
|
agent_type = type(agent).__name__
|
|
43
120
|
|
|
44
121
|
if agent_type == "CompiledStateGraph":
|
|
45
122
|
return self._wrap_langgraph(agent)
|
|
46
|
-
|
|
47
123
|
if agent_type == "Crew":
|
|
48
124
|
return self._wrap_crewai(agent)
|
|
49
|
-
|
|
50
125
|
if callable(agent) or hasattr(agent, "invoke"):
|
|
51
126
|
return self._wrap_callable(agent)
|
|
52
127
|
|
|
@@ -62,8 +137,7 @@ class CortexTracer:
|
|
|
62
137
|
def invoke(self_, input: dict, config: dict | None = None, **kwargs) -> dict:
|
|
63
138
|
return tracer._run_traced(
|
|
64
139
|
fn=lambda: graph.invoke(input, config, **kwargs),
|
|
65
|
-
input=input,
|
|
66
|
-
framework="langgraph",
|
|
140
|
+
input=input, framework="langgraph",
|
|
67
141
|
)
|
|
68
142
|
|
|
69
143
|
async def ainvoke(self_, input: dict, config: dict | None = None, **kwargs) -> dict:
|
|
@@ -71,8 +145,7 @@ class CortexTracer:
|
|
|
71
145
|
return await asyncio.get_event_loop().run_in_executor(
|
|
72
146
|
None, lambda: tracer._run_traced(
|
|
73
147
|
fn=lambda: graph.invoke(input, config, **kwargs),
|
|
74
|
-
input=input,
|
|
75
|
-
framework="langgraph",
|
|
148
|
+
input=input, framework="langgraph",
|
|
76
149
|
)
|
|
77
150
|
)
|
|
78
151
|
|
|
@@ -91,8 +164,7 @@ class CortexTracer:
|
|
|
91
164
|
def kickoff(self_, inputs: dict | None = None) -> Any:
|
|
92
165
|
return tracer._run_traced(
|
|
93
166
|
fn=lambda: crew.kickoff(inputs=inputs),
|
|
94
|
-
input=inputs or {},
|
|
95
|
-
framework="crewai",
|
|
167
|
+
input=inputs or {}, framework="crewai",
|
|
96
168
|
)
|
|
97
169
|
|
|
98
170
|
def __getattr__(self_, name: str):
|
|
@@ -104,7 +176,6 @@ class CortexTracer:
|
|
|
104
176
|
tracer = self
|
|
105
177
|
|
|
106
178
|
if hasattr(fn, "invoke"):
|
|
107
|
-
# Object with .invoke() — wrap that method
|
|
108
179
|
original_invoke = fn.invoke
|
|
109
180
|
|
|
110
181
|
class InvokeWrapper:
|
|
@@ -121,7 +192,6 @@ class CortexTracer:
|
|
|
121
192
|
|
|
122
193
|
return InvokeWrapper()
|
|
123
194
|
|
|
124
|
-
# Plain callable
|
|
125
195
|
def wrapper(*args, **kwargs):
|
|
126
196
|
input_data = {"args": list(args), "kwargs": kwargs}
|
|
127
197
|
return tracer._run_traced(fn=lambda: fn(*args, **kwargs), input=input_data, framework="generic")
|
|
@@ -129,10 +199,11 @@ class CortexTracer:
|
|
|
129
199
|
return wrapper
|
|
130
200
|
|
|
131
201
|
def _run_traced(self, fn: Callable, input: dict, framework: str) -> Any:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
202
|
+
import random
|
|
203
|
+
if self.sample_rate < 1.0 and random.random() > self.sample_rate:
|
|
204
|
+
return fn()
|
|
205
|
+
|
|
206
|
+
trace = Trace(project=self.project, input=input)
|
|
136
207
|
self._current_trace = trace
|
|
137
208
|
t0 = time.perf_counter()
|
|
138
209
|
|
|
@@ -176,12 +247,9 @@ class CortexTracer:
|
|
|
176
247
|
) -> ToolCall:
|
|
177
248
|
"""Manually record a tool call onto the current active trace."""
|
|
178
249
|
tc = ToolCall(
|
|
179
|
-
name=name,
|
|
180
|
-
args=args or {},
|
|
181
|
-
result=result,
|
|
250
|
+
name=name, args=args or {}, result=result,
|
|
182
251
|
status=ToolCallStatus.ERROR if error else ToolCallStatus.SUCCESS,
|
|
183
|
-
latency_ms=latency_ms,
|
|
184
|
-
error=error,
|
|
252
|
+
latency_ms=latency_ms, error=error,
|
|
185
253
|
)
|
|
186
254
|
if self._current_trace and self._current_trace.nodes:
|
|
187
255
|
self._current_trace.nodes[-1].tool_calls.append(tc)
|
|
@@ -201,10 +269,10 @@ class CortexTracer:
|
|
|
201
269
|
try:
|
|
202
270
|
import httpx
|
|
203
271
|
httpx.post(
|
|
204
|
-
"
|
|
272
|
+
f"{self.api_url}/v1/traces",
|
|
205
273
|
json=trace.model_dump(mode="json"),
|
|
206
|
-
headers={"
|
|
274
|
+
headers={"X-API-Key": self.api_key},
|
|
207
275
|
timeout=2.0,
|
|
208
276
|
)
|
|
209
277
|
except Exception:
|
|
210
|
-
pass # non-blocking — tracing never breaks the agent
|
|
278
|
+
pass # non-blocking — tracing never breaks the agent
|
|
@@ -4,19 +4,19 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cortexops"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Reliability infrastructure for AI agents — evaluation, observability, and regression testing"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
11
11
|
authors = [
|
|
12
|
-
{ name = "Ashish", email = "
|
|
12
|
+
{ name = "Ashish", email = "ashish@getcortexops.com" },
|
|
13
13
|
]
|
|
14
14
|
keywords = [
|
|
15
15
|
"llm", "agents", "evaluation", "observability",
|
|
16
16
|
"langgraph", "crewai", "autogen", "ai", "testing",
|
|
17
17
|
]
|
|
18
18
|
classifiers = [
|
|
19
|
-
"Development Status ::
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
20
|
"Intended Audience :: Developers",
|
|
21
21
|
"License :: OSI Approved :: MIT License",
|
|
22
22
|
"Operating System :: OS Independent",
|
|
@@ -33,7 +33,6 @@ requires-python = ">=3.10"
|
|
|
33
33
|
dependencies = [
|
|
34
34
|
"pydantic>=2.0",
|
|
35
35
|
"pyyaml>=6.0",
|
|
36
|
-
"setuptools>=82.0.1",
|
|
37
36
|
]
|
|
38
37
|
|
|
39
38
|
[project.optional-dependencies]
|
|
@@ -49,9 +48,9 @@ dev = [
|
|
|
49
48
|
]
|
|
50
49
|
|
|
51
50
|
[project.urls]
|
|
52
|
-
Homepage = "https://
|
|
51
|
+
Homepage = "https://getcortexops.com"
|
|
53
52
|
Repository = "https://github.com/ashishodu2023/cortexops"
|
|
54
|
-
Documentation = "https://docs.
|
|
53
|
+
Documentation = "https://docs.getcortexops.com"
|
|
55
54
|
"Bug Tracker" = "https://github.com/ashishodu2023/cortexops/issues"
|
|
56
55
|
Changelog = "https://github.com/ashishodu2023/cortexops/releases"
|
|
57
56
|
|
|
@@ -71,7 +70,7 @@ include = [
|
|
|
71
70
|
]
|
|
72
71
|
|
|
73
72
|
[tool.ruff]
|
|
74
|
-
line-length =
|
|
73
|
+
line-length = 121
|
|
75
74
|
target-version = "py310"
|
|
76
75
|
|
|
77
76
|
[tool.ruff.lint]
|
|
@@ -85,4 +84,4 @@ ignore_missing_imports = true
|
|
|
85
84
|
|
|
86
85
|
[tool.pytest.ini_options]
|
|
87
86
|
asyncio_mode = "auto"
|
|
88
|
-
testpaths = ["tests"]
|
|
87
|
+
testpaths = ["tests"]
|