Python API¶
The public API is everything importable from thinkless,
thinkless.providers, thinkless.llm, thinkless.tracing,
thinkless.shadow, thinkless.integrations and thinkless.server. Anything
with a leading underscore is internal and may change.
Engine¶
thinkless.Engine
¶
Answers typed questions with the cheapest provider that is confident enough.
Providers are tried in the order given. Each provider receives, in one
call, every still-open question it supports. An answer whose normalized
confidence meets the question's threshold is accepted; the rest move on to
the next provider. When every provider has been tried, questions that are
still open come back uncertain with the best answer seen, or
abstained if nobody answered.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
providers
|
Sequence[DecisionProvider]
|
The decision cascade, cheapest first. A typical order is rules, then small local models, then a hosted decision model, then an LLM. |
()
|
llm
|
LLM | None
|
The reasoning plane, used by :meth: |
None
|
threshold
|
float
|
Default minimum confidence to accept an answer. |
0.8
|
thresholds
|
Mapping[str, float] | None
|
Overrides keyed by question name, or by
|
None
|
trust_uncalibrated
|
bool
|
Accept answers from providers without calibrated
probabilities (a prompted LLM) as final. Set it to |
True
|
tracer
|
Tracer | None
|
Where spans go. Defaults to a tracer with no sinks. |
None
|
prices
|
PriceTable | None
|
Price table for cost estimates. Defaults to the bundled table,
or |
None
|
on_error
|
Literal['continue', 'raise']
|
|
'continue'
|
escalation_context
|
bool
|
Tell providers that accept it (the LLM decider) which questions of the same batch are already settled, and how. An escalated question otherwise reaches the LLM stripped of its siblings, and on the support benchmark that changed answers: asked alone whether a request for a human is an injection, one model said yes to 5 of 10 such messages. The cost is one short line per settled question. |
True
|
deadline_ms
|
float | None
|
Time budget for one |
None
|
spend_limit
|
SpendLimit | None
|
A :class: |
None
|
Example
engine = Engine([Rules(), GLiNER(), Laya(), LLMDecider(llm)], llm=llm) intent = engine.decide(ticket, Choice("What does the customer want?", options=[...])) if intent.is_("refund"): ... ...
run(name, *, input=None, max_cost_usd=None, **attributes)
¶
Open a root span for one unit of work (a ticket, a request, a task).
Everything the engine does inside the block, including decorated tool calls, becomes part of this trace.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Span name. |
required |
input
|
Any
|
Recorded on the root span when content capture is on. |
None
|
max_cost_usd
|
float | None
|
Spend cap for this run. Past it, paid providers are
skipped and |
None
|
attributes
|
Any
|
Recorded on the root span. |
{}
|
step(name, **attributes)
¶
Group related work under a named phase of the trace.
rule(name, value, **attributes)
¶
Record a deterministic check made in application code, and return its value.
Example
if not engine.rule("authenticated", ticket.customer_id is not None): ... return ask_to_sign_in()
decide(state, question, *, name=None, deadline_ms=None)
¶
Answer one question. See :meth:decide_many for batching.
decide_many(state, questions, *, label=None, deadline_ms=None)
¶
Answer several questions about the same state.
Questions travel through the cascade together: each provider gets one call with all the open questions it supports, which is how System One models are meant to be used and what keeps LLM-only baselines fair.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state
|
State
|
Text, a JSON-like object, or a list of either. |
required |
questions
|
Mapping[str, Question] | Sequence[Question]
|
A mapping of name to question, or a sequence of
questions keyed by their |
required |
label
|
str | None
|
Span name. Defaults to the joined question names. |
None
|
deadline_ms
|
float | None
|
Time budget for this call. Defaults to the engine's
|
None
|
Returns:
| Type | Description |
|---|---|
dict[str, Decision]
|
Decisions keyed by question name, in input order. |
extract(state, fields, *, required=(), name=None, threshold=None)
¶
Extract fields from state. Shorthand for deciding an :class:Extract.
generate(prompt, *, system=None, max_tokens=512, temperature=None, name='generate', llm=None)
¶
Generate text with the reasoning plane and record it as an llm span.
warmup(questions=None, *, state=WARMUP_STATE, rounds=2)
¶
Load every local model now instead of on the first request.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
questions
|
Mapping[str, Question] | Sequence[Question] | None
|
The questions the application will ask. When given,
every non-LLM provider answers them |
None
|
state
|
State
|
Sample input for the warmup rounds. |
WARMUP_STATE
|
rounds
|
int
|
Warmup passes per provider. |
2
|
thinkless.Run
¶
Questions¶
thinkless.Choice
¶
Bases: Question
Pick exactly one option.
Options are given as a mapping of label to description, or as a plain list of labels. Descriptions matter: every provider uses them to tell options apart, so write them the way you would brief a new teammate.
Example
Choice( ... "What does the customer want?", ... options={"refund": "wants money back", "status": "asks where an order is"}, ... )
thinkless.Score
¶
Bases: Question
Place the input on an ordered scale.
Levels are ordered from lowest to highest. The decision value is the
expected level index (a float between 0 and len(levels) - 1) and the
most likely level is available as Decision.level.
thinkless.YesNo
¶
Bases: Question
A binary question. The decision value is a bool.
yes_means and no_means optionally spell out what each answer covers,
which helps with questions whose boundary is subtle.
thinkless.Extract
¶
Bases: Question
Pull typed fields out of the input.
fields maps a field name to a description. Fields listed in
required must be found for the answer to count as confident; a missing
optional field is simply None.
Example
Extract(fields={"order_id": "the order number, digits only"})
thinkless.questions.question_from_spec(spec, *, name=None)
¶
Rebuild a question from :meth:Question.spec output, or from JSON sent by a client.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spec
|
Mapping[str, Any]
|
A mapping with |
required |
name
|
str | None
|
Overrides |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the kind is unknown or the fields do not validate. |
Results¶
thinkless.Decision
¶
Bases: BaseModel
The resolved answer to a question.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
The question key. |
kind |
Kind
|
The question kind. |
value |
Any
|
The answer. A label for |
status |
Status
|
|
confidence |
float | None
|
Normalized confidence in [0, 1]. |
probability |
float | None
|
Probability of the chosen answer, when known. |
probabilities |
dict[str, float] | None
|
Full distribution over answers, when known. |
level |
str | None
|
For |
threshold |
float
|
The threshold that was applied. |
provider |
str | None
|
Name of the provider whose answer was used. |
plane |
Plane | None
|
Plane of that provider. |
model |
str | None
|
Model identifier reported by that provider. |
latency_ms |
float
|
Wall time spent on this question across all attempts. When several questions share a provider call, the call time is counted once per question. |
attempts |
list[Attempt]
|
Every provider that was tried, in order. |
usage |
Usage
|
Tokens consumed by the answering provider's call. |
cost_usd |
float
|
Estimated cost of the answering provider's call. |
raw |
dict[str, Any] | None
|
The provider's native payload for this question. |
span_id |
str | None
|
Trace span that recorded this decision. |
escalated
property
¶
True when an earlier provider answered (or failed) and was passed over.
A provider that abstains, such as a rule that does not fire, hands the question on without an escalation: that is the cascade working as designed, at no cost.
is_(value)
¶
True when the decision was accepted and equals value.
Convenient for routing code: if intent.is_("refund"): ... never
fires on an uncertain answer.
summary()
¶
Compact form used in trace attributes.
thinkless.Attempt
¶
Bases: BaseModel
One provider's try at one question.
thinkless.Status
¶
Bases: str, Enum
Outcome of a decision after the cascade finished.
accepted means an answer met its threshold (or came from a provider the
policy trusts without calibration). uncertain means every provider
answered below threshold; the best answer is returned and the application
decides what to do. abstained means no provider produced an answer.
thinkless.Plane
¶
Bases: str, Enum
Where a piece of work ran.
rule is deterministic code, model is a small decision model,
llm is a generative model, tool is an action with side effects and
code is application logic recorded for the trace.
Limits¶
thinkless.SpendLimit
¶
A dollar budget for an engine, over its lifetime or a rolling window.
Once the budget is used up, the engine stops asking paid providers: a
decision that would have reached a hosted LLM comes back uncertain
with the best answer the free providers gave, and generate raises
:class:SpendLimitError. Rules and local models keep answering.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
max_usd
|
float
|
The budget. |
required |
window_s
|
float | None
|
Rolling window in seconds, for example |
None
|
The check happens before each paid call, so the last call before the limit can overshoot it by the cost of one call.
Example
engine = Engine(providers, spend_limit=SpendLimit(20.0, window_s=86400))
thinkless.SpendLimitError
¶
Bases: ThinkLessError
A generation was refused because a spend limit is used up.
Shadow mode¶
thinkless.shadow.Shadow
¶
Runs a candidate engine on the same inputs as an existing system.
The existing system (the primary) keeps answering: its result is
returned unchanged, its exceptions propagate, and by default the candidate
runs on background threads so it adds no latency. Each shadow run appends
one record to a JSONL log with both answers, whether they agree, and what
each cost. thinkless shadow report turns the log into agreement,
projected savings and a per-question verdict.
The primary can be anything: a function that calls an LLM, a rules engine, a human queue, or another ThinkLess engine. The same class also audits a ThinkLess engine in production: make ThinkLess the primary and an LLM-only engine the shadow, sampled at a few percent.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
engine
|
Engine
|
The candidate. Give it its own tracer (or none): shadow runs
are separate traces, marked |
required |
log
|
str | Path | ShadowLog
|
A path for the JSONL log, or a :class: |
required |
sample
|
float
|
Share of calls to shadow, between 0 and 1. Shadowing an LLM costs what the LLM costs, so sample high-volume traffic. |
1.0
|
background
|
bool
|
Run the candidate on worker threads. |
True
|
workers
|
int
|
Worker threads for background runs. |
2
|
max_pending
|
int
|
Queued runs beyond this are dropped and counted, so a slow candidate can never build an unbounded backlog. |
256
|
max_cost_usd
|
float | None
|
Stop shadowing once the candidate has cost this much. |
None
|
capture_content
|
bool | None
|
Store the input in the log. Defaults to the candidate tracer's setting. When off, only a fingerprint of the input is stored, which still lets you join records to your own data. |
None
|
seed
|
int | None
|
Seed for the sampling, for reproducible tests. |
None
|
name
|
str
|
Name of the root span of each shadow trace. |
'shadow'
|
Example
shadow = Shadow(candidate, log="shadow/intent.jsonl", sample=0.2) @shadow.watch(INTENT) ... def classify(ticket: str) -> str: ... return call_existing_llm(ticket)
compare(state, question, primary, *, name=None, cost_usd=None, to_value=None)
¶
Run primary, shadow it, and return the primary's result.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state
|
Any
|
The input both systems see. |
required |
question
|
Question
|
The question the primary answers. |
required |
primary
|
Callable[[], Any] | Engine
|
A zero-argument callable, or an engine (its
:class: |
required |
name
|
str | None
|
Question key. Defaults to |
None
|
cost_usd
|
CostArg
|
What one primary call costs, or a function of its result. Leave it out when unknown; the report then shows the candidate's cost without a saving. |
None
|
to_value
|
Callable[[Any], Any] | None
|
Turns the primary's result into an answer comparable
with the question, for example |
None
|
acompare(state, question, primary, *, name=None, cost_usd=None, to_value=None)
async
¶
Async form of :meth:compare for a coroutine-returning primary.
observe(state, question, value, *, name=None, cost_usd=None, latency_ms=None)
¶
Shadow an answer the primary already gave.
Use it where the existing decision happens somewhere you cannot wrap, or when replaying logged decisions: pass what was decided, and what it cost if you know.
watch(question, *, state=None, name=None, cost_usd=None, to_value=None)
¶
Decorate an existing decision function so every call is shadowed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
question
|
Question
|
What the function decides. |
required |
state
|
Callable[..., Any] | None
|
Builds the shadow input from the call's arguments. Defaults to the first positional argument. |
None
|
name
|
str | None
|
Question key. Defaults to |
None
|
cost_usd
|
CostArg
|
What one call costs, or a function of its result. |
None
|
to_value
|
Callable[[Any], Any] | None
|
Turns the function's result into a comparable answer. |
None
|
Works on plain and async functions. The function's result and exceptions are passed through untouched.
flush(timeout=None)
¶
Wait for queued shadow runs to finish.
close(timeout=None)
¶
Finish queued runs, stop the workers and close the log.
thinkless.shadow.ShadowStats
¶
Bases: BaseModel
Counters for one :class:Shadow.
Attributes:
| Name | Type | Description |
|---|---|---|
seen |
int
|
Calls that reached the shadow. |
sampled |
int
|
Calls picked for a shadow run. |
recorded |
int
|
Shadow runs written to the log. |
skipped |
int
|
Calls left out by sampling. |
dropped |
int
|
Calls dropped because |
capped |
int
|
Calls left out because the shadow spend reached |
errors |
int
|
Shadow runs that raised. They are logged, never re-raised. |
spent_usd |
float
|
What the shadow engine has cost so far. |
thinkless.shadow.build_report(records, *, source='', target=0.95, min_calls=100, monthly_volume=None)
¶
Summarize shadow records.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
records
|
Iterable[dict[str, Any]]
|
Records from :func: |
required |
source
|
str
|
Where they came from, for the report header. |
''
|
target
|
float
|
Agreement the shadow must reach, as a 95% lower bound, for a question to be called ready. |
0.95
|
min_calls
|
int
|
Compared calls needed before any verdict. |
100
|
monthly_volume
|
int | None
|
Decisions a month, to project the monthly saving. |
None
|
thinkless.shadow.ShadowReport
¶
Bases: BaseModel
The whole log, per question and in total.
thinkless.shadow.QuestionReport
¶
Bases: BaseModel
Everything the report says about one question.
by_plane groups agreement by the plane that answered in the shadow
engine. primary_by_plane does the same for the primary, when the
primary is a ThinkLess engine: that is the view for auditing a live
engine against an LLM.
thinkless.shadow.export_labels(records, out, *, question=None, disagreements_only=True, label_from='primary')
¶
Write records as labeling rows that thinkless calibrate reads.
Each row has text (the logged input), label (pre-filled from
label_from, or null), both answers, the shadow's confidence and
agree. Correct the labels, then calibrate on the file.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
records
|
Iterable[dict[str, Any]]
|
Records from :func: |
required |
out
|
str | Path
|
Output JSONL path. |
required |
question
|
str | None
|
Only this question. |
None
|
disagreements_only
|
bool
|
Only calls where the two systems disagreed, which is where a person's label teaches the most. |
True
|
label_from
|
str
|
|
'primary'
|
Returns:
| Type | Description |
|---|---|
int
|
Rows written. Records logged without content are skipped, since |
int
|
there is no input to label. |
thinkless.shadow.agree(question, a, b)
¶
Whether two answers agree, and for Extract, which fields agree.
Returns (None, None) when either side has no answer.
Integrations¶
thinkless.integrations.Router
¶
Bases: Generic[T]
Maps the answer to a question onto a destination.
routes maps answers to destinations (node names, agents, handlers).
Only an accepted answer follows its route; an uncertain or abstained
decision, or an answer with no route, goes to default. That default
is usually the LLM path you run today, so routing never gets worse than
the current behavior.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
engine
|
Engine
|
The engine that answers. |
required |
question
|
Question
|
What to decide. |
required |
routes
|
Mapping[Any, T]
|
Answer to destination. |
required |
default
|
T
|
Where everything else goes. |
required |
state
|
Callable[[Any], Any] | None
|
Builds the question's input from whatever the router is called with. Defaults to passing it through unchanged. |
None
|
thinkless.integrations.route(engine, state, question, routes, default)
¶
Decide question on state and return the matching destination.
Example
handler = route(engine, ticket, INTENT, {"refund": refunds, "order_status": tracking}, agent)
thinkless.integrations.gate(engine, question, *, allow_when=True, on_uncertain='block', state=None, on_block=None, name=None)
¶
Check a tool call with a decision before it runs.
The question is asked about {"tool": name, "arguments": {...}} by
default, so rules can read the arguments directly and a model sees them
as text. The call goes ahead only when the decision is accepted and
equals allow_when.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
engine
|
Engine
|
The engine that answers. |
required |
question
|
Question
|
Usually a :class: |
required |
allow_when
|
Any
|
The answer that lets the call through. |
True
|
on_uncertain
|
OnUncertain
|
|
'block'
|
state
|
Callable[..., Any] | None
|
Builds the question's input from the call's arguments instead. |
None
|
on_block
|
Callable[[Decision], Any] | None
|
Called with the decision when a call is blocked; its return
value becomes the tool's result. Without it, :class: |
None
|
name
|
str | None
|
Tool name in the input and in errors. Defaults to the function name. |
None
|
Example
@gate(engine, YesNo("Is this refund within policy?", name="refund_ok")) ... def refund(order_id: str, amount: float) -> str: ...
thinkless.integrations.ToolBlockedError
¶
Bases: ThinkLessError
A gated tool call was not allowed. decision says why.
thinkless.integrations.last_user_text(value)
¶
The text of the most recent user message.
Accepts a plain string, a list of LangChain messages, OpenAI chat or
Responses API input items, or a mapping with a messages list (a
LangGraph state). Falls back to the last message of any role when none is
marked as the user's.
thinkless.integrations.langgraph.router(engine, question, routes, default, *, state=None, key='decisions')
¶
A conditional-edge function that routes on one decision.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
engine
|
Engine
|
The engine that answers. |
required |
question
|
Question
|
What to decide, for example the intent. |
required |
routes
|
Mapping[Any, str]
|
Answer to node name. |
required |
default
|
str
|
The node for uncertain answers and answers with no route. |
required |
state
|
Callable[[Any], Any] | None
|
Builds the question's input from the graph state. Defaults to
the text of the latest user message in |
None
|
key
|
str | None
|
Where a :func: |
'decisions'
|
Returns:
| Type | Description |
|---|---|
GraphRouter
|
A callable router. Pass |
GraphRouter
|
so the graph drawing shows every edge. |
thinkless.integrations.langgraph.decision_node(engine, questions, *, state=None, key='decisions')
¶
A node that answers several questions and writes them to state[key].
Each entry holds value, status, accepted, confidence,
plane, provider and level. Declare the key in the state
schema, for example decisions: dict[str, dict].
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
engine
|
Engine
|
The engine that answers. |
required |
questions
|
Mapping[str, Question] | Sequence[Question]
|
The questions, answered in one batch. |
required |
state
|
Callable[[Any], Any] | None
|
Builds the input from the graph state. Defaults to the text of the latest user message. |
None
|
key
|
str
|
The state key to write. |
'decisions'
|
thinkless.integrations.langgraph.adecision_node(engine, questions, *, state=None, key='decisions')
¶
The async form of :func:decision_node, for graphs run with ainvoke.
The engine runs on a worker thread, so local model inference does not block the event loop.
thinkless.integrations.openai_agents.input_guardrail(engine, question, *, trip_when=True, state=None, name=None, run_in_parallel=False)
¶
An input guardrail that trips on a ThinkLess decision.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
engine
|
Engine
|
The engine that answers. |
required |
question
|
Question
|
What to check, for example |
required |
trip_when
|
Any
|
The accepted answer that trips the guardrail. An uncertain decision never trips it; give the engine an LLM decider as its last provider if uncertain inputs need a verdict. |
True
|
state
|
Callable[[Any], Any] | None
|
Builds the question's input from the agent input (a string or a list of input items). Defaults to the latest user message. |
None
|
name
|
str | None
|
Guardrail name in traces. Defaults to the question key. |
None
|
run_in_parallel
|
bool
|
|
False
|
Returns:
| Type | Description |
|---|---|
Any
|
An |
Any
|
The decision summary is in |
thinkless.integrations.openai_agents.tool_input_guardrail(engine, question, *, allow_when=True, on_uncertain='reject', message='This action was not allowed by policy. Tell the user it needs a human review.', on_reject='reject', state=None, name=None)
¶
A tool input guardrail that allows a call only on a confident decision.
The question is asked about {"tool": name, "arguments": {...}} by
default.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
engine
|
Engine
|
The engine that answers. |
required |
question
|
Question
|
Usually a :class: |
required |
allow_when
|
Any
|
The accepted answer that lets the call run. |
True
|
on_uncertain
|
Literal['reject', 'allow', 'raise']
|
|
'reject'
|
message
|
str | Callable[[Decision], str]
|
What the model reads when a call is rejected, or a function of the decision. |
'This action was not allowed by policy. Tell the user it needs a human review.'
|
on_reject
|
Literal['reject', 'raise']
|
|
'reject'
|
state
|
Callable[[Any], Any] | None
|
Builds the question's input from the SDK's
|
None
|
name
|
str | None
|
Guardrail name in traces. Defaults to the question key. |
None
|
Returns:
| Type | Description |
|---|---|
Any
|
An |
Any
|
|
thinkless.integrations.openai_agents.route_agent(engine, input, question, agents, default, *, state=None)
async
¶
Pick the agent for a request with a decision instead of a triage LLM.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
engine
|
Engine
|
The engine that answers. |
required |
input
|
Any
|
The run input, a string or a list of input items. |
required |
question
|
Question
|
Usually the intent. |
required |
agents
|
Mapping[Any, Any]
|
Answer to agent. |
required |
default
|
Any
|
The agent for uncertain answers, typically the triage agent with handoffs you run today. |
required |
state
|
Callable[[Any], Any] | None
|
Builds the question's input from |
None
|
Example
agent = await route_agent(engine, message, INTENT, {"refund": refunds}, triage) result = await Runner.run(agent, message)
Serving¶
thinkless.server.app.create_app(engine, questions=None, *, api_key=None, allow_ad_hoc=True, max_questions=64, warmup=True, model_name='thinkless')
¶
Build the FastAPI app around an engine.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
engine
|
Engine
|
The engine that answers every request. |
required |
questions
|
Mapping[str, Question] | Sequence[Question] | None
|
Questions clients can ask by name. |
None
|
api_key
|
str | None
|
When set, every |
None
|
allow_ad_hoc
|
bool
|
Accept question specs in the request as well as registered names. Turn it off to limit clients to the registry. |
True
|
max_questions
|
int
|
Upper bound on questions per request. |
64
|
warmup
|
bool
|
Load local models (and warm them on the registered questions) at startup rather than on the first request. |
True
|
model_name
|
str
|
The |
'thinkless'
|
Example
app = create_app(engine, [INTENT, URGENCY], api_key=os.environ["THINKLESS_API_KEY"])
uvicorn mymodule:app --workers 1¶
thinkless.server.mcp.create_mcp_server(engine, questions, *, name='thinkless')
¶
An MCP server with one tool per question.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
engine
|
Engine
|
The engine that answers. |
required |
questions
|
Mapping[str, Question] | Sequence[Question]
|
The questions to expose. |
required |
name
|
str
|
Server name shown to clients. |
'thinkless'
|
Returns:
| Type | Description |
|---|---|
Any
|
The SDK's server object. Call |
Any
|
|
Example
create_mcp_server(engine, [INTENT, INJECTION]).run()
Providers¶
thinkless.providers.DecisionProvider
¶
Bases: ABC
Answers typed questions.
Subclasses set
name: Unique name within an engine, used in traces and allowlists.
plane: rule, model or llm.
kinds: Question kinds the provider can answer.
calibrated: Whether its probabilities are meaningful enough to
threshold. Prompted LLMs are not.
accepts_context: Whether answer takes a context keyword with
the decisions already settled in the same batch. The engine only
passes it to providers that set this.
price_key: Provider id used for price lookup.
thinkless.providers.ProviderResult
¶
Bases: BaseModel
What a provider returns for one batched call.
Attributes:
| Name | Type | Description |
|---|---|---|
answers |
dict[str, Answer | None]
|
One entry per question asked. |
model |
str | None
|
The model that served the call, as reported by the backend. |
usage |
Usage
|
Tokens consumed by the call. |
cost_usd |
float | None
|
Cost reported by the backend for this call. When |
meta |
dict[str, Any]
|
Structural details recorded on the trace span. |
content |
dict[str, Any]
|
Prompts and raw completions; recorded only when content capture is on. |
thinkless.providers.Rules
¶
Bases: DecisionProvider
Python functions that answer questions when they can.
A rule is registered for a question name and called with the state (and,
if it accepts a second argument, the question). It returns an answer to
settle the question with full confidence, or None to pass it to the
next provider in the cascade.
Example
rules = Rules() @rules.rule("wants_human") ... def asks_for_person(state): ... return True if "real person" in state["message"].lower() else None rules.match("intent", r"\bunsubscribe\b", "cancel_subscription") rules.extract("order", order_id=r"#\s?(\d{4,6})")
rule(question)
¶
Decorator form of :meth:add.
match(question, pattern, value, *, field=None, flags=re.IGNORECASE)
¶
Answer value when pattern matches the state text.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
question
|
str
|
Question name. |
required |
pattern
|
str
|
Regular expression searched in the rendered state, or in
|
required |
value
|
Any
|
The answer to return on a match. |
required |
extract(question, *, field=None, flags=re.IGNORECASE, **patterns)
¶
Extract fields with regular expressions.
Each keyword maps a field name to a pattern; the first capture group (or the whole match) becomes the value. The rule answers when at least one pattern matches.
thinkless.providers.gliner.GLiNER
¶
Bases: DecisionProvider
Answers choice and extract questions with a GLiNER2 model.
Choice questions are scored per label (the model emits an independent score for every option) and the scores are normalized into a distribution, so confidence is comparable with other providers. All choice questions of a call share one forward pass, and so do all extract questions.
Measured on an RTX 5060 laptop GPU with gliner2.5-base-v1: about 16 ms
per classification and 38 ms per extraction; about 85 ms each on CPU.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
str
|
|
'fastino/gliner2.5-base-v1'
|
device
|
str
|
|
'auto'
|
name
|
str
|
Provider name in traces. |
'gliner'
|
extract_threshold
|
float
|
Minimum span confidence for an extracted field. |
0.5
|
Requires the gliner extra. Weights download on first use.
warmup()
¶
Load the weights and run representative inferences of each kind.
The first forward passes on a GPU are several times slower than the rest while kernels initialize, and GLiNER's TorchScript parts only specialize after a couple of runs; paying for that here keeps it off the first requests.
thinkless.providers.laya.Laya
¶
Bases: DecisionProvider
Answers choice, score and yes/no questions with Laya.
Laya is a non-autoregressive encoder (about 421M parameters for the English checkpoint) that answers every question of a call in one forward pass. Measured on an RTX 5060 laptop GPU: about 30 ms for three questions; about 650 ms on CPU.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
str
|
Hugging Face repo of the checkpoint. |
'convaiinnovations/laya'
|
device
|
str
|
|
'auto'
|
name
|
str
|
Provider name in traces. |
'laya'
|
max_len
|
int | None
|
Token budget for the state. Longer inputs are truncated by the model. |
None
|
Requires the laya extra. Weights download on first use.
warmup()
¶
Load the weights and run one tiny inference, so the first request is not slow.
thinkless.providers.SystemOne
¶
Bases: DecisionProvider
Any server that implements POST /v1/systemone.
Works with TypeSafe's hosted Jev and with self-hosted servers that copy its wire format, such as Kev and OpenJev. All questions of one call are answered in a single request.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Provider name in traces. |
'jev'
|
base_url
|
str
|
Server root, without the |
'https://api.typesafe.ai'
|
model
|
str
|
Model id or alias, for example |
'jev-latest'
|
api_key
|
str | None
|
Bearer token. Defaults to the |
None
|
api_key_env
|
str | None
|
Environment variable read when |
'TYPESAFE_API_KEY'
|
price_key
|
str
|
Provider id for price lookup. |
'typesafe'
|
timeout
|
float
|
Per-request timeout in seconds. |
30.0
|
max_retries
|
int
|
Retries on 429, 529, 5xx and connection errors, with
exponential backoff that honors |
3
|
client
|
Client | None
|
A preconfigured |
None
|
Use :meth:jev and :meth:self_hosted for the common setups.
thinkless.providers.LLMDecider
¶
Bases: DecisionProvider
Answers any question kind by prompting a generative model.
Answers carry no probabilities, so the engine treats them as uncalibrated:
by default they are accepted as the final word of a cascade (see
Engine(trust_uncalibrated=...)).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
llm
|
LLM
|
The model to prompt. |
required |
name
|
str
|
Provider name. Defaults to |
'llm'
|
max_tokens
|
int
|
Reply budget per batch, plus 48 tokens per question. |
256
|
retry_on_truncation
|
bool
|
When a reply is cut off at the token limit and answers are missing, retry once with three times the budget. Reasoning models are the usual cause: hidden reasoning tokens count against the limit. The retry is recorded in the trace. |
True
|
LLM backends¶
thinkless.llm.LLM
¶
Bases: ABC
A generative model.
Implementations wrap one backend. provider identifies the backend in
traces and in the price table (anthropic, openai, local, ...).
Args of :meth:complete:
messages: Conversation turns, oldest first.
system: Optional system prompt.
max_tokens: Upper bound on generated tokens.
temperature: Sampling temperature. None leaves the backend
default; some hosted models reject the parameter entirely, and
their implementations ignore it.
json_mode: Ask the backend for a JSON object when it supports that.
thinkless.llm.Completion
¶
Bases: BaseModel
The result of one generation.
cost_usd is the cost the backend reported for this call (OpenRouter
does). When it is None the engine estimates cost from the price table.
thinkless.llm.local.TransformersLLM
¶
Bases: LLM
Runs a chat model in-process with transformers.
This is the zero-setup option: no server, no API key. For throughput, run
the same weights behind Ollama, vLLM or llama.cpp and use
:class:~thinkless.llm.OpenAICompatibleLLM instead; a serving engine is
several times faster than the plain generate loop used here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
str
|
Hugging Face model id or local path. |
'Qwen/Qwen3-1.7B'
|
device
|
str
|
|
'auto'
|
dtype
|
str
|
|
'auto'
|
enable_thinking
|
bool
|
Passed to chat templates that support a thinking switch (Qwen3 and later). Off by default: decisions and short replies do not benefit from long reasoning traces. |
False
|
Requires the local-llm extra.
warmup()
¶
Load the weights and generate one token, so the first request is not slow.
thinkless.llm.openai_compat.OpenAICompatibleLLM
¶
Bases: LLM
Chat Completions client for OpenAI and compatible servers.
Covers OpenAI itself plus Ollama, vLLM, LM Studio, llama.cpp server,
Groq, Together and any other server that implements
/v1/chat/completions. For OpenRouter, use
:class:~thinkless.llm.OpenRouterLLM, which sets the endpoint, key and
reported cost for you.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
str
|
Model id as the server knows it. |
required |
base_url
|
str | None
|
Server URL, for example |
None
|
api_key
|
str | None
|
Defaults to |
None
|
provider
|
str
|
Name used in traces and for price lookup. Use |
'openai'
|
token_param
|
str | None
|
|
None
|
supports_json_mode
|
bool
|
Whether to send |
True
|
default_headers
|
Mapping[str, str] | None
|
Extra HTTP headers sent with every request. |
None
|
extra_body
|
Mapping[str, Any] | None
|
Extra fields merged into every request body, for server-specific options. |
None
|
Requires the openai extra.
thinkless.llm.anthropic.AnthropicLLM
¶
Bases: LLM
Claude models via the Messages API.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
str
|
Claude model id. Defaults to |
'claude-opus-5'
|
api_key
|
str | None
|
Defaults to the SDK's credential resolution
( |
None
|
effort
|
str | None
|
Optional |
None
|
fallbacks
|
bool | None
|
Server-side refusal fallbacks ( |
None
|
client
|
Any | None
|
A preconfigured |
None
|
Sampling parameters are not sent: current Claude models reject
temperature and control depth through effort instead.
Requires the anthropic extra.
thinkless.llm.ScriptedLLM
¶
Bases: LLM
Returns canned responses.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
responses
|
Sequence[str] | Responder
|
Either a list of strings returned in order (the last one
repeats), or a callable |
required |
model
|
str
|
Name reported in traces. |
'scripted'
|
latency_ms
|
float
|
Artificial delay, to make dry-run traces look realistic. |
0.0
|
Token usage is approximated from character counts.
thinkless.llm.from_spec(spec, *, device='auto', base_url=None, reasoning=None)
¶
Create an LLM from backend[:model].
Examples:
local (Qwen3-1.7B in-process), local:Qwen/Qwen3-4B,
anthropic (Claude Opus 5), anthropic:claude-haiku-4-5,
openrouter:qwen/qwen3.7-flash, openrouter:anthropic/claude-haiku-4.5,
openai:<model>, ollama:qwen3:8b, vllm:<model>.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spec
|
str
|
Backend name, optionally followed by a colon and a model id. |
required |
device
|
str
|
Device for the |
'auto'
|
base_url
|
str | None
|
Server URL for |
None
|
reasoning
|
str | None
|
|
None
|
Tracing¶
thinkless.Tracer
¶
Creates spans and forwards them to sinks.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sinks
|
Sequence[Any] | None
|
Where finished spans go. See :mod: |
None
|
capture_content
|
bool
|
Record inputs, outputs, prompts and tool arguments. Turn it off where traces must not hold user content; structure, timings, confidences and token counts are still recorded. |
True
|
Sink failures are logged and never propagate into application code.
content(value)
¶
Return value as JSON-safe data, or a marker when content capture is off.
thinkless.tracing.Span
dataclass
¶
A timed unit of work.
kind says what the span represents: run (a root), step (an
application-defined phase), decide (a batch of questions), attempt
(one provider call inside a decision), llm (a generation), tool (an
action) or rule (a deterministic check recorded by application code).
plane says where the work ran and drives cost and latency roll-ups.
set(**attributes)
¶
Attach attributes. Later values overwrite earlier ones.
thinkless.tracing.TraceSummary
¶
Bases: BaseModel
What a run cost and where its time went.
Attributes:
| Name | Type | Description |
|---|---|---|
llm_calls |
int
|
Every call to a generative model, whether it generated text or answered decision questions. |
generation_calls |
int
|
LLM calls made through |
decisions |
int
|
Questions resolved in the run, plus deterministic checks
recorded with |
decisions_by_plane |
dict[str, int]
|
How many decisions each plane resolved.
|
uncertain |
int
|
Decisions that ended below threshold on every provider. |
escalations |
int
|
Decisions where a provider answered below threshold (or failed) and the next provider was asked. |
llm_input_tokens, |
llm_output_tokens
|
Tokens spent on generative models. |
model_input_tokens |
int
|
Tokens processed by small decision models. |
cost_usd |
float
|
Estimated spend, from the configured price table. |
time_by_plane_ms |
dict[str, float]
|
Time inside leaf work (provider attempts, generations, tools and rules) grouped by plane. |
thinkless.tracing.summarize(spans)
¶
Summarize one trace.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spans
|
Iterable[Mapping[str, Any]]
|
Span dictionaries of a single trace, in any order. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If there are no spans. |
thinkless.tracing.JSONLSink
¶
Writes one JSON Lines file per trace.
Files are named <UTC timestamp>_<root name>_<trace id prefix>.jsonl so a
directory listing reads as a run log. Each line is one span; the root span
is always the last line, which makes a truncated file easy to detect.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
directory
|
str | Path
|
Output directory, created on first write. |
required |
path(trace_id)
¶
Where a trace is being written, while its root span is open.
thinkless.tracing.MemorySink
¶
Keeps finished spans in memory. Used by tests and benchmarks.
traces()
¶
Finished spans grouped by trace id, as dictionaries.
thinkless.tracing.ConsoleSink
¶
Prints each finished trace as a tree, when its root span closes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
render
|
Callable[[list[dict[str, Any]]], None] | None
|
Callable that receives the finished span dictionaries of one
trace. Defaults to :func: |
None
|
thinkless.tracing.otel.OTelSink
¶
Forward spans to an OpenTelemetry TracerProvider.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tracer_provider
|
Any | None
|
The provider to export through. Defaults to the global provider configured by the application. |
None
|
include_content
|
bool
|
Export attributes that may contain user content. |
False
|
thinkless.tool(fn=None, /, *, name=None)
¶
tool(fn: F) -> F
tool(*, name: str | None = None) -> Callable[[F], F]
Record calls to a function as tool spans.
Outside a traced run the function runs untouched, so decorated tools stay usable in tests and scripts.
Example
@tool ... def refund_payment(payment_id: str, amount: float) -> dict: ...
thinkless.tracing.export.iter_decisions(paths)
¶
Every decision recorded in trace files or directories, one dict each.
Each dict has the question name and kind, the input (state, or
None when content capture was off), the value, status, plane,
provider, confidence and whether it escalated, plus the trace and span
ids to find it again.
thinkless.tracing.export.export_trace_labels(paths, out, *, question=None, statuses=None, planes=None, limit=None, seed=0)
¶
Write traced decisions as rows to label, in the format thinkless calibrate reads.
Rows hold text (the input), label (the decision's value when it
was accepted, else null), and the decision's status, plane, provider
and confidence. Decisions traced without content are skipped.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
paths
|
Iterable[str | Path]
|
Trace files or directories. |
required |
out
|
str | Path
|
Output JSONL path. |
required |
question
|
str | None
|
Only this question. |
None
|
statuses
|
Sequence[str] | None
|
Only these statuses, for example |
None
|
planes
|
Sequence[str] | None
|
Only decisions settled by these planes. |
None
|
limit
|
int | None
|
At most this many rows, sampled at random with |
None
|
Returns:
| Type | Description |
|---|---|
int
|
Rows written. |
thinkless.tracing.export.drift_report(baseline, current, *, llm_share_points=0.1, unsure_points=0.1, answer_shift_limit=0.15, confidence_drop=0.05, min_decisions=30)
¶
Compare decisions in two sets of traces, question by question.
A question is flagged when the share reaching an LLM rises by more than
llm_share_points, when the share not accepted (uncertain or
abstained) rises by more than unsure_points, when the answer
distribution moves by more than
answer_shift_limit (total variation distance), or when the mean
confidence of calibrated answers drops by more than confidence_drop.
Questions with fewer than min_decisions in either period are
reported without flags.
Confidence and pricing¶
thinkless.confidence
¶
Confidence normalization.
Providers disagree about what "confidence" means. Measured on the same input,
Laya reports 1 - normalized entropy for choices and max(p, 1 - p) for
yes/no questions, while TypeSafe documents (k * p_max - 1) / (k - 1). A
threshold of 0.8 would therefore mean something different depending on which
backend answered.
ThinkLess computes one confidence from each provider's probability
distribution and applies thresholds to that number only. The provider's own
fields are kept untouched in Decision.raw.
The formula is the normalized maximum probability, the same one TypeSafe documents for Jev::
confidence = (k * p_max - 1) / (k - 1)
It is 0 for a uniform distribution over k outcomes and 1 when all mass sits
on one outcome. For a yes/no question (k = 2) it reduces to
2 * max(p, 1 - p) - 1, so a threshold of 0.8 requires p >= 0.9.
normalize_distribution(scores)
¶
Rescale non-negative scores so they sum to 1.
Useful for providers that emit independent per-label scores (for example sigmoid outputs) rather than a softmax distribution.
from_distribution(probabilities)
¶
Normalized confidence of a categorical distribution.
from_yes_probability(p_yes)
¶
Normalized confidence of a binary answer given P(yes).