typellm 0.2.0__tar.gz → 0.2.2__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 (34) hide show
  1. {typellm-0.2.0 → typellm-0.2.2}/PKG-INFO +22 -14
  2. {typellm-0.2.0 → typellm-0.2.2}/README.md +21 -13
  3. {typellm-0.2.0 → typellm-0.2.2}/pyproject.toml +1 -1
  4. typellm-0.2.2/tests/test_batching.py +237 -0
  5. {typellm-0.2.0 → typellm-0.2.2}/tests/test_dependencies.py +1 -1
  6. {typellm-0.2.0 → typellm-0.2.2}/tests/test_grammar_fields.py +15 -1
  7. {typellm-0.2.0 → typellm-0.2.2}/tests/test_images.py +0 -1
  8. {typellm-0.2.0 → typellm-0.2.2}/tests/test_protocols.py +10 -10
  9. {typellm-0.2.0 → typellm-0.2.2}/tests/test_text.py +2 -2
  10. {typellm-0.2.0 → typellm-0.2.2}/tests/test_typellm.py +18 -14
  11. {typellm-0.2.0 → typellm-0.2.2}/typellm/runtime.py +74 -48
  12. {typellm-0.2.0 → typellm-0.2.2}/typellm/schema.py +10 -1
  13. {typellm-0.2.0 → typellm-0.2.2}/typellm/sglang.py +198 -84
  14. {typellm-0.2.0 → typellm-0.2.2}/typellm.egg-info/PKG-INFO +22 -14
  15. typellm-0.2.0/tests/test_batching.py +0 -129
  16. {typellm-0.2.0 → typellm-0.2.2}/LICENSE +0 -0
  17. {typellm-0.2.0 → typellm-0.2.2}/setup.cfg +0 -0
  18. {typellm-0.2.0 → typellm-0.2.2}/tests/test_concurrency.py +0 -0
  19. {typellm-0.2.0 → typellm-0.2.2}/tests/test_nullable.py +0 -0
  20. {typellm-0.2.0 → typellm-0.2.2}/tests/test_permutations.py +0 -0
  21. {typellm-0.2.0 → typellm-0.2.2}/tests/test_prefill.py +0 -0
  22. {typellm-0.2.0 → typellm-0.2.2}/tests/test_probabilities.py +0 -0
  23. {typellm-0.2.0 → typellm-0.2.2}/tests/test_prompts.py +0 -0
  24. {typellm-0.2.0 → typellm-0.2.2}/typellm/__init__.py +0 -0
  25. {typellm-0.2.0 → typellm-0.2.2}/typellm/__main__.py +0 -0
  26. {typellm-0.2.0 → typellm-0.2.2}/typellm/benchmark.py +0 -0
  27. {typellm-0.2.0 → typellm-0.2.2}/typellm/cli.py +0 -0
  28. {typellm-0.2.0 → typellm-0.2.2}/typellm/images.py +0 -0
  29. {typellm-0.2.0 → typellm-0.2.2}/typellm/protocol.py +0 -0
  30. {typellm-0.2.0 → typellm-0.2.2}/typellm.egg-info/SOURCES.txt +0 -0
  31. {typellm-0.2.0 → typellm-0.2.2}/typellm.egg-info/dependency_links.txt +0 -0
  32. {typellm-0.2.0 → typellm-0.2.2}/typellm.egg-info/entry_points.txt +0 -0
  33. {typellm-0.2.0 → typellm-0.2.2}/typellm.egg-info/requires.txt +0 -0
  34. {typellm-0.2.0 → typellm-0.2.2}/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.0
3
+ Version: 0.2.2
4
4
  Summary: Type-Safe Decoding for Autoregressive LLMs
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://typellm.ai/
@@ -168,6 +168,9 @@ result = client.generate(
168
168
  )
169
169
  ```
170
170
 
171
+ 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.
173
+
171
174
  Ask for a numeric answer without enumerating every possible value:
172
175
 
173
176
  ```python
