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
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}")