diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/Tests/AnthropicChat.php b/Tests/AnthropicChat.php new file mode 100644 index 0000000..0cf09fc --- /dev/null +++ b/Tests/AnthropicChat.php @@ -0,0 +1,43 @@ + $api_key + ]); + + echo "Provider created with API key\n"; + echo "Provider name: " . $provider->getName() . "\n\n"; + + // Test 1: Simple prompt + echo "Test 1: Simple prompt\n"; + echo str_repeat('-', 50) . "\n"; + + $response = $provider->chat("Translate the following sentence into French: Joomla makes website building easy and fun.", ['model' => 'claude-3-haiku-20240307']); + + echo "API call successful!\n"; + echo "Response: " . $response->getContent() . "\n"; + echo "Provider: " . $response->getProvider() . "\n"; + echo "Status: " . $response->getStatusCode() . "\n"; + + $metadata = $response->getMetadata(); + echo "Model used: " . ($metadata['model']) . "\n"; + echo "Input Tokens used: " . ($metadata['input_tokens']) . "\n"; + echo "Output Tokens used: " . ($metadata['output_tokens']) . "\n"; + echo "\n"; + + echo "\n" . str_repeat('=', 60) . "\n"; + echo "All Messages endpoint tests completed successfully!\n"; +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/AnthropicModelManagement.php b/Tests/AnthropicModelManagement.php new file mode 100644 index 0000000..ac041f6 --- /dev/null +++ b/Tests/AnthropicModelManagement.php @@ -0,0 +1,134 @@ + $api_key + ]); + + echo "Provider: " . $provider->getName() . "\n"; + echo "Provider created successfully!\n\n"; + + // Test 1: Get all available models + echo "=== Test 1: Get Available Models ===\n"; + + $availableModels = $provider->getAvailableModels(); + echo "Available Models: " . implode(', ', $availableModels) . "\n"; + echo "Total: " . count($availableModels) . " models\n"; + + echo "\n" . str_repeat("=", 60) . "\n\n"; + + // Test 2: Test with a known model ID (if different from available models) + echo "=== Test 2: Get Known Model (claude-3-haiku-20240307) ===\n"; + + try { + $validModelResponse = $provider->getModel('claude-3-haiku-20240307'); + + echo "Model Information:\n"; + echo $validModelResponse->getContent() . "\n"; + + } catch (Exception $e) { + echo "Error getting known model: " . $e->getMessage() . "\n"; + } + + echo "\n" . str_repeat("=", 60) . "\n\n"; + + // Test 3: Test with invalid model ID (error handling) + echo "=== Test 3: Error Handling - Invalid Model ID ===\n"; + + try { + $invalidModelResponse = $provider->getModel('invalid-model-12345'); + echo "Unexpected success with invalid model\n"; + } catch (Exception $e) { + echo "Expected error caught: " . $e->getMessage() . "\n"; + } + + echo "\n" . str_repeat("=", 60) . "\n\n"; + + // Test 4: Basic Chat with saveFile + echo "=== Test 4: Basic Chat with saveFile ===\n"; + echo str_repeat("-", 50) . "\n"; + + $chatResponse = $provider->chat( + "Hi", + [ + 'model' => 'claude-3-haiku-20240307', + 'max_tokens' => 150 + ] + ); + + echo "Chat Response:\n"; + echo $chatResponse->getContent() . "\n"; + echo "Status: " . $chatResponse->getStatusCode() . "\n"; + echo "Provider: " . $chatResponse->getProvider() . "\n"; + + $metadata = $chatResponse->getMetadata(); + echo "Model: " . $metadata['model'] . "\n"; + echo "Input Tokens: " . $metadata['input_tokens'] . "\n"; + echo "Output Tokens: " . $metadata['output_tokens'] . "\n"; + echo "Stop Reason: " . $metadata['stop_reason'] . "\n"; + + echo "\n" . str_repeat("=", 60) . "\n\n"; + + // Test 5: Vision Analysis + echo "=== Test 5: Vision Analysis ===\n"; + echo str_repeat("-", 50) . "\n"; + + $testImage = "test_files/fish.png"; + $visionResponse = $provider->vision( + "What do you see in this image? Describe in one line.", + $testImage, + ); + + echo "Vision Response:\n"; + echo $visionResponse->getContent() . "\n"; + echo "Status: " . $visionResponse->getStatusCode() . "\n"; + + $visionMetadata = $visionResponse->getMetadata(); + echo "Model: " . $visionMetadata['model'] . "\n"; + echo "Input Tokens: " . $visionMetadata['input_tokens'] . "\n"; + echo "Output Tokens: " . $visionMetadata['output_tokens'] . "\n"; + + echo "\n" . str_repeat("=", 60) . "\n\n"; + + // Test 6: Advanced Chat with Options + echo "=== Test 6: Advanced Chat with Options ===\n"; + echo str_repeat("-", 50) . "\n"; + + $advancedResponse = $provider->chat( + "What is the full form of AI?", + [ + 'model' => 'claude-3-haiku-20240307', + 'max_tokens' => 200, + 'temperature' => 0.7, + 'top_p' => 0.9, + 'stop_sequences' => ['\n\n\n'] + ] + ); + + echo "Advanced Chat Response:\n"; + echo $advancedResponse->getContent() . "\n"; + + $advancedMetadata = $advancedResponse->getMetadata(); + echo "Model: " . $advancedMetadata['model'] . "\n"; + echo "Input Tokens: " . $advancedMetadata['input_tokens'] . "\n"; + echo "Output Tokens: " . $advancedMetadata['output_tokens'] . "\n"; + echo "Stop Reason: " . $advancedMetadata['stop_reason'] . "\n"; + + echo "\n" . str_repeat("=", 60) . "\n\n"; + + echo "\nAll Anthropic tests completed successfully!\n"; + +} catch (Exception $e) { + echo "Test failed with error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/AnthropicVision.php b/Tests/AnthropicVision.php new file mode 100644 index 0000000..01db7c7 --- /dev/null +++ b/Tests/AnthropicVision.php @@ -0,0 +1,52 @@ + $api_key + ]); + + echo "Provider created with API key\n"; + echo "Provider name: " . $provider->getName() . "\n\n"; + + // Test vision capability + echo "Test: Vision with image description\n"; + echo str_repeat('-', 50) . "\n"; + + // You can use a base64 image or URL + $imageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg"; + + $response = $provider->vision( + "What do you see in this image? Describe it in detail.", + $imageUrl, + [ + 'model' => 'claude-3-5-sonnet-20241022', + ] + ); + + echo "Vision API call successful!\n"; + echo "Response: " . $response->getContent() . "\n"; + echo "Provider: " . $response->getProvider() . "\n"; + echo "Status: " . $response->getStatusCode() . "\n"; + + $metadata = $response->getMetadata(); + echo "Model used: " . ($metadata['model']) . "\n"; + echo "Input Tokens used: " . ($metadata['input_tokens']) . "\n"; + echo "Stop reason: " . ($metadata['stop_reason']) . "\n"; + echo "\n"; + + echo "\n" . str_repeat('=', 60) . "\n"; + echo "Anthropic Vision tests completed successfully!\n"; +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/ChatCompletionsTest.php b/Tests/ChatCompletionsTest.php new file mode 100644 index 0000000..c2852fe --- /dev/null +++ b/Tests/ChatCompletionsTest.php @@ -0,0 +1,105 @@ + $api_key + ]); + + echo "Provider created with API key\n"; + echo "Provider name: " . $provider->getName() . "\n\n"; + + // To Do: Check if the provider is supported. Currently key set as env variables only + // if (!OpenAIProvider::isSupported()) { + // throw new \Exception('OpenAI API is not supported or API key is missing.'); + // } + + // Test 1: Simple prompt + echo "Test 1: Simple prompt\n"; + echo str_repeat('-', 50) . "\n"; + + $response = $provider->chat("Hello! How are you?"); + + echo "API call successful!\n"; + echo "Response: " . $response->getContent() . "\n"; + echo "Provider: " . $response->getProvider() . "\n"; + echo "Status: " . $response->getStatusCode() . "\n"; + + $metadata = $response->getMetadata(); + if (!empty($metadata)) { + echo "Model used: " . ($metadata['model']) . "\n"; + if (isset($metadata['usage'])) { + echo "Tokens used: " . ($metadata['usage']['total_tokens']) . "\n"; + } + } + echo "\n"; + + // Test 2: Multiple Response Choices (n parameter) + echo "Test 2: Multiple Response Choices (n parameter)\n"; + echo str_repeat('-', 50) . "\n"; + $response = $provider->chat("Suggest a name for a movie based on pilots and astronauts", [ + 'n' => 3, + ]); + echo "Model: " . $response->getMetadata()['model'] . "\n"; + echo "Response: " . $response->getContent() . "\n"; + + $metadata = $response->getMetadata(); + if (isset($metadata['choices']) && is_array($metadata['choices'])) { + echo "Number of choices returned: " . count($metadata['choices']) . "\n"; + for ($i = 0; $i < count($metadata['choices']); $i++) { + echo "Choice " . ($i + 1) . ": " . ($metadata['choices'][$i]['message']['content'] ?? 'No content') . "\n"; + } + } else { + echo "Expected multiple choices but got single response. Check OpenAI provider implementation.\n"; + } + echo "\n"; + + // Test 3:Test chat completions audio capability + echo "Test 3: Test chat completions audio capability\n"; + echo str_repeat('-', 50) . "\n"; + $response = $provider->chat("Say a few words on Joomla! for about 30 seconds in english.", [ + 'model' => 'gpt-4o-audio-preview', + 'modalities' => ['text', 'audio'], + 'audio' => [ + 'voice' => 'alloy', + 'format' => 'wav' + ], + ]); + + $metadata = $response->getMetadata(); + $debugFile = "output/full_audio_response_structure.json"; + $fullStructure = [ + 'response_class' => get_class($response), + 'content' => $response->getContent(), + 'status_code' => $response->getStatusCode(), + 'provider' => $response->getProvider(), + 'metadata' => $metadata + ]; + file_put_contents($debugFile, json_encode($fullStructure, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + echo "Full response structure saved to: $debugFile\n"; + + if (isset($metadata['choices'][0]['message']['audio']['data'])) { + $audioData = $metadata['choices'][0]['message']['audio']['data']; + $audioDatab64 = base64_decode($audioData, true); + $audioFile = file_put_contents("output/chat_completions_audio.wav", $audioDatab64); + echo "Audio file found and saved to: \"output/chat_completions_audio.wav\".\n"; + } else { + echo "Audio file not found.\n"; + } + echo "\n"; + + echo "\n" . str_repeat('=', 60) . "\n"; + echo "All Chat Completions API tests completed successfully!\n"; +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/ChatWithVisionTest.php b/Tests/ChatWithVisionTest.php new file mode 100644 index 0000000..ab5853c --- /dev/null +++ b/Tests/ChatWithVisionTest.php @@ -0,0 +1,65 @@ + $api_key + ]); + + echo "Provider created with API key\n"; + echo "Provider name: " . $provider->getName() . "\n\n"; + + // Test 1: Vision with URL image + echo "Test 1: Vision with image URL...\n"; + $imageUrl = "https://upload.wikimedia.org/wikipedia/commons/e/eb/Ash_Tree_-_geograph.org.uk_-_590710.jpg"; + + $response = $provider->chatWithVision("What do you see in this image?", $imageUrl); + + echo "Vision API call successful!\n"; + echo "Response: " . $response->getContent() . "\n"; + echo "Provider: " . $response->getProvider() . "\n"; + echo "Status: " . $response->getStatusCode() . "\n"; + + $metadata = $response->getMetadata(); + if (!empty($metadata)) { + echo "Model used: " . ($metadata['model']) . "\n"; + if (isset($metadata['usage'])) { + echo "Tokens used: " . ($metadata['usage']['total_tokens']) . "\n"; + } + } + + echo "\n" . str_repeat("-", 50) . "\n\n"; + + // Test 2: Vision with specific model + echo "Test 2: Vision with specific model (gpt-4o)...\n"; + $response = $provider->chatWithVision( + "Describe the colors and mood of this image.", + $imageUrl, + ['model' => 'gpt-4o', 'max_tokens' => 100] + ); + + echo "Vision API call successful!\n"; + echo "Response: " . $response->getContent() . "\n"; + echo "Provider: " . $response->getProvider() . "\n"; + + $metadata = $response->getMetadata(); + if (!empty($metadata)) { + echo "Model used: " . ($metadata['model']) . "\n"; + if (isset($metadata['usage'])) { + echo "Tokens used: " . ($metadata['usage']['total_tokens']) . "\n"; + } + } + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/DefaultModels.php b/Tests/DefaultModels.php new file mode 100644 index 0000000..8709ba9 --- /dev/null +++ b/Tests/DefaultModels.php @@ -0,0 +1,110 @@ + $api_key + ]); + + echo "Provider created with API key\n"; + echo "Provider name: " . $provider->getName() . "\n\n"; + + // To Do: Check if the provider is supported. Currently key set as env variables only + // if (!OpenAIProvider::isSupported()) { + // throw new \Exception('OpenAI API is not supported or API key is missing.'); + // } + + // Set default model for all subsequent calls + $provider->setDefaultModel('gpt-3.5-turbo'); + echo "Default model: " . $provider->getDefaultModel() . "\n\n"; + + // Test 1: Simple prompt- Will use default model + echo "Test 1: Simple prompt- Will use default model gpt-3.5-turbo\n"; + echo str_repeat('-', 50) . "\n"; + + $response = $provider->chat("Hello! How are you?"); + echo "Model: " . $response->getMetadata()['model'] . "\n"; + echo "Response: " . $response->getContent() . "\n"; + echo "\n"; + + // Test 2: Multiple Response Choices Will use default model + echo "Test 2: Multiple Response Choices- Will use default model gpt-3.5-turbo\n"; + echo str_repeat('-', 50) . "\n"; + + $response = $provider->chat("Suggest a name for a movie based on pilots and astronauts"); + echo "Model: " . $response->getMetadata()['model'] . "\n"; + echo "Response: " . $response->getContent() . "\n"; + echo "\n"; + + // Test 3: This will override the default and use gpt-4o-audio-preview model + echo "Test 3: Test chat completions audio capability- Will override the default and use gpt-4o-audio-preview model\n"; + echo str_repeat('-', 50) . "\n"; + $response = $provider->chat("Say a few words on Joomla! for about 30 seconds in english.", [ + 'model' => 'gpt-4o-audio-preview', + 'modalities' => ['text', 'audio'], + 'audio' => [ + 'voice' => 'alloy', + 'format' => 'wav' + ], + ]); + + $metadata = $response->getMetadata(); + echo "Model: " . $response->getMetadata()['model'] . "\n"; + $debugFile = "output/full_audio_response_structure.json"; + $fullStructure = [ + 'response_class' => get_class($response), + 'content' => $response->getContent(), + 'status_code' => $response->getStatusCode(), + 'provider' => $response->getProvider(), + 'metadata' => $metadata + ]; + file_put_contents($debugFile, json_encode($fullStructure, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + echo "Full response structure saved to: $debugFile\n"; + + if (isset($metadata['choices'][0]['message']['audio']['data'])) { + $audioData = $metadata['choices'][0]['message']['audio']['data']; + $audioDatab64 = base64_decode($audioData, true); + $audioFile = file_put_contents("output/chat_completions_audio.wav", $audioDatab64); + echo "Audio file found and saved to: \"output/chat_completions_audio.wav\".\n"; + } else { + echo "Audio file not found.\n"; + } + echo "\n"; + + // Test 4: Simple prompt- Will use default model + echo "Test 4: Simple prompt- Will use default model gpt-3.5-turbo because default model was not unset\n"; + echo str_repeat('-', 50) . "\n"; + + $response = $provider->chat("What is the capital of France?"); + echo "Model: " . $response->getMetadata()['model'] . "\n"; + echo "Response: " . $response->getContent() . "\n"; + echo "\n"; + + // Unset default model + $provider->unsetDefaultModel(); + echo "Default model unset\n\n"; + + // Test 5: This will use the provider's default (gpt-4o-mini) + echo "Test 5: Simple prompt- Will use use the provider's default (gpt-4o-mini) because default model was unset\n"; + echo str_repeat('-', 50) . "\n"; + + $response = $provider->chat("What is the color of the sky?"); + echo "Model: " . $response->getMetadata()['model'] . "\n"; + echo "Response: " . $response->getContent() . "\n"; + echo "\n"; + + echo "\n" . str_repeat('=', 60) . "\n"; + echo "All Chat Completions API tests completed successfully!\n"; +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/DefaultsTest.php b/Tests/DefaultsTest.php new file mode 100644 index 0000000..4427321 --- /dev/null +++ b/Tests/DefaultsTest.php @@ -0,0 +1,112 @@ + $api_key, + ]); + + // =========================================== + // Basic Chat Tests + // =========================================== + + // Test 1: Basic Prompt Testing + echo "Test 1: Basic Prompt Testing\n"; + echo str_repeat("-", 40) . "\n"; + + $response1 = $provider->chat("Hi, Can you write a paragraph on the importance of AI in modern technology?"); + echo $response1->getContent(); + $response1->saveContentToFile('output/chat.txt'); + + echo "\n" . str_repeat("=", 50) . "\n"; + + // =========================================== + // Image Generation Tests + // =========================================== + + // Test 2: Basic Image Generation + echo "Test 2: Basic Image Generation\n"; + echo str_repeat("-", 40) . "\n"; + + $response2 = $provider->generateImage("Please generate an image for my blog post about my mount fuji hiking trip"); + $response2->saveContentToFile('output/mount_fuji.png'); + + echo "\n" . str_repeat("=", 50) . "\n"; + + // Test 3: DALL-E 3 with URL response + echo "Test 3: DALL-E 3 with URL response...\n"; + echo str_repeat("-", 40) . "\n"; + + $response3 = $provider->generateImage( + "Can you generate an image of a slice of pizza riding a bicycle for my shopping website?", + [ + 'model' => 'dall-e-2', + 'response_format' => 'url', + 'n' => 3, + ] + ); + $response3->saveContentToFile('output/thin_pizza.txt'); + + echo "\n" . str_repeat("=", 50) . "\n\n"; + + // =========================================== + // Speech Generation Tests + // =========================================== + + // Test 4: Basic Speech Generation + echo "Test 4: Basic Speech Generation\n"; + echo str_repeat("-", 40) . "\n"; + + $text = "Hello world! This is a test of the OpenAI text-to-speech capability."; + + $response4 = $provider->speech($text); + $response4->saveContentToFile('output/speech_4.mp3'); + + echo str_repeat("=", 50) . "\n\n"; + + // =========================================== + // Transcription Tests + // =========================================== + + // Test 5: Basic Transcription + echo "Test 5: Basic Transcription \n"; + echo str_repeat("-", 40) . "\n"; + + $audioFile = 'test_files/test_audio.wav'; + + $response5 = $provider->transcribe($audioFile); + $response5->getContent(); + $response5->saveContentToFile('output/transcribed.txt'); + + echo str_repeat("=", 50) . "\n\n"; + + // =========================================== + // Translation Tests + // =========================================== + + // Test 6: Basic Translation + echo "Test 6: Basic Translation \n"; + echo str_repeat("-", 40) . "\n"; + + $testAudioFile = 'test_files/test_german_audio.wav'; + + $response6 = $provider->translate($testAudioFile); + echo $response6->getContent(); + $response6->saveContentToFile('output/translated.txt'); + + echo "\n" . str_repeat("=", 50) . "\n\n"; + + echo "=== All Tests Completed Successfully! ===\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/EmbeddingsTest.php b/Tests/EmbeddingsTest.php new file mode 100644 index 0000000..ac0cab6 --- /dev/null +++ b/Tests/EmbeddingsTest.php @@ -0,0 +1,77 @@ + $api_key + ]); + + // Test 1: Single Text Embedding + echo "Test 1: Single Text Embedding\n"; + echo "------------------------------\n"; + + $text = "The quick brown fox jumps over the lazy dog"; + $response1 = $provider->createEmbeddings($text, 'text-embedding-ada-002'); + + $embedding = $response1->getContent(); + echo "Text: \"$text\"\n"; + if (is_string($embedding)) { + $embedding = json_decode($embedding, true); + } + if (is_array($embedding)) { + echo "Embedding dimensions: " . count($embedding) . "\n"; + echo "First 5 values: [" . implode(', ', array_slice($embedding, 0, 5)) . "...]\n"; + } else { + echo "Embedding is not an array. Type: " . gettype($embedding) . "\n"; + } + + $metadata1 = $response1->getMetadata(); + echo "Model: " . $metadata1['model'] . "\n"; + + // Test 2: Multiple Text Embeddings + echo "Test 2: Multiple Text Embeddings\n"; + echo "--------------------------------\n"; + + $texts = [ + "I love programming in PHP", + "Python is great for machine learning", + "JavaScript runs in the browser", + "Cats are wonderful pets" + ]; + + $response2 = $provider->createEmbeddings($texts, 'text-embedding-ada-002'); + + $embeddings = $response2->getContent(); + if (is_string($embeddings)) { + $embeddings = json_decode($embeddings, true); + } + echo "Number of texts: " . count($texts) . "\n"; + echo "Number of embeddings: " . count($embeddings) . "\n"; + + foreach ($texts as $i => $text) { + echo "Text $i: \"$text\"\n"; + $embeddingArr = $embeddings[$i]['embedding']; + if (is_string($embeddingArr)) { + $embeddingArr = json_decode($embeddingArr, true); + } + if (is_array($embeddingArr)) { + echo " Embedding dimensions: " . count($embeddingArr) . "\n"; + echo " First 3 values: [" . implode(', ', array_slice($embeddingArr, 0, 3)) . "...]\n"; + } else { + echo " Embedding is not an array. Type: " . gettype($embeddingArr) . "\n"; + } + } + echo "\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} \ No newline at end of file diff --git a/Tests/GPT-Image-1Tests.php b/Tests/GPT-Image-1Tests.php new file mode 100644 index 0000000..0d29b06 --- /dev/null +++ b/Tests/GPT-Image-1Tests.php @@ -0,0 +1,137 @@ + $api_key, + 'base_url' => $base_url + ]); + + // ==================================================================== + // TEST 1: Basic Image Generation + // ==================================================================== + echo "Test 1: Basic Image Generation\n"; + echo str_repeat("-", 40) . "\n"; + + $response = $provider->generateImage( + "A cute baby sea otter floating on its back in crystal clear water, photorealistic style", + [ + 'model' => 'gpt-image-1', + ] + ); + + echo "Image generation successful!\n\n"; + + $metadata = $response->getMetadata(); + echo "Response Details:\n"; + echo "Model: " . ($metadata['model'] ?? 'unknown') . "\n"; + echo "Size: " . ($metadata['size'] ?? 'unknown') . "\n"; + echo "Format: " . ($metadata['response_format'] ?? 'base64') . "\n"; + echo "Provider: " . $response->getProvider() . "\n"; + + $image = saveBase64Image($response->getContent(), 'output/test1_basic_sea_otter.png'); + echo "\nImage saved: output/test1_basic_sea_otter.png\n"; + + echo "\n" . str_repeat("=", 50) . "\n"; + sleep(15); + + // ==================================================================== + // TEST 2: Single Image Edit + // ==================================================================== + echo "Test 2: Single Image Editing\n"; + echo str_repeat("-", 40) . "\n"; + + $editResponse1 = $provider->editImage( + 'test_files/fish.png', + 'Change the colour of the fish to green', + [ + 'model' => 'gpt-image-1', + 'output_format' => 'png', + ] + ); + + $metadata1 = $editResponse1->getMetadata(); + echo "Single image edit successful!\n"; + echo "Model: " . ($metadata1['model'] ?? 'unknown') . "\n"; + echo "Output format: " . ($metadata1['output_format'] ?? 'unknown') . "\n"; + + $edit1 = saveBase64Image($editResponse1->getContent(), 'output/edited_fish.png'); + echo "Saved: output/edited_fish.png ({$edit1} bytes)\n"; + + echo "\n" . str_repeat("=", 50) . "\n"; + sleep(15); // Simulate some delay before next test + + // ==================================================================== + // TEST 3: Edit with Transparency + // ==================================================================== + echo "Test 3: Editing with Transparent Background\n"; + echo str_repeat("-", 40) . "\n"; + + $transparentResponse = $provider->editImage( + 'test_files/fish.png', + 'Extract the main subject and remove the background, creating a clean isolated object suitable for logos', + [ + 'model' => 'gpt-image-1', + 'background' => 'transparent', + 'output_format' => 'png', + 'size' => '1024x1024' + ] + ); + + $transparentImage = saveBase64Image($transparentResponse->getContent(), 'output/edited_transparent.png'); + echo "Transparent background edit successful!\n"; + echo "Saved: output/edited_transparent.png ({$transparentImage} bytes)\n\n"; + + echo "\n" . str_repeat("=", 50) . "\n"; + sleep(15); // Simulate some delay before next test + + // ==================================================================== + // TEST 4: Test model with multiple images + // ==================================================================== + echo "Test 4: Test model with multiple images\n"; + echo str_repeat("-", 40) . "\n"; + + // Use existing generated images from previous tests + $existingImages = ['output/test1_basic_sea_otter.png', 'output/edited_transparent.png']; + + if (file_exists($existingImages[0]) && file_exists($existingImages[1])) { + echo "Using existing images for multiple edit test:\n"; + echo "- " . $existingImages[0] . "\n"; + echo "- " . $existingImages[1] . "\n"; + + $response = $provider->editImage( + $existingImages, + 'Combine these images into an artistic collage', + [ + 'model' => 'gpt-image-1', + ] + ); + + echo "Image generation successful!\n\n"; + $image = saveBase64Image($response->getContent(), 'output/multi_image_test.png'); + echo "\nImage saved: output/multi_image_test.png\n"; + + echo "\n" . str_repeat("=", 50) . "\n"; + } else { + echo "No existing images found. Run the basic generation tests first.\n"; + } + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/ImageEditingTest.php b/Tests/ImageEditingTest.php new file mode 100644 index 0000000..683080f --- /dev/null +++ b/Tests/ImageEditingTest.php @@ -0,0 +1,60 @@ + $api_key + ]); + + echo "=== Testing OpenAI Image Editing Capability ===\n\n"; + + // Test 1: Single Image Edit with DALL-E 2 + echo "Test 1: Single Image Edit with DALL-E 2\n"; + echo "----------------------------------------\n"; + + $imagePath = 'test_files/dog_img.png'; + $maskImagePath = 'test_files/mask_dog_img.png'; // Mask image + $prompt = "picture of a dog and a rabbit"; + + $editOptions = [ + 'model' => 'dall-e-2', + 'mask' => $maskImagePath, + 'response_format' => 'url' + ]; + + $response1 = $provider->editImage($imagePath, $prompt, $editOptions); + + echo "Success!\n"; + + $metadata1 = $response1->getMetadata(); + echo "Image Count: " . $metadata1['image_count'] . "\n"; + echo "Response Format: " . $metadata1['response_format'] . "\n"; + + echo "\nGenerated Images:\n"; + if ($metadata1['response_format'] === 'url') { + if ($metadata1['image_count'] === 1) { + echo " Image URL: " . $response1->getContent() . "\n"; + } else { + $urls = json_decode($response1->getContent(), true); + foreach ($urls as $index => $url) { + echo " Image " . ($index + 1) . ": " . $url . "\n"; + } + } + } + + echo "\n" . str_repeat("=", 60) . "\n\n"; + + echo "\n=== All Tests Completed! ===\n"; + + // Test b64_json response format + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/ImageGenerationTest.php b/Tests/ImageGenerationTest.php new file mode 100644 index 0000000..b89ead2 --- /dev/null +++ b/Tests/ImageGenerationTest.php @@ -0,0 +1,175 @@ + $api_key]); + + echo "Provider: " . $provider->getName() . "\n\n"; + + // ============================================ + // TEST 1: DALL-E 3 with Base64 + // ============================================ + + echo "Test 1: DALL-E 3 with Base64 response...\n"; + $response = $provider->generateImage( + "A red apple on a white table", + [ + 'model' => 'dall-e-3', + 'response_format' => 'b64_json', + ] + ); + + echo "Status: " . $response->getStatusCode() . "\n"; + echo "Provider: " . $response->getProvider() . "\n"; + $metadata = $response->getMetadata(); + + if (isset($metadata['revised_prompt'])) { + echo "Revised prompt: " . "\n"; + } + + echo "Response format: " . ($metadata['response_format'] ?? 'unknown') . "\n"; + if ($metadata['response_format'] === 'url') { + echo " Image URL: " . $response->getContent() . "\n"; + } elseif ($metadata['response_format'] === 'b64_json') { + echo "Base64 data received. \n"; + + saveBase64Image($response->getContent(), 'output/test1_dalle3_base64.png'); + echo "Image saved as: output/test1_dalle3_base64.png \n"; + } + + echo "\n" . str_repeat("-", 50) . "\n\n"; + + // ============================================ + // TEST 2: DALL-E 3 with URL response + // ============================================ + + echo "Test 2: DALL-E 3 with URL response...\n"; + $response = $provider->generateImage( + "A blue ocean with waves", + [ + 'model' => 'dall-e-3', + 'response_format' => 'url' + ] + ); + + $metadata = $response->getMetadata(); + echo "Response format: " . ($metadata['response_format'] ?? 'unknown') . "\n"; + if ($metadata['response_format'] === 'url') { + echo " Image URL: " . $response->getContent() . "\n"; + } elseif ($metadata['response_format'] === 'b64_json') { + echo "Base64 data received. \n"; + + saveBase64Image($response->getContent(), 'output/test2_dalle3_base64.png'); + echo "Image saved as: output/test2_dalle3_base64.png \n"; + } + + echo "\n" . str_repeat("-", 50) . "\n\n"; + + // ============================================ + // TEST 3: DALL-E 2 with Base64 + // ============================================ + + echo "Test 3: DALL-E 2 with Base64 response...\n"; + $response = $provider->generateImage( + "A simple drawing of a house", + [ + 'model' => 'dall-e-2', + 'response_format' => 'b64_json', + 'n' => 2 + ] + ); + + $metadata = $response->getMetadata(); + echo "Model: " . ($metadata['model'] ?? 'unknown') . "\n"; // Not given as response + echo "Response format: " . ($metadata['response_format'] ?? 'unknown') . "\n"; + + $content = $response->getContent(); + echo "Response format: " . ($metadata['response_format'] ?? 'unknown') . "\n"; + if ($metadata['response_format'] === 'url') { + if ($metadata['image_count'] === 1) { + echo " Image URL: " . $response->getContent() . "\n"; + } else { + $urls = json_decode($response->getContent(), true); + foreach ($urls as $index => $url) { + echo " Image " . ($index + 1) . ": " . $url . "\n"; + } + } + } elseif ($metadata['response_format'] === 'b64_json') { + echo "Base64 data received. \n"; + + if ($metadata['image_count'] === 1) { + $fileSize = saveBase64Image($response->getContent(), 'output/test3_dalle2_base64.png'); + echo " Image saved as: output/test3_dalle2_base64.png (Size: {$fileSize} bytes)\n"; + } else { + $base64Data = json_decode($response->getContent(), true); + foreach ($base64Data as $index => $data) { + $filename = 'output/test3_dalle2_base64_' . ($index + 1) . '.png'; + $fileSize = saveBase64Image($data, $filename); + echo " Image " . ($index + 1) . " saved as: {$filename} (Size: {$fileSize} bytes)\n"; + } + } + } + + echo "\n" . str_repeat("-", 50) . "\n\n"; + + // ============================================ + // TEST 4: DALL-E 2 with URL response + // ============================================ + + echo "Test 4: DALL-E 2 with URL response...\n"; + $response = $provider->generateImage( + "A cartoon cat wearing sunglasses", + [ + 'model' => 'dall-e-2', + 'response_format' => 'url', + ] + ); + + $metadata = $response->getMetadata(); + echo "Response format: " . ($metadata['response_format'] ?? 'unknown') . "\n"; + if ($metadata['response_format'] === 'url') { + if ($metadata['image_count'] === 1) { + echo " Image URL: " . $response->getContent() . "\n"; + } else { + $urls = json_decode($response->getContent(), true); + foreach ($urls as $index => $url) { + echo " Image " . ($index + 1) . ": " . $url . "\n"; + } + } + } elseif ($metadata['response_format'] === 'b64_json') { + echo "Base64 data received. \n"; + + if ($metadata['image_count'] === 1) { + $fileSize = saveBase64Image($response->getContent(), 'output/test4_dalle2_base64.png'); + echo " Image saved as: output/test4_dalle2_base64.png (Size: {$fileSize} bytes)\n"; + } else { + $base64Data = json_decode($response->getContent(), true); + foreach ($base64Data as $index => $data) { + $filename = 'output/test4_dalle2_base64_' . ($index + 1) . '.png'; + $fileSize = saveBase64Image($data, $filename); + echo " Image " . ($index + 1) . " saved as: {$filename} (Size: {$fileSize} bytes)\n"; + } + } + } + + echo "\n" . str_repeat("-", 50) . "\n\n"; + + echo "ALL TESTS COMPLETED!\n\n"; +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/ImageVariationTest.php b/Tests/ImageVariationTest.php new file mode 100644 index 0000000..07e8c8a --- /dev/null +++ b/Tests/ImageVariationTest.php @@ -0,0 +1,68 @@ + $api_key + ]); + + $imagePath = 'test_files/fish.png'; + + $variationOptions = [ + 'model' => 'dall-e-2', + 'n' => 3, + 'size' => '512x512', + 'response_format' => 'url' + ]; + + echo "Testing image variation creation...\n"; + echo "Image path: " . $imagePath . "\n"; + echo "Options: " . json_encode($variationOptions, JSON_PRETTY_PRINT) . "\n\n"; + + $response = $provider->createImageVariation($imagePath, $variationOptions); + + echo "Success!\n"; + echo "Provider: " . $response->getProvider() . "\n"; + echo "Status Code: " . $response->getStatusCode() . "\n\n"; + + $metadata = $response->getMetadata(); + + echo "Response Details:\n"; + echo " Image Count: " . $metadata['image_count'] . "\n"; + echo " Response Format: " . $metadata['response_format'] . "\n"; + echo " Created: " . date('Y-m-d H:i:s', $metadata['created']) . "\n"; + + if (isset($metadata['url_expires'])) { + echo " URL Expiry: " . $metadata['url_expires'] . "\n"; + } + + echo "\nGenerated Image Variations:\n"; + + if ($metadata['response_format'] === 'url') { + if ($metadata['image_count'] === 1) { + echo " Image URL: " . $response->getContent() . "\n"; + } else { + $urls = json_decode($response->getContent(), true); + foreach ($urls as $index => $url) { + echo " Image " . ($index + 1) . ": " . $url . "\n"; + } + } + } elseif ($metadata['response_format'] === 'b64_json') { + echo " Base64 data received (length: " . strlen($response->getContent()) . " characters)\n"; + if ($metadata['image_count'] > 1) { + $base64Data = json_decode($response->getContent(), true); + foreach ($base64Data as $index => $data) { + echo " Image " . ($index + 1) . " length: " . strlen($data) . " characters\n"; + } + } + } +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/ModelManagementTest.php b/Tests/ModelManagementTest.php new file mode 100644 index 0000000..8f47476 --- /dev/null +++ b/Tests/ModelManagementTest.php @@ -0,0 +1,111 @@ + $api_key + ]); + + echo "Provider created successfully\n"; + echo "Provider name: " . $provider->getName() . "\n\n"; + + // Test 1: Get all available models + echo "=== Test 1: Get Available Models ===\n"; + + $availableModels = $provider->getAvailableModels(); + echo "Available models count: " . count($availableModels) . "\n"; + + // Test 2: Get chat models + echo "=== Test 2: Get Chat Models ===\n"; + + $chatModels = $provider->getChatModels(); + echo "Chat models count: " . count($chatModels) . "\n"; + + // Test 3: Get vision models + echo "=== Test 3: Get Vision Models ===\n"; + + $visionModels = $provider->getVisionModels(); + echo "Vision models count: " . count($visionModels) . "\n"; + + // Test 4: Check if specific models are supported + echo "=== Test 4: Model Support Check ===\n"; + + $testModels = ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo', 'dall-e-3', 'nonexistent-model']; + foreach ($testModels as $model) { + $isSupported = $provider->isModelSupported($model); + echo "Model '$model': " . ($isSupported ? "Supported" : "Not supported") . "\n"; + } + echo "\n"; + + // Test 5: Check model capabilities + echo "=== Test 5: Model Capability Check ===\n"; + + $capabilityTests = [ + ['gpt-4o-mini', 'chat'], + ['gpt-4o-mini', 'vision'], + ['dall-e-3', 'chat'], + ['nonexistent-model', 'chat'] + ]; + foreach ($capabilityTests as [$model, $capability]) { + $isCapable = $provider->isModelCapable($model, $capability); + echo "Model '$model' can do '$capability': " . ($isCapable ? "Yes" : "No") . "\n"; + } + echo "\n"; + + // Test 6: Test model validation in actual chat request + echo "=== Test 6: Model Validation in Chat Request ===\n"; + + // Test with valid chat model + try { + echo "Testing chat with valid model (gpt-4o-mini)...\n"; + $response = $provider->chat("Say hello", ['model' => 'gpt-4o-mini']); + echo "Response: " . $response->getContent() . "\n"; + } catch (Exception $e) { + echo "Chat with gpt-4o-mini failed: " . $e->getMessage() . "\n\n"; + } + + // Test with invalid model for chat + try { + echo "Testing chat with invalid model (dall-e-3)...\n"; + $response = $provider->chat("Say hello", ['model' => 'dall-e-3']); + } catch (Exception $e) { + echo "Correctly caught error: " . $e->getMessage() . "\n\n"; + } + + // Test 7: Test model validation in vision request + echo "=== Test 7: Model Validation in Vision Request ===\n"; + $imageUrl = "https://upload.wikimedia.org/wikipedia/commons/e/eb/Ash_Tree_-_geograph.org.uk_-_590710.jpg"; + + // Test with valid vision model + try { + echo "Testing vision with valid model (gpt-4o-mini)...\n"; + $response = $provider->chatWithVision("What do you see?", $imageUrl); + echo "Vision with gpt-4o-mini successful\n"; + echo "Response: " . $response->getContent() . "\n\n"; + } catch (Exception $e) { + echo "Vision with gpt-4o-mini failed: " . $e->getMessage() . "\n\n"; + } + + // Test with invalid model for vision + try { + echo "Testing vision with invalid model (gpt-3.5-turbo)...\n"; + $response = $provider->chatWithVision("What do you see?", $imageUrl, ['model' => 'gpt-3.5-turbo']); + echo "This should not succeed!\n\n"; + } catch (Exception $e) { + echo "Correctly caught error: " . $e->getMessage() . "\n\n"; + } + + echo "=== All Model Management Tests Completed ===\n"; + +} catch (Exception $e) { + echo "Test failed with error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/ModerationTest.php b/Tests/ModerationTest.php new file mode 100644 index 0000000..f800c72 --- /dev/null +++ b/Tests/ModerationTest.php @@ -0,0 +1,153 @@ + $api_key + ]); + + echo "Provider created with API key\n"; + echo "Provider: " . $provider->getName() . "\n\n"; + + // ============================================ + // TEST 1: Chat Moderation (Clean Content) + // ============================================ + echo "Test 1: Chat with Clean Content \n"; + echo str_repeat("-", 50) . "\n"; + + try { + $cleanMessage = "Hello! How are you today? Can you help me with a programming question?"; + echo "Testing message: \"$cleanMessage\"\n"; + + $response = $provider->chat($cleanMessage); + + echo "Response: " . $response->getContent() . "\n"; + echo "Status: (PASSED) Clean content was allowed\n"; + + } catch (Exception $e) { + echo "Status: (UNEXPECTED) Clean content was blocked: " . $e->getMessage() . "\n"; + } + + echo "\n" . str_repeat("=", 60) . "\n\n"; + + // ============================================ + // TEST 2: Chat Moderation (Flagged Content) + // ============================================ + echo "Test 2: Chat with Flagged Content \n"; + echo str_repeat("-", 50) . "\n"; + + try { + $flaggedMessage = "I want to hurt people and cause violence to others. I hate everyone."; + echo "Testing message: \"$flaggedMessage\"\n"; + + $response = $provider->chat($flaggedMessage); + + echo "Response: " . $response->getContent() . "\n"; + echo "Status: (FAILED) Flagged content was NOT blocked (this shouldn't happen)\n"; + + } catch (Exception $e) { + if ($e->getMessage() !== false) { + echo "Block reason: " . $e->getMessage() . "\n"; + echo "Status: (PASSED) Flagged content was correctly blocked by moderation\n"; + } else { + echo "Status: (FAILED) Content blocked but not by moderation: " . $e->getMessage() . "\n"; + } + } + + echo "\n" . str_repeat("=", 60) . "\n\n"; + + // ============================================ + // TEST 3: Image Generation (Clean Prompt) + // ============================================ + echo "Test 3: Image Generation with Clean Prompt \n"; + echo str_repeat("-", 50) . "\n"; + + try { + $cleanPrompt = "A beautiful sunset over a mountain landscape with birds flying"; + echo "Testing prompt: \"$cleanPrompt\"\n"; + + $response = $provider->generateImage($cleanPrompt, [ + 'model' => 'dall-e-2', + 'response_format' => 'url' + ]); + + echo "Image URL: " . $response->getContent() . "\n"; + echo "Status: (PASSED) Clean prompt was allowed\n"; + + } catch (Exception $e) { + echo "Status: (UNEXPECTED) Clean content was blocked: " . $e->getMessage() . "\n"; + } + + echo "\n" . str_repeat("=", 60) . "\n\n"; + + // ============================================ + // TEST 4: Image Generation (Flagged Prompt) + // ============================================ + echo "Test 4: Image Generation with Flagged Prompt \n"; + echo str_repeat("-", 50) . "\n"; + + try { + $flaggedPrompt = "Generate violent imagery showing people getting hurt and blood everywhere"; + echo "Testing prompt: \"$flaggedPrompt\"\n"; + + $response = $provider->generateImage($flaggedPrompt, [ + 'model' => 'dall-e-2', + 'response_format' => 'url' + ]); + + echo "Image URL: " . $response->getContent() . "\n"; + echo "Status: (FAILED) Flagged prompt was NOT blocked (this shouldn't happen)\n"; + + } catch (Exception $e) { + if ($e->getMessage() !== false) { + echo "Block reason: " . $e->getMessage() . "\n"; + echo "Status: (PASSED) Flagged content was correctly blocked by moderation\n"; + } else { + echo "Status: (FAILED) Content blocked but not by moderation: " . $e->getMessage() . "\n"; + } + } + + echo "\n" . str_repeat("=", 60) . "\n\n"; + + // ============================================ + // TEST 5: Vision Moderation + // ============================================ + echo "Test 5: Vision Chat with Flagged Text (Should Block)\n"; + echo str_repeat("-", 50) . "\n"; + + try { + $flaggedVisionMessage = "I want to cause violence to the people in this image"; + $sampleImageUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png"; + + echo "Testing vision message: \"$flaggedVisionMessage\"\n"; + echo "With image URL: $sampleImageUrl\n"; + + $response = $provider->chatWithVision($flaggedVisionMessage, $sampleImageUrl); + + echo "Response: " . $response->getContent() . "\n"; + echo "Status: (FAILED) Flagged vision content was NOT blocked\n"; + + } catch (Exception $e) { + if ($e->getMessage() !== false) { + echo "Block reason: " . $e->getMessage() . "\n"; + echo "Status: (PASSED) Flagged content was correctly blocked by moderation\n"; + } else { + echo "Status: (FAILED) Content blocked but not by moderation: " . $e->getMessage() . "\n"; + } + } + + echo "\n" . str_repeat("=", 60) . "\n\n"; + + echo "All tests completed!\n"; +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/Ollama.php b/Tests/Ollama.php new file mode 100644 index 0000000..e8b83e3 --- /dev/null +++ b/Tests/Ollama.php @@ -0,0 +1,94 @@ +getName() . "\n\n"; + + // Test 2: Get Available Models + echo "Test 2: Getting available models\n"; + echo str_repeat('-', 50) . "\n"; + + $models = $provider->getAvailableModels(); + echo "Available models:\n"; + foreach ($models as $model) { + echo "- $model\n"; + } + echo "\n"; + + // Test 3: Pull a new model (llama2 if not present) + echo "Test 3: Pulling a new model (granite-embedding)\n"; + echo str_repeat('-', 50) . "\n"; + + $modelToPull = 'granite-embedding:30m'; + if (!in_array($modelToPull, $models)) { + echo "Model $modelToPull not found locally. Pulling...\n"; + $result = $provider->pullModel($modelToPull); + echo $result ? "Pull successful!\n" : "Pull failed!\n"; + } else { + echo "Model $modelToPull is already available locally.\n"; + } + echo "\n"; + + // Test 4: Attempt to pull an already present model + echo "Test 4: Attempting to pull an already present model\n"; + echo str_repeat('-', 50) . "\n"; + + // Get first available model from the list + if (!empty($models)) { + $existingModel = $models[0]; + $result = $provider->pullModel($existingModel); + } else { + echo "No models available to test with.\n"; + } + echo "\n"; + + // Test 5: Attempt to pull a non-existent model + echo "Test 5: Attempting to pull a non-existent model\n"; + echo str_repeat('-', 50) . "\n"; + + $nonExistentModel = 'non-existent-model-123'; + try { + echo "Attempting to pull non-existent model: $nonExistentModel\n"; + $result = $provider->pullModel($nonExistentModel); + echo "Unexpected success!\n"; + } catch (Exception $e) { + echo "Expected error occurred: " . $e->getMessage() . "\n"; + } + echo "\n"; + + // Final check of available models after all operations + echo "Final check: Getting available models after operations\n"; + echo str_repeat('-', 50) . "\n"; + + $finalModels = $provider->getAvailableModels(); + echo "Final available models:\n"; + foreach ($finalModels as $model) { + echo "- $model\n"; + } + echo "\n"; + + echo "\n" . str_repeat('=', 60) . "\n"; + echo "All Ollama Provider tests completed successfully!\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} \ No newline at end of file diff --git a/Tests/OllamaChat.php b/Tests/OllamaChat.php new file mode 100644 index 0000000..7b0b3a1 --- /dev/null +++ b/Tests/OllamaChat.php @@ -0,0 +1,41 @@ +getName() . "\n\n"; + + // Test 1: Simple prompt + echo "Test 1: Simple prompt\n"; + echo str_repeat('-', 50) . "\n"; + + $response = $provider->chat("Can you write a short poem on Joomla focusing on its features and benefits?"); + + echo "API call successful!\n"; + echo "Response: " . "\n" . $response->getContent() . "\n"; + + // $metadata = $response->getMetadata(); + // if (!empty($metadata)) { + // echo "Model used: " . ($metadata['model'] ?? 'N/A') . "\n"; + // echo "Total duration: " . ($metadata['total_duration'] ?? 'N/A') . " ns\n"; + // echo "Eval count: " . ($metadata['eval_count'] ?? 'N/A') . "\n"; + // if (isset($metadata['usage'])) { + // echo "Input tokens: " . ($metadata['usage']['input_tokens'] ?? 'N/A') . "\n"; + // echo "Output tokens: " . ($metadata['usage']['output_tokens'] ?? 'N/A') . "\n"; + // } + // } + // echo "\n"; + + echo "\n" . str_repeat('=', 60) . "\n"; + echo "All Chat tests completed successfully!\n"; +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} \ No newline at end of file diff --git a/Tests/OllamaDefaultModelsTest.php b/Tests/OllamaDefaultModelsTest.php new file mode 100644 index 0000000..12a9df4 --- /dev/null +++ b/Tests/OllamaDefaultModelsTest.php @@ -0,0 +1,76 @@ +getName() . "\n\n"; + + // Set default model for all subsequent calls + $provider->setDefaultModel('tinyllama'); + echo "Default model: " . $provider->getDefaultModel() . "\n\n"; + + // Test 1: Simple prompt- Will use default model + echo "Test 1: Simple prompt- Will use default model tinyllama\n"; + echo str_repeat('-', 50) . "\n"; + + $response = $provider->chat("Hello! How are you?"); + echo "Model: " . $response->getMetadata()['model'] . "\n"; + echo "Response: " . $response->getContent() . "\n"; + echo "\n"; + + // Test 2: Multiple Response Choices Will use default model + echo "Test 2: Multiple Response Choices- Will use default model tinyllama\n"; + echo str_repeat('-', 50) . "\n"; + + $response = $provider->generate("Suggest a name for a movie based on pilots and astronauts"); + echo "Model: " . $response->getMetadata()['model'] . "\n"; + echo "Response: " . $response->getContent() . "\n"; + echo "\n"; + + // Test 3: This will override the default and use llama model + echo "Test 3: Test chat completions audio capability- Will override the default and use llama model\n"; + echo str_repeat('-', 50) . "\n"; + $response = $provider->chat("Write a few words on Joomla! in english.", [ + 'model' => 'deepseek-r1:1.5b' + ]); + + $metadata = $response->getMetadata(); + echo "Model: " . $response->getMetadata()['model'] . "\n"; + echo "Response: " . $response->getContent() . "\n"; + echo "\n"; + + // Test 4: Simple prompt- Will use default model + echo "Test 4: Simple prompt- Will use default model tinyllama because default model was not unset\n"; + echo str_repeat('-', 50) . "\n"; + + $response = $provider->chat("What is the capital of France?"); + echo "Model: " . $response->getMetadata()['model'] . "\n"; + echo "Response: " . $response->getContent() . "\n"; + echo "\n"; + + // Unset default model + $provider->unsetDefaultModel(); + echo "Default model unset\n\n"; + + // Test 5: This will use the provider's default (tinyllama) + echo "Test 5: Simple prompt- Will use use the provider's default (tinyllama) because default model was unset\n"; + echo str_repeat('-', 50) . "\n"; + + $response = $provider->chat("What is the color of the sky?"); + echo "Model: " . $response->getMetadata()['model'] . "\n"; + echo "Response: " . $response->getContent() . "\n"; + echo "\n"; + + echo "\n" . str_repeat('=', 60) . "\n"; + echo "All Chat Completions API tests completed successfully!\n"; +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/OllamaGenerate.php b/Tests/OllamaGenerate.php new file mode 100644 index 0000000..9a6fac5 --- /dev/null +++ b/Tests/OllamaGenerate.php @@ -0,0 +1,102 @@ +getName() . "\n\n"; + + // Test 1: Simple prompt + echo "Test 1: Simple prompt\n"; + echo str_repeat('-', 50) . "\n"; + + $response1 = $provider->generate("Write a short paragraph about artificial intelligence."); + echo $response1->getContent() . "\n\n"; + + // // Test 2: With streaming + // echo "Test 2: With streaming\n"; + // echo str_repeat('-', 50) . "\n"; + + // $response2 = $provider->generate("Explain how neural networks work in 3-4 sentences.", ['stream' => true]); + // echo $response2->getContent() . "\n\n"; + + // // Test 3: With suffix option + // echo "Test 3: With suffix option\n"; + // echo str_repeat('-', 50) . "\n"; + // $options = [ + // 'model' => 'codellama:7b-code-q4_0', + // 'suffix' => " return result", + // 'options' => [ + // 'temperature' => 0 + // ], + // 'stream' => false + // ]; + + // $response3 = $provider->generate("def compute_gcd(a, b):", $options); + // echo $response3->getContent() . "\n\n"; + + // Test 4: With response_format option + echo "Test 4: Structured JSON output\n"; + echo str_repeat('-', 50) . "\n"; + + $options = [ + 'format' => [ + 'type' => 'object', + 'properties' => [ + 'age' => [ + 'type' => 'integer' + ], + 'available' => [ + 'type' => 'boolean' + ] + ], + 'required' => [ + 'age', + 'available' + ] + ] + ]; + + $response = $provider->generate( + "Ollama is 22 years old and is busy saving the world. Respond using JSON", + $options + ); + + echo $response->getContent() . "\n\n"; + + // Test 5: Image processing with base64 encoding + echo "Test 5: Image processing with base64 encoding\n"; + echo str_repeat('-', 50) . "\n"; + + // Read the image file and convert to base64 + $imagePath = __DIR__ . '/test_files/fish.png'; + $imageData = file_get_contents($imagePath); + if ($imageData === false) { + throw new Exception("Failed to read image file: $imagePath"); + } + $base64Image = base64_encode($imageData); + + $options = [ + 'model' => 'llava', + 'images' => [$base64Image] + ]; + + $response = $provider->generate( + "What is in this picture?", + $options + ); + + echo "Response content:\n"; + echo $response->getContent() . "\n\n"; + + echo "\n" . str_repeat('=', 60) . "\n"; + echo "All Generate tests completed successfully!\n"; +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/OllamaModelManagementTest.php b/Tests/OllamaModelManagementTest.php new file mode 100644 index 0000000..5e07c42 --- /dev/null +++ b/Tests/OllamaModelManagementTest.php @@ -0,0 +1,45 @@ +getName() . "\n\n"; + + // Test 0: Pulll granite-embedding:30m model + echo "Test 0: Pull granite-embedding:30m model\n"; + echo str_repeat('-', 50) . "\n"; + + $modelName = 'granite-embedding:30m'; + $result = $provider->pullModel($modelName); + + // Test 1: Copying a new model + echo "Test 1: Copying a new model (granite-embedding)\n"; + echo str_repeat('-', 50) . "\n"; + + $modelToCopy = 'granite-embedding:30m'; + $newModelName = 'granite-embedding-copy:30m'; + + $result = $provider->copyModel($modelToCopy, $newModelName); + + echo "\n"; + + //Test 2: Deleting a model + echo "Test 2: Deleting a model (granite-embedding)\n"; + echo str_repeat('-', 50) . "\n"; + + $result = $provider->deleteModel($modelToCopy); + + echo "\n" . str_repeat('=', 60) . "\n"; + echo "All Ollama Provider tests completed successfully!\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} \ No newline at end of file diff --git a/Tests/TextToSpeechTest.php b/Tests/TextToSpeechTest.php new file mode 100644 index 0000000..8395bf4 --- /dev/null +++ b/Tests/TextToSpeechTest.php @@ -0,0 +1,135 @@ + $api_key + ]); + + echo "=== Testing OpenAI Text-to-Speech Capability ===\n\n"; + + // Test 1: Basic Speech Generation + echo "Test 1: Basic Speech Generation\n"; + echo "-------------------------------\n"; + + $text = "Hello world! This is a test of the OpenAI text-to-speech capability."; + $options = [ + 'model' => 'tts-1', + 'voice' => 'alloy', + ]; + + $response = $provider->speech($text, $options); + + echo "Provider: " . $response->getProvider() . "\n"; + + $metadata = $response->getMetadata(); + echo "Model Used: " . $metadata['model'] . "\n"; + echo "Voice Used: " . $metadata['voice'] . "\n"; + echo "Audio Format: " . $metadata['format'] . "\n"; + echo "Content Type: " . $metadata['content_type'] . "\n"; + echo "Audio Size: " . $metadata['size_bytes'] . " bytes\n"; + + // Save audio to file + $response->saveContentToFile('output/speech_test_1.mp3'); + echo "Audio saved as 'output/speech_test_1.mp3'\n\n"; + + echo str_repeat("=", 50) . "\n\n"; + + // Test 2: Different Voice and Format + echo "Test 2: Different Voice and WAV Format\n"; + echo "--------------------------------------\n"; + + $text2 = "Hello world! This is a test of the OpenAI text-to-speech capability. This is a test with a different voice and WAV format."; + $options2 = [ + 'model' => 'tts-1-hd', + 'voice' => 'nova', + 'response_format' => 'wav', + 'speed' => 1.2 + ]; + + $response2 = $provider->speech($text2, $options2); + + $metadata2 = $response2->getMetadata(); + echo "Model Used: " . $metadata2['model'] . "\n"; + echo "Voice Used: " . $metadata2['voice'] . "\n"; + echo "Audio Format: " . $metadata2['format'] . "\n"; + echo "Content Type: " . $metadata2['content_type'] . "\n"; + echo "Speed: " . $metadata2['speed'] . "\n"; + echo "Audio Size: " . $metadata2['size_bytes'] . " bytes\n"; + + // Save WAV file + $response2->saveContentToFile('output/speech_test_2.wav'); + echo "Audio saved as 'output/speech_test_2.wav'\n\n"; + + echo str_repeat("=", 50) . "\n\n"; + + // Test 3: GPT-4o-mini-tts Model with Instructions + echo "Test 3: GPT-4o-mini-tts with Instructions\n"; + echo "----------------------------------------\n"; + + $text3 = "Hello world! This is a test of the OpenAI text-to-speech capability. This is a test with GPT-4o-mini-tts model and specific instructions to speak in a cheerful and professional customer service tone in the voice coral."; + $options3 = [ + 'model' => 'gpt-4o-mini-tts', + 'voice' => 'coral', + 'instructions' => 'Speak in a cheerful and professional customer service tone.', + 'response_format' => 'mp3', + 'speed' => 0.9 + ]; + + $response3 = $provider->speech($text3, $options3); + + $metadata3 = $response3->getMetadata(); + echo "Model Used: " . $metadata3['model'] . "\n"; + echo "Voice Used: " . $metadata3['voice'] . "\n"; + echo "Audio Format: " . $metadata3['format'] . "\n"; + echo "Content Type: " . $metadata3['content_type'] . "\n"; + echo "Speed: " . $metadata3['speed'] . "\n"; + echo "Audio Size: " . $metadata3['size_bytes'] . " bytes\n"; + echo "Instructions Used: " . ($metadata3['instructions'] ?? 'None') . "\n"; + + // Save audio file + $response3->saveContentToFile('output/speech_test_3.mp3'); + echo "Audio saved as 'output/speech_test_3.mp3'\n\n"; + + echo str_repeat("=", 50) . "\n\n"; + + // Test 4: Test Helper Methods + echo "Test 4: Helper Methods\n"; + echo "---------------------\n"; + + $availableModels = $provider->getAvailableModels(); + echo "Available models: " . implode(", ", $availableModels) . "\n"; + + echo "Available Voices:\n"; + $voices = $provider->getAvailableVoices(); + foreach ($voices as $voice) { + echo " - $voice\n"; + } + echo "\n"; + + echo "Available TTS Models:\n"; + $models = $provider->getTTSModels(); + foreach ($models as $model) { + echo " - $model\n"; + } + echo "\n"; + + echo "Supported Audio Formats:\n"; + $formats = $provider->getSupportedAudioFormats(); + foreach ($formats as $format) { + echo " - $format\n"; + } + echo "\n"; + + echo "=== All Tests Completed Successfully! ===\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/TranscriptionTest.php b/Tests/TranscriptionTest.php new file mode 100644 index 0000000..579805c --- /dev/null +++ b/Tests/TranscriptionTest.php @@ -0,0 +1,96 @@ + $api_key + ]); + + echo "=== Testing OpenAI Audio Transcription Functionality ===\n\n"; + + echo "Step 0: Creating Test Audio File\n"; + echo "--------------------------------\n"; + + $testText = "Hello world! This is a test of the OpenAI transcription functionality. We are testing speech to text conversion with the Whisper model."; + + $speechResponse = $provider->speech($testText, ['model' => 'tts-1', 'voice' => 'alloy', 'response_format' => 'wav']); + $speechResponse->saveContentToFile('test_files/test_audio.wav'); + echo "Audio file created: test_files/test_audio.wav\n\n"; + + echo str_repeat("=", 60) . "\n\n"; + + // Test 1: Basic Transcription with Whisper-1 + echo "Test 1: Basic Transcription (Whisper-1)\n"; + echo "---------------------------------------\n"; + + $audioFile = 'test_files/test_audio.wav'; + $options = [ + 'model' => 'whisper-1', + ]; + + echo "Audio File: $audioFile\n"; + + $response1 = $provider->transcribe($audioFile, $options); + + $metadata1 = $response1->getMetadata(); + echo "Model Used: " . $metadata1['model'] . "\n"; + echo "Response Format: " . $metadata1['response_format'] . "\n"; + echo "Duration: " . ($metadata1['duration'] ?? 'N/A') . " seconds\n"; + + $transcribedText = $response1->getContent(); + echo "\n Transcribed Text:\n"; + echo "\"$transcribedText\"\n\n"; + + echo "Original: \"$testText\"\n"; + echo "Transcribed: \"$transcribedText\"\n"; + + echo str_repeat("=", 60) . "\n\n"; + + // Test 2: Different Response Formats + echo "Test 2: Different Response Formats\n"; + echo "----------------------------------\n"; + + $formats = ['text', 'srt', 'vtt']; + + foreach ($formats as $format) { + echo "Testing format: $format\n"; + echo str_repeat("-", 20) . "\n"; + + try { + $response = $provider->transcribe($audioFile, [ + 'model' => 'whisper-1', + 'response_format' => $format + ]); + + $content = $response->getContent(); + $metadata = $response->getMetadata(); + + echo "Success - Format: " . $metadata['response_format'] . "\n"; + + if ($format === 'text') { + echo "Content: \"" . trim($content) . "\"\n"; + } else { + $filename = "transcription_test.$format"; + file_put_contents("output/$filename", $content); + echo "Saved as: $filename\n"; + } + + } catch (Exception $e) { + echo "Format $format failed: " . $e->getMessage() . "\n"; + } + + echo "\n"; + } + + echo str_repeat("=", 60) . "\n\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/TranslationTest.php b/Tests/TranslationTest.php new file mode 100644 index 0000000..c99aa31 --- /dev/null +++ b/Tests/TranslationTest.php @@ -0,0 +1,48 @@ + $api_key + ]); + + echo "Step 0: Creating Test Audio File (german)\n"; + echo "----------------------------------------\n"; + + // Create a german test audio file using TTS + $testText = "Hallo, hiermit testen wir die Übersetzungsfunktion von OpenAI. Audio wird ins Englische übersetzt. Wir geben die Dateien und das zu verwendende Modell ein; aktuell ist nur Whisper-1 verfügbar. Ein optionaler Text dient zur Orientierung des Modells oder zur Fortsetzung eines vorherigen Audiosegments. Die Eingabeaufforderung sollte auf Englisch sein. Das Ausgabeformat kann in einer der folgenden Optionen gewählt werden: JSON, Text, SRT, Verbose_JSON oder VTT. Wir hoffen, dies funktioniert."; + + $speechResponse = $provider->speech($testText, ['model' => 'tts-1', 'voice' => 'alloy', 'response_format' => 'wav']); + $speechResponse->saveContentToFile('test_files/test_german_audio.wav'); + echo "Audio file created: test_files/test_german_audio.wav\n\n"; + + echo str_repeat("=", 60) . "\n\n"; + + // Test 1: Basic Translation (JSON response) + echo "Test 1: Basic Translation (JSON Format)\n"; + echo "---------------------------------------\n"; + + $testAudioFile = 'test_files/test_german_audio.wav'; + + $response1 = $provider->translate($testAudioFile, ['model' => 'whisper-1']); + + echo "English Translation: " . $response1->getContent() . "\n"; + + $metadata1 = $response1->getMetadata(); + echo "Model Used: " . $metadata1['model'] . "\n"; + echo "Response Format: " . $metadata1['response_format'] . "\n"; + + echo str_repeat("-", 40) . "\n\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/generate.php b/Tests/generate.php new file mode 100644 index 0000000..85a3048 --- /dev/null +++ b/Tests/generate.php @@ -0,0 +1,44 @@ +getName() . "\n\n"; + + // Test 1: Simple prompt + echo "Test 1: Simple prompt\n"; + echo str_repeat('-', 50) . "\n"; + + $response = $provider->generate("Hello! How are you?", [ + 'model' => 'phi4-mini:latest', + 'stream' => false + ]); + + echo "API call successful!\n"; + echo "Response: " . $response->getContent() . "\n"; + echo "Provider: " . $response->getProvider() . "\n"; + echo "Status: " . $response->getStatusCode() . "\n"; + + $metadata = $response->getMetadata(); + if (!empty($metadata)) { + echo "Model used: " . ($metadata['model']) . "\n"; + if (isset($metadata['usage'])) { + echo "Input tokens: " . ($metadata['usage']['input_tokens'] ?? 'N/A') . "\n"; + echo "Output tokens: " . ($metadata['usage']['output_tokens'] ?? 'N/A') . "\n"; + } + } + echo "\n"; + + echo "\n" . str_repeat('=', 60) . "\n"; + echo "All Chat Completions API tests completed successfully!\n"; +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/Tests/results/edited_fish.png b/Tests/results/edited_fish.png new file mode 100644 index 0000000..b362f4e Binary files /dev/null and b/Tests/results/edited_fish.png differ diff --git a/Tests/results/edited_transparent.png b/Tests/results/edited_transparent.png new file mode 100644 index 0000000..8fa2697 Binary files /dev/null and b/Tests/results/edited_transparent.png differ diff --git a/Tests/results/multi_image_test.png b/Tests/results/multi_image_test.png new file mode 100644 index 0000000..739b0c5 Binary files /dev/null and b/Tests/results/multi_image_test.png differ diff --git a/Tests/results/test1_basic_sea_otter.png b/Tests/results/test1_basic_sea_otter.png new file mode 100644 index 0000000..29e4f68 Binary files /dev/null and b/Tests/results/test1_basic_sea_otter.png differ diff --git a/Tests/test_files/dog_img.png b/Tests/test_files/dog_img.png new file mode 100644 index 0000000..08c3386 Binary files /dev/null and b/Tests/test_files/dog_img.png differ diff --git a/Tests/test_files/fish.png b/Tests/test_files/fish.png new file mode 100644 index 0000000..f955988 Binary files /dev/null and b/Tests/test_files/fish.png differ diff --git a/Tests/test_files/mask_dog_img.png b/Tests/test_files/mask_dog_img.png new file mode 100644 index 0000000..0841105 Binary files /dev/null and b/Tests/test_files/mask_dog_img.png differ diff --git a/Tests/test_files/test_audio.wav b/Tests/test_files/test_audio.wav new file mode 100644 index 0000000..256f1c5 Binary files /dev/null and b/Tests/test_files/test_audio.wav differ diff --git a/Tests/test_files/test_german_audio.wav b/Tests/test_files/test_german_audio.wav new file mode 100644 index 0000000..9ae5351 Binary files /dev/null and b/Tests/test_files/test_german_audio.wav differ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..16d834b --- /dev/null +++ b/composer.json @@ -0,0 +1,32 @@ +{ + "name": "joomla-framework/ai", + "type": "joomla-package", + "description": "Joomla AI Package", + "keywords": ["joomla", "framework", "ai"], + "homepage": "https://github.com/joomla-projects/gsoc25_ai_framework", + "license": "GPL-2.0-or-later", + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/joomla-framework/http.git" + } + ], + "require": { + "php": "^8.1.0", + "joomla/http": "dev-4.x-dev", + "joomla/filesystem": "~3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5.28" + }, + "autoload": { + "psr-4": { + "Joomla\\AI\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Joomla\\AI\\Tests\\": "Tests/" + } + } +} \ No newline at end of file diff --git a/src/AbstractProvider.php b/src/AbstractProvider.php new file mode 100644 index 0000000..c80bd6d --- /dev/null +++ b/src/AbstractProvider.php @@ -0,0 +1,511 @@ + + * @license GNU General Public License version 2 or later; see LICENSE + */ + +namespace Joomla\AI; + +use Joomla\Http\HttpFactory; +use Joomla\AI\Exception\AuthenticationException; +use Joomla\AI\Exception\ProviderException; +use Joomla\AI\Exception\RateLimitException; +use Joomla\AI\Exception\QuotaExceededException; +use Joomla\AI\Exception\UnserializableResponseException; +use Joomla\AI\Interface\ProviderInterface; +use Joomla\AI\Interface\ModerationInterface; + +/** + * Abstract provider class. + * + * @since __DEPLOY_VERSION__ + */ +abstract class AbstractProvider implements ProviderInterface +{ + /** + * The provider options. + * + * @var array|\ArrayAccess + * @since __DEPLOY_VERSION__ + */ + protected $options; + + /** + * The HTTP factory instance. + * + * @var HttpFactory + * @since __DEPLOY_VERSION__ + */ + protected $httpFactory; + + /** + * The default model to use for API requests. + * + * @var string|null + * @since __DEPLOY_VERSION__ + */ + protected $defaultModel = null; + + /** + * Constructor. + * + * @param array|\ArrayAccess $options Provider options array. + * @param HttpFactory $httpFactory The http factory + * + * @throws \InvalidArgumentException + * @since ___DEPLOY_VERSION___ + */ + public function __construct(array $options = [], ?HttpFactory $httpFactory = null) + { + // Validate provider is suported + if (!\is_array($options) && !($options instanceof \ArrayAccess)) { + throw new \InvalidArgumentException( + 'The options param must be an array or implement the ArrayAccess interface.' + ); + } + + $this->options = $options; + $this->httpFactory = $httpFactory ?: new HttpFactory(); + } + + /** + * Get an option from the AI provider. + * + * @param string $key The name of the option to get. + * @param mixed $default The default value if the option is not set. + * + * @return mixed The option value. + * @since ___DEPLOY_VERSION___ + */ + protected function getOption(string $key, $default = null) + { + return $this->options[$key] ?? $default; + } + + /** + * Set the default model to use for API requests. + * + * @param string $model The model name to set as default + * @since __DEPLOY_VERSION__ + */ + public function setDefaultModel(string $model) + { + $this->defaultModel = $model; + return $this; + } + + /** + * Unset the default model, reverting to provider-specific defaults. + * + * @since __DEPLOY_VERSION__ + */ + public function unsetDefaultModel() + { + $this->defaultModel = null; + return $this; + } + + /** + * Get the current default model. + * + * @return string|null The current default model or null if not set + * @since __DEPLOY_VERSION__ + */ + public function getDefaultModel() + { + return $this->defaultModel; + } + + /** + * Make HTTP GET request to AI provider API. + * + * @param string $url API endpoint URL + * @param array $headers Additional HTTP headers + * @param integer $timeout Request timeout in seconds + * + * @return \Joomla\Http\Response + * @throws \Exception + * @since ___DEPLOY_VERSION___ + */ + protected function makeGetRequest(string $url, array $headers = [], $timeout = null) + { + try { + $response = $this->httpFactory->getHttp([])->get($url, $headers, $timeout); + + $this->validateResponse($response); + } catch (AuthenticationException|RateLimitException|QuotaExceededException $e) { + throw $e; + } catch (ProviderException $e) { + throw $e; + } + + return $response; + } + + /** + * Make HTTP POST request. + * + * @param string $url API endpoint URL + * @param mixed $data POST data + * @param array $headers HTTP headers + * @param integer $timeout Request timeout + * + * @return \Joomla\Http\Response + * @throws \Exception + * @since ___DEPLOY_VERSION___ + */ + protected function makePostRequest(string $url, $data, array $headers = [], $timeout = null) + { + try { + $response = $this->httpFactory->getHttp([])->post($url, $data, $headers, $timeout); + + $this->validateResponse($response); + + } catch (AuthenticationException|RateLimitException|QuotaExceededException $e) { + throw $e; + } catch (ProviderException $e) { + throw $e; + } + + return $response; + } + + /** + * Make HTTP DELETE request. + * + * @param string $url API endpoint URL + * @param mixed $data Data to send with DELETE request + * @param array $headers HTTP headers + * @param integer $timeout Request timeout + * + * @return \Joomla\Http\Response + * @throws \Exception + * @since ___DEPLOY_VERSION___ + */ + protected function makeDeleteRequest(string $url, $data, array $headers = [], $timeout = null) + { + try { + $response = $this->httpFactory->getHttp([])->delete($url, $headers, $timeout, $data); + + $this->validateResponse($response); + + } catch (AuthenticationException|RateLimitException|QuotaExceededException $e) { + throw $e; + } catch (ProviderException $e) { + throw $e; + } + + return $response; + } + /** + * Make multipart HTTP POST request. + * + * @param string $url API endpoint URL + * @param array $data Form data + * @param array $headers HTTP headers + * + * @return \Joomla\Http\Response + * @since __DEPLOY_VERSION__ + */ + protected function makeMultipartPostRequest(string $url, array $data, array $headers): \Joomla\Http\Response + { + $boundary = '----aiframeworkjoomla-boundary-' . uniqid(); + $postData = ''; + + foreach ($data as $key => $value) { + // Handle metadata fields + if (in_array($key, ['_filename', '_filepath'])) { + continue; + } + + // Handle creating audio file object + if ($key === 'file' && isset($data['_filepath'])) { + $filepath = $data['_filepath']; + $filename = $data['_filename']; + $mimeType = $this->detectAudioMimeType($filepath); + + $fileResource = fopen($filepath, 'rb'); + if (!$fileResource) { + throw new \Exception("Cannot open file: $filepath"); + } + + $postData .= "--{$boundary}\r\n"; + $postData .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n"; + $postData .= "Content-Type: {$mimeType}\r\n\r\n"; + + $fileContent = stream_get_contents($fileResource); + fclose($fileResource); + + $postData .= $fileContent . "\r\n"; + } + // To do: Currently strict format + elseif ($key === 'image') { + if (is_array($value)) { + foreach ($value as $index => $imageData) { + $postData .= "--{$boundary}\r\n"; + $postData .= "Content-Disposition: form-data; name=\"image\"; filename=\"image{$index}.png\"\r\n"; + $postData .= "Content-Type: image/png\r\n\r\n"; + $postData .= $imageData . "\r\n"; + } + } else { + // Single image + $postData .= "--{$boundary}\r\n"; + $postData .= "Content-Disposition: form-data; name=\"image\"; filename=\"image.png\"\r\n"; + $postData .= "Content-Type: image/png\r\n\r\n"; + $postData .= $value . "\r\n"; + } + } + // Handle mask file + elseif ($key === 'mask') { + $postData .= "--{$boundary}\r\n"; + $postData .= "Content-Disposition: form-data; name=\"mask\"; filename=\"mask.png\"\r\n"; + $postData .= "Content-Type: image/png\r\n\r\n"; + $postData .= $value . "\r\n"; + } + // Handle regular form fields + else { + $postData .= "--{$boundary}\r\n"; + $postData .= "Content-Disposition: form-data; name=\"{$key}\"\r\n\r\n"; + $postData .= $value . "\r\n"; + } + } + $postData .= "--{$boundary}--\r\n"; + + $headers['Content-Type'] = "multipart/form-data; boundary={$boundary}"; + + return $this->makePostRequest($url, $postData, $headers); + } + + /** + * Extract filename from multipart field or generate default. + * + * @param string $fieldName The form field name + * @param string $data The file data + * + * @return string The filename + * @since __DEPLOY_VERSION__ + */ + protected function extractFilename(string $fieldName, string $data): string + { + $mimeType = $this->detectImageMimeType($data); + $extension = $this->getExtensionFromMimeType($mimeType); + + if (strpos($fieldName, 'image[') === 0) { + $index = preg_replace('/[^0-9]/', '', $fieldName); + return "image_{$index}.{$extension}"; + } + + return "image.{$extension}"; + + } + + /** + * Detect MIME type from image binary data. + * + * @param string $imageData Binary image data + * + * @return string MIME type + * @since __DEPLOY_VERSION__ + */ + protected function detectImageMimeType(string $imageData): string + { + $header = substr($imageData, 0, 16); + + // PNG signature + if (substr($header, 0, 8) === "\x89PNG\r\n\x1a\n") { + return 'image/png'; + } + + // JPEG signature + if (substr($header, 0, 2) === "\xFF\xD8") { + return 'image/jpeg'; + } + + // WebP signature + if (substr($header, 0, 4) === 'RIFF' && substr($header, 8, 4) === 'WEBP') { + return 'image/webp'; + } + + throw new \InvalidArgumentException('Unsupported image format. Only PNG, JPEG, and WebP are supported.'); + } + + protected function getExtensionFromMimeType(string $mimeType): string + { + switch ($mimeType) { + case 'image/jpeg': + return 'jpg'; + case 'image/webp': + return 'webp'; + case 'image/png': + default: + return 'png'; + } + } + + /** + * Get audio MIME type from file path. + * + * @param string $filepath The file path + * + * @return string The MIME type + * @since __DEPLOY_VERSION__ + */ + protected function detectAudioMimeType(string $input): string + { + if (strpos($input, '.') !== false && !in_array($input, ['mp3', 'wav', 'flac', 'mp4', 'mpeg', 'mpga', 'm4a', 'ogg', 'webm', 'opus', 'aac', 'pcm'])) { + $input = strtolower(pathinfo($input, PATHINFO_EXTENSION)); + } + + $mimeMap = [ + 'mp3' => 'audio/mpeg', + 'wav' => 'audio/wav', + 'flac' => 'audio/flac', + 'ogg' => 'audio/ogg', + 'webm' => 'audio/webm', + 'mp4' => 'audio/mp4', + 'mpeg' => 'audio/mpeg', + 'mpga' => 'audio/mpeg', + 'm4a' => 'audio/mp4', + 'opus' => 'audio/opus', + 'aac' => 'audio/aac', + 'pcm' => 'audio/pcm', + ]; + + return $mimeMap[$input]; + } + + /** + * Check if a model is available with the provider. + * + * @param string $model The model to check + * @param array $availableModels Array of available models + * + * @return bool + * @since __DEPLOY_VERSION__ + */ + protected function isModelAvailable(string $model, array $availableModels): bool + { + return in_array($model, $availableModels, true); + } + + /** + * Get models that support a specific capability from available models. + * + * @param array $availableModels All available models + * @param array $capableModels Models that support the capability + * + * @return array + * @since __DEPLOY_VERSION__ + */ + protected function getModelsByCapability(array $availableModels, array $capableModels): array + { + return array_values(array_intersect($availableModels, $capableModels)); + } + + /** + * Check if a model supports a specific capability. + * + * @param string $model The model to check + * @param string $capability The capability to check + * @param array $capabilityMap Map of capabilities to model arrays + * + * @return bool + * @since __DEPLOY_VERSION__ + */ + protected function checkModelCapability(string $model, string $capability, array $capabilityMap): bool + { + if (!isset($capabilityMap[$capability])) { + return false; + } + + return $this->isModelAvailable($model, $capabilityMap[$capability]); + } + + /** + * Check response code and handle errors + * + * @param \Joomla\Http\Response $response HTTP response + * + * @return boolean True if successful + * @throws \Exception + * @since ___DEPLOY_VERSION___ + */ + protected function validateResponse($response): bool + { + if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { + $responseBody = $response->getBody(); + $errorData = json_decode($responseBody, true) ?? ['message' => $responseBody]; + $message = $errorData['message'] ?? $errorData['error']['message'] ?? 'Unknown error'; + $providerErrorCode = $errorData['code'] ?? $errorData['error']['code'] ?? $errorData['type'] ?? $errorData['error']['type'] ?? null; + + // Handle specific HTTP status codes with appropriate exceptions + switch ($response->getStatusCode()) { + case 401: + case 403: + throw new AuthenticationException($this->getName(), $errorData, $response->getStatusCode()); + + case 429: + if (str_contains(strtolower($message), 'quota') || str_contains(strtolower($message), 'credits') ||str_contains(strtolower($message), 'billing')) { + throw new QuotaExceededException($this->getName(), $errorData, $response->getStatusCode()); + } elseif (str_contains(strtolower($message), 'rate') || str_contains(strtolower($message), 'limit') || str_contains(strtolower($message), 'too many requests')) { + throw new RateLimitException($this->getName(), $errorData, $response->getStatusCode()); + } + + default: + throw new ProviderException($this->getName(), $errorData, $response->getStatusCode(), $providerErrorCode); + } + } + + return true; + } + + protected function isJsonResponse(string $responseBody): bool + { + // JSON responses start with { or [ + $trimmed = ltrim($responseBody); + return !empty($trimmed) && ($trimmed[0] === '{' || $trimmed[0] === '['); + } + + /** + * Parse JSON response safely + * + * @param string $jsonString The JSON string to parse + * + * @return array The parsed JSON data + * @throws UnserializableResponseException If JSON parsing fails + * @since ___DEPLOY_VERSION___ + */ + protected function parseJsonResponse(string $jsonString): array + { + $decoded = json_decode($jsonString, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new UnserializableResponseException($this->getName(), $jsonString, json_last_error_msg(), 422); + } + + return $decoded; + } + + /** + * Apply moderation to the input if the provider implements ModerationInterface. + * + * @param string|array $input The input to moderate (text or images) + * @param array $options Additional options for moderation + * + * @return bool + * @since __DEPLOY_VERSION__ + */ + protected function moderateInput($input, array $options = []): bool + { + // Check if the provider supports moderation + if (!($this instanceof ModerationInterface)) { + return false; + } + + $moderationResult = $this->moderate($input, $options); + return $this->isContentFlagged($moderationResult); + } +} diff --git a/src/Exception/AIException.php b/src/Exception/AIException.php new file mode 100644 index 0000000..67e9f72 --- /dev/null +++ b/src/Exception/AIException.php @@ -0,0 +1,148 @@ + + * @license GNU General Public License version 2 or later; see LICENSE + */ + +namespace Joomla\AI\Exception; + +/** + * Base exception class for the AI framework. + * + * @since __DEPLOY_VERSION__ + */ +class AIException extends \Exception +{ + /** + * The AI provider that caused the exception. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected string $provider = ''; + + /** + * Additional context information about the error. + * + * @var array + * @since __DEPLOY_VERSION__ + */ + protected array $context = []; + + /** + * HTTP status code if applicable. + * + * @var int|null + * @since __DEPLOY_VERSION__ + */ + protected ?int $httpStatusCode = null; + + /** + * Provider-specific error code. + * + * @var string|null + * @since __DEPLOY_VERSION__ + */ + protected ?string $providerErrorCode = null; + + /** + * Constructor. + * + * @param string $message The exception message + * @param string $provider The AI provider name + * @param array $context Additional context information + * @param \Throwable|null $previous Previous exception + * @param int|null $httpStatusCode HTTP status code if applicable + * @param string|null $providerErrorCode Provider-specific error code + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(string $message, string $provider, array $context, ?\Throwable $previous, ?int $httpStatusCode, ?string $providerErrorCode) { + parent::__construct($message, 0, $previous); + $this->provider = $provider; + $this->context = $context; + $this->httpStatusCode = $httpStatusCode; + $this->providerErrorCode = $providerErrorCode; + } + + /** + * Get the provider name that caused this exception. + * + * @return string The provider name + * @since __DEPLOY_VERSION__ + */ + public function getProvider(): string + { + return $this->provider; + } + + /** + * Get additional context information about the error. + * + * @return array Context information + * @since __DEPLOY_VERSION__ + */ + public function getContext(): array + { + return $this->context; + } + + /** + * Get the HTTP status code if applicable. + * + * @return int|null HTTP status code or null + * @since __DEPLOY_VERSION__ + */ + public function getHttpStatusCode(): ?int + { + return $this->httpStatusCode; + } + + /** + * Get the provider-specific error code. + * + * @return string|null Provider error code or null + * @since __DEPLOY_VERSION__ + */ + public function getProviderErrorCode(): ?string + { + return $this->providerErrorCode; + } + + /** + * Check if this exception is retryable. + * + * @return bool True if retryable, false otherwise + * @since __DEPLOY_VERSION__ + */ + public function isRetryable(): bool + { + return false; + } + + /** + * Build a detailed error message for all AI exceptions. + * + * @param string $provider The AI provider name + * @param string $errorType The error type (e.g. Authentication, Rate Limit, etc.) + * @param string $message The error message + * @param int|null $httpStatusCode HTTP status code + * @param string|int|null $providerErrorCode Provider-specific error code + * + * @return string Detailed error message + */ + protected function buildDetailedMessage(string $provider, string $errorType, string $message, ?int $httpStatusCode = null, string|int|null $providerErrorCode = null): string { + $details = []; + $details[] = "Provider: {$provider}"; + $details[] = "HTTP Status: " . ($httpStatusCode ?? 'Unknown'); + $details[] = "Error Type: {$errorType}"; + if ($providerErrorCode) { + $details[] = "Error Code: {$providerErrorCode}"; + } + $details[] = "Message: {$message}"; + return implode("\n", $details); + } +} diff --git a/src/Exception/AuthenticationException.php b/src/Exception/AuthenticationException.php new file mode 100644 index 0000000..a00d2bd --- /dev/null +++ b/src/Exception/AuthenticationException.php @@ -0,0 +1,57 @@ + + * @license GNU General Public License version 2 or later; see LICENSE + */ + +namespace Joomla\AI\Exception; + +/** + * Exception for authentication and authorization errors from AI providers. + * + * @since __DEPLOY_VERSION__ + */ +class AuthenticationException extends AIException +{ + /** + * Constructor + * + * @param string $provider The AI provider name + * @param array $errorData Raw error data from provider response + * @param int $httpStatusCode HTTP status code + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(string $provider, array $errorData, int $httpStatusCode) + { + $context = ['error_data' => $errorData]; + $providerErrorCode = $errorData['code'] ?? $errorData['error']['code'] ?? null; + $errorType = $errorData['type'] ?? $errorData['error']['type'] ?? 'Authentication'; + $message = $errorData['message'] ?? $errorData['error']['message'] ?? $errorData['error'] ?? null; + if (is_array($message)) { + $message = implode('. ', $message); + } + if (!$message) { + $code = $providerErrorCode; + $message = $code ? "Error: $code" : 'Authentication error'; + } + + $detailedMessage = $this->buildDetailedMessage($provider, $errorType, $message, $httpStatusCode, $providerErrorCode); + parent::__construct($detailedMessage, $provider, $context, null, $httpStatusCode, $providerErrorCode); + } + + /** + * Get comprehensive authentication error details in one formatted message. + * + * @return string Complete error information including provider, status, type, code, message, and retry info + * + * @since __DEPLOY_VERSION__ + */ + public function getAuthenticationErrorDetails(): string + { + return $this->getMessage(); + } +} diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php new file mode 100644 index 0000000..6a6f4f0 --- /dev/null +++ b/src/Exception/InvalidArgumentException.php @@ -0,0 +1,343 @@ + + * @license GNU General Public License version 2 or later; see LICENSE + */ + +namespace Joomla\AI\Exception; + +/** + * Exception thrown when invalid arguments are provided to AI methods. + * + * @since __DEPLOY_VERSION__ + */ +class InvalidArgumentException extends AIException +{ + /** + * Create exception for invalid model name. + * + * @param string $model The invalid model name + * @param string $provider The provider name + * @param array $validModels Array of valid models for this capability + * @param string $capability The capability being used (optional) + * + * @return self + * @since __DEPLOY_VERSION__ + */ + public static function invalidModel(string $model, string $provider, array $validModels = [], string $capability = ''): self + { + $message = "Model '{$model}' is not supported by {$provider}"; + + if ($capability) { + $message = "Model '{$model}' does not support {$capability} capability on {$provider}"; + } + + if (!empty($validModels)) { + $message .= ". Valid models: " . implode(', ', $validModels); + } + + return new self( + $message, + $provider, + [ + 'requested_model' => $model, + 'valid_models' => $validModels, + 'capability' => $capability, + 'validation_type' => 'model' + ], + null, + null, + null + ); + } + + /** + * Create exception for invalid temperature value. + * + * @param float $temperature The invalid temperature value + * @param string $provider The provider name + * @param float $min Minimum allowed value + * @param float $max Maximum allowed value + * + * @return self + * @since __DEPLOY_VERSION__ + */ + public static function invalidTemperature(float $temperature, string $provider, float $min = 0.0, float $max = 2.0): self + { + $message = "Temperature value {$temperature} is invalid. Must be between {$min} and {$max}"; + + return new self( + $message, + $provider, + [ + 'temperature' => $temperature, + 'min_value' => $min, + 'max_value' => $max, + 'validation_type' => 'temperature' + ], + null, + null, + null + ); + } + + /** + * Create exception for file size validation. + * + * @param string $filePath The file path + * @param int $fileSize Current file size in bytes + * @param int $maxSize Maximum allowed size in bytes + * @param string $provider The provider name + * + * @return self + * @since __DEPLOY_VERSION__ + */ + public static function fileSizeExceeded(string $filePath, int $fileSize, int $maxSize, string $provider, string $model = ''): self + { + $fileSizeMB = round($fileSize / 1024 / 1024, 2); + $maxSizeMB = round($maxSize / 1024 / 1024, 2); + + $message = "File '{$filePath}' size ({$fileSizeMB}MB) exceeds maximum allowed size ({$maxSizeMB}MB)"; + + if ($model) { + $message .= " for model '{$model}'"; + } + return new self( + $message, + $provider, + [ + 'file_path' => $filePath, + 'file_size_bytes' => $fileSize, + 'file_size_mb' => $fileSizeMB, + 'max_size_mb' => $maxSizeMB, + 'validation_type' => 'file_size' + ], + null, + null, + null + ); + } + + /** + * Create exception for invalid file format. + * + * @param string $filePath The file path + * @param string $currentFormat Current file format/extension + * @param array $allowedFormats Array of allowed formats + * @param string $provider The provider name + * @param string $operation The operation being performed + * + * @return self + * @since __DEPLOY_VERSION__ + */ + public static function invalidFileFormat(string $filePath, string $currentFormat, array $allowedFormats, string $provider, string $operation = ''): self + { + $message = "File '{$filePath}' has unsupported format '{$currentFormat}'"; + + if ($operation) { + $message .= " for {$operation}"; + } + + $message .= " on {$provider}. Supported formats: " . implode(', ', $allowedFormats); + + return new self( + $message, + $provider, + [ + 'file_path' => $filePath, + 'current_format' => $currentFormat, + 'allowed_formats' => $allowedFormats, + 'operation' => $operation, + 'validation_type' => 'file_format' + ], + null, + null, + null + ); + } + + /** + * Create exception for invalid voice parameter. + * + * @param string $voice The invalid voice name + * @param array $availableVoices Array of available voices + * @param string $provider The provider name + * + * @return self + * @since __DEPLOY_VERSION__ + */ + public static function invalidVoice(string $voice, array $availableVoices, string $provider): self + { + $message = "Voice '{$voice}' is not available on {$provider}. Available voices: " . implode(', ', $availableVoices); + + return new self( + $message, + $provider, + [ + 'requested_voice' => $voice, + 'available_voices' => $availableVoices, + 'validation_type' => 'voice' + ], + null, + null, + null + ); + } + + /** + * Create exception for empty or invalid message array. + * + * @param string $provider The provider name + * @param string $reason Specific reason for validation failure + * + * @return self + * @since __DEPLOY_VERSION__ + */ + public static function invalidMessages(string $provider, string $reason = 'Messages array cannot be empty'): self + { + return new self( + $reason, + $provider, + [ + 'validation_type' => 'messages', + 'requirement' => 'non-empty array with valid message structure' + ], + null, + null, + null + ); + } + + /** + * Create exception for invalid image size parameter. + * + * @param string $size The invalid size + * @param array $allowedSizes Array of allowed sizes + * @param string $provider The provider name + * @param string $model The model being used + * + * @return self + * @since __DEPLOY_VERSION__ + */ + public static function invalidImageSize(string $size, array $allowedSizes, string $provider, string $model): self + { + $message = "Image size '{$size}' is not supported"; + + if ($model) { + $message .= " for model '{$model}'."; + } + + $message .= " Supported sizes: " . implode(', ', $allowedSizes); + + return new self( + $message, + $provider, + [ + 'requested_size' => $size, + 'allowed_sizes' => $allowedSizes, + 'model' => $model, + 'validation_type' => 'image_size' + ], + null, + null, + null + ); + } + + /** + * Create exception for invalid parameter value. + * + * @param string $parameter The parameter name + * @param mixed $value The invalid value + * @param string $provider The provider name + * @param string $requirement Description of what's required + * @param array $context Additional context information + * + * @return self + * @since __DEPLOY_VERSION__ + */ + public static function invalidParameter(string $parameter, $value, string $provider, string $requirement, array $context = []): self + { + $valueStr = is_scalar($value) ? (string)$value : gettype($value); + $message = "Parameter '{$parameter}' has invalid value '{$valueStr}'. {$requirement}"; + + $contextData = array_merge([ + 'parameter' => $parameter, + 'invalid_value' => $value, + 'requirement' => $requirement, + 'validation_type' => 'parameter' + ], $context); + + return new self($message, $provider, $contextData, null, null, null); + } + + /** + * Create exception for missing required parameter. + * + * @param string $parameter The missing parameter name + * @param string $provider The provider name + * @param string $method The method being called + * + * @return self + * @since __DEPLOY_VERSION__ + */ + public static function missingParameter(string $parameter, string $provider, string $method = ''): self + { + $message = "Required parameter '{$parameter}' is missing"; + + if ($method) { + $message .= " for {$method}()"; + } + + return new self( + $message, + $provider, + [ + 'missing_parameter' => $parameter, + 'method' => $method, + 'validation_type' => 'missing_parameter' + ], + null, + null, + null + ); + } + + /** + * Create exception for file not found. + * + * @param string $filePath The file path that wasn't found + * @param string $provider The provider name + * + * @return self + * @since __DEPLOY_VERSION__ + */ + public static function fileNotFound(string $filePath, string $provider): self + { + return new self( + "File '{$filePath}' not found or is not readable", + $provider, + [ + 'file_path' => $filePath, + 'validation_type' => 'file_existence' + ], + null, + null, + null + ); + } + + /** + * Check if this validation exception is retryable. + * + * @return bool Always false for validation errors + * @since __DEPLOY_VERSION__ + */ + public function isRetryable(): bool + { + return false; + } +} diff --git a/src/Exception/ProviderException.php b/src/Exception/ProviderException.php new file mode 100644 index 0000000..ee80b5e --- /dev/null +++ b/src/Exception/ProviderException.php @@ -0,0 +1,54 @@ + + * @license GNU General Public License version 2 or later; see LICENSE + */ + +namespace Joomla\AI\Exception; + +/** + * Exception for provider-specific or uncategorized errors from AI providers. + * + * @since __DEPLOY_VERSION__ + */ +class ProviderException extends AIException +{ + /** + * Constructor. + * + * @param string $provider The AI provider name + * @param string|array $errorData The error message or error data array + * @param int|null $httpStatusCode HTTP status code (if available) + * @param string|int|null $providerErrorCode Provider-specific error code (if available) + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(string $provider, string|array $errorData, ?int $httpStatusCode = null, string|int|null $providerErrorCode = null) + { + $context = ['error_data' => $errorData]; + $providerErrorCode = $errorData['code'] ?? $errorData['error']['code'] ?? null; + $errorType = $errorData['type'] ?? $errorData['error']['type'] ?? 'Unknown Error'; + + if (is_array($errorData)) { + $message = $errorData['message'] ?? $errorData['error']['message'] ?? $errorData['error'] ?? 'Unknown provider error'; + } else { + $message = $errorData; + } + + $detailedMessage = $this->buildDetailedMessage($provider, $errorType, $message, $httpStatusCode, $providerErrorCode); + + parent::__construct($detailedMessage, $provider, $context, null, $httpStatusCode, $providerErrorCode); + } + + /** + * Get comprehensive provider error details in one formatted message. + * + * @return string + */ + public function getProviderErrorDetails(): string + { + return $this->getMessage(); + } +} diff --git a/src/Exception/QuotaExceededException.php b/src/Exception/QuotaExceededException.php new file mode 100644 index 0000000..e4beb5d --- /dev/null +++ b/src/Exception/QuotaExceededException.php @@ -0,0 +1,61 @@ + $errorData]; + $providerErrorCode = $errorData['code'] ?? $errorData['error']['code'] ?? null; + $errorType = $errorData['type'] ?? $errorData['error']['type'] ?? 'Quota Exceeded'; + $message = $errorData['message'] ?? $errorData['error']['message'] ?? $errorData['error'] ?? null; + + if (is_array($message)) { + $message = implode('. ', $message); + } + if (!$message) { + $code = $providerErrorCode; + $message = $code ? "Error: $code" : 'Quota exceeded error'; + } + + $detailedMessage = $this->buildDetailedMessage($provider, $errorType, $message, $httpStatusCode, $providerErrorCode); + + parent::__construct($detailedMessage, $provider, $context, null, $httpStatusCode, $providerErrorCode); + } + + /** + * Get comprehensive quota exceeded error details in one formatted message. + * + * @return string Complete error information including provider, status, type, code, message, and retry info + * + * @since __DEPLOY_VERSION__ + */ + public function getQuotaExceededErrorDetails(): string + { + return $this->getMessage(); + } +} diff --git a/src/Exception/RateLimitException.php b/src/Exception/RateLimitException.php new file mode 100644 index 0000000..b7f343b --- /dev/null +++ b/src/Exception/RateLimitException.php @@ -0,0 +1,61 @@ + $errorData]; + $providerErrorCode = $errorData['code'] ?? $errorData['error']['code'] ?? null; + $errorType = $errorData['type'] ?? $errorData['error']['type'] ?? 'Rate Limit Exceeded'; + $message = $errorData['message'] ?? $errorData['error']['message'] ?? $errorData['error'] ?? null; + + if (is_array($message)) { + $message = implode('. ', $message); + } + if (!$message) { + $code = $providerErrorCode; + $message = $code ? "Error: $code" : 'Rate limit exceeded error'; + } + + $detailedMessage = $this->buildDetailedMessage($provider, $errorType, $message, $httpStatusCode, $providerErrorCode); + + parent::__construct($detailedMessage, $provider, $context, null, $httpStatusCode, $providerErrorCode); + } + + /** + * Get comprehensive rate limit error details in one formatted message. + * + * @return string Complete error information including provider, status, type, code, message, and retry info + * + * @since __DEPLOY_VERSION__ + */ + public function getRateLimitErrorDetails(): string + { + return $this->getMessage(); + } +} diff --git a/src/Exception/UnserializableResponseException.php b/src/Exception/UnserializableResponseException.php new file mode 100644 index 0000000..1e038cc --- /dev/null +++ b/src/Exception/UnserializableResponseException.php @@ -0,0 +1,58 @@ + + * @license GNU General Public License version 2 or later; see LICENSE + */ + +namespace Joomla\AI\Exception; + +/** + * Exception for unserializable or malformed responses from AI providers. + * + * Handles cases where the API response cannot be properly parsed or processed, + * such as invalid JSON, unexpected response structure, corrupted data, or empty responses. + * + * @since __DEPLOY_VERSION__ + */ +class UnserializableResponseException extends AIException +{ + /** + * Constructor + * + * @param string $provider The AI provider name + * @param string $rawResponse Raw response content that couldn't be parsed + * @param string $parseError The specific parsing error message + * @param int $httpStatusCode HTTP status code (default 422) + * @param string|int|null $providerErrorCode Provider-specific error code + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(string $provider, string $rawResponse, string $parseError = '', int $httpStatusCode = 422, string|int|null $providerErrorCode = null) + { + $context = [ + 'raw_response' => $rawResponse, + 'parse_error' => $parseError, + ]; + $errorType = 'Response Parsing'; + $message = $parseError ?: (empty($rawResponse) ? 'Received empty response from provider' : 'Unable to parse response'); + + $detailedMessage = $this->buildDetailedMessage($provider, $errorType, $message, $httpStatusCode, $providerErrorCode); + + parent::__construct($detailedMessage, $provider, $context, null, $httpStatusCode, $providerErrorCode); + } + + /** + * Get comprehensive response parsing error details in one formatted message. + * + * @return string Complete error information including provider, status, type, parsing details, and response info + * + * @since __DEPLOY_VERSION__ + */ + public function getResponseErrorDetails(): string + { + return $this->getMessage(); + } +} diff --git a/src/Interface/AudioInterface.php b/src/Interface/AudioInterface.php new file mode 100644 index 0000000..8474563 --- /dev/null +++ b/src/Interface/AudioInterface.php @@ -0,0 +1,81 @@ + + * @license GNU General Public License version 2 or later; see LICENSE + */ + +namespace Joomla\AI\Interface; + +use Joomla\AI\Response\Response; + +/** + * AI provider audio capability interface. + * + * @since __DEPLOY_VERSION__ + */ +interface AudioInterface +{ + /** + * Generate speech audio from text input. + * + * @param string $text The text to convert to speech + * @param string $model The TTS model to use for speech generation + * @param string $voice The voice to use for speech generation + * @param array $options Additional options for speech generation + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + public function speech(string $text, array $options = []): Response; + + /** + * Get available voices for speech generation. + * + * @return array Array of available voice names + * @since __DEPLOY_VERSION__ + */ + public function getAvailableVoices(): array; + + /** + * Get available TTS models for this provider. + * + * @return array Array of available TTS model names + * @since __DEPLOY_VERSION__ + */ + public function getTTSModels(): array; + + /** + * Get supported audio output formats. + * + * @return array Array of supported format names + * @since __DEPLOY_VERSION__ + */ + public function getSupportedAudioFormats(): array; + + /** + * Transcribe audio to text. + * + * @param string $audioFile Path to the audio file to transcribe + * @param string $model The transcription model to use + * @param array $options Additional options for transcription + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + public function transcribe(string $audioFile, array $options = []): Response; + + /** + * Translate audio to English text. + * + * @param string $audioFile Path to audio file to translate + * @param string $model Model to use for translation + * @param array $options Additional options + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + public function translate(string $audioFile, array $options = []): Response; +} diff --git a/src/Interface/ChatInterface.php b/src/Interface/ChatInterface.php new file mode 100644 index 0000000..02acd3c --- /dev/null +++ b/src/Interface/ChatInterface.php @@ -0,0 +1,43 @@ + + * @license GNU General Public License version 2 or later; see LICENSE + */ + +namespace Joomla\AI\Interface; + +use Joomla\AI\Response\Response; + +/** + * AI provider chat capability interface. + * + * @since __DEPLOY_VERSION__ + */ +interface ChatInterface +{ + /** + * Generate a chat response from the AI provider. + * + * @param string $message The message to send to the AI provider. + * @param array $options An associative array of options to send with the request. + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + public function chat(string $message, array $options = []): Response; + + /** + * Generate chat completion with vision capability from the AI provider. + * + * @param string $message The chat message about the image to send to the AI provider. + * @param string $image Image URL or base64 encoded image. + * @param array $options An associative array of options to send with the request. + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + public function chatWithVision(string $message, string $image, array $options = []): Response; +} diff --git a/src/Interface/EmbeddingInterface.php b/src/Interface/EmbeddingInterface.php new file mode 100644 index 0000000..0b59fd8 --- /dev/null +++ b/src/Interface/EmbeddingInterface.php @@ -0,0 +1,40 @@ + + * @license GNU General Public License version 2 or later; see LICENSE + */ + +namespace Joomla\AI\Interface; + +use Joomla\AI\Response\Response; + +/** + * AI provider embedding capability interface. + * + * @since __DEPLOY_VERSION__ + */ +interface EmbeddingInterface +{ + /** + * Create embeddings for the given input text(s). + * + * @param string|array $input Text string or array of texts to embed + * @param string $model The embedding model to use + * @param array $options Additional options + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + public function createEmbeddings($input, string $model, array $options = []): Response; + + /** + * Get available embedding models for this provider. + * + * @return array + * @since __DEPLOY_VERSION__ + */ + public function getEmbeddingModels(): array; +} diff --git a/src/Interface/ImageInterface.php b/src/Interface/ImageInterface.php new file mode 100644 index 0000000..6325226 --- /dev/null +++ b/src/Interface/ImageInterface.php @@ -0,0 +1,54 @@ + + * @license GNU General Public License version 2 or later; see LICENSE + */ + +namespace Joomla\AI\Interface; + +use Joomla\AI\Response\Response; + +/** + * AI provider image capability interface. + * + * @since __DEPLOY_VERSION__ + */ +interface ImageInterface +{ + /** + * Generate an image from the text prompt given to the AI provider. + * + * @param string $prompt The text prompt describing the desired image + * @param array $options An associative array of options to send with the request. + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + public function generateImage(string $prompt, array $options = []): Response; + + /** + * Modify an existing image from the text prompt given to the AI provider. + * + * @param string $imagePath Path to the image file to modify. + * @param string $prompt Text description of the desired modifications + * @param array $options An associative array of options to send with the request. + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + // public function editImage(string $imagePath, string $prompt, array $options = []): Response; + + /** + * Create alternative versions of an image from the text prompt given to the AI provider. + * + * @param string $imagePath Path to the source image file. + * @param array $options An associative array of options to send with the request. + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + // public function createImageVariations(string $imagePath, array $options = []): Response; +} diff --git a/src/Interface/ModelInterface.php b/src/Interface/ModelInterface.php new file mode 100644 index 0000000..d88beb8 --- /dev/null +++ b/src/Interface/ModelInterface.php @@ -0,0 +1,61 @@ + + * @license GNU General Public License version 2 or later; see LICENSE + */ + +namespace Joomla\AI\Interface; + +/** + * Model management interface for AI providers. + * + * @since __DEPLOY_VERSION__ + */ +interface ModelInterface +{ + /** + * Get all available models for this provider. + * + * @return array Array of available model names + * @since __DEPLOY_VERSION__ + */ + public function getAvailableModels(): array; + + /** + * Get models that support chat capability. + * + * @return array Array of chat capable model names + * @since __DEPLOY_VERSION__ + */ + public function getChatModels(): array; + + /** + * Get models that support vision capability. + * + * @return array Array of vision capable model names + * @since __DEPLOY_VERSION__ + */ + public function getVisionModels(): array; + + /** + * Get models that support image generation capability. + * + * @return array Array of image capable model names + * @since __DEPLOY_VERSION__ + */ + // public function getImageModels(): array; + + /** + * Check if a model supports a specific capability. + * + * @param string $model The model name to check + * @param string $capability The capability to check (chat, image, audio) + * + * @return bool + * @since __DEPLOY_VERSION__ + */ + public function isModelCapable(string $model, string $capability): bool; +} diff --git a/src/Interface/ModerationInterface.php b/src/Interface/ModerationInterface.php new file mode 100644 index 0000000..8fb26e0 --- /dev/null +++ b/src/Interface/ModerationInterface.php @@ -0,0 +1,39 @@ + + * @license GNU General Public License version 2 or later; see LICENSE + */ + +namespace Joomla\AI\Interface; + +use Joomla\AI\Response\Response; + +/** + * AI provider class interface. + * + * @since __DEPLOY_VERSION__ + */ +interface ProviderInterface +{ + /** + * Method to check if AI provider is available for using + * + * @return boolean True if available else false + * @since __DEPLOY_VERSION__ + */ + public static function isSupported(): bool; + + /** + * Method to get the name of the AI provider. + * + * @return string The name of the AI provider. + * @since __DEPLOY_VERSION__ + */ + public function getName(): string; + + // Should be a smart router in future versions. + /** + * Send a prompt to the AI provider and return a Response object with the response. + * + * @param string $prompt The prompt to send to the AI provider. + * @param array $options An associative array of options to send with the request. + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + // public function prompt(string $prompt, array $options = []): Response; +} diff --git a/src/Provider/AnthropicProvider.php b/src/Provider/AnthropicProvider.php new file mode 100644 index 0000000..821e474 --- /dev/null +++ b/src/Provider/AnthropicProvider.php @@ -0,0 +1,495 @@ +baseUrl = $this->getOption('base_url', 'https://api.anthropic.com/v1'); + + // Remove trailing slash if present + if (substr($this->baseUrl, -1) === '/') { + $this->baseUrl = rtrim($this->baseUrl, '/'); + } + } + + /** + * Check if Anthropic provider is supported/configured. + * + * @return boolean True if API key is available + * @since __DEPLOY_VERSION__ + */ + public static function isSupported(): bool + { + return !empty($_ENV['ANTHROPIC_API_KEY']) || + !empty(getenv('ANTHROPIC_API_KEY')); + } + + /** + * Get the provider name. + * + * @return string The provider name + * @since __DEPLOY_VERSION__ + */ + public function getName(): string + { + return 'Anthropic'; + } + + /** + * Build HTTP headers for Anthropic API request. + * + * @return array HTTP headers + * @since __DEPLOY_VERSION__ + */ + private function buildHeaders(): array + { + $apiKey = $this->getApiKey(); + + return [ + 'x-api-key' => $apiKey, + 'anthropic-version' => '2023-06-01', // Latest version + 'content-type' => 'application/json' + ]; + } + + /** + * Get the Anthropic API key. + * + * @return string The API key + * @throws AuthenticationException If API key is not found + * @since __DEPLOY_VERSION__ + */ + private function getApiKey(): string + { + $apiKey = $this->getOption('api_key') ?? + $_ENV['ANTHROPIC_API_KEY'] ?? + getenv('ANTHROPIC_API_KEY'); + + if (empty($apiKey)) { + throw new AuthenticationException( + $this->getName(), + ['message' => 'Anthropic API key not configured. Set ANTHROPIC_API_KEY environment variable or provide api_key option.'], + 401 + ); + } + + return $apiKey; + } + + /** + * Get the messages endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getMessagesEndpoint(): string + { + return $this->baseUrl . '/messages'; + } + + /** + * Get the models endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getModelsEndpoint(): string + { + return $this->baseUrl . '/models'; + } + + /** + * List available models from Anthropic. + * + * @param array $options Optional parameters for the request + * + * @return Response The response containing model list + * @since __DEPLOY_VERSION__ + */ + public function getAvailableModels(): array + { + $headers = $this->buildHeaders(); + $response = $this->makeGetRequest($this->getModelsEndpoint(), $headers); + $data = $this->parseJsonResponse($response->getBody()); + + return array_column($data['data'], 'id'); + } + + /** + * Get information about a specific model. + * + * @param string $modelId The model identifier or alias + * + * @return Response The response containing model information + * @since __DEPLOY_VERSION__ + */ + public function getModel(string $modelId): Response + { + $endpoint = $this->getModelsEndpoint() . '/' . urlencode($modelId); + $headers = $this->buildHeaders(); + $httpResponse = $this->makeGetRequest($endpoint, $headers); + $data = $this->parseJsonResponse($httpResponse->getBody()); + + if (isset($data['error'])) { + throw new ProviderException($this->getName(), $data); + } + + return new Response( + json_encode($data, JSON_PRETTY_PRINT), + $this->getName(), + ['raw_response' => $data], + 200 + ); + } + + /** + * Build payload for chat request. + * + * @param string $message The user message to send + * @param array $options Additional options + * + * @return array The request payload + * @since __DEPLOY_VERSION__ + */ + private function buildChatRequestPayload(string $message, array $options = []): array + { + $model = $options['model'] ?? $this->getOption('model', 'claude-3-haiku-20240307'); + $maxTokens = $options['max_tokens'] ?? 1024; + + $messages = $options['messages'] ?? [ + [ + 'role' => 'user', + 'content' => $message + ] + ]; + + $payload = [ + 'model' => $model, + 'messages' => $messages, + 'max_tokens' => $maxTokens + ]; + + return $payload; + } + + /** + * Build payload for vision request. + * + * @param string $message The chat message about the image + * @param string $image Image URL or base64 encoded image + * @param array $options Additional options + * + * @return array The request payload + * @throws \InvalidArgumentException If model does not support vision capability + * @since __DEPLOY_VERSION__ + */ + private function buildVisionRequestPayload(string $message, string $image, array $options = []): array + { + $model = $options['model'] ?? $this->defaultModel ?? $this->getOption('model', 'claude-3-haiku-20240307'); + $maxTokens = $options['max_tokens'] ?? 1024; + + // Determine image format and validate + $imageContent = $this->buildImageContent($image); + + $content = [ + [ + 'type' => 'text', + 'text' => $message + ], + $imageContent + ]; + + $messages = [ + [ + 'role' => 'user', + 'content' => $content + ] + ]; + + $payload = [ + 'model' => $model, + 'messages' => $messages, + 'max_tokens' => $maxTokens + ]; + + // Add optional parameters + if (isset($options['temperature'])) { + $payload['temperature'] = (float) $options['temperature']; + } + + if (isset($options['top_k'])) { + $payload['top_k'] = (int) $options['top_k']; + } + + if (isset($options['top_p'])) { + $payload['top_p'] = (float) $options['top_p']; + } + + if (isset($options['stop_sequences'])) { + $payload['stop_sequences'] = $options['stop_sequences']; + } + + if (isset($options['system'])) { + $payload['system'] = $options['system']; + } + + return $payload; + } + + /** + * Send a message to Anthropic and return response. + * + * @param string $message The message to send + * @param array $options Additional options for the request + * + * @return Response The AI response object + * @since __DEPLOY_VERSION__ + */ + public function chat(string $message, array $options = []): Response + { + $payload = $this->buildChatRequestPayload($message, $options); + + $headers = $this->buildHeaders(); + + $httpResponse = $this->makePostRequest( + $this->getMessagesEndpoint(), + json_encode($payload), + $headers + ); + + return $this->parseAnthropicResponse($httpResponse->getBody()); + } + + /** + * Generate chat completion with vision capability and return Response. + * + * @param string $message The chat message about the image. + * @param string $image Image URL or base64 encoded image. + * @param array $options Additional options for the request. + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + public function vision(string $message, string $image, array $options = []): Response + { + $payload = $this->buildVisionRequestPayload($message, $image, $options); + + $headers = $this->buildHeaders(); + + $httpResponse = $this->makePostRequest( + $this->getMessagesEndpoint(), + json_encode($payload), + $headers + ); + + return $this->parseAnthropicResponse($httpResponse->getBody()); + } + + /** + * Parse Anthropic API response into unified Response object. + * + * @param string $responseBody The JSON response body + * + * @return Response Unified response object + * @since __DEPLOY_VERSION__ + */ + private function parseAnthropicResponse(string $responseBody): Response + { + $data = $this->parseJsonResponse($responseBody); + + if (isset($data['error'])) { + throw new ProviderException($this->getName(), $data); + } + + // Get the text content from the first content block + $content = ''; + if (!empty($data['content'][0]['text'])) { + $content = $data['content'][0]['text']; + } + + $statusCode = $this->determineAIStatusCode($data); + + $metadata = [ + 'id' => $data['id'] ?? null, + 'model' => $data['model'], + 'role' => $data['role'], + 'type' => $data['type'], + 'usage' => $data['usage'] ?? [], + 'input_tokens' => $data['usage']['input_tokens'] ?? 0, + 'output_tokens' => $data['usage']['output_tokens'] ?? 0, + 'stop_reason' => $data['stop_reason'], + 'stop_sequence' => $data['stop_sequence'] + ]; + + return new Response( + $content, + $this->getName(), + $metadata, + $statusCode + ); + } + + /** + * Determine status code based on Anthropic's stop_reason. + * + * @param array $data Parsed Anthropic response + * @return int Status code + */ + private function determineAIStatusCode(array $data): int + { + $stopReason = $data['stop_reason'] ?? null; + + switch ($stopReason) { + case 'end_turn': + return 200; + case 'max_tokens': + return 429; + case 'refusal': + return 403; + case null: + return 200; // Streaming: message_start event + default: + return 200; + } + } + + /** + * Build image content block for Anthropic API. + * + * @param string $image Image URL or base64 encoded image + * + * @return array Image content block + * @throws \InvalidArgumentException If image format is invalid + * @since __DEPLOY_VERSION__ + */ + private function buildImageContent(string $image): array + { + // Check if it's a URL + if (filter_var($image, FILTER_VALIDATE_URL)) { + $imageData = $this->fetchImageFromUrl($image); + $mimeType = $this->detectImageMimeType($imageData); + + return [ + 'type' => 'image', + 'source' => [ + 'type' => 'base64', + 'media_type' => $mimeType, + 'data' => base64_encode($imageData) + ] + ]; + } + + // Check if it's already base64 encoded + if (preg_match('/^data:image\/([a-zA-Z0-9+\/]+);base64,(.+)$/', $image, $matches)) { + $mimeType = 'image/' . $matches[1]; + $base64Data = $matches[2]; + + $this->validateImageMimeType($mimeType); + + return [ + 'type' => 'image', + 'source' => [ + 'type' => 'base64', + 'media_type' => $mimeType, + 'data' => $base64Data + ] + ]; + } + + // If it is a file path + if (file_exists($image)) { + $imageData = file_get_contents($image); + $mimeType = $this->detectImageMimeType($imageData); + + return [ + 'type' => 'image', + 'source' => [ + 'type' => 'base64', + 'media_type' => $mimeType, + 'data' => base64_encode($imageData) + ] + ]; + } + + throw InvalidArgumentException::invalidParameter('image', $image, 'anthropic', 'Image must be a valid URL, file path, or base64 encoded data.'); + } + + /** + * Fetch image data from URL. + * + * @param string $url Image URL + * + * @return string Image binary data + * @throws \Exception If image cannot be fetched + * @since __DEPLOY_VERSION__ + */ + private function fetchImageFromUrl(string $url): string + { + $httpResponse = $this->makeGetRequest($url); + + if ($httpResponse->getStatusCode() !== 200) { + throw new \Exception("Failed to fetch image from URL: {$url}"); + } + + return $httpResponse->getBody(); + } + + /** + * Validate image MIME type for Anthropic API. + * + * @param string $mimeType MIME type to validate + * + * @throws \InvalidArgumentException If MIME type is not supported + * @since __DEPLOY_VERSION__ + */ + private function validateImageMimeType(string $mimeType): void + { + $supportedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + + if (!in_array($mimeType, $supportedTypes)) { + throw InvalidArgumentException::invalidParameter('image_type', $mimeType, 'anthropic', 'Supported image types: ' . implode(', ', $supportedTypes), ['supported_types' => $supportedTypes]); + } + } +} diff --git a/src/Provider/OllamaProvider.php b/src/Provider/OllamaProvider.php new file mode 100644 index 0000000..f14a507 --- /dev/null +++ b/src/Provider/OllamaProvider.php @@ -0,0 +1,797 @@ +baseUrl = $this->getOption('base_url', 'http://localhost:11434'); + + // Remove trailing slash if present + if (substr($this->baseUrl, -1) === '/') { + $this->baseUrl = rtrim($this->baseUrl, '/'); + } + } + + /** + * Check if Ollama provider is supported/configured. + * + * @return boolean True if Ollama server is accessible + * @since __DEPLOY_VERSION__ + */ + public static function isSupported(): bool + { + // We'll implement a check to see if Ollama server is running + try { + $response = file_get_contents('http://localhost:11434/api/tags'); + return $response !== false; + } catch (\Exception $e) { + return false; + } + } + + /** + * Ensure server is running + * + * @throws AuthenticationException If the server is not running + * @since __DEPLOY_VERSION__ + */ + private function validateConnection(): void + { + try { + $this->makeGetRequest($this->baseUrl . '/api/tags'); + } catch (\Exception $e) { + throw new AuthenticationException( + $this->getName(), + ['message' => 'Ollama server not running. Please start with: ollama serve'], + 401 + ); + } + } + + /** + * Get the provider name. + * + * @return string The provider name + * @since __DEPLOY_VERSION__ + */ + public function getName(): string + { + return 'Ollama'; + } + + /** + * Get the chat endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getChatEndpoint(): string + { + return $this->baseUrl . '/api/chat'; + } + + /** + * Get the copy model endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getCopyModelEndpoint(): string + { + return $this->baseUrl . '/api/copy'; + } + + /** + * Get the delete model endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getDeleteModelEndpoint(): string + { + return $this->baseUrl . '/api/delete'; + } + + /** + * Get the pull model endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getPullEndpoint(): string + { + return $this->baseUrl . '/api/pull'; + } + + /** + * Get the generate endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getGenerateEndpoint(): string + { + return $this->baseUrl . '/api/generate'; + } + + /** + * Get all available models for this provider. + * + * @return array Array of available model names + * @since __DEPLOY_VERSION__ + */ + public function getAvailableModels(): array + { + $this->validateConnection(); + + $response = $this->makeGetRequest($this->baseUrl . '/api/tags'); + $data = $this->parseJsonResponse($response->getBody()); + + return array_column($data['models'], 'name'); + } + + /** + * List models currently loaded into memory (running) and echo their names. + * + * @return array Array of running model info + * @throws ProviderException If the request fails + * @since __DEPLOY_VERSION__ + */ + public function getRunningModels() + { + $this->validateConnection(); + + $endpoint = $this->baseUrl . '/api/ps'; + $response = $this->makeGetRequest($endpoint); + $data = $this->parseJsonResponse($response->getBody()); + + $models = $data['models'] ?? []; + + if (empty($models)) { + echo "No models are currently loaded into memory.\n"; + } else { + echo "Running models:\n"; + foreach ($models as $model) { + echo "- " . ($model['name'] ?? '[unknown]') . "\n"; + } + } + } + + /** + * Check if a model exists in the available models list, handling name variations + * + * @param string $modelName Model name to check + * @param array $availableModels List of available models + * @return bool True if model exists + */ + private function checkModelExists(string $modelName, array $availableModels): bool + { + // To Do: Improve logic + if (in_array($modelName, $availableModels)) { + return true; + } + + // Check with :latest suffix added + if (!str_ends_with($modelName, ':latest') && in_array($modelName . ':latest', $availableModels)) { + return true; + } + + // Check with :latest suffix removed + if (str_ends_with($modelName, ':latest')) { + $baseModelName = str_replace(':latest', '', $modelName); + if (in_array($baseModelName, $availableModels)) { + return true; + } + } + + return false; + } + + /** + * Copy a model to a new name. + * + * @param string $sourceModel The name of the source model to copy + * @param string $destinationModel The new name for the copied model + * + * @return bool True if copy was successful + * @since __DEPLOY_VERSION__ + */ + public function copyModel(string $sourceModel, string $destinationModel): bool + { + $this->validateConnection(); + + $endpoint = $this->getCopyModelEndpoint(); + $payload = [ + 'source' => $sourceModel, + 'destination' => $destinationModel + ]; + + $jsonData = json_encode($payload); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ProviderException( + $this->getName(), + ['message' => 'Failed to encode copy request: ' . json_last_error_msg()] + ); + } + + $httpResponse = $this->makePostRequest($endpoint, $jsonData); + $status = $httpResponse->getStatusCode(); + + if ($status === 200) { + echo "Model '$sourceModel' copied to '$destinationModel' successfully.\n"; + return true; + } elseif ($status === 404) { + throw InvalidArgumentException::invalidModel( + $sourceModel, + $this->getName(), + ['message' => "Source model '$sourceModel' does not exist."] + ); + } else { + throw new ProviderException( + $this->getName(), + ['message' => "Unexpected status code $status from copy API."] + ); + } + } + + /** + * Delete a model and its data. + * + * @param string $modelName The name of the model to delete + * + * @return bool True if deletion was successful + * @throws ProviderException If the deletion fails or model does not exist + * @since __DEPLOY_VERSION__ + */ + public function deleteModel(string $modelName): bool + { + $this->validateConnection(); + + $endpoint = $this->getDeleteModelEndpoint(); + $payload = [ + 'model' => $modelName + ]; + + $jsonData = json_encode($payload); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ProviderException( + $this->getName(), + ['message' => 'Failed to encode delete request: ' . json_last_error_msg()] + ); + } + + $httpResponse = $this->makeDeleteRequest($endpoint, $jsonData); + $status = $httpResponse->getStatusCode(); + + if ($status === 200) { + echo "Model '$modelName' deleted successfully.\n"; + return true; + } elseif ($status === 404) { + throw InvalidArgumentException::invalidModel( + $modelName, + $this->getName(), + ['message' => "Model '$modelName' does not exist."] + ); + } else { + throw new ProviderException( + $this->getName(), + ['message' => "Unexpected status code $status from delete API."] + ); + } + } + + /** + * Pull a model from Ollama library + * + * @param string $modelName Name of the model to pull + * @param bool $stream Whether to stream the response (for progress updates) + * @param bool $insecure Allow insecure connections to the library + * + * @return bool True if model was pulled successfully + * @throws InvalidArgumentException If model doesn't exist in Ollama library + * @throws ProviderException If pull fails for other reasons + * @since __DEPLOY_VERSION__ + */ + public function pullModel(string $modelName, bool $stream = true, bool $insecure = false) + { + $this->validateConnection(); + + $availableModels = $this->getAvailableModels(); + if ($this->checkModelExists($modelName, $availableModels)) { + echo "Model '$modelName' is already available locally.\n"; + return true; + } + + $endpoint = $this->getPullEndpoint(); + + $requestData = [ + 'model' => $modelName + ]; + if ($insecure) { + $requestData['insecure'] = true; + } + if (!$stream) { + $requestData['stream'] = false; + } + + try { + $jsonData = json_encode($requestData); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ProviderException( + $this->getName(), + ['message' => 'Failed to encode request data: ' . json_last_error_msg()] + ); + } + + $response = $this->makePostRequest($endpoint, $jsonData); + + if (!$stream) { + $data = $this->parseJsonResponse($response->getBody()); + return isset($data['status']) && $data['status'] === 'success'; + } + + $body = $response->getBody(); + $fullContent = (string) $body; + + $lines = explode("\n", $fullContent); + $hasError = false; + $errorMessage = ''; + + foreach ($lines as $line) { + $line = trim($line); + if (empty($line)) continue; + + $data = json_decode($line, true); + if (json_last_error() !== JSON_ERROR_NONE) { + continue; + } + + // Check for error in response + if (isset($data['error'])) { + $errorMessage = $data['error']; + + // Check if this is a "model not found" type error + if (strpos(strtolower($errorMessage), 'file does not exist') !== false || + strpos(strtolower($errorMessage), 'model') !== false && strpos(strtolower($errorMessage), 'not found') !== false || + strpos(strtolower($errorMessage), 'manifest') !== false && strpos(strtolower($errorMessage), 'not found') !== false) { + + throw InvalidArgumentException::invalidModel( + $modelName, + $this->getName(), + [] + ); + } + + // For other errors, throw ProviderException + throw new ProviderException( + $this->getName(), + ['message' => $errorMessage] + ); + } + } + + // Check if success status exists in the response + if (strpos($fullContent, '"status":"success"') !== false) { + foreach ($lines as $line) { + $line = trim($line); + if (empty($line)) continue; + + $data = json_decode($line, true); + if (json_last_error() !== JSON_ERROR_NONE) continue; + + $status = $data['status'] ?? ''; + + if (strpos($status, 'pulling') === 0 && isset($data['digest'])) { + $digest = $data['digest']; + $total = $data['total'] ?? 0; + $completed = $data['completed'] ?? 0; + if ($total > 0) { + $percentage = round(($completed / $total) * 100, 1); + echo "\rPulling $digest: $percentage%"; + } + } elseif ($status === 'verifying sha256 digest') { + echo "\nVerifying sha256 digest...\n"; + } elseif ($status === 'writing manifest') { + echo "Writing manifest...\n"; + } elseif ($status === 'success') { + echo "\nModel $modelName successfully pulled!\n"; + } + } + + return true; + } + } catch (InvalidArgumentException $e) { + throw $e; + } catch (ProviderException $e) { + throw $e; + } catch (\Exception $e) { + throw new ProviderException( + $this->getName(), + ['message' => 'Failed to pull model: ' . $e->getMessage()] + ); + } + } + + /** + * Ensure model is available, pulling it if necessary + * + * @param string $modelName Name of the model to ensure + * @return bool True if model is available + * @throws ProviderException If model cannot be made available + */ + private function ensureModelAvailable(string $modelName): bool + { + $availableModels = $this->getAvailableModels(); + + $availableModels = $this->getAvailableModels(); + if (!$this->checkModelExists($modelName, $availableModels)) { + echo "Model $modelName not found locally. Attempting to pull...\n"; + $this->pullModel($modelName, true, false); + } elseif ($this->checkModelExists($modelName, $availableModels)) { + echo "Model $modelName is already available locally.\n"; + } + return true; + } + + /** + * Build the request payload for the chat endpoint + * + * @param string $message The user message to send + * @param array $options Additional options + * + * @return array The request payload + * @throws \InvalidArgumentException If model does not support chat capability + * @since __DEPLOY_VERSION__ + */ + public function buildChatRequestPayload(string $message, array $options = []) + { + $this->validateConnection(); + + $model = $options['model'] ?? $this->defaultModel ?? $this->getOption('model', 'tinyllama'); + $this->ensureModelAvailable($model); + echo "Using model: $model\n"; + + if (isset($options['messages'])) { + $messages = $options['messages']; + if (!is_array($messages) || empty($messages)) { + throw InvalidArgumentException::invalidMessages('ollama', 'Messages must be a non-empty array.'); + } + } else { + $messages = [ + [ + 'role' => 'user', + 'content' => $message + ] + ]; + } + + $payload = [ + 'model' => $model, + 'messages' => $messages, + 'stream' => false + ]; + + if (isset($options['stream'])) { + $payload['stream'] = (bool) $options['stream']; + } + + return $payload; + } + + /** + * Send a chat message to the Ollama server + * + * @param string $message The user message to send + * @param array $options Additional options + * + * @return Response The AI response + * @throws ProviderException If the request fails + * @since __DEPLOY_VERSION__ + */ + public function chat(string $message, array $options = []): Response + { + $payload = $this->buildChatRequestPayload($message, $options); + + $endpoint = $this->getChatEndpoint(); + + $jsonData = json_encode($payload); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ProviderException( + $this->getName(), + ['message' => 'Failed to encode request data: ' . json_last_error_msg()] + ); + } + + $httpResponse = $this->makePostRequest( + $endpoint, + $jsonData, + ); + + // Check if this is a streaming response + if (isset($payload['stream']) && $payload['stream'] === true) { + return $this->parseOllamaStreamingResponse($httpResponse->getBody(), true); + } + + return $this->parseOllamaResponse($httpResponse->getBody(), true); + } + + /** + * Build the request payload for the generate endpoint + * + * @param string $prompt The prompt to generate a response for + * @param array $options Additional options + * @return array The formatted payload + * @throws InvalidArgumentException If options are invalid + * @since __DEPLOY_VERSION__ + */ + public function buildGenerateRequestPayload(string $prompt, array $options = []): array + { + $this->validateConnection(); + + $model = $options['model'] ?? $this->defaultModel ?? $this->getOption('model', 'tinyllama'); + $this->ensureModelAvailable($model); + echo "Using model: $model\n"; + + $payload = [ + 'model' => $model, + 'prompt' => $prompt, + 'stream' => false + ]; + + // Handle optional parameters + if (isset($options['stream'])) { + $payload['stream'] = (bool) $options['stream']; + } + + if (isset($options['suffix'])) { + $payload['suffix'] = $options['suffix']; + } + + if (isset($options['images']) && is_array($options['images'])) { + $payload['images'] = $options['images']; + } + + if (isset($options['format'])) { + $payload['format'] = $options['format']; + } + + if (isset($options['options']) && is_array($options['options'])) { + $payload['options'] = $options['options']; + } + + if (isset($options['system'])) { + $payload['system'] = $options['system']; + } + + if (isset($options['template'])) { + $payload['template'] = $options['template']; + } + + if (isset($options['context'])) { + $payload['context'] = $options['context']; + } + + if (isset($options['raw'])) { + $payload['raw'] = (bool) $options['raw']; + } + + if (isset($options['keep_alive'])) { + $payload['keep_alive'] = $options['keep_alive']; + } + + return $payload; + } + + /** + * Generate a completion for a given prompt + * + * @param string $prompt The prompt to generate a response for + * @param array $options Additional options + * @param callable $callback Optional callback function for streaming responses + * @return Response The AI response + * @throws ProviderException If the request fails + * @since __DEPLOY_VERSION__ + */ + public function generate(string $prompt, array $options = []): Response + { + $payload = $this->buildGenerateRequestPayload($prompt, $options); + + $endpoint = $this->getGenerateEndpoint(); + + $jsonData = json_encode($payload); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ProviderException( + $this->getName(), + ['message' => 'Failed to encode request data: ' . json_last_error_msg()] + ); + } + + $httpResponse = $this->makePostRequest( + $endpoint, + $jsonData, + ); + + // Check if this is a streaming response + if (isset($payload['stream']) && $payload['stream'] === true) { + return $this->parseOllamaStreamingResponse($httpResponse->getBody(), false); + } + + return $this->parseOllamaResponse($httpResponse->getBody(), false); + } + + /** + * Parse a streaming response from Ollama API + * + * @param string $responseBody The raw response body + * @param bool $isChat Whether this is a chat response (true) or generate response (false) + * + * @return Response The parsed response + * @since __DEPLOY_VERSION__ + */ + private function parseOllamaStreamingResponse(string $responseBody, bool $isChat = true): Response + { + $lines = explode("\n", $responseBody); + $fullContent = ''; + $lastMetadata = []; + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') continue; + + $data = json_decode($line, true); + if (json_last_error() !== JSON_ERROR_NONE) continue; + + // Accumulate content from each chunk - handle both chat and generate formats + if ($isChat && isset($data['message']['content'])) { + $fullContent .= $data['message']['content']; + } elseif (!$isChat && isset($data['response'])) { + $fullContent .= $data['response']; + } + + // Keep track of the last metadata + if ($data['done'] === true) { + $lastMetadata = [ + 'model' => $data['model'], + 'created_at' => $data['created_at'], + 'done_reason' => $data['done_reason'] ?? null, + 'done' => $data['done'], + 'total_duration' => $data['total_duration'], + 'load_duration' => $data['load_duration'], + 'prompt_eval_count' => $data['prompt_eval_count'], + 'prompt_eval_duration' => $data['prompt_eval_duration'], + 'eval_count' => $data['eval_count'], + 'eval_duration' => $data['eval_duration'] + ]; + + // Add chat-specific metadata + if ($isChat && isset($data['message']['role'])) { + $lastMetadata['role'] = $data['message']['role']; + } + + // Add generate-specific metadata + if (!$isChat && isset($data['context'])) { + $lastMetadata['context'] = $data['context']; + } + } + } + + $statusCode = isset($lastMetadata['done_reason']) ? $this->determineAIStatusCode($lastMetadata) : 200; + + return new Response( + $fullContent, + $this->getName(), + $lastMetadata, + $statusCode + ); + } + + /** + * Parse a non-streaming response from Ollama API (works for both chat and generate endpoints) + * + * @param string $responseBody The raw response body + * @param bool $isChat Whether this is a chat response (true) or generate response (false) + * + * @return Response The parsed response + * @throws ProviderException If the response contains an error + * @since __DEPLOY_VERSION__ + */ + private function parseOllamaResponse(string $responseBody, bool $isChat = true): Response + { + $data = $this->parseJsonResponse($responseBody); + + if (isset($data['error'])) { + throw new ProviderException($this->getName(), $data); + } + + // Extract content based on whether it's a chat or generate response + $content = $isChat ? ($data['message']['content'] ?? '') : ($data['response'] ?? ''); + + $statusCode = isset($data['done_reason']) ? $this->determineAIStatusCode($data) : 200; + + // Build common metadata + $metadata = [ + 'model' => $data['model'], + 'created_at' => $data['created_at'] ?? $data['created'] ?? time(), + 'done_reason' => $data['done_reason'] ?? null, + 'done' => $data['done'] ?? true, + 'total_duration' => $data['total_duration'] ?? 0, + 'load_duration' => $data['load_duration'] ?? 0, + 'prompt_eval_count' => $data['prompt_eval_count'] ?? 0, + 'prompt_eval_duration' => $data['prompt_eval_duration'] ?? 0, + 'eval_count' => $data['eval_count'] ?? 0, + 'eval_duration' => $data['eval_duration'] ?? 0 + ]; + + // Add chat-specific metadata + if ($isChat && isset($data['message']['role'])) { + $metadata['role'] = $data['message']['role']; + } + + // Add generate-specific metadata + if (!$isChat && isset($data['context'])) { + $metadata['context'] = $data['context']; + } + + return new Response( + $content, + $this->getName(), + $metadata, + $statusCode + ); + } + + private function determineAIStatusCode(array $data): int + { + $finishReason = $data['done_reason']; + + switch ($finishReason) { + case 'stop': + return 200; + + case 'length': + return 206; + + case 'content_filter': + return 422; + + case 'tool_calls': + case 'function_call': + return 202; + + default: + return 200; + } + } +} diff --git a/src/Provider/OpenAIProvider.php b/src/Provider/OpenAIProvider.php new file mode 100644 index 0000000..88b1289 --- /dev/null +++ b/src/Provider/OpenAIProvider.php @@ -0,0 +1,2151 @@ +baseUrl = $this->getOption('base_url', 'https://api.openai.com/v1'); + + // Remove trailing slash if present + if (substr($this->baseUrl, -1) === '/') { + $this->baseUrl = rtrim($this->baseUrl, '/'); + } + } + + /** + * Check if OpenAI provider is supported/configured. + * + * @return boolean True if API key is available + * @since __DEPLOY_VERSION__ + */ + public static function isSupported(): bool + { + return !empty($_ENV['OPENAI_API_KEY']) || + !empty(getenv('OPENAI_API_KEY')); + } + + /** + * Get the provider name. + * + * @return string The provider name + * @since __DEPLOY_VERSION__ + */ + public function getName(): string + { + return 'OpenAI'; + } + + /** + * Get the chat completions endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getChatEndpoint(): string + { + return $this->baseUrl . '/chat/completions'; + } + + /** + * Get the image generation endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getImageEndpoint(): string + { + return $this->baseUrl . '/images/generations'; + } + + /** + * Get the image edit endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getImageEditEndpoint(): string + { + return $this->baseUrl . '/images/edits'; + } + + /** + * Get the image variations endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getImageVariationsEndpoint(): string + { + return $this->baseUrl . '/images/variations'; + } + + /** + * Get the audio speech endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getAudioSpeechEndpoint(): string + { + return $this->baseUrl . '/audio/speech'; + } + + /** + * Get the audio transcription endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getAudioTranscriptionEndpoint(): string + { + return $this->baseUrl . '/audio/transcriptions'; + } + + /** + * Get the audio translation endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getAudioTranslationEndpoint(): string + { + return $this->baseUrl . '/audio/translations'; + } + + /** + * Get the embeddings endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getEmbeddingsEndpoint(): string + { + return $this->baseUrl . '/embeddings'; + } + + /** + * Get the content moderation endpoint URL. + * + * @return string The endpoint URL + * @since __DEPLOY_VERSION__ + */ + private function getModerationEndpoint(): string + { + return $this->baseUrl . '/moderations'; + } + + /** + * Get all available models for this provider. + * + * @return array Array of available model names + * @since __DEPLOY_VERSION__ + */ + public function getAvailableModels(): array + { + $headers = $this->buildHeaders(); + $response = $this->makeGetRequest('https://api.openai.com/v1/models', $headers); + $data = $this->parseJsonResponse($response->getBody()); + + return array_column($data['data'], 'id'); + } + + /** + * Get models that support chat capability. + * + * @return array Array of chat-capable model names + * @since __DEPLOY_VERSION__ + */ + public function getChatModels(): array + { + $available = $this->getAvailableModels(); + return $this->getModelsByCapability($available, self::CHAT_MODELS); + } + + /** + * Get models that support vision capability. + * + * @return array Array of vision-capable model names + * @since __DEPLOY_VERSION__ + */ + public function getVisionModels(): array + { + $available = $this->getAvailableModels(); + return $this->getModelsByCapability($available, self::VISION_MODELS); + } + + /** + * Get models that support image generation capability. + * + * @return array Array of image capable model names + * @since __DEPLOY_VERSION__ + */ + public function getImageModels(): array + { + $available = $this->getAvailableModels(); + return $this->getModelsByCapability($available, self::IMAGE_MODELS); + } + + /** + * Get available TTS models for this provider. + * + * @return array Array of available TTS model names + * @since __DEPLOY_VERSION__ + */ + public function getTTSModels(): array + { + $available = $this->getAvailableModels(); + return $this->getModelsByCapability($available, self::TTS_MODELS); + } + + /** + * Get available transcription models for this provider. + * + * @return array Array of available transcription model names + * @since __DEPLOY_VERSION__ + */ + public function getTranscriptionModels(): array + { + return self::TRANSCRIPTION_MODELS; + } + + /** + * Get available embedding models for this provider. + * + * @return array Array of available embedding model names + * @since __DEPLOY_VERSION__ + */ + public function getEmbeddingModels(): array + { + return self::EMBEDDING_MODELS; + } + + /** + * Get available voices for speech generation. + * + * @return array Array of available voice names + * @since __DEPLOY_VERSION__ + */ + public function getAvailableVoices(): array + { + return self::VOICES; + } + + /** + * Get supported audio output formats. + * + * @return array Array of supported format names + * @since __DEPLOY_VERSION__ + */ + public function getSupportedAudioFormats(): array + { + return self::AUDIO_FORMATS; + } + + /** + * Get supported audio input formats for transcription. + * + * @return array Array of supported input format names + * @since __DEPLOY_VERSION__ + */ + public function getSupportedTranscriptionFormats(): array + { + return self::TRANSCRIPTION_INPUT_FORMATS; + } + + /** + * Check if a specific model is supported by this provider. + * + * @param string $model The model name to check + * + * @return bool True if model is available, false otherwise + * @since __DEPLOY_VERSION__ + */ + public function isModelSupported(string $model): bool + { + $available = $this->getAvailableModels(); + return $this->isModelAvailable($model, $available); + } + + /** + * Check if a model supports a specific capability. + * + * @param string $model The model name to check + * @param string $capability The capability to check (chat, image, vision) + * + * @return bool True if model supports the capability, false otherwise + * @since __DEPLOY_VERSION__ + */ + public function isModelCapable(string $model, string $capability): bool + { + $capabilityMap = [ + 'chat' => self::CHAT_MODELS, + 'vision' => self::VISION_MODELS, + 'image' => self::IMAGE_MODELS, + 'text-to-speech' => self::TTS_MODELS, + 'transcription' => self::TRANSCRIPTION_MODELS, + 'embedding' => self::EMBEDDING_MODELS, + ]; + return $this->checkModelCapability($model, $capability, $capabilityMap); + } + + /** + * Send a message to OpenAI and return response. + * + * @param string $message The message to send + * @param array $options Additional options for the request + * + * @return Response The AI response object + * @since __DEPLOY_VERSION__ + */ + public function chat(string $message, array $options = []): Response + { + // Apply moderation to the chat message + $isBlocked = $this->moderateInput($message, []); + + if ($isBlocked) { + throw new \Exception('Content flagged by moderation system and blocked.'); + } + + $payload = $this->buildChatRequestPayload($message, $options); + + // To Do: Remove repetition + $endpoint = $this->getChatEndpoint(); + $headers = $this->buildHeaders(); + + $httpResponse = $this->makePostRequest( + $endpoint, + json_encode($payload), + $headers + ); + + return $this->parseOpenAIResponse($httpResponse->getBody()); + } + + /** + * Generate chat completion with vision capability and return Response. + * + * @param string $message The chat message about the image. + * @param string $image Image URL or base64 encoded image. + * @param array $options Additional options for the request. + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + public function chatWithVision(string $message, string $image, array $options = []): Response + { + // Apply moderation to the input (text + image) + $multiModalInput = [ + ['type' => 'text', 'text' => $message], + ['type' => 'image_url', 'image_url' => ['url' => $image]] + ]; + $isBlocked = $this->moderateInput($multiModalInput, []); + + if ($isBlocked) { + throw new \Exception('Content flagged by moderation system and blocked.'); + } + + $payload = $this->buildVisionRequestPayload($message, $image, $options, 'vision'); + + $endpoint = $this->getChatEndpoint(); + $headers = $this->buildHeaders(); + + $httpResponse = $this->makePostRequest( + $endpoint, + json_encode($payload), + $headers + ); + + return $this->parseOpenAIResponse($httpResponse->getBody()); + } + + /** + * Generate a new image from the given prompt. + * + * @param string $prompt Descriptive text prompt for the desired image. + * @param array $options Additional options for the request. + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + public function generateImage(string $prompt, array $options = []): Response + { + // Apply moderation to the image generation prompt + $isBlocked = $this->moderateInput($prompt, []); + + if ($isBlocked) { + throw new \Exception('Content flagged by moderation system and blocked.'); + } + + $payload = $this->buildImageRequestPayload($prompt, $options); + + $headers = $this->buildHeaders(); + + $httpResponse = $this->makePostRequest( + $this->getImageEndpoint(), + json_encode($payload), + $headers + ); + + return $this->parseImageResponse($httpResponse->getBody()); + } + + /** + * Create variations of an image using OpenAI Image API. + * + * @param string $imagePath Path to the image file to create variations of. + * @param array $options Additional options for the request. + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + public function createImageVariation(string $imagePath, array $options = []): Response + { + $payload = $this->buildImageVariationPayload($imagePath, $options); + + $headers = $this->buildMultipartHeaders(); + + $httpResponse = $this->makeMultipartPostRequest( + $this->getImageVariationsEndpoint(), + $payload, + $headers + ); + + return $this->parseImageResponse($httpResponse->getBody()); + } + + /** + * Edit an image using OpenAI Image API. + * + * @param mixed $images Single image path or array of image paths + * @param string $prompt Description of desired edits + * @param array $options Additional options for the request + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + public function editImage($images, string $prompt, array $options = []): Response + { + // Apply moderation to the image editing prompt + $isBlocked = $this->moderateInput($prompt, []); + + if ($isBlocked) { + throw new \Exception('Content flagged by moderation system and blocked.'); + } + + $payload = $this->buildImageEditPayload($images, $prompt, $options); + + $headers = $this->buildMultipartHeaders(); + + $httpResponse = $this->makeMultipartPostRequest( + $this->getImageEditEndpoint(), + $payload, + $headers + ); + + return $this->parseImageResponse($httpResponse->getBody()); + } + + /** + * Generate speech audio from text input. + * + * @param string $text The text to convert to speech + * @param string $model The model to use for speech synthesis + * @param string $voice The voice to use for speech synthesis + * @param array $options Additional options for speech generation + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + public function speech(string $text, array $options = []): Response + { + // Apply moderation to the text input for speech generation + $isBlocked = $this->moderateInput($text, []); + + if ($isBlocked) { + throw new \Exception('Content flagged by moderation system and blocked.'); + } + + $payload = $this->buildSpeechPayload($text, $options); + + $endpoint = $this->getAudioSpeechEndpoint(); + $headers = $this->buildHeaders(); + $httpResponse = $this->makePostRequest($endpoint, json_encode($payload), $headers); + + return $this->parseAudioResponse($httpResponse->getBody(), $payload); + } + + /** + * Transcribe audio into text. + * + * @param string $audioFile Path to audio file + * @param string $model The transcription model to use + * @param array $options Additional options for transcription + * + * @return Response The AI response containing transcribed text + * @since __DEPLOY_VERSION__ + */ + public function transcribe(string $audioFile, array $options = []): Response + { + $payload = $this->buildTranscriptionPayload($audioFile, $options); + + $headers = $this->buildMultipartHeaders(); + + $httpResponse = $this->makeMultipartPostRequest( + $this->getAudioTranscriptionEndpoint(), + $payload, + $headers + ); + + return $this->parseAudioTextResponse($httpResponse->getBody(), $payload, 'Transcription'); + } + + /** + * Translate audio to English text. + * + * @param string $audioFile Path to audio file + * @param string $model Model to use for translation + * @param array $options Additional options + * + * @return Response Translation response + * @since __DEPLOY_VERSION__ + */ + public function translate(string $audioFile, array $options = []): Response + { + $payload = $this->buildTranslationPayload($audioFile, $options); + + $headers = $this->buildMultipartHeaders(); + + $httpResponse = $this->makeMultipartPostRequest( + $this->getAudioTranslationEndpoint(), + $payload, + $headers + ); + + return $this->parseAudioTextResponse($httpResponse->getBody(), $payload, 'Translation'); + } + + /** + * Create embeddings for the given input text(s). + * + * @param string|array $input Text string or array of texts to embed + * @param string $model The embedding model to use + * @param array $options Additional options + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + public function createEmbeddings($input, string $model, array $options = []): Response + { + // Apply moderation to the text input for embeddings + $isBlocked = $this->moderateInput($input, []); + + if ($isBlocked) { + throw new \Exception('Content flagged by moderation system and blocked.'); + } + + $payload = $this->buildEmbeddingPayload($input, $model, $options); + + $headers = $this->buildHeaders(); + + $httpResponse = $this->makePostRequest( + $this->getEmbeddingsEndpoint(), + json_encode($payload), + $headers + ); + + return $this->parseEmbeddingResponse($httpResponse->getBody(), $payload); + } + + /** + * Moderate content using OpenAI's moderation endpoint. + * + * @param string|array $input Text/Image input(s) to moderate + * @param array $options Additional options for moderation + * + * @return array + * @throws \Exception + * @since __DEPLOY_VERSION__ + */ + public function moderate($input, array $options = []): array + { + $model = $options['model'] ?? 'omni-moderation-latest'; + + if (!in_array($model, self::MODERATION_MODELS)) { + throw InvalidArgumentException::invalidModel($model, 'openai', self::MODERATION_MODELS, 'moderation'); + } + + $payload = [ + 'input' => $input, + 'model' => $model + ]; + + $headers = $this->buildHeaders(); + + $httpResponse = $this->makePostRequest( + $this->getModerationEndpoint(), + json_encode($payload), + $headers + ); + + $data = $this->parseJsonResponse($httpResponse->getBody()); + + return $data; + } + + /** + * Check if content is flagged by OpenAI moderation. + * + * @param array $moderationResult Result from moderate() method + * + * @return bool + * @throws \InvalidArgumentException + * @since __DEPLOY_VERSION__ + */ + public function isContentFlagged(array $moderationResult): bool + { + if (!isset($moderationResult['results']) || empty($moderationResult['results'])) { + throw InvalidArgumentException::invalidParameter('moderation[results]', $moderationResult, 'openai', 'Moderation result must contain valid results array.'); + } + + return $moderationResult['results'][0]['flagged'] ?? false; + } + + /** + * Build payload for chat request. + * + * @param string $message The user message to send + * @param array $options Additional options + * + * @return array The request payload + * @throws \InvalidArgumentException If model does not support chat capability + * @since __DEPLOY_VERSION__ + */ + private function buildChatRequestPayload(string $message, array $options = []): array + { + $model = $options['model'] ?? $this->defaultModel ?? $this->getOption('model', 'gpt-4o-mini'); + + if (isset($options['messages'])) { + $messages = $options['messages']; + if (!is_array($messages) || empty($messages)) { + throw InvalidArgumentException::invalidMessages('openai', 'Messages must be a non-empty array.'); + } + $this->validateMessages($messages); + } else { + $messages = [ + [ + 'role' => 'user', + 'content' => $message + ] + ]; + } + + $payload = [ + 'model' => $model, + 'messages' => $messages + ]; + + // Handle modalities parameter + if (isset($options['modalities'])) { + if (!is_array($options['modalities'])) { + throw InvalidArgumentException::invalidParameter('modalities', $options['modalities'], 'openai', 'Modalities must be an array.'); + } + + $validModalities = ['text', 'audio']; + foreach ($options['modalities'] as $modality) { + if (!in_array($modality, $validModalities)) { + throw InvalidArgumentException::invalidParameter('modality', $modality, 'openai', 'Valid modalities: ' . implode(', ', $validModalities), ['valid_modalities' => $validModalities]); + } + } + + // Audio modality requires gpt-4o-audio-preview model + if (in_array('audio', $options['modalities']) && $model !== 'gpt-4o-audio-preview') { + throw InvalidArgumentException::invalidModel($model, 'openai', ['gpt-4o-audio-preview'], 'audio'); + } + + $payload['modalities'] = $options['modalities']; + } + + // Handle audio output parameters + if (isset($options['audio'])) { + // Audio output requires audio modality + if (!is_array($options['audio']) || !isset($payload['modalities']) || !in_array('audio', $payload['modalities'])) { + throw InvalidArgumentException::invalidParameter('audio', $options['audio'], 'openai', 'Audio output parameter must be an array and requires modalities to include "audio".', ['required_modalities' => ['audio']]); + } + + $audioParams = []; + + // Validate and set audio format + if (!isset($options['audio']['format'])) { + throw InvalidArgumentException::missingParameter('audio.format', 'openai', 'chat'); + } + + $validAudioFormats = ['wav', 'mp3', 'flac', 'opus', 'pcm16']; + if (!in_array($options['audio']['format'], $validAudioFormats)) { + throw InvalidArgumentException::invalidParameter('audio.format', $options['audio']['format'], 'openai', 'Audio format must be one of: ' . implode(', ', $validAudioFormats), ['valid_formats' => $validAudioFormats]); + } + $audioParams['format'] = $options['audio']['format']; + + // Validate and set voice + if (!isset($options['audio']['voice'])) { + throw InvalidArgumentException::missingParameter('audio.voice', 'openai', 'chat'); + } + + $validVoices = ['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer']; + if (!in_array($options['audio']['voice'], $validVoices)) { + throw InvalidArgumentException::invalidVoice($options['audio']['voice'], $validVoices, 'openai'); + } + $audioParams['voice'] = $options['audio']['voice']; + + $payload['audio'] = $audioParams; + } + + if (isset($options['n'])) { + $n = (int) $options['n']; + if ($n < 1 || $n > 128) { + throw InvalidArgumentException::invalidParameter('n', $options['n'], 'openai', 'Parameter "n" must be between 1 and 128.', ['min_value' => 1, 'max_value' => 128]); + } + $payload['n'] = $n; + } + + if (isset($options['stream'])) { + $payload['stream'] = (bool) $options['stream']; + } + + if (isset($options['max_tokens'])) { + $payload['max_tokens'] = (int) $options['max_tokens']; + } + + if (isset($options['temperature'])) { + $payload['temperature'] = (float) $options['temperature']; + } + + return $payload; + } + + /** + * Build payload for vision request. + * + * @param string $message The chat message about the image + * @param string $image Image URL or base64 encoded image + * @param array $options Additional options + * + * @return array The request payload + * @throws \InvalidArgumentException If model does not support vision capability + * @since __DEPLOY_VERSION__ + */ + private function buildVisionRequestPayload(string $message, string $image, array $options = [], string $capability): array + { + $model = $options['model'] ?? $this->defaultModel ?? $this->getOption('model', 'gpt-4o-mini'); + + if (!$this->isModelCapable($model, $capability)) { + throw InvalidArgumentException::invalidModel($model, 'openai', self::VISION_MODELS, $capability); + } + + $content = [ + [ + 'type' => 'text', + 'text' => $message + ], + [ + 'type' => 'image_url', + 'image_url' => [ + 'url' => $image + ] + ] + ]; + + $payload = [ + 'model' => $model, + 'messages' => [ + [ + 'role' => 'user', + 'content' => $content + ] + ] + ]; + + // To Do: Add optional parameters if provided + if (isset($options['max_tokens'])) { + $payload['max_tokens'] = (int) $options['max_tokens']; + } + + if (isset($options['temperature'])) { + $payload['temperature'] = (float) $options['temperature']; + } + + if (isset($options['n'])) { + $payload['n'] = (int) $options['n']; + } + + return $payload; + } + + /** + * Build payload for image generation request. + * + * @param string $prompt The image generation prompt. + * @param array $options Additional options for the request. + * @param string $capability Required capability. + * + * @return array The request payload. + * @since __DEPLOY_VERSION__ + */ + private function buildImageRequestPayload(string $prompt, array $options): array + { + $model = $options['model'] ?? $this->defaultModel ?? $this->getOption('model', 'dall-e-2'); + + if (!in_array($model, ['dall-e-2', 'gpt-image-1', 'dall-e-3'])) { + throw InvalidArgumentException::invalidModel($model, 'openai', self::IMAGE_MODELS, 'image generation'); + } + + $this->validateImagePrompt($prompt, $model); + + $payload = [ + 'model' => $model, + 'prompt' => $prompt + ]; + + // Add optional parameters based on model support + if (isset($options['n'])) { + $n = (int) $options['n']; + if ($model === 'dall-e-3' && $n !== 1) { + throw InvalidArgumentException::invalidParameter('n', $options['n'], 'openai', 'For dall-e-3, only n=1 is supported.', ['model' => 'dall-e-3', 'allowed_value' => 1]); + } + if ($n < 1 || $n > 10) { + throw InvalidArgumentException::invalidParameter('n', $options['n'], 'openai', 'Parameter "n" must be between 1 and 10.', ['min_value' => 1, 'max_value' => 10]); + } + $payload['n'] = $n; + } + + if (isset($options['size'])) { + $this->validateImageSize($options['size'], $model, 'generation'); + $payload['size'] = $options['size']; + } + + if (isset($options['quality'])) { + $this->validateImageQuality($options['quality'], $model); + $payload['quality'] = $options['quality']; + } + + if (isset($options['style'])) { + if ($model !== 'dall-e-3') { + throw InvalidArgumentException::invalidParameter('style', $options['style'], 'openai', 'Style parameter is only supported for dall-e-3.', ['model' => $model, 'supported_models' => ['dall-e-3']]); + } + if (!in_array($options['style'], ['vivid', 'natural'])) { + throw InvalidArgumentException::invalidParameter('style', $options['style'], 'openai', 'Style must be either "vivid" or "natural".', ['valid_values' => ['vivid', 'natural']]); + } + $payload['style'] = $options['style']; + } + + if (isset($options['response_format'])) { + if ($model === 'gpt-image-1') { + throw InvalidArgumentException::invalidParameter('response_format', $options['response_format'], 'openai', 'response_format is not supported for gpt-image-1 (always returns base64).', ['model' => 'gpt-image-1', 'fixed_format' => 'base64']); + } elseif (!in_array($options['response_format'], ['url', 'b64_json'])) { + throw InvalidArgumentException::invalidParameter('response_format', $options['response_format'], 'openai', 'Response format must be either "url" or "b64_json".', ['valid_values' => ['url', 'b64_json']]); + } else { + $payload['response_format'] = $options['response_format']; + } + } + + if (!isset($options['response_format']) && $model !== 'gpt-image-1') { + $payload['response_format'] = 'b64_json'; + } + + // gpt-image-1 specific parameters + if ($model === 'gpt-image-1') { + if (isset($options['background']) && !in_array($options['background'], ['transparent', 'opaque', 'auto'])) { + throw InvalidArgumentException::invalidParameter('background', $options['background'], 'openai', 'Background must be one of: transparent, opaque, auto.', ['valid_values' => ['transparent', 'opaque', 'auto']]); + } + if (isset($options['background'])) { + $payload['background'] = $options['background']; + } + + if (isset($options['output_format'])) { + if (!in_array($options['output_format'], ['png', 'jpeg', 'webp'])) { + throw InvalidArgumentException::invalidParameter('output_format', $options['output_format'], 'openai', 'Output format must be one of: png, jpeg, webp.', ['valid_values' => ['png', 'jpeg', 'webp']]); + } + $payload['output_format'] = $options['output_format']; + } + + if (isset($options['output_compression'])) { + $compression = (int) $options['output_compression']; + if ($compression < 0 || $compression > 100) { + throw InvalidArgumentException::invalidParameter('output_compression', $options['output_compression'], 'openai', 'Output compression must be between 0 and 100.', ['valid_range' => [0, 100]]); + } + $payload['output_compression'] = $compression; + } + + if (isset($options['moderation']) && !in_array($options['moderation'], ['low', 'auto'])) { + throw InvalidArgumentException::invalidParameter('moderation', $options['moderation'], 'openai', 'Moderation must be either "low" or "auto".', ['valid_values' => ['low', 'auto']]); + } + if (isset($options['moderation'])) { + $payload['moderation'] = $options['moderation']; + } + } + + if (isset($options['user'])) { + $payload['user'] = $options['user']; + } + + return $payload; + } + + /** + * Build payload for image variation request. + * + * @param string $imagePath Path to the image file. + * @param array $options Additional options for the request. + * + * @return array The form data for multipart request. + * @since __DEPLOY_VERSION__ + */ + private function buildImageVariationPayload(string $imagePath, array $options): array + { + $model = $options['model'] ?? $this->defaultModel ?? 'dall-e-2'; + + // Only dall-e-2 supports variations + if ($model !== 'dall-e-2') { + throw InvalidArgumentException::invalidModel($model, 'openai', ['dall-e-2'], 'image variation'); + } + + $this->validateImageFile($imagePath, $model, 'variation'); + + $payload = [ + 'model' => $model, + 'image' => file_get_contents($imagePath) + ]; + + if (isset($options['n'])) { + $n = (int) $options['n']; + if ($n < 1 || $n > 10) { + throw InvalidArgumentException::invalidParameter('n', $options['n'], 'openai', 'Parameter "n" must be between 1 and 10.', ['valid_range' => [1, 10]]); + } + $payload['n'] = $n; + } + + if (isset($options['size'])) { + $validSizes = ['256x256', '512x512', '1024x1024']; + if (!in_array($options['size'], $validSizes)) { + throw InvalidArgumentException::invalidParameter('size', $options['size'], 'openai', 'Size must be one of: ' . implode(', ', $validSizes), ['valid_values' => $validSizes]); + } + $payload['size'] = $options['size']; + } + + if (isset($options['response_format'])) { + $validFormats = ['url', 'b64_json']; + if (!in_array($options['response_format'], $validFormats)) { + throw InvalidArgumentException::invalidParameter('response_format', $options['response_format'], 'openai', 'Response format must be either "url" or "b64_json".', ['valid_values' => $validFormats]); + } + $payload['response_format'] = $options['response_format']; + } + + if(!isset($options['response_format'])) { + $payload['response_format'] = 'b64_json'; + } + + if (isset($options['user'])) { + $payload['user'] = $options['user']; + } + + return $payload; + } + + /** + * Build payload for image editing request. + * + * @param mixed $images Single image path or array of image paths + * @param string $prompt Description of desired edits + * @param array $options Additional options + * + * @return array + * @since __DEPLOY_VERSION__ + */ + private function buildImageEditPayload($images, string $prompt, array $options): array + { + $model = $options['model'] ?? $this->defaultModel ?? $this->getOption('model', 'dall-e-2'); + + // Only dall-e-2 and gpt-image-1 support image editing + if (!in_array($model, ['dall-e-2', 'gpt-image-1'])) { + throw InvalidArgumentException::invalidModel($model, 'openai', ['dall-e-2', 'gpt-image-1'], 'image editing'); + } + + $this->validateImageEditInputs($images, $prompt, $model, $options); + + $payload = [ + 'model' => $model, + 'prompt' => $prompt + ]; + + // Handle images + if (is_string($images)) { + // Single image + $payload['image'] = file_get_contents($images); + } else { + // Multiple images for gpt-image-1 model + if ($model !== 'gpt-image-1') { + throw InvalidArgumentException::invalidModel($model, 'openai', ['gpt-image-1'], 'image editing'); + } + + $imageArray = []; + foreach ($images as $imagePath) { + if (!file_exists($imagePath)) { + throw InvalidArgumentException::fileNotFound($imagePath, 'openai'); + } + $imageArray[] = file_get_contents($imagePath); + } + $payload['image'] = $imageArray; + } + + // Add mask if provided + if (isset($options['mask'])) { + $this->validateMaskFile($options['mask']); + $payload['mask'] = file_get_contents($options['mask']); + } + + if (isset($options['n'])) { + $n = (int) $options['n']; + if ($n < 1 || $n > 10) { + throw InvalidArgumentException::invalidParameter('n', $options['n'], 'openai', 'Parameter "n" must be between 1 and 10.', ['valid_range' => [1, 10]]); + } + $payload['n'] = $n; + } + + if (isset($options['size'])) { + $this->validateImageSize($options['size'], $model, 'edit'); + $payload['size'] = $options['size']; + } + + if (isset($options['quality'])) { + $this->validateImageQuality($options['quality'], $model); + $payload['quality'] = $options['quality']; + } + + if (isset($options['response_format'])) { + if ($model === 'gpt-image-1') { + throw InvalidArgumentException::invalidParameter('response_format', $options['response_format'], 'openai', 'response_format is not supported for gpt-image-1 (always returns base64).', ['model' => 'gpt-image-1', 'fixed_format' => 'base64']); + } elseif (!in_array($options['response_format'], ['url', 'b64_json'])) { + throw InvalidArgumentException::invalidParameter('response_format', $options['response_format'], 'openai', 'Response format must be either "url" or "b64_json".', ['valid_values' => ['url', 'b64_json']]); + } else { + $payload['response_format'] = $options['response_format']; + } + } + + if (!isset($options['response_format']) && $model !== 'gpt-image-1') { + $payload['response_format'] = 'b64_json'; + } + + // gpt-image-1 specific parameters + if ($model === 'gpt-image-1') { + if (isset($options['background']) && !in_array($options['background'], ['transparent', 'opaque', 'auto'])) { + throw InvalidArgumentException::invalidParameter('background', $options['background'], 'openai', 'Background must be one of: transparent, opaque, auto.', ['valid_values' => ['transparent', 'opaque', 'auto']]); + } + if (isset($options['background'])) { + $payload['background'] = $options['background']; + } + + if (isset($options['output_format']) && !in_array($options['output_format'], ['png', 'jpeg', 'webp'])) { + throw InvalidArgumentException::invalidParameter('output_format', $options['output_format'], 'openai', 'Output format must be one of: png, jpeg, webp.', ['valid_values' => ['png', 'jpeg', 'webp']]); + } + if (isset($options['output_format'])) { + $payload['output_format'] = $options['output_format']; + } + + if (isset($options['output_compression'])) { + $compression = (int) $options['output_compression']; + if ($compression < 0 || $compression > 100) { + throw InvalidArgumentException::invalidParameter('output_compression', $compression, 'openai', 'Output compression must be between 0 and 100.', ['valid_range' => [0, 100]]); + } + $payload['output_compression'] = $compression; + } + } + + if (isset($options['user'])) { + $payload['user'] = $options['user']; + } + + return $payload; + } + + /** + * Build payload for text-to-speech request. + * + * @param string $text The text to convert to speech + * @param string $model The model to use for speech synthesis + * @param string $voice The voice to use for speech synthesis + * @param array $options Additional options for speech generation + * + * @return array The request payload. + * @since __DEPLOY_VERSION__ + */ + private function buildSpeechPayload(string $text, array $options): array + { + $model = $options['model'] ?? $this->defaultModel ?? $this->getOption('model', 'gpt-4o-mini-tts'); + $voice = $options['voice'] ?? $this->getOption('voice', 'alloy'); + + // Validate model + if (!in_array($model, self::TTS_MODELS)) { + throw InvalidArgumentException::invalidModel($model, 'openai', self::TTS_MODELS, 'text-to-speech'); + } + + // Validate voice + if (!in_array($voice, self::VOICES)) { + throw InvalidArgumentException::invalidVoice($voice, self::VOICES, 'openai'); + } + + // Validate input text + if (strlen($text) > 4096) { + throw InvalidArgumentException::invalidParameter('text', $text, 'openai', 'Speech input text cannot exceed 4096 characters, got: ' . strlen($text) . ' characters.', ['max_length' => 4096, 'actual_length' => strlen($text)]); + } + + $payload = [ + 'input' => $text, + 'model' => $model, + 'voice' => $voice + ]; + + $responseFormat = $options['response_format'] ?? 'mp3'; + if (!in_array($responseFormat, self::AUDIO_FORMATS)) { + throw InvalidArgumentException::invalidParameter('response_format', $responseFormat, 'openai', 'Audio response format must be one of: ' . implode(', ', self::AUDIO_FORMATS), ['valid_formats' => self::AUDIO_FORMATS]); + } + $payload['response_format'] = $responseFormat; + + if (isset($options['speed'])) { + $speed = (float) $options['speed']; + if ($speed < 0.25 || $speed > 4.0) { + throw InvalidArgumentException::invalidParameter('speed', $speed, 'openai', 'Speed must be between 0.25 and 4.0, got: ' . $speed, ['valid_range' => [0.25, 4.0]]); + } + $payload['speed'] = $speed; + } + + if (isset($options['instructions'])) { + if ($model !== 'gpt-4o-mini-tts') { + throw InvalidArgumentException::invalidModel($model, 'openai', ['gpt-4o-mini-tts'], 'instructions parameter for text-to-speech'); + } + if (!is_string($options['instructions']) || empty(trim($options['instructions']))) { + throw InvalidArgumentException::invalidParameter('instructions', $options['instructions'], 'openai', 'Instructions must be a non-empty string.', ['expected_type' => 'string', 'actual_type' => gettype($options['instructions'])]); + } + $payload['instructions'] = $options['instructions']; + } + + if (isset($options['stream_format'])) { + if ($model !== 'gpt-4o-mini-tts') { + throw InvalidArgumentException::invalidModel($model, 'openai', ['gpt-4o-mini-tts'], 'stream format parameter for text-to-speech'); + } + if (!in_array($options['stream_format'], ['sse', 'audio'])) { + throw InvalidArgumentException::invalidParameter('stream_format', $options['stream_format'], 'openai', "Stream format must be 'sse' or 'audio', got: " . $options['stream_format']); + } + $payload['stream_format'] = $options['stream_format']; + } + + return $payload; + } + + /** + * Build payload for transcription request. + * + * @param string $audioFile The audio file + * @param string $model The transcription model + * @param array $options Additional options + * + * @return array Form data for multipart request + * @throws \InvalidArgumentException If parameters are invalid + * @since __DEPLOY_VERSION__ + */ + private function buildTranscriptionPayload(string $audioFile, array $options): array + { + // Validate audio file + $this->validateAudioFile($audioFile); + + $model = $options['model'] ?? $this->defaultModel ?? $this->getOption('model', 'gpt-4o-transcribe'); + + // Validate model + if (!in_array($model, self::TRANSCRIPTION_MODELS)) { + throw InvalidArgumentException::invalidModel($model, 'openai', self::TRANSCRIPTION_MODELS, 'transcription'); + } + + $payload = [ + 'model' => $model, + 'file' => null, + '_filename' => basename($audioFile), + '_filepath' => $audioFile, + ]; + + $responseFormat = $options['response_format'] ?? 'json'; + $validFormats = ['json', 'text', 'srt', 'verbose_json', 'vtt']; + + if (in_array($model, ['gpt-4o-transcribe', 'gpt-4o-mini-transcribe'])) { + if ($responseFormat !== 'json') { + throw InvalidArgumentException::invalidParameter('response_format', $responseFormat, 'openai', "For $model, only 'json' response format is supported."); + } + } elseif (!in_array($responseFormat, $validFormats)) { + throw InvalidArgumentException::invalidParameter('response_format', $responseFormat, 'openai', "Invalid response format: $responseFormat. Valid formats: " . implode(', ', $validFormats)); + } + $payload['response_format'] = $responseFormat; + + if (isset($options['language'])) { + $payload['language'] = $options['language']; + } + + if (isset($options['prompt'])) { + $payload['prompt'] = $options['prompt']; + } + + if (isset($options['temperature'])) { + $temperature = (float) $options['temperature']; + if ($temperature < 0 || $temperature > 1) { + throw InvalidArgumentException::invalidTemperature($temperature, 'openai'); + } + $payload['temperature'] = $temperature; + } + + if (isset($options['chunking_strategy'])) { + $payload['chunking_strategy'] = $options['chunking_strategy']; + } + + if (isset($options['include'])) { + if (!is_array($options['include'])) { + throw InvalidArgumentException::invalidParameter('include', $options['include'], 'openai', "Include parameter must be an array."); + } + $validIncludes = ['logprobs']; + foreach ($options['include'] as $include) { + if (!in_array($include, $validIncludes)) { + throw InvalidArgumentException::invalidParameter('include', $include, 'openai', "Invalid include option: $include. Valid options: " . implode(', ', $validIncludes)); + } + } + // logprobs only works with json format and specific models + if (in_array('logprobs', $options['include'])) { + if ($responseFormat !== 'json') { + throw InvalidArgumentException::invalidParameter('response_format', $responseFormat, 'openai', "logprobs include option only works with 'json' response format."); + } + if (!in_array($model, ['gpt-4o-transcribe', 'gpt-4o-mini-transcribe'])) { + throw InvalidArgumentException::invalidParameter('include', 'logprobs', 'openai', 'logprobs include option only works with gpt-4o-transcribe and gpt-4o-mini-transcribe models.'); + } + } + $payload['include'] = $options['include']; + } + + if (isset($options['stream'])) { + if ($model === 'whisper-1') { + throw InvalidArgumentException::invalidModel($model, 'openai', ['whisper-1'], 'streaming for text-to-speech'); + } + $payload['stream'] = (bool) $options['stream']; + } + + if (isset($options['timestamp_granularities'])) { + if ($responseFormat !== 'verbose_json') { + throw InvalidArgumentException::invalidParameter('response_format', $responseFormat, 'openai', "timestamp_granularities only works with 'verbose_json' response format."); + } + if (!is_array($options['timestamp_granularities'])) { + throw InvalidArgumentException::invalidParameter('timestamp_granularities', $options['timestamp_granularities'], 'openai', "timestamp_granularities must be an array."); + } + $validGranularities = ['word', 'segment']; + foreach ($options['timestamp_granularities'] as $granularity) { + if (!in_array($granularity, $validGranularities)) { + throw InvalidArgumentException::invalidParameter('timestamp_granularities', $granularity, 'openai', "Valid options: " . implode(', ', $validGranularities)); + } + } + $payload['timestamp_granularities'] = $options['timestamp_granularities']; + } + + return $payload; + } + + /** + * Build payload for translation request. + * + * @param string $audioFile Path to the audio file + * @param string $model The translation model to use + * @param array $options Additional options for translation + * + * @return array Form data for multipart request + * @throws \InvalidArgumentException If parameters are invalid + * @since __DEPLOY_VERSION__ + */ + private function buildTranslationPayload(string $audioFile, array $options): array + { + // Validate audio file + $this->validateAudioFile($audioFile); + + $model = $options['model'] ?? $this->defaultModel ?? $this->getOption('model', 'whisper-1'); + + // Validate model + if ($model !== 'whisper-1') { + throw InvalidArgumentException::invalidModel($model, 'openai', ['whisper-1'], 'translation'); + } + + $payload = [ + 'model' => $model, + 'file' => null, + '_filename' => basename($audioFile), + '_filepath' => $audioFile, + ]; + + $responseFormat = $options['response_format'] ?? 'json'; + $validFormats = ['json', 'text', 'srt', 'verbose_json', 'vtt']; + if (!in_array($responseFormat, $validFormats)) { + throw InvalidArgumentException::invalidParameter('response_format', $responseFormat, 'openai', "Valid formats: " . implode(', ', $validFormats)); + } + $payload['response_format'] = $responseFormat; + + if (isset($options['prompt'])) { + $payload['prompt'] = $options['prompt']; + } + + if (isset($options['temperature'])) { + $temperature = (float) $options['temperature']; + if ($temperature < 0 || $temperature > 1) { + throw InvalidArgumentException::invalidTemperature($temperature, 'openai'); + } + $payload['temperature'] = $temperature; + } + + return $payload; + } + + /** + * Build request payload for embeddings. + * + * @param string|array $input Text input(s) to embed + * @param string $model The embedding model to use + * @param array $options Additional options + * + * @return array + * @throws \InvalidArgumentException If parameters are invalid + * @since __DEPLOY_VERSION__ + */ + private function buildEmbeddingPayload($input, string $model, array $options): array + { + // Validate model + if (!in_array($model, self::EMBEDDING_MODELS)) { + throw InvalidArgumentException::invalidModel($model, 'openai', self::EMBEDDING_MODELS, 'embedding'); + } + + $payload = [ + 'input' => $input, + 'model' => $model + ]; + + $encodingFormat = $options['encoding_format'] ?? 'float'; + if (!in_array($encodingFormat, ['float', 'base64'])) { + throw InvalidArgumentException::invalidParameter('encoding_format', $encodingFormat, 'openai', "Encoding format must be 'float' or 'base64'."); + } + $payload['encoding_format'] = $encodingFormat; + + if (isset($options['dimensions'])) { + if (!in_array($model, ['text-embedding-3-large', 'text-embedding-3-small'])) { + throw InvalidArgumentException::invalidParameter('dimensions', $options['dimensions'], 'openai', "Dimensions parameter is only supported for text-embedding-3-large and text-embedding-3-small models."); + } + + $dimensions = (int) $options['dimensions']; + $maxDimensions = $model === 'text-embedding-3-large' ? 3072 : 1536; + + if ($dimensions < 1 || $dimensions > $maxDimensions) { + throw InvalidArgumentException::invalidParameter('dimensions', $dimensions, 'openai', "Dimensions must be between 1 and $maxDimensions for $model."); + } + + $payload['dimensions'] = $dimensions; + } + + if (isset($options['user'])) { + $payload['user'] = $options['user']; + } + + return $payload; + } + + /** + * Build HTTP headers for OpenAI API request. + * + * @return array HTTP headers + * @since __DEPLOY_VERSION__ + */ + private function buildHeaders(): array + { + $apiKey = $this->getApiKey(); + + return [ + 'Authorization' => 'Bearer ' . $apiKey, + 'Content-Type' => 'application/json', + 'User-Agent' => 'Joomla-AI-Framework' + ]; + } + + /** + * Build HTTP headers for multipart form data requests. + * + * @return array HTTP headers + * @since __DEPLOY_VERSION__ + */ + private function buildMultipartHeaders(): array + { + $apiKey = $this->getApiKey(); + + return [ + 'Authorization' => 'Bearer ' . $apiKey, + 'User-Agent' => 'Joomla-AI-Framework' + ]; + } + + /** + * Get the OpenAI API key. + * + * @return string The API key + * @throws AuthenticationException If API key is not found + * @since __DEPLOY_VERSION__ + */ + private function getApiKey(): string + { + // To do: Move this to a configuration file or environment variable + $apiKey = $this->getOption('api_key') ?? + $_ENV['OPENAI_API_KEY'] ?? + getenv('OPENAI_API_KEY'); + + if (empty($apiKey)) { + throw new AuthenticationException( + $this->getName(), + ['message' => 'OpenAI API key not configured. Set OPENAI_API_KEY environment variable or provide api_key option.'], + 401 + ); + } + + return $apiKey; + } + + /** + * Parse OpenAI API response into unified Response object. + * + * @param string $responseBody The JSON response body + * + * @return Response Unified response object + * @throws \Exception If response parsing fails + * @since __DEPLOY_VERSION__ + */ + private function parseOpenAIResponse(string $responseBody): Response + { + $data = $this->parseJsonResponse($responseBody); + + if (isset($data['error'])) { + throw new ProviderException($this->getName(), $data); + } + + // Handle multiple choices - use first choice for content, but include all in metadata + $content = $data['choices'][0]['message']['content'] ?? ''; + + $statusCode = $this->determineAIStatusCode($data); + + $metadata = [ + 'model' => $data['model'], + 'usage' => $data['usage'], + 'finish_reason' => $data['choices'][0]['finish_reason'], + 'created' => $data['created'] ?? time(), + 'id' => $data['id'], + 'choices' => $data['choices'] + ]; + + return new Response( + $content, + $this->getName(), + $metadata, + $statusCode + ); + } + + /** + * Parse OpenAI Image API response into unified Response object. + * + * @param string $responseBody The JSON response body + * + * @return Response Unified response object + * @throws \Exception If response parsing fails + * @since __DEPLOY_VERSION__ + */ + private function parseImageResponse(string $responseBody): Response + { + // To Do: Clean Image API response for generation and editing + $data = $this->parseJsonResponse($responseBody); + // error_log('OpenAI Image Response: ' . print_r($data, true)); + + if (isset($data['error'])) { + throw new ProviderException($this->getName(), $data); + } + + $images = []; + $responseFormat = ''; + + if (isset($data['data']) && is_array($data['data'])) { + foreach ($data['data'] as $imageData) { + $imageItem = []; + + // Handle URL format + if (isset($imageData['url'])) { + $imageItem['url'] = $imageData['url']; + $responseFormat = 'url'; + } + + // Handle base64 format + if (isset($imageData['b64_json'])) { + $imageItem['b64_json'] = $imageData['b64_json']; + $responseFormat = 'b64_json'; + } + + // Handle revised prompt (DALL-E 3 only) + if (isset($imageData['revised_prompt'])) { + $imageItem['revised_prompt'] = $imageData['revised_prompt']; + } + + $images[] = $imageItem; + } + } + + $content = ''; + if ($responseFormat === 'url') { + // For URLs, create a clean list + $urls = array_column($images, 'url'); + $content = count($urls) === 1 ? $urls[0] : json_encode($urls, JSON_PRETTY_PRINT); + } elseif ($responseFormat === 'b64_json') { + // For base64, return the data + $base64Data = array_column($images, 'b64_json'); + $content = count($base64Data) === 1 ? $base64Data[0] : json_encode($base64Data, JSON_PRETTY_PRINT); + } + + $metadata = [ + 'created' => $data['created'] ?? time(), + 'response_format' => $responseFormat, + 'image_count' => count($images), + 'images' => $images + ]; + + if ($responseFormat === 'url') { + $metadata['url_expires'] = 'URLs are valid for 60 minutes'; + } elseif ($responseFormat === 'b64_json') { + $metadata['format'] = 'base64_png'; + } + + if (isset($data['usage'])) { + $metadata['usage'] = $data['usage']; + } + + if (isset($data['model'])) { + $metadata['model'] = $data['model']; + } + + return new Response( + $content, + $this->getName(), + $metadata, + 200 + ); + } + + /** + * Parse OpenAI Audio API response into unified Response object. + * + * @param string $responseBody The binary or JSON response body + * @param array $payload The original request payload for metadata + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + private function parseAudioResponse(string $responseBody, array $payload): Response + { + + if ($this->isJsonResponse($responseBody)) { + $data = $this->parseJsonResponse($responseBody); + + if (isset($data['error'])) { + throw new ProviderException($this->getName(), $data); + } + } + + $metadata = [ + 'model' => $payload['model'], + 'voice' => $payload['voice'], + 'format' => $payload['response_format'], + 'speed' => $payload['speed'] ?? 1.0, + 'content_type' => $this->detectAudioMimeType($payload['response_format']), + 'data_type' => 'binary_audio', + 'size_bytes' => strlen($responseBody), + 'created' => time() + ]; + + // Add instructions if present (gpt-4o-mini-tts only) + if (isset($payload['instructions'])) { + $metadata['instructions'] = $payload['instructions']; + } + + return new Response( + $responseBody, // Binary audio data + $this->getName(), + $metadata, + 200 + ); + } + + /** + * Parse OpenAI Audio API response (transcription/translation) into unified Response object. + * + * @param string $responseBody The response body + * @param array $payload The original request payload for metadata + * @param string $apiType Either ' Transcription' or 'Translation' + * + * @return Response Unified response object + * @throws \Exception If response parsing fails + * @since __DEPLOY_VERSION__ + */ + private function parseAudioTextResponse(string $responseBody, array $payload, string $apiType): Response + { + $responseFormat = $payload['response_format']; + $content = ''; + $metadata = []; + + switch ($responseFormat) { + case 'json': + case 'verbose_json': + $data = $this->parseJsonResponse($responseBody); + + if (isset($data['error'])) { + throw new ProviderException($this->getName(), $data); + } + + $content = $data['text'] ?? ''; + $metadata = [ + 'model' => $payload['model'], + 'response_format' => $responseFormat, + 'created' => time() + ]; + + if (isset($data['usage'])) { + $metadata['usage'] = $data['usage']; + } + + // Add language info for transcription + if ($apiType === 'Transcription' && isset($data['language'])) { + $metadata['language'] = $data['language']; + } + + if (isset($data['duration'])) { + $metadata['duration'] = $data['duration']; + } + + if ($responseFormat === 'verbose_json' && isset($data['segments'])) { + $metadata['segments'] = $data['segments']; + } + + if (isset($data['words'])) { + $metadata['words'] = $data['words']; + } + + break; + + case 'text': + $content = trim($responseBody); + $metadata = [ + 'model' => $payload['model'], + 'response_format' => 'text', + 'created' => time() + ]; + break; + + case 'srt': + case 'vtt': + $content = $responseBody; + $metadata = [ + 'model' => $payload['model'], + 'response_format' => $responseFormat, + 'created' => time() + ]; + + if ($apiType === 'Transcription') { + $metadata['subtitle_format'] = $responseFormat; + } + break; + + default: + throw new ProviderException($this->getName(), ['error' => 'Unsupported response format: ' . $responseFormat]); + } + + return new Response( + $content, + $this->getName(), + $metadata, + 200 + ); + } + + /** + * Parse OpenAI Embeddings API response into unified Response object. + * + * @param string $responseBody The JSON response body + * @param array $payload The original request payload for metadata + * + * @return Response + * @since __DEPLOY_VERSION__ + */ + private function parseEmbeddingResponse(string $responseBody, array $payload): Response + { + $data = $this->parseJsonResponse($responseBody); + + if (isset($data['error'])) { + throw new ProviderException($this->getName(), $data + ); + } + + $embeddings = []; + if (isset($data['data']) && is_array($data['data'])) { + foreach ($data['data'] as $embeddingData) { + $embeddings[] = [ + 'embedding' => $embeddingData['embedding'], + 'index' => $embeddingData['index'], + 'object' => $embeddingData['object'] + ]; + } + } + + $contentData = count($embeddings) === 1 ? $embeddings[0]['embedding'] : $embeddings; + $content = json_encode($contentData); + + $metadata = [ + 'model' => $data['model'], + 'object' => $data['object'], + 'embedding_count' => count($embeddings), + 'encoding_format' => $payload['encoding_format'], + 'input_type' => is_array($payload['input']) ? 'array' : 'string', + 'raw_embeddings' => $embeddings, + ]; + + if (isset($data['usage'])) { + $metadata['usage'] = $data['usage']; + } + + if (isset($payload['dimensions'])) { + $metadata['requested_dimensions'] = $payload['dimensions']; + } + + return new Response( + $content, + $this->getName(), + $metadata, + 200 + ); + } + + /** + * Basic validation for messages array + * + * @param array $messages Array of messages to validate + * + * @throws \InvalidArgumentException If basic structure is invalid + * @since __DEPLOY_VERSION__ + */ + private function validateMessages(array $messages): void + { + $validRoles = ['developer', 'system', 'user', 'assistant', 'tool', 'function']; + + foreach ($messages as $index => $message) { + if (!is_array($message) || !isset($message['role'])) { + throw InvalidArgumentException::invalidParameter('messages', $message, 'openai', "Message at index $index must be an array with a 'role' field."); + } + + if (!in_array($message['role'], $validRoles)) { + throw InvalidArgumentException::invalidParameter('role', $message['role'], 'openai', "Invalid role '{$message['role']}' at message index $index. Valid roles are: " . implode(', ', $validRoles)); + } + + // For most roles, content is required (except assistant with tool_calls) + if (!isset($message['content']) && + !($message['role'] === 'assistant' && (isset($message['tool_calls']) || isset($message['function_call'])))) { + throw InvalidArgumentException::missingParameter('content at message index ' . $index, 'openai'); + } + } + } + + /** + * Validate image prompt for generation and editing operations. + * + * @param string $prompt The prompt to validate + * @param string $model The model being used + * + * @return void + * @throws \InvalidArgumentException If validation fails + * @since __DEPLOY_VERSION__ + */ + private function validateImagePrompt(string $prompt, string $model): void + { + // Max lengths per model + $maxLengths = [ + 'gpt-image-1' => 32000, + 'dall-e-2' => 1000, + 'dall-e-3' => 4000 + ]; + + // Validate prompt length + if (isset($maxLengths[$model]) && strlen(trim($prompt)) > $maxLengths[$model]) { + throw InvalidArgumentException::invalidParameter('prompt', $prompt, 'openai', "Prompt length (" . strlen(trim($prompt)) . ") exceeds maximum for $model ({$maxLengths[$model]} characters)"); + } + } + + /** + * Validate inputs for image editing. + * + * @param mixed $images Single image path or array of image paths + * @param string $prompt Description of desired edits + * @param string $model The model to use + * @param array $options Additional options + * + * @throws \InvalidArgumentException If inputs are invalid + * @since __DEPLOY_VERSION__ + */ + private function validateImageEditInputs($images, string $prompt, string $model, array $options): void + { + $this->validateImagePrompt($prompt, $model); + + // Validate images + if (is_string($images)) { + $this->validateImageFile($images, $model, 'edit'); + } else { + if ($model !== 'gpt-image-1') { + throw InvalidArgumentException::invalidParameter('images', $images, 'openai', 'Multiple images only supported with gpt-image-1 model.'); + } + + if (count($images) > 16) { + throw InvalidArgumentException::invalidParameter('images', $images, 'openai', 'Maximum 16 images allowed for gpt-image-1.'); + } + + foreach ($images as $imagePath) { + $this->validateImageFile($imagePath, $model, 'edit'); + } + } + } + + /** + * Validate an image file. + * + * @param string $imagePath Path to the image file + * @param string $model The model being used + * @param string $operation The operation + * + * @throws \InvalidArgumentException If file is invalid + * @since __DEPLOY_VERSION__ + */ + private function validateImageFile(string $imagePath, string $model, string $operation): void + { + if (!file_exists($imagePath)) { + throw InvalidArgumentException::fileNotFound($imagePath, 'openai'); + } + + $fileSize = filesize($imagePath); + $fileInfo = pathinfo($imagePath); + $extension = strtolower($fileInfo['extension'] ?? ''); + + if ($model === 'gpt-image-1') { + // gpt-image-1 supports png, webp, jpg, max 50MB + if (!in_array($extension, ['png', 'webp', 'jpg', 'jpeg'])) { + throw InvalidArgumentException::invalidParameter('image', $imagePath, 'openai', "For gpt-image-1, image must be png, webp, or jpg. Got: $extension"); + } + + if ($fileSize > 50 * 1024 * 1024) { // 50MB + throw InvalidArgumentException::fileSizeExceeded($imagePath, $fileSize, 50, $model, 'openai'); + } + } elseif ($model === 'dall-e-2') { + // dall-e-2 requires square PNG, max 4MB + if ($extension !== 'png') { + throw InvalidArgumentException::invalidParameter('image', $imagePath, 'openai', "For dall-e-2, image must be a PNG file. Got: $extension"); + } + + if ($fileSize > 4 * 1024 * 1024) { // 4MB + throw InvalidArgumentException::fileSizeExceeded($imagePath, $fileSize, 4, $model, 'openai'); + } + + // Check if image is square (for variations) + if ($operation === 'variation') { + $imageInfo = getimagesize($imagePath); + if ($imageInfo === false) { + throw InvalidArgumentException::invalidParameter('image', $imagePath, 'openai', "Unable to read image dimensions from: $imagePath"); + } + if ($imageInfo[0] !== $imageInfo[1]) { + throw InvalidArgumentException::invalidParameter('image', $imagePath, 'openai', "For dall-e-2 variations, image must be square. Current dimensions: {$imageInfo[0]}x{$imageInfo[1]}"); + } + } + } + } + + /** + * Validate a mask file. + * + * @param string $maskPath Path to the mask file + * + * @throws \InvalidArgumentException If mask file is invalid + * @since __DEPLOY_VERSION__ + */ + private function validateMaskFile(string $maskPath): void + { + if (!file_exists($maskPath)) { + throw InvalidArgumentException::fileNotFound($maskPath, 'openai'); + } + + $fileSize = filesize($maskPath); + $fileInfo = pathinfo($maskPath); + $extension = strtolower($fileInfo['extension'] ?? ''); + + if ($extension !== 'png') { + throw InvalidArgumentException::invalidFileFormat($maskPath, $extension, ['png'], 'openai'); + } + + if ($fileSize > 4 * 1024 * 1024) { // 4MB + throw InvalidArgumentException::fileSizeExceeded($maskPath, $fileSize, 4, 'openai'); + } + } + + /** + * Validate image size parameter. + * + * @param string $size The size to validate + * @param string $model The model being used + * @param string $operation The operation (generation, edit, variation) + * + * @throws \InvalidArgumentException If size is invalid + * @since __DEPLOY_VERSION__ + */ + private function validateImageSize(string $size, string $model, string $operation): void + { + $validSizes = []; + + switch ($model) { + case 'gpt-image-1': + $validSizes = ['1024x1024', '1536x1024', '1024x1536', 'auto']; + break; + + case 'dall-e-2': + $validSizes = ['256x256', '512x512', '1024x1024']; + break; + + case 'dall-e-3': + $validSizes = ['1024x1024', '1792x1024', '1024x1792']; + break; + } + + if (!in_array($size, $validSizes)) { + throw InvalidArgumentException::invalidImageSize($size, $validSizes, $model, $operation); + } + } + + /** + * Validate image quality parameter. + * + * @param string $quality The quality to validate + * @param string $model The model being used + * + * @throws \InvalidArgumentException If quality is invalid + * @since __DEPLOY_VERSION__ + */ + private function validateImageQuality(string $quality, string $model): void + { + $validQualities = []; + + switch ($model) { + case 'gpt-image-1': + $validQualities = ['auto', 'high', 'medium', 'low']; + break; + + case 'dall-e-2': + $validQualities = ['standard']; + break; + + case 'dall-e-3': + $validQualities = ['auto', 'hd', 'standard']; + break; + } + + if (!in_array($quality, $validQualities)) { + throw InvalidArgumentException::invalidParameter('quality', $quality, 'openai', 'Valid qualities: ' . implode(', ', $validQualities) . ' for model: ' . $model); + } + } + + /** + * Validate audio file according to OpenAI API requirements. + * + * @param string $audioFile Path to the audio file + * + * @throws \InvalidArgumentException If audio file is invalid + * @throws \Exception If file cannot be read + * @since __DEPLOY_VERSION__ + */ + private function validateAudioFile(string $audioFile): void + { + if (!file_exists($audioFile)) { + throw InvalidArgumentException::fileNotFound($audioFile, 'openai'); + } + + $audioData = file_get_contents($audioFile); + if ($audioData === false) { + throw InvalidArgumentException::fileNotFound($audioFile, 'openai'); + } + + $fileInfo = pathinfo($audioFile); + $extension = strtolower($fileInfo['extension'] ?? ''); + + if (!in_array($extension, self::TRANSCRIPTION_INPUT_FORMATS)) { + throw InvalidArgumentException::invalidFileFormat($audioFile, $extension, self::TRANSCRIPTION_INPUT_FORMATS, 'openai'); + } + + // Check file size (OpenAI has a 25MB limit for audio files) + $fileSize = filesize($audioFile); + if ($fileSize > 25 * 1024 * 1024) { // 25MB + throw InvalidArgumentException::fileSizeExceeded($audioFile, $fileSize, 25, 'openai'); + } + } + + /** + * Determine status code based on OpenAI's finish_reason. + * + * @param array $data Parsed OpenAI response + * + * @return integer Status Code + * @since __DEPLOY_VERSION__ + */ + private function determineAIStatusCode(array $data): int + { + $finishReason = $data['choices'][0]['finish_reason']; + + switch ($finishReason) { + case 'stop': + return 200; + + case 'length': + return 206; + + case 'content_filter': + return 422; + + case 'tool_calls': + case 'function_call': + return 202; + + default: + return 200; + } + } +} diff --git a/src/Response/Response.php b/src/Response/Response.php new file mode 100644 index 0000000..9f2c1b0 --- /dev/null +++ b/src/Response/Response.php @@ -0,0 +1,245 @@ + + * @license GNU General Public License version 2 or later; see LICENSE + */ + +namespace Joomla\AI\Response; + +use Joomla\Filesystem\File; +use Joomla\Filesystem\Folder; +use Joomla\Filesystem\Path; + +/** + * AI response data object class. + * + * @since __DEPLOY_VERSION__ + */ +class Response + { + /** + * The content of the response. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + private $content; + + /** + * The status code of the response. + * + * @var int + * @since __DEPLOY_VERSION__ + */ + private $statusCode; + + /** + * The metadata of the response. + * + * @var array + * @since __DEPLOY_VERSION__ + */ + private $metadata; + + /** + * The provider of the response. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + private $provider; + + /** + * Constructor. + * + * @param string $content The content of the response. + * @param string $provider The provider of the response. + * @param array $metadata The metadata of the response. + * @param int $status The status code of the response. + * + * @since __DEPLOY_VERSION__ + */ + public function __construct(string $content, string $provider, array $metadata = [], int $status = 200) + { + $this->content = $content; + $this->provider = $provider; + $this->metadata = $metadata; + $this->statusCode = $status; + } + + /** + * Get the content of the response. + * + * @return string The content of the response. + * @since __DEPLOY_VERSION__ + */ + public function getContent(): string + { + return $this->content; + } + + /** + * Save the response content to file(s) based on response format. + * + * @param string $filename The base filename. + * + * @throws \RuntimeException + * @since __DEPLOY_VERSION__ + */ + public function saveContentToFile(string $filename) + { + // Create directory if it doesn't exist + $dir = dirname($filename); + if (!is_dir($dir)) { + if (!Folder::create($dir)) { + throw new \RuntimeException('Failed to create directory: ' . $dir); + } + } + + $metadata = $this->getMetadata(); + $format = $metadata['response_format'] ?? null; + $content = $this->getContent(); + + // Handle images with base64 data + if ($format === 'b64_json') { + $savedFiles = []; + $imageCount = $metadata['image_count'] ?? 1; + $dir = Path::clean(dirname($filename)); + $baseName = pathinfo($filename, PATHINFO_FILENAME); + $ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'png'; + + $data = json_decode($content, true); + if ($imageCount === 1 && is_string($content)) { + $decodedContent = base64_decode($content); + if (File::write($filename, $decodedContent)) { + $savedFiles[] = $filename; + } + } elseif (is_array($data)) { + foreach ($data as $index => $b64) { + $file = Path::clean($dir . '/' . $baseName . '_' . ($index + 1) . '.' . $ext); + $decodedContent = base64_decode($b64); + if (File::write($file, $decodedContent)) { + $savedFiles[] = $file; + } + } + } + return $savedFiles; + } + + // Handle images with URLs + if ($format === 'url') { + $imageCount = $metadata['image_count'] ?? 1; + $data = json_decode($content, true); + + $lines = []; + if ($imageCount === 1 && is_string($content)) { + $lines[] = " Image URL: " . $content; + } elseif (is_array($data)) { + foreach ($data as $index => $url) { + if (is_array($url) && isset($url['url'])) { + $url = $url['url']; + } + $lines[] = " Image " . ($index + 1) . ": " . $url; + } + } + + if (!empty($lines)) { + if (File::write($filename, implode(PHP_EOL, $lines))) { + return [$filename]; + } + } + } + + // For binary content (like audio files) + if (isset($metadata['data_type']) && $metadata['data_type'] === 'binary_audio') { + if (File::write($filename, $content)) { + return [$filename]; + } + } + + // For all other content + if ($content !== null) { + if (File::write($filename, $content)) { + return [$filename]; + } + } + + return false; + } + + /** + * Get the metadata of the response. + * + * @return array The metadata of the response. + * @since __DEPLOY_VERSION__ + */ + public function getMetadata(): array + { + return $this->metadata; + } + + /** + * Get the provider of the response. + * + * @return string The provider of the response. + * @since __DEPLOY_VERSION__ + */ + public function getProvider(): string + { + return $this->provider; + } + + /** + * Get the status code of the response. + * + * @return int The status code of the response. + * @since __DEPLOY_VERSION__ + */ + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * Magic method to access properties of the response object. + * + * @param string $name The name of the property to get. + * + * @return mixed The value of the property. + * @since __DEPLOY_VERSION__ + */ + public function __get($name) + { + switch (strtolower($name)) { + case 'content': + return $this->getContent(); + + case 'metadata': + return $this->getMetadata(); + + case 'provider': + return $this->getProvider(); + + case 'statuscode': + return $this->getStatusCode(); + + default: + $trace = debug_backtrace(); + + trigger_error( + sprintf( + 'Undefined property via __get(): %s in %s on line %s', + $name, + $trace[0]['file'], + $trace[0]['line'] + ), + E_USER_NOTICE + ); + + break; + } + } +}