cognocient 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.
- cognocient-0.1.0/.gitignore +7 -0
- cognocient-0.1.0/LICENSE +21 -0
- cognocient-0.1.0/PKG-INFO +141 -0
- cognocient-0.1.0/README.md +123 -0
- cognocient-0.1.0/benchmark/benchmark_wrapper_overhead.py +114 -0
- cognocient-0.1.0/pyproject.toml +25 -0
- cognocient-0.1.0/src/cognocient/__init__.py +6 -0
- cognocient-0.1.0/src/cognocient/_reporter.py +142 -0
- cognocient-0.1.0/src/cognocient/_tags.py +32 -0
- cognocient-0.1.0/src/cognocient/anthropic_wrapper.py +89 -0
- cognocient-0.1.0/src/cognocient/openai_wrapper.py +124 -0
- cognocient-0.1.0/tests/test_reporter_failure_isolation.py +123 -0
- cognocient-0.1.0/tests/test_smoke_current_sdks.py +66 -0
cognocient-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cognocient
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cognocient
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop-in async-reporting wrapper for the OpenAI and Anthropic Python SDKs — live cost attribution without changing your base_url.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: httpx>=0.24
|
|
9
|
+
Provides-Extra: anthropic
|
|
10
|
+
Requires-Dist: anthropic>=0.25; extra == 'anthropic'
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: anthropic>=0.25; extra == 'dev'
|
|
13
|
+
Requires-Dist: openai>=1.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
15
|
+
Provides-Extra: openai
|
|
16
|
+
Requires-Dist: openai>=1.0; extra == 'openai'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# cognocient
|
|
20
|
+
|
|
21
|
+
A drop-in wrapper around the OpenAI and Anthropic Python SDKs that reports
|
|
22
|
+
usage to Cognocient asynchronously, so you get live cost attribution
|
|
23
|
+
without changing your `base_url` or routing traffic through a proxy.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install cognocient[openai] # or cognocient[anthropic], or both
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
# Before
|
|
31
|
+
from openai import OpenAI
|
|
32
|
+
client = OpenAI(api_key="sk-...")
|
|
33
|
+
|
|
34
|
+
# After
|
|
35
|
+
from cognocient import CognocientOpenAI as OpenAI
|
|
36
|
+
client = OpenAI(
|
|
37
|
+
api_key="sk-...", # your own real OpenAI key, used exactly as before
|
|
38
|
+
cognocient_key="sk-cog-...", # the same proxy key you'd use with the Cognocient proxy
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
client.chat.completions.create(
|
|
42
|
+
model="gpt-4o",
|
|
43
|
+
messages=[{"role": "user", "content": "hello"}],
|
|
44
|
+
cognocient_feature="support-bot", # optional attribution — same field names the proxy accepts as X-Cost-* headers
|
|
45
|
+
)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Every method the real SDK exposes still works unchanged. This wrapper only
|
|
49
|
+
intercepts `chat.completions.create()` (`messages.create()` for Anthropic)
|
|
50
|
+
to time the call and report its usage after the fact; everything else is
|
|
51
|
+
forwarded to the real client untouched.
|
|
52
|
+
|
|
53
|
+
## This is one of three ways to see your Cognocient dashboard
|
|
54
|
+
|
|
55
|
+
| | Live attribution | Pre-call enforcement (block/degrade) | Code change |
|
|
56
|
+
|---|---|---|---|
|
|
57
|
+
| **Proxy** (`base_url` swap) | Yes | Yes | One line |
|
|
58
|
+
| **This wrapper** | Yes | No — see below | Swap the import, add a key |
|
|
59
|
+
| **CSV/OTel import** | No (historical only) | No | None |
|
|
60
|
+
|
|
61
|
+
## Security — read this before you decide
|
|
62
|
+
|
|
63
|
+
**This wrapper is not more secure than the proxy. It is a different
|
|
64
|
+
tradeoff, not a strictly better one.**
|
|
65
|
+
|
|
66
|
+
With the proxy, your real provider API key lives server-side, under
|
|
67
|
+
Cognocient's control, in one place. With this wrapper, your real provider
|
|
68
|
+
key stays in your own application process, exactly as it does today
|
|
69
|
+
without Cognocient at all — the wrapper calls the provider directly,
|
|
70
|
+
using your key, inside your runtime. Some security teams prefer that
|
|
71
|
+
(no third-party network hop in the request path); others are less
|
|
72
|
+
comfortable with third-party code executing inside their process with
|
|
73
|
+
key access. Both are reasonable positions. We're not going to tell you
|
|
74
|
+
this "removes a security roadblock" — it trades one shape of exposure
|
|
75
|
+
for a different one.
|
|
76
|
+
|
|
77
|
+
What this wrapper honestly gives you over the proxy:
|
|
78
|
+
- **Zero added request latency.** Reporting happens after your real
|
|
79
|
+
call already returned, on a background thread, off the critical path.
|
|
80
|
+
- **Zero risk of a Cognocient outage affecting your production call.**
|
|
81
|
+
If Cognocient's ingestion API is down or unreachable, your call to
|
|
82
|
+
OpenAI/Anthropic still completes normally — see "Reliability" below.
|
|
83
|
+
|
|
84
|
+
What you give up versus the proxy: pre-call enforcement. Because
|
|
85
|
+
Cognocient only hears about a call after it already happened, budgets
|
|
86
|
+
configured in Cognocient cannot block or degrade a call made through
|
|
87
|
+
this wrapper before it fires. The dashboard will say so explicitly for
|
|
88
|
+
any account using this path.
|
|
89
|
+
|
|
90
|
+
## Reliability
|
|
91
|
+
|
|
92
|
+
Reporting is fire-and-forget on a background thread with a bounded local
|
|
93
|
+
queue, flushed every few seconds or every 50 calls, whichever comes
|
|
94
|
+
first. If the ingestion API is slow, down, or unreachable:
|
|
95
|
+
|
|
96
|
+
- Your real provider call is completely unaffected — it already happened
|
|
97
|
+
before reporting was attempted.
|
|
98
|
+
- No exception is ever raised into your code from a reporting failure.
|
|
99
|
+
- No retry loop that could pile up work in your process — a failed batch
|
|
100
|
+
is dropped and logged locally at `DEBUG` level via the `cognocient`
|
|
101
|
+
logger, not retried.
|
|
102
|
+
|
|
103
|
+
See `tests/test_reporter_failure_isolation.py` for a test that simulates
|
|
104
|
+
an unreachable ingestion endpoint and asserts the real call still
|
|
105
|
+
completes normally.
|
|
106
|
+
|
|
107
|
+
## Known limitation: streaming isn't reported yet
|
|
108
|
+
|
|
109
|
+
`stream=True` calls are passed through to the real SDK completely
|
|
110
|
+
unmodified — your application behaves identically — but are **not**
|
|
111
|
+
currently reported to Cognocient. Usage totals aren't available until a
|
|
112
|
+
stream completes, and reliably capturing them requires wrapping the
|
|
113
|
+
stream iterator itself, which this version doesn't do. If most of your
|
|
114
|
+
traffic streams, this wrapper will under-report your usage today. Use
|
|
115
|
+
the proxy or the CSV/OTel importer if that matters for your evaluation.
|
|
116
|
+
|
|
117
|
+
## Attribution fields
|
|
118
|
+
|
|
119
|
+
Same field names the proxy accepts as `X-Cost-*` headers, passed as
|
|
120
|
+
keyword arguments instead:
|
|
121
|
+
|
|
122
|
+
| Wrapper kwarg | Proxy header |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `cognocient_feature` | `X-Cost-Feature` |
|
|
125
|
+
| `cognocient_department` | `X-Cost-Department` |
|
|
126
|
+
| `cognocient_user` | `X-Cost-User` |
|
|
127
|
+
| `cognocient_session` | `X-Cost-Session` |
|
|
128
|
+
| `cognocient_tier` | `X-Cost-Tier` |
|
|
129
|
+
| `cognocient_project` | `X-Cost-Project` |
|
|
130
|
+
| `cognocient_gl_account` | `X-Cost-GL-Account` |
|
|
131
|
+
| `cognocient_workload` | `X-Cost-Workload` |
|
|
132
|
+
| `cognocient_outcome` | `X-Cost-Outcome` |
|
|
133
|
+
| `cognocient_run_id` | `X-Cost-Run-ID` |
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
pip install -e ".[dev]"
|
|
139
|
+
pytest
|
|
140
|
+
python benchmark/benchmark_wrapper_overhead.py
|
|
141
|
+
```
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# cognocient
|
|
2
|
+
|
|
3
|
+
A drop-in wrapper around the OpenAI and Anthropic Python SDKs that reports
|
|
4
|
+
usage to Cognocient asynchronously, so you get live cost attribution
|
|
5
|
+
without changing your `base_url` or routing traffic through a proxy.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install cognocient[openai] # or cognocient[anthropic], or both
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
# Before
|
|
13
|
+
from openai import OpenAI
|
|
14
|
+
client = OpenAI(api_key="sk-...")
|
|
15
|
+
|
|
16
|
+
# After
|
|
17
|
+
from cognocient import CognocientOpenAI as OpenAI
|
|
18
|
+
client = OpenAI(
|
|
19
|
+
api_key="sk-...", # your own real OpenAI key, used exactly as before
|
|
20
|
+
cognocient_key="sk-cog-...", # the same proxy key you'd use with the Cognocient proxy
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
client.chat.completions.create(
|
|
24
|
+
model="gpt-4o",
|
|
25
|
+
messages=[{"role": "user", "content": "hello"}],
|
|
26
|
+
cognocient_feature="support-bot", # optional attribution — same field names the proxy accepts as X-Cost-* headers
|
|
27
|
+
)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Every method the real SDK exposes still works unchanged. This wrapper only
|
|
31
|
+
intercepts `chat.completions.create()` (`messages.create()` for Anthropic)
|
|
32
|
+
to time the call and report its usage after the fact; everything else is
|
|
33
|
+
forwarded to the real client untouched.
|
|
34
|
+
|
|
35
|
+
## This is one of three ways to see your Cognocient dashboard
|
|
36
|
+
|
|
37
|
+
| | Live attribution | Pre-call enforcement (block/degrade) | Code change |
|
|
38
|
+
|---|---|---|---|
|
|
39
|
+
| **Proxy** (`base_url` swap) | Yes | Yes | One line |
|
|
40
|
+
| **This wrapper** | Yes | No — see below | Swap the import, add a key |
|
|
41
|
+
| **CSV/OTel import** | No (historical only) | No | None |
|
|
42
|
+
|
|
43
|
+
## Security — read this before you decide
|
|
44
|
+
|
|
45
|
+
**This wrapper is not more secure than the proxy. It is a different
|
|
46
|
+
tradeoff, not a strictly better one.**
|
|
47
|
+
|
|
48
|
+
With the proxy, your real provider API key lives server-side, under
|
|
49
|
+
Cognocient's control, in one place. With this wrapper, your real provider
|
|
50
|
+
key stays in your own application process, exactly as it does today
|
|
51
|
+
without Cognocient at all — the wrapper calls the provider directly,
|
|
52
|
+
using your key, inside your runtime. Some security teams prefer that
|
|
53
|
+
(no third-party network hop in the request path); others are less
|
|
54
|
+
comfortable with third-party code executing inside their process with
|
|
55
|
+
key access. Both are reasonable positions. We're not going to tell you
|
|
56
|
+
this "removes a security roadblock" — it trades one shape of exposure
|
|
57
|
+
for a different one.
|
|
58
|
+
|
|
59
|
+
What this wrapper honestly gives you over the proxy:
|
|
60
|
+
- **Zero added request latency.** Reporting happens after your real
|
|
61
|
+
call already returned, on a background thread, off the critical path.
|
|
62
|
+
- **Zero risk of a Cognocient outage affecting your production call.**
|
|
63
|
+
If Cognocient's ingestion API is down or unreachable, your call to
|
|
64
|
+
OpenAI/Anthropic still completes normally — see "Reliability" below.
|
|
65
|
+
|
|
66
|
+
What you give up versus the proxy: pre-call enforcement. Because
|
|
67
|
+
Cognocient only hears about a call after it already happened, budgets
|
|
68
|
+
configured in Cognocient cannot block or degrade a call made through
|
|
69
|
+
this wrapper before it fires. The dashboard will say so explicitly for
|
|
70
|
+
any account using this path.
|
|
71
|
+
|
|
72
|
+
## Reliability
|
|
73
|
+
|
|
74
|
+
Reporting is fire-and-forget on a background thread with a bounded local
|
|
75
|
+
queue, flushed every few seconds or every 50 calls, whichever comes
|
|
76
|
+
first. If the ingestion API is slow, down, or unreachable:
|
|
77
|
+
|
|
78
|
+
- Your real provider call is completely unaffected — it already happened
|
|
79
|
+
before reporting was attempted.
|
|
80
|
+
- No exception is ever raised into your code from a reporting failure.
|
|
81
|
+
- No retry loop that could pile up work in your process — a failed batch
|
|
82
|
+
is dropped and logged locally at `DEBUG` level via the `cognocient`
|
|
83
|
+
logger, not retried.
|
|
84
|
+
|
|
85
|
+
See `tests/test_reporter_failure_isolation.py` for a test that simulates
|
|
86
|
+
an unreachable ingestion endpoint and asserts the real call still
|
|
87
|
+
completes normally.
|
|
88
|
+
|
|
89
|
+
## Known limitation: streaming isn't reported yet
|
|
90
|
+
|
|
91
|
+
`stream=True` calls are passed through to the real SDK completely
|
|
92
|
+
unmodified — your application behaves identically — but are **not**
|
|
93
|
+
currently reported to Cognocient. Usage totals aren't available until a
|
|
94
|
+
stream completes, and reliably capturing them requires wrapping the
|
|
95
|
+
stream iterator itself, which this version doesn't do. If most of your
|
|
96
|
+
traffic streams, this wrapper will under-report your usage today. Use
|
|
97
|
+
the proxy or the CSV/OTel importer if that matters for your evaluation.
|
|
98
|
+
|
|
99
|
+
## Attribution fields
|
|
100
|
+
|
|
101
|
+
Same field names the proxy accepts as `X-Cost-*` headers, passed as
|
|
102
|
+
keyword arguments instead:
|
|
103
|
+
|
|
104
|
+
| Wrapper kwarg | Proxy header |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `cognocient_feature` | `X-Cost-Feature` |
|
|
107
|
+
| `cognocient_department` | `X-Cost-Department` |
|
|
108
|
+
| `cognocient_user` | `X-Cost-User` |
|
|
109
|
+
| `cognocient_session` | `X-Cost-Session` |
|
|
110
|
+
| `cognocient_tier` | `X-Cost-Tier` |
|
|
111
|
+
| `cognocient_project` | `X-Cost-Project` |
|
|
112
|
+
| `cognocient_gl_account` | `X-Cost-GL-Account` |
|
|
113
|
+
| `cognocient_workload` | `X-Cost-Workload` |
|
|
114
|
+
| `cognocient_outcome` | `X-Cost-Outcome` |
|
|
115
|
+
| `cognocient_run_id` | `X-Cost-Run-ID` |
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pip install -e ".[dev]"
|
|
121
|
+
pytest
|
|
122
|
+
python benchmark/benchmark_wrapper_overhead.py
|
|
123
|
+
```
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Real, measured latency benchmark for the cognocient Python wrapper's
|
|
3
|
+
call-path overhead.
|
|
4
|
+
|
|
5
|
+
Methodology: compares (a) a direct call through the real openai.OpenAI
|
|
6
|
+
client against (b) the identical call through CognocientOpenAI, both
|
|
7
|
+
hitting the SAME in-process httpx.MockTransport (so provider network
|
|
8
|
+
variance is excluded from both legs and cannot advantage either side).
|
|
9
|
+
This isolates exactly what the wrapper's own interception code — timing,
|
|
10
|
+
tag stripping, and queuing the report — costs on the customer's request
|
|
11
|
+
path. It does NOT include reporting delivery time, because reporting
|
|
12
|
+
runs on a background thread the request path never waits on; that's the
|
|
13
|
+
architectural claim this benchmark exists to check, not assume.
|
|
14
|
+
|
|
15
|
+
What's faked and why (disclosed here, not hidden):
|
|
16
|
+
- No real network call to OpenAI or to Cognocient's ingestion API —
|
|
17
|
+
both would introduce variance neither leg of this comparison is
|
|
18
|
+
trying to measure. Real network latency to either service is NOT
|
|
19
|
+
part of these numbers; only the wrapper's own added CPU work on the
|
|
20
|
+
request path is.
|
|
21
|
+
- The reporting queue is left completely unconsumed during the timed
|
|
22
|
+
loop (the background thread's flush interval is longer than the
|
|
23
|
+
whole benchmark run), so a measured cost that includes
|
|
24
|
+
queue.put_nowait() reflects the actual per-call cost, not a case
|
|
25
|
+
where a concurrently-draining queue happened to be cheap.
|
|
26
|
+
|
|
27
|
+
Run: python benchmark/benchmark_wrapper_overhead.py
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import statistics
|
|
31
|
+
import time
|
|
32
|
+
|
|
33
|
+
import httpx
|
|
34
|
+
import openai
|
|
35
|
+
|
|
36
|
+
from cognocient import CognocientOpenAI
|
|
37
|
+
|
|
38
|
+
ITERATIONS = 2000
|
|
39
|
+
WARMUP = 200
|
|
40
|
+
|
|
41
|
+
_CHAT_COMPLETION_JSON = {
|
|
42
|
+
"id": "chatcmpl-bench",
|
|
43
|
+
"object": "chat.completion",
|
|
44
|
+
"created": 1700000000,
|
|
45
|
+
"model": "gpt-4o-mini",
|
|
46
|
+
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
|
|
47
|
+
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _mock_transport():
|
|
52
|
+
def handler(request):
|
|
53
|
+
return httpx.Response(200, json=_CHAT_COMPLETION_JSON)
|
|
54
|
+
return httpx.MockTransport(handler)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _time_calls(create_fn, iterations):
|
|
58
|
+
samples = []
|
|
59
|
+
for _ in range(iterations):
|
|
60
|
+
start = time.perf_counter()
|
|
61
|
+
create_fn()
|
|
62
|
+
samples.append((time.perf_counter() - start) * 1000) # ms
|
|
63
|
+
return samples
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _pct(samples, p):
|
|
67
|
+
return statistics.quantiles(samples, n=100)[p - 1]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main():
|
|
71
|
+
real_client = openai.OpenAI(api_key="sk-bench-fake", http_client=httpx.Client(transport=_mock_transport()))
|
|
72
|
+
wrapped_client = CognocientOpenAI(
|
|
73
|
+
api_key="sk-bench-fake",
|
|
74
|
+
http_client=httpx.Client(transport=_mock_transport()),
|
|
75
|
+
cognocient_key="sk-cog-bench",
|
|
76
|
+
# Deliberately unreachable (nothing listens on loopback port 1) —
|
|
77
|
+
# if a future regression accidentally made the request path wait
|
|
78
|
+
# on delivery, that would show up here as a multi-second outlier,
|
|
79
|
+
# not a subtle few-ms difference easy to miss.
|
|
80
|
+
cognocient_ingest_url="http://127.0.0.1:1/api/ingest/wrapper",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def raw_call():
|
|
84
|
+
real_client.chat.completions.create(model="gpt-4o-mini", messages=[{"role": "user", "content": "hi"}])
|
|
85
|
+
|
|
86
|
+
def wrapped_call():
|
|
87
|
+
wrapped_client.chat.completions.create(
|
|
88
|
+
model="gpt-4o-mini",
|
|
89
|
+
messages=[{"role": "user", "content": "hi"}],
|
|
90
|
+
cognocient_feature="benchmark",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
_time_calls(raw_call, WARMUP)
|
|
94
|
+
_time_calls(wrapped_call, WARMUP)
|
|
95
|
+
|
|
96
|
+
raw_samples = _time_calls(raw_call, ITERATIONS)
|
|
97
|
+
wrapped_samples = _time_calls(wrapped_call, ITERATIONS)
|
|
98
|
+
|
|
99
|
+
print(f"Iterations per leg: {ITERATIONS}\n")
|
|
100
|
+
print(f"{'':20} {'p50 (ms)':>10} {'p95 (ms)':>10} {'p99 (ms)':>10} {'mean (ms)':>10}")
|
|
101
|
+
print(f"{'Raw SDK call':20} {_pct(raw_samples, 50):>10.4f} {_pct(raw_samples, 95):>10.4f} "
|
|
102
|
+
f"{_pct(raw_samples, 99):>10.4f} {statistics.mean(raw_samples):>10.4f}")
|
|
103
|
+
print(f"{'Wrapped call':20} {_pct(wrapped_samples, 50):>10.4f} {_pct(wrapped_samples, 95):>10.4f} "
|
|
104
|
+
f"{_pct(wrapped_samples, 99):>10.4f} {statistics.mean(wrapped_samples):>10.4f}")
|
|
105
|
+
|
|
106
|
+
added_p50 = _pct(wrapped_samples, 50) - _pct(raw_samples, 50)
|
|
107
|
+
added_mean = statistics.mean(wrapped_samples) - statistics.mean(raw_samples)
|
|
108
|
+
print(f"\nAdded overhead vs raw SDK — p50: {added_p50:.4f} ms, mean: {added_mean:.4f} ms")
|
|
109
|
+
print("\n(Real network latency to OpenAI/Anthropic and to Cognocient's ingestion API")
|
|
110
|
+
print(" is NOT included above — see this script's module docstring for why.)")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
main()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cognocient"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Drop-in async-reporting wrapper for the OpenAI and Anthropic Python SDKs — live cost attribution without changing your base_url."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.24",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
openai = ["openai>=1.0"]
|
|
18
|
+
anthropic = ["anthropic>=0.25"]
|
|
19
|
+
dev = ["pytest>=7.0", "openai>=1.0", "anthropic>=0.25"]
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/cognocient"]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from ._reporter import CallReport, Reporter, DEFAULT_INGEST_URL
|
|
2
|
+
from .openai_wrapper import CognocientOpenAI
|
|
3
|
+
from .anthropic_wrapper import CognocientAnthropic
|
|
4
|
+
|
|
5
|
+
__all__ = ["CognocientOpenAI", "CognocientAnthropic", "CallReport", "Reporter", "DEFAULT_INGEST_URL"]
|
|
6
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async batching reporter — fires call metadata to Cognocient's ingestion
|
|
3
|
+
API without ever blocking or delaying the customer's real provider call,
|
|
4
|
+
and without ever raising back into their code.
|
|
5
|
+
|
|
6
|
+
Runs its own background daemon thread with a plain stdlib queue.Queue,
|
|
7
|
+
independent of whether the customer's application uses asyncio at all.
|
|
8
|
+
Most of the value of this wrapper is for the plain synchronous
|
|
9
|
+
openai.OpenAI() / anthropic.Anthropic() clients shown in the README,
|
|
10
|
+
which have no event loop to hang an asyncio task off of — a thread-based
|
|
11
|
+
worker is the one design that reports reliably regardless of whether the
|
|
12
|
+
calling application is sync or async.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import atexit
|
|
16
|
+
import logging
|
|
17
|
+
import queue
|
|
18
|
+
import threading
|
|
19
|
+
from dataclasses import asdict, dataclass
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("cognocient")
|
|
25
|
+
|
|
26
|
+
DEFAULT_INGEST_URL = "https://api.cognocient.com/api/ingest/wrapper"
|
|
27
|
+
FLUSH_INTERVAL_SECONDS = 5.0
|
|
28
|
+
FLUSH_MAX_BATCH = 50
|
|
29
|
+
QUEUE_MAX_SIZE = 500
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class CallReport:
|
|
34
|
+
model: str
|
|
35
|
+
provider: str
|
|
36
|
+
prompt_tokens: int
|
|
37
|
+
completion_tokens: int
|
|
38
|
+
latency_ms: int
|
|
39
|
+
status_code: int = 200
|
|
40
|
+
cost_usd: Optional[float] = None
|
|
41
|
+
tag_feature: Optional[str] = None
|
|
42
|
+
tag_department: Optional[str] = None
|
|
43
|
+
tag_user: Optional[str] = None
|
|
44
|
+
tag_session: Optional[str] = None
|
|
45
|
+
tag_tier: Optional[str] = None
|
|
46
|
+
tag_project: Optional[str] = None
|
|
47
|
+
tag_gl_account: Optional[str] = None
|
|
48
|
+
tag_workload: Optional[str] = None
|
|
49
|
+
tag_outcome: Optional[str] = None
|
|
50
|
+
run_id: Optional[str] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Reporter:
|
|
54
|
+
"""
|
|
55
|
+
One instance per client. Every failure mode (network error, ingestion
|
|
56
|
+
API unreachable, local queue full) is caught and logged locally at
|
|
57
|
+
DEBUG level — never raised back into the caller. This is the actual
|
|
58
|
+
reliability guarantee of the wrapper; it has to hold here, not just be
|
|
59
|
+
claimed in the docs. See tests/test_reporter_failure_isolation.py.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
cognocient_key: str,
|
|
65
|
+
ingest_url: str = DEFAULT_INGEST_URL,
|
|
66
|
+
flush_interval: float = FLUSH_INTERVAL_SECONDS,
|
|
67
|
+
flush_max_batch: int = FLUSH_MAX_BATCH,
|
|
68
|
+
queue_max_size: int = QUEUE_MAX_SIZE,
|
|
69
|
+
http_timeout: float = 5.0,
|
|
70
|
+
):
|
|
71
|
+
self._cognocient_key = cognocient_key
|
|
72
|
+
self._ingest_url = ingest_url
|
|
73
|
+
self._flush_interval = flush_interval
|
|
74
|
+
self._flush_max_batch = flush_max_batch
|
|
75
|
+
self._queue: "queue.Queue[CallReport]" = queue.Queue(maxsize=queue_max_size)
|
|
76
|
+
self._client = httpx.Client(timeout=http_timeout)
|
|
77
|
+
self._dropped_count = 0
|
|
78
|
+
self._wake = threading.Event()
|
|
79
|
+
self._stop = False
|
|
80
|
+
self._thread = threading.Thread(target=self._run, name="cognocient-reporter", daemon=True)
|
|
81
|
+
self._thread.start()
|
|
82
|
+
atexit.register(self._shutdown)
|
|
83
|
+
|
|
84
|
+
def report(self, call: CallReport) -> None:
|
|
85
|
+
"""Non-blocking, never raises. Call this right after the real provider call returns."""
|
|
86
|
+
try:
|
|
87
|
+
self._queue.put_nowait(call)
|
|
88
|
+
if self._queue.qsize() >= self._flush_max_batch:
|
|
89
|
+
self._wake.set()
|
|
90
|
+
except queue.Full:
|
|
91
|
+
self._dropped_count += 1
|
|
92
|
+
logger.debug(
|
|
93
|
+
"cognocient: reporting queue full, dropped a report (%d dropped total)",
|
|
94
|
+
self._dropped_count,
|
|
95
|
+
)
|
|
96
|
+
except Exception:
|
|
97
|
+
logger.debug("cognocient: failed to queue a report", exc_info=True)
|
|
98
|
+
|
|
99
|
+
def flush(self) -> None:
|
|
100
|
+
"""Force an immediate flush — e.g. before a short-lived script exits."""
|
|
101
|
+
try:
|
|
102
|
+
self._flush()
|
|
103
|
+
except Exception:
|
|
104
|
+
logger.debug("cognocient: manual flush failed", exc_info=True)
|
|
105
|
+
|
|
106
|
+
def _run(self) -> None:
|
|
107
|
+
while not self._stop:
|
|
108
|
+
self._wake.wait(timeout=self._flush_interval)
|
|
109
|
+
self._wake.clear()
|
|
110
|
+
self._flush()
|
|
111
|
+
self._flush() # final drain on shutdown
|
|
112
|
+
|
|
113
|
+
def _flush(self) -> None:
|
|
114
|
+
batch = []
|
|
115
|
+
while len(batch) < self._flush_max_batch:
|
|
116
|
+
try:
|
|
117
|
+
batch.append(self._queue.get_nowait())
|
|
118
|
+
except queue.Empty:
|
|
119
|
+
break
|
|
120
|
+
if not batch:
|
|
121
|
+
return
|
|
122
|
+
try:
|
|
123
|
+
self._client.post(
|
|
124
|
+
self._ingest_url,
|
|
125
|
+
json={"reports": [asdict(c) for c in batch]},
|
|
126
|
+
headers={"Authorization": f"Bearer {self._cognocient_key}"},
|
|
127
|
+
)
|
|
128
|
+
except Exception:
|
|
129
|
+
# Network error, timeout, Cognocient down — never propagate.
|
|
130
|
+
# The batch is simply dropped; retrying would risk piling up
|
|
131
|
+
# unbounded background work in the customer's process for
|
|
132
|
+
# something that is not their application's job to guarantee.
|
|
133
|
+
logger.debug("cognocient: failed to report %d call(s)", len(batch), exc_info=True)
|
|
134
|
+
|
|
135
|
+
def _shutdown(self) -> None:
|
|
136
|
+
try:
|
|
137
|
+
self._stop = True
|
|
138
|
+
self._wake.set()
|
|
139
|
+
self._thread.join(timeout=2.0)
|
|
140
|
+
self._client.close()
|
|
141
|
+
except Exception:
|
|
142
|
+
logger.debug("cognocient: error during reporter shutdown", exc_info=True)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Attribution kwargs, matching the exact tag names the Cognocient proxy
|
|
3
|
+
already accepts as X-Cost-* headers (backend/app/proxy.py tag_header_map)
|
|
4
|
+
so a customer moving between the wrapper and the proxy uses the same
|
|
5
|
+
tagging model, not two different ones to learn.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
TAG_KWARGS = (
|
|
9
|
+
"cognocient_feature",
|
|
10
|
+
"cognocient_department",
|
|
11
|
+
"cognocient_user",
|
|
12
|
+
"cognocient_session",
|
|
13
|
+
"cognocient_tier",
|
|
14
|
+
"cognocient_project",
|
|
15
|
+
"cognocient_gl_account",
|
|
16
|
+
"cognocient_workload",
|
|
17
|
+
"cognocient_outcome",
|
|
18
|
+
"cognocient_run_id",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def pop_tags(kwargs: dict) -> dict:
|
|
23
|
+
"""Strips cognocient_* kwargs out of a call's kwargs and returns them
|
|
24
|
+
mapped to CallReport field names, so the real SDK call underneath never
|
|
25
|
+
sees (and never rejects) an argument it doesn't recognize."""
|
|
26
|
+
tags = {}
|
|
27
|
+
for key in TAG_KWARGS:
|
|
28
|
+
if key in kwargs:
|
|
29
|
+
value = kwargs.pop(key)
|
|
30
|
+
name = key[len("cognocient_"):]
|
|
31
|
+
tags["run_id" if name == "run_id" else f"tag_{name}"] = value
|
|
32
|
+
return tags
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Drop-in wrapper around anthropic.Anthropic that reports every completed
|
|
3
|
+
messages.create() call to Cognocient asynchronously, without ever
|
|
4
|
+
blocking or delaying the real call to Anthropic, and without ever raising
|
|
5
|
+
a reporting failure back into the caller.
|
|
6
|
+
|
|
7
|
+
See openai_wrapper.py's module docstring for the security framing (same
|
|
8
|
+
tradeoffs apply here — this is not "more secure" than the proxy) and the
|
|
9
|
+
streaming limitation (also identical: non-streaming only in this version).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ._reporter import DEFAULT_INGEST_URL, CallReport, Reporter
|
|
16
|
+
from ._tags import pop_tags
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _ReportingMessages:
|
|
20
|
+
def __init__(self, real_messages: Any, reporter: Reporter, provider: str):
|
|
21
|
+
self._real = real_messages
|
|
22
|
+
self._reporter = reporter
|
|
23
|
+
self._provider = provider
|
|
24
|
+
|
|
25
|
+
def create(self, *args, **kwargs):
|
|
26
|
+
tags = pop_tags(kwargs)
|
|
27
|
+
is_streaming = bool(kwargs.get("stream"))
|
|
28
|
+
|
|
29
|
+
start = time.monotonic()
|
|
30
|
+
response = self._real.create(*args, **kwargs)
|
|
31
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
32
|
+
|
|
33
|
+
if is_streaming:
|
|
34
|
+
return response # not reported in this version — see module docstring
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
usage = getattr(response, "usage", None)
|
|
38
|
+
model = getattr(response, "model", None) or kwargs.get("model", "unknown")
|
|
39
|
+
# Anthropic's usage field names differ from OpenAI's — input/output, not prompt/completion.
|
|
40
|
+
prompt_tokens = getattr(usage, "input_tokens", 0) if usage else 0
|
|
41
|
+
completion_tokens = getattr(usage, "output_tokens", 0) if usage else 0
|
|
42
|
+
self._reporter.report(CallReport(
|
|
43
|
+
model=model,
|
|
44
|
+
provider=self._provider,
|
|
45
|
+
prompt_tokens=prompt_tokens,
|
|
46
|
+
completion_tokens=completion_tokens,
|
|
47
|
+
latency_ms=latency_ms,
|
|
48
|
+
status_code=200,
|
|
49
|
+
**tags,
|
|
50
|
+
))
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
return response
|
|
55
|
+
|
|
56
|
+
def __getattr__(self, name):
|
|
57
|
+
return getattr(self._real, name)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CognocientAnthropic:
|
|
61
|
+
"""
|
|
62
|
+
Drop-in replacement import for anthropic.Anthropic:
|
|
63
|
+
|
|
64
|
+
from cognocient import CognocientAnthropic as Anthropic
|
|
65
|
+
client = Anthropic(
|
|
66
|
+
api_key="sk-ant-...", # your own real Anthropic key
|
|
67
|
+
cognocient_key="sk-cog-...", # your Cognocient proxy key, reused here
|
|
68
|
+
)
|
|
69
|
+
client.messages.create(
|
|
70
|
+
model="claude-sonnet-4-6", max_tokens=1024, messages=[...],
|
|
71
|
+
cognocient_feature="support-bot",
|
|
72
|
+
)
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, *args, cognocient_key: str, cognocient_ingest_url: str = DEFAULT_INGEST_URL, **kwargs):
|
|
76
|
+
try:
|
|
77
|
+
import anthropic
|
|
78
|
+
except ImportError as e:
|
|
79
|
+
raise ImportError(
|
|
80
|
+
"cognocient.CognocientAnthropic requires the 'anthropic' package. "
|
|
81
|
+
"Install it with: pip install cognocient[anthropic]"
|
|
82
|
+
) from e
|
|
83
|
+
|
|
84
|
+
self._real = anthropic.Anthropic(*args, **kwargs)
|
|
85
|
+
self._reporter = Reporter(cognocient_key, ingest_url=cognocient_ingest_url)
|
|
86
|
+
self.messages = _ReportingMessages(self._real.messages, self._reporter, "anthropic")
|
|
87
|
+
|
|
88
|
+
def __getattr__(self, name):
|
|
89
|
+
return getattr(self._real, name)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Drop-in wrapper around openai.OpenAI that reports every completed
|
|
3
|
+
chat.completions.create() call to Cognocient asynchronously, without ever
|
|
4
|
+
blocking or delaying the real call to OpenAI, and without ever raising a
|
|
5
|
+
reporting failure back into the caller.
|
|
6
|
+
|
|
7
|
+
SECURITY NOTE (read before writing any customer-facing copy referencing
|
|
8
|
+
this file): this wrapper runs INSIDE your application process and holds
|
|
9
|
+
your real OpenAI API key to make the call directly. That is a DIFFERENT
|
|
10
|
+
security exposure than the Cognocient proxy (where the key lives
|
|
11
|
+
server-side, under Cognocient's control, in one place) — not a strictly
|
|
12
|
+
lesser one. This wrapper is NOT "more secure" than the proxy. Its honest,
|
|
13
|
+
defensible benefits are zero added request latency and zero risk of a
|
|
14
|
+
Cognocient outage affecting your production call. See ../README.md.
|
|
15
|
+
|
|
16
|
+
LIMITATION: usage reporting only covers non-streaming calls in this
|
|
17
|
+
version. Streaming calls (stream=True) are passed through to the real SDK
|
|
18
|
+
completely unmodified — your application behaves identically — but are
|
|
19
|
+
not currently reported to Cognocient, since usage totals aren't available
|
|
20
|
+
until a stream completes, and reliably capturing them requires wrapping
|
|
21
|
+
the stream iterator itself, which this version doesn't do. If most of
|
|
22
|
+
your traffic streams, this wrapper will under-report your usage today.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import time
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ._reporter import DEFAULT_INGEST_URL, CallReport, Reporter
|
|
29
|
+
from ._tags import pop_tags
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _ReportingCompletions:
|
|
33
|
+
"""Wraps client.chat.completions — every attribute except create()
|
|
34
|
+
is forwarded untouched to the real SDK object."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, real_completions: Any, reporter: Reporter, provider: str):
|
|
37
|
+
self._real = real_completions
|
|
38
|
+
self._reporter = reporter
|
|
39
|
+
self._provider = provider
|
|
40
|
+
|
|
41
|
+
def create(self, *args, **kwargs):
|
|
42
|
+
tags = pop_tags(kwargs)
|
|
43
|
+
is_streaming = bool(kwargs.get("stream"))
|
|
44
|
+
|
|
45
|
+
start = time.monotonic()
|
|
46
|
+
# The real call always happens first, and its result/exception is
|
|
47
|
+
# returned to the caller exactly as the real SDK would — nothing
|
|
48
|
+
# about reporting can change this line's outcome.
|
|
49
|
+
response = self._real.create(*args, **kwargs)
|
|
50
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
51
|
+
|
|
52
|
+
if is_streaming:
|
|
53
|
+
return response # not reported in this version — see module docstring
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
usage = getattr(response, "usage", None)
|
|
57
|
+
model = getattr(response, "model", None) or kwargs.get("model", "unknown")
|
|
58
|
+
prompt_tokens = getattr(usage, "prompt_tokens", 0) if usage else 0
|
|
59
|
+
completion_tokens = getattr(usage, "completion_tokens", 0) if usage else 0
|
|
60
|
+
self._reporter.report(CallReport(
|
|
61
|
+
model=model,
|
|
62
|
+
provider=self._provider,
|
|
63
|
+
prompt_tokens=prompt_tokens,
|
|
64
|
+
completion_tokens=completion_tokens,
|
|
65
|
+
latency_ms=latency_ms,
|
|
66
|
+
status_code=200,
|
|
67
|
+
**tags,
|
|
68
|
+
))
|
|
69
|
+
except Exception:
|
|
70
|
+
# A bug in this wrapper's own bookkeeping must never surface
|
|
71
|
+
# to the caller — the real response above has already
|
|
72
|
+
# returned successfully by this point.
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
return response
|
|
76
|
+
|
|
77
|
+
def __getattr__(self, name):
|
|
78
|
+
return getattr(self._real, name)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _ReportingChat:
|
|
82
|
+
def __init__(self, real_chat: Any, reporter: Reporter, provider: str):
|
|
83
|
+
self._real = real_chat
|
|
84
|
+
self.completions = _ReportingCompletions(real_chat.completions, reporter, provider)
|
|
85
|
+
|
|
86
|
+
def __getattr__(self, name):
|
|
87
|
+
return getattr(self._real, name)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CognocientOpenAI:
|
|
91
|
+
"""
|
|
92
|
+
Drop-in replacement import for openai.OpenAI:
|
|
93
|
+
|
|
94
|
+
from cognocient import CognocientOpenAI as OpenAI
|
|
95
|
+
client = OpenAI(
|
|
96
|
+
api_key="sk-...", # your own real OpenAI key
|
|
97
|
+
cognocient_key="sk-cog-...", # your Cognocient proxy key, reused here
|
|
98
|
+
)
|
|
99
|
+
client.chat.completions.create(
|
|
100
|
+
model="gpt-4o", messages=[...],
|
|
101
|
+
cognocient_feature="support-bot", # optional attribution, same field names as the proxy's X-Cost-* headers
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
Every method the real OpenAI client exposes still works unchanged —
|
|
105
|
+
this class only intercepts chat.completions.create() to report usage
|
|
106
|
+
after the fact; everything else is forwarded to the real client
|
|
107
|
+
untouched via attribute delegation.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, *args, cognocient_key: str, cognocient_ingest_url: str = DEFAULT_INGEST_URL, **kwargs):
|
|
111
|
+
try:
|
|
112
|
+
import openai
|
|
113
|
+
except ImportError as e:
|
|
114
|
+
raise ImportError(
|
|
115
|
+
"cognocient.CognocientOpenAI requires the 'openai' package. "
|
|
116
|
+
"Install it with: pip install cognocient[openai]"
|
|
117
|
+
) from e
|
|
118
|
+
|
|
119
|
+
self._real = openai.OpenAI(*args, **kwargs)
|
|
120
|
+
self._reporter = Reporter(cognocient_key, ingest_url=cognocient_ingest_url)
|
|
121
|
+
self.chat = _ReportingChat(self._real.chat, self._reporter, "openai")
|
|
122
|
+
|
|
123
|
+
def __getattr__(self, name):
|
|
124
|
+
return getattr(self._real, name)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Proves the core reliability promise: if Cognocient's ingestion API is
|
|
3
|
+
unreachable, the customer's real provider call still completes normally
|
|
4
|
+
and no exception ever surfaces from the reporting path. Uses httpx.MockTransport
|
|
5
|
+
to fake the real OpenAI/Anthropic HTTP response (so this doesn't require
|
|
6
|
+
real API keys or network access to the providers), while pointing the
|
|
7
|
+
reporter at a real, guaranteed-unreachable address to force an actual
|
|
8
|
+
connection failure, not a mocked one.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from cognocient import CognocientAnthropic, CognocientOpenAI
|
|
17
|
+
|
|
18
|
+
# Port 1 on loopback: nothing listens there, connection is refused immediately
|
|
19
|
+
# and reliably, in every environment (unlike a DNS-based "unreachable" host,
|
|
20
|
+
# which can vary by network config / CI sandboxing).
|
|
21
|
+
UNREACHABLE_INGEST_URL = "http://127.0.0.1:1/api/ingest/wrapper"
|
|
22
|
+
|
|
23
|
+
_OPENAI_CHAT_COMPLETION_JSON = {
|
|
24
|
+
"id": "chatcmpl-test123",
|
|
25
|
+
"object": "chat.completion",
|
|
26
|
+
"created": 1700000000,
|
|
27
|
+
"model": "gpt-4o-mini",
|
|
28
|
+
"choices": [{
|
|
29
|
+
"index": 0,
|
|
30
|
+
"message": {"role": "assistant", "content": "hello from the real provider"},
|
|
31
|
+
"finish_reason": "stop",
|
|
32
|
+
}],
|
|
33
|
+
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_ANTHROPIC_MESSAGE_JSON = {
|
|
37
|
+
"id": "msg_test123",
|
|
38
|
+
"type": "message",
|
|
39
|
+
"role": "assistant",
|
|
40
|
+
"model": "claude-sonnet-4-6",
|
|
41
|
+
"content": [{"type": "text", "text": "hello from the real provider"}],
|
|
42
|
+
"stop_reason": "end_turn",
|
|
43
|
+
"usage": {"input_tokens": 12, "output_tokens": 6},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _mock_transport(json_body):
|
|
48
|
+
def handler(request):
|
|
49
|
+
return httpx.Response(200, json=json_body)
|
|
50
|
+
return httpx.MockTransport(handler)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_openai_call_completes_and_raises_nothing_when_ingest_unreachable(caplog):
|
|
54
|
+
mock_http_client = httpx.Client(transport=_mock_transport(_OPENAI_CHAT_COMPLETION_JSON))
|
|
55
|
+
client = CognocientOpenAI(
|
|
56
|
+
api_key="sk-test-fake",
|
|
57
|
+
http_client=mock_http_client,
|
|
58
|
+
cognocient_key="sk-cog-test",
|
|
59
|
+
cognocient_ingest_url=UNREACHABLE_INGEST_URL,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
response = client.chat.completions.create(
|
|
63
|
+
model="gpt-4o-mini",
|
|
64
|
+
messages=[{"role": "user", "content": "hi"}],
|
|
65
|
+
cognocient_feature="test-suite",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# The real call succeeded and its real content is unaffected by reporting.
|
|
69
|
+
assert response.choices[0].message.content == "hello from the real provider"
|
|
70
|
+
assert response.usage.prompt_tokens == 10
|
|
71
|
+
|
|
72
|
+
# Force the reporter to actually attempt (and fail) delivery right now,
|
|
73
|
+
# synchronously, rather than relying on the background timer — proves
|
|
74
|
+
# the failure is caught, not merely "not yet attempted".
|
|
75
|
+
with caplog.at_level(logging.DEBUG, logger="cognocient"):
|
|
76
|
+
client._reporter.flush() # must not raise
|
|
77
|
+
assert any("failed to report" in r.message for r in caplog.records)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_anthropic_call_completes_and_raises_nothing_when_ingest_unreachable(caplog):
|
|
81
|
+
mock_http_client = httpx.Client(transport=_mock_transport(_ANTHROPIC_MESSAGE_JSON))
|
|
82
|
+
client = CognocientAnthropic(
|
|
83
|
+
api_key="sk-ant-test-fake",
|
|
84
|
+
http_client=mock_http_client,
|
|
85
|
+
cognocient_key="sk-cog-test",
|
|
86
|
+
cognocient_ingest_url=UNREACHABLE_INGEST_URL,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
response = client.messages.create(
|
|
90
|
+
model="claude-sonnet-4-6",
|
|
91
|
+
max_tokens=1024,
|
|
92
|
+
messages=[{"role": "user", "content": "hi"}],
|
|
93
|
+
cognocient_feature="test-suite",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
assert response.content[0].text == "hello from the real provider"
|
|
97
|
+
assert response.usage.input_tokens == 12
|
|
98
|
+
|
|
99
|
+
with caplog.at_level(logging.DEBUG, logger="cognocient"):
|
|
100
|
+
client._reporter.flush()
|
|
101
|
+
assert any("failed to report" in r.message for r in caplog.records)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_report_queue_full_drops_silently_without_raising():
|
|
105
|
+
reporter_client = CognocientOpenAI(
|
|
106
|
+
api_key="sk-test-fake",
|
|
107
|
+
http_client=httpx.Client(transport=_mock_transport(_OPENAI_CHAT_COMPLETION_JSON)),
|
|
108
|
+
cognocient_key="sk-cog-test",
|
|
109
|
+
cognocient_ingest_url=UNREACHABLE_INGEST_URL,
|
|
110
|
+
)
|
|
111
|
+
reporter = reporter_client._reporter
|
|
112
|
+
# Stop the background thread so the queue can't drain on its own, then
|
|
113
|
+
# fill it past capacity to force queue.Full on the caller's own thread.
|
|
114
|
+
reporter._stop = True
|
|
115
|
+
reporter._wake.set()
|
|
116
|
+
reporter._thread.join(timeout=2.0)
|
|
117
|
+
|
|
118
|
+
from cognocient import CallReport
|
|
119
|
+
for _ in range(reporter._queue.maxsize + 10):
|
|
120
|
+
reporter.report(CallReport(
|
|
121
|
+
model="gpt-4o-mini", provider="openai",
|
|
122
|
+
prompt_tokens=1, completion_tokens=1, latency_ms=1,
|
|
123
|
+
)) # must never raise, even once the queue is full
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Basic interface smoke test against whatever OpenAI/Anthropic SDK versions
|
|
3
|
+
are actually installed (pip install cognocient[dev] pulls the current
|
|
4
|
+
releases, unpinned) — catches a breaking upstream interface change before
|
|
5
|
+
a customer hits it in production, not after. Run in CI on every push via
|
|
6
|
+
.github/workflows/python_wrapper_smoke_test.yml.
|
|
7
|
+
|
|
8
|
+
Deliberately does NOT hit real provider APIs — only checks that the real
|
|
9
|
+
SDK objects still expose the attributes this wrapper's delegation depends
|
|
10
|
+
on (client.chat.completions.create, client.messages.create, response.usage
|
|
11
|
+
field names), which is exactly the kind of change that would silently
|
|
12
|
+
break this wrapper without raising an ImportError.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import inspect
|
|
16
|
+
|
|
17
|
+
import anthropic
|
|
18
|
+
import openai
|
|
19
|
+
|
|
20
|
+
from cognocient import CognocientAnthropic, CognocientOpenAI
|
|
21
|
+
from cognocient._tags import TAG_KWARGS, pop_tags
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_openai_sdk_still_exposes_expected_interface():
|
|
25
|
+
real_client = openai.OpenAI(api_key="sk-test-fake")
|
|
26
|
+
assert hasattr(real_client, "chat")
|
|
27
|
+
assert hasattr(real_client.chat, "completions")
|
|
28
|
+
assert callable(real_client.chat.completions.create)
|
|
29
|
+
|
|
30
|
+
sig = inspect.signature(real_client.chat.completions.create)
|
|
31
|
+
assert "model" in sig.parameters
|
|
32
|
+
assert "messages" in sig.parameters
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_anthropic_sdk_still_exposes_expected_interface():
|
|
36
|
+
real_client = anthropic.Anthropic(api_key="sk-ant-test-fake")
|
|
37
|
+
assert hasattr(real_client, "messages")
|
|
38
|
+
assert callable(real_client.messages.create)
|
|
39
|
+
|
|
40
|
+
sig = inspect.signature(real_client.messages.create)
|
|
41
|
+
assert "model" in sig.parameters
|
|
42
|
+
assert "messages" in sig.parameters
|
|
43
|
+
assert "max_tokens" in sig.parameters
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_cognocient_openai_delegates_unknown_attributes_to_real_client():
|
|
47
|
+
client = CognocientOpenAI(api_key="sk-test-fake", cognocient_key="sk-cog-test")
|
|
48
|
+
# models, embeddings, etc. are not specially wrapped — must still resolve
|
|
49
|
+
# via __getattr__ delegation to the real underlying client.
|
|
50
|
+
assert client.models is client._real.models
|
|
51
|
+
assert client.embeddings is client._real.embeddings
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_cognocient_anthropic_delegates_unknown_attributes_to_real_client():
|
|
55
|
+
client = CognocientAnthropic(api_key="sk-ant-test-fake", cognocient_key="sk-cog-test")
|
|
56
|
+
assert client.completions is client._real.completions
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_tag_kwargs_never_reach_the_real_sdk_call():
|
|
60
|
+
kwargs = {"model": "gpt-4o", "cognocient_feature": "x", "cognocient_run_id": "run_1"}
|
|
61
|
+
tags = pop_tags(kwargs)
|
|
62
|
+
assert tags == {"tag_feature": "x", "run_id": "run_1"}
|
|
63
|
+
# Every cognocient_* kwarg must be stripped before the real SDK sees kwargs,
|
|
64
|
+
# since neither real SDK accepts them and would raise a TypeError.
|
|
65
|
+
assert not any(k in kwargs for k in TAG_KWARGS)
|
|
66
|
+
assert kwargs == {"model": "gpt-4o"}
|