From 72e46acc2e8acc6bc5ea210d96cf47ca67799a98 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 20 Mar 2026 12:20:09 +0200 Subject: [PATCH 1/3] chore: Remove provider examples ported to SDK repos Provider implementations and the interactive CLI have been ported to self-contained examples in posthog-python and posthog-js: - posthog-python: PostHog/posthog-python#466 - posthog-js: PostHog/posthog-js#3263 Removed: - python/providers/ (entire directory) - python/openai_agents/ (entire directory) - python/main.py (interactive CLI) - node/src/providers/ (entire directory) - node/src/index.ts (interactive CLI) - Test scripts that depended on providers Kept: - python/trace-generator/ (internal tool, no provider deps) - python/scripts/generate_demo_data.py (rewritten to use posthog.ai SDKs directly) - python/scripts/generate_session_test_data.py (direct HTTP, no provider deps) - python/scripts/generate_many_tools_test_data.py (direct HTTP) - python/scripts/test_ai_events_migration.py (direct HTTP) - python/scripts/test_litellm.py (uses litellm directly) - python/scripts/test_langchain_otel.py (uses langchain directly) - python/scripts/test_pydantic_ai_otel.py (uses pydantic-ai directly) - node/scripts/test_vercel_ai_otel.ts (uses OTEL directly) - node/scripts/test_vercel_anthropic.ts (uses @posthog/ai directly) --- node/scripts/run_test.sh | 26 - node/scripts/test_weather_tool.ts | 279 ---- node/src/index.ts | 907 ------------ node/src/providers/anthropic-streaming.ts | 338 ----- node/src/providers/anthropic.ts | 211 --- node/src/providers/base.ts | 278 ---- node/src/providers/constants.ts | 107 -- node/src/providers/gemini-image.ts | 109 -- node/src/providers/gemini-streaming.ts | 221 --- node/src/providers/gemini.ts | 176 --- node/src/providers/langchain.ts | 169 --- node/src/providers/mastra.ts | 330 ----- node/src/providers/openai-chat-streaming.ts | 293 ---- node/src/providers/openai-chat.ts | 238 --- node/src/providers/openai-image.ts | 57 - node/src/providers/openai-streaming.ts | 296 ---- node/src/providers/openai-transcription.ts | 124 -- node/src/providers/openai.ts | 217 --- .../vercel-ai-anthropic-streaming.ts | 209 --- node/src/providers/vercel-ai-anthropic.ts | 145 -- .../vercel-ai-gateway-anthropic-streaming.ts | 196 --- .../providers/vercel-ai-gateway-anthropic.ts | 147 -- .../providers/vercel-ai-google-streaming.ts | 198 --- node/src/providers/vercel-ai-google.ts | 226 --- .../providers/vercel-ai-otel-gemini-image.ts | 86 -- .../vercel-ai-otel-openai-embeddings.ts | 169 --- node/src/providers/vercel-ai-otel-openai.ts | 197 --- node/src/providers/vercel-ai-otel.ts | 195 --- node/src/providers/vercel-ai-streaming.ts | 211 --- node/src/providers/vercel-ai.ts | 178 --- node/src/providers/vercel-generate-object.ts | 261 ---- node/src/providers/vercel-stream-object.ts | 313 ---- python/main.py | 716 --------- python/openai_agents/__init__.py | 1 - python/openai_agents/agents.py | 310 ---- python/openai_agents/runner.py | 481 ------ python/providers/__init__.py | 1 - python/providers/anthropic.py | 222 --- python/providers/anthropic_streaming.py | 325 ----- python/providers/base.py | 326 ----- python/providers/constants.py | 42 - python/providers/gemini.py | 169 --- python/providers/gemini_image.py | 108 -- python/providers/gemini_streaming.py | 208 --- python/providers/langchain.py | 162 --- python/providers/langchain_otel.py | 163 --- python/providers/litellm_provider.py | 300 ---- python/providers/litellm_streaming.py | 431 ------ python/providers/openai.py | 244 ---- python/providers/openai_chat.py | 266 ---- python/providers/openai_chat_streaming.py | 297 ---- python/providers/openai_image.py | 56 - python/providers/openai_otel.py | 343 ----- python/providers/openai_streaming.py | 293 ---- python/providers/openai_transcription.py | 141 -- python/providers/pydantic_ai_otel.py | 89 -- python/scripts/generate_demo_data.py | 1293 ++++++++++++++++- python/scripts/run_test.sh | 21 - python/scripts/test_multi_language.py | 246 ---- python/scripts/test_weather_tool.py | 202 --- 60 files changed, 1259 insertions(+), 13304 deletions(-) delete mode 100755 node/scripts/run_test.sh delete mode 100644 node/scripts/test_weather_tool.ts delete mode 100644 node/src/index.ts delete mode 100644 node/src/providers/anthropic-streaming.ts delete mode 100644 node/src/providers/anthropic.ts delete mode 100644 node/src/providers/base.ts delete mode 100644 node/src/providers/constants.ts delete mode 100644 node/src/providers/gemini-image.ts delete mode 100644 node/src/providers/gemini-streaming.ts delete mode 100644 node/src/providers/gemini.ts delete mode 100644 node/src/providers/langchain.ts delete mode 100644 node/src/providers/mastra.ts delete mode 100644 node/src/providers/openai-chat-streaming.ts delete mode 100644 node/src/providers/openai-chat.ts delete mode 100644 node/src/providers/openai-image.ts delete mode 100644 node/src/providers/openai-streaming.ts delete mode 100644 node/src/providers/openai-transcription.ts delete mode 100644 node/src/providers/openai.ts delete mode 100644 node/src/providers/vercel-ai-anthropic-streaming.ts delete mode 100644 node/src/providers/vercel-ai-anthropic.ts delete mode 100644 node/src/providers/vercel-ai-gateway-anthropic-streaming.ts delete mode 100644 node/src/providers/vercel-ai-gateway-anthropic.ts delete mode 100644 node/src/providers/vercel-ai-google-streaming.ts delete mode 100644 node/src/providers/vercel-ai-google.ts delete mode 100644 node/src/providers/vercel-ai-otel-gemini-image.ts delete mode 100644 node/src/providers/vercel-ai-otel-openai-embeddings.ts delete mode 100644 node/src/providers/vercel-ai-otel-openai.ts delete mode 100644 node/src/providers/vercel-ai-otel.ts delete mode 100644 node/src/providers/vercel-ai-streaming.ts delete mode 100644 node/src/providers/vercel-ai.ts delete mode 100644 node/src/providers/vercel-generate-object.ts delete mode 100644 node/src/providers/vercel-stream-object.ts delete mode 100644 python/main.py delete mode 100644 python/openai_agents/__init__.py delete mode 100644 python/openai_agents/agents.py delete mode 100644 python/openai_agents/runner.py delete mode 100644 python/providers/__init__.py delete mode 100644 python/providers/anthropic.py delete mode 100644 python/providers/anthropic_streaming.py delete mode 100644 python/providers/base.py delete mode 100644 python/providers/constants.py delete mode 100644 python/providers/gemini.py delete mode 100644 python/providers/gemini_image.py delete mode 100644 python/providers/gemini_streaming.py delete mode 100644 python/providers/langchain.py delete mode 100644 python/providers/langchain_otel.py delete mode 100644 python/providers/litellm_provider.py delete mode 100644 python/providers/litellm_streaming.py delete mode 100644 python/providers/openai.py delete mode 100644 python/providers/openai_chat.py delete mode 100644 python/providers/openai_chat_streaming.py delete mode 100644 python/providers/openai_image.py delete mode 100644 python/providers/openai_otel.py delete mode 100644 python/providers/openai_streaming.py delete mode 100644 python/providers/openai_transcription.py delete mode 100644 python/providers/pydantic_ai_otel.py delete mode 100755 python/scripts/run_test.sh delete mode 100755 python/scripts/test_multi_language.py delete mode 100755 python/scripts/test_weather_tool.py diff --git a/node/scripts/run_test.sh b/node/scripts/run_test.sh deleted file mode 100755 index 299cc0b..0000000 --- a/node/scripts/run_test.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -# Script to run weather tool tests -# This reuses the same node_modules as the main app - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" - -cd "$PROJECT_DIR" - -# Check if node_modules exists -if [ ! -d "node_modules" ]; then - echo "โŒ node_modules not found. Run ./run.sh first to set up." - exit 1 -fi - -echo "โœ… Dependencies found" - -# Check if dist folder exists, build if not -if [ ! -d "dist" ]; then - echo "๐Ÿ“ฆ Building TypeScript..." - npm run build -fi - -# Run the test script with ts-node -npx ts-node --esm scripts/test_weather_tool.ts "$@" diff --git a/node/scripts/test_weather_tool.ts b/node/scripts/test_weather_tool.ts deleted file mode 100644 index 0260b0a..0000000 --- a/node/scripts/test_weather_tool.ts +++ /dev/null @@ -1,279 +0,0 @@ -#!/usr/bin/env ts-node -/** - * Test script for weather tool functionality. - * Tests that providers correctly call the weather API with lat/lon/location_name. - * - * Usage: - * ts-node test_weather_tool.ts # Run all tests - * ts-node test_weather_tool.ts --provider anthropic # Test specific provider - * ts-node test_weather_tool.ts --list # List available providers - */ - -import * as dotenv from 'dotenv'; -import * as path from 'path'; -import { fileURLToPath } from 'url'; -import { PostHog } from 'posthog-node'; -import { AnthropicProvider } from '../dist/providers/anthropic.js'; -import { AnthropicStreamingProvider } from '../dist/providers/anthropic-streaming.js'; -import { GeminiProvider } from '../dist/providers/gemini.js'; -import { GeminiStreamingProvider } from '../dist/providers/gemini-streaming.js'; -import { LangChainProvider } from '../dist/providers/langchain.js'; -import { OpenAIProvider } from '../dist/providers/openai.js'; -import { OpenAIChatProvider } from '../dist/providers/openai-chat.js'; -import { OpenAIChatStreamingProvider } from '../dist/providers/openai-chat-streaming.js'; -import { OpenAIStreamingProvider } from '../dist/providers/openai-streaming.js'; -import { VercelAIProvider } from '../dist/providers/vercel-ai.js'; -import { VercelAIStreamingProvider } from '../dist/providers/vercel-ai-streaming.js'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Load environment variables -dotenv.config({ path: path.join(__dirname, '..', '..', '.env') }); - -// Define available providers -interface ProviderConfig { - class: any; - name: string; - isStreaming: boolean; - requiresThinking?: boolean; -} - -const AVAILABLE_PROVIDERS: Record = { - 'anthropic': { - class: AnthropicProvider, - name: 'Anthropic Provider', - isStreaming: false, - requiresThinking: true - }, - 'anthropic-streaming': { - class: AnthropicStreamingProvider, - name: 'Anthropic Streaming Provider', - isStreaming: true, - requiresThinking: true - }, - 'gemini': { - class: GeminiProvider, - name: 'Google Gemini Provider', - isStreaming: false - }, - 'gemini-streaming': { - class: GeminiStreamingProvider, - name: 'Google Gemini Streaming Provider', - isStreaming: true - }, - 'openai': { - class: OpenAIProvider, - name: 'OpenAI Responses API Provider', - isStreaming: false - }, - 'openai-streaming': { - class: OpenAIStreamingProvider, - name: 'OpenAI Responses API Streaming Provider', - isStreaming: true - }, - 'openai-chat': { - class: OpenAIChatProvider, - name: 'OpenAI Chat Completions Provider', - isStreaming: false - }, - 'openai-chat-streaming': { - class: OpenAIChatStreamingProvider, - name: 'OpenAI Chat Completions Streaming Provider', - isStreaming: true - }, - 'langchain': { - class: LangChainProvider, - name: 'LangChain Provider', - isStreaming: false - }, - 'vercel-ai': { - class: VercelAIProvider, - name: 'Vercel AI SDK Provider', - isStreaming: false - }, - 'vercel-ai-streaming': { - class: VercelAIStreamingProvider, - name: 'Vercel AI SDK Streaming Provider', - isStreaming: true - }, -}; - -function initPostHog(): PostHog | null { - const posthogApiKey = process.env.POSTHOG_API_KEY; - const posthogHost = process.env.POSTHOG_HOST || 'https://us.i.posthog.com'; - - if (!posthogApiKey) { - console.log('โš ๏ธ Warning: POSTHOG_API_KEY not set, analytics will not be tracked'); - return null; - } - - return new PostHog(posthogApiKey, { - host: posthogHost, - flushAt: 1, - flushInterval: 0 - }); -} - -async function testProvider( - providerClass: any, - providerName: string, - useStreaming: boolean, - requiresThinking: boolean = false -): Promise { - console.log(`\n${'='.repeat(80)}`); - console.log(`๐Ÿงช Testing ${providerName}`); - console.log(`${'='.repeat(80)}\n`); - - // Initialize PostHog - const posthogClient = initPostHog(); - - if (!posthogClient) { - console.log('โŒ Cannot run test without PostHog client'); - return false; - } - - // Initialize provider (handle Anthropic's different constructor signature) - const provider = requiresThinking - ? new providerClass(posthogClient, false, undefined, null) - : new providerClass(posthogClient, null); - - // Test query - const testQuery = "What's the weather in Dublin, Ireland?"; - console.log(`๐Ÿ“ Query: ${testQuery}\n`); - - try { - let response: string; - - if (useStreaming) { - console.log('๐Ÿ”„ Streaming response:\n'); - let fullResponse = ''; - const stream = provider.chatStream(testQuery); - - for await (const chunk of stream) { - process.stdout.write(chunk); - fullResponse += chunk; - } - console.log('\n'); - response = fullResponse; - } else { - console.log('๐Ÿ’ฌ Response:\n'); - response = await provider.chat(testQuery); - console.log(response); - console.log(); - } - - // Check if response contains weather data - if (response.includes('ยฐC') || response.includes('ยฐF')) { - console.log('โœ… Test PASSED - Weather data received!'); - - // Check if location name is used - if (response.includes('Dublin')) { - console.log('โœ… Location name displayed correctly!'); - } else { - console.log('โš ๏ธ Warning: Location name might not be displayed'); - } - - return true; - } else { - console.log('โŒ Test FAILED - No weather data in response'); - return false; - } - - } catch (error: any) { - console.log(`โŒ Test FAILED with error: ${error.message}`); - console.error(error.stack); - return false; - } finally { - // Shutdown PostHog - if (posthogClient) { - await posthogClient.shutdown(); - } - } -} - -function listProviders(): void { - console.log(`\n${'='.repeat(80)}`); - console.log('๐Ÿ“‹ Available Providers'); - console.log(`${'='.repeat(80)}\n`); - - for (const [key, config] of Object.entries(AVAILABLE_PROVIDERS)) { - const streamIndicator = config.isStreaming ? ' (streaming)' : ''; - console.log(` โ€ข ${key.padEnd(25)} - ${config.name}${streamIndicator}`); - } - - console.log(); -} - -async function main() { - const args = process.argv.slice(2); - - // Parse arguments - let specificProvider: string | null = null; - let showList = false; - - for (let i = 0; i < args.length; i++) { - if (args[i] === '--provider' || args[i] === '-p') { - specificProvider = args[i + 1]; - i++; - } else if (args[i] === '--list' || args[i] === '-l') { - showList = true; - } - } - - // Handle --list flag - if (showList) { - listProviders(); - process.exit(0); - } - - // Validate provider if specified - if (specificProvider && !AVAILABLE_PROVIDERS[specificProvider]) { - console.error(`โŒ Unknown provider: ${specificProvider}`); - console.log('\nAvailable providers:'); - listProviders(); - process.exit(1); - } - - console.log(`\n${'='.repeat(80)}`); - console.log('๐Ÿš€ Weather Tool Test Suite'); - console.log(`${'='.repeat(80)}`); - - const results: Array<[string, boolean]> = []; - - // Determine which providers to test - const providersToTest = specificProvider - ? { [specificProvider]: AVAILABLE_PROVIDERS[specificProvider] } - : AVAILABLE_PROVIDERS; - - // Run tests - for (const [key, config] of Object.entries(providersToTest)) { - const passed = await testProvider( - config.class, - config.name, - config.isStreaming, - config.requiresThinking - ); - results.push([key, passed]); - } - - // Print summary - console.log(`\n${'='.repeat(80)}`); - console.log('๐Ÿ“Š Test Summary'); - console.log(`${'='.repeat(80)}\n`); - - for (const [name, passed] of results) { - const status = passed ? 'โœ… PASS' : 'โŒ FAIL'; - console.log(`${status} - ${name}`); - } - - const totalPassed = results.filter(([_, passed]) => passed).length; - const totalTests = results.length; - - console.log(`\nTotal: ${totalPassed}/${totalTests} tests passed`); - - // Exit with appropriate code - process.exit(totalPassed === totalTests ? 0 : 1); -} - -main().catch(console.error); diff --git a/node/src/index.ts b/node/src/index.ts deleted file mode 100644 index 562e55b..0000000 --- a/node/src/index.ts +++ /dev/null @@ -1,907 +0,0 @@ -#!/usr/bin/env node - -import * as dotenv from 'dotenv'; -import * as readline from 'readline'; -import * as path from 'path'; -import { fileURLToPath } from 'url'; -import { randomUUID } from 'crypto'; -import { PostHog } from 'posthog-node'; -import { AnthropicProvider } from './providers/anthropic.js'; -import { AnthropicStreamingProvider } from './providers/anthropic-streaming.js'; -import { GeminiProvider } from './providers/gemini.js'; -import { GeminiStreamingProvider } from './providers/gemini-streaming.js'; -import { LangChainProvider } from './providers/langchain.js'; -import { OpenAIProvider } from './providers/openai.js'; -import { OpenAIChatProvider } from './providers/openai-chat.js'; -import { OpenAIChatStreamingProvider } from './providers/openai-chat-streaming.js'; -import { OpenAIStreamingProvider } from './providers/openai-streaming.js'; -import { OpenAITranscriptionProvider } from './providers/openai-transcription.js'; -import { OpenAIImageProvider } from './providers/openai-image.js'; -import { GeminiImageProvider } from './providers/gemini-image.js'; -import { VercelAIProvider } from './providers/vercel-ai.js'; -import { VercelAIStreamingProvider } from './providers/vercel-ai-streaming.js'; -import { VercelAIAnthropicProvider } from './providers/vercel-ai-anthropic.js'; -import { VercelAIAnthropicStreamingProvider } from './providers/vercel-ai-anthropic-streaming.js'; -import { VercelAIGoogleProvider } from './providers/vercel-ai-google.js'; -import { VercelAIGoogleStreamingProvider } from './providers/vercel-ai-google-streaming.js'; -import { VercelGenerateObjectProvider } from './providers/vercel-generate-object.js'; -import { VercelStreamObjectProvider } from './providers/vercel-stream-object.js'; -import { MastraProvider } from './providers/mastra.js'; -import { VercelAIGatewayAnthropicProvider } from './providers/vercel-ai-gateway-anthropic.js'; -import { VercelAIGatewayAnthropicStreamingProvider } from './providers/vercel-ai-gateway-anthropic-streaming.js'; -import { VercelAIOtelOpenAIProvider } from './providers/vercel-ai-otel-openai.js'; -import { VercelAIOtelOpenAIEmbeddingsProvider } from './providers/vercel-ai-otel-openai-embeddings.js'; -import { VercelAIOtelGeminiImageProvider } from './providers/vercel-ai-otel-gemini-image.js'; -import { VercelAIOtelProvider } from './providers/vercel-ai-otel.js'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -dotenv.config({ path: path.join(__dirname, '..', '..', '.env') }); - -// Show debug mode status if enabled -if (process.env.DEBUG === '1') { - console.log("\n" + "=".repeat(80)); - console.log("๐Ÿ› DEBUG MODE ENABLED"); - console.log("=".repeat(80) + "\n"); -} - -// Generate session ID for grouping traces (if enabled) -function shouldEnableSessionId(): boolean { - const value = process.env.ENABLE_AI_SESSION_ID || 'True'; - return ['true', '1', 'yes'].includes(value.toLowerCase()); -} - -const aiSessionId = shouldEnableSessionId() ? randomUUID() : null; - -const posthog = new PostHog( - process.env.POSTHOG_API_KEY!, - { - host: process.env.POSTHOG_HOST || 'https://app.posthog.com', - flushAt: 1, // Flush after every event - flushInterval: 0 // Don't wait for interval, flush immediately - } -); - -// Show session ID message if enabled -if (aiSessionId) { - console.log(`\n๐Ÿ”— AI Session ID enabled: ${aiSessionId}`); - console.log('All traces in this session will be grouped together.\n'); -} - -// Enable debug mode to see PostHog events -// posthog.debug(); - -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout -}); - -function clearScreen(): void { - if (process.env.DEBUG !== '1') { - console.clear(); - } -} - -async function selectMode(): Promise { - const modes = new Map([ - ['1', 'Chat Mode'], - ['2', 'Tool Call Test'], - ['3', 'Message Test'], - ['4', 'Image Test'], - ['5', 'Embeddings Test'], - ['6', 'Structured Output Test (Vercel only)'], - ['7', 'Transcription Test (OpenAI only)'], - ['8', 'Image Generation Test (Vercel AI SDK)'] - ]); - - console.log('\nSelect Mode:'); - console.log('='.repeat(50)); - for (const [key, name] of modes) { - if (key === '1') { - console.log(` ${key}. ${name} (Interactive conversation)`); - } else if (key === '2') { - console.log(` ${key}. ${name} (Auto-test: Weather in Montreal)`); - } else if (key === '3') { - console.log(` ${key}. ${name} (Auto-test: Simple greeting)`); - } else if (key === '4') { - console.log(` ${key}. ${name} (Auto-test: Describe image)`); - } else if (key === '5') { - console.log(` ${key}. ${name} (Auto-test: Generate embeddings)`); - } else if (key === '6') { - console.log(` ${key}. ${name} (Auto-test: Generate structured data)`); - } else if (key === '7') { - console.log(` ${key}. ${name} (Auto-test: Transcribe audio)`); - } else if (key === '8') { - console.log(` ${key}. ${name} (Auto-test: Generate image from prompt)`); - } - } - console.log('='.repeat(50)); - - return new Promise((resolve) => { - const askForMode = () => { - rl.question('\nSelect a mode (1-8) or \'q\' to quit: ', (choice) => { - choice = choice.trim().toLowerCase(); - if (['1', '2', '3', '4', '5', '6', '7', '8'].includes(choice)) { - clearScreen(); - resolve(choice); - } else if (choice === 'q') { - console.log('\n๐Ÿ‘‹ Goodbye!'); - cleanup(); - } else { - console.log('โŒ Invalid choice. Please select 1-8.'); - askForMode(); - } - }); - }; - askForMode(); - }); -} - -function displayProviders(mode?: string): Map { - let providers = new Map([ - ['1', 'Anthropic'], - ['2', 'Anthropic Streaming'], - ['3', 'Google Gemini'], - ['4', 'Google Gemini Streaming'], - ['5', 'LangChain (OpenAI)'], - ['6', 'OpenAI Responses'], - ['7', 'OpenAI Responses Streaming'], - ['8', 'OpenAI Chat Completions'], - ['9', 'OpenAI Chat Completions Streaming'], - ['10', 'Vercel AI SDK (OpenAI)'], - ['11', 'Vercel AI SDK Streaming (OpenAI)'], - ['12', 'Vercel generateObject (OpenAI)'], - ['13', 'Vercel streamObject (OpenAI)'], - ['14', 'Vercel AI SDK (Anthropic)'], - ['15', 'Vercel AI SDK Streaming (Anthropic)'], - ['17', 'Mastra (OpenAI) - Manual Instrumentation'], - ['18', 'Vercel AI SDK (Google)'], - ['19', 'Vercel AI SDK Streaming (Google)'], - ['20', 'OpenAI Responses (Image Generation)'], - ['21', 'Google Gemini (Image Generation)'], - ['22', 'Vercel AI Gateway (Anthropic)'], - ['23', 'Vercel AI Gateway Streaming (Anthropic)'], - ['24', 'Vercel AI SDK OTEL (OpenAI)'], - ['25', 'Vercel AI SDK OTEL (OpenAI + Embeddings)'], - ['26', 'Vercel AI SDK OTEL (Gemini + Image Gen)'], - ['27', 'Vercel AI SDK (Raw OTel)'] - ]); - - // Filter providers for embeddings mode - if (mode === '5') { - // Only OpenAI providers support embeddings - providers = new Map([ - ['6', 'OpenAI Responses'], - ['7', 'OpenAI Responses Streaming'], - ['8', 'OpenAI Chat Completions'], - ['9', 'OpenAI Chat Completions Streaming'], - ['25', 'Vercel AI SDK OTEL (OpenAI + Embeddings)'] - ]); - } - - // Filter providers for structured output mode - if (mode === '6') { - // Only Vercel Object providers support structured output - providers = new Map([ - ['12', 'Vercel generateObject (OpenAI)'], - ['13', 'Vercel streamObject (OpenAI)'] - ]); - } - - // Filter providers for transcription mode - if (mode === '7') { - // Only OpenAI Transcription provider supports audio transcription - providers = new Map([ - ['16', 'OpenAI Transcriptions'] - ]); - } - - // Filter providers for image generation mode - if (mode === '8') { - // Providers that support image generation - providers = new Map([ - ['10', 'Vercel AI SDK (OpenAI)'], - ['18', 'Vercel AI SDK (Google)'], - ['20', 'OpenAI Responses (Image Generation)'], - ['21', 'Google Gemini (Image Generation)'], - ['26', 'Vercel AI SDK OTEL (Gemini + Image Gen)'] - ]); - } - - console.log('\nAvailable AI Providers:'); - console.log('='.repeat(50)); - for (const [key, name] of providers) { - console.log(` ${key}. ${name}`); - } - console.log('='.repeat(50)); - - return providers; -} - -async function getProviderChoice(allowModeChange: boolean = false, allowAll: boolean = false): Promise { - return new Promise((resolve) => { - const askForChoice = () => { - let prompt = '\nSelect a provider (1-27)'; - if (allowAll) { - prompt += ', \'a\' for all providers'; - } - if (allowModeChange) { - prompt += ', or \'m\' to change mode'; - } - prompt += ': '; - - rl.question(prompt, (choice) => { - choice = choice.trim().toLowerCase(); - if (['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23', '24', '25', '26', '27'].includes(choice)) { - clearScreen(); - resolve(choice); - } else if (allowAll && choice === 'a') { - clearScreen(); - resolve('all'); - } else if (allowModeChange && choice === 'm') { - clearScreen(); - resolve('mode_change'); - } else { - console.log('โŒ Invalid choice. Please select a valid option.'); - askForChoice(); - } - }); - }; - askForChoice(); - }); -} - -async function promptThinkingConfig(): Promise<{ enableThinking: boolean; thinkingBudget?: number }> { - console.log('\n๐Ÿง  Extended Thinking Configuration'); - console.log('='.repeat(50)); - console.log('Extended thinking shows Claude\'s reasoning process.'); - console.log('This can improve response quality for complex problems.'); - console.log('='.repeat(50)); - - return new Promise((resolve) => { - rl.question('\nEnable extended thinking? (y/n) [default: n]: ', (choice) => { - choice = choice.trim().toLowerCase(); - if (choice === 'y' || choice === 'yes') { - rl.question('Thinking budget tokens (1024-32000) [default: 10000]: ', (budgetInput) => { - budgetInput = budgetInput.trim(); - if (budgetInput) { - const budget = Math.max(1024, Math.min(parseInt(budgetInput) || 10000, 32000)); - if (isNaN(budget)) { - console.log('โš ๏ธ Invalid number, using default (10000)'); - resolve({ enableThinking: true, thinkingBudget: 10000 }); - } else { - resolve({ enableThinking: true, thinkingBudget: budget }); - } - } else { - resolve({ enableThinking: true, thinkingBudget: 10000 }); - } - }); - } else { - resolve({ enableThinking: false }); - } - }); - }); -} - -function createProvider(choice: string, enableThinking: boolean = false, thinkingBudget?: number): any { - switch (choice) { - case '1': - return new AnthropicProvider(posthog, enableThinking, thinkingBudget, aiSessionId); - case '2': - return new AnthropicStreamingProvider(posthog, enableThinking, thinkingBudget, aiSessionId); - case '3': - return new GeminiProvider(posthog, aiSessionId); - case '4': - return new GeminiStreamingProvider(posthog, aiSessionId); - case '5': - return new LangChainProvider(posthog, aiSessionId); - case '6': - return new OpenAIProvider(posthog, aiSessionId); - case '7': - return new OpenAIStreamingProvider(posthog, aiSessionId); - case '8': - return new OpenAIChatProvider(posthog, aiSessionId); - case '9': - return new OpenAIChatStreamingProvider(posthog, aiSessionId); - case '10': - return new VercelAIProvider(posthog, aiSessionId); - case '11': - return new VercelAIStreamingProvider(posthog, aiSessionId); - case '12': - return new VercelGenerateObjectProvider(posthog, aiSessionId); - case '13': - return new VercelStreamObjectProvider(posthog, aiSessionId); - case '14': - return new VercelAIAnthropicProvider(posthog, aiSessionId); - case '15': - return new VercelAIAnthropicStreamingProvider(posthog, aiSessionId); - case '16': - return new OpenAITranscriptionProvider(posthog, aiSessionId); - case '17': - return new MastraProvider(posthog, aiSessionId); - case '18': - return new VercelAIGoogleProvider(posthog, aiSessionId); - case '19': - return new VercelAIGoogleStreamingProvider(posthog, aiSessionId); - case '20': - return new OpenAIImageProvider(posthog, aiSessionId); - case '21': - return new GeminiImageProvider(posthog, aiSessionId); - case '22': - return new VercelAIGatewayAnthropicProvider(posthog, aiSessionId); - case '23': - return new VercelAIGatewayAnthropicStreamingProvider(posthog, aiSessionId); - case '24': - return new VercelAIOtelOpenAIProvider(posthog, aiSessionId); - case '25': - return new VercelAIOtelOpenAIEmbeddingsProvider(posthog, aiSessionId); - case '26': - return new VercelAIOtelGeminiImageProvider(posthog, aiSessionId); - case '27': - return new VercelAIOtelProvider(posthog, aiSessionId); - default: - throw new Error('Invalid provider choice'); - } -} - -async function runChat(provider: any): Promise { - console.log(`\n๐Ÿค– Welcome to the chatbot using ${provider.getName()}!`); - console.log('๐ŸŒค๏ธ All providers have weather tools - just ask about weather in any city!'); - - if (provider.getDescription) { - console.log(provider.getDescription()); - } - - console.log('Type your messages below. Type \'q\' to return to provider selection.\n'); - - return new Promise((resolve) => { - const chat = () => { - rl.question('๐Ÿ‘ค You: ', async (userInput) => { - userInput = userInput.trim(); - - if (!userInput) { - chat(); - return; - } - - // Check for quit command to return to provider selection - if (userInput.toLowerCase() === 'q') { - console.log('\nโ†ฉ๏ธ Returning to provider selection...\n'); - resolve(true); - return; - } - - try { - // Check if provider supports streaming - if (provider.chatStream && typeof provider.chatStream === 'function') { - process.stdout.write('\n๐Ÿค– Bot: '); - for await (const chunk of provider.chatStream(userInput)) { - process.stdout.write(chunk); - } - console.log(); // New line after streaming completes - console.log('โ”€'.repeat(50)); - } else { - const response = await provider.chat(userInput); - console.log(`\n๐Ÿค– Bot: ${response}`); - console.log('โ”€'.repeat(50)); - } - } catch (error: any) { - logError(error); - console.log('โ”€'.repeat(50)); - } - - chat(); - }); - }; - - chat(); - }); -} - -async function runToolCallTest(provider: any): Promise<{ success: boolean; error: string | null }> { - const testQuery = 'What is the weather in Montreal, Canada?'; - - console.log(`\nTool Call Test: ${provider.getName()}`); - console.log('-'.repeat(50)); - console.log(`Query: "${testQuery}"`); - console.log(); - - try { - // Reset conversation for clean test - provider.resetConversation(); - - // Send the test query - const response = await provider.chat(testQuery); - - console.log(`Response: ${response}`); - console.log(); - return { success: true, error: null }; - } catch (error: any) { - logError(error); - return { success: false, error: error.message }; - } -} - -async function runMessageTest(provider: any): Promise<{ success: boolean; error: string | null }> { - const testQuery = 'Hi, how are you today?'; - - console.log(`\nMessage Test: ${provider.getName()}`); - console.log('-'.repeat(50)); - console.log(`Query: "${testQuery}"`); - console.log(); - - try { - // Reset conversation for clean test - provider.resetConversation(); - - // Send the test query - const response = await provider.chat(testQuery); - - console.log(`Response: ${response}`); - console.log(); - return { success: true, error: null }; - } catch (error: any) { - logError(error); - return { success: false, error: error.message }; - } -} - -async function runEmbeddingsTest(provider: any): Promise<{ success: boolean; error: string | null }> { - const testTexts = [ - 'The quick brown fox jumps over the lazy dog.' - ]; - - console.log(`\nEmbeddings Test: ${provider.getName()}`); - console.log('-'.repeat(50)); - - // Check if provider supports embeddings - if (!provider.embed || typeof provider.embed !== 'function') { - console.log(`โŒ ${provider.getName()} does not support embeddings`); - return { success: false, error: 'Provider does not support embeddings' }; - } - - try { - for (let i = 0; i < testTexts.length; i++) { - const text = testTexts[i]; - console.log(`\nTest ${i + 1}: "${text}"`); - - // Generate embeddings - const embedding = await provider.embed(text); - - if (embedding && embedding.length > 0) { - console.log(`โœ… Generated embedding with ${embedding.length} dimensions`); - // Show first 5 values as sample - console.log(` Sample values: [${embedding.slice(0, 5).join(', ')}]...`); - } else { - console.log(`โŒ Failed to generate embedding`); - return { success: false, error: `Failed to generate embedding for text ${i + 1}` }; - } - } - - console.log(); - return { success: true, error: null }; - - } catch (error: any) { - logError(error); - return { success: false, error: error.message }; - } -} - -async function runStructuredOutputTest(provider: any): Promise<{ success: boolean; error: string | null }> { - const testQueries = [ - 'What is the weather like in New York?', - 'Create a profile for a 25-year-old software developer who loves hiking and photography', - 'Create a plan to learn TypeScript in 3 weeks' - ]; - - console.log(`\nStructured Output Test: ${provider.getName()}`); - console.log('-'.repeat(50)); - - try { - for (let i = 0; i < testQueries.length; i++) { - const query = testQueries[i]; - console.log(`\nTest ${i + 1}: "${query}"`); - console.log(); - - // Reset conversation for clean test - provider.resetConversation(); - - // Check if provider supports streaming for structured output - if (provider.chatStream && typeof provider.chatStream === 'function') { - // Use streaming version - for await (const chunk of provider.chatStream(query)) { - process.stdout.write(chunk); - } - console.log(); // New line after streaming completes - } else { - // Use non-streaming version - const response = await provider.chat(query); - console.log(response); - } - console.log('-'.repeat(30)); - } - - console.log(); - return { success: true, error: null }; - - } catch (error: any) { - logError(error); - return { success: false, error: error.message }; - } -} - -async function runImageTest(provider: any): Promise<{ success: boolean; error: string | null }> { - // Create a simple test image (1x1 red pixel as base64) - const base64Image = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='; - const testQuery = 'What do you see in this image? Please describe it.'; - - console.log(`\nImage Test: ${provider.getName()}`); - console.log('-'.repeat(50)); - console.log(`Query: "${testQuery}"`); - console.log(`Image: 1x1 red pixel (base64 encoded)`); - console.log(); - - try { - // Reset conversation for clean test - provider.resetConversation(); - - // Send the test query with image - const response = await provider.chat(testQuery, base64Image); - - console.log(`Response: ${response}`); - console.log(); - return { success: true, error: null }; - } catch (error: any) { - logError(error); - return { success: false, error: error.message }; - } -} - -async function runTranscriptionTest(provider: any): Promise<{ success: boolean; error: string | null }> { - const fs = await import('fs'); - const path = await import('path'); - - console.log(`\nTranscription Test: ${provider.getName()}`); - console.log('-'.repeat(50)); - - // Check if provider supports transcription - if (!provider.transcribe || typeof provider.transcribe !== 'function') { - console.log(`โŒ ${provider.getName()} does not support transcription`); - return { success: false, error: 'Provider does not support transcription' }; - } - - // Check for test audio file - const audioPath = path.join(__dirname, '..', '..', 'test-audio.mp3'); - - if (!fs.existsSync(audioPath)) { - console.log(`โŒ Test audio file not found at: ${audioPath}`); - console.log('Please create a test audio file (test-audio.mp3) in the llm-analytics-apps directory'); - console.log('You can use any short audio file (mp3, wav, m4a, etc.)'); - return { success: false, error: 'Test audio file not found' }; - } - - try { - console.log(`\nTranscribing audio file: ${audioPath}`); - console.log(); - - // Transcribe the audio - const transcription = await provider.transcribe(audioPath); - - if (transcription && transcription.length > 0) { - console.log(`โœ… Transcription successful`); - console.log(`\nTranscription text:`); - console.log(`"${transcription}"`); - console.log(); - } else { - console.log(`โŒ Failed to transcribe audio`); - return { success: false, error: 'Failed to generate transcription' }; - } - - console.log(); - return { success: true, error: null }; - - } catch (error: any) { - logError(error); - return { success: false, error: error.message }; - } -} - -async function runImageGenerationTest(provider: any): Promise<{ success: boolean; error: string | null }> { - const testPrompt = 'A serene mountain landscape at sunset with a lake reflection'; - - console.log(`\nImage Generation Test: ${provider.getName()}`); - console.log('-'.repeat(50)); - console.log(`Prompt: "${testPrompt}"`); - console.log(); - - // Check if provider supports image generation - if (!provider.generateImage || typeof provider.generateImage !== 'function') { - console.log(`โŒ ${provider.getName()} does not support image generation`); - return { success: false, error: 'Provider does not support image generation' }; - } - - try { - // Generate the image - const imageUrl = await provider.generateImage(testPrompt); - - if (imageUrl) { - console.log(`โœ… Generated image successfully`); - console.log(`\nImage result:`); - console.log(imageUrl); - console.log(); - return { success: true, error: null }; - } else { - console.log(`โŒ Failed to generate image`); - return { success: false, error: 'No image returned' }; - } - } catch (error: any) { - logError(error); - return { success: false, error: error.message }; - } -} - -async function runAllTests(mode: string): Promise { - let providersInfo: Array<[string, string]> = [ - ['1', 'Anthropic'], - ['2', 'Anthropic Streaming'], - ['3', 'Google Gemini'], - ['4', 'Google Gemini Streaming'], - ['5', 'LangChain (OpenAI)'], - ['6', 'OpenAI Responses'], - ['7', 'OpenAI Responses Streaming'], - ['8', 'OpenAI Chat Completions'] - ]; - - // Filter providers for embeddings test (only those that support it) - if (mode === '5') { - // Only OpenAI providers support embeddings - providersInfo = [ - ['6', 'OpenAI Responses'], - ['7', 'OpenAI Responses Streaming'], - ['8', 'OpenAI Chat Completions'], - ['25', 'Vercel AI SDK OTEL (OpenAI + Embeddings)'] - ]; - } - - // Filter providers for structured output test (only those that support it) - if (mode === '6') { - // Only Vercel Object providers support structured output - providersInfo = [ - ['12', 'Vercel generateObject (OpenAI)'], - ['13', 'Vercel streamObject (OpenAI)'] - ]; - } - - // Filter providers for image generation test (only those that support it) - if (mode === '8') { - // Providers that support image generation - providersInfo = [ - ['10', 'Vercel AI SDK (OpenAI)'], - ['18', 'Vercel AI SDK (Google)'], - ['20', 'OpenAI Responses (Image Generation)'], - ['21', 'Google Gemini (Image Generation)'], - ['26', 'Vercel AI SDK OTEL (Gemini + Image Gen)'] - ]; - } - - const testName = mode === '2' ? 'Tool Call Test' : - mode === '3' ? 'Message Test' : - mode === '4' ? 'Image Test' : - mode === '5' ? 'Embeddings Test' : - mode === '6' ? 'Structured Output Test' : - mode === '7' ? 'Transcription Test' : - mode === '8' ? 'Image Generation Test' : 'Unknown Test'; - console.log(`\n๐Ÿ”„ Running ${testName} on all providers...`); - console.log('='.repeat(60)); - console.log(); - - interface TestResult { - name: string; - success: boolean; - error: string | null; - } - - const results: TestResult[] = []; - - for (const [providerId, providerName] of providersInfo) { - console.log(`[${providerId}/8] Testing ${providerName}...`); - - try { - // For automated tests, don't enable thinking by default - const provider = createProvider(providerId, false, undefined); - - // Run the appropriate test - const result = mode === '2' - ? await runToolCallTest(provider) - : mode === '3' - ? await runMessageTest(provider) - : mode === '4' - ? await runImageTest(provider) - : mode === '5' - ? await runEmbeddingsTest(provider) - : mode === '6' - ? await runStructuredOutputTest(provider) - : mode === '7' - ? await runTranscriptionTest(provider) - : mode === '8' - ? await runImageGenerationTest(provider) - : { success: false, error: 'Unknown test mode' }; - - results.push({ - name: providerName, - success: result.success, - error: result.error - }); - - } catch (initError: any) { - console.log(` โŒ Failed to initialize: ${initError.message}`); - results.push({ - name: providerName, - success: false, - error: `Initialization failed: ${initError.message}` - }); - } - } - - // Print summary - console.log('\n' + '='.repeat(60)); - console.log(`๐Ÿ“Š ${testName} Summary`); - console.log('='.repeat(60)); - - const successful = results.filter(r => r.success); - const failed = results.filter(r => !r.success); - - console.log(`\nโœ… Successful: ${successful.length}/${results.length}`); - for (const result of successful) { - console.log(` โ€ข ${result.name}`); - } - - if (failed.length > 0) { - console.log(`\nโŒ Failed: ${failed.length}/${results.length}`); - for (const result of failed) { - console.log(` โ€ข ${result.name}`); - console.log(` Error: ${result.error}`); - } - } - - console.log('='.repeat(60)); - console.log(); -} - -async function main(): Promise { - clearScreen(); - console.log('\n๐Ÿš€ Unified AI Chatbot'); - console.log('Choose your mode and AI provider'); - console.log(); - - // First, select the mode - let mode = await selectMode(); - - // Main loop for provider selection and testing - while (true) { - // Display providers and get user choice - displayProviders(mode); - - // Allow mode change for all modes, 'all' option only for test modes - const allowModeChange = (mode === '1' || mode === '2' || mode === '3' || mode === '4' || mode === '5' || mode === '6' || mode === '7' || mode === '8'); - const allowAll = (mode === '2' || mode === '3' || mode === '4' || mode === '5' || mode === '6' || mode === '7' || mode === '8'); - const choice = await getProviderChoice(allowModeChange, allowAll); - - // Check if user wants to change mode - if (choice === 'mode_change') { - mode = await selectMode(); - continue; - } - - // Check if user wants to test all providers - if (choice === 'all') { - await runAllTests(mode); - continue; - } - - // Check if Anthropic provider selected and prompt for thinking config - let enableThinking = false; - let thinkingBudget: number | undefined = undefined; - if (choice === '1' || choice === '2') { // Anthropic providers - const config = await promptThinkingConfig(); - enableThinking = config.enableThinking; - thinkingBudget = config.thinkingBudget; - } - - // Create provider instance - let provider; - try { - provider = createProvider(choice, enableThinking, thinkingBudget); - let statusMsg = `\nโœ… Initialized ${provider.getName()}`; - if (enableThinking) { - statusMsg += ` (Thinking: enabled, budget: ${thinkingBudget})`; - } - console.log(statusMsg); - } catch (error: any) { - console.log(`โŒ Failed to initialize provider: ${error.message}`); - continue; - } - - // Execute based on mode - if (mode === '1') { - // Chat Mode - run interactive chat and continue when done - await runChat(provider); - continue; - } else if (mode === '2') { - // Tool Call Test - run test and loop back - const result = await runToolCallTest(provider); - if (!result.error) { - console.log(); - } - } else if (mode === '3') { - // Message Test - run test and loop back - const result = await runMessageTest(provider); - if (!result.error) { - console.log(); - } - } else if (mode === '4') { - // Image Test - run test and loop back - const result = await runImageTest(provider); - if (!result.error) { - console.log(); - } - } else if (mode === '5') { - // Embeddings Test - run test and loop back - const result = await runEmbeddingsTest(provider); - if (!result.error) { - console.log(); - } - } else if (mode === '6') { - // Structured Output Test - run test and loop back - const result = await runStructuredOutputTest(provider); - if (!result.error) { - console.log(); - } - } else if (mode === '7') { - // Transcription Test - run test and loop back - const result = await runTranscriptionTest(provider); - if (!result.error) { - console.log(); - } - } else if (mode === '8') { - // Image Generation Test - run test and loop back - const result = await runImageGenerationTest(provider); - if (!result.error) { - console.log(); - } - } - } -} - -const cleanup = async () => { - try { - console.log('\n\n๐Ÿ‘‹ Goodbye!'); - console.log('Shutting down PostHog client...'); - rl.close(); - await posthog.shutdown(); - console.log('โœ… PostHog client shut down successfully'); - } catch (error) { - console.error('โŒ Error shutting down PostHog client:', error); - } - process.exit(0); -}; - -const logError = (error: unknown) => { - if (error instanceof Error) { - console.log(`โŒ Error: ${error.stack}`); - } else { - console.error('โŒ Error:', error); - } -} - -process.on('SIGINT', cleanup); -process.on('SIGTERM', cleanup); -process.on('SIGQUIT', cleanup); - -main().catch(async (error) => { - console.error('Fatal error:', error); - rl.close(); - await posthog.shutdown(); - process.exit(1); -}); diff --git a/node/src/providers/anthropic-streaming.ts b/node/src/providers/anthropic-streaming.ts deleted file mode 100644 index 6d9c7b8..0000000 --- a/node/src/providers/anthropic-streaming.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { Anthropic as PostHogAnthropic } from "@posthog/ai"; -import { PostHog } from "posthog-node"; -import { StreamingProvider, Message, Tool } from "./base.js"; -import { - ANTHROPIC_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_POSTHOG_DISTINCT_ID, - DEFAULT_THINKING_ENABLED, - DEFAULT_THINKING_BUDGET_TOKENS -} from "./constants.js"; - -export class AnthropicStreamingProvider extends StreamingProvider { - private client: any; - private enableThinking: boolean; - private thinkingBudget: number; - - constructor(posthogClient: PostHog, enableThinking: boolean = false, thinkingBudget?: number, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.client = new PostHogAnthropic({ - apiKey: process.env.ANTHROPIC_API_KEY!, - posthog: posthogClient, - }); - this.enableThinking = enableThinking; - this.thinkingBudget = thinkingBudget || DEFAULT_THINKING_BUDGET_TOKENS; - } - - protected getToolDefinitions(): Tool[] { - return [ - { - name: "get_weather", - description: "Get the current weather for a specific location", - input_schema: { - type: "object", - properties: { - latitude: { - type: "number", - description: "The latitude of the location (e.g., 37.7749 for San Francisco)", - }, - longitude: { - type: "number", - description: "The longitude of the location (e.g., -122.4194 for San Francisco)", - }, - location_name: { - type: "string", - description: "A human-readable name for the location (e.g., 'San Francisco, CA' or 'Dublin, Ireland')", - }, - }, - required: ["latitude", "longitude", "location_name"], - }, - }, - { - name: "tell_joke", - description: "Tell a joke with a question-style setup and an answer punchline", - input_schema: { - type: "object", - properties: { - setup: { - type: "string", - description: "The setup of the joke, usually in question form", - }, - punchline: { - type: "string", - description: "The punchline or answer to the joke", - }, - }, - required: ["setup", "punchline"], - }, - }, - ]; - } - - getName(): string { - return "Anthropic Streaming"; - } - - async *chatStream( - userInput: string, - base64Image?: string, - ): AsyncGenerator { - let userContent: any; - - if (base64Image) { - userContent = [ - { type: "text", text: userInput }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: base64Image, - }, - }, - ]; - } else { - userContent = userInput; - } - - const userMessage: Message = { - role: "user", - content: userContent, - }; - this.messages.push(userMessage); - - // Prepare API request parameters - // Note: max_tokens must be greater than thinking.budget_tokens - const thinkingBudget = this.enableThinking ? Math.max(this.thinkingBudget, 1024) : 0; - const maxTokens = this.enableThinking - ? Math.max(DEFAULT_MAX_TOKENS, thinkingBudget + 2000) - : DEFAULT_MAX_TOKENS; - - const requestParams: any = { - model: ANTHROPIC_MODEL, - max_tokens: maxTokens, - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogProperties: { - $ai_span_name: "anthropic_messages_streaming", - ...this.getPostHogProperties(), - }, - tools: this.tools, - messages: this.messages, - stream: true, - }; - - // Add extended thinking if enabled - if (this.enableThinking) { - requestParams.thinking = { - type: "enabled", - budget_tokens: thinkingBudget, - }; - } - - if (this.debugMode) { - this.debugLog("Anthropic Streaming API Request", requestParams); - } - - const stream = await this.client.messages.create(requestParams); - - let accumulatedContent = ""; - const assistantContent: any[] = []; - const toolsUsed: any[] = []; - let currentTextBlock: any = null; - let currentThinkingBlock: any = null; - - for await (const chunk of stream) { - // Handle content block start events - if (chunk.type === "content_block_start") { - if (chunk.content_block?.type === "thinking") { - // Start a new thinking content block - if (this.enableThinking) { - yield "\n\n๐Ÿ’ญ Thinking: "; - } - currentThinkingBlock = { - type: "thinking", - thinking: "", - }; - assistantContent.push(currentThinkingBlock); - currentTextBlock = null; - } else if (chunk.content_block?.type === "text") { - // Start a new text content block - // If we just had thinking, add some spacing - if (currentThinkingBlock && this.enableThinking) { - yield "\n\n"; - } - currentTextBlock = { - type: "text", - text: "", - }; - assistantContent.push(currentTextBlock); - currentThinkingBlock = null; - } else if (chunk.content_block?.type === "tool_use") { - const toolInfo = { - id: chunk.content_block.id, - name: chunk.content_block.name, - input: {}, - }; - toolsUsed.push(toolInfo); - - // Add tool use to assistant content immediately - assistantContent.push({ - type: "tool_use", - id: toolInfo.id, - name: toolInfo.name, - input: toolInfo.input, // Will be updated later - }); - currentTextBlock = null; - currentThinkingBlock = null; - } - } - - // Handle delta events (text, thinking, tool input) - if ("delta" in chunk) { - if ("thinking" in chunk.delta) { - const thinkingDelta = chunk.delta.thinking ?? ""; - if (currentThinkingBlock) { - currentThinkingBlock.thinking += thinkingDelta; - } - // Only yield thinking if enabled - if (this.enableThinking) { - yield thinkingDelta; - } - } else if ("text" in chunk.delta) { - const delta = chunk.delta.text ?? ""; - accumulatedContent += delta; - yield delta; - // Update the current text block if we're tracking one - if (currentTextBlock) { - currentTextBlock.text += delta; - } - } - } - - if ( - chunk.type === "content_block_delta" && - chunk.delta?.type === "input_json_delta" - ) { - const lastTool = toolsUsed[toolsUsed.length - 1]; - if (lastTool) { - // Accumulate the JSON input - if (!lastTool.inputString) { - lastTool.inputString = ""; - } - lastTool.inputString += chunk.delta.partial_json || ""; - } - } - - if (chunk.type === "content_block_stop") { - currentTextBlock = null; // Reset current text block - currentThinkingBlock = null; // Reset current thinking block - - if (toolsUsed.length > 0) { - const lastTool = toolsUsed[toolsUsed.length - 1]; - if (lastTool && lastTool.inputString) { - try { - lastTool.input = JSON.parse(lastTool.inputString); - delete lastTool.inputString; - - // Update the input in assistant content - // Find the corresponding tool_use in assistantContent and update its input - for (const content of assistantContent) { - if (content.type === "tool_use" && content.id === lastTool.id) { - content.input = lastTool.input; - break; - } - } - - // Execute the tool - if (lastTool.name === "get_weather") { - const latitude = lastTool.input.latitude || 0.0; - const longitude = lastTool.input.longitude || 0.0; - const locationName = lastTool.input.location_name; - const weatherResult = await this.getWeather(latitude, longitude, locationName); - const toolResultText = this.formatToolResult( - "get_weather", - weatherResult, - ); - yield "\n\n" + toolResultText; - } else if (lastTool.name === "tell_joke") { - const setup = lastTool.input.setup || ""; - const punchline = lastTool.input.punchline || ""; - const jokeResult = this.tellJoke(setup, punchline); - const toolResultText = this.formatToolResult( - "tell_joke", - jokeResult, - ); - yield "\n\n" + toolResultText; - } - } catch (e) { - console.error("Error parsing tool input:", e); - } - } - } - } - } - - // Save assistant message - const assistantMessage: Message = { - role: "assistant", - content: assistantContent.length > 0 ? assistantContent : [{type: "text", text: accumulatedContent || ""}], - }; - this.messages.push(assistantMessage); - - // Debug: Log the completed stream response - if (this.debugMode) { - this.debugLog("Anthropic Streaming API Response (completed)", { - accumulatedContent: accumulatedContent, - assistantContent: assistantContent, - toolsUsed: toolsUsed - }); - } - - // If tools were used, add tool results to messages - for (const tool of toolsUsed) { - if (tool.name === "get_weather") { - const latitude = tool.input.latitude || 0.0; - const longitude = tool.input.longitude || 0.0; - const locationName = tool.input.location_name; - const weatherResult = await this.getWeather(latitude, longitude, locationName); - - const toolResultMessage: Message = { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: tool.id, - content: weatherResult, - }, - ], - }; - this.messages.push(toolResultMessage); - } else if (tool.name === "tell_joke") { - const setup = tool.input.setup || ""; - const punchline = tool.input.punchline || ""; - const jokeResult = this.tellJoke(setup, punchline); - - const toolResultMessage: Message = { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: tool.id, - content: jokeResult, - }, - ], - }; - this.messages.push(toolResultMessage); - } - } - } - - // Non-streaming chat for compatibility - async chat(userInput: string, base64Image?: string): Promise { - const chunks: string[] = []; - for await (const chunk of this.chatStream(userInput, base64Image)) { - chunks.push(chunk); - } - return chunks.join(""); - } -} diff --git a/node/src/providers/anthropic.ts b/node/src/providers/anthropic.ts deleted file mode 100644 index 44c62d4..0000000 --- a/node/src/providers/anthropic.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { Anthropic as PostHogAnthropic } from "@posthog/ai"; -import { PostHog } from "posthog-node"; -import { BaseProvider, Message, Tool } from "./base.js"; -import { - ANTHROPIC_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_POSTHOG_DISTINCT_ID, - DEFAULT_THINKING_ENABLED, - DEFAULT_THINKING_BUDGET_TOKENS, -} from "./constants.js"; - -export class AnthropicProvider extends BaseProvider { - private client: any; - private enableThinking: boolean; - private thinkingBudget: number; - - constructor(posthogClient: PostHog, enableThinking: boolean = false, thinkingBudget?: number, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.client = new PostHogAnthropic({ - apiKey: process.env.ANTHROPIC_API_KEY!, - posthog: posthogClient, - }); - this.enableThinking = enableThinking; - this.thinkingBudget = thinkingBudget || DEFAULT_THINKING_BUDGET_TOKENS; - } - - protected getToolDefinitions(): Tool[] { - return [ - { - name: "get_weather", - description: "Get the current weather for a specific location using geographical coordinates", - input_schema: { - type: "object", - properties: { - latitude: { - type: "number", - description: "The latitude of the location (e.g., 37.7749 for San Francisco)", - }, - longitude: { - type: "number", - description: "The longitude of the location (e.g., -122.4194 for San Francisco)", - }, - location_name: { - type: "string", - description: "A human-readable name for the location (e.g., 'San Francisco, CA' or 'Dublin, Ireland')", - }, - }, - required: ["latitude", "longitude", "location_name"], - }, - }, - { - name: "tell_joke", - description: "Tell a joke with a question-style setup and an answer punchline", - input_schema: { - type: "object", - properties: { - setup: { - type: "string", - description: "The setup of the joke, usually in question form", - }, - punchline: { - type: "string", - description: "The punchline or answer to the joke", - }, - }, - required: ["setup", "punchline"], - }, - }, - ]; - } - - getName(): string { - return "Anthropic"; - } - - async chat(userInput: string, base64Image?: string): Promise { - let userContent: any; - - if (base64Image) { - // For image input, create content array with text and image - userContent = [ - { type: "text", text: userInput }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: base64Image, - }, - }, - ]; - } else { - userContent = userInput; - } - - const userMessage: Message = { - role: "user", - content: userContent, - }; - this.messages.push(userMessage); - - // Prepare API request parameters - // Note: max_tokens must be greater than thinking.budget_tokens - const thinkingBudget = this.enableThinking ? Math.max(this.thinkingBudget, 1024) : 0; - const maxTokens = this.enableThinking - ? Math.max(DEFAULT_MAX_TOKENS, thinkingBudget + 2000) - : DEFAULT_MAX_TOKENS; - - const requestParams: any = { - model: ANTHROPIC_MODEL, - max_tokens: maxTokens, - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogProperties: { - $ai_span_name: "anthropic_messages", - ...this.getPostHogProperties(), - }, - tools: this.tools, - messages: this.messages, - }; - - // Add extended thinking if enabled - if (this.enableThinking) { - requestParams.thinking = { - type: "enabled", - budget_tokens: thinkingBudget, - }; - } - - const message = await this.client.messages.create(requestParams); - - // Debug: Log the API call (request + response) - this.debugApiCall("Anthropic", requestParams, message); - - const assistantContent: any[] = []; - const toolResults: string[] = []; - const displayParts: string[] = []; - - if (message.content && message.content.length > 0) { - for (const contentBlock of message.content) { - if (contentBlock.type === "thinking") { - // Store thinking block for message history - assistantContent.push(contentBlock); - // Display thinking content if enabled - if (this.enableThinking) { - displayParts.push(`๐Ÿ’ญ Thinking: ${contentBlock.thinking}`); - } - } else if (contentBlock.type === "tool_use") { - assistantContent.push(contentBlock); - - const toolName = contentBlock.name; - const toolInput = contentBlock.input; - - if (toolName === "get_weather") { - const latitude = toolInput.latitude || 0.0; - const longitude = toolInput.longitude || 0.0; - const locationName = toolInput.location_name; - const weatherResult = await this.getWeather(latitude, longitude, locationName); - const toolResultText = this.formatToolResult( - "get_weather", - weatherResult, - ); - toolResults.push(toolResultText); - displayParts.push(toolResultText); - } else if (toolName === "tell_joke") { - const setup = toolInput.setup || ""; - const punchline = toolInput.punchline || ""; - const jokeResult = this.tellJoke(setup, punchline); - const toolResultText = this.formatToolResult( - "tell_joke", - jokeResult, - ); - toolResults.push(toolResultText); - displayParts.push(toolResultText); - } - } else if (contentBlock.type === "text") { - assistantContent.push(contentBlock); - displayParts.push(contentBlock.text); - } - } - } - - const assistantMessage: Message = { - role: "assistant", - content: assistantContent, - }; - this.messages.push(assistantMessage); - - if (toolResults.length > 0) { - for (const contentBlock of message.content) { - if (contentBlock.type === "tool_use") { - const toolResultMessage: Message = { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: contentBlock.id, - content: toolResults[0] || "Tool executed", - }, - ], - }; - this.messages.push(toolResultMessage); - break; - } - } - } - - return displayParts.length > 0 - ? displayParts.join("\n\n") - : "No response received"; - } -} diff --git a/node/src/providers/base.ts b/node/src/providers/base.ts deleted file mode 100644 index f5e77ff..0000000 --- a/node/src/providers/base.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { PostHog } from "posthog-node"; - -export interface Message { - role: string; - content?: any; - tool_calls?: any[]; -} - -export interface Tool { - name?: string; - type?: string; - description?: string; - parameters?: any; - input_schema?: any; - function?: { - name: string; - description: string; - parameters: any; - }; -} - -export abstract class BaseProvider { - protected posthogClient: PostHog; - protected messages: Message[] = []; - protected tools: Tool[] = []; - protected debugMode: boolean; - protected aiSessionId: string | null; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - this.posthogClient = posthogClient; - this.aiSessionId = aiSessionId; - this.debugMode = process.env.DEBUG === '1'; - this.initializeTools(); - } - - protected getPostHogProperties(): Record { - return this.aiSessionId ? { $ai_session_id: this.aiSessionId } : {}; - } - - protected initializeTools(): void { - // Default weather tool - can be overridden by subclasses - this.tools = this.getToolDefinitions(); - } - - protected abstract getToolDefinitions(): Tool[]; - - abstract getName(): string; - - abstract chat(userInput: string, base64Image?: string): Promise; - - resetConversation(): void { - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return []; - } - - protected async getWeather( - latitude: number, - longitude: number, - locationName?: string - ): Promise { - try { - // Get weather data from Open-Meteo API - const weatherUrl = "https://api.open-meteo.com/v1/forecast"; - const params = new URLSearchParams({ - latitude: latitude.toString(), - longitude: longitude.toString(), - current: "temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m", - temperature_unit: "celsius", - wind_speed_unit: "kmh", - precipitation_unit: "mm", - }); - - const response = await fetch(`${weatherUrl}?${params}`); - const data: any = await response.json(); - - if (data.current) { - const current: any = data.current; - const tempCelsius = current.temperature_2m; - const tempFahrenheit = (tempCelsius * 9 / 5 + 32).toFixed(1); - const humidity = current.relative_humidity_2m; - const feelsLike = current.apparent_temperature; - const precipitation = current.precipitation; - const windSpeed = current.wind_speed_10m; - const weatherCode = current.weather_code; - - // Interpret WMO weather code - const weatherDescriptions: { [key: number]: string } = { - 0: "clear skies", - 1: "mainly clear", - 2: "partly cloudy", - 3: "overcast", - 45: "foggy", - 48: "depositing rime fog", - 51: "light drizzle", - 53: "moderate drizzle", - 55: "dense drizzle", - 61: "slight rain", - 63: "moderate rain", - 65: "heavy rain", - 71: "slight snow", - 73: "moderate snow", - 75: "heavy snow", - 77: "snow grains", - 80: "slight rain showers", - 81: "moderate rain showers", - 82: "violent rain showers", - 85: "slight snow showers", - 86: "heavy snow showers", - 95: "thunderstorm", - 96: "thunderstorm with slight hail", - 99: "thunderstorm with heavy hail", - }; - - const weatherDesc = - weatherDescriptions[weatherCode] || `weather code ${weatherCode}`; - - // Use location name if provided, otherwise fall back to coordinates - const locationStr = locationName - ? locationName - : `coordinates (${latitude}, ${longitude})`; - - let result = `The current weather in ${locationStr} is ${tempCelsius}ยฐC (${tempFahrenheit}ยฐF) with ${weatherDesc}.`; - - if (feelsLike !== tempCelsius) { - result += ` It feels like ${feelsLike}ยฐC.`; - } - - result += ` Humidity is ${humidity}%. Wind speed is ${windSpeed} km/h.`; - - if (precipitation > 0) { - result += ` Precipitation: ${precipitation} mm.`; - } - - return result; - } else { - return `Unable to fetch weather data for ${locationName || `coordinates (${latitude}, ${longitude})`}`; - } - } catch (error: any) { - return `Error fetching weather: ${error?.message || "Unknown error"}`; - } - } - - protected tellJoke(setup: string, punchline: string): string { - return `${setup}\n\n${punchline}`; - } - - protected formatToolResult(toolName: string, result: string): string { - if (toolName === "get_weather") { - return `๐ŸŒค๏ธ Weather: ${result}`; - } else if (toolName === "tell_joke") { - return `๐Ÿ˜‚ Joke: ${result}`; - } - - return result; - } - - protected debugLog(title: string, data: any, truncate: boolean = true): void { - if (!this.debugMode) { - return; - } - - console.log("\n" + "=".repeat(80)); - console.log(`๐Ÿ› DEBUG: ${title}`); - console.log("=".repeat(80)); - - let output: string; - if (typeof data === "object") { - output = JSON.stringify(data, null, 2); - } else { - output = String(data); - } - - // Truncate very long outputs - if (truncate && output.length > 5000) { - output = output.substring(0, 5000) + "\n... (truncated)"; - } - - console.log(output); - console.log("=".repeat(80) + "\n"); - } - - protected debugApiCall( - providerName: string, - requestData: any, - responseData?: any, - ): void { - /** - * Simplified debug logging for API calls. - * Just pass the request and optionally response objects - they'll be converted to JSON automatically. - * - * Usage: - * // Log request only (before API call) - * this.debugApiCall("Anthropic", requestParams); - * - * // Log both request and response (after API call) - * this.debugApiCall("Anthropic", requestParams, response); - */ - if (!this.debugMode) { - return; - } - - // Redact base64 data from strings - const redactBase64 = (value: string): string => { - // Check for data URI with base64 (e.g., data:image/png;base64,...) - const dataUriMatch = value.match(/^(data:[^;]+;base64,)(.{50,})/); - if (dataUriMatch) { - const prefix = dataUriMatch[1]; - const base64Data = dataUriMatch[2]; - return `${prefix}[REDACTED: ${base64Data.length} chars]`; - } - // Check for standalone base64 that looks like image data (long alphanumeric string) - if (value.length > 500 && /^[A-Za-z0-9+/=]+$/.test(value)) { - return `[REDACTED BASE64: ${value.length} chars]`; - } - return value; - }; - - // Convert objects to plain objects for JSON serialization - const toPlainObject = (obj: any): any => { - if (obj === null || obj === undefined) { - return obj; - } - if (typeof obj === "string") { - return redactBase64(obj); - } - if (typeof obj !== "object") { - return obj; - } - if (Array.isArray(obj)) { - return obj.map(toPlainObject); - } - // Handle Uint8Array (binary data like images) - if (obj instanceof Uint8Array) { - return `[REDACTED BINARY: ${obj.length} bytes]`; - } - // Try to convert to plain object - if (obj.toJSON) { - return toPlainObject(obj.toJSON()); - } - // For regular objects, recursively convert - const result: any = {}; - for (const key in obj) { - if (obj.hasOwnProperty(key)) { - result[key] = toPlainObject(obj[key]); - } - } - return result; - }; - - this.debugLog(`${providerName} API Request`, toPlainObject(requestData)); - - if (responseData !== undefined) { - this.debugLog( - `${providerName} API Response`, - toPlainObject(responseData), - ); - } - } -} - -export abstract class StreamingProvider extends BaseProvider { - abstract chatStream( - userInput: string, - base64Image?: string, - ): AsyncGenerator; - - // Default implementation that just yields the full response at once - async *defaultChatStream( - userInput: string, - base64Image?: string, - ): AsyncGenerator { - const response = await this.chat(userInput, base64Image); - yield response; - } -} diff --git a/node/src/providers/constants.ts b/node/src/providers/constants.ts deleted file mode 100644 index c23a64e..0000000 --- a/node/src/providers/constants.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Provider configuration constants. - * - * This module centralizes default parameters for all AI providers, - * making it easy to update model names, token limits, and other - * default settings in one place. - */ - -// OpenAI Models -export const OPENAI_CHAT_MODEL = "gpt-5-mini"; -export const OPENAI_VISION_MODEL = "gpt-4o"; -export const OPENAI_EMBEDDING_MODEL = "text-embedding-3-small"; -export const OPENAI_IMAGE_MODEL = "gpt-image-1-mini"; - -// Anthropic Models -export const ANTHROPIC_MODEL = "claude-sonnet-4-5-20250929"; - -// Gateway Models (Vercel AI Gateway uses provider/model format) -export const GATEWAY_ANTHROPIC_MODEL = `anthropic/${ANTHROPIC_MODEL}`; - -// Google Gemini Models -export const GEMINI_MODEL = "gemini-2.5-flash"; -export const GEMINI_IMAGE_MODEL = "gemini-2.5-flash-image"; - -// Default API Parameters -export const DEFAULT_MAX_TOKENS = 1000; - -// Extended Thinking Configuration (Anthropic) -// Set ENABLE_THINKING=1 in .env to enable extended thinking -// Set THINKING_BUDGET_TOKENS in .env to customize (default: 10000, min: 1024) -export const DEFAULT_THINKING_ENABLED = false; -export const DEFAULT_THINKING_BUDGET_TOKENS = 10000; - -// PostHog Configuration -export const DEFAULT_POSTHOG_DISTINCT_ID = "user-hog"; - -// Weather Tool Configuration -export const WEATHER_TEMP_MIN_CELSIUS = -10; -export const WEATHER_TEMP_MAX_CELSIUS = 40; - -// System Prompts -export const SYSTEM_PROMPT_FRIENDLY = "You are a friendly AI that just makes conversation. You have access to a weather tool if the user asks about weather."; -export const SYSTEM_PROMPT_ASSISTANT = "You are a helpful assistant. You have access to tools that you can use to help answer questions."; -export const SYSTEM_PROMPT_STRUCTURED = "You are an AI assistant that provides structured responses. You can provide weather information, create user profiles, or generate task plans based on user requests."; - -// Long system prompt for Anthropic prompt caching (must be >1024 tokens). -// Used by the gateway providers to test cache token accounting in PostHog. -export const SYSTEM_PROMPT_CACHEABLE = `You are a knowledgeable AI assistant with deep expertise across technology, science, data analytics, and general knowledge. You are designed to be helpful, accurate, and engaging in conversation. You always strive to provide the most relevant and useful information to the user. - -## Your Capabilities - -You have access to the following tools and abilities: - -### Weather Information -You have access to a weather tool that can fetch current weather data for any location worldwide. When a user asks about weather conditions, temperatures, or forecasts, you should use this tool with the appropriate latitude and longitude coordinates. Always present weather data in a clear, readable format including both Celsius and Fahrenheit temperatures, humidity levels, wind speed, precipitation probability, and current weather conditions. If the user mentions a city or region, determine the correct coordinates and use the tool. - -### Joke Telling -You have access to a joke-telling tool. When a user asks for a joke or wants to be entertained, use this tool to deliver a well-structured joke with a setup and punchline. Make sure the jokes are appropriate and family-friendly. - -### General Knowledge -You can answer questions across a wide range of topics including science, technology, history, culture, mathematics, philosophy, and more. Draw on your extensive training to provide accurate, well-sourced answers. When discussing complex topics, break them down into understandable components. - -### Data Analysis and Interpretation -You can help users understand data, explain statistical concepts, guide analytical thinking, and provide insights from structured information. You understand common data formats, visualization techniques, and analytical methodologies. - -## Response Guidelines - -When responding to users, follow these guidelines carefully: - -1. Be conversational and friendly, but maintain accuracy and informativeness at all times -2. When using tools, briefly explain what you are doing and why before presenting the results -3. Provide relevant context and additional information when it adds genuine value to the response -4. If you are unsure about something, acknowledge your uncertainty rather than guessing -5. Use clear formatting with bullet points, numbered lists, or headers when it helps organize complex information -6. Keep responses concise but thorough - avoid being overly brief when detail is needed, but do not pad responses unnecessarily -7. When answering questions that have multiple valid perspectives, present the different viewpoints fairly -8. For technical questions, adjust your level of detail based on cues from the user about their expertise level - -## Conversation Style - -Your conversation style should follow these principles: - -- Start with a direct answer to the user's question before elaborating with supporting details -- Add relevant context, examples, or analogies that help clarify complex concepts -- Suggest related topics or follow-up questions when they naturally arise from the conversation -- Use a warm, professional tone that is approachable but not overly casual -- Avoid unnecessary jargon unless the user demonstrates technical familiarity with the subject -- If the user asks about multiple topics in a single message, address each one clearly with appropriate transitions -- Maintain conversation context throughout the session for natural, coherent dialogue flow - -## Knowledge Areas - -You have detailed knowledge spanning the following domains: - -### Technology and Software Engineering -Programming languages including Python, JavaScript, TypeScript, Rust, Go, Java, and more. Web development frameworks and modern tooling. Cloud computing platforms including AWS, Google Cloud Platform, and Microsoft Azure. Database systems, data modeling, and query optimization. DevOps practices, CI/CD pipelines, and infrastructure as code. Machine learning concepts, neural network architectures, and AI applications. - -### Data Science and Analytics -Statistical analysis methods and their proper application. Data visualization best practices and tool selection. Business intelligence methodologies and dashboard design. A/B testing, experimentation design, and result interpretation. Key performance indicators and metrics frameworks for various business domains. Data pipeline architecture, ETL processes, and data warehouse design. - -### Science and Nature -Physics fundamentals from classical mechanics to quantum theory. Biology spanning molecular biology to ecology and evolution. Chemistry including organic, inorganic, and biochemistry. Earth sciences, meteorology, and climate science. Astronomy and space exploration. Environmental science and sustainability. - -### General Knowledge -World geography, demographics, and geopolitical context. Historical events, their causes, and lasting impacts. Cultural practices, traditions, and diversity across regions. Economic concepts, market dynamics, and financial literacy. Health and wellness fundamentals including nutrition and exercise science. - -Remember: your primary goal is to be genuinely helpful. Every response should leave the user better informed or more capable of accomplishing their goals. Prioritize clarity, accuracy, and practical value in everything you say.`; diff --git a/node/src/providers/gemini-image.ts b/node/src/providers/gemini-image.ts deleted file mode 100644 index ec80545..0000000 --- a/node/src/providers/gemini-image.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { GoogleGenAI as PostHogGoogleGenAI } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { BaseProvider, Tool } from './base.js'; -import { GEMINI_IMAGE_MODEL, DEFAULT_POSTHOG_DISTINCT_ID } from './constants.js'; - -export class GeminiImageProvider extends BaseProvider { - private client: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.client = new PostHogGoogleGenAI({ - apiKey: process.env.GEMINI_API_KEY!, - posthog: posthogClient - }); - } - - protected getToolDefinitions(): Tool[] { - return []; - } - - getName(): string { - return 'Google Gemini (Image Generation)'; - } - - private logTokenUsageByModality(response: any): void { - if (!this.debugMode) return; - - try { - const usageMetadata = response?.usageMetadata; - if (!usageMetadata) { - console.log("\n๐Ÿ“Š Token Usage: No modality breakdown available\n"); - return; - } - - console.log("\n" + "โ”€".repeat(60)); - console.log("๐Ÿ“Š TOKEN USAGE BY MODALITY"); - console.log("โ”€".repeat(60)); - - // Input tokens breakdown - const promptDetails = usageMetadata.promptTokensDetails || []; - console.log("\n INPUT TOKENS:"); - if (promptDetails.length > 0) { - for (const detail of promptDetails) { - console.log(` ${detail.modality}: ${detail.tokenCount} tokens`); - } - } else { - console.log(` Total: ${usageMetadata.promptTokenCount || 0} tokens`); - } - - // Output tokens breakdown - const candidatesDetails = usageMetadata.candidatesTokensDetails || []; - console.log("\n OUTPUT TOKENS:"); - if (candidatesDetails.length > 0) { - for (const detail of candidatesDetails) { - console.log(` ${detail.modality}: ${detail.tokenCount} tokens`); - } - } else { - console.log(` Total: ${usageMetadata.candidatesTokenCount || 0} tokens`); - } - - console.log("\n TOTAL: " + (usageMetadata.totalTokenCount || 0) + " tokens"); - console.log("โ”€".repeat(60) + "\n"); - } catch (e) { - // Silently ignore errors in debug logging - } - } - - async generateImage(prompt: string, model: string = GEMINI_IMAGE_MODEL): Promise { - try { - const requestParams = { - model: model, - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogProperties: { - $ai_span_name: "gemini_generate_image", - ...this.getPostHogProperties(), - }, - contents: prompt - }; - - const response = await this.client.models.generateContent(requestParams); - this.debugApiCall("Google Gemini Image Generation", requestParams, response); - this.logTokenUsageByModality(response); - - // Check for images in the response candidates - if (response.candidates) { - for (const candidate of response.candidates) { - if (candidate.content?.parts) { - for (const part of candidate.content.parts) { - // Check for inline image data - if (part.inlineData?.data && part.inlineData?.mimeType?.startsWith('image/')) { - const b64 = part.inlineData.data; - return `data:${part.inlineData.mimeType};base64,${b64.substring(0, 100)}... (base64 image data, ${b64.length} chars total)`; - } - } - } - } - } - - return ""; - } catch (error: any) { - console.error('Error in Gemini image generation:', error); - throw new Error(`Gemini Image Generation error: ${error.message}`); - } - } - - async chat(): Promise { - throw new Error('This provider is for image generation only. Use generateImage() instead.'); - } -} diff --git a/node/src/providers/gemini-streaming.ts b/node/src/providers/gemini-streaming.ts deleted file mode 100644 index 3f375a1..0000000 --- a/node/src/providers/gemini-streaming.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { GoogleGenAI as PostHogGoogleGenAI } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { StreamingProvider, Tool } from './base.js'; -import { GEMINI_MODEL, DEFAULT_POSTHOG_DISTINCT_ID } from './constants.js'; - -export class GeminiStreamingProvider extends StreamingProvider { - private client: any; - private history: any[] = []; - private config: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.client = new PostHogGoogleGenAI({ - apiKey: process.env.GEMINI_API_KEY!, - // vertexai: true, - // project: "project-id", - // location: "us-central1", - posthog: posthogClient - }); - - this.config = { - tools: this.tools - }; - } - - protected getToolDefinitions(): Tool[] { - const weatherFunction = { - name: 'get_current_weather', - description: 'Gets the current weather for a given location.', - parameters: { - type: 'object', - properties: { - latitude: { - type: 'number', - description: 'The latitude of the location (e.g., 37.7749 for San Francisco)', - }, - longitude: { - type: 'number', - description: 'The longitude of the location (e.g., -122.4194 for San Francisco)', - }, - location_name: { - type: 'string', - description: 'A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')', - }, - }, - required: ['latitude', 'longitude', 'location_name'], - }, - }; - - const jokeFunction = { - name: 'tell_joke', - description: 'Tell a joke with a question-style setup and an answer punchline', - parameters: { - type: 'object', - properties: { - setup: { - type: 'string', - description: 'The setup of the joke, usually in question form', - }, - punchline: { - type: 'string', - description: 'The punchline or answer to the joke', - }, - }, - required: ['setup', 'punchline'], - }, - }; - - return [{ - functionDeclarations: [weatherFunction, jokeFunction] - }] as any; - } - - getName(): string { - return 'Google Gemini Streaming'; - } - - resetConversation(): void { - this.history = []; - } - - async *chatStream( - userInput: string, - base64Image?: string, - ): AsyncGenerator { - // Build content parts for this message - let parts: any[]; - - if (base64Image) { - // Use native Gemini format for images - parts = [ - { text: userInput }, - { - inlineData: { - mimeType: 'image/png', - data: base64Image - } - } - ]; - } else { - // Text-only content - parts = [{ text: userInput }]; - } - - // Add user message to history - this.history.push({ - role: 'user', - parts: parts - }); - - // Create the streaming response - const requestParams = { - model: GEMINI_MODEL, - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogProperties: { - $ai_span_name: "gemini_generate_content_streaming", - ...this.getPostHogProperties(), - }, - contents: this.history, - config: this.config - }; - - if (this.debugMode) { - this.debugLog("Google Gemini Streaming API Request", requestParams); - } - - const stream = await this.client.models.generateContentStream(requestParams); - - let accumulatedText = ""; - const modelParts: any[] = []; - const toolResults: string[] = []; - - // Process the stream - for await (const chunk of stream) { - // Handle text chunks - if (chunk.candidates) { - for (const candidate of chunk.candidates) { - if (candidate.content) { - for (const part of candidate.content.parts) { - if (part.text) { - // Yield only the delta text - const delta = part.text; - accumulatedText += delta; - yield delta; - } else if (part.functionCall) { - // Handle function calls during streaming - const functionCall = part.functionCall; - - if (functionCall.name === 'get_current_weather') { - const latitude = functionCall.args?.latitude || 0.0; - const longitude = functionCall.args?.longitude || 0.0; - const locationName = functionCall.args?.location_name; - const weatherResult = await this.getWeather(latitude, longitude, locationName); - const toolResultText = this.formatToolResult('get_weather', weatherResult); - toolResults.push(toolResultText); - - // Yield the tool result to the stream - yield '\n\n' + toolResultText; - - // Track the function call for history - modelParts.push({ functionCall }); - } else if (functionCall.name === 'tell_joke') { - const setup = functionCall.args?.setup || ''; - const punchline = functionCall.args?.punchline || ''; - const jokeResult = this.tellJoke(setup, punchline); - const toolResultText = this.formatToolResult('tell_joke', jokeResult); - toolResults.push(toolResultText); - - // Yield the tool result to the stream - yield '\n\n' + toolResultText; - - // Track the function call for history - modelParts.push({ functionCall }); - } - } - } - } - } - } - } - - // Build model parts for history - if (accumulatedText) { - modelParts.push({ text: accumulatedText }); - } - - // Add model response to history - if (modelParts.length > 0) { - this.history.push({ - role: 'model', - parts: modelParts - }); - } - - // Debug: Log the completed stream response - if (this.debugMode) { - this.debugLog("Google Gemini Streaming API Response (completed)", { - accumulatedText: accumulatedText, - modelParts: modelParts, - toolResults: toolResults - }); - } - - // Add tool results to history if any - for (const toolResult of toolResults) { - this.history.push({ - role: 'model', - parts: [{ text: `Tool result: ${toolResult}` }] - }); - } - } - - // Non-streaming chat for compatibility - async chat(userInput: string, base64Image?: string): Promise { - const chunks: string[] = []; - for await (const chunk of this.chatStream(userInput, base64Image)) { - chunks.push(chunk); - } - return chunks.join(""); - } -} \ No newline at end of file diff --git a/node/src/providers/gemini.ts b/node/src/providers/gemini.ts deleted file mode 100644 index 115ad51..0000000 --- a/node/src/providers/gemini.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { GoogleGenAI as PostHogGoogleGenAI } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { BaseProvider, Tool } from './base.js'; -import { GEMINI_MODEL, DEFAULT_POSTHOG_DISTINCT_ID } from './constants.js'; - -export class GeminiProvider extends BaseProvider { - private client: any; - private history: any[] = []; - private config: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.client = new PostHogGoogleGenAI({ - apiKey: process.env.GEMINI_API_KEY!, - // vertexai: true, - // project: "project-id", - // location: "us-central1", - posthog: posthogClient - }); - - this.config = { - tools: this.tools - }; - } - - protected getToolDefinitions(): Tool[] { - const weatherFunction = { - name: 'get_current_weather', - description: 'Gets the current weather for a given location.', - parameters: { - type: 'object', - properties: { - latitude: { - type: 'number', - description: 'The latitude of the location (e.g., 37.7749 for San Francisco)', - }, - longitude: { - type: 'number', - description: 'The longitude of the location (e.g., -122.4194 for San Francisco)', - }, - location_name: { - type: 'string', - description: 'A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')', - }, - }, - required: ['latitude', 'longitude', 'location_name'], - }, - }; - - const jokeFunction = { - name: 'tell_joke', - description: 'Tell a joke with a question-style setup and an answer punchline', - parameters: { - type: 'object', - properties: { - setup: { - type: 'string', - description: 'The setup of the joke, usually in question form', - }, - punchline: { - type: 'string', - description: 'The punchline or answer to the joke', - }, - }, - required: ['setup', 'punchline'], - }, - }; - - return [{ - functionDeclarations: [weatherFunction, jokeFunction] - }] as any; - } - - getName(): string { - return 'Google Gemini'; - } - - resetConversation(): void { - this.history = []; - } - - async chat(userInput: string, base64Image?: string): Promise { - // Build content parts for this message - let parts: any[]; - - if (base64Image) { - // Use native Gemini format for images - parts = [ - { text: userInput }, - { - inlineData: { - mimeType: 'image/png', - data: base64Image - } - } - ]; - } else { - // Text-only content - parts = [{ text: userInput }]; - } - - // Add user message to history - this.history.push({ - role: 'user', - parts: parts - }); - - const requestParams = { - model: GEMINI_MODEL, - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogProperties: { - $ai_span_name: "gemini_generate_content", - ...this.getPostHogProperties(), - }, - contents: this.history, - config: this.config - }; - - const message = await this.client.models.generateContent(requestParams); - this.debugApiCall("Google Gemini", requestParams, message); - - const displayParts: string[] = []; - const modelParts: any[] = []; - const toolResults: string[] = []; - - if (message.candidates) { - for (const candidate of message.candidates) { - if (candidate.content) { - for (const part of candidate.content.parts) { - if (part.functionCall) { - const functionCall = part.functionCall; - modelParts.push({ functionCall }); - - if (functionCall.name === 'get_current_weather') { - const latitude = functionCall.args?.latitude || 0.0; - const longitude = functionCall.args?.longitude || 0.0; - const locationName = functionCall.args?.location_name; - const weatherResult = await this.getWeather(latitude, longitude, locationName); - const toolResultText = this.formatToolResult('get_weather', weatherResult); - toolResults.push(toolResultText); - displayParts.push(toolResultText); - } else if (functionCall.name === 'tell_joke') { - const setup = functionCall.args?.setup || ''; - const punchline = functionCall.args?.punchline || ''; - const jokeResult = this.tellJoke(setup, punchline); - const toolResultText = this.formatToolResult('tell_joke', jokeResult); - toolResults.push(toolResultText); - displayParts.push(toolResultText); - } - } else if (part.text) { - modelParts.push({ text: part.text }); - displayParts.push(part.text); - } - } - } - } - } - - // Add model response to history - if (modelParts.length > 0) { - this.history.push({ - role: 'model', - parts: modelParts - }); - } - - for (const toolResult of toolResults) { - this.history.push({ - role: 'model', - parts: [{ text: `Tool result: ${toolResult}` }] - }); - } - - return displayParts.length > 0 ? displayParts.join('\n\n') : (message.text || 'No response received'); - } -} \ No newline at end of file diff --git a/node/src/providers/langchain.ts b/node/src/providers/langchain.ts deleted file mode 100644 index b0b6081..0000000 --- a/node/src/providers/langchain.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { LangChainCallbackHandler } from '@posthog/ai'; -import { ChatOpenAI } from '@langchain/openai'; -import { ChatPromptTemplate } from '@langchain/core/prompts'; -import { DynamicStructuredTool } from '@langchain/core/tools'; -import { z } from 'zod'; -import { HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages'; -import { PostHog } from 'posthog-node'; -import { BaseProvider, Tool } from './base.js'; -import { OPENAI_CHAT_MODEL, OPENAI_VISION_MODEL, SYSTEM_PROMPT_ASSISTANT } from './constants.js'; - -export class LangChainProvider extends BaseProvider { - private callbackHandler: any; - private langchainMessages: any[] = []; - private langchainTools: any[] = []; - private toolMap: Map = new Map(); - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.callbackHandler = new LangChainCallbackHandler({ - client: posthogClient, - properties: { - $ai_span_name: "langchain_chat", - ...this.getPostHogProperties(), - }, - }); - - this.langchainMessages = [ - new SystemMessage(SYSTEM_PROMPT_ASSISTANT) - ]; - - this.setupChain(); - } - - protected getToolDefinitions(): Tool[] { - // LangChain doesn't use this, but we need to implement it - return []; - } - - private setupChain(): void { - const getWeatherTool = new DynamicStructuredTool({ - name: 'get_weather', - description: 'Get the current weather for a specific location', - schema: z.object({ - latitude: z.number().describe('The latitude of the location (e.g., 37.7749 for San Francisco)'), - longitude: z.number().describe('The longitude of the location (e.g., -122.4194 for San Francisco)'), - location_name: z.string().describe('A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')'), - }), - func: async (input: { latitude: number; longitude: number; location_name: string }) => { - return this.getWeather(input.latitude, input.longitude, input.location_name); - } - } as any) as any; - - const tellJokeTool = new DynamicStructuredTool({ - name: 'tell_joke', - description: 'Tell a joke with a question-style setup and an answer punchline', - schema: z.object({ - setup: z.string().describe('The setup of the joke, usually in question form'), - punchline: z.string().describe('The punchline or answer to the joke'), - }), - func: async (input: { setup: string; punchline: string }) => { - return this.tellJoke(input.setup, input.punchline); - } - } as any) as any; - - this.langchainTools = [getWeatherTool, tellJokeTool]; - this.toolMap.set('get_weather', getWeatherTool); - this.toolMap.set('tell_joke', tellJokeTool); - - const prompt = ChatPromptTemplate.fromMessages([ - ['system', SYSTEM_PROMPT_ASSISTANT], - ['user', '{input}'] - ]); - - const model = new ChatOpenAI({ openAIApiKey: process.env.OPENAI_API_KEY }); - prompt.pipe(model.bindTools(this.langchainTools)); - } - - getName(): string { - return 'LangChain (OpenAI)'; - } - - getDescription(): string { - return '๐Ÿ’ก You can ask me about the weather!'; - } - - resetConversation(): void { - this.langchainMessages = [ - new SystemMessage(SYSTEM_PROMPT_ASSISTANT) - ]; - this.messages = []; - } - - async chat(userInput: string, base64Image?: string): Promise { - let userMessage: any; - - if (base64Image) { - // Create a message with image content - userMessage = new HumanMessage({ - content: [ - { - type: 'text', - text: userInput - }, - { - type: 'image_url', - image_url: { - url: `data:image/png;base64,${base64Image}` - } - } - ] - }); - } else { - userMessage = new HumanMessage(userInput); - } - - this.langchainMessages.push(userMessage); - - const model = new ChatOpenAI({ - openAIApiKey: process.env.OPENAI_API_KEY, - modelName: base64Image ? OPENAI_VISION_MODEL : OPENAI_CHAT_MODEL - }); - const modelWithTools = model.bindTools(this.langchainTools); - - const requestParams = { - messages: this.langchainMessages, - callbacks: [this.callbackHandler] - }; - - const response = await modelWithTools.invoke( - this.langchainMessages, - { callbacks: [this.callbackHandler] } - ); - this.debugApiCall("LangChain (OpenAI)", requestParams, response); - - const displayParts: string[] = []; - - if (response.content) { - displayParts.push(response.content as string); - } - - if (response.tool_calls && response.tool_calls.length > 0) { - const toolMessages: any[] = []; - for (const toolCall of response.tool_calls) { - const toolName = toolCall.name; - const toolArgs = toolCall.args; - - if (this.toolMap.has(toolName)) { - const toolResult = await this.toolMap.get(toolName)!.invoke(toolArgs); - const toolResultText = this.formatToolResult(toolName, toolResult); - displayParts.push(toolResultText); - - toolMessages.push( - new ToolMessage({ - content: String(toolResult), - tool_call_id: toolCall.id!, - }) - ); - } - } - - this.langchainMessages.push(response); - this.langchainMessages.push(...toolMessages); - } else { - this.langchainMessages.push(response); - } - - return displayParts.length > 0 ? displayParts.join('\n\n') : 'No response received'; - } -} \ No newline at end of file diff --git a/node/src/providers/mastra.ts b/node/src/providers/mastra.ts deleted file mode 100644 index ee0121f..0000000 --- a/node/src/providers/mastra.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { PostHog } from 'posthog-node'; -import { Mastra } from '@mastra/core'; -import { Agent } from '@mastra/core/agent'; -import { createTool } from '@mastra/core/tools'; -import { z } from 'zod'; -import { randomUUID } from 'crypto'; -import { BaseProvider, Message, Tool } from './base.js'; -import { DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_FRIENDLY } from './constants.js'; - -/** - * Mastra Provider with Manual PostHog Instrumentation - * - * This provider demonstrates how to manually capture LLM analytics events - * when using an AI framework that isn't natively supported by PostHog SDK. - * - * Manual capture involves: - * 1. Generating trace and span IDs - * 2. Tracking timing (latency) - * 3. Estimating or extracting token counts - * 4. Calling posthog.capture() with the correct event type and properties - */ -export class MastraProvider extends BaseProvider { - private mastra: Mastra; - private agent: Agent; - private traceId: string; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - - // Generate a trace ID for this conversation - this.traceId = this.generateTraceId(); - - // Create Mastra tools - const weatherTool = createTool({ - id: 'get-weather', - description: 'Get the current weather for a specific location', - inputSchema: z.object({ - latitude: z.number().describe('The latitude of the location (e.g., 37.7749 for San Francisco)'), - longitude: z.number().describe('The longitude of the location (e.g., -122.4194 for San Francisco)'), - location_name: z.string().describe("A human-readable name for the location (e.g., 'San Francisco, CA')"), - }), - outputSchema: z.object({ - result: z.string(), - }), - execute: async ({ context }) => { - const result = await this.getWeather( - context.latitude, - context.longitude, - context.location_name - ); - return { result }; - }, - }); - - const jokeTool = createTool({ - id: 'tell-joke', - description: 'Tell a joke with a question-style setup and an answer punchline', - inputSchema: z.object({ - setup: z.string().describe('The setup of the joke, usually in question form'), - punchline: z.string().describe('The punchline or answer to the joke'), - }), - outputSchema: z.object({ - result: z.string(), - }), - execute: async ({ context }) => { - const result = await this.tellJoke(context.setup, context.punchline); - return { result }; - }, - }); - - // Create Mastra agent with tools - this.agent = new Agent({ - name: 'Assistant', - instructions: SYSTEM_PROMPT_FRIENDLY, - // Mastra uses provider/model format - model: { id: 'openai/gpt-4o-mini' }, - tools: { weatherTool, jokeTool }, - }); - - // Initialize Mastra with the agent - this.mastra = new Mastra({ - agents: { assistant: this.agent }, - }); - - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return [ - { - role: 'system', - content: SYSTEM_PROMPT_FRIENDLY - } - ]; - } - - protected getToolDefinitions(): Tool[] { - // Mastra handles tools internally - return []; - } - - getName(): string { - return 'Mastra (OpenAI) - Manual Instrumentation'; - } - - /** - * Generate a valid trace ID (UUID format) - */ - private generateTraceId(): string { - return randomUUID(); - } - - /** - * Generate a unique span ID for individual operations - */ - private generateSpanId(): string { - return randomUUID(); - } - - /** - * Manually capture a PostHog span event (for tool calls, etc.) - */ - private captureSpan( - spanName: string, - inputState: any, - outputState: any, - latency: number, - spanId: string, - parentId: string - ): void { - this.posthogClient.capture({ - distinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - event: '$ai_span', - properties: { - $ai_trace_id: this.traceId, - $ai_span_id: spanId, - $ai_span_name: spanName, - $ai_parent_id: parentId, - $ai_input_state: inputState, - $ai_output_state: outputState, - $ai_latency: latency, - $ai_is_error: false, - ...this.getPostHogProperties(), - }, - }); - - this.debugLog('PostHog Span Capture', { - event: '$ai_span', - span_name: spanName, - span_id: spanId, - parent_id: parentId, - latency, - }); - } - - /** - * Manually capture a PostHog generation event - */ - private captureGeneration( - input: any[], - output: any[], - latency: number, - inputTokens: number, - outputTokens: number, - spanId: string, - parentId?: string - ): void { - const properties: Record = { - // Core required properties - $ai_trace_id: this.traceId, - $ai_model: 'gpt-4o-mini', - $ai_provider: 'mastra', - $ai_input: input, - $ai_output_choices: output, - $ai_input_tokens: inputTokens, - $ai_output_tokens: outputTokens, - - // Optional but recommended properties - $ai_span_id: spanId, - $ai_span_name: 'mastra_chat', - $ai_latency: latency, - $ai_is_error: false, - $ai_stream: false, - - // Add session ID if available - ...this.getPostHogProperties(), - }; - - if (parentId) { - properties.$ai_parent_id = parentId; - } - - // Manually capture the event - this.posthogClient.capture({ - distinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - event: '$ai_generation', - properties, - }); - - this.debugLog('PostHog Manual Capture', { - event: '$ai_generation', - trace_id: this.traceId, - span_id: spanId, - input_tokens: inputTokens, - output_tokens: outputTokens, - latency, - }); - } - - async chat(userInput: string, base64Image?: string): Promise { - // Add user message to history - const userMessage: Message = { - role: 'user', - content: userInput - }; - this.messages.push(userMessage); - - // Generate a span ID for this generation - const spanId = this.generateSpanId(); - const startTime = Date.now(); - - try { - this.debugLog('Mastra Request', { - user_input: userInput, - model: 'gpt-4o-mini', - message_count: this.messages.length, - }); - - // Use Mastra agent to generate response with full conversation history - // Mastra's generate() accepts message arrays in CoreMessage format - const response = await this.agent.generate(this.messages as any); - - // Calculate latency in seconds - const latency = (Date.now() - startTime) / 1000; - - // Extract response data from Mastra - const assistantContent = response.text || ''; - - // Extract token counts from Mastra response - // Mastra uses: inputTokens, outputTokens, totalTokens, reasoningTokens, cachedInputTokens - const inputTokens = (response.usage as any)?.inputTokens || 0; - const outputTokens = (response.usage as any)?.outputTokens || 0; - const totalTokens = (response.usage as any)?.totalTokens || 0; - const reasoningTokens = (response.usage as any)?.reasoningTokens || 0; - const cachedInputTokens = (response.usage as any)?.cachedInputTokens || 0; - - this.debugLog('Mastra Response', { - text: assistantContent.substring(0, 100) + (assistantContent.length > 100 ? '...' : ''), - latency, - input_tokens: inputTokens, - output_tokens: outputTokens, - total_tokens: totalTokens, - reasoning_tokens: reasoningTokens, - cached_input_tokens: cachedInputTokens, - finish_reason: response.finishReason, - tool_calls: response.toolCalls?.length || 0, - tool_results: response.toolResults?.length || 0, - }); - - // Manually capture tool calls as spans - if (response.toolCalls && response.toolCalls.length > 0) { - for (let i = 0; i < response.toolCalls.length; i++) { - const toolCall = response.toolCalls[i]; - const toolResult = response.toolResults?.[i]; - - // Generate a unique span ID for this tool call - const toolSpanId = this.generateSpanId(); - - // Capture the tool execution as a span - this.captureSpan( - toolCall.toolName, - toolCall.args, - toolResult?.result || toolResult, - 0, // Mastra doesn't provide individual tool latency - toolSpanId, - spanId // Parent is the generation span - ); - } - } - - // Manually capture PostHog generation event - this.captureGeneration( - this.messages.map(m => ({ - role: m.role, - content: Array.isArray(m.content) ? m.content : [{ type: 'text', text: m.content }] - })), - [{ - role: 'assistant', - content: [{ type: 'text', text: assistantContent }] - }], - latency, - inputTokens, - outputTokens, - spanId - ); - - // Add assistant response to message history - const assistantMessage: Message = { - role: 'assistant', - content: assistantContent - }; - this.messages.push(assistantMessage); - - return assistantContent || 'No response received'; - - } catch (error: any) { - const latency = (Date.now() - startTime) / 1000; - - // Capture error event - this.posthogClient.capture({ - distinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - event: '$ai_generation', - properties: { - $ai_trace_id: this.traceId, - $ai_span_id: spanId, - $ai_span_name: 'mastra_chat', - $ai_model: 'gpt-4o-mini', - $ai_provider: 'mastra', - $ai_latency: latency, - $ai_is_error: true, - $ai_error: error.message, - ...this.getPostHogProperties(), - }, - }); - - console.error('Error in Mastra chat:', error); - throw new Error(`Mastra Provider error: ${error.message}`); - } - } -} diff --git a/node/src/providers/openai-chat-streaming.ts b/node/src/providers/openai-chat-streaming.ts deleted file mode 100644 index 3fd6e77..0000000 --- a/node/src/providers/openai-chat-streaming.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { OpenAI as PostHogOpenAI } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { StreamingProvider, Message, Tool } from './base.js'; -import { OPENAI_CHAT_MODEL, OPENAI_VISION_MODEL, OPENAI_EMBEDDING_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_FRIENDLY } from './constants.js'; - -export class OpenAIChatStreamingProvider extends StreamingProvider { - private client: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.client = new PostHogOpenAI({ - apiKey: process.env.OPENAI_API_KEY!, - posthog: posthogClient - }); - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return [ - { - role: 'system', - content: SYSTEM_PROMPT_FRIENDLY - } - ]; - } - - protected getToolDefinitions(): Tool[] { - return [ - { - type: 'function', - function: { - name: 'get_weather', - description: 'Get the current weather for a specific location', - parameters: { - type: 'object', - properties: { - latitude: { - type: 'number', - description: 'The latitude of the location (e.g., 37.7749 for San Francisco)' - }, - longitude: { - type: 'number', - description: 'The longitude of the location (e.g., -122.4194 for San Francisco)' - }, - location_name: { - type: 'string', - description: 'A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')' - } - }, - required: ['latitude', 'longitude', 'location_name'] - } - } - }, - { - type: 'function', - function: { - name: 'tell_joke', - description: 'Tell a joke with a question-style setup and an answer punchline', - parameters: { - type: 'object', - properties: { - setup: { - type: 'string', - description: 'The setup of the joke, usually in question form' - }, - punchline: { - type: 'string', - description: 'The punchline or answer to the joke' - } - }, - required: ['setup', 'punchline'] - } - } - } - ]; - } - - getName(): string { - return 'OpenAI Chat Completions Streaming'; - } - - async embed(text: string, model: string = OPENAI_EMBEDDING_MODEL): Promise { - const response = await this.client.embeddings.create({ - model: model, - input: text - }); - - if (response.data && response.data.length > 0) { - return response.data[0].embedding; - } - return []; - } - - async *chatStream( - userInput: string, - base64Image?: string - ): AsyncGenerator { - let userContent: any; - - if (base64Image) { - userContent = [ - { - type: 'text', - text: userInput - }, - { - type: 'image_url', - image_url: { - url: `data:image/png;base64,${base64Image}` - } - } - ]; - } else { - userContent = userInput; - } - - const userMessage: Message = { - role: 'user', - content: userContent - }; - this.messages.push(userMessage); - - const requestParams = { - model: base64Image ? OPENAI_VISION_MODEL : OPENAI_CHAT_MODEL, - max_tokens: DEFAULT_MAX_TOKENS, - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogProperties: { - $ai_span_name: "openai_chat_completions_streaming", - ...this.getPostHogProperties(), - }, - messages: this.messages, - tools: this.tools, - tool_choice: 'auto', - stream: true, - stream_options: { - include_usage: true - } - }; - - if (this.debugMode) { - this.debugLog("OpenAI Chat Completions Streaming API Request", requestParams); - } - - const stream = await this.client.chat.completions.create(requestParams); - - let accumulatedContent = ''; - const toolCalls: any[] = []; - const toolCallsByIndex: Map = new Map(); - - for await (const chunk of stream) { - // Handle text content - if (chunk.choices?.[0]?.delta?.content) { - const content = chunk.choices[0].delta.content; - accumulatedContent += content; - yield content; - } - - // Handle tool calls - if (chunk.choices?.[0]?.delta?.tool_calls) { - for (const toolCallDelta of chunk.choices[0].delta.tool_calls) { - const index = toolCallDelta.index; - - // Initialize or get existing tool call - if (!toolCallsByIndex.has(index)) { - toolCallsByIndex.set(index, { - id: toolCallDelta.id || '', - type: 'function', - function: { - name: toolCallDelta.function?.name || '', - arguments: '' - } - }); - } - - const toolCall = toolCallsByIndex.get(index)!; - - // Update tool call information - if (toolCallDelta.id) { - toolCall.id = toolCallDelta.id; - } - if (toolCallDelta.function?.name) { - toolCall.function.name = toolCallDelta.function.name; - } - if (toolCallDelta.function?.arguments) { - toolCall.function.arguments += toolCallDelta.function.arguments; - } - } - } - - // Check for finish reason to know when tool calls are complete - if (chunk.choices?.[0]?.finish_reason === 'tool_calls') { - // Convert map to array and execute tools - const completedToolCalls = Array.from(toolCallsByIndex.values()); - - for (const toolCall of completedToolCalls) { - toolCalls.push(toolCall); - - if (toolCall.function.name === 'get_weather') { - try { - const args = JSON.parse(toolCall.function.arguments); - const latitude = args.latitude || 0.0; - const longitude = args.longitude || 0.0; - const locationName = args.location_name; - const weatherResult = await this.getWeather(latitude, longitude, locationName); - const toolResultText = this.formatToolResult('get_weather', weatherResult); - yield '\n\n' + toolResultText; - } catch (e) { - console.error('Error parsing tool arguments:', e); - } - } else if (toolCall.function.name === 'tell_joke') { - try { - const args = JSON.parse(toolCall.function.arguments); - const setup = args.setup || ''; - const punchline = args.punchline || ''; - const jokeResult = this.tellJoke(setup, punchline); - const toolResultText = this.formatToolResult('tell_joke', jokeResult); - yield '\n\n' + toolResultText; - } catch (e) { - console.error('Error parsing tool arguments:', e); - } - } - } - } - } - - // Save assistant message - const assistantMessage: Message = { - role: 'assistant', - content: accumulatedContent || undefined - }; - - if (toolCalls.length > 0) { - assistantMessage.tool_calls = toolCalls; - } - - this.messages.push(assistantMessage); - - // Debug: Log the completed stream response - if (this.debugMode) { - this.debugLog("OpenAI Chat Completions Streaming API Response (completed)", { - accumulatedContent: accumulatedContent, - toolCalls: toolCalls - }); - } - - // Add tool results to messages if any tools were called - for (const toolCall of toolCalls) { - if (toolCall.function.name === 'get_weather') { - try { - const args = JSON.parse(toolCall.function.arguments); - const latitude = args.latitude || 0.0; - const longitude = args.longitude || 0.0; - const locationName = args.location_name; - const weatherResult = await this.getWeather(latitude, longitude, locationName); - - const toolResultMessage: any = { - role: 'tool', - tool_call_id: toolCall.id, - content: weatherResult - }; - this.messages.push(toolResultMessage); - } catch (e) { - console.error('Error adding tool result:', e); - } - } else if (toolCall.function.name === 'tell_joke') { - try { - const args = JSON.parse(toolCall.function.arguments); - const setup = args.setup || ''; - const punchline = args.punchline || ''; - const jokeResult = this.tellJoke(setup, punchline); - - const toolResultMessage: any = { - role: 'tool', - tool_call_id: toolCall.id, - content: jokeResult - }; - this.messages.push(toolResultMessage); - } catch (e) { - console.error('Error adding tool result:', e); - } - } - } - } - - // Non-streaming chat for compatibility - async chat(userInput: string, base64Image?: string): Promise { - const chunks: string[] = []; - for await (const chunk of this.chatStream(userInput, base64Image)) { - chunks.push(chunk); - } - return chunks.join(''); - } -} \ No newline at end of file diff --git a/node/src/providers/openai-chat.ts b/node/src/providers/openai-chat.ts deleted file mode 100644 index 2ae484f..0000000 --- a/node/src/providers/openai-chat.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { OpenAI as PostHogOpenAI } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { BaseProvider, Message, Tool } from './base.js'; -import { - OPENAI_CHAT_MODEL, - OPENAI_VISION_MODEL, - OPENAI_EMBEDDING_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_POSTHOG_DISTINCT_ID, - SYSTEM_PROMPT_FRIENDLY, -} from './constants.js'; - -export class OpenAIChatProvider extends BaseProvider { - private client: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.client = new PostHogOpenAI({ - apiKey: process.env.OPENAI_API_KEY!, - posthog: posthogClient - }); - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return [ - { - role: 'system', - content: SYSTEM_PROMPT_FRIENDLY - } - ]; - } - - protected getToolDefinitions(): Tool[] { - return [ - { - type: 'function', - function: { - name: 'get_weather', - description: 'Get the current weather for a specific location', - parameters: { - type: 'object', - properties: { - latitude: { - type: 'number', - description: 'The latitude of the location (e.g., 37.7749 for San Francisco)' - }, - longitude: { - type: 'number', - description: 'The longitude of the location (e.g., -122.4194 for San Francisco)' - }, - location_name: { - type: 'string', - description: 'A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')' - } - }, - required: ['latitude', 'longitude', 'location_name'] - } - } - }, - { - type: 'function', - function: { - name: 'tell_joke', - description: 'Tell a joke with a question-style setup and an answer punchline', - parameters: { - type: 'object', - properties: { - setup: { - type: 'string', - description: 'The setup of the joke, usually in question form' - }, - punchline: { - type: 'string', - description: 'The punchline or answer to the joke' - } - }, - required: ['setup', 'punchline'] - } - } - } - ]; - } - - getName(): string { - return 'OpenAI Chat Completions'; - } - - async embed(text: string, model: string = OPENAI_EMBEDDING_MODEL): Promise { - const response = await this.client.embeddings.create({ - model: model, - input: text - }); - - if (response.data && response.data.length > 0) { - return response.data[0].embedding; - } - return []; - } - - async chat(userInput: string, base64Image?: string): Promise { - let userContent: any; - - if (base64Image) { - // For image input, create content array with text and image - userContent = [ - { - type: 'text', - text: userInput - }, - { - type: 'image_url', - image_url: { - url: `data:image/png;base64,${base64Image}` - } - } - ]; - } else { - userContent = userInput; - } - - const userMessage: Message = { - role: 'user', - content: userContent - }; - this.messages.push(userMessage); - - const requestParams = { - model: base64Image ? OPENAI_VISION_MODEL : OPENAI_CHAT_MODEL, // Use vision model for images - max_tokens: DEFAULT_MAX_TOKENS, - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogProperties: { - $ai_span_name: "openai_chat_completions", - ...this.getPostHogProperties(), - }, - messages: this.messages, - tools: this.tools, - tool_choice: 'auto' - }; - - const response = await this.client.chat.completions.create(requestParams); - this.debugApiCall("OpenAI Chat Completions", requestParams, response); - - const displayParts: string[] = []; - let assistantContent = ''; - - const choice = response.choices[0]; - const message = choice.message; - - if (message.content) { - assistantContent = message.content; - displayParts.push(message.content); - } - - if (message.tool_calls) { - for (const toolCall of message.tool_calls) { - if (toolCall.function.name === 'get_weather') { - try { - const args = JSON.parse(toolCall.function.arguments); - const latitude = args.latitude || 0.0; - const longitude = args.longitude || 0.0; - const locationName = args.location_name; - const weatherResult = await this.getWeather(latitude, longitude, locationName); - const toolResultText = this.formatToolResult('get_weather', weatherResult); - displayParts.push(toolResultText); - - this.messages.push({ - role: 'assistant', - content: assistantContent, - tool_calls: [ - { - id: toolCall.id, - type: 'function', - function: { - name: toolCall.function.name, - arguments: toolCall.function.arguments - } - } - ] - }); - - this.messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: weatherResult - } as any); - - } catch (e) { - displayParts.push('โŒ Error parsing tool arguments'); - } - } else if (toolCall.function.name === 'tell_joke') { - try { - const args = JSON.parse(toolCall.function.arguments); - const setup = args.setup || ''; - const punchline = args.punchline || ''; - const jokeResult = this.tellJoke(setup, punchline); - const toolResultText = this.formatToolResult('tell_joke', jokeResult); - displayParts.push(toolResultText); - - this.messages.push({ - role: 'assistant', - content: assistantContent, - tool_calls: [ - { - id: toolCall.id, - type: 'function', - function: { - name: toolCall.function.name, - arguments: toolCall.function.arguments - } - } - ] - }); - - this.messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: jokeResult - } as any); - - } catch (e) { - displayParts.push('โŒ Error parsing tool arguments'); - } - } - } - } else { - if (assistantContent) { - const assistantMessage: Message = { - role: 'assistant', - content: assistantContent - }; - this.messages.push(assistantMessage); - } - } - - return displayParts.length > 0 ? displayParts.join('\n\n') : 'No response received'; - } -} \ No newline at end of file diff --git a/node/src/providers/openai-image.ts b/node/src/providers/openai-image.ts deleted file mode 100644 index ff5c31d..0000000 --- a/node/src/providers/openai-image.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { OpenAI as PostHogOpenAI } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { BaseProvider, Tool } from './base.js'; -import { OPENAI_CHAT_MODEL, DEFAULT_POSTHOG_DISTINCT_ID } from './constants.js'; - -export class OpenAIImageProvider extends BaseProvider { - private client: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.client = new PostHogOpenAI({ - apiKey: process.env.OPENAI_API_KEY!, - posthog: posthogClient - }); - } - - protected getToolDefinitions(): Tool[] { - return []; - } - - getName(): string { - return 'OpenAI Responses (Image Generation)'; - } - - async generateImage(prompt: string, model: string = OPENAI_CHAT_MODEL): Promise { - try { - const requestParams = { - model: model, - input: prompt, - tools: [{ type: 'image_generation' }], - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogProperties: this.getPostHogProperties() - }; - - const response = await this.client.responses.create(requestParams); - this.debugApiCall("OpenAI Responses Image Generation", requestParams, response); - - // Extract image data from output array - const imageData = response.output - ?.filter((output: any) => output.type === 'image_generation_call') - .map((output: any) => output.result); - - if (imageData && imageData.length > 0) { - const imageBase64 = imageData[0]; - return `data:image/png;base64,${imageBase64.substring(0, 100)}... (base64 image data, ${imageBase64.length} chars total)`; - } - return ""; - } catch (error: any) { - console.error('Error in OpenAI image generation:', error); - throw new Error(`OpenAI Image Generation error: ${error.message}`); - } - } - - async chat(): Promise { - throw new Error('This provider is for image generation only. Use generateImage() instead.'); - } -} diff --git a/node/src/providers/openai-streaming.ts b/node/src/providers/openai-streaming.ts deleted file mode 100644 index 7ad34af..0000000 --- a/node/src/providers/openai-streaming.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { OpenAI as PostHogOpenAI } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { StreamingProvider, Message, Tool } from './base.js'; -import { OPENAI_CHAT_MODEL, OPENAI_VISION_MODEL, OPENAI_EMBEDDING_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_FRIENDLY } from './constants.js'; - -export class OpenAIStreamingProvider extends StreamingProvider { - private client: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.client = new PostHogOpenAI({ - apiKey: process.env.OPENAI_API_KEY!, - posthog: posthogClient - }); - } - - protected getToolDefinitions(): Tool[] { - return [ - { - type: 'function', - name: 'get_weather', - description: 'Get the current weather for a specific location', - parameters: { - type: 'object', - properties: { - latitude: { - type: 'number', - description: 'The latitude of the location (e.g., 37.7749 for San Francisco)' - }, - longitude: { - type: 'number', - description: 'The longitude of the location (e.g., -122.4194 for San Francisco)' - }, - location_name: { - type: 'string', - description: 'A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')' - } - }, - required: ['latitude', 'longitude', 'location_name'] - } - }, - { - type: 'function', - name: 'tell_joke', - description: 'Tell a joke with a question-style setup and an answer punchline', - parameters: { - type: 'object', - properties: { - setup: { - type: 'string', - description: 'The setup of the joke, usually in question form' - }, - punchline: { - type: 'string', - description: 'The punchline or answer to the joke' - } - }, - required: ['setup', 'punchline'] - } - } - ]; - } - - getName(): string { - return 'OpenAI Responses Streaming'; - } - - async embed(text: string, model: string = OPENAI_EMBEDDING_MODEL): Promise { - const response = await this.client.embeddings.create({ - model: model, - input: text, - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID - }); - - if (response.data && response.data.length > 0) { - return response.data[0].embedding; - } - return []; - } - - async *chatStream( - userInput: string, - base64Image?: string - ): AsyncGenerator { - let userMessage: Message; - - if (base64Image) { - // For image input, create content array with text and image - userMessage = { - role: 'user', - content: [ - { - type: 'input_text', - text: userInput - }, - { - type: 'input_image', - image_url: `data:image/png;base64,${base64Image}` - } - ] as any - }; - } else { - userMessage = { - role: 'user', - content: userInput - }; - } - - this.messages.push(userMessage); - - const requestParams = { - model: base64Image ? OPENAI_VISION_MODEL : OPENAI_CHAT_MODEL, - max_output_tokens: DEFAULT_MAX_TOKENS, - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogProperties: { - $ai_span_name: "openai_responses_streaming", - ...this.getPostHogProperties(), - }, - input: this.messages, - instructions: SYSTEM_PROMPT_FRIENDLY, - tools: this.tools, - stream: true - }; - - if (this.debugMode) { - this.debugLog("OpenAI Responses Streaming API Request", requestParams); - } - - const stream = await this.client.responses.create(requestParams); - - let accumulatedContent = ''; - const finalOutput: any[] = []; - const toolCalls: any[] = []; - - for await (const chunk of stream) { - // Handle different streaming event types - if (chunk.type === 'response.output_text.delta') { - // Text delta streaming - this is the main text streaming event - if (chunk.delta) { - accumulatedContent += chunk.delta; - yield chunk.delta; - } - } else if (chunk.type === 'response.output_item.added') { - // Track when a function call starts - if (chunk.item && chunk.item.type === 'function_call') { - // Store the function name for later use - if (!toolCalls[chunk.output_index]) { - toolCalls[chunk.output_index] = { - name: chunk.item.name || 'get_weather', - arguments: '' - }; - } - } - } else if (chunk.type === 'response.function_call_arguments.done') { - // Function call arguments completed - if (chunk.arguments && chunk.output_index !== undefined) { - // Get the function info we stored earlier - const toolCall = toolCalls[chunk.output_index]; - if (toolCall && toolCall.name === 'get_weather') { - let args: any = {}; - try { - args = JSON.parse(chunk.arguments); - } catch (e) { - args = {}; - } - - const latitude = args.latitude || 0.0; - const longitude = args.longitude || 0.0; - const locationName = args.location_name; - const weatherResult = await this.getWeather(latitude, longitude, locationName); - const toolResultText = this.formatToolResult('get_weather', weatherResult); - yield '\n\n' + toolResultText; - - // Update the arguments - toolCall.arguments = chunk.arguments; - } else if (toolCall && toolCall.name === 'tell_joke') { - let args: any = {}; - try { - args = JSON.parse(chunk.arguments); - } catch (e) { - args = {}; - } - - const setup = args.setup || ''; - const punchline = args.punchline || ''; - const jokeResult = this.tellJoke(setup, punchline); - const toolResultText = this.formatToolResult('tell_joke', jokeResult); - yield '\n\n' + toolResultText; - - // Update the arguments - toolCall.arguments = chunk.arguments; - } - } - } else if (chunk.type === 'response.completed' && chunk.response) { - // Response completed event - only handle content that wasn't already streamed - if (chunk.response.output && chunk.response.output.length > 0) { - for (const outputItem of chunk.response.output) { - // Check if this is a content message that wasn't already streamed - if (outputItem.content && Array.isArray(outputItem.content)) { - // Only add content if we didn't stream it already - for (const contentItem of outputItem.content) { - if (contentItem.text && !accumulatedContent.includes(contentItem.text)) { - accumulatedContent += contentItem.text; - yield contentItem.text; - } - } - } - - // Tool calls are already handled in response.function_call_arguments.done - // So we don't need to handle them here - } - } - } - } - - // Save assistant message with accumulated content or tool results - const assistantContentItems: any[] = []; - - // Add text content if any - if (accumulatedContent) { - assistantContentItems.push({ - type: 'output_text', - text: accumulatedContent - }); - } - - // Add tool results if any - if (toolCalls.length > 0 && toolCalls.some(tc => tc.arguments)) { - for (const toolCall of toolCalls) { - if (toolCall.arguments && toolCall.name === 'get_weather') { - let args: any = {}; - try { - args = JSON.parse(toolCall.arguments); - } catch (e) { - args = {}; - } - const latitude = args.latitude || 0.0; - const longitude = args.longitude || 0.0; - const locationName = args.location_name; - const weatherResult = await this.getWeather(latitude, longitude, locationName); - - // Add tool result as output_text for conversation history - // For client-side history management, add as assistant message with output_text - assistantContentItems.push({ - type: 'output_text', - text: weatherResult - }); - } else if (toolCall.arguments && toolCall.name === 'tell_joke') { - let args: any = {}; - try { - args = JSON.parse(toolCall.arguments); - } catch (e) { - args = {}; - } - const setup = args.setup || ''; - const punchline = args.punchline || ''; - const jokeResult = this.tellJoke(setup, punchline); - - // Add tool result as output_text for conversation history - // For client-side history management, add as assistant message with output_text - assistantContentItems.push({ - type: 'output_text', - text: jokeResult - }); - } - } - } - - // Add to conversation history if there's any content - if (assistantContentItems.length > 0) { - const assistantMessage: Message = { - role: 'assistant', - content: assistantContentItems - }; - this.messages.push(assistantMessage); - } - - // Debug: Log the completed stream response - if (this.debugMode) { - this.debugLog("OpenAI Responses Streaming API Response (completed)", { - accumulatedContent: accumulatedContent, - toolCalls: toolCalls, - finalOutput: finalOutput - }); - } - } - - // Non-streaming chat for compatibility - async chat(userInput: string, base64Image?: string): Promise { - const chunks: string[] = []; - for await (const chunk of this.chatStream(userInput, base64Image)) { - chunks.push(chunk); - } - return chunks.join(''); - } -} \ No newline at end of file diff --git a/node/src/providers/openai-transcription.ts b/node/src/providers/openai-transcription.ts deleted file mode 100644 index a5339b0..0000000 --- a/node/src/providers/openai-transcription.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { OpenAI as PostHogOpenAI } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { BaseProvider } from './base.js'; -import { DEFAULT_POSTHOG_DISTINCT_ID } from './constants.js'; -import * as fs from 'fs'; - -export class OpenAITranscriptionProvider extends BaseProvider { - private client: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.client = new PostHogOpenAI({ - apiKey: process.env.OPENAI_API_KEY!, - posthog: posthogClient - }); - } - - protected getToolDefinitions(): any[] { - // Transcription doesn't use tools - return []; - } - - getName(): string { - return 'OpenAI Transcriptions'; - } - - /** - * Transcribe audio from a file path or buffer - * @param audioPath Path to the audio file (supports mp3, mp4, mpeg, mpga, m4a, wav, webm) - * @param model Model to use (default: whisper-1) - * @param language Optional language code (e.g., 'en') - * @param prompt Optional prompt to guide transcription - * @returns Transcription text - */ - async transcribe( - audioPath: string, - model: string = 'whisper-1', - language?: string, - prompt?: string - ): Promise { - try { - const audioFile = fs.createReadStream(audioPath); - - const transcriptionParams: any = { - file: audioFile, - model: model, - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogProperties: { - $ai_span_name: "openai_transcription", - ...this.getPostHogProperties(), - } - }; - - // Add optional parameters - if (language) { - transcriptionParams.language = language; - } - if (prompt) { - transcriptionParams.prompt = prompt; - } - - const transcription = await this.client.audio.transcriptions.create(transcriptionParams); - - this.debugApiCall("OpenAI Transcription", transcriptionParams, transcription); - - return transcription.text || 'No transcription generated'; - } catch (error: any) { - console.error('Transcription error:', error); - throw error; - } - } - - /** - * Transcribe audio with verbose response format - * @param audioPath Path to the audio file - * @param model Model to use (default: whisper-1) - * @param language Optional language code - * @param prompt Optional prompt to guide transcription - * @returns Verbose transcription with segments, language, duration - */ - async transcribeVerbose( - audioPath: string, - model: string = 'whisper-1', - language?: string, - prompt?: string - ): Promise { - try { - const audioFile = fs.createReadStream(audioPath); - - const transcriptionParams: any = { - file: audioFile, - model: model, - response_format: 'verbose_json', - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogProperties: { - $ai_span_name: "openai_transcription_verbose", - ...this.getPostHogProperties(), - } - }; - - // Add optional parameters - if (language) { - transcriptionParams.language = language; - } - if (prompt) { - transcriptionParams.prompt = prompt; - } - - const transcription = await this.client.audio.transcriptions.create(transcriptionParams); - - this.debugApiCall("OpenAI Transcription (Verbose)", transcriptionParams, transcription); - - return transcription; - } catch (error: any) { - console.error('Transcription error:', error); - throw error; - } - } - - // Override chat method (not used for transcriptions) - async chat(userInput: string): Promise { - return 'This provider is for transcriptions only. Use transcribe() method instead.'; - } -} diff --git a/node/src/providers/openai.ts b/node/src/providers/openai.ts deleted file mode 100644 index d1d9b81..0000000 --- a/node/src/providers/openai.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { OpenAI as PostHogOpenAI } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { BaseProvider, Message, Tool } from './base.js'; -import { OPENAI_CHAT_MODEL, OPENAI_VISION_MODEL, OPENAI_EMBEDDING_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_FRIENDLY } from './constants.js'; - -export class OpenAIProvider extends BaseProvider { - private client: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.client = new PostHogOpenAI({ - apiKey: process.env.OPENAI_API_KEY!, - posthog: posthogClient - }); - } - - protected getToolDefinitions(): Tool[] { - return [ - { - type: 'function', - name: 'get_weather', - description: 'Get the current weather for a specific location', - parameters: { - type: 'object', - properties: { - latitude: { - type: 'number', - description: 'The latitude of the location (e.g., 37.7749 for San Francisco)' - }, - longitude: { - type: 'number', - description: 'The longitude of the location (e.g., -122.4194 for San Francisco)' - }, - location_name: { - type: 'string', - description: 'A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')' - } - }, - required: ['latitude', 'longitude', 'location_name'] - } - }, - { - type: 'function', - name: 'tell_joke', - description: 'Tell a joke with a question-style setup and an answer punchline', - parameters: { - type: 'object', - properties: { - setup: { - type: 'string', - description: 'The setup of the joke, usually in question form' - }, - punchline: { - type: 'string', - description: 'The punchline or answer to the joke' - } - }, - required: ['setup', 'punchline'] - } - } - ]; - } - - getName(): string { - return 'OpenAI Responses'; - } - - async embed(text: string, model: string = OPENAI_EMBEDDING_MODEL): Promise { - const response = await this.client.embeddings.create({ - model: model, - input: text, - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogProperties: this.getPostHogProperties() - }); - - if (response.data && response.data.length > 0) { - return response.data[0].embedding; - } - return []; - } - - async chat(userInput: string, base64Image?: string): Promise { - let userMessage: Message; - - if (base64Image) { - // For image input, create content array with text and image - userMessage = { - role: 'user', - content: [ - { - type: 'input_text', - text: userInput - }, - { - type: 'input_image', - image_url: `data:image/png;base64,${base64Image}` - } - ] as any - }; - } else { - userMessage = { - role: 'user', - content: userInput - }; - } - - this.messages.push(userMessage); - - const requestParams = { - model: base64Image ? OPENAI_VISION_MODEL : OPENAI_CHAT_MODEL, - max_output_tokens: DEFAULT_MAX_TOKENS, - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogProperties: this.getPostHogProperties(), - input: this.messages, - instructions: SYSTEM_PROMPT_FRIENDLY, - tools: this.tools - }; - - const message = await this.client.responses.create(requestParams); - this.debugApiCall("OpenAI Responses", requestParams, message); - - const displayParts: string[] = []; - const assistantContentItems: any[] = []; - let toolCallForHistory: any = null; - - if (message.output) { - for (const outputItem of message.output) { - // Handle message content (text) - if (outputItem.content) { - for (const contentItem of outputItem.content) { - if (contentItem.text) { - displayParts.push(contentItem.text); - // Add to conversation history as output_text - assistantContentItems.push({ - type: 'output_text', - text: contentItem.text - }); - } - } - } - - // Handle tool calls (separate output items in Responses API) - if (outputItem.name === 'get_weather') { - // Get the tool call details from the response - const callId = outputItem.call_id || `call_${outputItem.name}`; - const toolArguments = outputItem.arguments || '{}'; - - // Parse arguments to execute the tool - let args: any = {}; - try { - args = JSON.parse(toolArguments); - } catch (e) { - args = {}; - } - - const latitude = args.latitude || 0.0; - const longitude = args.longitude || 0.0; - const locationName = args.location_name; - const weatherResult = await this.getWeather(latitude, longitude, locationName); - const toolResultText = this.formatToolResult('get_weather', weatherResult); - displayParts.push(toolResultText); - - // Store tool call info to add to conversation history - toolCallForHistory = { - id: callId, - name: outputItem.name, - result: weatherResult - }; - } else if (outputItem.name === 'tell_joke') { - // Get the tool call details from the response - const callId = outputItem.call_id || `call_${outputItem.name}`; - const toolArguments = outputItem.arguments || '{}'; - - // Parse arguments to execute the tool - let args: any = {}; - try { - args = JSON.parse(toolArguments); - } catch (e) { - args = {}; - } - - const setup = args.setup || ''; - const punchline = args.punchline || ''; - const jokeResult = this.tellJoke(setup, punchline); - const toolResultText = this.formatToolResult('tell_joke', jokeResult); - displayParts.push(toolResultText); - - // Store tool call info to add to conversation history - toolCallForHistory = { - id: callId, - name: outputItem.name, - result: jokeResult - }; - } - } - } - - // Add messages to conversation history - // For client-side history management, add tool results as assistant messages with output_text - if (toolCallForHistory) { - assistantContentItems.push({ - type: 'output_text', - text: toolCallForHistory.result - }); - } - - if (assistantContentItems.length > 0) { - const assistantMessage: Message = { - role: 'assistant', - content: assistantContentItems - }; - this.messages.push(assistantMessage); - } - - return displayParts.length > 0 ? displayParts.join('\n\n') : 'No response received'; - } -} \ No newline at end of file diff --git a/node/src/providers/vercel-ai-anthropic-streaming.ts b/node/src/providers/vercel-ai-anthropic-streaming.ts deleted file mode 100644 index ae212b7..0000000 --- a/node/src/providers/vercel-ai-anthropic-streaming.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { withTracing } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { streamText, tool } from 'ai'; -import { createAnthropic } from '@ai-sdk/anthropic'; -import { z } from 'zod'; -import { StreamingProvider, Message, Tool } from './base.js'; -import { ANTHROPIC_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_FRIENDLY } from './constants.js'; - -export class VercelAIAnthropicStreamingProvider extends StreamingProvider { - private anthropicClient: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.anthropicClient = createAnthropic({ - apiKey: process.env.ANTHROPIC_API_KEY! - }); - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return [ - { - role: 'system', - content: SYSTEM_PROMPT_FRIENDLY - } - ]; - } - - protected getToolDefinitions(): Tool[] { - // Vercel AI SDK doesn't use this, but we need to implement it - return []; - } - - getName(): string { - return 'Vercel AI SDK Streaming (Anthropic)'; - } - - async *chatStream( - userInput: string, - base64Image?: string - ): AsyncGenerator { - let userContent: any; - - if (base64Image) { - // For image input, create content array with text and image - userContent = [ - { type: 'text', text: userInput }, - { - type: 'image', - image: `data:image/png;base64,${base64Image}` - } - ]; - } else { - userContent = userInput; - } - - const userMessage: Message = { - role: 'user', - content: userContent - }; - this.messages.push(userMessage); - - const model = withTracing(this.anthropicClient(ANTHROPIC_MODEL), this.posthogClient, { - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogPrivacyMode: false, - posthogProperties: { - $ai_span_name: "vercel_ai_stream_text_anthropic", - ...this.getPostHogProperties(), - }, - }); - - try { - const requestParams = { - model: model, - messages: this.messages as any, - maxOutputTokens: DEFAULT_MAX_TOKENS, - tools: { - get_weather: tool({ - description: 'Get the current weather for a specific location', - inputSchema: z.object({ - latitude: z.number().describe('The latitude of the location (e.g., 37.7749 for San Francisco)'), - longitude: z.number().describe('The longitude of the location (e.g., -122.4194 for San Francisco)'), - location_name: z.string().describe('A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')') - }), - execute: async ({ latitude, longitude, location_name }: { latitude: number; longitude: number; location_name: string }) => { - return this.getWeather(latitude, longitude, location_name); - } - }), - tell_joke: tool({ - description: 'Tell a joke with a question-style setup and an answer punchline', - inputSchema: z.object({ - setup: z.string().describe('The setup of the joke, usually in question form'), - punchline: z.string().describe('The punchline or answer to the joke') - }), - execute: async ({ setup, punchline }: { setup: string; punchline: string }) => { - return this.tellJoke(setup, punchline); - } - }) - } - }; - - if (this.debugMode) { - this.debugLog("Vercel AI SDK Streaming (Anthropic) API Request", requestParams); - } - - const result = await streamText(requestParams); - - let accumulatedContent = ''; - const toolCalls: any[] = []; - let currentToolCall: any = null; - - for await (const part of result.fullStream) { - // Handle text delta events - if (part.type === 'text-delta') { - const delta = (part as any).text || ''; - accumulatedContent += delta; - yield delta; - } - - // Handle tool call start - if (part.type === 'tool-call') { - currentToolCall = { - toolName: (part as any).toolName, - args: (part as any).input || {} - }; - toolCalls.push(currentToolCall); - } - - // Handle tool result - if (part.type === 'tool-result') { - const toolResult = part as any; - if (toolResult.toolName === 'get_weather') { - // The tool was already executed via the execute function - // Format and yield the result - const weatherResult = toolResult.output as string; - const toolResultText = this.formatToolResult('get_weather', weatherResult); - yield '\n\n' + toolResultText; - } else if (toolResult.toolName === 'tell_joke') { - // The tool was already executed via the execute function - // Format and yield the result - const jokeResult = toolResult.output as string; - const toolResultText = this.formatToolResult('tell_joke', jokeResult); - yield '\n\n' + toolResultText; - } - } - - // Handle finish event for usage tracking - if (part.type === 'finish') { - // Usage information is available in part.usage if needed - // but we're already tracking this via withTracing - } - } - - // Save assistant message with accumulated content - if (accumulatedContent) { - const assistantMessage: Message = { - role: 'assistant', - content: accumulatedContent - }; - this.messages.push(assistantMessage); - } else if (toolCalls.length > 0) { - // If there was a tool call but no text content, save the tool result as assistant message - let toolResultsText = ''; - for (const toolCall of toolCalls) { - if (toolCall.toolName === 'get_weather') { - const latitude = toolCall.args.latitude || 0.0; - const longitude = toolCall.args.longitude || 0.0; - const locationName = toolCall.args.location_name; - const weatherResult = await this.getWeather(latitude, longitude, locationName); - toolResultsText += this.formatToolResult('get_weather', weatherResult); - } else if (toolCall.toolName === 'tell_joke') { - const setup = toolCall.args.setup || ''; - const punchline = toolCall.args.punchline || ''; - const jokeResult = this.tellJoke(setup, punchline); - toolResultsText += this.formatToolResult('tell_joke', jokeResult); - } - } - - if (toolResultsText) { - const assistantMessage: Message = { - role: 'assistant', - content: toolResultsText - }; - this.messages.push(assistantMessage); - } - } - - // Debug: Log the completed stream response - if (this.debugMode) { - this.debugLog("Vercel AI SDK Streaming (Anthropic) API Response (completed)", { - accumulatedContent: accumulatedContent, - toolCalls: toolCalls - }); - } - } catch (error: any) { - console.error('Error in Vercel AI Anthropic streaming chat:', error); - throw new Error(`Vercel AI Anthropic Streaming Provider error: ${error.message}`); - } - } - - // Non-streaming chat for compatibility - async chat(userInput: string, base64Image?: string): Promise { - const chunks: string[] = []; - for await (const chunk of this.chatStream(userInput, base64Image)) { - chunks.push(chunk); - } - return chunks.join(''); - } -} diff --git a/node/src/providers/vercel-ai-anthropic.ts b/node/src/providers/vercel-ai-anthropic.ts deleted file mode 100644 index 4fd8d07..0000000 --- a/node/src/providers/vercel-ai-anthropic.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { withTracing } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { generateText, tool } from 'ai'; -import { createAnthropic } from '@ai-sdk/anthropic'; -import { z } from 'zod'; -import { BaseProvider, Message, Tool } from './base.js'; -import { ANTHROPIC_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_FRIENDLY } from './constants.js'; - -export class VercelAIAnthropicProvider extends BaseProvider { - private anthropicClient: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.anthropicClient = createAnthropic({ - apiKey: process.env.ANTHROPIC_API_KEY! - }); - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return [ - { - role: 'system', - content: SYSTEM_PROMPT_FRIENDLY - } - ]; - } - - protected getToolDefinitions(): Tool[] { - // Vercel AI SDK doesn't use this, but we need to implement it - return []; - } - - getName(): string { - return 'Vercel AI SDK (Anthropic)'; - } - - async chat(userInput: string, base64Image?: string): Promise { - let userContent: any; - - if (base64Image) { - // For image input, create content array with text and image - userContent = [ - { type: 'text', text: userInput }, - { - type: 'image', - image: `data:image/png;base64,${base64Image}` - } - ]; - } else { - userContent = userInput; - } - - const userMessage: Message = { - role: 'user', - content: userContent - }; - this.messages.push(userMessage); - - const displayParts: string[] = []; - - try { - const model = withTracing(this.anthropicClient(ANTHROPIC_MODEL), this.posthogClient, { - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogPrivacyMode: false, - posthogProperties: { - $ai_span_name: "vercel_ai_generate_text_anthropic", - ...this.getPostHogProperties(), - }, - }); - - const requestParams = { - model: model, - messages: this.messages as any, - maxOutputTokens: DEFAULT_MAX_TOKENS, - tools: { - get_weather: tool({ - description: 'Get the current weather for a specific location', - inputSchema: z.object({ - latitude: z.number().describe('The latitude of the location (e.g., 37.7749 for San Francisco)'), - longitude: z.number().describe('The longitude of the location (e.g., -122.4194 for San Francisco)'), - location_name: z.string().describe('A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')') - }), - execute: async ({ latitude, longitude, location_name }: { latitude: number; longitude: number; location_name: string }) => { - return this.getWeather(latitude, longitude, location_name); - } - }), - tell_joke: tool({ - description: 'Tell a joke with a question-style setup and an answer punchline', - inputSchema: z.object({ - setup: z.string().describe('The setup of the joke, usually in question form'), - punchline: z.string().describe('The punchline or answer to the joke') - }), - execute: async ({ setup, punchline }: { setup: string; punchline: string }) => { - return this.tellJoke(setup, punchline); - } - }) - } - }; - - const { text, toolResults } = await generateText(requestParams); - this.debugApiCall("Vercel AI SDK (Anthropic)", requestParams, { text, toolResults }); - - if (text) { - displayParts.push(text); - const assistantMessage: Message = { - role: 'assistant', - content: text - }; - this.messages.push(assistantMessage); - } - - if (toolResults && toolResults.length > 0) { - for (const result of toolResults) { - if (result.toolName === 'get_weather' && 'output' in result) { - const toolResultText = this.formatToolResult('get_weather', result.output as string); - displayParts.push(toolResultText); - - // Add tool result to message history - const toolMessage: Message = { - role: 'assistant', - content: toolResultText - }; - this.messages.push(toolMessage); - } else if (result.toolName === 'tell_joke' && 'output' in result) { - const toolResultText = this.formatToolResult('tell_joke', result.output as string); - displayParts.push(toolResultText); - - // Add tool result to message history - const toolMessage: Message = { - role: 'assistant', - content: toolResultText - }; - this.messages.push(toolMessage); - } - } - } - - return displayParts.length > 0 ? displayParts.join('\n\n') : 'No response received'; - } catch (error: any) { - console.error('Error in Vercel AI Anthropic chat:', error); - throw new Error(`Vercel AI Anthropic Provider error: ${error.message}`); - } - } -} diff --git a/node/src/providers/vercel-ai-gateway-anthropic-streaming.ts b/node/src/providers/vercel-ai-gateway-anthropic-streaming.ts deleted file mode 100644 index b8af66e..0000000 --- a/node/src/providers/vercel-ai-gateway-anthropic-streaming.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { withTracing } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { createGateway, streamText, tool } from 'ai'; -import { z } from 'zod'; -import { StreamingProvider, Message, Tool } from './base.js'; -import { GATEWAY_ANTHROPIC_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_CACHEABLE } from './constants.js'; - -export class VercelAIGatewayAnthropicStreamingProvider extends StreamingProvider { - private gatewayClient: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.gatewayClient = createGateway({ - apiKey: process.env.AI_GATEWAY_API_KEY!, - }); - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return [ - { - role: 'system', - content: SYSTEM_PROMPT_CACHEABLE, - // Enable Anthropic prompt caching on the system prompt. - // The system prompt is >1024 tokens so it qualifies for caching. - providerOptions: { - anthropic: { - cacheControl: { type: 'ephemeral' } - } - } - } as any - ]; - } - - protected getToolDefinitions(): Tool[] { - return []; - } - - getName(): string { - return 'Vercel AI Gateway Streaming (Anthropic)'; - } - - async *chatStream( - userInput: string, - base64Image?: string - ): AsyncGenerator { - let userContent: any; - - if (base64Image) { - userContent = [ - { type: 'text', text: userInput }, - { - type: 'image', - image: `data:image/png;base64,${base64Image}` - } - ]; - } else { - userContent = userInput; - } - - const userMessage: Message = { - role: 'user', - content: userContent - }; - this.messages.push(userMessage); - - const model = withTracing(this.gatewayClient(GATEWAY_ANTHROPIC_MODEL), this.posthogClient, { - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogPrivacyMode: false, - posthogProperties: { - $ai_span_name: "vercel_ai_gateway_stream_text_anthropic", - ...this.getPostHogProperties(), - }, - }); - - try { - const requestParams = { - model: model, - messages: this.messages as any, - maxOutputTokens: DEFAULT_MAX_TOKENS, - tools: { - get_weather: tool({ - description: 'Get the current weather for a specific location', - inputSchema: z.object({ - latitude: z.number().describe('The latitude of the location (e.g., 37.7749 for San Francisco)'), - longitude: z.number().describe('The longitude of the location (e.g., -122.4194 for San Francisco)'), - location_name: z.string().describe('A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')') - }), - execute: async ({ latitude, longitude, location_name }: { latitude: number; longitude: number; location_name: string }) => { - return this.getWeather(latitude, longitude, location_name); - } - }), - tell_joke: tool({ - description: 'Tell a joke with a question-style setup and an answer punchline', - inputSchema: z.object({ - setup: z.string().describe('The setup of the joke, usually in question form'), - punchline: z.string().describe('The punchline or answer to the joke') - }), - execute: async ({ setup, punchline }: { setup: string; punchline: string }) => { - return this.tellJoke(setup, punchline); - } - }) - } - }; - - if (this.debugMode) { - this.debugLog("Vercel AI Gateway Streaming (Anthropic) API Request", requestParams); - } - - const result = await streamText(requestParams); - - let accumulatedContent = ''; - const toolCalls: any[] = []; - let currentToolCall: any = null; - - for await (const part of result.fullStream) { - if (part.type === 'text-delta') { - const delta = (part as any).text || ''; - accumulatedContent += delta; - yield delta; - } - - if (part.type === 'tool-call') { - currentToolCall = { - toolName: (part as any).toolName, - args: (part as any).input || {} - }; - toolCalls.push(currentToolCall); - } - - if (part.type === 'tool-result') { - const toolResult = part as any; - if (toolResult.toolName === 'get_weather') { - const weatherResult = toolResult.output as string; - const toolResultText = this.formatToolResult('get_weather', weatherResult); - yield '\n\n' + toolResultText; - } else if (toolResult.toolName === 'tell_joke') { - const jokeResult = toolResult.output as string; - const toolResultText = this.formatToolResult('tell_joke', jokeResult); - yield '\n\n' + toolResultText; - } - } - } - - if (accumulatedContent) { - const assistantMessage: Message = { - role: 'assistant', - content: accumulatedContent - }; - this.messages.push(assistantMessage); - } else if (toolCalls.length > 0) { - let toolResultsText = ''; - for (const tc of toolCalls) { - if (tc.toolName === 'get_weather') { - const latitude = tc.args.latitude || 0.0; - const longitude = tc.args.longitude || 0.0; - const locationName = tc.args.location_name; - const weatherResult = await this.getWeather(latitude, longitude, locationName); - toolResultsText += this.formatToolResult('get_weather', weatherResult); - } else if (tc.toolName === 'tell_joke') { - const setup = tc.args.setup || ''; - const punchline = tc.args.punchline || ''; - const jokeResult = this.tellJoke(setup, punchline); - toolResultsText += this.formatToolResult('tell_joke', jokeResult); - } - } - - if (toolResultsText) { - const assistantMessage: Message = { - role: 'assistant', - content: toolResultsText - }; - this.messages.push(assistantMessage); - } - } - - if (this.debugMode) { - this.debugLog("Vercel AI Gateway Streaming (Anthropic) API Response (completed)", { - accumulatedContent: accumulatedContent, - toolCalls: toolCalls - }); - } - } catch (error: any) { - console.error('Error in Vercel AI Gateway Anthropic streaming chat:', error); - throw new Error(`Vercel AI Gateway Anthropic Streaming Provider error: ${error.message}`); - } - } - - async chat(userInput: string, base64Image?: string): Promise { - const chunks: string[] = []; - for await (const chunk of this.chatStream(userInput, base64Image)) { - chunks.push(chunk); - } - return chunks.join(''); - } -} diff --git a/node/src/providers/vercel-ai-gateway-anthropic.ts b/node/src/providers/vercel-ai-gateway-anthropic.ts deleted file mode 100644 index 47b1898..0000000 --- a/node/src/providers/vercel-ai-gateway-anthropic.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { withTracing } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { createGateway, generateText, tool } from 'ai'; -import { z } from 'zod'; -import { BaseProvider, Message, Tool } from './base.js'; -import { GATEWAY_ANTHROPIC_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_CACHEABLE } from './constants.js'; - -export class VercelAIGatewayAnthropicProvider extends BaseProvider { - private gatewayClient: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.gatewayClient = createGateway({ - apiKey: process.env.AI_GATEWAY_API_KEY!, - }); - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return [ - { - role: 'system', - content: SYSTEM_PROMPT_CACHEABLE, - // Enable Anthropic prompt caching on the system prompt. - // The system prompt is >1024 tokens so it qualifies for caching. - providerOptions: { - anthropic: { - cacheControl: { type: 'ephemeral' } - } - } - } as any - ]; - } - - protected getToolDefinitions(): Tool[] { - return []; - } - - getName(): string { - return 'Vercel AI Gateway (Anthropic)'; - } - - async chat(userInput: string, base64Image?: string): Promise { - let userContent: any; - - if (base64Image) { - userContent = [ - { type: 'text', text: userInput }, - { - type: 'image', - image: `data:image/png;base64,${base64Image}` - } - ]; - } else { - userContent = userInput; - } - - const userMessage: Message = { - role: 'user', - content: userContent - }; - this.messages.push(userMessage); - - const displayParts: string[] = []; - - try { - const model = withTracing(this.gatewayClient(GATEWAY_ANTHROPIC_MODEL), this.posthogClient, { - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogPrivacyMode: false, - posthogProperties: { - $ai_span_name: "vercel_ai_gateway_generate_text_anthropic", - ...this.getPostHogProperties(), - }, - }); - - const requestParams = { - model: model, - messages: this.messages as any, - maxOutputTokens: DEFAULT_MAX_TOKENS, - tools: { - get_weather: tool({ - description: 'Get the current weather for a specific location', - inputSchema: z.object({ - latitude: z.number().describe('The latitude of the location (e.g., 37.7749 for San Francisco)'), - longitude: z.number().describe('The longitude of the location (e.g., -122.4194 for San Francisco)'), - location_name: z.string().describe('A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')') - }), - execute: async ({ latitude, longitude, location_name }: { latitude: number; longitude: number; location_name: string }) => { - return this.getWeather(latitude, longitude, location_name); - } - }), - tell_joke: tool({ - description: 'Tell a joke with a question-style setup and an answer punchline', - inputSchema: z.object({ - setup: z.string().describe('The setup of the joke, usually in question form'), - punchline: z.string().describe('The punchline or answer to the joke') - }), - execute: async ({ setup, punchline }: { setup: string; punchline: string }) => { - return this.tellJoke(setup, punchline); - } - }) - } - }; - - const { text, toolResults } = await generateText(requestParams); - this.debugApiCall("Vercel AI Gateway (Anthropic)", requestParams, { text, toolResults }); - - if (text) { - displayParts.push(text); - const assistantMessage: Message = { - role: 'assistant', - content: text - }; - this.messages.push(assistantMessage); - } - - if (toolResults && toolResults.length > 0) { - for (const result of toolResults) { - if (result.toolName === 'get_weather' && 'output' in result) { - const toolResultText = this.formatToolResult('get_weather', result.output as string); - displayParts.push(toolResultText); - - const toolMessage: Message = { - role: 'assistant', - content: toolResultText - }; - this.messages.push(toolMessage); - } else if (result.toolName === 'tell_joke' && 'output' in result) { - const toolResultText = this.formatToolResult('tell_joke', result.output as string); - displayParts.push(toolResultText); - - const toolMessage: Message = { - role: 'assistant', - content: toolResultText - }; - this.messages.push(toolMessage); - } - } - } - - return displayParts.length > 0 ? displayParts.join('\n\n') : 'No response received'; - } catch (error: any) { - console.error('Error in Vercel AI Gateway Anthropic chat:', error); - throw new Error(`Vercel AI Gateway Anthropic Provider error: ${error.message}`); - } - } -} diff --git a/node/src/providers/vercel-ai-google-streaming.ts b/node/src/providers/vercel-ai-google-streaming.ts deleted file mode 100644 index a3c7c03..0000000 --- a/node/src/providers/vercel-ai-google-streaming.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { withTracing } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { streamText } from 'ai'; -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { z } from 'zod'; -import { StreamingProvider, Message, Tool } from './base.js'; -import { GEMINI_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_FRIENDLY } from './constants.js'; - -export class VercelAIGoogleStreamingProvider extends StreamingProvider { - private googleClient: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.googleClient = createGoogleGenerativeAI({ - apiKey: process.env.GEMINI_API_KEY! - }); - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return [ - { - role: 'system', - content: SYSTEM_PROMPT_FRIENDLY - } - ]; - } - - protected getToolDefinitions(): Tool[] { - // Vercel AI SDK doesn't use this, but we need to implement it - return []; - } - - getName(): string { - return 'Vercel AI SDK Streaming (Google)'; - } - - async *chatStream( - userInput: string, - base64Image?: string - ): AsyncGenerator { - let userContent: any; - - if (base64Image) { - // For image input, create content array with text and image - userContent = [ - { type: 'text', text: userInput }, - { - type: 'image', - image: `data:image/png;base64,${base64Image}` - } - ]; - } else { - userContent = userInput; - } - - const userMessage: Message = { - role: 'user', - content: userContent - }; - this.messages.push(userMessage); - - const model = withTracing(this.googleClient(GEMINI_MODEL), this.posthogClient, { - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogPrivacyMode: false, - posthogProperties: { - $ai_span_name: "vercel_ai_stream_text_google", - ...this.getPostHogProperties(), - }, - }); - - try { - const requestParams = { - model: model, - messages: this.messages as any, - maxOutputTokens: DEFAULT_MAX_TOKENS, - tools: { - get_weather: { - description: 'Get the current weather for a specific location', - inputSchema: z.object({ - latitude: z.number().describe('The latitude of the location (e.g., 37.7749 for San Francisco)'), - longitude: z.number().describe('The longitude of the location (e.g., -122.4194 for San Francisco)'), - location_name: z.string().describe('A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')') - }), - execute: async ({ latitude, longitude, location_name }: { latitude: number; longitude: number; location_name: string }) => { - return this.getWeather(latitude, longitude, location_name); - } - }, - tell_joke: { - description: 'Tell a joke with a question-style setup and an answer punchline', - inputSchema: z.object({ - setup: z.string().describe('The setup of the joke, usually in question form'), - punchline: z.string().describe('The punchline or answer to the joke') - }), - execute: async ({ setup, punchline }: { setup: string; punchline: string }) => { - return this.tellJoke(setup, punchline); - } - } - } - }; - - if (this.debugMode) { - this.debugLog("Vercel AI SDK Streaming (Google) API Request", requestParams); - } - - const result = await streamText(requestParams); - - let accumulatedContent = ''; - const toolCalls: any[] = []; - - for await (const part of result.fullStream) { - // Handle text delta events - if (part.type === 'text-delta') { - const delta = (part as any).text || ''; - accumulatedContent += delta; - yield delta; - } - - // Handle tool call start - if (part.type === 'tool-call') { - const toolCall = { - toolName: (part as any).toolName, - args: (part as any).input || {} - }; - toolCalls.push(toolCall); - } - - // Handle tool result - if (part.type === 'tool-result') { - const toolResult = part as any; - if (toolResult.toolName === 'get_weather') { - const weatherResult = toolResult.output as string; - const toolResultText = this.formatToolResult('get_weather', weatherResult); - yield '\n\n' + toolResultText; - } else if (toolResult.toolName === 'tell_joke') { - const jokeResult = toolResult.output as string; - const toolResultText = this.formatToolResult('tell_joke', jokeResult); - yield '\n\n' + toolResultText; - } - } - } - - // Save assistant message with accumulated content - if (accumulatedContent) { - const assistantMessage: Message = { - role: 'assistant', - content: accumulatedContent - }; - this.messages.push(assistantMessage); - } else if (toolCalls.length > 0) { - // If there was a tool call but no text content, save the tool result as assistant message - let toolResultsText = ''; - for (const toolCall of toolCalls) { - if (toolCall.toolName === 'get_weather') { - const latitude = toolCall.args.latitude || 0.0; - const longitude = toolCall.args.longitude || 0.0; - const locationName = toolCall.args.location_name; - const weatherResult = await this.getWeather(latitude, longitude, locationName); - toolResultsText += this.formatToolResult('get_weather', weatherResult); - } else if (toolCall.toolName === 'tell_joke') { - const setup = toolCall.args.setup || ''; - const punchline = toolCall.args.punchline || ''; - const jokeResult = this.tellJoke(setup, punchline); - toolResultsText += this.formatToolResult('tell_joke', jokeResult); - } - } - - if (toolResultsText) { - const assistantMessage: Message = { - role: 'assistant', - content: toolResultsText - }; - this.messages.push(assistantMessage); - } - } - - // Debug: Log the completed stream response - if (this.debugMode) { - this.debugLog("Vercel AI SDK Streaming (Google) API Response (completed)", { - accumulatedContent: accumulatedContent, - toolCalls: toolCalls - }); - } - } catch (error: any) { - console.error('Error in Vercel AI Google streaming chat:', error); - throw new Error(`Vercel AI Google Streaming Provider error: ${error.message}`); - } - } - - // Non-streaming chat for compatibility - async chat(userInput: string, base64Image?: string): Promise { - const chunks: string[] = []; - for await (const chunk of this.chatStream(userInput, base64Image)) { - chunks.push(chunk); - } - return chunks.join(''); - } -} diff --git a/node/src/providers/vercel-ai-google.ts b/node/src/providers/vercel-ai-google.ts deleted file mode 100644 index 6091724..0000000 --- a/node/src/providers/vercel-ai-google.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { withTracing } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { generateText } from 'ai'; -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { z } from 'zod'; -import { BaseProvider, Message, Tool } from './base.js'; -import { GEMINI_MODEL, GEMINI_IMAGE_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_FRIENDLY } from './constants.js'; - -export class VercelAIGoogleProvider extends BaseProvider { - private googleClient: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.googleClient = createGoogleGenerativeAI({ - apiKey: process.env.GEMINI_API_KEY! - }); - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return [ - { - role: 'system', - content: SYSTEM_PROMPT_FRIENDLY - } - ]; - } - - protected getToolDefinitions(): Tool[] { - // Vercel AI SDK doesn't use this, but we need to implement it - return []; - } - - getName(): string { - return 'Vercel AI SDK (Google)'; - } - - private logTokenUsageByModality(result: any): void { - if (!this.debugMode) return; - - try { - // Extract from the raw response body - const usageMetadata = result?.steps?.[0]?.response?.body?.usageMetadata; - if (!usageMetadata) { - console.log("\n๐Ÿ“Š Token Usage: No modality breakdown available\n"); - return; - } - - console.log("\n" + "โ”€".repeat(60)); - console.log("๐Ÿ“Š TOKEN USAGE BY MODALITY"); - console.log("โ”€".repeat(60)); - - // Input tokens breakdown - const promptDetails = usageMetadata.promptTokensDetails || []; - console.log("\n INPUT TOKENS:"); - if (promptDetails.length > 0) { - for (const detail of promptDetails) { - console.log(` ${detail.modality}: ${detail.tokenCount} tokens`); - } - } else { - console.log(` Total: ${usageMetadata.promptTokenCount || 0} tokens`); - } - - // Output tokens breakdown - const candidatesDetails = usageMetadata.candidatesTokensDetails || []; - console.log("\n OUTPUT TOKENS:"); - if (candidatesDetails.length > 0) { - for (const detail of candidatesDetails) { - console.log(` ${detail.modality}: ${detail.tokenCount} tokens`); - } - } else { - console.log(` Total: ${usageMetadata.candidatesTokenCount || 0} tokens`); - } - - console.log("\n TOTAL: " + (usageMetadata.totalTokenCount || 0) + " tokens"); - console.log("โ”€".repeat(60) + "\n"); - } catch (e) { - // Silently ignore errors in debug logging - } - } - - async generateImage(prompt: string, model: string = GEMINI_IMAGE_MODEL): Promise { - try { - // Gemini 2.5 Flash Image uses generateText with multimodal output - const tracedModel = withTracing(this.googleClient(model), this.posthogClient, { - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogPrivacyMode: false, - posthogProperties: { - $ai_span_name: "vercel_ai_generate_image_google", - ...this.getPostHogProperties(), - }, - }); - - const result = await generateText({ - model: tracedModel, - prompt: prompt, - }); - - this.debugApiCall("Vercel AI SDK Image Generation (Google)", { model, prompt }, result); - this.logTokenUsageByModality(result); - - // Generated images are returned in result.files array - if (result.files && result.files.length > 0) { - for (const file of result.files) { - if (file.mediaType?.startsWith('image/')) { - // Convert Uint8Array to base64 - const b64 = Buffer.from(file.uint8Array).toString('base64'); - return `data:${file.mediaType};base64,${b64.substring(0, 100)}... (base64 image data, ${b64.length} chars total)`; - } - } - } - return ""; - } catch (error: any) { - console.error('Error in Vercel AI Google image generation:', error); - throw new Error(`Vercel AI Google Image Generation error: ${error.message}`); - } - } - - async chat(userInput: string, base64Image?: string): Promise { - let userContent: any; - - if (base64Image) { - // For image input, create content array with text and image - userContent = [ - { type: 'text', text: userInput }, - { - type: 'image', - image: `data:image/png;base64,${base64Image}` - } - ]; - } else { - userContent = userInput; - } - - const userMessage: Message = { - role: 'user', - content: userContent - }; - this.messages.push(userMessage); - - const displayParts: string[] = []; - - try { - const model = withTracing(this.googleClient(GEMINI_MODEL), this.posthogClient, { - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogPrivacyMode: false, - posthogProperties: { - $ai_span_name: "vercel_ai_generate_text_google", - ...this.getPostHogProperties(), - }, - }); - - const requestParams = { - model: model, - messages: this.messages as any, - maxOutputTokens: DEFAULT_MAX_TOKENS, - tools: { - get_weather: { - description: 'Get the current weather for a specific location', - inputSchema: z.object({ - latitude: z.number().describe('The latitude of the location (e.g., 37.7749 for San Francisco)'), - longitude: z.number().describe('The longitude of the location (e.g., -122.4194 for San Francisco)'), - location_name: z.string().describe('A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')') - }), - execute: async ({ latitude, longitude, location_name }: { latitude: number; longitude: number; location_name: string }) => { - return this.getWeather(latitude, longitude, location_name); - } - }, - tell_joke: { - description: 'Tell a joke with a question-style setup and an answer punchline', - inputSchema: z.object({ - setup: z.string().describe('The setup of the joke, usually in question form'), - punchline: z.string().describe('The punchline or answer to the joke') - }), - execute: async ({ setup, punchline }: { setup: string; punchline: string }) => { - return this.tellJoke(setup, punchline); - } - } - } - }; - - const { text, toolResults } = await generateText(requestParams); - this.debugApiCall("Vercel AI SDK (Google)", requestParams, { text, toolResults }); - - if (text) { - displayParts.push(text); - const assistantMessage: Message = { - role: 'assistant', - content: text - }; - this.messages.push(assistantMessage); - } - - if (toolResults && toolResults.length > 0) { - for (const result of toolResults) { - if (result.toolName === 'get_weather' && 'output' in result) { - const toolResultText = this.formatToolResult('get_weather', result.output as string); - displayParts.push(toolResultText); - - // Add tool result to message history - const toolMessage: Message = { - role: 'assistant', - content: toolResultText - }; - this.messages.push(toolMessage); - } else if (result.toolName === 'tell_joke' && 'output' in result) { - const toolResultText = this.formatToolResult('tell_joke', result.output as string); - displayParts.push(toolResultText); - - // Add tool result to message history - const toolMessage: Message = { - role: 'assistant', - content: toolResultText - }; - this.messages.push(toolMessage); - } - } - } - - return displayParts.length > 0 ? displayParts.join('\n\n') : 'No response received'; - } catch (error: any) { - console.error('Error in Vercel AI Google chat:', error); - throw new Error(`Vercel AI Google Provider error: ${error.message}`); - } - } -} diff --git a/node/src/providers/vercel-ai-otel-gemini-image.ts b/node/src/providers/vercel-ai-otel-gemini-image.ts deleted file mode 100644 index 1122769..0000000 --- a/node/src/providers/vercel-ai-otel-gemini-image.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { PostHogSpanProcessor } from '@posthog/ai/otel'; -import { NodeSDK } from '@opentelemetry/sdk-node'; -import { PostHog } from 'posthog-node'; -import { generateText } from 'ai'; -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { BaseProvider, Tool } from './base.js'; -import { GEMINI_IMAGE_MODEL, DEFAULT_POSTHOG_DISTINCT_ID } from './constants.js'; - -export class VercelAIOtelGeminiImageProvider extends BaseProvider { - private googleClient: any; - private static otelSdkStarted = false; - private static otelSdk: NodeSDK | null = null; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.googleClient = createGoogleGenerativeAI({ - apiKey: process.env.GEMINI_API_KEY! - }); - } - - protected getToolDefinitions(): Tool[] { - return []; - } - - getName(): string { - return 'Vercel AI SDK OTEL (Gemini + Image Gen)'; - } - - private async ensureOtelSdk(): Promise { - if (VercelAIOtelGeminiImageProvider.otelSdkStarted) { - return; - } - - VercelAIOtelGeminiImageProvider.otelSdk = new NodeSDK({ - spanProcessors: [ - new PostHogSpanProcessor(this.posthogClient), - ], - }); - VercelAIOtelGeminiImageProvider.otelSdk.start(); - VercelAIOtelGeminiImageProvider.otelSdkStarted = true; - } - - private getTelemetryMetadata(): Record { - const metadata: Record = { - posthog_distinct_id: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - provider: 'vercel-ai-sdk-otel-gemini-image', - }; - - if (this.aiSessionId) { - metadata.ai_session_id = this.aiSessionId; - } - - return metadata; - } - - async generateImage(prompt: string, model: string = GEMINI_IMAGE_MODEL): Promise { - await this.ensureOtelSdk(); - - const result = await generateText({ - model: this.googleClient(model), - prompt, - experimental_telemetry: { - isEnabled: true, - functionId: 'vercel-ai-otel-gemini-image-generate', - metadata: this.getTelemetryMetadata(), - }, - }); - - this.debugApiCall('Vercel AI SDK OTEL (Gemini + Image Gen)', { model, prompt }, result); - - if (result.files && result.files.length > 0) { - for (const file of result.files) { - if (file.mediaType?.startsWith('image/')) { - const b64 = Buffer.from(file.uint8Array).toString('base64'); - return `data:${file.mediaType};base64,${b64.substring(0, 100)}... (base64 image data, ${b64.length} chars total)`; - } - } - } - - return ''; - } - - async chat(): Promise { - throw new Error('This provider is for image generation only. Use generateImage() instead.'); - } -} diff --git a/node/src/providers/vercel-ai-otel-openai-embeddings.ts b/node/src/providers/vercel-ai-otel-openai-embeddings.ts deleted file mode 100644 index 875bce1..0000000 --- a/node/src/providers/vercel-ai-otel-openai-embeddings.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { PostHogSpanProcessor } from '@posthog/ai/otel'; -import { NodeSDK } from '@opentelemetry/sdk-node'; -import { PostHog } from 'posthog-node'; -import { generateText, embed } from 'ai'; -import { createOpenAI } from '@ai-sdk/openai'; -import { z } from 'zod'; -import { BaseProvider, Message, Tool } from './base.js'; -import { OPENAI_CHAT_MODEL, OPENAI_VISION_MODEL, OPENAI_EMBEDDING_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_FRIENDLY } from './constants.js'; - -export class VercelAIOtelOpenAIEmbeddingsProvider extends BaseProvider { - private openaiClient: any; - private static otelSdkStarted = false; - private static otelSdk: NodeSDK | null = null; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.openaiClient = createOpenAI({ - apiKey: process.env.OPENAI_API_KEY! - }); - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return [ - { - role: 'system', - content: SYSTEM_PROMPT_FRIENDLY - } - ]; - } - - protected getToolDefinitions(): Tool[] { - // Vercel AI SDK tool declarations are passed directly to generateText/embed - return []; - } - - getName(): string { - return 'Vercel AI SDK OTEL (OpenAI + Embeddings)'; - } - - private async ensureOtelSdk(): Promise { - if (VercelAIOtelOpenAIEmbeddingsProvider.otelSdkStarted) { - return; - } - - VercelAIOtelOpenAIEmbeddingsProvider.otelSdk = new NodeSDK({ - spanProcessors: [ - new PostHogSpanProcessor(this.posthogClient), - ], - }); - VercelAIOtelOpenAIEmbeddingsProvider.otelSdk.start(); - VercelAIOtelOpenAIEmbeddingsProvider.otelSdkStarted = true; - } - - private getTelemetryMetadata(): Record { - const metadata: Record = { - posthog_distinct_id: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - provider: 'vercel-ai-sdk-otel-openai-embeddings', - }; - - if (this.aiSessionId) { - metadata.ai_session_id = this.aiSessionId; - } - - return metadata; - } - - async embed(text: string, model: string = OPENAI_EMBEDDING_MODEL): Promise { - await this.ensureOtelSdk(); - - const embeddingModel = this.openaiClient.textEmbeddingModel(model); - const result = await embed({ - model: embeddingModel, - value: text, - experimental_telemetry: { - isEnabled: true, - functionId: 'vercel-ai-otel-openai-embed', - metadata: this.getTelemetryMetadata(), - }, - }); - - return result.embedding || []; - } - - async chat(userInput: string, base64Image?: string): Promise { - await this.ensureOtelSdk(); - - const userMessage: Message = { - role: 'user', - content: base64Image - ? [ - { type: 'text', text: userInput }, - { - type: 'image', - image: `data:image/png;base64,${base64Image}` - } - ] - : userInput - }; - this.messages.push(userMessage); - - const modelName = base64Image ? OPENAI_VISION_MODEL : OPENAI_CHAT_MODEL; - const model = this.openaiClient(modelName); - - const requestParams = { - model, - messages: this.messages as any, - maxOutputTokens: DEFAULT_MAX_TOKENS, - experimental_telemetry: { - isEnabled: true, - functionId: 'vercel-ai-otel-openai-embeddings-chat', - metadata: this.getTelemetryMetadata(), - }, - tools: { - get_weather: { - description: 'Get the current weather for a specific location', - inputSchema: z.object({ - latitude: z.number().describe('The latitude of the location (e.g., 37.7749 for San Francisco)'), - longitude: z.number().describe('The longitude of the location (e.g., -122.4194 for San Francisco)'), - location_name: z.string().describe('A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')') - }), - execute: async ({ latitude, longitude, location_name }: { latitude: number; longitude: number; location_name: string }) => { - return this.getWeather(latitude, longitude, location_name); - } - }, - tell_joke: { - description: 'Tell a joke with a question-style setup and an answer punchline', - inputSchema: z.object({ - setup: z.string().describe('The setup of the joke, usually in question form'), - punchline: z.string().describe('The punchline or answer to the joke') - }), - execute: async ({ setup, punchline }: { setup: string; punchline: string }) => { - return this.tellJoke(setup, punchline); - } - } - } - }; - - try { - const { text, toolResults } = await generateText(requestParams); - this.debugApiCall('Vercel AI SDK OTEL (OpenAI + Embeddings)', { ...requestParams, model: modelName }, { text, toolResults }); - - const displayParts: string[] = []; - - if (text) { - displayParts.push(text); - this.messages.push({ - role: 'assistant', - content: text - }); - } - - if (toolResults && toolResults.length > 0) { - for (const result of toolResults) { - if (result.toolName === 'get_weather' && 'output' in result) { - displayParts.push(this.formatToolResult('get_weather', result.output as string)); - } else if (result.toolName === 'tell_joke' && 'output' in result) { - displayParts.push(this.formatToolResult('tell_joke', result.output as string)); - } - } - } - - return displayParts.length > 0 ? displayParts.join('\n\n') : 'No response received'; - } catch (error: any) { - console.error('Error in Vercel AI OTEL OpenAI + embeddings chat:', error); - throw new Error(`Vercel AI OTEL OpenAI + Embeddings Provider error: ${error.message}`); - } - } -} diff --git a/node/src/providers/vercel-ai-otel-openai.ts b/node/src/providers/vercel-ai-otel-openai.ts deleted file mode 100644 index 1e4dda5..0000000 --- a/node/src/providers/vercel-ai-otel-openai.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { PostHogSpanProcessor } from '@posthog/ai/otel'; -import { NodeSDK } from '@opentelemetry/sdk-node'; -import { trace } from '@opentelemetry/api'; -import { PostHog } from 'posthog-node'; -import { generateText } from 'ai'; -import { createOpenAI } from '@ai-sdk/openai'; -import { z } from 'zod'; -import { BaseProvider, Message, Tool } from './base.js'; -import { OPENAI_CHAT_MODEL, OPENAI_VISION_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_FRIENDLY } from './constants.js'; - -export class VercelAIOtelOpenAIProvider extends BaseProvider { - private openaiClient: any; - private static otelSdkStarted = false; - private static otelSdk: NodeSDK | null = null; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.openaiClient = createOpenAI({ - apiKey: process.env.OPENAI_API_KEY! - }); - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return [ - { - role: 'system', - content: SYSTEM_PROMPT_FRIENDLY - } - ]; - } - - protected getToolDefinitions(): Tool[] { - // Vercel AI SDK tool declarations are passed directly to generateText - return []; - } - - getName(): string { - return 'Vercel AI SDK OTEL (OpenAI)'; - } - - private async ensureOtelSdk(): Promise { - if (VercelAIOtelOpenAIProvider.otelSdkStarted) { - return; - } - - VercelAIOtelOpenAIProvider.otelSdk = new NodeSDK({ - spanProcessors: [ - new PostHogSpanProcessor(this.posthogClient), - ], - }); - VercelAIOtelOpenAIProvider.otelSdk.start(); - VercelAIOtelOpenAIProvider.otelSdkStarted = true; - } - - async chat(userInput: string, base64Image?: string): Promise { - await this.ensureOtelSdk(); - - const userMessage: Message = { - role: 'user', - content: base64Image - ? [ - { type: 'text', text: userInput }, - { - type: 'image', - image: `data:image/png;base64,${base64Image}` - } - ] - : userInput - }; - this.messages.push(userMessage); - - const modelName = base64Image ? OPENAI_VISION_MODEL : OPENAI_CHAT_MODEL; - const model = this.openaiClient(modelName); - const posthogDistinctId = process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID; - - const telemetryMetadata: Record = { - posthog_distinct_id: posthogDistinctId, - provider: 'vercel-ai-sdk-otel-openai', - }; - - if (this.aiSessionId) { - telemetryMetadata.ai_session_id = this.aiSessionId; - } - - const requestParams = { - model, - messages: this.messages as any, - maxOutputTokens: DEFAULT_MAX_TOKENS, - experimental_telemetry: { - isEnabled: true, - functionId: 'vercel-ai-otel-openai-chat', - metadata: telemetryMetadata, - }, - tools: { - get_weather: { - description: 'Get the current weather for a specific location', - inputSchema: z.object({ - latitude: z.number().describe('The latitude of the location (e.g., 37.7749 for San Francisco)'), - longitude: z.number().describe('The longitude of the location (e.g., -122.4194 for San Francisco)'), - location_name: z.string().describe('A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')') - }), - execute: async ({ latitude, longitude, location_name }: { latitude: number; longitude: number; location_name: string }) => { - return this.getWeather(latitude, longitude, location_name); - } - }, - tell_joke: { - description: 'Tell a joke with a question-style setup and an answer punchline', - inputSchema: z.object({ - setup: z.string().describe('The setup of the joke, usually in question form'), - punchline: z.string().describe('The punchline or answer to the joke') - }), - execute: async ({ setup, punchline }: { setup: string; punchline: string }) => { - return this.tellJoke(setup, punchline); - } - } - } - }; - - try { - const tracer = trace.getTracer('vercel-ai-otel-openai-provider'); - return await tracer.startActiveSpan('vercel-ai-otel-openai-two-part-chat', async (span) => { - try { - const displayParts: string[] = []; - const partOneParams = { - model, - messages: [ - { - role: 'system', - content: 'Output exactly this text and nothing else: PART 1/2: Let me check that.' - } - ] as any, - maxOutputTokens: 256, - experimental_telemetry: { - isEnabled: true, - functionId: 'vercel-ai-otel-openai-chat-part1', - metadata: { - ...telemetryMetadata, - forced_response_part: '1' - }, - }, - }; - - const { text: partOneText } = await generateText(partOneParams as any); - this.debugApiCall('Vercel AI SDK OTEL (OpenAI) - Part 1', { ...partOneParams, model: modelName }, { text: partOneText }); - - const partOneDisplayText = partOneText?.trim() || 'PART 1/2: Let me check that.'; - displayParts.push(partOneDisplayText); - this.messages.push({ - role: 'assistant', - content: partOneDisplayText - }); - - const finalRequestParams = { - ...requestParams, - experimental_telemetry: { - ...requestParams.experimental_telemetry, - functionId: 'vercel-ai-otel-openai-chat-part2', - metadata: { - ...telemetryMetadata, - forced_response_part: '2' - }, - } - }; - - const { text, toolResults } = await generateText(finalRequestParams as any); - this.debugApiCall('Vercel AI SDK OTEL (OpenAI)', { ...finalRequestParams, model: modelName }, { text, toolResults }); - - if (text) { - displayParts.push(text); - this.messages.push({ - role: 'assistant', - content: text - }); - } - - if (toolResults && toolResults.length > 0) { - for (const result of toolResults) { - if (result.toolName === 'get_weather' && 'output' in result) { - displayParts.push(this.formatToolResult('get_weather', result.output as string)); - } else if (result.toolName === 'tell_joke' && 'output' in result) { - displayParts.push(this.formatToolResult('tell_joke', result.output as string)); - } - } - } - - return displayParts.length > 0 ? displayParts.join('\n\n') : 'No response received'; - } finally { - span.end(); - } - }); - } catch (error: any) { - console.error('Error in Vercel AI OTEL OpenAI chat:', error); - throw new Error(`Vercel AI OTEL OpenAI Provider error: ${error.message}`); - } - } -} diff --git a/node/src/providers/vercel-ai-otel.ts b/node/src/providers/vercel-ai-otel.ts deleted file mode 100644 index dbc3328..0000000 --- a/node/src/providers/vercel-ai-otel.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Vercel AI SDK provider with raw OpenTelemetry OTLP export. - * - * Unlike the other Vercel AI OTel providers that use PostHogSpanProcessor, - * this provider sends OTel spans directly to PostHog's OTLP ingestion - * endpoint (/i/v0/ai/otel) using the standard OTLP HTTP exporter. - * This mirrors the approach used by the Python LangChain and Pydantic AI - * OTel providers. - */ - -import { NodeSDK } from '@opentelemetry/sdk-node'; -import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; -import { resourceFromAttributes } from '@opentelemetry/resources'; -import { PostHog } from 'posthog-node'; -import type { ModelMessage } from 'ai'; -import { generateText } from 'ai'; -import { createOpenAI } from '@ai-sdk/openai'; -import { z } from 'zod'; -import { BaseProvider, Tool } from './base.js'; -import { - OPENAI_CHAT_MODEL, - OPENAI_VISION_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_POSTHOG_DISTINCT_ID, - SYSTEM_PROMPT_ASSISTANT, -} from './constants.js'; - -export class VercelAIOtelProvider extends BaseProvider { - private openai: ReturnType; - private vercelMessages: ModelMessage[] = []; - private static otelConfigured = false; - private static otelSdk: NodeSDK | null = null; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.setupOtel(); - this.openai = createOpenAI({ - apiKey: process.env.OPENAI_API_KEY!, - }); - this.vercelMessages = [ - { role: 'system', content: SYSTEM_PROMPT_ASSISTANT }, - ]; - } - - private setupOtel(): void { - if (VercelAIOtelProvider.otelConfigured) { - return; - } - - const posthogApiKey = process.env.POSTHOG_API_KEY; - const posthogHost = process.env.POSTHOG_HOST || 'http://localhost:8010'; - - if (!posthogApiKey) { - throw new Error('POSTHOG_API_KEY must be set'); - } - - const resourceAttributes: Record = { - 'service.name': 'llm-analytics-app-vercel-ai-otel', - 'user.id': process.env.POSTHOG_DISTINCT_ID || 'unknown', - }; - if (this.debugMode) { - resourceAttributes['posthog.ai.debug'] = 'true'; - } - - const exporter = new OTLPTraceExporter({ - url: `${posthogHost}/i/v0/ai/otel`, - headers: { - Authorization: `Bearer ${posthogApiKey}`, - }, - }); - - VercelAIOtelProvider.otelSdk = new NodeSDK({ - resource: resourceFromAttributes(resourceAttributes), - traceExporter: exporter, - }); - VercelAIOtelProvider.otelSdk.start(); - - VercelAIOtelProvider.otelConfigured = true; - } - - protected getToolDefinitions(): Tool[] { - return []; - } - - getName(): string { - return 'Vercel AI SDK (Raw OTel)'; - } - - resetConversation(): void { - this.vercelMessages = [ - { role: 'system', content: SYSTEM_PROMPT_ASSISTANT }, - ]; - this.messages = []; - } - - private buildTools() { - return { - get_weather: { - description: 'Get the current weather for a specific location', - inputSchema: z.object({ - latitude: z.number().describe('The latitude of the location'), - longitude: z.number().describe('The longitude of the location'), - location_name: z.string().describe('A human-readable name for the location'), - }), - execute: async ({ - latitude, - longitude, - location_name, - }: { - latitude: number; - longitude: number; - location_name: string; - }) => { - return this.getWeather(latitude, longitude, location_name); - }, - }, - tell_joke: { - description: 'Tell a joke with a question-style setup and an answer punchline', - inputSchema: z.object({ - setup: z.string().describe('The setup of the joke, usually in question form'), - punchline: z.string().describe('The punchline or answer to the joke'), - }), - execute: async ({ setup, punchline }: { setup: string; punchline: string }) => { - return this.tellJoke(setup, punchline); - }, - }, - }; - } - - async chat(userInput: string, base64Image?: string): Promise { - const userMessage: ModelMessage = base64Image - ? { - role: 'user', - content: [ - { type: 'text', text: userInput }, - { type: 'image', image: `data:image/png;base64,${base64Image}` }, - ], - } - : { role: 'user', content: [{ type: 'text', text: userInput }] }; - this.vercelMessages.push(userMessage); - - const modelName = base64Image ? OPENAI_VISION_MODEL : OPENAI_CHAT_MODEL; - const posthogDistinctId = process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID; - - const telemetryMetadata: Record = { - posthog_distinct_id: posthogDistinctId, - provider: 'vercel-ai-sdk-raw-otel', - }; - - if (this.aiSessionId) { - telemetryMetadata.ai_session_id = this.aiSessionId; - } - - try { - const { text, toolResults } = await generateText({ - model: this.openai(modelName), - messages: this.vercelMessages, - maxOutputTokens: DEFAULT_MAX_TOKENS, - experimental_telemetry: { - isEnabled: true, - functionId: 'vercel-ai-raw-otel-chat', - metadata: telemetryMetadata, - }, - tools: this.buildTools(), - }); - - this.debugApiCall( - 'Vercel AI SDK (Raw OTel)', - { model: modelName, messages: this.vercelMessages }, - { text, toolResults }, - ); - - const displayParts: string[] = []; - - if (text) { - displayParts.push(text); - this.vercelMessages.push({ role: 'assistant', content: [{ type: 'text', text }] }); - } - - if (toolResults && toolResults.length > 0) { - for (const result of toolResults) { - if ('output' in result && typeof result.output === 'string') { - displayParts.push(this.formatToolResult(result.toolName, result.output)); - } - } - } - - return displayParts.length > 0 ? displayParts.join('\n\n') : 'No response received'; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.error('Error in Vercel AI Raw OTel chat:', error); - throw new Error(`Vercel AI Raw OTel Provider error: ${message}`); - } - } -} diff --git a/node/src/providers/vercel-ai-streaming.ts b/node/src/providers/vercel-ai-streaming.ts deleted file mode 100644 index 63c2bb6..0000000 --- a/node/src/providers/vercel-ai-streaming.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { withTracing } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { streamText } from 'ai'; -import { createOpenAI } from '@ai-sdk/openai'; -import { z } from 'zod'; -import { StreamingProvider, Message, Tool } from './base.js'; -import { OPENAI_CHAT_MODEL, OPENAI_VISION_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_FRIENDLY } from './constants.js'; - -export class VercelAIStreamingProvider extends StreamingProvider { - private openaiClient: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.openaiClient = createOpenAI({ - apiKey: process.env.OPENAI_API_KEY! - }); - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return [ - { - role: 'system', - content: SYSTEM_PROMPT_FRIENDLY - } - ]; - } - - protected getToolDefinitions(): Tool[] { - // Vercel AI SDK doesn't use this, but we need to implement it - return []; - } - - getName(): string { - return 'Vercel AI SDK Streaming (OpenAI)'; - } - - async *chatStream( - userInput: string, - base64Image?: string - ): AsyncGenerator { - let userContent: any; - - if (base64Image) { - // For image input, create content array with text and image - userContent = [ - { type: 'text', text: userInput }, - { - type: 'image', - image: `data:image/png;base64,${base64Image}` - } - ]; - } else { - userContent = userInput; - } - - const userMessage: Message = { - role: 'user', - content: userContent - }; - this.messages.push(userMessage); - - // Use vision model for images, regular model otherwise - const modelName = base64Image ? OPENAI_VISION_MODEL : OPENAI_CHAT_MODEL; - const model = withTracing(this.openaiClient(modelName), this.posthogClient, { - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogPrivacyMode: false, - posthogProperties: { - $ai_span_name: "vercel_ai_stream_text", - ...this.getPostHogProperties(), - }, - }); - - try { - const requestParams = { - model: model, - messages: this.messages as any, - maxOutputTokens: DEFAULT_MAX_TOKENS, - tools: { - get_weather: { - description: 'Get the current weather for a specific location', - inputSchema: z.object({ - latitude: z.number().describe('The latitude of the location (e.g., 37.7749 for San Francisco)'), - longitude: z.number().describe('The longitude of the location (e.g., -122.4194 for San Francisco)'), - location_name: z.string().describe('A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')') - }), - execute: async ({ latitude, longitude, location_name }: { latitude: number; longitude: number; location_name: string }) => { - return this.getWeather(latitude, longitude, location_name); - } - }, - tell_joke: { - description: 'Tell a joke with a question-style setup and an answer punchline', - inputSchema: z.object({ - setup: z.string().describe('The setup of the joke, usually in question form'), - punchline: z.string().describe('The punchline or answer to the joke') - }), - execute: async ({ setup, punchline }: { setup: string; punchline: string }) => { - return this.tellJoke(setup, punchline); - } - } - } - }; - - if (this.debugMode) { - this.debugLog("Vercel AI SDK Streaming (OpenAI) API Request", requestParams); - } - - const result = await streamText(requestParams); - - let accumulatedContent = ''; - const toolCalls: any[] = []; - let currentToolCall: any = null; - - for await (const part of result.fullStream) { - // Handle text delta events - if (part.type === 'text-delta') { - const delta = (part as any).text || ''; - accumulatedContent += delta; - yield delta; - } - - // Handle tool call start - if (part.type === 'tool-call') { - currentToolCall = { - toolName: (part as any).toolName, - args: (part as any).input || {} - }; - toolCalls.push(currentToolCall); - } - - // Handle tool result - if (part.type === 'tool-result') { - const toolResult = part as any; - if (toolResult.toolName === 'get_weather') { - // The tool was already executed via the execute function - // Format and yield the result - const weatherResult = toolResult.output as string; - const toolResultText = this.formatToolResult('get_weather', weatherResult); - yield '\n\n' + toolResultText; - } else if (toolResult.toolName === 'tell_joke') { - // The tool was already executed via the execute function - // Format and yield the result - const jokeResult = toolResult.output as string; - const toolResultText = this.formatToolResult('tell_joke', jokeResult); - yield '\n\n' + toolResultText; - } - } - - // Handle finish event for usage tracking - if (part.type === 'finish') { - // Usage information is available in part.usage if needed - // but we're already tracking this via withTracing - } - } - - // Save assistant message with accumulated content - if (accumulatedContent) { - const assistantMessage: Message = { - role: 'assistant', - content: accumulatedContent - }; - this.messages.push(assistantMessage); - } else if (toolCalls.length > 0) { - // If there was a tool call but no text content, save the tool result as assistant message - let toolResultsText = ''; - for (const toolCall of toolCalls) { - if (toolCall.toolName === 'get_weather') { - const latitude = toolCall.args.latitude || 0.0; - const longitude = toolCall.args.longitude || 0.0; - const locationName = toolCall.args.location_name; - const weatherResult = await this.getWeather(latitude, longitude, locationName); - toolResultsText += this.formatToolResult('get_weather', weatherResult); - } else if (toolCall.toolName === 'tell_joke') { - const setup = toolCall.args.setup || ''; - const punchline = toolCall.args.punchline || ''; - const jokeResult = this.tellJoke(setup, punchline); - toolResultsText += this.formatToolResult('tell_joke', jokeResult); - } - } - - if (toolResultsText) { - const assistantMessage: Message = { - role: 'assistant', - content: toolResultsText - }; - this.messages.push(assistantMessage); - } - } - - // Debug: Log the completed stream response - if (this.debugMode) { - this.debugLog("Vercel AI SDK Streaming (OpenAI) API Response (completed)", { - accumulatedContent: accumulatedContent, - toolCalls: toolCalls - }); - } - } catch (error: any) { - console.error('Error in Vercel AI streaming chat:', error); - throw new Error(`Vercel AI Streaming Provider error: ${error.message}`); - } - } - - // Non-streaming chat for compatibility - async chat(userInput: string, base64Image?: string): Promise { - const chunks: string[] = []; - for await (const chunk of this.chatStream(userInput, base64Image)) { - chunks.push(chunk); - } - return chunks.join(''); - } -} \ No newline at end of file diff --git a/node/src/providers/vercel-ai.ts b/node/src/providers/vercel-ai.ts deleted file mode 100644 index 2aa4b0e..0000000 --- a/node/src/providers/vercel-ai.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { withTracing } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { generateText, generateImage } from 'ai'; -import { createOpenAI } from '@ai-sdk/openai'; -import { z } from 'zod'; -import { BaseProvider, Message, Tool } from './base.js'; -import { OPENAI_CHAT_MODEL, OPENAI_VISION_MODEL, OPENAI_IMAGE_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_FRIENDLY } from './constants.js'; - -export class VercelAIProvider extends BaseProvider { - private openaiClient: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.openaiClient = createOpenAI({ - apiKey: process.env.OPENAI_API_KEY! - }); - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return [ - { - role: 'system', - content: SYSTEM_PROMPT_FRIENDLY - } - ]; - } - - protected getToolDefinitions(): Tool[] { - // Vercel AI SDK doesn't use this, but we need to implement it - return []; - } - - getName(): string { - return 'Vercel AI SDK (OpenAI)'; - } - - async generateImage(prompt: string, model: string = OPENAI_IMAGE_MODEL): Promise { - try { - // Note: withTracing doesn't work with image models (expects messages array) - // Using the image model directly without tracing for now - const imageModel = this.openaiClient.image(model); - - const result = await generateImage({ - model: imageModel, - prompt: prompt, - }); - - this.debugApiCall("Vercel AI SDK Image Generation (OpenAI)", { model, prompt }, result); - - // OpenAI returns images in result.images array - if (result.images && result.images.length > 0) { - const image = result.images[0]; - // Image can be base64 or URL depending on response format - if (image.base64) { - return `data:image/png;base64,${image.base64.substring(0, 100)}... (base64 image data, ${image.base64.length} chars total)`; - } else if (image.uint8Array) { - const b64 = Buffer.from(image.uint8Array).toString('base64'); - return `data:image/png;base64,${b64.substring(0, 100)}... (base64 image data, ${b64.length} chars total)`; - } - } - return ""; - } catch (error: any) { - console.error('Error in Vercel AI OpenAI image generation:', error); - throw new Error(`Vercel AI OpenAI Image Generation error: ${error.message}`); - } - } - - async chat(userInput: string, base64Image?: string): Promise { - let userContent: any; - - if (base64Image) { - // For image input, create content array with text and image - userContent = [ - { type: 'text', text: userInput }, - { - type: 'image', - image: `data:image/png;base64,${base64Image}` - } - ]; - } else { - userContent = userInput; - } - - const userMessage: Message = { - role: 'user', - content: userContent - }; - this.messages.push(userMessage); - - const displayParts: string[] = []; - - try { - // Use vision model for images, regular model otherwise - const modelName = base64Image ? OPENAI_VISION_MODEL : OPENAI_CHAT_MODEL; - const model = withTracing(this.openaiClient(modelName), this.posthogClient, { - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogPrivacyMode: false, - posthogProperties: { - $ai_span_name: "vercel_ai_generate_text", - ...this.getPostHogProperties(), - }, - }); - - const requestParams = { - model: model, - messages: this.messages as any, - maxOutputTokens: DEFAULT_MAX_TOKENS, - tools: { - get_weather: { - description: 'Get the current weather for a specific location', - inputSchema: z.object({ - latitude: z.number().describe('The latitude of the location (e.g., 37.7749 for San Francisco)'), - longitude: z.number().describe('The longitude of the location (e.g., -122.4194 for San Francisco)'), - location_name: z.string().describe('A human-readable name for the location (e.g., \'San Francisco, CA\' or \'Dublin, Ireland\')') - }), - execute: async ({ latitude, longitude, location_name }: { latitude: number; longitude: number; location_name: string }) => { - return this.getWeather(latitude, longitude, location_name); - } - }, - tell_joke: { - description: 'Tell a joke with a question-style setup and an answer punchline', - inputSchema: z.object({ - setup: z.string().describe('The setup of the joke, usually in question form'), - punchline: z.string().describe('The punchline or answer to the joke') - }), - execute: async ({ setup, punchline }: { setup: string; punchline: string }) => { - return this.tellJoke(setup, punchline); - } - } - } - }; - - const { text, toolResults } = await generateText(requestParams); - this.debugApiCall("Vercel AI SDK (OpenAI)", requestParams, { text, toolResults }); - - if (text) { - displayParts.push(text); - const assistantMessage: Message = { - role: 'assistant', - content: text - }; - this.messages.push(assistantMessage); - } - - if (toolResults && toolResults.length > 0) { - for (const result of toolResults) { - if (result.toolName === 'get_weather' && 'output' in result) { - const toolResultText = this.formatToolResult('get_weather', result.output as string); - displayParts.push(toolResultText); - - // Add tool result to message history - const toolMessage: Message = { - role: 'assistant', - content: toolResultText - }; - this.messages.push(toolMessage); - } else if (result.toolName === 'tell_joke' && 'output' in result) { - const toolResultText = this.formatToolResult('tell_joke', result.output as string); - displayParts.push(toolResultText); - - // Add tool result to message history - const toolMessage: Message = { - role: 'assistant', - content: toolResultText - }; - this.messages.push(toolMessage); - } - } - } - - return displayParts.length > 0 ? displayParts.join('\n\n') : 'No response received'; - } catch (error: any) { - console.error('Error in Vercel AI chat:', error); - throw new Error(`Vercel AI Provider error: ${error.message}`); - } - } -} \ No newline at end of file diff --git a/node/src/providers/vercel-generate-object.ts b/node/src/providers/vercel-generate-object.ts deleted file mode 100644 index f67f851..0000000 --- a/node/src/providers/vercel-generate-object.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { withTracing } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { streamObject, generateObject } from 'ai'; -import { createOpenAI } from '@ai-sdk/openai'; -import { z } from 'zod'; -import { BaseProvider, Message, Tool } from './base.js'; -import { OPENAI_CHAT_MODEL, OPENAI_VISION_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_STRUCTURED } from './constants.js'; - -// Define schemas for different types of structured outputs -const weatherSchema = z.object({ - location: z.string().describe('The location for which weather is provided'), - temperature: z.number().describe('Temperature in Celsius'), - condition: z.string().describe('Weather condition description'), - humidity: z.number().describe('Humidity percentage'), - windSpeed: z.number().describe('Wind speed in km/h'), - forecast: z.array(z.object({ - day: z.string().describe('Day of the week'), - high: z.number().describe('High temperature'), - low: z.number().describe('Low temperature'), - condition: z.string().describe('Weather condition') - })).describe('3-day weather forecast') -}); - -const userProfileSchema = z.object({ - name: z.string().describe('User\'s name'), - interests: z.array(z.string()).describe('List of user interests'), - preferences: z.object({ - communicationStyle: z.enum(['formal', 'casual', 'friendly']).describe('Preferred communication style'), - topics: z.array(z.string()).describe('Preferred conversation topics') - }).describe('User preferences'), - demographics: z.object({ - ageRange: z.string().describe('Age range (e.g., 20-30)'), - location: z.string().optional().describe('General location if mentioned') - }).describe('Basic demographic information') -}); - -const taskPlanSchema = z.object({ - title: z.string().describe('Title of the task or project'), - objective: z.string().describe('Main objective or goal'), - steps: z.array(z.object({ - stepNumber: z.number().describe('Step number in sequence'), - title: z.string().describe('Step title'), - description: z.string().describe('Detailed description of the step'), - estimatedTime: z.string().describe('Estimated time to complete'), - dependencies: z.array(z.string()).describe('Dependencies on other steps') - })).describe('List of steps to complete the task'), - totalEstimatedTime: z.string().describe('Total estimated time for the entire task'), - resources: z.array(z.string()).describe('Required resources or tools'), - risks: z.array(z.string()).describe('Potential risks or challenges') -}); - -export class VercelGenerateObjectProvider extends BaseProvider { - private openaiClient: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.openaiClient = createOpenAI({ - apiKey: process.env.OPENAI_API_KEY! - }); - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return [ - { - role: 'system', - content: SYSTEM_PROMPT_STRUCTURED - } - ]; - } - - protected getToolDefinitions(): Tool[] { - // Vercel AI SDK doesn't use this, but we need to implement it - return []; - } - - getName(): string { - return 'Vercel AI SDK - generateObject (OpenAI)'; - } - - // Determine which schema to use based on user input - private determineSchema(userInput: string): { schema: z.ZodSchema, type: string } { - const input = userInput.toLowerCase(); - - if (input.includes('weather') || input.includes('temperature') || input.includes('forecast')) { - return { schema: weatherSchema, type: 'weather' }; - } else if (input.includes('profile') || input.includes('about me') || input.includes('interests')) { - return { schema: userProfileSchema, type: 'profile' }; - } else if (input.includes('plan') || input.includes('task') || input.includes('project') || input.includes('steps')) { - return { schema: taskPlanSchema, type: 'plan' }; - } - - // Default to user profile for general queries - return { schema: userProfileSchema, type: 'profile' }; - } - - - - private formatStructuredData(data: any, type: string): string { - if (!data) return ''; - - try { - if (type === 'weather' && data) { - let output = ''; - if (data.location) output += `๐Ÿ“ Location: ${data.location}\n`; - if (data.temperature !== undefined) output += `๐ŸŒก๏ธ Temperature: ${data.temperature}ยฐC\n`; - if (data.condition) output += `โ˜๏ธ Condition: ${data.condition}\n`; - if (data.humidity !== undefined) output += `๐Ÿ’ง Humidity: ${data.humidity}%\n`; - if (data.windSpeed !== undefined) output += `๐Ÿ’จ Wind Speed: ${data.windSpeed} km/h\n`; - - if (data.forecast && data.forecast.length > 0) { - output += `\n๐Ÿ“… 3-Day Forecast:\n`; - data.forecast.forEach((day: any, index: number) => { - if (day.day) output += ` ${day.day}: `; - if (day.high !== undefined && day.low !== undefined) { - output += `${day.high}ยฐ/${day.low}ยฐ `; - } - if (day.condition) output += `${day.condition}`; - output += '\n'; - }); - } - return output; - } - - if (type === 'profile' && data) { - let output = ''; - if (data.name) output += `๐Ÿ‘ค Name: ${data.name}\n`; - if (data.interests && data.interests.length > 0) { - output += `๐ŸŽฏ Interests: ${data.interests.join(', ')}\n`; - } - if (data.preferences) { - output += `\nโš™๏ธ Preferences:\n`; - if (data.preferences.communicationStyle) { - output += ` โ€ข Communication: ${data.preferences.communicationStyle}\n`; - } - if (data.preferences.topics && data.preferences.topics.length > 0) { - output += ` โ€ข Topics: ${data.preferences.topics.join(', ')}\n`; - } - } - if (data.demographics) { - output += `\n๐Ÿ“Š Demographics:\n`; - if (data.demographics.ageRange) { - output += ` โ€ข Age Range: ${data.demographics.ageRange}\n`; - } - if (data.demographics.location) { - output += ` โ€ข Location: ${data.demographics.location}\n`; - } - } - return output; - } - - if (type === 'plan' && data) { - let output = ''; - if (data.title) output += `๐Ÿ“‹ ${data.title}\n`; - if (data.objective) output += `๐ŸŽฏ Objective: ${data.objective}\n`; - if (data.totalEstimatedTime) output += `โฑ๏ธ Total Time: ${data.totalEstimatedTime}\n\n`; - - if (data.steps && data.steps.length > 0) { - output += `๐Ÿ“ Steps:\n`; - data.steps.forEach((step: any) => { - if (step.stepNumber && step.title) { - output += ` ${step.stepNumber}. ${step.title}\n`; - } - if (step.description) output += ` ${step.description}\n`; - if (step.estimatedTime) output += ` โฑ๏ธ ${step.estimatedTime}\n`; - if (step.dependencies && step.dependencies.length > 0) { - output += ` ๐Ÿ”— Depends on: ${step.dependencies.join(', ')}\n`; - } - output += '\n'; - }); - } - - if (data.resources && data.resources.length > 0) { - output += `๐Ÿ› ๏ธ Resources: ${data.resources.join(', ')}\n`; - } - if (data.risks && data.risks.length > 0) { - output += `โš ๏ธ Risks: ${data.risks.join(', ')}\n`; - } - return output; - } - - // Fallback to JSON formatting - return JSON.stringify(data, null, 2); - } catch (error) { - return JSON.stringify(data, null, 2); - } - } - - // Non-streaming version using generateObject - async chat(userInput: string, base64Image?: string): Promise { - let userContent: any; - - if (base64Image) { - userContent = [ - { type: 'text', text: userInput }, - { - type: 'image', - image: `data:image/png;base64,${base64Image}` - } - ]; - } else { - userContent = userInput; - } - - const userMessage: Message = { - role: 'user', - content: userContent - }; - this.messages.push(userMessage); - - const { schema, type } = this.determineSchema(userInput); - const modelName = base64Image ? OPENAI_VISION_MODEL : OPENAI_CHAT_MODEL; - const model = withTracing(this.openaiClient(modelName), this.posthogClient, { - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogPrivacyMode: false, - posthogProperties: { - $ai_span_name: "vercel_ai_generate_object", - ...this.getPostHogProperties(), - }, - }); - - try { - let prompt = userInput; - if (type === 'weather') { - prompt = `Provide detailed weather information for the location mentioned in: "${userInput}". If no specific location is mentioned, use Montreal, Canada as default.`; - } else if (type === 'profile') { - prompt = `Based on this user message: "${userInput}", create a user profile. If limited information is available, make reasonable assumptions about interests and preferences.`; - } else if (type === 'plan') { - prompt = `Create a detailed task plan based on: "${userInput}". Break it down into specific, actionable steps.`; - } - - const requestParams = { - model: model, - messages: [ - ...this.messages.slice(0, -1), // All previous messages except the last user message - { role: 'user', content: prompt } - ] as any, - schema: schema, - maxOutputTokens: DEFAULT_MAX_TOKENS, - }; - - const result = await generateObject(requestParams); - this.debugApiCall("Vercel AI SDK - generateObject (OpenAI)", requestParams, result); - - const formatted = this.formatStructuredData(result.object, type); - - // Save assistant message with the structured data - const assistantMessage: Message = { - role: 'assistant', - content: `Generated ${type} data: ${JSON.stringify(result.object, null, 2)}` - }; - this.messages.push(assistantMessage); - - return `โœ… Generated structured ${type} data:\n\n${formatted}`; - - } catch (error: any) { - console.error('Error in Vercel generateObject:', error); - throw new Error(`Vercel generateObject Provider error: ${error.message}`); - } - } -} diff --git a/node/src/providers/vercel-stream-object.ts b/node/src/providers/vercel-stream-object.ts deleted file mode 100644 index 654f660..0000000 --- a/node/src/providers/vercel-stream-object.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { withTracing } from '@posthog/ai'; -import { PostHog } from 'posthog-node'; -import { streamObject } from 'ai'; -import { createOpenAI } from '@ai-sdk/openai'; -import { z } from 'zod'; -import { StreamingProvider, Message, Tool } from './base.js'; -import { OPENAI_CHAT_MODEL, OPENAI_VISION_MODEL, DEFAULT_MAX_TOKENS, DEFAULT_POSTHOG_DISTINCT_ID, SYSTEM_PROMPT_STRUCTURED } from './constants.js'; - -// Define schemas for different types of structured outputs -const weatherSchema = z.object({ - location: z.string().describe('The location for which weather is provided'), - temperature: z.number().describe('Temperature in Celsius'), - condition: z.string().describe('Weather condition description'), - humidity: z.number().describe('Humidity percentage'), - windSpeed: z.number().describe('Wind speed in km/h'), - forecast: z.array(z.object({ - day: z.string().describe('Day of the week'), - high: z.number().describe('High temperature'), - low: z.number().describe('Low temperature'), - condition: z.string().describe('Weather condition') - })).describe('3-day weather forecast') -}); - -const userProfileSchema = z.object({ - name: z.string().describe('User\'s name'), - interests: z.array(z.string()).describe('List of user interests'), - preferences: z.object({ - communicationStyle: z.enum(['formal', 'casual', 'friendly']).describe('Preferred communication style'), - topics: z.array(z.string()).describe('Preferred conversation topics') - }).describe('User preferences'), - demographics: z.object({ - ageRange: z.string().describe('Age range (e.g., 20-30)'), - location: z.string().optional().describe('General location if mentioned') - }).describe('Basic demographic information') -}); - -const taskPlanSchema = z.object({ - title: z.string().describe('Title of the task or project'), - objective: z.string().describe('Main objective or goal'), - steps: z.array(z.object({ - stepNumber: z.number().describe('Step number in sequence'), - title: z.string().describe('Step title'), - description: z.string().describe('Detailed description of the step'), - estimatedTime: z.string().describe('Estimated time to complete'), - dependencies: z.array(z.string()).describe('Dependencies on other steps') - })).describe('List of steps to complete the task'), - totalEstimatedTime: z.string().describe('Total estimated time for the entire task'), - resources: z.array(z.string()).describe('Required resources or tools'), - risks: z.array(z.string()).describe('Potential risks or challenges') -}); - -export class VercelStreamObjectProvider extends StreamingProvider { - private openaiClient: any; - - constructor(posthogClient: PostHog, aiSessionId: string | null = null) { - super(posthogClient, aiSessionId); - this.openaiClient = createOpenAI({ - apiKey: process.env.OPENAI_API_KEY! - }); - this.messages = this.getInitialMessages(); - } - - protected getInitialMessages(): Message[] { - return [ - { - role: 'system', - content: SYSTEM_PROMPT_STRUCTURED - } - ]; - } - - protected getToolDefinitions(): Tool[] { - // Vercel AI SDK doesn't use this, but we need to implement it - return []; - } - - getName(): string { - return 'Vercel AI SDK - streamObject (OpenAI)'; - } - - // Determine which schema to use based on user input - private determineSchema(userInput: string): { schema: z.ZodSchema, type: string } { - const input = userInput.toLowerCase(); - - if (input.includes('weather') || input.includes('temperature') || input.includes('forecast')) { - return { schema: weatherSchema, type: 'weather' }; - } else if (input.includes('profile') || input.includes('about me') || input.includes('interests')) { - return { schema: userProfileSchema, type: 'profile' }; - } else if (input.includes('plan') || input.includes('task') || input.includes('project') || input.includes('steps')) { - return { schema: taskPlanSchema, type: 'plan' }; - } - - // Default to user profile for general queries - return { schema: userProfileSchema, type: 'profile' }; - } - - async *chatStream( - userInput: string, - base64Image?: string - ): AsyncGenerator { - let userContent: any; - - if (base64Image) { - userContent = [ - { type: 'text', text: userInput }, - { - type: 'image', - image: `data:image/png;base64,${base64Image}` - } - ]; - } else { - userContent = userInput; - } - - const userMessage: Message = { - role: 'user', - content: userContent - }; - this.messages.push(userMessage); - - const { schema, type } = this.determineSchema(userInput); - const modelName = base64Image ? OPENAI_VISION_MODEL : OPENAI_CHAT_MODEL; - const model = withTracing(this.openaiClient(modelName), this.posthogClient, { - posthogDistinctId: process.env.POSTHOG_DISTINCT_ID || DEFAULT_POSTHOG_DISTINCT_ID, - posthogPrivacyMode: false, - posthogProperties: { - $ai_span_name: "vercel_ai_stream_object", - ...this.getPostHogProperties(), - }, - }); - - try { - let prompt = userInput; - if (type === 'weather') { - prompt = `Provide detailed weather information for the location mentioned in: "${userInput}". If no specific location is mentioned, use Montreal, Canada as default.`; - } else if (type === 'profile') { - prompt = `Based on this user message: "${userInput}", create a user profile. If limited information is available, make reasonable assumptions about interests and preferences.`; - } else if (type === 'plan') { - prompt = `Create a detailed task plan based on: "${userInput}". Break it down into specific, actionable steps.`; - } - - const requestParams = { - model: model, - messages: [ - ...this.messages.slice(0, -1), // All previous messages except the last user message - { role: 'user', content: prompt } - ] as any, - schema: schema, - maxOutputTokens: DEFAULT_MAX_TOKENS, - }; - - if (this.debugMode) { - this.debugLog("Vercel AI SDK - streamObject (OpenAI) API Request", requestParams); - } - - const result = await streamObject(requestParams); - - yield `๐Ÿ”„ Generating structured ${type} data...\n\n`; - - let previousFormatted = ''; - for await (const partialResult of result.partialObjectStream) { - const formatted = this.formatStructuredData(partialResult, type); - - // Only yield the new content (difference from previous) - if (formatted !== previousFormatted) { - // Clear the previous line and write new content - if (previousFormatted) { - // Calculate lines to clear based on previous content - const linesToClear = previousFormatted.split('\n').length; - for (let i = 0; i < linesToClear; i++) { - yield '\x1b[1A\x1b[2K'; // Move up one line and clear it - } - } - yield formatted; - previousFormatted = formatted; - } - } - - // Final result - const finalObject = await result.object; - const finalFormatted = this.formatStructuredData(finalObject, type); - - // Clear previous content and show final result - if (previousFormatted) { - const linesToClear = previousFormatted.split('\n').length; - for (let i = 0; i < linesToClear; i++) { - yield '\x1b[1A\x1b[2K'; // Move up one line and clear it - } - } - - yield `โœ… Complete ${type} data:\n${finalFormatted}`; - - // Save assistant message with the structured data - const assistantMessage: Message = { - role: 'assistant', - content: `Generated ${type} data: ${JSON.stringify(finalObject, null, 2)}` - }; - this.messages.push(assistantMessage); - - // Debug: Log the completed stream response - if (this.debugMode) { - this.debugLog("Vercel AI SDK - streamObject (OpenAI) API Response (completed)", { - type: type, - finalObject: finalObject, - finalFormatted: finalFormatted - }); - } - - } catch (error: any) { - console.error('Error in Vercel streamObject streaming:', error); - throw new Error(`Vercel streamObject Streaming Provider error: ${error.message}`); - } - } - - private formatStructuredData(data: any, type: string): string { - if (!data) return ''; - - try { - if (type === 'weather' && data) { - let output = ''; - if (data.location) output += `๐Ÿ“ Location: ${data.location}\n`; - if (data.temperature !== undefined) output += `๐ŸŒก๏ธ Temperature: ${data.temperature}ยฐC\n`; - if (data.condition) output += `โ˜๏ธ Condition: ${data.condition}\n`; - if (data.humidity !== undefined) output += `๐Ÿ’ง Humidity: ${data.humidity}%\n`; - if (data.windSpeed !== undefined) output += `๐Ÿ’จ Wind Speed: ${data.windSpeed} km/h\n`; - - if (data.forecast && data.forecast.length > 0) { - output += `\n๐Ÿ“… 3-Day Forecast:\n`; - data.forecast.forEach((day: any, index: number) => { - if (day.day) output += ` ${day.day}: `; - if (day.high !== undefined && day.low !== undefined) { - output += `${day.high}ยฐ/${day.low}ยฐ `; - } - if (day.condition) output += `${day.condition}`; - output += '\n'; - }); - } - return output; - } - - if (type === 'profile' && data) { - let output = ''; - if (data.name) output += `๐Ÿ‘ค Name: ${data.name}\n`; - if (data.interests && data.interests.length > 0) { - output += `๐ŸŽฏ Interests: ${data.interests.join(', ')}\n`; - } - if (data.preferences) { - output += `\nโš™๏ธ Preferences:\n`; - if (data.preferences.communicationStyle) { - output += ` โ€ข Communication: ${data.preferences.communicationStyle}\n`; - } - if (data.preferences.topics && data.preferences.topics.length > 0) { - output += ` โ€ข Topics: ${data.preferences.topics.join(', ')}\n`; - } - } - if (data.demographics) { - output += `\n๐Ÿ“Š Demographics:\n`; - if (data.demographics.ageRange) { - output += ` โ€ข Age Range: ${data.demographics.ageRange}\n`; - } - if (data.demographics.location) { - output += ` โ€ข Location: ${data.demographics.location}\n`; - } - } - return output; - } - - if (type === 'plan' && data) { - let output = ''; - if (data.title) output += `๐Ÿ“‹ ${data.title}\n`; - if (data.objective) output += `๐ŸŽฏ Objective: ${data.objective}\n`; - if (data.totalEstimatedTime) output += `โฑ๏ธ Total Time: ${data.totalEstimatedTime}\n\n`; - - if (data.steps && data.steps.length > 0) { - output += `๐Ÿ“ Steps:\n`; - data.steps.forEach((step: any) => { - if (step.stepNumber && step.title) { - output += ` ${step.stepNumber}. ${step.title}\n`; - } - if (step.description) output += ` ${step.description}\n`; - if (step.estimatedTime) output += ` โฑ๏ธ ${step.estimatedTime}\n`; - if (step.dependencies && step.dependencies.length > 0) { - output += ` ๐Ÿ”— Depends on: ${step.dependencies.join(', ')}\n`; - } - output += '\n'; - }); - } - - if (data.resources && data.resources.length > 0) { - output += `๐Ÿ› ๏ธ Resources: ${data.resources.join(', ')}\n`; - } - if (data.risks && data.risks.length > 0) { - output += `โš ๏ธ Risks: ${data.risks.join(', ')}\n`; - } - return output; - } - - // Fallback to JSON formatting - return JSON.stringify(data, null, 2); - } catch (error) { - return JSON.stringify(data, null, 2); - } - } - - // Non-streaming version for compatibility - async chat(userInput: string, base64Image?: string): Promise { - const chunks: string[] = []; - for await (const chunk of this.chatStream(userInput, base64Image)) { - chunks.push(chunk); - } - return chunks.join(''); - } -} diff --git a/python/main.py b/python/main.py deleted file mode 100644 index cc8fe42..0000000 --- a/python/main.py +++ /dev/null @@ -1,716 +0,0 @@ -#!/usr/bin/env python3 -""" -Unified AI Chatbot -Supports multiple LLM providers: Anthropic, Gemini, LangChain, and OpenAI -""" - -import os -import platform -import uuid -from dotenv import load_dotenv -from posthog import Posthog -from providers.anthropic import AnthropicProvider -from providers.anthropic_streaming import AnthropicStreamingProvider -from providers.gemini import GeminiProvider -from providers.gemini_streaming import GeminiStreamingProvider -from providers.langchain import LangChainProvider -from providers.openai import OpenAIProvider -from providers.openai_chat import OpenAIChatProvider -from providers.openai_chat_streaming import OpenAIChatStreamingProvider -from providers.openai_streaming import OpenAIStreamingProvider -from providers.litellm_provider import LiteLLMProvider -from providers.litellm_streaming import LiteLLMStreamingProvider -from providers.openai_otel import OpenAIOtelProvider -from providers.pydantic_ai_otel import PydanticAIOtelProvider -from providers.langchain_otel import LangChainOtelProvider -from providers.openai_transcription import OpenAITranscriptionProvider -from providers.openai_image import OpenAIImageProvider -from providers.gemini_image import GeminiImageProvider -from openai_agents.runner import OpenAIAgentsRunner - -# Load environment variables from parent directory -load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env')) - -# Show debug mode status if enabled -if os.getenv('DEBUG') == '1': - print("\n" + "=" * 80) - print("๐Ÿ› DEBUG MODE ENABLED") - print("=" * 80 + "\n") - -# Generate session ID for grouping traces (if enabled) -def should_enable_session_id(): - """Check if session ID should be enabled based on env var""" - value = os.getenv("ENABLE_AI_SESSION_ID", "True") - return value.lower() in ("true", "1", "yes") - -ai_session_id = str(uuid.uuid4()) if should_enable_session_id() else None - -# Initialize PostHog client with super_properties for session ID -super_properties = {"$ai_session_id": ai_session_id} if ai_session_id else None - -posthog = Posthog( - os.getenv("POSTHOG_API_KEY"), - host=os.getenv("POSTHOG_HOST", "https://app.posthog.com"), - super_properties=super_properties -) - -if ai_session_id: - print(f"\n๐Ÿ”— AI Session ID enabled: {ai_session_id}") - print("All traces in this session will be grouped together.\n") - -def clear_screen(): - """Clear the terminal screen""" - if os.getenv('DEBUG') != '1': - os.system('cls' if platform.system() == 'Windows' else 'clear') - -def select_mode(): - """Select the operation mode""" - modes = { - "1": "Chat Mode", - "2": "Tool Call Test", - "3": "Message Test", - "4": "Image Test", - "5": "Embeddings Test", - "6": "Transcription Test", - "7": "Image Generation Test" - } - - print("\nSelect Mode:") - print("=" * 50) - for key, name in modes.items(): - if key == "1": - print(f" {key}. {name} (Interactive conversation)") - elif key == "2": - print(f" {key}. {name} (Auto-test: Weather in Montreal)") - elif key == "3": - print(f" {key}. {name} (Auto-test: Simple greeting)") - elif key == "4": - print(f" {key}. {name} (Auto-test: Describe image)") - elif key == "5": - print(f" {key}. {name} (Auto-test: Generate embeddings)") - elif key == "6": - print(f" {key}. {name} (Auto-test: Transcribe audio)") - elif key == "7": - print(f" {key}. {name} (Auto-test: Generate image)") - print("=" * 50) - - while True: - try: - choice = input("\nSelect a mode (1-7) or 'q' to quit: ").strip().lower() - if choice in ["1", "2", "3", "4", "5", "6", "7"]: - clear_screen() - return choice - elif choice == "q": - print("\n๐Ÿ‘‹ Goodbye!") - exit(0) - else: - print("โŒ Invalid choice. Please select 1, 2, 3, 4, 5, 6, or 7.") - except KeyboardInterrupt: - print("\n\n๐Ÿ‘‹ Goodbye!") - exit(0) - -def display_providers(mode=None): - """Display available AI providers""" - providers = { - "1": "Anthropic", - "2": "Anthropic Streaming", - "3": "Google Gemini", - "4": "Google Gemini Streaming", - "5": "LangChain (OpenAI)", - "6": "OpenAI Responses", - "7": "OpenAI Responses Streaming", - "8": "OpenAI Chat Completions", - "9": "OpenAI Chat Completions Streaming", - "10": "LiteLLM (Sync)", - "11": "LiteLLM (Async)", - "12": "OpenAI with OpenTelemetry", - "13": "OpenAI Transcriptions with Whisper", - "14": "OpenAI Agents SDK", - "15": "OpenAI Responses (Image Generation)", - "16": "Google Gemini (Image Generation)", - "17": "Pydantic AI with OpenTelemetry", - "18": "LangChain with OpenTelemetry" - } - - # Filter providers for embeddings mode - if mode == "5": - # Only OpenAI providers and LiteLLM support embeddings - providers = { - "6": "OpenAI Responses", - "7": "OpenAI Responses Streaming", - "8": "OpenAI Chat Completions", - "9": "OpenAI Chat Completions Streaming", - "10": "LiteLLM (Sync)", - "11": "LiteLLM (Async)" - } - # Filter providers for transcription mode - elif mode == "6": - # Only OpenAI Transcription provider supports audio transcription - providers = { - "13": "OpenAI Transcriptions - Whisper" - } - # Filter providers for image generation mode - elif mode == "7": - # Only image generation providers - providers = { - "15": "OpenAI Responses (Image Generation)", - "16": "Google Gemini (Image Generation)" - } - - print("\nAvailable AI Providers:") - print("=" * 50) - for key, name in providers.items(): - print(f" {key}. {name}") - print("=" * 50) - - return providers - -def get_provider_choice(allow_mode_change=False, allow_all=False, valid_choices=None): - """Get user's provider choice""" - if valid_choices is None: - valid_choices = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18"] - - # Build prompt based on valid choices - if len(valid_choices) == 1: - prompt = f"\nSelect a provider ({valid_choices[0]})" - elif len(valid_choices) == 2: - prompt = f"\nSelect a provider ({valid_choices[0]}-{valid_choices[1]})" - elif len(valid_choices) == 3: - prompt = f"\nSelect a provider ({valid_choices[0]}-{valid_choices[2]})" - else: - prompt = "\nSelect a provider (1-18)" - - if allow_all: - prompt += ", 'a' for all providers" - if allow_mode_change: - prompt += ", or 'm' to change mode" - prompt += ": " - - while True: - try: - choice = input(prompt).strip().lower() - if choice in valid_choices: - clear_screen() - return choice - elif allow_all and choice == "a": - clear_screen() - return "all" - elif allow_mode_change and choice == "m": - clear_screen() - return "mode_change" - else: - print("โŒ Invalid choice. Please select a valid option.") - except KeyboardInterrupt: - print("\n\n๐Ÿ‘‹ Goodbye!") - exit(0) - -def prompt_thinking_config(): - """Ask user if they want extended thinking enabled for Anthropic""" - print("\n๐Ÿง  Extended Thinking Configuration") - print("=" * 50) - print("Extended thinking shows Claude's reasoning process.") - print("This can improve response quality for complex problems.") - print("=" * 50) - - while True: - try: - choice = input("\nEnable extended thinking? (y/n) [default: n]: ").strip().lower() - if choice in ["y", "yes"]: - # Ask for budget (optional) - budget_input = input("Thinking budget tokens (1024-32000) [default: 10000]: ").strip() - if budget_input: - try: - budget = int(budget_input) - budget = max(1024, min(budget, 32000)) # Clamp between 1024 and 32000 - return True, budget - except ValueError: - print("โš ๏ธ Invalid number, using default (10000)") - return True, 10000 - return True, 10000 - elif choice in ["n", "no", ""]: - return False, None - else: - print("โŒ Please enter 'y' or 'n'") - except KeyboardInterrupt: - print("\n\n๐Ÿ‘‹ Goodbye!") - exit(0) - -def create_provider(choice, enable_thinking=False, thinking_budget=None): - """Create the selected provider instance""" - if choice == "1": - return AnthropicProvider(posthog, enable_thinking, thinking_budget) - elif choice == "2": - return AnthropicStreamingProvider(posthog, enable_thinking, thinking_budget) - elif choice == "3": - return GeminiProvider(posthog) - elif choice == "4": - return GeminiStreamingProvider(posthog) - elif choice == "5": - return LangChainProvider(posthog) - elif choice == "6": - return OpenAIProvider(posthog) - elif choice == "7": - return OpenAIStreamingProvider(posthog) - elif choice == "8": - return OpenAIChatProvider(posthog) - elif choice == "9": - return OpenAIChatStreamingProvider(posthog) - elif choice == "10": - return LiteLLMProvider(posthog) - elif choice == "11": - return LiteLLMStreamingProvider(posthog) - elif choice == "12": - return OpenAIOtelProvider(posthog) - elif choice == "13": - return OpenAITranscriptionProvider(posthog) - elif choice == "14": - return OpenAIAgentsRunner(posthog) - elif choice == "15": - return OpenAIImageProvider(posthog) - elif choice == "16": - return GeminiImageProvider(posthog) - elif choice == "17": - return PydanticAIOtelProvider(posthog) - elif choice == "18": - return LangChainOtelProvider(posthog) - -def run_chat(provider): - """Run the chat loop with the selected provider""" - print(f'\n๐Ÿค– Welcome to the chatbot using {provider.get_name()}!') - print("๐ŸŒค๏ธ All providers have weather tools - just ask about weather in any city!") - - # Show additional info for LangChain if available - if hasattr(provider, 'get_description'): - print(provider.get_description()) - - print("Type your messages below. Type 'q' to return to provider selection.\n") - - while True: - try: - user_input = input('๐Ÿ‘ค You: ').strip() - - if not user_input: - continue - - # Check for quit command to return to provider selection - if user_input.lower() == 'q': - print('\nโ†ฉ๏ธ Returning to provider selection...\n') - return True - - # Check if provider supports streaming - if hasattr(provider, 'chat_stream') and callable(getattr(provider, 'chat_stream')): - # Stream the response - import sys - print('\n๐Ÿค– Bot: ', end='', flush=True) - for chunk in provider.chat_stream(user_input): - sys.stdout.write(chunk) - sys.stdout.flush() - print() # New line after streaming completes - else: - # Get response from the provider - response = provider.chat(user_input) - print(f'\n๐Ÿค– Bot: {response}') - - print('โ”€' * 50) - - except KeyboardInterrupt: - print('\n\n๐Ÿ‘‹ Goodbye!') - exit(0) - except Exception as error: - print(f'โŒ Error: {str(error)}') - print('โ”€' * 50) - -def run_tool_call_test(provider): - """Run automated tool call test with weather query""" - test_query = "What is the weather in Montreal, Canada?" - - print(f'\nTool Call Test: {provider.get_name()}') - print('-' * 50) - print(f'Query: "{test_query}"') - print() - - try: - # Reset conversation for clean test - provider.reset_conversation() - - # Send the test query - response = provider.chat(test_query) - - print(f'Response: {response}') - print() - return True, None - - except Exception as error: - print(f'โŒ Error: {str(error)}') - return False, str(error) - -def run_message_test(provider): - """Run automated message test with simple greeting""" - test_query = "Hi, how are you today?" - - print(f'\nMessage Test: {provider.get_name()}') - print('-' * 50) - print(f'Query: "{test_query}"') - print() - - try: - # Reset conversation for clean test - provider.reset_conversation() - - # Send the test query - response = provider.chat(test_query) - - print(f'Response: {response}') - print() - return True, None - - except Exception as error: - print(f'โŒ Error: {str(error)}') - return False, str(error) - -def run_image_test(provider): - """Run automated image test with sample image""" - base64_image = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==' - test_query = 'What do you see in this image? Please describe it.' - - print(f'\nImage Test: {provider.get_name()}') - print('-' * 50) - print(f'Query: "{test_query}"') - print('Image: 1x1 red pixel (base64 encoded)') - print() - - try: - # Reset conversation for clean test - provider.reset_conversation() - - # Send the test query with image - response = provider.chat(test_query, base64_image) - - print(f'Response: {response}') - print() - return True, None - - except Exception as error: - print(f'โŒ Error: {str(error)}') - return False, str(error) - -def run_embeddings_test(provider): - """Run automated embeddings test""" - test_texts = [ - "The quick brown fox jumps over the lazy dog." - ] - - print(f'\nEmbeddings Test: {provider.get_name()}') - print('-' * 50) - - # Check if provider supports embeddings - if not hasattr(provider, 'embed'): - print(f'โŒ {provider.get_name()} does not support embeddings') - return False, "Provider does not support embeddings" - - try: - for i, text in enumerate(test_texts, 1): - print(f'\nTest {i}: "{text}"') - - # Generate embeddings - embedding = provider.embed(text) - - if embedding: - print(f'โœ… Generated embedding with {len(embedding)} dimensions') - # Show first 5 values as sample - print(f' Sample values: {embedding[:5]}...') - else: - print('โŒ Failed to generate embedding') - return False, f"Failed to generate embedding for text {i}" - - print() - return True, None - - except Exception as error: - print(f'โŒ Error: {str(error)}') - return False, str(error) - -def run_image_generation_test(provider): - """Run automated image generation test""" - test_prompt = "A serene mountain landscape at sunset with a crystal clear lake reflecting the sky" - - print(f'\nImage Generation Test: {provider.get_name()}') - print('-' * 50) - print(f'Prompt: "{test_prompt}"') - print() - - # Check if provider supports image generation - if not hasattr(provider, 'generate_image'): - print(f'โŒ {provider.get_name()} does not support image generation') - return False, "Provider does not support image generation" - - try: - # Generate image - image_url = provider.generate_image(test_prompt) - - if image_url: - print(f'โœ… Image generated successfully') - print(f' {image_url}') - else: - print(f'โŒ Failed to generate image') - return False, "Failed to generate image" - - print() - return True, None - - except Exception as error: - print(f'โŒ Error: {str(error)}') - return False, str(error) - - -def run_transcription_test(provider): - """Run automated transcription test with audio file""" - print(f'\nTranscription Test: {provider.get_name()}') - print('-' * 50) - - # Check if provider supports transcription - if not hasattr(provider, 'transcribe') or not callable(getattr(provider, 'transcribe')): - print(f'โŒ {provider.get_name()} does not support transcription') - return False, "Provider does not support transcription" - - # Check for test audio file (in the root of llm-analytics-apps) - script_dir = os.path.dirname(os.path.abspath(__file__)) - audio_path = os.path.join(script_dir, '..', 'test-audio.mp3') - - if not os.path.exists(audio_path): - print(f'โŒ Test audio file not found at: {audio_path}') - print('Please create a test audio file (test-audio.mp3) in the llm-analytics-apps directory') - print('You can use any short audio file (mp3, wav, m4a, etc.)') - return False, "Test audio file not found" - - try: - print(f'\nTranscribing audio file: {audio_path}') - print() - - # Transcribe the audio - transcription = provider.transcribe(audio_path) - - if transcription and len(transcription) > 0: - print('โœ… Transcription successful') - print('\nTranscription text:') - print(f'"{transcription}"') - print() - else: - print('โŒ Failed to transcribe audio') - return False, "Failed to generate transcription" - - return True, None - - except Exception as error: - print(f'โŒ Error: {str(error)}') - return False, str(error) - - -def run_all_tests(mode): - """Run tests on all providers and show summary""" - providers_info = [ - ("1", "Anthropic"), - ("2", "Anthropic Streaming"), - ("3", "Google Gemini"), - ("4", "Google Gemini Streaming"), - ("5", "LangChain (OpenAI)"), - ("6", "OpenAI Responses"), - ("7", "OpenAI Responses Streaming"), - ("8", "OpenAI Chat Completions"), - ("9", "OpenAI Chat Completions Streaming"), - ("10", "LiteLLM (Sync)"), - ("11", "LiteLLM (Async)"), - ("14", "OpenAI Agents SDK"), - ("17", "Pydantic AI with OpenTelemetry"), - ("18", "LangChain with OpenTelemetry") - ] - - # Filter providers for embeddings test (only those that support it) - if mode == "5": - # Only OpenAI providers and LiteLLM support embeddings - providers_info = [ - ("6", "OpenAI Responses"), - ("7", "OpenAI Responses Streaming"), - ("8", "OpenAI Chat Completions"), - ("9", "OpenAI Chat Completions Streaming"), - ("10", "LiteLLM (Sync)"), - ("11", "LiteLLM (Async)") - ] - # Filter providers for image generation test - elif mode == "7": - # Only image generation providers - providers_info = [ - ("15", "OpenAI Responses (Image Generation)"), - ("16", "Google Gemini (Image Generation)") - ] - - if mode == "2": - test_name = "Tool Call Test" - elif mode == "3": - test_name = "Message Test" - elif mode == "4": - test_name = "Image Test" - elif mode == "5": - test_name = "Embeddings Test" - elif mode == "6": - test_name = "Transcription Test" - elif mode == "7": - test_name = "Image Generation Test" - else: - test_name = "Unknown Test" - - print(f"\n๐Ÿ”„ Running {test_name} on all providers...") - print("=" * 60) - print() - - results = [] - - for provider_id, provider_name in providers_info: - print(f"[{provider_id}/{len(providers_info)}] Testing {provider_name}...") - - try: - # For automated tests, don't enable thinking by default - provider = create_provider(provider_id, False, None) - - # Run the appropriate test - if mode == "2": - success, error = run_tool_call_test(provider) - elif mode == "3": - success, error = run_message_test(provider) - elif mode == "4": - success, error = run_image_test(provider) - elif mode == "5": - success, error = run_embeddings_test(provider) - elif mode == "6": - success, error = run_transcription_test(provider) - elif mode == "7": - success, error = run_image_generation_test(provider) - else: - success, error = False, "Unknown test mode" - - results.append({ - "name": provider_name, - "success": success, - "error": error - }) - - except Exception as init_error: - print(f" โŒ Failed to initialize: {str(init_error)}") - results.append({ - "name": provider_name, - "success": False, - "error": f"Initialization failed: {str(init_error)}" - }) - - # Print summary - print("\n" + "=" * 60) - print(f"๐Ÿ“Š {test_name} Summary") - print("=" * 60) - - successful = [r for r in results if r["success"]] - failed = [r for r in results if not r["success"]] - - print(f"\nโœ… Successful: {len(successful)}/{len(results)}") - for result in successful: - print(f" โ€ข {result['name']}") - - if failed: - print(f"\nโŒ Failed: {len(failed)}/{len(results)}") - for result in failed: - print(f" โ€ข {result['name']}") - print(f" Error: {result['error']}") - - print("=" * 60) - print() - -def main(): - """Main application entry point""" - clear_screen() - print("\n๐Ÿš€ Unified AI Chatbot") - print("Choose your mode and AI provider") - print() - - # First, select the mode - mode = select_mode() - - # Main loop for provider selection and testing - while True: - # Display providers and get user choice - providers = display_providers(mode) - - # Allow mode change for all modes, 'all' option only for test modes (not transcription - only 1 provider) - allow_mode_change = (mode in ["1", "2", "3", "4", "5", "6", "7"]) - allow_all = (mode in ["2", "3", "4", "5", "6", "7"]) - valid_choices = list(providers.keys()) - choice = get_provider_choice(allow_mode_change=allow_mode_change, allow_all=allow_all, valid_choices=valid_choices) - - # Check if user wants to change mode - if choice == "mode_change": - mode = select_mode() - continue - - # Check if user wants to test all providers - if choice == "all": - run_all_tests(mode) - continue - - # Check if Anthropic provider selected and prompt for thinking config - enable_thinking = False - thinking_budget = None - if choice in ["1", "2"]: # Anthropic providers - enable_thinking, thinking_budget = prompt_thinking_config() - - # Create provider instance - try: - provider = create_provider(choice, enable_thinking, thinking_budget) - status_msg = f"\nโœ… Initialized {provider.get_name()}" - if enable_thinking: - status_msg += f" (Thinking: enabled, budget: {thinking_budget})" - print(status_msg) - - # Show mode selection for OpenAI Agents SDK - if choice == "14" and hasattr(provider, 'prompt_mode_selection'): - provider.prompt_mode_selection() - except Exception as error: - print(f"โŒ Failed to initialize provider: {str(error)}") - continue - - # Execute based on mode - if mode == "1": - # Chat Mode - run interactive chat and continue when done - run_chat(provider) - continue - elif mode == "2": - # Tool Call Test - run test and loop back - success, error = run_tool_call_test(provider) - if not error: - print() - elif mode == "3": - # Message Test - run test and loop back - success, error = run_message_test(provider) - if not error: - print() - elif mode == "4": - # Image Test - run test and loop back - success, error = run_image_test(provider) - if not error: - print() - elif mode == "5": - # Embeddings Test - run test and loop back - success, error = run_embeddings_test(provider) - if not error: - print() - elif mode == "6": - # Transcription Test - run test and loop back - success, error = run_transcription_test(provider) - if not error: - print() - elif mode == "7": - # Image Generation Test - run test and loop back - success, error = run_image_generation_test(provider) - if not error: - print() - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/python/openai_agents/__init__.py b/python/openai_agents/__init__.py deleted file mode 100644 index 8a55d76..0000000 --- a/python/openai_agents/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# OpenAI Agents SDK example with PostHog instrumentation diff --git a/python/openai_agents/agents.py b/python/openai_agents/agents.py deleted file mode 100644 index baff115..0000000 --- a/python/openai_agents/agents.py +++ /dev/null @@ -1,310 +0,0 @@ -""" -OpenAI Agents SDK - Multi-Agent Example with PostHog Tracing - -This example demonstrates: -- Multiple specialized agents with different capabilities -- Agent handoffs based on user intent -- Tool usage (weather, math) -- Input/Output guardrails for content filtering -- Custom spans for tracking custom operations -- PostHog instrumentation for LLM analytics - -The triage agent routes requests to specialized agents: -- Weather Agent: Handles weather-related queries with a weather tool -- Math Agent: Handles calculations and math problems -- General Agent: Handles general questions and conversation -- Guarded Agent: Demonstrates input/output guardrails -""" - -from typing import Annotated -from pydantic import BaseModel, Field -from agents import Agent, Runner, function_tool, input_guardrail, output_guardrail, GuardrailFunctionOutput, RunContextWrapper, TResponseInputItem, trace -from agents.tracing import custom_span - - -# Weather tool and agent -class WeatherInfo(BaseModel): - city: str = Field(description="The city name") - temperature_range: str = Field(description="The temperature range") - conditions: str = Field(description="Weather conditions") - humidity: str = Field(description="Humidity percentage") - - -@function_tool -def get_weather( - city: Annotated[str, "The city to get weather for"], - country: Annotated[str, "The country (optional)"] = "" -) -> WeatherInfo: - """Get current weather information for a city.""" - print(f"[tool] get_weather called for {city}") - # Mock weather data - in production this would call a real API - weather_data = { - "tokyo": WeatherInfo( - city="Tokyo", - temperature_range="18-24ยฐC", - conditions="Partly cloudy", - humidity="65%" - ), - "london": WeatherInfo( - city="London", - temperature_range="12-18ยฐC", - conditions="Overcast with light rain", - humidity="78%" - ), - "new york": WeatherInfo( - city="New York", - temperature_range="15-22ยฐC", - conditions="Clear and sunny", - humidity="55%" - ), - "paris": WeatherInfo( - city="Paris", - temperature_range="14-20ยฐC", - conditions="Sunny", - humidity="60%" - ), - "montreal": WeatherInfo( - city="Montreal", - temperature_range="-5-2ยฐC", - conditions="Snow showers", - humidity="82%" - ), - } - city_lower = city.lower() - if city_lower in weather_data: - return weather_data[city_lower] - return WeatherInfo( - city=city, - temperature_range="15-25ยฐC", - conditions="Clear", - humidity="50%" - ) - - -# Math tool -@function_tool -def calculate( - expression: Annotated[str, "A mathematical expression to evaluate (e.g., '2 + 2 * 3')"] -) -> str: - """Safely evaluate a mathematical expression.""" - print(f"[tool] calculate called with: {expression}") - try: - # Only allow safe math operations - allowed_chars = set("0123456789+-*/().^ ") - if not all(c in allowed_chars for c in expression): - return f"Error: Invalid characters in expression" - # Replace ^ with ** for exponentiation - expression = expression.replace("^", "**") - result = eval(expression) - return f"Result: {result}" - except Exception as e: - return f"Error calculating: {str(e)}" - - -# Specialized Agents -weather_agent = Agent( - name="WeatherAgent", - instructions="""You are a helpful weather assistant. - Use the get_weather tool to fetch weather information for any city the user asks about. - Provide friendly, informative responses about the weather. - Always include the temperature, conditions, and humidity in your response.""", - model="gpt-4o-mini", - tools=[get_weather], -) - -math_agent = Agent( - name="MathAgent", - instructions="""You are a helpful math assistant. - Use the calculate tool to solve mathematical problems. - Explain your work step by step when solving complex problems. - You can handle basic arithmetic, percentages, and simple algebra.""", - model="gpt-4o-mini", - tools=[calculate], -) - -general_agent = Agent( - name="GeneralAgent", - instructions="""You are a helpful general assistant. - You help with general questions, provide information, and engage in friendly conversation. - If the user asks about weather or math, let them know you can help with that too!""", - model="gpt-4o-mini", -) - -# Triage Agent - routes to specialized agents -triage_agent = Agent( - name="TriageAgent", - instructions="""You are a helpful assistant that routes requests to specialized agents. - - Analyze the user's message and hand off to the appropriate agent: - - WeatherAgent: For weather-related questions (forecasts, temperature, conditions) - - MathAgent: For math problems, calculations, or number-related questions - - GeneralAgent: For general questions, conversation, or anything else - - Be efficient - hand off to the specialist right away without asking follow-up questions.""", - model="gpt-4o-mini", - handoffs=[weather_agent, math_agent, general_agent], -) - - -# Simple single agent for basic testing -simple_agent = Agent( - name="SimpleAgent", - instructions="""You are a helpful assistant with weather and math capabilities. - Use your tools to help users with weather queries and calculations.""", - model="gpt-4o-mini", - tools=[get_weather, calculate], -) - - -# ============================================================================ -# Guardrails - demonstrate input/output content filtering -# ============================================================================ - -# Blocked words for content filtering demonstration -BLOCKED_INPUT_WORDS = ["hack", "exploit", "bypass", "illegal"] -BLOCKED_OUTPUT_WORDS = ["confidential", "secret", "classified"] - - -@input_guardrail -async def content_filter_guardrail( - ctx: RunContextWrapper[None], agent: Agent, input: str | list[TResponseInputItem] -) -> GuardrailFunctionOutput: - """ - Input guardrail that filters potentially harmful content. - This demonstrates how guardrails trigger $ai_error_type: input_guardrail_triggered - """ - # Convert input to string for checking - if isinstance(input, list): - input_text = " ".join( - str(item.get("content", "")) if isinstance(item, dict) else str(item) - for item in input - ).lower() - else: - input_text = input.lower() - - # Check for blocked words - for word in BLOCKED_INPUT_WORDS: - if word in input_text: - return GuardrailFunctionOutput( - output_info={"blocked_word": word, "reason": "content_policy"}, - tripwire_triggered=True, - ) - - return GuardrailFunctionOutput( - output_info={"status": "passed"}, - tripwire_triggered=False, - ) - - -@output_guardrail -async def sensitive_data_guardrail( - ctx: RunContextWrapper[None], agent: Agent, output: str -) -> GuardrailFunctionOutput: - """ - Output guardrail that prevents leaking sensitive information. - This demonstrates how guardrails trigger $ai_error_type: output_guardrail_triggered - """ - output_lower = output.lower() - - # Check for sensitive words in output - for word in BLOCKED_OUTPUT_WORDS: - if word in output_lower: - return GuardrailFunctionOutput( - output_info={"blocked_word": word, "reason": "data_protection"}, - tripwire_triggered=True, - ) - - return GuardrailFunctionOutput( - output_info={"status": "passed"}, - tripwire_triggered=False, - ) - - -# Guarded Agent - demonstrates guardrails -guarded_agent = Agent( - name="GuardedAgent", - instructions="""You are a helpful assistant with content filtering. - You help users while respecting content policies and data protection rules. - Be helpful and informative, but avoid discussing anything harmful or leaking sensitive data.""", - model="gpt-4o-mini", - input_guardrails=[content_filter_guardrail], - output_guardrails=[sensitive_data_guardrail], -) - - -# ============================================================================ -# Custom Span Examples - demonstrate custom operation tracking -# ============================================================================ - -async def process_with_custom_spans(user_input: str, group_id: str = None) -> dict: - """ - Example function demonstrating custom spans for tracking custom operations. - This creates nested spans that show up in PostHog as $ai_span events with type=custom. - - Must be wrapped in a trace context to be tracked properly. - """ - result = {} - - # Wrap everything in a trace context so custom spans are recorded - with trace("custom_processing_workflow", group_id=group_id): - - # Outer custom span for the entire processing pipeline - with custom_span(name="data_processing_pipeline"): - - # Custom span for input preprocessing - with custom_span(name="preprocess_input", data={"input_length": len(user_input)}): - # Simulate preprocessing - processed_input = user_input.strip().lower() - result["preprocessed"] = processed_input - - # Custom span for validation - with custom_span(name="validate_input", data={"input": processed_input}): - # Simulate validation - is_valid = len(processed_input) > 0 and len(processed_input) < 1000 - result["valid"] = is_valid - - # Custom span for any custom business logic - with custom_span(name="business_logic", data={"validated": is_valid}): - # Simulate some business logic - if is_valid: - result["processed"] = True - result["word_count"] = len(processed_input.split()) - else: - result["processed"] = False - result["error"] = "Invalid input" - - return result - - -# ============================================================================ -# Error demonstration agent - for testing error tracking -# ============================================================================ - -@function_tool -def unreliable_tool( - action: Annotated[str, "The action to perform: 'succeed', 'fail', or 'error'"] -) -> str: - """A tool that can succeed, fail gracefully, or raise an error for testing.""" - print(f"[tool] unreliable_tool called with action: {action}") - if action == "succeed": - return "Operation completed successfully!" - elif action == "fail": - return "Error: Operation failed due to invalid state" - elif action == "error": - raise ValueError("Simulated tool error for testing error tracking") - else: - return f"Unknown action: {action}" - - -error_demo_agent = Agent( - name="ErrorDemoAgent", - instructions="""You are a testing assistant that demonstrates error handling. - Use the unreliable_tool to test different outcomes: - - 'succeed': Shows successful tool execution - - 'fail': Shows graceful failure handling - - 'error': Shows exception handling and error tracking - - When asked to test errors, use the appropriate action.""", - model="gpt-4o-mini", - tools=[unreliable_tool], -) diff --git a/python/openai_agents/runner.py b/python/openai_agents/runner.py deleted file mode 100644 index 0eac356..0000000 --- a/python/openai_agents/runner.py +++ /dev/null @@ -1,481 +0,0 @@ -""" -OpenAI Agents SDK Runner with PostHog Instrumentation - -This module provides the integration between OpenAI Agents SDK and PostHog -for LLM analytics tracking. -""" - -import os -import uuid -from typing import Any, Dict, List, Optional - -from agents import Runner, RunConfig - -from .agents import ( - triage_agent, - simple_agent, - guarded_agent, - error_demo_agent, - process_with_custom_spans, -) - - -def setup_posthog_tracing(posthog_client, distinct_id: Optional[str] = None): - """ - Initialize PostHog tracing for OpenAI Agents SDK. - - This registers a PostHogTracingProcessor with the Agents SDK that - automatically captures all traces, spans, and generations. - """ - try: - from posthog.ai.openai_agents import instrument - - processor = instrument( - client=posthog_client, - distinct_id=distinct_id or os.getenv("POSTHOG_DISTINCT_ID", "openai-agents-user"), - privacy_mode=False, - properties={ - "app": "llm-analytics-apps", - "agent_type": "openai-agents-sdk", - } - ) - print("[PostHog] OpenAI Agents SDK instrumentation enabled") - return processor - except ImportError as e: - print(f"[PostHog] Warning: Could not enable instrumentation: {e}") - print("[PostHog] Make sure posthog-python is installed with OpenAI agents support") - return None - except Exception as e: - print(f"[PostHog] Error enabling instrumentation: {e}") - return None - - -class OpenAIAgentsRunner: - """ - Runner class for OpenAI Agents SDK examples with PostHog integration. - - Supports multiple modes: - - Multi-agent with handoffs (triage -> specialized agents) - - Single agent with tools - - Guarded agent with input/output guardrails - - Error demo agent for testing error tracking - - Custom spans demo for nested span tracking - """ - - # Mode constants - MODE_TRIAGE = 1 - MODE_SIMPLE = 2 - MODE_GUARDED = 3 - MODE_ERROR_DEMO = 4 - MODE_CUSTOM_SPANS = 5 - - MODE_NAMES = { - MODE_TRIAGE: "Triage (Multi-Agent with Handoffs)", - MODE_SIMPLE: "Simple (Single Agent with Tools)", - MODE_GUARDED: "Guarded (Input/Output Guardrails)", - MODE_ERROR_DEMO: "Error Demo (Error Tracking)", - MODE_CUSTOM_SPANS: "Custom Spans (Nested Tracking)", - } - - def __init__(self, posthog_client=None): - self.posthog_client = posthog_client - self.conversation_id = str(uuid.uuid4().hex[:16]) - self._processor = None - self._history: List[Dict[str, Any]] = [] - self._mode = self.MODE_TRIAGE # Default mode - - # Initialize PostHog tracing if client provided - if posthog_client: - self._processor = setup_posthog_tracing(posthog_client) - - def prompt_mode_selection(self): - """Show mode selection menu and set the mode.""" - print("\nSelect agent mode:") - for mode_id, mode_name in self.MODE_NAMES.items(): - default = " - DEFAULT" if mode_id == self.MODE_TRIAGE else "" - print(f" {mode_id}. {mode_name}{default}") - - mode_input = input("\nEnter mode [1-5, default=1]: ").strip() - self._mode = int(mode_input) if mode_input.isdigit() and 1 <= int(mode_input) <= 5 else self.MODE_TRIAGE - - print(f"\n[Mode] {self.MODE_NAMES[self._mode]}") - - # Show mode-specific description - if self._mode == self.MODE_TRIAGE: - print(" Routes your message to specialized agents (Weather/Math/General).") - print(" Demonstrates agent handoffs and multi-agent workflows.") - elif self._mode == self.MODE_SIMPLE: - print(" Single agent with weather and math tools.") - print(" Simpler traces without handoffs - good for basic tool call testing.") - elif self._mode == self.MODE_GUARDED: - print(" Agent with input/output guardrails for content filtering.") - print(" Try words like 'hack' or 'exploit' to trigger input guardrails.") - elif self._mode == self.MODE_ERROR_DEMO: - print(" Agent with an unreliable tool to test error tracking.") - print(" Ask it to test 'succeed', 'fail', or 'error' actions.") - elif self._mode == self.MODE_CUSTOM_SPANS: - print(" Processes text through nested custom spans (no LLM call).") - print(" Check PostHog for $ai_span events with type=custom.") - - def get_name(self) -> str: - return "OpenAI Agents SDK" - - def get_description(self) -> str: - return """ -OpenAI Agents SDK Demo - Features: -- Multi-agent system with handoffs (Triage -> Weather/Math/General) -- Tool usage tracking (weather, math, unreliable_tool) -- Input/Output guardrails for content filtering -- Custom spans for tracking custom operations -- Error tracking with $ai_error_type categorization -""" - - def chat(self, user_input: str, base64_image: str = None) -> str: - """ - Main chat method compatible with the provider interface. - - Routes to the appropriate agent based on the selected mode. - Uses Runner.run_sync() which properly handles the event loop - and context variables for tracing. Maintains conversation history - and uses group_id to link related traces in PostHog. - """ - import asyncio - - # Handle custom spans mode specially (no LLM call) - if self._mode == self.MODE_CUSTOM_SPANS: - result = asyncio.run(process_with_custom_spans(user_input, group_id=self.conversation_id)) - return f"Custom spans processed: {result}" - - # Add user message to history - self._history.append({"role": "user", "content": user_input}) - - # Configure run with group_id to link related traces - run_config = RunConfig( - group_id=self.conversation_id, - tracing_disabled=False, - ) - - # Select agent based on mode - if self._mode == self.MODE_SIMPLE: - agent = simple_agent - elif self._mode == self.MODE_GUARDED: - agent = guarded_agent - elif self._mode == self.MODE_ERROR_DEMO: - agent = error_demo_agent - else: # MODE_TRIAGE (default) - agent = triage_agent - - # Pass conversation history to the agent - result = Runner.run_sync( - agent, - self._history, - run_config=run_config, - ) - - # Add assistant response to history - response = str(result.final_output) - self._history.append({"role": "assistant", "content": response}) - - return response - - def chat_simple(self, user_input: str) -> str: - """Run the simple single agent with tools.""" - self._history.append({"role": "user", "content": user_input}) - - run_config = RunConfig( - group_id=self.conversation_id, - tracing_disabled=False, - ) - - result = Runner.run_sync( - simple_agent, - self._history, - run_config=run_config, - ) - - response = str(result.final_output) - self._history.append({"role": "assistant", "content": response}) - - return response - - def chat_guarded(self, user_input: str) -> str: - """ - Run the guarded agent with input/output guardrails. - - This demonstrates: - - Input guardrails blocking harmful content - - Output guardrails preventing data leaks - - Error tracking with $ai_error_type for guardrail triggers - """ - self._history.append({"role": "user", "content": user_input}) - - run_config = RunConfig( - group_id=self.conversation_id, - tracing_disabled=False, - ) - - result = Runner.run_sync( - guarded_agent, - self._history, - run_config=run_config, - ) - - response = str(result.final_output) - self._history.append({"role": "assistant", "content": response}) - - return response - - def chat_error_demo(self, user_input: str) -> str: - """ - Run the error demo agent to test error tracking. - - This demonstrates: - - Tool execution errors - - $ai_error and $ai_error_type tracking - """ - self._history.append({"role": "user", "content": user_input}) - - run_config = RunConfig( - group_id=self.conversation_id, - tracing_disabled=False, - ) - - result = Runner.run_sync( - error_demo_agent, - self._history, - run_config=run_config, - ) - - response = str(result.final_output) - self._history.append({"role": "assistant", "content": response}) - - return response - - async def run_custom_spans_demo(self, user_input: str) -> dict: - """ - Run the custom spans demo to show nested custom span tracking. - - This demonstrates: - - Custom spans with type=custom - - Nested span hierarchies - - Custom data attached to spans - """ - return await process_with_custom_spans(user_input) - - def reset_conversation(self): - """Reset the conversation ID and history for a new session.""" - self.conversation_id = str(uuid.uuid4().hex[:16]) - self._history = [] - - -def run_demo(posthog_client=None): - """ - Run a comprehensive demo of the OpenAI Agents SDK with PostHog tracing. - Showcases all features: handoffs, tools, guardrails, custom spans, errors. - """ - import asyncio - - print("\n" + "=" * 60) - print("OpenAI Agents SDK Demo with PostHog Tracing") - print("=" * 60) - - runner = OpenAIAgentsRunner(posthog_client) - print(runner.get_description()) - - # Demo 1: Multi-agent handoffs - print("\n" + "=" * 50) - print("DEMO 1: Multi-Agent Handoffs & Tools") - print("=" * 50) - - handoff_queries = [ - "What's the weather like in Tokyo?", - "Calculate 15% of 250", - "Tell me a fun fact about pandas", - ] - - for query in handoff_queries: - print(f"\n[User] {query}") - try: - response = runner.chat(query) - print(f"[Agent] {response}") - except Exception as e: - print(f"[Error] {e}") - - runner.reset_conversation() - - # Demo 2: Guardrails - print("\n" + "=" * 50) - print("DEMO 2: Input/Output Guardrails") - print("=" * 50) - - print("\n[Testing input guardrail - should block 'hack']") - try: - response = runner.chat_guarded("How do I hack into a system?") - print(f"[Agent] {response}") - except Exception as e: - print(f"[Guardrail Triggered] {e}") - - runner.reset_conversation() - - print("\n[Testing normal message - should pass]") - try: - response = runner.chat_guarded("What's a good recipe for pasta?") - print(f"[Agent] {response}") - except Exception as e: - print(f"[Error] {e}") - - runner.reset_conversation() - - # Demo 3: Custom Spans - print("\n" + "=" * 50) - print("DEMO 3: Custom Spans (Nested Tracking)") - print("=" * 50) - - print("\n[Processing text through custom span pipeline]") - try: - result = asyncio.run(runner.run_custom_spans_demo("Hello World from PostHog!")) - print(f"[Result] {result}") - except Exception as e: - print(f"[Error] {e}") - - # Demo 4: Error Tracking - print("\n" + "=" * 50) - print("DEMO 4: Error Tracking") - print("=" * 50) - - print("\n[Testing successful tool call]") - try: - response = runner.chat_error_demo("Please test the succeed action") - print(f"[Agent] {response}") - except Exception as e: - print(f"[Error] {e}") - - runner.reset_conversation() - - print("\n[Testing error tool call - will raise exception]") - try: - response = runner.chat_error_demo("Please test the error action") - print(f"[Agent] {response}") - except Exception as e: - print(f"[Error Tracked] {e}") - - print("\n" + "=" * 50) - print("[Demo Complete] Check PostHog for:") - print(" - $ai_trace events (workflow traces)") - print(" - $ai_generation events (LLM calls)") - print(" - $ai_span events (agents, tools, handoffs, guardrails)") - print(" - $ai_error_type for categorized errors") - print(" - $ai_total_tokens for token counts") - print("=" * 50) - - # Flush PostHog events - if posthog_client: - try: - posthog_client.flush() - except Exception: - pass - - -def run_interactive(posthog_client=None): - """ - Run an interactive chat session with the OpenAI Agents SDK. - """ - import asyncio - - print("\n" + "=" * 60) - print("OpenAI Agents SDK - Interactive Mode") - print("=" * 60) - - runner = OpenAIAgentsRunner(posthog_client) - - # Agent mode selection - print("\nSelect agent mode:") - print(" 1. Triage (multi-agent with handoffs) - DEFAULT") - print(" 2. Simple (single agent with tools)") - print(" 3. Guarded (input/output guardrails demo)") - print(" 4. Error Demo (error tracking demo)") - print(" 5. Custom Spans Demo (nested span tracking)") - - mode_input = input("\nEnter mode [1-5, default=1]: ").strip() - mode = int(mode_input) if mode_input.isdigit() and 1 <= int(mode_input) <= 5 else 1 - - mode_names = { - 1: "Triage (Multi-Agent)", - 2: "Simple (Single Agent)", - 3: "Guarded (Guardrails)", - 4: "Error Demo", - 5: "Custom Spans Demo", - } - print(f"\n[Mode] {mode_names[mode]}") - - if mode == 3: - print("\n[Guardrails Info]") - print(" - Input blocked words: hack, exploit, bypass, illegal") - print(" - Output blocked words: confidential, secret, classified") - print(" Try messages containing these words to trigger guardrails!") - - if mode == 4: - print("\n[Error Demo Info]") - print(" Ask the agent to test 'succeed', 'fail', or 'error' actions") - print(" Example: 'Please test the error action'") - - if mode == 5: - print("\n[Custom Spans Demo]") - print(" Enter any text to process through nested custom spans") - print(" Watch PostHog for $ai_span events with type=custom") - - print("\nType 'quit' or 'q' to exit, 'reset' to start a new conversation") - print("-" * 60) - - while True: - try: - user_input = input("\n[You] ").strip() - - if not user_input: - continue - - if user_input.lower() in ['quit', 'q', 'exit']: - print("\nGoodbye!") - break - - if user_input.lower() == 'reset': - runner.reset_conversation() - print("[System] Conversation reset") - continue - - # Route to appropriate method based on mode - if mode == 1: - response = runner.chat(user_input) - elif mode == 2: - response = runner.chat_simple(user_input) - elif mode == 3: - response = runner.chat_guarded(user_input) - elif mode == 4: - response = runner.chat_error_demo(user_input) - elif mode == 5: - result = asyncio.run(runner.run_custom_spans_demo(user_input)) - response = f"Custom spans processed: {result}" - else: - response = runner.chat(user_input) - - print(f"[Agent] {response}") - - except KeyboardInterrupt: - print("\n\nGoodbye!") - break - except Exception as e: - print(f"[Error] {e}") - import traceback - traceback.print_exc() - - # Flush PostHog events - if posthog_client: - try: - posthog_client.flush() - except Exception: - pass - - -if __name__ == "__main__": - # Can be run standalone for testing - run_demo() diff --git a/python/providers/__init__.py b/python/providers/__init__.py deleted file mode 100644 index 90394f6..0000000 --- a/python/providers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Provider implementations for unified AI chatbot \ No newline at end of file diff --git a/python/providers/anthropic.py b/python/providers/anthropic.py deleted file mode 100644 index 65c3094..0000000 --- a/python/providers/anthropic.py +++ /dev/null @@ -1,222 +0,0 @@ -import os -from posthog.ai.anthropic import Anthropic -from posthog import Posthog -from .base import BaseProvider -from .constants import ( - ANTHROPIC_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_POSTHOG_DISTINCT_ID, - DEFAULT_THINKING_ENABLED, - DEFAULT_THINKING_BUDGET_TOKENS -) - -class AnthropicProvider(BaseProvider): - def __init__(self, posthog_client: Posthog, enable_thinking: bool = False, thinking_budget: int = None): - super().__init__(posthog_client) - - # Set span name for this provider (merge with existing super_properties) - existing_props = posthog_client.super_properties or {} - posthog_client.super_properties = {**existing_props, "$ai_span_name": "anthropic_messages"} - - self.client = Anthropic( - api_key=os.getenv("ANTHROPIC_API_KEY"), - posthog_client=posthog_client - ) - self.enable_thinking = enable_thinking - self.thinking_budget = thinking_budget or DEFAULT_THINKING_BUDGET_TOKENS - - def get_tool_definitions(self): - """Return tool definitions in Anthropic format""" - return [ - { - "name": "get_weather", - "description": "Get the current weather for a specific location using geographical coordinates", - "input_schema": { - "type": "object", - "properties": { - "latitude": {"type": "number", "description": "The latitude of the location"}, - "longitude": {"type": "number", "description": "The longitude of the location"}, - "location_name": {"type": "string", "description": "A human-readable name for the location"}, - }, - "required": ["latitude", "longitude", "location_name"], - }, - }, - { - "name": "tell_joke", - "description": "Tell a joke with a question-style setup and an answer punchline", - "input_schema": { - "type": "object", - "properties": { - "setup": {"type": "string", "description": "The setup or question part of the joke"}, - "punchline": {"type": "string", "description": "The punchline or answer to the joke"}, - }, - "required": ["setup", "punchline"], - }, - }, - { - "name": "roll_dice", - "description": "Roll one or more dice with a specified number of sides", - "input_schema": { - "type": "object", - "properties": { - "num_dice": {"type": "integer", "description": "Number of dice to roll (default: 1)"}, - "sides": {"type": "integer", "description": "Number of sides per die (default: 6)"}, - }, - "required": [], - }, - }, - { - "name": "check_time", - "description": "Get the current time in a specific timezone (e.g., 'America/New_York', 'Europe/London', 'Asia/Tokyo')", - "input_schema": { - "type": "object", - "properties": { - "timezone": {"type": "string", "description": "IANA timezone name (e.g., 'US/Eastern', 'Europe/Paris')"}, - }, - "required": ["timezone"], - }, - }, - { - "name": "calculate", - "description": "Evaluate a mathematical expression (basic arithmetic: +, -, *, /, parentheses)", - "input_schema": { - "type": "object", - "properties": { - "expression": {"type": "string", "description": "The math expression to evaluate (e.g., '(2 + 3) * 4')"}, - }, - "required": ["expression"], - }, - }, - { - "name": "convert_units", - "description": "Convert a value between common units (km/miles, kg/lbs, celsius/fahrenheit, meters/feet, liters/gallons)", - "input_schema": { - "type": "object", - "properties": { - "value": {"type": "number", "description": "The numeric value to convert"}, - "from_unit": {"type": "string", "description": "The source unit (e.g., 'km', 'celsius', 'kg')"}, - "to_unit": {"type": "string", "description": "The target unit (e.g., 'miles', 'fahrenheit', 'lbs')"}, - }, - "required": ["value", "from_unit", "to_unit"], - }, - }, - { - "name": "generate_inspirational_quote", - "description": "Get an inspirational quote, optionally on a specific topic (general, perseverance, creativity, success, teamwork)", - "input_schema": { - "type": "object", - "properties": { - "topic": {"type": "string", "description": "Topic for the quote (general, perseverance, creativity, success, teamwork)"}, - }, - "required": [], - }, - }, - ] - - def get_name(self): - return "Anthropic" - - def chat(self, user_input: str, base64_image: str = None) -> str: - """Send a message to Anthropic and get response""" - # Add user message to history - if base64_image: - # For image input, create content array with text and image - user_content = [ - {"type": "text", "text": user_input}, - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": base64_image - } - } - ] - else: - user_content = user_input - - user_message = { - "role": "user", - "content": user_content - } - self.messages.append(user_message) - - # Prepare API request parameters - # Note: max_tokens must be greater than thinking.budget_tokens - thinking_budget = max(self.thinking_budget, 1024) if self.enable_thinking else 0 - max_tokens = max(DEFAULT_MAX_TOKENS, thinking_budget + 2000) if self.enable_thinking else DEFAULT_MAX_TOKENS - - request_params = { - "model": ANTHROPIC_MODEL, - "max_tokens": max_tokens, - "posthog_distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - "tools": self.tools, - "messages": self.messages - } - - # Add extended thinking if enabled - if self.enable_thinking: - request_params["thinking"] = { - "type": "enabled", - "budget_tokens": thinking_budget - } - - # Send all messages in conversation history - message = self.client.messages.create(**request_params) - - # Debug: Log the API call (request + response) - self._debug_api_call("Anthropic", request_params, message) - - # Process all content blocks to handle text, thinking, and tool calls - assistant_content = [] - tool_results = [] - display_parts = [] - - if message.content and len(message.content) > 0: - for content_block in message.content: - if content_block.type == "thinking": - # Store thinking block for message history - assistant_content.append(content_block) - # Display thinking content if enabled - if self.enable_thinking: - display_parts.append(f"๐Ÿ’ญ Thinking: {content_block.thinking}") - elif content_block.type == "tool_use": - # Store the tool use block for message history - assistant_content.append(content_block) - - # Execute the tool and prepare result for display and history - tool_result = self.execute_tool(content_block.name, content_block.input) - if tool_result is not None: - tool_result_text = self.format_tool_result(content_block.name, tool_result) - tool_results.append(tool_result_text) - display_parts.append(tool_result_text) - elif content_block.type == "text": - # Store text content for both display and history - assistant_content.append(content_block) - display_parts.append(content_block.text) - - # Add assistant's response to conversation history - assistant_message = { - "role": "assistant", - "content": assistant_content - } - self.messages.append(assistant_message) - - # If we have tool results, add them as tool result messages to history - if tool_results: - for i, content_block in enumerate(message.content): - if content_block.type == "tool_use": - tool_result_message = { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": content_block.id, - "content": tool_results[0] if tool_results else "Tool executed" - } - ] - } - self.messages.append(tool_result_message) - break - - return "\n\n".join(display_parts) if display_parts else "No response received" \ No newline at end of file diff --git a/python/providers/anthropic_streaming.py b/python/providers/anthropic_streaming.py deleted file mode 100644 index c0c66dd..0000000 --- a/python/providers/anthropic_streaming.py +++ /dev/null @@ -1,325 +0,0 @@ -import os -from posthog.ai.anthropic import Anthropic -from posthog import Posthog -from .base import StreamingProvider -from typing import Generator, Optional -import json -from .constants import ( - ANTHROPIC_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_POSTHOG_DISTINCT_ID, - DEFAULT_THINKING_ENABLED, - DEFAULT_THINKING_BUDGET_TOKENS -) - -class AnthropicStreamingProvider(StreamingProvider): - def __init__(self, posthog_client: Posthog, enable_thinking: bool = False, thinking_budget: int = None): - super().__init__(posthog_client) - - # Set span name for this provider - existing_props = posthog_client.super_properties or {} - posthog_client.super_properties = {**existing_props, "$ai_span_name": "anthropic_messages_streaming"} - - self.client = Anthropic( - api_key=os.getenv("ANTHROPIC_API_KEY"), - posthog_client=posthog_client - ) - self.enable_thinking = enable_thinking - self.thinking_budget = thinking_budget or DEFAULT_THINKING_BUDGET_TOKENS - - def get_tool_definitions(self): - """Return tool definitions in Anthropic format""" - return [ - { - "name": "get_weather", - "description": "Get the current weather for a specific location using geographical coordinates", - "input_schema": { - "type": "object", - "properties": { - "latitude": { - "type": "number", - "description": "The latitude of the location (e.g., 37.7749 for San Francisco)" - }, - "longitude": { - "type": "number", - "description": "The longitude of the location (e.g., -122.4194 for San Francisco)" - }, - "location_name": { - "type": "string", - "description": "A human-readable name for the location (e.g., 'San Francisco, CA' or 'Dublin, Ireland')" - } - }, - "required": ["latitude", "longitude", "location_name"] - } - }, - { - "name": "tell_joke", - "description": "Tell a joke with a question-style setup and an answer punchline", - "input_schema": { - "type": "object", - "properties": { - "setup": { - "type": "string", - "description": "The setup of the joke, typically a question (e.g., 'Why did the chicken cross the road?')" - }, - "punchline": { - "type": "string", - "description": "The punchline or answer to the joke (e.g., 'To get to the other side!')" - } - }, - "required": ["setup", "punchline"] - } - } - ] - - def get_name(self): - return "Anthropic Streaming" - - def chat_stream(self, user_input: str, base64_image: Optional[str] = None) -> Generator[str, None, None]: - """Send a message to Anthropic and stream the response""" - # Add user message to history - if base64_image: - user_content = [ - {"type": "text", "text": user_input}, - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": base64_image - } - } - ] - else: - user_content = user_input - - user_message = { - "role": "user", - "content": user_content - } - self.messages.append(user_message) - - # Prepare API request parameters - # Note: max_tokens must be greater than thinking.budget_tokens - thinking_budget = max(self.thinking_budget, 1024) if self.enable_thinking else 0 - max_tokens = max(DEFAULT_MAX_TOKENS, thinking_budget + 2000) if self.enable_thinking else DEFAULT_MAX_TOKENS - - request_params = { - "model": ANTHROPIC_MODEL, - "max_tokens": max_tokens, - "posthog_distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - "tools": self.tools, - "messages": self.messages, - "stream": True - } - - # Add extended thinking if enabled - if self.enable_thinking: - request_params["thinking"] = { - "type": "enabled", - "budget_tokens": thinking_budget - } - - # Debug: Log the API request (no response for streaming) - if self.debug_mode: - self._debug_log("Anthropic Streaming API Request", request_params) - - # Create streaming response using create() with stream=True - # The PostHog wrapper's stream() method expects stream=True to be passed - stream = self.client.messages.create(**request_params) - - accumulated_content = "" - assistant_content = [] - tools_used = [] - current_text_block = None - current_thinking_block = None - - # Process the stream events - try: - event_count = 0 - for event in stream: - event_count += 1 - - # Skip events without a type attribute or handle different event structures - if not hasattr(event, 'type'): - continue - - # Handle delta events (text, thinking, tool input) - if event.type == "content_block_delta": - if hasattr(event, 'delta') and hasattr(event.delta, 'thinking'): - # Handle thinking content delta - thinking_text = event.delta.thinking - if current_thinking_block is not None: - current_thinking_block["thinking"] += thinking_text - # Only yield thinking if enabled - if self.enable_thinking: - yield thinking_text - elif hasattr(event, 'delta') and hasattr(event.delta, 'text'): - text = event.delta.text - accumulated_content += text - yield text - # Update the current text block if we're tracking one - if current_text_block is not None: - current_text_block["text"] += text - elif hasattr(event, 'delta') and hasattr(event.delta, 'partial_json'): - # Handle tool input JSON delta - if tools_used: - last_tool = tools_used[-1] - if "input_string" not in last_tool: - last_tool["input_string"] = "" - last_tool["input_string"] += event.delta.partial_json or "" - - # Handle content block start - elif event.type == "content_block_start": - if hasattr(event, 'content_block'): - content_block = event.content_block - - if hasattr(content_block, 'type'): - if content_block.type == "thinking": - # Start a new thinking content block - if self.enable_thinking: - yield "\n\n๐Ÿ’ญ Thinking: " - current_thinking_block = { - "type": "thinking", - "thinking": "" - } - assistant_content.append(current_thinking_block) - current_text_block = None - elif content_block.type == "text": - # Start a new text content block - # If we just had thinking, add some spacing - if current_thinking_block is not None and self.enable_thinking: - yield "\n\n" - current_text_block = { - "type": "text", - "text": "" - } - assistant_content.append(current_text_block) - current_thinking_block = None - elif content_block.type == "tool_use": - tool_info = { - "id": content_block.id if hasattr(content_block, 'id') else None, - "name": content_block.name if hasattr(content_block, 'name') else None, - "input": {} - } - tools_used.append(tool_info) - - # Add tool use to assistant content immediately - assistant_content.append({ - "type": "tool_use", - "id": tool_info["id"], - "name": tool_info["name"], - "input": tool_info["input"] # Will be updated later - }) - current_text_block = None - current_thinking_block = None - - # Handle content block stop (finalize tool, text, or thinking) - elif event.type == "content_block_stop": - current_text_block = None # Reset current text block - current_thinking_block = None # Reset current thinking block - if tools_used and tools_used[-1].get("input_string"): - last_tool = tools_used[-1] - try: - last_tool["input"] = json.loads(last_tool["input_string"]) - del last_tool["input_string"] - - # Update the input in assistant_content - # Find the corresponding tool_use in assistant_content and update its input - for content in assistant_content: - if content.get("type") == "tool_use" and content.get("id") == last_tool["id"]: - content["input"] = last_tool["input"] - break - - # Execute the tool - if last_tool["name"] == "get_weather": - latitude = last_tool["input"].get("latitude", 0.0) - longitude = last_tool["input"].get("longitude", 0.0) - location_name = last_tool["input"].get("location_name") - weather_result = self.get_weather(latitude, longitude, location_name) - tool_result_text = self.format_tool_result("get_weather", weather_result) - yield "\n\n" + tool_result_text - elif last_tool["name"] == "tell_joke": - setup = last_tool["input"].get("setup", "") - punchline = last_tool["input"].get("punchline", "") - joke_result = self.tell_joke(setup, punchline) - tool_result_text = self.format_tool_result("tell_joke", joke_result) - yield "\n\n" + tool_result_text - except (json.JSONDecodeError, AttributeError): - pass - - # Handle message start/stop events - elif event.type in ["message_start", "message_stop", "message_delta"]: - # These events might contain usage info but we can skip them for now - pass - - except Exception as e: - # If there's an error, yield it as part of the response - yield f"\n\nError during streaming: {str(e)}" - - # If we didn't get any events at all, log it - if event_count == 0: - yield "No events received from stream" - - # Save assistant message - assistant_message = { - "role": "assistant", - "content": assistant_content if assistant_content else [{"type": "text", "text": accumulated_content or ""}] - } - self.messages.append(assistant_message) - - # Debug: Log the completed stream response - if self.debug_mode: - self._debug_log("Anthropic Streaming API Response (completed)", { - "accumulated_content": accumulated_content, - "assistant_content": assistant_content, - "tools_used": tools_used, - "event_count": event_count - }) - - # If tools were used, add tool results to messages - for tool in tools_used: - if tool.get("name") == "get_weather" and tool.get("input"): - latitude = tool["input"].get("latitude", 0.0) - longitude = tool["input"].get("longitude", 0.0) - location_name = tool["input"].get("location_name") - weather_result = self.get_weather(latitude, longitude, location_name) - - tool_result_message = { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": tool.get("id"), - "content": weather_result - } - ] - } - self.messages.append(tool_result_message) - elif tool.get("name") == "tell_joke" and tool.get("input"): - setup = tool["input"].get("setup", "") - punchline = tool["input"].get("punchline", "") - joke_result = self.tell_joke(setup, punchline) - - tool_result_message = { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": tool.get("id"), - "content": joke_result - } - ] - } - self.messages.append(tool_result_message) - - def chat(self, user_input: str, base64_image: Optional[str] = None) -> str: - """Non-streaming chat for compatibility""" - chunks = [] - for chunk in self.chat_stream(user_input, base64_image): - chunks.append(chunk) - result = "".join(chunks) - # If no result, return a message indicating no response - if not result.strip(): - return "No response received from streaming" - return result \ No newline at end of file diff --git a/python/providers/base.py b/python/providers/base.py deleted file mode 100644 index bd27874..0000000 --- a/python/providers/base.py +++ /dev/null @@ -1,326 +0,0 @@ -from abc import ABC, abstractmethod -from typing import List, Dict, Any, Optional, Generator -from posthog import Posthog -import os -import json -import math -import random -from datetime import datetime, timezone -from zoneinfo import ZoneInfo -import requests -from .constants import WEATHER_TEMP_MIN_CELSIUS, WEATHER_TEMP_MAX_CELSIUS - -class BaseProvider(ABC): - """Base class for all AI providers""" - - def __init__(self, posthog_client: Posthog): - self.posthog_client = posthog_client - self.messages: List[Dict[str, Any]] = [] - self.tools: List[Dict[str, Any]] = [] - self.debug_mode = os.getenv('DEBUG') == '1' - self._initialize_tools() - - def _initialize_tools(self): - """Initialize tools from provider-specific definitions""" - self.tools = self.get_tool_definitions() - - @abstractmethod - def get_tool_definitions(self) -> List[Dict[str, Any]]: - """Return tool definitions in provider-specific format""" - pass - - @abstractmethod - def get_name(self) -> str: - """Return the name of the provider""" - pass - - @abstractmethod - def chat(self, user_input: str, base64_image: Optional[str] = None) -> str: - """Send a message and get response""" - pass - - def reset_conversation(self): - """Reset the conversation history""" - self.messages = self.get_initial_messages() - - def get_initial_messages(self) -> List[Dict[str, Any]]: - """Return initial messages (e.g., system prompts)""" - return [] - - def get_weather(self, latitude: float, longitude: float, location_name: str = None) -> str: - """Get real weather data from Open-Meteo API using coordinates""" - try: - # Get weather data from Open-Meteo API - weather_url = "https://api.open-meteo.com/v1/forecast" - weather_params = { - "latitude": latitude, - "longitude": longitude, - "current": "temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m", - "temperature_unit": "celsius", - "wind_speed_unit": "kmh", - "precipitation_unit": "mm" - } - - weather_response = requests.get(weather_url, params=weather_params, timeout=10) - weather_response.raise_for_status() - weather_data = weather_response.json() - - current = weather_data.get("current", {}) - temp_celsius = current.get("temperature_2m", 0) - temp_fahrenheit = int(temp_celsius * 9/5 + 32) - feels_like_celsius = current.get("apparent_temperature", temp_celsius) - humidity = current.get("relative_humidity_2m", 0) - wind_speed = current.get("wind_speed_10m", 0) - precipitation = current.get("precipitation", 0) - - # Weather code interpretation (WMO codes) - weather_code = current.get("weather_code", 0) - weather_descriptions = { - 0: "clear skies", - 1: "mainly clear", - 2: "partly cloudy", - 3: "overcast", - 45: "foggy", - 48: "depositing rime fog", - 51: "light drizzle", - 53: "moderate drizzle", - 55: "dense drizzle", - 61: "slight rain", - 63: "moderate rain", - 65: "heavy rain", - 71: "slight snow", - 73: "moderate snow", - 75: "heavy snow", - 77: "snow grains", - 80: "slight rain showers", - 81: "moderate rain showers", - 82: "violent rain showers", - 85: "slight snow showers", - 86: "heavy snow showers", - 95: "thunderstorm", - 96: "thunderstorm with slight hail", - 99: "thunderstorm with heavy hail" - } - weather_desc = weather_descriptions.get(weather_code, "unknown conditions") - - # Use location_name if provided, otherwise fall back to coordinates - location_str = location_name if location_name else f"coordinates ({latitude}, {longitude})" - - result = f"The current weather in {location_str} is {temp_celsius}ยฐC ({temp_fahrenheit}ยฐF) " - result += f"with {weather_desc}. " - result += f"Feels like {feels_like_celsius}ยฐC. " - result += f"Humidity: {humidity}%, Wind: {wind_speed} km/h" - - if precipitation > 0: - result += f", Precipitation: {precipitation} mm" - - return result - - except requests.exceptions.RequestException as e: - # Fallback to mock data if API fails - location_str = location_name if location_name else f"coordinates ({latitude}, {longitude})" - return f"Weather API unavailable for {location_str}. Using mock data: experiencing typical weather conditions." - except (KeyError, ValueError, TypeError) as e: - location_str = location_name if location_name else f"coordinates ({latitude}, {longitude})" - return f"Error parsing weather data for {location_str}: {str(e)}" - - def tell_joke(self, setup: str, punchline: str) -> str: - """Tell a joke with a setup and punchline""" - return f"{setup}\n\n{punchline}" - - def roll_dice(self, num_dice: int = 1, sides: int = 6) -> str: - """Roll dice and return results""" - num_dice = max(1, min(num_dice, 20)) - sides = max(2, min(sides, 100)) - rolls = [random.randint(1, sides) for _ in range(num_dice)] - total = sum(rolls) - if num_dice == 1: - return f"Rolled a d{sides}: {rolls[0]}" - return f"Rolled {num_dice}d{sides}: {rolls} (total: {total})" - - def check_time(self, timezone_name: str = "UTC") -> str: - """Get the current time in a given timezone""" - try: - tz = ZoneInfo(timezone_name) - now = datetime.now(tz) - return f"The current time in {timezone_name} is {now.strftime('%I:%M %p on %A, %B %d, %Y')}" - except Exception: - now = datetime.now(timezone.utc) - return f"Unknown timezone '{timezone_name}'. UTC time is {now.strftime('%I:%M %p on %A, %B %d, %Y')}" - - def calculate(self, expression: str) -> str: - """Evaluate a math expression safely""" - allowed = set("0123456789+-*/.() ") - if not all(c in allowed for c in expression): - return f"Invalid expression: '{expression}'. Only basic arithmetic is supported." - try: - result = eval(expression, {"__builtins__": {}}, {"math": math}) - return f"{expression} = {result}" - except Exception as e: - return f"Error evaluating '{expression}': {e}" - - def convert_units(self, value: float, from_unit: str, to_unit: str) -> str: - """Convert between common units""" - conversions = { - ("km", "miles"): lambda v: v * 0.621371, - ("miles", "km"): lambda v: v * 1.60934, - ("kg", "lbs"): lambda v: v * 2.20462, - ("lbs", "kg"): lambda v: v * 0.453592, - ("celsius", "fahrenheit"): lambda v: v * 9 / 5 + 32, - ("fahrenheit", "celsius"): lambda v: (v - 32) * 5 / 9, - ("meters", "feet"): lambda v: v * 3.28084, - ("feet", "meters"): lambda v: v * 0.3048, - ("liters", "gallons"): lambda v: v * 0.264172, - ("gallons", "liters"): lambda v: v * 3.78541, - } - key = (from_unit.lower(), to_unit.lower()) - if key in conversions: - result = conversions[key](value) - return f"{value} {from_unit} = {result:.2f} {to_unit}" - return f"Cannot convert from {from_unit} to {to_unit}. Supported pairs: {', '.join(f'{a}->{b}' for a, b in conversions.keys())}" - - def generate_inspirational_quote(self, topic: str = "general") -> str: - """Generate an inspirational quote on a topic""" - quotes = { - "general": [ - "The only way to do great work is to love what you do. - Steve Jobs", - "In the middle of difficulty lies opportunity. - Albert Einstein", - "What you get by achieving your goals is not as important as what you become by achieving your goals. - Zig Ziglar", - ], - "perseverance": [ - "It does not matter how slowly you go as long as you do not stop. - Confucius", - "Fall seven times, stand up eight. - Japanese Proverb", - "Perseverance is not a long race; it is many short races one after the other. - Walter Elliot", - ], - "creativity": [ - "Creativity is intelligence having fun. - Albert Einstein", - "The chief enemy of creativity is good sense. - Pablo Picasso", - "Creativity takes courage. - Henri Matisse", - ], - "success": [ - "Success is not final, failure is not fatal: it is the courage to continue that counts. - Winston Churchill", - "The secret of success is to do the common thing uncommonly well. - John D. Rockefeller Jr.", - "Success usually comes to those who are too busy to be looking for it. - Henry David Thoreau", - ], - "teamwork": [ - "Alone we can do so little; together we can do so much. - Helen Keller", - "Coming together is a beginning, staying together is progress, and working together is success. - Henry Ford", - "If everyone is moving forward together, then success takes care of itself. - Henry Ford", - ], - } - topic_quotes = quotes.get(topic.lower(), quotes["general"]) - return random.choice(topic_quotes) - - def execute_tool(self, tool_name: str, tool_input: dict) -> Optional[str]: - """Execute a tool by name and return the result, or None if unknown""" - if tool_name == "get_weather": - return self.get_weather( - tool_input.get("latitude", 0.0), - tool_input.get("longitude", 0.0), - tool_input.get("location_name"), - ) - elif tool_name == "tell_joke": - return self.tell_joke( - tool_input.get("setup", ""), - tool_input.get("punchline", ""), - ) - elif tool_name == "roll_dice": - return self.roll_dice( - tool_input.get("num_dice", 1), - tool_input.get("sides", 6), - ) - elif tool_name == "check_time": - return self.check_time(tool_input.get("timezone", "UTC")) - elif tool_name == "calculate": - return self.calculate(tool_input.get("expression", "0")) - elif tool_name == "convert_units": - return self.convert_units( - tool_input.get("value", 0), - tool_input.get("from_unit", ""), - tool_input.get("to_unit", ""), - ) - elif tool_name == "generate_inspirational_quote": - return self.generate_inspirational_quote(tool_input.get("topic", "general")) - return None - - def format_tool_result(self, tool_name: str, result: str) -> str: - """Format tool result for display""" - icons = { - "get_weather": "๐ŸŒค๏ธ Weather", - "tell_joke": "๐Ÿ˜‚ Joke", - "roll_dice": "๐ŸŽฒ Dice", - "check_time": "๐Ÿ• Time", - "calculate": "๐Ÿงฎ Calculate", - "convert_units": "๐Ÿ“ Convert", - "generate_inspirational_quote": "๐Ÿ’ก Quote", - } - label = icons.get(tool_name, tool_name) - return f"{label}: {result}" - - def _debug_log(self, title: str, data: Any, truncate: bool = True): - """Log debug information in a clear, formatted way""" - if not self.debug_mode: - return - - print("\n" + "=" * 80) - print(f"๐Ÿ› DEBUG: {title}") - print("=" * 80) - - if isinstance(data, (dict, list)): - json_str = json.dumps(data, indent=2, default=str) - # Truncate very long outputs - if truncate and len(json_str) > 5000: - json_str = json_str[:5000] + "\n... (truncated)" - print(json_str) - else: - data_str = str(data) - if truncate and len(data_str) > 5000: - data_str = data_str[:5000] + "\n... (truncated)" - print(data_str) - - print("=" * 80 + "\n") - - def _debug_api_call(self, provider_name: str, request_data: Any, response_data: Any = None): - """ - Simplified debug logging for API calls. - Just pass the request and optionally response objects - they'll be converted to JSON automatically. - - Usage: - # Log request only (before API call) - self._debug_api_call("Anthropic", request_params) - - # Log both request and response (after API call) - self._debug_api_call("Anthropic", request_params, response) - """ - if not self.debug_mode: - return - - # Convert objects to dict for JSON serialization - def to_dict(obj): - if hasattr(obj, 'model_dump'): # Pydantic models - return obj.model_dump() - elif hasattr(obj, '__dict__'): # Regular objects - return obj.__dict__ - elif isinstance(obj, (dict, list, str, int, float, bool, type(None))): - return obj - else: - return str(obj) - - self._debug_log(f"{provider_name} API Request", to_dict(request_data)) - - if response_data is not None: - self._debug_log(f"{provider_name} API Response", to_dict(response_data)) - - -class StreamingProvider(BaseProvider): - """Base class for providers that support streaming""" - - @abstractmethod - def chat_stream(self, user_input: str, base64_image: Optional[str] = None) -> Generator[str, None, None]: - """Send a message and stream the response""" - pass - - def default_chat_stream(self, user_input: str, base64_image: Optional[str] = None) -> Generator[str, None, None]: - """Default implementation that yields the full response at once""" - response = self.chat(user_input, base64_image) - yield response \ No newline at end of file diff --git a/python/providers/constants.py b/python/providers/constants.py deleted file mode 100644 index 00e696f..0000000 --- a/python/providers/constants.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -Provider configuration constants. - -This module centralizes default parameters for all AI providers, -making it easy to update model names, tokens limits, and other -default settings in one place. -""" - -# OpenAI Models -OPENAI_CHAT_MODEL = "gpt-5-mini" -OPENAI_VISION_MODEL = "gpt-4o" -OPENAI_EMBEDDING_MODEL = "text-embedding-3-small" -OPENAI_IMAGE_MODEL = "gpt-image-1-mini" - -# Anthropic Models -ANTHROPIC_MODEL = "claude-sonnet-4-5-20250929" - -# Google Gemini Models -GEMINI_MODEL = "gemini-2.5-flash" -GEMINI_IMAGE_MODEL = "gemini-2.5-flash-image" - -# Default API Parameters -# Note: gpt-5-mini uses reasoning tokens internally, so needs higher limit -DEFAULT_MAX_TOKENS = 4000 - -# Extended Thinking Configuration (Anthropic) -# Set ENABLE_THINKING=1 in .env to enable extended thinking -# Set THINKING_BUDGET_TOKENS in .env to customize (default: 10000, min: 1024) -DEFAULT_THINKING_ENABLED = False -DEFAULT_THINKING_BUDGET_TOKENS = 10000 - -# PostHog Configuration -DEFAULT_POSTHOG_DISTINCT_ID = "user-hog" - -# Weather Tool Configuration -WEATHER_TEMP_MIN_CELSIUS = -10 -WEATHER_TEMP_MAX_CELSIUS = 40 - -# System Prompts -SYSTEM_PROMPT_FRIENDLY = "You are a friendly AI that just makes conversation. You have access to a weather tool if the user asks about weather." -SYSTEM_PROMPT_ASSISTANT = "You are a helpful assistant. You have access to tools that you can use to help answer questions." -SYSTEM_PROMPT_STRUCTURED = "You are an AI assistant that provides structured responses. You can provide weather information, create user profiles, or generate task plans based on user requests." diff --git a/python/providers/gemini.py b/python/providers/gemini.py deleted file mode 100644 index ce0d69e..0000000 --- a/python/providers/gemini.py +++ /dev/null @@ -1,169 +0,0 @@ -import os -from posthog.ai.gemini import Client -from posthog import Posthog -from google.genai import types -from .base import BaseProvider -from .constants import GEMINI_MODEL, DEFAULT_POSTHOG_DISTINCT_ID - -class GeminiProvider(BaseProvider): - def __init__(self, posthog_client: Posthog): - super().__init__(posthog_client) - - # Set span name for this provider - existing_props = posthog_client.super_properties or {} - posthog_client.super_properties = {**existing_props, "$ai_span_name": "gemini_generate_content"} - - self.client = Client( - api_key=os.getenv("GEMINI_API_KEY"), - # vertexai=True, - # project="project-id", - # location="us-central1", - posthog_client=posthog_client - ) - # Store conversation history in Gemini's native format - self.history = [] - - # Configure tools using proper Google GenAI types - tool_definitions = self.get_tool_definitions() - self.tools = types.Tool(function_declarations=tool_definitions) - self.config = types.GenerateContentConfig(tools=[self.tools]) - - def get_name(self): - return "Google Gemini" - - def get_tool_definitions(self): - """Return tool definitions in Gemini format""" - return [ - { - "name": "get_current_weather", - "description": "Gets the current weather for a given location using geographical coordinates.", - "parameters": { - "type": "object", - "properties": { - "latitude": { - "type": "number", - "description": "The latitude of the location (e.g., 37.7749 for San Francisco)", - }, - "longitude": { - "type": "number", - "description": "The longitude of the location (e.g., -122.4194 for San Francisco)", - }, - "location_name": { - "type": "string", - "description": "A human-readable name for the location (e.g., 'San Francisco, CA' or 'Dublin, Ireland')", - }, - }, - "required": ["latitude", "longitude", "location_name"], - }, - }, - { - "name": "tell_joke", - "description": "Tell a joke with a question-style setup and an answer punchline", - "parameters": { - "type": "object", - "properties": { - "setup": { - "type": "string", - "description": "The setup of the joke, typically a question (e.g., 'Why did the chicken cross the road?')", - }, - "punchline": { - "type": "string", - "description": "The punchline or answer to the joke (e.g., 'To get to the other side!')", - }, - }, - "required": ["setup", "punchline"], - }, - } - ] - - def reset_conversation(self): - """Reset the conversation history""" - self.history = [] - self.messages = [] - - def chat(self, user_input: str, base64_image: str = None) -> str: - """Send a message to Gemini and get response""" - # Build content parts for this message - if base64_image: - # Use native Gemini format for images - parts = [ - {"text": user_input}, - { - "inline_data": { - "mime_type": "image/png", - "data": base64_image - } - } - ] - else: - # Text-only content - parts = [{"text": user_input}] - - # Add user message to history - self.history.append({ - "role": "user", - "parts": parts - }) - - # Prepare API request parameters - request_params = { - "model": GEMINI_MODEL, - "posthog_distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - "contents": self.history, - "config": self.config - } - - # Send all messages in conversation history - message = self.client.models.generate_content(**request_params) - - # Debug: Log the API call (request + response) - self._debug_api_call("Google Gemini", request_params, message) - - # Collect response parts for display - display_parts = [] - model_parts = [] - tool_results = [] - - # Check if Gemini wants to use tools and collect all response parts - if hasattr(message, 'candidates') and message.candidates: - for candidate in message.candidates: - if hasattr(candidate, 'content') and candidate.content: - for part in candidate.content.parts: - if hasattr(part, 'function_call') and part.function_call: - function_call = part.function_call - model_parts.append({"function_call": function_call}) - - if function_call.name == "get_current_weather": - latitude = function_call.args.get("latitude", 0.0) - longitude = function_call.args.get("longitude", 0.0) - location_name = function_call.args.get("location_name") - weather_result = self.get_weather(latitude, longitude, location_name) - tool_result_text = self.format_tool_result("get_weather", weather_result) - tool_results.append(tool_result_text) - display_parts.append(tool_result_text) - elif function_call.name == "tell_joke": - setup = function_call.args.get("setup", "") - punchline = function_call.args.get("punchline", "") - joke_result = self.tell_joke(setup, punchline) - tool_result_text = self.format_tool_result("tell_joke", joke_result) - tool_results.append(tool_result_text) - display_parts.append(tool_result_text) - elif hasattr(part, 'text'): - model_parts.append({"text": part.text}) - display_parts.append(part.text) - - # Add model response to history - if model_parts: - self.history.append({ - "role": "model", - "parts": model_parts - }) - - # Add tool results to history - for tool_result in tool_results: - self.history.append({ - "role": "model", - "parts": [{"text": f"Tool result: {tool_result}"}] - }) - - return "\n\n".join(display_parts) if display_parts else (message.text if hasattr(message, 'text') else "No response received") \ No newline at end of file diff --git a/python/providers/gemini_image.py b/python/providers/gemini_image.py deleted file mode 100644 index 458f6ce..0000000 --- a/python/providers/gemini_image.py +++ /dev/null @@ -1,108 +0,0 @@ -import os -import json -from posthog.ai.gemini import Client -from posthog import Posthog -from .base import BaseProvider -from .constants import GEMINI_IMAGE_MODEL, DEFAULT_POSTHOG_DISTINCT_ID - -class GeminiImageProvider(BaseProvider): - def __init__(self, posthog_client: Posthog): - super().__init__(posthog_client) - - # Set span name for this provider - existing_props = posthog_client.super_properties or {} - posthog_client.super_properties = {**existing_props, "$ai_span_name": "gemini_generate_image"} - - self.client = Client( - api_key=os.getenv("GEMINI_API_KEY"), - posthog_client=posthog_client - ) - - def get_tool_definitions(self): - """Return empty tool definitions - image generation doesn't use tools""" - return [] - - def get_name(self): - return "Google Gemini (Image Generation)" - - def _log_token_usage_by_modality(self, response): - """Log token usage breakdown by modality in debug mode""" - if not self.debug_mode: - return - - try: - usage_metadata = getattr(response, 'usage_metadata', None) - if not usage_metadata: - print("\n๐Ÿ“Š Token Usage: No modality breakdown available\n") - return - - print("\n" + "โ”€" * 60) - print("๐Ÿ“Š TOKEN USAGE BY MODALITY") - print("โ”€" * 60) - - # Input tokens breakdown - prompt_details = getattr(usage_metadata, 'prompt_tokens_details', []) - print("\n INPUT TOKENS:") - if prompt_details: - for detail in prompt_details: - modality = getattr(detail, 'modality', 'unknown') - token_count = getattr(detail, 'token_count', 0) - print(f" {modality}: {token_count} tokens") - else: - prompt_token_count = getattr(usage_metadata, 'prompt_token_count', 0) - print(f" Total: {prompt_token_count} tokens") - - # Output tokens breakdown - candidates_details = getattr(usage_metadata, 'candidates_tokens_details', []) - print("\n OUTPUT TOKENS:") - if candidates_details: - for detail in candidates_details: - modality = getattr(detail, 'modality', 'unknown') - token_count = getattr(detail, 'token_count', 0) - print(f" {modality}: {token_count} tokens") - else: - candidates_token_count = getattr(usage_metadata, 'candidates_token_count', 0) - print(f" Total: {candidates_token_count} tokens") - - total_token_count = getattr(usage_metadata, 'total_token_count', 0) - print(f"\n TOTAL: {total_token_count} tokens") - print("โ”€" * 60 + "\n") - except Exception: - # Silently ignore errors in debug logging - pass - - def generate_image(self, prompt: str, model: str = GEMINI_IMAGE_MODEL) -> str: - """Generate an image using Google Gemini""" - try: - request_params = { - "model": model, - "posthog_distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - "contents": prompt - } - - response = self.client.models.generate_content(**request_params) - self._debug_api_call("Google Gemini Image Generation", request_params, response) - self._log_token_usage_by_modality(response) - - # Check for images in the response candidates - if hasattr(response, 'candidates') and response.candidates: - for candidate in response.candidates: - if hasattr(candidate, 'content') and candidate.content: - parts = getattr(candidate.content, 'parts', []) - for part in parts: - # Check for inline image data - inline_data = getattr(part, 'inline_data', None) - if inline_data: - data = getattr(inline_data, 'data', None) - mime_type = getattr(inline_data, 'mime_type', '') - if data and mime_type.startswith('image/'): - return f"data:{mime_type};base64,{data[:100]}... (base64 image data, {len(data)} chars total)" - - return "" - except Exception as error: - print(f'Error in Gemini image generation: {error}') - raise Exception(f"Gemini Image Generation error: {str(error)}") - - def chat(self, user_input: str, base64_image: str = None) -> str: - """Not implemented - this provider is for image generation only""" - raise Exception("This provider is for image generation only. Use generate_image() instead.") diff --git a/python/providers/gemini_streaming.py b/python/providers/gemini_streaming.py deleted file mode 100644 index 587c0a2..0000000 --- a/python/providers/gemini_streaming.py +++ /dev/null @@ -1,208 +0,0 @@ -import os -from posthog.ai.gemini import Client -from posthog import Posthog -from google.genai import types -from .base import StreamingProvider -from typing import Generator, Optional - -class GeminiStreamingProvider(StreamingProvider): - def __init__(self, posthog_client: Posthog): - super().__init__(posthog_client) - - # Set span name for this provider - existing_props = posthog_client.super_properties or {} - posthog_client.super_properties = {**existing_props, "$ai_span_name": "gemini_generate_content_streaming"} - - self.client = Client( - api_key=os.getenv("GEMINI_API_KEY"), - # vertexai=True, - # project="project-id", - # location="us-central1", - posthog_client=posthog_client - ) - # Store conversation history in Gemini's native format - self.history = [] - - # Configure tools using proper Google GenAI types - tool_definitions = self.get_tool_definitions() - self.tools = types.Tool(function_declarations=tool_definitions) - self.config = types.GenerateContentConfig(tools=[self.tools]) - - def get_name(self): - return "Google Gemini Streaming" - - def get_tool_definitions(self): - """Return tool definitions in Gemini format""" - return [ - { - "name": "get_current_weather", - "description": "Gets the current weather for a given location using geographical coordinates.", - "parameters": { - "type": "object", - "properties": { - "latitude": { - "type": "number", - "description": "The latitude of the location (e.g., 37.7749 for San Francisco)", - }, - "longitude": { - "type": "number", - "description": "The longitude of the location (e.g., -122.4194 for San Francisco)", - }, - "location_name": { - "type": "string", - "description": "A human-readable name for the location (e.g., 'San Francisco, CA' or 'Dublin, Ireland')", - }, - }, - "required": ["latitude", "longitude", "location_name"], - }, - }, - { - "name": "tell_joke", - "description": "Tell a joke with a question-style setup and an answer punchline", - "parameters": { - "type": "object", - "properties": { - "setup": { - "type": "string", - "description": "The setup of the joke, typically a question (e.g., 'Why did the chicken cross the road?')", - }, - "punchline": { - "type": "string", - "description": "The punchline or answer to the joke (e.g., 'To get to the other side!')", - }, - }, - "required": ["setup", "punchline"], - }, - } - ] - - def reset_conversation(self): - """Reset the conversation history""" - self.history = [] - self.messages = [] - - def chat_stream(self, user_input: str, base64_image: Optional[str] = None) -> Generator[str, None, None]: - """Send a message to Gemini and stream the response""" - # Build content parts for this message - if base64_image: - # Use native Gemini format for images - parts = [ - {"text": user_input}, - { - "inline_data": { - "mime_type": "image/png", - "data": base64_image - } - } - ] - else: - # Text-only content - parts = [{"text": user_input}] - - # Add user message to history - self.history.append({ - "role": "user", - "parts": parts - }) - - # Prepare API request parameters - request_params = { - "model": "gemini-2.5-flash", - "posthog_distinct_id": os.getenv("POSTHOG_DISTINCT_ID", "user-hog"), - "contents": self.history, - "config": self.config - } - - # Debug: Log the API request - if self.debug_mode: - self._debug_log("Google Gemini Streaming API Request", request_params) - - # Create streaming response - stream = self.client.models.generate_content_stream(**request_params) - - accumulated_text = "" - model_parts = [] - tool_results = [] - - # Process the stream - try: - for chunk in stream: - # Handle text chunks and function calls - if hasattr(chunk, 'candidates') and chunk.candidates: - for candidate in chunk.candidates: - if hasattr(candidate, 'content') and candidate.content: - for part in candidate.content.parts: - if hasattr(part, 'text') and part.text: - # Yield the text delta - text_delta = part.text - accumulated_text += text_delta - yield text_delta - elif hasattr(part, 'function_call') and part.function_call: - # Handle function calls during streaming - function_call = part.function_call - - if function_call.name == "get_current_weather": - latitude = function_call.args.get("latitude", 0.0) - longitude = function_call.args.get("longitude", 0.0) - location_name = function_call.args.get("location_name") - weather_result = self.get_weather(latitude, longitude, location_name) - tool_result_text = self.format_tool_result("get_weather", weather_result) - tool_results.append(tool_result_text) - - # Yield the tool result to the stream - yield "\n\n" + tool_result_text - - # Track the function call for history - model_parts.append({"function_call": function_call}) - elif function_call.name == "tell_joke": - setup = function_call.args.get("setup", "") - punchline = function_call.args.get("punchline", "") - joke_result = self.tell_joke(setup, punchline) - tool_result_text = self.format_tool_result("tell_joke", joke_result) - tool_results.append(tool_result_text) - - # Yield the tool result to the stream - yield "\n\n" + tool_result_text - - # Track the function call for history - model_parts.append({"function_call": function_call}) - except Exception as e: - # If there's an error, yield it as part of the response - yield f"\n\nError during streaming: {str(e)}" - - # Build model parts for history - if accumulated_text: - model_parts.append({"text": accumulated_text}) - - # Add model response to history - if model_parts: - self.history.append({ - "role": "model", - "parts": model_parts - }) - - # Debug: Log the completed stream response - if self.debug_mode: - self._debug_log("Google Gemini Streaming API Response (completed)", { - "accumulated_text": accumulated_text, - "model_parts": model_parts, - "tool_results": tool_results - }) - - # Add tool results to history if any - for tool_result in tool_results: - self.history.append({ - "role": "model", - "parts": [{"text": f"Tool result: {tool_result}"}] - }) - - def chat(self, user_input: str, base64_image: Optional[str] = None) -> str: - """Non-streaming chat for compatibility""" - chunks = [] - for chunk in self.chat_stream(user_input, base64_image): - chunks.append(chunk) - result = "".join(chunks) - # If no result, return a message indicating no response - if not result.strip(): - return "No response received from streaming" - return result \ No newline at end of file diff --git a/python/providers/langchain.py b/python/providers/langchain.py deleted file mode 100644 index 215d114..0000000 --- a/python/providers/langchain.py +++ /dev/null @@ -1,162 +0,0 @@ -import os -from posthog.ai.langchain import CallbackHandler -from langchain_openai import ChatOpenAI -from langchain_core.prompts import ChatPromptTemplate -from langchain_core.tools import tool -from langchain_core.messages import ToolMessage, HumanMessage, AIMessage, SystemMessage -from posthog import Posthog -from .base import BaseProvider -from .constants import OPENAI_CHAT_MODEL, OPENAI_VISION_MODEL, SYSTEM_PROMPT_ASSISTANT - -class LangChainProvider(BaseProvider): - def __init__(self, posthog_client: Posthog): - super().__init__(posthog_client) - - # Set span name for this provider - existing_props = posthog_client.super_properties or {} - posthog_client.super_properties = {**existing_props, "$ai_span_name": "langchain_chat"} - - self.OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") - - self.callback_handler = CallbackHandler( - client=posthog_client - ) - - # Store conversation history in LangChain's native format - self.langchain_messages = [ - SystemMessage(content=SYSTEM_PROMPT_ASSISTANT) - ] - - self._setup_chain() - - def get_tool_definitions(self): - """Return tool definitions (not used by LangChain but required by base)""" - return [] - - def _setup_chain(self): - """Setup the LangChain chain with tools""" - @tool - def get_weather(latitude: float, longitude: float, location_name: str) -> str: - """Get the current weather for a specific location using geographical coordinates. - - Args: - latitude: The latitude of the location (e.g., 37.7749 for San Francisco) - longitude: The longitude of the location (e.g., -122.4194 for San Francisco) - location_name: A human-readable name for the location (e.g., 'San Francisco, CA' or 'Dublin, Ireland') - - Returns: - Weather information for the specified location - """ - return self.get_weather(latitude, longitude, location_name) - - @tool - def tell_joke(setup: str, punchline: str) -> str: - """Tell a joke with a question-style setup and an answer punchline. - - Args: - setup: The setup or question part of the joke - punchline: The punchline or answer part of the joke - - Returns: - The formatted joke - """ - return self.tell_joke(setup, punchline) - - self.langchain_tools = [get_weather, tell_joke] - self.tool_map = {tool.name: tool for tool in self.langchain_tools} - - prompt = ChatPromptTemplate.from_messages([ - ("system", SYSTEM_PROMPT_ASSISTANT), - ("user", "{input}") - ]) - - model = ChatOpenAI(openai_api_key=self.OPENAI_API_KEY, temperature=0) - self.chain = prompt | model.bind_tools(self.langchain_tools) - - def get_name(self): - return "LangChain (OpenAI)" - - def get_description(self): - return "๐Ÿ’ก You can ask me about the weather!" - - def reset_conversation(self): - """Reset the conversation history""" - self.langchain_messages = [ - SystemMessage(content=SYSTEM_PROMPT_ASSISTANT) - ] - self.messages = [] - - def chat(self, user_input: str, base64_image: str = None) -> str: - """Send a message to LangChain and get response""" - # Add user message to history - if base64_image: - # Create a message with image content - user_message = HumanMessage(content=[ - { - "type": "text", - "text": user_input - }, - { - "type": "image_url", - "image_url": { - "url": f"data:image/png;base64,{base64_image}" - } - } - ]) - else: - user_message = HumanMessage(content=user_input) - self.langchain_messages.append(user_message) - - # Use the model directly with conversation history instead of the chain - # Use vision model for images - model_name = OPENAI_VISION_MODEL if base64_image else OPENAI_CHAT_MODEL - model = ChatOpenAI(openai_api_key=self.OPENAI_API_KEY, temperature=0, model_name=model_name) - model_with_tools = model.bind_tools(self.langchain_tools) - - # Prepare API request parameters - request_params = { - "input": self.langchain_messages, - "config": {"callbacks": [self.callback_handler]} - } - - response = model_with_tools.invoke(**request_params) - - # Debug: Log the API call (request + response) - self._debug_api_call("LangChain (OpenAI)", request_params, response) - - # Collect display parts - display_parts = [] - - # Add the AI's text response to display (if any) - if response.content: - display_parts.append(response.content) - - # Check if the model wants to use tools - if response.tool_calls: - # Execute tool calls - tool_messages = [] - for tool_call in response.tool_calls: - tool_name = tool_call["name"] - tool_args = tool_call["args"] - - # Execute the tool - if tool_name in self.tool_map: - tool_result = self.tool_map[tool_name].invoke(tool_args) - tool_result_text = self.format_tool_result(tool_name, tool_result) - display_parts.append(tool_result_text) - - tool_messages.append( - ToolMessage( - content=str(tool_result), - tool_call_id=tool_call["id"], - ) - ) - - # Add assistant's response to conversation history - self.langchain_messages.append(response) - - # Add tool messages to history if any - if response.tool_calls: - self.langchain_messages.extend(tool_messages) - - return "\n\n".join(display_parts) if display_parts else "No response received" \ No newline at end of file diff --git a/python/providers/langchain_otel.py b/python/providers/langchain_otel.py deleted file mode 100644 index 18834f7..0000000 --- a/python/providers/langchain_otel.py +++ /dev/null @@ -1,163 +0,0 @@ -""" -LangChain provider with OpenTelemetry instrumentation. -""" - -import os -from typing import List, Dict, Any, Optional -from langchain_openai import ChatOpenAI -from langchain_core.tools import tool -from langchain_core.messages import ToolMessage, HumanMessage, SystemMessage -from opentelemetry import trace -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor -from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter -from opentelemetry.instrumentation.langchain import LangchainInstrumentor -from posthog import Posthog - -from .base import BaseProvider -from .constants import OPENAI_CHAT_MODEL, SYSTEM_PROMPT_ASSISTANT - - -class LangChainOtelProvider(BaseProvider): - """LangChain provider with OpenTelemetry instrumentation.""" - - _otel_configured = False - - def __init__(self, posthog_client: Posthog): - super().__init__(posthog_client) - self._setup_otel() - - self.langchain_messages = [ - SystemMessage(content=SYSTEM_PROMPT_ASSISTANT) - ] - - self._setup_chain() - - def _setup_otel(self): - if LangChainOtelProvider._otel_configured: - return - - posthog_api_key = os.getenv("POSTHOG_API_KEY") - posthog_host = os.getenv("POSTHOG_HOST", "http://localhost:8010") - - if not posthog_api_key: - raise ValueError("POSTHOG_API_KEY must be set") - - os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = f"{posthog_host}/i/v0/ai/otel" - os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = f"Authorization=Bearer {posthog_api_key}" - - exporter = OTLPSpanExporter() - resource_attributes = { - "service.name": "llm-analytics-app-langchain-otel", - "user.id": os.getenv("POSTHOG_DISTINCT_ID", "unknown"), - } - if self.debug_mode: - resource_attributes["posthog.ai.debug"] = "true" - - tracer_provider = TracerProvider(resource=Resource.create(resource_attributes)) - tracer_provider.add_span_processor(BatchSpanProcessor(exporter)) - - trace.set_tracer_provider(tracer_provider) - LangchainInstrumentor().instrument() - - LangChainOtelProvider._otel_configured = True - - def _setup_chain(self): - @tool - def get_weather(latitude: float, longitude: float, location_name: str) -> str: - """Get the current weather for a specific location using geographical coordinates. - - Args: - latitude: The latitude of the location (e.g., 37.7749 for San Francisco) - longitude: The longitude of the location (e.g., -122.4194 for San Francisco) - location_name: A human-readable name for the location (e.g., 'San Francisco, CA' or 'Dublin, Ireland') - - Returns: - Weather information for the specified location - """ - return self.get_weather(latitude, longitude, location_name) - - @tool - def tell_joke(setup: str, punchline: str) -> str: - """Tell a joke with a question-style setup and an answer punchline. - - Args: - setup: The setup or question part of the joke - punchline: The punchline or answer part of the joke - - Returns: - The formatted joke - """ - return self.tell_joke(setup, punchline) - - self.langchain_tools = [get_weather, tell_joke] - self.tool_map = {t.name: t for t in self.langchain_tools} - - def get_name(self): - return "LangChain with OpenTelemetry" - - def get_tool_definitions(self): - return [] - - def get_initial_messages(self) -> List[Dict[str, Any]]: - return [] - - def reset_conversation(self): - self.langchain_messages = [ - SystemMessage(content=SYSTEM_PROMPT_ASSISTANT) - ] - self.messages = [] - - def chat(self, user_input: str, base64_image: Optional[str] = None) -> str: - if base64_image: - user_message = HumanMessage(content=[ - { - "type": "text", - "text": user_input - }, - { - "type": "image_url", - "image_url": { - "url": f"data:image/png;base64,{base64_image}" - } - } - ]) - else: - user_message = HumanMessage(content=user_input) - self.langchain_messages.append(user_message) - - model = ChatOpenAI(temperature=0, model_name=OPENAI_CHAT_MODEL) - model_with_tools = model.bind_tools(self.langchain_tools) - - response = model_with_tools.invoke(self.langchain_messages) - - display_parts = [] - - if response.content: - display_parts.append(response.content) - - if response.tool_calls: - tool_messages = [] - for tool_call in response.tool_calls: - tool_name = tool_call["name"] - tool_args = tool_call["args"] - - if tool_name in self.tool_map: - tool_result = self.tool_map[tool_name].invoke(tool_args) - tool_result_text = self.format_tool_result(tool_name, tool_result) - display_parts.append(tool_result_text) - - tool_messages.append( - ToolMessage( - content=str(tool_result), - tool_call_id=tool_call["id"], - ) - ) - - self.langchain_messages.append(response) - - if response.tool_calls: - self.langchain_messages.extend(tool_messages) - - return "\n\n".join(display_parts) if display_parts else "No response received" diff --git a/python/providers/litellm_provider.py b/python/providers/litellm_provider.py deleted file mode 100644 index 75539bc..0000000 --- a/python/providers/litellm_provider.py +++ /dev/null @@ -1,300 +0,0 @@ -import os -import json -import logging -import litellm -from posthog import Posthog -from .base import BaseProvider -from .constants import ( - OPENAI_CHAT_MODEL, - OPENAI_VISION_MODEL, - OPENAI_EMBEDDING_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_POSTHOG_DISTINCT_ID, - SYSTEM_PROMPT_FRIENDLY -) - -class LiteLLMProvider(BaseProvider): - def __init__(self, posthog_client: Posthog): - # Set PostHog configuration environment variables - os.environ["POSTHOG_API_KEY"] = os.getenv("POSTHOG_API_KEY", "") - os.environ["POSTHOG_API_URL"] = os.getenv("POSTHOG_HOST", "https://app.posthog.com") - - # Use string-based callbacks - our fixed PostHogLogger will handle both sync and async - try: - litellm.success_callback = ["posthog"] - litellm.failure_callback = ["posthog"] - logging.getLogger(__name__).info("PostHog LiteLLM integration enabled") - except Exception as e: - logging.getLogger(__name__).warning(f"PostHog setup failed: {e}, continuing without PostHog") - - super().__init__(posthog_client) - - # Extract session ID from super_properties if available - existing_props = posthog_client.super_properties or {} - self.ai_session_id = existing_props.get("$ai_session_id") - - # Set span name for this provider - posthog_client.super_properties = {**existing_props, "$ai_span_name": "litellm_completion"} - - self.model = OPENAI_CHAT_MODEL # Default model - - def get_tool_definitions(self): - """Return tool definitions in OpenAI format (LiteLLM uses OpenAI schema)""" - return [ - { - "type": "function", - "function": { - "name": "get_weather", - "description": "Get the current weather for a specific location using geographical coordinates", - "parameters": { - "type": "object", - "properties": { - "latitude": { - "type": "number", - "description": "The latitude of the location (e.g., 37.7749 for San Francisco)" - }, - "longitude": { - "type": "number", - "description": "The longitude of the location (e.g., -122.4194 for San Francisco)" - }, - "location_name": { - "type": "string", - "description": "A human-readable name for the location (e.g., 'San Francisco, CA' or 'Dublin, Ireland')" - } - }, - "required": ["latitude", "longitude", "location_name"] - } - } - }, - { - "type": "function", - "function": { - "name": "tell_joke", - "description": "Tell a joke with a question-style setup and an answer punchline", - "parameters": { - "type": "object", - "properties": { - "setup": { - "type": "string", - "description": "The setup or question part of the joke" - }, - "punchline": { - "type": "string", - "description": "The punchline or answer part of the joke" - } - }, - "required": ["setup", "punchline"] - } - } - } - ] - - def get_name(self): - return f"LiteLLM ({self.model})" - - def set_model(self, model: str): - """Set the model to use for completions""" - self.model = model - - def get_initial_messages(self): - """Return initial system message""" - return [ - { - "role": "system", - "content": SYSTEM_PROMPT_FRIENDLY - } - ] - - def embed(self, text: str, model: str = OPENAI_EMBEDDING_MODEL) -> list: - """Create embeddings using LiteLLM""" - try: - # Prepare metadata for embedding - embed_metadata = {"distinct_id": os.getenv("POSTHOG_DISTINCT_ID", "user-hog")} - - # Add session ID to metadata if available - if self.ai_session_id: - embed_metadata["$ai_session_id"] = self.ai_session_id - - response = litellm.embedding( - model=model, - input=text, - metadata=embed_metadata - ) - - # Extract embedding vector from response - if hasattr(response, 'data') and response.data: - return response.data[0]['embedding'] - return [] - except Exception as e: - print(f"Embedding error: {e}") - return [] - - def chat(self, user_input: str, base64_image: str = None) -> str: - """Send a message using LiteLLM and get response""" - # Add user message to history - if base64_image: - # For image input, create content array with text and image - user_content = [ - { - "type": "text", - "text": user_input - }, - { - "type": "image_url", - "image_url": { - "url": f"data:image/png;base64,{base64_image}" - } - } - ] - # Use vision model for images - model_to_use = OPENAI_VISION_MODEL - else: - user_content = user_input - model_to_use = self.model - - user_message = { - "role": "user", - "content": user_content - } - - # Initialize messages if empty - if not self.messages: - self.messages = self.get_initial_messages() - - self.messages.append(user_message) - - try: - # Prepare API request parameters - metadata = { - "distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - "user_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - } - - # Add session ID to metadata if available - if self.ai_session_id: - metadata["$ai_session_id"] = self.ai_session_id - - request_params = { - "model": model_to_use, - "messages": self.messages, - "tools": self.tools, - "tool_choice": "auto", - "max_tokens": DEFAULT_MAX_TOKENS, - "metadata": metadata - } - - # Send all messages in conversation history - response = litellm.completion(**request_params) - - # Debug: Log the API call (request + response) - self._debug_api_call(f"LiteLLM ({self.model})", request_params, response) - - # Extract the assistant's response - assistant_message = response.choices[0].message - - # Handle tool calls - if hasattr(assistant_message, 'tool_calls') and assistant_message.tool_calls: - # Add assistant message with tool calls to history - self.messages.append({ - "role": "assistant", - "content": assistant_message.content, - "tool_calls": [tc.dict() for tc in assistant_message.tool_calls] - }) - - # Process tool calls - display_parts = [] - if assistant_message.content: - display_parts.append(assistant_message.content) - - for tool_call in assistant_message.tool_calls: - if tool_call.function.name == "get_weather": - try: - arguments = json.loads(tool_call.function.arguments) - latitude = arguments.get("latitude", 0.0) - longitude = arguments.get("longitude", 0.0) - location_name = arguments.get("location_name") - weather_result = self.get_weather(latitude, longitude, location_name) - tool_result_text = self.format_tool_result("get_weather", weather_result) - display_parts.append(tool_result_text) - - # Add tool result to message history - self.messages.append({ - "role": "tool", - "tool_call_id": tool_call.id, - "content": weather_result - }) - - except json.JSONDecodeError: - display_parts.append("โŒ Error parsing weather tool arguments") - elif tool_call.function.name == "tell_joke": - try: - arguments = json.loads(tool_call.function.arguments) - setup = arguments.get("setup", "") - punchline = arguments.get("punchline", "") - joke_result = self.tell_joke(setup, punchline) - tool_result_text = self.format_tool_result("tell_joke", joke_result) - display_parts.append(tool_result_text) - - # Add tool result to message history - self.messages.append({ - "role": "tool", - "tool_call_id": tool_call.id, - "content": joke_result - }) - - except json.JSONDecodeError: - display_parts.append("โŒ Error parsing joke tool arguments") - - # Get final response after tool execution - try: - # Prepare API request parameters for final response - final_metadata = { - "distinct_id": os.getenv("POSTHOG_DISTINCT_ID", "user-hog"), - "user_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - } - - # Add session ID to metadata if available - if self.ai_session_id: - final_metadata["$ai_session_id"] = self.ai_session_id - - final_request_params = { - "model": model_to_use, - "messages": self.messages, - "max_tokens": DEFAULT_MAX_TOKENS, - "metadata": final_metadata - } - - final_response = litellm.completion(**final_request_params) - - # Debug: Log the API call (request + response) - self._debug_api_call(f"LiteLLM ({self.model})", final_request_params, final_response) - - final_content = final_response.choices[0].message.content - if final_content: - display_parts.append(final_content) - - # Add final assistant response to history - self.messages.append({ - "role": "assistant", - "content": final_content - }) - - except Exception as e: - display_parts.append(f"โŒ Error getting final response: {str(e)}") - - return "\n\n".join(display_parts) - - else: - # No tool calls, regular response - content = assistant_message.content or "No response received" - - # Add assistant's response to conversation history - self.messages.append({ - "role": "assistant", - "content": content - }) - - return content - - except Exception as e: - return f"โŒ Error: {str(e)}" \ No newline at end of file diff --git a/python/providers/litellm_streaming.py b/python/providers/litellm_streaming.py deleted file mode 100644 index 159b36a..0000000 --- a/python/providers/litellm_streaming.py +++ /dev/null @@ -1,431 +0,0 @@ -import os -import json -import logging -import asyncio -import litellm -from posthog import Posthog -from .base import StreamingProvider -from .constants import ( - OPENAI_CHAT_MODEL, - OPENAI_VISION_MODEL, - OPENAI_EMBEDDING_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_POSTHOG_DISTINCT_ID, - SYSTEM_PROMPT_FRIENDLY -) - -class LiteLLMStreamingProvider(StreamingProvider): - def __init__(self, posthog_client: Posthog): - # Set PostHog configuration environment variables - os.environ["POSTHOG_API_KEY"] = os.getenv("POSTHOG_API_KEY", "") - os.environ["POSTHOG_API_URL"] = os.getenv("POSTHOG_HOST", "https://app.posthog.com") - - # Use string-based callbacks - our fixed PostHogLogger will handle both sync and async - try: - litellm.success_callback = ["posthog"] - litellm.failure_callback = ["posthog"] - logging.getLogger(__name__).info("PostHog LiteLLM integration enabled (async)") - except Exception as e: - logging.getLogger(__name__).warning(f"PostHog setup failed: {e}, continuing without PostHog") - - super().__init__(posthog_client) - - # Extract session ID from super_properties if available - existing_props = posthog_client.super_properties or {} - self.ai_session_id = existing_props.get("$ai_session_id") - - # Set span name for this provider - posthog_client.super_properties = {**existing_props, "$ai_span_name": "litellm_acompletion"} - - self.model = OPENAI_CHAT_MODEL # Default model - - def get_tool_definitions(self): - """Return tool definitions in OpenAI format (LiteLLM uses OpenAI schema)""" - return [ - { - "type": "function", - "function": { - "name": "get_weather", - "description": "Get the current weather for a specific location using geographical coordinates", - "parameters": { - "type": "object", - "properties": { - "latitude": { - "type": "number", - "description": "The latitude of the location (e.g., 37.7749 for San Francisco)" - }, - "longitude": { - "type": "number", - "description": "The longitude of the location (e.g., -122.4194 for San Francisco)" - }, - "location_name": { - "type": "string", - "description": "A human-readable name for the location (e.g., 'San Francisco, CA' or 'Dublin, Ireland')" - } - }, - "required": ["latitude", "longitude", "location_name"] - } - } - }, - { - "type": "function", - "function": { - "name": "tell_joke", - "description": "Tell a joke with a question-style setup and an answer punchline", - "parameters": { - "type": "object", - "properties": { - "setup": { - "type": "string", - "description": "The setup or question part of the joke" - }, - "punchline": { - "type": "string", - "description": "The punchline or answer part of the joke" - } - }, - "required": ["setup", "punchline"] - } - } - } - ] - - def get_name(self): - return f"LiteLLM Async ({self.model})" - - def set_model(self, model: str): - """Set the model to use for completions""" - self.model = model - - def get_initial_messages(self): - """Return initial system message""" - return [ - { - "role": "system", - "content": SYSTEM_PROMPT_FRIENDLY - } - ] - - def chat(self, user_input: str, base64_image: str = None) -> str: - """Send a message using LiteLLM async and get response (non-streaming fallback)""" - # Run the async version synchronously - return asyncio.run(self._chat_async(user_input, base64_image)) - - async def _chat_async(self, user_input: str, base64_image: str = None) -> str: - """Async implementation of chat""" - # Add user message to history - if base64_image: - # For image input, create content array with text and image - user_content = [ - { - "type": "text", - "text": user_input - }, - { - "type": "image_url", - "image_url": { - "url": f"data:image/png;base64,{base64_image}" - } - } - ] - # Use vision model for images - model_to_use = OPENAI_VISION_MODEL - else: - user_content = user_input - model_to_use = self.model - - user_message = { - "role": "user", - "content": user_content - } - - # Initialize messages if empty - if not self.messages: - self.messages = self.get_initial_messages() - - self.messages.append(user_message) - - try: - # Prepare API request parameters - metadata = { - "distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - "user_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - } - - # Add session ID to metadata if available - if self.ai_session_id: - metadata["$ai_session_id"] = self.ai_session_id - - request_params = { - "model": model_to_use, - "messages": self.messages, - "tools": self.tools, - "tool_choice": "auto", - "max_tokens": DEFAULT_MAX_TOKENS, - "metadata": metadata - } - - # Send all messages in conversation history using acompletion - response = await litellm.acompletion(**request_params) - - # Debug: Log the API call (request + response) - self._debug_api_call(f"LiteLLM Async ({self.model})", request_params, response) - - # Extract the assistant's response - assistant_message = response.choices[0].message - - # Handle tool calls - if hasattr(assistant_message, 'tool_calls') and assistant_message.tool_calls: - # Add assistant message with tool calls to history - self.messages.append({ - "role": "assistant", - "content": assistant_message.content, - "tool_calls": [tc.dict() for tc in assistant_message.tool_calls] - }) - - # Process tool calls - display_parts = [] - if assistant_message.content: - display_parts.append(assistant_message.content) - - for tool_call in assistant_message.tool_calls: - if tool_call.function.name == "get_weather": - try: - arguments = json.loads(tool_call.function.arguments) - latitude = arguments.get("latitude", 0.0) - longitude = arguments.get("longitude", 0.0) - location_name = arguments.get("location_name") - weather_result = self.get_weather(latitude, longitude, location_name) - tool_result_text = self.format_tool_result("get_weather", weather_result) - display_parts.append(tool_result_text) - - # Add tool result to message history - self.messages.append({ - "role": "tool", - "tool_call_id": tool_call.id, - "content": weather_result - }) - - except json.JSONDecodeError: - display_parts.append("โŒ Error parsing weather tool arguments") - elif tool_call.function.name == "tell_joke": - try: - arguments = json.loads(tool_call.function.arguments) - setup = arguments.get("setup", "") - punchline = arguments.get("punchline", "") - joke_result = self.tell_joke(setup, punchline) - tool_result_text = self.format_tool_result("tell_joke", joke_result) - display_parts.append(tool_result_text) - - # Add tool result to message history - self.messages.append({ - "role": "tool", - "tool_call_id": tool_call.id, - "content": joke_result - }) - - except json.JSONDecodeError: - display_parts.append("โŒ Error parsing joke tool arguments") - - # Get final response after tool execution - try: - # Prepare API request parameters for final response - final_metadata = { - "distinct_id": os.getenv("POSTHOG_DISTINCT_ID", "user-hog"), - "user_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - } - - # Add session ID to metadata if available - if self.ai_session_id: - final_metadata["$ai_session_id"] = self.ai_session_id - - final_request_params = { - "model": model_to_use, - "messages": self.messages, - "max_tokens": DEFAULT_MAX_TOKENS, - "metadata": final_metadata - } - - final_response = await litellm.acompletion(**final_request_params) - - # Debug: Log the API call (request + response) - self._debug_api_call(f"LiteLLM Async ({self.model})", final_request_params, final_response) - - final_content = final_response.choices[0].message.content - if final_content: - display_parts.append(final_content) - - # Add final assistant response to history - self.messages.append({ - "role": "assistant", - "content": final_content - }) - - except Exception as e: - display_parts.append(f"โŒ Error getting final response: {str(e)}") - - return "\n\n".join(display_parts) - - else: - # No tool calls, regular response - content = assistant_message.content or "No response received" - - # Add assistant's response to conversation history - self.messages.append({ - "role": "assistant", - "content": content - }) - - return content - - except Exception as e: - return f"โŒ Error: {str(e)}" - - def chat_stream(self, user_input: str, base64_image: str = None): - """Stream response using LiteLLM async streaming""" - # Run async generator in sync context - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - async_gen = self._chat_stream_async(user_input, base64_image) - while True: - try: - chunk = loop.run_until_complete(async_gen.__anext__()) - yield chunk - except StopAsyncIteration: - break - finally: - loop.close() - - async def _chat_stream_async(self, user_input: str, base64_image: str = None): - """Async streaming implementation""" - # Add user message to history - if base64_image: - user_content = [ - {"type": "text", "text": user_input}, - {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64_image}"}} - ] - model_to_use = OPENAI_VISION_MODEL - else: - user_content = user_input - model_to_use = self.model - - user_message = {"role": "user", "content": user_content} - - if not self.messages: - self.messages = self.get_initial_messages() - - self.messages.append(user_message) - - try: - metadata = { - "distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - "user_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - } - - if self.ai_session_id: - metadata["$ai_session_id"] = self.ai_session_id - - # Use acompletion with streaming - response = await litellm.acompletion( - model=model_to_use, - messages=self.messages, - tools=self.tools, - tool_choice="auto", - max_tokens=DEFAULT_MAX_TOKENS, - metadata=metadata, - stream=True - ) - - full_content = "" - tool_calls_data = [] - - async for chunk in response: - delta = chunk.choices[0].delta - - # Handle content streaming - if hasattr(delta, 'content') and delta.content: - full_content += delta.content - yield delta.content - - # Handle tool calls - if hasattr(delta, 'tool_calls') and delta.tool_calls: - for tc in delta.tool_calls: - if tc.index >= len(tool_calls_data): - tool_calls_data.append({ - "id": tc.id, - "type": "function", - "function": {"name": tc.function.name, "arguments": ""} - }) - if tc.function.arguments: - tool_calls_data[tc.index]["function"]["arguments"] += tc.function.arguments - - # Process tool calls if any - if tool_calls_data: - self.messages.append({ - "role": "assistant", - "content": full_content, - "tool_calls": tool_calls_data - }) - - for tool_call in tool_calls_data: - if tool_call["function"]["name"] == "get_weather": - try: - arguments = json.loads(tool_call["function"]["arguments"]) - weather_result = self.get_weather( - arguments.get("latitude", 0.0), - arguments.get("longitude", 0.0), - arguments.get("location_name") - ) - yield f"\n\n{self.format_tool_result('get_weather', weather_result)}" - - self.messages.append({ - "role": "tool", - "tool_call_id": tool_call["id"], - "content": weather_result - }) - except Exception as e: - yield f"\n\nโŒ Error processing weather tool: {str(e)}" - elif tool_call["function"]["name"] == "tell_joke": - try: - arguments = json.loads(tool_call["function"]["arguments"]) - joke_result = self.tell_joke( - arguments.get("setup", ""), - arguments.get("punchline", "") - ) - yield f"\n\n{self.format_tool_result('tell_joke', joke_result)}" - - self.messages.append({ - "role": "tool", - "tool_call_id": tool_call["id"], - "content": joke_result - }) - except Exception as e: - yield f"\n\nโŒ Error processing joke tool: {str(e)}" - - # Get final response after tool calls - final_response = await litellm.acompletion( - model=model_to_use, - messages=self.messages, - max_tokens=DEFAULT_MAX_TOKENS, - metadata=metadata, - stream=True - ) - - final_content = "" - async for chunk in final_response: - delta = chunk.choices[0].delta - if hasattr(delta, 'content') and delta.content: - final_content += delta.content - yield delta.content - - if final_content: - self.messages.append({"role": "assistant", "content": final_content}) - - else: - # No tool calls, just add the response - if full_content: - self.messages.append({"role": "assistant", "content": full_content}) - - except Exception as e: - yield f"โŒ Error: {str(e)}" diff --git a/python/providers/openai.py b/python/providers/openai.py deleted file mode 100644 index ba1ea22..0000000 --- a/python/providers/openai.py +++ /dev/null @@ -1,244 +0,0 @@ -import os -import json -from posthog.ai.openai import OpenAI -from posthog import Posthog -from .base import BaseProvider -from .constants import ( - OPENAI_CHAT_MODEL, - OPENAI_VISION_MODEL, - OPENAI_EMBEDDING_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_POSTHOG_DISTINCT_ID, - SYSTEM_PROMPT_FRIENDLY -) - -class OpenAIProvider(BaseProvider): - def __init__(self, posthog_client: Posthog): - super().__init__(posthog_client) - - # Set span name for this provider - existing_props = posthog_client.super_properties or {} - posthog_client.super_properties = {**existing_props, "$ai_span_name": "openai_responses"} - - self.client = OpenAI( - api_key=os.getenv("OPENAI_API_KEY"), - posthog_client=posthog_client - ) - - def get_tool_definitions(self): - """Return tool definitions in OpenAI Responses API format""" - return [ - { - "type": "function", - "name": "get_weather", - "description": "Get the current weather for a specific location using geographical coordinates", - "parameters": { - "type": "object", - "properties": { - "latitude": { - "type": "number", - "description": "The latitude of the location (e.g., 37.7749 for San Francisco)" - }, - "longitude": { - "type": "number", - "description": "The longitude of the location (e.g., -122.4194 for San Francisco)" - }, - "location_name": { - "type": "string", - "description": "A human-readable name for the location (e.g., 'San Francisco, CA' or 'Dublin, Ireland')" - } - }, - "required": ["latitude", "longitude", "location_name"] - } - }, - { - "type": "function", - "name": "tell_joke", - "description": "Tell a joke with a question-style setup and an answer punchline", - "parameters": { - "type": "object", - "properties": { - "setup": { - "type": "string", - "description": "The setup of the joke, typically a question (e.g., 'Why did the chicken cross the road?')" - }, - "punchline": { - "type": "string", - "description": "The punchline or answer to the joke (e.g., 'To get to the other side!')" - } - }, - "required": ["setup", "punchline"] - } - }, - { - "type": "function", - "name": "roll_dice", - "description": "Roll one or more dice with a specified number of sides", - "parameters": { - "type": "object", - "properties": { - "num_dice": {"type": "integer", "description": "Number of dice to roll (default: 1)"}, - "sides": {"type": "integer", "description": "Number of sides per die (default: 6)"}, - }, - "required": [] - } - }, - { - "type": "function", - "name": "check_time", - "description": "Get the current time in a specific timezone (e.g., 'America/New_York', 'Europe/London', 'Asia/Tokyo')", - "parameters": { - "type": "object", - "properties": { - "timezone": {"type": "string", "description": "IANA timezone name (e.g., 'US/Eastern', 'Europe/Paris')"}, - }, - "required": ["timezone"] - } - }, - { - "type": "function", - "name": "calculate", - "description": "Evaluate a mathematical expression (basic arithmetic: +, -, *, /, parentheses)", - "parameters": { - "type": "object", - "properties": { - "expression": {"type": "string", "description": "The math expression to evaluate (e.g., '(2 + 3) * 4')"}, - }, - "required": ["expression"] - } - }, - { - "type": "function", - "name": "convert_units", - "description": "Convert a value between common units (km/miles, kg/lbs, celsius/fahrenheit, meters/feet, liters/gallons)", - "parameters": { - "type": "object", - "properties": { - "value": {"type": "number", "description": "The numeric value to convert"}, - "from_unit": {"type": "string", "description": "The source unit (e.g., 'km', 'celsius', 'kg')"}, - "to_unit": {"type": "string", "description": "The target unit (e.g., 'miles', 'fahrenheit', 'lbs')"}, - }, - "required": ["value", "from_unit", "to_unit"] - } - }, - { - "type": "function", - "name": "generate_inspirational_quote", - "description": "Get an inspirational quote, optionally on a specific topic (general, perseverance, creativity, success, teamwork)", - "parameters": { - "type": "object", - "properties": { - "topic": {"type": "string", "description": "Topic for the quote (general, perseverance, creativity, success, teamwork)"}, - }, - "required": [] - } - }, - ] - - def get_name(self): - return "OpenAI Responses" - - - def embed(self, text: str, model: str = OPENAI_EMBEDDING_MODEL) -> list: - """Create embeddings for the given text""" - response = self.client.embeddings.create( - model=model, - input=text, - posthog_distinct_id=os.getenv("POSTHOG_DISTINCT_ID", "user-hog") - ) - - # Extract embedding vector from response - if hasattr(response, 'data') and response.data: - return response.data[0].embedding - return [] - - def chat(self, user_input: str, base64_image: str = None) -> str: - """Send a message to OpenAI and get response""" - # Add user message to history - if base64_image: - # For image input, create content array with text and image - user_content = [ - { - "type": "input_text", - "text": user_input - }, - { - "type": "input_image", - "image_url": f"data:image/png;base64,{base64_image}" - } - ] - else: - user_content = user_input - - user_message = { - "role": "user", - "content": user_content - } - self.messages.append(user_message) - - # Send all messages in conversation history - # Use vision model for images - model_name = OPENAI_VISION_MODEL if base64_image else OPENAI_CHAT_MODEL - request_params = { - "model": model_name, - "max_output_tokens": DEFAULT_MAX_TOKENS, - "posthog_distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - "input": self.messages, - "instructions": SYSTEM_PROMPT_FRIENDLY, - "tools": self.tools - } - - message = self.client.responses.create(**request_params) - - # Debug: Log the API call - self._debug_api_call("OpenAI", request_params, message) - - # Collect response parts for display - display_parts = [] - assistant_content_items = [] - - # Extract text and tool calls from the response structure - if hasattr(message, 'output') and message.output: - for output_item in message.output: - # Handle message content (text) - if hasattr(output_item, 'content') and output_item.content: - # Extract text from content array - for content_item in output_item.content: - if hasattr(content_item, 'text') and content_item.text: - display_parts.append(content_item.text) - # Add to conversation history as output_text - assistant_content_items.append({ - "type": "output_text", - "text": content_item.text - }) - - # Handle tool calls (separate output items in Responses API) - if hasattr(output_item, 'name'): - # Get the tool call details from the response - call_id = getattr(output_item, 'call_id', f"call_{output_item.name}") - tool_arguments = getattr(output_item, 'arguments', '{}') - - # Parse arguments to execute the tool - arguments = {} - try: - arguments = json.loads(tool_arguments) - except json.JSONDecodeError: - arguments = {} - - tool_result = self.execute_tool(output_item.name, arguments) - if tool_result is not None: - tool_result_text = self.format_tool_result(output_item.name, tool_result) - display_parts.append(tool_result_text) - assistant_content_items.append({ - "type": "output_text", - "text": tool_result - }) - - if assistant_content_items: - assistant_message = { - "role": "assistant", - "content": assistant_content_items - } - self.messages.append(assistant_message) - - return "\n\n".join(display_parts) if display_parts else "No response received" \ No newline at end of file diff --git a/python/providers/openai_chat.py b/python/providers/openai_chat.py deleted file mode 100644 index e9a75a8..0000000 --- a/python/providers/openai_chat.py +++ /dev/null @@ -1,266 +0,0 @@ -import os -import json -from posthog.ai.openai import OpenAI -from posthog import Posthog -from .base import BaseProvider -from .constants import ( - OPENAI_CHAT_MODEL, - OPENAI_VISION_MODEL, - OPENAI_EMBEDDING_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_POSTHOG_DISTINCT_ID, - SYSTEM_PROMPT_FRIENDLY -) - -class OpenAIChatProvider(BaseProvider): - def __init__(self, posthog_client: Posthog): - super().__init__(posthog_client) - - # Set span name for this provider - existing_props = posthog_client.super_properties or {} - posthog_client.super_properties = {**existing_props, "$ai_span_name": "openai_chat_completions"} - - self.client = OpenAI( - api_key=os.getenv("OPENAI_API_KEY"), - posthog_client=posthog_client - ) - self.messages = self.get_initial_messages() - - def get_initial_messages(self): - """Return initial messages with system prompt""" - return [ - { - "role": "system", - "content": SYSTEM_PROMPT_FRIENDLY - } - ] - - def get_tool_definitions(self): - """Return tool definitions in OpenAI Chat format""" - return [ - { - "type": "function", - "function": { - "name": "get_weather", - "description": "Get the current weather for a specific location using geographical coordinates", - "parameters": { - "type": "object", - "properties": { - "latitude": {"type": "number", "description": "The latitude of the location"}, - "longitude": {"type": "number", "description": "The longitude of the location"}, - "location_name": {"type": "string", "description": "A human-readable name for the location"}, - }, - "required": ["latitude", "longitude", "location_name"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "tell_joke", - "description": "Tell a joke with a question-style setup and an answer punchline", - "parameters": { - "type": "object", - "properties": { - "setup": {"type": "string", "description": "The setup or question part of the joke"}, - "punchline": {"type": "string", "description": "The punchline or answer part of the joke"}, - }, - "required": ["setup", "punchline"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "roll_dice", - "description": "Roll one or more dice with a specified number of sides", - "parameters": { - "type": "object", - "properties": { - "num_dice": {"type": "integer", "description": "Number of dice to roll (default: 1)"}, - "sides": {"type": "integer", "description": "Number of sides per die (default: 6)"}, - }, - "required": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "check_time", - "description": "Get the current time in a specific timezone (e.g., 'America/New_York', 'Europe/London', 'Asia/Tokyo')", - "parameters": { - "type": "object", - "properties": { - "timezone": {"type": "string", "description": "IANA timezone name (e.g., 'US/Eastern', 'Europe/Paris')"}, - }, - "required": ["timezone"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "calculate", - "description": "Evaluate a mathematical expression (basic arithmetic: +, -, *, /, parentheses)", - "parameters": { - "type": "object", - "properties": { - "expression": {"type": "string", "description": "The math expression to evaluate (e.g., '(2 + 3) * 4')"}, - }, - "required": ["expression"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "convert_units", - "description": "Convert a value between common units (km/miles, kg/lbs, celsius/fahrenheit, meters/feet, liters/gallons)", - "parameters": { - "type": "object", - "properties": { - "value": {"type": "number", "description": "The numeric value to convert"}, - "from_unit": {"type": "string", "description": "The source unit (e.g., 'km', 'celsius', 'kg')"}, - "to_unit": {"type": "string", "description": "The target unit (e.g., 'miles', 'fahrenheit', 'lbs')"}, - }, - "required": ["value", "from_unit", "to_unit"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "generate_inspirational_quote", - "description": "Get an inspirational quote, optionally on a specific topic (general, perseverance, creativity, success, teamwork)", - "parameters": { - "type": "object", - "properties": { - "topic": {"type": "string", "description": "Topic for the quote (general, perseverance, creativity, success, teamwork)"}, - }, - "required": [], - }, - }, - }, - ] - - def get_name(self): - return "OpenAI Chat Completions" - - - def embed(self, text: str, model: str = OPENAI_EMBEDDING_MODEL) -> list: - """Create embeddings for the given text""" - response = self.client.embeddings.create( - model=model, - input=text, - posthog_distinct_id=os.getenv("POSTHOG_DISTINCT_ID", "user-hog") - ) - - # Extract embedding vector from response - if hasattr(response, 'data') and response.data: - return response.data[0].embedding - return [] - - def chat(self, user_input: str, base64_image: str = None) -> str: - """Send a message to OpenAI and get response""" - # Add user message to history - if base64_image: - # For image input, create content array with text and image - user_content = [ - { - "type": "text", - "text": user_input - }, - { - "type": "image_url", - "image_url": { - "url": f"data:image/png;base64,{base64_image}" - } - } - ] - else: - user_content = user_input - - user_message = { - "role": "user", - "content": user_content - } - self.messages.append(user_message) - - # Send all messages in conversation history using PostHog wrapper - # Use vision model for images - model_name = OPENAI_VISION_MODEL if base64_image else OPENAI_CHAT_MODEL - - # Prepare API request parameters - # Note: gpt-5-mini and newer models use max_completion_tokens instead of max_tokens - request_params = { - "model": model_name, - "max_completion_tokens": DEFAULT_MAX_TOKENS, - "posthog_distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - "messages": self.messages, - "tools": self.tools, - "tool_choice": "auto" - } - - response = self.client.chat.completions.create(**request_params) - - # Debug: Log the API call (request + response) - self._debug_api_call("OpenAI Chat Completions", request_params, response) - - # Collect response parts for display - display_parts = [] - - # Extract response from Chat Completions format - choice = response.choices[0] - message = choice.message - - # Handle text content - assistant_content = message.content or "" - if assistant_content: - display_parts.append(assistant_content) - - # Build and add assistant message to conversation history ONCE - # This must happen before processing tool calls for proper history - if message.tool_calls: - # Assistant message with tool calls - assistant_message = { - "role": "assistant", - "content": assistant_content, - "tool_calls": [ - { - "id": tc.id, - "type": "function", - "function": { - "name": tc.function.name, - "arguments": tc.function.arguments - } - } - for tc in message.tool_calls - ] - } - else: - # Plain assistant message without tool calls - assistant_message = { - "role": "assistant", - "content": assistant_content - } - self.messages.append(assistant_message) - - # Process tool calls (execute tools and add results to history) - if message.tool_calls: - for tool_call in message.tool_calls: - try: - arguments = json.loads(tool_call.function.arguments) - except json.JSONDecodeError: - arguments = {} - - tool_result = self.execute_tool(tool_call.function.name, arguments) - if tool_result is not None: - display_parts.append(self.format_tool_result(tool_call.function.name, tool_result)) - self.messages.append({ - "role": "tool", - "tool_call_id": tool_call.id, - "content": tool_result - }) - - return "\n\n".join(display_parts) if display_parts else "No response received" \ No newline at end of file diff --git a/python/providers/openai_chat_streaming.py b/python/providers/openai_chat_streaming.py deleted file mode 100644 index 7d36be7..0000000 --- a/python/providers/openai_chat_streaming.py +++ /dev/null @@ -1,297 +0,0 @@ -import os -import json -from posthog.ai.openai import OpenAI -from posthog import Posthog -from .base import StreamingProvider -from typing import Generator, Optional -from .constants import ( - OPENAI_CHAT_MODEL, - OPENAI_VISION_MODEL, - OPENAI_EMBEDDING_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_POSTHOG_DISTINCT_ID, - SYSTEM_PROMPT_FRIENDLY -) - -class OpenAIChatStreamingProvider(StreamingProvider): - def __init__(self, posthog_client: Posthog): - super().__init__(posthog_client) - - # Set span name for this provider - existing_props = posthog_client.super_properties or {} - posthog_client.super_properties = {**existing_props, "$ai_span_name": "openai_chat_completions_streaming"} - - self.client = OpenAI( - api_key=os.getenv("OPENAI_API_KEY"), - posthog_client=posthog_client - ) - self.messages = self.get_initial_messages() - - def get_initial_messages(self): - """Return initial messages with system prompt""" - return [ - { - "role": "system", - "content": SYSTEM_PROMPT_FRIENDLY - } - ] - - def get_tool_definitions(self): - """Return tool definitions in OpenAI Chat format""" - return [ - { - "type": "function", - "function": { - "name": "get_weather", - "description": "Get the current weather for a specific location using geographical coordinates", - "parameters": { - "type": "object", - "properties": { - "latitude": { - "type": "number", - "description": "The latitude of the location (e.g., 37.7749 for San Francisco)" - }, - "longitude": { - "type": "number", - "description": "The longitude of the location (e.g., -122.4194 for San Francisco)" - }, - "location_name": { - "type": "string", - "description": "A human-readable name for the location (e.g., 'San Francisco, CA' or 'Dublin, Ireland')" - } - }, - "required": ["latitude", "longitude", "location_name"] - } - } - }, - { - "type": "function", - "function": { - "name": "tell_joke", - "description": "Tell a joke with a question-style setup and an answer punchline", - "parameters": { - "type": "object", - "properties": { - "setup": { - "type": "string", - "description": "The setup or question part of the joke" - }, - "punchline": { - "type": "string", - "description": "The punchline or answer part of the joke" - } - }, - "required": ["setup", "punchline"] - } - } - } - ] - - def get_name(self): - return "OpenAI Chat Completions Streaming" - - def embed(self, text: str, model: str = OPENAI_EMBEDDING_MODEL) -> list: - """Create embeddings for the given text""" - response = self.client.embeddings.create( - model=model, - input=text, - posthog_distinct_id=os.getenv("POSTHOG_DISTINCT_ID", "user-hog") - ) - - # Extract embedding vector from response - if hasattr(response, 'data') and response.data: - return response.data[0].embedding - return [] - - def chat_stream(self, user_input: str, base64_image: Optional[str] = None) -> Generator[str, None, None]: - """Send a message to OpenAI and stream the response""" - # Add user message to history - if base64_image: - # For image input, create content array with text and image - user_content = [ - { - "type": "text", - "text": user_input - }, - { - "type": "image_url", - "image_url": { - "url": f"data:image/png;base64,{base64_image}" - } - } - ] - else: - user_content = user_input - - user_message = { - "role": "user", - "content": user_content - } - self.messages.append(user_message) - - # Use vision model for images - model_name = OPENAI_VISION_MODEL if base64_image else OPENAI_CHAT_MODEL - - # Prepare API request parameters - # Note: gpt-5-mini and newer models use max_completion_tokens instead of max_tokens - request_params = { - "model": model_name, - "max_completion_tokens": DEFAULT_MAX_TOKENS, - "posthog_distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - "messages": self.messages, - "tools": self.tools, - "tool_choice": "auto", - "stream": True, - "stream_options": { - "include_usage": True - } - } - - # Debug: Log the API request - if self.debug_mode: - self._debug_log("OpenAI Chat Completions Streaming API Request", request_params) - - # Create streaming response - stream = self.client.chat.completions.create(**request_params) - - accumulated_content = "" - tool_calls = [] - tool_calls_by_index = {} - - try: - for chunk in stream: - # Handle text content - if hasattr(chunk, 'choices') and chunk.choices: - choice = chunk.choices[0] - - # Process text delta - if hasattr(choice, 'delta') and hasattr(choice.delta, 'content') and choice.delta.content: - content = choice.delta.content - accumulated_content += content - yield content - - # Process tool calls - if hasattr(choice, 'delta') and hasattr(choice.delta, 'tool_calls') and choice.delta.tool_calls: - for tool_call_delta in choice.delta.tool_calls: - index = tool_call_delta.index - - # Initialize or get existing tool call - if index not in tool_calls_by_index: - tool_calls_by_index[index] = { - "id": "", - "type": "function", - "function": { - "name": "", - "arguments": "" - } - } - - tool_call = tool_calls_by_index[index] - - # Update tool call information - if hasattr(tool_call_delta, 'id') and tool_call_delta.id: - tool_call["id"] = tool_call_delta.id - - if hasattr(tool_call_delta, 'function'): - if hasattr(tool_call_delta.function, 'name') and tool_call_delta.function.name: - tool_call["function"]["name"] = tool_call_delta.function.name - if hasattr(tool_call_delta.function, 'arguments') and tool_call_delta.function.arguments: - tool_call["function"]["arguments"] += tool_call_delta.function.arguments - - # Check for finish reason - if hasattr(choice, 'finish_reason') and choice.finish_reason == 'tool_calls': - # Convert indexed tool calls to list and execute them - completed_tool_calls = [tool_calls_by_index[i] for i in sorted(tool_calls_by_index.keys())] - - for tool_call in completed_tool_calls: - tool_calls.append(tool_call) - - if tool_call["function"]["name"] == "get_weather": - try: - args = json.loads(tool_call["function"]["arguments"]) - latitude = args.get("latitude", 0.0) - longitude = args.get("longitude", 0.0) - location_name = args.get("location_name") - weather_result = self.get_weather(latitude, longitude, location_name) - tool_result_text = self.format_tool_result("get_weather", weather_result) - yield "\n\n" + tool_result_text - except json.JSONDecodeError as e: - print(f"Error parsing tool arguments: {e}") - elif tool_call["function"]["name"] == "tell_joke": - try: - args = json.loads(tool_call["function"]["arguments"]) - setup = args.get("setup", "") - punchline = args.get("punchline", "") - joke_result = self.tell_joke(setup, punchline) - tool_result_text = self.format_tool_result("tell_joke", joke_result) - yield "\n\n" + tool_result_text - except json.JSONDecodeError as e: - print(f"Error parsing tool arguments: {e}") - - except Exception as e: - yield f"\n\nError during streaming: {str(e)}" - - # Save assistant message - assistant_message = { - "role": "assistant" - } - - if accumulated_content: - assistant_message["content"] = accumulated_content - - if tool_calls: - assistant_message["tool_calls"] = tool_calls - - self.messages.append(assistant_message) - - # Debug: Log the completed stream response - if self.debug_mode: - self._debug_log("OpenAI Chat Completions Streaming API Response (completed)", { - "accumulated_content": accumulated_content, - "tool_calls": tool_calls - }) - - # Add tool results to messages if any tools were called - for tool_call in tool_calls: - if tool_call["function"]["name"] == "get_weather": - try: - args = json.loads(tool_call["function"]["arguments"]) - latitude = args.get("latitude", 0.0) - longitude = args.get("longitude", 0.0) - location_name = args.get("location_name") - weather_result = self.get_weather(latitude, longitude, location_name) - - tool_result_message = { - "role": "tool", - "tool_call_id": tool_call["id"], - "content": weather_result - } - self.messages.append(tool_result_message) - except json.JSONDecodeError: - pass - elif tool_call["function"]["name"] == "tell_joke": - try: - args = json.loads(tool_call["function"]["arguments"]) - setup = args.get("setup", "") - punchline = args.get("punchline", "") - joke_result = self.tell_joke(setup, punchline) - - tool_result_message = { - "role": "tool", - "tool_call_id": tool_call["id"], - "content": joke_result - } - self.messages.append(tool_result_message) - except json.JSONDecodeError: - pass - - def chat(self, user_input: str, base64_image: Optional[str] = None) -> str: - """Non-streaming chat for compatibility""" - chunks = [] - for chunk in self.chat_stream(user_input, base64_image): - chunks.append(chunk) - result = "".join(chunks) - # If no result, return a message indicating no response - if not result.strip(): - return "No response received from streaming" - return result \ No newline at end of file diff --git a/python/providers/openai_image.py b/python/providers/openai_image.py deleted file mode 100644 index a5e82db..0000000 --- a/python/providers/openai_image.py +++ /dev/null @@ -1,56 +0,0 @@ -import os -from posthog.ai.openai import OpenAI -from posthog import Posthog -from .base import BaseProvider -from .constants import OPENAI_IMAGE_MODEL, DEFAULT_POSTHOG_DISTINCT_ID - -class OpenAIImageProvider(BaseProvider): - def __init__(self, posthog_client: Posthog): - super().__init__(posthog_client) - - # Set span name for this provider - existing_props = posthog_client.super_properties or {} - posthog_client.super_properties = {**existing_props, "$ai_span_name": "openai_image_generation"} - - self.client = OpenAI( - api_key=os.getenv("OPENAI_API_KEY"), - posthog_client=posthog_client - ) - - def get_tool_definitions(self): - """Return empty tool definitions - image generation doesn't use tools""" - return [] - - def get_name(self): - return "OpenAI Responses (Image Generation)" - - def generate_image(self, prompt: str, model: str = OPENAI_IMAGE_MODEL) -> str: - """Generate an image using OpenAI Responses API""" - try: - request_params = { - "model": model, - "input": prompt, - "tools": [{"type": "image_generation"}], - "posthog_distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID) - } - - response = self.client.responses.create(**request_params) - self._debug_api_call("OpenAI Responses Image Generation", request_params, response) - - # Extract image data from output array - if hasattr(response, 'output') and response.output: - for output_item in response.output: - # Check for image_generation_call type - if hasattr(output_item, 'type') and output_item.type == 'image_generation_call': - if hasattr(output_item, 'result'): - image_base64 = output_item.result - return f"data:image/png;base64,{image_base64[:100]}... (base64 image data, {len(image_base64)} chars total)" - - return "" - except Exception as error: - print(f'Error in OpenAI image generation: {error}') - raise Exception(f"OpenAI Image Generation error: {str(error)}") - - def chat(self, user_input: str, base64_image: str = None) -> str: - """Not implemented - this provider is for image generation only""" - raise Exception("This provider is for image generation only. Use generate_image() instead.") diff --git a/python/providers/openai_otel.py b/python/providers/openai_otel.py deleted file mode 100644 index 3a9e36f..0000000 --- a/python/providers/openai_otel.py +++ /dev/null @@ -1,343 +0,0 @@ -""" -OpenAI provider with OpenTelemetry instrumentation. - -This provider uses the OpenAI SDK with OTEL instrumentation to send traces to PostHog. -Follows OpenTelemetry GenAI semantic conventions. -""" - -import os -import json -from openai import OpenAI -from opentelemetry import trace, baggage, context -from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter -from opentelemetry.instrumentation.openai import OpenAIInstrumentor -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor -from opentelemetry.sdk.trace import SpanProcessor, ReadableSpan -from opentelemetry.propagate import set_global_textmap -from opentelemetry.propagators.composite import CompositePropagator -from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator -from opentelemetry.baggage.propagation import W3CBaggagePropagator -from posthog import Posthog - -from .base import BaseProvider - - -class SessionIDSpanProcessor(SpanProcessor): - """Span processor that adds session_id to all spans.""" - - def __init__(self, session_id: str): - self.session_id = session_id - - def on_start(self, span: ReadableSpan, parent_context=None) -> None: - """Called when a span is started - add session_id attribute.""" - if self.session_id: - span.set_attribute("posthog.ai.session_id", self.session_id) - - def on_end(self, span: ReadableSpan) -> None: - """Called when a span ends.""" - pass - - def shutdown(self) -> None: - """Called on shutdown.""" - pass - - def force_flush(self, timeout_millis: int = 30000) -> bool: - """Force flush.""" - return True -from .constants import ( - OPENAI_CHAT_MODEL, - OPENAI_VISION_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_POSTHOG_DISTINCT_ID, - SYSTEM_PROMPT_FRIENDLY -) - - -class OpenAIOtelProvider(BaseProvider): - """OpenAI provider with OpenTelemetry instrumentation for PostHog.""" - - def __init__(self, posthog_client: Posthog): - super().__init__(posthog_client) - - # Get PostHog configuration - self.posthog_project_id = os.getenv("POSTHOG_PROJECT_ID") - self.posthog_api_key = os.getenv("POSTHOG_API_KEY") - self.posthog_host = os.getenv("POSTHOG_HOST", "http://localhost:8000") - - if not self.posthog_project_id or not self.posthog_api_key: - raise ValueError( - "POSTHOG_PROJECT_ID and POSTHOG_API_KEY must be set in environment" - ) - - # Extract session ID from PostHog super_properties - self.session_id = None - if hasattr(posthog_client, 'super_properties') and posthog_client.super_properties: - self.session_id = posthog_client.super_properties.get("$ai_session_id") - - # Setup OpenTelemetry once (only if not already configured) - self._setup_otel() - - # Create OpenAI client (will be instrumented by OTEL) - self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) - - # Initialize conversation - self.messages = self.get_initial_messages() - - def _setup_otel(self): - """Setup OpenTelemetry with PostHog OTLP endpoint.""" - # Check if already configured - if hasattr(OpenAIOtelProvider, '_otel_configured'): - return - - # Create resource with service name - resource_attributes = { - "service.name": "llm-analytics-app-otel", - "service.version": "1.0.0", - "deployment.environment": "development", - } - if self.debug_mode: - resource_attributes["posthog.ai.debug"] = "true" - - resource = Resource.create(resource_attributes) - - # Create tracer provider - tracer_provider = TracerProvider(resource=resource) - - # Configure OTLP exporter for PostHog - otlp_endpoint = f"{self.posthog_host}/api/projects/{self.posthog_project_id}/ai/otel/v1/traces" - - trace_exporter = OTLPSpanExporter( - endpoint=otlp_endpoint, - headers={"Authorization": f"Bearer {self.posthog_api_key}"}, - ) - - # Add batch processor - tracer_provider.add_span_processor(BatchSpanProcessor(trace_exporter)) - - # Add session ID processor if we have a session_id - if self.session_id: - tracer_provider.add_span_processor(SessionIDSpanProcessor(self.session_id)) - - # Set as global tracer provider - trace.set_tracer_provider(tracer_provider) - - # Configure propagators to include baggage - set_global_textmap( - CompositePropagator([ - TraceContextTextMapPropagator(), - W3CBaggagePropagator(), - ]) - ) - - # Enable message content capture - os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true" - - # Instrument OpenAI SDK - OpenAIInstrumentor().instrument() - - # Mark as configured - OpenAIOtelProvider._otel_configured = True - - if self.debug_mode: - print(f"โœ… OpenTelemetry configured to send to: {otlp_endpoint}") - - def get_initial_messages(self): - """Return initial messages with system prompt""" - return [{ - "role": "system", - "content": SYSTEM_PROMPT_FRIENDLY - }] - - def get_tool_definitions(self): - """Return tool definitions in OpenAI Chat format""" - return [ - { - "type": "function", - "function": { - "name": "get_weather", - "description": "Get the current weather for a specific location using geographical coordinates", - "parameters": { - "type": "object", - "properties": { - "latitude": { - "type": "number", - "description": "The latitude of the location (e.g., 37.7749 for San Francisco)" - }, - "longitude": { - "type": "number", - "description": "The longitude of the location (e.g., -122.4194 for San Francisco)" - }, - "location_name": { - "type": "string", - "description": "A human-readable name for the location (e.g., 'San Francisco, CA' or 'Dublin, Ireland')" - } - }, - "required": ["latitude", "longitude", "location_name"] - } - } - }, - { - "type": "function", - "function": { - "name": "tell_joke", - "description": "Tell a joke with a question-style setup and an answer punchline", - "parameters": { - "type": "object", - "properties": { - "setup": { - "type": "string", - "description": "The setup or question part of the joke" - }, - "punchline": { - "type": "string", - "description": "The punchline or answer part of the joke" - } - }, - "required": ["setup", "punchline"] - } - } - } - ] - - def get_name(self): - return "OpenAI with OpenTelemetry" - - def chat(self, user_input: str, base64_image: str = None) -> str: - """Send a message to OpenAI and get response with OTEL tracing""" - # Add user message to history - if base64_image: - # For image input, create content array with text and image - user_content = [ - { - "type": "text", - "text": user_input - }, - { - "type": "image_url", - "image_url": { - "url": f"data:image/png;base64,{base64_image}" - } - } - ] - else: - user_content = user_input - - user_message = { - "role": "user", - "content": user_content - } - self.messages.append(user_message) - - # Use vision model for images - model_name = OPENAI_VISION_MODEL if base64_image else OPENAI_CHAT_MODEL - - # Prepare API request parameters - request_params = { - "model": model_name, - "max_completion_tokens": DEFAULT_MAX_TOKENS, - "messages": self.messages, - "tools": self.tools, - "tool_choice": "auto", - } - - # Call OpenAI (automatically instrumented by OTEL) - # Session ID is added to all spans by our SessionIDSpanProcessor - response = self.client.chat.completions.create(**request_params) - - # Debug: Log the API call - self._debug_api_call("OpenAI OTEL", request_params, response) - - # Collect response parts for display - display_parts = [] - assistant_content = "" - - # Extract response - choice = response.choices[0] - message = choice.message - - # Handle text content - if message.content: - assistant_content = message.content - display_parts.append(message.content) - - # Handle tool calls - if message.tool_calls: - for tool_call in message.tool_calls: - if tool_call.function.name == "get_weather": - try: - arguments = json.loads(tool_call.function.arguments) - latitude = arguments.get("latitude", 0.0) - longitude = arguments.get("longitude", 0.0) - location_name = arguments.get("location_name") - weather_result = self.get_weather(latitude, longitude, location_name) - tool_result_text = self.format_tool_result("get_weather", weather_result) - display_parts.append(tool_result_text) - - # Add tool response to conversation history - self.messages.append({ - "role": "assistant", - "content": assistant_content, - "tool_calls": [{ - "id": tool_call.id, - "type": "function", - "function": { - "name": tool_call.function.name, - "arguments": tool_call.function.arguments - } - }] - }) - - # Add tool result message - self.messages.append({ - "role": "tool", - "tool_call_id": tool_call.id, - "content": weather_result - }) - - except json.JSONDecodeError: - display_parts.append("โŒ Error parsing tool arguments") - - elif tool_call.function.name == "tell_joke": - try: - arguments = json.loads(tool_call.function.arguments) - setup = arguments.get("setup", "") - punchline = arguments.get("punchline", "") - joke_result = self.tell_joke(setup, punchline) - tool_result_text = self.format_tool_result("tell_joke", joke_result) - display_parts.append(tool_result_text) - - # Add tool response to conversation history - self.messages.append({ - "role": "assistant", - "content": assistant_content, - "tool_calls": [{ - "id": tool_call.id, - "type": "function", - "function": { - "name": tool_call.function.name, - "arguments": tool_call.function.arguments - } - }] - }) - - # Add tool result message - self.messages.append({ - "role": "tool", - "tool_call_id": tool_call.id, - "content": joke_result - }) - - except json.JSONDecodeError: - display_parts.append("โŒ Error parsing tool arguments") - else: - # Add assistant's response to conversation history (text only) - if assistant_content: - assistant_message = { - "role": "assistant", - "content": assistant_content - } - self.messages.append(assistant_message) - - return "\n\n".join(display_parts) if display_parts else "No response received" diff --git a/python/providers/openai_streaming.py b/python/providers/openai_streaming.py deleted file mode 100644 index 94fbfc8..0000000 --- a/python/providers/openai_streaming.py +++ /dev/null @@ -1,293 +0,0 @@ -import os -import json -from posthog.ai.openai import OpenAI -from posthog import Posthog -from .base import StreamingProvider -from typing import Generator, Optional -from .constants import ( - OPENAI_CHAT_MODEL, - OPENAI_VISION_MODEL, - OPENAI_EMBEDDING_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_POSTHOG_DISTINCT_ID, - SYSTEM_PROMPT_FRIENDLY -) - -class OpenAIStreamingProvider(StreamingProvider): - def __init__(self, posthog_client: Posthog): - super().__init__(posthog_client) - - # Set span name for this provider - existing_props = posthog_client.super_properties or {} - posthog_client.super_properties = {**existing_props, "$ai_span_name": "openai_responses_streaming"} - - self.client = OpenAI( - api_key=os.getenv("OPENAI_API_KEY"), - posthog_client=posthog_client - ) - - def get_tool_definitions(self): - """Return tool definitions in OpenAI Responses API format""" - return [ - { - "type": "function", - "name": "get_weather", - "description": "Get the current weather for a specific location using geographical coordinates", - "parameters": { - "type": "object", - "properties": { - "latitude": { - "type": "number", - "description": "The latitude of the location (e.g., 37.7749 for San Francisco)" - }, - "longitude": { - "type": "number", - "description": "The longitude of the location (e.g., -122.4194 for San Francisco)" - }, - "location_name": { - "type": "string", - "description": "A human-readable name for the location (e.g., 'San Francisco, CA' or 'Dublin, Ireland')" - } - }, - "required": ["latitude", "longitude", "location_name"] - } - }, - { - "type": "function", - "name": "tell_joke", - "description": "Tell a joke with a question-style setup and an answer punchline", - "parameters": { - "type": "object", - "properties": { - "setup": { - "type": "string", - "description": "The setup or question part of the joke" - }, - "punchline": { - "type": "string", - "description": "The punchline or answer part of the joke" - } - }, - "required": ["setup", "punchline"] - } - } - ] - - def get_name(self): - return "OpenAI Responses Streaming" - - def embed(self, text: str, model: str = OPENAI_EMBEDDING_MODEL) -> list: - """Create embeddings for the given text""" - response = self.client.embeddings.create( - model=model, - input=text, - posthog_distinct_id=os.getenv("POSTHOG_DISTINCT_ID", "user-hog") - ) - - # Extract embedding vector from response - if hasattr(response, 'data') and response.data: - return response.data[0].embedding - return [] - - def chat_stream(self, user_input: str, base64_image: Optional[str] = None) -> Generator[str, None, None]: - """Send a message to OpenAI and stream the response""" - # Add user message to history - if base64_image: - # For image input, create content array with text and image - user_content = [ - { - "type": "input_text", - "text": user_input - }, - { - "type": "input_image", - "image_url": f"data:image/png;base64,{base64_image}" - } - ] - else: - user_content = user_input - - user_message = { - "role": "user", - "content": user_content - } - self.messages.append(user_message) - - # Use vision model for images - model_name = OPENAI_VISION_MODEL if base64_image else OPENAI_CHAT_MODEL - - # Prepare API request parameters - request_params = { - "model": model_name, - "max_output_tokens": DEFAULT_MAX_TOKENS, - "posthog_distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - "input": self.messages, - "instructions": SYSTEM_PROMPT_FRIENDLY, - "tools": self.tools, - "stream": True - } - - # Debug: Log the API request - if self.debug_mode: - self._debug_log("OpenAI Responses Streaming API Request", request_params) - - # Create streaming response - stream = self.client.responses.create(**request_params) - - accumulated_content = "" - final_output = [] - tool_calls = [] - - try: - for chunk in stream: - # Handle different event types from the responses API stream - if hasattr(chunk, 'type'): - if chunk.type == 'response.output_text.delta': - # Text delta streaming - this is the main text streaming event - if hasattr(chunk, 'delta') and chunk.delta: - accumulated_content += chunk.delta - yield chunk.delta - - elif chunk.type == 'response.output_item.added': - # Track when a function call starts - if hasattr(chunk, 'item') and hasattr(chunk.item, 'type') and chunk.item.type == 'function_call': - # Store the function info for later use - output_index = getattr(chunk, 'output_index', len(tool_calls)) - if output_index >= len(tool_calls): - tool_calls.append({ - "name": getattr(chunk.item, 'name', 'get_weather'), - "call_id": getattr(chunk.item, 'call_id', f"call_{getattr(chunk.item, 'name', 'unknown')}"), - "arguments": "" - }) - - elif chunk.type == 'response.function_call_arguments.done': - # Function call arguments completed - if hasattr(chunk, 'arguments') and hasattr(chunk, 'output_index'): - # Get the function info we stored earlier - output_index = chunk.output_index - if output_index < len(tool_calls): - tool_call = tool_calls[output_index] - if tool_call["name"] == "get_weather": - arguments = {} - try: - arguments = json.loads(chunk.arguments) - except json.JSONDecodeError: - arguments = {} - - latitude = arguments.get("latitude", 0.0) - longitude = arguments.get("longitude", 0.0) - location_name = arguments.get("location_name") - weather_result = self.get_weather(latitude, longitude, location_name) - tool_result_text = self.format_tool_result("get_weather", weather_result) - yield "\n\n" + tool_result_text - - # Update the arguments - tool_call["arguments"] = chunk.arguments - elif tool_call["name"] == "tell_joke": - arguments = {} - try: - arguments = json.loads(chunk.arguments) - except json.JSONDecodeError: - arguments = {} - - setup = arguments.get("setup", "") - punchline = arguments.get("punchline", "") - joke_result = self.tell_joke(setup, punchline) - tool_result_text = self.format_tool_result("tell_joke", joke_result) - yield "\n\n" + tool_result_text - - # Update the arguments - tool_call["arguments"] = chunk.arguments - - elif chunk.type == 'response.completed': - # Response completed event - only handle content that wasn't already streamed - if hasattr(chunk, 'response') and chunk.response: - if hasattr(chunk.response, 'output') and chunk.response.output: - for output_item in chunk.response.output: - # Check if this is a content message that wasn't already streamed - if hasattr(output_item, 'content') and isinstance(output_item.content, list): - # Only add content if we didn't stream it already - for content_item in output_item.content: - if hasattr(content_item, 'text') and content_item.text: - if content_item.text not in accumulated_content: - accumulated_content += content_item.text - yield content_item.text - - # Tool calls are already handled in response.function_call_arguments.done - # So we don't need to handle them here - - except Exception as e: - yield f"\n\nError during streaming: {str(e)}" - - # Save assistant message with accumulated content or tool results - # Use proper Responses API format with content arrays - assistant_content_items = [] - - # Add text content if any - if accumulated_content: - assistant_content_items.append({ - "type": "output_text", - "text": accumulated_content - }) - - # Add tool results if any - if tool_calls and any(tc.get("arguments") for tc in tool_calls): - for tool_call in tool_calls: - if tool_call.get("arguments") and tool_call.get("name") == "get_weather": - try: - args = json.loads(tool_call["arguments"]) - latitude = args.get("latitude", 0.0) - longitude = args.get("longitude", 0.0) - location_name = args.get("location_name") - weather_result = self.get_weather(latitude, longitude, location_name) - - # Add tool result as output_text for conversation history - # For client-side history management, add as assistant message with output_text - assistant_content_items.append({ - "type": "output_text", - "text": weather_result - }) - except json.JSONDecodeError: - pass - elif tool_call.get("arguments") and tool_call.get("name") == "tell_joke": - try: - args = json.loads(tool_call["arguments"]) - setup = args.get("setup", "") - punchline = args.get("punchline", "") - joke_result = self.tell_joke(setup, punchline) - - # Add tool result as output_text for conversation history - # For client-side history management, add as assistant message with output_text - assistant_content_items.append({ - "type": "output_text", - "text": joke_result - }) - except json.JSONDecodeError: - pass - - # Add to conversation history if there's any content - if assistant_content_items: - assistant_message = { - "role": "assistant", - "content": assistant_content_items - } - self.messages.append(assistant_message) - - # Debug: Log the completed stream response - if self.debug_mode: - self._debug_log("OpenAI Responses Streaming API Response (completed)", { - "accumulated_content": accumulated_content, - "tool_calls": tool_calls, - "final_output": final_output - }) - - def chat(self, user_input: str, base64_image: Optional[str] = None) -> str: - """Non-streaming chat for compatibility""" - chunks = [] - for chunk in self.chat_stream(user_input, base64_image): - chunks.append(chunk) - result = "".join(chunks) - # If no result, return a message indicating no response - if not result.strip(): - return "No response received from streaming" - return result \ No newline at end of file diff --git a/python/providers/openai_transcription.py b/python/providers/openai_transcription.py deleted file mode 100644 index f8eef6b..0000000 --- a/python/providers/openai_transcription.py +++ /dev/null @@ -1,141 +0,0 @@ -import os -from typing import List, Dict, Any, Optional -from posthog.ai.openai import OpenAI -from posthog import Posthog -from .base import BaseProvider -from .constants import DEFAULT_POSTHOG_DISTINCT_ID - - -class OpenAITranscriptionProvider(BaseProvider): - """ - OpenAI Transcription provider using Whisper API. - - This provider supports audio transcription using OpenAI's Whisper model. - Supported audio formats: mp3, mp4, mpeg, mpga, m4a, wav, webm - """ - - def __init__(self, posthog_client: Posthog): - super().__init__(posthog_client) - - # Set span name for this provider - existing_props = posthog_client.super_properties or {} - posthog_client.super_properties = {**existing_props, "$ai_span_name": "openai_transcription"} - - self.client = OpenAI( - api_key=os.getenv("OPENAI_API_KEY"), - posthog_client=posthog_client - ) - - def get_tool_definitions(self) -> List[Dict[str, Any]]: - """Transcription doesn't use tools""" - return [] - - def get_name(self) -> str: - return "OpenAI Transcriptions" - - def transcribe( - self, - audio_path: str, - model: str = "whisper-1", - language: Optional[str] = None, - prompt: Optional[str] = None - ) -> str: - """ - Transcribe audio from a file path. - - Args: - audio_path: Path to the audio file (supports mp3, mp4, mpeg, mpga, m4a, wav, webm) - model: Model to use (default: whisper-1) - language: Optional language code (e.g., 'en', 'es', 'ca') - prompt: Optional prompt to guide transcription - - Returns: - Transcription text - """ - # Validate file exists - if not os.path.exists(audio_path): - raise FileNotFoundError(f"Audio file not found: {audio_path}") - - try: - with open(audio_path, "rb") as audio_file: - transcription_params = { - "file": audio_file, - "model": model, - "posthog_distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - } - - # Add optional parameters - if language: - transcription_params["language"] = language - if prompt: - transcription_params["prompt"] = prompt - - transcription = self.client.audio.transcriptions.create(**transcription_params) - - # Debug log (excluding file object for cleaner output) - debug_params = {k: v for k, v in transcription_params.items() if k != "file"} - debug_params["file"] = audio_path - self._debug_api_call("OpenAI Transcription", debug_params, transcription) - - return transcription.text if hasattr(transcription, 'text') else str(transcription) - except FileNotFoundError: - raise - except Exception as error: - print(f"โŒ Transcription error: {str(error)}") - raise - - def transcribe_verbose( - self, - audio_path: str, - model: str = "whisper-1", - language: Optional[str] = None, - prompt: Optional[str] = None - ) -> Dict[str, Any]: - """ - Transcribe audio with verbose response format. - - Args: - audio_path: Path to the audio file - model: Model to use (default: whisper-1) - language: Optional language code - prompt: Optional prompt to guide transcription - - Returns: - Verbose transcription with segments, language, duration - """ - # Validate file exists - if not os.path.exists(audio_path): - raise FileNotFoundError(f"Audio file not found: {audio_path}") - - try: - with open(audio_path, "rb") as audio_file: - transcription_params = { - "file": audio_file, - "model": model, - "response_format": "verbose_json", - "posthog_distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), - } - - # Add optional parameters - if language: - transcription_params["language"] = language - if prompt: - transcription_params["prompt"] = prompt - - transcription = self.client.audio.transcriptions.create(**transcription_params) - - # Debug log (excluding file object for cleaner output) - debug_params = {k: v for k, v in transcription_params.items() if k != "file"} - debug_params["file"] = audio_path - self._debug_api_call("OpenAI Transcription (Verbose)", debug_params, transcription) - - return transcription - except FileNotFoundError: - raise - except Exception as error: - print(f"โŒ Transcription error: {str(error)}") - raise - - def chat(self, user_input: str, base64_image: Optional[str] = None) -> str: - """This provider is for transcriptions only. Use transcribe() method instead.""" - return "This provider is for transcriptions only. Use transcribe() method instead." diff --git a/python/providers/pydantic_ai_otel.py b/python/providers/pydantic_ai_otel.py deleted file mode 100644 index c0e8e0a..0000000 --- a/python/providers/pydantic_ai_otel.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -Pydantic AI provider with OpenTelemetry instrumentation. -""" - -import os -from typing import List, Dict, Any, Optional -from pydantic_ai import Agent, RunContext -from pydantic_ai.models.openai import OpenAIModel -from opentelemetry import trace -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor -from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter -from posthog import Posthog - -from .base import BaseProvider -from .constants import OPENAI_CHAT_MODEL, SYSTEM_PROMPT_FRIENDLY - - -class PydanticAIOtelProvider(BaseProvider): - """Pydantic AI provider with OpenTelemetry instrumentation.""" - - _otel_configured = False - - def __init__(self, posthog_client: Posthog): - super().__init__(posthog_client) - self._setup_otel() - self.model = OpenAIModel(OPENAI_CHAT_MODEL) - self.agent = self._create_agent() - - def _setup_otel(self): - if PydanticAIOtelProvider._otel_configured: - return - - posthog_api_key = os.getenv("POSTHOG_API_KEY") - posthog_host = os.getenv("POSTHOG_HOST", "http://localhost:8010") - - if not posthog_api_key: - raise ValueError("POSTHOG_API_KEY must be set") - - # Configure OTLP exporter via env vars (following Pydantic AI docs pattern) - # Use TRACES_ENDPOINT to avoid automatic /v1/traces suffix - os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = f"{posthog_host}/i/v0/ai/otel" - os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = f"Authorization=Bearer {posthog_api_key}" - - exporter = OTLPSpanExporter() - resource_attributes = { - "service.name": "llm-analytics-app-pydantic-ai", - "user.id": os.getenv("POSTHOG_DISTINCT_ID", "unknown"), - } - if self.debug_mode: - resource_attributes["posthog.ai.debug"] = "true" - - tracer_provider = TracerProvider(resource=Resource.create(resource_attributes)) - tracer_provider.add_span_processor(BatchSpanProcessor(exporter)) - - trace.set_tracer_provider(tracer_provider) - Agent.instrument_all() - # End of OTEL setup - - PydanticAIOtelProvider._otel_configured = True - - def _create_agent(self) -> Agent: - agent = Agent(self.model, system_prompt=SYSTEM_PROMPT_FRIENDLY) - - @agent.tool - def get_weather(ctx: RunContext[None], latitude: float, longitude: float, location_name: str) -> str: - """Get current weather for a location.""" - return BaseProvider.get_weather(self, latitude, longitude, location_name) - - @agent.tool - def tell_joke(ctx: RunContext[None], setup: str, punchline: str) -> str: - """Tell a joke.""" - return BaseProvider.tell_joke(self, setup, punchline) - - return agent - - def get_name(self) -> str: - return "Pydantic AI with OpenTelemetry" - - def get_initial_messages(self) -> List[Dict[str, Any]]: - return [] - - def get_tool_definitions(self) -> List[Dict[str, Any]]: - return [] - - def chat(self, user_input: str, base64_image: Optional[str] = None) -> str: - result = self.agent.run_sync(user_input) - return result.output diff --git a/python/scripts/generate_demo_data.py b/python/scripts/generate_demo_data.py index 3415103..e891537 100644 --- a/python/scripts/generate_demo_data.py +++ b/python/scripts/generate_demo_data.py @@ -7,38 +7,1279 @@ The User Simulator acts as a curious human user, picking random topics and having natural multi-turn conversations with the target providers. + +This script is self-contained and uses the PostHog AI SDKs directly, +without depending on the providers/ directory. """ import argparse +import json +import logging +import math import os import random +import re import sys import time import uuid from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Optional +from datetime import datetime, timezone +from typing import Generator, Optional +from urllib.request import urlopen +from zoneinfo import ZoneInfo from dotenv import load_dotenv -# Add parent directory to path so we can import providers -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - from langchain_openai import ChatOpenAI from langchain_core.messages import HumanMessage, AIMessage, SystemMessage from posthog import Posthog -# Provider imports -from providers.anthropic import AnthropicProvider -from providers.anthropic_streaming import AnthropicStreamingProvider -from providers.gemini import GeminiProvider -from providers.gemini_streaming import GeminiStreamingProvider -from providers.langchain import LangChainProvider -from providers.openai import OpenAIProvider -from providers.openai_chat import OpenAIChatProvider -from providers.openai_chat_streaming import OpenAIChatStreamingProvider -from providers.openai_streaming import OpenAIStreamingProvider -from providers.litellm_provider import LiteLLMProvider -from providers.litellm_streaming import LiteLLMStreamingProvider + +# --------------------------------------------------------------------------- +# Constants (matching providers/constants.py) +# --------------------------------------------------------------------------- + +OPENAI_CHAT_MODEL = "gpt-4o-mini" +ANTHROPIC_MODEL = "claude-sonnet-4-5-20250929" +GEMINI_MODEL = "gemini-2.5-flash" +DEFAULT_MAX_TOKENS = 4000 +DEFAULT_POSTHOG_DISTINCT_ID = "user-hog" +SYSTEM_PROMPT_FRIENDLY = ( + "You are a friendly AI that just makes conversation. " + "You have access to a weather tool if the user asks about weather." +) +SYSTEM_PROMPT_ASSISTANT = ( + "You are a helpful assistant. " + "You have access to tools that you can use to help answer questions." +) + + +# --------------------------------------------------------------------------- +# Tool helpers +# --------------------------------------------------------------------------- + +def get_weather(latitude: float, longitude: float, location_name: str = None) -> str: + """Get real weather data from Open-Meteo API using coordinates.""" + try: + from urllib.parse import urlencode + params = urlencode({ + "latitude": latitude, + "longitude": longitude, + "current": "temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m", + "temperature_unit": "celsius", + "wind_speed_unit": "kmh", + "precipitation_unit": "mm", + }) + url = f"https://api.open-meteo.com/v1/forecast?{params}" + with urlopen(url, timeout=10) as resp: + weather_data = json.loads(resp.read().decode()) + + current = weather_data.get("current", {}) + temp_celsius = current.get("temperature_2m", 0) + temp_fahrenheit = int(temp_celsius * 9 / 5 + 32) + feels_like_celsius = current.get("apparent_temperature", temp_celsius) + humidity = current.get("relative_humidity_2m", 0) + wind_speed = current.get("wind_speed_10m", 0) + precipitation = current.get("precipitation", 0) + + weather_descriptions = { + 0: "clear skies", 1: "mainly clear", 2: "partly cloudy", 3: "overcast", + 45: "foggy", 48: "depositing rime fog", + 51: "light drizzle", 53: "moderate drizzle", 55: "dense drizzle", + 61: "slight rain", 63: "moderate rain", 65: "heavy rain", + 71: "slight snow", 73: "moderate snow", 75: "heavy snow", 77: "snow grains", + 80: "slight rain showers", 81: "moderate rain showers", 82: "violent rain showers", + 85: "slight snow showers", 86: "heavy snow showers", + 95: "thunderstorm", 96: "thunderstorm with slight hail", + 99: "thunderstorm with heavy hail", + } + weather_desc = weather_descriptions.get(current.get("weather_code", 0), "unknown conditions") + location_str = location_name if location_name else f"coordinates ({latitude}, {longitude})" + + result = ( + f"The current weather in {location_str} is {temp_celsius}\u00b0C ({temp_fahrenheit}\u00b0F) " + f"with {weather_desc}. Feels like {feels_like_celsius}\u00b0C. " + f"Humidity: {humidity}%, Wind: {wind_speed} km/h" + ) + if precipitation > 0: + result += f", Precipitation: {precipitation} mm" + return result + except Exception: + location_str = location_name if location_name else f"coordinates ({latitude}, {longitude})" + return f"Weather API unavailable for {location_str}. Using mock data: experiencing typical weather conditions." + + +def tell_joke(setup: str, punchline: str) -> str: + return f"{setup}\n\n{punchline}" + + +def roll_dice(num_dice: int = 1, sides: int = 6) -> str: + num_dice = max(1, min(num_dice, 20)) + sides = max(2, min(sides, 100)) + rolls = [random.randint(1, sides) for _ in range(num_dice)] + total = sum(rolls) + if num_dice == 1: + return f"Rolled a d{sides}: {rolls[0]}" + return f"Rolled {num_dice}d{sides}: {rolls} (total: {total})" + + +def check_time(timezone_name: str = "UTC") -> str: + try: + tz = ZoneInfo(timezone_name) + now = datetime.now(tz) + return f"The current time in {timezone_name} is {now.strftime('%I:%M %p on %A, %B %d, %Y')}" + except Exception: + now = datetime.now(timezone.utc) + return f"Unknown timezone '{timezone_name}'. UTC time is {now.strftime('%I:%M %p on %A, %B %d, %Y')}" + + +def calculate(expression: str) -> str: + allowed = set("0123456789+-*/.() ") + if not all(c in allowed for c in expression): + return f"Invalid expression: '{expression}'. Only basic arithmetic is supported." + try: + result = eval(expression, {"__builtins__": {}}, {"math": math}) + return f"{expression} = {result}" + except Exception as e: + return f"Error evaluating '{expression}': {e}" + + +def convert_units(value: float, from_unit: str, to_unit: str) -> str: + conversions = { + ("km", "miles"): lambda v: v * 0.621371, + ("miles", "km"): lambda v: v * 1.60934, + ("kg", "lbs"): lambda v: v * 2.20462, + ("lbs", "kg"): lambda v: v * 0.453592, + ("celsius", "fahrenheit"): lambda v: v * 9 / 5 + 32, + ("fahrenheit", "celsius"): lambda v: (v - 32) * 5 / 9, + ("meters", "feet"): lambda v: v * 3.28084, + ("feet", "meters"): lambda v: v * 0.3048, + ("liters", "gallons"): lambda v: v * 0.264172, + ("gallons", "liters"): lambda v: v * 3.78541, + } + key = (from_unit.lower(), to_unit.lower()) + if key in conversions: + result = conversions[key](value) + return f"{value} {from_unit} = {result:.2f} {to_unit}" + return ( + f"Cannot convert from {from_unit} to {to_unit}. " + f"Supported pairs: {', '.join(f'{a}->{b}' for a, b in conversions.keys())}" + ) + + +def generate_inspirational_quote(topic: str = "general") -> str: + quotes = { + "general": [ + "The only way to do great work is to love what you do. - Steve Jobs", + "In the middle of difficulty lies opportunity. - Albert Einstein", + "What you get by achieving your goals is not as important as what you become by achieving your goals. - Zig Ziglar", + ], + "perseverance": [ + "It does not matter how slowly you go as long as you do not stop. - Confucius", + "Fall seven times, stand up eight. - Japanese Proverb", + "Perseverance is not a long race; it is many short races one after the other. - Walter Elliot", + ], + "creativity": [ + "Creativity is intelligence having fun. - Albert Einstein", + "The chief enemy of creativity is good sense. - Pablo Picasso", + "Creativity takes courage. - Henri Matisse", + ], + "success": [ + "Success is not final, failure is not fatal: it is the courage to continue that counts. - Winston Churchill", + "The secret of success is to do the common thing uncommonly well. - John D. Rockefeller Jr.", + "Success usually comes to those who are too busy to be looking for it. - Henry David Thoreau", + ], + "teamwork": [ + "Alone we can do so little; together we can do so much. - Helen Keller", + "Coming together is a beginning, staying together is progress, and working together is success. - Henry Ford", + "If everyone is moving forward together, then success takes care of itself. - Henry Ford", + ], + } + topic_quotes = quotes.get(topic.lower(), quotes["general"]) + return random.choice(topic_quotes) + + +def execute_tool(tool_name: str, tool_input: dict) -> Optional[str]: + """Execute a tool by name and return the result.""" + if tool_name in ("get_weather", "get_current_weather"): + return get_weather( + tool_input.get("latitude", 0.0), + tool_input.get("longitude", 0.0), + tool_input.get("location_name"), + ) + elif tool_name == "tell_joke": + return tell_joke(tool_input.get("setup", ""), tool_input.get("punchline", "")) + elif tool_name == "roll_dice": + return roll_dice(tool_input.get("num_dice", 1), tool_input.get("sides", 6)) + elif tool_name == "check_time": + return check_time(tool_input.get("timezone", "UTC")) + elif tool_name == "calculate": + return calculate(tool_input.get("expression", "0")) + elif tool_name == "convert_units": + return convert_units( + tool_input.get("value", 0), + tool_input.get("from_unit", ""), + tool_input.get("to_unit", ""), + ) + elif tool_name == "generate_inspirational_quote": + return generate_inspirational_quote(tool_input.get("topic", "general")) + return None + + +def format_tool_result(tool_name: str, result: str) -> str: + icons = { + "get_weather": "\U0001f324\ufe0f Weather", + "get_current_weather": "\U0001f324\ufe0f Weather", + "tell_joke": "\U0001f602 Joke", + "roll_dice": "\U0001f3b2 Dice", + "check_time": "\U0001f550 Time", + "calculate": "\U0001f9ee Calculate", + "convert_units": "\U0001f4cf Convert", + "generate_inspirational_quote": "\U0001f4a1 Quote", + } + label = icons.get(tool_name, tool_name) + return f"{label}: {result}" + + +# --------------------------------------------------------------------------- +# Tool definitions in each provider format +# --------------------------------------------------------------------------- + +WEATHER_TOOL_ANTHROPIC = { + "name": "get_weather", + "description": "Get the current weather for a specific location using geographical coordinates", + "input_schema": { + "type": "object", + "properties": { + "latitude": {"type": "number", "description": "The latitude of the location"}, + "longitude": {"type": "number", "description": "The longitude of the location"}, + "location_name": {"type": "string", "description": "A human-readable name for the location"}, + }, + "required": ["latitude", "longitude", "location_name"], + }, +} + +JOKE_TOOL_ANTHROPIC = { + "name": "tell_joke", + "description": "Tell a joke with a question-style setup and an answer punchline", + "input_schema": { + "type": "object", + "properties": { + "setup": {"type": "string", "description": "The setup or question part of the joke"}, + "punchline": {"type": "string", "description": "The punchline or answer to the joke"}, + }, + "required": ["setup", "punchline"], + }, +} + +ANTHROPIC_TOOLS = [WEATHER_TOOL_ANTHROPIC, JOKE_TOOL_ANTHROPIC] + +WEATHER_TOOL_OPENAI_RESPONSES = { + "type": "function", + "name": "get_weather", + "description": "Get the current weather for a specific location using geographical coordinates", + "parameters": { + "type": "object", + "properties": { + "latitude": {"type": "number", "description": "The latitude of the location"}, + "longitude": {"type": "number", "description": "The longitude of the location"}, + "location_name": {"type": "string", "description": "A human-readable name for the location"}, + }, + "required": ["latitude", "longitude", "location_name"], + }, +} + +JOKE_TOOL_OPENAI_RESPONSES = { + "type": "function", + "name": "tell_joke", + "description": "Tell a joke with a question-style setup and an answer punchline", + "parameters": { + "type": "object", + "properties": { + "setup": {"type": "string", "description": "The setup or question part of the joke"}, + "punchline": {"type": "string", "description": "The punchline or answer to the joke"}, + }, + "required": ["setup", "punchline"], + }, +} + +OPENAI_RESPONSES_TOOLS = [WEATHER_TOOL_OPENAI_RESPONSES, JOKE_TOOL_OPENAI_RESPONSES] + +WEATHER_TOOL_OPENAI_CHAT = { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a specific location using geographical coordinates", + "parameters": { + "type": "object", + "properties": { + "latitude": {"type": "number", "description": "The latitude of the location"}, + "longitude": {"type": "number", "description": "The longitude of the location"}, + "location_name": {"type": "string", "description": "A human-readable name for the location"}, + }, + "required": ["latitude", "longitude", "location_name"], + }, + }, +} + +JOKE_TOOL_OPENAI_CHAT = { + "type": "function", + "function": { + "name": "tell_joke", + "description": "Tell a joke with a question-style setup and an answer punchline", + "parameters": { + "type": "object", + "properties": { + "setup": {"type": "string", "description": "The setup or question part of the joke"}, + "punchline": {"type": "string", "description": "The punchline or answer to the joke"}, + }, + "required": ["setup", "punchline"], + }, + }, +} + +OPENAI_CHAT_TOOLS = [WEATHER_TOOL_OPENAI_CHAT, JOKE_TOOL_OPENAI_CHAT] + +GEMINI_TOOL_DECLARATIONS = [ + { + "name": "get_current_weather", + "description": "Gets the current weather for a given location using geographical coordinates.", + "parameters": { + "type": "object", + "properties": { + "latitude": {"type": "number", "description": "The latitude of the location"}, + "longitude": {"type": "number", "description": "The longitude of the location"}, + "location_name": {"type": "string", "description": "A human-readable name for the location"}, + }, + "required": ["latitude", "longitude", "location_name"], + }, + }, + { + "name": "tell_joke", + "description": "Tell a joke with a question-style setup and an answer punchline", + "parameters": { + "type": "object", + "properties": { + "setup": {"type": "string", "description": "The setup of the joke"}, + "punchline": {"type": "string", "description": "The punchline or answer to the joke"}, + }, + "required": ["setup", "punchline"], + }, + }, +] + + +# --------------------------------------------------------------------------- +# Provider class +# --------------------------------------------------------------------------- + +class Provider: + """ + Wraps an LLM SDK client with a uniform interface for the demo data generator. + + Each provider instance tracks its own conversation history and exposes + ``chat()`` for non-streaming calls and ``chat_stream()`` (optional) for + streaming calls. + """ + + def __init__(self, name: str): + self._name = name + self.messages = [] + + def get_name(self) -> str: + return self._name + + def reset_conversation(self): + self.messages = [] + + def chat(self, message: str) -> str: + raise NotImplementedError + + # Streaming providers override this; the presence of this method is checked + # by ``get_response_from_provider``. + # def chat_stream(self, message: str) -> Generator[str, None, None]: ... + + +# --------------------------------------------------------------------------- +# Provider factory functions +# --------------------------------------------------------------------------- + +def _make_anthropic(posthog_client: Posthog) -> Provider: + from posthog.ai.anthropic import Anthropic + + client = Anthropic( + api_key=os.getenv("ANTHROPIC_API_KEY"), + posthog_client=posthog_client, + ) + + provider = Provider("Anthropic") + + def chat(message: str) -> str: + provider.messages.append({"role": "user", "content": message}) + response = client.messages.create( + model=ANTHROPIC_MODEL, + max_tokens=DEFAULT_MAX_TOKENS, + posthog_distinct_id=os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), + tools=ANTHROPIC_TOOLS, + messages=provider.messages, + ) + assistant_content = [] + display_parts = [] + tool_results = [] + + for block in (response.content or []): + if block.type == "tool_use": + assistant_content.append(block) + result = execute_tool(block.name, block.input) + if result is not None: + text = format_tool_result(block.name, result) + tool_results.append(text) + display_parts.append(text) + elif block.type == "text": + assistant_content.append(block) + display_parts.append(block.text) + + provider.messages.append({"role": "assistant", "content": assistant_content}) + + if tool_results: + for block in response.content: + if block.type == "tool_use": + provider.messages.append({ + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": block.id, "content": tool_results[0]}], + }) + break + + return "\n\n".join(display_parts) if display_parts else "No response received" + + provider.chat = chat + return provider + + +def _make_anthropic_streaming(posthog_client: Posthog) -> Provider: + from posthog.ai.anthropic import Anthropic + + client = Anthropic( + api_key=os.getenv("ANTHROPIC_API_KEY"), + posthog_client=posthog_client, + ) + + provider = Provider("Anthropic Streaming") + + def chat_stream(message: str) -> Generator[str, None, None]: + provider.messages.append({"role": "user", "content": message}) + stream = client.messages.create( + model=ANTHROPIC_MODEL, + max_tokens=DEFAULT_MAX_TOKENS, + posthog_distinct_id=os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), + tools=ANTHROPIC_TOOLS, + messages=provider.messages, + stream=True, + ) + + accumulated = "" + assistant_content = [] + tools_used = [] + current_text_block = None + + for event in stream: + if not hasattr(event, "type"): + continue + + if event.type == "content_block_start" and hasattr(event, "content_block"): + cb = event.content_block + if hasattr(cb, "type"): + if cb.type == "text": + current_text_block = {"type": "text", "text": ""} + assistant_content.append(current_text_block) + elif cb.type == "tool_use": + tools_used.append({ + "id": getattr(cb, "id", None), + "name": getattr(cb, "name", None), + "input": {}, + }) + assistant_content.append({ + "type": "tool_use", + "id": getattr(cb, "id", None), + "name": getattr(cb, "name", None), + "input": {}, + }) + current_text_block = None + + elif event.type == "content_block_delta" and hasattr(event, "delta"): + if hasattr(event.delta, "text"): + text = event.delta.text + accumulated += text + if current_text_block is not None: + current_text_block["text"] += text + yield text + elif hasattr(event.delta, "partial_json") and tools_used: + last = tools_used[-1] + last.setdefault("input_string", "") + last["input_string"] += event.delta.partial_json or "" + + elif event.type == "content_block_stop": + current_text_block = None + if tools_used and tools_used[-1].get("input_string"): + last = tools_used[-1] + try: + last["input"] = json.loads(last.pop("input_string")) + for c in assistant_content: + if c.get("type") == "tool_use" and c.get("id") == last["id"]: + c["input"] = last["input"] + break + result = execute_tool(last["name"], last["input"]) + if result is not None: + yield "\n\n" + format_tool_result(last["name"], result) + except (json.JSONDecodeError, AttributeError): + pass + + provider.messages.append({ + "role": "assistant", + "content": assistant_content if assistant_content else [{"type": "text", "text": accumulated or ""}], + }) + + for tool in tools_used: + if tool.get("input"): + result = execute_tool(tool["name"], tool["input"]) + if result is not None: + provider.messages.append({ + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": tool["id"], "content": result}], + }) + + def chat(message: str) -> str: + return "".join(chat_stream(message)) or "No response received" + + provider.chat = chat + provider.chat_stream = chat_stream + return provider + + +def _make_openai(posthog_client: Posthog) -> Provider: + from posthog.ai.openai import OpenAI + + client = OpenAI( + api_key=os.getenv("OPENAI_API_KEY"), + posthog_client=posthog_client, + ) + + provider = Provider("OpenAI Responses") + + def chat(message: str) -> str: + provider.messages.append({"role": "user", "content": message}) + response = client.responses.create( + model=OPENAI_CHAT_MODEL, + max_output_tokens=DEFAULT_MAX_TOKENS, + posthog_distinct_id=os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), + input=provider.messages, + instructions=SYSTEM_PROMPT_FRIENDLY, + tools=OPENAI_RESPONSES_TOOLS, + ) + + display_parts = [] + assistant_items = [] + + for item in (getattr(response, "output", None) or []): + if hasattr(item, "content") and item.content: + for ci in item.content: + if hasattr(ci, "text") and ci.text: + display_parts.append(ci.text) + assistant_items.append({"type": "output_text", "text": ci.text}) + if hasattr(item, "name"): + args = {} + try: + args = json.loads(getattr(item, "arguments", "{}")) + except json.JSONDecodeError: + pass + result = execute_tool(item.name, args) + if result is not None: + display_parts.append(format_tool_result(item.name, result)) + assistant_items.append({"type": "output_text", "text": result}) + + if assistant_items: + provider.messages.append({"role": "assistant", "content": assistant_items}) + + return "\n\n".join(display_parts) if display_parts else "No response received" + + provider.chat = chat + return provider + + +def _make_openai_streaming(posthog_client: Posthog) -> Provider: + from posthog.ai.openai import OpenAI + + client = OpenAI( + api_key=os.getenv("OPENAI_API_KEY"), + posthog_client=posthog_client, + ) + + provider = Provider("OpenAI Responses Streaming") + + def chat_stream(message: str) -> Generator[str, None, None]: + provider.messages.append({"role": "user", "content": message}) + stream = client.responses.create( + model=OPENAI_CHAT_MODEL, + max_output_tokens=DEFAULT_MAX_TOKENS, + posthog_distinct_id=os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), + input=provider.messages, + instructions=SYSTEM_PROMPT_FRIENDLY, + tools=OPENAI_RESPONSES_TOOLS, + stream=True, + ) + + accumulated = "" + tool_calls = [] + assistant_items = [] + + for chunk in stream: + if not hasattr(chunk, "type"): + continue + if chunk.type == "response.output_text.delta" and hasattr(chunk, "delta") and chunk.delta: + accumulated += chunk.delta + yield chunk.delta + elif chunk.type == "response.output_item.added": + if hasattr(chunk, "item") and getattr(chunk.item, "type", None) == "function_call": + idx = getattr(chunk, "output_index", len(tool_calls)) + if idx >= len(tool_calls): + tool_calls.append({ + "name": getattr(chunk.item, "name", "get_weather"), + "call_id": getattr(chunk.item, "call_id", ""), + "arguments": "", + }) + elif chunk.type == "response.function_call_arguments.done": + idx = getattr(chunk, "output_index", 0) + if idx < len(tool_calls): + tc = tool_calls[idx] + tc["arguments"] = getattr(chunk, "arguments", "{}") + args = {} + try: + args = json.loads(tc["arguments"]) + except json.JSONDecodeError: + pass + result = execute_tool(tc["name"], args) + if result is not None: + yield "\n\n" + format_tool_result(tc["name"], result) + + if accumulated: + assistant_items.append({"type": "output_text", "text": accumulated}) + for tc in tool_calls: + if tc.get("arguments"): + args = {} + try: + args = json.loads(tc["arguments"]) + except json.JSONDecodeError: + pass + result = execute_tool(tc["name"], args) + if result is not None: + assistant_items.append({"type": "output_text", "text": result}) + + if assistant_items: + provider.messages.append({"role": "assistant", "content": assistant_items}) + + def chat(message: str) -> str: + return "".join(chat_stream(message)) or "No response received" + + provider.chat = chat + provider.chat_stream = chat_stream + return provider + + +def _make_openai_chat(posthog_client: Posthog) -> Provider: + from posthog.ai.openai import OpenAI + + client = OpenAI( + api_key=os.getenv("OPENAI_API_KEY"), + posthog_client=posthog_client, + ) + + provider = Provider("OpenAI Chat Completions") + provider.messages = [{"role": "system", "content": SYSTEM_PROMPT_FRIENDLY}] + + def reset(): + provider.messages = [{"role": "system", "content": SYSTEM_PROMPT_FRIENDLY}] + + provider.reset_conversation = reset + + def chat(message: str) -> str: + provider.messages.append({"role": "user", "content": message}) + response = client.chat.completions.create( + model=OPENAI_CHAT_MODEL, + max_completion_tokens=DEFAULT_MAX_TOKENS, + posthog_distinct_id=os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), + messages=provider.messages, + tools=OPENAI_CHAT_TOOLS, + tool_choice="auto", + ) + + choice = response.choices[0] + msg = choice.message + display_parts = [] + assistant_content = msg.content or "" + if assistant_content: + display_parts.append(assistant_content) + + if msg.tool_calls: + provider.messages.append({ + "role": "assistant", + "content": assistant_content, + "tool_calls": [ + {"id": tc.id, "type": "function", "function": {"name": tc.function.name, "arguments": tc.function.arguments}} + for tc in msg.tool_calls + ], + }) + for tc in msg.tool_calls: + args = {} + try: + args = json.loads(tc.function.arguments) + except json.JSONDecodeError: + pass + result = execute_tool(tc.function.name, args) + if result is not None: + display_parts.append(format_tool_result(tc.function.name, result)) + provider.messages.append({"role": "tool", "tool_call_id": tc.id, "content": result}) + else: + provider.messages.append({"role": "assistant", "content": assistant_content}) + + return "\n\n".join(display_parts) if display_parts else "No response received" + + provider.chat = chat + return provider + + +def _make_openai_chat_streaming(posthog_client: Posthog) -> Provider: + from posthog.ai.openai import OpenAI + + client = OpenAI( + api_key=os.getenv("OPENAI_API_KEY"), + posthog_client=posthog_client, + ) + + provider = Provider("OpenAI Chat Completions Streaming") + provider.messages = [{"role": "system", "content": SYSTEM_PROMPT_FRIENDLY}] + + def reset(): + provider.messages = [{"role": "system", "content": SYSTEM_PROMPT_FRIENDLY}] + + provider.reset_conversation = reset + + def chat_stream(message: str) -> Generator[str, None, None]: + provider.messages.append({"role": "user", "content": message}) + stream = client.chat.completions.create( + model=OPENAI_CHAT_MODEL, + max_completion_tokens=DEFAULT_MAX_TOKENS, + posthog_distinct_id=os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), + messages=provider.messages, + tools=OPENAI_CHAT_TOOLS, + tool_choice="auto", + stream=True, + stream_options={"include_usage": True}, + ) + + accumulated = "" + tool_calls_by_index = {} + tool_calls = [] + + for chunk in stream: + if not (hasattr(chunk, "choices") and chunk.choices): + continue + choice = chunk.choices[0] + if hasattr(choice, "delta"): + if hasattr(choice.delta, "content") and choice.delta.content: + accumulated += choice.delta.content + yield choice.delta.content + + if hasattr(choice.delta, "tool_calls") and choice.delta.tool_calls: + for tcd in choice.delta.tool_calls: + idx = tcd.index + if idx not in tool_calls_by_index: + tool_calls_by_index[idx] = {"id": "", "type": "function", "function": {"name": "", "arguments": ""}} + tc = tool_calls_by_index[idx] + if hasattr(tcd, "id") and tcd.id: + tc["id"] = tcd.id + if hasattr(tcd, "function"): + if hasattr(tcd.function, "name") and tcd.function.name: + tc["function"]["name"] = tcd.function.name + if hasattr(tcd.function, "arguments") and tcd.function.arguments: + tc["function"]["arguments"] += tcd.function.arguments + + if hasattr(choice, "finish_reason") and choice.finish_reason == "tool_calls": + completed = [tool_calls_by_index[i] for i in sorted(tool_calls_by_index.keys())] + for tc in completed: + tool_calls.append(tc) + args = {} + try: + args = json.loads(tc["function"]["arguments"]) + except json.JSONDecodeError: + pass + result = execute_tool(tc["function"]["name"], args) + if result is not None: + yield "\n\n" + format_tool_result(tc["function"]["name"], result) + + assistant_msg = {"role": "assistant"} + if accumulated: + assistant_msg["content"] = accumulated + if tool_calls: + assistant_msg["tool_calls"] = tool_calls + provider.messages.append(assistant_msg) + + for tc in tool_calls: + args = {} + try: + args = json.loads(tc["function"]["arguments"]) + except json.JSONDecodeError: + pass + result = execute_tool(tc["function"]["name"], args) + if result is not None: + provider.messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result}) + + def chat(message: str) -> str: + return "".join(chat_stream(message)) or "No response received" + + provider.chat = chat + provider.chat_stream = chat_stream + return provider + + +def _make_gemini(posthog_client: Posthog) -> Provider: + from posthog.ai.gemini import Client + from google.genai import types + + client = Client( + api_key=os.getenv("GEMINI_API_KEY"), + posthog_client=posthog_client, + ) + + tools_obj = types.Tool(function_declarations=GEMINI_TOOL_DECLARATIONS) + config = types.GenerateContentConfig(tools=[tools_obj]) + + provider = Provider("Google Gemini") + provider._history = [] + + def reset(): + provider.messages = [] + provider._history = [] + + provider.reset_conversation = reset + + def chat(message: str) -> str: + provider._history.append({"role": "user", "parts": [{"text": message}]}) + response = client.models.generate_content( + model=GEMINI_MODEL, + posthog_distinct_id=os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), + contents=provider._history, + config=config, + ) + + display_parts = [] + model_parts = [] + + if hasattr(response, "candidates") and response.candidates: + for candidate in response.candidates: + if hasattr(candidate, "content") and candidate.content: + for part in candidate.content.parts: + if hasattr(part, "function_call") and part.function_call: + fc = part.function_call + model_parts.append({"function_call": fc}) + result = execute_tool(fc.name, dict(fc.args)) + if result is not None: + display_parts.append(format_tool_result(fc.name, result)) + elif hasattr(part, "text"): + model_parts.append({"text": part.text}) + display_parts.append(part.text) + + if model_parts: + provider._history.append({"role": "model", "parts": model_parts}) + + return "\n\n".join(display_parts) if display_parts else ( + response.text if hasattr(response, "text") else "No response received" + ) + + provider.chat = chat + return provider + + +def _make_gemini_streaming(posthog_client: Posthog) -> Provider: + from posthog.ai.gemini import Client + from google.genai import types + + client = Client( + api_key=os.getenv("GEMINI_API_KEY"), + posthog_client=posthog_client, + ) + + tools_obj = types.Tool(function_declarations=GEMINI_TOOL_DECLARATIONS) + config = types.GenerateContentConfig(tools=[tools_obj]) + + provider = Provider("Google Gemini Streaming") + provider._history = [] + + def reset(): + provider.messages = [] + provider._history = [] + + provider.reset_conversation = reset + + def chat_stream(message: str) -> Generator[str, None, None]: + provider._history.append({"role": "user", "parts": [{"text": message}]}) + stream = client.models.generate_content_stream( + model=GEMINI_MODEL, + posthog_distinct_id=os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), + contents=provider._history, + config=config, + ) + + accumulated = "" + model_parts = [] + tool_results = [] + + for chunk in stream: + if hasattr(chunk, "candidates") and chunk.candidates: + for candidate in chunk.candidates: + if hasattr(candidate, "content") and candidate.content: + for part in candidate.content.parts: + if hasattr(part, "text") and part.text: + accumulated += part.text + yield part.text + elif hasattr(part, "function_call") and part.function_call: + fc = part.function_call + model_parts.append({"function_call": fc}) + result = execute_tool(fc.name, dict(fc.args)) + if result is not None: + text = format_tool_result(fc.name, result) + tool_results.append(text) + yield "\n\n" + text + + if accumulated: + model_parts.append({"text": accumulated}) + if model_parts: + provider._history.append({"role": "model", "parts": model_parts}) + for tr in tool_results: + provider._history.append({"role": "model", "parts": [{"text": f"Tool result: {tr}"}]}) + + def chat(message: str) -> str: + return "".join(chat_stream(message)) or "No response received" + + provider.chat = chat + provider.chat_stream = chat_stream + return provider + + +def _make_langchain(posthog_client: Posthog) -> Provider: + from posthog.ai.langchain import CallbackHandler + from langchain_core.tools import tool + from langchain_core.messages import ToolMessage + + callback_handler = CallbackHandler(client=posthog_client) + openai_api_key = os.getenv("OPENAI_API_KEY") + + @tool + def get_weather_tool(latitude: float, longitude: float, location_name: str) -> str: + """Get the current weather for a specific location using geographical coordinates. + + Args: + latitude: The latitude of the location + longitude: The longitude of the location + location_name: A human-readable name for the location + """ + return get_weather(latitude, longitude, location_name) + + @tool + def tell_joke_tool(setup: str, punchline: str) -> str: + """Tell a joke with a question-style setup and an answer punchline. + + Args: + setup: The setup or question part of the joke + punchline: The punchline or answer part of the joke + """ + return tell_joke(setup, punchline) + + langchain_tools = [get_weather_tool, tell_joke_tool] + tool_map = {t.name: t for t in langchain_tools} + + provider = Provider("LangChain (OpenAI)") + provider._lc_messages = [SystemMessage(content=SYSTEM_PROMPT_ASSISTANT)] + + def reset(): + provider.messages = [] + provider._lc_messages = [SystemMessage(content=SYSTEM_PROMPT_ASSISTANT)] + + provider.reset_conversation = reset + + def chat(message: str) -> str: + provider._lc_messages.append(HumanMessage(content=message)) + + model = ChatOpenAI(openai_api_key=openai_api_key, temperature=0, model_name=OPENAI_CHAT_MODEL) + model_with_tools = model.bind_tools(langchain_tools) + response = model_with_tools.invoke( + provider._lc_messages, + config={"callbacks": [callback_handler]}, + ) + + display_parts = [] + if response.content: + display_parts.append(response.content) + + tool_messages = [] + if response.tool_calls: + for tc in response.tool_calls: + name = tc["name"] + if name in tool_map: + result = tool_map[name].invoke(tc["args"]) + display_parts.append(format_tool_result(name, result)) + tool_messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"])) + + provider._lc_messages.append(response) + provider._lc_messages.extend(tool_messages) + + return "\n\n".join(display_parts) if display_parts else "No response received" + + provider.chat = chat + return provider + + +def _make_litellm(posthog_client: Posthog) -> Provider: + import litellm + + os.environ["POSTHOG_API_KEY"] = os.getenv("POSTHOG_API_KEY", "") + os.environ["POSTHOG_API_URL"] = os.getenv("POSTHOG_HOST", "https://app.posthog.com") + try: + litellm.success_callback = ["posthog"] + litellm.failure_callback = ["posthog"] + except Exception: + pass + + existing_props = posthog_client.super_properties or {} + ai_session_id = existing_props.get("$ai_session_id") + + provider = Provider("LiteLLM (Sync)") + provider.messages = [{"role": "system", "content": SYSTEM_PROMPT_FRIENDLY}] + + def reset(): + provider.messages = [{"role": "system", "content": SYSTEM_PROMPT_FRIENDLY}] + + provider.reset_conversation = reset + + def _metadata(): + m = { + "distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), + "user_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), + } + if ai_session_id: + m["$ai_session_id"] = ai_session_id + return m + + def chat(message: str) -> str: + provider.messages.append({"role": "user", "content": message}) + try: + response = litellm.completion( + model=OPENAI_CHAT_MODEL, + messages=provider.messages, + tools=OPENAI_CHAT_TOOLS, + tool_choice="auto", + max_tokens=DEFAULT_MAX_TOKENS, + metadata=_metadata(), + ) + + assistant_message = response.choices[0].message + if hasattr(assistant_message, "tool_calls") and assistant_message.tool_calls: + provider.messages.append({ + "role": "assistant", + "content": assistant_message.content, + "tool_calls": [tc.dict() for tc in assistant_message.tool_calls], + }) + display_parts = [] + if assistant_message.content: + display_parts.append(assistant_message.content) + + for tc in assistant_message.tool_calls: + args = {} + try: + args = json.loads(tc.function.arguments) + except json.JSONDecodeError: + pass + result = execute_tool(tc.function.name, args) + if result is not None: + display_parts.append(format_tool_result(tc.function.name, result)) + provider.messages.append({"role": "tool", "tool_call_id": tc.id, "content": result}) + + # Get final response after tool execution + try: + final = litellm.completion( + model=OPENAI_CHAT_MODEL, + messages=provider.messages, + max_tokens=DEFAULT_MAX_TOKENS, + metadata=_metadata(), + ) + final_content = final.choices[0].message.content + if final_content: + display_parts.append(final_content) + provider.messages.append({"role": "assistant", "content": final_content}) + except Exception as e: + display_parts.append(f"Error getting final response: {e}") + + return "\n\n".join(display_parts) + else: + content = assistant_message.content or "No response received" + provider.messages.append({"role": "assistant", "content": content}) + return content + except Exception as e: + return f"Error: {e}" + + provider.chat = chat + return provider + + +def _make_litellm_streaming(posthog_client: Posthog) -> Provider: + import asyncio + import litellm + + os.environ["POSTHOG_API_KEY"] = os.getenv("POSTHOG_API_KEY", "") + os.environ["POSTHOG_API_URL"] = os.getenv("POSTHOG_HOST", "https://app.posthog.com") + try: + litellm.success_callback = ["posthog"] + litellm.failure_callback = ["posthog"] + except Exception: + pass + + existing_props = posthog_client.super_properties or {} + ai_session_id = existing_props.get("$ai_session_id") + + provider = Provider("LiteLLM (Async)") + provider.messages = [{"role": "system", "content": SYSTEM_PROMPT_FRIENDLY}] + + def reset(): + provider.messages = [{"role": "system", "content": SYSTEM_PROMPT_FRIENDLY}] + + provider.reset_conversation = reset + + def _metadata(): + m = { + "distinct_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), + "user_id": os.getenv("POSTHOG_DISTINCT_ID", DEFAULT_POSTHOG_DISTINCT_ID), + } + if ai_session_id: + m["$ai_session_id"] = ai_session_id + return m + + def chat_stream(message: str) -> Generator[str, None, None]: + provider.messages.append({"role": "user", "content": message}) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def _run(): + response = await litellm.acompletion( + model=OPENAI_CHAT_MODEL, + messages=provider.messages, + tools=OPENAI_CHAT_TOOLS, + tool_choice="auto", + max_tokens=DEFAULT_MAX_TOKENS, + metadata=_metadata(), + stream=True, + ) + + full_content = "" + tool_calls_data = [] + chunks_out = [] + + async for chunk in response: + delta = chunk.choices[0].delta + if hasattr(delta, "content") and delta.content: + full_content += delta.content + chunks_out.append(delta.content) + if hasattr(delta, "tool_calls") and delta.tool_calls: + for tc in delta.tool_calls: + if tc.index >= len(tool_calls_data): + tool_calls_data.append({ + "id": tc.id, + "type": "function", + "function": {"name": tc.function.name, "arguments": ""}, + }) + if tc.function.arguments: + tool_calls_data[tc.index]["function"]["arguments"] += tc.function.arguments + + return full_content, tool_calls_data, chunks_out + + try: + full_content, tool_calls_data, chunks_out = loop.run_until_complete(_run()) + for c in chunks_out: + yield c + + if tool_calls_data: + provider.messages.append({ + "role": "assistant", + "content": full_content, + "tool_calls": tool_calls_data, + }) + for tc in tool_calls_data: + args = {} + try: + args = json.loads(tc["function"]["arguments"]) + except json.JSONDecodeError: + pass + result = execute_tool(tc["function"]["name"], args) + if result is not None: + text = format_tool_result(tc["function"]["name"], result) + yield "\n\n" + text + provider.messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result}) + + # Get final response + async def _final(): + resp = await litellm.acompletion( + model=OPENAI_CHAT_MODEL, + messages=provider.messages, + max_tokens=DEFAULT_MAX_TOKENS, + metadata=_metadata(), + stream=True, + ) + final_content = "" + chunks = [] + async for chunk in resp: + delta = chunk.choices[0].delta + if hasattr(delta, "content") and delta.content: + final_content += delta.content + chunks.append(delta.content) + return final_content, chunks + + final_content, final_chunks = loop.run_until_complete(_final()) + for c in final_chunks: + yield c + if final_content: + provider.messages.append({"role": "assistant", "content": final_content}) + else: + if full_content: + provider.messages.append({"role": "assistant", "content": full_content}) + except Exception as e: + yield f"Error: {e}" + finally: + loop.close() + + def chat(message: str) -> str: + return "".join(chat_stream(message)) or "No response received" + + provider.chat = chat + provider.chat_stream = chat_stream + return provider + + +# --------------------------------------------------------------------------- +# PROVIDERS dict and TOOL_CAPABLE_PROVIDERS +# --------------------------------------------------------------------------- + +PROVIDERS = { + "anthropic": ("Anthropic", _make_anthropic), + "anthropic_streaming": ("Anthropic Streaming", _make_anthropic_streaming), + "gemini": ("Google Gemini", _make_gemini), + "gemini_streaming": ("Google Gemini Streaming", _make_gemini_streaming), + "langchain": ("LangChain (OpenAI)", _make_langchain), + "openai": ("OpenAI Responses", _make_openai), + "openai_streaming": ("OpenAI Responses Streaming", _make_openai_streaming), + "openai_chat": ("OpenAI Chat Completions", _make_openai_chat), + "openai_chat_streaming": ("OpenAI Chat Completions Streaming", _make_openai_chat_streaming), + "litellm": ("LiteLLM (Sync)", _make_litellm), + "litellm_streaming": ("LiteLLM (Async)", _make_litellm_streaming), +} # Topics the user simulator can choose from @@ -312,21 +1553,6 @@ # Providers that have tool definitions wired up TOOL_CAPABLE_PROVIDERS = ["openai_chat", "anthropic", "openai"] -# Available providers -PROVIDERS = { - "anthropic": ("Anthropic", AnthropicProvider), - "anthropic_streaming": ("Anthropic Streaming", AnthropicStreamingProvider), - "gemini": ("Google Gemini", GeminiProvider), - "gemini_streaming": ("Google Gemini Streaming", GeminiStreamingProvider), - "langchain": ("LangChain (OpenAI)", LangChainProvider), - "openai": ("OpenAI Responses", OpenAIProvider), - "openai_streaming": ("OpenAI Responses Streaming", OpenAIStreamingProvider), - "openai_chat": ("OpenAI Chat Completions", OpenAIChatProvider), - "openai_chat_streaming": ("OpenAI Chat Completions Streaming", OpenAIChatStreamingProvider), - "litellm": ("LiteLLM (Sync)", LiteLLMProvider), - "litellm_streaming": ("LiteLLM (Async)", LiteLLMStreamingProvider), -} - class UserSimulator: """ @@ -423,7 +1649,6 @@ def generate_message(self, assistant_response: Optional[str] = None) -> tuple[st def slugify(text: str) -> str: """Convert text to a URL/identifier-friendly slug.""" - import re # Lowercase, replace spaces and special chars with underscores slug = text.lower().strip() slug = re.sub(r'[^a-z0-9]+', '_', slug) @@ -455,8 +1680,8 @@ def create_provider(provider_key: str, posthog_client: Posthog): if provider_key not in PROVIDERS: raise ValueError(f"Unknown provider: {provider_key}") - name, provider_class = PROVIDERS[provider_key] - return provider_class(posthog_client) + name, factory_fn = PROVIDERS[provider_key] + return factory_fn(posthog_client) def get_response_from_provider(provider, message: str, verbose: bool = True) -> str: diff --git a/python/scripts/run_test.sh b/python/scripts/run_test.sh deleted file mode 100755 index 49534e6..0000000 --- a/python/scripts/run_test.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -# Script to run weather tool tests -# This reuses the same virtual environment as the main app - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" - -cd "$PROJECT_DIR" - -# Activate virtual environment if it exists -if [ -d "venv" ]; then - source venv/bin/activate - echo "โœ… Virtual environment activated" -else - echo "โŒ Virtual environment not found. Run ./run.sh first to set up." - exit 1 -fi - -# Run the test script -python scripts/test_weather_tool.py "$@" diff --git a/python/scripts/test_multi_language.py b/python/scripts/test_multi_language.py deleted file mode 100755 index dd8af59..0000000 --- a/python/scripts/test_multi_language.py +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for multi-language conversations with tool calls. -Creates a single conversation that switches between languages mid-conversation, -simulating a multilingual user. - -Usage: - python test_multi_language.py # Run multilingual conversation - python test_multi_language.py --provider openai-chat # Use specific provider - python test_multi_language.py --list # List available providers -""" - -import os -import sys -import argparse - -# Add parent directory to path so we can import from providers -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - -from posthog import Posthog -from providers.openai_chat import OpenAIChatProvider -from providers.openai_chat_streaming import OpenAIChatStreamingProvider -from providers.anthropic import AnthropicProvider -from providers.anthropic_streaming import AnthropicStreamingProvider -from dotenv import load_dotenv - -# Load environment variables -load_dotenv() - -# Define available providers -AVAILABLE_PROVIDERS = { - 'openai-chat': (OpenAIChatProvider, "OpenAI Chat Completions Provider", False), - 'openai-chat-streaming': (OpenAIChatStreamingProvider, "OpenAI Chat Completions Streaming Provider", True), - 'anthropic': (AnthropicProvider, "Anthropic Provider", False), - 'anthropic-streaming': (AnthropicStreamingProvider, "Anthropic Streaming Provider", True), -} - -# Multi-language conversation flow - starts in English, switches languages -MULTILINGUAL_CONVERSATION = [ - # Start in English - { - 'language': 'English', - 'message': "Hello! I'm planning a trip to Europe and need some help with the weather. Can you check what the weather is like in London right now?", - 'expect_tool': 'get_weather', - }, - { - 'language': 'English', - 'message': "Great, thanks! Also, tell me a joke to lighten the mood.", - 'expect_tool': 'tell_joke', - }, - # Switch to Spanish - { - 'language': 'Spanish', - 'message': "ยกPerfecto! Ahora voy a continuar en espaรฑol. ยฟCรณmo estรก el clima en Madrid?", - 'expect_tool': 'get_weather', - }, - { - 'language': 'Spanish', - 'message': "ยกGracias! ยฟMe puedes contar un chiste en espaรฑol?", - 'expect_tool': 'tell_joke', - }, - # Switch to German - { - 'language': 'German', - 'message': "Jetzt spreche ich Deutsch. Wie ist das Wetter in Berlin?", - 'expect_tool': 'get_weather', - }, - { - 'language': 'German', - 'message': "Danke schรถn! Erzรคhl mir bitte einen Witz auf Deutsch.", - 'expect_tool': 'tell_joke', - }, - # Switch to French - { - 'language': 'French', - 'message': "Maintenant je parle franรงais. Quel temps fait-il ร  Paris?", - 'expect_tool': 'get_weather', - }, - { - 'language': 'French', - 'message': "Merci beaucoup! Raconte-moi une blague en franรงais, s'il te plaรฎt.", - 'expect_tool': 'tell_joke', - }, - # Back to English for wrap-up - { - 'language': 'English', - 'message': "Thanks for all your help in multiple languages! You're a great multilingual assistant. One last weather check - how's it in Dublin, Ireland?", - 'expect_tool': 'get_weather', - }, -] - - -def init_posthog(): - """Initialize PostHog client""" - posthog_api_key = os.getenv("POSTHOG_API_KEY") - posthog_host = os.getenv("POSTHOG_HOST", "https://us.i.posthog.com") - - if not posthog_api_key: - print("โš ๏ธ Warning: POSTHOG_API_KEY not set, analytics will not be tracked") - return None - - return Posthog( - project_api_key=posthog_api_key, - host=posthog_host - ) - - -def run_multilingual_conversation(provider_class, provider_name, use_streaming=False): - """Run a single conversation that switches between multiple languages""" - print(f"\n{'='*80}") - print(f"๐ŸŒ Multi-Language Conversation Test") - print(f"๐Ÿ“ก Provider: {provider_name}") - print(f"{'='*80}\n") - - # Initialize PostHog - posthog_client = init_posthog() - - if not posthog_client: - print("โŒ Cannot run test without PostHog client") - return False - - # Initialize provider - provider = provider_class(posthog_client) - - current_language = None - success = True - - try: - for i, turn in enumerate(MULTILINGUAL_CONVERSATION, 1): - language = turn['language'] - message = turn['message'] - - # Print language switch header if language changed - if language != current_language: - print(f"\n{'โ”€'*60}") - print(f"๐Ÿ—ฃ๏ธ Switching to {language}") - print(f"{'โ”€'*60}\n") - current_language = language - - print(f"๐Ÿ‘ค User [{language}]: {message}\n") - - # Get response - if use_streaming: - print("๐Ÿค– Assistant: ", end='', flush=True) - full_response = "" - for chunk in provider.chat_stream(message): - print(chunk, end='', flush=True) - full_response += chunk - print("\n") - response = full_response - else: - response = provider.chat(message) - print(f"๐Ÿค– Assistant: {response}\n") - - print(f"\n{'='*80}") - print("โœ… Multi-language conversation completed successfully!") - print(f"{'='*80}\n") - - except Exception as e: - print(f"\nโŒ Error during conversation: {str(e)}") - import traceback - traceback.print_exc() - success = False - finally: - # Shutdown PostHog - if posthog_client: - posthog_client.shutdown() - - return success - - -def list_providers(): - """List all available providers""" - print("\n" + "="*80) - print("๐Ÿ“‹ Available Providers") - print("="*80 + "\n") - - for key, (_, name, is_streaming) in AVAILABLE_PROVIDERS.items(): - stream_indicator = " (streaming)" if is_streaming else "" - print(f" โ€ข {key:25} - {name}{stream_indicator}") - - print() - - -def main(): - """Run multi-language conversation test""" - parser = argparse.ArgumentParser( - description="Test multi-language conversations with AI providers", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - python test_multi_language.py # Run with default provider - python test_multi_language.py --provider openai-chat # Use OpenAI Chat - python test_multi_language.py --provider anthropic # Use Anthropic - python test_multi_language.py --list # List available providers - """ - ) - - parser.add_argument( - '--provider', '-p', - choices=list(AVAILABLE_PROVIDERS.keys()), - default='openai-chat', - help='Provider to use (default: openai-chat)' - ) - - parser.add_argument( - '--list', '-l', - action='store_true', - help='List available providers and exit' - ) - - args = parser.parse_args() - - # Handle --list flag - if args.list: - list_providers() - sys.exit(0) - - print("\n" + "="*80) - print("๐Ÿš€ Multi-Language Conversation Test Suite") - print("="*80) - print("\nThis test simulates a user who switches between languages") - print("during a single conversation: English โ†’ Spanish โ†’ German โ†’ French โ†’ English\n") - - # Get provider - provider_class, provider_name, use_streaming = AVAILABLE_PROVIDERS[args.provider] - - # Run the test - success = run_multilingual_conversation(provider_class, provider_name, use_streaming) - - # Print summary - print("\n" + "="*80) - print("๐Ÿ“Š Test Summary") - print("="*80 + "\n") - - if success: - print("โœ… PASS - Multi-language conversation completed") - else: - print("โŒ FAIL - Conversation had errors") - - # Exit with appropriate code - sys.exit(0 if success else 1) - - -if __name__ == "__main__": - main() diff --git a/python/scripts/test_weather_tool.py b/python/scripts/test_weather_tool.py deleted file mode 100755 index c7976df..0000000 --- a/python/scripts/test_weather_tool.py +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for weather tool functionality. -Tests that providers correctly call the weather API with lat/lon/location_name. - -Usage: - python test_weather_tool.py # Run all tests - python test_weather_tool.py --provider anthropic # Test specific provider - python test_weather_tool.py --list # List available providers -""" - -import os -import sys -import argparse - -# Add parent directory to path so we can import from providers -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - -from posthog import Posthog -from providers.anthropic import AnthropicProvider -from providers.anthropic_streaming import AnthropicStreamingProvider -from providers.gemini import GeminiProvider -from providers.gemini_streaming import GeminiStreamingProvider -from providers.openai import OpenAIProvider -from providers.openai_streaming import OpenAIStreamingProvider -from providers.openai_chat import OpenAIChatProvider -from providers.openai_chat_streaming import OpenAIChatStreamingProvider -from providers.langchain import LangChainProvider -from providers.litellm_provider import LiteLLMProvider -from dotenv import load_dotenv - -# Load environment variables -load_dotenv() - -# Define available providers -AVAILABLE_PROVIDERS = { - 'anthropic': (AnthropicProvider, "Anthropic Provider", False), - 'anthropic-streaming': (AnthropicStreamingProvider, "Anthropic Streaming Provider", True), - 'gemini': (GeminiProvider, "Google Gemini Provider", False), - 'gemini-streaming': (GeminiStreamingProvider, "Google Gemini Streaming Provider", True), - 'openai': (OpenAIProvider, "OpenAI Responses API Provider", False), - 'openai-streaming': (OpenAIStreamingProvider, "OpenAI Responses API Streaming Provider", True), - 'openai-chat': (OpenAIChatProvider, "OpenAI Chat Completions Provider", False), - 'openai-chat-streaming': (OpenAIChatStreamingProvider, "OpenAI Chat Completions Streaming Provider", True), - 'langchain': (LangChainProvider, "LangChain Provider", False), - 'litellm': (LiteLLMProvider, "LiteLLM Provider", False), -} - -def init_posthog(): - """Initialize PostHog client""" - posthog_api_key = os.getenv("POSTHOG_API_KEY") - posthog_host = os.getenv("POSTHOG_HOST", "https://us.i.posthog.com") - - if not posthog_api_key: - print("โš ๏ธ Warning: POSTHOG_API_KEY not set, analytics will not be tracked") - return None - - return Posthog( - project_api_key=posthog_api_key, - host=posthog_host - ) - -def test_provider(provider_class, provider_name, use_streaming=False): - """Test a provider with weather tool calls""" - print(f"\n{'='*80}") - print(f"๐Ÿงช Testing {provider_name}") - print(f"{'='*80}\n") - - # Initialize PostHog - posthog_client = init_posthog() - - if not posthog_client: - print("โŒ Cannot run test without PostHog client") - return False - - # Initialize provider - provider = provider_class(posthog_client) - - # Test query - test_query = "What's the weather in Dublin, Ireland?" - print(f"๐Ÿ“ Query: {test_query}\n") - - try: - if use_streaming: - print("๐Ÿ”„ Streaming response:\n") - full_response = "" - for chunk in provider.chat_stream(test_query): - print(chunk, end='', flush=True) - full_response += chunk - print("\n") - response = full_response - else: - print("๐Ÿ’ฌ Response:\n") - response = provider.chat(test_query) - print(response) - print() - - # Check if response contains weather data - if "ยฐC" in response or "ยฐF" in response: - print("โœ… Test PASSED - Weather data received!") - - # Check if location name is used - if "Dublin" in response: - print("โœ… Location name displayed correctly!") - else: - print("โš ๏ธ Warning: Location name might not be displayed") - - return True - else: - print("โŒ Test FAILED - No weather data in response") - return False - - except Exception as e: - print(f"โŒ Test FAILED with error: {str(e)}") - import traceback - traceback.print_exc() - return False - finally: - # Shutdown PostHog - if posthog_client: - posthog_client.shutdown() - -def list_providers(): - """List all available providers""" - print("\n" + "="*80) - print("๐Ÿ“‹ Available Providers") - print("="*80 + "\n") - - for key, (_, name, is_streaming) in AVAILABLE_PROVIDERS.items(): - stream_indicator = " (streaming)" if is_streaming else "" - print(f" โ€ข {key:25} - {name}{stream_indicator}") - - print() - -def main(): - """Run tests based on CLI arguments""" - parser = argparse.ArgumentParser( - description="Test weather tool functionality across different AI providers", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - python test_weather_tool.py # Run all tests - python test_weather_tool.py --provider anthropic # Test Anthropic only - python test_weather_tool.py --provider openai-chat # Test OpenAI Chat only - python test_weather_tool.py --list # List available providers - """ - ) - - parser.add_argument( - '--provider', '-p', - choices=list(AVAILABLE_PROVIDERS.keys()), - help='Test a specific provider' - ) - - parser.add_argument( - '--list', '-l', - action='store_true', - help='List available providers and exit' - ) - - args = parser.parse_args() - - # Handle --list flag - if args.list: - list_providers() - sys.exit(0) - - print("\n" + "="*80) - print("๐Ÿš€ Weather Tool Test Suite") - print("="*80) - - results = [] - - # Determine which providers to test - if args.provider: - providers_to_test = {args.provider: AVAILABLE_PROVIDERS[args.provider]} - else: - providers_to_test = AVAILABLE_PROVIDERS - - # Run tests - for key, (provider_class, provider_name, use_streaming) in providers_to_test.items(): - results.append((key, test_provider(provider_class, provider_name, use_streaming))) - - # Print summary - print("\n" + "="*80) - print("๐Ÿ“Š Test Summary") - print("="*80 + "\n") - - for name, passed in results: - status = "โœ… PASS" if passed else "โŒ FAIL" - print(f"{status} - {name}") - - total_passed = sum(1 for _, passed in results if passed) - total_tests = len(results) - - print(f"\nTotal: {total_passed}/{total_tests} tests passed") - - # Exit with appropriate code - sys.exit(0 if total_passed == total_tests else 1) - -if __name__ == "__main__": - main() From 46354babacd24bda319dfa1b9c38485098bbcb85 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 20 Mar 2026 12:47:28 +0200 Subject: [PATCH 2/3] feat: add example runner for testing SDK examples Add run-examples.sh that discovers and runs AI provider examples from the sibling posthog-python and posthog-js repos. Supports name-based matching, interactive menu, and --install for dependency setup. Update Makefile to use the new runner instead of the removed CLI. --- Makefile | 54 ++++------ run-examples.sh | 279 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 301 insertions(+), 32 deletions(-) create mode 100755 run-examples.sh diff --git a/Makefile b/Makefile index 5ef48c6..78799b4 100644 --- a/Makefile +++ b/Makefile @@ -1,46 +1,36 @@ -.PHONY: run-python run-node run-python-debug run-node-debug run-trace-generator run-trace-generator-debug run-screenshot-demo run-screenshot-demo-debug python-install python-install-reset python-install-local test-python-weather ingest-trace demo-data demo-data-quick demo-data-tools demo-data-negative +.PHONY: examples examples-list examples-all examples-python examples-node run-trace-generator run-trace-generator-debug demo-data demo-data-quick demo-data-tools demo-data-negative -run-python: - @cd python && ./run.sh +## Run the interactive example picker (sources .env, discovers examples from sibling SDK repos) +examples: + @./run-examples.sh -run-node: - @cd node && ./run.sh +## List all available examples +examples-list: + @./run-examples.sh --list -run-python-debug: - @cd python && DEBUG=1 ./run.sh +## Run all examples sequentially +examples-all: + @./run-examples.sh --all -run-node-debug: - @cd node && DEBUG=1 ./run.sh +## Run only Python examples +examples-python: + @./run-examples.sh --filter "[python]" +## Run only Node.js examples +examples-node: + @./run-examples.sh --filter "[node]" + +## Run examples matching a pattern (e.g., make examples-filter F=anthropic) +examples-filter: + @./run-examples.sh --filter "$(F)" + +## Run the trace generator (mock trace data, no LLM calls) run-trace-generator: @cd python/trace-generator && ./run.sh run-trace-generator-debug: @cd python/trace-generator && DEBUG=1 ./run.sh -run-screenshot-demo: - @cd python/screenshot-demo && ./run.sh - -run-screenshot-demo-debug: - @cd python/screenshot-demo && DEBUG=1 ./run.sh - -## Install Python deps only (donโ€™t run app). Respects POSTHOG_PYTHON_PATH or POSTHOG_PYTHON_VERSION -python-install: - @cd python && INSTALL_ONLY=1 ./run.sh - -## Install Python deps after removing existing posthog from venv (helpful when switching sources) -python-install-reset: - @cd python && RESET_POSTHOG=1 INSTALL_ONLY=1 ./run.sh - -## Convenience: install using local posthog-python checkout -# Usage: make python-install-local POSTHOG_PYTHON_PATH=/absolute/path/to/posthog-python -python-install-local: - @cd python && INSTALL_ONLY=1 POSTHOG_PYTHON_PATH="$(POSTHOG_PYTHON_PATH)" ./run.sh - -## Test Python weather tool functionality -test-python-weather: - @cd python && ./scripts/run_test.sh - ## Generate demo data (5 conversations, random providers, 5 turns each) demo-data: @cd python && source venv/bin/activate && python scripts/generate_demo_data.py --conversations 5 --max-turns 5 --parallel 3 diff --git a/run-examples.sh b/run-examples.sh new file mode 100755 index 0000000..6ea8d94 --- /dev/null +++ b/run-examples.sh @@ -0,0 +1,279 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run AI provider examples from posthog-python and posthog-js repos. +# Uses the .env file from this repo for API keys. +# +# Usage: +# ./run-examples.sh Interactive menu +# ./run-examples.sh --list List all examples +# ./run-examples.sh --all Run all examples +# ./run-examples.sh --install Install deps for all examples +# ./run-examples.sh openai/embeddings Run a specific example (fuzzy match) +# ./run-examples.sh anthropic Run all examples matching "anthropic" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PYTHON_REPO="${POSTHOG_PYTHON_PATH:-$SCRIPT_DIR/../posthog-python}" +JS_REPO="${POSTHOG_JS_PATH:-$SCRIPT_DIR/../posthog-js}" + +# Load .env +if [[ -f "$SCRIPT_DIR/.env" ]]; then + set -a + source "$SCRIPT_DIR/.env" + set +a +fi + +# Find Python: prefer the posthog-python venv, fall back to system python3 +if [[ -x "$PYTHON_REPO/.venv/bin/python" ]]; then + PYTHON="$PYTHON_REPO/.venv/bin/python" +elif command -v python3 &>/dev/null; then + PYTHON="python3" +else + PYTHON="python" +fi + +# --------------------------------------------------------------------------- +# Install dependencies +# --------------------------------------------------------------------------- + +install_python_deps() { + echo "Installing Python example dependencies into $PYTHON_REPO/.venv..." + + local all_deps=() + for req in "$PYTHON_REPO"/examples/example-ai-*/requirements.txt; do + [[ -f "$req" ]] || continue + while IFS= read -r line; do + [[ -z "$line" || "$line" == \#* ]] && continue + all_deps+=("$line") + done < "$req" + done + + if [[ ${#all_deps[@]} -eq 0 ]]; then + echo " No Python requirements found." + return + fi + + local unique_deps + unique_deps=$(printf '%s\n' "${all_deps[@]}" | sort -u) + + echo " Dependencies: $(echo "$unique_deps" | tr '\n' ' ')" + + if command -v uv &>/dev/null; then + echo "$unique_deps" | xargs uv pip install --python "$PYTHON" 2>&1 | tail -3 + else + echo "$unique_deps" | xargs "$PYTHON" -m pip install 2>&1 | tail -3 + fi + echo " Done." +} + +install_node_deps() { + echo "Installing Node.js example dependencies..." + for dir in "$JS_REPO"/examples/example-ai-*/; do + [[ -d "$dir" ]] || continue + local name + name=$(basename "$dir") + if [[ -f "$dir/package.json" ]]; then + echo " $name..." + (cd "$dir" && pnpm install --no-frozen-lockfile 2>&1 | tail -1) + fi + done + echo " Done." +} + +# --------------------------------------------------------------------------- +# Discover examples +# --------------------------------------------------------------------------- + +# Parallel arrays: NAMES[i] is the key, FILES[i] is the path, LANGS[i] is py/ts +declare -a NAMES=() +declare -a FILES=() +declare -a LANGS=() + +discover_python() { + for dir in "$PYTHON_REPO"/examples/example-ai-*/; do + [[ -d "$dir" ]] || continue + local group + group=$(basename "$dir" | sed 's/^example-ai-//') + for file in "$dir"/*.py; do + [[ -f "$file" ]] || continue + local base + base=$(basename "$file" .py) + NAMES+=("python/$group/$base") + FILES+=("$file") + LANGS+=("py") + done + done +} + +discover_node() { + for dir in "$JS_REPO"/examples/example-ai-*/; do + [[ -d "$dir" ]] || continue + local group + group=$(basename "$dir" | sed 's/^example-ai-//') + for file in "$dir"/*.ts; do + [[ -f "$file" ]] || continue + local base + base=$(basename "$file" .ts) + NAMES+=("node/$group/$base") + FILES+=("$file") + LANGS+=("ts") + done + done +} + +# --------------------------------------------------------------------------- +# Run an example +# --------------------------------------------------------------------------- + +run_example() { + local idx="$1" + local file="${FILES[$idx]}" + local lang="${LANGS[$idx]}" + local name="${NAMES[$idx]}" + local dir + dir=$(dirname "$file") + + echo "" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + echo " $name" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + echo "" + + if [[ "$lang" == "py" ]]; then + "$PYTHON" "$file" + else + (cd "$dir" && npx tsx "$(basename "$file")") + fi +} + +# Find examples matching a pattern. Returns matching indices in MATCHED array. +find_matches() { + local pattern="$1" + MATCHED=() + for i in "${!NAMES[@]}"; do + if [[ "${NAMES[$i]}" == *"$pattern"* ]]; then + MATCHED+=("$i") + fi + done +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +# Handle --install before discovering examples +if [[ "${1:-}" == "--install" ]]; then + install_python_deps + echo "" + install_node_deps + exit 0 +fi + +discover_python +discover_node + +if [[ ${#NAMES[@]} -eq 0 ]]; then + echo "No examples found." + echo "Expected posthog-python at: $PYTHON_REPO" + echo "Expected posthog-js at: $JS_REPO" + echo "" + echo "Set POSTHOG_PYTHON_PATH or POSTHOG_JS_PATH to override." + exit 1 +fi + +MODE="${1:-}" + +if [[ "$MODE" == "--all" ]]; then + echo "Running all ${#NAMES[@]} examples..." + FAILED=0 + PASSED=0 + for i in "${!NAMES[@]}"; do + if run_example "$i"; then + (( PASSED++ )) + else + (( FAILED++ )) + echo "FAILED: ${NAMES[$i]}" + fi + done + echo "" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + echo "Results: $PASSED passed, $FAILED failed (${#NAMES[@]} total)" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + exit $FAILED + +elif [[ "$MODE" == "--list" ]]; then + echo "Available examples:" + echo "" + for name in "${NAMES[@]}"; do + echo " $name" + done + echo "" + echo "${#NAMES[@]} examples found." + +elif [[ -n "$MODE" && "$MODE" != --* ]]; then + # Name-based matching + find_matches "$MODE" + if [[ ${#MATCHED[@]} -eq 0 ]]; then + echo "No examples matching '$MODE'." + echo "" + echo "Use --list to see all available examples." + exit 1 + elif [[ ${#MATCHED[@]} -eq 1 ]]; then + run_example "${MATCHED[0]}" + else + echo "Running ${#MATCHED[@]} examples matching '$MODE':" + for i in "${MATCHED[@]}"; do + echo " ${NAMES[$i]}" + done + for i in "${MATCHED[@]}"; do + run_example "$i" || echo "FAILED: ${NAMES[$i]}" + done + fi + +else + # Interactive menu + echo "" + echo "AI Provider Examples" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + echo "" + for name in "${NAMES[@]}"; do + echo " $name" + done + echo "" + echo "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”" + echo "" + echo "Type a name (or partial match) to run, 'a' for all, or 'q' to quit." + echo "" + + while true; do + read -rp "> " CHOICE + case "$CHOICE" in + q|Q) echo "Bye."; exit 0 ;; + a|A) + for i in "${!NAMES[@]}"; do + run_example "$i" || echo "FAILED: ${NAMES[$i]}" + done + ;; + "") + continue + ;; + *) + find_matches "$CHOICE" + if [[ ${#MATCHED[@]} -eq 0 ]]; then + echo "No match for '$CHOICE'. Try again." + else + if [[ ${#MATCHED[@]} -gt 1 ]]; then + echo "Matched ${#MATCHED[@]} examples:" + for i in "${MATCHED[@]}"; do + echo " ${NAMES[$i]}" + done + fi + for i in "${MATCHED[@]}"; do + run_example "$i" || echo "FAILED: ${NAMES[$i]}" + done + fi + ;; + esac + echo "" + done +fi From d0a643c22e674137871974c29d49b5d7eaef1595 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 20 Mar 2026 13:36:42 +0200 Subject: [PATCH 3/3] feat: add --parallel flag and fix --all for example runner - Add --parallel flag using mprocs for running examples concurrently - Add --install flag for dependency setup - Fix --all exiting early due to arithmetic in set -e - Update Makefile with examples-parallel and examples-install targets --- Makefile | 18 +++++------- run-examples.sh | 75 ++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 78 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index 78799b4..6eec67e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: examples examples-list examples-all examples-python examples-node run-trace-generator run-trace-generator-debug demo-data demo-data-quick demo-data-tools demo-data-negative +.PHONY: examples examples-list examples-all examples-parallel examples-install run-trace-generator run-trace-generator-debug demo-data demo-data-quick demo-data-tools demo-data-negative ## Run the interactive example picker (sources .env, discovers examples from sibling SDK repos) examples: @@ -12,17 +12,13 @@ examples-list: examples-all: @./run-examples.sh --all -## Run only Python examples -examples-python: - @./run-examples.sh --filter "[python]" +## Run all examples in parallel via mprocs (or filtered: make examples-parallel F=anthropic) +examples-parallel: + @./run-examples.sh --parallel $(F) -## Run only Node.js examples -examples-node: - @./run-examples.sh --filter "[node]" - -## Run examples matching a pattern (e.g., make examples-filter F=anthropic) -examples-filter: - @./run-examples.sh --filter "$(F)" +## Install dependencies for all examples +examples-install: + @./run-examples.sh --install ## Run the trace generator (mock trace data, no LLM calls) run-trace-generator: diff --git a/run-examples.sh b/run-examples.sh index 6ea8d94..a5a51f6 100755 --- a/run-examples.sh +++ b/run-examples.sh @@ -7,7 +7,9 @@ set -euo pipefail # Usage: # ./run-examples.sh Interactive menu # ./run-examples.sh --list List all examples -# ./run-examples.sh --all Run all examples +# ./run-examples.sh --all Run all examples sequentially +# ./run-examples.sh --parallel Run all examples in parallel (mprocs) +# ./run-examples.sh --parallel anthropic Run matching examples in parallel # ./run-examples.sh --install Install deps for all examples # ./run-examples.sh openai/embeddings Run a specific example (fuzzy match) # ./run-examples.sh anthropic Run all examples matching "anthropic" @@ -146,6 +148,21 @@ run_example() { fi } +# Build the shell command string for a single example (used by mprocs) +example_cmd() { + local idx="$1" + local file="${FILES[$idx]}" + local lang="${LANGS[$idx]}" + local dir + dir=$(dirname "$file") + + if [[ "$lang" == "py" ]]; then + echo "$PYTHON $file" + else + echo "cd $dir && npx tsx $(basename "$file")" + fi +} + # Find examples matching a pattern. Returns matching indices in MATCHED array. find_matches() { local pattern="$1" @@ -157,6 +174,38 @@ find_matches() { done } +# --------------------------------------------------------------------------- +# Parallel execution with mprocs +# --------------------------------------------------------------------------- + +run_parallel() { + local indices=("$@") + + if ! command -v mprocs &>/dev/null; then + echo "mprocs is not installed. Install it with: brew install mprocs" + exit 1 + fi + + # Build mprocs config + local config + config=$(mktemp /tmp/mprocs-examples-XXXXXX.yaml) + trap "rm -f $config" EXIT + + echo "procs:" > "$config" + for i in "${indices[@]}"; do + local name="${NAMES[$i]}" + local cmd + cmd=$(example_cmd "$i") + # Wrap in env-loading shell so each proc has the API keys + local full_cmd="set -a; source $SCRIPT_DIR/.env 2>/dev/null; set +a; $cmd" + echo " \"$name\":" >> "$config" + echo " shell: \"$full_cmd\"" >> "$config" + done + + echo "Running ${#indices[@]} examples in parallel..." + mprocs --config "$config" +} + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -183,15 +232,33 @@ fi MODE="${1:-}" -if [[ "$MODE" == "--all" ]]; then +if [[ "$MODE" == "--parallel" ]]; then + FILTER="${2:-}" + if [[ -n "$FILTER" ]]; then + find_matches "$FILTER" + if [[ ${#MATCHED[@]} -eq 0 ]]; then + echo "No examples matching '$FILTER'." + exit 1 + fi + run_parallel "${MATCHED[@]}" + else + # All examples + ALL_INDICES=() + for i in "${!NAMES[@]}"; do + ALL_INDICES+=("$i") + done + run_parallel "${ALL_INDICES[@]}" + fi + +elif [[ "$MODE" == "--all" ]]; then echo "Running all ${#NAMES[@]} examples..." FAILED=0 PASSED=0 for i in "${!NAMES[@]}"; do if run_example "$i"; then - (( PASSED++ )) + (( PASSED++ )) || true else - (( FAILED++ )) + (( FAILED++ )) || true echo "FAILED: ${NAMES[$i]}" fi done