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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ build/
6
+ dist/
7
+ .venv/
@@ -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"}