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.
- {typellm-0.7.0 → typellm-0.7.2}/PKG-INFO +93 -1
- {typellm-0.7.0 → typellm-0.7.2}/README.md +92 -0
- {typellm-0.7.0 → typellm-0.7.2}/pyproject.toml +1 -1
- typellm-0.7.2/tests/test_box.py +217 -0
- {typellm-0.7.0 → typellm-0.7.2}/typellm/runtime.py +149 -15
- {typellm-0.7.0 → typellm-0.7.2}/typellm/schema.py +83 -9
- {typellm-0.7.0 → typellm-0.7.2}/typellm.egg-info/PKG-INFO +93 -1
- {typellm-0.7.0 → typellm-0.7.2}/typellm.egg-info/SOURCES.txt +1 -0
- {typellm-0.7.0 → typellm-0.7.2}/LICENSE +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/setup.cfg +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_batching.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_choices.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_concurrency.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_conditions.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_dependencies.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_given.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_grammar_fields.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_images.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_nullable.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_permutations.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_prefill.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_probabilities.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_prompts.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_protocols.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_scores.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_structured.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_text.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_typellm.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/tests/test_value_choices.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/typellm/__init__.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/typellm/__main__.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/typellm/benchmark.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/typellm/cli.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/typellm/images.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/typellm/protocol.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/typellm/sglang.py +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/typellm.egg-info/dependency_links.txt +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/typellm.egg-info/entry_points.txt +0 -0
- {typellm-0.7.0 → typellm-0.7.2}/typellm.egg-info/requires.txt +0 -0
- {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.
|
|
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:
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
[
|
|
1753
|
-
|
|
1754
|
-
|
|
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.
|
|
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
|
|
353
|
-
"""An array
|
|
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.
|
|
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:
|
|
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
|
|
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
|