The Provider Problem
When I started Dairector, I only supported Ollama. It was simple: one provider, one API, one response format. Then I wanted to add OpenAI support. Then Anthropic. Then someone asked about Groq.
Each new provider required:
- A new class implementing the provider interface
- A new case in the factory switch statement
- New config validation logic
- New error handling for provider-specific errors
- New tests
The switch statement was the worst part. Every new provider meant modifying an existing file, which risked breaking existing providers.
The Registry Pattern
The fix was the ProviderRegistry — a plugin system that separates provider registration from provider usage:
interface ProviderDefinition {
id: string;
name: string;
description?: string;
factory: (config: ModelConfig) => IProvider;
builtIn?: boolean;
}
class ProviderRegistry {
private static instance: ProviderRegistry;
private providers = new Map<string, ProviderDefinition>();
private cache = new Map<string, IProvider>();
static getInstance(): ProviderRegistry {
if (!this.instance) {
this.instance = new ProviderRegistry();
}
return this.instance;
}
registerProvider(definition: ProviderDefinition): void {
this.providers.set(definition.id, definition);
}
createProvider(config: ModelConfig): IProvider {
// Check cache first
const cacheKey = `${config.provider}:${config.model}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)!;
}
// Find and instantiate
const definition = this.providers.get(config.provider);
if (!definition) {
throw new Error(`Unknown provider: ${config.provider}`);
}
const provider = definition.factory(config);
this.cache.set(cacheKey, provider);
return provider;
}
}
The Provider Interface
Every provider implements the same interface:
interface IProvider {
call(request: LLMRequest): Promise<LLMResponse>;
validateConfig(config: ModelConfig): void;
}
class OllamaProvider implements IProvider {
async call(request: LLMRequest): Promise<LLMResponse> {
const response = await fetch(`${this.baseUrl}/api/generate`, {
method: 'POST',
body: JSON.stringify({
model: this.model,
prompt: request.prompt,
stream: false
})
});
const data = await response.json();
return { content: data.response, usage: data.usage };
}
validateConfig(config: ModelConfig): void {
if (!config.baseUrl) {
throw new Error('Ollama requires a baseUrl');
}
}
}
class OpenAIProvider implements IProvider {
async call(request: LLMRequest): Promise<LLMResponse> {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: this.model,
messages: [{ role: 'user', content: request.prompt }]
})
});
const data = await response.json();
return { content: data.choices[0].message.content, usage: data.usage };
}
validateConfig(config: ModelConfig): void {
if (!config.apiKey) {
throw new Error('OpenAI requires an apiKey');
}
}
}
Multi-Model Per Stage
The registry enables a powerful feature: different models for different stages:
# dairector.config.yaml
stages:
idea:
provider: ollama
model: llama3
analyze:
provider: openai
model: gpt-4
slice:
provider: ollama
model: qwen2.5-coder
handoff:
provider: openai
model: gpt-4
execute:
provider: ollama
model: codellama
validate:
provider: openai
model: gpt-4
This is cost optimization in action. Use cheap local models for exploratory stages (IDEA, SLICE) and expensive cloud models for precision stages (ANALYZE, HANDOFF). The registry makes this seamless.
The Cache Strategy
Provider instances are cached to avoid re-initialization. But the cache is per-configuration, not per-provider. If you use the same model with different parameters, you get different instances.
class ProviderRegistry {
private cache = new Map<string, IProvider>();
createProvider(config: ModelConfig): IProvider {
const cacheKey = `${config.provider}:${config.model}:${JSON.stringify(config.options || {})}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)!;
}
const provider = this.instantiateProvider(config);
this.cache.set(cacheKey, provider);
return provider;
}
}
The Cost/Benefit
Building the registry pattern took about 2 days. Maintaining a switch statement would have taken 30 minutes per provider. The registry paid for itself after 4 providers.
But the real value isn’t in the math. It’s in the architecture. The registry makes the system feel extensible. Adding a provider doesn’t feel like a hack — it feels like using the system as designed.
Next post: The ChatBridge — How Natural Language Becomes System Commands