basal-client 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.
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,39 @@
1
+ basal-rs
2
+ Copyright 2026 basal-rs contributors
3
+
4
+ Licensed under the Apache License, Version 2.0 (see LICENSE).
5
+
6
+ This project reimplements in Rust the inference protocol of basal
7
+ (https://github.com/rkinas/basal, Copyright 2026 Remigiusz Kinas, Apache License 2.0) and ports parts of it:
8
+ the prompt format and letter readout (basal/prompt.py), the System One request handling and answer semantics
9
+ including multi and act (basal/server.py), facts injection (basal/facts.py -> crates/basal-core/src/facts.rs),
10
+ evidence spans (basal/evidence.py -> crates/basal-core/src/evidence.rs) and shared-prefix packing (basal/engine.py).
11
+ Reference requests in tools/reference include examples from basal/examples.
12
+
13
+ The basal model weights (Remek/basal-1.0-*, Remek/basal-1.5-*) are not part of this repository; they are released by
14
+ their author under the Apache License 2.0.
15
+
16
+ The NOTICE file of basal (v1.5.0) follows.
17
+
18
+ --------------------------------------------------------------------------------
19
+
20
+ basal
21
+ Copyright 2026 Remigiusz Kinas
22
+
23
+ This product includes models fine-tuned from the following works, licensed under the Apache License, Version 2.0:
24
+
25
+ basal-1.5-4.5B <- speakleash/Bielik-4.5B-v3.0-Instruct (https://huggingface.co/speakleash/Bielik-4.5B-v3.0-Instruct)
26
+ basal-1.5-max <- speakleash/Bielik-PL-11B-v3.0-Instruct (https://huggingface.co/speakleash/Bielik-PL-11B-v3.0-Instruct)
27
+ basal-1.5-mini <- speakleash/Bielik-1.5B-v3.0-Instruct (https://huggingface.co/speakleash/Bielik-1.5B-v3.0-Instruct)
28
+ basal-1.0-4.5B <- speakleash/Bielik-4.5B-v3.0-Instruct (https://huggingface.co/speakleash/Bielik-4.5B-v3.0-Instruct)
29
+ basal-1.0-1.5B <- speakleash/Bielik-1.5B-v3.0-Instruct (https://huggingface.co/speakleash/Bielik-1.5B-v3.0-Instruct)
30
+
31
+ The inference engine in this repository is original work licensed under the Apache License, Version 2.0.
32
+
33
+ Training data attribution (basal-1.0 models):
34
+ English decision items in the training data were converted from the public corpus
35
+ avbiswas/bev-decision-150K (https://huggingface.co/datasets/avbiswas/bev-decision-150K), which aggregates questions
36
+ derived from many upstream sources. Its maintainers ask users to check and attribute those upstream sources; row-level
37
+ source identifiers are not available, so the upstream sources of these items are not documented individually.
38
+
39
+ basal-1.5 models: released under the Apache License, Version 2.0.
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.5
2
+ Name: basal-client
3
+ Version: 0.1.0
4
+ Summary: Python client for the native Basal HTTP API
5
+ Author: IT SOL
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-Expression: Apache-2.0
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Typing :: Typed
11
+ License-File: LICENSE
12
+ License-File: NOTICE
13
+ Requires-Dist: httpx>=0.28,<1
14
+ Project-URL: Documentation, https://github.com/itsoltech/basal-rs/blob/main/docs/SDK.md
15
+ Project-URL: Source, https://github.com/itsoltech/basal-rs/tree/main/sdk/python
16
+ Import-Name: basal_client
17
+
18
+ # basal-client (Python)
19
+
20
+ HTTP SDK for the native Basal API (`POST /v1/basal`). Python 3.11+; no GPU required on the client.
21
+
22
+ This package is versioned independently. Release tag: `sdk-python-vX.Y.Z`.
23
+ [Changelog](https://github.com/itsoltech/basal-rs/blob/main/sdk/python/CHANGELOG.md).
24
+
25
+ ```sh
26
+ pip install basal-client
27
+ ```
28
+
29
+ Choose either a normal dictionary or a declarative class. Both use the same factories.
30
+ A class gives each response field its exact inferred answer type:
31
+
32
+ ```python
33
+ from basal_client import BasalClient, Questions, choice, noul
34
+
35
+ routing = choice("Do którego działu skierować zgłoszenie?", {"returns": "Zwroty", "support": "Wsparcie techniczne"})
36
+
37
+ class TicketQuestions(Questions):
38
+ department = routing
39
+ second_department = routing
40
+ urgent = noul("Czy sprawa jest pilna?")
41
+
42
+ with BasalClient("http://127.0.0.1:8000") as client:
43
+ response = client.decide("Klient prosi o zwrot pieniędzy za uszkodzony telefon.", TicketQuestions)
44
+ print(response.answers.department.choice) # str, ChoiceAnswer
45
+ print(response.answers.second_department.choice) # independent answer
46
+ print(response.answers.urgent.noul) # float, NoulAnswer
47
+ ```
48
+
49
+ Pass the class itself, without `()`. No response class, field annotations, casts, type-checker
50
+ plugin or code generator is needed. The same definition may appear under several names.
51
+
52
+ For dynamic question sets, use a dictionary and read answers by their request keys:
53
+
54
+ ```python
55
+ from basal_client import BasalClient, choice
56
+
57
+ routing = choice("Which department?", {"returns": "Returns", "support": "Support"})
58
+ with BasalClient() as client:
59
+ response = client.decide("The phone is broken.", {
60
+ "department": routing,
61
+ "second_department": routing,
62
+ })
63
+ print(response.answers["department"])
64
+ print(response.answer("second_department"))
65
+ ```
66
+
67
+ Dictionary values are checked answer dataclasses, with the static union `AnswerResult`.
68
+ Use `isinstance(answer, ChoiceAnswer)` to narrow that union when needed. Python cannot infer
69
+ an individual answer type from an arbitrary string key; the class form supplies that information.
70
+ Choice/label keys remain `str` in Python, rather than inferred literal unions.
71
+ `answer()` now takes a request key, not a question object (a change before the initial release).
72
+
73
+ Native asynchronous HTTP with connection pooling supports both forms:
74
+
75
+ ```python
76
+ from basal_client import AsyncBasalClient, Questions, noul
77
+
78
+ class Availability(Questions):
79
+ urgent = noul("Is this urgent?")
80
+
81
+ async def decide():
82
+ async with AsyncBasalClient(timeout=120) as client:
83
+ response = await client.decide("The service is unavailable.", Availability)
84
+ return response.answers.urgent.noul
85
+ ```
86
+
87
+ `models()` reads `/v1/models`. An optional `api_key` adds a bearer token
88
+ for an authenticating reverse proxy. The server root URL may include a proxy prefix; do not append `/v1`.
89
+ HTTPS is required except for loopback HTTP, suitable for local servers and SSH tunnels.
90
+ URL credentials/query/fragment are rejected. `trust_env=False` is the default: environment proxies and
91
+ certificate overrides are ignored. Set `trust_env=True` only when you intend to trust that configuration.
92
+ `max_response_bytes` defaults to 8 MiB and limits the actual decoded stream, including HTTP errors;
93
+ oversized responses raise `BasalResponseTooLargeError`. TLS verification remains enabled.
94
+ The client requests `Accept-Encoding: identity` and rejects compressed responses before decoding,
95
+ including when a proxy ignores that header. Configure proxies to return uncompressed JSON for this client.
96
+ Malformed or excessively nested JSON raises `BasalProtocolError`.
97
+ `timeout` is in seconds and applies to connect/read/write/pool phases, not a total wall-clock deadline.
98
+
99
+ `BasalHTTPError` provides `status_code`, `body` and the raw `retry_after` header. Transport failures raise
100
+ `BasalTransportError`; malformed successful JSON raises `BasalProtocolError`. Original exceptions are
101
+ available through `__cause__`. There are no automatic retries or redirects. Type hints describe the native
102
+ contract; successful responses are checked to be JSON objects, without full schema validation. Unknown
103
+ JSON fields are preserved. Server validation errors (422) and overload (529) remain distinct.
104
+
105
+ Supports `choice`, `noul`, `score`, `multi`, `act`, `facts` and `evidence`. Criterion order matters. Native
106
+ choice/score accepts 2–10 options; multi has independent probabilities and `set_confidence`. Evidence
107
+ offsets count Unicode characters. The SDK does not evaluate model decisions or perform selected actions.
108
+
109
+ Factories `choice`, `noul`, `score`, `multi`, `act` return concrete generic question classes.
110
+ Constructors `ChoiceQuestion(...)` etc. have the same signatures. Required response fields are
111
+ validated before returning; evidence defaults to an empty tuple. `response.raw` retains native
112
+ JSON and extensions. The one-argument `decide(request)` remains available for full native
113
+ JSON dictionaries. `model=` and `facts=` work with either two-argument form.
114
+
115
+ Schema fields may be inherited and overridden with the same question kind for compatibility
116
+ with both mypy and Pyright. Changing a field's kind or removing it with `None` is not supported
117
+ by mypy. Field order follows reversed Python MRO, like dataclasses: for `class D(M1, M2)`,
118
+ fields from `M2` precede those from `M1`; overriding a field retains its original position.
119
+ Names starting with `_` are reserved. Schema instances are populated by the SDK without
120
+ calling user constructors, and their fields cannot be reassigned. Define schemas declaratively;
121
+ do not mutate class definitions while requests are running. Dictionary question maps are also
122
+ snapshotted before I/O. Response dictionary keys are read-only; nested native data is not deeply frozen.
123
+
124
+ Typed response/answer containers do not support the copy/pickle protocols. For caching or
125
+ passing a result between processes, serialize `response.raw` as JSON. Use `copy.deepcopy(response.raw)`
126
+ when a detached native result is needed. Question definitions may also be stored in ordinary
127
+ classes, `ClassVar` attributes or dataclasses; outside `Questions` they remain question definitions.
128
+
129
+ [Full documentation and publication setup](https://github.com/itsoltech/basal-rs/blob/main/docs/SDK.md).
130
+
131
+ Runtime uses HTTPX with seven dependencies in the audited Python 3.11 graph. Building requires only the
132
+ hash-locked Flit Core backend; consumers of the wheel do not install the build backend.
133
+
@@ -0,0 +1,115 @@
1
+ # basal-client (Python)
2
+
3
+ HTTP SDK for the native Basal API (`POST /v1/basal`). Python 3.11+; no GPU required on the client.
4
+
5
+ This package is versioned independently. Release tag: `sdk-python-vX.Y.Z`.
6
+ [Changelog](https://github.com/itsoltech/basal-rs/blob/main/sdk/python/CHANGELOG.md).
7
+
8
+ ```sh
9
+ pip install basal-client
10
+ ```
11
+
12
+ Choose either a normal dictionary or a declarative class. Both use the same factories.
13
+ A class gives each response field its exact inferred answer type:
14
+
15
+ ```python
16
+ from basal_client import BasalClient, Questions, choice, noul
17
+
18
+ routing = choice("Do którego działu skierować zgłoszenie?", {"returns": "Zwroty", "support": "Wsparcie techniczne"})
19
+
20
+ class TicketQuestions(Questions):
21
+ department = routing
22
+ second_department = routing
23
+ urgent = noul("Czy sprawa jest pilna?")
24
+
25
+ with BasalClient("http://127.0.0.1:8000") as client:
26
+ response = client.decide("Klient prosi o zwrot pieniędzy za uszkodzony telefon.", TicketQuestions)
27
+ print(response.answers.department.choice) # str, ChoiceAnswer
28
+ print(response.answers.second_department.choice) # independent answer
29
+ print(response.answers.urgent.noul) # float, NoulAnswer
30
+ ```
31
+
32
+ Pass the class itself, without `()`. No response class, field annotations, casts, type-checker
33
+ plugin or code generator is needed. The same definition may appear under several names.
34
+
35
+ For dynamic question sets, use a dictionary and read answers by their request keys:
36
+
37
+ ```python
38
+ from basal_client import BasalClient, choice
39
+
40
+ routing = choice("Which department?", {"returns": "Returns", "support": "Support"})
41
+ with BasalClient() as client:
42
+ response = client.decide("The phone is broken.", {
43
+ "department": routing,
44
+ "second_department": routing,
45
+ })
46
+ print(response.answers["department"])
47
+ print(response.answer("second_department"))
48
+ ```
49
+
50
+ Dictionary values are checked answer dataclasses, with the static union `AnswerResult`.
51
+ Use `isinstance(answer, ChoiceAnswer)` to narrow that union when needed. Python cannot infer
52
+ an individual answer type from an arbitrary string key; the class form supplies that information.
53
+ Choice/label keys remain `str` in Python, rather than inferred literal unions.
54
+ `answer()` now takes a request key, not a question object (a change before the initial release).
55
+
56
+ Native asynchronous HTTP with connection pooling supports both forms:
57
+
58
+ ```python
59
+ from basal_client import AsyncBasalClient, Questions, noul
60
+
61
+ class Availability(Questions):
62
+ urgent = noul("Is this urgent?")
63
+
64
+ async def decide():
65
+ async with AsyncBasalClient(timeout=120) as client:
66
+ response = await client.decide("The service is unavailable.", Availability)
67
+ return response.answers.urgent.noul
68
+ ```
69
+
70
+ `models()` reads `/v1/models`. An optional `api_key` adds a bearer token
71
+ for an authenticating reverse proxy. The server root URL may include a proxy prefix; do not append `/v1`.
72
+ HTTPS is required except for loopback HTTP, suitable for local servers and SSH tunnels.
73
+ URL credentials/query/fragment are rejected. `trust_env=False` is the default: environment proxies and
74
+ certificate overrides are ignored. Set `trust_env=True` only when you intend to trust that configuration.
75
+ `max_response_bytes` defaults to 8 MiB and limits the actual decoded stream, including HTTP errors;
76
+ oversized responses raise `BasalResponseTooLargeError`. TLS verification remains enabled.
77
+ The client requests `Accept-Encoding: identity` and rejects compressed responses before decoding,
78
+ including when a proxy ignores that header. Configure proxies to return uncompressed JSON for this client.
79
+ Malformed or excessively nested JSON raises `BasalProtocolError`.
80
+ `timeout` is in seconds and applies to connect/read/write/pool phases, not a total wall-clock deadline.
81
+
82
+ `BasalHTTPError` provides `status_code`, `body` and the raw `retry_after` header. Transport failures raise
83
+ `BasalTransportError`; malformed successful JSON raises `BasalProtocolError`. Original exceptions are
84
+ available through `__cause__`. There are no automatic retries or redirects. Type hints describe the native
85
+ contract; successful responses are checked to be JSON objects, without full schema validation. Unknown
86
+ JSON fields are preserved. Server validation errors (422) and overload (529) remain distinct.
87
+
88
+ Supports `choice`, `noul`, `score`, `multi`, `act`, `facts` and `evidence`. Criterion order matters. Native
89
+ choice/score accepts 2–10 options; multi has independent probabilities and `set_confidence`. Evidence
90
+ offsets count Unicode characters. The SDK does not evaluate model decisions or perform selected actions.
91
+
92
+ Factories `choice`, `noul`, `score`, `multi`, `act` return concrete generic question classes.
93
+ Constructors `ChoiceQuestion(...)` etc. have the same signatures. Required response fields are
94
+ validated before returning; evidence defaults to an empty tuple. `response.raw` retains native
95
+ JSON and extensions. The one-argument `decide(request)` remains available for full native
96
+ JSON dictionaries. `model=` and `facts=` work with either two-argument form.
97
+
98
+ Schema fields may be inherited and overridden with the same question kind for compatibility
99
+ with both mypy and Pyright. Changing a field's kind or removing it with `None` is not supported
100
+ by mypy. Field order follows reversed Python MRO, like dataclasses: for `class D(M1, M2)`,
101
+ fields from `M2` precede those from `M1`; overriding a field retains its original position.
102
+ Names starting with `_` are reserved. Schema instances are populated by the SDK without
103
+ calling user constructors, and their fields cannot be reassigned. Define schemas declaratively;
104
+ do not mutate class definitions while requests are running. Dictionary question maps are also
105
+ snapshotted before I/O. Response dictionary keys are read-only; nested native data is not deeply frozen.
106
+
107
+ Typed response/answer containers do not support the copy/pickle protocols. For caching or
108
+ passing a result between processes, serialize `response.raw` as JSON. Use `copy.deepcopy(response.raw)`
109
+ when a detached native result is needed. Question definitions may also be stored in ordinary
110
+ classes, `ClassVar` attributes or dataclasses; outside `Questions` they remain question definitions.
111
+
112
+ [Full documentation and publication setup](https://github.com/itsoltech/basal-rs/blob/main/docs/SDK.md).
113
+
114
+ Runtime uses HTTPX with seven dependencies in the audited Python 3.11 graph. Building requires only the
115
+ hash-locked Flit Core backend; consumers of the wheel do not install the build backend.
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["flit_core==4.1.0"]
3
+ build-backend = "flit_core.buildapi"
4
+
5
+ [project]
6
+ name = "basal-client"
7
+ version = "0.1.0"
8
+ description = "Python client for the native Basal HTTP API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "Apache-2.0"
12
+ license-files = ["LICENSE", "NOTICE"]
13
+ authors = [{name = "IT SOL"}]
14
+ dependencies = ["httpx>=0.28,<1"]
15
+ classifiers = ["Programming Language :: Python :: 3", "Typing :: Typed"]
16
+
17
+ [project.urls]
18
+ Source = "https://github.com/itsoltech/basal-rs/tree/main/sdk/python"
19
+ Documentation = "https://github.com/itsoltech/basal-rs/blob/main/docs/SDK.md"
20
+
21
+ [tool.flit.module]
22
+ name = "basal_client"
@@ -0,0 +1,30 @@
1
+ """Client for the native Basal API (/v1/basal)."""
2
+
3
+ from .client import (
4
+ AsyncBasalClient, BasalClient, BasalError, BasalHTTPError,
5
+ BasalProtocolError, BasalTransportError, BasalResponseTooLargeError,
6
+ )
7
+ from .types import (
8
+ Answer, DecisionRequest, DecisionResponse, EvidenceSpan,
9
+ JsonValue, ModelMetadata, ModelsResponse, Question, Usage,
10
+ )
11
+
12
+ __all__ = [
13
+ "AsyncBasalClient", "BasalClient", "BasalError", "BasalHTTPError",
14
+ "BasalProtocolError", "BasalTransportError", "BasalResponseTooLargeError", "Answer", "DecisionRequest",
15
+ "DecisionResponse", "EvidenceSpan", "JsonValue",
16
+ "ModelMetadata", "ModelsResponse", "Question", "Usage",
17
+ ]
18
+
19
+ from .questions import (
20
+ ChoiceAnswer, NoulAnswer, ScoreAnswer, MultiAnswer, ActAnswer,
21
+ ChoiceQuestion, NoulQuestion, ScoreQuestion, MultiQuestion, ActQuestion, QuestionSpec, QuestionSet, TypedDecisionResponse,
22
+ Questions, SchemaDecisionResponse, AnswerResult,
23
+ choice, noul, score, multi, act,
24
+ )
25
+ __all__ += [
26
+ "ChoiceAnswer", "NoulAnswer", "ScoreAnswer", "MultiAnswer", "ActAnswer",
27
+ "ChoiceQuestion", "NoulQuestion", "ScoreQuestion", "MultiQuestion", "ActQuestion", "QuestionSpec", "QuestionSet", "TypedDecisionResponse",
28
+ "Questions", "SchemaDecisionResponse", "AnswerResult",
29
+ "choice", "noul", "score", "multi", "act",
30
+ ]
@@ -0,0 +1,245 @@
1
+ """Connection-pooled synchronous and asynchronous clients for the Basal API."""
2
+
3
+ import ipaddress
4
+ import json
5
+ import math
6
+ from types import TracebackType
7
+ from typing import Any, Literal, Self, cast, overload
8
+ from urllib.parse import urlsplit
9
+
10
+ import httpx
11
+
12
+ from .types import DecisionRequest, DecisionResponse, ModelsResponse, JsonValue
13
+ from .questions import (
14
+ Questions, QuestionSet, TSchema, TypedDecisionResponse, SchemaDecisionResponse,
15
+ build_questions, schema_questions,
16
+ )
17
+
18
+
19
+ class BasalError(Exception):
20
+ """Base SDK error. Transport errors retain their original exception as cause."""
21
+
22
+
23
+ class BasalHTTPError(BasalError):
24
+ """Non-2xx response, retaining the body and unmodified Retry-After header."""
25
+
26
+ def __init__(self, status_code: int, body: Any, retry_after: str | None):
27
+ # Keep server bodies accessible explicitly; do not include state or secrets in logs.
28
+ super().__init__(f"Basal HTTP {status_code}")
29
+ self.status_code = status_code
30
+ self.body = body
31
+ self.retry_after = retry_after
32
+
33
+
34
+ class BasalTransportError(BasalError):
35
+ """Connection, timeout or response-body transport failure."""
36
+
37
+
38
+ class BasalResponseTooLargeError(BasalError):
39
+ """Actual streamed response exceeds the configured byte limit."""
40
+
41
+
42
+ class BasalProtocolError(BasalError):
43
+ """Invalid JSON envelope or unsupported response encoding."""
44
+
45
+
46
+ def _options(base_url: str, api_key: str | None, timeout: float) -> dict[str, Any]:
47
+ url = urlsplit(base_url)
48
+ if (
49
+ url.scheme not in ("http", "https")
50
+ or not url.hostname
51
+ or url.username is not None
52
+ or url.password is not None
53
+ or url.query
54
+ or url.fragment
55
+ or "?" in base_url
56
+ or "#" in base_url
57
+ ):
58
+ raise ValueError("base_url must be an HTTP(S) server URL without credentials, query or fragment")
59
+ try:
60
+ loopback = ipaddress.ip_address(url.hostname).is_loopback
61
+ except ValueError:
62
+ loopback = url.hostname.lower() == "localhost"
63
+ if url.scheme != "https" and not loopback:
64
+ raise ValueError("base_url requires HTTPS or loopback HTTP (e.g. an SSH tunnel)")
65
+ if not math.isfinite(timeout) or timeout <= 0:
66
+ raise ValueError("timeout must be finite and positive (seconds)")
67
+ headers = {"Accept": "application/json", "Accept-Encoding": "identity", "User-Agent": "basal-client-python"}
68
+ if api_key is not None:
69
+ if not api_key or "\r" in api_key or "\n" in api_key:
70
+ raise ValueError("api_key must be a nonempty, single-line token")
71
+ headers["Authorization"] = f"Bearer {api_key}"
72
+ return dict(base_url=base_url.rstrip("/") + "/", headers=headers, timeout=timeout,
73
+ follow_redirects=False)
74
+
75
+
76
+ def _decode(response: httpx.Response, raw: bytes) -> dict[str, Any]:
77
+ try:
78
+ body = json.loads(raw)
79
+ except (ValueError, UnicodeError, RecursionError) as error:
80
+ if response.is_success:
81
+ raise BasalProtocolError("Basal returned invalid JSON") from error
82
+ body = raw.decode("utf-8", errors="replace")
83
+ if not response.is_success:
84
+ raise BasalHTTPError(response.status_code, body, response.headers.get("Retry-After"))
85
+ if not isinstance(body, dict):
86
+ raise BasalProtocolError("Basal returned a JSON value other than an object")
87
+ return body
88
+
89
+
90
+ def _validate_limit(limit: int) -> int:
91
+ if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
92
+ raise ValueError("max_response_bytes must be a positive integer")
93
+ return limit
94
+
95
+
96
+ def _check_encoding(response: httpx.Response) -> None:
97
+ # Reject before HTTPX invokes a decompressor, including when a proxy ignores Accept-Encoding.
98
+ if response.headers.get("Content-Encoding", "").strip().lower() not in ("", "identity"):
99
+ raise BasalProtocolError("Basal requires an uncompressed response")
100
+
101
+
102
+ def _decision_request(state: DecisionRequest | JsonValue, questions: QuestionSet | None,
103
+ model: str | None, facts: Literal["off", "auto"] | None) -> DecisionRequest:
104
+ if questions is None:
105
+ if model is not None or facts is not None:
106
+ raise ValueError("pass model/facts in a raw request, or supply typed questions")
107
+ if not isinstance(state, dict):
108
+ raise TypeError("a raw request must be a dictionary")
109
+ return cast(DecisionRequest, state)
110
+ if state is None:
111
+ raise ValueError("state must not be null")
112
+ request: DecisionRequest = {"state": cast(JsonValue, state), "questions": build_questions(questions)}
113
+ if model is not None:
114
+ if not isinstance(model, str) or not model.strip():
115
+ raise ValueError("model must be a nonempty string")
116
+ request["model"] = model
117
+ if facts is not None:
118
+ if facts not in ("off", "auto"):
119
+ raise ValueError("facts must be off or auto")
120
+ request["facts"] = facts
121
+ return request
122
+
123
+
124
+ def _prepare_questions(questions: QuestionSet | type[Questions] | None) -> tuple[type[Questions] | None, QuestionSet | None]:
125
+ if isinstance(questions, type):
126
+ return questions, schema_questions(questions)
127
+ return None, None if questions is None else dict(questions)
128
+
129
+
130
+ class BasalClient:
131
+ """Sync client. Reuse one instance; close it or use a with block.
132
+
133
+ base_url is the server root (optionally a proxy prefix), without /v1.
134
+ timeout applies to each HTTPX network phase, not the whole request.
135
+ No automatic retries: the caller decides whether to repeat inference.
136
+ """
137
+
138
+ def __init__(self, base_url: str = "http://127.0.0.1:8000", *,
139
+ api_key: str | None = None, timeout: float = 120.0,
140
+ max_response_bytes: int = 8 * 1024 * 1024, trust_env: bool = False):
141
+ self._max_response_bytes = _validate_limit(max_response_bytes)
142
+ self._http = httpx.Client(**_options(base_url, api_key, timeout), trust_env=trust_env)
143
+
144
+ def _request(self, method: str, path: str, body: DecisionRequest | None = None) -> dict[str, Any]:
145
+ try:
146
+ with self._http.stream(method, path, json=body) as response:
147
+ _check_encoding(response)
148
+ raw = bytearray()
149
+ for chunk in response.iter_bytes():
150
+ if len(chunk) > self._max_response_bytes - len(raw):
151
+ raise BasalResponseTooLargeError("Basal response exceeds the configured byte limit")
152
+ raw.extend(chunk)
153
+ return _decode(response, bytes(raw))
154
+ except httpx.RequestError as error:
155
+ raise BasalTransportError("Basal HTTP transport failed") from error
156
+
157
+ @overload
158
+ def decide(self, state: DecisionRequest) -> DecisionResponse: ...
159
+
160
+ @overload
161
+ def decide(self, state: JsonValue, questions: QuestionSet, *, model: str | None = None,
162
+ facts: Literal["off", "auto"] | None = None) -> TypedDecisionResponse: ...
163
+
164
+ @overload
165
+ def decide(self, state: JsonValue, questions: type[TSchema], *, model: str | None = None,
166
+ facts: Literal["off", "auto"] | None = None) -> SchemaDecisionResponse[TSchema]: ...
167
+
168
+ def decide(self, state: DecisionRequest | JsonValue, questions: QuestionSet | type[Questions] | None = None, *,
169
+ model: str | None = None, facts: Literal["off", "auto"] | None = None) -> DecisionResponse | TypedDecisionResponse | SchemaDecisionResponse[Questions]:
170
+ """POST a raw request, or decide(state, a question dictionary or Questions class)."""
171
+ schema, fields = _prepare_questions(questions)
172
+ response = cast(DecisionResponse, self._request("POST", "v1/basal", _decision_request(state, fields, model, facts)))
173
+ if fields is None:
174
+ return response
175
+ return SchemaDecisionResponse(response, schema, fields) if schema is not None else TypedDecisionResponse(response, fields)
176
+
177
+ def models(self) -> ModelsResponse:
178
+ """GET /v1/models."""
179
+ return cast(ModelsResponse, self._request("GET", "v1/models"))
180
+
181
+ def close(self) -> None:
182
+ self._http.close()
183
+
184
+ def __enter__(self) -> Self:
185
+ return self
186
+
187
+ def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None,
188
+ traceback: TracebackType | None) -> None:
189
+ self.close()
190
+
191
+
192
+ class AsyncBasalClient:
193
+ """Native async HTTPX client; use async with or await aclose()."""
194
+
195
+ def __init__(self, base_url: str = "http://127.0.0.1:8000", *,
196
+ api_key: str | None = None, timeout: float = 120.0,
197
+ max_response_bytes: int = 8 * 1024 * 1024, trust_env: bool = False):
198
+ self._max_response_bytes = _validate_limit(max_response_bytes)
199
+ self._http = httpx.AsyncClient(**_options(base_url, api_key, timeout), trust_env=trust_env)
200
+
201
+ async def _request(self, method: str, path: str, body: DecisionRequest | None = None) -> dict[str, Any]:
202
+ try:
203
+ async with self._http.stream(method, path, json=body) as response:
204
+ _check_encoding(response)
205
+ raw = bytearray()
206
+ async for chunk in response.aiter_bytes():
207
+ if len(chunk) > self._max_response_bytes - len(raw):
208
+ raise BasalResponseTooLargeError("Basal response exceeds the configured byte limit")
209
+ raw.extend(chunk)
210
+ return _decode(response, bytes(raw))
211
+ except httpx.RequestError as error:
212
+ raise BasalTransportError("Basal HTTP transport failed") from error
213
+
214
+ @overload
215
+ async def decide(self, state: DecisionRequest) -> DecisionResponse: ...
216
+
217
+ @overload
218
+ async def decide(self, state: JsonValue, questions: QuestionSet, *, model: str | None = None,
219
+ facts: Literal["off", "auto"] | None = None) -> TypedDecisionResponse: ...
220
+
221
+ @overload
222
+ async def decide(self, state: JsonValue, questions: type[TSchema], *, model: str | None = None,
223
+ facts: Literal["off", "auto"] | None = None) -> SchemaDecisionResponse[TSchema]: ...
224
+
225
+ async def decide(self, state: DecisionRequest | JsonValue, questions: QuestionSet | type[Questions] | None = None, *,
226
+ model: str | None = None, facts: Literal["off", "auto"] | None = None) -> DecisionResponse | TypedDecisionResponse | SchemaDecisionResponse[Questions]:
227
+ """POST a raw request, a question dictionary, or a declarative Questions class."""
228
+ schema, fields = _prepare_questions(questions)
229
+ response = cast(DecisionResponse, await self._request("POST", "v1/basal", _decision_request(state, fields, model, facts)))
230
+ if fields is None:
231
+ return response
232
+ return SchemaDecisionResponse(response, schema, fields) if schema is not None else TypedDecisionResponse(response, fields)
233
+
234
+ async def models(self) -> ModelsResponse:
235
+ return cast(ModelsResponse, await self._request("GET", "v1/models"))
236
+
237
+ async def aclose(self) -> None:
238
+ await self._http.aclose()
239
+
240
+ async def __aenter__(self) -> Self:
241
+ return self
242
+
243
+ async def __aexit__(self, exc_type: type[BaseException] | None, exc: BaseException | None,
244
+ traceback: TracebackType | None) -> None:
245
+ await self.aclose()
File without changes
@@ -0,0 +1,348 @@
1
+ """Typed, validated question factories. Wire dictionaries remain available separately."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from copy import deepcopy
7
+ from dataclasses import dataclass
8
+ import math
9
+ from types import MappingProxyType
10
+ from typing import Any, Generic, Literal, Self, TypeVar, cast, overload
11
+
12
+ from .types import DecisionResponse, EvidenceSpan, JsonValue, Question as WireQuestion, Usage
13
+
14
+ OptionKeys = Literal["show", "hide"]
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class ChoiceAnswer:
19
+ choice: str
20
+ confidence: float
21
+ probabilities: dict[str, float]
22
+ evidence: tuple[EvidenceSpan, ...] = ()
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class NoulAnswer:
27
+ noul: float
28
+ confidence: float
29
+ probabilities: dict[str, float]
30
+ evidence: tuple[EvidenceSpan, ...] = ()
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class ScoreAnswer:
35
+ score: float
36
+ confidence: float
37
+ legend: dict[str, str]
38
+ probabilities: dict[str, float]
39
+ evidence: tuple[EvidenceSpan, ...] = ()
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class MultiAnswer:
44
+ selected: tuple[str, ...]
45
+ threshold: float
46
+ set_confidence: float
47
+ probabilities: dict[str, float]
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class ActAnswer:
52
+ action: str
53
+ answer: str
54
+ expected_costs: dict[str, float]
55
+ confidence: float
56
+ calibration: Literal["validated", "unvalidated"]
57
+ probabilities: dict[str, float]
58
+ evidence: tuple[EvidenceSpan, ...] = ()
59
+ refused: str | None = None
60
+ max_error: str | None = None
61
+
62
+
63
+ TAnswer = TypeVar("TAnswer")
64
+ AnswerResult = ChoiceAnswer | NoulAnswer | ScoreAnswer | MultiAnswer | ActAnswer
65
+ TSchema = TypeVar("TSchema", bound="Questions")
66
+ TSchema_co = TypeVar("TSchema_co", bound="Questions", covariant=True)
67
+
68
+
69
+ def _text(value: str, name: str) -> str:
70
+ if not isinstance(value, str) or not value.strip():
71
+ raise ValueError(f"{name} must be a nonempty string")
72
+ return value
73
+
74
+
75
+ def _criteria(values: Mapping[str, str], minimum: int, maximum: int | None) -> dict[str, JsonValue]:
76
+ if not isinstance(values, Mapping) or len(values) < minimum or (maximum is not None and len(values) > maximum):
77
+ raise ValueError("invalid number of criteria")
78
+ return {_text(key, "criterion key"): _text(value, "criterion description") for key, value in values.items()}
79
+
80
+
81
+ def _number(value: float, name: str, probability: bool = False) -> float:
82
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
83
+ raise ValueError(f"{name} must be finite")
84
+ if probability and not 0 <= value <= 1:
85
+ raise ValueError(f"{name} must be between 0 and 1")
86
+ return value
87
+
88
+
89
+ class QuestionSpec(Generic[TAnswer]):
90
+ """Typed question. to_dict() returns a copy; read() checks its answer's required fields."""
91
+
92
+ def __init__(self, kind: str, instructions: str, fields: dict[str, JsonValue]):
93
+ self._payload: dict[str, JsonValue] = {"type": kind, "instructions": _text(instructions, "instructions"), **fields}
94
+
95
+ def to_dict(self) -> WireQuestion:
96
+ return cast(WireQuestion, deepcopy(self._payload))
97
+
98
+ @overload
99
+ def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ...
100
+
101
+ @overload
102
+ def __get__(self, instance: Questions, owner: type[Any] | None = None) -> TAnswer: ...
103
+
104
+ @overload
105
+ def __get__(self, instance: object, owner: type[Any] | None = None) -> Self: ...
106
+
107
+ def __get__(self, instance: object, owner: type[Any] | None = None) -> Self | TAnswer:
108
+ # Non-data descriptor: validated answers stored under individual instance
109
+ # keys shadow the definition. Reusing one definition never merges keys.
110
+ # Outside a Questions schema, definitions remain ordinary values, also
111
+ # when stored in namespace classes, instance fields or dataclasses.
112
+ if not isinstance(instance, Questions):
113
+ return self
114
+ raise AttributeError("Question answers are populated by client.decide")
115
+
116
+ def read(self, response: DecisionResponse, question_id: str) -> TAnswer:
117
+ from .client import BasalProtocolError
118
+ answer = response.get("answers", {}).get(question_id)
119
+ if not isinstance(answer, dict) or answer.get("type") != self._payload["type"]:
120
+ raise BasalProtocolError("Basal returned a missing or mismatched answer")
121
+ data = cast(dict[str, Any], answer)
122
+ try:
123
+ probabilities = data["probabilities"]
124
+ if not isinstance(probabilities, dict) or any(not isinstance(k, str) for k in probabilities):
125
+ raise ValueError("invalid probabilities")
126
+ for value in probabilities.values():
127
+ _number(value, "probability", True)
128
+ spans = data.get("evidence", [])
129
+ if not isinstance(spans, list):
130
+ raise ValueError("invalid evidence")
131
+ for span in spans:
132
+ if not isinstance(span, dict) or not isinstance(span.get("text"), str):
133
+ raise ValueError("invalid span")
134
+ for field in ("start", "end"):
135
+ if isinstance(span.get(field), bool) or not isinstance(span.get(field), int) or span[field] < 0:
136
+ raise ValueError("invalid offset")
137
+ _number(span["probability"], "span probability", True)
138
+ evidence = tuple(cast(list[EvidenceSpan], spans))
139
+ kind = data["type"]
140
+ result: ChoiceAnswer | NoulAnswer | ScoreAnswer | MultiAnswer | ActAnswer
141
+ if kind == "choice":
142
+ selected = _text(data["choice"], "choice")
143
+ if selected not in cast(dict[str, JsonValue], self._payload["criteria"]):
144
+ raise ValueError("unknown choice")
145
+ result = ChoiceAnswer(selected, _number(data["confidence"], "confidence", True), probabilities, evidence)
146
+ elif kind == "noul":
147
+ result = NoulAnswer(_number(data["noul"], "noul", True), _number(data["confidence"], "confidence", True), probabilities, evidence)
148
+ elif kind == "score":
149
+ legend = data["legend"]
150
+ if not isinstance(legend, dict) or any(not isinstance(k, str) or not isinstance(v, str) for k, v in legend.items()):
151
+ raise ValueError("invalid legend")
152
+ result = ScoreAnswer(_number(data["score"], "score"), _number(data["confidence"], "confidence", True), legend, probabilities, evidence)
153
+ elif kind == "multi":
154
+ labels = data["selected"]
155
+ if not isinstance(labels, list) or any(not isinstance(k, str) or k not in cast(dict[str, JsonValue], self._payload["criteria"]) for k in labels):
156
+ raise ValueError("invalid selected labels")
157
+ result = MultiAnswer(tuple(labels), _number(data["threshold"], "threshold", True), _number(data["set_confidence"], "set_confidence", True), probabilities)
158
+ else:
159
+ action = _text(data["action"], "action")
160
+ if action not in cast(dict[str, JsonValue], self._payload["costs"]) or data["calibration"] not in ("validated", "unvalidated"):
161
+ raise ValueError("invalid action or calibration")
162
+ costs = data["expected_costs"]
163
+ if not isinstance(costs, dict) or any(not isinstance(k, str) for k in costs):
164
+ raise ValueError("invalid expected costs")
165
+ for value in costs.values():
166
+ _number(value, "expected cost")
167
+ refused, max_error = data.get("refused"), data.get("max_error")
168
+ if any(value is not None and not isinstance(value, str) for value in (refused, max_error)):
169
+ raise ValueError("invalid act metadata")
170
+ result = ActAnswer(action, _text(data["answer"], "answer"), costs, _number(data["confidence"], "confidence", True), data["calibration"], probabilities, evidence, refused, max_error)
171
+ return cast(TAnswer, result)
172
+ except (KeyError, TypeError, ValueError, OverflowError) as error:
173
+ raise BasalProtocolError("Basal returned an invalid typed answer") from error
174
+
175
+
176
+ def _display(evidence: bool, option_keys: OptionKeys) -> dict[str, JsonValue]:
177
+ if not isinstance(evidence, bool) or option_keys not in ("show", "hide"):
178
+ raise ValueError("invalid evidence or option_keys")
179
+ fields: dict[str, JsonValue] = {}
180
+ if evidence:
181
+ fields["evidence"] = True
182
+ if option_keys != "show":
183
+ fields["option_keys"] = option_keys
184
+ return fields
185
+
186
+
187
+ class ChoiceQuestion(QuestionSpec[ChoiceAnswer]):
188
+ def __init__(self, instructions: str, criteria: Mapping[str, str], *, evidence: bool = False,
189
+ option_keys: OptionKeys = "show"):
190
+ super().__init__("choice", instructions, {"criteria": _criteria(criteria, 2, 10), **_display(evidence, option_keys)})
191
+
192
+
193
+ class NoulQuestion(QuestionSpec[NoulAnswer]):
194
+ def __init__(self, instructions: str, *, evidence: bool = False):
195
+ super().__init__("noul", instructions, _display(evidence, "show"))
196
+
197
+
198
+ class ScoreQuestion(QuestionSpec[ScoreAnswer]):
199
+ def __init__(self, instructions: str, levels: Sequence[str], *, evidence: bool = False):
200
+ if isinstance(levels, str) or not 2 <= len(levels) <= 10:
201
+ raise ValueError("score requires 2–10 ordered levels")
202
+ super().__init__("score", instructions, {"criteria": [_text(value, "level") for value in levels], **_display(evidence, "show")})
203
+
204
+
205
+ class MultiQuestion(QuestionSpec[MultiAnswer]):
206
+ def __init__(self, instructions: str, criteria: Mapping[str, str], *, threshold: float = 0.5,
207
+ min: int | None = None, max: int | None = None):
208
+ fields: dict[str, JsonValue] = {"criteria": _criteria(criteria, 1, None)}
209
+ _number(threshold, "threshold", True)
210
+ if threshold != 0.5:
211
+ fields["threshold"] = threshold
212
+ for name, value in (("min", min), ("max", max)):
213
+ if value is not None:
214
+ if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= len(criteria):
215
+ raise ValueError(f"{name} must be an integer within the number of labels")
216
+ fields[name] = value
217
+ if min is not None and max is not None and min > max:
218
+ raise ValueError("min must not exceed max")
219
+ super().__init__("multi", instructions, fields)
220
+
221
+
222
+ class ActQuestion(QuestionSpec[ActAnswer]):
223
+ def __init__(self, instructions: str, costs: Mapping[str, Mapping[str, float]], *,
224
+ criteria: Mapping[str, str] | None = None, evidence: bool = False,
225
+ defer_action: str | None = None, max_error: float | None = None):
226
+ outcomes = set(criteria) if criteria is not None else {"true", "false"}
227
+ fields: dict[str, JsonValue] = _display(evidence, "show")
228
+ if criteria is not None:
229
+ fields["criteria"] = _criteria(criteria, 2, 10)
230
+ if not costs:
231
+ raise ValueError("act requires a nonempty cost matrix")
232
+ matrix: dict[str, JsonValue] = {}
233
+ for action, row in costs.items():
234
+ if not isinstance(row, Mapping) or set(row) != outcomes:
235
+ raise ValueError("each cost row must cover every outcome exactly")
236
+ matrix[_text(action, "action")] = {key: _number(value, "cost") for key, value in row.items()}
237
+ fields["costs"] = matrix
238
+ if defer_action is not None:
239
+ if defer_action not in costs:
240
+ raise ValueError("defer_action must identify a cost row")
241
+ fields["defer_action"] = defer_action
242
+ if max_error is not None:
243
+ fields["max_error"] = _number(max_error, "max_error", True)
244
+ super().__init__("act", instructions, fields)
245
+
246
+
247
+ # Factories and constructors share exactly the same typed signatures.
248
+ choice = ChoiceQuestion
249
+ noul = NoulQuestion
250
+ score = ScoreQuestion
251
+ multi = MultiQuestion
252
+ act = ActQuestion
253
+ QuestionSet = Mapping[str, QuestionSpec[Any]]
254
+
255
+
256
+ class Questions:
257
+ """Declare questions as class fields; pass the class itself to client.decide.
258
+
259
+ The returned response.answers is an instance with inferred answer fields.
260
+ Definitions may be reused or inherited. Constructors are not called when
261
+ populating responses; use this class as a declarative schema.
262
+ """
263
+
264
+ def __new__(cls) -> Self:
265
+ raise TypeError("Pass the Questions class to client.decide instead of constructing it")
266
+
267
+ def __setattr__(self, name: str, value: object) -> None:
268
+ raise AttributeError("Question answers are read-only")
269
+
270
+ def __delattr__(self, name: str) -> None:
271
+ raise AttributeError("Question answers are read-only")
272
+
273
+
274
+ def schema_questions(schema: type[Questions]) -> dict[str, QuestionSpec[Any]]:
275
+ """Snapshot effective definitions in declaration order, respecting Python MRO."""
276
+ if not issubclass(schema, Questions):
277
+ raise TypeError("expected a Questions subclass")
278
+ members: dict[str, Any] = {}
279
+ for base in reversed(schema.__mro__):
280
+ members.update(vars(base))
281
+ fields: dict[str, QuestionSpec[Any]] = {}
282
+ for name, value in members.items():
283
+ if isinstance(value, QuestionSpec):
284
+ if name.startswith("_"):
285
+ raise ValueError("Schema question names must not start with an underscore")
286
+ fields[name] = value
287
+ return fields
288
+
289
+
290
+ def build_questions(questions: QuestionSet) -> dict[str, WireQuestion]:
291
+ if not questions:
292
+ raise ValueError("questions must not be empty")
293
+ result: dict[str, WireQuestion] = {}
294
+ for key, question in questions.items():
295
+ _text(key, "question ID")
296
+ if not isinstance(question, (ChoiceQuestion, NoulQuestion, ScoreQuestion, MultiQuestion, ActQuestion)):
297
+ raise TypeError("questions must contain typed question specs")
298
+ result[key] = question.to_dict()
299
+ return result
300
+
301
+
302
+ class _DecisionResponseBase:
303
+ def __init__(self, raw: DecisionResponse):
304
+ from .client import BasalProtocolError
305
+ if not isinstance(raw.get("model"), str) or not raw.get("model") or not isinstance(raw.get("usage"), dict) or not isinstance(raw.get("answers"), dict):
306
+ raise BasalProtocolError("Invalid decision envelope")
307
+ for name in ("input_tokens", "output_tokens"):
308
+ value = raw["usage"].get(name)
309
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
310
+ raise BasalProtocolError("Invalid usage field")
311
+ self.model: str = raw["model"]
312
+ self.usage: Usage = raw["usage"]
313
+ self.raw = raw
314
+
315
+
316
+ class TypedDecisionResponse(_DecisionResponseBase):
317
+ """Checked answers from a dictionary request, indexed by their request keys.
318
+
319
+ Values have a union of answer types. Use a Questions class when each named
320
+ field should have an exact inferred type, or isinstance to narrow this union.
321
+ """
322
+
323
+ def __init__(self, raw: DecisionResponse, questions: QuestionSet):
324
+ super().__init__(raw)
325
+ self.answers: Mapping[str, AnswerResult] = MappingProxyType({
326
+ name: question.read(raw, name) for name, question in questions.items()
327
+ })
328
+
329
+ def answer(self, key: str) -> AnswerResult:
330
+ """Retrieve by request key; an unknown key raises KeyError."""
331
+ return self.answers[key]
332
+
333
+
334
+ class SchemaDecisionResponse(_DecisionResponseBase, Generic[TSchema_co]):
335
+ """Checked response with the named answer fields inferred from its schema."""
336
+
337
+ def __init__(self, raw: DecisionResponse, schema: type[TSchema_co], questions: QuestionSet):
338
+ super().__init__(raw)
339
+ parsed = {name: question.read(raw, name) for name, question in questions.items()}
340
+ # Populate only after all answers pass validation, bypassing user
341
+ # constructors. Instance attributes shadow non-data question descriptors.
342
+ self._answers = object.__new__(schema)
343
+ vars(self._answers).update(parsed)
344
+
345
+ @property
346
+ def answers(self) -> TSchema_co:
347
+ """Read-only schema instance with each field's inferred answer type."""
348
+ return self._answers
@@ -0,0 +1,84 @@
1
+ """Wire types for POST /v1/basal. Question and criteria order is significant."""
2
+
3
+ from typing import Literal, NotRequired, TypedDict
4
+
5
+ # Python 3.11 syntax keeps these aliases usable on the minimum supported version.
6
+ JsonValue = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
7
+
8
+
9
+ class Question(TypedDict, total=False):
10
+ """Native question fields; validation and calibration belong to the server."""
11
+
12
+ type: Literal["choice", "noul", "score", "multi", "act"]
13
+ instructions: JsonValue
14
+ criteria: dict[str, JsonValue] | list[JsonValue]
15
+ levels: dict[str, JsonValue] | list[JsonValue]
16
+ option_keys: Literal["show", "hide"]
17
+ evidence: bool
18
+ threshold: float
19
+ min: int
20
+ max: int
21
+ base: Literal["noul", "choice", "score"]
22
+ costs: dict[str, float] | dict[str, dict[str, float]]
23
+ defer_action: str
24
+ max_error: float
25
+
26
+
27
+ class DecisionRequest(TypedDict):
28
+ state: JsonValue
29
+ questions: dict[str, Question]
30
+ model: NotRequired[str]
31
+ facts: NotRequired[Literal["off", "auto"]]
32
+
33
+
34
+ class EvidenceSpan(TypedDict):
35
+ text: str
36
+ start: int
37
+ end: int
38
+ probability: float
39
+
40
+
41
+ class Answer(TypedDict):
42
+ type: Literal["choice", "noul", "score", "multi", "act"]
43
+ probabilities: dict[str, float]
44
+ confidence: NotRequired[float]
45
+ choice: NotRequired[str]
46
+ noul: NotRequired[float]
47
+ score: NotRequired[float]
48
+ legend: NotRequired[dict[str, str]]
49
+ selected: NotRequired[list[str]]
50
+ threshold: NotRequired[float]
51
+ set_confidence: NotRequired[float]
52
+ action: NotRequired[str]
53
+ answer: NotRequired[str]
54
+ expected_costs: NotRequired[dict[str, float]]
55
+ calibration: NotRequired[Literal["validated", "unvalidated"]]
56
+ refused: NotRequired[str]
57
+ max_error: NotRequired[str]
58
+ evidence: NotRequired[list[EvidenceSpan]]
59
+
60
+
61
+ class Usage(TypedDict):
62
+ input_tokens: int
63
+ output_tokens: int
64
+ questions: NotRequired[int]
65
+ branches: NotRequired[int]
66
+ latency_ms: NotRequired[float]
67
+
68
+
69
+ class DecisionResponse(TypedDict):
70
+ model: str
71
+ answers: dict[str, Answer]
72
+ usage: Usage
73
+
74
+
75
+ class ModelMetadata(TypedDict):
76
+ name: str
77
+ description: NotRequired[str]
78
+ release_date: NotRequired[str]
79
+ mode: NotRequired[str]
80
+ early_exit: NotRequired[list[JsonValue]]
81
+
82
+
83
+ class ModelsResponse(TypedDict):
84
+ models: list[ModelMetadata]