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.
- {typellm-0.2.0 → typellm-0.2.2}/PKG-INFO +22 -14
- {typellm-0.2.0 → typellm-0.2.2}/README.md +21 -13
- {typellm-0.2.0 → typellm-0.2.2}/pyproject.toml +1 -1
- typellm-0.2.2/tests/test_batching.py +237 -0
- {typellm-0.2.0 → typellm-0.2.2}/tests/test_dependencies.py +1 -1
- {typellm-0.2.0 → typellm-0.2.2}/tests/test_grammar_fields.py +15 -1
- {typellm-0.2.0 → typellm-0.2.2}/tests/test_images.py +0 -1
- {typellm-0.2.0 → typellm-0.2.2}/tests/test_protocols.py +10 -10
- {typellm-0.2.0 → typellm-0.2.2}/tests/test_text.py +2 -2
- {typellm-0.2.0 → typellm-0.2.2}/tests/test_typellm.py +18 -14
- {typellm-0.2.0 → typellm-0.2.2}/typellm/runtime.py +74 -48
- {typellm-0.2.0 → typellm-0.2.2}/typellm/schema.py +10 -1
- {typellm-0.2.0 → typellm-0.2.2}/typellm/sglang.py +198 -84
- {typellm-0.2.0 → typellm-0.2.2}/typellm.egg-info/PKG-INFO +22 -14
- typellm-0.2.0/tests/test_batching.py +0 -129
- {typellm-0.2.0 → typellm-0.2.2}/LICENSE +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/setup.cfg +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/tests/test_concurrency.py +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/tests/test_nullable.py +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/tests/test_permutations.py +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/tests/test_prefill.py +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/tests/test_probabilities.py +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/tests/test_prompts.py +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/typellm/__init__.py +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/typellm/__main__.py +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/typellm/benchmark.py +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/typellm/cli.py +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/typellm/images.py +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/typellm/protocol.py +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/typellm.egg-info/SOURCES.txt +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/typellm.egg-info/dependency_links.txt +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/typellm.egg-info/entry_points.txt +0 -0
- {typellm-0.2.0 → typellm-0.2.2}/typellm.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
@@ -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(
|
|
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,
|
|
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])
|
|
@@ -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(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
554
|
-
|
|
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
|
|