tensorcost 1.3.2__tar.gz → 1.4.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.
Files changed (64) hide show
  1. {tensorcost-1.3.2 → tensorcost-1.4.0}/CHANGELOG.md +11 -1
  2. {tensorcost-1.3.2 → tensorcost-1.4.0}/PKG-INFO +1 -1
  3. {tensorcost-1.3.2 → tensorcost-1.4.0}/pyproject.toml +1 -1
  4. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/__init__.py +3 -1
  5. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_admit_client.py +34 -5
  6. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_errors.py +59 -0
  7. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_proxy_client.py +6 -0
  8. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_version.py +1 -1
  9. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_providers/test_anthropic.py +1 -1
  10. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_providers/test_bedrock.py +1 -1
  11. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_providers/test_openai.py +1 -1
  12. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_providers/test_vertex.py +1 -1
  13. tensorcost-1.4.0/tests/test_run_stopped.py +157 -0
  14. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_wrap.py +1 -1
  15. {tensorcost-1.3.2 → tensorcost-1.4.0}/.gitignore +0 -0
  16. {tensorcost-1.3.2 → tensorcost-1.4.0}/.py-credentials.example +0 -0
  17. {tensorcost-1.3.2 → tensorcost-1.4.0}/LICENSE +0 -0
  18. {tensorcost-1.3.2 → tensorcost-1.4.0}/PUBLISHING.md +0 -0
  19. {tensorcost-1.3.2 → tensorcost-1.4.0}/README.md +0 -0
  20. {tensorcost-1.3.2 → tensorcost-1.4.0}/scripts/load-pypi-creds.sh +0 -0
  21. {tensorcost-1.3.2 → tensorcost-1.4.0}/scripts/publish.sh +0 -0
  22. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_applied_mode.py +0 -0
  23. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_attribution.py +0 -0
  24. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_circuit.py +0 -0
  25. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_compliance_guard.py +0 -0
  26. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_compliance_team.py +0 -0
  27. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_config.py +0 -0
  28. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_decision.py +0 -0
  29. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_dlp.py +0 -0
  30. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_layer.py +0 -0
  31. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_models.py +0 -0
  32. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_prewarm.py +0 -0
  33. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_providers/__init__.py +0 -0
  34. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_providers/_anthropic.py +0 -0
  35. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_providers/_bedrock.py +0 -0
  36. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_providers/_detect.py +0 -0
  37. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_providers/_openai.py +0 -0
  38. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_providers/_vertex.py +0 -0
  39. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_retry.py +0 -0
  40. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_runtime.py +0 -0
  41. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_sdk_layer.py +0 -0
  42. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_telemetry.py +0 -0
  43. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_transport.py +0 -0
  44. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_wrap.py +0 -0
  45. {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/py.typed +0 -0
  46. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/__init__.py +0 -0
  47. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/conftest.py +0 -0
  48. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_agent_run.py +0 -0
  49. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_applied_mode.py +0 -0
  50. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_circuit.py +0 -0
  51. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_config.py +0 -0
  52. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_customer_feature.py +0 -0
  53. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_dlp.py +0 -0
  54. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_errors.py +0 -0
  55. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_hardening.py +0 -0
  56. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_prewarm.py +0 -0
  57. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_providers/__init__.py +0 -0
  58. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_providers/test_detect.py +0 -0
  59. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_proxy_client.py +0 -0
  60. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_retry.py +0 -0
  61. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_sdk_layer.py +0 -0
  62. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_should_fall_back.py +0 -0
  63. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_telemetry.py +0 -0
  64. {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_transport.py +0 -0
@@ -3,7 +3,17 @@
3
3
  All notable changes to the `tensorcost` Python SDK are documented here.
4
4
  This project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
- ## Unreleased
6
+ ## 1.4.0 — 2026-10-25
7
+
8
+ ### Added
9
+
10
+ **`TensorCostRunStoppedError`** — maps proxy `403` / `RUN_STOPPED` (an operator
11
+ stopped the agent run), on the applied-mode chat path and on the govern-layer
12
+ `/v1/admit`. Carries `workflow_id`, `stopped_by`, `stopped_at` and `reason`.
13
+ Terminal: `should_fall_back_to_provider()` returns False for it, so with
14
+ `fail_open_enabled` (the default) a stopped run no longer goes to the
15
+ provider directly. Before this, the 403 surfaced as a generic provider error
16
+ and fail-open called the provider anyway.
7
17
 
8
18
  ## 1.3.0 — 2026-09-26
9
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tensorcost
3
- Version: 1.3.2
3
+ Version: 1.4.0
4
4
  Summary: One-line wrapper SDK for TensorCost — LLM observability and control-layer inference routing for OpenAI, Anthropic, Bedrock, and Vertex clients.
5
5
  Project-URL: Homepage, https://tensorcost.com
6
6
  Project-URL: Documentation, https://docs.tensorcost.com
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tensorcost"
7
- version = "1.3.2"
7
+ version = "1.4.0"
8
8
  description = "One-line wrapper SDK for TensorCost — LLM observability and control-layer inference routing for OpenAI, Anthropic, Bedrock, and Vertex clients."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -29,6 +29,7 @@ from ._errors import (
29
29
  TensorCostQuotaError,
30
30
  TensorCostProviderError,
31
31
  TensorCostRunBudgetExceededError,
32
+ TensorCostRunStoppedError,
32
33
  TensorCostPeriodBudgetExceededError,
33
34
  TensorCostComplianceDeniedError,
34
35
  TensorCostComplianceTeamMismatchError,
@@ -44,7 +45,7 @@ from ._telemetry import (
44
45
  )
45
46
  from ._retry import RetryConfig
46
47
 
47
- __version__ = "1.3.0"
48
+ __version__ = "1.4.0"
48
49
  __all__ = [
49
50
  "wrap",
50
51
  "prewarm",
@@ -59,6 +60,7 @@ __all__ = [
59
60
  "TensorCostQuotaError",
60
61
  "TensorCostProviderError",
61
62
  "TensorCostRunBudgetExceededError",
63
+ "TensorCostRunStoppedError",
62
64
  "TensorCostPeriodBudgetExceededError",
63
65
  "TensorCostComplianceDeniedError",
64
66
  "TensorCostComplianceTeamMismatchError",
@@ -1,4 +1,9 @@
1
- """Govern-layer admit — metadata-only POST /api/inference-proxy/v1/admit."""
1
+ """Govern-layer admit — metadata-only POST /api/inference-proxy/v1/admit.
2
+
3
+ Fail-open on transport errors. A 403 is a refusal, never a transport error;
4
+ when its body says RUN_STOPPED this raises TensorCostRunStoppedError so the
5
+ caller gets who stopped the run and why instead of a generic govern refusal.
6
+ """
2
7
 
3
8
  from __future__ import annotations
4
9
 
@@ -7,6 +12,7 @@ from typing import Any, Callable, Optional
7
12
 
8
13
  import httpx
9
14
 
15
+ from ._errors import TensorCostRunStoppedError
10
16
  from ._layer import ADMIT_PATH, ADMIT_TIMEOUT_S, ControlLayer
11
17
  from ._transport import ObservationTransport
12
18
  from ._version import SDK_VERSION, sdk_capability_header
@@ -27,7 +33,10 @@ def admit_request(
27
33
  timeout_s: float = ADMIT_TIMEOUT_S,
28
34
  http_client: Optional[httpx.Client] = None,
29
35
  ) -> tuple[bool, Optional[str]]:
30
- """Returns (allowed, decision_header). Fail-open on transport errors."""
36
+ """Returns (allowed, decision_header). Fail-open on transport errors.
37
+
38
+ Raises TensorCostRunStoppedError for a RUN_STOPPED 403.
39
+ """
31
40
  url = base_url.rstrip("/") + ADMIT_PATH
32
41
  headers = {
33
42
  "Authorization": f"Bearer {bearer_token}",
@@ -39,18 +48,23 @@ def admit_request(
39
48
 
40
49
  own_client = http_client is None
41
50
  client = http_client or httpx.Client(timeout=timeout_s)
51
+ # Raised after the try below: inside it, fail-open would swallow it.
52
+ stopped: Optional[TensorCostRunStoppedError] = None
42
53
  try:
43
54
  resp = client.post(url, headers=headers, json=metadata)
44
55
  decision_header = resp.headers.get("x-tc-decision")
45
56
  if resp.status_code == 403:
46
- return False, decision_header
47
- if resp.status_code >= 400:
57
+ stopped = _run_stopped_from(resp)
58
+ if stopped is None:
59
+ return False, decision_header
60
+ elif resp.status_code >= 400:
48
61
  raise httpx.HTTPStatusError(
49
62
  f"admit POST failed status={resp.status_code}",
50
63
  request=resp.request,
51
64
  response=resp,
52
65
  )
53
- return True, decision_header
66
+ else:
67
+ return True, decision_header
54
68
  except Exception as exc:
55
69
  if fail_open:
56
70
  if transport is not None:
@@ -82,3 +96,18 @@ def admit_request(
82
96
  finally:
83
97
  if own_client:
84
98
  client.close()
99
+ assert stopped is not None
100
+ raise stopped
101
+
102
+
103
+ def _run_stopped_from(resp: httpx.Response) -> Optional[TensorCostRunStoppedError]:
104
+ try:
105
+ body = resp.json()
106
+ except Exception:
107
+ return None
108
+ err = body.get("error") if isinstance(body, dict) else None
109
+ if isinstance(err, dict) and err.get("code") == "RUN_STOPPED":
110
+ return TensorCostRunStoppedError.from_proxy_detail(
111
+ err, request_id=resp.headers.get("x-tc-request-id")
112
+ )
113
+ return None
@@ -152,6 +152,65 @@ class TensorCostRunBudgetExceededError(TensorCostError):
152
152
  self.spent_cents = spent_cents
153
153
 
154
154
 
155
+ class TensorCostRunStoppedError(TensorCostError):
156
+ """An operator stopped this agent run (HTTP 403, ``code: RUN_STOPPED``).
157
+
158
+ Terminal for the same reason as the budget refusals: the stop comes from
159
+ a healthy proxy, so falling back to the provider would undo it. Carries
160
+ the who / when / why the proxy recorded.
161
+ """
162
+
163
+ def __init__(
164
+ self,
165
+ message: str,
166
+ workflow_id: Optional[str] = None,
167
+ stopped_by: Optional[str] = None,
168
+ stopped_at: Optional[str] = None,
169
+ reason: Optional[str] = None,
170
+ *,
171
+ root_cause: Optional[Exception] = None,
172
+ status: int = 403,
173
+ request_id: Optional[str] = None,
174
+ attempt: int = 1,
175
+ ) -> None:
176
+ super().__init__(
177
+ message,
178
+ root_cause=root_cause,
179
+ status=status,
180
+ request_id=request_id,
181
+ attempt=attempt,
182
+ )
183
+ self.workflow_id = workflow_id
184
+ self.stopped_by = stopped_by
185
+ self.stopped_at = stopped_at
186
+ self.reason = reason
187
+
188
+ @classmethod
189
+ def from_proxy_detail(
190
+ cls,
191
+ detail: dict,
192
+ *,
193
+ request_id: Optional[str] = None,
194
+ attempt: int = 1,
195
+ ) -> "TensorCostRunStoppedError":
196
+ """Build from the proxy's 403 ``error`` object (chat path and /admit)."""
197
+
198
+ def _s(key: str) -> Optional[str]:
199
+ v = detail.get(key)
200
+ return v if isinstance(v, str) else None
201
+
202
+ return cls(
203
+ _s("message") or "tensorcost: agent run was stopped",
204
+ workflow_id=_s("workflow_id"),
205
+ stopped_by=_s("stopped_by"),
206
+ stopped_at=_s("stopped_at"),
207
+ reason=_s("reason"),
208
+ status=403,
209
+ request_id=request_id,
210
+ attempt=attempt,
211
+ )
212
+
213
+
155
214
  class TensorCostPeriodBudgetExceededError(TensorCostError):
156
215
  """Daily/monthly agent or team cap reached (HTTP 403,
157
216
  ``code: PERIOD_BUDGET_EXCEEDED``). Terminal — must not fail-open.
@@ -34,6 +34,7 @@ from ._errors import (
34
34
  TensorCostQuotaError,
35
35
  TensorCostPeriodBudgetExceededError,
36
36
  TensorCostRunBudgetExceededError,
37
+ TensorCostRunStoppedError,
37
38
  TensorCostTimeoutError,
38
39
  )
39
40
  from ._retry import (
@@ -583,6 +584,10 @@ def _terminal_proxy_refusal_error(
583
584
  ) -> Optional[BaseException]:
584
585
  """Map a 403 proxy body to a terminal refusal error, or None if not ours."""
585
586
  code, detail = _extract_proxy_refusal_code(content)
587
+ if code == "RUN_STOPPED":
588
+ return TensorCostRunStoppedError.from_proxy_detail(
589
+ detail, request_id=request_id, attempt=attempt
590
+ )
586
591
  if code in ("RUN_BUDGET_EXCEEDED", "PERIOD_BUDGET_EXCEEDED"):
587
592
  if code == "PERIOD_BUDGET_EXCEEDED":
588
593
  return TensorCostPeriodBudgetExceededError(
@@ -797,6 +802,7 @@ def should_fall_back_to_provider(err: BaseException) -> bool:
797
802
  err,
798
803
  (
799
804
  TensorCostRunBudgetExceededError,
805
+ TensorCostRunStoppedError,
800
806
  TensorCostPeriodBudgetExceededError,
801
807
  TensorCostModelGovernanceDeniedError,
802
808
  TensorCostGuardrailHardStopError,
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- SDK_VERSION = "tensorcost-python/1.3.0"
5
+ SDK_VERSION = "tensorcost-python/1.4.0"
6
6
 
7
7
  # Advertise only capabilities this build implements (non-streaming v1).
8
8
  SDK_CAPABILITIES: tuple[str, ...] = ("refusal-v1",)
@@ -36,7 +36,7 @@ def test_messages_observation_shape(anthropic_client, recording_transport):
36
36
  assert obs["input_tokens"] == 21
37
37
  assert obs["output_tokens"] == 43
38
38
  assert "tenant_id" not in obs
39
- assert obs["sdk_version"] == "tensorcost-python/1.3.0"
39
+ assert obs["sdk_version"] == "tensorcost-python/1.4.0"
40
40
  assert obs["correlation_id"]
41
41
  assert "connection_id" not in obs
42
42
 
@@ -34,7 +34,7 @@ def test_invoke_model_anthropic_shape_observation(bedrock_client, recording_tran
34
34
  assert obs["input_tokens"] == 15
35
35
  assert obs["output_tokens"] == 30
36
36
  assert "tenant_id" not in obs
37
- assert obs["sdk_version"] == "tensorcost-python/1.3.0"
37
+ assert obs["sdk_version"] == "tensorcost-python/1.4.0"
38
38
  assert obs["correlation_id"]
39
39
  assert "connection_id" not in obs
40
40
 
@@ -33,7 +33,7 @@ def test_chat_completions_observation_shape(openai_client, recording_transport):
33
33
  assert obs["input_tokens"] == 12
34
34
  assert obs["output_tokens"] == 34
35
35
  assert "tenant_id" not in obs
36
- assert obs["sdk_version"] == "tensorcost-python/1.3.0"
36
+ assert obs["sdk_version"] == "tensorcost-python/1.4.0"
37
37
  # Phase A4 batch 4 — connection_id absent when wrap() got no
38
38
  # connection_id; backend then resolves env via the SDK envelope's
39
39
  # `environment` field or falls back to 'production'.
@@ -27,7 +27,7 @@ def test_generate_content_observation_shape(vertex_client, recording_transport):
27
27
  assert obs["status"] == "success"
28
28
  assert obs["input_tokens"] == 42
29
29
  assert obs["output_tokens"] == 17
30
- assert obs["sdk_version"] == "tensorcost-python/1.3.0"
30
+ assert obs["sdk_version"] == "tensorcost-python/1.4.0"
31
31
  assert obs["correlation_id"]
32
32
 
33
33
 
@@ -0,0 +1,157 @@
1
+ """An operator stop is terminal in the Python SDK.
2
+
3
+ The proxy answers a stopped run with 403 RUN_STOPPED, on the chat path and on
4
+ the govern-layer /v1/admit. Before these tests the SDK did not know the code:
5
+ on the chat path the 403 became a generic TensorCostProviderError, which
6
+ should_fall_back_to_provider() allows, so with fail_open_enabled (the default)
7
+ the wrapper called the provider directly and the stop did nothing. On /admit
8
+ the refusal lost who stopped the run and why.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+
15
+ import httpx
16
+ import pytest
17
+
18
+ from tensorcost import TensorCostRunStoppedError
19
+ from tensorcost._admit_client import admit_request
20
+ from tensorcost._errors import TensorCostProviderError
21
+ from tensorcost._proxy_client import (
22
+ _terminal_proxy_refusal_error,
23
+ proxy_request,
24
+ should_fall_back_to_provider,
25
+ )
26
+ from tensorcost._retry import RetryConfig
27
+
28
+ STOPPED_BODY = {
29
+ "error": {
30
+ "code": "RUN_STOPPED",
31
+ "message": "Agent run run-abc was stopped by ops@example.test at 2026-10-25T10:00:00.000Z: runaway",
32
+ "workflow_id": "run-abc",
33
+ "stopped_by": "ops@example.test",
34
+ "stopped_at": "2026-10-25T10:00:00.000Z",
35
+ "reason": "runaway",
36
+ }
37
+ }
38
+
39
+
40
+ def _resp(status: int, body, headers: dict | None = None) -> httpx.Response:
41
+ content = body if isinstance(body, bytes) else json.dumps(body).encode()
42
+ return httpx.Response(
43
+ status_code=status,
44
+ headers={"Content-Type": "application/json", **(headers or {})},
45
+ content=content,
46
+ )
47
+
48
+
49
+ def _client(handler) -> httpx.Client:
50
+ return httpx.Client(transport=httpx.MockTransport(handler))
51
+
52
+
53
+ def _assert_stop_detail(err: TensorCostRunStoppedError) -> None:
54
+ assert err.workflow_id == "run-abc"
55
+ assert err.stopped_by == "ops@example.test"
56
+ assert err.stopped_at == "2026-10-25T10:00:00.000Z"
57
+ assert err.reason == "runaway"
58
+ assert err.status == 403
59
+
60
+
61
+ def test_terminal_refusal_maps_run_stopped_with_its_detail():
62
+ err = _terminal_proxy_refusal_error(
63
+ json.dumps(STOPPED_BODY).encode(), request_id="req-1", attempt=2
64
+ )
65
+ assert isinstance(err, TensorCostRunStoppedError)
66
+ _assert_stop_detail(err)
67
+ assert err.request_id == "req-1"
68
+ assert err.attempt == 2
69
+
70
+
71
+ def test_run_stopped_does_not_fall_back_to_provider():
72
+ assert should_fall_back_to_provider(TensorCostRunStoppedError("stopped")) is False
73
+
74
+
75
+ def test_proxy_request_raises_run_stopped_and_does_not_retry():
76
+ calls = [0]
77
+
78
+ def handler(_req: httpx.Request) -> httpx.Response:
79
+ calls[0] += 1
80
+ return _resp(403, STOPPED_BODY)
81
+
82
+ with pytest.raises(TensorCostRunStoppedError) as info:
83
+ proxy_request(
84
+ proxy_url="https://proxy.example.com",
85
+ bearer_token="jwt",
86
+ provider_url="https://api.openai.com",
87
+ provider_auth="Bearer sk-test",
88
+ body={"model": "gpt-4o-mini", "messages": []},
89
+ model="gpt-4o-mini",
90
+ provider="openai",
91
+ operation="chat.completions",
92
+ correlation_id="c-1",
93
+ environment=None,
94
+ retry=RetryConfig(max_attempts=3),
95
+ http_client=_client(handler),
96
+ )
97
+ _assert_stop_detail(info.value)
98
+ assert calls[0] == 1
99
+
100
+
101
+ def _admit(handler, **kw):
102
+ return admit_request(
103
+ base_url="https://proxy.example.com",
104
+ bearer_token="jwt",
105
+ max_layer="govern",
106
+ metadata={"provider": "openai", "model": "gpt-4o-mini", "workflow_id": "run-abc"},
107
+ http_client=_client(handler),
108
+ **kw,
109
+ )
110
+
111
+
112
+ def test_admit_raises_run_stopped_even_with_fail_open():
113
+ with pytest.raises(TensorCostRunStoppedError) as info:
114
+ _admit(lambda _r: _resp(403, STOPPED_BODY, {"x-tc-request-id": "req-9"}), fail_open=True)
115
+ _assert_stop_detail(info.value)
116
+ assert info.value.request_id == "req-9"
117
+
118
+
119
+ def test_admit_other_403_is_still_a_plain_refusal():
120
+ allowed, header = _admit(
121
+ lambda _r: _resp(
122
+ 403,
123
+ {"error": {"code": "MODEL_GOVERNANCE_DENIED"}},
124
+ {"x-tc-decision": "layer=govern; action=refuse"},
125
+ )
126
+ )
127
+ assert allowed is False
128
+ assert header == "layer=govern; action=refuse"
129
+
130
+
131
+ def test_admit_403_with_non_json_body_is_a_plain_refusal():
132
+ allowed, _ = _admit(lambda _r: _resp(403, b"<html>403</html>"))
133
+ assert allowed is False
134
+
135
+
136
+ def test_admit_still_fails_open_on_transport_error_and_5xx():
137
+ def boom(_r):
138
+ raise httpx.ConnectError("refused")
139
+
140
+ assert _admit(boom) == (True, None)
141
+ assert _admit(lambda _r: _resp(503, {})) == (True, None)
142
+ assert _admit(lambda _r: _resp(204, b"", {"x-tc-decision": "action=allow"}))[0] is True
143
+
144
+
145
+ def test_admit_5xx_raises_when_fail_open_is_off():
146
+ with pytest.raises(httpx.HTTPStatusError):
147
+ _admit(lambda _r: _resp(503, {}), fail_open=False)
148
+
149
+
150
+ def test_unrelated_403_on_the_chat_path_stays_a_provider_error():
151
+ assert (
152
+ _terminal_proxy_refusal_error(
153
+ json.dumps({"error": {"code": "FORBIDDEN"}}).encode(), request_id=None, attempt=1
154
+ )
155
+ is None
156
+ )
157
+ assert should_fall_back_to_provider(TensorCostProviderError("403")) is True
@@ -5,7 +5,7 @@ from tensorcost._providers._detect import UnsupportedClientError
5
5
 
6
6
 
7
7
  def test_version_string():
8
- assert __version__ == "1.3.0"
8
+ assert __version__ == "1.4.0"
9
9
 
10
10
 
11
11
  def test_wrap_returns_same_object(openai_client):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes