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.
- thinkless/__init__.py +63 -0
- thinkless/__main__.py +3 -0
- thinkless/_hub.py +84 -0
- thinkless/_json.py +33 -0
- thinkless/_version.py +1 -0
- thinkless/bench/__init__.py +22 -0
- thinkless/bench/intents.py +406 -0
- thinkless/bench/intents_report.py +87 -0
- thinkless/bench/metrics.py +115 -0
- thinkless/bench/report.py +123 -0
- thinkless/bench/support.py +342 -0
- thinkless/cli/__init__.py +1 -0
- thinkless/cli/main.py +595 -0
- thinkless/confidence.py +62 -0
- thinkless/data/pricing.toml +56 -0
- thinkless/data/viewer.html +411 -0
- thinkless/decision.py +185 -0
- thinkless/demo/__init__.py +1 -0
- thinkless/demo/support/__init__.py +19 -0
- thinkless/demo/support/agent.py +379 -0
- thinkless/demo/support/data/calibration.jsonl +48 -0
- thinkless/demo/support/data/scenarios.jsonl +53 -0
- thinkless/demo/support/data/world.json +78 -0
- thinkless/demo/support/questions.py +118 -0
- thinkless/demo/support/stack.py +93 -0
- thinkless/demo/support/world.py +165 -0
- thinkless/engine.py +605 -0
- thinkless/errors.py +13 -0
- thinkless/llm/__init__.py +47 -0
- thinkless/llm/anthropic.py +126 -0
- thinkless/llm/base.py +82 -0
- thinkless/llm/factory.py +91 -0
- thinkless/llm/local.py +168 -0
- thinkless/llm/openai_compat.py +165 -0
- thinkless/llm/openrouter.py +78 -0
- thinkless/llm/scripted.py +75 -0
- thinkless/logs.py +68 -0
- thinkless/pricing.py +100 -0
- thinkless/providers/__init__.py +43 -0
- thinkless/providers/base.py +108 -0
- thinkless/providers/gliner.py +194 -0
- thinkless/providers/hf.py +161 -0
- thinkless/providers/laya.py +121 -0
- thinkless/providers/llm.py +305 -0
- thinkless/providers/rules.py +181 -0
- thinkless/providers/systemone.py +147 -0
- thinkless/providers/wire.py +108 -0
- thinkless/py.typed +0 -0
- thinkless/questions.py +246 -0
- thinkless/settings.py +93 -0
- thinkless/tracing/__init__.py +23 -0
- thinkless/tracing/console.py +169 -0
- thinkless/tracing/otel.py +139 -0
- thinkless/tracing/sinks.py +142 -0
- thinkless/tracing/span.py +98 -0
- thinkless/tracing/summary.py +143 -0
- thinkless/tracing/tracer.py +159 -0
- thinkless/tracing/viewer.py +87 -0
- thinkless-0.2.0.dist-info/METADATA +357 -0
- thinkless-0.2.0.dist-info/RECORD +63 -0
- thinkless-0.2.0.dist-info/WHEEL +4 -0
- thinkless-0.2.0.dist-info/entry_points.txt +2 -0
- thinkless-0.2.0.dist-info/licenses/LICENSE +202 -0
thinkless/engine.py
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
"""The engine: routes questions through a cascade of providers and traces everything."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
from .confidence import from_distribution, from_yes_probability
|
|
14
|
+
from .decision import Answer, Attempt, Decision, Plane, Status
|
|
15
|
+
from .errors import ConfigurationError
|
|
16
|
+
from .llm.base import LLM, Completion, Message, as_messages
|
|
17
|
+
from .logs import get_logger
|
|
18
|
+
from .pricing import PriceTable, default_prices
|
|
19
|
+
from .providers.base import DecisionProvider, ProviderResult, State
|
|
20
|
+
from .questions import Extract, Kind, Question, Score
|
|
21
|
+
from .tracing.span import Span
|
|
22
|
+
from .tracing.summary import TraceSummary, summarize
|
|
23
|
+
from .tracing.tracer import Tracer
|
|
24
|
+
|
|
25
|
+
__all__ = ["Engine", "Run"]
|
|
26
|
+
|
|
27
|
+
logger = get_logger("engine")
|
|
28
|
+
|
|
29
|
+
WARMUP_STATE = {
|
|
30
|
+
"subject": "Question about my order",
|
|
31
|
+
"message": "Hello, I placed order 4471 last Tuesday and the tracking page has not changed "
|
|
32
|
+
"since. Could you tell me when it will arrive, or refund it if it is lost? Thanks.",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _RunCollector:
|
|
37
|
+
"""Sink that keeps the spans of one trace while a run is open."""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self.trace_id: str | None = None
|
|
41
|
+
self.spans: list[dict[str, Any]] = []
|
|
42
|
+
self._lock = threading.Lock()
|
|
43
|
+
|
|
44
|
+
def on_end(self, span: Span) -> None:
|
|
45
|
+
if span.trace_id == self.trace_id:
|
|
46
|
+
with self._lock:
|
|
47
|
+
self.spans.append(span.to_dict())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Run:
|
|
51
|
+
"""Handle for a traced run, returned by :meth:`Engine.run`."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, span: Span, collector: _RunCollector) -> None:
|
|
54
|
+
self.span = span
|
|
55
|
+
self._collector = collector
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def trace_id(self) -> str:
|
|
59
|
+
return self.span.trace_id
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def spans(self) -> list[dict[str, Any]]:
|
|
63
|
+
return list(self._collector.spans)
|
|
64
|
+
|
|
65
|
+
def set(self, **attributes: Any) -> None:
|
|
66
|
+
"""Attach attributes to the run's root span (outcome, labels, ids)."""
|
|
67
|
+
self.span.set(**attributes)
|
|
68
|
+
|
|
69
|
+
def summary(self) -> TraceSummary:
|
|
70
|
+
"""Roll-up of the run. Complete once the ``with`` block has exited."""
|
|
71
|
+
return summarize(self.spans)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Engine:
|
|
75
|
+
"""Answers typed questions with the cheapest provider that is confident enough.
|
|
76
|
+
|
|
77
|
+
Providers are tried in the order given. Each provider receives, in one
|
|
78
|
+
call, every still-open question it supports. An answer whose normalized
|
|
79
|
+
confidence meets the question's threshold is accepted; the rest move on to
|
|
80
|
+
the next provider. When every provider has been tried, questions that are
|
|
81
|
+
still open come back ``uncertain`` with the best answer seen, or
|
|
82
|
+
``abstained`` if nobody answered.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
providers: The decision cascade, cheapest first. A typical order is
|
|
86
|
+
rules, then small local models, then a hosted decision model,
|
|
87
|
+
then an LLM.
|
|
88
|
+
llm: The reasoning plane, used by :meth:`generate`.
|
|
89
|
+
threshold: Default minimum confidence to accept an answer.
|
|
90
|
+
thresholds: Overrides keyed by question name, or by
|
|
91
|
+
``"<question>@<provider>"`` for a threshold that applies to one
|
|
92
|
+
provider only (providers calibrate differently, so the same
|
|
93
|
+
question often needs a different bar per model). Resolution
|
|
94
|
+
order: ``question@provider``, then ``Question(threshold=...)``,
|
|
95
|
+
then ``thresholds[question]``, then ``threshold``.
|
|
96
|
+
trust_uncalibrated: Accept answers from providers without calibrated
|
|
97
|
+
probabilities (a prompted LLM) as final. Set it to ``False`` to
|
|
98
|
+
have such answers come back ``uncertain`` instead.
|
|
99
|
+
tracer: Where spans go. Defaults to a tracer with no sinks.
|
|
100
|
+
prices: Price table for cost estimates. Defaults to the bundled table,
|
|
101
|
+
or ``$THINKLESS_PRICING`` when set.
|
|
102
|
+
on_error: ``continue`` logs a failing provider and moves down the
|
|
103
|
+
cascade; ``raise`` propagates the exception.
|
|
104
|
+
escalation_context: Tell providers that accept it (the LLM decider)
|
|
105
|
+
which questions of the same batch are already settled, and how.
|
|
106
|
+
An escalated question otherwise reaches the LLM stripped of its
|
|
107
|
+
siblings, and on the support benchmark that changed answers: asked
|
|
108
|
+
alone whether a request for a human is an injection, one model
|
|
109
|
+
said yes to 5 of 10 such messages. The cost is one short line per
|
|
110
|
+
settled question.
|
|
111
|
+
|
|
112
|
+
Example:
|
|
113
|
+
>>> engine = Engine([Rules(), GLiNER(), Laya(), LLMDecider(llm)], llm=llm)
|
|
114
|
+
>>> intent = engine.decide(ticket, Choice("What does the customer want?", options=[...]))
|
|
115
|
+
>>> if intent.is_("refund"):
|
|
116
|
+
... ...
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
providers: Sequence[DecisionProvider] = (),
|
|
122
|
+
llm: LLM | None = None,
|
|
123
|
+
*,
|
|
124
|
+
threshold: float = 0.8,
|
|
125
|
+
thresholds: Mapping[str, float] | None = None,
|
|
126
|
+
trust_uncalibrated: bool = True,
|
|
127
|
+
tracer: Tracer | None = None,
|
|
128
|
+
prices: PriceTable | None = None,
|
|
129
|
+
on_error: Literal["continue", "raise"] = "continue",
|
|
130
|
+
escalation_context: bool = True,
|
|
131
|
+
) -> None:
|
|
132
|
+
names = [p.name for p in providers]
|
|
133
|
+
duplicates = sorted({n for n in names if names.count(n) > 1})
|
|
134
|
+
if duplicates:
|
|
135
|
+
raise ConfigurationError(f"provider names must be unique, got duplicates: {duplicates}")
|
|
136
|
+
if not 0.0 <= threshold <= 1.0:
|
|
137
|
+
raise ConfigurationError("threshold must be between 0 and 1")
|
|
138
|
+
if on_error not in ("continue", "raise"):
|
|
139
|
+
raise ConfigurationError("on_error must be 'continue' or 'raise'")
|
|
140
|
+
self.providers: list[DecisionProvider] = list(providers)
|
|
141
|
+
self.llm = llm
|
|
142
|
+
self.threshold = threshold
|
|
143
|
+
self.thresholds = dict(thresholds or {})
|
|
144
|
+
self.trust_uncalibrated = trust_uncalibrated
|
|
145
|
+
self.tracer = tracer or Tracer()
|
|
146
|
+
self.prices = prices or default_prices()
|
|
147
|
+
self.on_error = on_error
|
|
148
|
+
self.escalation_context = escalation_context
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------ runs
|
|
151
|
+
|
|
152
|
+
@contextmanager
|
|
153
|
+
def run(self, name: str, *, input: Any = None, **attributes: Any) -> Iterator[Run]:
|
|
154
|
+
"""Open a root span for one unit of work (a ticket, a request, a task).
|
|
155
|
+
|
|
156
|
+
Everything the engine does inside the block, including decorated tool
|
|
157
|
+
calls, becomes part of this trace.
|
|
158
|
+
"""
|
|
159
|
+
collector = _RunCollector()
|
|
160
|
+
self.tracer.add_sink(collector)
|
|
161
|
+
try:
|
|
162
|
+
with self.tracer.span("run", name, **attributes) as span:
|
|
163
|
+
collector.trace_id = span.trace_id
|
|
164
|
+
if input is not None:
|
|
165
|
+
span.set(input=self.tracer.content(input))
|
|
166
|
+
yield Run(span, collector)
|
|
167
|
+
finally:
|
|
168
|
+
self.tracer.sinks.remove(collector)
|
|
169
|
+
|
|
170
|
+
@contextmanager
|
|
171
|
+
def step(self, name: str, **attributes: Any) -> Iterator[Span]:
|
|
172
|
+
"""Group related work under a named phase of the trace."""
|
|
173
|
+
with self.tracer.span("step", name, **attributes) as span:
|
|
174
|
+
yield span
|
|
175
|
+
|
|
176
|
+
def rule(self, name: str, value: Any, **attributes: Any) -> Any:
|
|
177
|
+
"""Record a deterministic check made in application code, and return its value.
|
|
178
|
+
|
|
179
|
+
Example:
|
|
180
|
+
>>> if not engine.rule("authenticated", ticket.customer_id is not None):
|
|
181
|
+
... return ask_to_sign_in()
|
|
182
|
+
"""
|
|
183
|
+
with self.tracer.span("rule", name, plane="rule", value=value, **attributes):
|
|
184
|
+
pass
|
|
185
|
+
return value
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------- decisions
|
|
188
|
+
|
|
189
|
+
def decide(self, state: State, question: Question, *, name: str | None = None) -> Decision:
|
|
190
|
+
"""Answer one question. See :meth:`decide_many` for batching."""
|
|
191
|
+
key = name or question.key
|
|
192
|
+
return self.decide_many(state, {key: question}, label=key)[key]
|
|
193
|
+
|
|
194
|
+
def decide_many(
|
|
195
|
+
self,
|
|
196
|
+
state: State,
|
|
197
|
+
questions: Mapping[str, Question] | Sequence[Question],
|
|
198
|
+
*,
|
|
199
|
+
label: str | None = None,
|
|
200
|
+
) -> dict[str, Decision]:
|
|
201
|
+
"""Answer several questions about the same state.
|
|
202
|
+
|
|
203
|
+
Questions travel through the cascade together: each provider gets one
|
|
204
|
+
call with all the open questions it supports, which is how System One
|
|
205
|
+
models are meant to be used and what keeps LLM-only baselines fair.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
state: Text, a JSON-like object, or a list of either.
|
|
209
|
+
questions: A mapping of name to question, or a sequence of
|
|
210
|
+
questions keyed by their ``key``.
|
|
211
|
+
label: Span name. Defaults to the joined question names.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Decisions keyed by question name, in input order.
|
|
215
|
+
"""
|
|
216
|
+
batch = self._as_mapping(questions)
|
|
217
|
+
with self.tracer.span("decide", label or ", ".join(batch)[:80]) as span:
|
|
218
|
+
span.set(
|
|
219
|
+
questions={
|
|
220
|
+
k: {"kind": q.kind.value, "threshold": self._threshold(k, q)}
|
|
221
|
+
for k, q in batch.items()
|
|
222
|
+
}
|
|
223
|
+
)
|
|
224
|
+
if self.tracer.capture_content:
|
|
225
|
+
span.set(state=self.tracer.content(state))
|
|
226
|
+
decisions = self._cascade(state, batch)
|
|
227
|
+
for decision in decisions.values():
|
|
228
|
+
decision.span_id = span.span_id
|
|
229
|
+
span.set(decisions=[self._trace_summary(d) for d in decisions.values()])
|
|
230
|
+
return decisions
|
|
231
|
+
|
|
232
|
+
def extract(
|
|
233
|
+
self,
|
|
234
|
+
state: State,
|
|
235
|
+
fields: Mapping[str, str] | Extract,
|
|
236
|
+
*,
|
|
237
|
+
required: Sequence[str] = (),
|
|
238
|
+
name: str | None = None,
|
|
239
|
+
threshold: float | None = None,
|
|
240
|
+
) -> Decision:
|
|
241
|
+
"""Extract fields from ``state``. Shorthand for deciding an :class:`Extract`."""
|
|
242
|
+
question = (
|
|
243
|
+
fields
|
|
244
|
+
if isinstance(fields, Extract)
|
|
245
|
+
else Extract(
|
|
246
|
+
fields=dict(fields), required=tuple(required), name=name, threshold=threshold
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
return self.decide(state, question)
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------ generation
|
|
252
|
+
|
|
253
|
+
def generate(
|
|
254
|
+
self,
|
|
255
|
+
prompt: str | Sequence[Message],
|
|
256
|
+
*,
|
|
257
|
+
system: str | None = None,
|
|
258
|
+
max_tokens: int = 512,
|
|
259
|
+
temperature: float | None = None,
|
|
260
|
+
name: str = "generate",
|
|
261
|
+
llm: LLM | None = None,
|
|
262
|
+
) -> Completion:
|
|
263
|
+
"""Generate text with the reasoning plane and record it as an ``llm`` span."""
|
|
264
|
+
model = llm or self.llm
|
|
265
|
+
if model is None:
|
|
266
|
+
raise ConfigurationError(
|
|
267
|
+
"generate() needs an LLM: pass llm= to Engine or to generate()"
|
|
268
|
+
)
|
|
269
|
+
messages = as_messages(prompt)
|
|
270
|
+
with self.tracer.span(
|
|
271
|
+
"llm", name, plane="llm", provider=model.provider, model=model.model
|
|
272
|
+
) as span:
|
|
273
|
+
if self.tracer.capture_content:
|
|
274
|
+
span.set(system=self.tracer.content(system), messages=self.tracer.content(messages))
|
|
275
|
+
completion = model.complete(
|
|
276
|
+
messages, system=system, max_tokens=max_tokens, temperature=temperature
|
|
277
|
+
)
|
|
278
|
+
cost, source = self._cost(
|
|
279
|
+
model.provider, completion.model, completion.usage, completion.cost_usd
|
|
280
|
+
)
|
|
281
|
+
span.set(
|
|
282
|
+
model=completion.model,
|
|
283
|
+
usage=completion.usage.model_dump(),
|
|
284
|
+
cost_usd=cost,
|
|
285
|
+
cost_known=source != "unknown",
|
|
286
|
+
cost_source=source,
|
|
287
|
+
stop_reason=completion.stop_reason,
|
|
288
|
+
)
|
|
289
|
+
if self.tracer.capture_content:
|
|
290
|
+
span.set(completion=completion.text)
|
|
291
|
+
return completion
|
|
292
|
+
|
|
293
|
+
# ----------------------------------------------------------------- async
|
|
294
|
+
|
|
295
|
+
async def adecide(
|
|
296
|
+
self, state: State, question: Question, *, name: str | None = None
|
|
297
|
+
) -> Decision:
|
|
298
|
+
return await asyncio.to_thread(self.decide, state, question, name=name)
|
|
299
|
+
|
|
300
|
+
async def adecide_many(
|
|
301
|
+
self,
|
|
302
|
+
state: State,
|
|
303
|
+
questions: Mapping[str, Question] | Sequence[Question],
|
|
304
|
+
*,
|
|
305
|
+
label: str | None = None,
|
|
306
|
+
) -> dict[str, Decision]:
|
|
307
|
+
return await asyncio.to_thread(self.decide_many, state, questions, label=label)
|
|
308
|
+
|
|
309
|
+
async def agenerate(self, prompt: str | Sequence[Message], **kwargs: Any) -> Completion:
|
|
310
|
+
return await asyncio.to_thread(self.generate, prompt, **kwargs)
|
|
311
|
+
|
|
312
|
+
# ------------------------------------------------------------- lifecycle
|
|
313
|
+
|
|
314
|
+
def warmup(
|
|
315
|
+
self,
|
|
316
|
+
questions: Mapping[str, Question] | Sequence[Question] | None = None,
|
|
317
|
+
*,
|
|
318
|
+
state: State = WARMUP_STATE,
|
|
319
|
+
rounds: int = 2,
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Load every local model now instead of on the first request.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
questions: The questions the application will ask. When given,
|
|
325
|
+
every non-LLM provider answers them ``rounds`` times on
|
|
326
|
+
``state``, so GPU kernels are specialized for the real
|
|
327
|
+
shapes before traffic arrives. LLM providers are never
|
|
328
|
+
called here.
|
|
329
|
+
state: Sample input for the warmup rounds.
|
|
330
|
+
rounds: Warmup passes per provider.
|
|
331
|
+
"""
|
|
332
|
+
for provider in self.providers:
|
|
333
|
+
provider.warmup()
|
|
334
|
+
if self.llm is not None:
|
|
335
|
+
self.llm.warmup()
|
|
336
|
+
if questions is None:
|
|
337
|
+
return
|
|
338
|
+
batch = self._as_mapping(questions)
|
|
339
|
+
for provider in self.providers:
|
|
340
|
+
if provider.plane is Plane.LLM:
|
|
341
|
+
continue
|
|
342
|
+
asked = {k: q for k, q in batch.items() if provider.supports(q)}
|
|
343
|
+
for _ in range(rounds if asked else 0):
|
|
344
|
+
provider.answer(state, asked)
|
|
345
|
+
|
|
346
|
+
def close(self) -> None:
|
|
347
|
+
for provider in self.providers:
|
|
348
|
+
provider.close()
|
|
349
|
+
if self.llm is not None:
|
|
350
|
+
self.llm.close()
|
|
351
|
+
self.tracer.flush()
|
|
352
|
+
|
|
353
|
+
# -------------------------------------------------------------- internals
|
|
354
|
+
|
|
355
|
+
@staticmethod
|
|
356
|
+
def _as_mapping(questions: Mapping[str, Question] | Sequence[Question]) -> dict[str, Question]:
|
|
357
|
+
if isinstance(questions, Mapping):
|
|
358
|
+
batch = dict(questions)
|
|
359
|
+
else:
|
|
360
|
+
batch = {}
|
|
361
|
+
for question in questions:
|
|
362
|
+
if question.key in batch:
|
|
363
|
+
raise ConfigurationError(f"duplicate question name {question.key!r}")
|
|
364
|
+
batch[question.key] = question
|
|
365
|
+
if not batch:
|
|
366
|
+
raise ConfigurationError("at least one question is required")
|
|
367
|
+
return batch
|
|
368
|
+
|
|
369
|
+
def _cost(
|
|
370
|
+
self, provider: str, model: str | None, usage: Any, reported: float | None
|
|
371
|
+
) -> tuple[float, str]:
|
|
372
|
+
"""Cost of one call and where the number came from.
|
|
373
|
+
|
|
374
|
+
A cost reported by the backend wins over the price table: it is what
|
|
375
|
+
was actually billed.
|
|
376
|
+
"""
|
|
377
|
+
if reported is not None:
|
|
378
|
+
return reported, "reported"
|
|
379
|
+
cost, known = self.prices.cost(provider, model, usage)
|
|
380
|
+
return cost, "price_table" if known else "unknown"
|
|
381
|
+
|
|
382
|
+
def _threshold(self, key: str, question: Question, provider: str | None = None) -> float:
|
|
383
|
+
# Most specific first: a calibrated threshold for this question on this
|
|
384
|
+
# provider, then the question's own, then the engine-wide settings.
|
|
385
|
+
if provider is not None and f"{key}@{provider}" in self.thresholds:
|
|
386
|
+
return self.thresholds[f"{key}@{provider}"]
|
|
387
|
+
if question.threshold is not None:
|
|
388
|
+
return question.threshold
|
|
389
|
+
return self.thresholds.get(key, self.threshold)
|
|
390
|
+
|
|
391
|
+
@staticmethod
|
|
392
|
+
def _confidence(question: Question, answer: Answer) -> float | None:
|
|
393
|
+
if answer.probabilities:
|
|
394
|
+
if question.kind is Kind.YES_NO:
|
|
395
|
+
return from_yes_probability(answer.probabilities.get("yes", 0.0))
|
|
396
|
+
return from_distribution(answer.probabilities)
|
|
397
|
+
if (
|
|
398
|
+
question.kind is Kind.EXTRACT
|
|
399
|
+
and answer.fields is not None
|
|
400
|
+
and isinstance(question, Extract)
|
|
401
|
+
):
|
|
402
|
+
values = answer.value if isinstance(answer.value, Mapping) else {}
|
|
403
|
+
if any(values.get(field) is None for field in question.required):
|
|
404
|
+
return 0.0
|
|
405
|
+
found = [answer.fields.get(f) for f, v in values.items() if v is not None]
|
|
406
|
+
if any(c is None for c in found):
|
|
407
|
+
return None
|
|
408
|
+
return min((float(c) for c in found if c is not None), default=1.0)
|
|
409
|
+
if answer.confidence is not None:
|
|
410
|
+
return max(0.0, min(1.0, float(answer.confidence)))
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
def _build(
|
|
414
|
+
self,
|
|
415
|
+
key: str,
|
|
416
|
+
question: Question,
|
|
417
|
+
answer: Answer,
|
|
418
|
+
confidence: float | None,
|
|
419
|
+
threshold: float,
|
|
420
|
+
provider: DecisionProvider,
|
|
421
|
+
result: ProviderResult,
|
|
422
|
+
cost: float,
|
|
423
|
+
status: Status,
|
|
424
|
+
) -> Decision:
|
|
425
|
+
probability = None
|
|
426
|
+
level = None
|
|
427
|
+
probs = answer.probabilities
|
|
428
|
+
if probs:
|
|
429
|
+
if question.kind is Kind.YES_NO:
|
|
430
|
+
p_yes = probs.get("yes", 0.0)
|
|
431
|
+
probability = p_yes if answer.value else 1.0 - p_yes
|
|
432
|
+
elif question.kind is Kind.CHOICE:
|
|
433
|
+
probability = probs.get(str(answer.value))
|
|
434
|
+
elif question.kind is Kind.SCORE:
|
|
435
|
+
level = max(probs, key=probs.__getitem__)
|
|
436
|
+
probability = probs[level]
|
|
437
|
+
if isinstance(question, Score) and level is None and answer.value is not None:
|
|
438
|
+
index = min(max(round(float(answer.value)), 0), len(question.levels) - 1)
|
|
439
|
+
level = question.levels[index]
|
|
440
|
+
return Decision(
|
|
441
|
+
name=key,
|
|
442
|
+
kind=question.kind,
|
|
443
|
+
value=answer.value,
|
|
444
|
+
status=status,
|
|
445
|
+
confidence=confidence,
|
|
446
|
+
probability=probability,
|
|
447
|
+
probabilities=probs,
|
|
448
|
+
level=level,
|
|
449
|
+
fields=answer.fields,
|
|
450
|
+
threshold=threshold,
|
|
451
|
+
provider=provider.name,
|
|
452
|
+
plane=provider.plane,
|
|
453
|
+
model=result.model,
|
|
454
|
+
usage=result.usage,
|
|
455
|
+
cost_usd=cost,
|
|
456
|
+
raw=answer.raw,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
def _trace_value(self, kind: Kind, value: Any) -> Any:
|
|
460
|
+
# Choice labels, levels and booleans come from the question itself;
|
|
461
|
+
# extracted values are user content.
|
|
462
|
+
return self.tracer.content(value) if kind is Kind.EXTRACT else value
|
|
463
|
+
|
|
464
|
+
def _trace_summary(self, decision: Decision) -> dict[str, Any]:
|
|
465
|
+
summary = decision.summary()
|
|
466
|
+
summary["value"] = self._trace_value(decision.kind, decision.value)
|
|
467
|
+
return summary
|
|
468
|
+
|
|
469
|
+
def _cascade(self, state: State, batch: dict[str, Question]) -> dict[str, Decision]:
|
|
470
|
+
pending = dict(batch)
|
|
471
|
+
resolved: dict[str, Decision] = {}
|
|
472
|
+
best: dict[str, Decision] = {}
|
|
473
|
+
attempts: dict[str, list[Attempt]] = defaultdict(list)
|
|
474
|
+
latency: dict[str, float] = defaultdict(float)
|
|
475
|
+
|
|
476
|
+
for provider in self.providers:
|
|
477
|
+
if not pending:
|
|
478
|
+
break
|
|
479
|
+
asked = {k: q for k, q in pending.items() if provider.supports(q)}
|
|
480
|
+
if not asked:
|
|
481
|
+
continue
|
|
482
|
+
with self.tracer.span(
|
|
483
|
+
"attempt",
|
|
484
|
+
provider.name,
|
|
485
|
+
plane=provider.plane.value,
|
|
486
|
+
provider=provider.name,
|
|
487
|
+
questions=list(asked),
|
|
488
|
+
) as span:
|
|
489
|
+
started = time.perf_counter()
|
|
490
|
+
try:
|
|
491
|
+
if self.escalation_context and provider.accepts_context and resolved:
|
|
492
|
+
settled = {k: (batch[k], d) for k, d in resolved.items()}
|
|
493
|
+
span.set(context_from=list(settled))
|
|
494
|
+
result = provider.answer(state, asked, context=settled) # type: ignore[call-arg]
|
|
495
|
+
else:
|
|
496
|
+
result = provider.answer(state, asked)
|
|
497
|
+
except Exception as exc:
|
|
498
|
+
elapsed = (time.perf_counter() - started) * 1000.0
|
|
499
|
+
span.fail(exc)
|
|
500
|
+
logger.warning("provider %s failed: %s", provider.name, exc)
|
|
501
|
+
for key in asked:
|
|
502
|
+
latency[key] += elapsed
|
|
503
|
+
attempts[key].append(
|
|
504
|
+
Attempt(
|
|
505
|
+
provider=provider.name,
|
|
506
|
+
plane=provider.plane,
|
|
507
|
+
latency_ms=elapsed,
|
|
508
|
+
reason=f"error: {type(exc).__name__}",
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
if self.on_error == "raise":
|
|
512
|
+
raise
|
|
513
|
+
continue
|
|
514
|
+
elapsed = (time.perf_counter() - started) * 1000.0
|
|
515
|
+
cost, source = self._cost(
|
|
516
|
+
provider.price_key, result.model, result.usage, result.cost_usd
|
|
517
|
+
)
|
|
518
|
+
span.set(
|
|
519
|
+
model=result.model,
|
|
520
|
+
usage=result.usage.model_dump(),
|
|
521
|
+
cost_usd=cost,
|
|
522
|
+
cost_known=source != "unknown",
|
|
523
|
+
cost_source=source,
|
|
524
|
+
**result.meta,
|
|
525
|
+
)
|
|
526
|
+
if self.tracer.capture_content and result.content:
|
|
527
|
+
span.set(**{k: self.tracer.content(v) for k, v in result.content.items()})
|
|
528
|
+
|
|
529
|
+
accepted: list[str] = []
|
|
530
|
+
outcomes: list[dict[str, Any]] = []
|
|
531
|
+
for key, question in asked.items():
|
|
532
|
+
latency[key] += elapsed
|
|
533
|
+
answer = result.answers.get(key)
|
|
534
|
+
if answer is None:
|
|
535
|
+
attempts[key].append(
|
|
536
|
+
Attempt(
|
|
537
|
+
provider=provider.name,
|
|
538
|
+
plane=provider.plane,
|
|
539
|
+
latency_ms=elapsed,
|
|
540
|
+
reason="abstained",
|
|
541
|
+
)
|
|
542
|
+
)
|
|
543
|
+
outcomes.append({"name": key, "accepted": False, "reason": "abstained"})
|
|
544
|
+
continue
|
|
545
|
+
confidence = self._confidence(question, answer)
|
|
546
|
+
threshold = self._threshold(key, question, provider.name)
|
|
547
|
+
if confidence is not None:
|
|
548
|
+
ok = confidence >= threshold
|
|
549
|
+
reason = "met_threshold" if ok else "below_threshold"
|
|
550
|
+
else:
|
|
551
|
+
ok = (not provider.calibrated) and self.trust_uncalibrated
|
|
552
|
+
reason = "trusted_uncalibrated" if ok else "no_confidence"
|
|
553
|
+
attempts[key].append(
|
|
554
|
+
Attempt(
|
|
555
|
+
provider=provider.name,
|
|
556
|
+
plane=provider.plane,
|
|
557
|
+
value=self._trace_value(question.kind, answer.value),
|
|
558
|
+
confidence=confidence,
|
|
559
|
+
latency_ms=elapsed,
|
|
560
|
+
accepted=ok,
|
|
561
|
+
reason=reason,
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
outcomes.append(
|
|
565
|
+
{
|
|
566
|
+
"name": key,
|
|
567
|
+
"value": self._trace_value(question.kind, answer.value),
|
|
568
|
+
"confidence": None if confidence is None else round(confidence, 4),
|
|
569
|
+
"threshold": threshold,
|
|
570
|
+
"accepted": ok,
|
|
571
|
+
"reason": reason,
|
|
572
|
+
}
|
|
573
|
+
)
|
|
574
|
+
candidate = self._build(
|
|
575
|
+
key,
|
|
576
|
+
question,
|
|
577
|
+
answer,
|
|
578
|
+
confidence,
|
|
579
|
+
threshold,
|
|
580
|
+
provider,
|
|
581
|
+
result,
|
|
582
|
+
cost,
|
|
583
|
+
Status.ACCEPTED if ok else Status.UNCERTAIN,
|
|
584
|
+
)
|
|
585
|
+
if ok:
|
|
586
|
+
resolved[key] = candidate
|
|
587
|
+
del pending[key]
|
|
588
|
+
accepted.append(key)
|
|
589
|
+
else:
|
|
590
|
+
previous = best.get(key)
|
|
591
|
+
if previous is None or (confidence or 0.0) > (previous.confidence or 0.0):
|
|
592
|
+
best[key] = candidate
|
|
593
|
+
span.set(accepted=accepted, results=outcomes)
|
|
594
|
+
|
|
595
|
+
for key, question in pending.items():
|
|
596
|
+
resolved[key] = best.get(key) or Decision(
|
|
597
|
+
name=key,
|
|
598
|
+
kind=question.kind,
|
|
599
|
+
status=Status.ABSTAINED,
|
|
600
|
+
threshold=self._threshold(key, question),
|
|
601
|
+
)
|
|
602
|
+
for key, decision in resolved.items():
|
|
603
|
+
decision.attempts = attempts[key]
|
|
604
|
+
decision.latency_ms = latency[key]
|
|
605
|
+
return {key: resolved[key] for key in batch}
|
thinkless/errors.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Exceptions raised by ThinkLess."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["ConfigurationError", "ThinkLessError"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ThinkLessError(Exception):
|
|
9
|
+
"""Base class for ThinkLess errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigurationError(ThinkLessError, ValueError):
|
|
13
|
+
"""The engine or a provider was set up in a way that cannot work."""
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Reasoning plane backends.
|
|
2
|
+
|
|
3
|
+
Heavy backends are imported lazily so ``import thinkless.llm`` works with only
|
|
4
|
+
the core dependencies installed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from .base import LLM, Completion, Message, as_messages
|
|
12
|
+
from .factory import from_spec
|
|
13
|
+
from .scripted import ScriptedLLM
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .anthropic import AnthropicLLM
|
|
17
|
+
from .local import TransformersLLM
|
|
18
|
+
from .openai_compat import OpenAICompatibleLLM
|
|
19
|
+
from .openrouter import OpenRouterLLM
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"LLM",
|
|
23
|
+
"AnthropicLLM",
|
|
24
|
+
"Completion",
|
|
25
|
+
"Message",
|
|
26
|
+
"OpenAICompatibleLLM",
|
|
27
|
+
"OpenRouterLLM",
|
|
28
|
+
"ScriptedLLM",
|
|
29
|
+
"TransformersLLM",
|
|
30
|
+
"as_messages",
|
|
31
|
+
"from_spec",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
_LAZY = {
|
|
35
|
+
"AnthropicLLM": ".anthropic",
|
|
36
|
+
"OpenAICompatibleLLM": ".openai_compat",
|
|
37
|
+
"OpenRouterLLM": ".openrouter",
|
|
38
|
+
"TransformersLLM": ".local",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def __getattr__(name: str) -> Any:
|
|
43
|
+
if name in _LAZY:
|
|
44
|
+
from importlib import import_module
|
|
45
|
+
|
|
46
|
+
return getattr(import_module(_LAZY[name], __name__), name)
|
|
47
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|