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.
@@ -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