AI Thinking / Reasoning¶
RoomKit provides first-class support for AI thinking (chain-of-thought reasoning). Models like Claude 3.5+, DeepSeek-R1, and QwQ produce internal reasoning before their answer. RoomKit captures this reasoning, preserves it across tool-loop rounds, and exposes it through hooks and ephemeral events.
Quick start¶
from roomkit import AIChannel
from roomkit.providers.anthropic.ai import AnthropicAIProvider
from roomkit.providers.anthropic.config import AnthropicConfig
provider = AnthropicAIProvider(
AnthropicConfig(api_key="sk-...", model="claude-opus-5")
)
ai = AIChannel(
"ai-thinker",
provider=provider,
system_prompt="Think step by step before answering.",
thinking_budget=8192, # Token budget for reasoning
)
That's it. When the provider supports thinking, the reasoning is automatically captured and preserved in conversation history.
How it works¶
User message arrives
│
▼
AIChannel builds AIContext (with thinking_budget)
│
▼
Provider generates response
├── Thinking: "Let me reason step by step..." → THINKING_START ephemeral event
│ → ON_AI_THINKING hook
│ → THINKING_END ephemeral event
└── Answer: "The answer is 42." → Broadcast as RoomEvent
│
▼
AIThinkingPart preserved in conversation history
│
▼
Next generation sees prior reasoning (required by Anthropic, useful for all)
Configuration¶
Three settings steer reasoning, and all three resolve through the same chain:
| Parameter | Type | Description |
|---|---|---|
thinking_budget |
int \| None |
Token budget for reasoning |
enable_thinking |
bool \| None |
Turn the reasoning block on or off, for providers that expose the switch |
reasoning_effort |
str \| None |
Reasoning verbosity, for providers that grade it — the accepted values are the provider's own |
None everywhere means "not set at this tier", so an unset knob defers
outward rather than overriding with a default.
The resolution chain¶
Each setting is resolved fresh at the start of every turn, from the most specific source that has an opinion:
1. Binding metadata → per-room operator intent, wins when it sets a value
2. config_provider result → resolved by your callback, every turn
3. AIChannel constructor → the channel default
4. Provider config → the provider's own setting
5. (nothing set) → the model's own default
A tier that does not set a value defers to the next one, and None (null)
counts as not set at every tier, binding metadata included: a host that
serializes an empty form field as null keeps the next tier's value. To turn a
channel default off for one room, set an explicit value instead
("thinking_budget": 0, "enable_thinking": False, "reasoning_effort": "none").
The switch is read across two keys: thinking_budget states it first (0 off,
above 0 on), then enable_thinking. An "enable_thinking": False set at a
tier turns thinking off whatever budget a less specific tier set, so a room
that says off is off even on a channel built with thinking_budget=8192; a
budget set at the same tier or a more specific one still decides.
Channel default¶
Set the default when creating the channel:
ai = AIChannel(
"ai-thinker",
provider=provider,
thinking_budget=8192,
enable_thinking=True,
reasoning_effort="low",
)
Per-room override¶
Override for specific rooms via binding metadata:
await kit.attach_channel("math-room", "ai-thinker",
category=ChannelCategory.INTELLIGENCE,
metadata={
"system_prompt": "You are a math tutor. Show your work.",
"thinking_budget": 16384, # More budget for complex reasoning
"reasoning_effort": "high",
},
)
Per-turn override (config_provider)¶
A thinking model costs two to three times the tokens and the latency of a
direct answer, and that trade is not the same in an agent's tool loop — where
the model is mostly shaping results it already has — as in a chat turn where
the reasoning is the value. config_provider lets you decide per turn
rather than standing up a second channel and a second provider to say so.
The callback runs at the start of every generation and returns an
AIChannelTurnConfig; None fields fall through to the tiers below:
from roomkit import AIChannelTurnConfig
async def per_turn(binding, context) -> AIChannelTurnConfig | None:
# Cheap, direct answers while the agent is working through its tools;
# full reasoning once it is composing the answer for a person.
if context.room.metadata.get("mode") == "agent":
return AIChannelTurnConfig(enable_thinking=False)
return AIChannelTurnConfig(enable_thinking=True, reasoning_effort="high")
ai = AIChannel("ai-thinker", provider=provider, config_provider=per_turn)
AIChannelTurnConfig carries system_prompt, tools, temperature,
max_tokens, thinking_budget, enable_thinking, reasoning_effort,
response_schema, turn_budget_tokens and turn_budget_usd.
Because it is resolved every turn, it is the right place for config that
changes underneath you — admin edits, per-user gating, feature flags — where
snapshotting into the channel or the binding at attach time would go stale.
When reasoning eats the output budget¶
Reasoning and the answer compete for the same max_tokens. A model that
spends its whole budget inside the thinking block returns empty content
with a truncation finish reason — not silence, but a cap that was too small
for both halves. RoomKit recognises that case and does not waste a retry
re-prompting under the same cap; it logs the real cause instead. If you see
it, raise max_tokens, lower reasoning_effort, or set
enable_thinking=False for that turn.
Provider support¶
Anthropic (native extended thinking)¶
AnthropicAIProvider uses the native extended thinking API. When thinking_budget is set:
- The API receives
thinking: {type: "enabled", budget_tokens: N} - Temperature is automatically set to 1 (required by the API)
- Thinking blocks include a
signaturefor round-trip fidelity AIThinkingPartis preserved verbatim in conversation history (Anthropic requires this)- Each block of a response is kept as its own part, with its own signature, and a
redacted_thinkingblock as its opaque data (AIThinkingPart.redacted): the next round of the tool loop replays them one by one, where they came relative to the round's calls and stretches of text. Anthropic wants the blocks back as it sent them: it refuses a round whose blocks changed in number, and a block without its signature, so a block the response cut before its signature is not replayed.AIResponse.thinking_partscarries the blocks to a caller ofgenerate(), which replays them first since a response does not say where they came;thinking_parts_of(response)reads them off any provider's response.
reasoning_effort rides output_config.effort as the turn spells it, with or
without tools: Anthropic's levels are low, medium, high, xhigh and
max, and a level the model does not take answers 400. Claude Sonnet 4.5 and
Claude Haiku 4.5 refuse the parameter, and are sent none.
A turn that states reasoning off (thinking_budget=0, enable_thinking=False
or reasoning_effort="none") is sent what turns its model off, as the model
catalogue declares it:
| Model | Sent for off |
|---|---|
| Claude Opus 5, Sonnet 5, Haiku 5.5 | thinking: {type: "disabled"} |
Claude Sonnet 5.5 (refuses disabled) |
thinking: {type: "between_tools"} |
| Claude Opus 5.5, Fable 5.1, Fable 5 (cannot stop reasoning) | output_config.effort: "low" |
| Claude Opus 4.8 and earlier, Sonnet 4.6 | nothing: leaving thinking out is off |
With reasoning off, Claude Opus 5, Sonnet 5.5 and Haiku 5.5 refuse an effort
above high, so xhigh and max go as high there. A model the catalogue
does not carry (a new release, a proxy's alias) is sent nothing for off.
from roomkit.providers.anthropic.ai import AnthropicAIProvider
from roomkit.providers.anthropic.config import AnthropicConfig
provider = AnthropicAIProvider(AnthropicConfig(
api_key="sk-...",
model="claude-opus-5",
))
ai = AIChannel("ai", provider=provider, thinking_budget=8192)
Ollama / vLLM (<think> tags)¶
Models served via Ollama or vLLM (DeepSeek-R1, QwQ, etc.) emit reasoning inside <think>...</think> tags. The OpenAIAIProvider parses these automatically:
- Streaming:
ThinkTagParserhandles tags split across chunk boundaries - Non-streaming: the same parser over the complete response, so
generate()hands back the same reasoning and answer as the stream, spaces included - History:
AIThinkingPartis re-wrapped as<think>tags when sent back to the model
from roomkit.providers.vllm import VLLMConfig, create_vllm_provider
provider = create_vllm_provider(VLLMConfig(
base_url="http://localhost:11434/v1",
api_key="ollama",
model="deepseek-r1:8b",
))
ai = AIChannel("ai", provider=provider, thinking_budget=8192)
vLLM authentication & native params¶
create_vllm_provider wraps OpenAIAIProvider — vLLM's online server is
the OpenAI-compatible API, so this is the canonical integration. Set
api_key to match vllm serve --api-key (sent as Authorization: Bearer).
headers adds proxy/non-Bearer headers.
provider = create_vllm_provider(VLLMConfig(
base_url="http://gpu-server:8000/v1",
model="meta-llama/Llama-3.1-8B-Instruct",
api_key="token-abc123", # vllm serve --api-key token-abc123
headers={"X-Proxy-Region": "eu"}, # optional reverse-proxy headers
top_k=40, # typed, no extra_body needed
repetition_penalty=1.05,
extra_body={
"guided_choice": ["yes", "no"], # constrain output to a choice set
},
))
vLLM sampling knobs¶
top_p, top_k, min_p, presence_penalty and repetition_penalty are
declared VLLMConfig fields, reaching parity with OllamaConfig. They were
always reachable through extra_body, but that left the caller to know which
are OpenAI fields and which are vLLM extensions; the config now routes all
five through the request body itself.
Each defaults to None, meaning "the server decides" — which is not the same
as sending the documented default, and is the only honest answer for a model
RoomKit cannot see. An explicit 0 survives: min_p=0.0 and
presence_penalty=0.0 are values, not absences.
This is what makes a vendor's published sampling profile expressible. Qwen3
asks for presence_penalty=1.5 in non-thinking mode, and the failure that
setting addresses is degenerate repetition:
provider = create_vllm_provider(VLLMConfig(
model="Qwen/Qwen3-8B",
enable_thinking=False,
presence_penalty=1.5, # Qwen3's own guidance for non-thinking mode
top_p=0.8,
top_k=20,
min_p=0.0,
))
vLLM reasoning knobs¶
vLLM renders the model's chat template server-side, so reasoning is
steered through chat_template_kwargs rather than a sampling parameter — the
top-level reasoning_effort an OpenAI-compatible client sends is not read by
a locally rendered template. enable_thinking and reasoning_effort map onto
those template kwargs, so a thinking model can be told to answer directly
without hand-writing extra_body:
provider = create_vllm_provider(VLLMConfig(
model="Qwen/Qwen3-8B",
enable_thinking=False, # → chat_template_kwargs
))
Both default to None, leaving the model's own default untouched. That
default matters: current Qwen builds think at their most verbose effort
unless told otherwise, and in a tool loop that reasoning competes with the
answer for the same max_tokens.
An explicit extra_body["chat_template_kwargs"] entry still wins, so the
escape hatch keeps working for templates this config does not model. A
per-turn setting resolves over the configured one by merging rather than
replacing, so a turn that switches enable_thinking cannot silently drop a
configured reasoning_effort it says nothing about.
Native Ollama provider & authentication¶
For Ollama specifically, prefer the native OllamaAIProvider. It calls
/api/chat directly, so the think parameter and the streamed thinking
field work without <think> tag parsing.
To reach a protected endpoint — Ollama Cloud/Turbo, or a self-hosted server
behind a reverse proxy — set api_key; it is sent as
Authorization: Bearer <key>. Use headers for extra proxy headers or a
non-Bearer scheme (api_key wins over an Authorization entry in headers).
When api_key is None, the SDK still falls back to the OLLAMA_API_KEY
environment variable.
from roomkit.providers.ollama import OllamaAIProvider, OllamaConfig
provider = OllamaAIProvider(OllamaConfig(
host="https://ollama.example.com",
model="deepseek-r1:8b",
api_key="sk-...", # → Authorization: Bearer sk-...
headers={"X-Proxy-Region": "eu"}, # optional extra headers
think="high",
))
ai = AIChannel("ai", provider=provider, thinking_budget=8192)
OllamaConfig also exposes per-config sampling options, mapped to Ollama's
options: max_tokens (→ num_predict), num_ctx, top_p, top_k, and
min_p. Each defaults to None (the model's own default). temperature is a
turn setting, set on the AIChannel (or per room, per turn) like on every
provider.
provider = OllamaAIProvider(OllamaConfig(
model="llama3.2",
num_ctx=8192,
top_p=0.9,
keep_alive=-1, # keep the model loaded indefinitely
))
keep_alive controls how long the model stays resident after a request. Ollama
reads a string keep_alive as a Go duration (e.g. "5m"), so a unit-less
value must be a number, not a numeric string: pass keep_alive=-1 (load
forever) or keep_alive=0 (unload immediately) as an int. A unit-less string
like "-1" is coerced to int automatically so Ollama doesn't reject it as a
malformed duration.
Gemini (thought summaries + thought signatures)¶
GeminiAIProvider streams thought summaries: the parts Gemini flags
thought=True surface as StreamThinkingDelta, everything else as
StreamTextDelta. Gemini takes a thinking level or a token budget, never both,
and the turn outranks the configuration on what it states:
GeminiConfig(thinking_level=...)—minimal,low,medium,high, for Gemini 3.x models. A turn'sreasoning_effortreplaces it, and reaches a model with no configured level when the catalogue says the model takes levels (every Gemini 3 model; Gemini 2.5 refuses one).minimalgoes aslowto a model that refuses it: measured on 2026-09-30,gemini-3.8-flash,gemini-3.7-flashandgemini-3.1-pro-previewthinking_budget(per turn, from the channel) — a token budget, for Gemini 2.5.0turns reasoning off, as on every other provider, a configured level included, and so doenable_thinking=Falseandreasoning_effort="none". Not every model can run without it:gemini-3.1-pro-previewandgemini-3.5-flash-liteanswer 400 to a budget of0(measured 2026-09-27), so off is sent there as the lowest level the model takes (low,minimal);gemini-3.7-flashandgemini-3.8-flashaccept0but still reason a little (46 to 150 thought tokens, 2026-09-30). A positive budget with a level set keeps the levelenable_thinking=Truealone sends a dynamic budget (-1), which turns reasoning on for a model that does not reason by default (gemini-3.1-flash-lite)
from roomkit.providers.gemini import GeminiAIProvider, GeminiConfig
provider = GeminiAIProvider(GeminiConfig(
api_key="...",
model="gemini-3.8-flash",
thinking_level="high",
))
When the model reasons and calls tools, each function call it returns carries a
thought signature, and Gemini 3 rejects a later turn whose history replays a
function call without one. RoomKit handles the round trip for you: the signature
is kept in AIToolCallPart.metadata["thought_signature"] and replayed on the
matching call.
A round of parallel calls is the case to know about — Gemini signs one call of the group, not all of them, so the provider lends that signature to the round's other calls when it rebuilds the history. Nothing to configure. If a whole round comes back unsigned there is nothing to lend, and the provider logs a warning naming the calls before Gemini rejects the next turn.
RoomKit also refuses, before the request leaves, a history that ends on a model
turn — Gemini answers a user turn and would reply 400 "Requests ending with a
model turn are not supported." The ProviderError names the condition instead,
because the cause is upstream: a turn generated with nothing new to answer,
typically concurrent turns on one room each rebuilding a history that ends on
another's reply.
Streaming¶
During streaming generation, thinking content arrives as StreamThinkingDelta events before StreamTextDelta events:
from roomkit.providers.ai.base import (
StreamThinkingDelta,
StreamTextDelta,
StreamToolCall,
StreamDone,
)
async for event in provider.generate_structured_stream(context):
if isinstance(event, StreamThinkingDelta):
print(f"Thinking: {event.thinking}")
elif isinstance(event, StreamTextDelta):
print(f"Text: {event.text}")
The AIChannel handles this automatically — thinking deltas trigger ephemeral events, and text deltas are delivered to downstream channels.
Hooks and ephemeral events¶
ON_AI_THINKING hook¶
Fires when the AI produces thinking content. Use it for logging, observability, or cost tracking:
from roomkit import HookTrigger
@kit.hook(HookTrigger.ON_AI_THINKING)
async def log_thinking(event, ctx):
thinking = ctx.get("thinking", "")
print(f"AI reasoning ({len(thinking)} chars): {thinking[:100]}...")
Ephemeral events¶
Two ephemeral events bracket the thinking phase:
| Event | When |
|---|---|
THINKING_START |
AI begins reasoning |
THINKING_END |
AI finishes reasoning (thinking text in payload) |
These are published via the RealtimeBackend and do not persist in the conversation store. Use them for real-time UI indicators (e.g., "AI is thinking...").
A round can bracket more than one reasoning phase. A model that reasons, answers, then reasons again — the shape Anthropic's interleaved thinking produces — opens and closes a window per switch, so subscribers see several THINKING_START / THINKING_END pairs for a single round. Each THINKING_END carries its own block only, never the blocks the earlier ones already delivered: a client appends what it receives and never has to de-duplicate. In conversation history, a provider whose reasoning comes in blocks (Anthropic) keeps each block, in its place; any other keeps the round's reasoning as one block.
The window also closes on every abnormal exit of a round: a turn cancelled through Cancel steering, a provider that fails mid-reasoning, a consumer that stops reading the stream. The THINKING_END then carries the block reasoned so far, with the deltas still buffered flushed ahead of it, so a subscriber never stays on "thinking" for a turn that is over.
Tool loop integration¶
Thinking is preserved across tool-loop rounds. When the AI calls a tool and then continues generating, the thinking from each round is kept in the conversation history:
Round 1: AI thinks → calls tool
├── AIThinkingPart(thinking="I need to look up...")
└── AIToolCallPart(name="search", ...)
Tool executes → result appended
Round 2: AI thinks → generates answer
├── AIThinkingPart(thinking="Based on the results...")
└── AITextPart(text="Here's what I found...")
This ensures the model has full context of its prior reasoning when generating follow-up responses.
Reasoning settings on a turn with tools¶
A turn that declares tools gets the same thinking_budget, enable_thinking
and reasoning_effort as one that does not (RFC §6.7). Where a provider's
configuration carries the same setting under the same name, the turn's value
(per-turn config, room override, channel default) outranks it, False
included. A vendor setting of the provider's own (Gemini's thinking_level,
Ollama's think, PolarGrid's thinking) yields to the turn on what the turn
states and supplies the rest: thinking_budget (0 off, above 0 on), then
enable_thinking, say whether the model reasons, a reasoning_effort of
none says off, and reasoning_effort says how much. Every provider reads
that switch the same way (Qwen, DeepSeek, vLLM and llama.cpp, Ollama,
PolarGrid, Gemini, Mistral, OpenRouter, LiteLLM, Anthropic), each in its own
wire form; on Anthropic, enable_thinking=True alone turns adaptive thinking
on where the model has it, and a model without it needs a thinking_budget.
| Configured | Turn | Sent |
|---|---|---|
Ollama think="high" |
thinking_budget=4096 |
think="high" |
Ollama think="high" |
reasoning_effort="low" |
think="low" |
Ollama think=None |
reasoning_effort="low" |
nothing: a level only goes to a model configured with one, since Ollama refuses a level on a model that takes none |
Gemini thinking_level="high" |
thinking_budget=0 |
thinking_budget=0, reasoning off |
Gemini, no level, gemini-3.7-flash |
reasoning_effort="low" |
thinking_level="low", the levels a model takes read from the catalogue |
PolarGrid thinking=True |
enable_thinking=False |
enable_thinking=false |
Where the vendor restricts what a turn with tools accepts, the provider sends the value accepted, read from its model catalogue:
| Provider | On a turn with tools |
|---|---|
| Anthropic, Gemini, Ollama, vLLM, Cerebras, Meta, Qwen, PolarGrid, xAI, Mistral, DeepSeek, OpenRouter | As on any turn |
OpenAI: gpt-5, gpt-5-mini, gpt-5-nano, gpt-5.1, gpt-5.2, o3, o4-mini |
The turn's reasoning_effort |
OpenAI: gpt-5.4, gpt-5.4-mini, gpt-5.4-nano, gpt-5.5, gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-6-sol, gpt-6-luna |
none, the only value Chat Completions takes with function tools |
OpenAI: a model not in the catalogue, or not tagged (gpt-4.1), OpenAI behind a base_url, Azure OpenAI, LiteLLM |
Left out, with one warning per provider: the model is not known. supports_reasoning_effort_with_tools=True sends it |
OpenAI: gpt-6-astra, gpt-6.1-sol, the -pro models |
The turn is refused before the request (ProviderError): Chat Completions takes no function tools for them, or does not serve them |
Behind a base_url, on an Azure deployment or a LiteLLM alias, the provider
cannot know which model answers, nor so whether it takes reasoning_effort
beside function tools. Left out, a reasoning model thinks at its own default
on every tool turn, which for an agent with tools is nearly every turn. The
host that knows its server states it on the config:
from roomkit.providers.openai import OpenAIAIProvider, OpenAIConfig
provider = OpenAIAIProvider(
OpenAIConfig(
api_key="...",
base_url="https://api.inceptionlabs.ai/v1",
model="mercury-2.5",
reasoning_effort="low",
supports_reasoning_effort_with_tools=True, # the server takes the pair
)
)
True sends the turn's reasoning on a turn with tools as on any other (on
LiteLLM, the thinking budget too); False leaves it out without the warning;
unset leaves it out and warns once. On OpenAI's own endpoint a model the
catalogue tags follows its tag whatever the field says, since the catalogue
records what Chat Completions measurably takes. The configs of the providers
that apply their vendor's own rule (xAI, OpenRouter, Meta, Cerebras, DeepSeek,
Qwen) refuse the field with a ValidationError rather than ignore it.
A server that does not take the pair answers the turn with an error, and one
that cannot read its model's reasoning back fails on the next round, so check
both first: examples/openai_compatible_tool_effort.py runs the same
two-round tool loop (a call, then an answer from the tool's result) both ways
and prints each round's reasoning tokens. On Inception's Mercury 2.5 at low
(2026-10-08), the first round measured 393 to 851 reasoning tokens with the
effort left out and 247 to 256 with it sent.
Data model¶
AIThinkingPart¶
Represents a thinking block in conversation history:
from roomkit.providers.ai.base import AIThinkingPart
part = AIThinkingPart(
thinking="Let me reason step by step...",
signature="abc123", # Optional, used by Anthropic for round-trip
)
redacted = AIThinkingPart(thinking="", redacted="EuYB...") # A block the vendor redacted
StreamThinkingDelta¶
A streaming event for thinking content:
from roomkit.providers.ai.base import StreamThinkingDelta
delta = StreamThinkingDelta(thinking="Step 1: Consider...")
| Field | Type | Description |
|---|---|---|
thinking |
str |
Reasoning text (may be empty) |
signature |
str \| None |
The block's signature, sent once its text is done (Anthropic) |
block |
int \| None |
The block the delta belongs to (Anthropic's content block index); None for reasoning without blocks, one block per round |
redacted |
str \| None |
A block the vendor redacted: its opaque data |
AIResponse fields¶
| Field | Type | Description |
|---|---|---|
thinking |
str \| None |
All the reasoning text the provider exposed |
thinking_signature |
str \| None |
The last block's signature (Anthropic) |
thinking_parts |
list[AIThinkingPart] \| None |
The blocks to replay, in order, each with its signature or redacted data; None for reasoning without blocks |
AIContext fields¶
Resolved per turn by the chain above and handed to the provider:
| Field | Type | Description |
|---|---|---|
thinking_budget |
int \| None |
Token budget for reasoning |
enable_thinking |
bool \| None |
Reasoning block on/off; None defers to the provider config, then to the model |
reasoning_effort |
str \| None |
Reasoning verbosity; accepted values are the provider's own |
max_tokens |
int \| None |
Output cap for this turn; None defers to the provider's configured max_tokens |
max_tokens defaults to None rather than a number on purpose. A non-None
default here would shadow every provider config — context.max_tokens or
self._config.max_tokens would never reach the second term, and a configured
cap would be unreachable.
Testing¶
Use MockAIProvider with AIResponse that includes thinking content:
from roomkit import AIChannel
from roomkit.providers.ai.mock import MockAIProvider
from roomkit.providers.ai.base import AIResponse
provider = MockAIProvider(
ai_responses=[
AIResponse(
content="The answer is 42.",
thinking="Let me reason about this...",
finish_reason="stop",
usage={"prompt_tokens": 20, "completion_tokens": 15},
),
],
streaming=True,
)
ai = AIChannel("ai", provider=provider, thinking_budget=8192)
When streaming=True, MockAIProvider.generate_structured_stream() yields StreamThinkingDelta before StreamTextDelta, matching the real provider behavior.
Example¶
See examples/ai_thinking.py for a runnable demo showing thinking with AIChannel and per-room configuration.
See examples/ai_turn_config.py for the per-turn config_provider chain, printing what each tier resolved to on the way to the provider.