cognocient 0.1.0__py3-none-any.whl
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/__init__.py +6 -0
- cognocient/_reporter.py +142 -0
- cognocient/_tags.py +32 -0
- cognocient/anthropic_wrapper.py +89 -0
- cognocient/openai_wrapper.py +124 -0
- cognocient-0.1.0.dist-info/METADATA +141 -0
- cognocient-0.1.0.dist-info/RECORD +9 -0
- cognocient-0.1.0.dist-info/WHEEL +4 -0
- cognocient-0.1.0.dist-info/licenses/LICENSE +21 -0
cognocient/__init__.py
ADDED
|
@@ -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"
|
cognocient/_reporter.py
ADDED
|
@@ -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)
|
cognocient/_tags.py
ADDED
|
@@ -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,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,9 @@
|
|
|
1
|
+
cognocient/__init__.py,sha256=wiWFp6g7jGFrR6AdS0R2LtpAAvSBs9CQmktaXyJ3RFE,285
|
|
2
|
+
cognocient/_reporter.py,sha256=mdcNNj7AOKKVb7aq-Uk20P8DO5D_Ra31Y-pUu_pone4,5160
|
|
3
|
+
cognocient/_tags.py,sha256=TJla6jqcBZZi2q_CuNtb6F4Goy6NPdtH1LvF5u_7qqU,1038
|
|
4
|
+
cognocient/anthropic_wrapper.py,sha256=cETjM20wJf-Q6Q0FMopCl-ksA_ILFWFSq6zTj1N7nLo,3316
|
|
5
|
+
cognocient/openai_wrapper.py,sha256=rOZmN3eDIGcGp80MDQfkUEWrbgry5jopNvo6tB3Y2WY,5150
|
|
6
|
+
cognocient-0.1.0.dist-info/METADATA,sha256=oRLapIa4hC8FpsWlhe6639gXscR-zuXAO9tOXTOQ7jo,5609
|
|
7
|
+
cognocient-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
cognocient-0.1.0.dist-info/licenses/LICENSE,sha256=qlYye1b2g2NDMrkTcarAdqVSNzUB31iShDBWUH24RWw,1067
|
|
9
|
+
cognocient-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|