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 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
+ )