argus-sdk 0.1.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.
- argus_sdk-0.1.0/.gitignore +45 -0
- argus_sdk-0.1.0/PKG-INFO +11 -0
- argus_sdk-0.1.0/argus_sdk/__init__.py +71 -0
- argus_sdk-0.1.0/argus_sdk/_anthropic.py +31 -0
- argus_sdk-0.1.0/argus_sdk/_openai.py +34 -0
- argus_sdk-0.1.0/argus_sdk/_reporter.py +17 -0
- argus_sdk-0.1.0/pyproject.toml +30 -0
- argus_sdk-0.1.0/tests/__init__.py +0 -0
- argus_sdk-0.1.0/tests/test_patch.py +323 -0
- argus_sdk-0.1.0/tests/test_reporter.py +99 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# ── Python ────────────────────────────────────────────
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.venv/
|
|
7
|
+
venv/
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
htmlcov/
|
|
15
|
+
.coverage
|
|
16
|
+
|
|
17
|
+
# ── Go ────────────────────────────────────────────────
|
|
18
|
+
server/bin/
|
|
19
|
+
*.exe
|
|
20
|
+
*.test
|
|
21
|
+
|
|
22
|
+
# ── Node / Next.js ────────────────────────────────────
|
|
23
|
+
ui/node_modules/
|
|
24
|
+
ui/.next/
|
|
25
|
+
ui/out/
|
|
26
|
+
ui/.env.local
|
|
27
|
+
|
|
28
|
+
# ── General ───────────────────────────────────────────
|
|
29
|
+
.env
|
|
30
|
+
.env.*
|
|
31
|
+
!.env.example
|
|
32
|
+
.DS_Store
|
|
33
|
+
Thumbs.db
|
|
34
|
+
|
|
35
|
+
# ── SQLite (runtime data, not schema) ─────────────────
|
|
36
|
+
*.db
|
|
37
|
+
*.db-shm
|
|
38
|
+
*.db-wal
|
|
39
|
+
|
|
40
|
+
# ── Claude context (local only) ───────────────────────
|
|
41
|
+
CLAUDE.md
|
|
42
|
+
|
|
43
|
+
# ── Reference docs (not source code) ─────────────────
|
|
44
|
+
*.docx
|
|
45
|
+
*.pdf
|
argus_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: argus-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop-in LLM behavioral drift detection
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: anthropic>=0.25
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def patch(endpoint: str = "http://localhost:4000", client: Any = None) -> None:
|
|
7
|
+
"""Instrument LLM clients to send signal events to the Argus server.
|
|
8
|
+
|
|
9
|
+
Usage (auto — instruments all future clients):
|
|
10
|
+
from argus_sdk import patch
|
|
11
|
+
patch(endpoint="http://localhost:4000")
|
|
12
|
+
|
|
13
|
+
import anthropic
|
|
14
|
+
client = anthropic.Anthropic() # automatically instrumented
|
|
15
|
+
|
|
16
|
+
Usage (explicit — instrument a specific instance):
|
|
17
|
+
patch(endpoint="http://localhost:4000", client=my_client)
|
|
18
|
+
"""
|
|
19
|
+
_endpoint = endpoint.rstrip("/")
|
|
20
|
+
|
|
21
|
+
if client is not None:
|
|
22
|
+
_patch_instance(client, _endpoint)
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
_try_patch_anthropic_class(_endpoint)
|
|
26
|
+
_try_patch_openai_class(_endpoint)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _patch_instance(client: Any, endpoint: str) -> None:
|
|
30
|
+
module = type(client).__module__ or ""
|
|
31
|
+
if "anthropic" in module:
|
|
32
|
+
from ._anthropic import patch as _ap
|
|
33
|
+
_ap(client, endpoint)
|
|
34
|
+
elif "openai" in module:
|
|
35
|
+
from ._openai import patch as _op
|
|
36
|
+
_op(client, endpoint)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _try_patch_anthropic_class(endpoint: str) -> None:
|
|
40
|
+
try:
|
|
41
|
+
import anthropic
|
|
42
|
+
_wrap_class_init(anthropic.Anthropic, endpoint, provider="anthropic")
|
|
43
|
+
except ImportError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _try_patch_openai_class(endpoint: str) -> None:
|
|
48
|
+
try:
|
|
49
|
+
import openai
|
|
50
|
+
_wrap_class_init(openai.OpenAI, endpoint, provider="openai")
|
|
51
|
+
except ImportError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _wrap_class_init(cls: type, endpoint: str, provider: str) -> None:
|
|
56
|
+
if getattr(cls, "_argus_patched", False):
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
original_init = cls.__init__
|
|
60
|
+
|
|
61
|
+
def __init__(self, *args, **kwargs):
|
|
62
|
+
original_init(self, *args, **kwargs)
|
|
63
|
+
if provider == "anthropic":
|
|
64
|
+
from ._anthropic import patch as _ap
|
|
65
|
+
_ap(self, endpoint)
|
|
66
|
+
else:
|
|
67
|
+
from ._openai import patch as _op
|
|
68
|
+
_op(self, endpoint)
|
|
69
|
+
|
|
70
|
+
cls.__init__ = __init__
|
|
71
|
+
cls._argus_patched = True
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from ._reporter import report
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def patch(client: object, endpoint: str) -> None:
|
|
8
|
+
"""Wrap client.messages.create to capture signals after each response."""
|
|
9
|
+
messages = client.messages # type: ignore[attr-defined]
|
|
10
|
+
original_create = messages.create
|
|
11
|
+
|
|
12
|
+
def _create(*args, **kwargs):
|
|
13
|
+
t0 = time.monotonic()
|
|
14
|
+
response = original_create(*args, **kwargs)
|
|
15
|
+
latency_ms = int((time.monotonic() - t0) * 1000)
|
|
16
|
+
report(endpoint, {
|
|
17
|
+
"model": response.model,
|
|
18
|
+
"provider": "anthropic",
|
|
19
|
+
"input_tokens": response.usage.input_tokens,
|
|
20
|
+
"output_tokens": response.usage.output_tokens,
|
|
21
|
+
"latency_ms": latency_ms,
|
|
22
|
+
"finish_reason": response.stop_reason or "",
|
|
23
|
+
"timestamp_utc": _now(),
|
|
24
|
+
})
|
|
25
|
+
return response
|
|
26
|
+
|
|
27
|
+
messages.create = _create
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _now() -> str:
|
|
31
|
+
return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from ._reporter import report
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def patch(client: object, endpoint: str) -> None:
|
|
8
|
+
"""Wrap client.chat.completions.create to capture signals after each response."""
|
|
9
|
+
completions = client.chat.completions # type: ignore[attr-defined]
|
|
10
|
+
original_create = completions.create
|
|
11
|
+
|
|
12
|
+
def _create(*args, **kwargs):
|
|
13
|
+
t0 = time.monotonic()
|
|
14
|
+
response = original_create(*args, **kwargs)
|
|
15
|
+
latency_ms = int((time.monotonic() - t0) * 1000)
|
|
16
|
+
finish_reason = ""
|
|
17
|
+
if response.choices:
|
|
18
|
+
finish_reason = response.choices[0].finish_reason or ""
|
|
19
|
+
report(endpoint, {
|
|
20
|
+
"model": response.model,
|
|
21
|
+
"provider": "openai",
|
|
22
|
+
"input_tokens": response.usage.prompt_tokens if response.usage else 0,
|
|
23
|
+
"output_tokens": response.usage.completion_tokens if response.usage else 0,
|
|
24
|
+
"latency_ms": latency_ms,
|
|
25
|
+
"finish_reason": finish_reason,
|
|
26
|
+
"timestamp_utc": _now(),
|
|
27
|
+
})
|
|
28
|
+
return response
|
|
29
|
+
|
|
30
|
+
completions.create = _create
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _now() -> str:
|
|
34
|
+
return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger("argus_sdk")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _post(endpoint: str, event: dict) -> None:
|
|
8
|
+
try:
|
|
9
|
+
import httpx
|
|
10
|
+
with httpx.Client(timeout=3.0) as client:
|
|
11
|
+
client.post(f"{endpoint}/api/v1/events", json=event)
|
|
12
|
+
except Exception as exc:
|
|
13
|
+
logger.debug("argus: failed to report event: %s", exc)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def report(endpoint: str, event: dict) -> None:
|
|
17
|
+
threading.Thread(target=_post, args=(endpoint, event), daemon=True).start()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "argus-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Drop-in LLM behavioral drift detection"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"anthropic>=0.25",
|
|
12
|
+
"httpx>=0.27",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=8.0",
|
|
18
|
+
"pytest-asyncio>=0.23",
|
|
19
|
+
"ruff>=0.4",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["argus_sdk"]
|
|
24
|
+
|
|
25
|
+
[tool.ruff]
|
|
26
|
+
line-length = 100
|
|
27
|
+
target-version = "py312"
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
asyncio_mode = "auto"
|
|
File without changes
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Tests for argus_sdk patch() and the Anthropic/OpenAI wrappers."""
|
|
2
|
+
import re
|
|
3
|
+
import time
|
|
4
|
+
from unittest.mock import MagicMock, call, patch as mock_patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from argus_sdk import patch, _wrap_class_init
|
|
9
|
+
from argus_sdk._anthropic import patch as anthropic_patch
|
|
10
|
+
from argus_sdk._openai import patch as openai_patch
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Helpers
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
def _anthropic_response(
|
|
17
|
+
model="claude-sonnet-4-6",
|
|
18
|
+
input_tokens=100,
|
|
19
|
+
output_tokens=50,
|
|
20
|
+
stop_reason="stop",
|
|
21
|
+
):
|
|
22
|
+
resp = MagicMock()
|
|
23
|
+
resp.model = model
|
|
24
|
+
resp.usage.input_tokens = input_tokens
|
|
25
|
+
resp.usage.output_tokens = output_tokens
|
|
26
|
+
resp.stop_reason = stop_reason
|
|
27
|
+
return resp
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _openai_response(
|
|
31
|
+
model="gpt-4o",
|
|
32
|
+
prompt_tokens=100,
|
|
33
|
+
completion_tokens=50,
|
|
34
|
+
finish_reason="stop",
|
|
35
|
+
):
|
|
36
|
+
resp = MagicMock()
|
|
37
|
+
resp.model = model
|
|
38
|
+
resp.usage.prompt_tokens = prompt_tokens
|
|
39
|
+
resp.usage.completion_tokens = completion_tokens
|
|
40
|
+
resp.choices = [MagicMock(finish_reason=finish_reason)]
|
|
41
|
+
return resp
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Anthropic — happy path
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def test_anthropic_patch_captures_event():
|
|
49
|
+
posted = []
|
|
50
|
+
|
|
51
|
+
client = MagicMock()
|
|
52
|
+
client.messages.create.return_value = _anthropic_response()
|
|
53
|
+
|
|
54
|
+
with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted.append(ev)):
|
|
55
|
+
anthropic_patch(client, "http://localhost:4000")
|
|
56
|
+
client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
|
|
57
|
+
|
|
58
|
+
assert len(posted) == 1
|
|
59
|
+
e = posted[0]
|
|
60
|
+
assert e["model"] == "claude-sonnet-4-6"
|
|
61
|
+
assert e["provider"] == "anthropic"
|
|
62
|
+
assert e["input_tokens"] == 100
|
|
63
|
+
assert e["output_tokens"] == 50
|
|
64
|
+
assert e["finish_reason"] == "stop"
|
|
65
|
+
assert e["latency_ms"] >= 0
|
|
66
|
+
assert e["timestamp_utc"].endswith("Z")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_anthropic_response_returned():
|
|
70
|
+
"""patch() must not swallow the response — user code depends on it."""
|
|
71
|
+
client = MagicMock()
|
|
72
|
+
response = _anthropic_response(output_tokens=77)
|
|
73
|
+
client.messages.create.return_value = response
|
|
74
|
+
|
|
75
|
+
with mock_patch("argus_sdk._anthropic.report"):
|
|
76
|
+
anthropic_patch(client, "http://localhost:4000")
|
|
77
|
+
result = client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
|
|
78
|
+
|
|
79
|
+
assert result is response
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Anthropic — edge cases
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def test_anthropic_null_stop_reason_becomes_empty_string():
|
|
87
|
+
"""stop_reason=None (e.g. mid-stream errors) must not produce null in the event."""
|
|
88
|
+
posted = []
|
|
89
|
+
|
|
90
|
+
client = MagicMock()
|
|
91
|
+
client.messages.create.return_value = _anthropic_response(stop_reason=None)
|
|
92
|
+
|
|
93
|
+
with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted.append(ev)):
|
|
94
|
+
anthropic_patch(client, "http://localhost:4000")
|
|
95
|
+
client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
|
|
96
|
+
|
|
97
|
+
assert posted[0]["finish_reason"] == ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_anthropic_event_has_all_required_keys():
|
|
101
|
+
"""The event payload must match the SDK integration contract exactly."""
|
|
102
|
+
REQUIRED = {"model", "provider", "input_tokens", "output_tokens", "latency_ms", "finish_reason", "timestamp_utc"}
|
|
103
|
+
posted = []
|
|
104
|
+
|
|
105
|
+
client = MagicMock()
|
|
106
|
+
client.messages.create.return_value = _anthropic_response()
|
|
107
|
+
|
|
108
|
+
with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted.append(ev)):
|
|
109
|
+
anthropic_patch(client, "http://localhost:4000")
|
|
110
|
+
client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
|
|
111
|
+
|
|
112
|
+
assert set(posted[0].keys()) == REQUIRED
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_anthropic_timestamp_format():
|
|
116
|
+
"""timestamp_utc must be ISO 8601 UTC: YYYY-MM-DDTHH:MM:SSZ."""
|
|
117
|
+
posted = []
|
|
118
|
+
|
|
119
|
+
client = MagicMock()
|
|
120
|
+
client.messages.create.return_value = _anthropic_response()
|
|
121
|
+
|
|
122
|
+
with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted.append(ev)):
|
|
123
|
+
anthropic_patch(client, "http://localhost:4000")
|
|
124
|
+
client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
|
|
125
|
+
|
|
126
|
+
ts = posted[0]["timestamp_utc"]
|
|
127
|
+
assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", ts), f"Bad timestamp: {ts!r}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_anthropic_latency_is_measured():
|
|
131
|
+
"""latency_ms must reflect actual wall-clock time of the underlying call."""
|
|
132
|
+
posted = []
|
|
133
|
+
|
|
134
|
+
client = MagicMock()
|
|
135
|
+
|
|
136
|
+
def slow_create(*args, **kwargs):
|
|
137
|
+
time.sleep(0.05)
|
|
138
|
+
return _anthropic_response()
|
|
139
|
+
|
|
140
|
+
client.messages.create.side_effect = slow_create
|
|
141
|
+
|
|
142
|
+
with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted.append(ev)):
|
|
143
|
+
anthropic_patch(client, "http://localhost:4000")
|
|
144
|
+
client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
|
|
145
|
+
|
|
146
|
+
assert posted[0]["latency_ms"] >= 40, "Expected at least 40 ms for a 50 ms sleep"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# OpenAI — happy path
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def test_openai_patch_captures_event():
|
|
154
|
+
posted = []
|
|
155
|
+
|
|
156
|
+
client = MagicMock()
|
|
157
|
+
client.chat.completions.create.return_value = _openai_response()
|
|
158
|
+
|
|
159
|
+
with mock_patch("argus_sdk._openai.report", side_effect=lambda ep, ev: posted.append(ev)):
|
|
160
|
+
openai_patch(client, "http://localhost:4000")
|
|
161
|
+
client.chat.completions.create(model="gpt-4o", messages=[])
|
|
162
|
+
|
|
163
|
+
assert len(posted) == 1
|
|
164
|
+
e = posted[0]
|
|
165
|
+
assert e["model"] == "gpt-4o"
|
|
166
|
+
assert e["provider"] == "openai"
|
|
167
|
+
assert e["input_tokens"] == 100
|
|
168
|
+
assert e["output_tokens"] == 50
|
|
169
|
+
assert e["finish_reason"] == "stop"
|
|
170
|
+
assert e["latency_ms"] >= 0
|
|
171
|
+
assert e["timestamp_utc"].endswith("Z")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_openai_response_returned():
|
|
175
|
+
"""OpenAI wrapper must not swallow the response."""
|
|
176
|
+
client = MagicMock()
|
|
177
|
+
response = _openai_response(completion_tokens=33)
|
|
178
|
+
client.chat.completions.create.return_value = response
|
|
179
|
+
|
|
180
|
+
with mock_patch("argus_sdk._openai.report"):
|
|
181
|
+
openai_patch(client, "http://localhost:4000")
|
|
182
|
+
result = client.chat.completions.create(model="gpt-4o", messages=[])
|
|
183
|
+
|
|
184
|
+
assert result is response
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# OpenAI — edge cases
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def test_openai_no_choices_gives_empty_finish_reason():
|
|
192
|
+
"""If choices is empty (shouldn't happen, but guard it), finish_reason is ''."""
|
|
193
|
+
posted = []
|
|
194
|
+
|
|
195
|
+
client = MagicMock()
|
|
196
|
+
resp = MagicMock()
|
|
197
|
+
resp.model = "gpt-4o"
|
|
198
|
+
resp.choices = []
|
|
199
|
+
resp.usage.prompt_tokens = 10
|
|
200
|
+
resp.usage.completion_tokens = 5
|
|
201
|
+
client.chat.completions.create.return_value = resp
|
|
202
|
+
|
|
203
|
+
with mock_patch("argus_sdk._openai.report", side_effect=lambda ep, ev: posted.append(ev)):
|
|
204
|
+
openai_patch(client, "http://localhost:4000")
|
|
205
|
+
client.chat.completions.create(model="gpt-4o", messages=[])
|
|
206
|
+
|
|
207
|
+
assert posted[0]["finish_reason"] == ""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_openai_null_usage_gives_zero_tokens():
|
|
211
|
+
"""Some streaming/mock responses omit usage; must not crash."""
|
|
212
|
+
posted = []
|
|
213
|
+
|
|
214
|
+
client = MagicMock()
|
|
215
|
+
resp = MagicMock()
|
|
216
|
+
resp.model = "gpt-4o"
|
|
217
|
+
resp.usage = None
|
|
218
|
+
resp.choices = [MagicMock(finish_reason="stop")]
|
|
219
|
+
client.chat.completions.create.return_value = resp
|
|
220
|
+
|
|
221
|
+
with mock_patch("argus_sdk._openai.report", side_effect=lambda ep, ev: posted.append(ev)):
|
|
222
|
+
openai_patch(client, "http://localhost:4000")
|
|
223
|
+
client.chat.completions.create(model="gpt-4o", messages=[])
|
|
224
|
+
|
|
225
|
+
assert posted[0]["input_tokens"] == 0
|
|
226
|
+
assert posted[0]["output_tokens"] == 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_openai_event_has_all_required_keys():
|
|
230
|
+
REQUIRED = {"model", "provider", "input_tokens", "output_tokens", "latency_ms", "finish_reason", "timestamp_utc"}
|
|
231
|
+
posted = []
|
|
232
|
+
|
|
233
|
+
client = MagicMock()
|
|
234
|
+
client.chat.completions.create.return_value = _openai_response()
|
|
235
|
+
|
|
236
|
+
with mock_patch("argus_sdk._openai.report", side_effect=lambda ep, ev: posted.append(ev)):
|
|
237
|
+
openai_patch(client, "http://localhost:4000")
|
|
238
|
+
client.chat.completions.create(model="gpt-4o", messages=[])
|
|
239
|
+
|
|
240
|
+
assert set(posted[0].keys()) == REQUIRED
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# patch() — top-level API
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def test_patch_noop_when_no_llm_library():
|
|
248
|
+
"""patch() must not raise even if neither anthropic nor openai is installed."""
|
|
249
|
+
with mock_patch.dict("sys.modules", {"anthropic": None, "openai": None}):
|
|
250
|
+
patch(endpoint="http://localhost:4000")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_patch_with_explicit_anthropic_client():
|
|
254
|
+
"""patch(client=...) should instrument a passed-in anthropic client directly."""
|
|
255
|
+
posted = []
|
|
256
|
+
|
|
257
|
+
class FakeAnthropicClient:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
FakeAnthropicClient.__module__ = "anthropic"
|
|
261
|
+
client = FakeAnthropicClient()
|
|
262
|
+
client.messages = MagicMock()
|
|
263
|
+
client.messages.create.return_value = _anthropic_response()
|
|
264
|
+
|
|
265
|
+
with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted.append(ev)):
|
|
266
|
+
patch(endpoint="http://localhost:4000", client=client)
|
|
267
|
+
client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
|
|
268
|
+
|
|
269
|
+
assert len(posted) == 1
|
|
270
|
+
assert posted[0]["provider"] == "anthropic"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_patch_with_explicit_openai_client():
|
|
274
|
+
"""patch(client=...) should instrument a passed-in openai client directly."""
|
|
275
|
+
posted = []
|
|
276
|
+
|
|
277
|
+
class FakeOpenAIClient:
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
FakeOpenAIClient.__module__ = "openai"
|
|
281
|
+
client = FakeOpenAIClient()
|
|
282
|
+
client.chat = MagicMock()
|
|
283
|
+
client.chat.completions.create.return_value = _openai_response()
|
|
284
|
+
|
|
285
|
+
with mock_patch("argus_sdk._openai.report", side_effect=lambda ep, ev: posted.append(ev)):
|
|
286
|
+
patch(endpoint="http://localhost:4000", client=client)
|
|
287
|
+
client.chat.completions.create(model="gpt-4o", messages=[])
|
|
288
|
+
|
|
289
|
+
assert len(posted) == 1
|
|
290
|
+
assert posted[0]["provider"] == "openai"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_class_level_patched_only_once():
|
|
294
|
+
"""_wrap_class_init must be idempotent — calling it twice should not double-wrap."""
|
|
295
|
+
class FakeClient:
|
|
296
|
+
def __init__(self):
|
|
297
|
+
self.messages = MagicMock()
|
|
298
|
+
self.messages.create.return_value = _anthropic_response()
|
|
299
|
+
|
|
300
|
+
_wrap_class_init(FakeClient, "http://localhost:4000", provider="anthropic")
|
|
301
|
+
_wrap_class_init(FakeClient, "http://localhost:4000", provider="anthropic") # second call
|
|
302
|
+
|
|
303
|
+
assert FakeClient._argus_patched is True
|
|
304
|
+
|
|
305
|
+
posted = []
|
|
306
|
+
with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted.append(ev)):
|
|
307
|
+
with mock_patch("argus_sdk._anthropic.patch") as mock_ap:
|
|
308
|
+
FakeClient()
|
|
309
|
+
assert mock_ap.call_count == 1 # wrapped once, not twice
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_endpoint_passed_to_reporter():
|
|
313
|
+
"""The endpoint given to patch() must be forwarded to report()."""
|
|
314
|
+
posted_endpoints = []
|
|
315
|
+
|
|
316
|
+
client = MagicMock()
|
|
317
|
+
client.messages.create.return_value = _anthropic_response()
|
|
318
|
+
|
|
319
|
+
with mock_patch("argus_sdk._anthropic.report", side_effect=lambda ep, ev: posted_endpoints.append(ep)):
|
|
320
|
+
anthropic_patch(client, "http://my-argus-server:4000")
|
|
321
|
+
client.messages.create(model="claude-sonnet-4-6", max_tokens=100, messages=[])
|
|
322
|
+
|
|
323
|
+
assert posted_endpoints[0] == "http://my-argus-server:4000"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Tests for argus_sdk._reporter — background HTTP posting."""
|
|
2
|
+
import threading
|
|
3
|
+
from unittest.mock import MagicMock, patch as mock_patch
|
|
4
|
+
|
|
5
|
+
from argus_sdk._reporter import _post, report
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_SAMPLE_EVENT = {
|
|
9
|
+
"model": "claude-sonnet-4-6",
|
|
10
|
+
"provider": "anthropic",
|
|
11
|
+
"input_tokens": 100,
|
|
12
|
+
"output_tokens": 50,
|
|
13
|
+
"latency_ms": 200,
|
|
14
|
+
"finish_reason": "stop",
|
|
15
|
+
"timestamp_utc": "2026-04-07T14:22:01Z",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_reporter_posts_to_correct_url():
|
|
20
|
+
"""`_post` must call POST /api/v1/events on the given endpoint."""
|
|
21
|
+
mock_response = MagicMock()
|
|
22
|
+
mock_client_instance = MagicMock()
|
|
23
|
+
mock_client_instance.post.return_value = mock_response
|
|
24
|
+
|
|
25
|
+
with mock_patch("httpx.Client") as mock_httpx_client:
|
|
26
|
+
mock_httpx_client.return_value.__enter__.return_value = mock_client_instance
|
|
27
|
+
_post("http://localhost:4000", _SAMPLE_EVENT)
|
|
28
|
+
|
|
29
|
+
mock_client_instance.post.assert_called_once_with(
|
|
30
|
+
"http://localhost:4000/api/v1/events",
|
|
31
|
+
json=_SAMPLE_EVENT,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_reporter_swallows_connection_error():
|
|
36
|
+
"""Network failures must never propagate to the caller."""
|
|
37
|
+
import httpx
|
|
38
|
+
|
|
39
|
+
with mock_patch("httpx.Client") as mock_httpx_client:
|
|
40
|
+
mock_httpx_client.return_value.__enter__.return_value.post.side_effect = (
|
|
41
|
+
httpx.ConnectError("Connection refused")
|
|
42
|
+
)
|
|
43
|
+
_post("http://localhost:4000", _SAMPLE_EVENT) # must not raise
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_reporter_swallows_timeout_error():
|
|
47
|
+
import httpx
|
|
48
|
+
|
|
49
|
+
with mock_patch("httpx.Client") as mock_httpx_client:
|
|
50
|
+
mock_httpx_client.return_value.__enter__.return_value.post.side_effect = (
|
|
51
|
+
httpx.TimeoutException("timed out")
|
|
52
|
+
)
|
|
53
|
+
_post("http://localhost:4000", _SAMPLE_EVENT) # must not raise
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_reporter_swallows_unexpected_exception():
|
|
57
|
+
with mock_patch("httpx.Client") as mock_httpx_client:
|
|
58
|
+
mock_httpx_client.return_value.__enter__.return_value.post.side_effect = RuntimeError("boom")
|
|
59
|
+
_post("http://localhost:4000", _SAMPLE_EVENT) # must not raise
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_report_fires_daemon_thread():
|
|
63
|
+
"""`report()` must start a daemon thread so it never blocks process exit."""
|
|
64
|
+
threads_started = []
|
|
65
|
+
|
|
66
|
+
real_thread_init = threading.Thread.__init__
|
|
67
|
+
|
|
68
|
+
def capture_thread(self, *args, **kwargs):
|
|
69
|
+
real_thread_init(self, *args, **kwargs)
|
|
70
|
+
threads_started.append(self)
|
|
71
|
+
|
|
72
|
+
with mock_patch.object(threading.Thread, "__init__", capture_thread):
|
|
73
|
+
with mock_patch("argus_sdk._reporter._post"): # don't actually POST
|
|
74
|
+
report("http://localhost:4000", _SAMPLE_EVENT)
|
|
75
|
+
|
|
76
|
+
assert len(threads_started) == 1
|
|
77
|
+
assert threads_started[0].daemon is True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_report_does_not_block():
|
|
81
|
+
"""`report()` must return before the HTTP call completes."""
|
|
82
|
+
import time
|
|
83
|
+
|
|
84
|
+
call_started = threading.Event()
|
|
85
|
+
call_done = threading.Event()
|
|
86
|
+
|
|
87
|
+
def slow_post(endpoint, event):
|
|
88
|
+
call_started.set()
|
|
89
|
+
time.sleep(0.1)
|
|
90
|
+
call_done.set()
|
|
91
|
+
|
|
92
|
+
with mock_patch("argus_sdk._reporter._post", side_effect=slow_post):
|
|
93
|
+
t0 = time.monotonic()
|
|
94
|
+
report("http://localhost:4000", _SAMPLE_EVENT)
|
|
95
|
+
elapsed = time.monotonic() - t0
|
|
96
|
+
|
|
97
|
+
# report() returns almost instantly; 100 ms sleep is in the background thread
|
|
98
|
+
assert elapsed < 0.05, f"report() blocked for {elapsed:.3f}s — expected < 0.05s"
|
|
99
|
+
call_started.wait(timeout=1.0) # background thread did fire
|