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
|
@@ -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
|