How aisuite's Async Support Works for Chat Completions and Tool Execution
aisuite implements async chat completions and tool execution through three coordinated layers: thread-offloaded provider APIs in Provider.achat_completions_create, coroutine-aware tool execution in Tools.aexecute_tool, and async orchestration in Completions.acreate.
The aisuite library provides a unified interface for multiple LLM providers, and its asynchronous architecture ensures that blocking I/O never stalls your event loop. This article explains exactly how the aisuite source code implements non-blocking chat completions and multi-turn tool execution.
Provider Async API: Thread-Offloaded Completion Methods
Every provider in aisuite inherits from the base Provider class in aisuite/provider.py. This class defines two key async methods that wrap synchronous SDK calls.
Single-Turn Async Completions
The achat_completions_create method in aisuite/provider.py (lines 34-44) provides an awaitable interface even when the underlying SDK is synchronous:
# aisuite/provider.py
async def achat_completions_create(self, model_id, messages, **kwargs):
"""Async wrapper for chat_completions_create using thread offloading."""
return await asyncio.to_thread(
self.chat_completions_create,
model_id,
messages,
**kwargs
)
The asyncio.to_thread call moves the blocking chat_completions_create into a worker thread, returning control to the event loop immediately.
Async Streaming Iterator
For streaming responses, achat_completions_create_stream (lines 58-89) converts any synchronous generator into an async iterator:
# aisuite/provider.py
async def achat_completions_create_stream(self, model_id, messages, **kwargs):
"""Convert synchronous streaming to async iterator via queue."""
queue = asyncio.Queue()
def sync_stream():
for chunk in self.chat_completions_create_stream(model_id, messages, **kwargs):
queue.put_nowait(chunk)
queue.put_nowait(None) # Sentinel
# Run synchronous generator in thread
asyncio.create_task(asyncio.to_thread(sync_stream))
# Yield chunks as they arrive
while True:
chunk = await queue.get()
if chunk is None:
break
yield chunk
Providers with native async support—such as OpenAI and Anthropic—can override these methods for true non-blocking I/O without thread overhead.
Tool Runner Async Wrapper: Coroutine-Aware Execution
The Tools class in aisuite/utils/tools.py handles tool execution that may involve both regular functions and async def coroutines.
Main Entry Point: aexecute_tool
The aexecute_tool method (lines 443-448) mirrors its synchronous counterpart:
# aisuite/utils/tools.py
async def aexecute_tool(self, tool_call, context=None):
"""Async version of execute_tool with full policy and tracing."""
# ... context preparation ...
result = await self._invoke_tool_async(tool_call, context)
# ... event emission and formatting ...
return result
Invocation Helper: _invoke_tool_async
The _invoke_tool_async method (lines 72-78) detects function type and dispatches accordingly:
# aisuite/utils/tools.py
async def _invoke_tool_async(self, tool_call, context):
"""Execute tool as coroutine or in thread based on inspection."""
tool_func = self._resolve_tool(tool_call)
if inspect.iscoroutinefunction(tool_func):
# Native async function: await directly
return await tool_func(**context.params)
else:
# Blocking function: offload to thread
return await asyncio.to_thread(tool_func, **context.params)
This design ensures that async tools run without thread overhead, while sync tools remain compatible without blocking the event loop.
Client Async Orchestration: Multi-Turn Tool Loops
The Completions class in aisuite/client.py coordinates the complete async flow, including multi-turn conversations with tool calls.
Entry Point: acreate
The acreate method (lines 84-95) branches based on whether tool execution is required:
# aisuite/client.py
async def acreate(self, model, messages, tools=None, max_turns=None, **kwargs):
"""Async entry point for chat completions."""
provider = self._resolve_provider(model)
if max_turns and tools:
# Multi-turn async tool loop
return await self._atool_runner(
provider, model, messages, tools, max_turns, **kwargs
)
# Single-turn async completion
return await provider.achat_completions_create(
model.split(":")[1], messages, **kwargs
)
Async Tool Loop: _atool_runner
The _atool_runner method (lines 95-115) implements the complete conversation loop:
- Awaits
provider.achat_completions_createfor each LLM call - Awaits
tools_instance.aexecute_toolfor each tool execution - Continues until
max_turnsis reached or no more tool calls are requested
# aisuite/client.py (conceptual structure)
async def _atool_runner(self, provider, model, messages, tools, max_turns, **kwargs):
tools_instance = Tools(tools)
for turn in range(max_turns):
response = await provider.achat_completions_create(
model_id, messages, tools=tools_instance.schema, **kwargs
)
if not response.tool_calls:
break
messages.append(response.message)
for tool_call in response.tool_calls:
result = await tools_instance.aexecute_tool(tool_call)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(result)
})
return response
Complete Working Example
Here is a fully asynchronous implementation using aisuite's async support:
import asyncio
from aisuite import Client
async def main():
client = Client({"openai": {"api_key": "sk-…"}})
# Single async completion (no tool loop)
resp = await client.chat.completions.acreate(
model="openai:gpt-4o-mini",
messages=[{"role": "user", "content": "What's the weather in Paris?"}],
)
print(resp.choices[0].message.content)
# Multi-turn async tool execution
def get_weather(city: str) -> str:
"""Return a fake weather report."""
return f"The weather in {city} is sunny."
async def fetch_forecast(city: str) -> dict:
"""Native async tool for API calls."""
# Imagine real async HTTP call here
return {"city": city, "temp": 22, "condition": "sunny"}
resp = await client.chat.completions.acreate(
model="openai:gpt-4o-mini",
messages=[{"role": "user", "content": "Compare weather in Berlin and Munich."}],
tools=[get_weather, fetch_forecast],
max_turns=3,
)
print(resp.choices[0].message.content)
asyncio.run(main())
Key Design Decisions
| Decision | Implementation | Benefit |
|---|---|---|
Thread offloading via asyncio.to_thread |
Provider.achat_completions_create wraps sync SDKs |
All providers gain async interface without code changes |
| Queue-based streaming | asyncio.Queue bridges sync generators to async iterators |
Unified streaming API regardless of provider capability |
| Runtime coroutine detection | inspect.iscoroutinefunction in Tools._invoke_tool_async |
Optimal execution path for each tool type |
| Mirrored sync/async paths | acreate/create, aexecute_tool/execute_tool |
Consistent mental model, easy migration |
Summary
- Provider layer:
Provider.achat_completions_createandachat_completions_create_streaminaisuite/provider.pyuseasyncio.to_threadandasyncio.Queueto make any sync SDK non-blocking - Tool layer:
Tools.aexecute_tooland_invoke_tool_asyncinaisuite/utils/tools.pydetect coroutines withinspect.iscoroutinefunctionand execute natively or in threads - Orchestration layer:
Completions.acreateand_atool_runnerinaisuite/client.pycoordinate multi-turn async conversations withawaiton both LLM calls and tool execution
Frequently Asked Questions
Does aisuite require providers to implement native async support?
No. The base Provider class automatically makes sync providers async through asyncio.to_thread. Native async providers can override achat_completions_create for better performance.
How does aisuite handle mixed sync and async tools in the same conversation?
The Tools._invoke_tool_async method inspects each tool at runtime. async def functions are awaited directly; regular functions are executed via asyncio.to_thread. This happens transparently for each tool call.
What is the performance impact of thread offloading?
Thread offloading adds minimal overhead for I/O-bound operations like LLM API calls. For CPU-bound tools, consider implementing them as async def with asyncio primitives, or use asyncio.to_thread for blocking CPU work to avoid stalling the event loop.
Can I use async streaming with any provider?
Yes. The base Provider.achat_completions_create_stream implementation works with any provider that has a synchronous streaming method. Providers with native async streaming can override this for zero-thread streaming.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →