Compare commits

...
3 Commits
Author SHA1 Message Date
Mervin Praison ba37ce39d3 Release v2.8.0 2025-12-30 15:08:22 +00:00
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
45 changed files with 7656 additions and 76 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.8.0" \
"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.8.0" \
"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.8.0" \
"praisonai[ui]" \
"praisonai[crewai]"
+41
View File
@@ -0,0 +1,41 @@
# AI SDK Examples
Examples demonstrating the AI SDK integration in PraisonAI TypeScript.
## Prerequisites
```bash
npm install praisonai ai @ai-sdk/openai @ai-sdk/anthropic @ai-sdk/google zod
```
## Environment Variables
```bash
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=sk-ant-...
export GOOGLE_API_KEY=AIza...
```
## Examples
| File | Description |
|------|-------------|
| `basic-chat.ts` | Simple text generation |
| `streaming.ts` | Streaming text output |
| `tool-calling.ts` | Function/tool calling |
| `multi-provider.ts` | Using multiple providers |
| `structured-output.ts` | JSON schema output |
| `multi-agent.ts` | Multi-agent attribution |
## Running Examples
```bash
npx ts-node basic-chat.ts
npx ts-node streaming.ts
npx ts-node tool-calling.ts
```
## More Information
- [AI SDK Documentation](https://docs.praisonai.com/docs/js/ai-sdk)
- [AI SDK CLI](https://docs.praisonai.com/docs/js/ai-sdk-cli)
+39
View File
@@ -0,0 +1,39 @@
/**
* AI SDK Basic Chat Example
*
* Demonstrates simple text generation using the AI SDK backend.
*
* Usage:
* npx ts-node basic-chat.ts
*
* Environment:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from 'praisonai';
async function main() {
// Create backend with model string
const backend = createAISDKBackend('openai/gpt-4o-mini');
console.log('Sending message to OpenAI...\n');
// Generate text
const result = await backend.generateText({
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'What are the three primary colors?' }
],
maxTokens: 200,
temperature: 0.7,
});
console.log('Response:', result.text);
console.log('\nFinish reason:', result.finishReason);
if (result.usage) {
console.log('Token usage:', result.usage);
}
}
main().catch(console.error);
+96
View File
@@ -0,0 +1,96 @@
/**
* AI SDK Multi-Agent Attribution Example
*
* Demonstrates how to propagate attribution context (agentId, runId, traceId)
* to LLM calls for multi-agent observability and safety.
*
* Usage:
* npx ts-node multi-agent.ts
*
* Environment:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from 'praisonai';
// Simulate a multi-agent system with attribution
interface AgentContext {
agentId: string;
runId: string;
traceId: string;
sessionId?: string;
}
async function runAgent(name: string, task: string, context: AgentContext) {
console.log(`\n[${name}] Starting task: ${task}`);
console.log(` Attribution: agentId=${context.agentId}, runId=${context.runId}`);
const backend = createAISDKBackend('openai/gpt-4o-mini', {
attribution: context,
timeout: 30000,
});
const result = await backend.generateText({
messages: [
{ role: 'system', content: `You are ${name}, a specialized AI agent.` },
{ role: 'user', content: task }
],
maxTokens: 100,
});
console.log(` Response: ${result.text.slice(0, 100)}...`);
return result.text;
}
async function main() {
// Create a shared trace ID for the entire workflow
const traceId = `trace-${Date.now().toString(36)}`;
const sessionId = 'session-user123';
console.log('Multi-Agent Workflow with Attribution');
console.log('=====================================');
console.log(`Trace ID: ${traceId}`);
console.log(`Session ID: ${sessionId}`);
// Agent 1: Research Agent
const researchResult = await runAgent(
'ResearchAgent',
'What are the key benefits of TypeScript?',
{
agentId: 'agent-research',
runId: `run-${Date.now().toString(36)}-1`,
traceId,
sessionId,
}
);
// Agent 2: Summary Agent
const summaryResult = await runAgent(
'SummaryAgent',
`Summarize this in one sentence: ${researchResult}`,
{
agentId: 'agent-summary',
runId: `run-${Date.now().toString(36)}-2`,
traceId,
sessionId,
}
);
// Agent 3: Review Agent
await runAgent(
'ReviewAgent',
`Review this summary for accuracy: ${summaryResult}`,
{
agentId: 'agent-review',
runId: `run-${Date.now().toString(36)}-3`,
traceId,
sessionId,
}
);
console.log('\n\nWorkflow complete!');
console.log('All requests were tagged with the same trace ID for observability.');
console.log('Check your LLM provider dashboard to see the correlated requests.');
}
main().catch(console.error);
+59
View File
@@ -0,0 +1,59 @@
/**
* AI SDK Multi-Provider Example
*
* Demonstrates using multiple LLM providers with the same interface.
*
* Usage:
* npx ts-node multi-provider.ts
*
* Environment:
* OPENAI_API_KEY - Your OpenAI API key
* ANTHROPIC_API_KEY - Your Anthropic API key
* GOOGLE_API_KEY - Your Google API key
*/
import { createAISDKBackend } from 'praisonai';
const PROMPT = 'Explain what an API is in one sentence.';
async function testProvider(modelString: string) {
try {
const backend = createAISDKBackend(modelString, {
timeout: 30000,
});
const startTime = Date.now();
const result = await backend.generateText({
messages: [{ role: 'user', content: PROMPT }],
maxTokens: 100,
});
const duration = Date.now() - startTime;
console.log(`\n${modelString}:`);
console.log(` Response: ${result.text}`);
console.log(` Duration: ${duration}ms`);
return true;
} catch (error: any) {
console.log(`\n${modelString}:`);
console.log(` Error: ${error.message}`);
return false;
}
}
async function main() {
console.log('Testing multiple providers with the same prompt...');
console.log(`Prompt: "${PROMPT}"`);
const providers = [
'openai/gpt-4o-mini',
'anthropic/claude-3-haiku-20240307',
'google/gemini-1.5-flash',
];
const results = await Promise.all(providers.map(testProvider));
const successful = results.filter(Boolean).length;
console.log(`\n\nSummary: ${successful}/${providers.length} providers succeeded`);
}
main().catch(console.error);
+39
View File
@@ -0,0 +1,39 @@
/**
* AI SDK Streaming Example
*
* Demonstrates streaming text generation with real-time output.
*
* Usage:
* npx ts-node streaming.ts
*
* Environment:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from 'praisonai';
async function main() {
const backend = createAISDKBackend('openai/gpt-4o-mini');
console.log('Streaming response from OpenAI...\n');
const stream = await backend.streamText({
messages: [
{ role: 'user', content: 'Write a short poem about coding.' }
],
maxTokens: 200,
});
// Stream chunks as they arrive
for await (const chunk of stream) {
if (chunk.text) {
process.stdout.write(chunk.text);
}
if (chunk.finishReason) {
console.log('\n\nFinish reason:', chunk.finishReason);
}
}
}
main().catch(console.error);
+72
View File
@@ -0,0 +1,72 @@
/**
* AI SDK Structured Output Example
*
* Demonstrates generating typed JSON objects with Zod schemas.
*
* Usage:
* npx ts-node structured-output.ts
*
* Environment:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { generateObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
// Define schema for a person profile
const PersonSchema = z.object({
name: z.string().describe('Full name'),
age: z.number().describe('Age in years'),
occupation: z.string().describe('Job title'),
city: z.string().describe('City of residence'),
interests: z.array(z.string()).describe('List of hobbies/interests'),
bio: z.string().describe('Short biography'),
});
// Define schema for a recipe
const RecipeSchema = z.object({
name: z.string().describe('Recipe name'),
cuisine: z.string().describe('Type of cuisine'),
prepTime: z.number().describe('Preparation time in minutes'),
cookTime: z.number().describe('Cooking time in minutes'),
servings: z.number().describe('Number of servings'),
ingredients: z.array(z.object({
item: z.string(),
amount: z.string(),
})).describe('List of ingredients'),
instructions: z.array(z.string()).describe('Step-by-step instructions'),
});
async function main() {
console.log('Generating structured output with Zod schemas...\n');
// Generate a person profile
console.log('1. Generating a fictional person profile...');
const personResult = await generateObject({
model: openai('gpt-4o-mini'),
schema: PersonSchema,
prompt: 'Generate a fictional software engineer profile.',
});
console.log('Person:', JSON.stringify(personResult.object, null, 2));
// Generate a recipe
console.log('\n2. Generating a recipe...');
const recipeResult = await generateObject({
model: openai('gpt-4o-mini'),
schema: RecipeSchema,
prompt: 'Generate a simple pasta recipe.',
});
console.log('Recipe:', JSON.stringify(recipeResult.object, null, 2));
// Type-safe access
console.log('\n3. Type-safe access:');
console.log(` Person name: ${personResult.object.name}`);
console.log(` Person age: ${personResult.object.age}`);
console.log(` Recipe name: ${recipeResult.object.name}`);
console.log(` Total time: ${recipeResult.object.prepTime + recipeResult.object.cookTime} minutes`);
}
main().catch(console.error);
+75
View File
@@ -0,0 +1,75 @@
/**
* AI SDK Tool Calling Example
*
* Demonstrates function/tool calling with Zod schemas.
*
* Usage:
* npx ts-node tool-calling.ts
*
* Environment:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { generateText, tool } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
// Define tools with Zod schemas
const tools = {
get_weather: tool({
description: 'Get the current weather for a city',
inputSchema: z.object({
city: z.string().describe('The city name'),
unit: z.enum(['celsius', 'fahrenheit']).optional().describe('Temperature unit'),
}),
execute: async ({ city, unit = 'celsius' }) => {
// Mock weather data
const weather = {
city,
temperature: unit === 'celsius' ? 22 : 72,
unit,
condition: 'sunny',
humidity: 45,
};
return weather;
},
}),
get_time: tool({
description: 'Get the current time in a timezone',
inputSchema: z.object({
timezone: z.string().describe('Timezone like "America/New_York"'),
}),
execute: async ({ timezone }) => {
const time = new Date().toLocaleString('en-US', { timeZone: timezone });
return { timezone, time };
},
}),
};
async function main() {
console.log('Asking about weather with tool calling...\n');
const result = await generateText({
model: openai('gpt-4o-mini'),
messages: [
{ role: 'user', content: 'What is the weather in Paris and what time is it in New York?' }
],
tools,
maxSteps: 5, // Allow multiple tool calls
});
console.log('Tool Calls:');
for (const toolCall of result.toolCalls) {
console.log(` - ${toolCall.toolName}:`, toolCall.args);
}
console.log('\nTool Results:');
for (const toolResult of result.toolResults) {
console.log(` - ${toolResult.toolName}:`, toolResult.result);
}
console.log('\nFinal Response:', result.text);
}
main().catch(console.error);
@@ -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);
@@ -0,0 +1,197 @@
"""
Isolated Registry Example - Multi-Agent Safety
This example demonstrates how to use isolated registries to prevent
provider registration collisions in multi-agent scenarios.
Key concepts:
- Each agent/context can have its own registry
- No global state mutation
- Safe for concurrent/parallel runs
- Different agents can use different provider implementations
Requirements:
- pip install praisonai
"""
import sys
sys.path.insert(0, '../../src/praisonai')
from praisonai.llm import (
LLMProviderRegistry,
create_llm_provider,
register_llm_provider,
get_default_llm_registry,
_reset_default_registry
)
# Define two different provider implementations with the same name
class Agent1Provider:
"""Provider implementation for Agent 1."""
provider_id = "custom"
def __init__(self, model_id, config=None):
self.model_id = model_id
self.config = config or {}
self.agent_name = "Agent1"
def generate(self, prompt):
return f"[{self.agent_name}] Response to: {prompt}"
class Agent2Provider:
"""Provider implementation for Agent 2 - different behavior."""
provider_id = "custom"
def __init__(self, model_id, config=None):
self.model_id = model_id
self.config = config or {}
self.agent_name = "Agent2"
def generate(self, prompt):
return f"[{self.agent_name}] Different response to: {prompt}"
def demonstrate_collision_problem():
"""Show what happens with global registry collisions."""
print("=== Problem: Global Registry Collision ===\n")
# Reset to clean state
_reset_default_registry()
# Agent 1 registers "custom" provider
register_llm_provider("custom", Agent1Provider)
print("Agent 1 registered 'custom' provider")
# Agent 2 tries to register same name - ERROR!
try:
register_llm_provider("custom", Agent2Provider)
print("Agent 2 registered 'custom' provider")
except ValueError as e:
print(f"Agent 2 FAILED: {e}")
print()
print("Problem: Both agents want to use 'custom' but with different implementations.")
print("Solution: Use isolated registries.\n")
def demonstrate_isolated_solution():
"""Show how isolated registries solve the collision problem."""
print("=== Solution: Isolated Registries ===\n")
# Create isolated registries for each agent
agent1_registry = LLMProviderRegistry()
agent2_registry = LLMProviderRegistry()
# Each agent registers its own "custom" provider
agent1_registry.register("custom", Agent1Provider)
agent2_registry.register("custom", Agent2Provider)
print("Agent 1 registry providers:", agent1_registry.list())
print("Agent 2 registry providers:", agent2_registry.list())
print()
# Create providers from each registry
provider1 = create_llm_provider("custom/model-v1", registry=agent1_registry)
provider2 = create_llm_provider("custom/model-v1", registry=agent2_registry)
# They have different implementations!
print("Provider 1 response:", provider1.generate("Hello"))
print("Provider 2 response:", provider2.generate("Hello"))
print()
# Verify isolation
print("Verification:")
print(f" provider1.agent_name = {provider1.agent_name}")
print(f" provider2.agent_name = {provider2.agent_name}")
print(f" Same class? {type(provider1) == type(provider2)}")
print()
def demonstrate_parallel_agents():
"""Simulate parallel agent execution with isolated registries."""
print("=== Parallel Agent Simulation ===\n")
def run_agent(agent_id, registry):
"""Simulate an agent run with its own registry."""
provider = create_llm_provider("custom/model", registry=registry)
result = provider.generate(f"Query from agent {agent_id}")
return result
# Setup registries
registries = {}
for i in range(3):
reg = LLMProviderRegistry()
# Each agent gets a slightly different provider
class DynamicProvider:
provider_id = "custom"
def __init__(self, model_id, config=None):
self.model_id = model_id
self.agent_id = i # Capture agent ID
def generate(self, prompt):
return f"Agent-{self.agent_id} processed: {prompt}"
reg.register("custom", DynamicProvider)
registries[i] = reg
# Simulate parallel execution
print("Simulating parallel agent runs:")
for agent_id, registry in registries.items():
result = run_agent(agent_id, registry)
print(f" {result}")
print()
print("Each agent used its own isolated registry - no collisions!")
print()
def demonstrate_shared_vs_isolated():
"""Compare shared (global) vs isolated registry patterns."""
print("=== Shared vs Isolated Patterns ===\n")
# Reset global registry
_reset_default_registry()
# Pattern 1: Shared (global) registry
print("Pattern 1: Shared Registry (use for app-wide providers)")
register_llm_provider("shared-provider", Agent1Provider)
# All code sees the same provider
global_reg = get_default_llm_registry()
print(f" Global registry has 'shared-provider': {global_reg.has('shared-provider')}")
# Pattern 2: Isolated registry
print("\nPattern 2: Isolated Registry (use for per-agent/per-run providers)")
isolated = LLMProviderRegistry()
isolated.register("isolated-provider", Agent2Provider)
# Only code with reference to this registry sees it
print(f" Isolated registry has 'isolated-provider': {isolated.has('isolated-provider')}")
print(f" Global registry has 'isolated-provider': {global_reg.has('isolated-provider')}")
print()
print("Recommendation:")
print(" - Use global registry for app-wide, stable providers")
print(" - Use isolated registries for per-agent or per-run customization")
print()
def main():
print("=" * 60)
print("Isolated Registry Example - Multi-Agent Safety")
print("=" * 60)
print()
demonstrate_collision_problem()
demonstrate_isolated_solution()
demonstrate_parallel_agents()
demonstrate_shared_vs_isolated()
print("=" * 60)
print("Example Complete")
print("=" * 60)
if __name__ == "__main__":
main()
@@ -0,0 +1,42 @@
/**
* AI SDK Basic Chat Example
*
* Demonstrates basic text generation using the AI SDK backend.
*
* Usage:
* npx ts-node examples/ai-sdk/basic-chat.ts
*
* Required environment variables:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from '../../src/llm/providers/ai-sdk';
async function main() {
console.log('AI SDK Basic Chat Example\n');
// Create a backend for OpenAI
const backend = createAISDKBackend('openai/gpt-4o-mini', {
timeout: 30000,
maxRetries: 2,
});
console.log(`Provider: ${backend.providerId}`);
console.log(`Model: ${backend.modelId}\n`);
// Generate text
const result = await backend.generateText({
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'What is the capital of France? Answer in one sentence.' }
],
temperature: 0.7,
maxTokens: 100,
});
console.log('Response:', result.text);
console.log('\nUsage:', result.usage);
console.log('Finish Reason:', result.finishReason);
}
main().catch(console.error);
@@ -0,0 +1,123 @@
/**
* AI SDK Multi-Agent Example
*
* Demonstrates multi-agent safety with attribution context.
* Each agent has its own ID and the context is propagated to LLM calls.
*
* Usage:
* npx ts-node examples/ai-sdk/multi-agent.ts
*
* Required environment variables:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from '../../src/llm/providers/ai-sdk';
import type { AttributionContext } from '../../src/llm/providers/ai-sdk/types';
// Simulate multiple agents running in parallel
async function runAgent(
agentId: string,
task: string,
attribution: AttributionContext
): Promise<{ agentId: string; result: string; duration: number }> {
console.log(`[${agentId}] Starting task: "${task}"`);
// Create backend with attribution context
const backend = createAISDKBackend('openai/gpt-4o-mini', {
timeout: 30000,
attribution,
});
const startTime = Date.now();
const result = await backend.generateText({
messages: [
{ role: 'system', content: `You are agent ${agentId}. Be concise.` },
{ role: 'user', content: task }
],
temperature: 0.7,
maxTokens: 100,
});
const duration = Date.now() - startTime;
console.log(`[${agentId}] Completed in ${duration}ms`);
return {
agentId,
result: result.text,
duration
};
}
async function main() {
console.log('AI SDK Multi-Agent Example\n');
console.log('Running multiple agents in parallel with attribution tracking...\n');
// Shared run context
const runId = `run-${Date.now()}`;
const traceId = `trace-${Math.random().toString(36).slice(2, 10)}`;
const sessionId = 'session-demo';
console.log(`Run ID: ${runId}`);
console.log(`Trace ID: ${traceId}`);
console.log(`Session ID: ${sessionId}\n`);
console.log('---\n');
// Define agents with their tasks
const agents = [
{
id: 'researcher',
task: 'What is the population of Tokyo? Just the number.',
attribution: {
agentId: 'researcher',
runId,
traceId,
sessionId,
}
},
{
id: 'analyst',
task: 'What is 15% of 1000? Just the number.',
attribution: {
agentId: 'analyst',
runId,
traceId,
sessionId,
}
},
{
id: 'writer',
task: 'Write a one-sentence tagline for a coffee shop.',
attribution: {
agentId: 'writer',
runId,
traceId,
sessionId,
}
}
];
// Run all agents in parallel
const startTime = Date.now();
const results = await Promise.all(
agents.map(agent => runAgent(agent.id, agent.task, agent.attribution))
);
const totalDuration = Date.now() - startTime;
console.log('\n---\n');
console.log('Results:\n');
for (const result of results) {
console.log(`[${result.agentId}] (${result.duration}ms)`);
console.log(` ${result.result.trim()}\n`);
}
console.log('---');
console.log(`Total parallel execution time: ${totalDuration}ms`);
console.log(`Sum of individual times: ${results.reduce((sum, r) => sum + r.duration, 0)}ms`);
console.log('\nNote: Attribution headers (X-Agent-Id, X-Run-Id, X-Trace-Id, X-Session-Id)');
console.log('are automatically injected into each LLM request for tracing and debugging.');
}
main().catch(console.error);
@@ -0,0 +1,68 @@
/**
* AI SDK Multi-Provider Example
*
* Demonstrates using multiple AI providers with the same interface.
*
* Usage:
* npx ts-node examples/ai-sdk/multi-provider.ts
*
* Required environment variables (set at least one):
* OPENAI_API_KEY - Your OpenAI API key
* ANTHROPIC_API_KEY - Your Anthropic API key
* GOOGLE_API_KEY - Your Google API key
*/
import { createAISDKBackend, validateProviderApiKey } from '../../src/llm/providers/ai-sdk';
const PROVIDERS = [
{ id: 'openai', model: 'gpt-4o-mini' },
{ id: 'anthropic', model: 'claude-3-haiku-20240307' },
{ id: 'google', model: 'gemini-1.5-flash' },
];
async function main() {
console.log('AI SDK Multi-Provider Example\n');
console.log('Testing multiple providers with the same prompt...\n');
const prompt = 'What is 2 + 2? Answer with just the number.';
console.log(`Prompt: "${prompt}"\n`);
console.log('---\n');
for (const provider of PROVIDERS) {
// Check if API key is available
if (!validateProviderApiKey(provider.id)) {
console.log(`${provider.id}: Skipped (no API key)`);
continue;
}
try {
const modelString = `${provider.id}/${provider.model}`;
console.log(`${provider.id} (${provider.model}):`);
const backend = createAISDKBackend(modelString, {
timeout: 30000,
maxRetries: 1,
});
const startTime = Date.now();
const result = await backend.generateText({
messages: [
{ role: 'user', content: prompt }
],
temperature: 0,
maxTokens: 10,
});
const duration = Date.now() - startTime;
console.log(` Response: ${result.text.trim()}`);
console.log(` Duration: ${duration}ms`);
console.log(` Tokens: ${result.usage?.totalTokens || 'N/A'}`);
console.log();
} catch (error) {
console.log(` Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
console.log();
}
}
}
main().catch(console.error);
@@ -0,0 +1,56 @@
/**
* AI SDK Streaming Example
*
* Demonstrates streaming text generation using the AI SDK backend.
*
* Usage:
* npx ts-node examples/ai-sdk/streaming.ts
*
* Required environment variables:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from '../../src/llm/providers/ai-sdk';
async function main() {
console.log('AI SDK Streaming Example\n');
// Create a backend for OpenAI
const backend = createAISDKBackend('openai/gpt-4o-mini', {
timeout: 60000,
});
console.log(`Provider: ${backend.providerId}`);
console.log(`Model: ${backend.modelId}\n`);
console.log('Streaming response:\n');
// Stream text
const stream = await backend.streamText({
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Write a short poem about programming.' }
],
temperature: 0.8,
maxTokens: 200,
onToken: (token) => {
// This callback is called for each token
process.stdout.write(token);
},
});
// Consume the stream
let totalTokens = 0;
for await (const chunk of stream) {
if (chunk.text) {
// Text is already printed via onToken callback
}
if (chunk.usage) {
totalTokens = chunk.usage.totalTokens || 0;
}
}
console.log('\n\n---');
console.log(`Total tokens: ${totalTokens}`);
}
main().catch(console.error);
@@ -0,0 +1,129 @@
/**
* AI SDK Structured Output Example
*
* Demonstrates generating structured JSON output using schemas.
*
* Usage:
* npx ts-node examples/ai-sdk/structured-output.ts
*
* Required environment variables:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from '../../src/llm/providers/ai-sdk';
// Define the schema for structured output
const recipeSchema = {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Name of the recipe'
},
ingredients: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
amount: { type: 'string' },
unit: { type: 'string' }
},
required: ['name', 'amount']
},
description: 'List of ingredients'
},
steps: {
type: 'array',
items: { type: 'string' },
description: 'Cooking steps'
},
prepTime: {
type: 'number',
description: 'Preparation time in minutes'
},
cookTime: {
type: 'number',
description: 'Cooking time in minutes'
},
servings: {
type: 'number',
description: 'Number of servings'
}
},
required: ['name', 'ingredients', 'steps']
};
interface Recipe {
name: string;
ingredients: Array<{
name: string;
amount: string;
unit?: string;
}>;
steps: string[];
prepTime?: number;
cookTime?: number;
servings?: number;
}
async function main() {
console.log('AI SDK Structured Output Example\n');
const backend = createAISDKBackend('openai/gpt-4o-mini', {
timeout: 30000,
});
console.log(`Provider: ${backend.providerId}`);
console.log(`Model: ${backend.modelId}\n`);
console.log('Generating a recipe in structured JSON format...\n');
const result = await backend.generateObject<Recipe>({
messages: [
{
role: 'system',
content: 'You are a helpful cooking assistant. Generate recipes in the requested format.'
},
{
role: 'user',
content: 'Give me a simple recipe for chocolate chip cookies.'
}
],
schema: recipeSchema,
temperature: 0.7,
});
console.log('Generated Recipe:');
console.log('================\n');
const recipe = result.object;
console.log(`📝 ${recipe.name}`);
console.log();
if (recipe.prepTime || recipe.cookTime) {
console.log(`⏱️ Prep: ${recipe.prepTime || 'N/A'} min | Cook: ${recipe.cookTime || 'N/A'} min`);
}
if (recipe.servings) {
console.log(`🍽️ Servings: ${recipe.servings}`);
}
console.log();
console.log('Ingredients:');
for (const ing of recipe.ingredients) {
const unit = ing.unit ? ` ${ing.unit}` : '';
console.log(` • ${ing.amount}${unit} ${ing.name}`);
}
console.log();
console.log('Steps:');
recipe.steps.forEach((step, i) => {
console.log(` ${i + 1}. ${step}`);
});
console.log('\n---');
console.log('Usage:', result.usage);
}
main().catch(console.error);
@@ -0,0 +1,144 @@
/**
* AI SDK Tool Calling Example
*
* Demonstrates tool calling using the AI SDK backend.
*
* Usage:
* npx ts-node examples/ai-sdk/tool-calling.ts
*
* Required environment variables:
* OPENAI_API_KEY - Your OpenAI API key
*/
import { createAISDKBackend } from '../../src/llm/providers/ai-sdk';
import type { ToolDefinition, Message } from '../../src/llm/providers/types';
// Define tools
const tools: ToolDefinition[] = [
{
name: 'get_weather',
description: 'Get the current weather for a location',
parameters: {
type: 'object',
properties: {
location: {
type: 'string',
description: 'The city and country, e.g., "London, UK"'
},
unit: {
type: 'string',
enum: ['celsius', 'fahrenheit'],
description: 'Temperature unit'
}
},
required: ['location']
}
},
{
name: 'get_time',
description: 'Get the current time for a timezone',
parameters: {
type: 'object',
properties: {
timezone: {
type: 'string',
description: 'The timezone, e.g., "America/New_York"'
}
},
required: ['timezone']
}
}
];
// Mock tool execution
function executeTool(name: string, args: Record<string, unknown>): string {
switch (name) {
case 'get_weather':
return JSON.stringify({
location: args.location,
temperature: 22,
unit: args.unit || 'celsius',
condition: 'Partly cloudy'
});
case 'get_time':
return JSON.stringify({
timezone: args.timezone,
time: new Date().toLocaleTimeString('en-US', {
timeZone: args.timezone as string
})
});
default:
return JSON.stringify({ error: 'Unknown tool' });
}
}
async function main() {
console.log('AI SDK Tool Calling Example\n');
const backend = createAISDKBackend('openai/gpt-4o-mini', {
timeout: 30000,
});
console.log(`Provider: ${backend.providerId}`);
console.log(`Model: ${backend.modelId}\n`);
// Initial conversation
const messages: Message[] = [
{ role: 'system', content: 'You are a helpful assistant with access to weather and time tools.' },
{ role: 'user', content: 'What is the weather in London and what time is it in New York?' }
];
console.log('User:', messages[1].content);
console.log('\n--- First LLM Call ---\n');
// First call - model should request tool calls
let result = await backend.generateText({
messages,
tools,
toolChoice: 'auto',
});
console.log('Finish Reason:', result.finishReason);
// Handle tool calls
if (result.toolCalls && result.toolCalls.length > 0) {
console.log(`\nTool Calls: ${result.toolCalls.length}`);
// Add assistant message with tool calls
messages.push({
role: 'assistant',
content: null,
tool_calls: result.toolCalls
});
// Execute each tool and add results
for (const toolCall of result.toolCalls) {
const args = JSON.parse(toolCall.function.arguments);
console.log(`\n Executing: ${toolCall.function.name}`);
console.log(` Arguments: ${JSON.stringify(args)}`);
const toolResult = executeTool(toolCall.function.name, args);
console.log(` Result: ${toolResult}`);
messages.push({
role: 'tool',
content: toolResult,
tool_call_id: toolCall.id,
name: toolCall.function.name
});
}
console.log('\n--- Second LLM Call (with tool results) ---\n');
// Second call with tool results
result = await backend.generateText({
messages,
tools,
});
}
console.log('Final Response:', result.text);
console.log('\nUsage:', result.usage);
}
main().catch(console.error);
+27 -3
View File
@@ -1,6 +1,6 @@
{
"name": "praisonai",
"version": "1.3.2",
"version": "1.3.3",
"description": "PraisonAI TypeScript AI Agents Framework - Node.js, npm, and Javascript AI Agents Framework",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -46,11 +46,15 @@
"author": "Mervin Praison",
"license": "MIT",
"devDependencies": {
"@ai-sdk/anthropic": "^3.0.1",
"@ai-sdk/google": "^3.0.1",
"@ai-sdk/openai": "^3.0.1",
"@types/figlet": "^1.7.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.12.0",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"ai": "^6.0.3",
"eslint": "^9.19.0",
"jest": "^29.7.0",
"rimraf": "^5.0.5",
@@ -60,12 +64,12 @@
"typescript": "^5.7.3"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"axios": "^1.7.9",
"dotenv": "^16.4.7",
"fast-xml-parser": "^4.5.1",
"node-fetch": "^2.6.9",
"openai": "^4.81.0",
"@modelcontextprotocol/sdk": "^1.12.1"
"openai": "^4.81.0"
},
"optionalDependencies": {
"boxen": "^7.1.1",
@@ -74,6 +78,26 @@
"figlet": "^1.7.0",
"ora": "^5.4.1"
},
"peerDependencies": {
"@ai-sdk/anthropic": ">=1.0.0",
"@ai-sdk/google": ">=1.0.0",
"@ai-sdk/openai": ">=1.0.0",
"ai": ">=4.0.0"
},
"peerDependenciesMeta": {
"ai": {
"optional": true
},
"@ai-sdk/openai": {
"optional": true
},
"@ai-sdk/anthropic": {
"optional": true
},
"@ai-sdk/google": {
"optional": true
}
},
"overrides": {
"whatwg-url": "^14.1.0",
"node-fetch": "^2.6.9"
+777
View File
@@ -0,0 +1,777 @@
/**
* LLM Command - AI SDK provider management and testing
*
* Subcommands:
* - providers: List available AI SDK providers
* - test: Test connectivity to a provider
* - validate: Validate provider configuration
* - run: Run a prompt with a specific model
*/
import { outputJson, formatSuccess, formatError } from '../output/json';
import * as pretty from '../output/pretty';
import { ERROR_CODES } from '../output/errors';
export interface LLMOptions {
verbose?: boolean;
output?: 'json' | 'text' | 'pretty';
json?: boolean;
model?: string;
stream?: boolean;
timeout?: number;
provider?: string;
schema?: string;
tool?: string;
}
// Exit codes specific to LLM commands
const LLM_EXIT_CODES = {
SUCCESS: 0,
GENERAL_ERROR: 1,
INVALID_ARGS: 2,
AUTH_ERROR: 3,
PROVIDER_NOT_FOUND: 4,
NETWORK_ERROR: 5,
TIMEOUT: 6,
} as const;
export async function execute(args: string[], options: LLMOptions): Promise<void> {
const subcommand = args[0] || 'providers';
const outputFormat = options.json ? 'json' : (options.output || 'pretty');
switch (subcommand) {
case 'providers':
await providersCommand(args.slice(1), options, outputFormat);
break;
case 'test':
await testCommand(args.slice(1), options, outputFormat);
break;
case 'validate':
await validateCommand(args.slice(1), options, outputFormat);
break;
case 'run':
await runCommand(args.slice(1), options, outputFormat);
break;
case 'models':
await modelsCommand(args.slice(1), options, outputFormat);
break;
case 'config':
await configCommand(args.slice(1), options, outputFormat);
break;
case 'trace':
await traceCommand(args.slice(1), options, outputFormat);
break;
case 'tools':
await toolsCommand(args.slice(1), options, outputFormat);
break;
case 'json':
await jsonCommand(args.slice(1), options, outputFormat);
break;
default:
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.INVALID_ARGS, `Unknown subcommand: ${subcommand}`));
} else {
await pretty.error(`Unknown subcommand: ${subcommand}`);
await pretty.info('Available subcommands: providers, test, validate, run, models, config, trace, tools, json');
}
process.exit(LLM_EXIT_CODES.INVALID_ARGS);
}
}
/**
* List available AI SDK providers
*/
async function providersCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
try {
const {
listSupportedProviders,
AISDK_PROVIDERS,
validateProviderApiKey,
isAISDKAvailable,
getAISDKVersion
} = await import('../../llm/providers/ai-sdk');
const providers = listSupportedProviders();
const aiSdkAvailable = await isAISDKAvailable();
const aiSdkVersion = await getAISDKVersion();
const providerDetails = providers.map((id: string) => {
const info = AISDK_PROVIDERS[id as keyof typeof AISDK_PROVIDERS];
const hasApiKey = validateProviderApiKey(id);
return {
id,
package: info?.package || 'custom',
envKey: info?.envKey || 'N/A',
hasApiKey,
status: hasApiKey ? 'ready' : 'missing_key'
};
});
if (outputFormat === 'json') {
outputJson(formatSuccess({
ai_sdk_available: aiSdkAvailable,
ai_sdk_version: aiSdkVersion,
providers: providerDetails,
total: providers.length
}));
} else {
await pretty.heading('AI SDK Providers');
if (aiSdkAvailable) {
await pretty.success(`AI SDK: ${aiSdkVersion || 'installed'}`);
} else {
await pretty.warn('AI SDK not installed. Run: npm install ai');
}
await pretty.newline();
await pretty.plain(' Available Providers:');
for (const p of providerDetails) {
const status = p.hasApiKey ? '✅' : '⚠️';
const keyInfo = p.hasApiKey ? '🔑' : `Missing ${p.envKey}`;
await pretty.plain(` ${status} ${p.id.padEnd(20)} ${keyInfo}`);
if (options.verbose) {
await pretty.dim(` Package: ${p.package}`);
}
}
await pretty.newline();
await pretty.info('Set API keys via environment variables');
await pretty.dim(' Example: export OPENAI_API_KEY=sk-...');
}
} catch (error) {
handleError(error, outputFormat, 'Failed to list providers');
}
}
/**
* Test connectivity to a provider
*/
async function testCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
const providerId = args[0] || options.provider;
if (!providerId) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Provider ID required'));
} else {
await pretty.error('Provider ID required. Usage: llm test <provider>');
await pretty.dim(' Example: praisonai-ts llm test openai');
}
process.exit(LLM_EXIT_CODES.INVALID_ARGS);
return;
}
try {
const {
createAISDKBackend,
validateProviderApiKey,
getMissingApiKeyMessage,
isProviderSupported
} = await import('../../llm/providers/ai-sdk');
// Check if provider is supported
if (!isProviderSupported(providerId)) {
if (outputFormat === 'json') {
outputJson(formatError('PROVIDER_NOT_FOUND', `Provider '${providerId}' is not supported`));
} else {
await pretty.error(`Provider '${providerId}' is not supported`);
}
process.exit(LLM_EXIT_CODES.PROVIDER_NOT_FOUND);
return;
}
// Check API key
if (!validateProviderApiKey(providerId)) {
const msg = getMissingApiKeyMessage(providerId);
if (outputFormat === 'json') {
outputJson(formatError('AUTH_ERROR', msg));
} else {
await pretty.error(msg);
}
process.exit(LLM_EXIT_CODES.AUTH_ERROR);
return;
}
// Determine test model based on provider
const testModels: Record<string, string> = {
openai: 'gpt-4o-mini',
anthropic: 'claude-3-haiku-20240307',
google: 'gemini-1.5-flash',
groq: 'llama-3.1-8b-instant',
mistral: 'mistral-small-latest',
cohere: 'command-light',
deepseek: 'deepseek-chat',
};
const modelId = options.model || testModels[providerId] || 'default';
const modelString = `${providerId}/${modelId}`;
if (outputFormat !== 'json') {
await pretty.info(`Testing ${modelString}...`);
}
const startTime = Date.now();
const backend = createAISDKBackend(modelString, {
timeout: options.timeout || 30000,
maxRetries: 0, // No retries for test
});
const result = await backend.generateText({
messages: [
{ role: 'user', content: 'Say "OK" and nothing else.' }
],
maxTokens: 10,
temperature: 0,
});
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess({
provider: providerId,
model: modelId,
status: 'success',
duration_ms: duration,
response: result.text,
usage: result.usage
}));
} else {
await pretty.success(`Connection successful! (${duration}ms)`);
await pretty.plain(` Provider: ${providerId}`);
await pretty.plain(` Model: ${modelId}`);
await pretty.plain(` Response: ${result.text.trim()}`);
if (result.usage) {
await pretty.dim(` Tokens: ${result.usage.totalTokens}`);
}
}
} catch (error) {
handleError(error, outputFormat, `Failed to test provider '${providerId}'`);
}
}
/**
* Validate provider configuration
*/
async function validateCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
const providerId = args[0] || options.provider;
if (!providerId) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Provider ID required'));
} else {
await pretty.error('Provider ID required. Usage: llm validate --provider <id>');
}
process.exit(LLM_EXIT_CODES.INVALID_ARGS);
return;
}
try {
const {
isProviderSupported,
validateProviderApiKey,
getProviderEnvKey,
getProviderPackage,
isAISDKAvailable
} = await import('../../llm/providers/ai-sdk');
const checks: Array<{ name: string; passed: boolean; message: string }> = [];
// Check AI SDK availability
const aiSdkAvailable = await isAISDKAvailable();
checks.push({
name: 'AI SDK Installed',
passed: aiSdkAvailable,
message: aiSdkAvailable ? 'AI SDK is installed' : 'AI SDK not found. Run: npm install ai'
});
// Check provider support
const isSupported = isProviderSupported(providerId);
checks.push({
name: 'Provider Supported',
passed: isSupported,
message: isSupported ? `Provider '${providerId}' is supported` : `Provider '${providerId}' is not supported`
});
if (isSupported) {
// Check provider package
const pkg = getProviderPackage(providerId);
if (pkg) {
let pkgInstalled = false;
try {
require.resolve(pkg);
pkgInstalled = true;
} catch {
pkgInstalled = false;
}
checks.push({
name: 'Provider Package',
passed: pkgInstalled,
message: pkgInstalled ? `${pkg} is installed` : `${pkg} not found. Run: npm install ${pkg}`
});
}
// Check API key
const hasApiKey = validateProviderApiKey(providerId);
const envKey = getProviderEnvKey(providerId);
checks.push({
name: 'API Key',
passed: hasApiKey,
message: hasApiKey
? `${envKey} is set`
: `${envKey} is not set`
});
}
const allPassed = checks.every(c => c.passed);
if (outputFormat === 'json') {
outputJson(formatSuccess({
provider: providerId,
valid: allPassed,
checks
}));
} else {
await pretty.heading(`Validation: ${providerId}`);
for (const check of checks) {
const icon = check.passed ? '✅' : '❌';
await pretty.plain(` ${icon} ${check.name}: ${check.message}`);
}
await pretty.newline();
if (allPassed) {
await pretty.success('All checks passed!');
} else {
await pretty.warn('Some checks failed. Fix the issues above.');
process.exit(LLM_EXIT_CODES.GENERAL_ERROR);
}
}
} catch (error) {
handleError(error, outputFormat, `Failed to validate provider '${providerId}'`);
}
}
/**
* Run a prompt with a specific model
*/
async function runCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
const prompt = args.join(' ');
if (!prompt) {
if (outputFormat === 'json') {
outputJson(formatError(ERROR_CODES.MISSING_ARG, 'Prompt required'));
} else {
await pretty.error('Prompt required. Usage: llm run "<prompt>" --model <provider/model>');
await pretty.dim(' Example: praisonai-ts llm run "Hello!" --model openai/gpt-4o-mini');
}
process.exit(LLM_EXIT_CODES.INVALID_ARGS);
return;
}
const modelString = options.model || 'openai/gpt-4o-mini';
try {
const { createAISDKBackend } = await import('../../llm/providers/ai-sdk');
const backend = createAISDKBackend(modelString, {
timeout: options.timeout || 60000,
});
if (options.stream) {
// Streaming mode
if (outputFormat === 'json') {
// JSON streaming - output each chunk as JSON line
const stream = await backend.streamText({
messages: [{ role: 'user', content: prompt }],
});
process.stdout.write('{"type":"stream_start"}\n');
let fullText = '';
for await (const chunk of stream) {
if (chunk.text) {
fullText += chunk.text;
process.stdout.write(JSON.stringify({ type: 'text', text: chunk.text }) + '\n');
}
if (chunk.finishReason) {
process.stdout.write(JSON.stringify({
type: 'finish',
finishReason: chunk.finishReason,
usage: chunk.usage
}) + '\n');
}
}
process.stdout.write(JSON.stringify({ type: 'stream_end', fullText }) + '\n');
} else {
// Pretty streaming - output text directly
const stream = await backend.streamText({
messages: [{ role: 'user', content: prompt }],
});
for await (const chunk of stream) {
if (chunk.text) {
process.stdout.write(chunk.text);
}
}
process.stdout.write('\n');
}
} else {
// Non-streaming mode
const startTime = Date.now();
const result = await backend.generateText({
messages: [{ role: 'user', content: prompt }],
});
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess({
model: modelString,
text: result.text,
usage: result.usage,
finishReason: result.finishReason,
duration_ms: duration
}));
} else {
await pretty.plain(result.text);
if (options.verbose) {
await pretty.newline();
await pretty.dim(`Model: ${modelString}`);
await pretty.dim(`Duration: ${duration}ms`);
if (result.usage) {
await pretty.dim(`Tokens: ${result.usage.totalTokens} (prompt: ${result.usage.promptTokens}, completion: ${result.usage.completionTokens})`);
}
}
}
}
} catch (error) {
handleError(error, outputFormat, `Failed to run prompt with model '${modelString}'`);
}
}
/**
* Handle errors with proper exit codes and formatting
*/
/**
* List common models for each provider
*/
async function modelsCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
try {
const { listSupportedProviders, AISDK_PROVIDERS } = await import('../../llm/providers/ai-sdk');
// Common models per provider (not exhaustive)
const commonModels: Record<string, string[]> = {
openai: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo', 'o1', 'o1-mini'],
anthropic: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest', 'claude-3-opus-latest'],
google: ['gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'],
groq: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'],
mistral: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest'],
cohere: ['command-r-plus', 'command-r', 'command-light'],
deepseek: ['deepseek-chat', 'deepseek-coder'],
xai: ['grok-2', 'grok-2-mini'],
};
const providers = listSupportedProviders();
const modelsData = providers.map((id: string) => ({
provider: id,
models: commonModels[id] || ['(check provider docs)'],
package: AISDK_PROVIDERS[id as keyof typeof AISDK_PROVIDERS]?.package || 'custom'
}));
if (outputFormat === 'json') {
outputJson(formatSuccess({
note: 'This is not an exhaustive list. Check provider documentation for all available models.',
providers: modelsData
}));
} else {
await pretty.heading('Common Models by Provider');
await pretty.dim('Note: This is not exhaustive. Check provider docs for all models.\n');
for (const p of modelsData) {
await pretty.plain(` ${p.provider}:`);
for (const model of p.models) {
await pretty.dim(` - ${model}`);
}
}
await pretty.newline();
await pretty.info('Usage: praisonai-ts llm run "prompt" --model provider/model');
}
} catch (error) {
handleError(error, outputFormat, 'Failed to list models');
}
}
/**
* Show resolved configuration (redacted)
*/
async function configCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
try {
const { SAFE_DEFAULTS, AISDK_PROVIDERS, PROVIDER_ALIASES } = await import('../../llm/providers/ai-sdk');
// Gather environment config (redacted)
const envConfig: Record<string, string> = {};
for (const [id, info] of Object.entries(AISDK_PROVIDERS)) {
const envKey = (info as any).envKey;
if (envKey && process.env[envKey]) {
envConfig[envKey] = '****' + process.env[envKey]!.slice(-4);
}
}
const config = {
defaults: SAFE_DEFAULTS,
aliases: PROVIDER_ALIASES,
environment: envConfig,
model: options.model || 'openai/gpt-4o-mini',
timeout: options.timeout || SAFE_DEFAULTS.timeout,
};
if (outputFormat === 'json') {
outputJson(formatSuccess(config));
} else {
await pretty.heading('AI SDK Configuration');
await pretty.plain(' Defaults:');
await pretty.dim(` timeout: ${SAFE_DEFAULTS.timeout}ms`);
await pretty.dim(` maxRetries: ${SAFE_DEFAULTS.maxRetries}`);
await pretty.dim(` maxOutputTokens: ${SAFE_DEFAULTS.maxOutputTokens}`);
await pretty.dim(` redactLogs: ${SAFE_DEFAULTS.redactLogs}`);
await pretty.newline();
await pretty.plain(' Provider Aliases:');
for (const [alias, target] of Object.entries(PROVIDER_ALIASES)) {
await pretty.dim(` ${alias} → ${target}`);
}
await pretty.newline();
await pretty.plain(' Environment (redacted):');
if (Object.keys(envConfig).length === 0) {
await pretty.dim(' No API keys detected');
} else {
for (const [key, value] of Object.entries(envConfig)) {
await pretty.dim(` ${key}: ${value}`);
}
}
}
} catch (error) {
handleError(error, outputFormat, 'Failed to show config');
}
}
/**
* Demo trace/attribution headers
*/
async function traceCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
try {
const { createAISDKBackend, createAttributionMiddleware } = await import('../../llm/providers/ai-sdk');
const modelString = options.model || 'openai/gpt-4o-mini';
// Generate demo attribution context
const attribution = {
agentId: `agent-${Date.now().toString(36)}`,
runId: `run-${Math.random().toString(36).slice(2, 10)}`,
traceId: `trace-${Math.random().toString(36).slice(2, 10)}`,
sessionId: options.verbose ? `session-demo` : undefined,
};
if (outputFormat !== 'json') {
await pretty.heading('Attribution Trace Demo');
await pretty.info(`Running with model: ${modelString}`);
await pretty.newline();
await pretty.plain(' Attribution Context:');
await pretty.dim(` agentId: ${attribution.agentId}`);
await pretty.dim(` runId: ${attribution.runId}`);
await pretty.dim(` traceId: ${attribution.traceId}`);
if (attribution.sessionId) {
await pretty.dim(` sessionId: ${attribution.sessionId}`);
}
await pretty.newline();
await pretty.plain(' Headers injected:');
await pretty.dim(` X-Agent-Id: ${attribution.agentId}`);
await pretty.dim(` X-Run-Id: ${attribution.runId}`);
await pretty.dim(` X-Trace-Id: ${attribution.traceId}`);
await pretty.newline();
}
const backend = createAISDKBackend(modelString, {
timeout: options.timeout || 30000,
attribution,
});
const startTime = Date.now();
const result = await backend.generateText({
messages: [{ role: 'user', content: 'Say "Trace OK" and nothing else.' }],
maxTokens: 10,
});
const duration = Date.now() - startTime;
if (outputFormat === 'json') {
outputJson(formatSuccess({
attribution,
headers: {
'X-Agent-Id': attribution.agentId,
'X-Run-Id': attribution.runId,
'X-Trace-Id': attribution.traceId,
},
response: result.text,
duration_ms: duration,
}));
} else {
await pretty.success(`Response: ${result.text.trim()} (${duration}ms)`);
await pretty.newline();
await pretty.info('Attribution headers are automatically injected into LLM requests.');
await pretty.dim('Use --verbose for session ID.');
}
} catch (error) {
handleError(error, outputFormat, 'Failed to run trace demo');
}
}
/**
* Demo tool calling - shows how tools work with AI SDK
* Note: AI SDK requires Zod schemas for tools, which requires runtime setup
*/
async function toolsCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
try {
const modelString = options.model || 'openai/gpt-4o-mini';
if (outputFormat !== 'json') {
await pretty.heading('Tool Calling Demo');
await pretty.info(`Model: ${modelString}`);
await pretty.newline();
await pretty.plain(' AI SDK Tool Calling requires Zod schemas.');
await pretty.newline();
await pretty.plain(' Example code:');
await pretty.dim(` import { generateText, tool } from 'ai';`);
await pretty.dim(` import { openai } from '@ai-sdk/openai';`);
await pretty.dim(` import { z } from 'zod';`);
await pretty.newline();
await pretty.dim(` const tools = {`);
await pretty.dim(` get_weather: tool({`);
await pretty.dim(` description: 'Get weather for a city',`);
await pretty.dim(` inputSchema: z.object({`);
await pretty.dim(` city: z.string().describe('City name'),`);
await pretty.dim(` }),`);
await pretty.dim(` }),`);
await pretty.dim(` };`);
await pretty.newline();
await pretty.dim(` const result = await generateText({`);
await pretty.dim(` model: openai('gpt-4o-mini'),`);
await pretty.dim(` messages: [{ role: 'user', content: 'Weather in Paris?' }],`);
await pretty.dim(` tools,`);
await pretty.dim(` });`);
await pretty.newline();
await pretty.info('See examples/ai-sdk/tool-calling.ts for a complete example.');
} else {
outputJson(formatSuccess({
model: modelString,
note: 'AI SDK tool calling requires Zod schemas at runtime',
example: {
import: "import { generateText, tool } from 'ai'; import { z } from 'zod';",
tool: "tool({ description: '...', inputSchema: z.object({ city: z.string() }) })",
},
docs: 'See examples/ai-sdk/tool-calling.ts',
}));
}
} catch (error) {
handleError(error, outputFormat, 'Failed to run tools demo');
}
}
/**
* Demo structured output (JSON schema) - shows how to use generateObject
* Note: AI SDK requires Zod schemas for structured output
*/
async function jsonCommand(args: string[], options: LLMOptions, outputFormat: string): Promise<void> {
try {
const modelString = options.model || 'openai/gpt-4o-mini';
if (outputFormat !== 'json') {
await pretty.heading('Structured Output (JSON) Demo');
await pretty.info(`Model: ${modelString}`);
await pretty.newline();
await pretty.plain(' AI SDK Structured Output requires Zod schemas.');
await pretty.newline();
await pretty.plain(' Example code:');
await pretty.dim(` import { generateObject } from 'ai';`);
await pretty.dim(` import { openai } from '@ai-sdk/openai';`);
await pretty.dim(` import { z } from 'zod';`);
await pretty.newline();
await pretty.dim(` const schema = z.object({`);
await pretty.dim(` name: z.string(),`);
await pretty.dim(` age: z.number(),`);
await pretty.dim(` city: z.string(),`);
await pretty.dim(` });`);
await pretty.newline();
await pretty.dim(` const result = await generateObject({`);
await pretty.dim(` model: openai('gpt-4o-mini'),`);
await pretty.dim(` schema,`);
await pretty.dim(` prompt: 'Generate a fictional person profile.',`);
await pretty.dim(` });`);
await pretty.newline();
await pretty.dim(` console.log(result.object); // { name: '...', age: 25, city: '...' }`);
await pretty.newline();
await pretty.info('See examples/ai-sdk/structured-output.ts for a complete example.');
} else {
outputJson(formatSuccess({
model: modelString,
note: 'AI SDK structured output requires Zod schemas at runtime',
example: {
import: "import { generateObject } from 'ai'; import { z } from 'zod';",
schema: "z.object({ name: z.string(), age: z.number(), city: z.string() })",
},
docs: 'See examples/ai-sdk/structured-output.ts',
}));
}
} catch (error) {
handleError(error, outputFormat, 'Failed to run JSON demo');
}
}
/**
* Handle errors with proper exit codes and formatting
*/
async function handleError(error: unknown, outputFormat: string, context: string): Promise<never> {
const { AISDKError } = await import('../../llm/providers/ai-sdk');
let exitCode: number = LLM_EXIT_CODES.GENERAL_ERROR;
let errorCode = 'UNKNOWN_ERROR';
let message = context;
if (error instanceof AISDKError) {
message = `${context}: ${error.message}`;
errorCode = error.code;
switch (error.code) {
case 'AUTHENTICATION':
exitCode = 3; // AUTH_ERROR
break;
case 'PROVIDER_NOT_FOUND':
case 'MODEL_NOT_FOUND':
exitCode = 4; // PROVIDER_NOT_FOUND
break;
case 'NETWORK':
exitCode = 5; // NETWORK_ERROR
break;
case 'TIMEOUT':
exitCode = 6; // TIMEOUT
break;
case 'INVALID_REQUEST':
exitCode = 2; // INVALID_ARGS
break;
}
} else if (error instanceof Error) {
message = `${context}: ${error.message}`;
}
if (outputFormat === 'json') {
outputJson(formatError(errorCode, message));
} else {
await pretty.error(message);
}
process.exit(exitCode);
}
+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];
}
}
+24
View File
@@ -97,6 +97,30 @@ export const COMMANDS: Record<string, Command> = {
description: 'List available LLM providers',
flags: []
},
llm: {
description: 'AI SDK provider management and testing',
subcommands: {
providers: { description: 'List available AI SDK providers' },
test: {
description: 'Test connectivity to a provider',
args: [{ name: 'provider', type: 'string', required: false, position: 0 }]
},
validate: {
description: 'Validate provider configuration',
args: [{ name: 'provider', type: 'string', required: false, position: 0 }]
},
run: {
description: 'Run a prompt with a specific model',
args: [{ name: 'prompt', type: 'string', required: true, position: 0 }]
}
},
flags: [
{ name: 'model', short: 'm', type: 'string', description: 'Model to use (provider/model format)' },
{ name: 'stream', short: 's', type: 'boolean', default: false, description: 'Enable streaming output' },
{ name: 'timeout', type: 'integer', description: 'Request timeout in milliseconds' },
{ name: 'provider', short: 'p', type: 'string', description: 'Provider ID' }
]
},
tools: {
description: 'List or manage tools',
subcommands: {
+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,
@@ -0,0 +1,457 @@
/**
* AI SDK Adapter
*
* Converts between praisonai-ts message/tool formats and AI SDK formats.
* Handles prompt construction, tool definitions, and result parsing.
*/
import type { Message, ToolDefinition, ToolCall, GenerateTextResult, TokenUsage as PraisonTokenUsage } from '../types';
import type {
AISDKToolDefinition,
AISDKToolCall,
AISDKToolResult,
TokenUsage,
FinishReason,
PraisonStreamChunk
} from './types';
/**
* AI SDK prompt message types (simplified for compatibility)
*/
export interface AISDKSystemMessage {
role: 'system';
content: string;
}
export interface AISDKUserMessage {
role: 'user';
content: Array<{ type: 'text'; text: string }>;
}
export interface AISDKAssistantMessage {
role: 'assistant';
content: Array<
| { type: 'text'; text: string }
| { type: 'tool-call'; toolCallId: string; toolName: string; args: unknown }
>;
}
export interface AISDKToolMessage {
role: 'tool';
content: Array<{
type: 'tool-result';
toolCallId: string;
toolName: string;
result: unknown;
}>;
}
export type AISDKMessage =
| AISDKSystemMessage
| AISDKUserMessage
| AISDKAssistantMessage
| AISDKToolMessage;
/**
* Convert praisonai-ts messages to AI SDK prompt format
*/
export function toAISDKPrompt(messages: Message[]): AISDKMessage[] {
return messages.map(msg => {
switch (msg.role) {
case 'system':
return {
role: 'system' as const,
content: msg.content || ''
};
case 'user':
return {
role: 'user' as const,
content: [{ type: 'text' as const, text: msg.content || '' }]
};
case 'assistant':
if (msg.tool_calls && msg.tool_calls.length > 0) {
return {
role: 'assistant' as const,
content: msg.tool_calls.map(tc => ({
type: 'tool-call' as const,
toolCallId: tc.id,
toolName: tc.function.name,
args: safeParseJSON(tc.function.arguments)
}))
};
}
return {
role: 'assistant' as const,
content: [{ type: 'text' as const, text: msg.content || '' }]
};
case 'tool':
return {
role: 'tool' as const,
content: [{
type: 'tool-result' as const,
toolCallId: msg.tool_call_id || '',
toolName: msg.name || '',
result: safeParseJSON(msg.content || '')
}]
};
default:
// Fallback to user message for unknown roles
return {
role: 'user' as const,
content: [{ type: 'text' as const, text: msg.content || '' }]
};
}
});
}
/**
* Convert praisonai-ts tool definitions to AI SDK format
* AI SDK expects tools created with the tool() helper from @ai-sdk/provider-utils
*/
export async function toAISDKTools(tools: ToolDefinition[]): Promise<Record<string, any>> {
const result: Record<string, any> = {};
// Import the tool helper from @ai-sdk/provider-utils
let toolHelper: any;
let jsonSchemaHelper: any;
try {
const providerUtils = await import('@ai-sdk/provider-utils');
toolHelper = providerUtils.tool;
jsonSchemaHelper = providerUtils.jsonSchema;
} catch {
toolHelper = null;
jsonSchemaHelper = null;
}
for (const toolDef of tools) {
// Build a proper JSON Schema for the tool parameters
const params = toolDef.parameters || {};
const schema = {
type: 'object' as const,
properties: params.properties || {},
required: params.required || [],
...(params.additionalProperties !== undefined
? { additionalProperties: params.additionalProperties }
: {}),
};
if (toolHelper && jsonSchemaHelper) {
// Use AI SDK's tool() helper for proper tool creation
result[toolDef.name] = toolHelper({
description: toolDef.description || '',
parameters: jsonSchemaHelper(schema),
});
} else {
// Fallback format - create tool-like object manually
result[toolDef.name] = {
description: toolDef.description || '',
parameters: schema,
};
}
}
return result;
}
/**
* Convert AI SDK tool call to praisonai-ts format
*/
export function fromAISDKToolCall(toolCall: AISDKToolCall): ToolCall {
return {
id: toolCall.toolCallId,
type: 'function',
function: {
name: toolCall.toolName,
arguments: typeof toolCall.args === 'string'
? toolCall.args
: JSON.stringify(toolCall.args)
}
};
}
/**
* Convert praisonai-ts tool result to AI SDK format
*/
export function toAISDKToolResult(
toolCallId: string,
toolName: string,
result: unknown
): AISDKToolResult {
return {
toolCallId,
toolName,
result
};
}
/**
* Extract text content from AI SDK response content
*/
export function extractTextFromContent(content: unknown[]): string {
if (!Array.isArray(content)) {
return '';
}
return content
.filter((part): part is { type: 'text'; text: string } =>
typeof part === 'object' &&
part !== null &&
'type' in part &&
part.type === 'text' &&
'text' in part
)
.map(part => part.text)
.join('');
}
/**
* Extract tool calls from AI SDK response content
*/
export function extractToolCallsFromContent(content: unknown[]): ToolCall[] {
if (!Array.isArray(content)) {
return [];
}
return content
.filter((part): part is { type: 'tool-call'; toolCallId: string; toolName: string; args: unknown } =>
typeof part === 'object' &&
part !== null &&
'type' in part &&
part.type === 'tool-call'
)
.map(part => fromAISDKToolCall({
toolCallId: part.toolCallId,
toolName: part.toolName,
args: part.args
}));
}
/**
* Convert AI SDK generate result to praisonai-ts format
*/
export function fromAISDKResult(result: {
text?: string;
content?: unknown[];
toolCalls?: AISDKToolCall[];
usage?: { promptTokens: number; completionTokens: number };
finishReason?: string;
}): GenerateTextResult {
// Extract text from content or use text directly
let text = result.text || '';
if (!text && result.content) {
text = extractTextFromContent(result.content);
}
// Extract tool calls
let toolCalls: ToolCall[] = [];
if (result.toolCalls && result.toolCalls.length > 0) {
toolCalls = result.toolCalls.map(fromAISDKToolCall);
} else if (result.content) {
toolCalls = extractToolCallsFromContent(result.content);
}
// Map usage - provide defaults if not available
const usage: PraisonTokenUsage = result.usage ? {
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.promptTokens + result.usage.completionTokens
} : {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0
};
// Map finish reason to praisonai-ts format
const finishReason = mapFinishReasonToProvider(result.finishReason);
return {
text,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
usage,
finishReason,
raw: result
};
}
/**
* Map AI SDK finish reason to internal format
*/
export function mapFinishReason(reason?: string): FinishReason {
if (!reason) return 'unknown';
switch (reason.toLowerCase()) {
case 'stop':
case 'end_turn':
case 'end':
return 'stop';
case 'length':
case 'max_tokens':
return 'length';
case 'tool_calls':
case 'tool-calls':
case 'function_call':
return 'tool-calls';
case 'content_filter':
case 'content-filter':
return 'content-filter';
case 'error':
return 'error';
case 'cancelled':
case 'canceled':
return 'cancelled';
default:
return 'unknown';
}
}
/**
* Map AI SDK finish reason to praisonai-ts provider format
* Uses underscore format for tool_calls as expected by GenerateTextResult
*/
export function mapFinishReasonToProvider(reason?: string): 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'error' {
if (!reason) return 'stop';
switch (reason.toLowerCase()) {
case 'stop':
case 'end_turn':
case 'end':
return 'stop';
case 'length':
case 'max_tokens':
return 'length';
case 'tool_calls':
case 'tool-calls':
case 'function_call':
return 'tool_calls';
case 'content_filter':
case 'content-filter':
return 'content_filter';
case 'error':
case 'cancelled':
case 'canceled':
default:
return 'error';
}
}
/**
* Convert AI SDK stream chunk to praisonai-ts format
*/
export function fromAISDKStreamChunk(chunk: {
type: string;
textDelta?: string;
toolCallId?: string;
toolName?: string;
argsTextDelta?: string;
args?: unknown;
finishReason?: string;
usage?: { promptTokens: number; completionTokens: number };
}): PraisonStreamChunk | null {
switch (chunk.type) {
case 'text-delta':
if (chunk.textDelta) {
return { type: 'text', text: chunk.textDelta };
}
return null;
case 'tool-call':
if (chunk.toolCallId && chunk.toolName) {
return {
type: 'tool-call-end',
toolCallId: chunk.toolCallId,
args: chunk.args
};
}
return null;
case 'tool-call-streaming-start':
if (chunk.toolCallId && chunk.toolName) {
return {
type: 'tool-call-start',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName
};
}
return null;
case 'tool-call-delta':
if (chunk.toolCallId && chunk.argsTextDelta) {
return {
type: 'tool-call-delta',
toolCallId: chunk.toolCallId,
argsTextDelta: chunk.argsTextDelta
};
}
return null;
case 'finish':
case 'step-finish':
return {
type: 'finish',
finishReason: mapFinishReason(chunk.finishReason),
usage: chunk.usage ? {
promptTokens: chunk.usage.promptTokens,
completionTokens: chunk.usage.completionTokens,
totalTokens: chunk.usage.promptTokens + chunk.usage.completionTokens
} : undefined
};
default:
return null;
}
}
/**
* Map praisonai-ts tool choice to AI SDK format
*/
export function toAISDKToolChoice(
toolChoice?: 'auto' | 'none' | 'required' | { type: 'tool'; toolName: string }
): 'auto' | 'none' | 'required' | { type: 'tool'; toolName: string } | undefined {
if (!toolChoice) return undefined;
if (typeof toolChoice === 'string') {
return toolChoice;
}
return toolChoice;
}
/**
* Safely parse JSON, returning the original string if parsing fails
*/
function safeParseJSON(str: string): unknown {
if (typeof str !== 'string') {
return str;
}
try {
return JSON.parse(str);
} catch {
return str;
}
}
/**
* Create a simple prompt from a string
*/
export function createSimplePrompt(prompt: string, systemPrompt?: string): AISDKMessage[] {
const messages: AISDKMessage[] = [];
if (systemPrompt) {
messages.push({
role: 'system',
content: systemPrompt
});
}
messages.push({
role: 'user',
content: [{ type: 'text', text: prompt }]
});
return messages;
}
@@ -0,0 +1,549 @@
/**
* AI SDK Backend
*
* Main implementation of the AI SDK backend for praisonai-ts.
* Provides generateText, streamText, and generateObject methods
* with retry logic, timeouts, and attribution support.
*/
import type {
LLMProvider,
GenerateTextOptions,
GenerateTextResult,
StreamTextOptions,
StreamChunk,
GenerateObjectOptions,
GenerateObjectResult,
Message,
ToolDefinition
} from '../types';
import type {
AISDKBackendConfig,
AttributionContext,
AISDKProviderOptions,
PraisonStreamChunk,
TokenUsage
} from './types';
import { AISDKError, SAFE_DEFAULTS } from './types';
import { parseModelString, createAISDKProvider, validateProviderApiKey, getMissingApiKeyMessage } from './provider-map';
import { toAISDKPrompt, toAISDKTools, fromAISDKResult, fromAISDKStreamChunk, toAISDKToolChoice, mapFinishReasonToProvider } from './adapter';
import { createStandardMiddleware, createAttributionMiddleware, composeMiddleware, type AISDKMiddleware } from './middleware';
/**
* Lazy-loaded AI SDK modules
* These are only imported when actually used to avoid startup cost
*
* Note: We use 'any' type here because the 'ai' package is an optional
* peer dependency. The actual types are checked at runtime.
*/
let _aiModule: any = null;
async function getAIModule(): Promise<any> {
if (!_aiModule) {
try {
// Dynamic import - 'ai' is an optional peer dependency
// Use variable to prevent TypeScript from resolving the module at compile time
const moduleName = 'ai';
_aiModule = await import(moduleName);
} catch (error) {
throw new AISDKError(
'AI SDK is not installed. Install it with: npm install ai',
'MISSING_DEPENDENCY',
false,
error
);
}
}
return _aiModule;
}
/**
* AI SDK Backend - implements LLMProvider interface
*/
export class AISDKBackend implements LLMProvider {
public readonly providerId: string;
public readonly modelId: string;
private readonly config: AISDKBackendConfig;
private readonly attribution?: AttributionContext;
private provider: unknown | null = null;
private model: unknown | null = null;
constructor(
modelString: string,
config: AISDKBackendConfig = {}
) {
const parsed = parseModelString(modelString, config.defaultProvider);
this.providerId = parsed.providerId;
this.modelId = parsed.modelId;
// Merge with safe defaults
this.config = {
...SAFE_DEFAULTS,
...config
};
this.attribution = config.attribution;
}
/**
* Initialize the AI SDK provider and model (lazy)
*/
private async ensureInitialized(): Promise<void> {
if (this.model) {
return;
}
// Validate API key
if (!validateProviderApiKey(this.providerId)) {
throw new AISDKError(
getMissingApiKeyMessage(this.providerId),
'AUTHENTICATION',
false
);
}
// Get provider options from config
const providerOptions: AISDKProviderOptions =
this.config.providers?.[this.providerId] || {};
// Create the AI SDK provider
this.provider = await createAISDKProvider(this.providerId, providerOptions);
// Get the language model from the provider
if (typeof this.provider === 'function') {
// Provider is callable (e.g., openai('gpt-4o'))
this.model = (this.provider as Function)(this.modelId);
} else if (this.provider && typeof (this.provider as any).languageModel === 'function') {
// Provider has languageModel method
this.model = (this.provider as any).languageModel(this.modelId);
} else if (this.provider && typeof (this.provider as any).chat === 'function') {
// Provider has chat method (OpenAI style)
this.model = (this.provider as any).chat(this.modelId);
} else {
throw new AISDKError(
`Could not get language model from provider '${this.providerId}'`,
'PROVIDER_ERROR',
false
);
}
}
/**
* Generate text (non-streaming)
*/
async generateText(options: GenerateTextOptions): Promise<GenerateTextResult> {
await this.ensureInitialized();
const ai = await getAIModule();
// Convert messages to AI SDK format
const prompt = toAISDKPrompt(options.messages);
// Convert tools if provided (now async)
const tools = options.tools ? await toAISDKTools(options.tools) : undefined;
// Build call options
const callOptions: Record<string, unknown> = {
model: this.model,
messages: prompt,
maxTokens: options.maxTokens || this.config.maxOutputTokens,
temperature: options.temperature,
topP: options.topP,
frequencyPenalty: options.frequencyPenalty,
presencePenalty: options.presencePenalty,
stopSequences: options.stop,
maxRetries: this.config.maxRetries,
};
if (tools && Object.keys(tools).length > 0) {
callOptions.tools = tools;
callOptions.toolChoice = toAISDKToolChoice(options.toolChoice as any);
}
// Add telemetry settings if configured
if (this.config.telemetry) {
callOptions.experimental_telemetry = this.config.telemetry;
}
// Execute with retry logic
let lastError: unknown;
const maxRetries = this.config.maxRetries || SAFE_DEFAULTS.maxRetries;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, this.config.timeout || SAFE_DEFAULTS.timeout);
try {
callOptions.abortSignal = controller.signal;
const result = await ai.generateText(callOptions as any);
clearTimeout(timeoutId);
return fromAISDKResult({
text: result.text,
toolCalls: result.toolCalls?.map((tc: any) => ({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
args: tc.args
})),
usage: result.usage,
finishReason: result.finishReason
});
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
lastError = error;
const classifiedError = classifyError(error);
// Don't retry non-retryable errors
if (!classifiedError.isRetryable || attempt === maxRetries) {
throw classifiedError;
}
// Exponential backoff
const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
await sleep(delay);
}
}
throw classifyError(lastError);
}
/**
* Stream text generation
*/
async streamText(options: StreamTextOptions): Promise<AsyncIterable<StreamChunk>> {
await this.ensureInitialized();
const ai = await getAIModule();
// Convert messages to AI SDK format
const prompt = toAISDKPrompt(options.messages);
// Convert tools if provided (now async)
const tools = options.tools ? await toAISDKTools(options.tools) : undefined;
// Build call options
const callOptions: Record<string, unknown> = {
model: this.model,
messages: prompt,
maxTokens: options.maxTokens || this.config.maxOutputTokens,
temperature: options.temperature,
topP: options.topP,
frequencyPenalty: options.frequencyPenalty,
presencePenalty: options.presencePenalty,
stopSequences: options.stop,
};
if (tools && Object.keys(tools).length > 0) {
callOptions.tools = tools;
callOptions.toolChoice = toAISDKToolChoice(options.toolChoice as any);
}
// Add telemetry settings if configured
if (this.config.telemetry) {
callOptions.experimental_telemetry = this.config.telemetry;
}
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, this.config.timeout || SAFE_DEFAULTS.timeout);
callOptions.abortSignal = controller.signal;
const self = this;
const onToken = options.onToken;
const onToolCall = options.onToolCall;
// Return async iterable
return {
async *[Symbol.asyncIterator](): AsyncGenerator<StreamChunk, void, unknown> {
try {
const result = await ai.streamText(callOptions as any);
let accumulatedText = '';
const toolCalls: Map<string, { id: string; name: string; args: string }> = new Map();
for await (const chunk of result.textStream) {
// AI SDK streamText returns text chunks directly
if (typeof chunk === 'string') {
accumulatedText += chunk;
if (onToken) {
onToken(chunk);
}
yield {
text: chunk
};
}
}
// Get final result for usage and tool calls
const finalResult = await result;
// Yield tool calls if any
if (finalResult.toolCalls && finalResult.toolCalls.length > 0) {
for (const tc of finalResult.toolCalls) {
const toolCall = {
id: tc.toolCallId,
type: 'function' as const,
function: {
name: tc.toolName,
arguments: typeof tc.args === 'string' ? tc.args : JSON.stringify(tc.args)
}
};
if (onToolCall) {
onToolCall(toolCall);
}
yield {
toolCalls: [toolCall]
};
}
}
// Yield final chunk with usage
yield {
usage: finalResult.usage ? {
promptTokens: finalResult.usage.promptTokens,
completionTokens: finalResult.usage.completionTokens,
totalTokens: finalResult.usage.promptTokens + finalResult.usage.completionTokens
} : undefined,
finishReason: finalResult.finishReason
};
} catch (error) {
throw classifyError(error);
} finally {
clearTimeout(timeoutId);
}
}
};
}
/**
* Generate structured object output
*/
async generateObject<T = unknown>(options: GenerateObjectOptions<T>): Promise<GenerateObjectResult<T>> {
await this.ensureInitialized();
const ai = await getAIModule();
// Convert messages to AI SDK format
const prompt = toAISDKPrompt(options.messages);
// Build call options
const callOptions: Record<string, unknown> = {
model: this.model,
messages: prompt,
schema: options.schema,
maxTokens: options.maxTokens || this.config.maxOutputTokens,
temperature: options.temperature,
maxRetries: options.maxRetries || this.config.maxRetries,
};
// Add telemetry settings if configured
if (this.config.telemetry) {
callOptions.experimental_telemetry = this.config.telemetry;
}
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, this.config.timeout || SAFE_DEFAULTS.timeout);
try {
callOptions.abortSignal = controller.signal;
const result = await ai.generateObject(callOptions as any);
return {
object: result.object as T,
usage: result.usage ? {
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.promptTokens + result.usage.completionTokens
} : {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0
},
raw: result
};
} catch (error) {
throw classifyError(error);
} finally {
clearTimeout(timeoutId);
}
}
/**
* Get the underlying AI SDK model (for advanced use)
*/
async getModel(): Promise<unknown> {
await this.ensureInitialized();
return this.model;
}
/**
* Get the underlying AI SDK provider (for advanced use)
*/
async getProvider(): Promise<unknown> {
await this.ensureInitialized();
return this.provider;
}
}
/**
* Create an AI SDK backend instance
*/
export function createAISDKBackend(
modelString: string,
config?: AISDKBackendConfig
): AISDKBackend {
return new AISDKBackend(modelString, config);
}
/**
* Classify an error into an AISDKError
*/
function classifyError(error: unknown): AISDKError {
if (error instanceof AISDKError) {
return error;
}
const message = error instanceof Error ? error.message : String(error);
const lowerMessage = message.toLowerCase();
// Check for timeout/abort
if (lowerMessage.includes('abort') || lowerMessage.includes('timeout')) {
return new AISDKError(
`Request timed out: ${message}`,
'TIMEOUT',
true,
error
);
}
// Check for rate limit
if (lowerMessage.includes('rate limit') || lowerMessage.includes('429') || lowerMessage.includes('too many')) {
return new AISDKError(
`Rate limit exceeded: ${message}`,
'RATE_LIMIT',
true,
error,
429
);
}
// Check for authentication
if (lowerMessage.includes('unauthorized') || lowerMessage.includes('401') ||
lowerMessage.includes('forbidden') || lowerMessage.includes('403') ||
lowerMessage.includes('api key') || lowerMessage.includes('authentication')) {
return new AISDKError(
`Authentication failed: ${message}`,
'AUTHENTICATION',
false,
error,
401
);
}
// Check for invalid request
if (lowerMessage.includes('bad request') || lowerMessage.includes('400') ||
lowerMessage.includes('invalid')) {
return new AISDKError(
`Invalid request: ${message}`,
'INVALID_REQUEST',
false,
error,
400
);
}
// Check for model not found
if (lowerMessage.includes('model not found') || lowerMessage.includes('404') ||
lowerMessage.includes('does not exist')) {
return new AISDKError(
`Model not found: ${message}`,
'MODEL_NOT_FOUND',
false,
error,
404
);
}
// Check for network errors
if (lowerMessage.includes('network') || lowerMessage.includes('econnrefused') ||
lowerMessage.includes('enotfound') || lowerMessage.includes('socket')) {
return new AISDKError(
`Network error: ${message}`,
'NETWORK',
true,
error
);
}
// Check for cancelled
if (lowerMessage.includes('cancel')) {
return new AISDKError(
`Request cancelled: ${message}`,
'CANCELLED',
false,
error
);
}
// Check for missing dependency
if (lowerMessage.includes('cannot find module') || lowerMessage.includes('module_not_found')) {
return new AISDKError(
`Missing dependency: ${message}`,
'MISSING_DEPENDENCY',
false,
error
);
}
// Check for server errors (retryable)
if (lowerMessage.includes('500') || lowerMessage.includes('502') ||
lowerMessage.includes('503') || lowerMessage.includes('504') ||
lowerMessage.includes('server error')) {
return new AISDKError(
`Server error: ${message}`,
'PROVIDER_ERROR',
true,
error,
500
);
}
// Default to unknown error
return new AISDKError(
message,
'UNKNOWN',
false,
error
);
}
/**
* Sleep for a given number of milliseconds
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
@@ -0,0 +1,195 @@
/**
* AI SDK Integration for praisonai-ts
*
* This module provides integration with Vercel's AI SDK for multi-provider LLM support.
* All imports are lazy-loaded to avoid startup cost when AI SDK is not used.
*
* @example Basic usage
* ```typescript
* import { createAISDKBackend, registerAISDKProviders } from 'praisonai';
*
* // Register AI SDK providers (one-time)
* registerAISDKProviders();
*
* // Create a backend for any supported provider
* const backend = createAISDKBackend('anthropic/claude-3-5-sonnet');
* const result = await backend.generateText({ messages: [...] });
* ```
*/
// Re-export types (these don't cause runtime imports)
export type {
AISDKBackendConfig,
AISDKProviderOptions,
AttributionContext,
AISDKTelemetrySettings,
AISDKErrorCode,
PraisonStreamChunk,
TokenUsage,
FinishReason,
AISDKToolDefinition,
AISDKToolCall,
AISDKToolResult,
} from './types';
export { AISDKError, SAFE_DEFAULTS, AISDK_PROVIDERS, PROVIDER_ALIASES } from './types';
// Re-export provider map functions
export {
parseModelString,
resolveProviderAlias,
isProviderSupported,
getProviderPackage,
getProviderEnvKey,
listSupportedProviders,
registerCustomProvider,
unregisterCustomProvider,
getCustomProvider,
createAISDKProvider,
validateProviderApiKey,
getMissingApiKeyMessage,
type ParsedModel,
type ProviderFactory,
} from './provider-map';
// Re-export adapter functions
export {
toAISDKPrompt,
toAISDKTools,
fromAISDKToolCall,
toAISDKToolResult,
fromAISDKResult,
fromAISDKStreamChunk,
mapFinishReason,
mapFinishReasonToProvider,
toAISDKToolChoice,
createSimplePrompt,
type AISDKMessage,
type AISDKSystemMessage,
type AISDKUserMessage,
type AISDKAssistantMessage,
type AISDKToolMessage,
} from './adapter';
// Re-export middleware functions
export {
createAttributionMiddleware,
createLoggingMiddleware,
createTimeoutMiddleware,
composeMiddleware,
createStandardMiddleware,
redactSensitiveData,
type AISDKMiddleware,
} from './middleware';
// Lazy-loaded backend exports
// These use getters to avoid importing the backend module until needed
let _AISDKBackend: typeof import('./backend').AISDKBackend | null = null;
let _createAISDKBackend: typeof import('./backend').createAISDKBackend | null = null;
/**
* Get the AISDKBackend class (lazy loaded)
*/
export function getAISDKBackendClass(): typeof import('./backend').AISDKBackend {
if (!_AISDKBackend) {
const backend = require('./backend');
_AISDKBackend = backend.AISDKBackend;
}
return _AISDKBackend!;
}
/**
* Create an AI SDK backend instance
*
* @param modelString - Model string in format "provider/model" or just "model"
* @param config - Optional configuration
* @returns AISDKBackend instance implementing LLMProvider
*
* @example
* ```typescript
* const backend = createAISDKBackend('openai/gpt-4o');
* const backend = createAISDKBackend('anthropic/claude-3-5-sonnet');
* const backend = createAISDKBackend('google/gemini-2.0-flash');
* ```
*/
export function createAISDKBackend(
modelString: string,
config?: import('./types').AISDKBackendConfig
): import('./backend').AISDKBackend {
if (!_createAISDKBackend) {
const backend = require('./backend');
_createAISDKBackend = backend.createAISDKBackend;
}
return _createAISDKBackend!(modelString, config);
}
// Re-export the class for direct instantiation
export { AISDKBackend } from './backend';
/**
* Register AI SDK providers with the praisonai-ts provider registry
* This allows using AI SDK providers via the standard createProvider() function
*
* @param customProviders - Optional map of custom provider factories
*
* @example
* ```typescript
* import { registerAISDKProviders, createProvider } from 'praisonai';
*
* // Register all AI SDK providers
* registerAISDKProviders();
*
* // Now you can use AI SDK providers via createProvider
* const provider = createProvider('aisdk:anthropic/claude-3-5-sonnet');
* ```
*/
export function registerAISDKProviders(
customProviders?: Record<string, import('./provider-map').ProviderFactory>
): void {
// Register custom providers if provided
if (customProviders) {
const { registerCustomProvider } = require('./provider-map');
for (const [id, factory] of Object.entries(customProviders)) {
registerCustomProvider(id, factory);
}
}
// Note: Integration with main provider registry is done in ../index.ts
// This function is mainly for registering custom AI SDK providers
}
/**
* Check if AI SDK is available (installed)
*/
export async function isAISDKAvailable(): Promise<boolean> {
try {
// Use variable to prevent TypeScript from resolving at compile time
const moduleName = 'ai';
await import(moduleName);
return true;
} catch {
return false;
}
}
/**
* Get AI SDK version if available
*/
export async function getAISDKVersion(): Promise<string | null> {
try {
// Use variable to prevent TypeScript from resolving at compile time
const moduleName = 'ai';
await import(moduleName);
// AI SDK doesn't export version directly, so we check package.json
try {
const pkgPath = 'ai/package.json';
const pkg = require(pkgPath);
return pkg.version;
} catch {
return 'installed';
}
} catch {
return null;
}
}
@@ -0,0 +1,316 @@
/**
* AI SDK Middleware
*
* Provides middleware for injecting attribution context into AI SDK calls.
* Supports multi-agent safety with agent_id, run_id, trace_id, session_id.
*/
import type { AttributionContext, AISDKBackendConfig } from './types';
/**
* Middleware interface for AI SDK language models
* Compatible with AI SDK's LanguageModelMiddleware type
*/
export interface AISDKMiddleware {
transformParams?: (options: {
params: Record<string, unknown>;
type: 'generate' | 'stream';
model: unknown;
}) => Promise<Record<string, unknown>> | Record<string, unknown>;
wrapGenerate?: (options: {
doGenerate: () => Promise<unknown>;
doStream: () => Promise<unknown>;
params: Record<string, unknown>;
model: unknown;
}) => Promise<unknown>;
wrapStream?: (options: {
doGenerate: () => Promise<unknown>;
doStream: () => Promise<unknown>;
params: Record<string, unknown>;
model: unknown;
}) => Promise<unknown>;
}
/**
* Create attribution middleware that injects agent context into requests
*
* Injects:
* - Headers: X-Agent-Id, X-Run-Id, X-Trace-Id, X-Session-Id
* - Provider options metadata where supported
*/
export function createAttributionMiddleware(ctx: AttributionContext): AISDKMiddleware {
return {
transformParams: async ({ params }) => {
// Build attribution headers
const attributionHeaders: Record<string, string> = {};
if (ctx.agentId) {
attributionHeaders['X-Agent-Id'] = ctx.agentId;
}
if (ctx.runId) {
attributionHeaders['X-Run-Id'] = ctx.runId;
}
if (ctx.traceId) {
attributionHeaders['X-Trace-Id'] = ctx.traceId;
}
if (ctx.sessionId) {
attributionHeaders['X-Session-Id'] = ctx.sessionId;
}
if (ctx.parentSpanId) {
attributionHeaders['X-Parent-Span-Id'] = ctx.parentSpanId;
}
// Merge with existing headers
const existingHeaders = (params.headers as Record<string, string>) || {};
const mergedHeaders = {
...existingHeaders,
...attributionHeaders
};
// Build provider options metadata
const attributionMetadata: Record<string, string> = {};
if (ctx.agentId) attributionMetadata.agentId = ctx.agentId;
if (ctx.runId) attributionMetadata.runId = ctx.runId;
if (ctx.traceId) attributionMetadata.traceId = ctx.traceId;
if (ctx.sessionId) attributionMetadata.sessionId = ctx.sessionId;
// Merge with existing provider options
const existingProviderOptions = (params.providerOptions as Record<string, unknown>) || {};
const existingMetadata = (existingProviderOptions.metadata as Record<string, unknown>) || {};
const mergedProviderOptions = {
...existingProviderOptions,
metadata: {
...existingMetadata,
...attributionMetadata
}
};
return {
...params,
headers: mergedHeaders,
providerOptions: mergedProviderOptions
};
}
};
}
/**
* Create logging middleware for debug purposes
* Only logs when debugLogging is enabled
*/
export function createLoggingMiddleware(config: AISDKBackendConfig): AISDKMiddleware {
if (!config.debugLogging) {
return {};
}
return {
transformParams: async ({ params, type }) => {
const safeParams = config.redactLogs !== false
? redactSensitiveData(params)
: params;
console.log(`[AI SDK] ${type} request:`, JSON.stringify(safeParams, null, 2));
return params;
},
wrapGenerate: async ({ doGenerate, params }) => {
const startTime = Date.now();
try {
const result = await doGenerate();
const duration = Date.now() - startTime;
console.log(`[AI SDK] generate completed in ${duration}ms`);
return result;
} catch (error) {
const duration = Date.now() - startTime;
console.error(`[AI SDK] generate failed after ${duration}ms:`, error);
throw error;
}
},
wrapStream: async ({ doStream, params }) => {
const startTime = Date.now();
console.log(`[AI SDK] stream started`);
try {
const result = await doStream();
const duration = Date.now() - startTime;
console.log(`[AI SDK] stream setup completed in ${duration}ms`);
return result;
} catch (error) {
const duration = Date.now() - startTime;
console.error(`[AI SDK] stream failed after ${duration}ms:`, error);
throw error;
}
}
};
}
/**
* Create timeout middleware that wraps requests with AbortController
*/
export function createTimeoutMiddleware(timeoutMs: number): AISDKMiddleware {
return {
transformParams: async ({ params }) => {
// If there's already an abort signal, don't override it
if (params.abortSignal) {
return params;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort(new Error(`Request timed out after ${timeoutMs}ms`));
}, timeoutMs);
// Store timeout ID for cleanup (attached to signal)
(controller.signal as any).__timeoutId = timeoutId;
return {
...params,
abortSignal: controller.signal
};
}
};
}
/**
* Compose multiple middlewares into one
* Middlewares are applied in order (first transforms first, last wraps innermost)
*/
export function composeMiddleware(...middlewares: AISDKMiddleware[]): AISDKMiddleware {
const validMiddlewares = middlewares.filter(m => m && Object.keys(m).length > 0);
if (validMiddlewares.length === 0) {
return {};
}
if (validMiddlewares.length === 1) {
return validMiddlewares[0];
}
return {
transformParams: async (options) => {
let params = options.params;
for (const middleware of validMiddlewares) {
if (middleware.transformParams) {
params = await middleware.transformParams({
...options,
params
});
}
}
return params;
},
wrapGenerate: async (options) => {
// Build nested wrapper from inside out
let doGenerate = options.doGenerate;
for (let i = validMiddlewares.length - 1; i >= 0; i--) {
const middleware = validMiddlewares[i];
if (middleware.wrapGenerate) {
const currentDoGenerate = doGenerate;
const currentMiddleware = middleware;
doGenerate = () => currentMiddleware.wrapGenerate!({
...options,
doGenerate: currentDoGenerate
});
}
}
return doGenerate();
},
wrapStream: async (options) => {
// Build nested wrapper from inside out
let doStream = options.doStream;
for (let i = validMiddlewares.length - 1; i >= 0; i--) {
const middleware = validMiddlewares[i];
if (middleware.wrapStream) {
const currentDoStream = doStream;
const currentMiddleware = middleware;
doStream = () => currentMiddleware.wrapStream!({
...options,
doStream: currentDoStream
});
}
}
return doStream();
}
};
}
/**
* Redact sensitive data from objects for safe logging
*/
export function redactSensitiveData(obj: unknown): unknown {
if (obj === null || obj === undefined) {
return obj;
}
if (typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(redactSensitiveData);
}
const result: Record<string, unknown> = {};
const sensitiveKeys = [
'apiKey', 'api_key', 'apikey',
'token', 'secret', 'password',
'authorization', 'auth',
'key', 'credential', 'credentials'
];
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
const lowerKey = key.toLowerCase();
if (sensitiveKeys.some(sk => lowerKey.includes(sk))) {
if (typeof value === 'string' && value.length > 4) {
result[key] = value.slice(0, 4) + '****';
} else {
result[key] = '****';
}
} else if (typeof value === 'object') {
result[key] = redactSensitiveData(value);
} else {
result[key] = value;
}
}
return result;
}
/**
* Create all standard middlewares for the AI SDK backend
*/
export function createStandardMiddleware(
config: AISDKBackendConfig,
attribution?: AttributionContext
): AISDKMiddleware {
const middlewares: AISDKMiddleware[] = [];
// Attribution middleware (if context provided)
if (attribution && Object.keys(attribution).length > 0) {
middlewares.push(createAttributionMiddleware(attribution));
}
// Timeout middleware
if (config.timeout && config.timeout > 0) {
middlewares.push(createTimeoutMiddleware(config.timeout));
}
// Logging middleware (only if debug enabled)
if (config.debugLogging) {
middlewares.push(createLoggingMiddleware(config));
}
return composeMiddleware(...middlewares);
}
@@ -0,0 +1,287 @@
/**
* AI SDK Provider Map
*
* Maps model strings to AI SDK providers with lazy loading.
* Supports formats: "provider/model" or "model" (uses default provider)
*/
import {
AISDKError,
AISDKProviderOptions,
AISDK_PROVIDERS,
PROVIDER_ALIASES
} from './types';
/**
* Parsed model string result
*/
export interface ParsedModel {
providerId: string;
modelId: string;
}
/**
* Provider factory function type
*/
export type ProviderFactory = (options?: AISDKProviderOptions) => unknown;
/**
* Custom provider registration
*/
const customProviders = new Map<string, ProviderFactory>();
/**
* Parse a model string into provider and model ID
*
* @example
* parseModelString("openai/gpt-4o") // { providerId: "openai", modelId: "gpt-4o" }
* parseModelString("gpt-4o-mini") // { providerId: "openai", modelId: "gpt-4o-mini" }
* parseModelString("claude-3-5-sonnet") // { providerId: "anthropic", modelId: "claude-3-5-sonnet" }
*/
export function parseModelString(model: string, defaultProvider: string = 'openai'): ParsedModel {
if (model.includes('/')) {
const [providerId, ...rest] = model.split('/');
const resolvedProvider = resolveProviderAlias(providerId.toLowerCase());
return {
providerId: resolvedProvider,
modelId: rest.join('/')
};
}
// Infer provider from model prefix
const inferredProvider = inferProviderFromModel(model);
return {
providerId: inferredProvider || defaultProvider,
modelId: model
};
}
/**
* Resolve provider alias to canonical name
*/
export function resolveProviderAlias(providerId: string): string {
return PROVIDER_ALIASES[providerId] || providerId;
}
/**
* Infer provider from model name prefix
*/
function inferProviderFromModel(model: string): string | null {
const lowerModel = model.toLowerCase();
// OpenAI models
if (lowerModel.startsWith('gpt-') ||
lowerModel.startsWith('o1') ||
lowerModel.startsWith('o3') ||
lowerModel.startsWith('text-') ||
lowerModel.startsWith('davinci') ||
lowerModel.startsWith('curie') ||
lowerModel.startsWith('babbage') ||
lowerModel.startsWith('ada')) {
return 'openai';
}
// Anthropic models
if (lowerModel.startsWith('claude-')) {
return 'anthropic';
}
// Google models
if (lowerModel.startsWith('gemini-') ||
lowerModel.startsWith('palm-') ||
lowerModel.startsWith('bison')) {
return 'google';
}
// Mistral models
if (lowerModel.startsWith('mistral-') ||
lowerModel.startsWith('mixtral-') ||
lowerModel.startsWith('codestral')) {
return 'mistral';
}
// Cohere models
if (lowerModel.startsWith('command-')) {
return 'cohere';
}
// DeepSeek models
if (lowerModel.startsWith('deepseek-')) {
return 'deepseek';
}
// Groq models (llama, mixtral via groq)
if (lowerModel.startsWith('llama-') || lowerModel.includes('groq')) {
return 'groq';
}
return null;
}
/**
* Check if a provider is supported
*/
export function isProviderSupported(providerId: string): boolean {
const resolved = resolveProviderAlias(providerId);
return resolved in AISDK_PROVIDERS || customProviders.has(resolved);
}
/**
* Get the package name for a provider
*/
export function getProviderPackage(providerId: string): string | null {
const resolved = resolveProviderAlias(providerId);
return AISDK_PROVIDERS[resolved]?.package || null;
}
/**
* Get the environment variable key for a provider's API key
*/
export function getProviderEnvKey(providerId: string): string | null {
const resolved = resolveProviderAlias(providerId);
return AISDK_PROVIDERS[resolved]?.envKey || null;
}
/**
* List all supported providers
*/
export function listSupportedProviders(): string[] {
const builtIn = Object.keys(AISDK_PROVIDERS);
const custom = Array.from(customProviders.keys());
return [...new Set([...builtIn, ...custom])];
}
/**
* Register a custom provider factory
*
* @example
* registerCustomProvider('my-provider', (options) => createMyProvider(options));
*/
export function registerCustomProvider(
providerId: string,
factory: ProviderFactory
): void {
customProviders.set(providerId.toLowerCase(), factory);
}
/**
* Unregister a custom provider
*/
export function unregisterCustomProvider(providerId: string): boolean {
return customProviders.delete(providerId.toLowerCase());
}
/**
* Get a custom provider factory
*/
export function getCustomProvider(providerId: string): ProviderFactory | undefined {
return customProviders.get(providerId.toLowerCase());
}
/**
* Dynamically import and create an AI SDK provider
* Uses lazy loading to avoid importing AI SDK packages until needed
*/
export async function createAISDKProvider(
providerId: string,
options?: AISDKProviderOptions
): Promise<unknown> {
const resolved = resolveProviderAlias(providerId);
// Check for custom provider first
const customFactory = customProviders.get(resolved);
if (customFactory) {
return customFactory(options);
}
// Check if provider is supported
const providerInfo = AISDK_PROVIDERS[resolved];
if (!providerInfo) {
throw new AISDKError(
`Provider '${providerId}' is not supported. ` +
`Supported providers: ${listSupportedProviders().join(', ')}`,
'PROVIDER_NOT_FOUND',
false
);
}
// Dynamically import the provider package
try {
const providerModule = await import(providerInfo.package);
// Most AI SDK providers export a create<Provider> function
const createFnName = `create${capitalize(resolved.replace(/-/g, ''))}`;
const createFn = providerModule[createFnName] || providerModule.default;
if (typeof createFn !== 'function') {
// Try direct provider export (some packages export the provider directly)
const providerName = resolved.replace(/-/g, '');
const directProvider = providerModule[providerName];
if (directProvider) {
return directProvider;
}
throw new AISDKError(
`Could not find provider factory in '${providerInfo.package}'`,
'PROVIDER_ERROR',
false
);
}
return createFn(options);
} catch (error: unknown) {
if (error instanceof AISDKError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
// Check if it's a missing dependency error
if (errorMessage.includes('Cannot find module') ||
errorMessage.includes('MODULE_NOT_FOUND')) {
throw new AISDKError(
`AI SDK provider package '${providerInfo.package}' is not installed. ` +
`Install it with: npm install ${providerInfo.package}`,
'MISSING_DEPENDENCY',
false,
error
);
}
throw new AISDKError(
`Failed to create AI SDK provider '${providerId}': ${errorMessage}`,
'PROVIDER_ERROR',
false,
error
);
}
}
/**
* Validate that a provider has the required API key
*/
export function validateProviderApiKey(providerId: string): boolean {
const envKey = getProviderEnvKey(providerId);
if (!envKey) {
return true; // Custom providers may not need env keys
}
return !!process.env[envKey];
}
/**
* Get missing API key message for a provider
*/
export function getMissingApiKeyMessage(providerId: string): string {
const envKey = getProviderEnvKey(providerId);
if (!envKey) {
return `Provider '${providerId}' may require authentication. Check provider documentation.`;
}
return `Missing API key for provider '${providerId}'. Set the ${envKey} environment variable.`;
}
/**
* Capitalize first letter of a string
*/
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
@@ -0,0 +1,227 @@
/**
* AI SDK Backend Types
*
* Type definitions for the AI SDK integration with praisonai-ts.
* These types define the configuration, errors, and stream chunk formats.
*/
/**
* Attribution context for multi-agent safety
* Propagated to AI SDK calls via middleware
*/
export interface AttributionContext {
/** Unique identifier for the agent instance */
agentId?: string;
/** Unique identifier for this execution run */
runId?: string;
/** Trace ID for distributed tracing (OpenTelemetry compatible) */
traceId?: string;
/** Session ID for conversation continuity */
sessionId?: string;
/** Parent span ID for nested operations */
parentSpanId?: string;
}
/**
* Provider-specific configuration options
*/
export interface AISDKProviderOptions {
/** API key for the provider */
apiKey?: string;
/** Base URL override for the provider */
baseURL?: string;
/** Custom headers to include in requests */
headers?: Record<string, string>;
/** Custom fetch implementation */
fetch?: typeof fetch;
}
/**
* Telemetry settings for AI SDK calls
*/
export interface AISDKTelemetrySettings {
/** Enable or disable telemetry */
isEnabled?: boolean;
/** Enable or disable input recording */
recordInputs?: boolean;
/** Enable or disable output recording */
recordOutputs?: boolean;
/** Function identifier for grouping telemetry */
functionId?: string;
/** Additional metadata for telemetry */
metadata?: Record<string, unknown>;
}
/**
* Configuration for the AI SDK backend
*/
export interface AISDKBackendConfig {
/** Default provider if not specified in model string */
defaultProvider?: string;
/** Provider-specific configurations */
providers?: Record<string, AISDKProviderOptions>;
/** Attribution context for multi-agent safety */
attribution?: AttributionContext;
/** Telemetry settings */
telemetry?: AISDKTelemetrySettings;
/** Request timeout in milliseconds (default: 60000) */
timeout?: number;
/** Number of retries for retryable errors (default: 2) */
maxRetries?: number;
/** Maximum output tokens (default: 4096) */
maxOutputTokens?: number;
/** Enable debug logging (default: false) */
debugLogging?: boolean;
/** Redact sensitive data in logs (default: true) */
redactLogs?: boolean;
}
/**
* Error codes for AI SDK errors
*/
export type AISDKErrorCode =
| 'PROVIDER_ERROR' // Provider-specific error
| 'RATE_LIMIT' // 429 - Too many requests
| 'AUTHENTICATION' // 401/403 - Auth failure
| 'INVALID_REQUEST' // 400 - Bad request
| 'MODEL_NOT_FOUND' // Model doesn't exist
| 'PROVIDER_NOT_FOUND' // Provider not registered
| 'TIMEOUT' // Request timeout
| 'NETWORK' // Network failure
| 'CANCELLED' // Request cancelled
| 'MISSING_DEPENDENCY' // AI SDK not installed
| 'UNKNOWN'; // Unknown error
/**
* Custom error class for AI SDK errors
*/
export class AISDKError extends Error {
public readonly code: AISDKErrorCode;
public readonly isRetryable: boolean;
public readonly cause?: unknown;
public readonly statusCode?: number;
constructor(
message: string,
code: AISDKErrorCode,
isRetryable: boolean,
cause?: unknown,
statusCode?: number
) {
super(message);
this.name = 'AISDKError';
this.code = code;
this.isRetryable = isRetryable;
this.cause = cause;
this.statusCode = statusCode;
// Maintain proper stack trace in V8
if (Error.captureStackTrace) {
Error.captureStackTrace(this, AISDKError);
}
}
}
/**
* Stream chunk types for streaming responses
*/
export type PraisonStreamChunk =
| { type: 'text'; text: string }
| { type: 'tool-call-start'; toolCallId: string; toolName: string }
| { type: 'tool-call-delta'; toolCallId: string; argsTextDelta: string }
| { type: 'tool-call-end'; toolCallId: string; args: unknown }
| { type: 'finish'; finishReason: string; usage?: TokenUsage }
| { type: 'error'; error: AISDKError };
/**
* Token usage information
*/
export interface TokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
/**
* Finish reasons for generation
*/
export type FinishReason =
| 'stop' // Natural completion
| 'length' // Max tokens reached
| 'tool-calls' // Tool calls requested
| 'content-filter' // Content filtered
| 'error' // Error occurred
| 'cancelled' // Request cancelled
| 'unknown'; // Unknown reason
/**
* Tool definition for AI SDK
*/
export interface AISDKToolDefinition {
type: 'function';
name: string;
description?: string;
parameters: Record<string, unknown>;
}
/**
* Tool call from AI SDK response
*/
export interface AISDKToolCall {
toolCallId: string;
toolName: string;
args: unknown;
}
/**
* Tool result to send back to AI SDK
*/
export interface AISDKToolResult {
toolCallId: string;
toolName: string;
result: unknown;
}
/**
* Safe defaults for AI SDK backend
*/
export const SAFE_DEFAULTS = {
timeout: 60000, // 60s request timeout
maxRetries: 2, // Retry on transient failures
maxOutputTokens: 4096, // Prevent runaway generation
redactLogs: true, // Never log API keys
debugLogging: false, // Opt-in verbose logging
} as const;
/**
* Supported AI SDK providers with their package names
*/
export const AISDK_PROVIDERS: Record<string, { package: string; envKey: string }> = {
openai: { package: '@ai-sdk/openai', envKey: 'OPENAI_API_KEY' },
anthropic: { package: '@ai-sdk/anthropic', envKey: 'ANTHROPIC_API_KEY' },
google: { package: '@ai-sdk/google', envKey: 'GOOGLE_API_KEY' },
azure: { package: '@ai-sdk/azure', envKey: 'AZURE_API_KEY' },
'amazon-bedrock': { package: '@ai-sdk/amazon-bedrock', envKey: 'AWS_ACCESS_KEY_ID' },
groq: { package: '@ai-sdk/groq', envKey: 'GROQ_API_KEY' },
mistral: { package: '@ai-sdk/mistral', envKey: 'MISTRAL_API_KEY' },
cohere: { package: '@ai-sdk/cohere', envKey: 'COHERE_API_KEY' },
deepseek: { package: '@ai-sdk/deepseek', envKey: 'DEEPSEEK_API_KEY' },
xai: { package: '@ai-sdk/xai', envKey: 'XAI_API_KEY' },
fireworks: { package: '@ai-sdk/fireworks', envKey: 'FIREWORKS_API_KEY' },
togetherai: { package: '@ai-sdk/togetherai', envKey: 'TOGETHER_API_KEY' },
perplexity: { package: '@ai-sdk/perplexity', envKey: 'PERPLEXITY_API_KEY' },
} as const;
/**
* Provider aliases for convenience
*/
export const PROVIDER_ALIASES: Record<string, string> = {
oai: 'openai',
claude: 'anthropic',
gemini: 'google',
gcp: 'google',
aws: 'amazon-bedrock',
bedrock: 'amazon-bedrock',
together: 'togetherai',
pplx: 'perplexity',
} as const;
+185 -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,88 @@ 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);
}
// ============================================================================
// AI SDK Integration Exports
// ============================================================================
/**
* AI SDK Backend - Multi-provider LLM support via Vercel's AI SDK
*
* @example
* ```typescript
* import { createAISDKBackend } from 'praisonai';
*
* const backend = createAISDKBackend('anthropic/claude-3-5-sonnet');
* const result = await backend.generateText({ messages: [...] });
* ```
*/
export {
// Backend
AISDKBackend,
createAISDKBackend,
// Provider utilities
isProviderSupported as isAISDKProviderSupported,
listSupportedProviders as listAISDKProviders,
registerCustomProvider as registerAISDKCustomProvider,
validateProviderApiKey as validateAISDKProviderApiKey,
getMissingApiKeyMessage as getAISDKMissingApiKeyMessage,
isAISDKAvailable,
getAISDKVersion,
// Types
AISDKError,
SAFE_DEFAULTS as AISDK_SAFE_DEFAULTS,
AISDK_PROVIDERS,
PROVIDER_ALIASES as AISDK_PROVIDER_ALIASES,
// Middleware
createAttributionMiddleware,
createStandardMiddleware,
redactSensitiveData,
} from './ai-sdk';
export type {
AISDKBackendConfig,
AISDKProviderOptions,
AttributionContext,
AISDKTelemetrySettings,
AISDKErrorCode,
PraisonStreamChunk,
AISDKToolDefinition,
AISDKToolCall,
AISDKToolResult,
AISDKMiddleware,
} from './ai-sdk';
@@ -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,391 @@
/**
* AI SDK Adapter Tests
*/
import {
toAISDKPrompt,
toAISDKTools,
fromAISDKToolCall,
fromAISDKResult,
fromAISDKStreamChunk,
mapFinishReason,
mapFinishReasonToProvider,
toAISDKToolChoice,
createSimplePrompt,
} from '../../../../src/llm/providers/ai-sdk/adapter';
import type { Message, ToolDefinition } from '../../../../src/llm/providers/types';
describe('toAISDKPrompt', () => {
it('should convert system message', () => {
const messages: Message[] = [
{ role: 'system', content: 'You are helpful' }
];
const result = toAISDKPrompt(messages);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
role: 'system',
content: 'You are helpful'
});
});
it('should convert user message', () => {
const messages: Message[] = [
{ role: 'user', content: 'Hello!' }
];
const result = toAISDKPrompt(messages);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'Hello!' }]
});
});
it('should convert assistant message with text', () => {
const messages: Message[] = [
{ role: 'assistant', content: 'Hi there!' }
];
const result = toAISDKPrompt(messages);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
role: 'assistant',
content: [{ type: 'text', text: 'Hi there!' }]
});
});
it('should convert assistant message with tool calls', () => {
const messages: Message[] = [
{
role: 'assistant',
content: null,
tool_calls: [{
id: 'call_123',
type: 'function',
function: {
name: 'get_weather',
arguments: '{"city": "London"}'
}
}]
}
];
const result = toAISDKPrompt(messages);
expect(result).toHaveLength(1);
expect(result[0].role).toBe('assistant');
expect((result[0] as any).content).toHaveLength(1);
expect((result[0] as any).content[0]).toEqual({
type: 'tool-call',
toolCallId: 'call_123',
toolName: 'get_weather',
args: { city: 'London' }
});
});
it('should convert tool message', () => {
const messages: Message[] = [
{
role: 'tool',
content: '{"temperature": 20}',
tool_call_id: 'call_123',
name: 'get_weather'
}
];
const result = toAISDKPrompt(messages);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
role: 'tool',
content: [{
type: 'tool-result',
toolCallId: 'call_123',
toolName: 'get_weather',
result: { temperature: 20 }
}]
});
});
it('should handle null content', () => {
const messages: Message[] = [
{ role: 'user', content: null }
];
const result = toAISDKPrompt(messages);
expect(result[0]).toEqual({
role: 'user',
content: [{ type: 'text', text: '' }]
});
});
});
describe('toAISDKTools', () => {
it('should convert tool definitions', async () => {
const tools: ToolDefinition[] = [
{
name: 'get_weather',
description: 'Get the weather for a city',
parameters: {
type: 'object',
properties: {
city: { type: 'string' }
},
required: ['city']
}
}
];
const result = await toAISDKTools(tools);
// Check that tool was created with description
expect(result.get_weather).toBeDefined();
expect(result.get_weather.description).toBe('Get the weather for a city');
});
it('should handle tools without parameters', async () => {
const tools: ToolDefinition[] = [
{ name: 'get_time' }
];
const result = await toAISDKTools(tools);
expect(result.get_time).toBeDefined();
});
});
describe('fromAISDKToolCall', () => {
it('should convert AI SDK tool call to praisonai format', () => {
const toolCall = {
toolCallId: 'call_123',
toolName: 'get_weather',
args: { city: 'London' }
};
const result = fromAISDKToolCall(toolCall);
expect(result).toEqual({
id: 'call_123',
type: 'function',
function: {
name: 'get_weather',
arguments: '{"city":"London"}'
}
});
});
it('should handle string args', () => {
const toolCall = {
toolCallId: 'call_123',
toolName: 'test',
args: '{"key": "value"}'
};
const result = fromAISDKToolCall(toolCall);
expect(result.function.arguments).toBe('{"key": "value"}');
});
});
describe('fromAISDKResult', () => {
it('should convert AI SDK result to praisonai format', () => {
const result = fromAISDKResult({
text: 'Hello!',
usage: { promptTokens: 10, completionTokens: 5 },
finishReason: 'stop'
});
expect(result.text).toBe('Hello!');
expect(result.usage).toEqual({
promptTokens: 10,
completionTokens: 5,
totalTokens: 15
});
expect(result.finishReason).toBe('stop');
});
it('should handle missing usage', () => {
const result = fromAISDKResult({
text: 'Hello!',
finishReason: 'stop'
});
expect(result.usage).toEqual({
promptTokens: 0,
completionTokens: 0,
totalTokens: 0
});
});
it('should handle tool calls', () => {
const result = fromAISDKResult({
text: '',
toolCalls: [{
toolCallId: 'call_123',
toolName: 'test',
args: {}
}],
finishReason: 'tool_calls'
});
expect(result.toolCalls).toHaveLength(1);
expect(result.toolCalls![0].id).toBe('call_123');
expect(result.finishReason).toBe('tool_calls');
});
});
describe('mapFinishReason', () => {
it('should map stop reasons', () => {
expect(mapFinishReason('stop')).toBe('stop');
expect(mapFinishReason('end_turn')).toBe('stop');
expect(mapFinishReason('end')).toBe('stop');
});
it('should map length reasons', () => {
expect(mapFinishReason('length')).toBe('length');
expect(mapFinishReason('max_tokens')).toBe('length');
});
it('should map tool call reasons', () => {
expect(mapFinishReason('tool_calls')).toBe('tool-calls');
expect(mapFinishReason('tool-calls')).toBe('tool-calls');
expect(mapFinishReason('function_call')).toBe('tool-calls');
});
it('should map content filter', () => {
expect(mapFinishReason('content_filter')).toBe('content-filter');
expect(mapFinishReason('content-filter')).toBe('content-filter');
});
it('should return unknown for undefined', () => {
expect(mapFinishReason(undefined)).toBe('unknown');
expect(mapFinishReason('something_else')).toBe('unknown');
});
});
describe('mapFinishReasonToProvider', () => {
it('should map to provider format with underscores', () => {
expect(mapFinishReasonToProvider('tool_calls')).toBe('tool_calls');
expect(mapFinishReasonToProvider('tool-calls')).toBe('tool_calls');
expect(mapFinishReasonToProvider('content_filter')).toBe('content_filter');
expect(mapFinishReasonToProvider('content-filter')).toBe('content_filter');
});
it('should default to stop for undefined', () => {
expect(mapFinishReasonToProvider(undefined)).toBe('stop');
});
});
describe('fromAISDKStreamChunk', () => {
it('should convert text delta', () => {
const chunk = fromAISDKStreamChunk({
type: 'text-delta',
textDelta: 'Hello'
});
expect(chunk).toEqual({ type: 'text', text: 'Hello' });
});
it('should convert tool call start', () => {
const chunk = fromAISDKStreamChunk({
type: 'tool-call-streaming-start',
toolCallId: 'call_123',
toolName: 'test'
});
expect(chunk).toEqual({
type: 'tool-call-start',
toolCallId: 'call_123',
toolName: 'test'
});
});
it('should convert tool call delta', () => {
const chunk = fromAISDKStreamChunk({
type: 'tool-call-delta',
toolCallId: 'call_123',
argsTextDelta: '{"key":'
});
expect(chunk).toEqual({
type: 'tool-call-delta',
toolCallId: 'call_123',
argsTextDelta: '{"key":'
});
});
it('should convert finish', () => {
const chunk = fromAISDKStreamChunk({
type: 'finish',
finishReason: 'stop',
usage: { promptTokens: 10, completionTokens: 5 }
});
expect(chunk).toEqual({
type: 'finish',
finishReason: 'stop',
usage: {
promptTokens: 10,
completionTokens: 5,
totalTokens: 15
}
});
});
it('should return null for unknown chunk types', () => {
const chunk = fromAISDKStreamChunk({
type: 'unknown-type'
});
expect(chunk).toBeNull();
});
});
describe('toAISDKToolChoice', () => {
it('should pass through string values', () => {
expect(toAISDKToolChoice('auto')).toBe('auto');
expect(toAISDKToolChoice('none')).toBe('none');
expect(toAISDKToolChoice('required')).toBe('required');
});
it('should pass through tool object', () => {
const choice = { type: 'tool' as const, toolName: 'test' };
expect(toAISDKToolChoice(choice)).toEqual(choice);
});
it('should return undefined for undefined', () => {
expect(toAISDKToolChoice(undefined)).toBeUndefined();
});
});
describe('createSimplePrompt', () => {
it('should create prompt with user message', () => {
const result = createSimplePrompt('Hello!');
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'Hello!' }]
});
});
it('should include system prompt if provided', () => {
const result = createSimplePrompt('Hello!', 'You are helpful');
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
role: 'system',
content: 'You are helpful'
});
expect(result[1]).toEqual({
role: 'user',
content: [{ type: 'text', text: 'Hello!' }]
});
});
});
@@ -0,0 +1,110 @@
/**
* AI SDK Lazy Import Tests
*
* Verifies that AI SDK modules are not loaded until actually used.
*/
describe('Lazy Import', () => {
beforeEach(() => {
// Clear module cache to test fresh imports
jest.resetModules();
});
it('should not import AI SDK when importing types', () => {
// Types should be importable without triggering AI SDK load
const types = require('../../../../src/llm/providers/ai-sdk/types');
expect(types.AISDKError).toBeDefined();
expect(types.SAFE_DEFAULTS).toBeDefined();
expect(types.AISDK_PROVIDERS).toBeDefined();
// AI SDK should not be in require.cache
const aiSdkCached = Object.keys(require.cache).some(k =>
k.includes('node_modules/ai/') || k.includes('node_modules/@ai-sdk/')
);
expect(aiSdkCached).toBe(false);
});
it('should not import AI SDK when importing provider-map', () => {
const providerMap = require('../../../../src/llm/providers/ai-sdk/provider-map');
expect(providerMap.parseModelString).toBeDefined();
expect(providerMap.isProviderSupported).toBeDefined();
// AI SDK should not be in require.cache
const aiSdkCached = Object.keys(require.cache).some(k =>
k.includes('node_modules/ai/') || k.includes('node_modules/@ai-sdk/')
);
expect(aiSdkCached).toBe(false);
});
it('should not import AI SDK when importing adapter', () => {
const adapter = require('../../../../src/llm/providers/ai-sdk/adapter');
expect(adapter.toAISDKPrompt).toBeDefined();
expect(adapter.fromAISDKResult).toBeDefined();
// AI SDK should not be in require.cache
const aiSdkCached = Object.keys(require.cache).some(k =>
k.includes('node_modules/ai/') || k.includes('node_modules/@ai-sdk/')
);
expect(aiSdkCached).toBe(false);
});
it('should not import AI SDK when importing middleware', () => {
const middleware = require('../../../../src/llm/providers/ai-sdk/middleware');
expect(middleware.createAttributionMiddleware).toBeDefined();
expect(middleware.redactSensitiveData).toBeDefined();
// AI SDK should not be in require.cache
const aiSdkCached = Object.keys(require.cache).some(k =>
k.includes('node_modules/ai/') || k.includes('node_modules/@ai-sdk/')
);
expect(aiSdkCached).toBe(false);
});
it('should not import AI SDK when importing index (before backend use)', () => {
const aiSdk = require('../../../../src/llm/providers/ai-sdk');
// These should be available without AI SDK
expect(aiSdk.AISDKError).toBeDefined();
expect(aiSdk.parseModelString).toBeDefined();
expect(aiSdk.toAISDKPrompt).toBeDefined();
expect(aiSdk.createAttributionMiddleware).toBeDefined();
// AI SDK should not be in require.cache yet
const aiSdkCached = Object.keys(require.cache).some(k =>
k.includes('node_modules/ai/') || k.includes('node_modules/@ai-sdk/')
);
expect(aiSdkCached).toBe(false);
});
it('should export createAISDKBackend function', () => {
const aiSdk = require('../../../../src/llm/providers/ai-sdk');
expect(typeof aiSdk.createAISDKBackend).toBe('function');
});
it('should export isAISDKAvailable function', () => {
const aiSdk = require('../../../../src/llm/providers/ai-sdk');
expect(typeof aiSdk.isAISDKAvailable).toBe('function');
});
});
describe('Backend Instantiation', () => {
it('should create backend instance without immediate AI SDK import', () => {
jest.resetModules();
const { createAISDKBackend } = require('../../../../src/llm/providers/ai-sdk');
// Creating the backend should not immediately import AI SDK
// The import happens on first method call
const backend = createAISDKBackend('openai/gpt-4o-mini');
expect(backend).toBeDefined();
expect(backend.providerId).toBe('openai');
expect(backend.modelId).toBe('gpt-4o-mini');
});
});
@@ -0,0 +1,296 @@
/**
* AI SDK Middleware Tests
*/
import {
createAttributionMiddleware,
createLoggingMiddleware,
createTimeoutMiddleware,
composeMiddleware,
createStandardMiddleware,
redactSensitiveData,
} from '../../../../src/llm/providers/ai-sdk/middleware';
import type { AttributionContext, AISDKBackendConfig } from '../../../../src/llm/providers/ai-sdk/types';
describe('createAttributionMiddleware', () => {
it('should inject attribution headers', async () => {
const ctx: AttributionContext = {
agentId: 'agent-123',
runId: 'run-456',
traceId: 'trace-789',
sessionId: 'session-abc'
};
const middleware = createAttributionMiddleware(ctx);
const result = await middleware.transformParams!({
params: {},
type: 'generate',
model: {}
});
expect(result.headers).toEqual({
'X-Agent-Id': 'agent-123',
'X-Run-Id': 'run-456',
'X-Trace-Id': 'trace-789',
'X-Session-Id': 'session-abc'
});
});
it('should inject provider options metadata', async () => {
const ctx: AttributionContext = {
agentId: 'agent-123',
runId: 'run-456'
};
const middleware = createAttributionMiddleware(ctx);
const result = await middleware.transformParams!({
params: {},
type: 'generate',
model: {}
});
expect((result.providerOptions as any).metadata).toEqual({
agentId: 'agent-123',
runId: 'run-456'
});
});
it('should merge with existing headers', async () => {
const ctx: AttributionContext = {
agentId: 'agent-123'
};
const middleware = createAttributionMiddleware(ctx);
const result = await middleware.transformParams!({
params: {
headers: { 'X-Custom': 'value' }
},
type: 'generate',
model: {}
});
expect(result.headers).toEqual({
'X-Custom': 'value',
'X-Agent-Id': 'agent-123'
});
});
it('should include parentSpanId if provided', async () => {
const ctx: AttributionContext = {
agentId: 'agent-123',
parentSpanId: 'span-xyz'
};
const middleware = createAttributionMiddleware(ctx);
const result = await middleware.transformParams!({
params: {},
type: 'generate',
model: {}
});
expect(result.headers).toEqual({
'X-Agent-Id': 'agent-123',
'X-Parent-Span-Id': 'span-xyz'
});
});
});
describe('createLoggingMiddleware', () => {
it('should return empty middleware when debugLogging is false', () => {
const config: AISDKBackendConfig = {
debugLogging: false
};
const middleware = createLoggingMiddleware(config);
expect(middleware.transformParams).toBeUndefined();
expect(middleware.wrapGenerate).toBeUndefined();
expect(middleware.wrapStream).toBeUndefined();
});
it('should return middleware when debugLogging is true', () => {
const config: AISDKBackendConfig = {
debugLogging: true
};
const middleware = createLoggingMiddleware(config);
expect(middleware.transformParams).toBeDefined();
expect(middleware.wrapGenerate).toBeDefined();
expect(middleware.wrapStream).toBeDefined();
});
});
describe('createTimeoutMiddleware', () => {
it('should add abort signal to params', async () => {
const middleware = createTimeoutMiddleware(5000);
const result = await middleware.transformParams!({
params: {},
type: 'generate',
model: {}
});
expect(result.abortSignal).toBeDefined();
expect(result.abortSignal).toBeInstanceOf(AbortSignal);
});
it('should not override existing abort signal', async () => {
const middleware = createTimeoutMiddleware(5000);
const existingSignal = new AbortController().signal;
const result = await middleware.transformParams!({
params: { abortSignal: existingSignal },
type: 'generate',
model: {}
});
expect(result.abortSignal).toBe(existingSignal);
});
});
describe('composeMiddleware', () => {
it('should return empty middleware for empty array', () => {
const composed = composeMiddleware();
expect(Object.keys(composed)).toHaveLength(0);
});
it('should return single middleware unchanged', () => {
const middleware = {
transformParams: async ({ params }: any) => ({ ...params, test: true })
};
const composed = composeMiddleware(middleware);
expect(composed).toBe(middleware);
});
it('should compose multiple transformParams', async () => {
const m1 = {
transformParams: async ({ params }: any) => ({ ...params, m1: true })
};
const m2 = {
transformParams: async ({ params }: any) => ({ ...params, m2: true })
};
const composed = composeMiddleware(m1, m2);
const result = await composed.transformParams!({
params: {},
type: 'generate',
model: {}
});
expect(result).toEqual({ m1: true, m2: true });
});
it('should filter out empty middlewares', () => {
const m1 = {
transformParams: async ({ params }: any) => ({ ...params, m1: true })
};
const composed = composeMiddleware({}, m1, {});
expect(composed.transformParams).toBeDefined();
});
});
describe('createStandardMiddleware', () => {
it('should include attribution middleware when context provided', async () => {
const config: AISDKBackendConfig = {};
const attribution: AttributionContext = { agentId: 'test' };
const middleware = createStandardMiddleware(config, attribution);
const result = await middleware.transformParams!({
params: {},
type: 'generate',
model: {}
});
expect(result.headers).toEqual({ 'X-Agent-Id': 'test' });
});
it('should include timeout middleware when timeout configured', async () => {
const config: AISDKBackendConfig = { timeout: 5000 };
const middleware = createStandardMiddleware(config);
const result = await middleware.transformParams!({
params: {},
type: 'generate',
model: {}
});
expect(result.abortSignal).toBeDefined();
});
it('should return empty middleware when no config', () => {
const config: AISDKBackendConfig = {};
const middleware = createStandardMiddleware(config);
// Should have no transformParams if no attribution or timeout
expect(Object.keys(middleware).length).toBe(0);
});
});
describe('redactSensitiveData', () => {
it('should redact API keys', () => {
const data = {
apiKey: 'sk-1234567890abcdef',
message: 'hello'
};
const result = redactSensitiveData(data) as any;
expect(result.apiKey).toBe('sk-1****');
expect(result.message).toBe('hello');
});
it('should redact nested sensitive data', () => {
const data = {
config: {
api_key: 'secret-key-12345',
url: 'https://api.example.com'
}
};
const result = redactSensitiveData(data) as any;
expect(result.config.api_key).toBe('secr****');
expect(result.config.url).toBe('https://api.example.com');
});
it('should redact arrays', () => {
const data = [
{ token: 'abc123', value: 'test' }
];
const result = redactSensitiveData(data) as any;
expect(result[0].token).toBe('abc1****');
expect(result[0].value).toBe('test');
});
it('should handle null and undefined', () => {
expect(redactSensitiveData(null)).toBeNull();
expect(redactSensitiveData(undefined)).toBeUndefined();
});
it('should handle primitives', () => {
expect(redactSensitiveData('string')).toBe('string');
expect(redactSensitiveData(123)).toBe(123);
expect(redactSensitiveData(true)).toBe(true);
});
it('should redact short sensitive values', () => {
const data = { key: 'abc' };
const result = redactSensitiveData(data) as any;
expect(result.key).toBe('****');
});
});
@@ -0,0 +1,210 @@
/**
* AI SDK Provider Map Tests
*/
import {
parseModelString,
resolveProviderAlias,
isProviderSupported,
getProviderPackage,
getProviderEnvKey,
listSupportedProviders,
registerCustomProvider,
unregisterCustomProvider,
getCustomProvider,
validateProviderApiKey,
getMissingApiKeyMessage,
} from '../../../../src/llm/providers/ai-sdk/provider-map';
describe('parseModelString', () => {
it('should parse provider/model format', () => {
const result = parseModelString('openai/gpt-4o');
expect(result.providerId).toBe('openai');
expect(result.modelId).toBe('gpt-4o');
});
it('should parse provider/model with slashes in model name', () => {
const result = parseModelString('azure/deployments/gpt-4/chat');
expect(result.providerId).toBe('azure');
expect(result.modelId).toBe('deployments/gpt-4/chat');
});
it('should infer openai from gpt- prefix', () => {
const result = parseModelString('gpt-4o-mini');
expect(result.providerId).toBe('openai');
expect(result.modelId).toBe('gpt-4o-mini');
});
it('should infer anthropic from claude- prefix', () => {
const result = parseModelString('claude-3-5-sonnet');
expect(result.providerId).toBe('anthropic');
expect(result.modelId).toBe('claude-3-5-sonnet');
});
it('should infer google from gemini- prefix', () => {
const result = parseModelString('gemini-2.0-flash');
expect(result.providerId).toBe('google');
expect(result.modelId).toBe('gemini-2.0-flash');
});
it('should infer mistral from mistral- prefix', () => {
const result = parseModelString('mistral-large');
expect(result.providerId).toBe('mistral');
expect(result.modelId).toBe('mistral-large');
});
it('should use default provider for unknown models', () => {
const result = parseModelString('unknown-model', 'openai');
expect(result.providerId).toBe('openai');
expect(result.modelId).toBe('unknown-model');
});
it('should lowercase provider id', () => {
const result = parseModelString('OpenAI/gpt-4o');
expect(result.providerId).toBe('openai');
});
});
describe('resolveProviderAlias', () => {
it('should resolve oai to openai', () => {
expect(resolveProviderAlias('oai')).toBe('openai');
});
it('should resolve claude to anthropic', () => {
expect(resolveProviderAlias('claude')).toBe('anthropic');
});
it('should resolve gemini to google', () => {
expect(resolveProviderAlias('gemini')).toBe('google');
});
it('should return original if no alias', () => {
expect(resolveProviderAlias('openai')).toBe('openai');
expect(resolveProviderAlias('custom-provider')).toBe('custom-provider');
});
});
describe('isProviderSupported', () => {
it('should return true for built-in providers', () => {
expect(isProviderSupported('openai')).toBe(true);
expect(isProviderSupported('anthropic')).toBe(true);
expect(isProviderSupported('google')).toBe(true);
});
it('should return true for aliases', () => {
expect(isProviderSupported('oai')).toBe(true);
expect(isProviderSupported('claude')).toBe(true);
expect(isProviderSupported('gemini')).toBe(true);
});
it('should return false for unknown providers', () => {
expect(isProviderSupported('unknown-provider')).toBe(false);
});
});
describe('getProviderPackage', () => {
it('should return package name for known providers', () => {
expect(getProviderPackage('openai')).toBe('@ai-sdk/openai');
expect(getProviderPackage('anthropic')).toBe('@ai-sdk/anthropic');
expect(getProviderPackage('google')).toBe('@ai-sdk/google');
});
it('should return null for unknown providers', () => {
expect(getProviderPackage('unknown')).toBeNull();
});
it('should resolve aliases', () => {
expect(getProviderPackage('oai')).toBe('@ai-sdk/openai');
expect(getProviderPackage('claude')).toBe('@ai-sdk/anthropic');
});
});
describe('getProviderEnvKey', () => {
it('should return env key for known providers', () => {
expect(getProviderEnvKey('openai')).toBe('OPENAI_API_KEY');
expect(getProviderEnvKey('anthropic')).toBe('ANTHROPIC_API_KEY');
expect(getProviderEnvKey('google')).toBe('GOOGLE_API_KEY');
});
it('should return null for unknown providers', () => {
expect(getProviderEnvKey('unknown')).toBeNull();
});
});
describe('listSupportedProviders', () => {
it('should return array of provider ids', () => {
const providers = listSupportedProviders();
expect(Array.isArray(providers)).toBe(true);
expect(providers.length).toBeGreaterThan(0);
expect(providers).toContain('openai');
expect(providers).toContain('anthropic');
expect(providers).toContain('google');
});
});
describe('custom providers', () => {
const customFactory = jest.fn().mockReturnValue({ test: true });
afterEach(() => {
unregisterCustomProvider('test-custom');
});
it('should register custom provider', () => {
registerCustomProvider('test-custom', customFactory);
expect(getCustomProvider('test-custom')).toBe(customFactory);
expect(isProviderSupported('test-custom')).toBe(true);
});
it('should unregister custom provider', () => {
registerCustomProvider('test-custom', customFactory);
expect(unregisterCustomProvider('test-custom')).toBe(true);
expect(getCustomProvider('test-custom')).toBeUndefined();
expect(isProviderSupported('test-custom')).toBe(false);
});
it('should include custom providers in list', () => {
registerCustomProvider('test-custom', customFactory);
const providers = listSupportedProviders();
expect(providers).toContain('test-custom');
});
});
describe('validateProviderApiKey', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterAll(() => {
process.env = originalEnv;
});
it('should return true when API key is set', () => {
process.env.OPENAI_API_KEY = 'test-key';
expect(validateProviderApiKey('openai')).toBe(true);
});
it('should return false when API key is not set', () => {
delete process.env.OPENAI_API_KEY;
expect(validateProviderApiKey('openai')).toBe(false);
});
it('should return true for custom providers without env key', () => {
registerCustomProvider('no-env-provider', () => ({}));
expect(validateProviderApiKey('no-env-provider')).toBe(true);
unregisterCustomProvider('no-env-provider');
});
});
describe('getMissingApiKeyMessage', () => {
it('should return message with env key for known providers', () => {
const msg = getMissingApiKeyMessage('openai');
expect(msg).toContain('OPENAI_API_KEY');
});
it('should return generic message for unknown providers', () => {
const msg = getMissingApiKeyMessage('unknown');
expect(msg).toContain('unknown');
});
});
@@ -0,0 +1,105 @@
/**
* AI SDK Types Tests
*/
import {
AISDKError,
SAFE_DEFAULTS,
AISDK_PROVIDERS,
PROVIDER_ALIASES,
} from '../../../../src/llm/providers/ai-sdk/types';
describe('AISDKError', () => {
it('should create error with all properties', () => {
const error = new AISDKError(
'Test error message',
'RATE_LIMIT',
true,
new Error('cause'),
429
);
expect(error.message).toBe('Test error message');
expect(error.code).toBe('RATE_LIMIT');
expect(error.isRetryable).toBe(true);
expect(error.cause).toBeInstanceOf(Error);
expect(error.statusCode).toBe(429);
expect(error.name).toBe('AISDKError');
});
it('should create error without optional properties', () => {
const error = new AISDKError('Simple error', 'UNKNOWN', false);
expect(error.message).toBe('Simple error');
expect(error.code).toBe('UNKNOWN');
expect(error.isRetryable).toBe(false);
expect(error.cause).toBeUndefined();
expect(error.statusCode).toBeUndefined();
});
it('should be instanceof Error', () => {
const error = new AISDKError('Test', 'UNKNOWN', false);
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(AISDKError);
});
});
describe('SAFE_DEFAULTS', () => {
it('should have correct default values', () => {
expect(SAFE_DEFAULTS.timeout).toBe(60000);
expect(SAFE_DEFAULTS.maxRetries).toBe(2);
expect(SAFE_DEFAULTS.maxOutputTokens).toBe(4096);
expect(SAFE_DEFAULTS.redactLogs).toBe(true);
expect(SAFE_DEFAULTS.debugLogging).toBe(false);
});
});
describe('AISDK_PROVIDERS', () => {
it('should have openai provider', () => {
expect(AISDK_PROVIDERS.openai).toBeDefined();
expect(AISDK_PROVIDERS.openai.package).toBe('@ai-sdk/openai');
expect(AISDK_PROVIDERS.openai.envKey).toBe('OPENAI_API_KEY');
});
it('should have anthropic provider', () => {
expect(AISDK_PROVIDERS.anthropic).toBeDefined();
expect(AISDK_PROVIDERS.anthropic.package).toBe('@ai-sdk/anthropic');
expect(AISDK_PROVIDERS.anthropic.envKey).toBe('ANTHROPIC_API_KEY');
});
it('should have google provider', () => {
expect(AISDK_PROVIDERS.google).toBeDefined();
expect(AISDK_PROVIDERS.google.package).toBe('@ai-sdk/google');
expect(AISDK_PROVIDERS.google.envKey).toBe('GOOGLE_API_KEY');
});
it('should have all expected providers', () => {
const expectedProviders = [
'openai', 'anthropic', 'google', 'azure', 'amazon-bedrock',
'groq', 'mistral', 'cohere', 'deepseek', 'xai',
'fireworks', 'togetherai', 'perplexity'
];
for (const provider of expectedProviders) {
expect(AISDK_PROVIDERS[provider as keyof typeof AISDK_PROVIDERS]).toBeDefined();
}
});
});
describe('PROVIDER_ALIASES', () => {
it('should map oai to openai', () => {
expect(PROVIDER_ALIASES.oai).toBe('openai');
});
it('should map claude to anthropic', () => {
expect(PROVIDER_ALIASES.claude).toBe('anthropic');
});
it('should map gemini to google', () => {
expect(PROVIDER_ALIASES.gemini).toBe('google');
});
it('should map bedrock to amazon-bedrock', () => {
expect(PROVIDER_ALIASES.bedrock).toBe('amazon-bedrock');
});
});
@@ -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.8.0.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.8.0.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.8.0 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+66
View File
@@ -0,0 +1,66 @@
"""
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
elif name == "_reset_default_registry":
from .registry import _reset_default_registry
_lazy_cache[name] = _reset_default_registry
return _reset_default_registry
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",
]
+382
View File
@@ -0,0 +1,382 @@
"""
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()
# Check collision with existing provider name
if normalized_alias in self._providers and not override:
raise ValueError(
f"Alias '{alias}' conflicts with existing provider name. "
f"Use override=True to replace it."
)
# Check collision with existing alias
if normalized_alias in self._aliases and not override:
existing_target = self._aliases[normalized_alias]
raise ValueError(
f"Alias '{alias}' is already registered (points to '{existing_target}'). "
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.8.0"
@@ -0,0 +1,484 @@
"""
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")
class TestCollisionDetection:
"""Tests for collision detection and error messages."""
def test_alias_collision_with_existing_alias(self):
"""Should raise error when alias collides with existing alias."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("provider-a", MockLLMProvider, aliases=["shared-alias"])
with pytest.raises(ValueError) as exc_info:
registry.register("provider-b", CustomCloudflareProvider, aliases=["shared-alias"])
error_msg = str(exc_info.value)
assert "shared-alias" in error_msg
assert "already registered" in error_msg
assert "provider-a" in error_msg # Should mention the existing target
def test_alias_collision_with_provider_name(self):
"""Should raise error when alias collides with provider name."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("existing-provider", MockLLMProvider)
with pytest.raises(ValueError) as exc_info:
registry.register("new-provider", CustomCloudflareProvider, aliases=["existing-provider"])
error_msg = str(exc_info.value)
assert "existing-provider" in error_msg
assert "conflicts" in error_msg
def test_alias_override_with_flag(self):
"""Should allow alias override with override=True."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("provider-a", MockLLMProvider, aliases=["shared-alias"])
registry.register("provider-b", CustomCloudflareProvider, aliases=["shared-alias"], override=True)
# Alias should now point to provider-b
provider = registry.resolve("shared-alias", "model")
assert provider.provider_id == "cloudflare"
def test_case_insensitive_collision(self):
"""Should detect collision regardless of case."""
from praisonai.llm.registry import LLMProviderRegistry
registry = LLMProviderRegistry()
registry.register("MyProvider", MockLLMProvider)
with pytest.raises(ValueError, match="already registered"):
registry.register("myprovider", CustomCloudflareProvider)
def test_error_message_includes_available_providers(self):
"""Error message should list available providers."""
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("unknown", "model")
error_msg = str(exc_info.value).lower()
assert "unknown provider" in error_msg
assert "openai" in error_msg
assert "anthropic" in error_msg
assert "register" in error_msg # Should suggest how to register
class TestParseModelString:
"""Tests for model string parsing."""
def test_parse_provider_model_format(self):
"""Should parse provider/model format."""
from praisonai.llm.registry import parse_model_string
result = parse_model_string("openai/gpt-4o")
assert result["provider_id"] == "openai"
assert result["model_id"] == "gpt-4o"
def test_parse_gpt_model_defaults_to_openai(self):
"""Should default gpt-* models to openai."""
from praisonai.llm.registry import parse_model_string
result = parse_model_string("gpt-4o-mini")
assert result["provider_id"] == "openai"
assert result["model_id"] == "gpt-4o-mini"
def test_parse_claude_model_defaults_to_anthropic(self):
"""Should default claude-* models to anthropic."""
from praisonai.llm.registry import parse_model_string
result = parse_model_string("claude-3-5-sonnet")
assert result["provider_id"] == "anthropic"
assert result["model_id"] == "claude-3-5-sonnet"
def test_parse_gemini_model_defaults_to_google(self):
"""Should default gemini-* models to google."""
from praisonai.llm.registry import parse_model_string
result = parse_model_string("gemini-2.0-flash")
assert result["provider_id"] == "google"
assert result["model_id"] == "gemini-2.0-flash"
def test_parse_o1_model_defaults_to_openai(self):
"""Should default o1* models to openai."""
from praisonai.llm.registry import parse_model_string
result = parse_model_string("o1-preview")
assert result["provider_id"] == "openai"
assert result["model_id"] == "o1-preview"
def test_parse_unknown_model_defaults_to_openai(self):
"""Should default unknown models to openai."""
from praisonai.llm.registry import parse_model_string
result = parse_model_string("llama-3.1-8b")
assert result["provider_id"] == "openai"
assert result["model_id"] == "llama-3.1-8b"
def test_parse_custom_provider_model(self):
"""Should parse custom provider/model format."""
from praisonai.llm.registry import parse_model_string
result = parse_model_string("cloudflare/workers-ai-model")
assert result["provider_id"] == "cloudflare"
assert result["model_id"] == "workers-ai-model"
class TestLiteLLMIsolation:
"""Tests to verify LiteLLM paths are not affected."""
def test_registry_does_not_import_litellm(self):
"""Registry module should not import litellm."""
import sys
# Clear any cached imports
modules_before = set(sys.modules.keys())
# Import registry
from praisonai.llm import registry
# Check litellm was not imported
modules_after = set(sys.modules.keys())
new_modules = modules_after - modules_before
litellm_modules = [m for m in new_modules if 'litellm' in m.lower()]
assert len(litellm_modules) == 0, f"Registry imported litellm modules: {litellm_modules}"
def test_registry_only_imports_typing(self):
"""Registry should only import from typing module."""
import ast
import inspect
from praisonai.llm import registry
source = inspect.getsource(registry)
tree = ast.parse(source)
imports = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
imports.append(alias.name)
elif isinstance(node, ast.ImportFrom):
if node.module:
imports.append(node.module)
# Only typing should be imported
assert imports == ['typing'], f"Unexpected imports: {imports}"