tensorcost 0.4.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,266 @@
1
+ """Anthropic client wrapping.
2
+
3
+ Intercepts ``client.messages.create``. Same shape and guarantees as the
4
+ OpenAI wrapper: metadata-only capture, fail-open on observation errors.
5
+
6
+ Applied-mode hardening (v0.4.0): messages.create calls route through the
7
+ proxy-client module, which handles retries, timeouts, typed errors,
8
+ telemetry hooks, and the fail-open circuit breaker.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import functools
14
+ import logging
15
+ import uuid
16
+ from datetime import datetime, timezone
17
+ from typing import Any, Optional
18
+
19
+ from .._applied_mode import (
20
+ anthropic_provider_auth,
21
+ anthropic_provider_base_url,
22
+ )
23
+ from .._circuit import CircuitBreaker
24
+ from .._errors import TensorCostProxyError
25
+ from .._models import Observation
26
+ from .._proxy_client import proxy_request
27
+ from .._retry import DEFAULT_RETRY_CONFIG, RetryConfig
28
+ from .._telemetry import LifecycleEventCallback, OnFallbackEvent, emit_event
29
+ from .._transport import ObservationTransport
30
+
31
+ _SDK_VERSION = "tensorcost-python/0.4.0"
32
+ _log = logging.getLogger("tensorcost")
33
+
34
+
35
+ def _now_iso() -> str:
36
+ return datetime.now(timezone.utc).isoformat()
37
+
38
+
39
+ def _extract_usage(response: Any) -> tuple[int | None, int | None]:
40
+ usage = getattr(response, "usage", None)
41
+ if usage is None and isinstance(response, dict):
42
+ usage = response.get("usage")
43
+ if usage is None:
44
+ return None, None
45
+ if isinstance(usage, dict):
46
+ return usage.get("input_tokens"), usage.get("output_tokens")
47
+ return (
48
+ getattr(usage, "input_tokens", None),
49
+ getattr(usage, "output_tokens", None),
50
+ )
51
+
52
+
53
+ def _post_obs(
54
+ transport: ObservationTransport,
55
+ model: str,
56
+ tenant_id: str | None,
57
+ environment: str | None,
58
+ connection_id: str | None,
59
+ correlation_id: str,
60
+ request_at: str,
61
+ input_tokens: int | None,
62
+ output_tokens: int | None,
63
+ status: str,
64
+ error_message: str | None,
65
+ ) -> None:
66
+ obs: dict = {
67
+ "sdk_version": _SDK_VERSION,
68
+ "provider": "anthropic",
69
+ "model": model,
70
+ "operation": "messages",
71
+ "request_at": request_at,
72
+ "response_at": _now_iso(),
73
+ "input_tokens": input_tokens,
74
+ "output_tokens": output_tokens,
75
+ "cost_usd_cents": None,
76
+ "status": status,
77
+ "error_message": error_message,
78
+ "correlation_id": correlation_id,
79
+ "tenant_id": tenant_id,
80
+ **({"environment": environment} if environment is not None else {}),
81
+ **({"connection_id": connection_id} if connection_id is not None else {}),
82
+ }
83
+ try:
84
+ transport.post(obs)
85
+ except Exception: # noqa: BLE001
86
+ pass
87
+
88
+
89
+ def _wrap_method(
90
+ bound_method: Any,
91
+ *,
92
+ transport: ObservationTransport,
93
+ tenant_id: str | None,
94
+ environment: str | None,
95
+ connection_id: str | None,
96
+ applied_mode: bool = False,
97
+ proxy_url: Optional[str] = None,
98
+ client_ref: Optional[Any] = None,
99
+ retry: RetryConfig = DEFAULT_RETRY_CONFIG,
100
+ timeout_s: float = 60.0,
101
+ on_lifecycle_event: Optional[LifecycleEventCallback] = None,
102
+ fail_open_enabled: bool = True,
103
+ circuit: Optional[CircuitBreaker] = None,
104
+ ) -> Any:
105
+ @functools.wraps(bound_method)
106
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
107
+ request_at = _now_iso()
108
+ model = kwargs.get("model") or (args[0] if args else "unknown")
109
+ correlation_id = str(uuid.uuid4())
110
+
111
+ # Applied-mode path.
112
+ if applied_mode and proxy_url:
113
+ provider_url = anthropic_provider_base_url(client_ref) if client_ref else "https://api.anthropic.com"
114
+ provider_auth = anthropic_provider_auth(client_ref) if client_ref else ""
115
+
116
+ body: dict[str, Any] = dict(kwargs)
117
+ body["model"] = str(model)
118
+
119
+ circuit_was_open = circuit is not None and circuit.should_bypass()
120
+ proxy_failed = False
121
+ proxy_error: Optional[Exception] = None
122
+ proxy_result: Optional[Any] = None
123
+
124
+ if not circuit_was_open:
125
+ try:
126
+ bearer_token = transport._get_token()
127
+ proxy_result = proxy_request(
128
+ proxy_url=proxy_url,
129
+ bearer_token=bearer_token,
130
+ provider_url=provider_url,
131
+ provider_auth=provider_auth,
132
+ body=body,
133
+ model=str(model),
134
+ provider="anthropic",
135
+ operation="messages",
136
+ correlation_id=correlation_id,
137
+ environment=environment,
138
+ retry=retry,
139
+ timeout_s=timeout_s,
140
+ on_lifecycle_event=on_lifecycle_event,
141
+ circuit=circuit,
142
+ )
143
+ except Exception as exc: # noqa: BLE001
144
+ proxy_failed = True
145
+ proxy_error = exc
146
+ else:
147
+ proxy_failed = True
148
+
149
+ if proxy_failed or circuit_was_open:
150
+ if not fail_open_enabled:
151
+ tc_err = (
152
+ proxy_error
153
+ if isinstance(proxy_error, TensorCostProxyError)
154
+ else TensorCostProxyError(
155
+ str(proxy_error) if proxy_error else "proxy unavailable",
156
+ root_cause=proxy_error if isinstance(proxy_error, Exception) else None,
157
+ attempt=1,
158
+ )
159
+ )
160
+ _post_obs(
161
+ transport, str(model), tenant_id, environment,
162
+ connection_id, correlation_id, request_at, None, None,
163
+ "error", f"{type(tc_err).__name__}: {tc_err}",
164
+ )
165
+ raise tc_err
166
+
167
+ emit_event(
168
+ on_lifecycle_event,
169
+ OnFallbackEvent(
170
+ kind="on_fallback",
171
+ provider="anthropic",
172
+ model=str(model),
173
+ operation="messages",
174
+ attempt_number=1,
175
+ elapsed_ms=0.0,
176
+ consecutive_failures=circuit.failures if circuit else 0,
177
+ ),
178
+ )
179
+ try:
180
+ direct_response = bound_method(*args, **kwargs)
181
+ input_tokens, output_tokens = _extract_usage(direct_response)
182
+ _post_obs(
183
+ transport, str(model), tenant_id, environment,
184
+ connection_id, correlation_id, request_at, input_tokens,
185
+ output_tokens, "success", None,
186
+ )
187
+ return direct_response
188
+ except Exception as exc:
189
+ _post_obs(
190
+ transport, str(model), tenant_id, environment,
191
+ connection_id, correlation_id, request_at, None, None,
192
+ "error", f"{type(exc).__name__}: {exc}",
193
+ )
194
+ raise
195
+
196
+ # Proxy success.
197
+ resp_dict = proxy_result if isinstance(proxy_result, dict) else {}
198
+ usage = resp_dict.get("usage") if isinstance(resp_dict, dict) else None
199
+ input_tokens = usage.get("input_tokens") if isinstance(usage, dict) else None
200
+ output_tokens = usage.get("output_tokens") if isinstance(usage, dict) else None
201
+ _post_obs(
202
+ transport, str(model), tenant_id, environment,
203
+ connection_id, correlation_id, request_at, input_tokens,
204
+ output_tokens, "success", None,
205
+ )
206
+ return proxy_result
207
+
208
+ # Observe-only path.
209
+ try:
210
+ response = bound_method(*args, **kwargs)
211
+ except Exception as exc:
212
+ _post_obs(
213
+ transport, str(model), tenant_id, environment,
214
+ connection_id, correlation_id, request_at, None, None,
215
+ "error", f"{type(exc).__name__}: {exc}",
216
+ )
217
+ raise
218
+
219
+ input_tokens, output_tokens = _extract_usage(response)
220
+ _post_obs(
221
+ transport, str(model), tenant_id, environment,
222
+ connection_id, correlation_id, request_at, input_tokens,
223
+ output_tokens, "success", None,
224
+ )
225
+ return response
226
+
227
+ setattr(wrapper, "__tensorcost_wrapped__", True)
228
+ return wrapper
229
+
230
+
231
+ def install(
232
+ client: Any,
233
+ transport: ObservationTransport,
234
+ tenant_id: str | None,
235
+ environment: str | None,
236
+ connection_id: str | None,
237
+ applied_mode: bool = False,
238
+ proxy_url: Optional[str] = None,
239
+ retry: RetryConfig = DEFAULT_RETRY_CONFIG,
240
+ timeout_s: float = 60.0,
241
+ on_lifecycle_event: Optional[LifecycleEventCallback] = None,
242
+ fail_open_enabled: bool = True,
243
+ circuit: Optional[CircuitBreaker] = None,
244
+ ) -> None:
245
+ """Monkey-patch ``client.messages.create`` in place."""
246
+ messages = getattr(client, "messages", None)
247
+ if messages is None or not callable(getattr(messages, "create", None)):
248
+ return
249
+ original = messages.create
250
+ if getattr(original, "__tensorcost_wrapped__", False):
251
+ return
252
+ messages.create = _wrap_method(
253
+ original,
254
+ transport=transport,
255
+ tenant_id=tenant_id,
256
+ environment=environment,
257
+ connection_id=connection_id,
258
+ applied_mode=applied_mode,
259
+ proxy_url=proxy_url,
260
+ client_ref=client,
261
+ retry=retry,
262
+ timeout_s=timeout_s,
263
+ on_lifecycle_event=on_lifecycle_event,
264
+ fail_open_enabled=fail_open_enabled,
265
+ circuit=circuit,
266
+ )
@@ -0,0 +1,46 @@
1
+ """Provider detection via duck-typing.
2
+
3
+ We deliberately avoid ``isinstance`` checks against the OpenAI /
4
+ Anthropic SDK classes so that the SDK has zero hard imports of those
5
+ packages. A customer who uses only OpenAI shouldn't need ``anthropic``
6
+ installed (and vice versa).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+
14
+ class UnsupportedClientError(TypeError):
15
+ """Raised by :func:`detect_provider` when the client shape is not
16
+ recognized as either an OpenAI or Anthropic client."""
17
+
18
+
19
+ def _looks_like_openai(client: Any) -> bool:
20
+ chat = getattr(client, "chat", None)
21
+ completions = getattr(chat, "completions", None) if chat is not None else None
22
+ return callable(getattr(completions, "create", None))
23
+
24
+
25
+ def _looks_like_anthropic(client: Any) -> bool:
26
+ # Anthropic clients expose ``client.messages.create``; we additionally
27
+ # require the *absence* of ``client.chat`` to disambiguate from OpenAI.
28
+ if getattr(client, "chat", None) is not None:
29
+ return False
30
+ messages = getattr(client, "messages", None)
31
+ return callable(getattr(messages, "create", None))
32
+
33
+
34
+ def detect_provider(client: Any) -> str:
35
+ """Return ``"openai"`` or ``"anthropic"`` based on duck-typing.
36
+
37
+ Raises :class:`UnsupportedClientError` if neither shape matches.
38
+ """
39
+ if _looks_like_openai(client):
40
+ return "openai"
41
+ if _looks_like_anthropic(client):
42
+ return "anthropic"
43
+ raise UnsupportedClientError(
44
+ "tensorcost.wrap(): client does not look like an OpenAI or Anthropic "
45
+ "client. Supported in v0.1.0: openai>=1.0, anthropic>=0.20."
46
+ )
@@ -0,0 +1,295 @@
1
+ """OpenAI client wrapping.
2
+
3
+ Intercepts:
4
+ * ``client.chat.completions.create``
5
+ * ``client.completions.create`` (legacy text completions; v0 still ships)
6
+
7
+ We capture only metadata (model, message count, token usage) — never
8
+ prompt content. Customers' prompts stay on their machine.
9
+
10
+ Applied-mode hardening (v0.4.0): chat/completions calls route through the
11
+ proxy-client module, which handles retries, timeouts, typed errors,
12
+ telemetry hooks, and the fail-open circuit breaker.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import functools
18
+ import logging
19
+ import uuid
20
+ from datetime import datetime, timezone
21
+ from typing import Any, Optional
22
+
23
+ from .._applied_mode import (
24
+ openai_provider_auth,
25
+ openai_provider_base_url,
26
+ )
27
+ from .._circuit import CircuitBreaker
28
+ from .._errors import TensorCostProxyError
29
+ from .._models import Observation
30
+ from .._proxy_client import proxy_request
31
+ from .._retry import DEFAULT_RETRY_CONFIG, RetryConfig
32
+ from .._telemetry import LifecycleEventCallback, OnFallbackEvent, emit_event
33
+ from .._transport import ObservationTransport
34
+
35
+ _SDK_VERSION = "tensorcost-python/0.4.0"
36
+ _log = logging.getLogger("tensorcost")
37
+
38
+
39
+ def _now_iso() -> str:
40
+ return datetime.now(timezone.utc).isoformat()
41
+
42
+
43
+ def _extract_usage(response: Any) -> tuple[int | None, int | None]:
44
+ usage = getattr(response, "usage", None)
45
+ if usage is None and isinstance(response, dict):
46
+ usage = response.get("usage")
47
+ if usage is None:
48
+ return None, None
49
+ if isinstance(usage, dict):
50
+ return usage.get("prompt_tokens"), usage.get("completion_tokens")
51
+ return (
52
+ getattr(usage, "prompt_tokens", None),
53
+ getattr(usage, "completion_tokens", None),
54
+ )
55
+
56
+
57
+ def _post_obs(
58
+ transport: ObservationTransport,
59
+ model: str,
60
+ operation: str,
61
+ tenant_id: str | None,
62
+ environment: str | None,
63
+ connection_id: str | None,
64
+ correlation_id: str,
65
+ request_at: str,
66
+ input_tokens: int | None,
67
+ output_tokens: int | None,
68
+ status: str,
69
+ error_message: str | None,
70
+ ) -> None:
71
+ obs: dict = {
72
+ "sdk_version": _SDK_VERSION,
73
+ "provider": "openai",
74
+ "model": model,
75
+ "operation": operation,
76
+ "request_at": request_at,
77
+ "response_at": _now_iso(),
78
+ "input_tokens": input_tokens,
79
+ "output_tokens": output_tokens,
80
+ "cost_usd_cents": None,
81
+ "status": status,
82
+ "error_message": error_message,
83
+ "correlation_id": correlation_id,
84
+ "tenant_id": tenant_id,
85
+ **({"environment": environment} if environment is not None else {}),
86
+ **({"connection_id": connection_id} if connection_id is not None else {}),
87
+ }
88
+ try:
89
+ transport.post(obs)
90
+ except Exception: # noqa: BLE001
91
+ pass
92
+
93
+
94
+ def _wrap_method(
95
+ bound_method: Any,
96
+ *,
97
+ operation: str,
98
+ transport: ObservationTransport,
99
+ tenant_id: str | None,
100
+ environment: str | None,
101
+ connection_id: str | None,
102
+ applied_mode: bool = False,
103
+ proxy_url: Optional[str] = None,
104
+ client_ref: Optional[Any] = None,
105
+ retry: RetryConfig = DEFAULT_RETRY_CONFIG,
106
+ timeout_s: float = 60.0,
107
+ on_lifecycle_event: Optional[LifecycleEventCallback] = None,
108
+ fail_open_enabled: bool = True,
109
+ circuit: Optional[CircuitBreaker] = None,
110
+ ) -> Any:
111
+ @functools.wraps(bound_method)
112
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
113
+ request_at = _now_iso()
114
+ model = kwargs.get("model") or (args[0] if args else "unknown")
115
+ correlation_id = str(uuid.uuid4())
116
+
117
+ # Applied-mode path: route chat/completions through the proxy-client.
118
+ if applied_mode and proxy_url and operation == "chat.completions":
119
+ provider_url = openai_provider_base_url(client_ref) if client_ref else "https://api.openai.com"
120
+ provider_auth = openai_provider_auth(client_ref) if client_ref else ""
121
+
122
+ body: dict[str, Any] = dict(kwargs)
123
+ body["model"] = str(model)
124
+
125
+ circuit_was_open = circuit is not None and circuit.should_bypass()
126
+ proxy_failed = False
127
+ proxy_error: Optional[Exception] = None
128
+ proxy_result: Optional[Any] = None
129
+
130
+ if not circuit_was_open:
131
+ try:
132
+ bearer_token = transport._get_token()
133
+ proxy_result = proxy_request(
134
+ proxy_url=proxy_url,
135
+ bearer_token=bearer_token,
136
+ provider_url=provider_url,
137
+ provider_auth=provider_auth,
138
+ body=body,
139
+ model=str(model),
140
+ provider="openai",
141
+ operation=operation,
142
+ correlation_id=correlation_id,
143
+ environment=environment,
144
+ retry=retry,
145
+ timeout_s=timeout_s,
146
+ on_lifecycle_event=on_lifecycle_event,
147
+ circuit=circuit,
148
+ )
149
+ except Exception as exc: # noqa: BLE001
150
+ proxy_failed = True
151
+ proxy_error = exc
152
+ else:
153
+ proxy_failed = True
154
+
155
+ if proxy_failed or circuit_was_open:
156
+ if not fail_open_enabled:
157
+ tc_err = (
158
+ proxy_error
159
+ if isinstance(proxy_error, TensorCostProxyError)
160
+ else TensorCostProxyError(
161
+ str(proxy_error) if proxy_error else "proxy unavailable",
162
+ root_cause=proxy_error if isinstance(proxy_error, Exception) else None,
163
+ attempt=1,
164
+ )
165
+ )
166
+ _post_obs(
167
+ transport, str(model), operation, tenant_id, environment,
168
+ connection_id, correlation_id, request_at, None, None,
169
+ "error", f"{type(tc_err).__name__}: {tc_err}",
170
+ )
171
+ raise tc_err
172
+
173
+ # Fail-open: emit fallback event and call provider directly.
174
+ emit_event(
175
+ on_lifecycle_event,
176
+ OnFallbackEvent(
177
+ kind="on_fallback",
178
+ provider="openai",
179
+ model=str(model),
180
+ operation=operation,
181
+ attempt_number=1,
182
+ elapsed_ms=0.0,
183
+ consecutive_failures=circuit.failures if circuit else 0,
184
+ ),
185
+ )
186
+ try:
187
+ direct_response = bound_method(*args, **kwargs)
188
+ input_tokens, output_tokens = _extract_usage(direct_response)
189
+ _post_obs(
190
+ transport, str(model), operation, tenant_id, environment,
191
+ connection_id, correlation_id, request_at, input_tokens,
192
+ output_tokens, "success", None,
193
+ )
194
+ return direct_response
195
+ except Exception as exc:
196
+ _post_obs(
197
+ transport, str(model), operation, tenant_id, environment,
198
+ connection_id, correlation_id, request_at, None, None,
199
+ "error", f"{type(exc).__name__}: {exc}",
200
+ )
201
+ raise
202
+
203
+ # Proxy success.
204
+ resp_dict = proxy_result if isinstance(proxy_result, dict) else {}
205
+ usage = resp_dict.get("usage") if isinstance(resp_dict, dict) else None
206
+ input_tokens = usage.get("prompt_tokens") if isinstance(usage, dict) else None
207
+ output_tokens = usage.get("completion_tokens") if isinstance(usage, dict) else None
208
+ _post_obs(
209
+ transport, str(model), operation, tenant_id, environment,
210
+ connection_id, correlation_id, request_at, input_tokens,
211
+ output_tokens, "success", None,
212
+ )
213
+ return proxy_result
214
+
215
+ # Observe-only path (applied_mode=False or non-chat operation).
216
+ try:
217
+ response = bound_method(*args, **kwargs)
218
+ except Exception as exc:
219
+ _post_obs(
220
+ transport, str(model), operation, tenant_id, environment,
221
+ connection_id, correlation_id, request_at, None, None,
222
+ "error", f"{type(exc).__name__}: {exc}",
223
+ )
224
+ raise
225
+
226
+ input_tokens, output_tokens = _extract_usage(response)
227
+ _post_obs(
228
+ transport, str(model), operation, tenant_id, environment,
229
+ connection_id, correlation_id, request_at, input_tokens,
230
+ output_tokens, "success", None,
231
+ )
232
+ return response
233
+
234
+ setattr(wrapper, "__tensorcost_wrapped__", True)
235
+ return wrapper
236
+
237
+
238
+ def install(
239
+ client: Any,
240
+ transport: ObservationTransport,
241
+ tenant_id: str | None,
242
+ environment: str | None,
243
+ connection_id: str | None,
244
+ applied_mode: bool = False,
245
+ proxy_url: Optional[str] = None,
246
+ retry: RetryConfig = DEFAULT_RETRY_CONFIG,
247
+ timeout_s: float = 60.0,
248
+ on_lifecycle_event: Optional[LifecycleEventCallback] = None,
249
+ fail_open_enabled: bool = True,
250
+ circuit: Optional[CircuitBreaker] = None,
251
+ ) -> None:
252
+ """Monkey-patch the OpenAI client's ``create`` methods in place."""
253
+ chat = getattr(client, "chat", None)
254
+ completions = getattr(chat, "completions", None) if chat is not None else None
255
+ if completions is not None and callable(getattr(completions, "create", None)):
256
+ original = completions.create
257
+ if not getattr(original, "__tensorcost_wrapped__", False):
258
+ completions.create = _wrap_method(
259
+ original,
260
+ operation="chat.completions",
261
+ transport=transport,
262
+ tenant_id=tenant_id,
263
+ environment=environment,
264
+ connection_id=connection_id,
265
+ applied_mode=applied_mode,
266
+ proxy_url=proxy_url,
267
+ client_ref=client,
268
+ retry=retry,
269
+ timeout_s=timeout_s,
270
+ on_lifecycle_event=on_lifecycle_event,
271
+ fail_open_enabled=fail_open_enabled,
272
+ circuit=circuit,
273
+ )
274
+
275
+ legacy = getattr(client, "completions", None)
276
+ if legacy is not None and callable(getattr(legacy, "create", None)):
277
+ original = legacy.create
278
+ if not getattr(original, "__tensorcost_wrapped__", False):
279
+ legacy.create = _wrap_method(
280
+ original,
281
+ operation="completions",
282
+ transport=transport,
283
+ tenant_id=tenant_id,
284
+ environment=environment,
285
+ connection_id=connection_id,
286
+ # Legacy completions endpoint not in scope for applied-mode v1.
287
+ applied_mode=False,
288
+ proxy_url=None,
289
+ client_ref=client,
290
+ retry=retry,
291
+ timeout_s=timeout_s,
292
+ on_lifecycle_event=on_lifecycle_event,
293
+ fail_open_enabled=fail_open_enabled,
294
+ circuit=circuit,
295
+ )