Write a chat integration¶
A chat integration is a class with the Chat protocol; write one to use a model that has no built-in integration.
When you need it¶
The built-in chat integrations do not cover your model or its API. Any class with the three members of Chat works wherever a chat model does. Tests need no integration: ScriptedChat plays the model (Test with ScriptedChat).
Steps¶
- Write a class with a
modelname, anasync def complete(request)that returns aChatResponse, and astream(request)that yieldsChatStreamChunks. - Map the
ChatRequest(messages,tools,tool_choice,output_schema) to your API. - Map the reply to a
ChatResponse(content,refusal,tool_calls,finish_reason,usage,model). - In
stream(), yield text ascontent_deltachunks and tool calls astool_call_deltadicts. - Pass an instance to
build_react_agentormodel_node.
Complete example¶
EchoChat answers with the last message it received:
import asyncio
from collections.abc import AsyncIterator
from nodestep import build_react_agent, run_agent
from nodestep.chat import ChatRequest, ChatResponse, ChatStreamChunk
class EchoChat:
model = "echo"
async def complete(self, request: ChatRequest) -> ChatResponse:
text = f"You said: {request.messages[-1].content}"
return ChatResponse(content=text, finish_reason="stop", model=self.model)
async def stream(self, request: ChatRequest) -> AsyncIterator[ChatStreamChunk]:
response = await self.complete(request)
yield ChatStreamChunk(content_delta=response.content, model=self.model)
yield ChatStreamChunk(finish_reason=response.finish_reason, model=self.model)
agent = build_react_agent(EchoChat(), tools=[])
print(asyncio.run(run_agent(agent, "hello")))
You said: hello
The agent calls complete() once, gets an answer without tool calls, and returns its text. A node built with stream=True calls stream() instead.
Streamed tool calls¶
A streamed tool call arrives in parts. Each part is a tool_call_delta dict shaped {"index": 0, "id": ..., "function": {"name": ..., "arguments": ...}}, and the argument text may be split across chunks. assemble_stream joins the chunks into one ChatResponse:
import asyncio
from collections.abc import AsyncIterator
from nodestep.chat import ChatStreamChunk, assemble_stream
async def chunks() -> AsyncIterator[ChatStreamChunk]:
yield ChatStreamChunk(
tool_call_delta={
"index": 0,
"id": "call_1",
"function": {"name": "get_weather", "arguments": '{"city": '},
}
)
yield ChatStreamChunk(
tool_call_delta={"index": 0, "function": {"arguments": '"Oslo"}'}}
)
yield ChatStreamChunk(finish_reason="tool_calls")
response = asyncio.run(assemble_stream(chunks()))
call = response.tool_calls[0]
print(call.id, call.name, call.arguments)
print(response.finish_reason)
call_1 get_weather {'city': 'Oslo'}
tool_calls
Good to know¶
| Rule or setting | What happens |
|---|---|
model: str |
The model name; middleware sees it as ctx.model |
async complete(request) |
Returns a ChatResponse |
stream(request) |
Yields ChatStreamChunks; used by nodes built with stream=True |
| Parsing of streamed tool calls | Parsing is lenient in two places: a delta without "index" belongs to the first tool call, and empty argument text means no arguments |
| A streamed tool call without a name or id | assemble_stream raises ModelProviderError |
| Arguments that are not a JSON object | That tool call fails: its arguments_error is set, and the tool does not run |