tacet 0.1.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.
- tacet-0.1.0/.gitignore +25 -0
- tacet-0.1.0/LICENSE +21 -0
- tacet-0.1.0/PKG-INFO +108 -0
- tacet-0.1.0/README.md +86 -0
- tacet-0.1.0/pyproject.toml +37 -0
- tacet-0.1.0/src/tacet/__init__.py +57 -0
- tacet-0.1.0/src/tacet/_async_client.py +121 -0
- tacet-0.1.0/src/tacet/_base_client.py +148 -0
- tacet-0.1.0/src/tacet/_client.py +121 -0
- tacet-0.1.0/src/tacet/_errors.py +83 -0
- tacet-0.1.0/src/tacet/_models.py +175 -0
- tacet-0.1.0/src/tacet/_questions.py +41 -0
- tacet-0.1.0/src/tacet/_version.py +1 -0
- tacet-0.1.0/src/tacet/py.typed +0 -0
- tacet-0.1.0/tests/_helpers.py +92 -0
- tacet-0.1.0/tests/test_auth.py +53 -0
- tacet-0.1.0/tests/test_decide.py +74 -0
- tacet-0.1.0/tests/test_errors.py +69 -0
- tacet-0.1.0/tests/test_models_and_health.py +67 -0
- tacet-0.1.0/tests/test_questions.py +41 -0
- tacet-0.1.0/tests/test_retry.py +125 -0
- tacet-0.1.0/tests/test_user_agent.py +39 -0
- tacet-0.1.0/uv.lock +342 -0
tacet-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
.venv/
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.mypy_cache/
|
|
11
|
+
|
|
12
|
+
# Node / JS
|
|
13
|
+
node_modules/
|
|
14
|
+
*.tgz
|
|
15
|
+
.turbo/
|
|
16
|
+
|
|
17
|
+
# Editors / OS
|
|
18
|
+
.vscode/
|
|
19
|
+
.idea/
|
|
20
|
+
.DS_Store
|
|
21
|
+
Thumbs.db
|
|
22
|
+
|
|
23
|
+
# Secrets - never commit
|
|
24
|
+
.env
|
|
25
|
+
*.key
|
tacet-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CodePawl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tacet-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tacet
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the Tacet API
|
|
5
|
+
Project-URL: Homepage, https://tacet.codepawl.com
|
|
6
|
+
Project-URL: Documentation, https://tacet.codepawl.com/docs
|
|
7
|
+
Author-email: CodePawl <hello@codepawl.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: api client,classification,llm,tacet,typed decisions
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Requires-Dist: httpx>=0.23
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# tacet
|
|
24
|
+
|
|
25
|
+
Official Python client for the [Tacet API](https://tacet.codepawl.com/docs).
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install tacet
|
|
31
|
+
# or
|
|
32
|
+
uv add tacet
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from tacet import Tacet, choice, score, noul
|
|
39
|
+
|
|
40
|
+
client = Tacet(api_key="tacet_sk_...") # or set TACET_API_KEY
|
|
41
|
+
|
|
42
|
+
result = client.decide(
|
|
43
|
+
state="Customer says their export has been stuck at 'processing' for two days.",
|
|
44
|
+
questions={
|
|
45
|
+
"routing_team": choice(
|
|
46
|
+
"Which team should own this ticket?",
|
|
47
|
+
{"billing": "Payment or invoicing issues", "support": "Product or account issues"},
|
|
48
|
+
),
|
|
49
|
+
"urgency": score(
|
|
50
|
+
"How urgent is this ticket?",
|
|
51
|
+
["low", "medium", "high", "critical"],
|
|
52
|
+
),
|
|
53
|
+
"needs_human": noul("Does this ticket need a human to step in?"),
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
print(result.answers["routing_team"].choice)
|
|
58
|
+
print(result.answers["urgency"].score)
|
|
59
|
+
print(result.answers["needs_human"].noul)
|
|
60
|
+
print(result.usage.input_tokens)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
An `AsyncTacet` client with the same methods, `await`ed, is available for
|
|
64
|
+
async code.
|
|
65
|
+
|
|
66
|
+
## Question types
|
|
67
|
+
|
|
68
|
+
- **choice**: pick one option out of a named set. `criteria` maps option
|
|
69
|
+
names to descriptions. The answer carries `choice`, `probabilities` (one
|
|
70
|
+
per option), and `confidence`.
|
|
71
|
+
- **score**: an expected level over an ordered list of levels. `criteria`
|
|
72
|
+
is that list. The answer carries `score`, `probabilities` (one per level
|
|
73
|
+
index), and `confidence`.
|
|
74
|
+
- **noul**: yes or no. `noul` is the probability of yes, from 0 to 1, and
|
|
75
|
+
an optional `criteria` object says what "true" and "false" mean. The answer
|
|
76
|
+
carries `noul` and `confidence`, no probabilities.
|
|
77
|
+
|
|
78
|
+
The `choice`, `score`, and `noul` helpers build the question dict for you;
|
|
79
|
+
you can also pass the dict shape directly.
|
|
80
|
+
|
|
81
|
+
## Errors and retries
|
|
82
|
+
|
|
83
|
+
Every non-2xx response raises a subclass of `TacetError`, which carries
|
|
84
|
+
`status`, `code`, `error_type`, `param`, `request_id`, and `message`:
|
|
85
|
+
|
|
86
|
+
- `AuthenticationError`: 401 (missing, invalid, or revoked API key)
|
|
87
|
+
- `InsufficientCreditError`: 402 (out of credit)
|
|
88
|
+
- `RateLimitError`: 429, also carries `retry_after` seconds
|
|
89
|
+
- `InvalidRequestError`: 400/404/413 (bad request, unknown model, state too large, etc.)
|
|
90
|
+
- `APIError`: 5xx
|
|
91
|
+
- `APIConnectionError`: the request failed before a response came back
|
|
92
|
+
|
|
93
|
+
The client retries 429s (honoring the `Retry-After` header) and 500/502/503/504
|
|
94
|
+
responses and connection failures, with exponential backoff and jitter, up to
|
|
95
|
+
`max_retries` times (default 2). Other 4xx errors fail immediately and are
|
|
96
|
+
never retried.
|
|
97
|
+
|
|
98
|
+
## Idle starts
|
|
99
|
+
|
|
100
|
+
If the API hasn't been called in a while, the first request after that idle
|
|
101
|
+
period can take tens of seconds while it starts back up. The default timeout
|
|
102
|
+
(90 seconds) accounts for this; don't lower it unless you know your traffic
|
|
103
|
+
is steady.
|
|
104
|
+
|
|
105
|
+
## Pricing
|
|
106
|
+
|
|
107
|
+
$0.042 per 1M input tokens, and output is free. Details are in the
|
|
108
|
+
[API docs](https://tacet.codepawl.com/docs).
|
tacet-0.1.0/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# tacet
|
|
2
|
+
|
|
3
|
+
Official Python client for the [Tacet API](https://tacet.codepawl.com/docs).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install tacet
|
|
9
|
+
# or
|
|
10
|
+
uv add tacet
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from tacet import Tacet, choice, score, noul
|
|
17
|
+
|
|
18
|
+
client = Tacet(api_key="tacet_sk_...") # or set TACET_API_KEY
|
|
19
|
+
|
|
20
|
+
result = client.decide(
|
|
21
|
+
state="Customer says their export has been stuck at 'processing' for two days.",
|
|
22
|
+
questions={
|
|
23
|
+
"routing_team": choice(
|
|
24
|
+
"Which team should own this ticket?",
|
|
25
|
+
{"billing": "Payment or invoicing issues", "support": "Product or account issues"},
|
|
26
|
+
),
|
|
27
|
+
"urgency": score(
|
|
28
|
+
"How urgent is this ticket?",
|
|
29
|
+
["low", "medium", "high", "critical"],
|
|
30
|
+
),
|
|
31
|
+
"needs_human": noul("Does this ticket need a human to step in?"),
|
|
32
|
+
},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
print(result.answers["routing_team"].choice)
|
|
36
|
+
print(result.answers["urgency"].score)
|
|
37
|
+
print(result.answers["needs_human"].noul)
|
|
38
|
+
print(result.usage.input_tokens)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
An `AsyncTacet` client with the same methods, `await`ed, is available for
|
|
42
|
+
async code.
|
|
43
|
+
|
|
44
|
+
## Question types
|
|
45
|
+
|
|
46
|
+
- **choice**: pick one option out of a named set. `criteria` maps option
|
|
47
|
+
names to descriptions. The answer carries `choice`, `probabilities` (one
|
|
48
|
+
per option), and `confidence`.
|
|
49
|
+
- **score**: an expected level over an ordered list of levels. `criteria`
|
|
50
|
+
is that list. The answer carries `score`, `probabilities` (one per level
|
|
51
|
+
index), and `confidence`.
|
|
52
|
+
- **noul**: yes or no. `noul` is the probability of yes, from 0 to 1, and
|
|
53
|
+
an optional `criteria` object says what "true" and "false" mean. The answer
|
|
54
|
+
carries `noul` and `confidence`, no probabilities.
|
|
55
|
+
|
|
56
|
+
The `choice`, `score`, and `noul` helpers build the question dict for you;
|
|
57
|
+
you can also pass the dict shape directly.
|
|
58
|
+
|
|
59
|
+
## Errors and retries
|
|
60
|
+
|
|
61
|
+
Every non-2xx response raises a subclass of `TacetError`, which carries
|
|
62
|
+
`status`, `code`, `error_type`, `param`, `request_id`, and `message`:
|
|
63
|
+
|
|
64
|
+
- `AuthenticationError`: 401 (missing, invalid, or revoked API key)
|
|
65
|
+
- `InsufficientCreditError`: 402 (out of credit)
|
|
66
|
+
- `RateLimitError`: 429, also carries `retry_after` seconds
|
|
67
|
+
- `InvalidRequestError`: 400/404/413 (bad request, unknown model, state too large, etc.)
|
|
68
|
+
- `APIError`: 5xx
|
|
69
|
+
- `APIConnectionError`: the request failed before a response came back
|
|
70
|
+
|
|
71
|
+
The client retries 429s (honoring the `Retry-After` header) and 500/502/503/504
|
|
72
|
+
responses and connection failures, with exponential backoff and jitter, up to
|
|
73
|
+
`max_retries` times (default 2). Other 4xx errors fail immediately and are
|
|
74
|
+
never retried.
|
|
75
|
+
|
|
76
|
+
## Idle starts
|
|
77
|
+
|
|
78
|
+
If the API hasn't been called in a while, the first request after that idle
|
|
79
|
+
period can take tens of seconds while it starts back up. The default timeout
|
|
80
|
+
(90 seconds) accounts for this; don't lower it unless you know your traffic
|
|
81
|
+
is steady.
|
|
82
|
+
|
|
83
|
+
## Pricing
|
|
84
|
+
|
|
85
|
+
$0.042 per 1M input tokens, and output is free. Details are in the
|
|
86
|
+
[API docs](https://tacet.codepawl.com/docs).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tacet"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for the Tacet API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "CodePawl", email = "hello@codepawl.com" }]
|
|
13
|
+
keywords = ["tacet", "typed decisions", "classification", "api client", "llm"]
|
|
14
|
+
dependencies = ["httpx>=0.23"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://tacet.codepawl.com"
|
|
28
|
+
Documentation = "https://tacet.codepawl.com/docs"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = ["pytest>=7", "pytest-asyncio>=0.23"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/tacet"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Official Python client for the Tacet API."""
|
|
2
|
+
|
|
3
|
+
from ._async_client import AsyncTacet
|
|
4
|
+
from ._client import Tacet
|
|
5
|
+
from ._errors import (
|
|
6
|
+
APIConnectionError,
|
|
7
|
+
APIError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
InsufficientCreditError,
|
|
10
|
+
InvalidRequestError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
TacetError,
|
|
13
|
+
)
|
|
14
|
+
from ._models import (
|
|
15
|
+
Answer,
|
|
16
|
+
ChoiceAnswer,
|
|
17
|
+
DecideResult,
|
|
18
|
+
HealthStatus,
|
|
19
|
+
Model,
|
|
20
|
+
ModelList,
|
|
21
|
+
ModelPricing,
|
|
22
|
+
NoulAnswer,
|
|
23
|
+
ScoreAnswer,
|
|
24
|
+
Usage,
|
|
25
|
+
)
|
|
26
|
+
from ._questions import Question, choice, noul, score
|
|
27
|
+
from ._version import __version__
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Tacet",
|
|
31
|
+
"AsyncTacet",
|
|
32
|
+
"__version__",
|
|
33
|
+
# question builders
|
|
34
|
+
"choice",
|
|
35
|
+
"score",
|
|
36
|
+
"noul",
|
|
37
|
+
"Question",
|
|
38
|
+
# result types
|
|
39
|
+
"DecideResult",
|
|
40
|
+
"Answer",
|
|
41
|
+
"ChoiceAnswer",
|
|
42
|
+
"ScoreAnswer",
|
|
43
|
+
"NoulAnswer",
|
|
44
|
+
"Usage",
|
|
45
|
+
"Model",
|
|
46
|
+
"ModelList",
|
|
47
|
+
"ModelPricing",
|
|
48
|
+
"HealthStatus",
|
|
49
|
+
# errors
|
|
50
|
+
"TacetError",
|
|
51
|
+
"AuthenticationError",
|
|
52
|
+
"InsufficientCreditError",
|
|
53
|
+
"RateLimitError",
|
|
54
|
+
"InvalidRequestError",
|
|
55
|
+
"APIError",
|
|
56
|
+
"APIConnectionError",
|
|
57
|
+
]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Asynchronous Tacet client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, Awaitable, Callable, Dict, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._base_client import (
|
|
12
|
+
DEFAULT_BASE_URL,
|
|
13
|
+
build_decide_payload,
|
|
14
|
+
build_headers,
|
|
15
|
+
compute_backoff_seconds,
|
|
16
|
+
error_from_response,
|
|
17
|
+
should_retry_status,
|
|
18
|
+
)
|
|
19
|
+
from ._errors import APIConnectionError
|
|
20
|
+
from ._models import (
|
|
21
|
+
DecideResult,
|
|
22
|
+
HealthStatus,
|
|
23
|
+
ModelList,
|
|
24
|
+
decode_decide_response,
|
|
25
|
+
decode_health,
|
|
26
|
+
decode_model_list,
|
|
27
|
+
)
|
|
28
|
+
from ._questions import Question
|
|
29
|
+
from ._version import __version__
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AsyncTacet:
|
|
33
|
+
"""Asynchronous client for the Tacet API."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_key: Optional[str] = None,
|
|
38
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
39
|
+
timeout: float = 90.0,
|
|
40
|
+
max_retries: int = 2,
|
|
41
|
+
*,
|
|
42
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
43
|
+
sleep_function: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
44
|
+
) -> None:
|
|
45
|
+
resolved_api_key = api_key or os.environ.get("TACET_API_KEY")
|
|
46
|
+
if not resolved_api_key:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"No Tacet API key provided. Pass api_key=... to AsyncTacet(), or set the "
|
|
49
|
+
"TACET_API_KEY environment variable."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
self.api_key = resolved_api_key
|
|
53
|
+
self.base_url = base_url.rstrip("/")
|
|
54
|
+
self.max_retries = max_retries
|
|
55
|
+
self._sleep_function = sleep_function
|
|
56
|
+
self._http_client = http_client or httpx.AsyncClient(timeout=timeout)
|
|
57
|
+
|
|
58
|
+
async def close(self) -> None:
|
|
59
|
+
await self._http_client.aclose()
|
|
60
|
+
|
|
61
|
+
async def __aenter__(self) -> "AsyncTacet":
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
async def __aexit__(self, *exception_info: object) -> None:
|
|
65
|
+
await self.close()
|
|
66
|
+
|
|
67
|
+
async def decide(
|
|
68
|
+
self,
|
|
69
|
+
state: Any,
|
|
70
|
+
questions: Dict[str, Question],
|
|
71
|
+
model: Optional[str] = "tacet-1",
|
|
72
|
+
) -> DecideResult:
|
|
73
|
+
"""Ask the model to answer ``questions`` about ``state``."""
|
|
74
|
+
payload = build_decide_payload(state, questions, model)
|
|
75
|
+
response = await self._request("POST", "/systemone", json_body=payload)
|
|
76
|
+
return decode_decide_response(response.json())
|
|
77
|
+
|
|
78
|
+
async def models(self) -> ModelList:
|
|
79
|
+
"""List the models available through the API."""
|
|
80
|
+
response = await self._request("GET", "/models")
|
|
81
|
+
return decode_model_list(response.json())
|
|
82
|
+
|
|
83
|
+
async def health(self) -> HealthStatus:
|
|
84
|
+
"""Check API health."""
|
|
85
|
+
response = await self._request("GET", "/health")
|
|
86
|
+
return decode_health(response.json())
|
|
87
|
+
|
|
88
|
+
async def _request(
|
|
89
|
+
self,
|
|
90
|
+
method: str,
|
|
91
|
+
path: str,
|
|
92
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
93
|
+
) -> httpx.Response:
|
|
94
|
+
headers = build_headers(self.api_key, f"tacet-python/{__version__}")
|
|
95
|
+
url = f"{self.base_url}{path}"
|
|
96
|
+
|
|
97
|
+
attempt_number = 0
|
|
98
|
+
while True:
|
|
99
|
+
try:
|
|
100
|
+
response = await self._http_client.request(method, url, headers=headers, json=json_body)
|
|
101
|
+
except httpx.HTTPError as network_error:
|
|
102
|
+
if attempt_number >= self.max_retries:
|
|
103
|
+
raise APIConnectionError(
|
|
104
|
+
f"Connection to Tacet API failed: {network_error}"
|
|
105
|
+
) from network_error
|
|
106
|
+
await self._sleep_function(compute_backoff_seconds(attempt_number, None))
|
|
107
|
+
attempt_number += 1
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
if response.status_code < 400:
|
|
111
|
+
return response
|
|
112
|
+
|
|
113
|
+
error = error_from_response(response)
|
|
114
|
+
|
|
115
|
+
if attempt_number < self.max_retries and should_retry_status(response.status_code):
|
|
116
|
+
retry_after_seconds = getattr(error, "retry_after", None)
|
|
117
|
+
await self._sleep_function(compute_backoff_seconds(attempt_number, retry_after_seconds))
|
|
118
|
+
attempt_number += 1
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
raise error
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Request/response plumbing shared by the sync and async Tacet clients.
|
|
2
|
+
|
|
3
|
+
Kept free of any actual HTTP I/O so both ``Tacet`` and ``AsyncTacet`` can
|
|
4
|
+
reuse it without duplicating the header, payload, error-decoding, and
|
|
5
|
+
retry-timing logic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ._errors import (
|
|
16
|
+
APIError,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
InsufficientCreditError,
|
|
19
|
+
InvalidRequestError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
TacetError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://tacet.codepawl.com/v1"
|
|
25
|
+
|
|
26
|
+
_RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
|
|
27
|
+
|
|
28
|
+
_DEFAULT_RETRY_AFTER_SECONDS = 1.0
|
|
29
|
+
_MAX_BACKOFF_SECONDS = 30.0
|
|
30
|
+
_JITTER_FRACTION = 0.1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_headers(api_key: str, user_agent: str) -> Dict[str, str]:
|
|
34
|
+
return {
|
|
35
|
+
"Authorization": f"Bearer {api_key}",
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
"User-Agent": user_agent,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_decide_payload(
|
|
42
|
+
state: Any,
|
|
43
|
+
questions: Dict[str, Any],
|
|
44
|
+
model: Optional[str],
|
|
45
|
+
) -> Dict[str, Any]:
|
|
46
|
+
payload: Dict[str, Any] = {"state": state, "questions": questions}
|
|
47
|
+
if model is not None:
|
|
48
|
+
payload["model"] = model
|
|
49
|
+
return payload
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def should_retry_status(status_code: int) -> bool:
|
|
53
|
+
return status_code in _RETRYABLE_STATUS_CODES
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_retry_after_seconds(header_value: Optional[str]) -> float:
|
|
57
|
+
if header_value is None:
|
|
58
|
+
return _DEFAULT_RETRY_AFTER_SECONDS
|
|
59
|
+
try:
|
|
60
|
+
return float(header_value)
|
|
61
|
+
except ValueError:
|
|
62
|
+
return _DEFAULT_RETRY_AFTER_SECONDS
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def compute_backoff_seconds(attempt_number: int, retry_after_seconds: Optional[float]) -> float:
|
|
66
|
+
"""Delay before the next attempt.
|
|
67
|
+
|
|
68
|
+
Honours the server's ``Retry-After`` when given; otherwise exponential
|
|
69
|
+
backoff with jitter, capped at ``_MAX_BACKOFF_SECONDS``.
|
|
70
|
+
"""
|
|
71
|
+
if retry_after_seconds is not None:
|
|
72
|
+
return retry_after_seconds
|
|
73
|
+
|
|
74
|
+
base_delay_seconds = min(2**attempt_number, _MAX_BACKOFF_SECONDS)
|
|
75
|
+
jitter_seconds = random.uniform(0, base_delay_seconds * _JITTER_FRACTION)
|
|
76
|
+
return base_delay_seconds + jitter_seconds
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def error_from_response(response: httpx.Response) -> TacetError:
|
|
80
|
+
"""Build the right ``TacetError`` subclass from a non-2xx response."""
|
|
81
|
+
status_code = response.status_code
|
|
82
|
+
request_id = response.headers.get("X-Request-Id")
|
|
83
|
+
|
|
84
|
+
message = f"Tacet API request failed with status {status_code}"
|
|
85
|
+
code: Optional[str] = None
|
|
86
|
+
error_type: Optional[str] = None
|
|
87
|
+
param: Optional[str] = None
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
error_envelope = response.json().get("error", {})
|
|
91
|
+
except ValueError:
|
|
92
|
+
error_envelope = {}
|
|
93
|
+
|
|
94
|
+
message = error_envelope.get("message", message)
|
|
95
|
+
code = error_envelope.get("code")
|
|
96
|
+
error_type = error_envelope.get("type")
|
|
97
|
+
param = error_envelope.get("param")
|
|
98
|
+
|
|
99
|
+
if status_code == 401:
|
|
100
|
+
return AuthenticationError(
|
|
101
|
+
message,
|
|
102
|
+
status=status_code,
|
|
103
|
+
code=code,
|
|
104
|
+
error_type=error_type,
|
|
105
|
+
param=param,
|
|
106
|
+
request_id=request_id,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if status_code == 402:
|
|
110
|
+
return InsufficientCreditError(
|
|
111
|
+
message,
|
|
112
|
+
status=status_code,
|
|
113
|
+
code=code,
|
|
114
|
+
error_type=error_type,
|
|
115
|
+
param=param,
|
|
116
|
+
request_id=request_id,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if status_code == 429:
|
|
120
|
+
retry_after_seconds = parse_retry_after_seconds(response.headers.get("Retry-After"))
|
|
121
|
+
return RateLimitError(
|
|
122
|
+
message,
|
|
123
|
+
retry_after=retry_after_seconds,
|
|
124
|
+
status=status_code,
|
|
125
|
+
code=code,
|
|
126
|
+
error_type=error_type,
|
|
127
|
+
param=param,
|
|
128
|
+
request_id=request_id,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if status_code in (400, 404, 413):
|
|
132
|
+
return InvalidRequestError(
|
|
133
|
+
message,
|
|
134
|
+
status=status_code,
|
|
135
|
+
code=code,
|
|
136
|
+
error_type=error_type,
|
|
137
|
+
param=param,
|
|
138
|
+
request_id=request_id,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return APIError(
|
|
142
|
+
message,
|
|
143
|
+
status=status_code,
|
|
144
|
+
code=code,
|
|
145
|
+
error_type=error_type,
|
|
146
|
+
param=param,
|
|
147
|
+
request_id=request_id,
|
|
148
|
+
)
|