typellm 0.2.1__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.
- {typellm-0.2.1 → typellm-0.2.2}/PKG-INFO +22 -14
- {typellm-0.2.1 → typellm-0.2.2}/README.md +21 -13
- {typellm-0.2.1 → typellm-0.2.2}/pyproject.toml +1 -1
- {typellm-0.2.1 → typellm-0.2.2}/tests/test_batching.py +95 -11
- {typellm-0.2.1 → typellm-0.2.2}/tests/test_dependencies.py +1 -1
- {typellm-0.2.1 → typellm-0.2.2}/tests/test_images.py +0 -1
- {typellm-0.2.1 → typellm-0.2.2}/tests/test_protocols.py +10 -10
- {typellm-0.2.1 → typellm-0.2.2}/tests/test_text.py +2 -2
- {typellm-0.2.1 → typellm-0.2.2}/tests/test_typellm.py +12 -14
- {typellm-0.2.1 → typellm-0.2.2}/typellm/runtime.py +46 -14
- {typellm-0.2.1 → typellm-0.2.2}/typellm/schema.py +10 -1
- {typellm-0.2.1 → typellm-0.2.2}/typellm/sglang.py +87 -27
- {typellm-0.2.1 → typellm-0.2.2}/typellm.egg-info/PKG-INFO +22 -14
- {typellm-0.2.1 → typellm-0.2.2}/LICENSE +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/setup.cfg +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/tests/test_concurrency.py +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/tests/test_grammar_fields.py +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/tests/test_nullable.py +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/tests/test_permutations.py +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/tests/test_prefill.py +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/tests/test_probabilities.py +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/tests/test_prompts.py +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/typellm/__init__.py +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/typellm/__main__.py +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/typellm/benchmark.py +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/typellm/cli.py +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/typellm/images.py +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/typellm/protocol.py +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/typellm.egg-info/SOURCES.txt +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/typellm.egg-info/dependency_links.txt +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/typellm.egg-info/entry_points.txt +0 -0
- {typellm-0.2.1 → typellm-0.2.2}/typellm.egg-info/requires.txt +0 -0
- {typellm-0.2.1 → 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.
|
|
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.
|
|
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
|
-
|
|
236
|
-
"
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
the
|
|
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
|
|
248
|
-
|
|
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.
|
|
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
|
-
|
|
221
|
-
"
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
the
|
|
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
|
|
233
|
-
|
|
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
|
|
@@ -26,8 +26,8 @@ class FakeServer(SGLangClient):
|
|
|
26
26
|
Numeric fields decode to 7: the server prefers the end token, then "7".
|
|
27
27
|
"""
|
|
28
28
|
|
|
29
|
-
def __init__(self
|
|
30
|
-
super().__init__(model="fake"
|
|
29
|
+
def __init__(self):
|
|
30
|
+
super().__init__(model="fake")
|
|
31
31
|
self._chat_tokenizer = ThinkingTokenizer()
|
|
32
32
|
self._context_length_cache = 100_000
|
|
33
33
|
self.payloads = []
|
|
@@ -83,6 +83,10 @@ def width(payload):
|
|
|
83
83
|
return 1 if isinstance(payload["text"], str) else len(payload["text"])
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
def thinks(questions):
|
|
87
|
+
return {name: {**field, "thinking": True} for name, field in questions.items()}
|
|
88
|
+
|
|
89
|
+
|
|
86
90
|
class PrefixWarmupTests(unittest.TestCase):
|
|
87
91
|
MIXED = {"a": {"type": "integer"}, "b": {"type": "number"}, "n": {"type": "string"},
|
|
88
92
|
"k": {"type": "string", "enum": ["x", "y"]}}
|
|
@@ -91,8 +95,8 @@ class PrefixWarmupTests(unittest.TestCase):
|
|
|
91
95
|
for thinking in (False, True):
|
|
92
96
|
with self.subTest(thinking=thinking):
|
|
93
97
|
client = TypeLLMClient(model="fake")
|
|
94
|
-
client.sglang = FakeServer(
|
|
95
|
-
client.generate(context="Receipt", questions=self.MIXED)
|
|
98
|
+
client.sglang = FakeServer()
|
|
99
|
+
client.generate(context="Receipt", questions=thinks(self.MIXED) if thinking else self.MIXED)
|
|
96
100
|
warm = client.sglang.requests("count")
|
|
97
101
|
self.assertEqual(len(warm), 1)
|
|
98
102
|
# First, before the thinking, number, text and scoring batches.
|
|
@@ -107,6 +111,86 @@ class PrefixWarmupTests(unittest.TestCase):
|
|
|
107
111
|
self.assertEqual(client.sglang.requests("count"), [])
|
|
108
112
|
|
|
109
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
|
+
|
|
110
194
|
class BatchedThinkingTests(unittest.TestCase):
|
|
111
195
|
QUESTIONS = {
|
|
112
196
|
"flag": {"type": "boolean"},
|
|
@@ -117,8 +201,8 @@ class BatchedThinkingTests(unittest.TestCase):
|
|
|
117
201
|
|
|
118
202
|
def test_one_thinking_request_covers_every_prompt_in_a_layer(self):
|
|
119
203
|
client = TypeLLMClient(model="fake")
|
|
120
|
-
client.sglang = FakeServer(
|
|
121
|
-
result = client.generate(context="Receipt", questions=self.QUESTIONS)
|
|
204
|
+
client.sglang = FakeServer()
|
|
205
|
+
result = client.generate(context="Receipt", questions=thinks(self.QUESTIONS))
|
|
122
206
|
self.assertEqual(result, {"flag": True, "count": 7, "name": "blue", "pick": "x"})
|
|
123
207
|
[think] = client.sglang.requests("think")
|
|
124
208
|
# Four fields plus the reversed ordering of "pick".
|
|
@@ -130,19 +214,19 @@ class BatchedThinkingTests(unittest.TestCase):
|
|
|
130
214
|
|
|
131
215
|
def test_dag_thinks_once_per_layer(self):
|
|
132
216
|
client = TypeLLMClient(model="fake")
|
|
133
|
-
client.sglang = FakeServer(
|
|
134
|
-
client.generate(context="Receipt", questions={
|
|
217
|
+
client.sglang = FakeServer()
|
|
218
|
+
client.generate(context="Receipt", questions=thinks({
|
|
135
219
|
"a": {"type": "boolean"}, "b": {"type": "integer"},
|
|
136
220
|
"c": {"type": "boolean", "depends_on": ["a", "b"]},
|
|
137
221
|
"d": {"type": "string", "depends_on": ["a"]},
|
|
138
|
-
})
|
|
222
|
+
}))
|
|
139
223
|
self.assertEqual([width(p) for p in client.sglang.requests("think")], [2, 2])
|
|
140
224
|
|
|
141
225
|
def test_images_count_prompt_tokens_in_one_batch(self):
|
|
142
226
|
client = TypeLLMClient(model="fake")
|
|
143
|
-
client.sglang = FakeServer(
|
|
227
|
+
client.sglang = FakeServer()
|
|
144
228
|
client.generate(context="Receipt", images=[PNG],
|
|
145
|
-
questions={"a": {"type": "boolean"}, "b": {"type": "boolean"}})
|
|
229
|
+
questions=thinks({"a": {"type": "boolean"}, "b": {"type": "boolean"}}))
|
|
146
230
|
counts = [p for p in client.sglang.requests("count") if width(p) > 1]
|
|
147
231
|
self.assertEqual([width(p) for p in counts], [2])
|
|
148
232
|
[think] = client.sglang.requests("think")
|
|
@@ -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(
|
|
105
|
+
client = SGLangClient()
|
|
106
106
|
|
|
107
107
|
class Tokenizer:
|
|
108
108
|
chat_template = 'test'
|
|
@@ -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,
|
|
43
|
-
client = SGLangClient(
|
|
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'
|
|
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,
|
|
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',
|
|
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
|
|
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
|
|
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(
|
|
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])
|
|
@@ -427,7 +427,7 @@ class ThinkingTests(unittest.TestCase):
|
|
|
427
427
|
from typellm import run_schema
|
|
428
428
|
self.assertIsNone(SGLangClient().thinking_budget)
|
|
429
429
|
self.assertIsNone(TypeLLMClient().sglang.thinking_budget)
|
|
430
|
-
client = SGLangClient(
|
|
430
|
+
client = SGLangClient()
|
|
431
431
|
client._chat_tokenizer = FakeChatTokenizer()
|
|
432
432
|
client._context_length_cache = 8192
|
|
433
433
|
client._request = Mock(return_value={"text": "Done.</think>"})
|
|
@@ -443,7 +443,7 @@ class ThinkingTests(unittest.TestCase):
|
|
|
443
443
|
|
|
444
444
|
def make_client(self, response=None):
|
|
445
445
|
from unittest.mock import Mock
|
|
446
|
-
client = SGLangClient(
|
|
446
|
+
client = SGLangClient(thinking_budget=128)
|
|
447
447
|
client._context_length_cache = 8192
|
|
448
448
|
tokenizer = FakeChatTokenizer()
|
|
449
449
|
tokenizer.apply_chat_template = Mock(return_value="assistant\n<think>\n")
|
|
@@ -453,7 +453,7 @@ class ThinkingTests(unittest.TestCase):
|
|
|
453
453
|
|
|
454
454
|
def test_thinking_stops_before_constrained_answer(self):
|
|
455
455
|
client = self.make_client()
|
|
456
|
-
prompt = client.render_chat([], add_generation_prompt=True)
|
|
456
|
+
prompt = client.render_chat([], add_generation_prompt=True, thinking=True)
|
|
457
457
|
self.assertEqual(prompt, "assistant\n<think>\nWork done. </think>\n\n")
|
|
458
458
|
self.assertNotIn("illegal answer", prompt)
|
|
459
459
|
params = client._request.call_args.args[1]["sampling_params"]
|
|
@@ -464,7 +464,7 @@ class ThinkingTests(unittest.TestCase):
|
|
|
464
464
|
|
|
465
465
|
def test_length_stop_forces_closure_and_retains_reasoning(self):
|
|
466
466
|
client = self.make_client({"text": "Partial reasoning", "meta_info": {"finish_reason": {"type": "length"}}})
|
|
467
|
-
prompt = client.render_chat([], add_generation_prompt=True)
|
|
467
|
+
prompt = client.render_chat([], add_generation_prompt=True, thinking=True)
|
|
468
468
|
self.assertIn("Partial reasoning", prompt)
|
|
469
469
|
self.assertIn("I will now give the final answer.", prompt)
|
|
470
470
|
self.assertTrue(prompt.endswith("</think>\n\n"))
|
|
@@ -499,7 +499,7 @@ class ThinkingTests(unittest.TestCase):
|
|
|
499
499
|
for text in ("Partial", "Partial</think>"):
|
|
500
500
|
client = self.make_client({"text": text, "meta_info": {"finish_reason": {"type": "abort"}}})
|
|
501
501
|
with self.assertRaisesRegex(SGLangError, "aborted"):
|
|
502
|
-
client.render_chat([], add_generation_prompt=True)
|
|
502
|
+
client.render_chat([], add_generation_prompt=True, thinking=True)
|
|
503
503
|
|
|
504
504
|
def test_forced_thinking_keeps_all_final_decoders(self):
|
|
505
505
|
stops = [{"type": "length"}, {"type": "stop", "matched": "<|im_end|>"}]
|
|
@@ -528,20 +528,19 @@ class ThinkingTests(unittest.TestCase):
|
|
|
528
528
|
client = self.make_client()
|
|
529
529
|
client._request.return_value = response
|
|
530
530
|
with self.subTest(response=response), self.assertRaises(SGLangError):
|
|
531
|
-
client.render_chat([], add_generation_prompt=True)
|
|
531
|
+
client.render_chat([], add_generation_prompt=True, thinking=True)
|
|
532
532
|
|
|
533
533
|
def test_unsupported_template_does_not_generate(self):
|
|
534
534
|
from typellm import SGLangError
|
|
535
535
|
client = self.make_client()
|
|
536
536
|
client._chat_tokenizer.apply_chat_template.return_value = "<think></think>"
|
|
537
537
|
with self.assertRaisesRegex(SGLangError, "native chat template"):
|
|
538
|
-
client.render_chat([], add_generation_prompt=True)
|
|
538
|
+
client.render_chat([], add_generation_prompt=True, thinking=True)
|
|
539
539
|
client._request.assert_not_called()
|
|
540
540
|
|
|
541
541
|
def test_always_thinking_template_reasons_even_when_thinking_is_off(self):
|
|
542
542
|
client = self.make_client()
|
|
543
|
-
client.
|
|
544
|
-
prompt = client.render_chat([], add_generation_prompt=True)
|
|
543
|
+
prompt = client.render_chat([], add_generation_prompt=True) # thinking not asked for
|
|
545
544
|
self.assertEqual(prompt, "assistant\n<think>\nWork done. </think>\n\n")
|
|
546
545
|
self.assertFalse(client._chat_tokenizer.apply_chat_template.call_args.kwargs["enable_thinking"])
|
|
547
546
|
|
|
@@ -552,12 +551,11 @@ class ThinkingTests(unittest.TestCase):
|
|
|
552
551
|
self.assertFalse(client._chat_tokenizer.apply_chat_template.call_args.kwargs["enable_thinking"])
|
|
553
552
|
|
|
554
553
|
def test_public_configuration_and_validation(self):
|
|
555
|
-
|
|
556
|
-
client = TypeLLMClient(thinking=True, thinking_budget=256)
|
|
557
|
-
self.assertTrue(client.sglang.thinking)
|
|
554
|
+
client = TypeLLMClient(thinking_budget=256)
|
|
558
555
|
self.assertEqual(client.sglang.thinking_budget, 256)
|
|
559
|
-
|
|
560
|
-
|
|
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}]:
|
|
561
559
|
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
|
|
562
560
|
TypeLLMClient(**kwargs)
|
|
563
561
|
|
|
@@ -63,6 +63,8 @@ class Choice:
|
|
|
63
63
|
return_probabilities: bool = False
|
|
64
64
|
depends_on: tuple[str, ...] | None = None
|
|
65
65
|
nullable: bool = False
|
|
66
|
+
thinking: bool | None = None
|
|
67
|
+
thinking_budget: int | None = None
|
|
66
68
|
|
|
67
69
|
def __post_init__(self) -> None:
|
|
68
70
|
if not self.choices and self.numeric_type is None and not self.text_type:
|
|
@@ -154,9 +156,8 @@ class TypeLLMClient:
|
|
|
154
156
|
label_pool: Sequence[str] | None = None,
|
|
155
157
|
numeric_max_digits: int = 32,
|
|
156
158
|
tokenizer: str | None = None,
|
|
157
|
-
thinking: bool = False,
|
|
158
159
|
thinking_budget: int | None = None,
|
|
159
|
-
text_max_tokens: int =
|
|
160
|
+
text_max_tokens: int = 128,
|
|
160
161
|
) -> None:
|
|
161
162
|
_validate_decoding(mode, temperature)
|
|
162
163
|
if type(numeric_max_digits) is not int or numeric_max_digits <= 0:
|
|
@@ -166,7 +167,6 @@ class TypeLLMClient:
|
|
|
166
167
|
model,
|
|
167
168
|
timeout,
|
|
168
169
|
tokenizer=tokenizer,
|
|
169
|
-
thinking=thinking,
|
|
170
170
|
thinking_budget=thinking_budget,
|
|
171
171
|
text_max_tokens=text_max_tokens,
|
|
172
172
|
answer_reserve_tokens=numeric_max_digits + 3,
|
|
@@ -184,23 +184,33 @@ class TypeLLMClient:
|
|
|
184
184
|
self._last_usage: ContextVar[Usage | None] = ContextVar(
|
|
185
185
|
f"typellm_last_usage_{id(self)}", default=None
|
|
186
186
|
)
|
|
187
|
+
self._last_thinking: ContextVar[dict[str, str]] = ContextVar(
|
|
188
|
+
f"typellm_last_thinking_{id(self)}", default={}
|
|
189
|
+
)
|
|
187
190
|
|
|
188
191
|
def __getstate__(self) -> dict[str, Any]:
|
|
189
192
|
state = self.__dict__.copy()
|
|
190
193
|
del state["_last_prompts"]
|
|
191
194
|
del state["_last_usage"]
|
|
195
|
+
del state["_last_thinking"]
|
|
192
196
|
return state
|
|
193
197
|
|
|
194
198
|
def __setstate__(self, state: dict[str, Any]) -> None:
|
|
195
199
|
self.__dict__.update(state)
|
|
196
200
|
self._last_prompts = ContextVar(f"typellm_last_prompts_{id(self)}", default=[])
|
|
197
201
|
self._last_usage = ContextVar(f"typellm_last_usage_{id(self)}", default=None)
|
|
202
|
+
self._last_thinking = ContextVar(f"typellm_last_thinking_{id(self)}", default={})
|
|
198
203
|
|
|
199
204
|
@property
|
|
200
205
|
def last_prompts(self) -> list[str]:
|
|
201
206
|
"""Final prompts of the last generate() call made in this thread or task."""
|
|
202
207
|
return self._last_prompts.get()
|
|
203
208
|
|
|
209
|
+
@property
|
|
210
|
+
def last_thinking(self) -> dict[str, str]:
|
|
211
|
+
"""Reasoning of the fields that thought in the last call in this thread or task."""
|
|
212
|
+
return self._last_thinking.get()
|
|
213
|
+
|
|
204
214
|
@property
|
|
205
215
|
def last_usage(self) -> Usage | None:
|
|
206
216
|
"""Requests and tokens of the last generate() call made in this thread or task.
|
|
@@ -256,6 +266,8 @@ class TypeLLMClient:
|
|
|
256
266
|
return_probabilities=item.return_probabilities,
|
|
257
267
|
depends_on=item.depends_on,
|
|
258
268
|
nullable=item.nullable,
|
|
269
|
+
thinking=item.thinking,
|
|
270
|
+
thinking_budget=item.thinking_budget,
|
|
259
271
|
)
|
|
260
272
|
for item in decisions
|
|
261
273
|
]
|
|
@@ -362,6 +374,7 @@ class TypeLLMClient:
|
|
|
362
374
|
Both stop the call before its next request to SGLang.
|
|
363
375
|
"""
|
|
364
376
|
self._last_usage.set(None)
|
|
377
|
+
self._last_thinking.set({})
|
|
365
378
|
if (context is None) == (state is None):
|
|
366
379
|
raise ValueError("provide exactly one of context or state")
|
|
367
380
|
context = state if state is not None else context
|
|
@@ -396,6 +409,8 @@ class TypeLLMClient:
|
|
|
396
409
|
finally:
|
|
397
410
|
self._last_usage.set(scope.usage)
|
|
398
411
|
self._last_prompts.set(prompts)
|
|
412
|
+
self._last_thinking.set({decision.name: row["thinking"] for decision, row in zip(decisions, rows)
|
|
413
|
+
if row.get("thinking")})
|
|
399
414
|
|
|
400
415
|
output: dict[str, Any] = {}
|
|
401
416
|
for decision, row in zip(decisions, rows):
|
|
@@ -694,14 +709,24 @@ def _execute_batch_decisions(
|
|
|
694
709
|
defer = callable(getattr(client, "prepare_answer_prefixes", None))
|
|
695
710
|
raw_prompts: list[str] = []
|
|
696
711
|
|
|
697
|
-
|
|
712
|
+
raw_thinking: list[bool | None] = []
|
|
713
|
+
raw_budgets: list[int | None] = []
|
|
714
|
+
|
|
715
|
+
def generation_prompt(parent, content, messages, decision):
|
|
716
|
+
# A field's own thinking settings are passed only when it has them, so
|
|
717
|
+
# clients without per-prompt thinking keep working.
|
|
718
|
+
own = {key: value for key, value in (("thinking", decision.thinking),
|
|
719
|
+
("thinking_budget", decision.thinking_budget))
|
|
720
|
+
if value is not None}
|
|
698
721
|
if parent is not None:
|
|
699
|
-
prompt = (client.extend_chat_prefix(parent, content, finish_thinking=False)
|
|
700
|
-
if defer else client.extend_chat_prefix(parent, content))
|
|
722
|
+
prompt = (client.extend_chat_prefix(parent, content, finish_thinking=False, **own)
|
|
723
|
+
if defer else client.extend_chat_prefix(parent, content, **own))
|
|
701
724
|
else:
|
|
702
|
-
prompt = (client.render_chat(messages, add_generation_prompt=True, finish_thinking=False)
|
|
703
|
-
if defer else client.render_chat(messages, add_generation_prompt=True))
|
|
725
|
+
prompt = (client.render_chat(messages, add_generation_prompt=True, finish_thinking=False, **own)
|
|
726
|
+
if defer else client.render_chat(messages, add_generation_prompt=True, **own))
|
|
704
727
|
raw_prompts.append(prompt)
|
|
728
|
+
raw_thinking.append(decision.thinking)
|
|
729
|
+
raw_budgets.append(decision.thinking_budget)
|
|
705
730
|
return len(raw_prompts) - 1
|
|
706
731
|
|
|
707
732
|
decision_slots = {}
|
|
@@ -712,7 +737,7 @@ def _execute_batch_decisions(
|
|
|
712
737
|
{"role": "user", "content": question_content(decision)}
|
|
713
738
|
]
|
|
714
739
|
parent = (parent_prefixes or {}).get(decision.name)
|
|
715
|
-
decision_slots[index] = generation_prompt(parent, question_content(decision), messages)
|
|
740
|
+
decision_slots[index] = generation_prompt(parent, question_content(decision), messages, decision)
|
|
716
741
|
if decision.text_type:
|
|
717
742
|
text_pending.append((index, decision, messages))
|
|
718
743
|
continue
|
|
@@ -734,7 +759,7 @@ def _execute_batch_decisions(
|
|
|
734
759
|
scoring_slots.append(
|
|
735
760
|
decision_slots[index] if variant.choices == decision.choices else
|
|
736
761
|
generation_prompt(parent, variant_content,
|
|
737
|
-
shared_messages + [{"role": "user", "content": variant_content}]))
|
|
762
|
+
shared_messages + [{"role": "user", "content": variant_content}], variant))
|
|
738
763
|
scoring_ids.append(ids)
|
|
739
764
|
scoring_prefills.append(decision.label_prefill)
|
|
740
765
|
finite_indexes.append(index)
|
|
@@ -753,7 +778,10 @@ def _execute_batch_decisions(
|
|
|
753
778
|
cache_meta = client.cache_prefix(shared_prefix)
|
|
754
779
|
LOG.info("batch_shared_prefix_cached_tokens=%s", cache_meta.get("cached_tokens"))
|
|
755
780
|
|
|
756
|
-
|
|
781
|
+
per_field = any(t is not None for t in raw_thinking) or any(b is not None for b in raw_budgets)
|
|
782
|
+
ready = (client.prepare_answer_prefixes(raw_prompts, **({"thinking": raw_thinking, "budgets": raw_budgets}
|
|
783
|
+
if per_field else {}))
|
|
784
|
+
if defer else raw_prompts)
|
|
757
785
|
prompts = [ready[decision_slots[index]] for index in finite_indexes]
|
|
758
786
|
scoring_prompts = [ready[slot] + prefill for slot, prefill in zip(scoring_slots, scoring_prefills)]
|
|
759
787
|
# Open fields continue from {"name": ; history gets the closed object.
|
|
@@ -860,6 +888,7 @@ def _execute_batch_decisions(
|
|
|
860
888
|
|
|
861
889
|
if open_results:
|
|
862
890
|
finite_rows = iter(zip(results, completed_prompts))
|
|
891
|
+
|
|
863
892
|
merged_results: list[dict] = []
|
|
864
893
|
merged_prompts: list[str] = []
|
|
865
894
|
for index in range(len(decisions)):
|
|
@@ -870,6 +899,11 @@ def _execute_batch_decisions(
|
|
|
870
899
|
merged_results.append(row)
|
|
871
900
|
merged_prompts.append(prompt)
|
|
872
901
|
results, completed_prompts = merged_results, merged_prompts
|
|
902
|
+
# Each field's reasoning, when it thought: what finishing thinking added.
|
|
903
|
+
read = getattr(client, "thinking_text", None)
|
|
904
|
+
for index, row in enumerate(results):
|
|
905
|
+
slot = decision_slots[index]
|
|
906
|
+
row["thinking"] = read(raw_prompts[slot], ready[slot]) if callable(read) and defer else None
|
|
873
907
|
return results, completed_prompts
|
|
874
908
|
|
|
875
909
|
|
|
@@ -894,9 +928,8 @@ def run_schema(
|
|
|
894
928
|
seed: int | None = None,
|
|
895
929
|
numeric_max_digits: int = 32,
|
|
896
930
|
tokenizer: str | None = None,
|
|
897
|
-
thinking: bool = False,
|
|
898
931
|
thinking_budget: int | None = None,
|
|
899
|
-
text_max_tokens: int =
|
|
932
|
+
text_max_tokens: int = 128,
|
|
900
933
|
print_final_prompt: bool = False,
|
|
901
934
|
) -> dict[str, Any]:
|
|
902
935
|
client = TypeLLMClient(
|
|
@@ -907,7 +940,6 @@ def run_schema(
|
|
|
907
940
|
seed=seed,
|
|
908
941
|
numeric_max_digits=numeric_max_digits,
|
|
909
942
|
tokenizer=tokenizer,
|
|
910
|
-
thinking=thinking,
|
|
911
943
|
thinking_budget=thinking_budget,
|
|
912
944
|
text_max_tokens=text_max_tokens,
|
|
913
945
|
)
|
|
@@ -32,6 +32,9 @@ class Decision:
|
|
|
32
32
|
return_probabilities: bool = False
|
|
33
33
|
depends_on: tuple[str, ...] | None = None
|
|
34
34
|
nullable: bool = False
|
|
35
|
+
# None follows the client's thinking and thinking_budget.
|
|
36
|
+
thinking: bool | None = None
|
|
37
|
+
thinking_budget: int | None = None
|
|
35
38
|
|
|
36
39
|
|
|
37
40
|
def _has_duplicates(values: Sequence[Any]) -> bool:
|
|
@@ -231,7 +234,13 @@ def compile_json_schema(schema: Mapping[str, Any]) -> list[Decision]:
|
|
|
231
234
|
if len(set(dependencies)) != len(dependencies):
|
|
232
235
|
raise SchemaError(f"depends_on for {decision.name!r} contains duplicates")
|
|
233
236
|
dependencies = tuple(dependencies)
|
|
234
|
-
|
|
237
|
+
thinking = field.get("thinking")
|
|
238
|
+
if thinking is not None and type(thinking) is not bool:
|
|
239
|
+
raise SchemaError(f"thinking for {decision.name!r} must be a boolean")
|
|
240
|
+
budget = field.get("thinking_budget")
|
|
241
|
+
if budget is not None and (type(budget) is not int or budget <= 0):
|
|
242
|
+
raise SchemaError(f"thinking_budget for {decision.name!r} must be a positive integer")
|
|
243
|
+
compiled.append(replace(decision, depends_on=dependencies, thinking=thinking, thinking_budget=budget))
|
|
235
244
|
dependency_layers(compiled)
|
|
236
245
|
return compiled
|
|
237
246
|
|
|
@@ -16,6 +16,11 @@ import httpx
|
|
|
16
16
|
from .protocol import detect_protocol
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
def _overrides(thinking: bool | None, budget: int | None) -> tuple:
|
|
20
|
+
"""A prompt's own thinking settings as extra arguments; none when it has none."""
|
|
21
|
+
return () if thinking is None and budget is None else (thinking, budget)
|
|
22
|
+
|
|
23
|
+
|
|
19
24
|
class SGLangError(RuntimeError):
|
|
20
25
|
def __init__(self, message: str = "", *, status: int | None = None) -> None:
|
|
21
26
|
super().__init__(message)
|
|
@@ -38,6 +43,8 @@ class Usage:
|
|
|
38
43
|
prompt_tokens: int = 0
|
|
39
44
|
cached_tokens: int = 0
|
|
40
45
|
completion_tokens: int = 0
|
|
46
|
+
# The part of completion_tokens that is reasoning, not typed answers.
|
|
47
|
+
thinking_tokens: int = 0
|
|
41
48
|
|
|
42
49
|
def _add(self, response: Any) -> None:
|
|
43
50
|
self.requests += 1
|
|
@@ -51,6 +58,18 @@ class Usage:
|
|
|
51
58
|
setattr(self, name, getattr(self, name) + value)
|
|
52
59
|
|
|
53
60
|
|
|
61
|
+
def _count_thinking(response: Any) -> None:
|
|
62
|
+
"""Add a thinking request's generated tokens to this call's usage."""
|
|
63
|
+
scope = _call_scope.get()
|
|
64
|
+
if scope is None:
|
|
65
|
+
return
|
|
66
|
+
for item in response if isinstance(response, list) else [response]:
|
|
67
|
+
meta = item.get("meta_info") if isinstance(item, Mapping) else None
|
|
68
|
+
tokens = meta.get("completion_tokens") if isinstance(meta, Mapping) else None
|
|
69
|
+
if type(tokens) is int:
|
|
70
|
+
scope.usage.thinking_tokens += tokens
|
|
71
|
+
|
|
72
|
+
|
|
54
73
|
@dataclass
|
|
55
74
|
class _CallScope:
|
|
56
75
|
usage: Usage = field(default_factory=Usage)
|
|
@@ -140,13 +159,10 @@ class SGLangClient:
|
|
|
140
159
|
timeout: float = 120.0,
|
|
141
160
|
tokenizer: str | None = None,
|
|
142
161
|
*,
|
|
143
|
-
thinking: bool = False,
|
|
144
162
|
thinking_budget: int | None = None,
|
|
145
|
-
text_max_tokens: int =
|
|
163
|
+
text_max_tokens: int = 128,
|
|
146
164
|
answer_reserve_tokens: int = 64,
|
|
147
165
|
) -> None:
|
|
148
|
-
if type(thinking) is not bool:
|
|
149
|
-
raise ValueError("thinking must be a boolean")
|
|
150
166
|
if thinking_budget is not None and (type(thinking_budget) is not int or thinking_budget <= 0):
|
|
151
167
|
raise ValueError("thinking_budget must be a positive integer or None")
|
|
152
168
|
if type(text_max_tokens) is not int or text_max_tokens <= 0:
|
|
@@ -156,7 +172,7 @@ class SGLangClient:
|
|
|
156
172
|
self.answer_reserve_tokens = max(answer_reserve_tokens, text_max_tokens)
|
|
157
173
|
self._context_length_cache: int | None = None
|
|
158
174
|
self.text_max_tokens = text_max_tokens
|
|
159
|
-
|
|
175
|
+
# The budget of fields that think without their own.
|
|
160
176
|
self.thinking_budget = thinking_budget
|
|
161
177
|
self.base_url = base_url.rstrip("/")
|
|
162
178
|
self.model = model
|
|
@@ -421,8 +437,14 @@ class SGLangClient:
|
|
|
421
437
|
*,
|
|
422
438
|
add_generation_prompt: bool,
|
|
423
439
|
finish_thinking: bool = True,
|
|
440
|
+
thinking: bool | None = None,
|
|
441
|
+
thinking_budget: int | None = None,
|
|
424
442
|
) -> str:
|
|
425
|
-
"""Render history; optionally finish thinking before constrained decoding.
|
|
443
|
+
"""Render history; optionally finish thinking before constrained decoding.
|
|
444
|
+
|
|
445
|
+
thinking asks this prompt to reason; thinking_budget overrides the client's.
|
|
446
|
+
"""
|
|
447
|
+
think = bool(thinking)
|
|
426
448
|
tokenizer = self._get_chat_tokenizer()
|
|
427
449
|
if not getattr(tokenizer, "chat_template", None):
|
|
428
450
|
raise SGLangError("The served tokenizer does not define a chat template")
|
|
@@ -435,7 +457,7 @@ class SGLangClient:
|
|
|
435
457
|
list(messages),
|
|
436
458
|
tokenize=False,
|
|
437
459
|
add_generation_prompt=add_generation_prompt,
|
|
438
|
-
enable_thinking=
|
|
460
|
+
enable_thinking=think and add_generation_prompt,
|
|
439
461
|
)
|
|
440
462
|
except Exception as exc:
|
|
441
463
|
raise SGLangError(
|
|
@@ -450,16 +472,19 @@ class SGLangClient:
|
|
|
450
472
|
if bos and rendered.startswith(bos) and tokenizer.encode("x")[0] == tokenizer.bos_token_id:
|
|
451
473
|
rendered = rendered[len(bos):]
|
|
452
474
|
if add_generation_prompt and finish_thinking:
|
|
453
|
-
return self._prepare_answer_prefix(rendered)
|
|
475
|
+
return self._prepare_answer_prefix(rendered, *_overrides(thinking, thinking_budget))
|
|
454
476
|
return rendered
|
|
455
477
|
|
|
456
|
-
def _prepare_answer_prefix(self, prefix: str
|
|
478
|
+
def _prepare_answer_prefix(self, prefix: str, thinking: bool | None = None,
|
|
479
|
+
budget: int | None = None) -> str:
|
|
457
480
|
protocol = detect_protocol(self._get_chat_tokenizer())
|
|
458
|
-
|
|
459
|
-
|
|
481
|
+
# A template that always opens thinking reasons whatever the setting.
|
|
482
|
+
if thinking or protocol.has_open_thinking(prefix):
|
|
483
|
+
return self._finish_thinking(prefix, *(() if budget is None else (budget,)))
|
|
460
484
|
return prefix
|
|
461
485
|
|
|
462
|
-
def _continuation_parts(self, question: str | None = None
|
|
486
|
+
def _continuation_parts(self, question: str | None = None,
|
|
487
|
+
thinking: bool | None = None) -> tuple[str, str]:
|
|
463
488
|
# Derive turn delimiters from the actual tokenizer, never hardcode a
|
|
464
489
|
# model's chat tokens. The marker is confined to this local template probe.
|
|
465
490
|
marker = "TYPELLM_ASSISTANT_BOUNDARY_8b46c9"
|
|
@@ -474,6 +499,7 @@ class SGLangClient:
|
|
|
474
499
|
extended = self.render_chat(
|
|
475
500
|
messages + [{"role": "user", "content": question}],
|
|
476
501
|
add_generation_prompt=True, finish_thinking=False,
|
|
502
|
+
**({} if thinking is None else {"thinking": thinking}),
|
|
477
503
|
)
|
|
478
504
|
if extended.count(marker) != 1:
|
|
479
505
|
raise SGLangError("Chat template cannot preserve assistant content for KV continuation")
|
|
@@ -486,20 +512,47 @@ class SGLangClient:
|
|
|
486
512
|
closing, _ = self._continuation_parts()
|
|
487
513
|
return prompt + answer + closing
|
|
488
514
|
|
|
489
|
-
def extend_chat_prefix(self, prefix: str, question: str, *, finish_thinking: bool = True
|
|
490
|
-
|
|
515
|
+
def extend_chat_prefix(self, prefix: str, question: str, *, finish_thinking: bool = True,
|
|
516
|
+
thinking: bool | None = None, thinking_budget: int | None = None) -> str:
|
|
517
|
+
_, suffix = self._continuation_parts(question, thinking)
|
|
491
518
|
prompt = prefix + suffix
|
|
492
|
-
return self._prepare_answer_prefix(prompt
|
|
519
|
+
return (self._prepare_answer_prefix(prompt, *_overrides(thinking, thinking_budget))
|
|
520
|
+
if finish_thinking else prompt)
|
|
521
|
+
|
|
522
|
+
def thinking_text(self, prompt: str, finished: str) -> str | None:
|
|
523
|
+
"""The reasoning that finishing thinking added to prompt, or None."""
|
|
524
|
+
if finished == prompt or not finished.startswith(prompt):
|
|
525
|
+
return None
|
|
526
|
+
added = finished[len(prompt):]
|
|
527
|
+
close = detect_protocol(self._get_chat_tokenizer()).thinking_close
|
|
528
|
+
if close and close in added:
|
|
529
|
+
added = added[:added.index(close)]
|
|
530
|
+
return added.strip() or None
|
|
531
|
+
|
|
532
|
+
def prepare_answer_prefixes(
|
|
533
|
+
self,
|
|
534
|
+
prefixes: Sequence[str],
|
|
535
|
+
thinking: Sequence[bool | None] | None = None,
|
|
536
|
+
budgets: Sequence[int | None] | None = None,
|
|
537
|
+
) -> list[str]:
|
|
538
|
+
"""Finish thinking for many generation prompts in one batched request.
|
|
493
539
|
|
|
494
|
-
|
|
495
|
-
|
|
540
|
+
thinking says which prompts reason and budgets their own budgets (None: the
|
|
541
|
+
client's); only the prompts that think join the batch.
|
|
542
|
+
"""
|
|
496
543
|
protocol = detect_protocol(self._get_chat_tokenizer())
|
|
497
|
-
|
|
544
|
+
wants = [bool(t) for t in (thinking or [None] * len(prefixes))]
|
|
545
|
+
budgets = list(budgets or [None] * len(prefixes))
|
|
546
|
+
pending = [i for i, p in enumerate(prefixes) if wants[i] or protocol.has_open_thinking(p)]
|
|
498
547
|
finished = list(prefixes)
|
|
548
|
+
# Budgets go only to prompts that have their own, as before for the rest.
|
|
549
|
+
own = [budgets[i] for i in pending]
|
|
499
550
|
if len(pending) == 1:
|
|
500
|
-
finished[pending[0]] = self._finish_thinking(prefixes[pending[0]])
|
|
551
|
+
finished[pending[0]] = self._finish_thinking(prefixes[pending[0]], *([] if own[0] is None else own))
|
|
501
552
|
elif pending:
|
|
502
|
-
|
|
553
|
+
done = self._finish_thinking_batch([prefixes[i] for i in pending],
|
|
554
|
+
*(() if all(b is None for b in own) else (own,)))
|
|
555
|
+
for i, value in zip(pending, done):
|
|
503
556
|
finished[i] = value
|
|
504
557
|
return finished
|
|
505
558
|
|
|
@@ -527,12 +580,15 @@ class SGLangClient:
|
|
|
527
580
|
self._context_length_cache = min(limits)
|
|
528
581
|
return self._context_length_cache
|
|
529
582
|
|
|
530
|
-
def _finish_thinking(self, prefix: str) -> str:
|
|
531
|
-
params, image_tokens = self._thinking_params(prefix)
|
|
583
|
+
def _finish_thinking(self, prefix: str, budget: int | None = None) -> str:
|
|
584
|
+
params, image_tokens = self._thinking_params(prefix, budget=budget)
|
|
532
585
|
response = self._generate({"text": prefix, "sampling_params": params})
|
|
586
|
+
_count_thinking(response)
|
|
533
587
|
return self._complete_thinking(prefix, response, image_tokens)
|
|
534
588
|
|
|
535
|
-
def _finish_thinking_batch(self, prefixes: Sequence[str]
|
|
589
|
+
def _finish_thinking_batch(self, prefixes: Sequence[str],
|
|
590
|
+
budgets: Sequence[int | None] | None = None) -> list[str]:
|
|
591
|
+
budgets = list(budgets or [None] * len(prefixes))
|
|
536
592
|
served: list[int | None] = [None] * len(prefixes)
|
|
537
593
|
if self._active_images.get():
|
|
538
594
|
response = self._generate({
|
|
@@ -543,23 +599,26 @@ class SGLangClient:
|
|
|
543
599
|
raise SGLangError("Unexpected prompt-count batch response shape")
|
|
544
600
|
served = [item.get("meta_info", {}).get("prompt_tokens") if isinstance(item, Mapping) else None
|
|
545
601
|
for item in response]
|
|
546
|
-
planned = [self._thinking_params(prefix, count
|
|
602
|
+
planned = [self._thinking_params(prefix, count, budget)
|
|
603
|
+
for prefix, count, budget in zip(prefixes, served, budgets)]
|
|
547
604
|
response = self._generate({
|
|
548
605
|
"text": list(prefixes),
|
|
549
606
|
"sampling_params": [params for params, _ in planned],
|
|
550
607
|
})
|
|
551
608
|
if not isinstance(response, list) or len(response) != len(prefixes):
|
|
552
609
|
raise SGLangError("Unexpected thinking batch response shape")
|
|
610
|
+
_count_thinking(response)
|
|
553
611
|
return [self._complete_thinking(prefix, item, image_tokens)
|
|
554
612
|
for prefix, item, (_, image_tokens) in zip(prefixes, response, planned)]
|
|
555
613
|
|
|
556
|
-
def _thinking_params(self, prefix: str, served_tokens: int | None = None
|
|
614
|
+
def _thinking_params(self, prefix: str, served_tokens: int | None = None,
|
|
615
|
+
budget: int | None = None) -> tuple[dict[str, Any], int]:
|
|
557
616
|
"""Return sampling params for one thinking request and its image-token count."""
|
|
558
617
|
tokenizer = self._get_chat_tokenizer()
|
|
559
618
|
protocol = detect_protocol(tokenizer)
|
|
560
619
|
if not protocol.has_open_thinking(prefix):
|
|
561
620
|
raise SGLangError(
|
|
562
|
-
f"thinking
|
|
621
|
+
f"thinking requires a native chat template ending in an open "
|
|
563
622
|
f"{protocol.thinking_open} block; this template may not support thinking"
|
|
564
623
|
)
|
|
565
624
|
forced_end = protocol.forced_close()
|
|
@@ -579,7 +638,8 @@ class SGLangClient:
|
|
|
579
638
|
available = self._context_length() - prefix_tokens - self.answer_reserve_tokens - closing_tokens - 16
|
|
580
639
|
if available <= 0:
|
|
581
640
|
raise SGLangError("Input leaves no room for thinking and the final constrained answer")
|
|
582
|
-
|
|
641
|
+
budget = self.thinking_budget if budget is None else budget
|
|
642
|
+
limit = available if budget is None else min(available, budget)
|
|
583
643
|
return {
|
|
584
644
|
"max_new_tokens": limit,
|
|
585
645
|
"temperature": 0.6, "top_p": 0.95, "top_k": 20,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: typellm
|
|
3
|
-
Version: 0.2.
|
|
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.
|
|
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
|
-
|
|
236
|
-
"
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
the
|
|
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
|
|
248
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|