launchables 0.4.0__tar.gz → 0.5.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.
Files changed (22) hide show
  1. {launchables-0.4.0 → launchables-0.5.0}/PKG-INFO +54 -5
  2. {launchables-0.4.0 → launchables-0.5.0}/README.md +53 -4
  3. {launchables-0.4.0 → launchables-0.5.0}/src/launchables/__init__.py +20 -0
  4. launchables-0.5.0/src/launchables/_version.py +1 -0
  5. launchables-0.5.0/src/launchables/batches.py +497 -0
  6. {launchables-0.4.0 → launchables-0.5.0}/src/launchables/client.py +95 -18
  7. {launchables-0.4.0 → launchables-0.5.0}/src/launchables.egg-info/PKG-INFO +54 -5
  8. {launchables-0.4.0 → launchables-0.5.0}/src/launchables.egg-info/SOURCES.txt +2 -0
  9. launchables-0.5.0/tests/test_batches.py +480 -0
  10. launchables-0.4.0/src/launchables/_version.py +0 -1
  11. {launchables-0.4.0 → launchables-0.5.0}/LICENSE +0 -0
  12. {launchables-0.4.0 → launchables-0.5.0}/pyproject.toml +0 -0
  13. {launchables-0.4.0 → launchables-0.5.0}/setup.cfg +0 -0
  14. {launchables-0.4.0 → launchables-0.5.0}/src/launchables/admin.py +0 -0
  15. {launchables-0.4.0 → launchables-0.5.0}/src/launchables/errors.py +0 -0
  16. {launchables-0.4.0 → launchables-0.5.0}/src/launchables/images.py +0 -0
  17. {launchables-0.4.0 → launchables-0.5.0}/src/launchables/py.typed +0 -0
  18. {launchables-0.4.0 → launchables-0.5.0}/src/launchables/questions.py +0 -0
  19. {launchables-0.4.0 → launchables-0.5.0}/src/launchables/responses.py +0 -0
  20. {launchables-0.4.0 → launchables-0.5.0}/src/launchables.egg-info/dependency_links.txt +0 -0
  21. {launchables-0.4.0 → launchables-0.5.0}/src/launchables.egg-info/top_level.txt +0 -0
  22. {launchables-0.4.0 → launchables-0.5.0}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: launchables
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Python client for the Launchables Decision API
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://www.launchables.ai/docs
@@ -34,6 +34,7 @@ resp = client.decide(
34
34
  "Customer bought the item 12 days ago and has no receipt. "
35
35
  "Policy: refunds within 30 days with proof of purchase.",
36
36
  {"refund": noul("Is a refund permitted under the policy?")},
37
+ model="myles-g-12b",
37
38
  )
38
39
 
39
40
  answer = resp.answers["refund"]
@@ -42,7 +43,9 @@ print(answer.decide(threshold_yes=0.9, threshold_no=0.1)) # "yes" | "no" | "per
42
43
  print(resp.usage.input_tokens, resp.model)
43
44
  ```
44
45
 
45
- `decide` returns a `DecisionResponse` with `.answers`, `.usage`, `.model` and `.raw` (the untouched JSON). `state` may be a string or a dict. To include images, see [Images](#images).
46
+ `decide(state, questions, model=...)` returns a `DecisionResponse` with `.answers`, `.usage`, `.model` and `.raw` (the untouched JSON). `state` may be a string or a dict. To include images, see [Images](#images).
47
+
48
+ Always pass `model`, a model id from `client.models()` (for example `"myles-g-12b"` or `"myles-g-31b"`), so every request names the model that answers it. The API is moving to requiring it; until then a request without one is answered by the server's default model, which can change.
46
49
 
47
50
  ### Question types
48
51
 
@@ -54,7 +57,7 @@ questions = {
54
57
  "topic": choice("What is this about?", ["billing", "returns", "other"]), # or {label: description}
55
58
  "urgency": score("How urgent is it?", ["not urgent", "low", "medium", "high"]), # lowest level first, numbered from 0
56
59
  }
57
- resp = client.decide(state, questions)
60
+ resp = client.decide(state, questions, model="myles-g-12b")
58
61
 
59
62
  resp.answers["topic"].choice # "returns"
60
63
  resp.answers["topic"].probabilities # {"billing": 0.06, "returns": 0.89, ...}
@@ -87,6 +90,7 @@ resp = client.decide(
87
90
  {"receipt": "receipt_photo", "note": "Customer asks for a refund"},
88
91
  {"total_matches": noul("Does the total printed on receipt_photo equal 23.40?")},
89
92
  assets={"receipt_photo": Image.from_path("receipt.jpg")},
93
+ model="myles-g-12b",
90
94
  image_budget=140, # optional: 70, 140 or 280 (the default)
91
95
  )
92
96
  ```
