typellm 0.7.0__tar.gz → 0.7.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 (40) hide show
  1. {typellm-0.7.0 → typellm-0.7.2}/PKG-INFO +93 -1
  2. {typellm-0.7.0 → typellm-0.7.2}/README.md +92 -0
  3. {typellm-0.7.0 → typellm-0.7.2}/pyproject.toml +1 -1
  4. typellm-0.7.2/tests/test_box.py +217 -0
  5. {typellm-0.7.0 → typellm-0.7.2}/typellm/runtime.py +149 -15
  6. {typellm-0.7.0 → typellm-0.7.2}/typellm/schema.py +83 -9
  7. {typellm-0.7.0 → typellm-0.7.2}/typellm.egg-info/PKG-INFO +93 -1
  8. {typellm-0.7.0 → typellm-0.7.2}/typellm.egg-info/SOURCES.txt +1 -0
  9. {typellm-0.7.0 → typellm-0.7.2}/LICENSE +0 -0
  10. {typellm-0.7.0 → typellm-0.7.2}/setup.cfg +0 -0
  11. {typellm-0.7.0 → typellm-0.7.2}/tests/test_batching.py +0 -0
  12. {typellm-0.7.0 → typellm-0.7.2}/tests/test_choices.py +0 -0
  13. {typellm-0.7.0 → typellm-0.7.2}/tests/test_concurrency.py +0 -0
  14. {typellm-0.7.0 → typellm-0.7.2}/tests/test_conditions.py +0 -0
  15. {typellm-0.7.0 → typellm-0.7.2}/tests/test_dependencies.py +0 -0
  16. {typellm-0.7.0 → typellm-0.7.2}/tests/test_given.py +0 -0
  17. {typellm-0.7.0 → typellm-0.7.2}/tests/test_grammar_fields.py +0 -0
  18. {typellm-0.7.0 → typellm-0.7.2}/tests/test_images.py +0 -0
  19. {typellm-0.7.0 → typellm-0.7.2}/tests/test_nullable.py +0 -0
  20. {typellm-0.7.0 → typellm-0.7.2}/tests/test_permutations.py +0 -0
  21. {typellm-0.7.0 → typellm-0.7.2}/tests/test_prefill.py +0 -0
  22. {typellm-0.7.0 → typellm-0.7.2}/tests/test_probabilities.py +0 -0
  23. {typellm-0.7.0 → typellm-0.7.2}/tests/test_prompts.py +0 -0
  24. {typellm-0.7.0 → typellm-0.7.2}/tests/test_protocols.py +0 -0
  25. {typellm-0.7.0 → typellm-0.7.2}/tests/test_scores.py +0 -0
  26. {typellm-0.7.0 → typellm-0.7.2}/tests/test_structured.py +0 -0
  27. {typellm-0.7.0 → typellm-0.7.2}/tests/test_text.py +0 -0
  28. {typellm-0.7.0 → typellm-0.7.2}/tests/test_typellm.py +0 -0
  29. {typellm-0.7.0 → typellm-0.7.2}/tests/test_value_choices.py +0 -0
  30. {typellm-0.7.0 → typellm-0.7.2}/typellm/__init__.py +0 -0
  31. {typellm-0.7.0 → typellm-0.7.2}/typellm/__main__.py +0 -0
  32. {typellm-0.7.0 → typellm-0.7.2}/typellm/benchmark.py +0 -0
  33. {typellm-0.7.0 → typellm-0.7.2}/typellm/cli.py +0 -0
  34. {typellm-0.7.0 → typellm-0.7.2}/typellm/images.py +0 -0
  35. {typellm-0.7.0 → typellm-0.7.2}/typellm/protocol.py +0 -0
  36. {typellm-0.7.0 → typellm-0.7.2}/typellm/sglang.py +0 -0
  37. {typellm-0.7.0 → typellm-0.7.2}/typellm.egg-info/dependency_links.txt +0 -0
  38. {typellm-0.7.0 → typellm-0.7.2}/typellm.egg-info/entry_points.txt +0 -0
  39. {typellm-0.7.0 → typellm-0.7.2}/typellm.egg-info/requires.txt +0 -0
  40. {typellm-0.7.0 → typellm-0.7.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.7.0
3
+ Version: 0.7.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/
@@ -31,6 +31,9 @@ Dynamic: license-file
31
31
 
32
32
  ## Updates
33
33
  - 🚀 **[TypeLLM API](https://typellm.ai)** is live: typed outputs without serving a model, $5 free credit. [Try it](https://typellm.ai/dashboard/playground) · [Quick start](#typellm-api-cloud) · [Examples](https://typellm.ai/examples).
34
+ - **[2026/10/15]** An [array of boxes](#boxes) finds every match in an image: every dog, every line item.
35
+ - **[2026/10/15]** Added [boxes](#boxes): find something in an image, returned as its edges.
36
+ - **[2026/10/14]** Added [tools and human input](#tools-and-human-input): a workflow pauses for answers from your own tools or a person, and resumes with them.
34
37
  - **[2026/10/08]** [Conditional fields](#conditional-fields) can test how sure an answer is: `"when": {"category": {"confidence": {"gte": 0.8}}}`.
35
38
  - **[2026/10/05]** Added [objects and arrays](#objects-and-arrays): group typed properties into one value, or return a variable number of typed items.
36
39
  - **[2026/10/01]** Added [conditional fields](#conditional-fields): a field with `when` is answered only if its dependencies meet conditions.
@@ -118,6 +121,23 @@ client = TypeLLMClient(
118
121
  )
119
122
  ```
120
123
 
124
+ | Setting | Default | Purpose |
125
+ | --- | --- | --- |
126
+ | `base_url` | `"http://127.0.0.1:30000"` | SGLang server URL. |
127
+ | `model` | `None` | Model ID; discovered from the server when omitted. |
128
+ | `tokenizer` | `None` | Matching tokenizer ID or local path; when omitted, the server's paths, then its served model name. |
129
+ | `text_max_tokens` | `128` | Text generation budget per field. |
130
+ | `numeric_max_digits` | `32` | Digit limit for open numeric fields. |
131
+ | `temperature` | `0` | 0 gives each field its most likely answer; above 0 samples at that temperature. |
132
+ | `seed` | `None` | Client random-number seed. |
133
+ | `timeout` | `120.0` | HTTP request timeout in seconds. |
134
+
135
+ On your own server, no service limits apply: your server's context length,
136
+ memory and timeouts are the only bounds. An enum takes up to 26 values, and a
137
+ field that thinks without a `thinking_budget` may reason until the context is
138
+ full. `label_pool` customizes the internal candidate labels; leave it unset for
139
+ normal use. For debugging, `print_final_prompt=True` prints the prompts sent.
140
+
121
141
  ### Make a request
122
142
 
123
143
  With either client:
@@ -419,6 +439,44 @@ Each image can be a local file path, an http(s) URL, a `data:` URI, raw bytes,
419
439
  or a PIL image. Local files are read by the client, so the SGLang server does
420
440
  not need access to your filesystem.
421
441
 
442
+ ### Boxes
443
+
444
+ A `box` field finds something in the image and returns where it is: `top`,
445
+ `bottom`, `left` and `right`, each from 0 to 1 across the image, from its
446
+ top-left corner.
447
+
448
+ ```python
449
+ response = client.generate(
450
+ context="Read the receipt photo.",
451
+ images=["receipt.jpg"],
452
+ questions={"total_line": {"type": "box", "instructions": "The line that shows the total."}},
453
+ )
454
+ response.result # {"total_line": {"top": 0.652, "bottom": 0.689, "left": 0.287, "right": 0.855}}
455
+ ```
456
+
457
+ Multiply by the image's width and height for pixels. There is always a box: to
458
+ check that the target is there, ask a boolean field and give the box a `when` on
459
+ it, so the box is asked only when the target is there. A box cannot be null,
460
+ sit inside an object's or an array's items, or be tested by a `when`.
461
+
462
+ An array of boxes finds every match, from none to `maxItems` (at most 50):
463
+
464
+ ```python
465
+ response = client.generate(
466
+ context="Read the receipt photo.",
467
+ images=["receipt.jpg"],
468
+ questions={"items": {"type": "array", "items": {"type": "box"},
469
+ "instructions": "Every line item, each the whole line from its name to its price."}},
470
+ )
471
+ response.result # {"items": [{"top": 0.421, "bottom": 0.461, "left": 0.12, "right": 0.837},
472
+ # {"top": 0.449, "bottom": 0.493, "left": 0.12, "right": 0.834},
473
+ # {"top": 0.48, "bottom": 0.521, "left": 0.118, "right": 0.829}]}
474
+ ```
475
+
476
+ It returns `[]` when the image has no match. Its `items` take only
477
+ `{"type": "box"}`: say what to find in the array's `instructions`. `minItems`
478
+ and `maxItems` bound it; `continue_from` is not supported.
479
+
422
480
  ## Dependency-aware generation
423
481
 
424
482
  Fields run together by default, and each sees only the original context.
@@ -516,6 +574,40 @@ skipped field are skipped too, even when their other dependencies ran. A skipped
516
574
  field sends no requests; only its definition counts toward the call's input
517
575
  tokens.
518
576
 
577
+ ### Tools and human input
578
+
579
+ Some answers come from outside the model: an order's status from your database,
580
+ a result from your own tool, or a person's decision. On the hosted API, a field
581
+ with `"source": "user"` is answered by your code. The call answers every field
582
+ that does not wait on it, then stops with `status` `"needs_input"`, and
583
+ `client.resume()` goes on with your answers:
584
+
585
+ ```python
586
+ response = client.generate(context=email, questions={
587
+ "order_id": {"type": ["string", "null"], "instructions": "The order number in the email."},
588
+ "order_status": {"type": "string", "enum": ["shipped", "delivered", "lost"],
589
+ "source": "user", "when": {"order_id": {"ne": None}}},
590
+ "reply": {"type": "string", "depends_on": ["order_status"]},
591
+ })
592
+ while response.status == "needs_input": # response.needs_input names the fields
593
+ response = client.resume(response, {"order_status": lookup(response.result["order_id"])})
594
+ ```
595
+
596
+ On your own server, pass answers you already have in `given`, as `result` gives
597
+ them, and fields to leave out in `skipped`. The model is not asked those fields,
598
+ and the fields that depend on them see them as answers:
599
+
600
+ ```python
601
+ response = client.generate(context=email, questions=questions,
602
+ given={"order_status": "lost"}, skipped=["refund"])
603
+ ```
604
+
605
+ Passing a call's `result` and `skipped` back, with more fields, answers only the
606
+ new ones. A field with `return_probabilities` is given as `result` has it, with
607
+ its probabilities.
608
+
609
+ [Docs](https://typellm.ai/docs/workflows#tools-and-human-input) · [Cookbook](https://typellm.ai/cookbook/tools-and-human-input)
610
+
519
611
  ## Probabilities and sampling
520
612
 
521
613
  Set `return_probabilities` on individual enum or boolean fields:
@@ -16,6 +16,9 @@
16
16
 
17
17
  ## Updates
18
18
  - 🚀 **[TypeLLM API](https://typellm.ai)** is live: typed outputs without serving a model, $5 free credit. [Try it](https://typellm.ai/dashboard/playground) · [Quick start](#typellm-api-cloud) · [Examples](https://typellm.ai/examples).
19
+ - **[2026/10/15]** An [array of boxes](#boxes) finds every match in an image: every dog, every line item.
20
+ - **[2026/10/15]** Added [boxes](#boxes): find something in an image, returned as its edges.
21
+ - **[2026/10/14]** Added [tools and human input](#tools-and-human-input): a workflow pauses for answers from your own tools or a person, and resumes with them.
19
22
  - **[2026/10/08]** [Conditional fields](#conditional-fields) can test how sure an answer is: `"when": {"category": {"confidence": {"gte": 0.8}}}`.
20
23
  - **[2026/10/05]** Added [objects and arrays](#objects-and-arrays): group typed properties into one value, or return a variable number of typed items.
21
24
  - **[2026/10/01]** Added [conditional fields](#conditional-fields): a field with `when` is answered only if its dependencies meet conditions.
@@ -103,6 +106,23 @@ client = TypeLLMClient(
103
106
  )
104
107
  ```
105
108
 
109
+ | Setting | Default | Purpose |
110
+ | --- | --- | --- |
111
+ | `base_url` | `"http://127.0.0.1:30000"` | SGLang server URL. |
112
+ | `model` | `None` | Model ID; discovered from the server when omitted. |
113
+ | `tokenizer` | `None` | Matching tokenizer ID or local path; when omitted, the server's paths, then its served model name. |
114
+ | `text_max_tokens` | `128` | Text generation budget per field. |
115
+ | `numeric_max_digits` | `32` | Digit limit for open numeric fields. |
116
+ | `temperature` | `0` | 0 gives each field its most likely answer; above 0 samples at that temperature. |
117
+ | `seed` | `None` | Client random-number seed. |
118
+ | `timeout` | `120.0` | HTTP request timeout in seconds. |
119
+
120
+ On your own server, no service limits apply: your server's context length,
121
+ memory and timeouts are the only bounds. An enum takes up to 26 values, and a
122
+ field that thinks without a `thinking_budget` may reason until the context is
123
+ full. `label_pool` customizes the internal candidate labels; leave it unset for
124
+ normal use. For debugging, `print_final_prompt=True` prints the prompts sent.
125
+
106
126
  ### Make a request
107
127
 
108
128
  With either client:
@@ -404,6 +424,44 @@ Each image can be a local file path, an http(s) URL, a `data:` URI, raw bytes,
404
424
  or a PIL image. Local files are read by the client, so the SGLang server does
405
425
  not need access to your filesystem.
406
426
 
427
+ ### Boxes
428
+
429
+ A `box` field finds something in the image and returns where it is: `top`,
430
+ `bottom`, `left` and `right`, each from 0 to 1 across the image, from its
431
+ top-left corner.
432
+
433
+ ```python
434
+ response = client.generate(
435
+ context="Read the receipt photo.",
436
+ images=["receipt.jpg"],
437
+ questions={"total_line": {"type": "box", "instructions": "The line that shows the total."}},
438
+ )
439
+ response.result # {"total_line": {"top": 0.652, "bottom": 0.689, "left": 0.287, "right": 0.855}}
440
+ ```
441
+
442
+ Multiply by the image's width and height for pixels. There is always a box: to
443
+ check that the target is there, ask a boolean field and give the box a `when` on
444
+ it, so the box is asked only when the target is there. A box cannot be null,
445
+ sit inside an object's or an array's items, or be tested by a `when`.
446
+
447
+ An array of boxes finds every match, from none to `maxItems` (at most 50):
448
+
449
+ ```python
450
+ response = client.generate(
451
+ context="Read the receipt photo.",
452
+ images=["receipt.jpg"],
453
+ questions={"items": {"type": "array", "items": {"type": "box"},
454
+ "instructions": "Every line item, each the whole line from its name to its price."}},
455
+ )
456
+ response.result # {"items": [{"top": 0.421, "bottom": 0.461, "left": 0.12, "right": 0.837},
457
+ # {"top": 0.449, "bottom": 0.493, "left": 0.12, "right": 0.834},
458
+ # {"top": 0.48, "bottom": 0.521, "left": 0.118, "right": 0.829}]}
459
+ ```
460
+
461
+ It returns `[]` when the image has no match. Its `items` take only
462
+ `{"type": "box"}`: say what to find in the array's `instructions`. `minItems`
463
+ and `maxItems` bound it; `continue_from` is not supported.
464
+
407
465
  ## Dependency-aware generation
408
466
 
409
467
  Fields run together by default, and each sees only the original context.
@@ -501,6 +559,40 @@ skipped field are skipped too, even when their other dependencies ran. A skipped
501
559
  field sends no requests; only its definition counts toward the call's input
502
560
  tokens.
503
561
 
562
+ ### Tools and human input
563
+
564
+ Some answers come from outside the model: an order's status from your database,
565
+ a result from your own tool, or a person's decision. On the hosted API, a field
566
+ with `"source": "user"` is answered by your code. The call answers every field
567
+ that does not wait on it, then stops with `status` `"needs_input"`, and
568
+ `client.resume()` goes on with your answers:
569
+
570
+ ```python
571
+ response = client.generate(context=email, questions={
572
+ "order_id": {"type": ["string", "null"], "instructions": "The order number in the email."},
573
+ "order_status": {"type": "string", "enum": ["shipped", "delivered", "lost"],
574
+ "source": "user", "when": {"order_id": {"ne": None}}},
575
+ "reply": {"type": "string", "depends_on": ["order_status"]},
576
+ })
577
+ while response.status == "needs_input": # response.needs_input names the fields
578
+ response = client.resume(response, {"order_status": lookup(response.result["order_id"])})
579
+ ```
580
+
581
+ On your own server, pass answers you already have in `given`, as `result` gives
582
+ them, and fields to leave out in `skipped`. The model is not asked those fields,
583
+ and the fields that depend on them see them as answers:
584
+
585
+ ```python
586
+ response = client.generate(context=email, questions=questions,
587
+ given={"order_status": "lost"}, skipped=["refund"])
588
+ ```
589
+
590
+ Passing a call's `result` and `skipped` back, with more fields, answers only the
591
+ new ones. A field with `return_probabilities` is given as `result` has it, with
592
+ its probabilities.
593
+
594
+ [Docs](https://typellm.ai/docs/workflows#tools-and-human-input) · [Cookbook](https://typellm.ai/cookbook/tools-and-human-input)
595
+
504
596
  ## Probabilities and sampling
505
597
 
506
598
  Set `return_probabilities` on individual enum or boolean fields:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "typellm"
7
- version = "0.7.0"
7
+ version = "0.7.2"
8
8
  description = "Type-Safe Decoding for Autoregressive LLMs"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -0,0 +1,217 @@
1
+ import unittest
2
+
3
+ from typellm import SchemaError, TypeLLMClient
4
+ from typellm.runtime import BOX_ITEM_TOKENS, BOX_MAX_TOKENS, BOX_PATTERN, box_list_pattern
5
+
6
+ from tests.test_batching import FakeServer
7
+ from tests.test_images import PNG
8
+
9
+
10
+ class BoxServer(FakeServer):
11
+ """A FakeServer that answers a box's grammar with `coordinates`, as the model writes them."""
12
+
13
+ def __init__(self, coordinates="199, 166, 400, 500]}"):
14
+ super().__init__()
15
+ self.coordinates = coordinates
16
+
17
+ def _request(self, path, payload=None, *, allow_text=False):
18
+ response = super()._request(path, payload, allow_text=allow_text)
19
+ if path == "/generate" and not isinstance(payload["sampling_params"], dict):
20
+ for params, item in zip(payload["sampling_params"], response if isinstance(response, list) else [response]):
21
+ if params.get("regex") == BOX_PATTERN:
22
+ item["text"] = self.coordinates
23
+ elif path == "/generate" and payload["sampling_params"].get("regex") == BOX_PATTERN:
24
+ response["text"] = self.coordinates
25
+ return response
26
+
27
+ def box_requests(self):
28
+ return [params for payload in self.payloads
29
+ for params in (payload["sampling_params"] if isinstance(payload["sampling_params"], list)
30
+ else [payload["sampling_params"]])
31
+ if params.get("regex") == BOX_PATTERN]
32
+
33
+
34
+ class BoxTests(unittest.TestCase):
35
+ def run_questions(self, questions, server=None, images=(PNG,), **options):
36
+ client = TypeLLMClient("http://127.0.0.1:30000", model="fake")
37
+ client.sglang = server or BoxServer()
38
+ done = client.generate(context="Read the receipt photo.", images=list(images), questions=questions, **options)
39
+ sent = [t for p in client.sglang.payloads
40
+ for t in ([p["text"]] if isinstance(p["text"], str) else p["text"])]
41
+ return done, sent, client.sglang
42
+
43
+ def test_a_box_comes_back_as_fractions_of_the_image(self):
44
+ done, sent, server = self.run_questions({"total": {"type": "box", "instructions": "The total line."}})
45
+ self.assertEqual(done.result, {"total": {"top": 0.166, "bottom": 0.5, "left": 0.199, "right": 0.4}})
46
+ [prompt] = [t for t in sent if t.endswith('{"bbox_2d": [')]
47
+ self.assertIn("Type: bounding box", prompt)
48
+ self.assertIn("Instructions: The total line.", prompt)
49
+ [params] = server.box_requests()
50
+ self.assertGreaterEqual(params["max_new_tokens"], BOX_MAX_TOKENS)
51
+
52
+ def test_edges_given_the_wrong_way_round_are_put_in_order(self):
53
+ done, _, _ = self.run_questions({"total": {"type": "box"}}, server=BoxServer("400, 500, 199, 166]}"))
54
+ self.assertEqual(done.result["total"], {"top": 0.166, "bottom": 0.5, "left": 0.199, "right": 0.4})
55
+
56
+ def test_a_box_without_instructions_is_asked_by_its_name(self):
57
+ _, sent, _ = self.run_questions({"signature": {"type": "box"}})
58
+ [prompt] = [t for t in sent if t.endswith('{"bbox_2d": [')]
59
+ self.assertIn('Instructions: Locate "signature" in the image.', prompt)
60
+
61
+ def test_fields_that_depend_on_a_box_see_it(self):
62
+ questions = {"total": {"type": "box"}, "legible": {"type": "boolean", "depends_on": ["total"]}}
63
+ done, sent, _ = self.run_questions(questions)
64
+ self.assertTrue(done.result["legible"])
65
+ [later] = [t for t in sent if 'Field: "legible"' in t[t.rindex("Field:"):]]
66
+ self.assertIn('{"bbox_2d": [199, 166, 400, 500]}', later) # the box in history, as the model wrote it
67
+ self.assertIn('"total": {"top": 0.166, "bottom": 0.5, "left": 0.199, "right": 0.4}', later)
68
+
69
+ def test_boxes_and_numbers_decode_in_one_request(self):
70
+ done, _, server = self.run_questions({"total": {"type": "box"}, "amount": {"type": "number"}})
71
+ self.assertEqual(done.result["amount"], 7)
72
+ self.assertEqual(len([p for p in server.payloads if any(
73
+ "regex" in params for params in (p["sampling_params"] if isinstance(p["sampling_params"], list)
74
+ else [p["sampling_params"]]))]), 1)
75
+
76
+ def test_a_box_can_be_given(self):
77
+ box = {"top": 0.2, "bottom": 0.4, "left": 0.1, "right": 0.3}
78
+ done, sent, server = self.run_questions(
79
+ {"total": {"type": "box"}, "legible": {"type": "boolean", "depends_on": ["total"]}}, given={"total": box})
80
+ self.assertEqual(done.result["total"], box)
81
+ self.assertEqual(server.box_requests(), [])
82
+ [later] = [t for t in sent if 'Field: "legible"' in t[t.rindex("Field:"):]]
83
+ self.assertIn('{"bbox_2d": [100, 200, 300, 400]}', later)
84
+ with self.assertRaisesRegex(SchemaError, "must be a box"):
85
+ self.run_questions({"total": {"type": "box"}}, given={"total": {"top": 2}})
86
+
87
+ def test_what_a_box_cannot_be(self):
88
+ cases = [
89
+ ({"total": {"type": "box"}}, (), "needs images"),
90
+ ({"total": {"type": ["box", "null"]}}, (PNG,), "cannot be null"),
91
+ ({"total": {"type": "box", "enum": [1, 2]}}, (PNG,), "enum is not supported on a box"),
92
+ ({"total": {"type": "box", "return_probabilities": True}}, (PNG,), "return_probabilities is not supported"),
93
+ ({"rows": {"type": "array", "items": {"type": "object", "properties": {"at": {"type": "box"}}}}},
94
+ (PNG,), "inside an array"),
95
+ ({"total": {"type": "box"}, "ok": {"type": "boolean", "when": {"total": None}}}, (PNG,), "a box"),
96
+ ]
97
+ for questions, images, message in cases:
98
+ with self.subTest(message=message):
99
+ with self.assertRaisesRegex(SchemaError, message):
100
+ self.run_questions(questions, images=images)
101
+
102
+
103
+
104
+ class ListServer(BoxServer):
105
+ """A BoxServer that answers a list of boxes' grammar with `listed`, after its "[" as the model writes it,
106
+ and whether there is any match with `present`."""
107
+
108
+ def __init__(self, listed='{"bbox_2d": [100, 200, 300, 400]}, {"bbox_2d": [500, 600, 700, 800]}]', present=True):
109
+ super().__init__()
110
+ self.listed = listed
111
+ self.present = present
112
+
113
+ def _request(self, path, payload=None, *, allow_text=False):
114
+ response = super()._request(path, payload, allow_text=allow_text)
115
+ if path == "/generate" and "token_ids_logprob" in payload and not self.present:
116
+ texts = [payload["text"]] if isinstance(payload["text"], str) else payload["text"]
117
+ for text, item in zip(texts, response if isinstance(response, list) else [response]):
118
+ if "Is there at least one match" in text[text.rindex("<|im_start|>user"):]:
119
+ scores = item["meta_info"]["output_token_ids_logprobs"][0]
120
+ # The second label, false, over the first.
121
+ scores[0][0], scores[1][0] = -9.0, 0.0
122
+ if path == "/generate":
123
+ params = payload["sampling_params"]
124
+ for one, item in zip(params if isinstance(params, list) else [params],
125
+ response if isinstance(response, list) else [response]):
126
+ if one.get("regex", "").startswith(("(?:" + r'\{"bbox_2d"', r'\{"bbox_2d"')):
127
+ item["text"] = self.listed
128
+ return response
129
+
130
+ def list_requests(self):
131
+ return [params for payload in self.payloads
132
+ for params in (payload["sampling_params"] if isinstance(payload["sampling_params"], list)
133
+ else [payload["sampling_params"]])
134
+ if params.get("regex", "").endswith(r"\]") and "bbox_2d" in params.get("regex", "")]
135
+
136
+
137
+ class BoxListTests(unittest.TestCase):
138
+ def run_questions(self, questions, server=None, **options):
139
+ return BoxTests.run_questions(self, questions, server=server or ListServer(), **options)
140
+
141
+ def test_an_array_of_boxes_lists_every_match_in_one_answer(self):
142
+ questions = {"dogs": {"type": "array", "items": {"type": "box"}, "instructions": "Every dog."}}
143
+ done, sent, server = self.run_questions(questions)
144
+ self.assertEqual(done.result["dogs"], [{"top": 0.2, "bottom": 0.4, "left": 0.1, "right": 0.3},
145
+ {"top": 0.6, "bottom": 0.8, "left": 0.5, "right": 0.7}])
146
+ [prompt] = [t for t in sent if t.endswith("Answer [] if there is none.<|im_end|>\n<|im_start|>assistant\n[")
147
+ or t.rstrip().endswith("[") and "list of bounding boxes" in t]
148
+ self.assertIn("Instructions: Every dog.", prompt)
149
+ [params] = server.list_requests()
150
+ # The hidden question said there is a match, so the list holds one at least.
151
+ self.assertEqual(params["regex"], box_list_pattern(1, 50))
152
+ self.assertGreaterEqual(params["max_new_tokens"], 50 * BOX_ITEM_TOKENS)
153
+ self.assertEqual(list(done.result), ["dogs"]) # the presence question is not in the result
154
+ self.assertEqual(done.skipped, [])
155
+
156
+ def test_bounds_shape_the_grammar(self):
157
+ questions = {"dogs": {"type": "array", "items": {"type": "box"}, "minItems": 1, "maxItems": 3}}
158
+ _, _, server = self.run_questions(questions)
159
+ [params] = server.list_requests()
160
+ self.assertEqual(params["regex"], box_list_pattern(1, 3))
161
+
162
+ def test_no_match_is_an_empty_list_and_asks_for_no_boxes(self):
163
+ questions = {"dogs": {"type": "array", "items": {"type": "box"}},
164
+ "count": {"type": "integer", "depends_on": ["dogs"]}}
165
+ done, sent, server = self.run_questions(questions, server=ListServer(present=False))
166
+ self.assertEqual(done.result, {"dogs": [], "count": 7})
167
+ self.assertEqual(server.list_requests(), [])
168
+ [later] = [t for t in sent if 'Field: "count"' in t[t.rindex("Field:"):]]
169
+ self.assertIn('assistant\n[]', later)
170
+
171
+ def test_a_list_that_must_hold_a_box_asks_no_presence_question(self):
172
+ questions = {"dogs": {"type": "array", "items": {"type": "box"}, "minItems": 1}}
173
+ done, sent, _ = self.run_questions(questions, server=ListServer(present=False))
174
+ self.assertEqual(len(done.result["dogs"]), 2)
175
+ self.assertFalse(any("Is there at least one match" in t for t in sent))
176
+
177
+ def test_a_box_listed_twice_comes_back_once(self):
178
+ twice = '{"bbox_2d": [100, 200, 300, 400]}, {"bbox_2d": [300, 400, 100, 200]}]'
179
+ done, _, _ = self.run_questions({"dogs": {"type": "array", "items": {"type": "box"}}}, server=ListServer(twice))
180
+ self.assertEqual(done.result["dogs"], [{"top": 0.2, "bottom": 0.4, "left": 0.1, "right": 0.3}])
181
+
182
+ def test_dependents_see_the_list(self):
183
+ questions = {"dogs": {"type": "array", "items": {"type": "box"}},
184
+ "count": {"type": "integer", "depends_on": ["dogs"]}}
185
+ done, sent, _ = self.run_questions(questions)
186
+ [later] = [t for t in sent if 'Field: "count"' in t[t.rindex("Field:"):]]
187
+ self.assertIn('[{"bbox_2d": [100, 200, 300, 400]}, {"bbox_2d": [500, 600, 700, 800]}]', later)
188
+
189
+ def test_a_list_can_be_given(self):
190
+ boxes = [{"top": 0.2, "bottom": 0.4, "left": 0.1, "right": 0.3}]
191
+ questions = {"dogs": {"type": "array", "items": {"type": "box"}},
192
+ "count": {"type": "integer", "depends_on": ["dogs"]}}
193
+ done, sent, server = self.run_questions(questions, given={"dogs": boxes})
194
+ self.assertEqual(done.result["dogs"], boxes)
195
+ self.assertEqual(server.list_requests(), [])
196
+ self.assertFalse(any("Is there at least one match" in t for t in sent))
197
+ [later] = [t for t in sent if 'Field: "count"' in t[t.rindex("Field:"):]]
198
+ self.assertIn('[{"bbox_2d": [100, 200, 300, 400]}]', later)
199
+ with self.assertRaisesRegex(SchemaError, "list of 0 to 50 boxes"):
200
+ self.run_questions(questions, given={"dogs": [{"top": 2}]})
201
+
202
+ def test_what_an_array_of_boxes_cannot_be(self):
203
+ cases = [
204
+ ({"dogs": {"type": "array", "items": {"type": "box", "instructions": "A dog."}}}, "take only their type"),
205
+ ({"dogs": {"type": "array", "items": {"type": "box"}, "continue_from": []}}, "continue_from"),
206
+ ({"dogs": {"type": "array", "items": {"type": "box"}, "maxItems": 60}}, "at most 50"),
207
+ ({"dogs": {"type": "array", "items": {"type": "box"}},
208
+ "ok": {"type": "boolean", "when": {"dogs": None}}}, "a box"),
209
+ ]
210
+ for questions, message in cases:
211
+ with self.subTest(message=message):
212
+ with self.assertRaisesRegex(SchemaError, message):
213
+ self.run_questions(questions)
214
+
215
+
216
+ if __name__ == "__main__":
217
+ unittest.main()
@@ -43,10 +43,61 @@ DEFAULT_SOCKET_TIMEOUT = 120.0
43
43
 
44
44
 
45
45
  def _closed_answer(decision: "Choice", value_json: str) -> str:
46
- """The full answer an open field leaves in history, e.g. {"total": 174600}."""
46
+ """The full answer an open field leaves in history, e.g. {"total": 174600}; a box's, its
47
+ coordinates as the model writes them: {"bbox_2d": [199, 166, 400, 500]}."""
48
+ if decision.box_list:
49
+ return decision.answer_prefill + value_json # the list after its "[", closing bracket included
50
+ if decision.box:
51
+ return decision.answer_prefill + value_json + "]}"
47
52
  return decision.answer_prefill + " " + value_json + "}" if decision.answer_prefill else value_json
48
53
 
49
54
 
55
+ # A box is asked as the model grounds things itself: [x1, y1, x2, y2], each from 0 to 1000 across
56
+ # the image, x from its left edge and y from its top. The answer is decoded to that grammar.
57
+ BOX_COORDINATE = r"(?:1000|[1-9][0-9]{0,2}|0)"
58
+ BOX_PATTERN = ", ".join([BOX_COORDINATE] * 4) + r"\]\}?"
59
+ # Tokens for the four coordinates and their separators, one digit a token at worst.
60
+ BOX_MAX_TOKENS = 32
61
+ # An array of boxes is the list the model writes when it grounds several things, after its "[":
62
+ # [{"bbox_2d": [x1, y1, x2, y2]}, ...], each box its own coordinates.
63
+ _BOX_ITEM = r'\{"bbox_2d": \[' + ", ".join([BOX_COORDINATE] * 4) + r"\]\}"
64
+ # Tokens for one box in the list: its key, coordinates and separators.
65
+ BOX_ITEM_TOKENS = 28
66
+
67
+
68
+ def box_list_pattern(min_items: int, max_items: int) -> str:
69
+ """The grammar of a list of boxes after its "[": from min_items to max_items boxes, then "]"."""
70
+ more = _BOX_ITEM + ("(?:, " + _BOX_ITEM + "){" + f"{max(min_items - 1, 0)},{max_items - 1}" + "}")
71
+ return (more if min_items else "(?:" + more + ")?") + r"\]"
72
+
73
+
74
+ def _box_list_value(text: str) -> tuple[list[dict[str, float]], str]:
75
+ """A decoded list of boxes as its value, each as _box_value gives it, and as history keeps it,
76
+ after the "[". A box the list already holds is not added again."""
77
+ boxes = json.loads("[" + text)
78
+ values: list[dict[str, float]] = []
79
+ written: list[str] = []
80
+ for box in boxes:
81
+ value, coordinates = _box_value(", ".join(str(n) for n in box["bbox_2d"]))
82
+ if value not in values:
83
+ values.append(value)
84
+ written.append('{"bbox_2d": [' + coordinates + "]}")
85
+ return values, ", ".join(written) + "]"
86
+
87
+
88
+ def _box_value(text: str) -> tuple[dict[str, float], str]:
89
+ """A decoded box as its value, {"top", "bottom", "left", "right"} from 0 to 1, and its
90
+ coordinates as history keeps them. Edges given the wrong way round are put in order."""
91
+ numbers = [int(part) for part in text.strip().rstrip("}").rstrip().rstrip("]").split(",")]
92
+ if len(numbers) != 4 or any(not 0 <= n <= 1000 for n in numbers):
93
+ raise ValueError(f"Generated an invalid box: {text!r}")
94
+ x1, y1, x2, y2 = numbers
95
+ left, right = sorted((x1, x2))
96
+ top, bottom = sorted((y1, y2))
97
+ value = {"top": top / 1000, "bottom": bottom / 1000, "left": left / 1000, "right": right / 1000}
98
+ return value, f"{left}, {top}, {right}, {bottom}"
99
+
100
+
50
101
  def _closed_label(decision: "Choice", label: str) -> str:
51
102
  """A choice answer as history keeps it, e.g. {"category": "A"}."""
52
103
  return decision.label_prefill + label + '"}' if decision.label_prefill else label
@@ -103,9 +154,19 @@ class Choice:
103
154
  # More choices than labels: the prompt lists values, the answer writes one, and the client's
104
155
  # choice scorer gives each its probability. choices is keyed "0", "1", ... in enum order.
105
156
  by_value: bool = False
157
+ # A box: where something is in the image (see BOX_PATTERN).
158
+ box: bool = False
159
+ box_list: tuple[int, int] | None = None
160
+ presence_from: str | None = None
161
+ presence_for: str | None = None
162
+
163
+ @property
164
+ def hidden(self) -> bool:
165
+ """A question the call asks for a field's sake, never in the result: an effort, a presence."""
166
+ return self.effort_for is not None or self.presence_for is not None
106
167
 
107
168
  def __post_init__(self) -> None:
108
- if not self.choices and self.numeric_type is None and not self.text_type:
169
+ if not self.choices and self.numeric_type is None and not self.text_type and not self.box:
109
170
  raise ValueError("Choice.choices must not be empty")
110
171
  if self.text_type and (self.choices or self.numeric_type is not None):
111
172
  raise ValueError("Text choices cannot have enum values or a numeric type")
@@ -136,6 +197,12 @@ class Choice:
136
197
  Chat models tend to answer {"name": value}, so the value is decoded right
137
198
  where they would write it.
138
199
  """
200
+ if self.box_list:
201
+ return "["
202
+ if self.box:
203
+ # The key the model grounds with, whatever the field's name: a box's own name can
204
+ # pull the model to answer the thing itself, such as the total, not where it is.
205
+ return '{"bbox_2d": ['
139
206
  if self.field_name is None or not (self.text_type or self.numeric_type is not None):
140
207
  return ""
141
208
  # No trailing space: in {"name": 12} the space belongs to the value's first token.
@@ -160,6 +227,19 @@ class Choice:
160
227
  # A nullable field says "or null" in both its type and its answer: a bare
161
228
  # <number> reads as "a number is required" and pulls absent values to 0.
162
229
  or_null = " or null" if self.nullable else ""
230
+ if self.box_list:
231
+ lines.append("Type: list of bounding boxes")
232
+ lines.append(f"Instructions: {self.question}")
233
+ lines.append('Answer with a JSON list of every match, each as {"bbox_2d": [x1, y1, x2, y2]} with '
234
+ "coordinates from 0 to 1000: x from the left edge, y from the top. "
235
+ "Answer [] if there is none.")
236
+ return "\n".join(lines)
237
+ if self.box:
238
+ lines.append("Type: bounding box")
239
+ lines.append(f"Instructions: {self.question}")
240
+ lines.append('Answer with its bounding box in the image as {"bbox_2d": [x1, y1, x2, y2]}, each '
241
+ "from 0 to 1000: x from the left edge, y from the top.")
242
+ return "\n".join(lines)
163
243
  if self.text_type:
164
244
  kind = f"string{or_null}"
165
245
  elif self.numeric_type is not None:
@@ -221,6 +301,9 @@ class ArrayChoice:
221
301
  dependency_groups: tuple[tuple[str, ...], ...] | None = None
222
302
  effort_for: None = None
223
303
  effort_from: None = None
304
+ presence_for: None = None
305
+ presence_from: None = None
306
+ hidden: bool = False
224
307
  return_probabilities: bool = False
225
308
  # Items the caller already has: the array starts from them (schema's continue_from).
226
309
  continue_from: tuple[Any, ...] = ()
@@ -475,9 +558,15 @@ class TypeLLMClient:
475
558
  dependency_groups=item.dependency_groups,
476
559
  descriptions=item.descriptions,
477
560
  levels=item.levels,
561
+ box=item.box,
562
+ box_list=item.box_list,
563
+ presence_from=item.presence_from,
564
+ presence_for=item.presence_for,
478
565
  )
479
566
 
480
567
  def bind_array(array):
568
+ if any(item.box for item in array.items):
569
+ raise SchemaError(f"{array.name!r}: a box is not supported inside an array's items")
481
570
  return ArrayChoice(
482
571
  name=array.name,
483
572
  question=array.question,
@@ -653,6 +742,9 @@ class TypeLLMClient:
653
742
  with call_scope(timeout, cancel) as scope:
654
743
  try:
655
744
  decisions = self.compile_schema(schema)
745
+ boxes = [d.name for d in decisions if getattr(d, "box", False)]
746
+ if boxes and not encoded_images:
747
+ raise SchemaError(f"{boxes!r} are boxes: a box locates something in an image, so the call needs images")
656
748
  given_by_name = _given_by_name(decisions, given or {})
657
749
  skip = _skip_names(decisions, skipped or ())
658
750
  count = getattr(self.sglang, "count_tokens", None)
@@ -879,7 +971,7 @@ def _assemble(decisions: Sequence[Any], rows: Sequence[dict | None], prefix: str
879
971
 
880
972
  output: dict[str, Any] = {}
881
973
  for decision, row in zip(decisions, rows):
882
- if decision.effort_for is None and row is not None: # not a hidden effort question, not skipped
974
+ if not decision.hidden and row is not None: # not a hidden question, not skipped
883
975
  _put(output, _path(decision), row["value"] if isinstance(decision, ArrayChoice)
884
976
  else _answer(decision, row))
885
977
  thinking: dict[str, str] = {}
@@ -887,6 +979,8 @@ def _assemble(decisions: Sequence[Any], rows: Sequence[dict | None], prefix: str
887
979
  skipped: list[str] = []
888
980
  for decision, row in zip(decisions, rows):
889
981
  path = _path(decision)
982
+ if decision.presence_for is not None:
983
+ continue
890
984
  if decision.effort_for is not None:
891
985
  if row is not None:
892
986
  effort[label(path[:-1])] = row["value"]
@@ -1010,6 +1104,14 @@ def _parse_numbers(
1010
1104
  """Read the grammar-generated numbers: (value, closed prompt, value text)."""
1011
1105
  outputs = []
1012
1106
  for (prompt, decision), raw in zip(items, texts):
1107
+ if decision.box_list:
1108
+ value, written = _box_list_value(raw)
1109
+ outputs.append((value, prompt + written, written))
1110
+ continue
1111
+ if decision.box:
1112
+ value, coordinates = _box_value(raw)
1113
+ outputs.append((value, prompt + coordinates + "]}", coordinates))
1114
+ continue
1013
1115
  text = raw.strip()
1014
1116
  if text.endswith("}"):
1015
1117
  text = text[:-1].rstrip()
@@ -1147,10 +1249,10 @@ def _given_value(given: Mapping[str, Any], path: Sequence[str]) -> Any:
1147
1249
  def _skip_names(decisions: Sequence[Any], skipped: Sequence[str]) -> set[str]:
1148
1250
  """The decisions skipped names, as Generation.skipped names them: a field, an object or a property."""
1149
1251
  paths = [tuple(name.split(".")) for name in skipped]
1150
- names = {d.name for d in decisions if d.effort_for is None and any(
1252
+ names = {d.name for d in decisions if not d.hidden and any(
1151
1253
  _path(d)[:len(path)] == path for path in paths)}
1152
1254
  unknown = [name for name, path in zip(skipped, paths)
1153
- if not any(_path(d)[:len(path)] == path for d in decisions if d.effort_for is None)]
1255
+ if not any(_path(d)[:len(path)] == path for d in decisions if not d.hidden)]
1154
1256
  if unknown:
1155
1257
  raise SchemaError(f"skipped names fields the schema does not have: {unknown!r}")
1156
1258
  return names
@@ -1160,12 +1262,12 @@ def _given_by_name(decisions: Sequence[Any], given: Mapping[str, Any]) -> dict[s
1160
1262
  """Each given decision's value, by decision name; a value is checked when its row is made."""
1161
1263
  by_name = {}
1162
1264
  for decision in decisions:
1163
- if decision.effort_for is not None:
1265
+ if decision.hidden:
1164
1266
  continue
1165
1267
  value = _given_value(given, _path(decision))
1166
1268
  if value is not _MISSING:
1167
1269
  by_name[decision.name] = value
1168
- tops = {_path(d)[0] for d in decisions if d.effort_for is None}
1270
+ tops = {_path(d)[0] for d in decisions if not d.hidden}
1169
1271
  unknown = [name for name in given if name not in tops]
1170
1272
  if unknown:
1171
1273
  raise SchemaError(f"given names fields the schema does not have: {unknown!r}")
@@ -1217,6 +1319,24 @@ def _given_row(client, context: str, decision: Any, given: Any, parent: str | No
1217
1319
  chosen = None
1218
1320
  if value is None and decision.nullable and (decision.text_type or decision.numeric_type is not None):
1219
1321
  closed = _closed_answer(decision, "null")
1322
+ elif decision.box_list:
1323
+ low, high = decision.box_list
1324
+ edges = ("top", "bottom", "left", "right")
1325
+ if (not isinstance(value, list) or not low <= len(value) <= high
1326
+ or not all(isinstance(box, Mapping) and set(box) == set(edges)
1327
+ and all(_is_number(box[edge]) and 0 <= box[edge] <= 1 for edge in edges)
1328
+ for box in value)):
1329
+ raise SchemaError(f"given {label} must be a list of {low} to {high} boxes: "
1330
+ "top, bottom, left and right from 0 to 1")
1331
+ closed = _closed_answer(decision, ", ".join(
1332
+ '{"bbox_2d": [' + ", ".join(str(round(box[edge] * 1000)) for edge in ("left", "top", "right", "bottom"))
1333
+ + "]}" for box in value) + "]")
1334
+ elif decision.box:
1335
+ edges = ("top", "bottom", "left", "right")
1336
+ if (not isinstance(value, Mapping) or set(value) != set(edges)
1337
+ or not all(_is_number(value[edge]) and 0 <= value[edge] <= 1 for edge in edges)):
1338
+ raise SchemaError(f"given {label} must be a box: top, bottom, left and right from 0 to 1")
1339
+ closed = _closed_answer(decision, ", ".join(str(round(value[edge] * 1000)) for edge in ("left", "top", "right", "bottom")))
1220
1340
  elif decision.text_type:
1221
1341
  if not isinstance(value, str):
1222
1342
  raise SchemaError(f"given {label} must be a string")
@@ -1274,11 +1394,12 @@ def _execute_dependency_decisions(
1274
1394
  rows_by_name = {}
1275
1395
  prompts_by_name = {}
1276
1396
  ancestors = {}
1277
- hidden = {d.name for d in decisions if d.effort_for is not None}
1397
+ hidden = {d.name for d in decisions if d.hidden}
1278
1398
  by_name = {d.name: d for d in decisions}
1279
1399
  given = given or {}
1280
- # A given or skipped field asks no effort question.
1281
- skip = set(skip) | {d.name for d in decisions if d.effort_for in given or d.effort_for in skip}
1400
+ # A given or skipped field asks no effort or presence question.
1401
+ skip = set(skip) | {d.name for d in decisions
1402
+ if d.hidden and (d.effort_for or d.presence_for) in set(given) | set(skip)}
1282
1403
 
1283
1404
  def confidence(name: str) -> float | None:
1284
1405
  """A dependency's confidence, for a "when" that tests it; None without probabilities."""
@@ -1335,6 +1456,13 @@ def _execute_dependency_decisions(
1335
1456
  if isinstance(decision, ArrayChoice):
1336
1457
  arrays.append(decision)
1337
1458
  continue
1459
+ if decision.presence_from is not None:
1460
+ if not rows_by_name[decision.presence_from]["value"]:
1461
+ # No match in the image: the list is empty, and no box is asked for.
1462
+ rows_by_name[decision.name], prompts_by_name[decision.name] = _given_row(
1463
+ client, context, decision, [], parent_prefixes.get(decision.name), values, image_count, None)
1464
+ continue
1465
+ decision = replace(decision, box_list=(1, decision.box_list[1]))
1338
1466
  if decision.effort_from is not None:
1339
1467
  budget = THINKING_EFFORTS[rows_by_name[decision.effort_from]["value"]]
1340
1468
  decision = replace(decision, thinking=budget is not None, thinking_budget=budget)
@@ -1670,7 +1798,8 @@ def _execute_batch_decisions(
1670
1798
  if decision.text_type:
1671
1799
  text_pending.append((index, decision, messages))
1672
1800
  continue
1673
- if decision.numeric_type is not None:
1801
+ # A box decodes beside the numbers, to its own grammar.
1802
+ if decision.numeric_type is not None or decision.box:
1674
1803
  numeric_pending.append((index, decision, messages))
1675
1804
  continue
1676
1805
  if decision.by_value:
@@ -1735,7 +1864,7 @@ def _execute_batch_decisions(
1735
1864
 
1736
1865
  open_pending = [(index, decision, messages, ready[decision_slots[index]])
1737
1866
  for index, decision, messages in numeric_pending + text_pending]
1738
- numeric_pending = [item for item in open_pending if item[1].numeric_type is not None]
1867
+ numeric_pending = [item for item in open_pending if item[1].numeric_type is not None or item[1].box]
1739
1868
  text_pending = [item for item in open_pending if item[1].text_type]
1740
1869
 
1741
1870
  # A layer's numbers and strings decode side by side in one request, and its choices are
@@ -1749,9 +1878,14 @@ def _execute_batch_decisions(
1749
1878
  text_seed = rng.randrange(2**31) if text_pending else 0
1750
1879
  fields = (
1751
1880
  [prompt for prompt, _ in number_items],
1752
- [numeric_pattern(d.numeric_type or "", numeric_max_digits, d.nullable) for _, d in number_items],
1753
- # Digits, sign, point and the closing brace; tokens hold one or more characters.
1754
- numeric_max_digits + 4,
1881
+ [box_list_pattern(*d.box_list) if d.box_list else BOX_PATTERN if d.box
1882
+ else numeric_pattern(d.numeric_type or "", numeric_max_digits, d.nullable)
1883
+ for _, d in number_items],
1884
+ # Digits, sign, point and the closing brace; tokens hold one or more characters. A box
1885
+ # needs its four coordinates, and a list of boxes each of its boxes.
1886
+ max([numeric_max_digits + 4]
1887
+ + [BOX_MAX_TOKENS for _, d in number_items if d.box]
1888
+ + [d.box_list[1] * BOX_ITEM_TOKENS + 4 for _, d in number_items if d.box_list]),
1755
1889
  # From {"name": the model writes the string's first token, quote included.
1756
1890
  [prompt + decision.answer_prefill for _, decision, _, prompt in text_pending],
1757
1891
  )
@@ -59,6 +59,14 @@ class Decision:
59
59
  # A score's ordered levels, lowest first: its choices are their labels, and its value is the
60
60
  # probability-weighted average of their indices. Empty for every other field.
61
61
  levels: tuple[str, ...] = ()
62
+ # A box: where something is in the image, as {"top", "bottom", "left", "right"} from 0 to 1.
63
+ box: bool = False
64
+ # An array of boxes: every match in the image, listed in one answer; (minItems, maxItems).
65
+ box_list: tuple[int, int] | None = None
66
+ # An array of boxes that may be empty asks first whether there is any match: presence_from on
67
+ # the list names that hidden question, presence_for on it names the list.
68
+ presence_from: str | None = None
69
+ presence_for: str | None = None
62
70
 
63
71
 
64
72
  @dataclass(frozen=True)
@@ -97,6 +105,9 @@ MAX_ARRAY_ITEMS = 50
97
105
  # context and arrays the model produces alike: "would another item improve the array" stopped empty
98
106
  # arrays early, and "does the context mention another" stopped produced ones.
99
107
  CONTINUE_QUESTION = "Should a new item be appended to the current array?"
108
+ # Asked before an array of boxes that may be empty: a model listing boxes lists some whether or
109
+ # not the image has the target, and answers this yes or no reliably.
110
+ PRESENCE_QUESTION = "Is there at least one match for the question below in the image?\n\nThe question:\n{question}"
100
111
  # The name the question is asked under, so its answer starts as a field's does.
101
112
  CONTINUE_FIELD = "append_item"
102
113
  # What opens the prompt of an item, or of each property of an object item.
@@ -126,7 +137,11 @@ EFFORT_QUESTION = (
126
137
  def _describe(decision: Decision) -> str:
127
138
  """A field's type, instructions and choices, as its own prompt states them."""
128
139
  or_null = " or null" if decision.nullable else ""
129
- if decision.text_type:
140
+ if decision.box_list:
141
+ kind = "list of bounding boxes"
142
+ elif decision.box:
143
+ kind = "bounding box"
144
+ elif decision.text_type:
130
145
  kind = "string" + or_null
131
146
  elif decision.numeric_type is not None:
132
147
  kind = decision.numeric_type + or_null
@@ -197,6 +212,11 @@ def _compile(schema: Mapping[str, Any]) -> list[Decision | ArrayField]:
197
212
  entries.extend(members)
198
213
  scope[name] = ("object", tuple(entry.decision.name for entry in members))
199
214
  order.append(name)
215
+ elif kind == "array" and _boxes(field):
216
+ # Every box is found in one answer, as the model lists them: one field, not an array's turns.
217
+ entries.append(_Entry(_box_list(name, field), field, None))
218
+ scope[name] = ("scalar", name)
219
+ order.append(name)
200
220
  elif kind == "array":
201
221
  scope[name] = ("array", name)
202
222
  order.append(_array(name, field))
@@ -349,14 +369,8 @@ def _object_entries(path: tuple[str, ...], field: Mapping[str, Any], label: str,
349
369
  return entries
350
370
 
351
371
 
352
- def _array(name: str, field: Mapping[str, Any]) -> ArrayField:
353
- """An array field, its items compiled and linked; its own depends_on and when come later."""
354
- question = _question(name, field, f'All the items of "{name}".')
355
- if "items" not in field:
356
- raise SchemaError(f"array {name!r} needs items")
357
- items = field["items"]
358
- if not isinstance(items, Mapping):
359
- raise SchemaError(f"items for {name!r} must be a schema object")
372
+ def _item_bounds(name: str, field: Mapping[str, Any]) -> tuple[int, int | None]:
373
+ """An array's minItems and maxItems, checked."""
360
374
  min_items = field.get("minItems", 0)
361
375
  if type(min_items) is not int or min_items < 0:
362
376
  raise SchemaError(f"minItems for {name!r} must be a non-negative integer")
@@ -367,6 +381,37 @@ def _array(name: str, field: Mapping[str, Any]) -> ArrayField:
367
381
  raise SchemaError(f"minItems for {name!r} is more than its maxItems")
368
382
  if min_items > MAX_ARRAY_ITEMS or (max_items or 0) > MAX_ARRAY_ITEMS:
369
383
  raise SchemaError(f"an array holds at most {MAX_ARRAY_ITEMS} items (on {name!r})")
384
+ return min_items, max_items
385
+
386
+
387
+ def _boxes(field: Mapping[str, Any]) -> bool:
388
+ """Whether an array's items are boxes."""
389
+ items = field.get("items")
390
+ return isinstance(items, Mapping) and items.get("type") == "box"
391
+
392
+
393
+ def _box_list(name: str, field: Mapping[str, Any]) -> Decision:
394
+ """An array of boxes: every match for the instructions in the image, from none to maxItems."""
395
+ if set(field["items"]) != {"type"}:
396
+ raise SchemaError(f"the items of {name!r} take only their type, box: "
397
+ "say what to find in the array's instructions")
398
+ if "continue_from" in field:
399
+ raise SchemaError(f"continue_from is not supported on an array of boxes ({name!r})")
400
+ min_items, max_items = _item_bounds(name, field)
401
+ question = _question(name, field, f'Every "{name}" in the image.')
402
+ return Decision(name, question, (), "Box", box=True,
403
+ box_list=(min_items, MAX_ARRAY_ITEMS if max_items is None else max_items))
404
+
405
+
406
+ def _array(name: str, field: Mapping[str, Any]) -> ArrayField:
407
+ """An array field, its items compiled and linked; its own depends_on and when come later."""
408
+ question = _question(name, field, f'All the items of "{name}".')
409
+ if "items" not in field:
410
+ raise SchemaError(f"array {name!r} needs items")
411
+ items = field["items"]
412
+ if not isinstance(items, Mapping):
413
+ raise SchemaError(f"items for {name!r} must be a schema object")
414
+ min_items, max_items = _item_bounds(name, field)
370
415
  label = f"{name}.items"
371
416
  kind = _kind(label, items)
372
417
  if kind == "array":
@@ -550,6 +595,16 @@ def _link(entries: Sequence[_Entry]) -> list[Decision]:
550
595
  compiled.append(replace(decision, depends_on=(dependencies or ()) + (effort,), effort_from=effort,
551
596
  when=when, dependency_groups=(groups_t or ()) + ((effort,),)))
552
597
  continue
598
+ if decision.box_list and decision.box_list[0] == 0:
599
+ present = f"{decision.name}.present"
600
+ # Asked with the list's own dependencies and condition, one layer before it; no: the list is empty.
601
+ compiled.append(Decision(present, PRESENCE_QUESTION.format(question=_describe(decision)),
602
+ (True, False), "Bool", depends_on=dependencies, thinking=False,
603
+ presence_for=decision.name, when=when, dependency_groups=groups_t,
604
+ path=(decision.name, "present")))
605
+ compiled.append(replace(decision, depends_on=(dependencies or ()) + (present,), presence_from=present,
606
+ when=when, dependency_groups=(groups_t or ()) + ((present,),)))
607
+ continue
553
608
  compiled.append(replace(decision, depends_on=dependencies, thinking=thinking, thinking_budget=budget,
554
609
  when=when, dependency_groups=groups_t))
555
610
  return compiled
@@ -576,6 +631,8 @@ def _scalar(name: str, label: str, field: Mapping[str, Any], limit: int | None =
576
631
  if len(field_type) != 2 or len(kinds) != 1 or not isinstance(kinds[0], str):
577
632
  raise SchemaError(f'type for {label!r} must be one type or [type, "null"]')
578
633
  field_type, nullable = kinds[0], True
634
+ if field_type == "box":
635
+ return _box(name, label, question, field, nullable)
579
636
  if "levels" in field:
580
637
  return _score(name, label, question, field, field_type, nullable)
581
638
  enum = field.get("enum")
@@ -682,6 +739,20 @@ def _scalar(name: str, label: str, field: Mapping[str, Any], limit: int | None =
682
739
  permutations=permutations, nullable=nullable, descriptions=descriptions)
683
740
 
684
741
 
742
+ def _box(name: str, label: str, question: str, field: Mapping[str, Any], nullable: bool) -> Decision:
743
+ """A box: where the instructions' target is in the image. There is always one; a boolean field
744
+ asks whether the target is there."""
745
+ if nullable:
746
+ raise SchemaError(f"a box ({label!r}) cannot be null: it always returns a box; "
747
+ "ask a boolean field whether the target is there")
748
+ for keyword in ("enum", "choices", "levels", "return_probabilities", "permutations"):
749
+ if keyword in field:
750
+ raise SchemaError(f"{keyword} is not supported on a box ({label!r})")
751
+ if "instructions" not in field and "description" not in field:
752
+ question = f'Locate "{label}" in the image.'
753
+ return Decision(name, question, (), "Box", box=True)
754
+
755
+
685
756
  def _score(name: str, label: str, question: str, field: Mapping[str, Any], field_type: Any,
686
757
  nullable: bool) -> Decision:
687
758
  """A score: a number from ordered "levels", [{"label": ..., "description": ...}, ...], lowest first.
@@ -786,6 +857,9 @@ def _condition(name: str, field: Mapping[str, Any], by_name: Mapping[str, Decisi
786
857
  if parent not in by_name:
787
858
  raise SchemaError(f"when for {name!r} names {parent!r}, which is not a field")
788
859
  source = by_name[parent]
860
+ if source.box:
861
+ raise SchemaError(f"when for {name!r} names {parent!r}, a box; a condition can test a box's presence "
862
+ "with a boolean field instead")
789
863
  if not isinstance(test, Mapping):
790
864
  test = {"in": test if isinstance(test, list) else [test]}
791
865
  if not test:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typellm
3
- Version: 0.7.0
3
+ Version: 0.7.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/
@@ -31,6 +31,9 @@ Dynamic: license-file
31
31
 
32
32
  ## Updates
33
33
  - 🚀 **[TypeLLM API](https://typellm.ai)** is live: typed outputs without serving a model, $5 free credit. [Try it](https://typellm.ai/dashboard/playground) · [Quick start](#typellm-api-cloud) · [Examples](https://typellm.ai/examples).
34
+ - **[2026/10/15]** An [array of boxes](#boxes) finds every match in an image: every dog, every line item.
35
+ - **[2026/10/15]** Added [boxes](#boxes): find something in an image, returned as its edges.
36
+ - **[2026/10/14]** Added [tools and human input](#tools-and-human-input): a workflow pauses for answers from your own tools or a person, and resumes with them.
34
37
  - **[2026/10/08]** [Conditional fields](#conditional-fields) can test how sure an answer is: `"when": {"category": {"confidence": {"gte": 0.8}}}`.
35
38
  - **[2026/10/05]** Added [objects and arrays](#objects-and-arrays): group typed properties into one value, or return a variable number of typed items.
36
39
  - **[2026/10/01]** Added [conditional fields](#conditional-fields): a field with `when` is answered only if its dependencies meet conditions.
@@ -118,6 +121,23 @@ client = TypeLLMClient(
118
121
  )
119
122
  ```
120
123
 
124
+ | Setting | Default | Purpose |
125
+ | --- | --- | --- |
126
+ | `base_url` | `"http://127.0.0.1:30000"` | SGLang server URL. |
127
+ | `model` | `None` | Model ID; discovered from the server when omitted. |
128
+ | `tokenizer` | `None` | Matching tokenizer ID or local path; when omitted, the server's paths, then its served model name. |
129
+ | `text_max_tokens` | `128` | Text generation budget per field. |
130
+ | `numeric_max_digits` | `32` | Digit limit for open numeric fields. |
131
+ | `temperature` | `0` | 0 gives each field its most likely answer; above 0 samples at that temperature. |
132
+ | `seed` | `None` | Client random-number seed. |
133
+ | `timeout` | `120.0` | HTTP request timeout in seconds. |
134
+
135
+ On your own server, no service limits apply: your server's context length,
136
+ memory and timeouts are the only bounds. An enum takes up to 26 values, and a
137
+ field that thinks without a `thinking_budget` may reason until the context is
138
+ full. `label_pool` customizes the internal candidate labels; leave it unset for
139
+ normal use. For debugging, `print_final_prompt=True` prints the prompts sent.
140
+
121
141
  ### Make a request
122
142
 
123
143
  With either client:
@@ -419,6 +439,44 @@ Each image can be a local file path, an http(s) URL, a `data:` URI, raw bytes,
419
439
  or a PIL image. Local files are read by the client, so the SGLang server does
420
440
  not need access to your filesystem.
421
441
 
442
+ ### Boxes
443
+
444
+ A `box` field finds something in the image and returns where it is: `top`,
445
+ `bottom`, `left` and `right`, each from 0 to 1 across the image, from its
446
+ top-left corner.
447
+
448
+ ```python
449
+ response = client.generate(
450
+ context="Read the receipt photo.",
451
+ images=["receipt.jpg"],
452
+ questions={"total_line": {"type": "box", "instructions": "The line that shows the total."}},
453
+ )
454
+ response.result # {"total_line": {"top": 0.652, "bottom": 0.689, "left": 0.287, "right": 0.855}}
455
+ ```
456
+
457
+ Multiply by the image's width and height for pixels. There is always a box: to
458
+ check that the target is there, ask a boolean field and give the box a `when` on
459
+ it, so the box is asked only when the target is there. A box cannot be null,
460
+ sit inside an object's or an array's items, or be tested by a `when`.
461
+
462
+ An array of boxes finds every match, from none to `maxItems` (at most 50):
463
+
464
+ ```python
465
+ response = client.generate(
466
+ context="Read the receipt photo.",
467
+ images=["receipt.jpg"],
468
+ questions={"items": {"type": "array", "items": {"type": "box"},
469
+ "instructions": "Every line item, each the whole line from its name to its price."}},
470
+ )
471
+ response.result # {"items": [{"top": 0.421, "bottom": 0.461, "left": 0.12, "right": 0.837},
472
+ # {"top": 0.449, "bottom": 0.493, "left": 0.12, "right": 0.834},
473
+ # {"top": 0.48, "bottom": 0.521, "left": 0.118, "right": 0.829}]}
474
+ ```
475
+
476
+ It returns `[]` when the image has no match. Its `items` take only
477
+ `{"type": "box"}`: say what to find in the array's `instructions`. `minItems`
478
+ and `maxItems` bound it; `continue_from` is not supported.
479
+
422
480
  ## Dependency-aware generation
423
481
 
424
482
  Fields run together by default, and each sees only the original context.
@@ -516,6 +574,40 @@ skipped field are skipped too, even when their other dependencies ran. A skipped
516
574
  field sends no requests; only its definition counts toward the call's input
517
575
  tokens.
518
576
 
577
+ ### Tools and human input
578
+
579
+ Some answers come from outside the model: an order's status from your database,
580
+ a result from your own tool, or a person's decision. On the hosted API, a field
581
+ with `"source": "user"` is answered by your code. The call answers every field
582
+ that does not wait on it, then stops with `status` `"needs_input"`, and
583
+ `client.resume()` goes on with your answers:
584
+
585
+ ```python
586
+ response = client.generate(context=email, questions={
587
+ "order_id": {"type": ["string", "null"], "instructions": "The order number in the email."},
588
+ "order_status": {"type": "string", "enum": ["shipped", "delivered", "lost"],
589
+ "source": "user", "when": {"order_id": {"ne": None}}},
590
+ "reply": {"type": "string", "depends_on": ["order_status"]},
591
+ })
592
+ while response.status == "needs_input": # response.needs_input names the fields
593
+ response = client.resume(response, {"order_status": lookup(response.result["order_id"])})
594
+ ```
595
+
596
+ On your own server, pass answers you already have in `given`, as `result` gives
597
+ them, and fields to leave out in `skipped`. The model is not asked those fields,
598
+ and the fields that depend on them see them as answers:
599
+
600
+ ```python
601
+ response = client.generate(context=email, questions=questions,
602
+ given={"order_status": "lost"}, skipped=["refund"])
603
+ ```
604
+
605
+ Passing a call's `result` and `skipped` back, with more fields, answers only the
606
+ new ones. A field with `return_probabilities` is given as `result` has it, with
607
+ its probabilities.
608
+
609
+ [Docs](https://typellm.ai/docs/workflows#tools-and-human-input) · [Cookbook](https://typellm.ai/cookbook/tools-and-human-input)
610
+
519
611
  ## Probabilities and sampling
520
612
 
521
613
  Set `return_probabilities` on individual enum or boolean fields:
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  tests/test_batching.py
5
+ tests/test_box.py
5
6
  tests/test_choices.py
6
7
  tests/test_concurrency.py
7
8
  tests/test_conditions.py
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