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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.