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.
- {tensorcost-1.3.2 → tensorcost-1.4.0}/CHANGELOG.md +11 -1
- {tensorcost-1.3.2 → tensorcost-1.4.0}/PKG-INFO +1 -1
- {tensorcost-1.3.2 → tensorcost-1.4.0}/pyproject.toml +1 -1
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/__init__.py +3 -1
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_admit_client.py +34 -5
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_errors.py +59 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_proxy_client.py +6 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_version.py +1 -1
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_providers/test_anthropic.py +1 -1
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_providers/test_bedrock.py +1 -1
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_providers/test_openai.py +1 -1
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_providers/test_vertex.py +1 -1
- tensorcost-1.4.0/tests/test_run_stopped.py +157 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_wrap.py +1 -1
- {tensorcost-1.3.2 → tensorcost-1.4.0}/.gitignore +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/.py-credentials.example +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/LICENSE +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/PUBLISHING.md +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/README.md +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/scripts/load-pypi-creds.sh +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/scripts/publish.sh +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_applied_mode.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_attribution.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_circuit.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_compliance_guard.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_compliance_team.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_config.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_decision.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_dlp.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_layer.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_models.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_prewarm.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_providers/__init__.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_providers/_anthropic.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_providers/_bedrock.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_providers/_detect.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_providers/_openai.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_providers/_vertex.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_retry.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_runtime.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_sdk_layer.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_telemetry.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_transport.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/_wrap.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/src/tensorcost/py.typed +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/__init__.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/conftest.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_agent_run.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_applied_mode.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_circuit.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_config.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_customer_feature.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_dlp.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_errors.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_hardening.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_prewarm.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_providers/__init__.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_providers/test_detect.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_proxy_client.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_retry.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_sdk_layer.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_should_fall_back.py +0 -0
- {tensorcost-1.3.2 → tensorcost-1.4.0}/tests/test_telemetry.py +0 -0
- {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
|
-
##
|
|
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
|
+
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.
|
|
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.
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|