How to Implement Thread-Safe Provider Initialization in aisuite
Use a per-client threading.RLock with double-checked locking in aisuite/client.py to guard the ProviderFactory.create_provider call, preventing race conditions when multiple threads concurrently initialize the same LLM provider for the first time.
The aisuite library by Andrew Ng enables unified access to multiple LLM providers through a lazy-loading architecture. When building multi-threaded applications with aisuite, concurrent calls to client.chat.completions.create() can trigger a race condition during provider initialization. This article demonstrates how to implement thread-safe provider initialization in aisuite by adding synchronization primitives to the Client class.
The Race Condition in Lazy Provider Initialization
aisuite creates concrete LLM-provider objects lazily—the first time a model is referenced, the Client loads the appropriate provider via ProviderFactory.create_provider. The core initialization logic lives in aisuite/client.py within the create method of the Completions class.
When multiple threads call client.chat.completions.create(...) concurrently, each thread may notice that the requested provider is missing in self.client.providers and attempt to instantiate it simultaneously. This race condition can lead to:
- Duplicate provider objects being created
- Inconsistent state if a provider performs one-time setup (e.g., establishing a session, loading credentials, or warming a cache)
- Unexpected
ImportErrororValueErrorwhen the module import or initialization is not re-entrant
The repository already flags this issue with a TODO comment in the source code:
# TODO: Add thread-safe provider initialization with lock to prevent race conditions
# when multiple threads try to initialize the same provider simultaneously.
Implementing Double-Checked Locking
The thread-safe fix introduces a per-client threading.RLock stored on the Client instance. Every accessor (chat, audio, etc.) shares the same synchronization primitive. The pattern uses double-checked locking: first check if the provider is present, acquire the lock, re-check, then create the provider if still absent.
This ensures only one thread performs the initialization while others wait or simply reuse the already-created instance. The lock scope is minimal—it protects only the creation block—so contention is negligible after the first initialization.
Code Implementation
Step 1: Add RLock to the Client Class
Modify aisuite/client.py to import threading and initialize the lock in the Client constructor:
# aisuite/client.py
import threading # ← new import
class Client:
def __init__(self, provider_configs: dict = {}, extra_param_mode: Literal["strict", "warn", "permissive"] = "warn"):
# ... existing initialization ...
self._provider_lock = threading.RLock() # ← thread-safe lock
# ...
Step 2: Guard Provider Creation in Completions.create
Update the Completions.create method (around lines 78-88) to use the lock when calling ProviderFactory.create_provider:
# aisuite/client.py – inside Completions.create
if provider_key not in self.client.providers:
# Double-checked locking
with self.client._provider_lock:
if provider_key not in self.client.providers: # re-check after acquiring lock
config = self.client.provider_configs.get(provider_key, {})
self.client.providers[provider_key] = ProviderFactory.create_provider(
provider_key, config
)
The surrounding validation logic (lines 66-78) remains unchanged. The lock only wraps the instantiation call to ProviderFactory.create_provider.
Step 3: Multi-Threaded Usage Example
The following example demonstrates safe concurrent access from multiple threads:
import threading
from aisuite import Client
client = Client(provider_configs={"openai": {"api_key": "..."}})
def worker(i):
response = client.chat.completions.create(
model="openai:gpt-4",
messages=[{"role": "user", "content": f"hello from {i}"}],
max_turns=0,
)
print(f"Thread {i}: {response.choices[0].message.content}")
threads = [threading.Thread(target=worker, args=(n,)) for n in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
All five threads safely share the same underlying OpenAI provider instance without race conditions.
Key Files and Architectural Flow
The implementation spans these critical files in the andrewyng/aisuite repository:
aisuite/provider.py: DefinesProviderFactoryand the abstractProviderbase class. Thecreate_providermethod is the synchronization target.aisuite/client.py: Contains theClientclass (where the lock is added) and theCompletions.createmethod (lines 66-88) where provider keys are parsed and validated againstProviderFactory.get_supported_providers().tests/client/test_manual_tool_calling.py: Unit tests exercising provider creation that validate the thread-safe behavior.tests/provider/test_provider.py: Tests the genericProviderFactorylogic.
The architectural flow follows this sequence:
- User calls
client.chat.completions.create(model="openai:gpt-4", ...) Completions.createparses theprovider_keyfrom the model string (lines 66-71)- Validates the key against
ProviderFactory.get_supported_providers()(lines 72-78) - Acquires the
RLockand checks theprovidersdictionary again - Calls
ProviderFactory.create_provideronly if the provider is still absent
Summary
- The race condition occurs in
aisuite/client.pywhen multiple threads concurrently trigger lazy provider initialization viaProviderFactory.create_provider. - Adding a
threading.RLockto theClientclass provides thread-safe synchronization without requiring global locks or process-wide mutexes. - Implement double-checked locking to minimize contention and ensure that initialization side-effects (network sessions, credential loading) happen exactly once.
- The lock scope is limited to the initialization block, preserving the library's lazy-loading design and maintaining testability.
Frequently Asked Questions
What causes the race condition in aisuite provider initialization?
The race condition occurs because aisuite uses lazy initialization. When multiple threads call client.chat.completions.create() simultaneously before a provider exists in self.client.providers, each thread attempts to invoke ProviderFactory.create_provider for the same provider key. This results in duplicate object creation and potential inconsistent state if the provider performs non-idempotent setup operations.
Why use RLock instead of a standard Lock in aisuite?
Using threading.RLock (reentrant lock) allows the same thread to acquire the lock multiple times without deadlocking, which provides safer synchronization if the initialization code path involves nested calls that might also try to acquire the lock. For the Client class in aisuite, this prevents potential deadlocks in complex scenarios while still blocking other threads from concurrent initialization.
Does thread-safe initialization affect performance?
No. The lock only guards the provider creation block, which executes exactly once per provider per Client instance. After initialization, subsequent threads simply perform a dictionary lookup on self.client.providers without acquiring the lock, resulting in negligible overhead during normal operation.
Where exactly should the lock be placed in the source code?
Add the threading.RLock instance in aisuite/client.py within the Client.__init__ method as self._provider_lock. Then use it in the Completions.create method around lines 78-88 where ProviderFactory.create_provider is called, following the double-checked locking pattern: check existence, acquire lock, re-check existence, then create.
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 →