typellm 0.2.4__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.4/typellm.egg-info → typellm-0.2.6}/PKG-INFO +35 -31
  2. {typellm-0.2.4 → typellm-0.2.6}/README.md +34 -30
  3. {typellm-0.2.4 → typellm-0.2.6}/pyproject.toml +1 -1
  4. {typellm-0.2.4 → typellm-0.2.6}/tests/test_batching.py +13 -15
  5. {typellm-0.2.4 → typellm-0.2.6}/tests/test_concurrency.py +33 -29
  6. {typellm-0.2.4 → typellm-0.2.6}/tests/test_dependencies.py +18 -18
  7. {typellm-0.2.4 → typellm-0.2.6}/tests/test_grammar_fields.py +9 -9
  8. {typellm-0.2.4 → typellm-0.2.6}/tests/test_images.py +9 -9
  9. {typellm-0.2.4 → typellm-0.2.6}/tests/test_nullable.py +8 -8
  10. {typellm-0.2.4 → typellm-0.2.6}/tests/test_permutations.py +7 -7
  11. {typellm-0.2.4 → typellm-0.2.6}/tests/test_prefill.py +12 -26
  12. {typellm-0.2.4 → typellm-0.2.6}/tests/test_probabilities.py +2 -2
  13. {typellm-0.2.4 → typellm-0.2.6}/tests/test_prompts.py +4 -13
  14. {typellm-0.2.4 → typellm-0.2.6}/tests/test_protocols.py +22 -0
  15. {typellm-0.2.4 → typellm-0.2.6}/tests/test_text.py +15 -18
  16. {typellm-0.2.4 → typellm-0.2.6}/tests/test_typellm.py +81 -80
  17. {typellm-0.2.4 → typellm-0.2.6}/typellm/__init__.py +2 -0
  18. {typellm-0.2.4 → typellm-0.2.6}/typellm/cli.py +4 -3
  19. {typellm-0.2.4 → typellm-0.2.6}/typellm/runtime.py +65 -79
  20. {typellm-0.2.4 → typellm-0.2.6}/typellm/schema.py +10 -23
  21. {typellm-0.2.4 → typellm-0.2.6}/typellm/sglang.py +50 -76
  22. {typellm-0.2.4 → typellm-0.2.6/typellm.egg-info}/PKG-INFO +35 -31
  23. {typellm-0.2.4 → typellm-0.2.6}/LICENSE +0 -0
  24. {typellm-0.2.4 → typellm-0.2.6}/setup.cfg +0 -0
  25. {typellm-0.2.4 → typellm-0.2.6}/typellm/__main__.py +0 -0
  26. {typellm-0.2.4 → typellm-0.2.6}/typellm/benchmark.py +0 -0
  27. {typellm-0.2.4 → typellm-0.2.6}/typellm/images.py +0 -0
  28. {typellm-0.2.4 → typellm-0.2.6}/typellm/protocol.py +0 -0
  29. {typellm-0.2.4 → typellm-0.2.6}/typellm.egg-info/SOURCES.txt +0 -0
  30. {typellm-0.2.4 → typellm-0.2.6}/typellm.egg-info/dependency_links.txt +0 -0
  31. {typellm-0.2.4 → typellm-0.2.6}/typellm.egg-info/entry_points.txt +0 -0
  32. {typellm-0.2.4 → typellm-0.2.6}/typellm.egg-info/requires.txt +0 -0
  33. {typellm-0.2.4 → 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.4
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": {
@@ -347,19 +349,20 @@ result = client.generate(
347
349
  Only opted-in fields return `value` and `probabilities`; other fields return plain values.
348
350
  The option is not supported on open Numeric or Text fields.
349
351
 
350
- Argmax is the default. To enable sampling:
352
+ `temperature` is 0 by default: each field gets its most likely answer. Above 0,
353
+ TypeLLM samples at that temperature:
351
354
 
352
355
  ```python
353
356
  client = TypeLLMClient(
354
357
  "http://127.0.0.1:30000",
355
- mode="sample",
356
358
  temperature=0.8,
357
359
  seed=42,
358
360
  )
359
361
  ```
360
362
 
361
363
  Sampling applies to finite candidates for Choice fields and to token generation
362
- for Numeric and Text fields. `temperature` controls sampling in each case.
364
+ for Numeric and Text fields. `generate(temperature=...)` sets it for one call.
365
+ The older `mode="argmax"` / `mode="sample"` still works for now but is deprecated.
363
366
 
364
367
  A seed fixes TypeLLM's own random choices. SGLang's log probabilities can shift
365
368
  with its prefix cache and batching, so the same seed may still give different
@@ -370,7 +373,7 @@ For a one-off request, use the convenience function:
370
373
  ```python
371
374
  from typellm import run_schema
372
375
 
373
- result = run_schema(
376
+ response = run_schema(
374
377
  context=context,
375
378
  questions=questions,
376
379
  base_url="http://127.0.0.1:30000",
@@ -383,7 +386,7 @@ result = run_schema(
383
386
  Add `permutations` to an `enum` question to reduce option-order bias. TypeLLM averages the probabilities and keeps the same return format.
384
387
 
385
388
  ```python
386
- result = client.generate(
389
+ response = client.generate(
387
390
  context="A single roll of a fair die.",
388
391
  questions={"roll": {
389
392
  "type": "string",
@@ -416,19 +419,19 @@ prompts and usage, and connections to SGLang are reused.
416
419
  import threading
417
420
 
418
421
  cancel = threading.Event()
419
- result = client.generate(
422
+ response = client.generate(
420
423
  context=context,
421
424
  questions=questions,
422
425
  seed=7, # this call's random choices only
423
426
  timeout=30, # seconds for the whole call; raises GenerationTimeout
424
427
  cancel=cancel, # set it from another thread; raises GenerationCancelled
425
428
  )
426
- print(client.last_usage)
427
- # 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)
428
431
  ```
429
432
 
430
- `last_usage` reports the tokens of the last call made in the current thread,
431
- 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
432
435
  counted once: the context, the questions as JSON, and the images. The other
433
436
  counts are SGLang's for the call's requests, where every prompt carries the
434
437
  shared context. A timeout or cancel stops
@@ -467,8 +470,9 @@ Other sizes in the Qwen3.5 and Qwen3.8 families are expected to be compatible.
467
470
 
468
471
  [Image input](#image-input) has been tested with `Qwen/Qwen3.8-27B`.
469
472
 
470
- Use the checkpoint ID as `model=`. If the server's tokenizer path is unavailable
471
- 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.
472
476
  The tokenizer must load from standard artifacts without custom model code.
473
477
 
474
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": {
@@ -332,19 +334,20 @@ result = client.generate(
332
334
  Only opted-in fields return `value` and `probabilities`; other fields return plain values.
333
335
  The option is not supported on open Numeric or Text fields.
334
336
 
335
- Argmax is the default. To enable sampling:
337
+ `temperature` is 0 by default: each field gets its most likely answer. Above 0,
338
+ TypeLLM samples at that temperature:
336
339
 
337
340
  ```python
338
341
  client = TypeLLMClient(
339
342
  "http://127.0.0.1:30000",
340
- mode="sample",
341
343
  temperature=0.8,
342
344
  seed=42,
343
345
  )
344
346
  ```
345
347
 
346
348
  Sampling applies to finite candidates for Choice fields and to token generation
347
- for Numeric and Text fields. `temperature` controls sampling in each case.
349
+ for Numeric and Text fields. `generate(temperature=...)` sets it for one call.
350
+ The older `mode="argmax"` / `mode="sample"` still works for now but is deprecated.
348
351
 
349
352
  A seed fixes TypeLLM's own random choices. SGLang's log probabilities can shift
350
353
  with its prefix cache and batching, so the same seed may still give different
@@ -355,7 +358,7 @@ For a one-off request, use the convenience function:
355
358
  ```python
356
359
  from typellm import run_schema
357
360
 
358
- result = run_schema(
361
+ response = run_schema(
359
362
  context=context,
360
363
  questions=questions,
361
364
  base_url="http://127.0.0.1:30000",
@@ -368,7 +371,7 @@ result = run_schema(
368
371
  Add `permutations` to an `enum` question to reduce option-order bias. TypeLLM averages the probabilities and keeps the same return format.
369
372
 
370
373
  ```python
371
- result = client.generate(
374
+ response = client.generate(
372
375
  context="A single roll of a fair die.",
373
376
  questions={"roll": {
374
377
  "type": "string",
@@ -401,19 +404,19 @@ prompts and usage, and connections to SGLang are reused.
401
404
  import threading
402
405
 
403
406
  cancel = threading.Event()
404
- result = client.generate(
407
+ response = client.generate(
405
408
  context=context,
406
409
  questions=questions,
407
410
  seed=7, # this call's random choices only
408
411
  timeout=30, # seconds for the whole call; raises GenerationTimeout
409
412
  cancel=cancel, # set it from another thread; raises GenerationCancelled
410
413
  )
411
- print(client.last_usage)
412
- # 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)
413
416
  ```
414
417
 
415
- `last_usage` reports the tokens of the last call made in the current thread,
416
- 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
417
420
  counted once: the context, the questions as JSON, and the images. The other
418
421
  counts are SGLang's for the call's requests, where every prompt carries the
419
422
  shared context. A timeout or cancel stops
@@ -452,8 +455,9 @@ Other sizes in the Qwen3.5 and Qwen3.8 families are expected to be compatible.
452
455
 
453
456
  [Image input](#image-input) has been tested with `Qwen/Qwen3.8-27B`.
454
457
 
455
- Use the checkpoint ID as `model=`. If the server's tokenizer path is unavailable
456
- 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.
457
461
  The tokenizer must load from standard artifacts without custom model code.
458
462
 
459
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.4"
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