tacet 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.
- tacet/__init__.py +57 -0
- tacet/_async_client.py +121 -0
- tacet/_base_client.py +148 -0
- tacet/_client.py +121 -0
- tacet/_errors.py +83 -0
- tacet/_models.py +175 -0
- tacet/_questions.py +41 -0
- tacet/_version.py +1 -0
- tacet/py.typed +0 -0
- tacet-0.1.0.dist-info/METADATA +108 -0
- tacet-0.1.0.dist-info/RECORD +13 -0
- tacet-0.1.0.dist-info/WHEEL +4 -0
- tacet-0.1.0.dist-info/licenses/LICENSE +21 -0
tacet/__init__.py
ADDED
|
@@ -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
|
+
]
|
tacet/_async_client.py
ADDED
|
@@ -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
|
tacet/_base_client.py
ADDED
|
@@ -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
|
+
)
|
tacet/_client.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Synchronous Tacet client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, 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 Tacet:
|
|
33
|
+
"""Synchronous 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.Client] = None,
|
|
43
|
+
sleep_function: Callable[[float], None] = time.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 Tacet(), 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.Client(timeout=timeout)
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
self._http_client.close()
|
|
60
|
+
|
|
61
|
+
def __enter__(self) -> "Tacet":
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
def __exit__(self, *exception_info: object) -> None:
|
|
65
|
+
self.close()
|
|
66
|
+
|
|
67
|
+
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 = self._request("POST", "/systemone", json_body=payload)
|
|
76
|
+
return decode_decide_response(response.json())
|
|
77
|
+
|
|
78
|
+
def models(self) -> ModelList:
|
|
79
|
+
"""List the models available through the API."""
|
|
80
|
+
response = self._request("GET", "/models")
|
|
81
|
+
return decode_model_list(response.json())
|
|
82
|
+
|
|
83
|
+
def health(self) -> HealthStatus:
|
|
84
|
+
"""Check API health."""
|
|
85
|
+
response = self._request("GET", "/health")
|
|
86
|
+
return decode_health(response.json())
|
|
87
|
+
|
|
88
|
+
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 = 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
|
+
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
|
+
self._sleep_function(compute_backoff_seconds(attempt_number, retry_after_seconds))
|
|
118
|
+
attempt_number += 1
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
raise error
|
tacet/_errors.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Exception hierarchy raised by the Tacet client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional, Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TacetError(Exception):
|
|
9
|
+
"""Base class for every error the Tacet client can raise.
|
|
10
|
+
|
|
11
|
+
Attributes mirror the fields Tacet's error envelope sends back, plus the
|
|
12
|
+
HTTP status and the request id from the ``X-Request-Id`` response header.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
message: str,
|
|
18
|
+
*,
|
|
19
|
+
status: Optional[int] = None,
|
|
20
|
+
code: Optional[str] = None,
|
|
21
|
+
error_type: Optional[str] = None,
|
|
22
|
+
param: Optional[str] = None,
|
|
23
|
+
request_id: Optional[str] = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.message = message
|
|
27
|
+
self.status = status
|
|
28
|
+
self.code = code
|
|
29
|
+
self.error_type = error_type
|
|
30
|
+
self.param = param
|
|
31
|
+
self.request_id = request_id
|
|
32
|
+
|
|
33
|
+
def __str__(self) -> str:
|
|
34
|
+
return self.message
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AuthenticationError(TacetError):
|
|
38
|
+
"""Raised for HTTP 401 responses (missing, invalid, or revoked API key)."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InsufficientCreditError(TacetError):
|
|
42
|
+
"""Raised for HTTP 402 responses when the account is out of credit."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RateLimitError(TacetError):
|
|
46
|
+
"""Raised for HTTP 429 responses.
|
|
47
|
+
|
|
48
|
+
``retry_after`` is the number of seconds the server asked the caller to
|
|
49
|
+
wait, taken from the ``Retry-After`` response header.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
message: str,
|
|
55
|
+
*,
|
|
56
|
+
retry_after: Union[int, float],
|
|
57
|
+
status: Optional[int] = None,
|
|
58
|
+
code: Optional[str] = None,
|
|
59
|
+
error_type: Optional[str] = None,
|
|
60
|
+
param: Optional[str] = None,
|
|
61
|
+
request_id: Optional[str] = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
super().__init__(
|
|
64
|
+
message,
|
|
65
|
+
status=status,
|
|
66
|
+
code=code,
|
|
67
|
+
error_type=error_type,
|
|
68
|
+
param=param,
|
|
69
|
+
request_id=request_id,
|
|
70
|
+
)
|
|
71
|
+
self.retry_after = retry_after
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class InvalidRequestError(TacetError):
|
|
75
|
+
"""Raised for HTTP 400/404/413 responses that are not auth, credit, or rate-limit errors."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class APIError(TacetError):
|
|
79
|
+
"""Raised for HTTP 5xx responses."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class APIConnectionError(TacetError):
|
|
83
|
+
"""Raised when a request fails before a response is received (network or connection failure)."""
|
tacet/_models.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Typed result objects returned by the Tacet client, and their JSON decoders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, Iterator, List, Optional, Union
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ChoiceAnswer:
|
|
11
|
+
"""Answer to a "choice" question."""
|
|
12
|
+
|
|
13
|
+
choice: str
|
|
14
|
+
probabilities: Dict[str, float]
|
|
15
|
+
confidence: float
|
|
16
|
+
type: str = "choice"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ScoreAnswer:
|
|
21
|
+
"""Answer to a "score" question. ``score`` is the expected value."""
|
|
22
|
+
|
|
23
|
+
score: float
|
|
24
|
+
probabilities: Dict[str, float]
|
|
25
|
+
confidence: float
|
|
26
|
+
type: str = "score"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class NoulAnswer:
|
|
31
|
+
"""Answer to a "noul" question. ``noul`` is a 0.0-1.0 value, no probabilities."""
|
|
32
|
+
|
|
33
|
+
noul: float
|
|
34
|
+
confidence: float
|
|
35
|
+
type: str = "noul"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
Answer = Union[ChoiceAnswer, ScoreAnswer, NoulAnswer]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Usage:
|
|
43
|
+
input_tokens: int
|
|
44
|
+
output_tokens: int
|
|
45
|
+
state_truncated: Optional[bool] = None
|
|
46
|
+
options_truncated: Optional[bool] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class DecideResult:
|
|
51
|
+
"""Result of a call to ``decide()``."""
|
|
52
|
+
|
|
53
|
+
model: str
|
|
54
|
+
answers: Dict[str, Answer]
|
|
55
|
+
usage: Usage
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class ModelPricing:
|
|
60
|
+
input_per_million_tokens_usd: float
|
|
61
|
+
output_per_million_tokens_usd: float
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Model:
|
|
66
|
+
id: str
|
|
67
|
+
object: str
|
|
68
|
+
owned_by: str
|
|
69
|
+
name: str
|
|
70
|
+
context_length: int
|
|
71
|
+
max_output_tokens: int
|
|
72
|
+
pricing: ModelPricing
|
|
73
|
+
features: List[str] = field(default_factory=list)
|
|
74
|
+
datacenter: Optional[str] = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class ModelList:
|
|
79
|
+
"""Result of a call to ``models()``."""
|
|
80
|
+
|
|
81
|
+
object: str
|
|
82
|
+
data: List[Model]
|
|
83
|
+
|
|
84
|
+
def __iter__(self) -> Iterator[Model]:
|
|
85
|
+
return iter(self.data)
|
|
86
|
+
|
|
87
|
+
def __len__(self) -> int:
|
|
88
|
+
return len(self.data)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class HealthStatus:
|
|
93
|
+
"""Result of a call to ``health()``."""
|
|
94
|
+
|
|
95
|
+
ok: bool
|
|
96
|
+
model: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def decode_answer(question_id: str, raw_answer: Dict[str, Any]) -> Answer:
|
|
100
|
+
answer_type = raw_answer.get("type")
|
|
101
|
+
|
|
102
|
+
if answer_type == "choice":
|
|
103
|
+
return ChoiceAnswer(
|
|
104
|
+
choice=raw_answer["choice"],
|
|
105
|
+
probabilities=raw_answer.get("probabilities", {}),
|
|
106
|
+
confidence=raw_answer["confidence"],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if answer_type == "score":
|
|
110
|
+
return ScoreAnswer(
|
|
111
|
+
score=raw_answer["score"],
|
|
112
|
+
probabilities=raw_answer.get("probabilities", {}),
|
|
113
|
+
confidence=raw_answer["confidence"],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if answer_type == "noul":
|
|
117
|
+
return NoulAnswer(
|
|
118
|
+
noul=raw_answer["noul"],
|
|
119
|
+
confidence=raw_answer["confidence"],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
raise ValueError(f"Unknown answer type {answer_type!r} for question {question_id!r}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def decode_usage(raw_usage: Dict[str, Any]) -> Usage:
|
|
126
|
+
return Usage(
|
|
127
|
+
input_tokens=raw_usage["input_tokens"],
|
|
128
|
+
output_tokens=raw_usage["output_tokens"],
|
|
129
|
+
state_truncated=raw_usage.get("state_truncated"),
|
|
130
|
+
options_truncated=raw_usage.get("options_truncated"),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def decode_decide_response(raw_response: Dict[str, Any]) -> DecideResult:
|
|
135
|
+
answers = {
|
|
136
|
+
question_id: decode_answer(question_id, raw_answer)
|
|
137
|
+
for question_id, raw_answer in raw_response.get("answers", {}).items()
|
|
138
|
+
}
|
|
139
|
+
return DecideResult(
|
|
140
|
+
model=raw_response["model"],
|
|
141
|
+
answers=answers,
|
|
142
|
+
usage=decode_usage(raw_response.get("usage", {})),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def decode_model(raw_model: Dict[str, Any]) -> Model:
|
|
147
|
+
raw_pricing = raw_model.get("pricing", {})
|
|
148
|
+
return Model(
|
|
149
|
+
id=raw_model["id"],
|
|
150
|
+
object=raw_model["object"],
|
|
151
|
+
owned_by=raw_model["owned_by"],
|
|
152
|
+
name=raw_model["name"],
|
|
153
|
+
context_length=raw_model["context_length"],
|
|
154
|
+
max_output_tokens=raw_model["max_output_tokens"],
|
|
155
|
+
pricing=ModelPricing(
|
|
156
|
+
input_per_million_tokens_usd=raw_pricing.get("input_per_million_tokens_usd", 0.0),
|
|
157
|
+
output_per_million_tokens_usd=raw_pricing.get("output_per_million_tokens_usd", 0.0),
|
|
158
|
+
),
|
|
159
|
+
features=raw_model.get("features", []),
|
|
160
|
+
datacenter=raw_model.get("datacenter"),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def decode_model_list(raw_response: Dict[str, Any]) -> ModelList:
|
|
165
|
+
return ModelList(
|
|
166
|
+
object=raw_response["object"],
|
|
167
|
+
data=[decode_model(raw_model) for raw_model in raw_response.get("data", [])],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def decode_health(raw_response: Dict[str, Any]) -> HealthStatus:
|
|
172
|
+
return HealthStatus(
|
|
173
|
+
ok=raw_response["ok"],
|
|
174
|
+
model=raw_response["model"],
|
|
175
|
+
)
|
tacet/_questions.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Convenience builders for the question shapes the Tacet API accepts.
|
|
2
|
+
|
|
3
|
+
Each builder returns a plain dict matching the request body shape documented
|
|
4
|
+
at https://tacet.codepawl.com/docs — callers can also build that dict by hand
|
|
5
|
+
and pass it into ``decide(questions=...)`` directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
11
|
+
|
|
12
|
+
Question = Dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def choice(instructions: str, options: Mapping[str, Any]) -> Question:
|
|
16
|
+
"""Build a "choice" question: pick one option out of a named set."""
|
|
17
|
+
return {
|
|
18
|
+
"type": "choice",
|
|
19
|
+
"instructions": instructions,
|
|
20
|
+
"criteria": dict(options),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def score(instructions: str, levels: List[Any]) -> Question:
|
|
25
|
+
"""Build a "score" question: an expected value over an ordered list of levels."""
|
|
26
|
+
return {
|
|
27
|
+
"type": "score",
|
|
28
|
+
"instructions": instructions,
|
|
29
|
+
"criteria": list(levels),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def noul(instructions: str, criteria: Optional[Mapping[str, Any]] = None) -> Question:
|
|
34
|
+
"""Build a "noul" question: a 0.0-1.0 true/false-leaning judgment."""
|
|
35
|
+
question: Question = {
|
|
36
|
+
"type": "noul",
|
|
37
|
+
"instructions": instructions,
|
|
38
|
+
}
|
|
39
|
+
if criteria is not None:
|
|
40
|
+
question["criteria"] = dict(criteria)
|
|
41
|
+
return question
|
tacet/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
tacet/py.typed
ADDED
|
File without changes
|
|
@@ -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).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
tacet/__init__.py,sha256=tFUrEuKPUGb2bK-f5xXmxguskcLkPVgJ3hhMya1OeP0,1076
|
|
2
|
+
tacet/_async_client.py,sha256=VwnOWS_QFsY8E3JUwxlG_ZPUZIX-ZwvSz1WSY5ldKs0,3949
|
|
3
|
+
tacet/_base_client.py,sha256=t4-lI6EEDLEmBJux-uTrN397w62XowlrvFl8OMuWziU,4102
|
|
4
|
+
tacet/_client.py,sha256=qS14gV2sdsIndtu-ibsFd5iRAAbzIwVLJjsUP16t9As,3801
|
|
5
|
+
tacet/_errors.py,sha256=b5zm_sQDbG1iU5NMlBLK6RmKu9AbI-p08bETSPenmXI,2303
|
|
6
|
+
tacet/_models.py,sha256=YtkwA3Yy_DUzmyuoQt2hI9K3X3-H4qB28YOI6hxhbVY,4736
|
|
7
|
+
tacet/_questions.py,sha256=ScqFQblG7v9xgO8bHQx3dkO_w_xzpsGAD6dtb15N19c,1286
|
|
8
|
+
tacet/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
9
|
+
tacet/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
tacet-0.1.0.dist-info/METADATA,sha256=-4EtY8RJkBc-51PIG-LinctdL2RFjhoBm3qFYWQ0yqk,3745
|
|
11
|
+
tacet-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
12
|
+
tacet-0.1.0.dist-info/licenses/LICENSE,sha256=YU_PGB_Yd5Yj8eOtpG9WxLmz8V3zf5QtkhV392fJnGk,1065
|
|
13
|
+
tacet-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|