typellm 0.2.5__tar.gz → 0.2.6__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 (33) hide show
  1. {typellm-0.2.5/typellm.egg-info → typellm-0.2.6}/PKG-INFO +31 -28
  2. {typellm-0.2.5 → typellm-0.2.6}/README.md +30 -27
  3. {typellm-0.2.5 → typellm-0.2.6}/pyproject.toml +1 -1
  4. {typellm-0.2.5 → typellm-0.2.6}/tests/test_batching.py +13 -15
  5. {typellm-0.2.5 → typellm-0.2.6}/tests/test_concurrency.py +33 -29
  6. {typellm-0.2.5 → typellm-0.2.6}/tests/test_dependencies.py +18 -18
  7. {typellm-0.2.5 → typellm-0.2.6}/tests/test_grammar_fields.py +8 -8
  8. {typellm-0.2.5 → typellm-0.2.6}/tests/test_images.py +9 -9
  9. {typellm-0.2.5 → typellm-0.2.6}/tests/test_nullable.py +8 -8
  10. {typellm-0.2.5 → typellm-0.2.6}/tests/test_permutations.py +6 -6
  11. {typellm-0.2.5 → typellm-0.2.6}/tests/test_prefill.py +12 -26
  12. {typellm-0.2.5 → typellm-0.2.6}/tests/test_probabilities.py +2 -2
  13. {typellm-0.2.5 → typellm-0.2.6}/tests/test_prompts.py +4 -13
  14. {typellm-0.2.5 → typellm-0.2.6}/tests/test_protocols.py +22 -0
  15. {typellm-0.2.5 → typellm-0.2.6}/tests/test_text.py +15 -18
  16. {typellm-0.2.5 → typellm-0.2.6}/tests/test_typellm.py +55 -80
  17. {typellm-0.2.5 → typellm-0.2.6}/typellm/__init__.py +2 -0
  18. {typellm-0.2.5 → typellm-0.2.6}/typellm/cli.py +1 -1
  19. {typellm-0.2.5 → typellm-0.2.6}/typellm/runtime.py +25 -66
  20. {typellm-0.2.5 → typellm-0.2.6}/typellm/schema.py +10 -23
  21. {typellm-0.2.5 → typellm-0.2.6}/typellm/sglang.py +50 -76
  22. {typellm-0.2.5 → typellm-0.2.6/typellm.egg-info}/PKG-INFO +31 -28
  23. {typellm-0.2.5 → typellm-0.2.6}/LICENSE +0 -0
  24. {typellm-0.2.5 → typellm-0.2.6}/setup.cfg +0 -0
  25. {typellm-0.2.5 → typellm-0.2.6}/typellm/__main__.py +0 -0
  26. {typellm-0.2.5 → typellm-0.2.6}/typellm/benchmark.py +0 -0
  27. {typellm-0.2.5 → typellm-0.2.6}/typellm/images.py +0 -0
  28. {typellm-0.2.5 → typellm-0.2.6}/typellm/protocol.py +0 -0
  29. {typellm-0.2.5 → typellm-0.2.6}/typellm.egg-info/SOURCES.txt +0 -0
  30. {typellm-0.2.5 → typellm-0.2.6}/typellm.egg-info/dependency_links.txt +0 -0
  31. {typellm-0.2.5 → typellm-0.2.6}/typellm.egg-info/entry_points.txt +0 -0
  32. {typellm-0.2.5 → typellm-0.2.6}/typellm.egg-info/requires.txt +0 -0
  33. {typellm-0.2.5 → typellm-0.2.6}/typellm.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typellm
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: Type-Safe Decoding for Autoregressive LLMs
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://typellm.ai/
@@ -96,7 +96,7 @@ client = TypeLLMClient(
96
96
  Example request:
97
97
 
98
98
  ```python
99
- result = client.generate(
99
+ response = client.generate(
100
100
  context="""
101
101
  Receipt from Hilton London
102
102
  Total: £324.50
@@ -128,10 +128,12 @@ result = client.generate(
128
128
  },
129
129
  )
130
130
 
131
- print(result)
131
+ print(response.result)
132
132
  ```
133
133
 
134
- Example output:
134
+ `generate()` returns what the HTTP API does: the typed answers in `.result`, the
135
+ reasoning of each field that thought in `.thinking`, and the call's tokens in
136
+ `.usage`. Example `.result`:
135
137
 
136
138
  ```python
137
139
  {
@@ -160,7 +162,7 @@ Enum choices support `string`, `integer`, and `number` types, with at most 24 va
160
162
  A string without `enum` generates free text:
161
163
 
162
164
  ```python
163
- result = client.generate(
165
+ response = client.generate(
164
166
  context="The train ticket is for a client meeting.",
165
167
  questions={
166
168
  "summary": {"type": "string", "instructions": "Summarize in one sentence."},
@@ -169,12 +171,13 @@ result = client.generate(
169
171
  ```
170
172
 
171
173
  Free text stops after 128 tokens. Pass `text_max_tokens=` to the client for
172
- longer answers, or set `maxLength` on a field to cap its characters.
174
+ longer answers, and ask for the length you want in the field's instructions.
175
+ `maxLength` is not supported.
173
176
 
174
177
  Ask for a numeric answer without enumerating every possible value:
175
178
 
176
179
  ```python
177
- result = client.generate(
180
+ response = client.generate(
178
181
  context="Calculate the requested value accurately.",
179
182
  questions={
180
183
  "answer": {
@@ -184,7 +187,7 @@ result = client.generate(
184
187
  },
185
188
  )
186
189
 
187
- print(result)
190
+ print(response.result)
188
191
  # {"answer": 70.0}
189
192
  ```
190
193
 
@@ -210,7 +213,7 @@ Add `"null"` to the type to allow a missing value. The field returns `None`
210
213
  when the input has no value for it:
211
214
 
212
215
  ```python
213
- result = client.generate(
216
+ response = client.generate(
214
217
  context="Read the attached receipt.",
215
218
  images=["receipt.jpg"],
216
219
  questions={
@@ -236,7 +239,7 @@ Thinking is off by default. Turn it on for the fields that need it; the others
236
239
  answer at once, and the fields that think reason side by side:
237
240
 
238
241
  ```python
239
- result = client.generate(context=context, questions={
242
+ response = client.generate(context=context, questions={
240
243
  "total": {"type": "number"},
241
244
  "category": {"type": "string", "enum": ["meal", "travel", "equipment"]},
242
245
  "policy_ok": {"type": "boolean", "instructions": "Does it meet the travel policy?",
@@ -244,13 +247,12 @@ result = client.generate(context=context, questions={
244
247
  })
245
248
  ```
246
249
 
247
- `thinking_budget` caps a field's reasoning; there is no budget by default.
248
- `TypeLLMClient(..., thinking_budget=2048)` sets one for every field that thinks
249
- without its own. When reasoning reaches the budget, TypeLLM closes it and moves
250
- on to the typed answer.
250
+ `thinking_budget` caps a field's reasoning. Without one, a field may reason until
251
+ the model's context is full. When reasoning reaches the budget, TypeLLM closes it
252
+ and moves on to the typed answer.
251
253
 
252
- After a call, `client.last_thinking` maps each field that thought to its
253
- reasoning, and `client.last_usage.thinking_tokens` counts the reasoning tokens.
254
+ `response.thinking` maps each field that thought to its reasoning, and
255
+ `response.usage.thinking_tokens` counts the reasoning tokens.
254
256
 
255
257
  Models with always-on thinking reason on every field; `thinking_budget` applies
256
258
  to them too. See [Supported models](#supported-models).
@@ -261,7 +263,7 @@ Pass images with `images=` alongside the text context. The served model must be
261
263
  a vision-language model, such as `Qwen/Qwen3.8-27B`.
262
264
 
263
265
  ```python
264
- result = client.generate(
266
+ response = client.generate(
265
267
  context="The customer says this receipt was charged twice.",
266
268
  images=["receipt.png"],
267
269
  questions={
@@ -281,7 +283,7 @@ Fields run together by default, and each sees only the original context.
281
283
  When a field needs earlier results, list them in `depends_on`:
282
284
 
283
285
  ```python
284
- result = client.generate(
286
+ response = client.generate(
285
287
  context="The payments service is returning errors after a deployment.",
286
288
  questions={
287
289
  "system": {
@@ -319,7 +321,7 @@ step reuses its parent's cached prompt. Unknown names and cycles raise
319
321
  Set `return_probabilities` on individual enum or boolean fields:
320
322
 
321
323
  ```python
322
- result = client.generate(
324
+ response = client.generate(
323
325
  context=context,
324
326
  questions={
325
327
  "expense_type": {
@@ -371,7 +373,7 @@ For a one-off request, use the convenience function:
371
373
  ```python
372
374
  from typellm import run_schema
373
375
 
374
- result = run_schema(
376
+ response = run_schema(
375
377
  context=context,
376
378
  questions=questions,
377
379
  base_url="http://127.0.0.1:30000",
@@ -384,7 +386,7 @@ result = run_schema(
384
386
  Add `permutations` to an `enum` question to reduce option-order bias. TypeLLM averages the probabilities and keeps the same return format.
385
387
 
386
388
  ```python
387
- result = client.generate(
389
+ response = client.generate(
388
390
  context="A single roll of a fair die.",
389
391
  questions={"roll": {
390
392
  "type": "string",
@@ -417,19 +419,19 @@ prompts and usage, and connections to SGLang are reused.
417
419
  import threading
418
420
 
419
421
  cancel = threading.Event()
420
- result = client.generate(
422
+ response = client.generate(
421
423
  context=context,
422
424
  questions=questions,
423
425
  seed=7, # this call's random choices only
424
426
  timeout=30, # seconds for the whole call; raises GenerationTimeout
425
427
  cancel=cancel, # set it from another thread; raises GenerationCancelled
426
428
  )
427
- print(client.last_usage)
428
- # Usage(requests=4, prompt_tokens=1830, cached_tokens=1504, completion_tokens=7, thinking_tokens=0, input_tokens=410)
429
+ print(response.usage)
430
+ # Usage(input_tokens=410, thinking_tokens=0, requests=4, prompt_tokens=1830, cached_tokens=1504, completion_tokens=7)
429
431
  ```
430
432
 
431
- `last_usage` reports the tokens of the last call made in the current thread,
432
- including a call that failed partway. `input_tokens` is what you sent, each part
433
+ `usage` reports the tokens of the call. When a call fails partway, the
434
+ exception's `.usage` holds what it spent. `input_tokens` is what you sent, each part
433
435
  counted once: the context, the questions as JSON, and the images. The other
434
436
  counts are SGLang's for the call's requests, where every prompt carries the
435
437
  shared context. A timeout or cancel stops
@@ -468,8 +470,9 @@ Other sizes in the Qwen3.5 and Qwen3.8 families are expected to be compatible.
468
470
 
469
471
  [Image input](#image-input) has been tested with `Qwen/Qwen3.8-27B`.
470
472
 
471
- Use the checkpoint ID as `model=`. If the server's tokenizer path is unavailable
472
- locally, set `tokenizer=` to its matching Hugging Face ID or local directory.
473
+ Use the checkpoint ID as `model=`. TypeLLM loads the tokenizer from the server's
474
+ paths, then its served model name; if none of those loads locally, set
475
+ `tokenizer=` to the matching Hugging Face ID or local directory.
473
476
  The tokenizer must load from standard artifacts without custom model code.
474
477
 
475
478
  ## Comparison with Jev-style models
@@ -81,7 +81,7 @@ client = TypeLLMClient(
81
81
  Example request:
82
82
 
83
83
  ```python
84
- result = client.generate(
84
+ response = client.generate(
85
85
  context="""
86
86
  Receipt from Hilton London
87
87
  Total: £324.50
@@ -113,10 +113,12 @@ result = client.generate(
113
113
  },
114
114
  )
115
115
 
116
- print(result)
116
+ print(response.result)
117
117
  ```
118
118
 
119
- Example output:
119
+ `generate()` returns what the HTTP API does: the typed answers in `.result`, the
120
+ reasoning of each field that thought in `.thinking`, and the call's tokens in
121
+ `.usage`. Example `.result`:
120
122
 
121
123
  ```python
122
124
  {
@@ -145,7 +147,7 @@ Enum choices support `string`, `integer`, and `number` types, with at most 24 va
145
147
  A string without `enum` generates free text:
146
148
 
147
149
  ```python
148
- result = client.generate(
150
+ response = client.generate(
149
151
  context="The train ticket is for a client meeting.",
150
152
  questions={
151
153
  "summary": {"type": "string", "instructions": "Summarize in one sentence."},
@@ -154,12 +156,13 @@ result = client.generate(
154
156
  ```
155
157
 
156
158
  Free text stops after 128 tokens. Pass `text_max_tokens=` to the client for
157
- longer answers, or set `maxLength` on a field to cap its characters.
159
+ longer answers, and ask for the length you want in the field's instructions.
160
+ `maxLength` is not supported.
158
161
 
159
162
  Ask for a numeric answer without enumerating every possible value:
160
163
 
161
164
  ```python
162
- result = client.generate(
165
+ response = client.generate(
163
166
  context="Calculate the requested value accurately.",
164
167
  questions={
165
168
  "answer": {
@@ -169,7 +172,7 @@ result = client.generate(
169
172
  },
170
173
  )
171
174
 
172
- print(result)
175
+ print(response.result)
173
176
  # {"answer": 70.0}
174
177
  ```
175
178
 
@@ -195,7 +198,7 @@ Add `"null"` to the type to allow a missing value. The field returns `None`
195
198
  when the input has no value for it:
196
199
 
197
200
  ```python
198
- result = client.generate(
201
+ response = client.generate(
199
202
  context="Read the attached receipt.",
200
203
  images=["receipt.jpg"],
201
204
  questions={
@@ -221,7 +224,7 @@ Thinking is off by default. Turn it on for the fields that need it; the others
221
224
  answer at once, and the fields that think reason side by side:
222
225
 
223
226
  ```python
224
- result = client.generate(context=context, questions={
227
+ response = client.generate(context=context, questions={
225
228
  "total": {"type": "number"},
226
229
  "category": {"type": "string", "enum": ["meal", "travel", "equipment"]},
227
230
  "policy_ok": {"type": "boolean", "instructions": "Does it meet the travel policy?",
@@ -229,13 +232,12 @@ result = client.generate(context=context, questions={
229
232
  })
230
233
  ```
231
234
 
232
- `thinking_budget` caps a field's reasoning; there is no budget by default.
233
- `TypeLLMClient(..., thinking_budget=2048)` sets one for every field that thinks
234
- without its own. When reasoning reaches the budget, TypeLLM closes it and moves
235
- on to the typed answer.
235
+ `thinking_budget` caps a field's reasoning. Without one, a field may reason until
236
+ the model's context is full. When reasoning reaches the budget, TypeLLM closes it
237
+ and moves on to the typed answer.
236
238
 
237
- After a call, `client.last_thinking` maps each field that thought to its
238
- reasoning, and `client.last_usage.thinking_tokens` counts the reasoning tokens.
239
+ `response.thinking` maps each field that thought to its reasoning, and
240
+ `response.usage.thinking_tokens` counts the reasoning tokens.
239
241
 
240
242
  Models with always-on thinking reason on every field; `thinking_budget` applies
241
243
  to them too. See [Supported models](#supported-models).
@@ -246,7 +248,7 @@ Pass images with `images=` alongside the text context. The served model must be
246
248
  a vision-language model, such as `Qwen/Qwen3.8-27B`.
247
249
 
248
250
  ```python
249
- result = client.generate(
251
+ response = client.generate(
250
252
  context="The customer says this receipt was charged twice.",
251
253
  images=["receipt.png"],
252
254
  questions={
@@ -266,7 +268,7 @@ Fields run together by default, and each sees only the original context.
266
268
  When a field needs earlier results, list them in `depends_on`:
267
269
 
268
270
  ```python
269
- result = client.generate(
271
+ response = client.generate(
270
272
  context="The payments service is returning errors after a deployment.",
271
273
  questions={
272
274
  "system": {
@@ -304,7 +306,7 @@ step reuses its parent's cached prompt. Unknown names and cycles raise
304
306
  Set `return_probabilities` on individual enum or boolean fields:
305
307
 
306
308
  ```python
307
- result = client.generate(
309
+ response = client.generate(
308
310
  context=context,
309
311
  questions={
310
312
  "expense_type": {
@@ -356,7 +358,7 @@ For a one-off request, use the convenience function:
356
358
  ```python
357
359
  from typellm import run_schema
358
360
 
359
- result = run_schema(
361
+ response = run_schema(
360
362
  context=context,
361
363
  questions=questions,
362
364
  base_url="http://127.0.0.1:30000",
@@ -369,7 +371,7 @@ result = run_schema(
369
371
  Add `permutations` to an `enum` question to reduce option-order bias. TypeLLM averages the probabilities and keeps the same return format.
370
372
 
371
373
  ```python
372
- result = client.generate(
374
+ response = client.generate(
373
375
  context="A single roll of a fair die.",
374
376
  questions={"roll": {
375
377
  "type": "string",
@@ -402,19 +404,19 @@ prompts and usage, and connections to SGLang are reused.
402
404
  import threading
403
405
 
404
406
  cancel = threading.Event()
405
- result = client.generate(
407
+ response = client.generate(
406
408
  context=context,
407
409
  questions=questions,
408
410
  seed=7, # this call's random choices only
409
411
  timeout=30, # seconds for the whole call; raises GenerationTimeout
410
412
  cancel=cancel, # set it from another thread; raises GenerationCancelled
411
413
  )
412
- print(client.last_usage)
413
- # Usage(requests=4, prompt_tokens=1830, cached_tokens=1504, completion_tokens=7, thinking_tokens=0, input_tokens=410)
414
+ print(response.usage)
415
+ # Usage(input_tokens=410, thinking_tokens=0, requests=4, prompt_tokens=1830, cached_tokens=1504, completion_tokens=7)
414
416
  ```
415
417
 
416
- `last_usage` reports the tokens of the last call made in the current thread,
417
- including a call that failed partway. `input_tokens` is what you sent, each part
418
+ `usage` reports the tokens of the call. When a call fails partway, the
419
+ exception's `.usage` holds what it spent. `input_tokens` is what you sent, each part
418
420
  counted once: the context, the questions as JSON, and the images. The other
419
421
  counts are SGLang's for the call's requests, where every prompt carries the
420
422
  shared context. A timeout or cancel stops
@@ -453,8 +455,9 @@ Other sizes in the Qwen3.5 and Qwen3.8 families are expected to be compatible.
453
455
 
454
456
  [Image input](#image-input) has been tested with `Qwen/Qwen3.8-27B`.
455
457
 
456
- Use the checkpoint ID as `model=`. If the server's tokenizer path is unavailable
457
- locally, set `tokenizer=` to its matching Hugging Face ID or local directory.
458
+ Use the checkpoint ID as `model=`. TypeLLM loads the tokenizer from the server's
459
+ paths, then its served model name; if none of those loads locally, set
460
+ `tokenizer=` to the matching Hugging Face ID or local directory.
458
461
  The tokenizer must load from standard artifacts without custom model code.
459
462
 
460
463
  ## Comparison with Jev-style models
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "typellm"
7
- version = "0.2.5"
7
+ version = "0.2.6"
8
8
  description = "Type-Safe Decoding for Autoregressive LLMs"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -96,7 +96,7 @@ class PrefixWarmupTests(unittest.TestCase):
96
96
  with self.subTest(thinking=thinking):
97
97
  client = TypeLLMClient(model="fake")
98
98
  client.sglang = FakeServer()
99
- client.generate(context="Receipt", questions=thinks(self.MIXED) if thinking else self.MIXED)
99
+ client.generate(context="Receipt", questions=thinks(self.MIXED) if thinking else self.MIXED).result
100
100
  warm = client.sglang.requests("count")
101
101
  self.assertEqual(len(warm), 1)
102
102
  # First, before the thinking, number, text and scoring batches.
@@ -107,7 +107,7 @@ class PrefixWarmupTests(unittest.TestCase):
107
107
  def test_open_fields_alone_are_not_warmed(self):
108
108
  client = TypeLLMClient(model="fake")
109
109
  client.sglang = FakeServer()
110
- client.generate(context="Receipt", questions={"a": {"type": "integer"}, "n": {"type": "string"}})
110
+ client.generate(context="Receipt", questions={"a": {"type": "integer"}, "n": {"type": "string"}}).result
111
111
  self.assertEqual(client.sglang.requests("count"), [])
112
112
 
113
113
 
@@ -123,7 +123,7 @@ class PerFieldThinkingTests(unittest.TestCase):
123
123
  "hard": {"type": "boolean", "instructions": "Hard?", "thinking": True},
124
124
  "easy": {"type": "boolean", "instructions": "Easy?"},
125
125
  "count": {"type": "integer"},
126
- })
126
+ }).result
127
127
  self.assertEqual(result, {"hard": True, "easy": True, "count": 7})
128
128
  [prompt] = self.thinking_prompts(client.sglang) # one batch, one prompt
129
129
  self.assertIn("Hard?", prompt)
@@ -139,7 +139,7 @@ class PerFieldThinkingTests(unittest.TestCase):
139
139
  "a": {"type": "boolean", "instructions": "A?", "thinking": True},
140
140
  "b": {"type": "boolean", "instructions": "B?", "thinking": True},
141
141
  "skip": {"type": "boolean", "instructions": "Skip?", "thinking": False},
142
- })
142
+ }).result
143
143
  prompts = self.thinking_prompts(client.sglang)
144
144
  self.assertEqual(len(client.sglang.requests("think")), 1)
145
145
  self.assertEqual(len(prompts), 2)
@@ -153,7 +153,7 @@ class PerFieldThinkingTests(unittest.TestCase):
153
153
  "short": {"type": "boolean", "instructions": "Short?", "thinking": True, "thinking_budget": 100},
154
154
  "long": {"type": "boolean", "instructions": "Long?", "thinking": True, "thinking_budget": 300},
155
155
  "default": {"type": "boolean", "instructions": "Default?", "thinking": True},
156
- })
156
+ }).result
157
157
  [request] = client.sglang.requests("think")
158
158
  budgets = {next(q for q in ("Short?", "Long?", "Default?") if q in t): params["max_new_tokens"]
159
159
  for t, params in zip(request["text"], request["sampling_params"])}
@@ -172,16 +172,14 @@ class PerFieldThinkingTests(unittest.TestCase):
172
172
 
173
173
  client = TypeLLMClient(model="fake")
174
174
  client.sglang = Metered()
175
- client.generate(context="Receipt", questions={
175
+ done = client.generate(context="Receipt", questions={
176
176
  "hard": {"type": "boolean", "instructions": "Hard?", "thinking": True},
177
177
  "easy": {"type": "boolean", "instructions": "Easy?"},
178
178
  })
179
- self.assertEqual(client.last_thinking, {"hard": "Reasoned."})
180
- self.assertEqual(client.last_usage.thinking_tokens, 40)
181
- self.assertGreater(client.last_usage.completion_tokens, 40) # answers count too
182
- with self.assertRaises(ValueError):
183
- client.generate(questions={"x": {"type": "boolean"}})
184
- self.assertEqual(client.last_thinking, {})
179
+ self.assertEqual(done.thinking, {"hard": "Reasoned."})
180
+ self.assertEqual(done.usage.thinking_tokens, 40)
181
+ self.assertGreater(done.usage.completion_tokens, 40) # answers count too
182
+ self.assertEqual(client.generate(context="Receipt", questions={"x": {"type": "boolean"}}).thinking, {})
185
183
 
186
184
  def test_invalid_settings_are_schema_errors(self):
187
185
  from typellm import SchemaError
@@ -202,7 +200,7 @@ class BatchedThinkingTests(unittest.TestCase):
202
200
  def test_one_thinking_request_covers_every_prompt_in_a_layer(self):
203
201
  client = TypeLLMClient(model="fake")
204
202
  client.sglang = FakeServer()
205
- result = client.generate(context="Receipt", questions=thinks(self.QUESTIONS))
203
+ result = client.generate(context="Receipt", questions=thinks(self.QUESTIONS)).result
206
204
  self.assertEqual(result, {"flag": True, "count": 7, "name": "blue", "pick": "x"})
207
205
  [think] = client.sglang.requests("think")
208
206
  # Four fields plus the reversed ordering of "pick".
@@ -219,14 +217,14 @@ class BatchedThinkingTests(unittest.TestCase):
219
217
  "a": {"type": "boolean"}, "b": {"type": "integer"},
220
218
  "c": {"type": "boolean", "depends_on": ["a", "b"]},
221
219
  "d": {"type": "string", "depends_on": ["a"]},
222
- }))
220
+ })).result
223
221
  self.assertEqual([width(p) for p in client.sglang.requests("think")], [2, 2])
224
222
 
225
223
  def test_images_count_prompt_tokens_in_one_batch(self):
226
224
  client = TypeLLMClient(model="fake")
227
225
  client.sglang = FakeServer()
228
226
  client.generate(context="Receipt", images=[PNG],
229
- questions=thinks({"a": {"type": "boolean"}, "b": {"type": "boolean"}}))
227
+ questions=thinks({"a": {"type": "boolean"}, "b": {"type": "boolean"}})).result
230
228
  counts = [p for p in client.sglang.requests("count") if width(p) > 1]
231
229
  self.assertEqual([width(p) for p in counts], [2])
232
230
  [think] = client.sglang.requests("think")
@@ -42,9 +42,9 @@ class SharedClientTests(unittest.TestCase):
42
42
  def run(n):
43
43
  client.generate(context=f"Receipt {n}", questions={
44
44
  "a": {"type": "integer"}, "b": {"type": "boolean"},
45
- })
45
+ }).result
46
46
  finished.wait() # Every call has finished before any reads its prompts.
47
- return n, client.last_prompts
47
+ return n, client._last_prompts.get()
48
48
 
49
49
  with ThreadPoolExecutor(8) as pool:
50
50
  for n, prompts in pool.map(run, range(16)):
@@ -56,22 +56,22 @@ class SharedClientTests(unittest.TestCase):
56
56
  client = TypeLLMClient(model="fake", seed=1)
57
57
  client.sglang = FakeServer()
58
58
  state = client.rng.getstate()
59
- client.generate(context="Roll", questions=PICK, seed=5)
60
- first = client.last_prompts
61
- client.generate(context="Roll", questions=PICK, seed=5)
62
- self.assertEqual(client.last_prompts, first)
59
+ client.generate(context="Roll", questions=PICK, seed=5).result
60
+ first = client._last_prompts.get()
61
+ client.generate(context="Roll", questions=PICK, seed=5).result
62
+ self.assertEqual(client._last_prompts.get(), first)
63
63
  self.assertEqual(client.rng.getstate(), state)
64
- client.generate(context="Roll", questions=PICK)
64
+ client.generate(context="Roll", questions=PICK).result
65
65
  self.assertNotEqual(client.rng.getstate(), state)
66
66
 
67
67
  def test_clients_pickle_after_a_call(self):
68
68
  client = TypeLLMClient(model="fake")
69
69
  client.sglang = FakeServer()
70
- client.generate(context="Receipt", questions={"b": {"type": "boolean"}})
70
+ client.generate(context="Receipt", questions={"b": {"type": "boolean"}}).result
71
71
  copy = pickle.loads(pickle.dumps(client))
72
- self.assertEqual(copy.last_prompts, [])
73
- copy.generate(context="Receipt", questions={"b": {"type": "boolean"}})
74
- self.assertEqual(len(copy.last_prompts), 1)
72
+ self.assertEqual(copy._last_prompts.get(), [])
73
+ copy.generate(context="Receipt", questions={"b": {"type": "boolean"}}).result
74
+ self.assertEqual(len(copy._last_prompts.get()), 1)
75
75
 
76
76
 
77
77
  class MeteredServer(SlowServer):
@@ -102,19 +102,23 @@ class UsageTests(unittest.TestCase):
102
102
  def test_usage_sums_every_generate_request_of_the_call(self):
103
103
  client = TypeLLMClient(model="fake")
104
104
  client.sglang = MeteredServer()
105
- self.assertIsNone(client.last_usage)
106
- client.generate(context="Receipt", questions=self.QUESTIONS)
105
+ usage = client.generate(context="Receipt", questions=self.QUESTIONS).usage
107
106
  n = prompts_sent(client.sglang)
108
107
  # The input counts once, though every prompt carries it.
109
108
  sent = client.sglang.count_tokens("Receipt") + client.sglang.count_tokens(json.dumps(self.QUESTIONS))
110
- self.assertEqual(client.last_usage, Usage(
109
+ self.assertEqual(usage, Usage(
111
110
  requests=len(client.sglang.payloads),
112
111
  prompt_tokens=10 * n, cached_tokens=4 * n, completion_tokens=n, input_tokens=sent,
113
112
  ))
114
- self.assertGreater(client.last_usage.requests, 1)
113
+ self.assertGreater(usage.requests, 1)
115
114
  client.sglang.payloads.clear()
116
- client.generate(context="Receipt", questions={"b": {"type": "boolean"}})
117
- self.assertEqual(client.last_usage.requests, len(client.sglang.payloads))
115
+ usage = client.generate(context="Receipt", questions={"b": {"type": "boolean"}}).usage
116
+ self.assertEqual(usage.requests, len(client.sglang.payloads))
117
+
118
+ def test_usage_shows_billed_tokens_first_and_only_counts_it_has(self):
119
+ self.assertEqual(repr(Usage(input_tokens=66)), "Usage(input_tokens=66, thinking_tokens=0)")
120
+ self.assertEqual(repr(Usage(input_tokens=5, requests=2, prompt_tokens=30)),
121
+ "Usage(input_tokens=5, thinking_tokens=0, requests=2, prompt_tokens=30)")
118
122
 
119
123
  def test_concurrent_calls_count_only_their_own_requests(self):
120
124
  client = TypeLLMClient(model="fake")
@@ -122,9 +126,9 @@ class UsageTests(unittest.TestCase):
122
126
  finished = threading.Barrier(4)
123
127
 
124
128
  def run(questions):
125
- client.generate(context="Receipt", questions=questions)
129
+ usage = client.generate(context="Receipt", questions=questions).usage
126
130
  finished.wait()
127
- return client.last_usage.requests
131
+ return usage.requests
128
132
 
129
133
  one = {"b": {"type": "boolean"}}
130
134
  with ThreadPoolExecutor(4) as pool:
@@ -137,13 +141,13 @@ class UsageTests(unittest.TestCase):
137
141
  def test_a_failed_call_still_reports_the_requests_it_made(self):
138
142
  client = TypeLLMClient(model="fake")
139
143
  client.sglang = MeteredServer(fail_after=2)
140
- with self.assertRaisesRegex(SGLangError, "went away"):
144
+ with self.assertRaisesRegex(SGLangError, "went away") as caught:
141
145
  client.generate(context="Receipt", questions=self.QUESTIONS)
142
- self.assertEqual(client.last_usage.requests, 2)
143
- self.assertGreater(client.last_usage.input_tokens, 0)
144
- with self.assertRaises(ValueError):
146
+ self.assertEqual(caught.exception.usage.requests, 2)
147
+ self.assertGreater(caught.exception.usage.input_tokens, 0)
148
+ with self.assertRaises(ValueError) as caught:
145
149
  client.generate(questions=self.QUESTIONS)
146
- self.assertIsNone(client.last_usage)
150
+ self.assertFalse(hasattr(caught.exception, "usage")) # refused before any work
147
151
 
148
152
 
149
153
  class DeadlineTests(unittest.TestCase):
@@ -158,13 +162,13 @@ class DeadlineTests(unittest.TestCase):
158
162
 
159
163
  client = TypeLLMClient(model="fake")
160
164
  client.sglang = Slower()
161
- client.generate(context="Receipt", questions=self.QUESTIONS)
165
+ client.generate(context="Receipt", questions=self.QUESTIONS).result
162
166
  needed = len(client.sglang.payloads)
163
167
  client.sglang.payloads.clear()
164
- with self.assertRaises(GenerationTimeout):
168
+ with self.assertRaises(GenerationTimeout) as caught:
165
169
  client.generate(context="Receipt", questions=self.QUESTIONS, timeout=0.05)
166
170
  self.assertLess(len(client.sglang.payloads), needed)
167
- self.assertEqual(client.last_usage.requests, len(client.sglang.payloads))
171
+ self.assertEqual(caught.exception.usage.requests, len(client.sglang.payloads))
168
172
 
169
173
  def test_a_cancelled_call_sends_no_further_requests(self):
170
174
  cancel = threading.Event()
@@ -179,7 +183,7 @@ class DeadlineTests(unittest.TestCase):
179
183
  client = TypeLLMClient(model="fake")
180
184
  client.sglang = Cancelling()
181
185
  with self.assertRaises(GenerationCancelled):
182
- client.generate(context="Receipt", questions=self.QUESTIONS, cancel=cancel)
186
+ client.generate(context="Receipt", questions=self.QUESTIONS, cancel=cancel).result
183
187
  self.assertEqual(len(client.sglang.payloads), 2)
184
188
 
185
189
  def test_socket_timeouts_never_outlast_the_call(self):
@@ -256,7 +260,7 @@ class DeadlineTests(unittest.TestCase):
256
260
  for kwargs in ({"timeout": 0}, {"timeout": -1}, {"timeout": True}, {"timeout": "5"},
257
261
  {"cancel": True}):
258
262
  with self.subTest(**kwargs), self.assertRaises(ValueError):
259
- client.generate(context="Receipt", questions={"b": {"type": "boolean"}}, **kwargs)
263
+ client.generate(context="Receipt", questions={"b": {"type": "boolean"}}, **kwargs).result
260
264
  self.assertEqual(client.sglang.paths, []) # Rejected before even compiling.
261
265
 
262
266