thinkless 0.2.0__py3-none-any.whl

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 (63) hide show
  1. thinkless/__init__.py +63 -0
  2. thinkless/__main__.py +3 -0
  3. thinkless/_hub.py +84 -0
  4. thinkless/_json.py +33 -0
  5. thinkless/_version.py +1 -0
  6. thinkless/bench/__init__.py +22 -0
  7. thinkless/bench/intents.py +406 -0
  8. thinkless/bench/intents_report.py +87 -0
  9. thinkless/bench/metrics.py +115 -0
  10. thinkless/bench/report.py +123 -0
  11. thinkless/bench/support.py +342 -0
  12. thinkless/cli/__init__.py +1 -0
  13. thinkless/cli/main.py +595 -0
  14. thinkless/confidence.py +62 -0
  15. thinkless/data/pricing.toml +56 -0
  16. thinkless/data/viewer.html +411 -0
  17. thinkless/decision.py +185 -0
  18. thinkless/demo/__init__.py +1 -0
  19. thinkless/demo/support/__init__.py +19 -0
  20. thinkless/demo/support/agent.py +379 -0
  21. thinkless/demo/support/data/calibration.jsonl +48 -0
  22. thinkless/demo/support/data/scenarios.jsonl +53 -0
  23. thinkless/demo/support/data/world.json +78 -0
  24. thinkless/demo/support/questions.py +118 -0
  25. thinkless/demo/support/stack.py +93 -0
  26. thinkless/demo/support/world.py +165 -0
  27. thinkless/engine.py +605 -0
  28. thinkless/errors.py +13 -0
  29. thinkless/llm/__init__.py +47 -0
  30. thinkless/llm/anthropic.py +126 -0
  31. thinkless/llm/base.py +82 -0
  32. thinkless/llm/factory.py +91 -0
  33. thinkless/llm/local.py +168 -0
  34. thinkless/llm/openai_compat.py +165 -0
  35. thinkless/llm/openrouter.py +78 -0
  36. thinkless/llm/scripted.py +75 -0
  37. thinkless/logs.py +68 -0
  38. thinkless/pricing.py +100 -0
  39. thinkless/providers/__init__.py +43 -0
  40. thinkless/providers/base.py +108 -0
  41. thinkless/providers/gliner.py +194 -0
  42. thinkless/providers/hf.py +161 -0
  43. thinkless/providers/laya.py +121 -0
  44. thinkless/providers/llm.py +305 -0
  45. thinkless/providers/rules.py +181 -0
  46. thinkless/providers/systemone.py +147 -0
  47. thinkless/providers/wire.py +108 -0
  48. thinkless/py.typed +0 -0
  49. thinkless/questions.py +246 -0
  50. thinkless/settings.py +93 -0
  51. thinkless/tracing/__init__.py +23 -0
  52. thinkless/tracing/console.py +169 -0
  53. thinkless/tracing/otel.py +139 -0
  54. thinkless/tracing/sinks.py +142 -0
  55. thinkless/tracing/span.py +98 -0
  56. thinkless/tracing/summary.py +143 -0
  57. thinkless/tracing/tracer.py +159 -0
  58. thinkless/tracing/viewer.py +87 -0
  59. thinkless-0.2.0.dist-info/METADATA +357 -0
  60. thinkless-0.2.0.dist-info/RECORD +63 -0
  61. thinkless-0.2.0.dist-info/WHEEL +4 -0
  62. thinkless-0.2.0.dist-info/entry_points.txt +2 -0
  63. thinkless-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,161 @@