@@ -104,6 +108,51 @@ Limits, checked by the server (each breach is an `InvalidRequestError` whose mes
104
108
 
105
109
  Images count as input tokens and are included in `usage.input_tokens`. At budget 280 one image costs up to about 280 tokens, at 140 up to about 140, at 70 up to about 70. An image referenced in the state is billed once per request however many questions read it; one referenced in a question's instructions or criteria is billed for that question, once per use.
106
110
 
111
+ ## Batches
112
+
113
+ The Batch API answers many decisions from one submission, within 24 hours, at half the live price per input token. Use it for work that does not need an answer straight away: back-catalogues, nightly reviews, re-scoring after a policy change. Batch support is in version 0.5.0 and later.
114
+
115
+ ```python
116
+ from launchables import Launchables, choice, noul
117
+
118
+ client = Launchables()
119
+
120
+ batch = client.batch([
121
+ {"custom_id": "ticket-1001", "model": "myles-g-12b", "state": "Charged twice for order 4471",
122
+ "questions": {"refund": noul("Is a refund due?")}},
123
+ {"custom_id": "ticket-1002", "model": "myles-g-31b", "state": {"message": "The app crashes on login"},
124
+ "questions": {"team": choice("Which team?", ["billing", "returns", "technical", "other"])}},
125
+ ]) # CreatedBatch(id="batch_...", status="queued", lines=2, estimated_cost=..., expires_at=..., data_region=...)
126
+
127
+ finished = client.batches.wait_for(batch.id) # polls 5 s, 7.5 s, 11.25 s ... up to 60 s apart
128
+
129
+ for r in client.batches.stream_results(batch.id):
130
+ if r.ok:
131
+ print(r.custom_id, r.response.answers) # a DecisionResponse, exactly as decide() returns it
132
+ else:
133
+ print(r.custom_id, r.error.get("type")) # e.g. "expired", "cancelled", "invalid_question"
134
+ ```
135
+
136
+ Each line is a dict: the body of one `decide` request with your own `custom_id` (1 to 64 letters, digits or `_ . : -`, unique in the batch) and a `model`, which every line must name: a batch has no default model. Keys are as they are sent: `custom_id`, `model`, `state`, `questions`, and optionally `assets` and `image_budget`. Lines may name different models. Images must be inline (`Image.from_path`, `Image.from_bytes`, or a dict with `data_base64`); a batch line cannot carry file parts.
137
+
138
+ | Call | What it does |
139
+ | --- | --- |
140
+ | `batch(lines, webhook_url=None)` or `batches.create(...)` | Checks every line, sends them as one JSONL file and returns the queued batch (`CreatedBatch`) with its cost estimate. |
141
+ | `batches.list()` | The calling key's batches (`Batch`), oldest first. |
142
+ | `batches.retrieve(batch_id)` | Status (`"queued"`, `"running"`, `"done"`, `"failed"` or `"cancelled"`), `counts` (`done`, `failed`, `pending`) and times. |
143
+ | `batches.wait_for(batch_id, ...)` | Polls until the results can be collected. The first check is at once; the wait after it, `poll_interval` (5 seconds), grows 1.5 times a check up to `max_interval` (60 seconds), both at least 1 second; `timeout` (25 hours) raises `BatchTimeoutError`; setting `stop` (a `threading.Event`) raises `BatchWaitCancelledError`; `on_poll(batch)` sees each status. It keeps waiting through a brief outage (connection errors, 429 and 5xx other than `batch_unavailable`). Giving up never cancels the batch. |
144
+ | `batches.stream_results(batch_id)` | Each result line as it arrives, in input order, so a large file is never held in memory. |
145
+ | `batches.results(batch_id)` | Every result line in one list. |
146
+ | `batches.cancel(batch_id)` | Stops the batch. Lines already answered stay in the results and are billed; the rest read as cancelled and are not. |
147
+
148
+ A result is an `AnsweredBatchResult` (`ok` is `True`, `response` is a `DecisionResponse`) or a `FailedBatchResult` (`ok` is `False`, `error` is a dict saying why). Both have `custom_id`, `model` (the exact model build that answered), `status` (the HTTP status the line got, 200 when answered, or `None` for a line that never ran), `usage` (`input_tokens` is what the line is billed on) and `raw`. `BatchTimeoutError` and `BatchWaitCancelledError` carry the last status read in `.batch`.
149
+
150
+ The client checks every line before sending and raises `ValidationError` naming the line (`line 3 (custom_id ticket-1003): ...`). The server checks every line again and refuses the whole batch if any is invalid: `InvalidRequestError` with `.type` `"invalid_batch"` and each bad line in `e.body["errors"]` (`{"line", "type", "error"}`), so nothing is half-queued. Other batch errors: `RateLimitError` with `.type` `"too_many_batches"` when the key already has its limit of batches queued or running (not retried; `.retry_after` says when to try); `ConflictError` with `.type` `"batch_not_finished"` for results before the batch has ended, or `"batch_finished"` for cancelling a batch that is already done or failed; `NotFoundError` (`"batch_not_found"`) for an id the key does not own; `APIError` with `.type` `"batch_unavailable"` where the Batch API is not switched on (not retried).
151
+
152
+ A submission is retried only after a refusal that stored nothing (429 `rate_limited`, 503 `busy`), never after a lost connection or a 502, so a retry cannot submit the same batch twice. Status, results and cancel calls are retried like any other.
153
+
154
+ Batches belong to the API key that submitted them. A rolled key is a new key: collect a batch's results with the key that submitted it, before that key stops working. See the [Batch API documentation](https://www.launchables.ai/docs/api/batches) for limits, the 24-hour window, webhooks, retention and where batch data is stored.
155
+
107
156
  ## Key expiry
108
157
 
109
158
  Every API key expires. The client reads the expiry date from each response, so your program can plan rotation instead of finding out when the key stops working.
@@ -157,7 +206,7 @@ All errors derive from `LaunchablesError` and carry `.status`, `.message` (the s
157
206
  | `PermissionDeniedError` | 403: the key's workspace may not use this model (`.type` `"model_not_allowed"`), or the admin key's maker is no longer an owner |
158
207
  | `RateLimitError` | 429 (`.retry_after` seconds, if sent) |
159
208
  | `InvalidRequestError` | 400, 411, 413, 422 and other 4xx; `ValidationError` is the local pre-flight subclass. `.type` says what was wrong with an image: `"invalid_assets"`, `"unsupported_image_format"`, `"invalid_image"`, `"image_too_large"`, `"image_dimensions"`, `"too_many_images"`, `"unreferenced_asset"` or `"image_reference_truncated"` (400), or `"invalid_image_budget"` (422). |
160
- | `NotFoundError`, `ConflictError` | 404 and 409 from the key management API (subclasses of `InvalidRequestError`) |
209
+ | `NotFoundError`, `ConflictError` | 404 and 409 from the key management API and the Batch API (subclasses of `InvalidRequestError`) |
161
210
  | `APIError` | 5xx |
162
211
  | `APIConnectionError` | could not reach the server (including timeouts) |
163
212
 
@@ -165,7 +214,7 @@ All errors derive from `LaunchablesError` and carry `.status`, `.message` (the s
165
214
  from launchables import OutOfCreditError, RateLimitError, InvalidRequestError, LaunchablesError
166
215
 
167
216
  try:
168
- resp = client.decide(state, questions)
217
+ resp = client.decide(state, questions, model="myles-g-12b")
169
218
  except OutOfCreditError as e:
170
219
  print("workspace budget reached" if e.type == "budget_exceeded" else "top up credit")
171
220
  except RateLimitError as e:
@@ -17,6 +17,7 @@ resp = client.decide(
17
17
  "Customer bought the item 12 days ago and has no receipt. "
18
18
  "Policy: refunds within 30 days with proof of purchase.",
19
19
  {"refund": noul("Is a refund permitted under the policy?")},
20
+ model="myles-g-12b",
20
21
  )
21
22
 
22
23
  answer = resp.answers["refund"]
@@ -25,7 +26,9 @@ print(answer.decide(threshold_yes=0.9, threshold_no=0.1)) # "yes" | "no" | "per
25
26
  print(resp.usage.input_tokens, resp.model)
26
27
  ```
27
28
 
28
- `decide` returns a `DecisionResponse` with `.answers`, `.usage`, `.model` and `.raw` (the untouched JSON). `state` may be a string or a dict. To include images, see [Images](#images).
29
+ `decide(state, questions, model=...)` returns a `DecisionResponse` with `.answers`, `.usage`, `.model` and `.raw` (the untouched JSON). `state` may be a string or a dict. To include images, see [Images](#images).
30
+
31
+ Always pass `model`, a model id from `client.models()` (for example `"myles-g-12b"` or `"myles-g-31b"`), so every request names the model that answers it. The API is moving to requiring it; until then a request without one is answered by the server's default model, which can change.
29
32
 
30
33
  ### Question types
31
34
 
@@ -37,7 +40,7 @@ questions = {
37
40
  "topic": choice("What is this about?", ["billing", "returns", "other"]), # or {label: description}
38
41
  "urgency": score("How urgent is it?", ["not urgent", "low", "medium", "high"]), # lowest level first, numbered from 0
39
42
  }
40
- resp = client.decide(state, questions)
43
+ resp = client.decide(state, questions, model="myles-g-12b")
41
44
 
42
45
  resp.answers["topic"].choice # "returns"
43
46
  resp.answers["topic"].probabilities # {"billing": 0.06, "returns": 0.89, ...}
@@ -70,6 +73,7 @@ resp = client.decide(
70
73
  {"receipt": "receipt_photo", "note": "Customer asks for a refund"},
71
74
  {"total_matches": noul("Does the total printed on receipt_photo equal 23.40?")},
72
75
  assets={"receipt_photo": Image.from_path("receipt.jpg")},
76
+ model="myles-g-12b",
73
77
  image_budget=140, # optional: 70, 140 or 280 (the default)
74
78
  )
75
79
  ```
@@ -87,6 +91,51 @@ Limits, checked by the server (each breach is an `InvalidRequestError` whose mes
87
91
 
88
92
  Images count as input tokens and are included in `usage.input_tokens`. At budget 280 one image costs up to about 280 tokens, at 140 up to about 140, at 70 up to about 70. An image referenced in the state is billed once per request however many questions read it; one referenced in a question's instructions or criteria is billed for that question, once per use.
89
93
 
94
+ ## Batches
95
+
96
+ The Batch API answers many decisions from one submission, within 24 hours, at half the live price per input token. Use it for work that does not need an answer straight away: back-catalogues, nightly reviews, re-scoring after a policy change. Batch support is in version 0.5.0 and later.
97
+
98
+ ```python
99
+ from launchables import Launchables, choice, noul
100
+
101
+ client = Launchables()
102
+
103
+ batch = client.batch([
104
+ {"custom_id": "ticket-1001", "model": "myles-g-12b", "state": "Charged twice for order 4471",
105
+ "questions": {"refund": noul("Is a refund due?")}},
106
+ {"custom_id": "ticket-1002", "model": "myles-g-31b", "state": {"message": "The app crashes on login"},
107
+ "questions": {"team": choice("Which team?", ["billing", "returns", "technical", "other"])}},
108
+ ]) # CreatedBatch(id="batch_...", status="queued", lines=2, estimated_cost=..., expires_at=..., data_region=...)
109
+
110
+ finished = client.batches.wait_for(batch.id) # polls 5 s, 7.5 s, 11.25 s ... up to 60 s apart
111
+
112
+ for r in client.batches.stream_results(batch.id):
113
+ if r.ok:
114
+ print(r.custom_id, r.response.answers) # a DecisionResponse, exactly as decide() returns it
115
+ else:
116
+ print(r.custom_id, r.error.get("type")) # e.g. "expired", "cancelled", "invalid_question"
117
+ ```
118
+
119
+ Each line is a dict: the body of one `decide` request with your own `custom_id` (1 to 64 letters, digits or `_ . : -`, unique in the batch) and a `model`, which every line must name: a batch has no default model. Keys are as they are sent: `custom_id`, `model`, `state`, `questions`, and optionally `assets` and `image_budget`. Lines may name different models. Images must be inline (`Image.from_path`, `Image.from_bytes`, or a dict with `data_base64`); a batch line cannot carry file parts.
120
+
121
+ | Call | What it does |
122
+ | --- | --- |
123
+ | `batch(lines, webhook_url=None)` or `batches.create(...)` | Checks every line, sends them as one JSONL file and returns the queued batch (`CreatedBatch`) with its cost estimate. |
124
+ | `batches.list()` | The calling key's batches (`Batch`), oldest first. |
125
+ | `batches.retrieve(batch_id)` | Status (`"queued"`, `"running"`, `"done"`, `"failed"` or `"cancelled"`), `counts` (`done`, `failed`, `pending`) and times. |
126
+ | `batches.wait_for(batch_id, ...)` | Polls until the results can be collected. The first check is at once; the wait after it, `poll_interval` (5 seconds), grows 1.5 times a check up to `max_interval` (60 seconds), both at least 1 second; `timeout` (25 hours) raises `BatchTimeoutError`; setting `stop` (a `threading.Event`) raises `BatchWaitCancelledError`; `on_poll(batch)` sees each status. It keeps waiting through a brief outage (connection errors, 429 and 5xx other than `batch_unavailable`). Giving up never cancels the batch. |
127
+ | `batches.stream_results(batch_id)` | Each result line as it arrives, in input order, so a large file is never held in memory. |
128
+ | `batches.results(batch_id)` | Every result line in one list. |
129
+ | `batches.cancel(batch_id)` | Stops the batch. Lines already answered stay in the results and are billed; the rest read as cancelled and are not. |
130
+
131
+ A result is an `AnsweredBatchResult` (`ok` is `True`, `response` is a `DecisionResponse`) or a `FailedBatchResult` (`ok` is `False`, `error` is a dict saying why). Both have `custom_id`, `model` (the exact model build that answered), `status` (the HTTP status the line got, 200 when answered, or `None` for a line that never ran), `usage` (`input_tokens` is what the line is billed on) and `raw`. `BatchTimeoutError` and `BatchWaitCancelledError` carry the last status read in `.batch`.
132
+
133
+ The client checks every line before sending and raises `ValidationError` naming the line (`line 3 (custom_id ticket-1003): ...`). The server checks every line again and refuses the whole batch if any is invalid: `InvalidRequestError` with `.type` `"invalid_batch"` and each bad line in `e.body["errors"]` (`{"line", "type", "error"}`), so nothing is half-queued. Other batch errors: `RateLimitError` with `.type` `"too_many_batches"` when the key already has its limit of batches queued or running (not retried; `.retry_after` says when to try); `ConflictError` with `.type` `"batch_not_finished"` for results before the batch has ended, or `"batch_finished"` for cancelling a batch that is already done or failed; `NotFoundError` (`"batch_not_found"`) for an id the key does not own; `APIError` with `.type` `"batch_unavailable"` where the Batch API is not switched on (not retried).
134
+
135
+ A submission is retried only after a refusal that stored nothing (429 `rate_limited`, 503 `busy`), never after a lost connection or a 502, so a retry cannot submit the same batch twice. Status, results and cancel calls are retried like any other.
136
+
137
+ Batches belong to the API key that submitted them. A rolled key is a new key: collect a batch's results with the key that submitted it, before that key stops working. See the [Batch API documentation](https://www.launchables.ai/docs/api/batches) for limits, the 24-hour window, webhooks, retention and where batch data is stored.
138
+
90
139
  ## Key expiry
91
140
 
92
141
  Every API key expires. The client reads the expiry date from each response, so your program can plan rotation instead of finding out when the key stops working.
@@ -140,7 +189,7 @@ All errors derive from `LaunchablesError` and carry `.status`, `.message` (the s
140
189
  | `PermissionDeniedError` | 403: the key's workspace may not use this model (`.type` `"model_not_allowed"`), or the admin key's maker is no longer an owner |
141
190
  | `RateLimitError` | 429 (`.retry_after` seconds, if sent) |
142
191
  | `InvalidRequestError` | 400, 411, 413, 422 and other 4xx; `ValidationError` is the local pre-flight subclass. `.type` says what was wrong with an image: `"invalid_assets"`, `"unsupported_image_format"`, `"invalid_image"`, `"image_too_large"`, `"image_dimensions"`, `"too_many_images"`, `"unreferenced_asset"` or `"image_reference_truncated"` (400), or `"invalid_image_budget"` (422). |
143
- | `NotFoundError`, `ConflictError` | 404 and 409 from the key management API (subclasses of `InvalidRequestError`) |
192
+ | `NotFoundError`, `ConflictError` | 404 and 409 from the key management API and the Batch API (subclasses of `InvalidRequestError`) |
144
193
  | `APIError` | 5xx |
145
194
  | `APIConnectionError` | could not reach the server (including timeouts) |
146
195
 
@@ -148,7 +197,7 @@ All errors derive from `LaunchablesError` and carry `.status`, `.message` (the s
148
197
  from launchables import OutOfCreditError, RateLimitError, InvalidRequestError, LaunchablesError
149
198
 
150
199
  try:
151
- resp = client.decide(state, questions)
200
+ resp = client.decide(state, questions, model="myles-g-12b")
152
201
  except OutOfCreditError as e:
153
202
  print("workspace budget reached" if e.type == "budget_exceeded" else "top up credit")
154
203
  except RateLimitError as e:
@@ -2,6 +2,23 @@
2
2
 
3
3
  from ._version import __version__
4
4
  from .admin import AdminKeys
5
+ from .batches import (
6
+ BATCH_FINISHED_STATUSES,
7
+ CUSTOM_ID_PATTERN,
8
+ AnsweredBatchResult,
9
+ Batch,
10
+ BatchCostEstimate,
11
+ BatchCounts,
12
+ Batches,
13
+ BatchLine,
14
+ BatchResult,
15
+ BatchTimeoutError,
16
+ BatchUsage,
17
+ BatchWaitCancelledError,
18
+ CreatedBatch,
19
+ FailedBatchResult,
20
+ to_jsonl,
21
+ )
5
22
  from .client import DEFAULT_ADMIN_BASE_URL, DEFAULT_BASE_URL, KEY_EXPIRY_WARNING_DAYS, KeyExpiryWarning, Launchables
6
23
  from .errors import (
7
24
  APIConnectionError,
@@ -36,4 +53,7 @@ __all__ = [
36
53
  "LaunchablesError", "AuthenticationError", "OutOfCreditError", "PermissionDeniedError",
37
54
  "RateLimitError", "InvalidRequestError", "NotFoundError", "ConflictError",
38
55
  "ValidationError", "APIError", "APIConnectionError",
56
+ "Batches", "Batch", "BatchLine", "BatchCounts", "BatchCostEstimate", "BatchUsage", "CreatedBatch", "BatchResult",
57
+ "AnsweredBatchResult", "FailedBatchResult", "BatchTimeoutError", "BatchWaitCancelledError",
58
+ "BATCH_FINISHED_STATUSES", "CUSTOM_ID_PATTERN", "to_jsonl",
39
59
  ]
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"