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,143 @@
1
+ """Roll-ups computed from the spans of one trace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections import Counter, defaultdict
7
+ from collections.abc import Iterable, Mapping
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ __all__ = ["TraceSummary", "summarize"]
13
+
14
+ PLANES = ("rule", "model", "llm", "tool", "code")
15
+
16
+
17
+ class TraceSummary(BaseModel):
18
+ """What a run cost and where its time went.
19
+
20
+ Attributes:
21
+ llm_calls: Every call to a generative model, whether it generated text
22
+ or answered decision questions.
23
+ generation_calls: LLM calls made through ``Engine.generate``.
24
+ decisions: Questions resolved in the run, plus deterministic checks
25
+ recorded with ``Engine.rule``.
26
+ decisions_by_plane: How many decisions each plane resolved.
27
+ ``unresolved`` counts abstentions.
28
+ uncertain: Decisions that ended below threshold on every provider.
29
+ escalations: Decisions where a provider answered below threshold (or
30
+ failed) and the next provider was asked.
31
+ llm_input_tokens, llm_output_tokens: Tokens spent on generative models.
32
+ model_input_tokens: Tokens processed by small decision models.
33
+ cost_usd: Estimated spend, from the configured price table.
34
+ time_by_plane_ms: Time inside leaf work (provider attempts, generations,
35
+ tools and rules) grouped by plane.
36
+ """
37
+
38
+ trace_id: str
39
+ name: str
40
+ status: str
41
+ started_at: str
42
+ duration_ms: float
43
+ attributes: dict[str, Any] = Field(default_factory=dict)
44
+ llm_calls: int = 0
45
+ generation_calls: int = 0
46
+ decisions: int = 0
47
+ decisions_by_plane: dict[str, int] = Field(default_factory=dict)
48
+ uncertain: int = 0
49
+ escalations: int = 0
50
+ tool_calls: int = 0
51
+ errors: int = 0
52
+ llm_input_tokens: int = 0
53
+ llm_output_tokens: int = 0
54
+ model_input_tokens: int = 0
55
+ cost_usd: float = 0.0
56
+ time_by_plane_ms: dict[str, float] = Field(default_factory=dict)
57
+
58
+ @property
59
+ def llm_tokens(self) -> int:
60
+ return self.llm_input_tokens + self.llm_output_tokens
61
+
62
+
63
+ def _usage(attrs: Mapping[str, Any]) -> tuple[int, int]:
64
+ usage = attrs.get("usage") or {}
65
+ return int(usage.get("input_tokens") or 0), int(usage.get("output_tokens") or 0)
66
+
67
+
68
+ def summarize(spans: Iterable[Mapping[str, Any]]) -> TraceSummary:
69
+ """Summarize one trace.
70
+
71
+ Args:
72
+ spans: Span dictionaries of a single trace, in any order.
73
+
74
+ Raises:
75
+ ValueError: If there are no spans.
76
+ """
77
+ spans = list(spans)
78
+ if not spans:
79
+ raise ValueError("cannot summarize an empty trace")
80
+ root = next((s for s in spans if s.get("parent_id") is None), None)
81
+ if root is None:
82
+ root = min(spans, key=lambda s: s.get("start_ms") or 0.0)
83
+
84
+ by_plane: Counter[str] = Counter()
85
+ time_by_plane: defaultdict[str, float] = defaultdict(float)
86
+ summary = TraceSummary(
87
+ trace_id=root["trace_id"],
88
+ name=root["name"],
89
+ status=root.get("status", "ok"),
90
+ started_at=time.strftime(
91
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime((root.get("start_ms") or 0.0) / 1000.0)
92
+ ),
93
+ duration_ms=float(root.get("duration_ms") or 0.0),
94
+ attributes={k: v for k, v in (root.get("attributes") or {}).items() if k != "input"},
95
+ )
96
+
97
+ for span in spans:
98
+ kind = span.get("kind")
99
+ plane = span.get("plane")
100
+ attrs = span.get("attributes") or {}
101
+ duration = float(span.get("duration_ms") or 0.0)
102
+ if span.get("status") == "error":
103
+ summary.errors += 1
104
+ summary.cost_usd += float(attrs.get("cost_usd") or 0.0)
105
+
106
+ if kind in ("attempt", "llm", "tool", "rule") and plane:
107
+ time_by_plane[plane] += duration
108
+
109
+ if kind == "llm":
110
+ summary.llm_calls += 1
111
+ summary.generation_calls += 1
112
+ inp, out = _usage(attrs)
113
+ summary.llm_input_tokens += inp
114
+ summary.llm_output_tokens += out
115
+ elif kind == "attempt":
116
+ inp, out = _usage(attrs)
117
+ if plane == "llm":
118
+ summary.llm_calls += 1
119
+ summary.llm_input_tokens += inp
120
+ summary.llm_output_tokens += out
121
+ elif plane == "model":
122
+ summary.model_input_tokens += inp
123
+ elif kind == "tool":
124
+ summary.tool_calls += 1
125
+ elif kind == "rule":
126
+ summary.decisions += 1
127
+ by_plane["rule"] += 1
128
+ elif kind == "decide":
129
+ for decision in attrs.get("decisions") or []:
130
+ summary.decisions += 1
131
+ by_plane[decision.get("plane") or "unresolved"] += 1
132
+ if decision.get("status") == "uncertain":
133
+ summary.uncertain += 1
134
+ escalated = decision.get("escalated")
135
+ if escalated is None:
136
+ escalated = len(decision.get("attempts") or []) > 1
137
+ if escalated:
138
+ summary.escalations += 1
139
+
140
+ summary.cost_usd = round(summary.cost_usd, 8)
141
+ summary.decisions_by_plane = dict(by_plane)
142
+ summary.time_by_plane_ms = {k: round(v, 3) for k, v in time_by_plane.items()}
143
+ return summary
@@ -0,0 +1,159 @@
1
+ """The tracer: opens spans, links them and hands them to sinks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ import logging
8
+ from collections.abc import Callable, Iterator, Sequence
9
+ from contextlib import contextmanager
10
+ from typing import Any, TypeVar, overload
11
+
12
+ from .._json import jsonable
13
+ from .span import Span, _current_span, _current_tracer, current_tracer, new_span_id, new_trace_id
14
+
15
+ __all__ = ["REDACTED", "Tracer", "jsonable", "tool"]
16
+
17
+ logger = logging.getLogger("thinkless.tracing")
18
+
19
+ REDACTED = "[redacted]"
20
+
21
+ F = TypeVar("F", bound=Callable[..., Any])
22
+
23
+
24
+ class Tracer:
25
+ """Creates spans and forwards them to sinks.
26
+
27
+ Args:
28
+ sinks: Where finished spans go. See :mod:`thinkless.tracing.sinks`.
29
+ capture_content: Record inputs, outputs, prompts and tool arguments.
30
+ Turn it off where traces must not hold user content; structure,
31
+ timings, confidences and token counts are still recorded.
32
+
33
+ Sink failures are logged and never propagate into application code.
34
+ """
35
+
36
+ def __init__(self, sinks: Sequence[Any] | None = None, *, capture_content: bool = True) -> None:
37
+ self.sinks: list[Any] = list(sinks or [])
38
+ self.capture_content = capture_content
39
+
40
+ def add_sink(self, sink: Any) -> None:
41
+ self.sinks.append(sink)
42
+
43
+ def content(self, value: Any) -> Any:
44
+ """Return ``value`` as JSON-safe data, or a marker when content capture is off."""
45
+ return jsonable(value) if self.capture_content else REDACTED
46
+
47
+ @contextmanager
48
+ def span(
49
+ self, kind: str, name: str, *, plane: str | None = None, **attributes: Any
50
+ ) -> Iterator[Span]:
51
+ parent = _current_span.get()
52
+ span = Span(
53
+ trace_id=parent.trace_id if parent else new_trace_id(),
54
+ span_id=new_span_id(),
55
+ parent_id=parent.span_id if parent else None,
56
+ kind=kind,
57
+ name=name,
58
+ plane=plane,
59
+ attributes={k: jsonable(v) for k, v in attributes.items()},
60
+ )
61
+ span_token = _current_span.set(span)
62
+ tracer_token = _current_tracer.set(self)
63
+ self._emit("on_start", span)
64
+ try:
65
+ yield span
66
+ except BaseException as exc:
67
+ span.fail(exc)
68
+ raise
69
+ finally:
70
+ span.finish()
71
+ _current_span.reset(span_token)
72
+ _current_tracer.reset(tracer_token)
73
+ self._emit("on_end", span)
74
+
75
+ def flush(self) -> None:
76
+ for sink in self.sinks:
77
+ flush = getattr(sink, "flush", None)
78
+ if flush is not None:
79
+ self._call(flush)
80
+
81
+ def close(self) -> None:
82
+ for sink in self.sinks:
83
+ close = getattr(sink, "close", None)
84
+ if close is not None:
85
+ self._call(close)
86
+
87
+ def _emit(self, hook: str, span: Span) -> None:
88
+ for sink in tuple(self.sinks):
89
+ method = getattr(sink, hook, None)
90
+ if method is not None:
91
+ self._call(method, span)
92
+
93
+ @staticmethod
94
+ def _call(fn: Callable[..., Any], *args: Any) -> None:
95
+ try:
96
+ fn(*args)
97
+ except Exception:
98
+ logger.exception("trace sink %r failed", fn)
99
+
100
+
101
+ @overload
102
+ def tool(fn: F, /) -> F: ...
103
+ @overload
104
+ def tool(*, name: str | None = None) -> Callable[[F], F]: ...
105
+
106
+
107
+ def tool(fn: F | None = None, /, *, name: str | None = None) -> F | Callable[[F], F]:
108
+ """Record calls to a function as ``tool`` spans.
109
+
110
+ Outside a traced run the function runs untouched, so decorated tools stay
111
+ usable in tests and scripts.
112
+
113
+ Example:
114
+ >>> @tool
115
+ ... def refund_payment(payment_id: str, amount: float) -> dict: ...
116
+ """
117
+
118
+ def decorate(func: F) -> F:
119
+ span_name = name or func.__name__
120
+ signature = inspect.signature(func)
121
+
122
+ def arguments(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
123
+ try:
124
+ bound = signature.bind_partial(*args, **kwargs)
125
+ except TypeError:
126
+ return {"args": args, "kwargs": kwargs}
127
+ return {k: v for k, v in bound.arguments.items() if k not in ("self", "cls")}
128
+
129
+ if inspect.iscoroutinefunction(func):
130
+
131
+ @functools.wraps(func)
132
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
133
+ tracer = current_tracer()
134
+ if tracer is None:
135
+ return await func(*args, **kwargs)
136
+ with tracer.span("tool", span_name, plane="tool") as span:
137
+ span.set(arguments=tracer.content(arguments(args, kwargs)))
138
+ result = await func(*args, **kwargs)
139
+ span.set(result=tracer.content(result))
140
+ return result
141
+
142
+ return async_wrapper # type: ignore[return-value]
143
+
144
+ @functools.wraps(func)
145
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
146
+ tracer = current_tracer()
147
+ if tracer is None:
148
+ return func(*args, **kwargs)
149
+ with tracer.span("tool", span_name, plane="tool") as span:
150
+ span.set(arguments=tracer.content(arguments(args, kwargs)))
151
+ result = func(*args, **kwargs)
152
+ span.set(result=tracer.content(result))
153
+ return result
154
+
155
+ return wrapper # type: ignore[return-value]
156
+
157
+ if fn is not None:
158
+ return decorate(fn)
159
+ return decorate
@@ -0,0 +1,87 @@
1
+ """A self-contained HTML viewer for traces.
2
+
3
+ The output is one HTML file with the trace data embedded and no external
4
+ requests, so it opens offline and can be attached to an issue or a pull
5
+ request as-is.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from collections.abc import Iterable, Sequence
13
+ from importlib import resources
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .sinks import iter_trace_files, read_trace
18
+ from .summary import summarize
19
+
20
+ __all__ = ["build_viewer", "collect_traces", "write_viewer"]
21
+
22
+ PLACEHOLDER = "__THINKLESS_DATA__"
23
+
24
+
25
+ def collect_traces(
26
+ paths: Iterable[str | Path], *, limit: int | None = None
27
+ ) -> list[dict[str, Any]]:
28
+ """Load trace files, expanding directories, newest first."""
29
+ files: list[Path] = []
30
+ for raw in paths:
31
+ path = Path(raw)
32
+ files.extend(iter_trace_files(path) if path.is_dir() else [path])
33
+ traces = []
34
+ for file in files[:limit] if limit else files:
35
+ spans = read_trace(file)
36
+ if not spans:
37
+ continue
38
+ traces.append({"file": file.name, "spans": spans})
39
+ return traces
40
+
41
+
42
+ def build_viewer(
43
+ traces: Sequence[dict[str, Any]] | Sequence[Sequence[dict[str, Any]]], *, title: str = "Traces"
44
+ ) -> str:
45
+ """Render traces into the viewer HTML.
46
+
47
+ Args:
48
+ traces: Either ``{"file", "spans"}`` records from :func:`collect_traces`
49
+ or plain lists of span dictionaries.
50
+ title: Shown in the header.
51
+ """
52
+ records = []
53
+ for item in traces:
54
+ if isinstance(item, dict):
55
+ file, spans = item.get("file"), list(item["spans"])
56
+ else:
57
+ file, spans = None, list(item)
58
+ if not spans:
59
+ continue
60
+ records.append(
61
+ {"file": file, "spans": spans, "summary": summarize(spans).model_dump(mode="json")}
62
+ )
63
+ payload = {
64
+ "title": title,
65
+ "generated_at": time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime()),
66
+ "traces": records,
67
+ }
68
+ # Keep the JSON inert inside the script tag.
69
+ embedded = json.dumps(payload, ensure_ascii=False, default=str).replace("</", "<\\/")
70
+ template = resources.files("thinkless").joinpath("data/viewer.html").read_text(encoding="utf-8")
71
+ return template.replace(PLACEHOLDER, embedded)
72
+
73
+
74
+ def write_viewer(
75
+ paths: Iterable[str | Path],
76
+ output: str | Path,
77
+ *,
78
+ title: str = "Traces",
79
+ limit: int | None = None,
80
+ ) -> Path:
81
+ """Collect traces from ``paths`` and write the viewer to ``output``."""
82
+ target = Path(output)
83
+ target.parent.mkdir(parents=True, exist_ok=True)
84
+ target.write_text(
85
+ build_viewer(collect_traces(paths, limit=limit), title=title), encoding="utf-8"
86
+ )
87
+ return target