@@ -229,23 +232,28 @@ result = client.generate(
229
232
 
230
233
  ## Thinking mode
231
234
 
232
- Thinking is off by default. Enable it when constructing the client:
235
+ Thinking is off by default. Turn it on for the fields that need it; the others
236
+ answer at once, and the fields that think reason side by side:
233
237
 
234
238
  ```python
235
- client = TypeLLMClient(
236
- "http://127.0.0.1:30000",
237
- model="Qwen/Qwen3.8-27B",
238
- thinking=True,
239
- )
240
- result = client.generate(context=context, questions=questions)
239
+ result = client.generate(context=context, questions={
240
+ "total": {"type": "number"},
241
+ "category": {"type": "string", "enum": ["meal", "travel", "equipment"]},
242
+ "policy_ok": {"type": "boolean", "instructions": "Does it meet the travel policy?",
243
+ "thinking": True, "thinking_budget": 1024},
244
+ })
241
245
  ```
242
246
 
243
- Set `thinking_budget=2048` to cap reasoning per field; there is no budget by
244
- default. When reasoning reaches the budget, TypeLLM closes it and moves on to
245
- the typed answer.
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.
251
+
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.
246
254
 
247
- Models with always-on thinking still reason with `thinking=False`;
248
- `thinking_budget` applies to them too. See [Supported models](#supported-models).
255
+ Models with always-on thinking reason on every field; `thinking_budget` applies
256
+ to them too. See [Supported models](#supported-models).
249
257
 
250
258
  ## Image input
251
259
 
@@ -416,7 +424,7 @@ result = client.generate(
416
424
  cancel=cancel, # set it from another thread; raises GenerationCancelled
417
425
  )
418
426
  print(client.last_usage)
419
- # Usage(requests=4, prompt_tokens=1830, cached_tokens=1504, completion_tokens=7)
427
+ # Usage(requests=4, prompt_tokens=1830, cached_tokens=1504, completion_tokens=7, thinking_tokens=0)
420
428
  ```
421
429
 
422
430
  `last_usage` reports the SGLang requests and tokens of the last call made in the
@@ -153,6 +153,9 @@ result = client.generate(
153
153
  )
154
154
  ```
155
155
 
156
+ 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.
158
+
156
159
  Ask for a numeric answer without enumerating every possible value:
157
160
 
158
161
  ```python
@@ -214,23 +217,28 @@ result = client.generate(
214
217
 
215
218
  ## Thinking mode
216
219
 
217
- Thinking is off by default. Enable it when constructing the client:
220
+ Thinking is off by default. Turn it on for the fields that need it; the others
221
+ answer at once, and the fields that think reason side by side:
218
222
 
219
223
  ```python
220
- client = TypeLLMClient(
221
- "http://127.0.0.1:30000",
222
- model="Qwen/Qwen3.8-27B",
223
- thinking=True,
224
- )
225
- result = client.generate(context=context, questions=questions)
224
+ result = client.generate(context=context, questions={
225
+ "total": {"type": "number"},
226
+ "category": {"type": "string", "enum": ["meal", "travel", "equipment"]},
227
+ "policy_ok": {"type": "boolean", "instructions": "Does it meet the travel policy?",
228
+ "thinking": True, "thinking_budget": 1024},
229
+ })
226
230
  ```
227
231
 
228
- Set `thinking_budget=2048` to cap reasoning per field; there is no budget by
229
- default. When reasoning reaches the budget, TypeLLM closes it and moves on to
230
- the typed answer.
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.
236
+
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.
231
239
 
232
- Models with always-on thinking still reason with `thinking=False`;
233
- `thinking_budget` applies to them too. See [Supported models](#supported-models).
240
+ Models with always-on thinking reason on every field; `thinking_budget` applies
241
+ to them too. See [Supported models](#supported-models).
234
242
 
235
243
  ## Image input
236
244
 
@@ -401,7 +409,7 @@ result = client.generate(
401
409
  cancel=cancel, # set it from another thread; raises GenerationCancelled
402
410
  )
403
411
  print(client.last_usage)
404
- # Usage(requests=4, prompt_tokens=1830, cached_tokens=1504, completion_tokens=7)
412
+ # Usage(requests=4, prompt_tokens=1830, cached_tokens=1504, completion_tokens=7, thinking_tokens=0)
405
413
  ```
406
414
 
407
415
  `last_usage` reports the SGLang requests and tokens of the last call made in the
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "typellm"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Type-Safe Decoding for Autoregressive LLMs"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -0,0 +1,237 @@
1
+ import json
2
+ import unittest
3
+
4
+ from typellm import SGLangClient, TypeLLMClient
5
+ from typellm.images import encode_image
6
+
7
+ from tests.test_images import PNG, VisionTokenizer, fake_detokenize, fake_tokenize
8
+
9
+ THINK_STOP = "</think>"
10
+
11
+
12
+ class ThinkingTokenizer(VisionTokenizer):
13
+ def apply_chat_template(self, messages, *, add_generation_prompt, enable_thinking=False, **kwargs):
14
+ rendered = super().apply_chat_template(messages, add_generation_prompt=add_generation_prompt)
15
+ return rendered + ("<think>\n" if add_generation_prompt and enable_thinking else "")
16
+
17
+
18
+ def is_number_pattern(pattern):
19
+ """Whether a regex is typellm's numeric one rather than the string one."""
20
+ return pattern.startswith((" ?-?", " ?(?:-?"))
21
+
22
+
23
+ class FakeServer(SGLangClient):
24
+ """A real SGLangClient whose HTTP layer answers like SGLang.
25
+
26
+ Numeric fields decode to 7: the server prefers the end token, then "7".
27
+ """
28
+
29
+ def __init__(self):
30
+ super().__init__(model="fake")
31
+ self._chat_tokenizer = ThinkingTokenizer()
32
+ self._context_length_cache = 100_000
33
+ self.payloads = []
34
+
35
+ def _request(self, path, payload=None, *, allow_text=False):
36
+ if path == "/v1/tokenize":
37
+ return {"tokens": fake_tokenize(payload["prompt"])}
38
+ if path == "/v1/detokenize":
39
+ return {"text": fake_detokenize(payload["tokens"])}
40
+ assert path == "/generate", path
41
+ self.payloads.append(payload)
42
+ texts = [payload["text"]] if isinstance(payload["text"], str) else payload["text"]
43
+ params = payload["sampling_params"]
44
+ params = params if isinstance(params, list) else [params] * len(texts)
45
+ if "token_ids_logprob" in payload:
46
+ rows = payload["token_ids_logprob"]
47
+ rows = [rows] if isinstance(rows[0], int) else rows
48
+ out = []
49
+ for ids in rows:
50
+ # Like the real model: a number starts with the lone space token.
51
+ pick = ord(" ") if ord(" ") in ids else 1 if 1 in ids else ord("7") if ord("7") in ids else ids[0]
52
+ out.append({"meta_info": {"output_token_ids_logprobs": [
53
+ [[0.0 if t == pick else -9.0, t, "?"] for t in ids]]}})
54
+ elif params[0].get("stop") == [THINK_STOP]:
55
+ out = [{"text": "Reasoned." + THINK_STOP, "meta_info": {}} for _ in texts]
56
+ elif "regex" in params[0]:
57
+ # The prompt ends with '{"name":'; a number's digits, or a string's quote,
58
+ # characters and '"}' follow.
59
+ out = [{"text": " 7}" if is_number_pattern(p["regex"]) else ' "blue"}',
60
+ "meta_info": {"finish_reason": {"type": "stop"}}} for p in params]
61
+ elif "json_schema" in params[0]:
62
+ out = []
63
+ for p in params:
64
+ schema = json.loads(p["json_schema"])
65
+ # An object schema answers {"key": "blue"}; a plain string schema "blue".
66
+ value = {next(iter(schema["properties"])): "blue"} if schema["type"] == "object" else "blue"
67
+ out.append({"text": json.dumps(value), "meta_info": {"finish_reason": {"type": "stop"}}})
68
+ else:
69
+ out = [{"meta_info": {"prompt_tokens": 900}} for _ in texts]
70
+ return out[0] if isinstance(payload["text"], str) else out
71
+
72
+ def requests(self, kind):
73
+ def matches(p):
74
+ params = p["sampling_params"]
75
+ first = params[0] if isinstance(params, list) else params
76
+ return {"score": "token_ids_logprob" in p,
77
+ "think": first.get("stop") == [THINK_STOP],
78
+ "count": first.get("max_new_tokens") == 0}[kind]
79
+ return [p for p in self.payloads if matches(p)]
80
+
81
+
82
+ def width(payload):
83
+ return 1 if isinstance(payload["text"], str) else len(payload["text"])
84
+
85
+
86
+ def thinks(questions):
87
+ return {name: {**field, "thinking": True} for name, field in questions.items()}
88
+
89
+
90
+ class PrefixWarmupTests(unittest.TestCase):
91
+ MIXED = {"a": {"type": "integer"}, "b": {"type": "number"}, "n": {"type": "string"},
92
+ "k": {"type": "string", "enum": ["x", "y"]}}
93
+
94
+ def test_the_context_is_warmed_before_any_batch_that_shares_it(self):
95
+ for thinking in (False, True):
96
+ with self.subTest(thinking=thinking):
97
+ client = TypeLLMClient(model="fake")
98
+ client.sglang = FakeServer()
99
+ client.generate(context="Receipt", questions=thinks(self.MIXED) if thinking else self.MIXED)
100
+ warm = client.sglang.requests("count")
101
+ self.assertEqual(len(warm), 1)
102
+ # First, before the thinking, number, text and scoring batches.
103
+ self.assertIs(client.sglang.payloads[0], warm[0])
104
+ self.assertIn("Receipt", warm[0]["text"])
105
+ self.assertNotIn('{"a":', warm[0]["text"])
106
+
107
+ def test_open_fields_alone_are_not_warmed(self):
108
+ client = TypeLLMClient(model="fake")
109
+ client.sglang = FakeServer()
110
+ client.generate(context="Receipt", questions={"a": {"type": "integer"}, "n": {"type": "string"}})
111
+ self.assertEqual(client.sglang.requests("count"), [])
112
+
113
+
114
+ class PerFieldThinkingTests(unittest.TestCase):
115
+ def thinking_prompts(self, server):
116
+ return [t for p in server.requests("think")
117
+ for t in ([p["text"]] if isinstance(p["text"], str) else p["text"])]
118
+
119
+ def test_only_fields_that_ask_for_it_think(self):
120
+ client = TypeLLMClient(model="fake")
121
+ client.sglang = FakeServer()
122
+ result = client.generate(context="Receipt", questions={
123
+ "hard": {"type": "boolean", "instructions": "Hard?", "thinking": True},
124
+ "easy": {"type": "boolean", "instructions": "Easy?"},
125
+ "count": {"type": "integer"},
126
+ })
127
+ self.assertEqual(result, {"hard": True, "easy": True, "count": 7})
128
+ [prompt] = self.thinking_prompts(client.sglang) # one batch, one prompt
129
+ self.assertIn("Hard?", prompt)
130
+ self.assertTrue(prompt.endswith("<think>\n"))
131
+ answers = [t for p in client.sglang.payloads if p not in client.sglang.requests("think")
132
+ for t in ([p["text"]] if isinstance(p["text"], str) else p["text"])]
133
+ self.assertTrue(any("Easy?" in t and "<think>" not in t for t in answers))
134
+
135
+ def test_thinking_false_is_the_same_as_leaving_it_out(self):
136
+ client = TypeLLMClient(model="fake")
137
+ client.sglang = FakeServer()
138
+ client.generate(context="Receipt", questions={
139
+ "a": {"type": "boolean", "instructions": "A?", "thinking": True},
140
+ "b": {"type": "boolean", "instructions": "B?", "thinking": True},
141
+ "skip": {"type": "boolean", "instructions": "Skip?", "thinking": False},
142
+ })
143
+ prompts = self.thinking_prompts(client.sglang)
144
+ self.assertEqual(len(client.sglang.requests("think")), 1)
145
+ self.assertEqual(len(prompts), 2)
146
+ self.assertFalse(any("Skip?" in t for t in prompts))
147
+
148
+ def test_each_field_gets_its_own_budget(self):
149
+ client = TypeLLMClient(model="fake")
150
+ client.sglang = FakeServer()
151
+ client.sglang.thinking_budget = 500 # the client's budget, for fields without their own
152
+ client.generate(context="Receipt", questions={
153
+ "short": {"type": "boolean", "instructions": "Short?", "thinking": True, "thinking_budget": 100},
154
+ "long": {"type": "boolean", "instructions": "Long?", "thinking": True, "thinking_budget": 300},
155
+ "default": {"type": "boolean", "instructions": "Default?", "thinking": True},
156
+ })
157
+ [request] = client.sglang.requests("think")
158
+ budgets = {next(q for q in ("Short?", "Long?", "Default?") if q in t): params["max_new_tokens"]
159
+ for t, params in zip(request["text"], request["sampling_params"])}
160
+ self.assertEqual(budgets, {"Short?": 100, "Long?": 300, "Default?": 500})
161
+
162
+ def test_reasoning_and_its_tokens_are_reported_per_field(self):
163
+ class Metered(FakeServer):
164
+ def _request(self, path, payload=None, *, allow_text=False):
165
+ response = super()._request(path, payload, allow_text=allow_text)
166
+ if path == "/generate":
167
+ params = payload["sampling_params"]
168
+ thinking = (params[0] if isinstance(params, list) else params).get("stop") == [THINK_STOP]
169
+ for item in response if isinstance(response, list) else [response]:
170
+ item["meta_info"]["completion_tokens"] = 40 if thinking else 1
171
+ return response
172
+
173
+ client = TypeLLMClient(model="fake")
174
+ client.sglang = Metered()
175
+ client.generate(context="Receipt", questions={
176
+ "hard": {"type": "boolean", "instructions": "Hard?", "thinking": True},
177
+ "easy": {"type": "boolean", "instructions": "Easy?"},
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, {})
185
+
186
+ def test_invalid_settings_are_schema_errors(self):
187
+ from typellm import SchemaError
188
+ for field in ({"type": "boolean", "thinking": "yes"}, {"type": "boolean", "thinking_budget": 0},
189
+ {"type": "boolean", "thinking_budget": 2.5}):
190
+ with self.subTest(field=field), self.assertRaises(SchemaError):
191
+ TypeLLMClient(model="fake").compile_schema({"type": "object", "properties": {"x": field}})
192
+
193
+
194
+ class BatchedThinkingTests(unittest.TestCase):
195
+ QUESTIONS = {
196
+ "flag": {"type": "boolean"},
197
+ "count": {"type": "integer"},
198
+ "name": {"type": "string"},
199
+ "pick": {"type": "string", "enum": ["x", "y"], "permutations": "all"},
200
+ }
201
+
202
+ def test_one_thinking_request_covers_every_prompt_in_a_layer(self):
203
+ client = TypeLLMClient(model="fake")
204
+ client.sglang = FakeServer()
205
+ result = client.generate(context="Receipt", questions=thinks(self.QUESTIONS))
206
+ self.assertEqual(result, {"flag": True, "count": 7, "name": "blue", "pick": "x"})
207
+ [think] = client.sglang.requests("think")
208
+ # Four fields plus the reversed ordering of "pick".
209
+ self.assertEqual(width(think), 5)
210
+ self.assertEqual(len(think["sampling_params"]), 5)
211
+ for payload in client.sglang.requests("score"):
212
+ texts = [payload["text"]] if isinstance(payload["text"], str) else payload["text"]
213
+ self.assertTrue(all("Reasoned." + THINK_STOP in t for t in texts))
214
+
215
+ def test_dag_thinks_once_per_layer(self):
216
+ client = TypeLLMClient(model="fake")
217
+ client.sglang = FakeServer()
218
+ client.generate(context="Receipt", questions=thinks({
219
+ "a": {"type": "boolean"}, "b": {"type": "integer"},
220
+ "c": {"type": "boolean", "depends_on": ["a", "b"]},
221
+ "d": {"type": "string", "depends_on": ["a"]},
222
+ }))
223
+ self.assertEqual([width(p) for p in client.sglang.requests("think")], [2, 2])
224
+
225
+ def test_images_count_prompt_tokens_in_one_batch(self):
226
+ client = TypeLLMClient(model="fake")
227
+ client.sglang = FakeServer()
228
+ client.generate(context="Receipt", images=[PNG],
229
+ questions=thinks({"a": {"type": "boolean"}, "b": {"type": "boolean"}}))
230
+ counts = [p for p in client.sglang.requests("count") if width(p) > 1]
231
+ self.assertEqual([width(p) for p in counts], [2])
232
+ [think] = client.sglang.requests("think")
233
+ self.assertEqual(think["image_data"], encode_image(PNG))
234
+
235
+
236
+ if __name__ == "__main__":
237
+ unittest.main()
@@ -102,7 +102,7 @@ class DependencyTests(unittest.TestCase):
102
102
  self.assertTrue(client.last_prompts[1].startswith(client.last_prompts[0]))
103
103
 
104
104
  def test_always_thinking_template_continuation_runs_once(self):
105
- client = SGLangClient(thinking=False)
105
+ client = SGLangClient()
106
106
 
107
107
  class Tokenizer:
108
108
  chat_template = 'test'
@@ -5,7 +5,7 @@ import unittest
5
5
  from typellm import SGLangError, TypeLLMClient
6
6
  from typellm.runtime import _numeric_text_is_complete, numeric_pattern
7
7
 
8
- from tests.test_batching import FakeServer, width
8
+ from tests.test_batching import FakeServer, is_number_pattern, width
9
9
  from tests.test_nullable import NullServer
10
10
 
11
11
 
@@ -72,6 +72,20 @@ class GrammarNumberTests(unittest.TestCase):
72
72
  self.assertEqual(numbers["sampling_params"][0]["temperature"], 0)
73
73
  self.assertIn('{"a": 7}', client.last_prompts[0])
74
74
 
75
+ def test_numbers_and_strings_of_a_layer_share_one_request(self):
76
+ client = TypeLLMClient(model="fake")
77
+ client.sglang = FakeServer()
78
+ result = client.generate(context="Receipt", questions={
79
+ "a": {"type": "integer"}, "n": {"type": "string"},
80
+ "b": {"type": "number"}, "m": {"type": ["string", "null"]}})
81
+ self.assertEqual(result, {"a": 7, "n": "blue", "b": 7, "m": "blue"})
82
+ [request] = regex_requests(client.sglang)
83
+ self.assertEqual(width(request), 4)
84
+ patterns = [p["regex"] for p in request["sampling_params"]]
85
+ self.assertEqual([is_number_pattern(p) for p in patterns], [True, True, False, False])
86
+ self.assertTrue(all(t.endswith(k) for t, k in zip(request["text"], ('{"a":', '{"b":', '{"n":', '{"m":'))))
87
+ self.assertIn("null", patterns[3])
88
+
75
89
  def test_sampling_passes_the_temperature_and_no_truncation(self):
76
90
  client = TypeLLMClient(model="fake", mode="sample", temperature=0.7, seed=1)
77
91
  client.sglang = FakeServer()
@@ -255,7 +255,6 @@ class ImageRequestTests(unittest.TestCase):
255
255
 
256
256
  def test_thinking_budget_counts_image_tokens_on_the_server(self):
257
257
  client = FakeServerClient()
258
- client.thinking = True
259
258
  client.thinking_budget = None
260
259
  client._context_length_cache = 1000
261
260
  prefix = VisionTokenizer().apply_chat_template(
@@ -39,8 +39,8 @@ class ProtocolTokenizer(FakeChatTokenizer):
39
39
 
40
40
 
41
41
  class ProtocolTests(unittest.TestCase):
42
- def client(self, family, thinking=False, response=None):
43
- client = SGLangClient(thinking=thinking, thinking_budget=64)
42
+ def client(self, family, response=None):
43
+ client = SGLangClient(thinking_budget=64)
44
44
  client._chat_tokenizer = ProtocolTokenizer(family)
45
45
  client._context_length_cache = 8192
46
46
  end = '</think>'
@@ -59,9 +59,9 @@ class ProtocolTests(unittest.TestCase):
59
59
 
60
60
  def test_gemma_is_rejected_before_inference(self):
61
61
  for thinking in (False, True):
62
- client = self.client('gemma', thinking)
62
+ client = self.client('gemma')
63
63
  with self.assertRaisesRegex(SGLangError, 'Gemma 4.*not supported'):
64
- client.render_chat([], add_generation_prompt=True)
64
+ client.render_chat([], add_generation_prompt=True, thinking=thinking)
65
65
  client._request.assert_not_called()
66
66
 
67
67
  def test_native_turn_stop_recovers_preserved_and_filtered_terminators(self):
@@ -69,7 +69,7 @@ class ProtocolTests(unittest.TestCase):
69
69
  for matched in (ending, token):
70
70
  for suffix in ('', ending + '\n'):
71
71
  with self.subTest(family=family, matched=matched, suffix=suffix):
72
- client = self.client(family, True, {
72
+ client = self.client(family, {
73
73
  'text': 'Keep reasoning' + suffix,
74
74
  'meta_info': {'finish_reason': {'type': 'stop', 'matched': matched}},
75
75
  })
@@ -91,15 +91,15 @@ class ProtocolTests(unittest.TestCase):
91
91
  ('Partial<|im_end|>', {'type': 'error', 'matched': 3}),
92
92
  ]:
93
93
  with self.subTest(text=text, finish=finish):
94
- client = self.client('minicpm5', True, {'text': text, 'meta_info': {'finish_reason': finish}})
94
+ client = self.client('minicpm5', {'text': text, 'meta_info': {'finish_reason': finish}})
95
95
  with self.assertRaises(SGLangError):
96
96
  client._finish_thinking('<think>\n')
97
97
 
98
98
  def test_minicpm5_switch_and_ring_always_thinking(self):
99
99
  for family in ('minicpm5', 'ring'):
100
100
  for thinking in (False, True):
101
- client = self.client(family, thinking)
102
- prompt = client.render_chat([], add_generation_prompt=True)
101
+ client = self.client(family)
102
+ prompt = client.render_chat([], add_generation_prompt=True, thinking=thinking)
103
103
  expected = thinking or family == 'ring'
104
104
  self.assertEqual(client._request.called, expected)
105
105
  parent = client.complete_chat_prefix(prompt, 'A')
@@ -108,9 +108,9 @@ class ProtocolTests(unittest.TestCase):
108
108
 
109
109
  def test_non_thinking_templates_reject_requested_thinking(self):
110
110
  for family in ('ling',):
111
- client = self.client(family, True)
111
+ client = self.client(family)
112
112
  with self.assertRaisesRegex(SGLangError, 'may not support thinking'):
113
- client.render_chat([], add_generation_prompt=True)
113
+ client.render_chat([], add_generation_prompt=True, thinking=True)
114
114
  client._request.assert_not_called()
115
115
 
116
116
 
@@ -63,13 +63,13 @@ class TextTests(unittest.TestCase):
63
63
  TypeLLMClient(text_max_tokens=budget)
64
64
 
65
65
  def test_thinking_then_constrained_text(self):
66
- client=TypeLLMClient(thinking=True)
66
+ client=TypeLLMClient()
67
67
  client.sglang._context_length_cache=8192
68
68
  client.sglang._chat_tokenizer=FakeChatTokenizer()
69
69
  client.sglang._chat_tokenizer.apply_chat_template=lambda *args,**kw: "assistant\n<think>\n" if kw["enable_thinking"] else "completed"
70
70
  responses=[{'text':'brief</think>'},[{'text':' "done"}','meta_info':{'finish_reason':{'type':'stop'}}}]]
71
71
  with patch.object(client.sglang,'_request',side_effect=responses) as request:
72
- self.assertEqual(client.generate(state='x',questions={'t':{'type':'string'}}),{'t':'done'})
72
+ self.assertEqual(client.generate(state='x',questions={'t':{'type':'string','thinking':True}}),{'t':'done'})
73
73
  self.assertIn('</think>',request.call_args.args[1]['text'][0])
74
74
  self.assertTrue(request.call_args.args[1]['text'][0].endswith('{"t":'))
75
75
  self.assertIn('regex',request.call_args.args[1]['sampling_params'][0])
@@ -60,6 +60,12 @@ class FakeSGLang:
60
60
  self.number_patterns.extend(patterns)
61
61
  return [next(self.numbers, " 7}") for _ in prefixes]
62
62
 
63
+ def generate_fields(self, number_prefixes, patterns, number_max_tokens, text_prefixes, max_lengths,
64
+ *, temperature=0, number_seed=0, text_seed=0, after_key=False, nullable=None):
65
+ numbers = self.generate_numbers(number_prefixes, patterns, number_max_tokens)
66
+ texts = self.generate_texts(text_prefixes, max_lengths) if text_prefixes else []
67
+ return numbers, texts
68
+
63
69
  def score_candidates(self, prefix, candidate_ids):
64
70
  self.prompts.append(prefix)
65
71
  self.candidate_sets.append(list(candidate_ids))
@@ -421,7 +427,7 @@ class ThinkingTests(unittest.TestCase):
421
427
  from typellm import run_schema
422
428
  self.assertIsNone(SGLangClient().thinking_budget)
423
429
  self.assertIsNone(TypeLLMClient().sglang.thinking_budget)
424
- client = SGLangClient(thinking=True)
430
+ client = SGLangClient()
425
431
  client._chat_tokenizer = FakeChatTokenizer()
426
432
  client._context_length_cache = 8192
427
433
  client._request = Mock(return_value={"text": "Done.</think>"})
@@ -437,7 +443,7 @@ class ThinkingTests(unittest.TestCase):
437
443
 
438
444
  def make_client(self, response=None):
439
445
  from unittest.mock import Mock
440
- client = SGLangClient(thinking=True, thinking_budget=128)
446
+ client = SGLangClient(thinking_budget=128)
441
447
  client._context_length_cache = 8192
442
448
  tokenizer = FakeChatTokenizer()
443
449
  tokenizer.apply_chat_template = Mock(return_value="assistant\n<think>\n")
@@ -447,7 +453,7 @@ class ThinkingTests(unittest.TestCase):
447
453
 
448
454
  def test_thinking_stops_before_constrained_answer(self):
449
455
  client = self.make_client()
450
- prompt = client.render_chat([], add_generation_prompt=True)
456
+ prompt = client.render_chat([], add_generation_prompt=True, thinking=True)
451
457
  self.assertEqual(prompt, "assistant\n<think>\nWork done. </think>\n\n")
452
458
  self.assertNotIn("illegal answer", prompt)
453
459
  params = client._request.call_args.args[1]["sampling_params"]
@@ -458,7 +464,7 @@ class ThinkingTests(unittest.TestCase):
458
464
 
459
465
  def test_length_stop_forces_closure_and_retains_reasoning(self):
460
466
  client = self.make_client({"text": "Partial reasoning", "meta_info": {"finish_reason": {"type": "length"}}})
461
- prompt = client.render_chat([], add_generation_prompt=True)
467
+ prompt = client.render_chat([], add_generation_prompt=True, thinking=True)
462
468
  self.assertIn("Partial reasoning", prompt)
463
469
  self.assertIn("I will now give the final answer.", prompt)
464
470
  self.assertTrue(prompt.endswith("</think>\n\n"))
@@ -493,7 +499,7 @@ class ThinkingTests(unittest.TestCase):
493
499
  for text in ("Partial", "Partial</think>"):
494
500
  client = self.make_client({"text": text, "meta_info": {"finish_reason": {"type": "abort"}}})
495
501
  with self.assertRaisesRegex(SGLangError, "aborted"):
496
- client.render_chat([], add_generation_prompt=True)
502
+ client.render_chat([], add_generation_prompt=True, thinking=True)
497
503
 
498
504
  def test_forced_thinking_keeps_all_final_decoders(self):
499
505
  stops = [{"type": "length"}, {"type": "stop", "matched": "<|im_end|>"}]
@@ -522,20 +528,19 @@ class ThinkingTests(unittest.TestCase):
522
528
  client = self.make_client()
523
529
  client._request.return_value = response
524
530
  with self.subTest(response=response), self.assertRaises(SGLangError):
525
- client.render_chat([], add_generation_prompt=True)
531
+ client.render_chat([], add_generation_prompt=True, thinking=True)
526
532
 
527
533
  def test_unsupported_template_does_not_generate(self):
528
534
  from typellm import SGLangError
529
535
  client = self.make_client()
530
536
  client._chat_tokenizer.apply_chat_template.return_value = "<think></think>"
531
537
  with self.assertRaisesRegex(SGLangError, "native chat template"):
532
- client.render_chat([], add_generation_prompt=True)
538
+ client.render_chat([], add_generation_prompt=True, thinking=True)
533
539
  client._request.assert_not_called()
534
540
 
535
541
  def test_always_thinking_template_reasons_even_when_thinking_is_off(self):
536
542
  client = self.make_client()
537
- client.thinking = False
538
- prompt = client.render_chat([], add_generation_prompt=True)
543
+ prompt = client.render_chat([], add_generation_prompt=True) # thinking not asked for
539
544
  self.assertEqual(prompt, "assistant\n<think>\nWork done. </think>\n\n")
540
545
  self.assertFalse(client._chat_tokenizer.apply_chat_template.call_args.kwargs["enable_thinking"])
541
546
 
@@ -546,12 +551,11 @@ class ThinkingTests(unittest.TestCase):
546
551
  self.assertFalse(client._chat_tokenizer.apply_chat_template.call_args.kwargs["enable_thinking"])
547
552
 
548
553
  def test_public_configuration_and_validation(self):
549
- self.assertFalse(TypeLLMClient().sglang.thinking)
550
- client = TypeLLMClient(thinking=True, thinking_budget=256)
551
- self.assertTrue(client.sglang.thinking)
554
+ client = TypeLLMClient(thinking_budget=256)
552
555
  self.assertEqual(client.sglang.thinking_budget, 256)
553
- for kwargs in [{"thinking": "false"}, {"thinking": 1}, {"thinking_budget": 0},
554
- {"thinking_budget": True}, {"thinking_budget": 1.5}]:
556
+ with self.assertRaises(TypeError):
557
+ TypeLLMClient(thinking=True) # thinking is chosen per field
558
+ for kwargs in [{"thinking_budget": 0}, {"thinking_budget": True}, {"thinking_budget": 1.5}]:
555
559
  with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
556
560
  TypeLLMClient(**kwargs)
557
561