1
+ """Any Hugging Face text-classification model as a decision provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from collections.abc import Mapping
8
+ from typing import Any, ClassVar
9
+
10
+ from .._hub import ensure_downloaded
11
+ from ..confidence import normalize_distribution
12
+ from ..decision import Answer, Plane
13
+ from ..logs import get_logger
14
+ from ..questions import Choice, Kind, Question, YesNo
15
+ from ..settings import resolve_device
16
+ from .base import DecisionProvider, ProviderResult, State, render_state
17
+
18
+ __all__ = ["HFClassifier"]
19
+
20
+ logger = get_logger("providers.hf")
21
+
22
+
23
+ class HFClassifier(DecisionProvider):
24
+ """A fine-tuned sequence classifier answering the questions it was trained for.
25
+
26
+ General decision models answer anything reasonably; a classifier trained
27
+ for one narrow question (prompt injection, toxicity, your own intent
28
+ taxonomy) usually answers that question much better, and fast. This
29
+ provider maps a classifier's labels onto ThinkLess questions, so it drops
30
+ into a cascade for those questions only.
31
+
32
+ Args:
33
+ model: Hugging Face model id or local path of a
34
+ ``text-classification`` model.
35
+ answers: Question name to label mapping. For a ``YesNo`` question,
36
+ ``{"yes": "<model label>"}`` names the label that means yes; every
37
+ other label counts as no. For a ``Choice`` question, map each
38
+ option to a model label, ``{"<option>": "<model label>", ...}``;
39
+ options without a label get probability 0.
40
+ name: Provider name in traces. Defaults to the model's last path part.
41
+ field: Read this field of a mapping state instead of the rendered
42
+ state, for example ``"message"``.
43
+ device: ``auto``, ``cpu``, ``cuda`` or ``mps``.
44
+ max_length: Token limit; longer inputs are truncated.
45
+ pipeline: A ready ``transformers`` pipeline (tests, custom loading).
46
+
47
+ Example:
48
+ >>> HFClassifier(
49
+ ... "protectai/deberta-v3-base-prompt-injection-v2",
50
+ ... answers={"injection": {"yes": "INJECTION"}},
51
+ ... field="message",
52
+ ... )
53
+
54
+ Requires ``transformers`` and ``torch`` (the ``local-llm`` extra covers both).
55
+ """
56
+
57
+ plane: ClassVar[Plane] = Plane.MODEL
58
+ kinds: ClassVar[frozenset[Kind]] = frozenset({Kind.CHOICE, Kind.YES_NO})
59
+
60
+ def __init__(
61
+ self,
62
+ model: str,
63
+ *,
64
+ answers: Mapping[str, Mapping[str, str]],
65
+ name: str | None = None,
66
+ field: str | None = None,
67
+ device: str = "auto",
68
+ max_length: int = 512,
69
+ pipeline: Any | None = None,
70
+ ) -> None:
71
+ if not answers:
72
+ raise ValueError("HFClassifier needs at least one question in answers")
73
+ self.model = model
74
+ self.name = name or model.rstrip("/").split("/")[-1]
75
+ self.answers = {key: dict(mapping) for key, mapping in answers.items()}
76
+ self._field = field
77
+ self._device_request = device
78
+ self._max_length = max_length
79
+ self._pipeline = pipeline
80
+ self._lock = threading.Lock()
81
+ self.device: str | None = None
82
+
83
+ def supports(self, question: Question) -> bool:
84
+ return question.key in self.answers and super().supports(question)
85
+
86
+ def warmup(self) -> None:
87
+ pipe = self._load()
88
+ with self._lock:
89
+ for _ in range(2):
90
+ pipe("A short message to warm up the classifier.", top_k=None, truncation=True)
91
+
92
+ def _load(self) -> Any:
93
+ if self._pipeline is not None:
94
+ return self._pipeline
95
+ with self._lock:
96
+ if self._pipeline is None:
97
+ try:
98
+ from transformers import pipeline
99
+ except ImportError as exc: # pragma: no cover - depends on the extra
100
+ raise ImportError(
101
+ 'HFClassifier needs transformers: pip install "thinkless[local-llm]"'
102
+ ) from exc
103
+ device = resolve_device(self._device_request)
104
+ started = time.perf_counter()
105
+ ensure_downloaded(self.model)
106
+ self._pipeline = pipeline(
107
+ "text-classification",
108
+ model=self.model,
109
+ device=device,
110
+ max_length=self._max_length,
111
+ )
112
+ self.device = device
113
+ logger.info(
114
+ "loaded %s on %s in %.1f s", self.model, device, time.perf_counter() - started
115
+ )
116
+ return self._pipeline
117
+
118
+ def _text(self, state: State) -> str:
119
+ if self._field is not None and isinstance(state, Mapping):
120
+ return str(state.get(self._field) or "")
121
+ return render_state(state)
122
+
123
+ def answer(self, state: State, questions: Mapping[str, Question]) -> ProviderResult:
124
+ pipe = self._load()
125
+ with self._lock:
126
+ raw = pipe(self._text(state), top_k=None, truncation=True)
127
+ # Pipelines return a list of {"label", "score"}, sometimes nested once.
128
+ items = raw[0] if raw and isinstance(raw[0], list) else raw
129
+ scores = {str(item["label"]): float(item["score"]) for item in items}
130
+ answers: dict[str, Answer | None] = {}
131
+ for key, question in questions.items():
132
+ mapping = self.answers.get(question.key, {})
133
+ answers[key] = self._answer(question, mapping, scores)
134
+ return ProviderResult(answers=answers, model=self.model, meta={"labels": scores})
135
+
136
+ @staticmethod
137
+ def _answer(
138
+ question: Question, mapping: Mapping[str, str], scores: Mapping[str, float]
139
+ ) -> Answer | None:
140
+ if isinstance(question, YesNo):
141
+ yes_label = mapping.get("yes")
142
+ if yes_label is None or yes_label not in scores:
143
+ return None
144
+ total = sum(scores.values()) or 1.0
145
+ p_yes = scores[yes_label] / total
146
+ return Answer(value=p_yes >= 0.5, probabilities={"yes": p_yes, "no": 1.0 - p_yes})
147
+ if isinstance(question, Choice):
148
+ mapped = {
149
+ option: scores.get(label, 0.0)
150
+ for option, label in mapping.items()
151
+ if option in question.options
152
+ }
153
+ if not any(mapped.values()):
154
+ return None
155
+ probabilities = normalize_distribution({o: mapped.get(o, 0.0) for o in question.labels})
156
+ best = max(probabilities, key=probabilities.__getitem__)
157
+ return Answer(value=best, probabilities=probabilities)
158
+ return None
159
+
160
+ def close(self) -> None:
161
+ self._pipeline = None
@@ -0,0 +1,121 @@
1
+ """Laya: an open-weight System One decision model that runs locally."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from collections.abc import Mapping
8
+ from typing import Any, ClassVar
9
+
10
+ from .._hub import ensure_downloaded
11
+ from ..decision import Plane, Usage
12
+ from ..logs import get_logger
13
+ from ..questions import Kind, Question
14
+ from ..settings import resolve_device
15
+ from .base import DecisionProvider, ProviderResult, State
16
+ from .wire import from_wire, to_wire
17
+
18
+ __all__ = ["Laya"]
19
+
20
+ logger = get_logger("providers.laya")
21
+
22
+
23
+ class Laya(DecisionProvider):
24
+ """Answers choice, score and yes/no questions with Laya.
25
+
26
+ Laya is a non-autoregressive encoder (about 421M parameters for the
27
+ English checkpoint) that answers every question of a call in one forward
28
+ pass. Measured on an RTX 5060 laptop GPU: about 30 ms for three questions;
29
+ about 650 ms on CPU.
30
+
31
+ Args:
32
+ model: Hugging Face repo of the checkpoint. ``convaiinnovations/laya``
33
+ is the English model; see the Laya project for the multilingual
34
+ and fine-tuned checkpoints.
35
+ device: ``auto``, ``cpu``, ``cuda`` or ``mps``.
36
+ name: Provider name in traces.
37
+ max_len: Token budget for the state. Longer inputs are truncated by
38
+ the model.
39
+
40
+ Requires the ``laya`` extra. Weights download on first use.
41
+ """
42
+
43
+ plane: ClassVar[Plane] = Plane.MODEL
44
+ kinds: ClassVar[frozenset[Kind]] = frozenset({Kind.CHOICE, Kind.SCORE, Kind.YES_NO})
45
+
46
+ def __init__(
47
+ self,
48
+ model: str = "convaiinnovations/laya",
49
+ *,
50
+ device: str = "auto",
51
+ name: str = "laya",
52
+ max_len: int | None = None,
53
+ ) -> None:
54
+ self.name = name
55
+ self.model = model
56
+ self._device_request = device
57
+ self._max_len = max_len
58
+ self._agent: Any = None
59
+ self._lock = threading.Lock()
60
+ self.device: str | None = None
61
+
62
+ def warmup(self) -> None:
63
+ """Load the weights and run one tiny inference, so the first request is not slow."""
64
+ agent = self._load()
65
+ with self._lock:
66
+ agent.predict(
67
+ "Hello, I placed order 4471 last Tuesday and the tracking page has not changed since. "
68
+ "Could you tell me when it will arrive, or refund it if it is lost?",
69
+ {
70
+ "a": {
71
+ "type": "choice",
72
+ "instructions": "Topic?",
73
+ "criteria": {f"t{i}": None for i in range(7)},
74
+ },
75
+ "b": {
76
+ "type": "score",
77
+ "instructions": "Urgency?",
78
+ "criteria": ["low", "medium", "high"],
79
+ },
80
+ "c": {"type": "noul", "instructions": "Is this a test?"},
81
+ },
82
+ )
83
+
84
+ def _load(self) -> Any:
85
+ if self._agent is not None:
86
+ return self._agent
87
+ with self._lock:
88
+ if self._agent is None:
89
+ try:
90
+ import laya
91
+ except ImportError as exc: # pragma: no cover - depends on the extra
92
+ raise ImportError(
93
+ 'The Laya provider needs the laya extra: pip install "thinkless[laya]"'
94
+ ) from exc
95
+ device = resolve_device(self._device_request)
96
+ started = time.perf_counter()
97
+ ensure_downloaded(self.model)
98
+ self._agent = laya.load(self.model, device=device)
99
+ self.device = device
100
+ logger.info(
101
+ "loaded %s on %s in %.1f s", self.model, device, time.perf_counter() - started
102
+ )
103
+ return self._agent
104
+
105
+ def answer(self, state: State, questions: Mapping[str, Question]) -> ProviderResult:
106
+ agent = self._load()
107
+ kwargs: dict[str, Any] = {}
108
+ if self._max_len is not None:
109
+ kwargs["max_len"] = self._max_len
110
+ with self._lock:
111
+ result = agent.predict(state, to_wire(questions), **kwargs)
112
+ usage = result.get("usage") or {}
113
+ return ProviderResult(
114
+ answers=from_wire(result.get("answers") or {}, questions),
115
+ model=result.get("model") or self.model,
116
+ usage=Usage(input_tokens=int(usage.get("input_tokens") or 0)),
117
+ meta={"device": self.device, "checkpoint": self.model},
118
+ )
119
+
120
+ def close(self) -> None:
121
+ self._agent = None
@@ -0,0 +1,305 @@
1
+ """A generative model as a decision provider.
2
+
3
+ This is the usual way agents make decisions today: describe the options in a
4
+ prompt and parse the reply. ThinkLess uses it as the last step of a cascade and
5
+ as the whole decision plane in LLM-only baselines, so benchmarks compare the
6
+ same questions answered two ways.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from collections.abc import Mapping
14
+ from typing import Any, ClassVar
15
+
16
+ from ..decision import Answer, Decision, Plane
17
+ from ..llm.base import LLM
18
+ from ..logs import get_logger
19
+ from ..questions import Choice, Extract, Kind, Question, Score, YesNo
20
+ from .base import DecisionProvider, ProviderResult, State, render_state
21
+
22
+ __all__ = ["LLMDecider", "build_prompt", "parse_reply"]
23
+
24
+ logger = get_logger("providers.llm")
25
+
26
+ # Stop reasons that mean the reply hit the token limit, across backends.
27
+ TRUNCATED = frozenset({"length", "max_tokens"})
28
+
29
+ SYSTEM_PROMPT = (
30
+ "You answer typed questions about an input. Follow the allowed answers exactly. "
31
+ "Reply with a single JSON object and nothing else."
32
+ )
33
+
34
+ _YES = {"yes", "true", "y", "1"}
35
+ _NO = {"no", "false", "n", "0"}
36
+
37
+
38
+ def _describe(key: str, question: Question, number: int) -> tuple[str, str]:
39
+ """Prompt lines for one question and its JSON placeholder."""
40
+ lines = []
41
+ if isinstance(question, Choice):
42
+ lines.append(f'{number}. "{key}": {question.instructions}')
43
+ lines.append(" Answer with exactly one of these labels:")
44
+ for label, description in question.options.items():
45
+ lines.append(f" - {label}" + (f": {description}" if description else ""))
46
+ placeholder = '"<label>"'
47
+ elif isinstance(question, Score):
48
+ lines.append(f'{number}. "{key}": {question.instructions}')
49
+ lines.append(
50
+ " Answer with one level, from lowest to highest: " + ", ".join(question.levels)
51
+ )
52
+ placeholder = '"<level>"'
53
+ elif isinstance(question, YesNo):
54
+ lines.append(f'{number}. "{key}": {question.instructions}')
55
+ if question.yes_means:
56
+ lines.append(f" Yes means: {question.yes_means}")
57
+ if question.no_means:
58
+ lines.append(f" No means: {question.no_means}")
59
+ lines.append(" Answer true or false.")
60
+ placeholder = "<true or false>"
61
+ elif isinstance(question, Extract):
62
+ lines.append(f'{number}. "{key}": {question.instructions}')
63
+ for name, description in question.fields.items():
64
+ lines.append(f" - {name}: {description}")
65
+ lines.append(" Copy values from the input. Use null for a field that is not present.")
66
+ placeholder = (
67
+ "{" + ", ".join(f'"{name}": <string or null>' for name in question.fields) + "}"
68
+ )
69
+ else: # pragma: no cover - guarded by kinds
70
+ raise TypeError(type(question).__name__)
71
+ return "\n".join(lines), f'"{key}": {placeholder}'
72
+
73
+
74
+ def _settled_value(question: Question, decision: Decision) -> str:
75
+ if isinstance(question, YesNo):
76
+ return "yes" if decision.value else "no"
77
+ if isinstance(question, Score):
78
+ return str(decision.level)
79
+ if isinstance(question, Extract):
80
+ return json.dumps(decision.value, ensure_ascii=False)
81
+ return str(decision.value)
82
+
83
+
84
+ def build_prompt(
85
+ state: State,
86
+ questions: Mapping[str, Question],
87
+ context: Mapping[str, tuple[Question, Decision]] | None = None,
88
+ ) -> str:
89
+ """The user prompt for a batch of questions.
90
+
91
+ ``context`` lists decisions already settled for the same input by other
92
+ providers. They are shown as established facts so the model reads each
93
+ open question with the same information it would have had in one batch.
94
+ """
95
+ blocks = []
96
+ shapes = []
97
+ for number, (key, question) in enumerate(questions.items(), start=1):
98
+ block, shape = _describe(key, question, number)
99
+ blocks.append(block)
100
+ shapes.append(shape)
101
+ known = ""
102
+ if context:
103
+ lines = [
104
+ f'- "{key}" ({question.instructions}): {_settled_value(question, decision)}'
105
+ for key, (question, decision) in context.items()
106
+ ]
107
+ known = "Already established by other checks:\n" + "\n".join(lines) + "\n\n"
108
+ return (
109
+ "Input:\n<<<\n"
110
+ + render_state(state)
111
+ + "\n>>>\n\n"
112
+ + known
113
+ + "Questions:\n"
114
+ + "\n".join(blocks)
115
+ + "\n\nReply with JSON in this shape:\n{"
116
+ + ", ".join(shapes)
117
+ + "}"
118
+ )
119
+
120
+
121
+ def _json_object(text: str) -> dict[str, Any] | None:
122
+ """Find the first top-level JSON object in a reply."""
123
+ text = re.sub(r"```(?:json)?", "", text)
124
+ start = text.find("{")
125
+ while start != -1:
126
+ depth = 0
127
+ in_string = False
128
+ escaped = False
129
+ for index in range(start, len(text)):
130
+ char = text[index]
131
+ if in_string:
132
+ if escaped:
133
+ escaped = False
134
+ elif char == "\\":
135
+ escaped = True
136
+ elif char == '"':
137
+ in_string = False
138
+ elif char == '"':
139
+ in_string = True
140
+ elif char == "{":
141
+ depth += 1
142
+ elif char == "}":
143
+ depth -= 1
144
+ if depth == 0:
145
+ candidate = text[start : index + 1]
146
+ try:
147
+ parsed = json.loads(candidate)
148
+ except json.JSONDecodeError:
149
+ try:
150
+ parsed = json.loads(re.sub(r",\s*([}\]])", r"\1", candidate))
151
+ except json.JSONDecodeError:
152
+ break
153
+ return parsed if isinstance(parsed, dict) else None
154
+ start = text.find("{", start + 1)
155
+ return None
156
+
157
+
158
+ def _normalize(text: str) -> str:
159
+ return re.sub(r"[\s\-]+", "_", text.strip().lower())
160
+
161
+
162
+ def _coerce(question: Question, value: Any) -> Answer | None:
163
+ if value is None:
164
+ return None
165
+ if isinstance(question, Choice):
166
+ wanted = _normalize(str(value))
167
+ for label in question.options:
168
+ if _normalize(label) == wanted:
169
+ return Answer(value=label)
170
+ return None
171
+ if isinstance(question, Score):
172
+ levels = list(question.levels)
173
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
174
+ index = round(float(value))
175
+ return (
176
+ Answer(value=float(index), raw={"level": levels[index]})
177
+ if 0 <= index < len(levels)
178
+ else None
179
+ )
180
+ wanted = _normalize(str(value))
181
+ for index, level in enumerate(levels):
182
+ if _normalize(level) == wanted:
183
+ return Answer(value=float(index), raw={"level": level})
184
+ return None
185
+ if isinstance(question, YesNo):
186
+ if isinstance(value, bool):
187
+ return Answer(value=value)
188
+ word = str(value).strip().lower()
189
+ if word in _YES:
190
+ return Answer(value=True)
191
+ if word in _NO:
192
+ return Answer(value=False)
193
+ return None
194
+ if isinstance(question, Extract):
195
+ if not isinstance(value, Mapping):
196
+ return None
197
+ values = {}
198
+ for name in question.fields:
199
+ item = value.get(name)
200
+ values[name] = None if item in (None, "", "null") else str(item)
201
+ return Answer(value=values)
202
+ return None
203
+
204
+
205
+ def parse_reply(text: str, questions: Mapping[str, Question]) -> dict[str, Answer | None]:
206
+ """Map a model reply onto the questions. Invalid answers become ``None``."""
207
+ payload = _json_object(text) or {}
208
+ lowered = {str(k).lower(): v for k, v in payload.items()}
209
+ return {
210
+ key: _coerce(q, payload.get(key, lowered.get(key.lower()))) for key, q in questions.items()
211
+ }
212
+
213
+
214
+ class LLMDecider(DecisionProvider):
215
+ """Answers any question kind by prompting a generative model.
216
+
217
+ Answers carry no probabilities, so the engine treats them as uncalibrated:
218
+ by default they are accepted as the final word of a cascade (see
219
+ ``Engine(trust_uncalibrated=...)``).
220
+
221
+ Args:
222
+ llm: The model to prompt.
223
+ name: Provider name. Defaults to ``llm``.
224
+ max_tokens: Reply budget per batch, plus 48 tokens per question.
225
+ retry_on_truncation: When a reply is cut off at the token limit and
226
+ answers are missing, retry once with three times the budget.
227
+ Reasoning models are the usual cause: hidden reasoning tokens
228
+ count against the limit. The retry is recorded in the trace.
229
+ """
230
+
231
+ plane: ClassVar[Plane] = Plane.LLM
232
+ kinds: ClassVar[frozenset[Kind]] = frozenset(Kind)
233
+ calibrated: ClassVar[bool] = False
234
+ accepts_context: ClassVar[bool] = True
235
+
236
+ def __init__(
237
+ self,
238
+ llm: LLM,
239
+ *,
240
+ name: str = "llm",
241
+ max_tokens: int = 256,
242
+ retry_on_truncation: bool = True,
243
+ ) -> None:
244
+ self.llm = llm
245
+ self.name = name
246
+ self.price_key = llm.provider
247
+ self._max_tokens = max_tokens
248
+ self._retry_on_truncation = retry_on_truncation
249
+
250
+ def warmup(self) -> None:
251
+ self.llm.warmup()
252
+
253
+ def answer(
254
+ self,
255
+ state: State,
256
+ questions: Mapping[str, Question],
257
+ *,
258
+ context: Mapping[str, tuple[Question, Decision]] | None = None,
259
+ ) -> ProviderResult:
260
+ prompt = build_prompt(state, questions, context)
261
+ budget = self._max_tokens + 48 * len(questions)
262
+ messages = [{"role": "user", "content": prompt}]
263
+ completion = self.llm.complete(
264
+ messages, system=SYSTEM_PROMPT, max_tokens=budget, json_mode=True
265
+ )
266
+ answers = parse_reply(completion.text, questions)
267
+ invalid = [key for key, answer in answers.items() if answer is None]
268
+ usage = completion.usage
269
+ cost = completion.cost_usd
270
+ truncated = completion.stop_reason in TRUNCATED and bool(invalid)
271
+ retried = False
272
+ if truncated:
273
+ logger.warning(
274
+ "%s stopped at the %d token limit with %d of %d answers missing; if the model "
275
+ "reasons, lower its reasoning effort (for example --reasoning off)",
276
+ completion.model,
277
+ budget,
278
+ len(invalid),
279
+ len(questions),
280
+ )
281
+ if self._retry_on_truncation:
282
+ retried = True
283
+ completion = self.llm.complete(
284
+ messages, system=SYSTEM_PROMPT, max_tokens=budget * 3, json_mode=True
285
+ )
286
+ answers = parse_reply(completion.text, questions)
287
+ invalid = [key for key, answer in answers.items() if answer is None]
288
+ usage = usage + completion.usage
289
+ if cost is not None and completion.cost_usd is not None:
290
+ cost += completion.cost_usd
291
+ else:
292
+ cost = None
293
+ return ProviderResult(
294
+ answers=answers,
295
+ model=completion.model,
296
+ usage=usage,
297
+ cost_usd=cost,
298
+ meta={
299
+ "invalid": invalid,
300
+ "stop_reason": completion.stop_reason,
301
+ "truncated": truncated,
302
+ "retried": retried,
303
+ },
304
+ content={"system": SYSTEM_PROMPT, "prompt": prompt, "completion": completion.text},
305
+ )