typellm 0.2.0__tar.gz → 0.2.1__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 (33) hide show
  1. {typellm-0.2.0/typellm.egg-info → typellm-0.2.1}/PKG-INFO +1 -1
  2. {typellm-0.2.0 → typellm-0.2.1}/pyproject.toml +1 -1
  3. {typellm-0.2.0 → typellm-0.2.1}/tests/test_batching.py +24 -0
  4. {typellm-0.2.0 → typellm-0.2.1}/tests/test_grammar_fields.py +15 -1
  5. {typellm-0.2.0 → typellm-0.2.1}/tests/test_typellm.py +6 -0
  6. {typellm-0.2.0 → typellm-0.2.1}/typellm/runtime.py +28 -34
  7. {typellm-0.2.0 → typellm-0.2.1}/typellm/sglang.py +111 -57
  8. {typellm-0.2.0 → typellm-0.2.1/typellm.egg-info}/PKG-INFO +1 -1
  9. {typellm-0.2.0 → typellm-0.2.1}/LICENSE +0 -0
  10. {typellm-0.2.0 → typellm-0.2.1}/README.md +0 -0
  11. {typellm-0.2.0 → typellm-0.2.1}/setup.cfg +0 -0
  12. {typellm-0.2.0 → typellm-0.2.1}/tests/test_concurrency.py +0 -0
  13. {typellm-0.2.0 → typellm-0.2.1}/tests/test_dependencies.py +0 -0
  14. {typellm-0.2.0 → typellm-0.2.1}/tests/test_images.py +0 -0
  15. {typellm-0.2.0 → typellm-0.2.1}/tests/test_nullable.py +0 -0
  16. {typellm-0.2.0 → typellm-0.2.1}/tests/test_permutations.py +0 -0
  17. {typellm-0.2.0 → typellm-0.2.1}/tests/test_prefill.py +0 -0
  18. {typellm-0.2.0 → typellm-0.2.1}/tests/test_probabilities.py +0 -0
  19. {typellm-0.2.0 → typellm-0.2.1}/tests/test_prompts.py +0 -0
  20. {typellm-0.2.0 → typellm-0.2.1}/tests/test_protocols.py +0 -0
  21. {typellm-0.2.0 → typellm-0.2.1}/tests/test_text.py +0 -0
  22. {typellm-0.2.0 → typellm-0.2.1}/typellm/__init__.py +0 -0
  23. {typellm-0.2.0 → typellm-0.2.1}/typellm/__main__.py +0 -0
  24. {typellm-0.2.0 → typellm-0.2.1}/typellm/benchmark.py +0 -0
  25. {typellm-0.2.0 → typellm-0.2.1}/typellm/cli.py +0 -0
  26. {typellm-0.2.0 → typellm-0.2.1}/typellm/images.py +0 -0
  27. {typellm-0.2.0 → typellm-0.2.1}/typellm/protocol.py +0 -0
  28. {typellm-0.2.0 → typellm-0.2.1}/typellm/schema.py +0 -0
  29. {typellm-0.2.0 → typellm-0.2.1}/typellm.egg-info/SOURCES.txt +0 -0
  30. {typellm-0.2.0 → typellm-0.2.1}/typellm.egg-info/dependency_links.txt +0 -0
  31. {typellm-0.2.0 → typellm-0.2.1}/typellm.egg-info/entry_points.txt +0 -0
  32. {typellm-0.2.0 → typellm-0.2.1}/typellm.egg-info/requires.txt +0 -0
  33. {typellm-0.2.0 → typellm-0.2.1}/typellm.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typellm
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Type-Safe Decoding for Autoregressive LLMs
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://typellm.ai/
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "typellm"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Type-Safe Decoding for Autoregressive LLMs"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -83,6 +83,30 @@ def width(payload):
83
83
  return 1 if isinstance(payload["text"], str) else len(payload["text"])
84
84
 
85
85
 
