agentcompile 0.2.1__tar.gz → 0.3.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.
- {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/workflows/ci.yml +2 -2
- {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/workflows/release.yml +3 -3
- agentcompile-0.3.0/.release-please-manifest.json +1 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/CHANGELOG.md +7 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/PKG-INFO +1 -1
- {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/__init__.py +26 -1
- agentcompile-0.3.0/src/agentcompile/_capture.py +117 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_core.py +14 -1
- {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_version.py +2 -2
- agentcompile-0.3.0/tests/test_capture.py +150 -0
- agentcompile-0.2.1/.release-please-manifest.json +0 -1
- {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/CODEOWNERS +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/dependabot.yml +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/.github/pull_request_template.md +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/.gitignore +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/CONTRIBUTING.md +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/LICENSE +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/README.md +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/SECURITY.md +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/pyproject.toml +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/release-please-config.json +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_anthropic.py +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_conversation.py +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_decide.py +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_openai.py +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_payload.py +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_streams.py +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/_trail.py +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/src/agentcompile/cli.py +0 -0
- {agentcompile-0.2.1 → agentcompile-0.3.0}/tests/test_wrap.py +0 -0
|
@@ -12,10 +12,10 @@ jobs:
|
|
|
12
12
|
matrix:
|
|
13
13
|
python: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
14
14
|
steps:
|
|
15
|
-
- uses: actions/checkout@
|
|
15
|
+
- uses: actions/checkout@v7
|
|
16
16
|
with:
|
|
17
17
|
fetch-depth: 0 # the version comes from git tags
|
|
18
|
-
- uses: actions/setup-python@
|
|
18
|
+
- uses: actions/setup-python@v7
|
|
19
19
|
with:
|
|
20
20
|
python-version: ${{ matrix.python }}
|
|
21
21
|
- run: pip install -e ".[dev]" ruff
|
|
@@ -23,7 +23,7 @@ jobs:
|
|
|
23
23
|
released: ${{ steps.rp.outputs.release_created }}
|
|
24
24
|
tag: ${{ steps.rp.outputs.tag_name }}
|
|
25
25
|
steps:
|
|
26
|
-
- uses: googleapis/release-please-action@
|
|
26
|
+
- uses: googleapis/release-please-action@v5
|
|
27
27
|
id: rp
|
|
28
28
|
with:
|
|
29
29
|
config-file: release-please-config.json
|
|
@@ -39,11 +39,11 @@ jobs:
|
|
|
39
39
|
id-token: write
|
|
40
40
|
contents: read
|
|
41
41
|
steps:
|
|
42
|
-
- uses: actions/checkout@
|
|
42
|
+
- uses: actions/checkout@v7
|
|
43
43
|
with:
|
|
44
44
|
ref: ${{ needs.release-please.outputs.tag || github.ref }}
|
|
45
45
|
fetch-depth: 0
|
|
46
|
-
- uses: actions/setup-python@
|
|
46
|
+
- uses: actions/setup-python@v7
|
|
47
47
|
with:
|
|
48
48
|
python-version: "3.12"
|
|
49
49
|
- run: pip install build && python -m build
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{".":"0.3.0"}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0](https://github.com/kbhatnagar1506/agentcompile-python/compare/v0.2.1...v0.3.0) (2026-10-01)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* capture (opt-in) sends each call's request and answer in the background ([#7](https://github.com/kbhatnagar1506/agentcompile-python/issues/7)) ([58f564d](https://github.com/kbhatnagar1506/agentcompile-python/commit/58f564da708c31dbf6d4c11c377dab2e315994de))
|
|
9
|
+
|
|
3
10
|
## [0.2.1](https://github.com/kbhatnagar1506/agentcompile-python/compare/v0.2.0...v0.2.1) (2026-10-01)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentcompile
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Wrap your agent's model client: repeated jobs run compiled, everything else goes to your model unchanged.
|
|
5
5
|
Project-URL: Homepage, https://tryagentcompile.com
|
|
6
6
|
Author-email: AgentCompile <founders@tryagentcompile.com>
|
|
@@ -15,12 +15,13 @@ import inspect
|
|
|
15
15
|
import os
|
|
16
16
|
from typing import Any
|
|
17
17
|
|
|
18
|
+
from ._capture import Capturer, flush_all
|
|
18
19
|
from ._conversation import conversation
|
|
19
20
|
from ._core import LIVE, SHADOW, Proxy, Router, async_create, sync_create
|
|
20
21
|
from ._decide import AsyncDecider, Decider, Settings
|
|
21
22
|
from ._trail import OnEvent, Trail
|
|
22
23
|
|
|
23
|
-
__all__ = ["LIVE", "SHADOW", "conversation", "wrap"]
|
|
24
|
+
__all__ = ["LIVE", "SHADOW", "Settings", "conversation", "flush", "wrap"]
|
|
24
25
|
try:
|
|
25
26
|
from ._version import __version__
|
|
26
27
|
except ImportError: # running from a source checkout that was never built
|
|
@@ -40,6 +41,7 @@ def wrap(
|
|
|
40
41
|
on_event: OnEvent | None = None,
|
|
41
42
|
company: str | None = None,
|
|
42
43
|
http_client: Any = None,
|
|
44
|
+
capture: bool | None = None,
|
|
43
45
|
) -> Any:
|
|
44
46
|
"""Wrap an OpenAI- or Anthropic-style client; returns an object you use exactly like it.
|
|
45
47
|
|
|
@@ -47,6 +49,8 @@ def wrap(
|
|
|
47
49
|
mode: "live" answers known jobs; "shadow" decides but always calls your model, so you can
|
|
48
50
|
see what it would have done. timeout: seconds to wait for a decision before failing open.
|
|
49
51
|
trail: a path, True for ~/.agentcompile/trail.jsonl, or False. on_event: called per call.
|
|
52
|
+
capture: send each call's request and answer to AgentCompile in the background, so it can
|
|
53
|
+
find the jobs your agent repeats (opt-in; or AGENTCOMPILE_CAPTURE=1). Never slows a call.
|
|
50
54
|
"""
|
|
51
55
|
settings = Settings(
|
|
52
56
|
base_url=base_url or os.environ.get("AGENTCOMPILE_URL", DEFAULT_URL),
|
|
@@ -55,6 +59,14 @@ def wrap(
|
|
|
55
59
|
timeout=timeout,
|
|
56
60
|
)
|
|
57
61
|
the_trail = Trail(trail, on_event)
|
|
62
|
+
if capture is None:
|
|
63
|
+
capture = os.environ.get("AGENTCOMPILE_CAPTURE", "") in ("1", "true", "yes")
|
|
64
|
+
# One sender per wrapped client; it always sends from its own thread (sync client).
|
|
65
|
+
capturer = (
|
|
66
|
+
Capturer(settings, http_client if _is_sync_http(http_client) else None)
|
|
67
|
+
if capture
|
|
68
|
+
else None
|
|
69
|
+
)
|
|
58
70
|
|
|
59
71
|
chat = getattr(client, "chat", None)
|
|
60
72
|
completions = getattr(chat, "completions", None)
|
|
@@ -62,6 +74,7 @@ def wrap(
|
|
|
62
74
|
from . import _openai
|
|
63
75
|
|
|
64
76
|
router = _router("openai", completions.create, settings, the_trail, mode, http_client)
|
|
77
|
+
router.capturer = capturer
|
|
65
78
|
create = _create(router, completions.create, _openai.build)
|
|
66
79
|
return Proxy(
|
|
67
80
|
client,
|
|
@@ -73,12 +86,18 @@ def wrap(
|
|
|
73
86
|
from . import _anthropic
|
|
74
87
|
|
|
75
88
|
router = _router("anthropic", messages.create, settings, the_trail, mode, http_client)
|
|
89
|
+
router.capturer = capturer
|
|
76
90
|
create = _create(router, messages.create, _anthropic.build)
|
|
77
91
|
return Proxy(client, {"messages": Proxy(messages, {"create": create})})
|
|
78
92
|
|
|
79
93
|
raise TypeError("agentcompile.wrap expects an OpenAI- or Anthropic-style client")
|
|
80
94
|
|
|
81
95
|
|
|
96
|
+
def flush(timeout: float = 5.0) -> None:
|
|
97
|
+
"""Send every captured call still queued (short scripts and tests; also runs at exit)."""
|
|
98
|
+
flush_all(timeout)
|
|
99
|
+
|
|
100
|
+
|
|
82
101
|
def _router(
|
|
83
102
|
provider: str, create: Any, settings: Settings, trail: Trail, mode: str, http_client: Any
|
|
84
103
|
) -> Router:
|
|
@@ -98,6 +117,12 @@ def _create(router: Router, original: Any, build: Any) -> Any:
|
|
|
98
117
|
)
|
|
99
118
|
|
|
100
119
|
|
|
120
|
+
def _is_sync_http(http_client: Any) -> bool:
|
|
121
|
+
"""Any client with a plain (not async) post(): httpx, httpx2, a test client."""
|
|
122
|
+
post = getattr(http_client, "post", None)
|
|
123
|
+
return post is not None and not inspect.iscoroutinefunction(inspect.unwrap(post))
|
|
124
|
+
|
|
125
|
+
|
|
101
126
|
def _is_async(fn: Any) -> bool:
|
|
102
127
|
"""The SDKs wrap create() in decorators; look through them."""
|
|
103
128
|
return inspect.iscoroutinefunction(inspect.unwrap(fn))
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Capture: each model call's request and answer, sent to AgentCompile in the background so it
|
|
2
|
+
can find the jobs your agent repeats (opt-in: `wrap(..., capture=True)`).
|
|
3
|
+
|
|
4
|
+
Never in the way: calls are queued and sent by one background thread in batches; a full queue
|
|
5
|
+
drops the oldest, a failed send is dropped and counted, and nothing here ever raises into your
|
|
6
|
+
agent. Each record is one line in the exchange-log shape AgentCompile's importer reads:
|
|
7
|
+
{"provider", "conversation_id", "timestamp", "request", "response"}.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import atexit
|
|
13
|
+
import collections
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from ._decide import CONVERSATION_HEADER, Settings, _request
|
|
22
|
+
from ._payload import jsonable, payload
|
|
23
|
+
|
|
24
|
+
MAX_QUEUE = 2000
|
|
25
|
+
BATCH = 50
|
|
26
|
+
INTERVAL = 1.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_ALL: list[Capturer] = []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def flush_all(timeout: float = 5.0) -> None:
|
|
33
|
+
for capturer in list(_ALL):
|
|
34
|
+
capturer.flush(timeout)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Capturer:
|
|
38
|
+
def __init__(self, settings: Settings, http: httpx.Client | None = None) -> None:
|
|
39
|
+
self.settings = settings
|
|
40
|
+
self._http = http
|
|
41
|
+
self._queue: collections.deque[dict[str, Any]] = collections.deque(maxlen=MAX_QUEUE)
|
|
42
|
+
self._lock = threading.Lock()
|
|
43
|
+
self._wake = threading.Event()
|
|
44
|
+
self._thread: threading.Thread | None = None
|
|
45
|
+
self.sent = 0
|
|
46
|
+
self.dropped = 0
|
|
47
|
+
self.failed = 0
|
|
48
|
+
atexit.register(self.flush, 2.0)
|
|
49
|
+
_ALL.append(self)
|
|
50
|
+
|
|
51
|
+
def add(
|
|
52
|
+
self,
|
|
53
|
+
provider: str,
|
|
54
|
+
conversation_id: str | None,
|
|
55
|
+
kwargs: dict[str, Any],
|
|
56
|
+
response: Any,
|
|
57
|
+
stream: bool = False,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Queue one call. Never raises. A streamed answer is not captured yet: the record
|
|
60
|
+
keeps the request and says so."""
|
|
61
|
+
try:
|
|
62
|
+
record = {
|
|
63
|
+
"provider": provider,
|
|
64
|
+
"conversation_id": conversation_id,
|
|
65
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
66
|
+
"request": payload(kwargs),
|
|
67
|
+
"response": None if stream else jsonable(response),
|
|
68
|
+
}
|
|
69
|
+
if stream:
|
|
70
|
+
record["stream"] = True
|
|
71
|
+
with self._lock:
|
|
72
|
+
if len(self._queue) == self._queue.maxlen:
|
|
73
|
+
self.dropped += 1 # the deque drops the oldest
|
|
74
|
+
self._queue.append(record)
|
|
75
|
+
self._start()
|
|
76
|
+
if len(self._queue) >= BATCH:
|
|
77
|
+
self._wake.set()
|
|
78
|
+
except Exception:
|
|
79
|
+
self.dropped += 1
|
|
80
|
+
|
|
81
|
+
def flush(self, timeout: float = 5.0) -> None:
|
|
82
|
+
"""Send everything queued now (tests, and at exit)."""
|
|
83
|
+
deadline = time.monotonic() + timeout
|
|
84
|
+
while self._queue and time.monotonic() < deadline:
|
|
85
|
+
self._send_batch()
|
|
86
|
+
|
|
87
|
+
def _start(self) -> None:
|
|
88
|
+
if self._thread is None or not self._thread.is_alive():
|
|
89
|
+
self._thread = threading.Thread(target=self._run, name="agentcompile-capture")
|
|
90
|
+
self._thread.daemon = True
|
|
91
|
+
self._thread.start()
|
|
92
|
+
|
|
93
|
+
def _run(self) -> None:
|
|
94
|
+
while True:
|
|
95
|
+
self._wake.wait(INTERVAL)
|
|
96
|
+
self._wake.clear()
|
|
97
|
+
while self._queue:
|
|
98
|
+
self._send_batch()
|
|
99
|
+
|
|
100
|
+
def _send_batch(self) -> None:
|
|
101
|
+
with self._lock:
|
|
102
|
+
batch = [self._queue.popleft() for _ in range(min(BATCH, len(self._queue)))]
|
|
103
|
+
if not batch:
|
|
104
|
+
return
|
|
105
|
+
url, headers, _ = _request(self.settings, batch[0]["provider"], "capture", {})
|
|
106
|
+
headers.pop(CONVERSATION_HEADER, None)
|
|
107
|
+
url = url.rsplit("/v1/decide", 1)[0] + "/v1/capture"
|
|
108
|
+
try:
|
|
109
|
+
if self._http is None:
|
|
110
|
+
self._http = httpx.Client(timeout=self.settings.timeout)
|
|
111
|
+
response = self._http.post(url, headers=headers, json={"exchanges": batch})
|
|
112
|
+
if response.status_code == 200:
|
|
113
|
+
self.sent += len(batch)
|
|
114
|
+
else:
|
|
115
|
+
self.failed += len(batch)
|
|
116
|
+
except Exception:
|
|
117
|
+
self.failed += len(batch)
|
|
@@ -16,9 +16,12 @@ LIVE, SHADOW = "live", "shadow"
|
|
|
16
16
|
class Router:
|
|
17
17
|
"""Holds the decision client, the trail and the mode for one wrapped client."""
|
|
18
18
|
|
|
19
|
-
def __init__(
|
|
19
|
+
def __init__(
|
|
20
|
+
self, provider: str, decider: Any, trail: Trail, mode: str, capturer: Any = None
|
|
21
|
+
) -> None:
|
|
20
22
|
if mode not in (LIVE, SHADOW):
|
|
21
23
|
raise ValueError("mode must be 'live' or 'shadow'")
|
|
24
|
+
self.capturer = capturer
|
|
22
25
|
self.provider = provider
|
|
23
26
|
self.decider = decider
|
|
24
27
|
self.trail = trail
|
|
@@ -44,6 +47,12 @@ class Router:
|
|
|
44
47
|
return "shadow", None
|
|
45
48
|
return "compiled", decision
|
|
46
49
|
|
|
50
|
+
def capture(
|
|
51
|
+
self, conversation_id: str | None, kwargs: dict[str, Any], result: Any, stream: bool
|
|
52
|
+
) -> None:
|
|
53
|
+
if self.capturer is not None:
|
|
54
|
+
self.capturer.add(self.provider, conversation_id, kwargs, result, stream)
|
|
55
|
+
|
|
47
56
|
def record(
|
|
48
57
|
self,
|
|
49
58
|
*,
|
|
@@ -103,8 +112,10 @@ def sync_create(
|
|
|
103
112
|
started=started,
|
|
104
113
|
stream=stream,
|
|
105
114
|
)
|
|
115
|
+
router.capture(conversation_id, real_kwargs, result, stream)
|
|
106
116
|
return result
|
|
107
117
|
result = original(*args, **real_kwargs)
|
|
118
|
+
router.capture(conversation_id, real_kwargs, result, stream)
|
|
108
119
|
router.record(
|
|
109
120
|
route=route,
|
|
110
121
|
conversation_id=conversation_id,
|
|
@@ -149,8 +160,10 @@ def async_create(
|
|
|
149
160
|
started=started,
|
|
150
161
|
stream=stream,
|
|
151
162
|
)
|
|
163
|
+
router.capture(conversation_id, real_kwargs, result, stream)
|
|
152
164
|
return result
|
|
153
165
|
result = await original(*args, **real_kwargs)
|
|
166
|
+
router.capture(conversation_id, real_kwargs, result, stream)
|
|
154
167
|
router.record(
|
|
155
168
|
route=route,
|
|
156
169
|
conversation_id=conversation_id,
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.3.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Capture (wrap(..., capture=True)): each call's request and answer reach AgentCompile in the
|
|
2
|
+
background, in the importer's exchange shape, and never slow or break the call."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import openai
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
import agentcompile
|
|
14
|
+
from agentcompile import _capture
|
|
15
|
+
|
|
16
|
+
MESSAGES = [{"role": "user", "content": "cancel order #W1"}]
|
|
17
|
+
FORWARD = {"action": "forward", "reason": "no job matched"}
|
|
18
|
+
COMPLETION = {
|
|
19
|
+
"id": "c1",
|
|
20
|
+
"object": "chat.completion",
|
|
21
|
+
"created": 0,
|
|
22
|
+
"model": "gpt-x",
|
|
23
|
+
"choices": [
|
|
24
|
+
{
|
|
25
|
+
"index": 0,
|
|
26
|
+
"message": {"role": "assistant", "content": "from the model"},
|
|
27
|
+
"finish_reason": "stop",
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Service:
|
|
34
|
+
"""AgentCompile: answers decide with forward, records capture batches."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, capture_status: int = 200) -> None:
|
|
37
|
+
self.batches: list[dict[str, Any]] = []
|
|
38
|
+
self.capture_status = capture_status
|
|
39
|
+
|
|
40
|
+
def __call__(self, request: httpx.Request) -> httpx.Response:
|
|
41
|
+
if request.url.path == "/v1/capture":
|
|
42
|
+
self.batches.append(json.loads(request.content))
|
|
43
|
+
assert request.headers["x-agentcompiler-key"] == "ack_acme.k"
|
|
44
|
+
assert "x-agentcompiler-conversation" not in request.headers
|
|
45
|
+
return httpx.Response(self.capture_status, json={"kept": 1})
|
|
46
|
+
return httpx.Response(200, json=FORWARD)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _client(service: Service, **kw: Any) -> Any:
|
|
50
|
+
provider = openai.OpenAI(
|
|
51
|
+
api_key="sk-test",
|
|
52
|
+
base_url="http://provider.test/v1",
|
|
53
|
+
http_client=httpx.Client(
|
|
54
|
+
transport=httpx.MockTransport(lambda r: httpx.Response(200, json=COMPLETION))
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
return agentcompile.wrap(
|
|
58
|
+
provider,
|
|
59
|
+
key="ack_acme.k",
|
|
60
|
+
base_url="http://ac.test",
|
|
61
|
+
trail=False,
|
|
62
|
+
http_client=httpx.Client(transport=httpx.MockTransport(service)),
|
|
63
|
+
**kw,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture(autouse=True)
|
|
68
|
+
def _no_leftover_senders() -> Any:
|
|
69
|
+
yield
|
|
70
|
+
_capture._ALL.clear()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_capture_is_off_unless_asked(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
74
|
+
monkeypatch.delenv("AGENTCOMPILE_CAPTURE", raising=False)
|
|
75
|
+
service = Service()
|
|
76
|
+
_client(service).chat.completions.create(
|
|
77
|
+
model="gpt-x", messages=MESSAGES, conversation_id="c1"
|
|
78
|
+
)
|
|
79
|
+
agentcompile.flush(2)
|
|
80
|
+
assert service.batches == []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_each_call_is_sent_in_the_importers_exchange_shape() -> None:
|
|
84
|
+
service = Service()
|
|
85
|
+
client = _client(service, capture=True)
|
|
86
|
+
for _ in range(3):
|
|
87
|
+
client.chat.completions.create(model="gpt-x", messages=MESSAGES, conversation_id="c1")
|
|
88
|
+
agentcompile.flush(5)
|
|
89
|
+
sent = [x for batch in service.batches for x in batch["exchanges"]]
|
|
90
|
+
assert len(sent) == 3
|
|
91
|
+
first = sent[0]
|
|
92
|
+
assert first["provider"] == "openai" and first["conversation_id"] == "c1"
|
|
93
|
+
assert first["request"]["messages"] == MESSAGES and first["request"]["model"] == "gpt-x"
|
|
94
|
+
assert first["response"]["choices"][0]["message"]["content"] == "from the model"
|
|
95
|
+
assert first["timestamp"]
|
|
96
|
+
assert "conversation_id" not in first["request"] # our keyword never reaches the model
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_the_environment_turns_capture_on(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
100
|
+
monkeypatch.setenv("AGENTCOMPILE_CAPTURE", "1")
|
|
101
|
+
service = Service()
|
|
102
|
+
_client(service).chat.completions.create(
|
|
103
|
+
model="gpt-x", messages=MESSAGES, conversation_id="c1"
|
|
104
|
+
)
|
|
105
|
+
agentcompile.flush(5)
|
|
106
|
+
assert len(service.batches) == 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_a_failing_service_never_breaks_the_call() -> None:
|
|
110
|
+
service = Service(capture_status=500)
|
|
111
|
+
client = _client(service, capture=True)
|
|
112
|
+
resp = client.chat.completions.create(
|
|
113
|
+
model="gpt-x", messages=MESSAGES, conversation_id="c1"
|
|
114
|
+
)
|
|
115
|
+
assert resp.choices[0].message.content == "from the model"
|
|
116
|
+
agentcompile.flush(5)
|
|
117
|
+
assert _capture._ALL[-1].failed == 1 and _capture._ALL[-1].sent == 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_a_full_queue_drops_the_oldest(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
121
|
+
monkeypatch.setattr(_capture, "MAX_QUEUE", 2)
|
|
122
|
+
settings = agentcompile.Settings("http://ac.test", "ack_acme.k", None, 1.0)
|
|
123
|
+
capturer = _capture.Capturer(
|
|
124
|
+
settings, httpx.Client(transport=httpx.MockTransport(Service()))
|
|
125
|
+
)
|
|
126
|
+
capturer._start = lambda: None # type: ignore[method-assign]
|
|
127
|
+
for n in range(3):
|
|
128
|
+
capturer.add("openai", f"c{n}", {"messages": []}, {"n": n})
|
|
129
|
+
assert capturer.dropped == 1
|
|
130
|
+
assert [r["conversation_id"] for r in capturer._queue] == ["c1", "c2"]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_a_streamed_answer_is_marked_not_captured() -> None:
|
|
134
|
+
settings = agentcompile.Settings("http://ac.test", "ack_acme.k", None, 1.0)
|
|
135
|
+
capturer = _capture.Capturer(
|
|
136
|
+
settings, httpx.Client(transport=httpx.MockTransport(Service()))
|
|
137
|
+
)
|
|
138
|
+
capturer._start = lambda: None # type: ignore[method-assign]
|
|
139
|
+
capturer.add("openai", "c1", {"messages": MESSAGES, "stream": True}, object(), stream=True)
|
|
140
|
+
[record] = list(capturer._queue)
|
|
141
|
+
assert record["response"] is None and record["stream"] is True
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_a_record_that_cannot_be_built_is_dropped_quietly() -> None:
|
|
145
|
+
settings = agentcompile.Settings("http://ac.test", "ack_acme.k", None, 1.0)
|
|
146
|
+
capturer = _capture.Capturer(
|
|
147
|
+
settings, httpx.Client(transport=httpx.MockTransport(Service()))
|
|
148
|
+
)
|
|
149
|
+
capturer.add("openai", "c1", None, None) # type: ignore[arg-type]
|
|
150
|
+
assert capturer.dropped == 1
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{".":"0.2.1"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|