Compare commits

...
2 Commits
Author SHA1 Message Date
Mervin Praison bf3fdd10f1 Release v2.7.9 2025-12-30 11:15:36 +00:00
Mervin Praison 4fdc51c6ed Add provider registry system with custom provider support
Implements extensible provider registry allowing users to register custom providers (e.g., Cloudflare, Ollama) alongside built-in providers. Updates `providers` CLI command to show registered/builtin/custom providers with `list` and `info` subcommands. Enhances `createProvider()` to accept provider instances, spec objects, or strings. Updates `hasApiKey()` to support provider aliases and custom env vars. Exports registry functions (`register
2025-12-30 11:04:31 +00:00
17 changed files with 2265 additions and 73 deletions

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.7.8" \
"praisonai>=2.7.9" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.7.8" \
"praisonai>=2.7.9" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.7.8" \
"praisonai>=2.7.9" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -0,0 +1,225 @@
"""
Custom Provider Registry Example (Python)
This example demonstrates how to register and use custom LLM providers
with the PraisonAI Python wrapper.
Note: The Python provider registry is for custom provider extensions.
Built-in providers (OpenAI, Anthropic, Google) are handled by LiteLLM
in praisonaiagents automatically.
"""
import sys
sys.path.insert(0, '../../src/praisonai')
from praisonai.llm import (
LLMProviderRegistry,
register_llm_provider,
unregister_llm_provider,
has_llm_provider,
list_llm_providers,
create_llm_provider,
get_default_llm_registry,
parse_model_string
)
# Example 1: Simple Custom Provider
# ---------------------------------
class SimpleCustomProvider:
"""A minimal custom provider example."""
provider_id = "simple-custom"
def __init__(self, model_id: str, config: dict = None):
self.model_id = model_id
self.config = config or {}
self.api_endpoint = self.config.get('api_endpoint', 'https://api.example.com')
def generate(self, prompt: str) -> str:
"""Generate a response (simulated)."""
print(f"[SimpleCustomProvider] Generating with model: {self.model_id}")
print(f"[SimpleCustomProvider] Prompt: {prompt[:50]}...")
return f"Response from {self.provider_id}/{self.model_id}: Hello! This is a simulated response."
# Example 2: Ollama Provider
# --------------------------
class OllamaProvider:
"""Custom provider for local Ollama integration."""
provider_id = "ollama"
def __init__(self, model_id: str, config: dict = None):
self.model_id = model_id
self.config = config or {}
self.base_url = self.config.get('base_url', 'http://localhost:11434')
def generate(self, prompt: str) -> str:
"""Generate a response using Ollama API."""
import requests
response = requests.post(
f"{self.base_url}/api/generate",
json={
"model": self.model_id,
"prompt": prompt,
"stream": False
}
)
if response.status_code != 200:
raise Exception(f"Ollama API error: {response.text}")
return response.json().get("response", "")
def generate_stream(self, prompt: str):
"""Generate a streaming response using Ollama API."""
import requests
response = requests.post(
f"{self.base_url}/api/generate",
json={
"model": self.model_id,
"prompt": prompt,
"stream": True
},
stream=True
)
for line in response.iter_lines():
if line:
import json
data = json.loads(line)
yield data.get("response", "")
if data.get("done"):
break
# Example 3: Cloudflare Workers AI Provider
# -----------------------------------------
class CloudflareProvider:
"""Custom provider for Cloudflare Workers AI."""
provider_id = "cloudflare"
def __init__(self, model_id: str, config: dict = None):
self.model_id = model_id
self.config = config or {}
self.account_id = self.config.get('account_id')
self.api_token = self.config.get('api_token')
def generate(self, prompt: str) -> str:
"""Generate a response using Cloudflare Workers AI."""
import requests
if not self.account_id or not self.api_token:
raise ValueError("Cloudflare account_id and api_token are required")
response = requests.post(
f"https://api.cloudflare.com/client/v4/accounts/{self.account_id}/ai/run/{self.model_id}",
headers={
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json"
},
json={"prompt": prompt}
)
if response.status_code != 200:
raise Exception(f"Cloudflare API error: {response.text}")
return response.json().get("result", {}).get("response", "")
def main():
print("=== Provider Registry Example (Python) ===\n")
# Check initial state
print("Initial providers:", list_llm_providers())
print()
# Register custom providers
print("Registering custom providers...")
register_llm_provider("simple-custom", SimpleCustomProvider)
register_llm_provider("ollama", OllamaProvider, aliases=["local"])
register_llm_provider("cloudflare", CloudflareProvider, aliases=["cf", "workers-ai"])
print()
# Check providers after registration
print("Providers after registration:", list_llm_providers())
print("Has ollama:", has_llm_provider("ollama"))
print("Has local (alias):", has_llm_provider("local"))
print("Has cloudflare:", has_llm_provider("cloudflare"))
print("Has cf (alias):", has_llm_provider("cf"))
print()
# Parse model strings
print("=== Model String Parsing ===\n")
test_strings = [
"openai/gpt-4o-mini",
"gpt-4o-mini",
"claude-3-5-sonnet-latest",
"gemini-2.0-flash",
"ollama/llama2",
"cloudflare/workers-ai-model"
]
for model_str in test_strings:
parsed = parse_model_string(model_str)
print(f" '{model_str}' -> provider={parsed['provider_id']}, model={parsed['model_id']}")
print()
# Create and use providers
print("=== Using Custom Providers ===\n")
# Use simple custom provider
provider = create_llm_provider("simple-custom/test-model")
print(f"Created provider: {provider.provider_id}/{provider.model_id}")
response = provider.generate("Hello, world!")
print(f"Response: {response}")
print()
# Use ollama provider via alias
provider = create_llm_provider("local/llama2", config={"base_url": "http://localhost:11434"})
print(f"Created provider: {provider.provider_id}/{provider.model_id}")
print()
# Demonstrate error handling
print("=== Error Handling ===\n")
try:
create_llm_provider("unknown-provider/model")
except ValueError as e:
print(f"Expected error: {e}")
print()
# Demonstrate isolated registries
print("=== Isolated Registries ===\n")
# Create isolated registry
isolated_registry = LLMProviderRegistry()
isolated_registry.register("isolated-provider", SimpleCustomProvider)
print(f"Default registry providers: {list_llm_providers()}")
print(f"Isolated registry providers: {isolated_registry.list()}")
# Use isolated registry
provider = create_llm_provider("isolated-provider/model", registry=isolated_registry)
print(f"Created from isolated registry: {provider.provider_id}/{provider.model_id}")
print()
# Cleanup
print("=== Cleanup ===\n")
unregister_llm_provider("simple-custom")
unregister_llm_provider("ollama")
unregister_llm_provider("cloudflare")
print(f"Providers after cleanup: {list_llm_providers()}")
print("\n=== Example Complete ===")
if __name__ == "__main__":
main()
@@ -0,0 +1,239 @@
/**
* Custom Provider Registry Example
*
* This example demonstrates how to register and use custom LLM providers
* with the PraisonAI TypeScript SDK.
*/
import {
registerProvider,
createProvider,
BaseProvider,
listProviders,
hasProvider,
type ProviderConfig,
type GenerateTextOptions,
type GenerateTextResult,
type StreamTextOptions,
type StreamChunk,
type GenerateObjectOptions,
type GenerateObjectResult,
type Message,
type ToolDefinition
} from 'praisonai';
// Example 1: Simple Custom Provider
// ---------------------------------
// A minimal custom provider that wraps a hypothetical API
class SimpleCustomProvider extends BaseProvider {
readonly providerId = 'simple-custom';
private apiEndpoint: string;
constructor(modelId: string, config?: ProviderConfig) {
super(modelId, config);
this.apiEndpoint = (config as any)?.apiEndpoint || 'https://api.example.com';
}
async generateText(options: GenerateTextOptions): Promise<GenerateTextResult> {
// In a real implementation, you would call your API here
console.log(`[SimpleCustomProvider] Generating text with model: ${this.modelId}`);
console.log(`[SimpleCustomProvider] Messages:`, options.messages);
// Simulated response
return {
text: `Response from ${this.providerId}/${this.modelId}: Hello! This is a simulated response.`,
usage: {
promptTokens: 10,
completionTokens: 20,
totalTokens: 30
}
};
}
async *streamText(options: StreamTextOptions): AsyncGenerator<StreamChunk> {
const words = ['Hello', 'from', 'streaming', 'response!'];
for (const word of words) {
yield { text: word + ' ', done: false };
await new Promise(resolve => setTimeout(resolve, 100));
}
yield { text: '', done: true };
}
async generateObject<T>(options: GenerateObjectOptions<T>): Promise<GenerateObjectResult<T>> {
// Simulated structured output
return {
object: { message: 'Structured response' } as T,
usage: {
promptTokens: 10,
completionTokens: 20,
totalTokens: 30
}
};
}
formatTools(tools: ToolDefinition[]): any[] {
return tools;
}
formatMessages(messages: Message[]): any[] {
return messages;
}
}
// Example 2: Ollama Provider
// --------------------------
// A more realistic example for local Ollama integration
class OllamaProvider extends BaseProvider {
readonly providerId = 'ollama';
private baseUrl: string;
constructor(modelId: string, config?: ProviderConfig) {
super(modelId, config);
this.baseUrl = (config as any)?.baseUrl || 'http://localhost:11434';
}
async generateText(options: GenerateTextOptions): Promise<GenerateTextResult> {
const response = await fetch(`${this.baseUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelId,
prompt: options.messages.map(m => `${m.role}: ${m.content}`).join('\n'),
stream: false
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.statusText}`);
}
const data = await response.json();
return {
text: data.response,
usage: {
promptTokens: data.prompt_eval_count || 0,
completionTokens: data.eval_count || 0,
totalTokens: (data.prompt_eval_count || 0) + (data.eval_count || 0)
}
};
}
async *streamText(options: StreamTextOptions): AsyncGenerator<StreamChunk> {
const response = await fetch(`${this.baseUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelId,
prompt: options.messages.map(m => `${m.role}: ${m.content}`).join('\n'),
stream: true
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const data = JSON.parse(line);
yield { text: data.response || '', done: data.done || false };
} catch {
// Skip invalid JSON
}
}
}
}
async generateObject<T>(options: GenerateObjectOptions<T>): Promise<GenerateObjectResult<T>> {
const result = await this.generateText({
messages: [
...options.messages,
{ role: 'system', content: `Respond with valid JSON matching this schema: ${JSON.stringify(options.schema)}` }
]
});
return {
object: JSON.parse(result.text) as T,
usage: result.usage
};
}
formatTools(tools: ToolDefinition[]): any[] {
return tools;
}
formatMessages(messages: Message[]): any[] {
return messages;
}
}
// Main Example
// ------------
async function main() {
console.log('=== Provider Registry Example ===\n');
// Check initial providers
console.log('Initial providers:', listProviders());
console.log('Has openai:', hasProvider('openai'));
console.log('Has ollama:', hasProvider('ollama'));
console.log();
// Register custom providers
console.log('Registering custom providers...');
registerProvider('simple-custom', SimpleCustomProvider);
registerProvider('ollama', OllamaProvider, { aliases: ['local'] });
console.log();
// Check providers after registration
console.log('Providers after registration:', listProviders());
console.log('Has ollama:', hasProvider('ollama'));
console.log('Has local (alias):', hasProvider('local'));
console.log();
// Create and use providers
console.log('=== Using Custom Providers ===\n');
// Use simple custom provider
const simpleProvider = createProvider('simple-custom/test-model');
console.log(`Created provider: ${simpleProvider.providerId}/${simpleProvider.modelId}`);
const simpleResult = await simpleProvider.generateText({
messages: [{ role: 'user', content: 'Hello!' }]
});
console.log('Response:', simpleResult.text);
console.log('Usage:', simpleResult.usage);
console.log();
// Use ollama provider via alias
const ollamaProvider = createProvider('local/llama2', {
baseUrl: 'http://localhost:11434'
} as any);
console.log(`Created provider: ${ollamaProvider.providerId}/${ollamaProvider.modelId}`);
console.log();
// Demonstrate error handling for unknown provider
console.log('=== Error Handling ===\n');
try {
createProvider('unknown-provider/model');
} catch (error: any) {
console.log('Expected error:', error.message);
}
console.log('\n=== Example Complete ===');
}
main().catch(console.error);
+122 -36
View File
@@ -1,11 +1,23 @@
/**
* Providers command - List available LLM providers
* Providers command - List and manage LLM providers
*
* Supports:
* - list: List all registered providers
* - register: Register a custom provider (programmatic only)
* - info: Show provider details
*/
import { getAvailableProviders } from '../../llm/providers';
import {
getAvailableProviders,
listProviders,
hasProvider,
getDefaultRegistry,
isProviderAvailable
} from '../../llm/providers';
import { hasApiKey } from '../runtime/env';
import { outputJson, formatSuccess } from '../output/json';
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { ERROR_CODES } from '../output/errors';
export interface ProvidersOptions {
verbose?: boolean;
@@ -15,11 +27,15 @@ export interface ProvidersOptions {
interface ProviderInfo {
name: string;
registered: boolean;
available: boolean;
hasApiKey: boolean;
isBuiltin: boolean;
models?: string[];
}
const BUILTIN_PROVIDERS = ['openai', 'anthropic', 'google', 'gemini', 'oai', 'claude'];
const PROVIDER_MODELS: Record<string, string[]> = {
openai: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'],
anthropic: ['claude-sonnet-4-20250514', 'claude-3-5-sonnet-20241022', 'claude-3-opus-20240229', 'claude-3-haiku-20240307'],
@@ -27,58 +43,128 @@ const PROVIDER_MODELS: Record<string, string[]> = {
};
export async function execute(args: string[], options: ProvidersOptions): Promise<void> {
const subcommand = args[0] || 'list';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
switch (subcommand) {
case 'list':
await listProvidersCommand(options, outputFormat);
break;
case 'info':
await providerInfoCommand(args[1], options, outputFormat);
break;
default:
await listProvidersCommand(options, outputFormat);
}
}
async function listProvidersCommand(options: ProvidersOptions, outputFormat: string): Promise<void> {
const registry = getDefaultRegistry();
const registeredProviders = registry.list();
const availableProviders = getAvailableProviders();
const providers: ProviderInfo[] = [
{
name: 'openai',
available: availableProviders.includes('openai'),
hasApiKey: hasApiKey('openai'),
models: PROVIDER_MODELS.openai
},
{
name: 'anthropic',
available: availableProviders.includes('anthropic'),
hasApiKey: hasApiKey('anthropic'),
models: PROVIDER_MODELS.anthropic
},
{
name: 'google',
available: availableProviders.includes('google'),
hasApiKey: hasApiKey('google'),
models: PROVIDER_MODELS.google
}
];
const providers: ProviderInfo[] = registeredProviders.map(name => {
const isBuiltin = BUILTIN_PROVIDERS.includes(name);
return {
name,
registered: true,
available: isProviderAvailable(name),
hasApiKey: hasApiKey(name),
isBuiltin,
models: PROVIDER_MODELS[name]
};
});
if (outputFormat === 'json') {
outputJson(formatSuccess({
providers: providers.map(p => ({
name: p.name,
registered: p.registered,
available: p.available,
has_api_key: p.hasApiKey,
is_builtin: p.isBuiltin,
models: options.verbose ? p.models : undefined
}))
})),
total: providers.length,
builtin_count: providers.filter(p => p.isBuiltin).length,
custom_count: providers.filter(p => !p.isBuiltin).length
}));
} else {
await pretty.heading('Available Providers');
await pretty.heading('Registered LLM Providers');
for (const provider of providers) {
const status = provider.available ? '✅' : '❌';
const keyStatus = provider.hasApiKey ? '🔑' : '⚠️ No API key';
await pretty.plain(` ${status} ${provider.name} ${keyStatus}`);
if (options.verbose && provider.models) {
for (const model of provider.models) {
await pretty.dim(` - ${model}`);
// Built-in providers
const builtinProviders = providers.filter(p => p.isBuiltin);
if (builtinProviders.length > 0) {
await pretty.plain('\n Built-in:');
for (const provider of builtinProviders) {
const status = provider.available ? '✅' : '❌';
const keyStatus = provider.hasApiKey ? '🔑' : '⚠️ No API key';
await pretty.plain(` ${status} ${provider.name} ${keyStatus}`);
if (options.verbose && provider.models) {
for (const model of provider.models) {
await pretty.dim(` - ${model}`);
}
}
}
}
// Custom providers
const customProviders = providers.filter(p => !p.isBuiltin);
if (customProviders.length > 0) {
await pretty.plain('\n Custom:');
for (const provider of customProviders) {
const status = provider.available ? '✅' : '❌';
await pretty.plain(` ${status} ${provider.name}`);
}
}
await pretty.newline();
await pretty.info('Register custom providers with registerProvider():');
await pretty.dim(' import { registerProvider } from "praisonai";');
await pretty.dim(' registerProvider("cloudflare", CloudflareProvider);');
await pretty.newline();
await pretty.info('Set API keys via environment variables:');
await pretty.dim(' OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY');
}
}
async function providerInfoCommand(name: string | undefined, options: ProvidersOptions, outputFormat: string): Promise<void> {
if (!name) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Provider name required'));
} else {
await pretty.error('Provider name required. Usage: providers info <name>');
}
return;
}
const registry = getDefaultRegistry();
const isRegistered = registry.has(name);
const isBuiltin = BUILTIN_PROVIDERS.includes(name.toLowerCase());
const available = isProviderAvailable(name);
const models = PROVIDER_MODELS[name.toLowerCase()];
if (outputFormat === 'json') {
outputJson(formatSuccess({
name,
registered: isRegistered,
available,
is_builtin: isBuiltin,
has_api_key: hasApiKey(name),
models: models || []
}));
} else {
await pretty.heading(`Provider: ${name}`);
await pretty.plain(` Registered: ${isRegistered ? '✅ Yes' : '❌ No'}`);
await pretty.plain(` Available: ${available ? '✅ Yes' : '❌ No'}`);
await pretty.plain(` Type: ${isBuiltin ? 'Built-in' : 'Custom'}`);
await pretty.plain(` API Key: ${hasApiKey(name) ? '🔑 Set' : '⚠️ Not set'}`);
if (models && models.length > 0) {
await pretty.plain('\n Models:');
for (const model of models) {
await pretty.dim(` - ${model}`);
}
}
}
}
+9 -3
View File
@@ -29,16 +29,22 @@ export function loadEnvConfig(): EnvConfig {
return config;
}
export function hasApiKey(provider: 'openai' | 'anthropic' | 'google'): boolean {
switch (provider) {
export function hasApiKey(provider: string): boolean {
const normalizedProvider = provider.toLowerCase();
switch (normalizedProvider) {
case 'openai':
case 'oai':
return !!process.env[ENV_VARS.OPENAI_API_KEY];
case 'anthropic':
case 'claude':
return !!process.env[ENV_VARS.ANTHROPIC_API_KEY];
case 'google':
case 'gemini':
return !!process.env[ENV_VARS.GOOGLE_API_KEY];
default:
return false;
// For custom providers, check for provider-specific env var
const envVarName = `${normalizedProvider.toUpperCase()}_API_KEY`;
return !!process.env[envVarName];
}
}
+18
View File
@@ -225,18 +225,36 @@ export {
// Export providers with explicit names to avoid conflicts
export {
// Provider factory and utilities
createProvider,
getDefaultProvider,
parseModelString,
isProviderAvailable,
getAvailableProviders,
// Provider classes
OpenAIProvider,
AnthropicProvider,
GoogleProvider,
BaseProvider,
// Provider registry (extensibility API)
ProviderRegistry,
registerProvider,
unregisterProvider,
hasProvider,
listProviders,
getDefaultRegistry,
createProviderRegistry,
registerBuiltinProviders,
// Types
type LLMProvider,
type ProviderConfig,
type ProviderFactory,
type ProviderConstructor,
type ProviderLoader,
type RegisterOptions,
type IProviderRegistry,
type ProviderInput,
type CreateProviderOptions,
type GenerateTextOptions,
type GenerateTextResult,
type StreamTextOptions,
+131 -27
View File
@@ -1,5 +1,18 @@
/**
* LLM Providers - Factory and exports for multi-provider support
*
* This module provides an extensible provider registry system that allows
* users to register custom providers (e.g., Cloudflare, Ollama) alongside
* the built-in providers (OpenAI, Anthropic, Google).
*
* @example Register a custom provider
* ```typescript
* import { registerProvider, Agent } from 'praisonai';
* import { CloudflareProvider } from './my-cloudflare-provider';
*
* registerProvider('cloudflare', CloudflareProvider);
* const agent = new Agent({ llm: 'cloudflare/workers-ai' });
* ```
*/
export * from './types';
@@ -8,20 +21,25 @@ export { OpenAIProvider } from './openai';
export { AnthropicProvider } from './anthropic';
export { GoogleProvider } from './google';
import type { LLMProvider, ProviderConfig } from './types';
import { OpenAIProvider } from './openai';
import { AnthropicProvider } from './anthropic';
import { GoogleProvider } from './google';
// Export registry types and functions
export {
ProviderRegistry,
ProviderConstructor,
ProviderLoader,
RegisterOptions,
IProviderRegistry,
getDefaultRegistry,
createProviderRegistry,
registerProvider,
unregisterProvider,
hasProvider,
listProviders,
registerBuiltinProviders,
} from './registry';
/**
* Provider registry for dynamic provider loading
*/
const PROVIDER_MAP: Record<string, new (modelId: string, config?: ProviderConfig) => LLMProvider> = {
openai: OpenAIProvider,
anthropic: AnthropicProvider,
google: GoogleProvider,
gemini: GoogleProvider, // Alias
};
import type { LLMProvider, ProviderConfig } from './types';
import { getDefaultRegistry } from './registry';
import type { ProviderConstructor, ProviderRegistry } from './registry';
/**
* Parse model string into provider and model ID
@@ -51,25 +69,99 @@ export function parseModelString(model: string): { providerId: string; modelId:
}
/**
* Create a provider instance from a model string
* Input types for createProvider
*/
export type ProviderInput =
| string // "provider/model" or "model"
| LLMProvider // Already instantiated provider
| { name: string; modelId?: string; config?: ProviderConfig }; // Spec object
/**
* Options for createProvider
*/
export interface CreateProviderOptions {
/** Override the default registry */
registry?: ProviderRegistry;
/** Provider configuration */
config?: ProviderConfig;
/** Model ID (used when input is a constructor or spec without modelId) */
modelId?: string;
}
/**
* Create a provider instance from various input types
*
* @example
* @example String input (backward compatible)
* ```typescript
* const provider = createProvider('openai/gpt-4o');
* const provider = createProvider('anthropic/claude-3-5-sonnet-latest');
* const provider = createProvider('google/gemini-2.0-flash');
* const provider = createProvider('gpt-4o-mini'); // Defaults to OpenAI
* ```
*
* @example Custom provider (after registration)
* ```typescript
* registerProvider('cloudflare', CloudflareProvider);
* const provider = createProvider('cloudflare/workers-ai');
* ```
*
* @example Provider instance (pass-through)
* ```typescript
* const myProvider = new CustomProvider('model');
* const provider = createProvider(myProvider); // Returns same instance
* ```
*
* @example Spec object
* ```typescript
* const provider = createProvider({ name: 'openai', modelId: 'gpt-4o', config: { timeout: 5000 } });
* ```
*/
export function createProvider(model: string, config?: ProviderConfig): LLMProvider {
const { providerId, modelId } = parseModelString(model);
const ProviderClass = PROVIDER_MAP[providerId];
if (!ProviderClass) {
throw new Error(`Unknown provider: ${providerId}. Available providers: ${Object.keys(PROVIDER_MAP).join(', ')}`);
export function createProvider(
input: ProviderInput,
options?: CreateProviderOptions | ProviderConfig
): LLMProvider {
// Handle legacy signature: createProvider(model, config)
const opts: CreateProviderOptions = options && 'registry' in options
? options
: { config: options as ProviderConfig | undefined };
const registry = opts.registry || getDefaultRegistry();
// Case 1: Already a provider instance - pass through
if (isProviderInstance(input)) {
return input;
}
return new ProviderClass(modelId, config);
// Case 2: Spec object
if (typeof input === 'object' && 'name' in input) {
const { name, modelId, config } = input;
return registry.resolve(name, modelId || 'default', config || opts.config);
}
// Case 3: String - parse and resolve
if (typeof input === 'string') {
const { providerId, modelId } = parseModelString(input);
return registry.resolve(providerId, modelId, opts.config);
}
throw new Error(
`Invalid provider input. Expected string, provider instance, or spec object. ` +
`Got: ${typeof input}`
);
}
/**
* Type guard to check if value is a provider instance
*/
function isProviderInstance(value: unknown): value is LLMProvider {
return (
typeof value === 'object' &&
value !== null &&
'providerId' in value &&
'modelId' in value &&
'generateText' in value &&
typeof (value as any).generateText === 'function'
);
}
/**
@@ -83,22 +175,34 @@ export function getDefaultProvider(config?: ProviderConfig): LLMProvider {
* Check if a provider is available (has required API key)
*/
export function isProviderAvailable(providerId: string): boolean {
switch (providerId.toLowerCase()) {
const normalizedId = providerId.toLowerCase();
// Check if provider is registered
if (!getDefaultRegistry().has(normalizedId)) {
return false;
}
// Check for API keys based on known providers
switch (normalizedId) {
case 'openai':
case 'oai':
return !!process.env.OPENAI_API_KEY;
case 'anthropic':
case 'claude':
return !!process.env.ANTHROPIC_API_KEY;
case 'google':
case 'gemini':
return !!process.env.GOOGLE_API_KEY;
default:
return false;
// For custom providers, assume available if registered
// Users can override this behavior
return true;
}
}
/**
* Get list of available providers
* Get list of available providers (registered and with API keys)
*/
export function getAvailableProviders(): string[] {
return Object.keys(PROVIDER_MAP).filter(isProviderAvailable);
return getDefaultRegistry().list().filter(isProviderAvailable);
}
@@ -0,0 +1,367 @@
/**
* Provider Registry - Extensible provider registration system
*
* Fixes Issue #1095: Allows users to register custom providers
* that can be resolved by name via createProvider().
*/
import type { LLMProvider, ProviderConfig } from './types';
/**
* Provider constructor type
*/
export type ProviderConstructor = new (modelId: string, config?: ProviderConfig) => LLMProvider;
/**
* Lazy loader function that returns a provider constructor
* Used for tree-shaking and lazy loading of provider implementations
*/
export type ProviderLoader = () => ProviderConstructor;
/**
* Options for registering a provider
*/
export interface RegisterOptions {
/** Allow overwriting an existing registration */
override?: boolean;
/** Additional names that resolve to this provider */
aliases?: string[];
}
/**
* Internal entry in the registry
*/
interface RegistryEntry {
/** The provider constructor or loader */
provider: ProviderConstructor | ProviderLoader;
/** Whether this is a loader function (vs direct constructor) */
isLoader: boolean;
/** Cached constructor if loaded from a loader */
cachedConstructor?: ProviderConstructor;
/** The canonical name (for aliases) */
canonicalName: string;
}
/**
* Provider Registry Interface
*/
export interface IProviderRegistry {
register(name: string, provider: ProviderConstructor | ProviderLoader, options?: RegisterOptions): void;
unregister(name: string): boolean;
has(name: string): boolean;
list(): string[];
resolve(name: string, modelId: string, config?: ProviderConfig): LLMProvider;
get(name: string): ProviderConstructor | ProviderLoader | undefined;
}
/**
* Provider Registry Implementation
*
* Manages registration and resolution of LLM providers by name.
* Supports lazy loading, aliases, and isolated instances.
*/
export class ProviderRegistry implements IProviderRegistry {
private entries: Map<string, RegistryEntry> = new Map();
private aliases: Map<string, string> = new Map(); // alias -> canonical name
/**
* Register a provider by name
*
* @param name - Provider name (e.g., 'cloudflare', 'ollama')
* @param provider - Provider constructor or lazy loader function
* @param options - Registration options
* @throws Error if name is already registered (unless override: true)
*/
register(
name: string,
provider: ProviderConstructor | ProviderLoader,
options: RegisterOptions = {}
): void {
const normalizedName = name.toLowerCase();
// Check for existing registration
if (this.entries.has(normalizedName) && !options.override) {
throw new Error(
`Provider '${name}' is already registered. ` +
`Use { override: true } to replace it.`
);
}
// Determine if this is a loader function or direct constructor
const isLoader = this.isLoaderFunction(provider);
const entry: RegistryEntry = {
provider,
isLoader,
canonicalName: normalizedName,
};
this.entries.set(normalizedName, entry);
// Register aliases
if (options.aliases) {
for (const alias of options.aliases) {
const normalizedAlias = alias.toLowerCase();
if (this.entries.has(normalizedAlias) && !options.override) {
throw new Error(
`Alias '${alias}' conflicts with existing provider. ` +
`Use { override: true } to replace it.`
);
}
this.aliases.set(normalizedAlias, normalizedName);
}
}
}
/**
* Unregister a provider by name
*
* @param name - Provider name to unregister
* @returns true if provider was unregistered, false if not found
*/
unregister(name: string): boolean {
const normalizedName = name.toLowerCase();
// Check if it's an alias
if (this.aliases.has(normalizedName)) {
this.aliases.delete(normalizedName);
return true;
}
// Check if it's a canonical name
if (this.entries.has(normalizedName)) {
// Remove all aliases pointing to this provider
for (const [alias, canonical] of this.aliases.entries()) {
if (canonical === normalizedName) {
this.aliases.delete(alias);
}
}
this.entries.delete(normalizedName);
return true;
}
return false;
}
/**
* Check if a provider is registered
*
* @param name - Provider name to check
* @returns true if provider is registered
*/
has(name: string): boolean {
const normalizedName = name.toLowerCase();
return this.entries.has(normalizedName) || this.aliases.has(normalizedName);
}
/**
* List all registered provider names (canonical names only)
*
* @returns Array of provider names
*/
list(): string[] {
return Array.from(this.entries.keys());
}
/**
* List all names including aliases
*
* @returns Array of all registered names and aliases
*/
listAll(): string[] {
return [...this.entries.keys(), ...this.aliases.keys()];
}
/**
* Resolve a provider by name, creating an instance
*
* @param name - Provider name
* @param modelId - Model ID to pass to constructor
* @param config - Optional provider config
* @returns Provider instance
* @throws Error if provider not found
*/
resolve(name: string, modelId: string, config?: ProviderConfig): LLMProvider {
const normalizedName = name.toLowerCase();
// Resolve alias to canonical name
const canonicalName = this.aliases.get(normalizedName) || normalizedName;
const entry = this.entries.get(canonicalName);
if (!entry) {
const available = this.list();
throw new Error(
`Unknown provider: '${name}'. ` +
`Available providers: ${available.length > 0 ? available.join(', ') : 'none'}. ` +
`Register a custom provider with registerProvider('${name}', YourProviderClass).`
);
}
// Get constructor (resolve loader if needed)
const Constructor = this.getConstructor(entry);
return new Constructor(modelId, config);
}
/**
* Get the provider constructor/loader without instantiating
*
* @param name - Provider name
* @returns Provider constructor/loader or undefined
*/
get(name: string): ProviderConstructor | ProviderLoader | undefined {
const normalizedName = name.toLowerCase();
const canonicalName = this.aliases.get(normalizedName) || normalizedName;
return this.entries.get(canonicalName)?.provider;
}
/**
* Get the resolved constructor for an entry
*/
private getConstructor(entry: RegistryEntry): ProviderConstructor {
if (!entry.isLoader) {
return entry.provider as ProviderConstructor;
}
// Lazy load and cache
if (!entry.cachedConstructor) {
const loader = entry.provider as ProviderLoader;
entry.cachedConstructor = loader();
}
return entry.cachedConstructor;
}
/**
* Determine if a value is a loader function vs a constructor
* Loaders are arrow functions or regular functions that return a class
* Constructors have a prototype with constructor
*/
private isLoaderFunction(value: ProviderConstructor | ProviderLoader): boolean {
// If it has a prototype with more than just constructor, it's likely a class
if (value.prototype && Object.getOwnPropertyNames(value.prototype).length > 1) {
return false;
}
// Check if it looks like a class (has 'prototype' that is an object with constructor)
if (value.prototype && value.prototype.constructor === value) {
// Additional check: classes typically have a name and their toString starts with 'class'
const str = value.toString();
if (str.startsWith('class ') || str.includes('function ')) {
return false;
}
}
// Arrow functions and simple functions are loaders
return typeof value === 'function' && !value.prototype?.constructor;
}
}
// ============================================================================
// Default Registry Singleton
// ============================================================================
let defaultRegistry: ProviderRegistry | null = null;
/**
* Get the default global provider registry
*
* This is the registry used by createProvider() when no custom registry is specified.
* Built-in providers (OpenAI, Anthropic, Google) are registered here.
*/
export function getDefaultRegistry(): ProviderRegistry {
if (!defaultRegistry) {
defaultRegistry = new ProviderRegistry();
registerBuiltinProviders(defaultRegistry);
}
return defaultRegistry;
}
/**
* Create a new isolated provider registry
*
* Use this when you need a separate registry that doesn't share
* registrations with the default global registry.
*/
export function createProviderRegistry(): ProviderRegistry {
return new ProviderRegistry();
}
/**
* Register a provider to the default global registry
*
* @example
* ```typescript
* import { registerProvider } from 'praisonai';
* import { CloudflareProvider } from './my-cloudflare-provider';
*
* registerProvider('cloudflare', CloudflareProvider);
*
* // Now works:
* const agent = new Agent({ llm: 'cloudflare/workers-ai' });
* ```
*/
export function registerProvider(
name: string,
provider: ProviderConstructor | ProviderLoader,
options?: RegisterOptions
): void {
getDefaultRegistry().register(name, provider, options);
}
/**
* Unregister a provider from the default global registry
*/
export function unregisterProvider(name: string): boolean {
return getDefaultRegistry().unregister(name);
}
/**
* Check if a provider is registered in the default registry
*/
export function hasProvider(name: string): boolean {
return getDefaultRegistry().has(name);
}
/**
* List all providers in the default registry
*/
export function listProviders(): string[] {
return getDefaultRegistry().list();
}
// ============================================================================
// Built-in Provider Registration
// ============================================================================
/**
* Register built-in providers to a registry
* Uses lazy loaders to avoid importing all providers at module load time
*/
export function registerBuiltinProviders(registry: ProviderRegistry): void {
// OpenAI - lazy loaded
registry.register('openai', () => {
const { OpenAIProvider } = require('./openai');
return OpenAIProvider;
}, { aliases: ['oai'] });
// Anthropic - lazy loaded
registry.register('anthropic', () => {
const { AnthropicProvider } = require('./anthropic');
return AnthropicProvider;
}, { aliases: ['claude'] });
// Google - lazy loaded
registry.register('google', () => {
const { GoogleProvider } = require('./google');
return GoogleProvider;
}, { aliases: ['gemini'] });
}
/**
* Reset the default registry (mainly for testing)
* @internal
*/
export function _resetDefaultRegistry(): void {
defaultRegistry = null;
}
@@ -0,0 +1,401 @@
/**
* Provider Registry Unit Tests (TDD)
*
* Tests for the extensible provider registry that fixes Issue #1095
* These tests define the expected behavior before implementation.
*/
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import type { LLMProvider, ProviderConfig, GenerateTextOptions, GenerateTextResult, StreamTextOptions, StreamChunk, GenerateObjectOptions, GenerateObjectResult } from '../../../src/llm/providers/types';
// Mock provider for testing
class MockProvider implements LLMProvider {
readonly providerId: string;
readonly modelId: string;
constructor(modelId: string, config?: ProviderConfig) {
this.providerId = 'mock';
this.modelId = modelId;
}
async generateText(options: GenerateTextOptions): Promise<GenerateTextResult> {
return {
text: 'mock response',
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
finishReason: 'stop'
};
}
async streamText(options: StreamTextOptions): Promise<AsyncIterable<StreamChunk>> {
return {
async *[Symbol.asyncIterator]() {
yield { text: 'mock' };
}
};
}
async generateObject<T>(options: GenerateObjectOptions<T>): Promise<GenerateObjectResult<T>> {
return {
object: {} as T,
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 }
};
}
}
// Custom provider for testing registration
class CustomCloudflareProvider implements LLMProvider {
readonly providerId = 'cloudflare';
readonly modelId: string;
constructor(modelId: string, config?: ProviderConfig) {
this.modelId = modelId;
}
async generateText(options: GenerateTextOptions): Promise<GenerateTextResult> {
return {
text: 'cloudflare response',
usage: { promptTokens: 5, completionTokens: 15, totalTokens: 20 },
finishReason: 'stop'
};
}
async streamText(options: StreamTextOptions): Promise<AsyncIterable<StreamChunk>> {
return {
async *[Symbol.asyncIterator]() {
yield { text: 'cloudflare' };
}
};
}
async generateObject<T>(options: GenerateObjectOptions<T>): Promise<GenerateObjectResult<T>> {
return {
object: {} as T,
usage: { promptTokens: 5, completionTokens: 15, totalTokens: 20 }
};
}
}
describe('ProviderRegistry', () => {
// Import will be from the implementation once created
let ProviderRegistry: any;
let createProviderRegistry: any;
let getDefaultRegistry: any;
let registerProvider: any;
let unregisterProvider: any;
beforeEach(async () => {
// Dynamic import to get the registry module
const registryModule = await import('../../../src/llm/providers/registry');
ProviderRegistry = registryModule.ProviderRegistry;
createProviderRegistry = registryModule.createProviderRegistry;
getDefaultRegistry = registryModule.getDefaultRegistry;
registerProvider = registryModule.registerProvider;
unregisterProvider = registryModule.unregisterProvider;
});
describe('Registry Creation', () => {
it('should create an empty registry', () => {
const registry = createProviderRegistry();
expect(registry).toBeDefined();
expect(registry.list()).toEqual([]);
});
it('should get the default registry singleton', () => {
const registry1 = getDefaultRegistry();
const registry2 = getDefaultRegistry();
expect(registry1).toBe(registry2);
});
});
describe('Provider Registration', () => {
it('should register a provider class', () => {
const registry = createProviderRegistry();
registry.register('mock', MockProvider);
expect(registry.has('mock')).toBe(true);
expect(registry.list()).toContain('mock');
});
it('should register a provider with lazy loader function', () => {
const registry = createProviderRegistry();
const loader = () => MockProvider;
registry.register('mock', loader);
expect(registry.has('mock')).toBe(true);
});
it('should register provider with aliases', () => {
const registry = createProviderRegistry();
registry.register('cloudflare', CustomCloudflareProvider, { aliases: ['cf', 'workers-ai'] });
expect(registry.has('cloudflare')).toBe(true);
expect(registry.has('cf')).toBe(true);
expect(registry.has('workers-ai')).toBe(true);
});
it('should throw on duplicate registration by default', () => {
const registry = createProviderRegistry();
registry.register('mock', MockProvider);
expect(() => {
registry.register('mock', CustomCloudflareProvider);
}).toThrow(/already registered/i);
});
it('should allow override with explicit flag', () => {
const registry = createProviderRegistry();
registry.register('mock', MockProvider);
registry.register('mock', CustomCloudflareProvider, { override: true });
const provider = registry.resolve('mock', 'test-model');
expect(provider.providerId).toBe('cloudflare');
});
it('should unregister a provider', () => {
const registry = createProviderRegistry();
registry.register('mock', MockProvider);
expect(registry.has('mock')).toBe(true);
const result = registry.unregister('mock');
expect(result).toBe(true);
expect(registry.has('mock')).toBe(false);
});
it('should return false when unregistering non-existent provider', () => {
const registry = createProviderRegistry();
const result = registry.unregister('nonexistent');
expect(result).toBe(false);
});
});
describe('Provider Resolution', () => {
it('should resolve a registered provider by name', () => {
const registry = createProviderRegistry();
registry.register('mock', MockProvider);
const provider = registry.resolve('mock', 'test-model');
expect(provider).toBeInstanceOf(MockProvider);
expect(provider.modelId).toBe('test-model');
});
it('should resolve provider with config', () => {
const registry = createProviderRegistry();
registry.register('mock', MockProvider);
const config: ProviderConfig = { apiKey: 'test-key', timeout: 5000 };
const provider = registry.resolve('mock', 'test-model', config);
expect(provider).toBeInstanceOf(MockProvider);
});
it('should resolve provider from lazy loader', () => {
const registry = createProviderRegistry();
const loaderFn = jest.fn(() => MockProvider);
registry.register('mock', loaderFn);
// First resolution should call loader
const provider1 = registry.resolve('mock', 'model-1');
expect(loaderFn).toHaveBeenCalledTimes(1);
// Second resolution should reuse cached constructor
const provider2 = registry.resolve('mock', 'model-2');
expect(loaderFn).toHaveBeenCalledTimes(1); // Still 1, not called again
});
it('should throw clear error for unknown provider', () => {
const registry = createProviderRegistry();
registry.register('openai', MockProvider);
registry.register('anthropic', MockProvider);
expect(() => {
registry.resolve('cloudflare', 'model');
}).toThrow(/unknown provider.*cloudflare/i);
// Error should include available providers
try {
registry.resolve('cloudflare', 'model');
} catch (e: any) {
expect(e.message).toMatch(/openai/i);
expect(e.message).toMatch(/anthropic/i);
}
});
it('should resolve via alias', () => {
const registry = createProviderRegistry();
registry.register('cloudflare', CustomCloudflareProvider, { aliases: ['cf'] });
const provider = registry.resolve('cf', 'workers-ai-model');
expect(provider.providerId).toBe('cloudflare');
});
});
describe('Global Registration Functions', () => {
it('should register provider to default registry', () => {
// Reset default registry state
const registry = getDefaultRegistry();
registerProvider('custom-test', MockProvider);
expect(registry.has('custom-test')).toBe(true);
// Cleanup
unregisterProvider('custom-test');
});
it('should unregister provider from default registry', () => {
registerProvider('custom-test-2', MockProvider);
expect(getDefaultRegistry().has('custom-test-2')).toBe(true);
unregisterProvider('custom-test-2');
expect(getDefaultRegistry().has('custom-test-2')).toBe(false);
});
});
describe('Multi-agent Safety', () => {
it('should support isolated registries per context', () => {
const registry1 = createProviderRegistry();
const registry2 = createProviderRegistry();
registry1.register('provider-a', MockProvider);
registry2.register('provider-b', CustomCloudflareProvider);
expect(registry1.has('provider-a')).toBe(true);
expect(registry1.has('provider-b')).toBe(false);
expect(registry2.has('provider-a')).toBe(false);
expect(registry2.has('provider-b')).toBe(true);
});
it('should not leak registrations between isolated registries', () => {
const registry1 = createProviderRegistry();
const registry2 = createProviderRegistry();
registry1.register('shared-name', MockProvider);
registry2.register('shared-name', CustomCloudflareProvider);
const provider1 = registry1.resolve('shared-name', 'model');
const provider2 = registry2.resolve('shared-name', 'model');
expect(provider1.providerId).toBe('mock');
expect(provider2.providerId).toBe('cloudflare');
});
});
});
describe('createProvider with Registry', () => {
let createProvider: any;
let registerProvider: any;
let unregisterProvider: any;
let getDefaultRegistry: any;
beforeEach(async () => {
const providersModule = await import('../../../src/llm/providers');
createProvider = providersModule.createProvider;
registerProvider = providersModule.registerProvider;
unregisterProvider = providersModule.unregisterProvider;
getDefaultRegistry = providersModule.getDefaultRegistry;
});
describe('Backward Compatibility', () => {
it('should resolve built-in openai provider', () => {
if (!process.env.OPENAI_API_KEY) {
console.log('Skipping: OPENAI_API_KEY not set');
return;
}
const provider = createProvider('openai/gpt-4o-mini');
expect(provider.providerId).toBe('openai');
expect(provider.modelId).toBe('gpt-4o-mini');
});
it('should resolve built-in anthropic provider', () => {
if (!process.env.ANTHROPIC_API_KEY) {
console.log('Skipping: ANTHROPIC_API_KEY not set');
return;
}
const provider = createProvider('anthropic/claude-3-5-sonnet-latest');
expect(provider.providerId).toBe('anthropic');
});
it('should resolve built-in google provider', () => {
if (!process.env.GOOGLE_API_KEY) {
console.log('Skipping: GOOGLE_API_KEY not set');
return;
}
const provider = createProvider('google/gemini-2.0-flash');
expect(provider.providerId).toBe('google');
});
it('should default to openai for gpt- models', () => {
if (!process.env.OPENAI_API_KEY) {
console.log('Skipping: OPENAI_API_KEY not set');
return;
}
const provider = createProvider('gpt-4o-mini');
expect(provider.providerId).toBe('openai');
});
});
describe('Custom Provider Support', () => {
afterEach(() => {
// Cleanup custom registrations
try {
unregisterProvider('custom-test');
} catch {}
});
it('should resolve custom registered provider', () => {
registerProvider('custom-test', MockProvider);
const provider = createProvider('custom-test/my-model');
expect(provider.providerId).toBe('mock');
expect(provider.modelId).toBe('my-model');
});
it('should accept provider instance directly', () => {
const instance = new MockProvider('direct-model');
const provider = createProvider(instance);
expect(provider).toBe(instance);
expect(provider.modelId).toBe('direct-model');
});
it('should accept provider constructor', () => {
const provider = createProvider(MockProvider, { modelId: 'constructor-model' });
expect(provider).toBeInstanceOf(MockProvider);
});
it('should accept provider spec object', () => {
registerProvider('custom-test', MockProvider);
const provider = createProvider({
name: 'custom-test',
modelId: 'spec-model',
config: { timeout: 5000 }
});
expect(provider.providerId).toBe('mock');
expect(provider.modelId).toBe('spec-model');
});
it('should use custom registry when provided', () => {
const { createProviderRegistry } = require('../../../src/llm/providers/registry');
const customRegistry = createProviderRegistry();
customRegistry.register('isolated', CustomCloudflareProvider);
const provider = createProvider('isolated/model', { registry: customRegistry });
expect(provider.providerId).toBe('cloudflare');
// Should not be in default registry
expect(() => createProvider('isolated/model')).toThrow(/unknown provider/i);
});
});
describe('Error Messages', () => {
it('should provide helpful error for unknown provider', () => {
expect(() => createProvider('nonexistent/model')).toThrow();
try {
createProvider('nonexistent/model');
} catch (e: any) {
expect(e.message).toMatch(/unknown provider/i);
expect(e.message).toMatch(/nonexistent/i);
expect(e.message).toMatch(/available/i);
}
});
});
});
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.8.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.8.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.9.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.7.9.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.7.8 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.7.9 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+62
View File
@@ -0,0 +1,62 @@
"""
LLM Module for PraisonAI CLI Wrapper.
This module provides LLM provider registry and utilities for the CLI wrapper.
The core LLM functionality is in praisonaiagents.llm.
"""
# Lazy loading for performance
_lazy_cache = {}
def __getattr__(name):
"""Lazy load LLM registry classes."""
if name in _lazy_cache:
return _lazy_cache[name]
if name == "LLMProviderRegistry":
from .registry import LLMProviderRegistry
_lazy_cache[name] = LLMProviderRegistry
return LLMProviderRegistry
elif name == "get_default_llm_registry":
from .registry import get_default_llm_registry
_lazy_cache[name] = get_default_llm_registry
return get_default_llm_registry
elif name == "register_llm_provider":
from .registry import register_llm_provider
_lazy_cache[name] = register_llm_provider
return register_llm_provider
elif name == "unregister_llm_provider":
from .registry import unregister_llm_provider
_lazy_cache[name] = unregister_llm_provider
return unregister_llm_provider
elif name == "has_llm_provider":
from .registry import has_llm_provider
_lazy_cache[name] = has_llm_provider
return has_llm_provider
elif name == "list_llm_providers":
from .registry import list_llm_providers
_lazy_cache[name] = list_llm_providers
return list_llm_providers
elif name == "create_llm_provider":
from .registry import create_llm_provider
_lazy_cache[name] = create_llm_provider
return create_llm_provider
elif name == "parse_model_string":
from .registry import parse_model_string
_lazy_cache[name] = parse_model_string
return parse_model_string
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"LLMProviderRegistry",
"get_default_llm_registry",
"register_llm_provider",
"unregister_llm_provider",
"has_llm_provider",
"list_llm_providers",
"create_llm_provider",
"parse_model_string",
]
+374
View File
@@ -0,0 +1,374 @@
"""
LLM Provider Registry - Extensible provider registration system for Python.
This module provides parity with the TypeScript ProviderRegistry,
allowing users to register custom LLM providers that can be resolved by name.
Example:
from praisonai.llm import register_llm_provider, create_llm_provider
class CloudflareProvider:
provider_id = "cloudflare"
def __init__(self, model_id, config=None):
self.model_id = model_id
self.config = config or {}
def generate(self, prompt):
# Implementation
pass
register_llm_provider("cloudflare", CloudflareProvider)
provider = create_llm_provider("cloudflare/workers-ai")
"""
from typing import Any, Callable, Dict, List, Optional, Type, Union
# Type aliases
ProviderClass = Type[Any] # Class with __init__(model_id, config)
ProviderFactory = Callable[[str, Optional[Dict[str, Any]]], Any]
ProviderType = Union[ProviderClass, ProviderFactory]
class LLMProviderRegistry:
"""
Registry for LLM providers.
Manages registration and resolution of LLM providers by name.
Supports lazy loading, aliases, and isolated instances.
"""
def __init__(self):
"""Initialize an empty registry."""
self._providers: Dict[str, ProviderType] = {}
self._aliases: Dict[str, str] = {} # alias -> canonical name
def register(
self,
name: str,
provider: ProviderType,
*,
override: bool = False,
aliases: Optional[List[str]] = None
) -> None:
"""
Register a provider by name.
Args:
name: Provider name (e.g., 'cloudflare', 'ollama')
provider: Provider class or factory function
override: Allow overwriting existing registration
aliases: Additional names that resolve to this provider
Raises:
ValueError: If name is already registered (unless override=True)
"""
normalized_name = name.lower()
# Check for existing registration
if normalized_name in self._providers and not override:
raise ValueError(
f"Provider '{name}' is already registered. "
f"Use override=True to replace it."
)
self._providers[normalized_name] = provider
# Register aliases
if aliases:
for alias in aliases:
normalized_alias = alias.lower()
if normalized_alias in self._providers and not override:
raise ValueError(
f"Alias '{alias}' conflicts with existing provider. "
f"Use override=True to replace it."
)
self._aliases[normalized_alias] = normalized_name
def unregister(self, name: str) -> bool:
"""
Unregister a provider by name.
Args:
name: Provider name to unregister
Returns:
True if provider was unregistered, False if not found
"""
normalized_name = name.lower()
# Check if it's an alias
if normalized_name in self._aliases:
del self._aliases[normalized_name]
return True
# Check if it's a canonical name
if normalized_name in self._providers:
# Remove all aliases pointing to this provider
aliases_to_remove = [
alias for alias, canonical in self._aliases.items()
if canonical == normalized_name
]
for alias in aliases_to_remove:
del self._aliases[alias]
del self._providers[normalized_name]
return True
return False
def has(self, name: str) -> bool:
"""
Check if a provider is registered.
Args:
name: Provider name to check
Returns:
True if provider is registered
"""
normalized_name = name.lower()
return normalized_name in self._providers or normalized_name in self._aliases
def list(self) -> List[str]:
"""
List all registered provider names (canonical names only).
Returns:
List of provider names
"""
return list(self._providers.keys())
def list_all(self) -> List[str]:
"""
List all names including aliases.
Returns:
List of all registered names and aliases
"""
return list(self._providers.keys()) + list(self._aliases.keys())
def resolve(
self,
name: str,
model_id: str,
config: Optional[Dict[str, Any]] = None
) -> Any:
"""
Resolve a provider by name, creating an instance.
Args:
name: Provider name
model_id: Model ID to pass to constructor
config: Optional provider config
Returns:
Provider instance
Raises:
ValueError: If provider not found
"""
normalized_name = name.lower()
# Resolve alias to canonical name
canonical_name = self._aliases.get(normalized_name, normalized_name)
provider = self._providers.get(canonical_name)
if provider is None:
available = self.list()
raise ValueError(
f"Unknown provider: '{name}'. "
f"Available providers: {', '.join(available) if available else 'none'}. "
f"Register a custom provider with register_llm_provider('{name}', YourProviderClass)."
)
# Create instance
return provider(model_id, config)
def get(self, name: str) -> Optional[ProviderType]:
"""
Get the provider class/factory without instantiating.
Args:
name: Provider name
Returns:
Provider class/factory or None
"""
normalized_name = name.lower()
canonical_name = self._aliases.get(normalized_name, normalized_name)
return self._providers.get(canonical_name)
# ============================================================================
# Default Registry Singleton
# ============================================================================
_default_registry: Optional[LLMProviderRegistry] = None
def get_default_llm_registry() -> LLMProviderRegistry:
"""
Get the default global LLM provider registry.
This is the registry used by create_llm_provider() when no custom registry
is specified.
"""
global _default_registry
if _default_registry is None:
_default_registry = LLMProviderRegistry()
_register_builtin_providers(_default_registry)
return _default_registry
def _register_builtin_providers(registry: LLMProviderRegistry) -> None:
"""
Register built-in providers to a registry.
Uses lazy loading to avoid importing heavy dependencies at module load time.
"""
# Note: In Python, the actual LLM providers are in praisonaiagents.llm
# which uses LiteLLM. This registry is for custom provider extensions.
# Built-in providers are handled by LiteLLM automatically.
pass
def register_llm_provider(
name: str,
provider: ProviderType,
*,
override: bool = False,
aliases: Optional[List[str]] = None
) -> None:
"""
Register a provider to the default global registry.
Example:
from praisonai.llm import register_llm_provider
class CloudflareProvider:
provider_id = "cloudflare"
def __init__(self, model_id, config=None):
self.model_id = model_id
self.config = config or {}
register_llm_provider("cloudflare", CloudflareProvider)
"""
get_default_llm_registry().register(name, provider, override=override, aliases=aliases)
def unregister_llm_provider(name: str) -> bool:
"""Unregister a provider from the default global registry."""
return get_default_llm_registry().unregister(name)
def has_llm_provider(name: str) -> bool:
"""Check if a provider is registered in the default registry."""
return get_default_llm_registry().has(name)
def list_llm_providers() -> List[str]:
"""List all providers in the default registry."""
return get_default_llm_registry().list()
# ============================================================================
# Provider Creation
# ============================================================================
def parse_model_string(model: str) -> Dict[str, str]:
"""
Parse model string into provider and model ID.
Supports formats:
- "provider/model" (e.g., "openai/gpt-4o")
- "model" (defaults based on prefix)
Args:
model: Model string
Returns:
Dict with 'provider_id' and 'model_id' keys
"""
if "/" in model:
parts = model.split("/", 1)
return {"provider_id": parts[0].lower(), "model_id": parts[1]}
# Default based on model prefix
model_lower = model.lower()
if model_lower.startswith("gpt-") or model_lower.startswith("o1") or model_lower.startswith("o3"):
return {"provider_id": "openai", "model_id": model}
if model_lower.startswith("claude-"):
return {"provider_id": "anthropic", "model_id": model}
if model_lower.startswith("gemini-"):
return {"provider_id": "google", "model_id": model}
# Default to openai
return {"provider_id": "openai", "model_id": model}
def create_llm_provider(
input_value: Union[str, Dict[str, Any], Any],
*,
registry: Optional[LLMProviderRegistry] = None,
config: Optional[Dict[str, Any]] = None
) -> Any:
"""
Create a provider instance from various input types.
Args:
input_value: One of:
- String: "provider/model" or "model"
- Dict: {"name": "provider", "model_id": "model", "config": {...}}
- Provider instance: passed through
registry: Optional custom registry (defaults to global)
config: Optional provider config
Returns:
Provider instance
Example:
# String input
provider = create_llm_provider("openai/gpt-4o")
# Custom provider (after registration)
register_llm_provider("cloudflare", CloudflareProvider)
provider = create_llm_provider("cloudflare/workers-ai")
# Provider instance (pass-through)
provider = create_llm_provider(existing_provider)
# Spec dict
provider = create_llm_provider({
"name": "openai",
"model_id": "gpt-4o",
"config": {"timeout": 5000}
})
"""
reg = registry or get_default_llm_registry()
# Case 1: Already a provider instance (has provider_id and model_id)
if hasattr(input_value, "provider_id") and hasattr(input_value, "model_id"):
return input_value
# Case 2: Spec dict
if isinstance(input_value, dict) and "name" in input_value:
name = input_value["name"]
model_id = input_value.get("model_id", "default")
provider_config = input_value.get("config") or config
return reg.resolve(name, model_id, provider_config)
# Case 3: String - parse and resolve
if isinstance(input_value, str):
parsed = parse_model_string(input_value)
return reg.resolve(parsed["provider_id"], parsed["model_id"], config)
raise ValueError(
f"Invalid provider input. Expected string, provider instance, or spec dict. "
f"Got: {type(input_value).__name__}"
)
def _reset_default_registry() -> None:
"""Reset the default registry (mainly for testing)."""
global _default_registry
_default_registry = None
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.7.8"
__version__ = "2.7.9"
@@ -0,0 +1,310 @@
"""
LLM Provider Registry Unit Tests (TDD)
Tests for the extensible LLM provider registry that provides parity
with the TypeScript implementation for Issue #1095.
"""
import pytest
from typing import Any, Dict, Optional
class MockLLMProvider:
"""Mock LLM provider for testing."""
provider_id = "mock"
def __init__(self, model_id: str, config: Optional[Dict[str, Any]] = None):
self.model_id = model_id
self.config = config or {}
def generate(self, prompt: str) -> str:
return f"Mock response for: {prompt}"
class CustomCloudflareProvider:
"""Custom provider for testing registration."""
provider_id = "cloudflare"
def __init__(self, model_id: str, config: Optional[Dict[str, Any]] = None):
self.model_id = model_id
self.config = config or {}
def generate(self, prompt: str) -> str:
return f"Cloudflare response for: {prompt}"
class TestLLMProviderRegistry:
"""Tests for LLMProviderRegistry class."""
def test_create_empty_registry(self):
"""Should create an empty registry."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
assert registry is not None
assert registry.list() == []
def test_register_provider_class(self):
"""Should register a provider class."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("mock", MockLLMProvider)
assert registry.has("mock")
assert "mock" in registry.list()
def test_register_provider_with_factory(self):
"""Should register a provider with a factory function."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
def factory(model_id, config=None):
return MockLLMProvider(model_id, config)
registry.register("mock", factory)
assert registry.has("mock")
def test_register_provider_with_aliases(self):
"""Should register provider with aliases."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("cloudflare", CustomCloudflareProvider, aliases=["cf", "workers-ai"])
assert registry.has("cloudflare")
assert registry.has("cf")
assert registry.has("workers-ai")
def test_duplicate_registration_raises_error(self):
"""Should raise error on duplicate registration by default."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("mock", MockLLMProvider)
with pytest.raises(ValueError, match="already registered"):
registry.register("mock", CustomCloudflareProvider)
def test_override_registration(self):
"""Should allow override with explicit flag."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("mock", MockLLMProvider)
registry.register("mock", CustomCloudflareProvider, override=True)
provider = registry.resolve("mock", "test-model")
assert provider.provider_id == "cloudflare"
def test_unregister_provider(self):
"""Should unregister a provider."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("mock", MockLLMProvider)
assert registry.has("mock")
result = registry.unregister("mock")
assert result is True
assert not registry.has("mock")
def test_unregister_nonexistent_returns_false(self):
"""Should return False when unregistering non-existent provider."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
result = registry.unregister("nonexistent")
assert result is False
def test_resolve_provider(self):
"""Should resolve a registered provider by name."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("mock", MockLLMProvider)
provider = registry.resolve("mock", "test-model")
assert isinstance(provider, MockLLMProvider)
assert provider.model_id == "test-model"
def test_resolve_provider_with_config(self):
"""Should resolve provider with config."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("mock", MockLLMProvider)
config = {"api_key": "test-key", "timeout": 5000}
provider = registry.resolve("mock", "test-model", config)
assert provider.config == config
def test_resolve_via_alias(self):
"""Should resolve provider via alias."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("cloudflare", CustomCloudflareProvider, aliases=["cf"])
provider = registry.resolve("cf", "workers-ai-model")
assert provider.provider_id == "cloudflare"
def test_resolve_unknown_provider_raises_error(self):
"""Should raise clear error for unknown provider."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("openai", MockLLMProvider)
registry.register("anthropic", MockLLMProvider)
with pytest.raises(ValueError) as exc_info:
registry.resolve("cloudflare", "model")
error_msg = str(exc_info.value).lower()
assert "unknown provider" in error_msg
assert "cloudflare" in error_msg
assert "openai" in error_msg or "anthropic" in error_msg
class TestDefaultRegistry:
"""Tests for default global registry."""
def test_get_default_registry_singleton(self):
"""Should return the same registry instance."""
from praisonai.llm.registry import get_default_llm_registry
registry1 = get_default_llm_registry()
registry2 = get_default_llm_registry()
assert registry1 is registry2
def test_register_provider_global(self):
"""Should register provider to default registry."""
from praisonai.llm.registry import (
register_llm_provider,
unregister_llm_provider,
get_default_llm_registry
)
register_llm_provider("custom-test", MockLLMProvider)
assert get_default_llm_registry().has("custom-test")
# Cleanup
unregister_llm_provider("custom-test")
def test_unregister_provider_global(self):
"""Should unregister provider from default registry."""
from praisonai.llm.registry import (
register_llm_provider,
unregister_llm_provider,
get_default_llm_registry
)
register_llm_provider("custom-test-2", MockLLMProvider)
assert get_default_llm_registry().has("custom-test-2")
unregister_llm_provider("custom-test-2")
assert not get_default_llm_registry().has("custom-test-2")
class TestMultiAgentSafety:
"""Tests for multi-agent safety with isolated registries."""
def test_isolated_registries(self):
"""Should support isolated registries per context."""
from praisonai.llm.registry import LLMProviderRegistry
registry1 = LLMProviderRegistry()
registry2 = LLMProviderRegistry()
registry1.register("provider-a", MockLLMProvider)
registry2.register("provider-b", CustomCloudflareProvider)
assert registry1.has("provider-a")
assert not registry1.has("provider-b")
assert not registry2.has("provider-a")
assert registry2.has("provider-b")
def test_no_leak_between_registries(self):
"""Should not leak registrations between isolated registries."""
from praisonai.llm.registry import LLMProviderRegistry
registry1 = LLMProviderRegistry()
registry2 = LLMProviderRegistry()
registry1.register("shared-name", MockLLMProvider)
registry2.register("shared-name", CustomCloudflareProvider)
provider1 = registry1.resolve("shared-name", "model")
provider2 = registry2.resolve("shared-name", "model")
assert provider1.provider_id == "mock"
assert provider2.provider_id == "cloudflare"
class TestCreateLLMProvider:
"""Tests for create_llm_provider function."""
def test_create_from_string(self):
"""Should create provider from string."""
from praisonai.llm.registry import (
create_llm_provider,
register_llm_provider,
unregister_llm_provider
)
register_llm_provider("mock", MockLLMProvider)
try:
provider = create_llm_provider("mock/test-model")
assert provider.provider_id == "mock"
assert provider.model_id == "test-model"
finally:
unregister_llm_provider("mock")
def test_create_from_instance(self):
"""Should pass through provider instance."""
from praisonai.llm.registry import create_llm_provider
instance = MockLLMProvider("direct-model")
provider = create_llm_provider(instance)
assert provider is instance
assert provider.model_id == "direct-model"
def test_create_from_spec_dict(self):
"""Should create provider from spec dict."""
from praisonai.llm.registry import (
create_llm_provider,
register_llm_provider,
unregister_llm_provider
)
register_llm_provider("mock", MockLLMProvider)
try:
provider = create_llm_provider({
"name": "mock",
"model_id": "spec-model",
"config": {"timeout": 5000}
})
assert provider.provider_id == "mock"
assert provider.model_id == "spec-model"
finally:
unregister_llm_provider("mock")
def test_create_with_custom_registry(self):
"""Should use custom registry when provided."""
from praisonai.llm.registry import create_llm_provider, LLMProviderRegistry
custom_registry = LLMProviderRegistry()
custom_registry.register("isolated", CustomCloudflareProvider)
provider = create_llm_provider("isolated/model", registry=custom_registry)
assert provider.provider_id == "cloudflare"
# Should not be in default registry
with pytest.raises(ValueError):
create_llm_provider("isolated/model")