86
+ class PrefixWarmupTests(unittest.TestCase):
87
+ MIXED = {"a": {"type": "integer"}, "b": {"type": "number"}, "n": {"type": "string"},
88
+ "k": {"type": "string", "enum": ["x", "y"]}}
89
+
90
+ def test_the_context_is_warmed_before_any_batch_that_shares_it(self):
91
+ for thinking in (False, True):
92
+ with self.subTest(thinking=thinking):
93
+ client = TypeLLMClient(model="fake")
94
+ client.sglang = FakeServer(thinking=thinking)
95
+ client.generate(context="Receipt", questions=self.MIXED)
96
+ warm = client.sglang.requests("count")
97
+ self.assertEqual(len(warm), 1)
98
+ # First, before the thinking, number, text and scoring batches.
99
+ self.assertIs(client.sglang.payloads[0], warm[0])
100
+ self.assertIn("Receipt", warm[0]["text"])
101
+ self.assertNotIn('{"a":', warm[0]["text"])
102
+
103
+ def test_open_fields_alone_are_not_warmed(self):
104
+ client = TypeLLMClient(model="fake")
105
+ client.sglang = FakeServer()
106
+ client.generate(context="Receipt", questions={"a": {"type": "integer"}, "n": {"type": "string"}})
107
+ self.assertEqual(client.sglang.requests("count"), [])
108
+
109
+
86
110
  class BatchedThinkingTests(unittest.TestCase):
