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, always wins
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
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 and reasoning_effort.
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)
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: Regex extraction from the complete response
- 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: temperature (default 0.7), max_tokens (→ num_predict),
num_ctx, top_p, top_k, and min_p. Each defaults to None (the model's
own default) except temperature.
provider = OllamaAIProvider(OllamaConfig(
model="llama3.2",
temperature=0.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. Two knobs reach the same ThinkingConfig, and
thinking_level wins when both are set:
GeminiConfig(thinking_level=...)—minimal,low,medium,high, for Gemini 3.x modelsthinking_budget(per turn, from the channel) — a token budget, for Gemini 2.5
from roomkit.providers.gemini import GeminiAIProvider, GeminiConfig
provider = GeminiAIProvider(GeminiConfig(
api_key="...",
model="gemini-3.6-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. The reasoning kept in conversation history stays whole, all blocks of the round concatenated.
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.
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
)
StreamThinkingDelta¶
A streaming event for thinking content:
from roomkit.providers.ai.base import StreamThinkingDelta
delta = StreamThinkingDelta(thinking="Step 1: Consider...")
AIResponse fields¶
| Field | Type | Description |
|---|---|---|
thinking |
str \| None |
Accumulated thinking text |
thinking_signature |
str \| None |
Provider-specific signature (Anthropic) |
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.