ai-token-tracker 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.
- ai_token_tracker/__init__.py +33 -0
- ai_token_tracker/classifier.py +105 -0
- ai_token_tracker/constants.py +32 -0
- ai_token_tracker/envelope.py +67 -0
- ai_token_tracker/ingestion_client.py +146 -0
- ai_token_tracker/interception.py +524 -0
- ai_token_tracker/interception_scope.py +53 -0
- ai_token_tracker/options.py +43 -0
- ai_token_tracker/py.typed +0 -0
- ai_token_tracker/runtime.py +13 -0
- ai_token_tracker/sdk_client.py +160 -0
- ai_token_tracker/types.py +89 -0
- ai_token_tracker-0.1.0.dist-info/METADATA +300 -0
- ai_token_tracker-0.1.0.dist-info/RECORD +16 -0
- ai_token_tracker-0.1.0.dist-info/WHEEL +5 -0
- ai_token_tracker-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Ai Token Tracker Python SDK."""
|
|
2
|
+
|
|
3
|
+
from .classifier import DefaultLlmRequestClassifier
|
|
4
|
+
from .envelope import map_tracked_call_to_envelope
|
|
5
|
+
from .ingestion_client import AiTokenTrackerIngestionClient
|
|
6
|
+
from .interception_scope import AiTokenTrackerInterceptionScope
|
|
7
|
+
from .runtime import install_http_interception, uninstall_http_interception
|
|
8
|
+
from .sdk_client import AiTokenTrackerSdkClient, CallScope
|
|
9
|
+
from .types import (
|
|
10
|
+
AiTokenTrackerOptions,
|
|
11
|
+
IngestionEnvelope,
|
|
12
|
+
LlmRequestClassificationInput,
|
|
13
|
+
LlmRequestClassifier,
|
|
14
|
+
TrackResult,
|
|
15
|
+
TrackedLlmCall,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AiTokenTrackerIngestionClient",
|
|
20
|
+
"AiTokenTrackerOptions",
|
|
21
|
+
"AiTokenTrackerSdkClient",
|
|
22
|
+
"AiTokenTrackerInterceptionScope",
|
|
23
|
+
"CallScope",
|
|
24
|
+
"DefaultLlmRequestClassifier",
|
|
25
|
+
"IngestionEnvelope",
|
|
26
|
+
"LlmRequestClassificationInput",
|
|
27
|
+
"LlmRequestClassifier",
|
|
28
|
+
"TrackResult",
|
|
29
|
+
"TrackedLlmCall",
|
|
30
|
+
"install_http_interception",
|
|
31
|
+
"map_tracked_call_to_envelope",
|
|
32
|
+
"uninstall_http_interception",
|
|
33
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Request classification heuristics for LLM provider traffic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from .constants import (
|
|
8
|
+
PROVIDER_ANTHROPIC,
|
|
9
|
+
PROVIDER_GEMINI,
|
|
10
|
+
PROVIDER_OPENAI,
|
|
11
|
+
PROVIDER_UNIFY,
|
|
12
|
+
PROVIDER_UNKNOWN,
|
|
13
|
+
)
|
|
14
|
+
from .types import HeaderMap, LlmRequestClassificationInput, LlmRequestClassifier
|
|
15
|
+
|
|
16
|
+
ANTHROPIC_HOST_INDICATORS = ("anthropic.com",)
|
|
17
|
+
OPENAI_HOST_INDICATORS = ("openai.com", "openai.azure.com")
|
|
18
|
+
GEMINI_HOST_INDICATORS = ("generativelanguage.googleapis.com", "aiplatform.googleapis.com")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DefaultLlmRequestClassifier(LlmRequestClassifier):
|
|
22
|
+
def try_classify(self, input_value: LlmRequestClassificationInput) -> str | None:
|
|
23
|
+
host = _parse_host(input_value.url)
|
|
24
|
+
if host is None:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
if _contains_any(host, ANTHROPIC_HOST_INDICATORS) or _has_header(input_value.headers, "anthropic-version"):
|
|
28
|
+
return PROVIDER_ANTHROPIC
|
|
29
|
+
|
|
30
|
+
if _contains_any(host, OPENAI_HOST_INDICATORS) or (
|
|
31
|
+
_has_authorization_bearer(input_value.headers) and _host_contains(host, "openai")
|
|
32
|
+
):
|
|
33
|
+
return PROVIDER_OPENAI
|
|
34
|
+
|
|
35
|
+
if _contains_any(host, GEMINI_HOST_INDICATORS) or _has_header(input_value.headers, "x-goog-api-key"):
|
|
36
|
+
return PROVIDER_GEMINI
|
|
37
|
+
|
|
38
|
+
if _host_contains(host, "unify"):
|
|
39
|
+
return PROVIDER_UNIFY
|
|
40
|
+
|
|
41
|
+
if _looks_like_llm_path(input_value.url):
|
|
42
|
+
return PROVIDER_UNKNOWN
|
|
43
|
+
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_host(url: str | None) -> str | None:
|
|
48
|
+
if not url:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
parsed = urlparse(url)
|
|
53
|
+
except ValueError:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
return parsed.hostname
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _contains_any(value: str, indicators: tuple[str, ...]) -> bool:
|
|
60
|
+
return any(_host_contains(value, indicator) for indicator in indicators)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _host_contains(host: str, substring: str) -> bool:
|
|
64
|
+
return substring.lower() in host.lower()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _has_header(headers: HeaderMap, header_name: str) -> bool:
|
|
68
|
+
target = header_name.lower()
|
|
69
|
+
return any(key.lower() == target for key in headers.keys())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _has_authorization_bearer(headers: HeaderMap) -> bool:
|
|
73
|
+
first_auth = _get_first_header_value(headers, "authorization")
|
|
74
|
+
if first_auth is None:
|
|
75
|
+
return False
|
|
76
|
+
return first_auth.lower().startswith("bearer ")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _get_first_header_value(headers: HeaderMap, header_name: str) -> str | None:
|
|
80
|
+
target = header_name.lower()
|
|
81
|
+
for key, values in headers.items():
|
|
82
|
+
if key.lower() != target:
|
|
83
|
+
continue
|
|
84
|
+
if len(values) == 0:
|
|
85
|
+
continue
|
|
86
|
+
return values[0]
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _looks_like_llm_path(url: str | None) -> bool:
|
|
91
|
+
if not url:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
path = urlparse(url).path.lower()
|
|
96
|
+
except ValueError:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
"/chat/completions" in path
|
|
101
|
+
or "/completions" in path
|
|
102
|
+
or "/responses" in path
|
|
103
|
+
or "/messages" in path
|
|
104
|
+
or ":generatecontent" in path
|
|
105
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Internal constants for Ai Token Tracker SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
DEFAULT_API_BASE_URL = "http://localhost:8082"
|
|
6
|
+
INTERNAL_AUTH_HEADER_NAME = "X-API-Key"
|
|
7
|
+
INTERNAL_INGEST_PATH = "/ingest"
|
|
8
|
+
DEFAULT_ENABLE_AUTO_INTERCEPTION = True
|
|
9
|
+
DEFAULT_ENABLE_DIAGNOSTICS_FALLBACK = True
|
|
10
|
+
|
|
11
|
+
HEADER_CONTENT_TYPE = "content-type"
|
|
12
|
+
JSON_CONTENT_TYPE = "application/json"
|
|
13
|
+
|
|
14
|
+
PROVIDER_ANTHROPIC = "anthropic"
|
|
15
|
+
PROVIDER_OPENAI = "openai"
|
|
16
|
+
PROVIDER_GEMINI = "gemini"
|
|
17
|
+
PROVIDER_UNIFY = "unify"
|
|
18
|
+
PROVIDER_UNKNOWN = "unknown"
|
|
19
|
+
|
|
20
|
+
DEFAULT_SUCCESS_STATUS_CODE = 200
|
|
21
|
+
DEFAULT_FAILURE_STATUS_CODE = 500
|
|
22
|
+
|
|
23
|
+
ERR_TRACKING_FAILURE = "AiTokenTracker explicit ingestion failed."
|
|
24
|
+
ERR_TRACKING_SERIALIZE_FAILURE = "AiTokenTracker explicit ingestion serialization failed."
|
|
25
|
+
ERR_SCOPE_COMPLETE_FAILURE = "AiTokenTracker SDK scope completion failed."
|
|
26
|
+
ERR_SCOPE_FAIL_FAILURE = "AiTokenTracker SDK scope failure tracking failed."
|
|
27
|
+
ERR_SCOPE_SERIALIZE_REQUEST_FAILURE = "AiTokenTracker could not serialize SDK wrapper request payload."
|
|
28
|
+
ERR_SCOPE_SERIALIZE_RESPONSE_FAILURE = "AiTokenTracker could not serialize SDK wrapper response payload."
|
|
29
|
+
ERR_INTERCEPT_INGEST_FAILURE = "AiTokenTracker auto interception ingestion failed."
|
|
30
|
+
ERR_INTERCEPT_CAPTURE_FAILURE = "AiTokenTracker failed to capture HTTP envelope for ingestion."
|
|
31
|
+
ERR_FALLBACK_FAILURE = "AiTokenTracker diagnostics fallback event handling failed."
|
|
32
|
+
ERR_NON_SUCCESS_STATUS = "AiTokenTracker ingest returned non-success status code: %s"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Envelope mapping helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .types import HeaderMap, HttpMessageEnvelope, IngestionEnvelope, TrackedLlmCall
|
|
6
|
+
|
|
7
|
+
_REDACTED_VALUE = "[REDACTED]"
|
|
8
|
+
_SENSITIVE_HEADER_NAMES = {
|
|
9
|
+
"authorization",
|
|
10
|
+
"proxy-authorization",
|
|
11
|
+
"cookie",
|
|
12
|
+
"set-cookie",
|
|
13
|
+
"api-key",
|
|
14
|
+
"openai-organization",
|
|
15
|
+
"openai-project",
|
|
16
|
+
"anthropic-api-key",
|
|
17
|
+
"x-goog-api-key",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def map_tracked_call_to_envelope(llm_call: TrackedLlmCall) -> IngestionEnvelope:
|
|
22
|
+
return IngestionEnvelope(
|
|
23
|
+
providerHint=llm_call.provider_hint,
|
|
24
|
+
request=HttpMessageEnvelope(
|
|
25
|
+
method=llm_call.method,
|
|
26
|
+
url=llm_call.url,
|
|
27
|
+
statusCode=None,
|
|
28
|
+
headers=_redact_headers(_empty_headers_if_none(llm_call.request_headers)),
|
|
29
|
+
body=llm_call.request_body,
|
|
30
|
+
),
|
|
31
|
+
response=HttpMessageEnvelope(
|
|
32
|
+
method=llm_call.method,
|
|
33
|
+
url=llm_call.url,
|
|
34
|
+
statusCode=llm_call.status_code,
|
|
35
|
+
headers=_redact_headers(_empty_headers_if_none(llm_call.response_headers)),
|
|
36
|
+
body=llm_call.response_body,
|
|
37
|
+
),
|
|
38
|
+
customFilters=llm_call.custom_filters,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _empty_headers_if_none(value: HeaderMap | None) -> HeaderMap:
|
|
43
|
+
if value is None:
|
|
44
|
+
return {}
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _redact_headers(headers: HeaderMap) -> HeaderMap:
|
|
49
|
+
if not headers:
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
redacted: HeaderMap = {}
|
|
53
|
+
for key, values in headers.items():
|
|
54
|
+
redacted[key] = [_REDACTED_VALUE] if _is_sensitive_header(key) else list(values)
|
|
55
|
+
|
|
56
|
+
return redacted
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_sensitive_header(header_name: str) -> bool:
|
|
60
|
+
normalized = header_name.lower()
|
|
61
|
+
if normalized == "x-api-key":
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
if normalized.startswith("x-amz-"):
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
return normalized in _SENSITIVE_HEADER_NAMES
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Ingestion client for Ai Token Tracker envelopes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import threading
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from dataclasses import asdict
|
|
11
|
+
from typing import Callable, Sequence
|
|
12
|
+
from urllib.error import HTTPError, URLError
|
|
13
|
+
from urllib.request import Request, urlopen
|
|
14
|
+
|
|
15
|
+
from .classifier import DefaultLlmRequestClassifier
|
|
16
|
+
from .constants import (
|
|
17
|
+
ERR_NON_SUCCESS_STATUS,
|
|
18
|
+
ERR_TRACKING_FAILURE,
|
|
19
|
+
ERR_TRACKING_SERIALIZE_FAILURE,
|
|
20
|
+
HEADER_CONTENT_TYPE,
|
|
21
|
+
INTERNAL_AUTH_HEADER_NAME,
|
|
22
|
+
JSON_CONTENT_TYPE,
|
|
23
|
+
)
|
|
24
|
+
from .envelope import map_tracked_call_to_envelope
|
|
25
|
+
from .interception import (
|
|
26
|
+
_InterceptionSubscriber,
|
|
27
|
+
install_interception,
|
|
28
|
+
mark_ingest_call_end,
|
|
29
|
+
mark_ingest_call_start,
|
|
30
|
+
)
|
|
31
|
+
from .options import resolve_options, to_ingest_url
|
|
32
|
+
from .types import (
|
|
33
|
+
AiTokenTrackerOptions,
|
|
34
|
+
LlmRequestClassificationInput,
|
|
35
|
+
LlmRequestClassifier,
|
|
36
|
+
LoggerLike,
|
|
37
|
+
ResolvedAiTokenTrackerOptions,
|
|
38
|
+
TrackResult,
|
|
39
|
+
TrackedLlmCall,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AiTokenTrackerIngestionClient:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
options: AiTokenTrackerOptions,
|
|
47
|
+
*,
|
|
48
|
+
logger: LoggerLike | None = None,
|
|
49
|
+
request_classifiers: Sequence[LlmRequestClassifier] | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._options: ResolvedAiTokenTrackerOptions = resolve_options(options)
|
|
52
|
+
self._logger: LoggerLike = logger if logger is not None else logging.getLogger("ai_token_tracker")
|
|
53
|
+
self._classifier: LlmRequestClassifier = _CompositeRequestClassifier(
|
|
54
|
+
list(request_classifiers or []) + [DefaultLlmRequestClassifier()]
|
|
55
|
+
)
|
|
56
|
+
self._dispose_interception: Callable[[], None] | None = None
|
|
57
|
+
self._lock = threading.RLock()
|
|
58
|
+
self._executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="ai-token-tracker")
|
|
59
|
+
|
|
60
|
+
def track(self, call: TrackedLlmCall) -> TrackResult:
|
|
61
|
+
envelope = map_tracked_call_to_envelope(call)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
payload = json.dumps(asdict(envelope), separators=(",", ":"))
|
|
65
|
+
except Exception as ex:
|
|
66
|
+
self._logger.warning(ERR_TRACKING_SERIALIZE_FAILURE, exc_info=ex)
|
|
67
|
+
return TrackResult(success=False, status_code=None, error=ex)
|
|
68
|
+
|
|
69
|
+
request = Request(
|
|
70
|
+
to_ingest_url(self._options),
|
|
71
|
+
data=payload.encode("utf-8"),
|
|
72
|
+
method="POST",
|
|
73
|
+
headers={
|
|
74
|
+
HEADER_CONTENT_TYPE: JSON_CONTENT_TYPE,
|
|
75
|
+
INTERNAL_AUTH_HEADER_NAME: self._options.auth_token,
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
mark_ingest_call_start()
|
|
80
|
+
try:
|
|
81
|
+
with urlopen(request, timeout=10) as response: # noqa: S310
|
|
82
|
+
status_code = response.getcode()
|
|
83
|
+
if status_code < 200 or status_code > 299:
|
|
84
|
+
self._logger.warning(ERR_NON_SUCCESS_STATUS, status_code)
|
|
85
|
+
return TrackResult(success=False, status_code=status_code, error=None)
|
|
86
|
+
return TrackResult(success=True, status_code=status_code, error=None)
|
|
87
|
+
except HTTPError as ex:
|
|
88
|
+
self._logger.warning(ERR_NON_SUCCESS_STATUS, ex.code)
|
|
89
|
+
return TrackResult(success=False, status_code=ex.code, error=None)
|
|
90
|
+
except (URLError, TimeoutError, OSError) as ex:
|
|
91
|
+
self._logger.warning(ERR_TRACKING_FAILURE, exc_info=ex)
|
|
92
|
+
return TrackResult(success=False, status_code=None, error=ex)
|
|
93
|
+
except Exception as ex:
|
|
94
|
+
self._logger.warning(ERR_TRACKING_FAILURE, exc_info=ex)
|
|
95
|
+
return TrackResult(success=False, status_code=None, error=ex)
|
|
96
|
+
finally:
|
|
97
|
+
mark_ingest_call_end()
|
|
98
|
+
|
|
99
|
+
async def track_async(self, call: TrackedLlmCall) -> TrackResult:
|
|
100
|
+
return await asyncio.to_thread(self.track, call)
|
|
101
|
+
|
|
102
|
+
def install_http_interception(self) -> None:
|
|
103
|
+
if not self._options.enable_auto_interception and not self._options.enable_diagnostics_fallback:
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
with self._lock:
|
|
107
|
+
if self._dispose_interception is not None:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
subscriber = _InterceptionSubscriber(
|
|
111
|
+
enable_auto_interception=self._options.enable_auto_interception,
|
|
112
|
+
enable_diagnostics_fallback=self._options.enable_diagnostics_fallback,
|
|
113
|
+
classifier=self._classifier,
|
|
114
|
+
on_tracked_call=self._submit_background_track,
|
|
115
|
+
logger=self._logger,
|
|
116
|
+
)
|
|
117
|
+
self._dispose_interception = install_interception(subscriber)
|
|
118
|
+
|
|
119
|
+
def uninstall_http_interception(self) -> None:
|
|
120
|
+
with self._lock:
|
|
121
|
+
if self._dispose_interception is None:
|
|
122
|
+
return
|
|
123
|
+
self._dispose_interception()
|
|
124
|
+
self._dispose_interception = None
|
|
125
|
+
|
|
126
|
+
def close(self) -> None:
|
|
127
|
+
self.uninstall_http_interception()
|
|
128
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
129
|
+
|
|
130
|
+
def _submit_background_track(self, call: TrackedLlmCall) -> None:
|
|
131
|
+
try:
|
|
132
|
+
self._executor.submit(self.track, call)
|
|
133
|
+
except Exception as ex:
|
|
134
|
+
self._logger.debug("AiTokenTracker background ingestion submission failed.", exc_info=ex)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _CompositeRequestClassifier(LlmRequestClassifier):
|
|
138
|
+
def __init__(self, classifiers: list[LlmRequestClassifier]) -> None:
|
|
139
|
+
self._classifiers = classifiers
|
|
140
|
+
|
|
141
|
+
def try_classify(self, input_value: LlmRequestClassificationInput) -> str | None:
|
|
142
|
+
for classifier in self._classifiers:
|
|
143
|
+
provider_hint = classifier.try_classify(input_value)
|
|
144
|
+
if provider_hint is not None:
|
|
145
|
+
return provider_hint
|
|
146
|
+
return None
|