87
111
  QUESTIONS = {
88
112
  "flag": {"type": "boolean"},
@@ -5,7 +5,7 @@ import unittest
5
5
  from typellm import SGLangError, TypeLLMClient
6
6
  from typellm.runtime import _numeric_text_is_complete, numeric_pattern
7
7
 
8
- from tests.test_batching import FakeServer, width
8
+ from tests.test_batching import FakeServer, is_number_pattern, width
9
9
  from tests.test_nullable import NullServer
10
10
 
11
11
 
@@ -72,6 +72,20 @@ class GrammarNumberTests(unittest.TestCase):
72
72
  self.assertEqual(numbers["sampling_params"][0]["temperature"], 0)
73
73
  self.assertIn('{"a": 7}', client.last_prompts[0])
74
74
 
75
+ def test_numbers_and_strings_of_a_layer_share_one_request(self):
76
+ client = TypeLLMClient(model="fake")
77
+ client.sglang = FakeServer()
78
+ result = client.generate(context="Receipt", questions={
79
+ "a": {"type": "integer"}, "n": {"type": "string"},
80
+ "b": {"type": "number"}, "m": {"type": ["string", "null"]}})
81
+ self.assertEqual(result, {"a": 7, "n": "blue", "b": 7, "m": "blue"})
82
+ [request] = regex_requests(client.sglang)
83
+ self.assertEqual(width(request), 4)
84
+ patterns = [p["regex"] for p in request["sampling_params"]]
85
+ self.assertEqual([is_number_pattern(p) for p in patterns], [True, True, False, False])
86
+ self.assertTrue(all(t.endswith(k) for t, k in zip(request["text"], ('{"a":', '{"b":', '{"n":', '{"m":'))))
87
+ self.assertIn("null", patterns[3])
88
+
75
89
  def test_sampling_passes_the_temperature_and_no_truncation(self):
76
90
  client = TypeLLMClient(model="fake", mode="sample", temperature=0.7, seed=1)
77
91
  client.sglang = FakeServer()
@@ -60,6 +60,12 @@ class FakeSGLang:
60
60
  self.number_patterns.extend(patterns)
61
61
  return [next(self.numbers, " 7}") for _ in prefixes]
62
62
 
63
+ def generate_fields(self, number_prefixes, patterns, number_max_tokens, text_prefixes, max_lengths,
64
+ *, temperature=0, number_seed=0, text_seed=0, after_key=False, nullable=None):
65
+ numbers = self.generate_numbers(number_prefixes, patterns, number_max_tokens)
66
+ texts = self.generate_texts(text_prefixes, max_lengths) if text_prefixes else []
67
+ return numbers, texts
68
+
63
69
  def score_candidates(self, prefix, candidate_ids):
64
70
  self.prompts.append(prefix)
65
71
  self.candidate_sets.append(list(candidate_ids))
@@ -510,23 +510,10 @@ def numeric_pattern(numeric_type: str, max_digits: int, nullable: bool = False)
510
510
  return " ?" + value + r"\}?"
511
511
 
512
512
 
513
- def _generate_numeric_batch(
514
- client: SGLangClient,
515
- items: Sequence[tuple[str, Choice]],
516
- mode: str,
517
- temperature: float,
518
- rng: random.Random,
519
- max_digits: int,
513
+ def _parse_numbers(
514
+ items: Sequence[tuple[str, Choice]], texts: Sequence[str],
520
515
  ) -> list[tuple[int | float | None, str, str]]:
521
- """Decode prefilled numeric fields in one grammar-constrained batch request."""
522
- patterns = [numeric_pattern(d.numeric_type or "", max_digits, d.nullable) for _, d in items]
523
- texts = client.generate_numbers(
524
- [prompt for prompt, _ in items], patterns,
525
- # Digits, sign, point and the closing brace; tokens hold one or more characters.
526
- max_new_tokens=max_digits + 4,
527
- temperature=0 if mode == "argmax" else temperature,
528
- seed=rng.randrange(2**31),
529
- )
516
+ """Read the grammar-generated numbers: (value, closed prompt, value text)."""
530
517
  outputs = []
531
518
  for (prompt, decision), raw in zip(items, texts):
532
519
  text = raw.strip()
@@ -758,6 +745,14 @@ def _execute_batch_decisions(
758
745
  {label: token_id for label, (token_id, _) in label_tokens.items()},
759
746
  )
760
747
 
748
+ # Warm the exact common prefix once, before any batch that shares it: prompts
749
+ # prefilled in the same batch cannot reuse each other's cache, so thinking,
750
+ # number and text batches would each prefill the context once per prompt.
751
+ # SGLang then forks the cached state; TypeLLM never reads or moves KV tensors.
752
+ if finite_indexes and not incremental:
753
+ cache_meta = client.cache_prefix(shared_prefix)
754
+ LOG.info("batch_shared_prefix_cached_tokens=%s", cache_meta.get("cached_tokens"))
755
+
761
756
  ready = client.prepare_answer_prefixes(raw_prompts) if defer else raw_prompts
762
757
  prompts = [ready[decision_slots[index]] for index in finite_indexes]
763
758
  scoring_prompts = [ready[slot] + prefill for slot, prefill in zip(scoring_slots, scoring_prefills)]
@@ -771,37 +766,36 @@ def _execute_batch_decisions(
771
766
  numeric_pending = [item for item in open_pending if item[1].numeric_type is not None]
772
767
  text_pending = [item for item in open_pending if item[1].text_type]
773
768
 
774
- if numeric_pending:
775
- decoded = _generate_numeric_batch(
776
- client,
777
- [(prompt + decision.answer_prefill, decision) for _, decision, _, prompt in numeric_pending],
778
- mode, temperature, rng, numeric_max_digits,
779
- )
780
- for (index, decision, messages, prompt), (value, _completed, generated_text) in zip(numeric_pending, decoded):
781
- open_results[index] = (open_row(decision, value),
782
- complete(prompt, messages, _closed_answer(decision, generated_text)))
783
-
784
- if text_pending:
785
- values = client.generate_texts(
769
+ # A layer's numbers and strings decode side by side in one request.
770
+ if numeric_pending or text_pending:
771
+ number_items = [(prompt + decision.answer_prefill, decision)
772
+ for _, decision, _, prompt in numeric_pending]
773
+ number_seed = rng.randrange(2**31) if numeric_pending else 0
774
+ text_seed = rng.randrange(2**31) if text_pending else 0
775
+ raw_numbers, values = client.generate_fields(
776
+ [prompt for prompt, _ in number_items],
777
+ [numeric_pattern(d.numeric_type or "", numeric_max_digits, d.nullable) for _, d in number_items],
778
+ # Digits, sign, point and the closing brace; tokens hold one or more characters.
779
+ numeric_max_digits + 4,
786
780
  # From {"name": the model writes the string's first token, quote included.
787
781
  [prompt + decision.answer_prefill for _, decision, _, prompt in text_pending],
788
782
  [decision.max_length for _, decision, _, _ in text_pending],
789
783
  temperature=0 if mode == "argmax" else temperature,
790
- seed=rng.randrange(2**31),
784
+ number_seed=number_seed,
785
+ text_seed=text_seed,
791
786
  after_key=all(decision.answer_prefill for _, decision, _, _ in text_pending),
792
787
  # A nullable string writes null or its text in the same request.
793
788
  nullable=[decision.nullable for _, decision, _, _ in text_pending],
794
789
  )
790
+ for (index, decision, messages, prompt), (value, _completed, generated_text) in zip(
791
+ numeric_pending, _parse_numbers(number_items, raw_numbers)):
792
+ open_results[index] = (open_row(decision, value),
793
+ complete(prompt, messages, _closed_answer(decision, generated_text)))
795
794
  for (index, decision, messages, prompt), value in zip(text_pending, values):
796
795
  completed = complete(prompt, messages, _closed_answer(decision, json.dumps(value, ensure_ascii=False)))
797
796
  open_results[index] = (open_row(decision, value), completed)
798
797
 
799
- # Warm the exact common prefix once, then let SGLang fork the cached state
800
- # across the K batched prompts. TypeLLM never reads or moves KV tensors.
801
798
  if prompts:
802
- if not incremental:
803
- cache_meta = client.cache_prefix(shared_prefix)
804
- LOG.info("batch_shared_prefix_cached_tokens=%s", cache_meta.get("cached_tokens"))
805
799
  scored, elapsed = client.score_candidates_batch(scoring_prompts, scoring_ids)
806
800
  grouped_scores = []
807
801
  offset = 0
@@ -744,35 +744,26 @@ class SGLangClient:
744
744
  results.append((scores, meta if isinstance(meta, Mapping) else {}))
745
745
  return results, elapsed
746
746
 
747
- def generate_numbers(
748
- self,
749
- prefixes: Sequence[str],
750
- patterns: Sequence[str],
751
- max_new_tokens: int,
752
- *,
753
- temperature: float = 0,
754
- seed: int = 0,
755
- ) -> list[str]:
756
- """Generate one regex-constrained value per prompt in a native batch.
757
-
758
- SGLang masks every step to the pattern, so a number takes one request
759
- instead of one per token. Returns the raw generated text.
760
- """
761
- if len(prefixes) != len(patterns):
762
- raise ValueError("prefixes and patterns must have the same length")
763
- if not prefixes:
764
- return []
765
- params = [{"max_new_tokens": max_new_tokens, "temperature": temperature,
766
- "top_p": 1.0, "top_k": -1, "min_p": 0.0,
767
- "sampling_seed": seed + i, "regex": pattern}
768
- for i, pattern in enumerate(patterns)]
769
- response = self._generate({"text": list(prefixes), "sampling_params": params})
747
+ def _batch(self, prefixes: Sequence[str], params: Sequence[Mapping[str, Any]], what: str) -> list[Any]:
748
+ """One /generate request with one sampling-params dict per prompt."""
749
+ response = self._generate({"text": list(prefixes), "sampling_params": list(params)})
770
750
  if isinstance(response, Mapping) and len(prefixes) == 1:
771
751
  response = [response]
772
752
  if not isinstance(response, list) or len(response) != len(prefixes):
773
- raise SGLangError("Unexpected number batch response shape")
753
+ raise SGLangError(f"Unexpected {what} batch response shape")
754
+ return response
755
+
756
+ @staticmethod
757
+ def _number_params(patterns: Sequence[str], max_new_tokens: int, temperature: float, seed: int) -> list[dict]:
758
+ return [{"max_new_tokens": max_new_tokens, "temperature": temperature,
759
+ "top_p": 1.0, "top_k": -1, "min_p": 0.0,
760
+ "sampling_seed": seed + i, "regex": pattern}
761
+ for i, pattern in enumerate(patterns)]
762
+
763
+ @staticmethod
764
+ def _read_numbers(items: Sequence[Any]) -> list[str]:
774
765
  texts = []
775
- for item in response:
766
+ for item in items:
776
767
  meta = item.get("meta_info", {}) if isinstance(item, Mapping) else {}
777
768
  finish = meta.get("finish_reason", {}) if isinstance(meta, Mapping) else {}
778
769
  kind = finish.get("type") if isinstance(finish, Mapping) else finish
@@ -782,34 +773,10 @@ class SGLangClient:
782
773
  texts.append(text)
783
774
  return texts
784
775
 
785
- def generate_texts(
786
- self,
787
- prefixes: Sequence[str],
788
- max_lengths: Sequence[int | None],
789
- *,
790
- temperature: float = 0,
791
- seed: int = 0,
792
- after_key: bool = False,
793
- nullable: Sequence[bool] | None = None,
794
- ) -> list[str | None]:
795
- """Generate JSON strings in a native batch, then validate every value.
796
-
797
- With after_key, every prompt ends with '{"name":', and the model writes the
798
- opening quote, the characters and the closing '"}'. Writing the quote
799
- itself lets it start with a merged token such as ' "$', which keeps the
800
- first character. A max length then truncates: generation is capped near
801
- that many tokens and the string is cut to that many characters, as a
802
- length-bounded grammar would.
803
- """
804
- if len(prefixes) != len(max_lengths):
805
- raise ValueError("prefixes and max_lengths must have the same length")
806
- nullable = [False] * len(prefixes) if nullable is None else list(nullable)
807
- if len(nullable) != len(prefixes):
808
- raise ValueError("prefixes and nullable must have the same length")
776
+ def _text_params(self, max_lengths: Sequence[int | None], nullable: Sequence[bool],
777
+ after_key: bool, temperature: float, seed: int) -> list[dict]:
809
778
  if any(nullable) and not after_key:
810
779
  raise ValueError("nullable text needs the prefilled '{\"name\":' prompt")
811
- if not prefixes:
812
- return []
813
780
  params = []
814
781
  for limit, can_be_null in zip(max_lengths, nullable):
815
782
  budget = self.text_max_tokens
@@ -832,13 +799,13 @@ class SGLangClient:
832
799
  constraint = {"json_schema": json.dumps(schema)}
833
800
  params.append({"max_new_tokens": budget,
834
801
  "temperature": temperature, "sampling_seed": seed, **constraint})
835
- response = self._generate({"text": list(prefixes), "sampling_params": params})
836
- if isinstance(response, Mapping) and len(prefixes) == 1:
837
- response = [response]
838
- if not isinstance(response, list) or len(response) != len(prefixes):
839
- raise SGLangError("Unexpected text batch response shape")
802
+ return params
803
+
804
+ @staticmethod
805
+ def _read_texts(items: Sequence[Any], max_lengths: Sequence[int | None],
806
+ nullable: Sequence[bool], after_key: bool) -> list[str | None]:
840
807
  values: list[str | None] = []
841
- for item, limit, can_be_null in zip(response, max_lengths, nullable):
808
+ for item, limit, can_be_null in zip(items, max_lengths, nullable):
842
809
  if not isinstance(item, Mapping):
843
810
  raise SGLangError("Invalid text response")
844
811
  meta = item.get("meta_info", {})
@@ -874,6 +841,93 @@ class SGLangClient:
874
841
  values.append(value)
875
842
  return values
876
843
 
844
+ @staticmethod
845
+ def _check_lengths(nullable, prefixes, max_lengths):
846
+ if len(prefixes) != len(max_lengths):
847
+ raise ValueError("prefixes and max_lengths must have the same length")
848
+ nullable = [False] * len(prefixes) if nullable is None else list(nullable)
849
+ if len(nullable) != len(prefixes):
850
+ raise ValueError("prefixes and nullable must have the same length")
851
+ return nullable
852
+
853
+ def generate_numbers(
854
+ self,
855
+ prefixes: Sequence[str],
856
+ patterns: Sequence[str],
857
+ max_new_tokens: int,
858
+ *,
859
+ temperature: float = 0,
860
+ seed: int = 0,
861
+ ) -> list[str]:
862
+ """Generate one regex-constrained value per prompt in a native batch.
863
+
864
+ SGLang masks every step to the pattern, so a number takes one request
865
+ instead of one per token. Returns the raw generated text.
866
+ """
867
+ if len(prefixes) != len(patterns):
868
+ raise ValueError("prefixes and patterns must have the same length")
869
+ if not prefixes:
870
+ return []
871
+ params = self._number_params(patterns, max_new_tokens, temperature, seed)
872
+ return self._read_numbers(self._batch(prefixes, params, "number"))
873
+
874
+ def generate_texts(
875
+ self,
876
+ prefixes: Sequence[str],
877
+ max_lengths: Sequence[int | None],
878
+ *,
879
+ temperature: float = 0,
880
+ seed: int = 0,
881
+ after_key: bool = False,
882
+ nullable: Sequence[bool] | None = None,
883
+ ) -> list[str | None]:
884
+ """Generate JSON strings in a native batch, then validate every value.
885
+
886
+ With after_key, every prompt ends with '{"name":', and the model writes the
887
+ opening quote, the characters and the closing '"}'. Writing the quote
888
+ itself lets it start with a merged token such as ' "$', which keeps the
889
+ first character. A max length then truncates: generation is capped near
890
+ that many tokens and the string is cut to that many characters, as a
891
+ length-bounded grammar would.
892
+ """
893
+ nullable = self._check_lengths(nullable, prefixes, max_lengths)
894
+ if not prefixes:
895
+ return []
896
+ params = self._text_params(max_lengths, nullable, after_key, temperature, seed)
897
+ return self._read_texts(self._batch(prefixes, params, "text"), max_lengths, nullable, after_key)
898
+
899
+ def generate_fields(
900
+ self,
901
+ number_prefixes: Sequence[str],
902
+ patterns: Sequence[str],
903
+ number_max_tokens: int,
904
+ text_prefixes: Sequence[str],
905
+ max_lengths: Sequence[int | None],
906
+ *,
907
+ temperature: float = 0,
908
+ number_seed: int = 0,
909
+ text_seed: int = 0,
910
+ after_key: bool = False,
911
+ nullable: Sequence[bool] | None = None,
912
+ ) -> tuple[list[str], list[str | None]]:
913
+ """Numbers and strings of one layer in a single request, decoded side by side.
914
+
915
+ Returns what generate_numbers and generate_texts would for each part.
916
+ """
917
+ if len(number_prefixes) != len(patterns):
918
+ raise ValueError("prefixes and patterns must have the same length")
919
+ nullable = self._check_lengths(nullable, text_prefixes, max_lengths)
920
+ params = (self._number_params(patterns, number_max_tokens, temperature, number_seed)
921
+ + (self._text_params(max_lengths, nullable, after_key, temperature, text_seed)
922
+ if text_prefixes else []))
923
+ prefixes = list(number_prefixes) + list(text_prefixes)
924
+ if not prefixes:
925
+ return [], []
926
+ items = self._batch(prefixes, params, "field")
927
+ split = len(number_prefixes)
928
+ return (self._read_numbers(items[:split]),
929
+ self._read_texts(items[split:], max_lengths, nullable, after_key))
930
+
877
931
  def flush_cache(self) -> None:
878
932
  reply = self._request("/flush_cache", {}, allow_text=True)
879
933
  if isinstance(reply, str) and not reply.startswith("Cache flushed."):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typellm
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Type-Safe Decoding for Autoregressive LLMs
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://typellm.ai/
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