cortexops 0.4.0__tar.gz → 0.5.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.4.0 → cortexops-0.5.0}/.gitignore +1 -0
- {cortexops-0.4.0 → cortexops-0.5.0}/PKG-INFO +3 -2
- {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/__init__.py +1 -1
- cortexops-0.5.0/cortexops/client.py +150 -0
- {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/pyproject.toml +1 -1
- {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/tracer.py +33 -8
- {cortexops-0.4.0 → cortexops-0.5.0}/pyproject.toml +3 -2
- cortexops-0.5.0/tests/__init__.py +1 -0
- cortexops-0.5.0/tests/conftest.py +38 -0
- cortexops-0.5.0/tests/helpers.py +51 -0
- cortexops-0.5.0/tests/test_agno.py +7 -0
- cortexops-0.5.0/tests/test_api_client.py +25 -0
- cortexops-0.5.0/tests/test_async.py +18 -0
- cortexops-0.5.0/tests/test_auth.py +17 -0
- cortexops-0.5.0/tests/test_autogen.py +12 -0
- cortexops-0.5.0/tests/test_cli.py +15 -0
- cortexops-0.5.0/tests/test_concurrency.py +17 -0
- cortexops-0.5.0/tests/test_config.py +26 -0
- cortexops-0.5.0/tests/test_cost_tracking.py +10 -0
- cortexops-0.5.0/tests/test_crewai.py +12 -0
- cortexops-0.5.0/tests/test_datadog.py +8 -0
- cortexops-0.5.0/tests/test_edge_cases.py +21 -0
- cortexops-0.5.0/tests/test_end_to_end.py +46 -0
- cortexops-0.5.0/tests/test_eval_suite.py +20 -0
- cortexops-0.5.0/tests/test_evaluation.py +38 -0
- cortexops-0.5.0/tests/test_exceptions.py +8 -0
- cortexops-0.5.0/tests/test_exporters.py +11 -0
- cortexops-0.5.0/tests/test_framework_detection.py +41 -0
- cortexops-0.5.0/tests/test_golden_dataset.py +15 -0
- cortexops-0.5.0/tests/test_google_adk.py +12 -0
- cortexops-0.5.0/tests/test_grafana.py +8 -0
- cortexops-0.5.0/tests/test_haystack.py +12 -0
- cortexops-0.5.0/tests/test_honeycomb.py +13 -0
- cortexops-0.5.0/tests/test_jaeger.py +9 -0
- cortexops-0.5.0/tests/test_langgraph.py +13 -0
- cortexops-0.5.0/tests/test_llamaindex.py +12 -0
- cortexops-0.5.0/tests/test_llm_calls.py +19 -0
- cortexops-0.5.0/tests/test_llm_judge.py +20 -0
- cortexops-0.5.0/tests/test_logging.py +12 -0
- cortexops-0.5.0/tests/test_metrics.py +35 -0
- cortexops-0.5.0/tests/test_nodes.py +25 -0
- cortexops-0.5.0/tests/test_openai_agents.py +7 -0
- cortexops-0.5.0/tests/test_otlp.py +7 -0
- cortexops-0.5.0/tests/test_pydantic_ai.py +14 -0
- cortexops-0.5.0/tests/test_redaction.py +9 -0
- cortexops-0.5.0/tests/test_regressions.py +19 -0
- cortexops-0.5.0/tests/test_retry.py +21 -0
- cortexops-0.5.0/tests/test_sampling.py +9 -0
- cortexops-0.5.0/tests/test_serialization.py +12 -0
- cortexops-0.5.0/tests/test_smolagents.py +12 -0
- cortexops-0.5.0/tests/test_spans.py +19 -0
- cortexops-0.5.0/tests/test_tools.py +23 -0
- cortexops-0.5.0/tests/test_tracing.py +54 -0
- cortexops-0.4.0/cortexops/client.py +0 -83
- cortexops-0.4.0/tests/__init__.py +0 -0
- cortexops-0.4.0/tests/conftest.py +0 -8
- cortexops-0.4.0/tests/test_cortexops.py +0 -211
- cortexops-0.4.0/tests/test_enhancements.py +0 -398
- {cortexops-0.4.0 → cortexops-0.5.0}/LICENSE +0 -0
- {cortexops-0.4.0 → cortexops-0.5.0}/README.md +0 -0
- {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/LICENSE +0 -0
- {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/README.md +0 -0
- {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/auth.py +0 -0
- {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/cli.py +0 -0
- {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/dataset.py +0 -0
- {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/eval.py +0 -0
- {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/judge.py +0 -0
- {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/metrics.py +0 -0
- {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/models.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cortexops
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: AI agent observability & eval platform — trace, monitor, and eval-gate LangGraph, CrewAI, OpenAI Agents, PydanticAI, Agno, AutoGen, Haystack, DSPy, LlamaIndex, Smolagents, and Google ADK
|
|
5
5
|
Project-URL: Homepage, https://getcortexops.com
|
|
6
6
|
Project-URL: Repository, https://github.com/ashishodu2023/cortexops
|
|
7
7
|
Project-URL: Documentation, https://docs.getcortexops.com
|
|
@@ -44,6 +44,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
|
|
|
44
44
|
Classifier: Topic :: Software Development :: Testing
|
|
45
45
|
Classifier: Typing :: Typed
|
|
46
46
|
Requires-Python: >=3.10
|
|
47
|
+
Requires-Dist: httpx>=0.27
|
|
47
48
|
Requires-Dist: pydantic>=2.0
|
|
48
49
|
Requires-Dist: pyyaml>=6.0
|
|
49
50
|
Provides-Extra: all
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import urljoin
|
|
4
|
+
|
|
5
|
+
from .models import Trace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CortexClient:
|
|
9
|
+
"""HTTP client for the CortexOps backend API.
|
|
10
|
+
|
|
11
|
+
Used by the SDK to push traces and pull eval history.
|
|
12
|
+
Not required for local-only usage.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
client = CortexClient(api_key="cxo-...", base_url="https://api.cortexops.ai")
|
|
16
|
+
client.push_trace(tracer.last_trace())
|
|
17
|
+
history = client.list_runs(project="payments-agent", limit=10)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://api.getcortexops.com"
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
api_key: str,
|
|
25
|
+
base_url: str | None = None,
|
|
26
|
+
timeout: float = 10.0,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.api_key = api_key
|
|
29
|
+
self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
|
|
30
|
+
self.timeout = timeout
|
|
31
|
+
|
|
32
|
+
def _headers(self) -> dict[str, str]:
|
|
33
|
+
return {
|
|
34
|
+
"X-API-Key": self.api_key,
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def _get(self, path: str, params: dict | None = None) -> dict:
|
|
39
|
+
import httpx
|
|
40
|
+
r = httpx.get(
|
|
41
|
+
urljoin(self.base_url + "/", path.lstrip("/")),
|
|
42
|
+
headers=self._headers(),
|
|
43
|
+
params=params,
|
|
44
|
+
timeout=self.timeout,
|
|
45
|
+
)
|
|
46
|
+
r.raise_for_status()
|
|
47
|
+
return r.json()
|
|
48
|
+
|
|
49
|
+
def _post(self, path: str, data: dict) -> dict:
|
|
50
|
+
import httpx
|
|
51
|
+
r = httpx.post(
|
|
52
|
+
urljoin(self.base_url + "/", path.lstrip("/")),
|
|
53
|
+
headers=self._headers(),
|
|
54
|
+
json=data,
|
|
55
|
+
timeout=self.timeout,
|
|
56
|
+
)
|
|
57
|
+
r.raise_for_status()
|
|
58
|
+
return r.json()
|
|
59
|
+
|
|
60
|
+
def push_trace(self, trace: Trace) -> dict:
|
|
61
|
+
return self._post("/v1/traces", trace.model_dump(mode="json"))
|
|
62
|
+
|
|
63
|
+
def get_trace(self, trace_id: str) -> dict:
|
|
64
|
+
return self._get(f"/v1/traces/{trace_id}")
|
|
65
|
+
|
|
66
|
+
def list_traces(self, project: str, limit: int = 50) -> list[dict]:
|
|
67
|
+
return self._get("/v1/traces", {"project": project, "limit": limit})
|
|
68
|
+
|
|
69
|
+
def push_eval(self, summary, *, dataset: dict | None = None, prompt: dict | None = None) -> dict:
|
|
70
|
+
"""Push a completed EvalSuite summary to POST /v1/evals/ingest."""
|
|
71
|
+
if hasattr(summary, "model_dump"):
|
|
72
|
+
data = summary.model_dump(mode="json")
|
|
73
|
+
else:
|
|
74
|
+
data = dict(summary)
|
|
75
|
+
for cr in data.get("case_results", []):
|
|
76
|
+
cr.pop("trace", None)
|
|
77
|
+
if cr.get("failure_kind") and hasattr(cr["failure_kind"], "value"):
|
|
78
|
+
cr["failure_kind"] = cr["failure_kind"].value
|
|
79
|
+
if dataset:
|
|
80
|
+
data["dataset"] = dataset
|
|
81
|
+
if prompt:
|
|
82
|
+
data["prompt"] = prompt
|
|
83
|
+
return self._post("/v1/evals/ingest", data)
|
|
84
|
+
|
|
85
|
+
def project_for_key(self) -> str:
|
|
86
|
+
"""Return the project bound to the current API key."""
|
|
87
|
+
return self._get("/v1/traces/quota")["project"]
|
|
88
|
+
|
|
89
|
+
def list_runs(self, project: str, limit: int = 10) -> list[dict]:
|
|
90
|
+
return self._get("/v1/evals", {"project": project, "limit": limit})
|
|
91
|
+
|
|
92
|
+
def run_eval(self, dataset: str, project: str) -> dict:
|
|
93
|
+
"""Trigger a server-side eval run (async via Celery)."""
|
|
94
|
+
return self._post("/v1/evals/run", {"dataset": dataset, "project": project})
|
|
95
|
+
|
|
96
|
+
def get_eval(self, run_id: str) -> dict:
|
|
97
|
+
return self._get(f"/v1/evals/{run_id}")
|
|
98
|
+
|
|
99
|
+
def diff(self, run_id_a: str, run_id_b: str) -> dict:
|
|
100
|
+
return self._get("/v1/evals/diff", {"a": run_id_a, "b": run_id_b})
|
|
101
|
+
|
|
102
|
+
def list_datasets(self) -> list[dict]:
|
|
103
|
+
return self._get("/v1/eval/datasets")
|
|
104
|
+
|
|
105
|
+
def get_dataset(self, dataset_id: str) -> dict:
|
|
106
|
+
return self._get(f"/v1/eval/datasets/{dataset_id}")
|
|
107
|
+
|
|
108
|
+
def create_dataset(self, name: str, cases: list[dict], description: str = "") -> dict:
|
|
109
|
+
return self._post("/v1/eval/datasets", {
|
|
110
|
+
"name": name,
|
|
111
|
+
"description": description,
|
|
112
|
+
"cases": cases,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
def add_case_from_trace(
|
|
116
|
+
self,
|
|
117
|
+
dataset_id: str,
|
|
118
|
+
trace_id: str,
|
|
119
|
+
*,
|
|
120
|
+
case_id: str | None = None,
|
|
121
|
+
) -> dict:
|
|
122
|
+
"""Promote a production trace into a golden dataset case."""
|
|
123
|
+
body: dict = {"trace_id": trace_id}
|
|
124
|
+
if case_id:
|
|
125
|
+
body["case_id"] = case_id
|
|
126
|
+
return self._post(f"/v1/eval/datasets/{dataset_id}/cases/from-trace", body)
|
|
127
|
+
|
|
128
|
+
def commit_prompt(
|
|
129
|
+
self,
|
|
130
|
+
project: str,
|
|
131
|
+
prompt_name: str,
|
|
132
|
+
content: str,
|
|
133
|
+
*,
|
|
134
|
+
model: str = "",
|
|
135
|
+
temperature: float = 0.7,
|
|
136
|
+
commit_message: str = "",
|
|
137
|
+
author: str = "",
|
|
138
|
+
) -> dict:
|
|
139
|
+
return self._post("/v1/prompts", {
|
|
140
|
+
"project": project,
|
|
141
|
+
"prompt_name": prompt_name,
|
|
142
|
+
"content": content,
|
|
143
|
+
"model": model,
|
|
144
|
+
"temperature": temperature,
|
|
145
|
+
"commit_message": commit_message,
|
|
146
|
+
"author": author,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
def list_prompt_catalog(self, project: str) -> list[dict]:
|
|
150
|
+
return self._get("/v1/prompts/catalog", {"project": project})
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cortexops"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "AI agent observability & eval platform — trace, monitor, and eval-gate LangGraph, CrewAI, OpenAI Agents, PydanticAI, Agno, AutoGen, Haystack, DSPy, LlamaIndex, Smolagents, and Google ADK"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import random as _random
|
|
4
5
|
import time
|
|
5
6
|
import uuid
|
|
6
7
|
from collections.abc import Callable
|
|
@@ -90,7 +91,11 @@ class CortexTracer:
|
|
|
90
91
|
self.api_url = _resolve_api_url(api_url)
|
|
91
92
|
# Environment: arg > env var > "development"
|
|
92
93
|
self.environment = environment or os.getenv(_ENV_ENV, "development")
|
|
94
|
+
# Validate and store sample rate (0.0 = trace nothing, 1.0 = trace everything)
|
|
95
|
+
if not 0.0 <= sample_rate <= 1.0:
|
|
96
|
+
raise ValueError(f"sample_rate must be between 0.0 and 1.0, got {sample_rate}")
|
|
93
97
|
self.sample_rate = sample_rate
|
|
98
|
+
self.sample_errors = True # always trace failures regardless of sample_rate
|
|
94
99
|
self.local_store = local_store
|
|
95
100
|
self._traces: list[Trace] = []
|
|
96
101
|
self._current_trace: Trace | None = None
|
|
@@ -129,21 +134,21 @@ class CortexTracer:
|
|
|
129
134
|
# CrewAI
|
|
130
135
|
if name == "Crew" and "crewai" in module:
|
|
131
136
|
return "crewai"
|
|
132
|
-
# OpenAI Agents
|
|
133
|
-
if
|
|
134
|
-
return "
|
|
137
|
+
# Google ADK (before OpenAI Agents — module path contains "agents")
|
|
138
|
+
if "google" in module and "adk" in module:
|
|
139
|
+
return "google_adk"
|
|
135
140
|
# PydanticAI — Agent class
|
|
136
141
|
if name == "Agent" and "pydantic_ai" in module:
|
|
137
142
|
return "pydantic_ai"
|
|
143
|
+
# OpenAI Agents SDK — top-level `agents` package only
|
|
144
|
+
if name == "Agent" and (module == "agents" or module.startswith("agents.")):
|
|
145
|
+
return "openai_agents"
|
|
138
146
|
# Agno / Phidata
|
|
139
147
|
if "agno" in module or "phi" in module:
|
|
140
148
|
return "agno"
|
|
141
149
|
# AutoGen — ConversableAgent, AssistantAgent, UserProxyAgent
|
|
142
150
|
if "autogen" in module and hasattr(agent, "initiate_chat"):
|
|
143
151
|
return "autogen"
|
|
144
|
-
# Google ADK
|
|
145
|
-
if "google" in module and "adk" in module:
|
|
146
|
-
return "google_adk"
|
|
147
152
|
# Smolagents (HuggingFace)
|
|
148
153
|
if "smolagents" in module and hasattr(agent, "run"):
|
|
149
154
|
return "smolagents"
|
|
@@ -617,10 +622,30 @@ class CortexTracer:
|
|
|
617
622
|
|
|
618
623
|
|
|
619
624
|
def _run_traced(self, fn: Callable, input: dict, framework: str) -> Any:
|
|
620
|
-
|
|
621
|
-
|
|
625
|
+
# Head-based sampling: decide whether to trace this run.
|
|
626
|
+
# Errors are always traced (if sample_errors=True) so you never
|
|
627
|
+
# miss a failure even at low sample rates.
|
|
628
|
+
sampled = self.sample_rate >= 1.0 or _random.random() < self.sample_rate
|
|
629
|
+
|
|
630
|
+
if not sampled and not self.sample_errors:
|
|
631
|
+
# Not sampled and not capturing errors — run untraced
|
|
622
632
|
return fn()
|
|
623
633
|
|
|
634
|
+
if not sampled and self.sample_errors:
|
|
635
|
+
# Not sampled, but we still want to catch errors.
|
|
636
|
+
# Run with lightweight error-only capture.
|
|
637
|
+
try:
|
|
638
|
+
return fn()
|
|
639
|
+
except Exception as exc:
|
|
640
|
+
# An error occurred — capture it even though not sampled
|
|
641
|
+
trace = Trace(project=self.project, input=input)
|
|
642
|
+
trace.status = RunStatus.FAILED
|
|
643
|
+
trace.failure_kind = FailureKind.UNKNOWN
|
|
644
|
+
trace.failure_detail = str(exc)
|
|
645
|
+
trace.metadata = {"sampled": False, "captured_reason": "error"}
|
|
646
|
+
self._traces.append(trace)
|
|
647
|
+
raise
|
|
648
|
+
|
|
624
649
|
trace = Trace(project=self.project, input=input)
|
|
625
650
|
self._current_trace = trace
|
|
626
651
|
t0 = time.perf_counter()
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cortexops"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "
|
|
7
|
+
version = "0.5.0"
|
|
8
|
+
description = "AI agent observability & eval platform — trace, monitor, and eval-gate LangGraph, CrewAI, OpenAI Agents, PydanticAI, Agno, AutoGen, Haystack, DSPy, LlamaIndex, Smolagents, and Google ADK"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
11
11
|
authors = [
|
|
@@ -33,6 +33,7 @@ requires-python = ">=3.10"
|
|
|
33
33
|
dependencies = [
|
|
34
34
|
"pydantic>=2.0",
|
|
35
35
|
"pyyaml>=6.0",
|
|
36
|
+
"httpx>=0.27",
|
|
36
37
|
]
|
|
37
38
|
|
|
38
39
|
[project.optional-dependencies]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Test package for CortexOps SDK integration tests.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Shared fixtures for CortexOps SDK integration tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from tests.helpers import make_framework_mock, make_trace
|
|
8
|
+
|
|
9
|
+
__all__ = ["make_framework_mock", "make_trace"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def project() -> str:
|
|
14
|
+
return "payments-agent"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def tracer(project):
|
|
19
|
+
from cortexops import CortexTracer
|
|
20
|
+
|
|
21
|
+
return CortexTracer(project=project, sample_rate=1.0, local_store=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def echo_agent():
|
|
26
|
+
def _agent(inp: dict) -> dict:
|
|
27
|
+
text = inp.get("input", inp.get("prompt", ""))
|
|
28
|
+
return {"output": f"Processed: {text}"}
|
|
29
|
+
|
|
30
|
+
return _agent
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def failing_agent():
|
|
35
|
+
def _agent(inp: dict) -> dict:
|
|
36
|
+
raise RuntimeError("agent exploded")
|
|
37
|
+
|
|
38
|
+
return _agent
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Shared helpers for CortexOps SDK integration tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cortexops.models import (
|
|
6
|
+
RunStatus,
|
|
7
|
+
ToolCall,
|
|
8
|
+
ToolCallStatus,
|
|
9
|
+
Trace,
|
|
10
|
+
TraceNode,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_trace(
|
|
15
|
+
output: dict | None = None,
|
|
16
|
+
tool_calls: list[str] | None = None,
|
|
17
|
+
latency_ms: float = 100.0,
|
|
18
|
+
*,
|
|
19
|
+
project: str = "test",
|
|
20
|
+
llm_prompt: str | None = None,
|
|
21
|
+
llm_response: str | None = None,
|
|
22
|
+
parent_nodes: list[TraceNode] | None = None,
|
|
23
|
+
) -> Trace:
|
|
24
|
+
tcs = [ToolCall(name=n, status=ToolCallStatus.SUCCESS) for n in (tool_calls or [])]
|
|
25
|
+
nodes = parent_nodes or [
|
|
26
|
+
TraceNode(
|
|
27
|
+
node_id="n1",
|
|
28
|
+
node_name="agent",
|
|
29
|
+
output=output or {},
|
|
30
|
+
tool_calls=tcs,
|
|
31
|
+
latency_ms=latency_ms,
|
|
32
|
+
llm_prompt=llm_prompt,
|
|
33
|
+
llm_response=llm_response,
|
|
34
|
+
)
|
|
35
|
+
]
|
|
36
|
+
return Trace(
|
|
37
|
+
project=project,
|
|
38
|
+
total_latency_ms=latency_ms,
|
|
39
|
+
output=output or {},
|
|
40
|
+
nodes=nodes,
|
|
41
|
+
status=RunStatus.COMPLETED,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def make_framework_mock(class_name: str, module: str, **attrs):
|
|
46
|
+
"""Build a lightweight stand-in with the given class name/module for detection."""
|
|
47
|
+
cls = type(class_name, (), {"__module__": module})
|
|
48
|
+
obj = cls()
|
|
49
|
+
for key, value in attrs.items():
|
|
50
|
+
setattr(obj, key, value)
|
|
51
|
+
return obj
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""CortexClient HTTPS upload path (mocked)."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
from cortexops.client import CortexClient
|
|
6
|
+
from tests.helpers import make_trace
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_upload_trace():
|
|
10
|
+
client = CortexClient(api_key="cxo-test", base_url="https://api.getcortexops.com")
|
|
11
|
+
trace = make_trace({"output": "ok"}, project="payments-agent")
|
|
12
|
+
with patch.object(client, "_post", return_value={"id": trace.trace_id}) as post:
|
|
13
|
+
resp = client.push_trace(trace)
|
|
14
|
+
assert resp["id"] == trace.trace_id
|
|
15
|
+
post.assert_called_once()
|
|
16
|
+
args, kwargs = post.call_args
|
|
17
|
+
assert args[0] == "/v1/traces"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_list_traces():
|
|
21
|
+
client = CortexClient(api_key="cxo-test", base_url="https://api.getcortexops.com")
|
|
22
|
+
with patch.object(client, "_get", return_value=[{"trace_id": "abc"}]) as get:
|
|
23
|
+
rows = client.list_traces("payments-agent", limit=10)
|
|
24
|
+
assert rows[0]["trace_id"] == "abc"
|
|
25
|
+
get.assert_called_once()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Async-friendly wrappers exist for supported frameworks."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from unittest.mock import MagicMock
|
|
5
|
+
|
|
6
|
+
from cortexops import CortexTracer
|
|
7
|
+
from tests.helpers import make_framework_mock
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_langgraph_ainvoke_exists():
|
|
11
|
+
tracer = CortexTracer(project="p", sample_rate=1.0)
|
|
12
|
+
mock = make_framework_mock("CompiledStateGraph", "langgraph.graph.graph")
|
|
13
|
+
mock.invoke = MagicMock(return_value={"ok": True})
|
|
14
|
+
wrapped = tracer.wrap(mock)
|
|
15
|
+
assert hasattr(wrapped, "ainvoke")
|
|
16
|
+
|
|
17
|
+
result = asyncio.run(wrapped.ainvoke({"messages": []}))
|
|
18
|
+
assert result["ok"] is True
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Credential save/load/clear."""
|
|
2
|
+
|
|
3
|
+
from cortexops.auth import save_credentials, load_credentials, clear_credentials
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_save_load_clear(tmp_path, monkeypatch):
|
|
7
|
+
cred_dir = tmp_path / ".cortexops"
|
|
8
|
+
cred_file = cred_dir / "credentials"
|
|
9
|
+
monkeypatch.setattr("cortexops.auth._CREDENTIALS_DIR", cred_dir)
|
|
10
|
+
monkeypatch.setattr("cortexops.auth._CREDENTIALS_FILE", cred_file)
|
|
11
|
+
|
|
12
|
+
save_credentials("cxo-testkey", "payments-agent")
|
|
13
|
+
creds = load_credentials()
|
|
14
|
+
assert creds["api_key"] == "cxo-testkey"
|
|
15
|
+
assert creds["project"] == "payments-agent"
|
|
16
|
+
clear_credentials()
|
|
17
|
+
assert load_credentials() is None
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
from cortexops.tracer import CortexTracer
|
|
3
|
+
from tests.helpers import make_framework_mock
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_autogen_detected():
|
|
7
|
+
mock = make_framework_mock(
|
|
8
|
+
"AssistantAgent",
|
|
9
|
+
"autogen.agentchat.assistant_agent",
|
|
10
|
+
initiate_chat=MagicMock(),
|
|
11
|
+
)
|
|
12
|
+
assert CortexTracer._detect_framework(mock) == "autogen"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""CLI entrypoints."""
|
|
2
|
+
|
|
3
|
+
from cortexops.cli import cmd_version
|
|
4
|
+
from argparse import Namespace
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_cli_eval():
|
|
8
|
+
"""Version command is wired and returns success."""
|
|
9
|
+
assert cmd_version(Namespace()) == 0
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_cli_main_importable():
|
|
13
|
+
from cortexops.cli import main
|
|
14
|
+
|
|
15
|
+
assert callable(main)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Tracer is usable across sequential calls (thread-local MVP)."""
|
|
2
|
+
|
|
3
|
+
from cortexops import CortexTracer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_sequential_traces_isolated():
|
|
7
|
+
tracer = CortexTracer(project="p", sample_rate=1.0)
|
|
8
|
+
|
|
9
|
+
def agent(inp: dict) -> dict:
|
|
10
|
+
return {"output": inp["input"]}
|
|
11
|
+
|
|
12
|
+
wrapped = tracer.wrap(agent)
|
|
13
|
+
wrapped({"input": "one"})
|
|
14
|
+
wrapped({"input": "two"})
|
|
15
|
+
traces = tracer.traces()
|
|
16
|
+
assert len(traces) == 2
|
|
17
|
+
assert traces[0].trace_id != traces[1].trace_id
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Tracer configuration."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from cortexops import CortexTracer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_sample_rate_bounds():
|
|
11
|
+
with pytest.raises(ValueError):
|
|
12
|
+
CortexTracer(project="p", sample_rate=1.5)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_project_from_env(monkeypatch):
|
|
16
|
+
monkeypatch.setenv("CORTEXOPS_PROJECT", "env-project")
|
|
17
|
+
t = CortexTracer(sample_rate=1.0)
|
|
18
|
+
assert t.project == "env-project"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_local_only_without_key(monkeypatch, tmp_path):
|
|
22
|
+
monkeypatch.delenv("CORTEXOPS_API_KEY", raising=False)
|
|
23
|
+
monkeypatch.setattr("cortexops.tracer._CREDENTIALS_FILE", tmp_path / "missing-credentials")
|
|
24
|
+
t = CortexTracer(project="local", api_key=None)
|
|
25
|
+
assert t.is_hosted is False
|
|
26
|
+
assert isinstance(tmp_path, Path)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Cost-related fields on traces (latency proxy for MVP)."""
|
|
2
|
+
|
|
3
|
+
from tests.helpers import make_trace
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_cost_proxy_latency():
|
|
7
|
+
t = make_trace({"output": "ok"}, latency_ms=250)
|
|
8
|
+
# MVP: cost analytics uses latency + tool count as proxies
|
|
9
|
+
cost_signal = {"latency_ms": t.total_latency_ms, "tool_calls": t.total_tool_calls()}
|
|
10
|
+
assert cost_signal["latency_ms"] == 250
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
from cortexops import CortexTracer, RunStatus
|
|
3
|
+
from tests.helpers import make_framework_mock
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_crewai_wrap_traces():
|
|
7
|
+
tracer = CortexTracer(project="test", sample_rate=1.0)
|
|
8
|
+
mock = make_framework_mock("Crew", "crewai.crew")
|
|
9
|
+
mock.kickoff = MagicMock(return_value="done")
|
|
10
|
+
wrapped = tracer.wrap(mock)
|
|
11
|
+
assert wrapped.kickoff(inputs={"ticket": "1"}) == "done"
|
|
12
|
+
assert tracer.last_trace().status == RunStatus.COMPLETED
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Edge cases."""
|
|
2
|
+
|
|
3
|
+
from cortexops import CortexTracer, RunStatus
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_empty_input(tracer):
|
|
7
|
+
def agent(inp: dict) -> dict:
|
|
8
|
+
return {"output": "ok"}
|
|
9
|
+
|
|
10
|
+
wrapped = tracer.wrap(agent)
|
|
11
|
+
wrapped({})
|
|
12
|
+
assert tracer.last_trace().status == RunStatus.COMPLETED
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_non_dict_output(tracer):
|
|
16
|
+
def agent(inp: dict):
|
|
17
|
+
return "plain-string"
|
|
18
|
+
|
|
19
|
+
wrapped = tracer.wrap(agent)
|
|
20
|
+
assert wrapped({"input": "x"}) == "plain-string"
|
|
21
|
+
assert tracer.last_trace() is not None
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
End-to-end MVP path:
|
|
3
|
+
|
|
4
|
+
Agent frameworks → CortexOps SDK (tracer.wrap) → local traces
|
|
5
|
+
→ metrics / eval gate → (optional) HTTPS upload via CortexClient
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
from cortexops import CortexTracer, EvalSuite, CortexClient
|
|
11
|
+
from cortexops.models import EvalCase, EvalDataset
|
|
12
|
+
from tests.helpers import make_trace
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_sdk_to_cloud_path():
|
|
16
|
+
tracer = CortexTracer(project="payments-agent", sample_rate=1.0)
|
|
17
|
+
|
|
18
|
+
def agent(inp: dict) -> dict:
|
|
19
|
+
return {"output": f"hello {inp.get('input', '')}"}
|
|
20
|
+
|
|
21
|
+
wrapped = tracer.wrap(agent)
|
|
22
|
+
wrapped({"input": "world"})
|
|
23
|
+
trace = tracer.last_trace()
|
|
24
|
+
assert trace.project == "payments-agent"
|
|
25
|
+
assert "hello" in str(trace.output)
|
|
26
|
+
|
|
27
|
+
ds = EvalDataset(
|
|
28
|
+
version=1,
|
|
29
|
+
project="payments-agent",
|
|
30
|
+
cases=[EvalCase(id="c1", input="world", expected_output_contains=["hello"])],
|
|
31
|
+
)
|
|
32
|
+
summary = EvalSuite.run(dataset=ds, agent=agent, verbose=False)
|
|
33
|
+
assert summary.passed == 1
|
|
34
|
+
|
|
35
|
+
client = CortexClient(api_key="cxo-test", base_url="https://api.getcortexops.com")
|
|
36
|
+
with patch.object(client, "push_trace", return_value={"ok": True}) as push:
|
|
37
|
+
client.push_trace(trace)
|
|
38
|
+
push.assert_called_once()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_dashboard_surface_fields():
|
|
42
|
+
"""Fields the dashboard consumes are present on traces/evals."""
|
|
43
|
+
t = make_trace({"output": "ok"}, tool_calls=["lookup"], latency_ms=33, project="payments-agent")
|
|
44
|
+
payload = t.model_dump(mode="json")
|
|
45
|
+
for key in ("trace_id", "project", "status", "total_latency_ms", "nodes", "failure_kind"):
|
|
46
|
+
assert key in payload
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""EvalSuite summary rendering."""
|
|
2
|
+
|
|
3
|
+
from cortexops import EvalSuite
|
|
4
|
+
from cortexops.models import EvalCase, EvalDataset
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_summary_string_renders():
|
|
8
|
+
ds = EvalDataset(
|
|
9
|
+
version=1,
|
|
10
|
+
project="test-agent",
|
|
11
|
+
cases=[EvalCase(id="c1", input="hi", expected_output_contains=["hi"])],
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
def agent(inp: dict) -> dict:
|
|
15
|
+
return {"output": inp.get("input", "")}
|
|
16
|
+
|
|
17
|
+
summary = EvalSuite.run(dataset=ds, agent=agent, verbose=False)
|
|
18
|
+
text = summary.summary()
|
|
19
|
+
assert "test-agent" in text
|
|
20
|
+
assert "Task completion" in text
|