From dd5fcd02f82b5d27d7bad87959602f8b76ddcd34 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 2 Jun 2025 00:09:00 +0200 Subject: [PATCH] kickoff platform, store and agent components and integration bundle --- .env | 66 +++ .gitignore | 1 + composer.json | 17 + example | 110 +++++ examples/anthropic/chat.php | 28 ++ examples/anthropic/image-input-binary.php | 32 ++ examples/anthropic/image-input-url.php | 32 ++ examples/anthropic/pdf-input-binary.php | 31 ++ examples/anthropic/pdf-input-url.php | 31 ++ examples/anthropic/stream.php | 33 ++ examples/anthropic/toolcall.php | 33 ++ examples/azure/audio-transcript.php | 28 ++ examples/azure/chat-gpt.php | 34 ++ examples/azure/chat-llama.php | 31 ++ examples/azure/embeddings.php | 33 ++ examples/bedrock/chat-claude.php | 29 ++ examples/bedrock/chat-llama.php | 29 ++ examples/bedrock/chat-nova.php | 29 ++ examples/bedrock/image-claude-binary.php | 33 ++ examples/bedrock/image-nova.php | 33 ++ examples/bedrock/toolcall-claude.php | 34 ++ examples/bedrock/toolcall-nova.php | 36 ++ examples/chat-system-prompt.php | 28 ++ examples/google/chat.php | 28 ++ examples/google/image-input.php | 32 ++ examples/google/stream.php | 33 ++ examples/huggingface/_model-listing.php | 39 ++ examples/huggingface/audio-classification.php | 25 + .../automatic-speech-recognition.php | 25 + examples/huggingface/chat-completion.php | 26 + examples/huggingface/feature-extraction.php | 26 + examples/huggingface/fill-mask.php | 23 + examples/huggingface/image-classification.php | 25 + examples/huggingface/image-segmentation.php | 25 + examples/huggingface/image-to-text.php | 25 + examples/huggingface/object-detection.php | 25 + examples/huggingface/question-answering.php | 28 ++ examples/huggingface/sentence-similarity.php | 32 ++ examples/huggingface/summarization.php | 33 ++ .../huggingface/table-question-answering.php | 31 ++ examples/huggingface/text-classification.php | 23 + examples/huggingface/text-generation.php | 23 + examples/huggingface/text-to-image.php | 26 + examples/huggingface/token-classification.php | 23 + examples/huggingface/translation.php | 25 + .../huggingface/zero-shot-classification.php | 25 + examples/mistral/chat.php | 27 ++ examples/mistral/embeddings.php | 28 ++ examples/mistral/image.php | 32 ++ examples/mistral/stream.php | 30 ++ examples/mistral/structured-output-math.php | 36 ++ examples/mistral/toolcall-stream.php | 38 ++ examples/mistral/toolcall.php | 31 ++ examples/ollama/chat-llama.php | 28 ++ examples/openai/audio-input.php | 31 ++ examples/openai/audio-transcript.php | 22 + examples/openai/chat-o1.php | 37 ++ examples/openai/chat.php | 32 ++ examples/openai/embeddings.php | 27 ++ examples/openai/image-input-binary.php | 32 ++ examples/openai/image-input-url.php | 32 ++ examples/openai/image-output-dall-e-2.php | 28 ++ examples/openai/image-output-dall-e-3.php | 32 ++ examples/openai/stream.php | 33 ++ examples/openai/structured-output-clock.php | 50 ++ examples/openai/structured-output-math.php | 31 ++ examples/openai/token-metadata.php | 38 ++ examples/openai/toolcall-stream.php | 42 ++ examples/openai/toolcall.php | 33 ++ examples/openrouter/chat-gemini.php | 30 ++ examples/parallel-chat-gpt.php | 36 ++ examples/parallel-embeddings.php | 32 ++ examples/replicate/chat-llama.php | 28 ++ examples/store/mongodb-similarity-search.php | 74 +++ examples/store/pinecone-similarity-search.php | 65 +++ examples/toolbox/brave.php | 35 ++ examples/toolbox/clock.php | 34 ++ examples/toolbox/serpapi.php | 32 ++ examples/toolbox/tavily.php | 32 ++ examples/toolbox/weather-event.php | 46 ++ examples/transformers/text-generation.php | 26 + examples/voyage/embeddings.php | 27 ++ fixtures/SomeStructure.php | 17 + fixtures/StructuredOutput/MathReasoning.php | 24 + fixtures/StructuredOutput/Step.php | 21 + fixtures/StructuredOutput/User.php | 24 + .../StructuredOutput/UserWithConstructor.php | 27 ++ fixtures/Tool/ToolArray.php | 27 ++ fixtures/Tool/ToolException.php | 23 + fixtures/Tool/ToolMisconfigured.php | 23 + fixtures/Tool/ToolMultiple.php | 36 ++ fixtures/Tool/ToolNoAttribute1.php | 24 + fixtures/Tool/ToolNoAttribute2.php | 32 ++ fixtures/Tool/ToolNoParams.php | 23 + fixtures/Tool/ToolOptionalParam.php | 27 ++ fixtures/Tool/ToolRequiredParams.php | 27 ++ .../Tool/ToolWithToolParameterAttribute.php | 71 +++ fixtures/Tool/ToolWithoutDocs.php | 23 + fixtures/Tool/ToolWrong.php | 24 + fixtures/audio.mp3 | Bin 0 -> 51019 bytes fixtures/document.pdf | Bin 0 -> 18404 bytes fixtures/image.jpg | Bin 0 -> 60085 bytes src/agent/.gitattributes | 6 + src/agent/.github/PULL_REQUEST_TEMPLATE.md | 8 + .../.github/workflows/close-pull-request.yml | 20 + src/agent/.gitignore | 3 + src/agent/LICENSE | 19 + src/agent/composer.json | 69 +++ src/agent/phpstan.dist.neon | 10 + src/agent/src/Agent.php | 121 +++++ src/agent/src/AgentAwareInterface.php | 20 + src/agent/src/AgentAwareTrait.php | 25 + src/agent/src/AgentInterface.php | 26 + .../src/Exception/ExceptionInterface.php | 19 + .../Exception/InvalidArgumentException.php | 19 + src/agent/src/Exception/LogicException.php | 19 + .../MissingModelSupportException.php | 43 ++ src/agent/src/Exception/RuntimeException.php | 19 + src/agent/src/Input.php | 47 ++ .../ModelOverrideInputProcessor.php | 38 ++ .../SystemPromptInputProcessor.php | 74 +++ src/agent/src/InputProcessorInterface.php | 20 + src/agent/src/Output.php | 33 ++ src/agent/src/OutputProcessorInterface.php | 20 + .../src/StructuredOutput/AgentProcessor.php | 94 ++++ .../ResponseFormatFactory.php | 39 ++ .../ResponseFormatFactoryInterface.php | 32 ++ src/agent/src/Toolbox/AgentProcessor.php | 122 +++++ src/agent/src/Toolbox/Attribute/AsTool.php | 26 + .../src/Toolbox/Event/ToolCallsExecuted.php | 37 ++ .../Toolbox/Exception/ExceptionInterface.php | 21 + .../Exception/ToolConfigurationException.php | 25 + .../src/Toolbox/Exception/ToolException.php | 31 ++ .../Exception/ToolExecutionException.php | 30 ++ .../Exception/ToolNotFoundException.php | 36 ++ .../src/Toolbox/FaultTolerantToolbox.php | 48 ++ src/agent/src/Toolbox/StreamResponse.php | 43 ++ src/agent/src/Toolbox/Tool/Agent.php | 40 ++ src/agent/src/Toolbox/Tool/Brave.php | 70 +++ src/agent/src/Toolbox/Tool/Clock.php | 37 ++ src/agent/src/Toolbox/Tool/Crawler.php | 42 ++ src/agent/src/Toolbox/Tool/OpenMeteo.php | 132 +++++ src/agent/src/Toolbox/Tool/SerpApi.php | 51 ++ .../src/Toolbox/Tool/SimilaritySearch.php | 59 +++ src/agent/src/Toolbox/Tool/Tavily.php | 65 +++ src/agent/src/Toolbox/Tool/Wikipedia.php | 99 ++++ .../src/Toolbox/Tool/YouTubeTranscriber.php | 79 +++ src/agent/src/Toolbox/ToolCallResult.php | 26 + .../ToolFactory/AbstractToolFactory.php | 44 ++ .../src/Toolbox/ToolFactory/ChainFactory.php | 54 +++ .../Toolbox/ToolFactory/MemoryToolFactory.php | 48 ++ .../ToolFactory/ReflectionToolFactory.php | 44 ++ .../src/Toolbox/ToolFactoryInterface.php | 28 ++ src/agent/src/Toolbox/ToolResultConverter.php | 42 ++ src/agent/src/Toolbox/Toolbox.php | 110 +++++ src/agent/src/Toolbox/ToolboxInterface.php | 34 ++ .../ModelOverrideInputProcessorTest.php | 74 +++ .../SystemPromptInputProcessorTest.php | 195 ++++++++ .../InputProcessor/SystemPromptService.php | 20 + .../StructuredOutput/ChainProcessorTest.php | 173 +++++++ .../ConfigurableResponseFormatFactory.php | 30 ++ .../ResponseFormatFactoryTest.php | 57 +++ .../tests/Toolbox/Attribute/AsToolTest.php | 33 ++ .../tests/Toolbox/ChainProcessorTest.php | 97 ++++ .../Toolbox/FaultTolerantToolboxTest.php | 92 ++++ .../MetadataFactory/ChainFactoryTest.php | 101 ++++ .../MetadataFactory/MemoryFactoryTest.php | 116 +++++ .../MetadataFactory/ReflectionFactoryTest.php | 155 ++++++ src/agent/tests/Toolbox/Tool/BraveTest.php | 82 ++++ .../tests/Toolbox/Tool/OpenMeteoTest.php | 83 ++++ .../tests/Toolbox/Tool/WikipediaTest.php | 123 +++++ .../tests/Toolbox/Tool/fixtures/brave.json | 276 +++++++++++ .../Tool/fixtures/openmeteo-current.json | 23 + .../Tool/fixtures/openmeteo-forecast.json | 37 ++ .../fixtures/wikipedia-article-missing.json | 16 + .../fixtures/wikipedia-article-redirect.json | 32 ++ .../Tool/fixtures/wikipedia-article.json | 26 + .../Tool/fixtures/wikipedia-search-empty.json | 11 + .../fixtures/wikipedia-search-result.json | 104 ++++ .../tests/Toolbox/ToolResultConverterTest.php | 66 +++ src/agent/tests/Toolbox/ToolboxTest.php | 265 ++++++++++ src/ai-bundle/.gitattributes | 6 + .../.github/PULL_REQUEST_TEMPLATE.md | 8 + .../.github/workflows/close-pull-request.yml | 20 + src/ai-bundle/.gitignore | 5 + src/ai-bundle/LICENSE | 19 + src/ai-bundle/README.md | 176 +++++++ src/ai-bundle/composer.json | 44 ++ src/ai-bundle/phpstan.dist.neon | 8 + src/ai-bundle/phpunit.xml.dist | 24 + src/ai-bundle/profiler.png | Bin 0 -> 172017 bytes src/ai-bundle/src/AIBundle.php | 18 + .../src/DependencyInjection/AIExtension.php | 456 ++++++++++++++++++ .../src/DependencyInjection/Configuration.php | 212 ++++++++ src/ai-bundle/src/Profiler/DataCollector.php | 89 ++++ .../src/Profiler/TraceablePlatform.php | 56 +++ .../src/Profiler/TraceableToolbox.php | 51 ++ .../src/Resources/config/services.php | 76 +++ .../Resources/views/data_collector.html.twig | 252 ++++++++++ src/ai-bundle/src/Resources/views/icon.svg | 16 + .../tests/Profiler/TraceableToolboxTest.php | 78 +++ src/platform/.gitattributes | 6 + src/platform/.github/PULL_REQUEST_TEMPLATE.md | 8 + .../.github/workflows/close-pull-request.yml | 20 + src/platform/.gitignore | 3 + src/platform/LICENSE | 19 + src/platform/composer.json | 74 +++ src/platform/phpstan.dist.neon | 10 + src/platform/phpunit.xml.dist | 24 + src/platform/src/Bridge/Anthropic/Claude.php | 47 ++ .../Contract/AssistantMessageNormalizer.php | 66 +++ .../Anthropic/Contract/DocumentNormalizer.php | 50 ++ .../Contract/DocumentUrlNormalizer.php | 49 ++ .../Anthropic/Contract/ImageNormalizer.php | 52 ++ .../Anthropic/Contract/ImageUrlNormalizer.php | 50 ++ .../Contract/MessageBagNormalizer.php | 64 +++ .../Contract/ToolCallMessageNormalizer.php | 63 +++ .../Anthropic/Contract/ToolNormalizer.php | 54 +++ .../src/Bridge/Anthropic/ModelClient.php | 54 +++ .../src/Bridge/Anthropic/PlatformFactory.php | 55 +++ .../Bridge/Anthropic/ResponseConverter.php | 88 ++++ .../src/Bridge/Azure/Meta/LlamaHandler.php | 64 +++ .../src/Bridge/Azure/Meta/PlatformFactory.php | 33 ++ .../Azure/OpenAI/EmbeddingsModelClient.php | 64 +++ .../Bridge/Azure/OpenAI/GPTModelClient.php | 61 +++ .../Bridge/Azure/OpenAI/PlatformFactory.php | 46 ++ .../Azure/OpenAI/WhisperModelClient.php | 62 +++ .../Bedrock/Anthropic/ClaudeHandler.php | 97 ++++ .../src/Bridge/Bedrock/BedrockModelClient.php | 29 ++ .../Bridge/Bedrock/Meta/LlamaModelClient.php | 68 +++ .../Contract/AssistantMessageNormalizer.php | 72 +++ .../Nova/Contract/MessageBagNormalizer.php | 58 +++ .../Contract/ToolCallMessageNormalizer.php | 65 +++ .../Bedrock/Nova/Contract/ToolNormalizer.php | 62 +++ .../Nova/Contract/UserMessageNormalizer.php | 72 +++ src/platform/src/Bridge/Bedrock/Nova/Nova.php | 46 ++ .../src/Bridge/Bedrock/Nova/NovaHandler.php | 98 ++++ src/platform/src/Bridge/Bedrock/Platform.php | 85 ++++ .../src/Bridge/Bedrock/PlatformFactory.php | 33 ++ .../Contract/AssistantMessageNormalizer.php | 49 ++ .../Google/Contract/MessageBagNormalizer.php | 69 +++ .../Google/Contract/UserMessageNormalizer.php | 58 +++ src/platform/src/Bridge/Google/Gemini.php | 41 ++ .../src/Bridge/Google/ModelHandler.php | 132 +++++ .../src/Bridge/Google/PlatformFactory.php | 41 ++ .../src/Bridge/HuggingFace/ApiClient.php | 43 ++ .../HuggingFace/Contract/FileNormalizer.php | 48 ++ .../Contract/MessageBagNormalizer.php | 54 +++ .../src/Bridge/HuggingFace/ModelClient.php | 94 ++++ .../HuggingFace/Output/Classification.php | 24 + .../Output/ClassificationResult.php | 36 ++ .../HuggingFace/Output/DetectedObject.php | 28 ++ .../HuggingFace/Output/FillMaskResult.php | 42 ++ .../HuggingFace/Output/ImageSegment.php | 25 + .../Output/ImageSegmentationResult.php | 36 ++ .../Bridge/HuggingFace/Output/MaskFill.php | 26 + .../Output/ObjectDetectionResult.php | 44 ++ .../Output/QuestionAnsweringResult.php | 39 ++ .../Output/SentenceSimilarityResult.php | 34 ++ .../Output/TableQuestionAnsweringResult.php | 41 ++ .../src/Bridge/HuggingFace/Output/Token.php | 27 ++ .../Output/TokenClassificationResult.php | 43 ++ .../Output/ZeroShotClassificationResult.php | 41 ++ .../Bridge/HuggingFace/PlatformFactory.php | 43 ++ .../src/Bridge/HuggingFace/Provider.php | 30 ++ .../Bridge/HuggingFace/ResponseConverter.php | 96 ++++ src/platform/src/Bridge/HuggingFace/Task.php | 38 ++ .../Meta/Contract/MessageBagNormalizer.php | 51 ++ src/platform/src/Bridge/Meta/Llama.php | 50 ++ .../src/Bridge/Meta/LlamaPromptConverter.php | 98 ++++ .../Mistral/Contract/ToolNormalizer.php | 29 ++ .../src/Bridge/Mistral/Embeddings.php | 33 ++ .../Bridge/Mistral/Embeddings/ModelClient.php | 54 +++ .../Mistral/Embeddings/ResponseConverter.php | 51 ++ .../src/Bridge/Mistral/Llm/ModelClient.php | 52 ++ .../Bridge/Mistral/Llm/ResponseConverter.php | 199 ++++++++ src/platform/src/Bridge/Mistral/Mistral.php | 66 +++ .../src/Bridge/Mistral/PlatformFactory.php | 42 ++ .../src/Bridge/Ollama/LlamaModelHandler.php | 65 +++ .../src/Bridge/Ollama/PlatformFactory.php | 32 ++ src/platform/src/Bridge/OpenAI/DallE.php | 35 ++ .../src/Bridge/OpenAI/DallE/Base64Image.php | 26 + .../src/Bridge/OpenAI/DallE/ImageResponse.php | 38 ++ .../src/Bridge/OpenAI/DallE/ModelClient.php | 76 +++ .../src/Bridge/OpenAI/DallE/UrlImage.php | 26 + src/platform/src/Bridge/OpenAI/Embeddings.php | 32 ++ .../Bridge/OpenAI/Embeddings/ModelClient.php | 50 ++ .../OpenAI/Embeddings/ResponseConverter.php | 47 ++ src/platform/src/Bridge/OpenAI/GPT.php | 90 ++++ .../src/Bridge/OpenAI/GPT/ModelClient.php | 51 ++ .../Bridge/OpenAI/GPT/ResponseConverter.php | 204 ++++++++ .../src/Bridge/OpenAI/PlatformFactory.php | 57 +++ .../Bridge/OpenAI/TokenOutputProcessor.php | 52 ++ src/platform/src/Bridge/OpenAI/Whisper.php | 36 ++ .../Bridge/OpenAI/Whisper/AudioNormalizer.php | 48 ++ .../src/Bridge/OpenAI/Whisper/ModelClient.php | 47 ++ .../OpenAI/Whisper/ResponseConverter.php | 37 ++ src/platform/src/Bridge/OpenRouter/Client.php | 70 +++ .../src/Bridge/OpenRouter/PlatformFactory.php | 41 ++ src/platform/src/Bridge/Replicate/Client.php | 64 +++ .../Contract/LlamaMessageBagNormalizer.php | 53 ++ .../src/Bridge/Replicate/LlamaModelClient.php | 41 ++ .../Replicate/LlamaResponseConverter.php | 42 ++ .../src/Bridge/Replicate/PlatformFactory.php | 37 ++ .../src/Bridge/TransformersPHP/Platform.php | 52 ++ .../TransformersPHP/PlatformFactory.php | 30 ++ .../src/Bridge/Voyage/ModelHandler.php | 63 +++ .../src/Bridge/Voyage/PlatformFactory.php | 33 ++ src/platform/src/Bridge/Voyage/Voyage.php | 36 ++ src/platform/src/Capability.php | 36 ++ src/platform/src/Contract.php | 86 ++++ .../Contract/JsonSchema/Attribute/With.php | 148 ++++++ .../Contract/JsonSchema/DescriptionParser.php | 59 +++ .../src/Contract/JsonSchema/Factory.php | 185 +++++++ .../Message/AssistantMessageNormalizer.php | 59 +++ .../Message/Content/AudioNormalizer.php | 56 +++ .../Message/Content/ImageNormalizer.php | 46 ++ .../Message/Content/ImageUrlNormalizer.php | 46 ++ .../Message/Content/TextNormalizer.php | 43 ++ .../Message/MessageBagNormalizer.php | 60 +++ .../Message/SystemMessageNormalizer.php | 46 ++ .../Message/ToolCallMessageNormalizer.php | 53 ++ .../Message/UserMessageNormalizer.php | 58 +++ .../Normalizer/ModelContractNormalizer.php | 49 ++ .../Response/ToolCallNormalizer.php | 57 +++ .../Contract/Normalizer/ToolNormalizer.php | 65 +++ .../src/Exception/ContentFilterException.php | 19 + .../src/Exception/ExceptionInterface.php | 19 + .../Exception/InvalidArgumentException.php | 19 + .../src/Exception/RuntimeException.php | 19 + src/platform/src/Message/AssistantMessage.php | 39 ++ src/platform/src/Message/Content/Audio.php | 19 + .../src/Message/Content/ContentInterface.php | 19 + src/platform/src/Message/Content/Document.php | 19 + .../src/Message/Content/DocumentUrl.php | 23 + src/platform/src/Message/Content/File.php | 87 ++++ src/platform/src/Message/Content/Image.php | 19 + src/platform/src/Message/Content/ImageUrl.php | 23 + src/platform/src/Message/Content/Text.php | 23 + src/platform/src/Message/Message.php | 56 +++ src/platform/src/Message/MessageBag.php | 116 +++++ .../src/Message/MessageBagInterface.php | 39 ++ src/platform/src/Message/MessageInterface.php | 20 + src/platform/src/Message/Role.php | 27 ++ src/platform/src/Message/SystemMessage.php | 27 ++ src/platform/src/Message/ToolCallMessage.php | 31 ++ src/platform/src/Message/UserMessage.php | 61 +++ src/platform/src/Model.php | 55 +++ src/platform/src/ModelClientInterface.php | 28 ++ src/platform/src/Platform.php | 90 ++++ src/platform/src/PlatformInterface.php | 26 + src/platform/src/Response/AsyncResponse.php | 83 ++++ src/platform/src/Response/BaseResponse.php | 23 + src/platform/src/Response/BinaryResponse.php | 45 ++ src/platform/src/Response/Choice.php | 50 ++ src/platform/src/Response/ChoiceResponse.php | 42 ++ .../RawResponseAlreadySetException.php | 25 + .../src/Response/Metadata/Metadata.php | 108 +++++ .../Response/Metadata/MetadataAwareTrait.php | 25 + src/platform/src/Response/ObjectResponse.php | 34 ++ .../src/Response/RawResponseAwareTrait.php | 37 ++ .../src/Response/ResponseInterface.php | 37 ++ src/platform/src/Response/StreamResponse.php | 28 ++ src/platform/src/Response/TextResponse.php | 28 ++ src/platform/src/Response/ToolCall.php | 50 ++ .../src/Response/ToolCallResponse.php | 42 ++ src/platform/src/Response/VectorResponse.php | 38 ++ .../src/ResponseConverterInterface.php | 28 ++ src/platform/src/Tool/ExecutionReference.php | 24 + src/platform/src/Tool/Tool.php | 33 ++ src/platform/src/Vector/NullVector.php | 30 ++ src/platform/src/Vector/Vector.php | 57 +++ src/platform/src/Vector/VectorInterface.php | 25 + .../Anthropic/ResponseConverterTest.php | 52 ++ .../Bridge/Bedrock/Nova/ContractTest.php | 145 ++++++ .../AssistantMessageNormalizerTest.php | 61 +++ .../Contract/MessageBagNormalizerTest.php | 157 ++++++ .../Contract/UserMessageNormalizerTest.php | 83 ++++ .../Bridge/HuggingFace/ModelClientTest.php | 151 ++++++ .../Bridge/Meta/LlamaPromptConverterTest.php | 146 ++++++ .../Bridge/OpenAI/DallE/Base64ImageTest.php | 41 ++ .../Bridge/OpenAI/DallE/ImageResponseTest.php | 63 +++ .../Bridge/OpenAI/DallE/ModelClientTest.php | 99 ++++ .../Bridge/OpenAI/DallE/UrlImageTest.php | 40 ++ .../tests/Bridge/OpenAI/DallETest.php | 41 ++ .../Embeddings/ResponseConverterTest.php | 67 +++ .../OpenAI/GPT/ResponseConverterTest.php | 192 ++++++++ .../OpenAI/TokenOutputProcessorTest.php | 155 ++++++ .../Attribute/ToolParameterTest.php | 263 ++++++++++ .../JsonSchema/DescriptionParserTest.php | 133 +++++ .../tests/Contract/JsonSchema/FactoryTest.php | 251 ++++++++++ .../AssistantMessageNormalizerTest.php | 115 +++++ .../Message/Content/AudioNormalizerTest.php | 83 ++++ .../Message/Content/ImageNormalizerTest.php | 59 +++ .../Content/ImageUrlNormalizerTest.php | 57 +++ .../Message/Content/TextNormalizerTest.php | 57 +++ .../Message/MessageBagNormalizerTest.php | 124 +++++ .../Message/SystemMessageNormalizerTest.php | 57 +++ .../Message/ToolCallMessageNormalizerTest.php | 73 +++ .../Message/UserMessageNormalizerTest.php | 91 ++++ .../Normalizer/ToolNormalizerTest.php | 160 ++++++ src/platform/tests/ContractTest.php | 217 +++++++++ .../tests/Message/AssistantMessageTest.php | 53 ++ .../tests/Message/Content/AudioTest.php | 69 +++ .../tests/Message/Content/BinaryTest.php | 115 +++++ .../tests/Message/Content/ImageTest.php | 45 ++ .../tests/Message/Content/ImageUrlTest.php | 31 ++ .../tests/Message/Content/TextTest.php | 31 ++ src/platform/tests/Message/MessageBagTest.php | 178 +++++++ src/platform/tests/Message/MessageTest.php | 111 +++++ src/platform/tests/Message/RoleTest.php | 56 +++ .../tests/Message/SystemMessageTest.php | 33 ++ .../tests/Message/ToolCallMessageTest.php | 36 ++ .../tests/Message/UserMessageTest.php | 83 ++++ src/platform/tests/ModelTest.php | 80 +++ .../tests/Response/AsyncResponseTest.php | 169 +++++++ .../tests/Response/BaseResponseTest.php | 80 +++ .../tests/Response/ChoiceResponseTest.php | 50 ++ src/platform/tests/Response/ChoiceTest.php | 66 +++ .../Exception/RawResponseAlreadySetTest.php | 31 ++ .../Metadata/MetadataAwareTraitTest.php | 47 ++ .../tests/Response/Metadata/MetadataTest.php | 137 ++++++ .../Response/RawResponseAwareTraitTest.php | 56 +++ .../tests/Response/StreamResponseTest.php | 41 ++ .../tests/Response/StructuredResponseTest.php | 37 ++ .../tests/Response/TextResponseTest.php | 30 ++ .../tests/Response/TollCallResponseTest.php | 43 ++ src/platform/tests/Response/ToolCallTest.php | 46 ++ src/store/.gitattributes | 6 + src/store/.github/PULL_REQUEST_TEMPLATE.md | 8 + .../.github/workflows/close-pull-request.yml | 20 + src/store/.gitignore | 3 + src/store/LICENSE | 19 + src/store/composer.json | 75 +++ src/store/phpstan.dist.neon | 10 + src/store/phpunit.xml.dist | 24 + src/store/src/Bridge/Azure/SearchStore.php | 121 +++++ src/store/src/Bridge/ChromaDB/Store.php | 66 +++ src/store/src/Bridge/MongoDB/Store.php | 196 ++++++++ src/store/src/Bridge/Pinecone/Store.php | 83 ++++ src/store/src/Document/Metadata.php | 21 + src/store/src/Document/TextDocument.php | 29 ++ src/store/src/Document/VectorDocument.php | 29 ++ src/store/src/Embedder.php | 97 ++++ .../src/Exception/ExceptionInterface.php | 19 + .../Exception/InvalidArgumentException.php | 19 + src/store/src/Exception/RuntimeException.php | 19 + src/store/src/InitializableStoreInterface.php | 23 + src/store/src/StoreInterface.php | 22 + src/store/src/VectorStoreInterface.php | 28 ++ src/store/tests/Document/NullVectorTest.php | 45 ++ src/store/tests/Document/VectorTest.php | 40 ++ .../tests/Double/PlatformTestHandler.php | 57 +++ src/store/tests/Double/TestStore.php | 31 ++ src/store/tests/EmbedderTest.php | 138 ++++++ 455 files changed, 24235 insertions(+) create mode 100644 .env create mode 100755 example create mode 100644 examples/anthropic/chat.php create mode 100644 examples/anthropic/image-input-binary.php create mode 100644 examples/anthropic/image-input-url.php create mode 100644 examples/anthropic/pdf-input-binary.php create mode 100644 examples/anthropic/pdf-input-url.php create mode 100644 examples/anthropic/stream.php create mode 100644 examples/anthropic/toolcall.php create mode 100644 examples/azure/audio-transcript.php create mode 100644 examples/azure/chat-gpt.php create mode 100644 examples/azure/chat-llama.php create mode 100644 examples/azure/embeddings.php create mode 100644 examples/bedrock/chat-claude.php create mode 100644 examples/bedrock/chat-llama.php create mode 100644 examples/bedrock/chat-nova.php create mode 100644 examples/bedrock/image-claude-binary.php create mode 100644 examples/bedrock/image-nova.php create mode 100644 examples/bedrock/toolcall-claude.php create mode 100644 examples/bedrock/toolcall-nova.php create mode 100644 examples/chat-system-prompt.php create mode 100644 examples/google/chat.php create mode 100644 examples/google/image-input.php create mode 100644 examples/google/stream.php create mode 100644 examples/huggingface/_model-listing.php create mode 100644 examples/huggingface/audio-classification.php create mode 100644 examples/huggingface/automatic-speech-recognition.php create mode 100644 examples/huggingface/chat-completion.php create mode 100644 examples/huggingface/feature-extraction.php create mode 100644 examples/huggingface/fill-mask.php create mode 100644 examples/huggingface/image-classification.php create mode 100644 examples/huggingface/image-segmentation.php create mode 100644 examples/huggingface/image-to-text.php create mode 100644 examples/huggingface/object-detection.php create mode 100644 examples/huggingface/question-answering.php create mode 100644 examples/huggingface/sentence-similarity.php create mode 100644 examples/huggingface/summarization.php create mode 100644 examples/huggingface/table-question-answering.php create mode 100644 examples/huggingface/text-classification.php create mode 100644 examples/huggingface/text-generation.php create mode 100644 examples/huggingface/text-to-image.php create mode 100644 examples/huggingface/token-classification.php create mode 100644 examples/huggingface/translation.php create mode 100644 examples/huggingface/zero-shot-classification.php create mode 100644 examples/mistral/chat.php create mode 100644 examples/mistral/embeddings.php create mode 100644 examples/mistral/image.php create mode 100644 examples/mistral/stream.php create mode 100644 examples/mistral/structured-output-math.php create mode 100644 examples/mistral/toolcall-stream.php create mode 100644 examples/mistral/toolcall.php create mode 100644 examples/ollama/chat-llama.php create mode 100644 examples/openai/audio-input.php create mode 100644 examples/openai/audio-transcript.php create mode 100644 examples/openai/chat-o1.php create mode 100644 examples/openai/chat.php create mode 100644 examples/openai/embeddings.php create mode 100644 examples/openai/image-input-binary.php create mode 100644 examples/openai/image-input-url.php create mode 100644 examples/openai/image-output-dall-e-2.php create mode 100644 examples/openai/image-output-dall-e-3.php create mode 100644 examples/openai/stream.php create mode 100644 examples/openai/structured-output-clock.php create mode 100644 examples/openai/structured-output-math.php create mode 100644 examples/openai/token-metadata.php create mode 100644 examples/openai/toolcall-stream.php create mode 100644 examples/openai/toolcall.php create mode 100644 examples/openrouter/chat-gemini.php create mode 100644 examples/parallel-chat-gpt.php create mode 100644 examples/parallel-embeddings.php create mode 100644 examples/replicate/chat-llama.php create mode 100644 examples/store/mongodb-similarity-search.php create mode 100644 examples/store/pinecone-similarity-search.php create mode 100644 examples/toolbox/brave.php create mode 100644 examples/toolbox/clock.php create mode 100644 examples/toolbox/serpapi.php create mode 100644 examples/toolbox/tavily.php create mode 100644 examples/toolbox/weather-event.php create mode 100644 examples/transformers/text-generation.php create mode 100644 examples/voyage/embeddings.php create mode 100644 fixtures/SomeStructure.php create mode 100644 fixtures/StructuredOutput/MathReasoning.php create mode 100644 fixtures/StructuredOutput/Step.php create mode 100644 fixtures/StructuredOutput/User.php create mode 100644 fixtures/StructuredOutput/UserWithConstructor.php create mode 100644 fixtures/Tool/ToolArray.php create mode 100644 fixtures/Tool/ToolException.php create mode 100644 fixtures/Tool/ToolMisconfigured.php create mode 100644 fixtures/Tool/ToolMultiple.php create mode 100644 fixtures/Tool/ToolNoAttribute1.php create mode 100644 fixtures/Tool/ToolNoAttribute2.php create mode 100644 fixtures/Tool/ToolNoParams.php create mode 100644 fixtures/Tool/ToolOptionalParam.php create mode 100644 fixtures/Tool/ToolRequiredParams.php create mode 100644 fixtures/Tool/ToolWithToolParameterAttribute.php create mode 100644 fixtures/Tool/ToolWithoutDocs.php create mode 100644 fixtures/Tool/ToolWrong.php create mode 100644 fixtures/audio.mp3 create mode 100644 fixtures/document.pdf create mode 100644 fixtures/image.jpg create mode 100644 src/agent/.gitattributes create mode 100644 src/agent/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 src/agent/.github/workflows/close-pull-request.yml create mode 100644 src/agent/.gitignore create mode 100644 src/agent/LICENSE create mode 100644 src/agent/composer.json create mode 100644 src/agent/phpstan.dist.neon create mode 100644 src/agent/src/Agent.php create mode 100644 src/agent/src/AgentAwareInterface.php create mode 100644 src/agent/src/AgentAwareTrait.php create mode 100644 src/agent/src/AgentInterface.php create mode 100644 src/agent/src/Exception/ExceptionInterface.php create mode 100644 src/agent/src/Exception/InvalidArgumentException.php create mode 100644 src/agent/src/Exception/LogicException.php create mode 100644 src/agent/src/Exception/MissingModelSupportException.php create mode 100644 src/agent/src/Exception/RuntimeException.php create mode 100644 src/agent/src/Input.php create mode 100644 src/agent/src/InputProcessor/ModelOverrideInputProcessor.php create mode 100644 src/agent/src/InputProcessor/SystemPromptInputProcessor.php create mode 100644 src/agent/src/InputProcessorInterface.php create mode 100644 src/agent/src/Output.php create mode 100644 src/agent/src/OutputProcessorInterface.php create mode 100644 src/agent/src/StructuredOutput/AgentProcessor.php create mode 100644 src/agent/src/StructuredOutput/ResponseFormatFactory.php create mode 100644 src/agent/src/StructuredOutput/ResponseFormatFactoryInterface.php create mode 100644 src/agent/src/Toolbox/AgentProcessor.php create mode 100644 src/agent/src/Toolbox/Attribute/AsTool.php create mode 100644 src/agent/src/Toolbox/Event/ToolCallsExecuted.php create mode 100644 src/agent/src/Toolbox/Exception/ExceptionInterface.php create mode 100644 src/agent/src/Toolbox/Exception/ToolConfigurationException.php create mode 100644 src/agent/src/Toolbox/Exception/ToolException.php create mode 100644 src/agent/src/Toolbox/Exception/ToolExecutionException.php create mode 100644 src/agent/src/Toolbox/Exception/ToolNotFoundException.php create mode 100644 src/agent/src/Toolbox/FaultTolerantToolbox.php create mode 100644 src/agent/src/Toolbox/StreamResponse.php create mode 100644 src/agent/src/Toolbox/Tool/Agent.php create mode 100644 src/agent/src/Toolbox/Tool/Brave.php create mode 100644 src/agent/src/Toolbox/Tool/Clock.php create mode 100644 src/agent/src/Toolbox/Tool/Crawler.php create mode 100644 src/agent/src/Toolbox/Tool/OpenMeteo.php create mode 100644 src/agent/src/Toolbox/Tool/SerpApi.php create mode 100644 src/agent/src/Toolbox/Tool/SimilaritySearch.php create mode 100644 src/agent/src/Toolbox/Tool/Tavily.php create mode 100644 src/agent/src/Toolbox/Tool/Wikipedia.php create mode 100644 src/agent/src/Toolbox/Tool/YouTubeTranscriber.php create mode 100644 src/agent/src/Toolbox/ToolCallResult.php create mode 100644 src/agent/src/Toolbox/ToolFactory/AbstractToolFactory.php create mode 100644 src/agent/src/Toolbox/ToolFactory/ChainFactory.php create mode 100644 src/agent/src/Toolbox/ToolFactory/MemoryToolFactory.php create mode 100644 src/agent/src/Toolbox/ToolFactory/ReflectionToolFactory.php create mode 100644 src/agent/src/Toolbox/ToolFactoryInterface.php create mode 100644 src/agent/src/Toolbox/ToolResultConverter.php create mode 100644 src/agent/src/Toolbox/Toolbox.php create mode 100644 src/agent/src/Toolbox/ToolboxInterface.php create mode 100644 src/agent/tests/InputProcessor/ModelOverrideInputProcessorTest.php create mode 100644 src/agent/tests/InputProcessor/SystemPromptInputProcessorTest.php create mode 100644 src/agent/tests/InputProcessor/SystemPromptService.php create mode 100644 src/agent/tests/StructuredOutput/ChainProcessorTest.php create mode 100644 src/agent/tests/StructuredOutput/ConfigurableResponseFormatFactory.php create mode 100644 src/agent/tests/StructuredOutput/ResponseFormatFactoryTest.php create mode 100644 src/agent/tests/Toolbox/Attribute/AsToolTest.php create mode 100644 src/agent/tests/Toolbox/ChainProcessorTest.php create mode 100644 src/agent/tests/Toolbox/FaultTolerantToolboxTest.php create mode 100644 src/agent/tests/Toolbox/MetadataFactory/ChainFactoryTest.php create mode 100644 src/agent/tests/Toolbox/MetadataFactory/MemoryFactoryTest.php create mode 100644 src/agent/tests/Toolbox/MetadataFactory/ReflectionFactoryTest.php create mode 100644 src/agent/tests/Toolbox/Tool/BraveTest.php create mode 100644 src/agent/tests/Toolbox/Tool/OpenMeteoTest.php create mode 100644 src/agent/tests/Toolbox/Tool/WikipediaTest.php create mode 100644 src/agent/tests/Toolbox/Tool/fixtures/brave.json create mode 100644 src/agent/tests/Toolbox/Tool/fixtures/openmeteo-current.json create mode 100644 src/agent/tests/Toolbox/Tool/fixtures/openmeteo-forecast.json create mode 100644 src/agent/tests/Toolbox/Tool/fixtures/wikipedia-article-missing.json create mode 100644 src/agent/tests/Toolbox/Tool/fixtures/wikipedia-article-redirect.json create mode 100644 src/agent/tests/Toolbox/Tool/fixtures/wikipedia-article.json create mode 100644 src/agent/tests/Toolbox/Tool/fixtures/wikipedia-search-empty.json create mode 100644 src/agent/tests/Toolbox/Tool/fixtures/wikipedia-search-result.json create mode 100644 src/agent/tests/Toolbox/ToolResultConverterTest.php create mode 100644 src/agent/tests/Toolbox/ToolboxTest.php create mode 100644 src/ai-bundle/.gitattributes create mode 100644 src/ai-bundle/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 src/ai-bundle/.github/workflows/close-pull-request.yml create mode 100644 src/ai-bundle/.gitignore create mode 100644 src/ai-bundle/LICENSE create mode 100644 src/ai-bundle/README.md create mode 100644 src/ai-bundle/composer.json create mode 100644 src/ai-bundle/phpstan.dist.neon create mode 100644 src/ai-bundle/phpunit.xml.dist create mode 100644 src/ai-bundle/profiler.png create mode 100644 src/ai-bundle/src/AIBundle.php create mode 100644 src/ai-bundle/src/DependencyInjection/AIExtension.php create mode 100644 src/ai-bundle/src/DependencyInjection/Configuration.php create mode 100644 src/ai-bundle/src/Profiler/DataCollector.php create mode 100644 src/ai-bundle/src/Profiler/TraceablePlatform.php create mode 100644 src/ai-bundle/src/Profiler/TraceableToolbox.php create mode 100644 src/ai-bundle/src/Resources/config/services.php create mode 100644 src/ai-bundle/src/Resources/views/data_collector.html.twig create mode 100644 src/ai-bundle/src/Resources/views/icon.svg create mode 100644 src/ai-bundle/tests/Profiler/TraceableToolboxTest.php create mode 100644 src/platform/.gitattributes create mode 100644 src/platform/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 src/platform/.github/workflows/close-pull-request.yml create mode 100644 src/platform/.gitignore create mode 100644 src/platform/LICENSE create mode 100644 src/platform/composer.json create mode 100644 src/platform/phpstan.dist.neon create mode 100644 src/platform/phpunit.xml.dist create mode 100644 src/platform/src/Bridge/Anthropic/Claude.php create mode 100644 src/platform/src/Bridge/Anthropic/Contract/AssistantMessageNormalizer.php create mode 100644 src/platform/src/Bridge/Anthropic/Contract/DocumentNormalizer.php create mode 100644 src/platform/src/Bridge/Anthropic/Contract/DocumentUrlNormalizer.php create mode 100644 src/platform/src/Bridge/Anthropic/Contract/ImageNormalizer.php create mode 100644 src/platform/src/Bridge/Anthropic/Contract/ImageUrlNormalizer.php create mode 100644 src/platform/src/Bridge/Anthropic/Contract/MessageBagNormalizer.php create mode 100644 src/platform/src/Bridge/Anthropic/Contract/ToolCallMessageNormalizer.php create mode 100644 src/platform/src/Bridge/Anthropic/Contract/ToolNormalizer.php create mode 100644 src/platform/src/Bridge/Anthropic/ModelClient.php create mode 100644 src/platform/src/Bridge/Anthropic/PlatformFactory.php create mode 100644 src/platform/src/Bridge/Anthropic/ResponseConverter.php create mode 100644 src/platform/src/Bridge/Azure/Meta/LlamaHandler.php create mode 100644 src/platform/src/Bridge/Azure/Meta/PlatformFactory.php create mode 100644 src/platform/src/Bridge/Azure/OpenAI/EmbeddingsModelClient.php create mode 100644 src/platform/src/Bridge/Azure/OpenAI/GPTModelClient.php create mode 100644 src/platform/src/Bridge/Azure/OpenAI/PlatformFactory.php create mode 100644 src/platform/src/Bridge/Azure/OpenAI/WhisperModelClient.php create mode 100644 src/platform/src/Bridge/Bedrock/Anthropic/ClaudeHandler.php create mode 100644 src/platform/src/Bridge/Bedrock/BedrockModelClient.php create mode 100644 src/platform/src/Bridge/Bedrock/Meta/LlamaModelClient.php create mode 100644 src/platform/src/Bridge/Bedrock/Nova/Contract/AssistantMessageNormalizer.php create mode 100644 src/platform/src/Bridge/Bedrock/Nova/Contract/MessageBagNormalizer.php create mode 100644 src/platform/src/Bridge/Bedrock/Nova/Contract/ToolCallMessageNormalizer.php create mode 100644 src/platform/src/Bridge/Bedrock/Nova/Contract/ToolNormalizer.php create mode 100644 src/platform/src/Bridge/Bedrock/Nova/Contract/UserMessageNormalizer.php create mode 100644 src/platform/src/Bridge/Bedrock/Nova/Nova.php create mode 100644 src/platform/src/Bridge/Bedrock/Nova/NovaHandler.php create mode 100644 src/platform/src/Bridge/Bedrock/Platform.php create mode 100644 src/platform/src/Bridge/Bedrock/PlatformFactory.php create mode 100644 src/platform/src/Bridge/Google/Contract/AssistantMessageNormalizer.php create mode 100644 src/platform/src/Bridge/Google/Contract/MessageBagNormalizer.php create mode 100644 src/platform/src/Bridge/Google/Contract/UserMessageNormalizer.php create mode 100644 src/platform/src/Bridge/Google/Gemini.php create mode 100644 src/platform/src/Bridge/Google/ModelHandler.php create mode 100644 src/platform/src/Bridge/Google/PlatformFactory.php create mode 100644 src/platform/src/Bridge/HuggingFace/ApiClient.php create mode 100644 src/platform/src/Bridge/HuggingFace/Contract/FileNormalizer.php create mode 100644 src/platform/src/Bridge/HuggingFace/Contract/MessageBagNormalizer.php create mode 100644 src/platform/src/Bridge/HuggingFace/ModelClient.php create mode 100644 src/platform/src/Bridge/HuggingFace/Output/Classification.php create mode 100644 src/platform/src/Bridge/HuggingFace/Output/ClassificationResult.php create mode 100644 src/platform/src/Bridge/HuggingFace/Output/DetectedObject.php create mode 100644 src/platform/src/Bridge/HuggingFace/Output/FillMaskResult.php create mode 100644 src/platform/src/Bridge/HuggingFace/Output/ImageSegment.php create mode 100644 src/platform/src/Bridge/HuggingFace/Output/ImageSegmentationResult.php create mode 100644 src/platform/src/Bridge/HuggingFace/Output/MaskFill.php create mode 100644 src/platform/src/Bridge/HuggingFace/Output/ObjectDetectionResult.php create mode 100644 src/platform/src/Bridge/HuggingFace/Output/QuestionAnsweringResult.php create mode 100644 src/platform/src/Bridge/HuggingFace/Output/SentenceSimilarityResult.php create mode 100644 src/platform/src/Bridge/HuggingFace/Output/TableQuestionAnsweringResult.php create mode 100644 src/platform/src/Bridge/HuggingFace/Output/Token.php create mode 100644 src/platform/src/Bridge/HuggingFace/Output/TokenClassificationResult.php create mode 100644 src/platform/src/Bridge/HuggingFace/Output/ZeroShotClassificationResult.php create mode 100644 src/platform/src/Bridge/HuggingFace/PlatformFactory.php create mode 100644 src/platform/src/Bridge/HuggingFace/Provider.php create mode 100644 src/platform/src/Bridge/HuggingFace/ResponseConverter.php create mode 100644 src/platform/src/Bridge/HuggingFace/Task.php create mode 100644 src/platform/src/Bridge/Meta/Contract/MessageBagNormalizer.php create mode 100644 src/platform/src/Bridge/Meta/Llama.php create mode 100644 src/platform/src/Bridge/Meta/LlamaPromptConverter.php create mode 100644 src/platform/src/Bridge/Mistral/Contract/ToolNormalizer.php create mode 100644 src/platform/src/Bridge/Mistral/Embeddings.php create mode 100644 src/platform/src/Bridge/Mistral/Embeddings/ModelClient.php create mode 100644 src/platform/src/Bridge/Mistral/Embeddings/ResponseConverter.php create mode 100644 src/platform/src/Bridge/Mistral/Llm/ModelClient.php create mode 100644 src/platform/src/Bridge/Mistral/Llm/ResponseConverter.php create mode 100644 src/platform/src/Bridge/Mistral/Mistral.php create mode 100644 src/platform/src/Bridge/Mistral/PlatformFactory.php create mode 100644 src/platform/src/Bridge/Ollama/LlamaModelHandler.php create mode 100644 src/platform/src/Bridge/Ollama/PlatformFactory.php create mode 100644 src/platform/src/Bridge/OpenAI/DallE.php create mode 100644 src/platform/src/Bridge/OpenAI/DallE/Base64Image.php create mode 100644 src/platform/src/Bridge/OpenAI/DallE/ImageResponse.php create mode 100644 src/platform/src/Bridge/OpenAI/DallE/ModelClient.php create mode 100644 src/platform/src/Bridge/OpenAI/DallE/UrlImage.php create mode 100644 src/platform/src/Bridge/OpenAI/Embeddings.php create mode 100644 src/platform/src/Bridge/OpenAI/Embeddings/ModelClient.php create mode 100644 src/platform/src/Bridge/OpenAI/Embeddings/ResponseConverter.php create mode 100644 src/platform/src/Bridge/OpenAI/GPT.php create mode 100644 src/platform/src/Bridge/OpenAI/GPT/ModelClient.php create mode 100644 src/platform/src/Bridge/OpenAI/GPT/ResponseConverter.php create mode 100644 src/platform/src/Bridge/OpenAI/PlatformFactory.php create mode 100644 src/platform/src/Bridge/OpenAI/TokenOutputProcessor.php create mode 100644 src/platform/src/Bridge/OpenAI/Whisper.php create mode 100644 src/platform/src/Bridge/OpenAI/Whisper/AudioNormalizer.php create mode 100644 src/platform/src/Bridge/OpenAI/Whisper/ModelClient.php create mode 100644 src/platform/src/Bridge/OpenAI/Whisper/ResponseConverter.php create mode 100644 src/platform/src/Bridge/OpenRouter/Client.php create mode 100644 src/platform/src/Bridge/OpenRouter/PlatformFactory.php create mode 100644 src/platform/src/Bridge/Replicate/Client.php create mode 100644 src/platform/src/Bridge/Replicate/Contract/LlamaMessageBagNormalizer.php create mode 100644 src/platform/src/Bridge/Replicate/LlamaModelClient.php create mode 100644 src/platform/src/Bridge/Replicate/LlamaResponseConverter.php create mode 100644 src/platform/src/Bridge/Replicate/PlatformFactory.php create mode 100644 src/platform/src/Bridge/TransformersPHP/Platform.php create mode 100644 src/platform/src/Bridge/TransformersPHP/PlatformFactory.php create mode 100644 src/platform/src/Bridge/Voyage/ModelHandler.php create mode 100644 src/platform/src/Bridge/Voyage/PlatformFactory.php create mode 100644 src/platform/src/Bridge/Voyage/Voyage.php create mode 100644 src/platform/src/Capability.php create mode 100644 src/platform/src/Contract.php create mode 100644 src/platform/src/Contract/JsonSchema/Attribute/With.php create mode 100644 src/platform/src/Contract/JsonSchema/DescriptionParser.php create mode 100644 src/platform/src/Contract/JsonSchema/Factory.php create mode 100644 src/platform/src/Contract/Normalizer/Message/AssistantMessageNormalizer.php create mode 100644 src/platform/src/Contract/Normalizer/Message/Content/AudioNormalizer.php create mode 100644 src/platform/src/Contract/Normalizer/Message/Content/ImageNormalizer.php create mode 100644 src/platform/src/Contract/Normalizer/Message/Content/ImageUrlNormalizer.php create mode 100644 src/platform/src/Contract/Normalizer/Message/Content/TextNormalizer.php create mode 100644 src/platform/src/Contract/Normalizer/Message/MessageBagNormalizer.php create mode 100644 src/platform/src/Contract/Normalizer/Message/SystemMessageNormalizer.php create mode 100644 src/platform/src/Contract/Normalizer/Message/ToolCallMessageNormalizer.php create mode 100644 src/platform/src/Contract/Normalizer/Message/UserMessageNormalizer.php create mode 100644 src/platform/src/Contract/Normalizer/ModelContractNormalizer.php create mode 100644 src/platform/src/Contract/Normalizer/Response/ToolCallNormalizer.php create mode 100644 src/platform/src/Contract/Normalizer/ToolNormalizer.php create mode 100644 src/platform/src/Exception/ContentFilterException.php create mode 100644 src/platform/src/Exception/ExceptionInterface.php create mode 100644 src/platform/src/Exception/InvalidArgumentException.php create mode 100644 src/platform/src/Exception/RuntimeException.php create mode 100644 src/platform/src/Message/AssistantMessage.php create mode 100644 src/platform/src/Message/Content/Audio.php create mode 100644 src/platform/src/Message/Content/ContentInterface.php create mode 100644 src/platform/src/Message/Content/Document.php create mode 100644 src/platform/src/Message/Content/DocumentUrl.php create mode 100644 src/platform/src/Message/Content/File.php create mode 100644 src/platform/src/Message/Content/Image.php create mode 100644 src/platform/src/Message/Content/ImageUrl.php create mode 100644 src/platform/src/Message/Content/Text.php create mode 100644 src/platform/src/Message/Message.php create mode 100644 src/platform/src/Message/MessageBag.php create mode 100644 src/platform/src/Message/MessageBagInterface.php create mode 100644 src/platform/src/Message/MessageInterface.php create mode 100644 src/platform/src/Message/Role.php create mode 100644 src/platform/src/Message/SystemMessage.php create mode 100644 src/platform/src/Message/ToolCallMessage.php create mode 100644 src/platform/src/Message/UserMessage.php create mode 100644 src/platform/src/Model.php create mode 100644 src/platform/src/ModelClientInterface.php create mode 100644 src/platform/src/Platform.php create mode 100644 src/platform/src/PlatformInterface.php create mode 100644 src/platform/src/Response/AsyncResponse.php create mode 100644 src/platform/src/Response/BaseResponse.php create mode 100644 src/platform/src/Response/BinaryResponse.php create mode 100644 src/platform/src/Response/Choice.php create mode 100644 src/platform/src/Response/ChoiceResponse.php create mode 100644 src/platform/src/Response/Exception/RawResponseAlreadySetException.php create mode 100644 src/platform/src/Response/Metadata/Metadata.php create mode 100644 src/platform/src/Response/Metadata/MetadataAwareTrait.php create mode 100644 src/platform/src/Response/ObjectResponse.php create mode 100644 src/platform/src/Response/RawResponseAwareTrait.php create mode 100644 src/platform/src/Response/ResponseInterface.php create mode 100644 src/platform/src/Response/StreamResponse.php create mode 100644 src/platform/src/Response/TextResponse.php create mode 100644 src/platform/src/Response/ToolCall.php create mode 100644 src/platform/src/Response/ToolCallResponse.php create mode 100644 src/platform/src/Response/VectorResponse.php create mode 100644 src/platform/src/ResponseConverterInterface.php create mode 100644 src/platform/src/Tool/ExecutionReference.php create mode 100644 src/platform/src/Tool/Tool.php create mode 100644 src/platform/src/Vector/NullVector.php create mode 100644 src/platform/src/Vector/Vector.php create mode 100644 src/platform/src/Vector/VectorInterface.php create mode 100644 src/platform/tests/Bridge/Anthropic/ResponseConverterTest.php create mode 100644 src/platform/tests/Bridge/Bedrock/Nova/ContractTest.php create mode 100644 src/platform/tests/Bridge/Google/Contract/AssistantMessageNormalizerTest.php create mode 100644 src/platform/tests/Bridge/Google/Contract/MessageBagNormalizerTest.php create mode 100644 src/platform/tests/Bridge/Google/Contract/UserMessageNormalizerTest.php create mode 100644 src/platform/tests/Bridge/HuggingFace/ModelClientTest.php create mode 100644 src/platform/tests/Bridge/Meta/LlamaPromptConverterTest.php create mode 100644 src/platform/tests/Bridge/OpenAI/DallE/Base64ImageTest.php create mode 100644 src/platform/tests/Bridge/OpenAI/DallE/ImageResponseTest.php create mode 100644 src/platform/tests/Bridge/OpenAI/DallE/ModelClientTest.php create mode 100644 src/platform/tests/Bridge/OpenAI/DallE/UrlImageTest.php create mode 100644 src/platform/tests/Bridge/OpenAI/DallETest.php create mode 100644 src/platform/tests/Bridge/OpenAI/Embeddings/ResponseConverterTest.php create mode 100644 src/platform/tests/Bridge/OpenAI/GPT/ResponseConverterTest.php create mode 100644 src/platform/tests/Bridge/OpenAI/TokenOutputProcessorTest.php create mode 100644 src/platform/tests/Contract/JsonSchema/Attribute/ToolParameterTest.php create mode 100644 src/platform/tests/Contract/JsonSchema/DescriptionParserTest.php create mode 100644 src/platform/tests/Contract/JsonSchema/FactoryTest.php create mode 100644 src/platform/tests/Contract/Normalizer/Message/AssistantMessageNormalizerTest.php create mode 100644 src/platform/tests/Contract/Normalizer/Message/Content/AudioNormalizerTest.php create mode 100644 src/platform/tests/Contract/Normalizer/Message/Content/ImageNormalizerTest.php create mode 100644 src/platform/tests/Contract/Normalizer/Message/Content/ImageUrlNormalizerTest.php create mode 100644 src/platform/tests/Contract/Normalizer/Message/Content/TextNormalizerTest.php create mode 100644 src/platform/tests/Contract/Normalizer/Message/MessageBagNormalizerTest.php create mode 100644 src/platform/tests/Contract/Normalizer/Message/SystemMessageNormalizerTest.php create mode 100644 src/platform/tests/Contract/Normalizer/Message/ToolCallMessageNormalizerTest.php create mode 100644 src/platform/tests/Contract/Normalizer/Message/UserMessageNormalizerTest.php create mode 100644 src/platform/tests/Contract/Normalizer/ToolNormalizerTest.php create mode 100644 src/platform/tests/ContractTest.php create mode 100644 src/platform/tests/Message/AssistantMessageTest.php create mode 100644 src/platform/tests/Message/Content/AudioTest.php create mode 100644 src/platform/tests/Message/Content/BinaryTest.php create mode 100644 src/platform/tests/Message/Content/ImageTest.php create mode 100644 src/platform/tests/Message/Content/ImageUrlTest.php create mode 100644 src/platform/tests/Message/Content/TextTest.php create mode 100644 src/platform/tests/Message/MessageBagTest.php create mode 100644 src/platform/tests/Message/MessageTest.php create mode 100644 src/platform/tests/Message/RoleTest.php create mode 100644 src/platform/tests/Message/SystemMessageTest.php create mode 100644 src/platform/tests/Message/ToolCallMessageTest.php create mode 100644 src/platform/tests/Message/UserMessageTest.php create mode 100644 src/platform/tests/ModelTest.php create mode 100644 src/platform/tests/Response/AsyncResponseTest.php create mode 100644 src/platform/tests/Response/BaseResponseTest.php create mode 100644 src/platform/tests/Response/ChoiceResponseTest.php create mode 100644 src/platform/tests/Response/ChoiceTest.php create mode 100644 src/platform/tests/Response/Exception/RawResponseAlreadySetTest.php create mode 100644 src/platform/tests/Response/Metadata/MetadataAwareTraitTest.php create mode 100644 src/platform/tests/Response/Metadata/MetadataTest.php create mode 100644 src/platform/tests/Response/RawResponseAwareTraitTest.php create mode 100644 src/platform/tests/Response/StreamResponseTest.php create mode 100644 src/platform/tests/Response/StructuredResponseTest.php create mode 100644 src/platform/tests/Response/TextResponseTest.php create mode 100644 src/platform/tests/Response/TollCallResponseTest.php create mode 100644 src/platform/tests/Response/ToolCallTest.php create mode 100644 src/store/.gitattributes create mode 100644 src/store/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 src/store/.github/workflows/close-pull-request.yml create mode 100644 src/store/.gitignore create mode 100644 src/store/LICENSE create mode 100644 src/store/composer.json create mode 100644 src/store/phpstan.dist.neon create mode 100644 src/store/phpunit.xml.dist create mode 100644 src/store/src/Bridge/Azure/SearchStore.php create mode 100644 src/store/src/Bridge/ChromaDB/Store.php create mode 100644 src/store/src/Bridge/MongoDB/Store.php create mode 100644 src/store/src/Bridge/Pinecone/Store.php create mode 100644 src/store/src/Document/Metadata.php create mode 100644 src/store/src/Document/TextDocument.php create mode 100644 src/store/src/Document/VectorDocument.php create mode 100644 src/store/src/Embedder.php create mode 100644 src/store/src/Exception/ExceptionInterface.php create mode 100644 src/store/src/Exception/InvalidArgumentException.php create mode 100644 src/store/src/Exception/RuntimeException.php create mode 100644 src/store/src/InitializableStoreInterface.php create mode 100644 src/store/src/StoreInterface.php create mode 100644 src/store/src/VectorStoreInterface.php create mode 100644 src/store/tests/Document/NullVectorTest.php create mode 100644 src/store/tests/Document/VectorTest.php create mode 100644 src/store/tests/Double/PlatformTestHandler.php create mode 100644 src/store/tests/Double/TestStore.php create mode 100644 src/store/tests/EmbedderTest.php diff --git a/.env b/.env new file mode 100644 index 000000000..79f19d23a --- /dev/null +++ b/.env @@ -0,0 +1,66 @@ +# You only need to fill in the values when running the examples, see examples/ + +# For using GPT on OpenAI +OPENAI_API_KEY= + +# For using Claude on Anthropic +ANTHROPIC_API_KEY= + +# For using Mistral +MISTRAL_API_KEY= + +# For using Voyage +VOYAGE_API_KEY= + +# For using Replicate +REPLICATE_API_KEY= + +# For using Ollama +OLLAMA_HOST_URL= + +# For using GPT on Azure +AZURE_OPENAI_BASEURL= +AZURE_OPENAI_KEY= +AZURE_OPENAI_GPT_DEPLOYMENT= +AZURE_OPENAI_GPT_API_VERSION= +AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT= +AZURE_OPENAI_EMBEDDINGS_API_VERSION= +AZURE_OPENAI_WHISPER_DEPLOYMENT= +AZURE_OPENAI_WHISPER_API_VERSION= + +# For using Llama on Azure +AZURE_LLAMA_BASEURL= +AZURE_LLAMA_KEY= + +# For using Bedrock +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION= + +# Hugging Face Access Token +HUGGINGFACE_KEY= + +# For using OpenRouter +OPENROUTER_KEY= + +# For using SerpApi (tool) +SERP_API_KEY= + +# For using Tavily (tool) +TAVILY_API_KEY= + +# For using Brave (tool) +BRAVE_API_KEY= + +# For using MongoDB Atlas (store) +MONGODB_URI= + +# For using Pinecone (store) +PINECONE_API_KEY= +PINECONE_HOST= + +# Some examples are expensive to run, so we disable them by default +RUN_EXPENSIVE_EXAMPLES=false + +# For using Gemini +GOOGLE_API_KEY= diff --git a/.gitignore b/.gitignore index 3c8128ab5..1abc003df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .doctor-rst.cache .php-cs-fixer.cache .phpunit.result.cache +.env.local /composer.lock /vendor diff --git a/composer.json b/composer.json index 0024d3c17..7d404e74c 100644 --- a/composer.json +++ b/composer.json @@ -6,8 +6,25 @@ ], "require-dev": { "php": ">=8.1", + "symfony/ai-agent": "@dev", + "symfony/ai-platform": "@dev", + "symfony/console": "^6.4|^7.0", + "symfony/css-selector": "^6.4|^7.0", + "symfony/dom-crawler": "^6.4|^7.0", + "symfony/dotenv": "^6.4|^7.0", "symfony/filesystem": "^6.4|^7.0", "symfony/finder": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", "php-cs-fixer/shim": "^3.75" + }, + "repositories": [ + { "type": "path", "url": "src/agent" }, + { "type": "path", "url": "src/platform" } + ], + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\Fixtures\\": "fixtures/" + } } } diff --git a/example b/example new file mode 100755 index 000000000..bcb6a135b --- /dev/null +++ b/example @@ -0,0 +1,110 @@ +#!/usr/bin/env php +setDescription('Runs all Symfony AI examples in folder examples/') + ->addArgument('subdirectory', InputArgument::OPTIONAL, 'Subdirectory to run examples from, e.g. "anthropic" or "huggingface".') + ->setCode(function (InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); + $io->title('Symfony AI Examples'); + + $directory = __DIR__.'/examples'; + + if ($subdirectory = $input->getArgument('subdirectory')) { + $directory .= '/'.$subdirectory; + if (!is_dir($directory)) { + $io->error(sprintf('Subdirectory "%s" does not exist.', $subdirectory)); + return Command::FAILURE; + } + } + + $examples = (new Finder()) + ->in($directory) + ->name('*.php') + ->sortByName() + ->files(); + + /** @var array{example: SplFileInfo, process: Process} $exampleRuns */ + $exampleRuns = []; + foreach ($examples as $example) { + $exampleRuns[] = [ + 'example' => $example, + 'process' => $process = new Process(['php', $example->getRealPath()]), + ]; + $process->start(); + } + + $section = $output->section(); + $renderTable = function () use ($exampleRuns, $section) { + $section->clear(); + $table = new Table($section); + $table->setHeaders(['Example', 'State', 'Output']); + foreach ($exampleRuns as $run) { + /** @var SplFileInfo $example */ + /** @var Process $process */ + ['example' => $example, 'process' => $process] = $run; + + $output = str_replace(PHP_EOL, ' ', $process->getOutput()); + $output = strlen($output) <= 100 ? $output : substr($output, 0, 100).'...'; + $emptyOutput = 0 === strlen(trim($output)); + + $state = 'Running'; + if ($process->isTerminated()) { + $success = $process->isSuccessful() && !$emptyOutput; + $state = $success ? 'Finished' + : (1 === $run['process']->getExitCode() || $emptyOutput ? 'Failed' : 'Skipped'); + } + + $table->addRow([$example->getRelativePathname(), $state, $output]); + } + $table->render(); + }; + + $examplesRunning = fn () => array_reduce($exampleRuns, fn ($running, $example) => $running || $example['process']->isRunning(), false); + while ($examplesRunning()) { + $renderTable(); + sleep(1); + } + + $renderTable(); + $io->newLine(); + + $successCount = array_reduce($exampleRuns, function ($count, $example) { + if ($example['process']->isSuccessful() && strlen(trim($example['process']->getOutput())) > 0) { + return $count + 1; + } + return $count; + }, 0); + + $totalCount = count($exampleRuns); + + if ($successCount < $totalCount) { + $io->warning(sprintf('%d out of %d examples ran successfully.', $successCount, $totalCount)); + } else { + $io->success(sprintf('All %d examples ran successfully!', $totalCount)); + } + + foreach ($exampleRuns as $run) { + if (!$run['process']->isSuccessful()) { + $io->section('Error in ' . $run['example']->getRelativePathname()); + $io->text($run['process']->getOutput()); + $io->text($run['process']->getErrorOutput()); + } + } + + return Command::SUCCESS; + }) + ->run(); diff --git a/examples/anthropic/chat.php b/examples/anthropic/chat.php new file mode 100644 index 000000000..b33e34f2f --- /dev/null +++ b/examples/anthropic/chat.php @@ -0,0 +1,28 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['ANTHROPIC_API_KEY'])) { + echo 'Please set the ANTHROPIC_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['ANTHROPIC_API_KEY']); +$model = new Claude(Claude::SONNET_37); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are a pirate and you write funny.'), + Message::ofUser('What is the Symfony framework?'), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/anthropic/image-input-binary.php b/examples/anthropic/image-input-binary.php new file mode 100644 index 000000000..6df96ae64 --- /dev/null +++ b/examples/anthropic/image-input-binary.php @@ -0,0 +1,32 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['ANTHROPIC_API_KEY'])) { + echo 'Please set the ANTHROPIC_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['ANTHROPIC_API_KEY']); +$model = new Claude(Claude::SONNET_37); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), + Message::ofUser( + Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'), + 'Describe this image.', + ), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/anthropic/image-input-url.php b/examples/anthropic/image-input-url.php new file mode 100644 index 000000000..cceed03c4 --- /dev/null +++ b/examples/anthropic/image-input-url.php @@ -0,0 +1,32 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['ANTHROPIC_API_KEY'])) { + echo 'Please set the ANTHROPIC_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['ANTHROPIC_API_KEY']); +$model = new Claude(Claude::SONNET_37); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), + Message::ofUser( + new ImageUrl('https://upload.wikimedia.org/wikipedia/commons/a/a7/Camponotus_flavomarginatus_ant.jpg'), + 'Describe this image.', + ), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/anthropic/pdf-input-binary.php b/examples/anthropic/pdf-input-binary.php new file mode 100644 index 000000000..f74beb025 --- /dev/null +++ b/examples/anthropic/pdf-input-binary.php @@ -0,0 +1,31 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['ANTHROPIC_API_KEY'])) { + echo 'Please set the ANTHROPIC_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['ANTHROPIC_API_KEY']); +$model = new Claude(Claude::SONNET_37); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::ofUser( + Document::fromFile(dirname(__DIR__, 2).'/tests/Fixture/document.pdf'), + 'What is this document about?', + ), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/anthropic/pdf-input-url.php b/examples/anthropic/pdf-input-url.php new file mode 100644 index 000000000..cbe4211b2 --- /dev/null +++ b/examples/anthropic/pdf-input-url.php @@ -0,0 +1,31 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['ANTHROPIC_API_KEY'])) { + echo 'Please set the ANTHROPIC_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['ANTHROPIC_API_KEY']); +$model = new Claude(Claude::SONNET_37); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::ofUser( + new DocumentUrl('https://upload.wikimedia.org/wikipedia/commons/2/20/Re_example.pdf'), + 'What is this document about?', + ), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/anthropic/stream.php b/examples/anthropic/stream.php new file mode 100644 index 000000000..757acfbf4 --- /dev/null +++ b/examples/anthropic/stream.php @@ -0,0 +1,33 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['ANTHROPIC_API_KEY'])) { + echo 'Please set the ANTHROPIC_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['ANTHROPIC_API_KEY']); +$model = new Claude(); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are a thoughtful philosopher.'), + Message::ofUser('What is the purpose of an ant?'), +); +$response = $agent->call($messages, [ + 'stream' => true, // enable streaming of response text +]); + +foreach ($response->getContent() as $word) { + echo $word; +} +echo \PHP_EOL; diff --git a/examples/anthropic/toolcall.php b/examples/anthropic/toolcall.php new file mode 100644 index 000000000..16ba6616a --- /dev/null +++ b/examples/anthropic/toolcall.php @@ -0,0 +1,33 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['ANTHROPIC_API_KEY'])) { + echo 'Please set the ANTHROPIC_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['ANTHROPIC_API_KEY']); +$model = new Claude(); + +$wikipedia = new Wikipedia(HttpClient::create()); +$toolbox = Toolbox::create($wikipedia); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor]); + +$messages = new MessageBag(Message::ofUser('Who is the current chancellor of Germany?')); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/azure/audio-transcript.php b/examples/azure/audio-transcript.php new file mode 100644 index 000000000..c25706f68 --- /dev/null +++ b/examples/azure/audio-transcript.php @@ -0,0 +1,28 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['AZURE_OPENAI_BASEURL']) || empty($_ENV['AZURE_OPENAI_WHISPER_DEPLOYMENT']) || empty($_ENV['AZURE_OPENAI_WHISPER_API_VERSION']) || empty($_ENV['AZURE_OPENAI_KEY']) +) { + echo 'Please set the AZURE_OPENAI_BASEURL, AZURE_OPENAI_WHISPER_DEPLOYMENT, AZURE_OPENAI_WHISPER_API_VERSION, and AZURE_OPENAI_KEY environment variables.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create( + $_ENV['AZURE_OPENAI_BASEURL'], + $_ENV['AZURE_OPENAI_WHISPER_DEPLOYMENT'], + $_ENV['AZURE_OPENAI_WHISPER_API_VERSION'], + $_ENV['AZURE_OPENAI_KEY'], +); +$model = new Whisper(); +$file = Audio::fromFile(dirname(__DIR__, 2).'/tests/Fixture/audio.mp3'); + +$response = $platform->request($model, $file); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/azure/chat-gpt.php b/examples/azure/chat-gpt.php new file mode 100644 index 000000000..b38a8aaae --- /dev/null +++ b/examples/azure/chat-gpt.php @@ -0,0 +1,34 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['AZURE_OPENAI_BASEURL']) || empty($_ENV['AZURE_OPENAI_GPT_DEPLOYMENT']) || empty($_ENV['AZURE_OPENAI_GPT_API_VERSION']) || empty($_ENV['AZURE_OPENAI_KEY']) +) { + echo 'Please set the AZURE_OPENAI_BASEURL, AZURE_OPENAI_GPT_DEPLOYMENT, AZURE_OPENAI_GPT_API_VERSION, and AZURE_OPENAI_KEY environment variables.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create( + $_ENV['AZURE_OPENAI_BASEURL'], + $_ENV['AZURE_OPENAI_GPT_DEPLOYMENT'], + $_ENV['AZURE_OPENAI_GPT_API_VERSION'], + $_ENV['AZURE_OPENAI_KEY'], +); +$model = new GPT(GPT::GPT_4O_MINI); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are a pirate and you write funny.'), + Message::ofUser('What is the Symfony framework?'), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/azure/chat-llama.php b/examples/azure/chat-llama.php new file mode 100644 index 000000000..931c33c89 --- /dev/null +++ b/examples/azure/chat-llama.php @@ -0,0 +1,31 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['AZURE_LLAMA_BASEURL']) || empty($_ENV['AZURE_LLAMA_KEY'])) { + echo 'Please set the AZURE_LLAMA_BASEURL and AZURE_LLAMA_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['AZURE_LLAMA_BASEURL'], $_ENV['AZURE_LLAMA_KEY']); +$model = new Llama(Llama::V3_3_70B_INSTRUCT); + +$agent = new Agent($platform, $model); +$messages = new MessageBag(Message::ofUser('I am going to Paris, what should I see?')); +$response = $agent->call($messages, [ + 'max_tokens' => 2048, + 'temperature' => 0.8, + 'top_p' => 0.1, + 'presence_penalty' => 0, + 'frequency_penalty' => 0, +]); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/azure/embeddings.php b/examples/azure/embeddings.php new file mode 100644 index 000000000..407063256 --- /dev/null +++ b/examples/azure/embeddings.php @@ -0,0 +1,33 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['AZURE_OPENAI_BASEURL']) || empty($_ENV['AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT']) || empty($_ENV['AZURE_OPENAI_EMBEDDINGS_API_VERSION']) || empty($_ENV['AZURE_OPENAI_KEY']) +) { + echo 'Please set the AZURE_OPENAI_BASEURL, AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT, AZURE_OPENAI_EMBEDDINGS_API_VERSION, and AZURE_OPENAI_KEY environment variables.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create( + $_ENV['AZURE_OPENAI_BASEURL'], + $_ENV['AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT'], + $_ENV['AZURE_OPENAI_EMBEDDINGS_API_VERSION'], + $_ENV['AZURE_OPENAI_KEY'], +); +$embeddings = new Embeddings(); + +$response = $platform->request($embeddings, <<getContent()[0]->getDimensions().\PHP_EOL; diff --git a/examples/bedrock/chat-claude.php b/examples/bedrock/chat-claude.php new file mode 100644 index 000000000..550ad978e --- /dev/null +++ b/examples/bedrock/chat-claude.php @@ -0,0 +1,29 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['AWS_ACCESS_KEY_ID']) || empty($_ENV['AWS_SECRET_ACCESS_KEY']) || empty($_ENV['AWS_DEFAULT_REGION']) +) { + echo 'Please set the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION environment variables.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create(); +$model = new Claude(); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You answer questions in short and concise manner.'), + Message::ofUser('What is the Symfony framework?'), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/bedrock/chat-llama.php b/examples/bedrock/chat-llama.php new file mode 100644 index 000000000..4cf1974f0 --- /dev/null +++ b/examples/bedrock/chat-llama.php @@ -0,0 +1,29 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['AWS_ACCESS_KEY_ID']) || empty($_ENV['AWS_SECRET_ACCESS_KEY']) || empty($_ENV['AWS_DEFAULT_REGION']) +) { + echo 'Please set the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION environment variables.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create(); +$model = new Llama(Llama::V3_2_3B_INSTRUCT); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are a pirate and you write funny.'), + Message::ofUser('What is the Symfony framework?'), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/bedrock/chat-nova.php b/examples/bedrock/chat-nova.php new file mode 100644 index 000000000..6ac21c5db --- /dev/null +++ b/examples/bedrock/chat-nova.php @@ -0,0 +1,29 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['AWS_ACCESS_KEY_ID']) || empty($_ENV['AWS_SECRET_ACCESS_KEY']) || empty($_ENV['AWS_DEFAULT_REGION']) +) { + echo 'Please set the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION environment variables.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create(); +$model = new Nova(Nova::PRO); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are a pirate and you write funny.'), + Message::ofUser('What is the Symfony framework?'), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/bedrock/image-claude-binary.php b/examples/bedrock/image-claude-binary.php new file mode 100644 index 000000000..91be24413 --- /dev/null +++ b/examples/bedrock/image-claude-binary.php @@ -0,0 +1,33 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['AWS_ACCESS_KEY_ID']) || empty($_ENV['AWS_SECRET_ACCESS_KEY']) || empty($_ENV['AWS_DEFAULT_REGION']) +) { + echo 'Please set the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION environment variables.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create(); +$model = new Claude(); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), + Message::ofUser( + 'Describe the image as a comedian would do it.', + Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'), + ), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/bedrock/image-nova.php b/examples/bedrock/image-nova.php new file mode 100644 index 000000000..9245ae523 --- /dev/null +++ b/examples/bedrock/image-nova.php @@ -0,0 +1,33 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['AWS_ACCESS_KEY_ID']) || empty($_ENV['AWS_SECRET_ACCESS_KEY']) || empty($_ENV['AWS_DEFAULT_REGION']) +) { + echo 'Please set the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION environment variables.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create(); +$model = new Nova(Nova::PRO); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), + Message::ofUser( + 'Describe the image as a comedian would do it.', + Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'), + ), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/bedrock/toolcall-claude.php b/examples/bedrock/toolcall-claude.php new file mode 100644 index 000000000..a6a6d7746 --- /dev/null +++ b/examples/bedrock/toolcall-claude.php @@ -0,0 +1,34 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['AWS_ACCESS_KEY_ID']) || empty($_ENV['AWS_SECRET_ACCESS_KEY']) || empty($_ENV['AWS_DEFAULT_REGION']) +) { + echo 'Please set the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION environment variables.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create(); +$model = new Claude(); + +$wikipedia = new Wikipedia(HttpClient::create()); +$toolbox = Toolbox::create($wikipedia); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor]); + +$messages = new MessageBag(Message::ofUser('Who is the current chancellor of Germany?')); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/bedrock/toolcall-nova.php b/examples/bedrock/toolcall-nova.php new file mode 100644 index 000000000..f00306a8b --- /dev/null +++ b/examples/bedrock/toolcall-nova.php @@ -0,0 +1,36 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['AWS_ACCESS_KEY_ID']) || empty($_ENV['AWS_SECRET_ACCESS_KEY']) || empty($_ENV['AWS_DEFAULT_REGION']) +) { + echo 'Please set the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION environment variables.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create(); +$model = new Nova(); + +$wikipedia = new Wikipedia(HttpClient::create()); +$toolbox = Toolbox::create($wikipedia); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor]); + +$messages = new MessageBag( + Message::ofUser('Who is the current chancellor of Germany? Use Wikipedia to find the answer.') +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/chat-system-prompt.php b/examples/chat-system-prompt.php new file mode 100644 index 000000000..1107f928d --- /dev/null +++ b/examples/chat-system-prompt.php @@ -0,0 +1,28 @@ +loadEnv(dirname(__DIR__).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI); + +$processor = new SystemPromptInputProcessor('You are Yoda and write like he speaks. But short.'); + +$agent = new Agent($platform, $model, [$processor]); +$messages = new MessageBag(Message::ofUser('What is the meaning of life?')); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/google/chat.php b/examples/google/chat.php new file mode 100644 index 000000000..c78eb5145 --- /dev/null +++ b/examples/google/chat.php @@ -0,0 +1,28 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['GOOGLE_API_KEY'])) { + echo 'Please set the GOOGLE_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['GOOGLE_API_KEY']); +$model = new Gemini(Gemini::GEMINI_2_FLASH); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are a pirate and you write funny.'), + Message::ofUser('What is the Symfony framework?'), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/google/image-input.php b/examples/google/image-input.php new file mode 100644 index 000000000..7e41e3a97 --- /dev/null +++ b/examples/google/image-input.php @@ -0,0 +1,32 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['GOOGLE_API_KEY'])) { + echo 'Please set the GOOGLE_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['GOOGLE_API_KEY']); +$model = new Gemini(Gemini::GEMINI_1_5_FLASH); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), + Message::ofUser( + 'Describe the image as a comedian would do it.', + Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'), + ), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/google/stream.php b/examples/google/stream.php new file mode 100644 index 000000000..aa8caa3f9 --- /dev/null +++ b/examples/google/stream.php @@ -0,0 +1,33 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['GOOGLE_API_KEY'])) { + echo 'Please set the GOOGLE_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['GOOGLE_API_KEY']); +$model = new Gemini(Gemini::GEMINI_2_FLASH); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are a funny clown that entertains people.'), + Message::ofUser('What is the purpose of an ant?'), +); +$response = $agent->call($messages, [ + 'stream' => true, // enable streaming of response text +]); + +foreach ($response->getContent() as $word) { + echo $word; +} +echo \PHP_EOL; diff --git a/examples/huggingface/_model-listing.php b/examples/huggingface/_model-listing.php new file mode 100644 index 000000000..3b5d2aab2 --- /dev/null +++ b/examples/huggingface/_model-listing.php @@ -0,0 +1,39 @@ +setDescription('Lists all available models on HuggingFace') + ->addOption('provider', 'p', InputOption::VALUE_REQUIRED, 'Name of the inference provider to filter models by') + ->addOption('task', 't', InputOption::VALUE_REQUIRED, 'Name of the task to filter models by') + ->setCode(function (InputInterface $input, OutputInterface $output) { + $io = new SymfonyStyle($input, $output); + $io->title('HuggingFace Model Listing'); + + $provider = $input->getOption('provider'); + $task = $input->getOption('task'); + + $models = (new ApiClient())->models($provider, $task); + + if (0 === count($models)) { + $io->error('No models found for the given provider and task.'); + + return Command::FAILURE; + } + + $io->listing( + array_map(fn (Model $model) => $model->getName(), $models) + ); + + return Command::SUCCESS; + }) + ->run(); diff --git a/examples/huggingface/audio-classification.php b/examples/huggingface/audio-classification.php new file mode 100644 index 000000000..c691d76c1 --- /dev/null +++ b/examples/huggingface/audio-classification.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('MIT/ast-finetuned-audioset-10-10-0.4593'); +$audio = Audio::fromFile(dirname(__DIR__, 2).'/tests/Fixture/audio.mp3'); + +$response = $platform->request($model, $audio, [ + 'task' => Task::AUDIO_CLASSIFICATION, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/automatic-speech-recognition.php b/examples/huggingface/automatic-speech-recognition.php new file mode 100644 index 000000000..ab51e3226 --- /dev/null +++ b/examples/huggingface/automatic-speech-recognition.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('openai/whisper-large-v3'); +$audio = Audio::fromFile(dirname(__DIR__, 2).'/tests/Fixture/audio.mp3'); + +$response = $platform->request($model, $audio, [ + 'task' => Task::AUTOMATIC_SPEECH_RECOGNITION, +]); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/huggingface/chat-completion.php b/examples/huggingface/chat-completion.php new file mode 100644 index 000000000..a324c1992 --- /dev/null +++ b/examples/huggingface/chat-completion.php @@ -0,0 +1,26 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('HuggingFaceH4/zephyr-7b-beta'); + +$messages = new MessageBag(Message::ofUser('Hello, how are you doing today?')); +$response = $platform->request($model, $messages, [ + 'task' => Task::CHAT_COMPLETION, +]); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/huggingface/feature-extraction.php b/examples/huggingface/feature-extraction.php new file mode 100644 index 000000000..ac63149a7 --- /dev/null +++ b/examples/huggingface/feature-extraction.php @@ -0,0 +1,26 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('thenlper/gte-large'); + +$response = $platform->request($model, 'Today is a sunny day and I will get some ice cream.', [ + 'task' => Task::FEATURE_EXTRACTION, +]); + +assert($response instanceof VectorResponse); + +echo 'Dimensions: '.$response->getContent()[0]->getDimensions().\PHP_EOL; diff --git a/examples/huggingface/fill-mask.php b/examples/huggingface/fill-mask.php new file mode 100644 index 000000000..5eac88c82 --- /dev/null +++ b/examples/huggingface/fill-mask.php @@ -0,0 +1,23 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('FacebookAI/xlm-roberta-base'); + +$response = $platform->request($model, 'Hello I\'m a model.', [ + 'task' => Task::FILL_MASK, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/image-classification.php b/examples/huggingface/image-classification.php new file mode 100644 index 000000000..c9f5a956d --- /dev/null +++ b/examples/huggingface/image-classification.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('google/vit-base-patch16-224'); + +$image = Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'); +$response = $platform->request($model, $image, [ + 'task' => Task::IMAGE_CLASSIFICATION, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/image-segmentation.php b/examples/huggingface/image-segmentation.php new file mode 100644 index 000000000..2e6952222 --- /dev/null +++ b/examples/huggingface/image-segmentation.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('nvidia/segformer-b0-finetuned-ade-512-512'); + +$image = Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'); +$response = $platform->request($model, $image, [ + 'task' => Task::IMAGE_SEGMENTATION, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/image-to-text.php b/examples/huggingface/image-to-text.php new file mode 100644 index 000000000..af0ddb237 --- /dev/null +++ b/examples/huggingface/image-to-text.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('Salesforce/blip-image-captioning-base'); + +$image = Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'); +$response = $platform->request($model, $image, [ + 'task' => Task::IMAGE_TO_TEXT, +]); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/huggingface/object-detection.php b/examples/huggingface/object-detection.php new file mode 100644 index 000000000..e02f0b24a --- /dev/null +++ b/examples/huggingface/object-detection.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('facebook/detr-resnet-50'); + +$image = Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'); +$response = $platform->request($model, $image, [ + 'task' => Task::OBJECT_DETECTION, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/question-answering.php b/examples/huggingface/question-answering.php new file mode 100644 index 000000000..78d79fbfa --- /dev/null +++ b/examples/huggingface/question-answering.php @@ -0,0 +1,28 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('deepset/roberta-base-squad2'); + +$input = [ + 'question' => 'What is the capital of France?', + 'context' => 'Paris is the capital and most populous city of France, with an estimated population of 2,175,601 residents as of 2018, in an area of more than 105 square kilometres.', +]; + +$response = $platform->request($model, $input, [ + 'task' => Task::QUESTION_ANSWERING, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/sentence-similarity.php b/examples/huggingface/sentence-similarity.php new file mode 100644 index 000000000..f67411719 --- /dev/null +++ b/examples/huggingface/sentence-similarity.php @@ -0,0 +1,32 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('sentence-transformers/all-MiniLM-L6-v2'); + +$input = [ + 'source_sentence' => 'That is a happy dog', + 'sentences' => [ + 'That is a happy canine', + 'That is a happy cat', + 'Today is a sunny day', + ], +]; + +$response = $platform->request($model, $input, [ + 'task' => Task::SENTENCE_SIMILARITY, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/summarization.php b/examples/huggingface/summarization.php new file mode 100644 index 000000000..3db60942e --- /dev/null +++ b/examples/huggingface/summarization.php @@ -0,0 +1,33 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('facebook/bart-large-cnn'); + +$longText = <<request($model, $longText, [ + 'task' => Task::SUMMARIZATION, +]); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/huggingface/table-question-answering.php b/examples/huggingface/table-question-answering.php new file mode 100644 index 000000000..469f2af71 --- /dev/null +++ b/examples/huggingface/table-question-answering.php @@ -0,0 +1,31 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('microsoft/tapex-base'); + +$input = [ + 'query' => 'select year where city = beijing', + 'table' => [ + 'year' => [1896, 1900, 1904, 2004, 2008, 2012], + 'city' => ['athens', 'paris', 'st. louis', 'athens', 'beijing', 'london'], + ], +]; + +$response = $platform->request($model, $input, [ + 'task' => Task::TABLE_QUESTION_ANSWERING, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/text-classification.php b/examples/huggingface/text-classification.php new file mode 100644 index 000000000..6e59742cf --- /dev/null +++ b/examples/huggingface/text-classification.php @@ -0,0 +1,23 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('ProsusAI/finbert'); + +$response = $platform->request($model, 'I like you. I love you.', [ + 'task' => Task::TEXT_CLASSIFICATION, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/text-generation.php b/examples/huggingface/text-generation.php new file mode 100644 index 000000000..210d3efc8 --- /dev/null +++ b/examples/huggingface/text-generation.php @@ -0,0 +1,23 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('gpt2'); + +$response = $platform->request($model, 'The quick brown fox jumps over the lazy', [ + 'task' => Task::TEXT_GENERATION, +]); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/huggingface/text-to-image.php b/examples/huggingface/text-to-image.php new file mode 100644 index 000000000..f17960026 --- /dev/null +++ b/examples/huggingface/text-to-image.php @@ -0,0 +1,26 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('black-forest-labs/FLUX.1-dev'); + +$response = $platform->request($model, 'Astronaut riding a horse', [ + 'task' => Task::TEXT_TO_IMAGE, +]); + +assert($response instanceof BinaryResponse); + +echo $response->toBase64().\PHP_EOL; diff --git a/examples/huggingface/token-classification.php b/examples/huggingface/token-classification.php new file mode 100644 index 000000000..3aaf8c892 --- /dev/null +++ b/examples/huggingface/token-classification.php @@ -0,0 +1,23 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('dbmdz/bert-large-cased-finetuned-conll03-english'); + +$response = $platform->request($model, 'John Smith works at Microsoft in London.', [ + 'task' => Task::TOKEN_CLASSIFICATION, +]); + +dump($response->getContent()); diff --git a/examples/huggingface/translation.php b/examples/huggingface/translation.php new file mode 100644 index 000000000..a4edfae33 --- /dev/null +++ b/examples/huggingface/translation.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('facebook/mbart-large-50-many-to-many-mmt'); + +$response = $platform->request($model, 'Меня зовут Вольфганг и я живу в Берлине', [ + 'task' => Task::TRANSLATION, + 'src_lang' => 'ru', + 'tgt_lang' => 'en', +]); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/huggingface/zero-shot-classification.php b/examples/huggingface/zero-shot-classification.php new file mode 100644 index 000000000..ee0069413 --- /dev/null +++ b/examples/huggingface/zero-shot-classification.php @@ -0,0 +1,25 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['HUGGINGFACE_KEY'])) { + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['HUGGINGFACE_KEY']); +$model = new Model('facebook/bart-large-mnli'); + +$text = 'Hi, I recently bought a device from your company but it is not working as advertised and I would like to get reimbursed!'; +$response = $platform->request($model, $text, [ + 'task' => Task::ZERO_SHOT_CLASSIFICATION, + 'candidate_labels' => ['refund', 'legal', 'faq'], +]); + +dump($response->getContent()); diff --git a/examples/mistral/chat.php b/examples/mistral/chat.php new file mode 100644 index 000000000..406caad29 --- /dev/null +++ b/examples/mistral/chat.php @@ -0,0 +1,27 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['MISTRAL_API_KEY'])) { + echo 'Please set the REPLICATE_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']); +$model = new Mistral(); +$agent = new Agent($platform, $model); + +$messages = new MessageBag(Message::ofUser('What is the best French cheese?')); +$response = $agent->call($messages, [ + 'temperature' => 0.7, +]); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/mistral/embeddings.php b/examples/mistral/embeddings.php new file mode 100644 index 000000000..b0ff9239f --- /dev/null +++ b/examples/mistral/embeddings.php @@ -0,0 +1,28 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['MISTRAL_API_KEY'])) { + echo 'Please set the MISTRAL_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']); +$model = new Embeddings(); + +$response = $platform->request($model, <<getContent()[0]->getDimensions().\PHP_EOL; diff --git a/examples/mistral/image.php b/examples/mistral/image.php new file mode 100644 index 000000000..ce369f578 --- /dev/null +++ b/examples/mistral/image.php @@ -0,0 +1,32 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']); +$model = new Mistral(Mistral::MISTRAL_SMALL); +$agent = new Agent($platform, $model); + +$messages = new MessageBag( + Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), + Message::ofUser( + 'Describe the image as a comedian would do it.', + Image::fromFile(dirname(__DIR__, 2).'/tests/Fixture/image.jpg'), + ), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/mistral/stream.php b/examples/mistral/stream.php new file mode 100644 index 000000000..12a81d7f3 --- /dev/null +++ b/examples/mistral/stream.php @@ -0,0 +1,30 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['MISTRAL_API_KEY'])) { + echo 'Please set the REPLICATE_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']); +$model = new Mistral(); +$agent = new Agent($platform, $model); + +$messages = new MessageBag(Message::ofUser('What is the eighth prime number?')); +$response = $agent->call($messages, [ + 'stream' => true, +]); + +foreach ($response->getContent() as $word) { + echo $word; +} +echo \PHP_EOL; diff --git a/examples/mistral/structured-output-math.php b/examples/mistral/structured-output-math.php new file mode 100644 index 000000000..358c68221 --- /dev/null +++ b/examples/mistral/structured-output-math.php @@ -0,0 +1,36 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['MISTRAL_API_KEY'])) { + echo 'Please set the MISTRAL_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']); +$model = new Mistral(Mistral::MISTRAL_SMALL); +$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]); + +$processor = new AgentProcessor(new ResponseFormatFactory(), $serializer); +$agent = new Agent($platform, $model, [$processor], [$processor]); +$messages = new MessageBag( + Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'), + Message::ofUser('how can I solve 8x + 7 = -23'), +); +$response = $agent->call($messages, ['output_structure' => MathReasoning::class]); + +dump($response->getContent()); diff --git a/examples/mistral/toolcall-stream.php b/examples/mistral/toolcall-stream.php new file mode 100644 index 000000000..9cd2ea544 --- /dev/null +++ b/examples/mistral/toolcall-stream.php @@ -0,0 +1,38 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['MISTRAL_API_KEY'])) { + echo 'Please set the REPLICATE_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']); +$model = new Mistral(); + +$transcriber = new YouTubeTranscriber(HttpClient::create()); +$toolbox = Toolbox::create($transcriber); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor]); + +$messages = new MessageBag(Message::ofUser('Please summarize this video for me: https://www.youtube.com/watch?v=6uXW-ulpj0s')); +$response = $agent->call($messages, [ + 'stream' => true, +]); + +foreach ($response->getContent() as $word) { + echo $word; +} +echo \PHP_EOL; diff --git a/examples/mistral/toolcall.php b/examples/mistral/toolcall.php new file mode 100644 index 000000000..3c9e5b9ff --- /dev/null +++ b/examples/mistral/toolcall.php @@ -0,0 +1,31 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['MISTRAL_API_KEY'])) { + echo 'Please set the REPLICATE_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['MISTRAL_API_KEY']); +$model = new Mistral(); + +$toolbox = Toolbox::create(new Clock()); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor]); + +$messages = new MessageBag(Message::ofUser('What time is it?')); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/ollama/chat-llama.php b/examples/ollama/chat-llama.php new file mode 100644 index 000000000..e3dc801c5 --- /dev/null +++ b/examples/ollama/chat-llama.php @@ -0,0 +1,28 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OLLAMA_HOST_URL'])) { + echo 'Please set the OLLAMA_HOST_URL environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OLLAMA_HOST_URL']); +$model = new Llama('llama3.2'); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are a helpful assistant.'), + Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/openai/audio-input.php b/examples/openai/audio-input.php new file mode 100644 index 000000000..d7c4b237e --- /dev/null +++ b/examples/openai/audio-input.php @@ -0,0 +1,31 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_AUDIO); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::ofUser( + 'What is this recording about?', + Audio::fromFile(dirname(__DIR__, 2).'/fixtures/audio.mp3'), + ), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/openai/audio-transcript.php b/examples/openai/audio-transcript.php new file mode 100644 index 000000000..20cdacab3 --- /dev/null +++ b/examples/openai/audio-transcript.php @@ -0,0 +1,22 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new Whisper(); +$file = Audio::fromFile(dirname(__DIR__, 2).'/fixtures/audio.mp3'); + +$response = $platform->request($model, $file); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/openai/chat-o1.php b/examples/openai/chat-o1.php new file mode 100644 index 000000000..5f7eb7bf0 --- /dev/null +++ b/examples/openai/chat-o1.php @@ -0,0 +1,37 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +if (empty($_ENV['RUN_EXPENSIVE_EXAMPLES']) || false === filter_var($_ENV['RUN_EXPENSIVE_EXAMPLES'], \FILTER_VALIDATE_BOOLEAN)) { + echo 'This example is marked as expensive and will not run unless RUN_EXPENSIVE_EXAMPLES is set to true.'.\PHP_EOL; + exit(134); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::O1_PREVIEW); + +$prompt = <<call(new MessageBag(Message::ofUser($prompt))); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/openai/chat.php b/examples/openai/chat.php new file mode 100644 index 000000000..9d59648ed --- /dev/null +++ b/examples/openai/chat.php @@ -0,0 +1,32 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI, [ + 'temperature' => 0.5, // default options for the model +]); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are a pirate and you write funny.'), + Message::ofUser('What is the Symfony framework?'), +); +$response = $agent->call($messages, [ + 'max_tokens' => 500, // specific options just for this call +]); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/openai/embeddings.php b/examples/openai/embeddings.php new file mode 100644 index 000000000..a2418b8be --- /dev/null +++ b/examples/openai/embeddings.php @@ -0,0 +1,27 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$embeddings = new Embeddings(); + +$response = $platform->request($embeddings, <<getContent()[0]->getDimensions().\PHP_EOL; diff --git a/examples/openai/image-input-binary.php b/examples/openai/image-input-binary.php new file mode 100644 index 000000000..e7d7c704f --- /dev/null +++ b/examples/openai/image-input-binary.php @@ -0,0 +1,32 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), + Message::ofUser( + 'Describe the image as a comedian would do it.', + Image::fromFile(dirname(__DIR__, 2).'/fixtures/image.jpg'), + ), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/openai/image-input-url.php b/examples/openai/image-input-url.php new file mode 100644 index 000000000..db0054dc3 --- /dev/null +++ b/examples/openai/image-input-url.php @@ -0,0 +1,32 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), + Message::ofUser( + 'Describe the image as a comedian would do it.', + new ImageUrl('https://upload.wikimedia.org/wikipedia/commons/thumb/3/31/Webysther_20160423_-_Elephpant.svg/350px-Webysther_20160423_-_Elephpant.svg.png'), + ), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/openai/image-output-dall-e-2.php b/examples/openai/image-output-dall-e-2.php new file mode 100644 index 000000000..62e93ea63 --- /dev/null +++ b/examples/openai/image-output-dall-e-2.php @@ -0,0 +1,28 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); + +$response = $platform->request( + model: new DallE(), // Utilize Dall-E 2 version in default + input: 'A cartoon-style elephant with a long trunk and large ears.', + options: [ + 'response_format' => 'url', // Generate response as URL + 'n' => 2, // Generate multiple images for example + ], +); + +foreach ($response->getContent() as $index => $image) { + echo 'Image '.$index.': '.$image->url.\PHP_EOL; +} diff --git a/examples/openai/image-output-dall-e-3.php b/examples/openai/image-output-dall-e-3.php new file mode 100644 index 000000000..7bb60110a --- /dev/null +++ b/examples/openai/image-output-dall-e-3.php @@ -0,0 +1,32 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); + +$response = $platform->request( + model: new DallE(name: DallE::DALL_E_3), + input: 'A cartoon-style elephant with a long trunk and large ears.', + options: [ + 'response_format' => 'url', // Generate response as URL + ], +); + +assert($response instanceof ImageResponse); + +echo 'Revised Prompt: '.$response->revisedPrompt.\PHP_EOL.\PHP_EOL; + +foreach ($response->getContent() as $index => $image) { + echo 'Image '.$index.': '.$image->url.\PHP_EOL; +} diff --git a/examples/openai/stream.php b/examples/openai/stream.php new file mode 100644 index 000000000..de7713e95 --- /dev/null +++ b/examples/openai/stream.php @@ -0,0 +1,33 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are a thoughtful philosopher.'), + Message::ofUser('What is the purpose of an ant?'), +); +$response = $agent->call($messages, [ + 'stream' => true, // enable streaming of response text +]); + +foreach ($response->getContent() as $word) { + echo $word; +} +echo \PHP_EOL; diff --git a/examples/openai/structured-output-clock.php b/examples/openai/structured-output-clock.php new file mode 100644 index 000000000..99c44ebd4 --- /dev/null +++ b/examples/openai/structured-output-clock.php @@ -0,0 +1,50 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI); + +$clock = new Clock(new SymfonyClock()); +$toolbox = Toolbox::create($clock); +$toolProcessor = new ToolProcessor($toolbox); +$structuredOutputProcessor = new StructuredOutputProcessor(); +$agent = new Agent($platform, $model, [$toolProcessor, $structuredOutputProcessor], [$toolProcessor, $structuredOutputProcessor]); + +$messages = new MessageBag(Message::ofUser('What date and time is it?')); +$response = $agent->call($messages, ['response_format' => [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => 'clock', + 'strict' => true, + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'date' => ['type' => 'string', 'description' => 'The current date in the format YYYY-MM-DD.'], + 'time' => ['type' => 'string', 'description' => 'The current time in the format HH:MM:SS.'], + ], + 'required' => ['date', 'time'], + 'additionalProperties' => false, + ], + ], +]]); + +dump($response->getContent()); diff --git a/examples/openai/structured-output-math.php b/examples/openai/structured-output-math.php new file mode 100644 index 000000000..435754852 --- /dev/null +++ b/examples/openai/structured-output-math.php @@ -0,0 +1,31 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI); + +$processor = new AgentProcessor(); +$agent = new Agent($platform, $model, [$processor], [$processor]); +$messages = new MessageBag( + Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'), + Message::ofUser('how can I solve 8x + 7 = -23'), +); +$response = $agent->call($messages, ['output_structure' => MathReasoning::class]); + +dump($response->getContent()); diff --git a/examples/openai/token-metadata.php b/examples/openai/token-metadata.php new file mode 100644 index 000000000..5bbdf6123 --- /dev/null +++ b/examples/openai/token-metadata.php @@ -0,0 +1,38 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI, [ + 'temperature' => 0.5, // default options for the model +]); + +$agent = new Agent($platform, $model, outputProcessors: [new TokenOutputProcessor()]); +$messages = new MessageBag( + Message::forSystem('You are a pirate and you write funny.'), + Message::ofUser('What is the Symfony framework?'), +); +$response = $agent->call($messages, [ + 'max_tokens' => 500, // specific options just for this call +]); + +$metadata = $response->getMetadata(); + +echo 'Utilized Tokens: '.$metadata['total_tokens'].\PHP_EOL; +echo '-- Prompt Tokens: '.$metadata['prompt_tokens'].\PHP_EOL; +echo '-- Completion Tokens: '.$metadata['completion_tokens'].\PHP_EOL; +echo 'Remaining Tokens: '.$metadata['remaining_tokens'].\PHP_EOL; diff --git a/examples/openai/toolcall-stream.php b/examples/openai/toolcall-stream.php new file mode 100644 index 000000000..4ddba513d --- /dev/null +++ b/examples/openai/toolcall-stream.php @@ -0,0 +1,42 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI); + +$wikipedia = new Wikipedia(HttpClient::create()); +$toolbox = Toolbox::create($wikipedia); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor]); +$messages = new MessageBag(Message::ofUser(<<call($messages, [ + 'stream' => true, // enable streaming of response text +]); + +foreach ($response->getContent() as $word) { + echo $word; +} + +echo \PHP_EOL; diff --git a/examples/openai/toolcall.php b/examples/openai/toolcall.php new file mode 100644 index 000000000..b42eaa731 --- /dev/null +++ b/examples/openai/toolcall.php @@ -0,0 +1,33 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI); + +$transcriber = new YouTubeTranscriber(HttpClient::create()); +$toolbox = Toolbox::create($transcriber); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor]); + +$messages = new MessageBag(Message::ofUser('Please summarize this video for me: https://www.youtube.com/watch?v=6uXW-ulpj0s')); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/openrouter/chat-gemini.php b/examples/openrouter/chat-gemini.php new file mode 100644 index 000000000..2ef571daf --- /dev/null +++ b/examples/openrouter/chat-gemini.php @@ -0,0 +1,30 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENROUTER_KEY'])) { + echo 'Please set the OPENROUTER_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENROUTER_KEY']); +// In case free is running into 429 rate limit errors, you can use the paid model: +// $model = new Model('google/gemini-2.0-flash-lite-001'); +$model = new Model('google/gemini-2.0-flash-exp:free'); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are a helpful assistant.'), + Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/parallel-chat-gpt.php b/examples/parallel-chat-gpt.php new file mode 100644 index 000000000..1292f6b0d --- /dev/null +++ b/examples/parallel-chat-gpt.php @@ -0,0 +1,36 @@ +loadEnv(dirname(__DIR__).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI, [ + 'temperature' => 0.5, // default options for the model +]); + +$messages = new MessageBag( + Message::forSystem('You will be given a letter and you answer with only the next letter of the alphabet.'), +); + +echo 'Initiating parallel calls to GPT on platform ...'.\PHP_EOL; +$responses = []; +foreach (range('A', 'D') as $letter) { + echo ' - Request for the letter '.$letter.' initiated.'.\PHP_EOL; + $responses[] = $platform->request($model, $messages->with(Message::ofUser($letter))); +} + +echo 'Waiting for the responses ...'.\PHP_EOL; +foreach ($responses as $response) { + echo 'Next Letter: '.$response->getContent().\PHP_EOL; +} diff --git a/examples/parallel-embeddings.php b/examples/parallel-embeddings.php new file mode 100644 index 000000000..54f6d1c6a --- /dev/null +++ b/examples/parallel-embeddings.php @@ -0,0 +1,32 @@ +loadEnv(dirname(__DIR__).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$ada = new Embeddings(Embeddings::TEXT_ADA_002); +$small = new Embeddings(Embeddings::TEXT_3_SMALL); +$large = new Embeddings(Embeddings::TEXT_3_LARGE); + +echo 'Initiating parallel embeddings calls to platform ...'.\PHP_EOL; +$responses = []; +foreach (['ADA' => $ada, 'Small' => $small, 'Large' => $large] as $name => $model) { + echo ' - Request for model '.$name.' initiated.'.\PHP_EOL; + $responses[] = $platform->request($model, 'Hello, world!'); +} + +echo 'Waiting for the responses ...'.\PHP_EOL; +foreach ($responses as $response) { + assert($response instanceof VectorResponse); + echo 'Dimensions: '.$response->getContent()[0]->getDimensions().\PHP_EOL; +} diff --git a/examples/replicate/chat-llama.php b/examples/replicate/chat-llama.php new file mode 100644 index 000000000..b74a494aa --- /dev/null +++ b/examples/replicate/chat-llama.php @@ -0,0 +1,28 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['REPLICATE_API_KEY'])) { + echo 'Please set the REPLICATE_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['REPLICATE_API_KEY']); +$model = new Llama(); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are a helpful assistant.'), + Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'), +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/store/mongodb-similarity-search.php b/examples/store/mongodb-similarity-search.php new file mode 100644 index 000000000..44c1b2b87 --- /dev/null +++ b/examples/store/mongodb-similarity-search.php @@ -0,0 +1,74 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY']) || empty($_ENV['MONGODB_URI'])) { + echo 'Please set OPENAI_API_KEY and MONGODB_URI environment variables.'.\PHP_EOL; + exit(1); +} + +// initialize the store +$store = new Store( + client: new MongoDBClient($_ENV['MONGODB_URI']), + databaseName: 'my-database', + collectionName: 'my-collection', + indexName: 'my-index', + vectorFieldName: 'vector', +); + +// our data +$movies = [ + ['title' => 'Inception', 'description' => 'A skilled thief is given a chance at redemption if he can successfully perform inception, the act of planting an idea in someone\'s subconscious.', 'director' => 'Christopher Nolan'], + ['title' => 'The Matrix', 'description' => 'A hacker discovers the world he lives in is a simulated reality and joins a rebellion to overthrow its controllers.', 'director' => 'The Wachowskis'], + ['title' => 'The Godfather', 'description' => 'The aging patriarch of an organized crime dynasty transfers control of his empire to his reluctant son.', 'director' => 'Francis Ford Coppola'], +]; + +// create embeddings and documents +foreach ($movies as $movie) { + $documents[] = new TextDocument( + id: Uuid::v4(), + content: 'Title: '.$movie['title'].\PHP_EOL.'Director: '.$movie['director'].\PHP_EOL.'Description: '.$movie['description'], + metadata: new Metadata($movie), + ); +} + +// create embeddings for documents +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$embedder = new Embedder($platform, $embeddings = new Embeddings(), $store); +$embedder->embed($documents); + +// initialize the index +$store->initialize(); + +$model = new GPT(GPT::GPT_4O_MINI); + +$similaritySearch = new SimilaritySearch($platform, $embeddings, $store); +$toolbox = Toolbox::create($similaritySearch); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor]); + +$messages = new MessageBag( + Message::forSystem('Please answer all user questions only using SimilaritySearch function.'), + Message::ofUser('Which movie fits the theme of the mafia?') +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/store/pinecone-similarity-search.php b/examples/store/pinecone-similarity-search.php new file mode 100644 index 000000000..06c335471 --- /dev/null +++ b/examples/store/pinecone-similarity-search.php @@ -0,0 +1,65 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY']) || empty($_ENV['PINECONE_API_KEY']) || empty($_ENV['PINECONE_HOST'])) { + echo 'Please set OPENAI_API_KEY, PINECONE_API_KEY and PINECONE_HOST environment variables.'.\PHP_EOL; + exit(1); +} + +// initialize the store +$store = new Store(Pinecone::client($_ENV['PINECONE_API_KEY'], $_ENV['PINECONE_HOST'])); + +// our data +$movies = [ + ['title' => 'Inception', 'description' => 'A skilled thief is given a chance at redemption if he can successfully perform inception, the act of planting an idea in someone\'s subconscious.', 'director' => 'Christopher Nolan'], + ['title' => 'The Matrix', 'description' => 'A hacker discovers the world he lives in is a simulated reality and joins a rebellion to overthrow its controllers.', 'director' => 'The Wachowskis'], + ['title' => 'The Godfather', 'description' => 'The aging patriarch of an organized crime dynasty transfers control of his empire to his reluctant son.', 'director' => 'Francis Ford Coppola'], +]; + +// create embeddings and documents +foreach ($movies as $movie) { + $documents[] = new TextDocument( + id: Uuid::v4(), + content: 'Title: '.$movie['title'].\PHP_EOL.'Director: '.$movie['director'].\PHP_EOL.'Description: '.$movie['description'], + metadata: new Metadata($movie), + ); +} + +// create embeddings for documents +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$embedder = new Embedder($platform, $embeddings = new Embeddings(), $store); +$embedder->embed($documents); + +$model = new GPT(GPT::GPT_4O_MINI); + +$similaritySearch = new SimilaritySearch($platform, $embeddings, $store); +$toolbox = Toolbox::create($similaritySearch); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor]); + +$messages = new MessageBag( + Message::forSystem('Please answer all user questions only using SimilaritySearch function.'), + Message::ofUser('Which movie fits the theme of the mafia?') +); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/toolbox/brave.php b/examples/toolbox/brave.php new file mode 100644 index 000000000..2ec0cff5a --- /dev/null +++ b/examples/toolbox/brave.php @@ -0,0 +1,35 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY']) || empty($_ENV['BRAVE_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY and BRAVE_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI); + +$httpClient = HttpClient::create(); +$brave = new Brave($httpClient, $_ENV['BRAVE_API_KEY']); +$crawler = new Crawler($httpClient); +$toolbox = Toolbox::create($brave, $crawler); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor]); + +$messages = new MessageBag(Message::ofUser('What was the latest game result of Dallas Cowboys?')); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/toolbox/clock.php b/examples/toolbox/clock.php new file mode 100644 index 000000000..262590a73 --- /dev/null +++ b/examples/toolbox/clock.php @@ -0,0 +1,34 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI); + +$metadataFactory = (new MemoryToolFactory()) + ->addTool(Clock::class, 'clock', 'Get the current date and time', 'now'); +$toolbox = new Toolbox($metadataFactory, [new Clock()]); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor]); + +$messages = new MessageBag(Message::ofUser('What date and time is it?')); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/toolbox/serpapi.php b/examples/toolbox/serpapi.php new file mode 100644 index 000000000..178e1c165 --- /dev/null +++ b/examples/toolbox/serpapi.php @@ -0,0 +1,32 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY']) || empty($_ENV['SERP_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY and SERP_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI); + +$serpApi = new SerpApi(HttpClient::create(), $_ENV['SERP_API_KEY']); +$toolbox = Toolbox::create($serpApi); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor]); + +$messages = new MessageBag(Message::ofUser('Who is the current chancellor of Germany?')); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/toolbox/tavily.php b/examples/toolbox/tavily.php new file mode 100644 index 000000000..ca52d17ce --- /dev/null +++ b/examples/toolbox/tavily.php @@ -0,0 +1,32 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY']) || empty($_ENV['TAVILY_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY and TAVILY_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI); + +$tavily = new Tavily(HttpClient::create(), $_ENV['TAVILY_API_KEY']); +$toolbox = Toolbox::create($tavily); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor]); + +$messages = new MessageBag(Message::ofUser('What was the latest game result of Dallas Cowboys?')); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/toolbox/weather-event.php b/examples/toolbox/weather-event.php new file mode 100644 index 000000000..6202df26f --- /dev/null +++ b/examples/toolbox/weather-event.php @@ -0,0 +1,46 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI); + +$openMeteo = new OpenMeteo(HttpClient::create()); +$toolbox = Toolbox::create($openMeteo); +$eventDispatcher = new EventDispatcher(); +$processor = new AgentProcessor($toolbox, eventDispatcher: $eventDispatcher); +$agent = new Agent($platform, $model, [$processor], [$processor]); + +// Add tool call result listener to enforce chain exits direct with structured response for weather tools +$eventDispatcher->addListener(ToolCallsExecuted::class, function (ToolCallsExecuted $event): void { + foreach ($event->toolCallResults as $toolCallResult) { + if (str_starts_with($toolCallResult->toolCall->name, 'weather_')) { + $event->response = new ObjectResponse($toolCallResult->result); + } + } +}); + +$messages = new MessageBag(Message::ofUser('How is the weather currently in Berlin?')); +$response = $agent->call($messages); + +dump($response->getContent()); diff --git a/examples/transformers/text-generation.php b/examples/transformers/text-generation.php new file mode 100644 index 000000000..a7cce6e30 --- /dev/null +++ b/examples/transformers/text-generation.php @@ -0,0 +1,26 @@ +request($model, 'How many continents are there in the world?', [ + 'task' => Task::Text2TextGeneration, +]); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/voyage/embeddings.php b/examples/voyage/embeddings.php new file mode 100644 index 000000000..f2c9be7e4 --- /dev/null +++ b/examples/voyage/embeddings.php @@ -0,0 +1,27 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['VOYAGE_API_KEY'])) { + echo 'Please set the VOYAGE_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['VOYAGE_API_KEY']); +$embeddings = new Voyage(); + +$response = $platform->request($embeddings, <<getContent()[0]->getDimensions().\PHP_EOL; diff --git a/fixtures/SomeStructure.php b/fixtures/SomeStructure.php new file mode 100644 index 000000000..e04ad2932 --- /dev/null +++ b/fixtures/SomeStructure.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixture; + +final class SomeStructure +{ + public string $some; +} diff --git a/fixtures/StructuredOutput/MathReasoning.php b/fixtures/StructuredOutput/MathReasoning.php new file mode 100644 index 000000000..9d49f9842 --- /dev/null +++ b/fixtures/StructuredOutput/MathReasoning.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\StructuredOutput; + +final class MathReasoning +{ + /** + * @param Step[] $steps + */ + public function __construct( + public array $steps, + public string $finalAnswer, + ) { + } +} diff --git a/fixtures/StructuredOutput/Step.php b/fixtures/StructuredOutput/Step.php new file mode 100644 index 000000000..8ef356149 --- /dev/null +++ b/fixtures/StructuredOutput/Step.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\StructuredOutput; + +final class Step +{ + public function __construct( + public string $explanation, + public string $output, + ) { + } +} diff --git a/fixtures/StructuredOutput/User.php b/fixtures/StructuredOutput/User.php new file mode 100644 index 000000000..f006b936c --- /dev/null +++ b/fixtures/StructuredOutput/User.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\StructuredOutput; + +final class User +{ + public int $id; + /** + * @var string The name of the user in lowercase + */ + public string $name; + public \DateTimeInterface $createdAt; + public bool $isActive; + public ?int $age = null; +} diff --git a/fixtures/StructuredOutput/UserWithConstructor.php b/fixtures/StructuredOutput/UserWithConstructor.php new file mode 100644 index 000000000..ceb7fa65d --- /dev/null +++ b/fixtures/StructuredOutput/UserWithConstructor.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\StructuredOutput; + +final class UserWithConstructor +{ + /** + * @param string $name The name of the user in lowercase + */ + public function __construct( + public int $id, + public string $name, + public \DateTimeInterface $createdAt, + public bool $isActive, + public ?int $age = null, + ) { + } +} diff --git a/fixtures/Tool/ToolArray.php b/fixtures/Tool/ToolArray.php new file mode 100644 index 000000000..23ff813e1 --- /dev/null +++ b/fixtures/Tool/ToolArray.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +#[AsTool('tool_no_params', 'A tool without parameters')] +final class ToolArray +{ + /** + * @param string[] $urls + * @param list $ids + */ + public function __invoke(array $urls, array $ids): string + { + return 'Hello world!'; + } +} diff --git a/fixtures/Tool/ToolException.php b/fixtures/Tool/ToolException.php new file mode 100644 index 000000000..f53aef7bc --- /dev/null +++ b/fixtures/Tool/ToolException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +#[AsTool('tool_exception', description: 'This tool is broken', method: 'bar')] +final class ToolException +{ + public function bar(): string + { + throw new \Exception('Tool error.'); + } +} diff --git a/fixtures/Tool/ToolMisconfigured.php b/fixtures/Tool/ToolMisconfigured.php new file mode 100644 index 000000000..25abd67ac --- /dev/null +++ b/fixtures/Tool/ToolMisconfigured.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +#[AsTool('tool_misconfigured', description: 'This tool is misconfigured, see method', method: 'foo')] +final class ToolMisconfigured +{ + public function bar(): string + { + return 'Wrong Config Attribute'; + } +} diff --git a/fixtures/Tool/ToolMultiple.php b/fixtures/Tool/ToolMultiple.php new file mode 100644 index 000000000..861b33ba2 --- /dev/null +++ b/fixtures/Tool/ToolMultiple.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +#[AsTool('tool_hello_world', 'Function to say hello', method: 'hello')] +#[AsTool('tool_required_params', 'Function to say a number', method: 'bar')] +final class ToolMultiple +{ + /** + * @param string $world The world to say hello to + */ + public function hello(string $world): string + { + return \sprintf('Hello "%s".', $world); + } + + /** + * @param string $text The text given to the tool + * @param int $number A number given to the tool + */ + public function bar(string $text, int $number): string + { + return \sprintf('%s says "%d".', $text, $number); + } +} diff --git a/fixtures/Tool/ToolNoAttribute1.php b/fixtures/Tool/ToolNoAttribute1.php new file mode 100644 index 000000000..c3fbc64a6 --- /dev/null +++ b/fixtures/Tool/ToolNoAttribute1.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\Tool; + +final class ToolNoAttribute1 +{ + /** + * @param string $name the name of the person + * @param int $years the age of the person + */ + public function __invoke(string $name, int $years): string + { + return \sprintf('Happy Birthday, %s! You are %d years old.', $name, $years); + } +} diff --git a/fixtures/Tool/ToolNoAttribute2.php b/fixtures/Tool/ToolNoAttribute2.php new file mode 100644 index 000000000..28345652d --- /dev/null +++ b/fixtures/Tool/ToolNoAttribute2.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\Tool; + +final class ToolNoAttribute2 +{ + /** + * @param int $id the ID of the product + * @param int $amount the number of products + */ + public function buy(int $id, int $amount): string + { + return \sprintf('You bought %d of product %d.', $amount, $id); + } + + /** + * @param string $orderId the ID of the order + */ + public function cancel(string $orderId): string + { + return \sprintf('You canceled order %s.', $orderId); + } +} diff --git a/fixtures/Tool/ToolNoParams.php b/fixtures/Tool/ToolNoParams.php new file mode 100644 index 000000000..1d7e2586d --- /dev/null +++ b/fixtures/Tool/ToolNoParams.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +#[AsTool('tool_no_params', 'A tool without parameters')] +final class ToolNoParams +{ + public function __invoke(): string + { + return 'Hello world!'; + } +} diff --git a/fixtures/Tool/ToolOptionalParam.php b/fixtures/Tool/ToolOptionalParam.php new file mode 100644 index 000000000..281c5f4e0 --- /dev/null +++ b/fixtures/Tool/ToolOptionalParam.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +#[AsTool('tool_optional_param', 'A tool with one optional parameter', method: 'bar')] +final class ToolOptionalParam +{ + /** + * @param string $text The text given to the tool + * @param int $number A number given to the tool + */ + public function bar(string $text, int $number = 3): string + { + return \sprintf('%s says "%d".', $text, $number); + } +} diff --git a/fixtures/Tool/ToolRequiredParams.php b/fixtures/Tool/ToolRequiredParams.php new file mode 100644 index 000000000..3b56204fb --- /dev/null +++ b/fixtures/Tool/ToolRequiredParams.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +#[AsTool('tool_required_params', 'A tool with required parameters', method: 'bar')] +final class ToolRequiredParams +{ + /** + * @param string $text The text given to the tool + * @param int $number A number given to the tool + */ + public function bar(string $text, int $number): string + { + return \sprintf('%s says "%d".', $text, $number); + } +} diff --git a/fixtures/Tool/ToolWithToolParameterAttribute.php b/fixtures/Tool/ToolWithToolParameterAttribute.php new file mode 100644 index 000000000..6c6803980 --- /dev/null +++ b/fixtures/Tool/ToolWithToolParameterAttribute.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; + +#[AsTool('tool_with_ToolParameter_attribute', 'A tool which has a parameter with described with #[ToolParameter] attribute')] +final class ToolWithToolParameterAttribute +{ + /** + * @param string $animal The animal given to the tool + * @param int $numberOfArticles The number of articles given to the tool + * @param string $infoEmail The info email given to the tool + * @param string $locales The locales given to the tool + * @param string $text The text given to the tool + * @param int $number The number given to the tool + * @param array $products The products given to the tool + * @param string $shippingAddress The shipping address given to the tool + */ + public function __invoke( + #[With(enum: ['dog', 'cat', 'bird'])] + string $animal, + #[With(const: 42)] + int $numberOfArticles, + #[With(const: 'info@example.de')] + string $infoEmail, + #[With(const: ['de', 'en'])] + string $locales, + #[With( + pattern: '^[a-zA-Z]+$', + minLength: 1, + maxLength: 10, + )] + string $text, + #[With( + minimum: 1, + maximum: 10, + multipleOf: 2, + exclusiveMinimum: 1, + exclusiveMaximum: 10, + )] + int $number, + #[With( + minItems: 1, + maxItems: 10, + uniqueItems: true, + minContains: 1, + maxContains: 10, + )] + array $products, + #[With( + required: true, + minProperties: 1, + maxProperties: 10, + dependentRequired: true, + )] + string $shippingAddress, + ): string { + return 'Hello, World!'; + } +} diff --git a/fixtures/Tool/ToolWithoutDocs.php b/fixtures/Tool/ToolWithoutDocs.php new file mode 100644 index 000000000..29a172558 --- /dev/null +++ b/fixtures/Tool/ToolWithoutDocs.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +#[AsTool('tool_without_docs', 'A tool with required parameters', method: 'bar')] +final class ToolWithoutDocs +{ + public function bar(string $text, int $number): string + { + return \sprintf('%s says "%d".', $text, $number); + } +} diff --git a/fixtures/Tool/ToolWrong.php b/fixtures/Tool/ToolWrong.php new file mode 100644 index 000000000..7297b4f13 --- /dev/null +++ b/fixtures/Tool/ToolWrong.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\Tool; + +final class ToolWrong +{ + /** + * @param string $text The text given to the tool + * @param int $number A number given to the tool + */ + public function bar(string $text, int $number): string + { + return \sprintf('%s says "%d".', $text, $number); + } +} diff --git a/fixtures/audio.mp3 b/fixtures/audio.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..509aa0fc4cfb7766ea92936a78d2796d8b966e6f GIT binary patch literal 51019 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 z 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 Q3cz-@2@tbjQ%Ip00E9Or6951J literal 0 HcmV?d00001 diff --git a/fixtures/document.pdf b/fixtures/document.pdf new file mode 100644 index 0000000000000000000000000000000000000000..00172f2664eeb926ac57e750ca6b58b6d3e889ac GIT binary patch literal 18404 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.2", + "ext-fileinfo": "*", + "oskarstark/enum-helper": "^1.5", + "phpdocumentor/reflection-docblock": "^5.4", + "phpstan/phpdoc-parser": "^2.1", + "psr/cache": "^3.0", + "psr/log": "^3.0", + "symfony/clock": "^6.4 || ^7.1", + "symfony/http-client": "^6.4 || ^7.1", + "symfony/property-access": "^6.4 || ^7.1", + "symfony/property-info": "^6.4 || ^7.1", + "symfony/serializer": "^6.4 || ^7.1", + "symfony/type-info": "^7.2.3", + "symfony/uid": "^6.4 || ^7.1", + "webmozart/assert": "^1.11" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^11.5", + "symfony/console": "^6.4 || ^7.1", + "symfony/css-selector": "^6.4 || ^7.1", + "symfony/dom-crawler": "^6.4 || ^7.1", + "symfony/dotenv": "^6.4 || ^7.1", + "symfony/event-dispatcher": "^6.4 || ^7.1", + "symfony/finder": "^6.4 || ^7.1", + "symfony/process": "^6.4 || ^7.1", + "symfony/var-dumper": "^6.4 || ^7.1" + }, + "suggest": { + "symfony/css-selector": "For using the YouTube transcription tool.", + "symfony/dom-crawler": "For using the YouTube transcription tool." + }, + "config": { + "sort-packages": true + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\Agent\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\Agent\\Tests\\": "tests/" + } + } +} diff --git a/src/agent/phpstan.dist.neon b/src/agent/phpstan.dist.neon new file mode 100644 index 000000000..8cc83f644 --- /dev/null +++ b/src/agent/phpstan.dist.neon @@ -0,0 +1,10 @@ +includes: + - vendor/phpstan/phpstan-webmozart-assert/extension.neon + - vendor/phpstan/phpstan-symfony/extension.neon + +parameters: + level: 6 + paths: + - src/ + - tests/ + diff --git a/src/agent/src/Agent.php b/src/agent/src/Agent.php new file mode 100644 index 000000000..1e9eb1566 --- /dev/null +++ b/src/agent/src/Agent.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\AI\Agent\Exception\InvalidArgumentException; +use Symfony\AI\Agent\Exception\MissingModelSupportException; +use Symfony\AI\Agent\Exception\RuntimeException; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Response\AsyncResponse; +use Symfony\AI\Platform\Response\ResponseInterface; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface; + +/** + * @author Christopher Hertel + */ +final readonly class Agent implements AgentInterface +{ + /** + * @var InputProcessorInterface[] + */ + private array $inputProcessors; + + /** + * @var OutputProcessorInterface[] + */ + private array $outputProcessors; + + /** + * @param InputProcessorInterface[] $inputProcessors + * @param OutputProcessorInterface[] $outputProcessors + */ + public function __construct( + private PlatformInterface $platform, + private Model $model, + iterable $inputProcessors = [], + iterable $outputProcessors = [], + private LoggerInterface $logger = new NullLogger(), + ) { + $this->inputProcessors = $this->initializeProcessors($inputProcessors, InputProcessorInterface::class); + $this->outputProcessors = $this->initializeProcessors($outputProcessors, OutputProcessorInterface::class); + } + + /** + * @param array $options + */ + public function call(MessageBagInterface $messages, array $options = []): ResponseInterface + { + $input = new Input($this->model, $messages, $options); + array_map(fn (InputProcessorInterface $processor) => $processor->processInput($input), $this->inputProcessors); + + $model = $input->model; + $messages = $input->messages; + $options = $input->getOptions(); + + if ($messages->containsAudio() && !$model->supports(Capability::INPUT_AUDIO)) { + throw MissingModelSupportException::forAudioInput($model::class); + } + + if ($messages->containsImage() && !$model->supports(Capability::INPUT_IMAGE)) { + throw MissingModelSupportException::forImageInput($model::class); + } + + try { + $response = $this->platform->request($model, $messages, $options); + + if ($response instanceof AsyncResponse) { + $response = $response->unwrap(); + } + } catch (ClientExceptionInterface $e) { + $message = $e->getMessage(); + $content = $e->getResponse()->toArray(false); + + $this->logger->debug($message, $content); + + throw new InvalidArgumentException('' === $message ? 'Invalid request to model or platform' : $message, previous: $e); + } catch (HttpExceptionInterface $e) { + throw new RuntimeException('Failed to request model', previous: $e); + } + + $output = new Output($model, $response, $messages, $options); + array_map(fn (OutputProcessorInterface $processor) => $processor->processOutput($output), $this->outputProcessors); + + return $output->response; + } + + /** + * @param InputProcessorInterface[]|OutputProcessorInterface[] $processors + * @param class-string $interface + * + * @return InputProcessorInterface[]|OutputProcessorInterface[] + */ + private function initializeProcessors(iterable $processors, string $interface): array + { + foreach ($processors as $processor) { + if (!$processor instanceof $interface) { + throw new InvalidArgumentException(\sprintf('Processor %s must implement %s interface.', $processor::class, $interface)); + } + + if ($processor instanceof AgentAwareInterface) { + $processor->setAgent($this); + } + } + + return $processors instanceof \Traversable ? iterator_to_array($processors) : $processors; + } +} diff --git a/src/agent/src/AgentAwareInterface.php b/src/agent/src/AgentAwareInterface.php new file mode 100644 index 000000000..843da2c74 --- /dev/null +++ b/src/agent/src/AgentAwareInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +/** + * @author Christopher Hertel + */ +interface AgentAwareInterface +{ + public function setAgent(AgentInterface $agent): void; +} diff --git a/src/agent/src/AgentAwareTrait.php b/src/agent/src/AgentAwareTrait.php new file mode 100644 index 000000000..56b826843 --- /dev/null +++ b/src/agent/src/AgentAwareTrait.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +/** + * @author Christopher Hertel + */ +trait AgentAwareTrait +{ + private AgentInterface $agent; + + public function setAgent(AgentInterface $agent): void + { + $this->agent = $agent; + } +} diff --git a/src/agent/src/AgentInterface.php b/src/agent/src/AgentInterface.php new file mode 100644 index 000000000..d205836dd --- /dev/null +++ b/src/agent/src/AgentInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Response\ResponseInterface; + +/** + * @author Denis Zunke + */ +interface AgentInterface +{ + /** + * @param array $options + */ + public function call(MessageBagInterface $messages, array $options = []): ResponseInterface; +} diff --git a/src/agent/src/Exception/ExceptionInterface.php b/src/agent/src/Exception/ExceptionInterface.php new file mode 100644 index 000000000..606960fc2 --- /dev/null +++ b/src/agent/src/Exception/ExceptionInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Exception; + +/** + * @author Oskar Stark + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/agent/src/Exception/InvalidArgumentException.php b/src/agent/src/Exception/InvalidArgumentException.php new file mode 100644 index 000000000..71e15909f --- /dev/null +++ b/src/agent/src/Exception/InvalidArgumentException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Exception; + +/** + * @author Oskar Stark + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/agent/src/Exception/LogicException.php b/src/agent/src/Exception/LogicException.php new file mode 100644 index 000000000..3eff060d9 --- /dev/null +++ b/src/agent/src/Exception/LogicException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Exception; + +/** + * @author Oskar Stark + */ +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/agent/src/Exception/MissingModelSupportException.php b/src/agent/src/Exception/MissingModelSupportException.php new file mode 100644 index 000000000..eb43df1ec --- /dev/null +++ b/src/agent/src/Exception/MissingModelSupportException.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Exception; + +/** + * @author Christopher Hertel + */ +final class MissingModelSupportException extends RuntimeException +{ + private function __construct(string $model, string $support) + { + parent::__construct(\sprintf('Model "%s" does not support "%s".', $model, $support)); + } + + public static function forToolCalling(string $model): self + { + return new self($model, 'tool calling'); + } + + public static function forAudioInput(string $model): self + { + return new self($model, 'audio input'); + } + + public static function forImageInput(string $model): self + { + return new self($model, 'image input'); + } + + public static function forStructuredOutput(string $model): self + { + return new self($model, 'structured output'); + } +} diff --git a/src/agent/src/Exception/RuntimeException.php b/src/agent/src/Exception/RuntimeException.php new file mode 100644 index 000000000..c0a2ee8db --- /dev/null +++ b/src/agent/src/Exception/RuntimeException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Exception; + +/** + * @author Oskar Stark + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/agent/src/Input.php b/src/agent/src/Input.php new file mode 100644 index 000000000..7b0bd905e --- /dev/null +++ b/src/agent/src/Input.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +final class Input +{ + /** + * @param array $options + */ + public function __construct( + public Model $model, + public MessageBagInterface $messages, + private array $options, + ) { + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @param array $options + */ + public function setOptions(array $options): void + { + $this->options = $options; + } +} diff --git a/src/agent/src/InputProcessor/ModelOverrideInputProcessor.php b/src/agent/src/InputProcessor/ModelOverrideInputProcessor.php new file mode 100644 index 000000000..7dafea175 --- /dev/null +++ b/src/agent/src/InputProcessor/ModelOverrideInputProcessor.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\InputProcessor; + +use Symfony\AI\Agent\Exception\InvalidArgumentException; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\InputProcessorInterface; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +final class ModelOverrideInputProcessor implements InputProcessorInterface +{ + public function processInput(Input $input): void + { + $options = $input->getOptions(); + + if (!\array_key_exists('model', $options)) { + return; + } + + if (!$options['model'] instanceof Model) { + throw new InvalidArgumentException(\sprintf('Option "model" must be an instance of %s.', Model::class)); + } + + $input->model = $options['model']; + } +} diff --git a/src/agent/src/InputProcessor/SystemPromptInputProcessor.php b/src/agent/src/InputProcessor/SystemPromptInputProcessor.php new file mode 100644 index 000000000..9e9c9c370 --- /dev/null +++ b/src/agent/src/InputProcessor/SystemPromptInputProcessor.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\InputProcessor; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\InputProcessorInterface; +use Symfony\AI\Agent\Toolbox\ToolboxInterface; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Tool\Tool; + +/** + * @author Christopher Hertel + */ +final readonly class SystemPromptInputProcessor implements InputProcessorInterface +{ + /** + * @param \Stringable|string $systemPrompt the system prompt to prepend to the input messages + * @param ToolboxInterface|null $toolbox the tool box to be used to append the tool definitions to the system prompt + */ + public function __construct( + private \Stringable|string $systemPrompt, + private ?ToolboxInterface $toolbox = null, + private LoggerInterface $logger = new NullLogger(), + ) { + } + + public function processInput(Input $input): void + { + $messages = $input->messages; + + if (null !== $messages->getSystemMessage()) { + $this->logger->debug('Skipping system prompt injection since MessageBag already contains a system message.'); + + return; + } + + $message = (string) $this->systemPrompt; + + if ($this->toolbox instanceof ToolboxInterface + && [] !== $this->toolbox->getTools() + ) { + $this->logger->debug('Append tool definitions to system prompt.'); + + $tools = implode(\PHP_EOL.\PHP_EOL, array_map( + fn (Tool $tool) => <<name} + {$tool->description} + TOOL, + $this->toolbox->getTools() + )); + + $message = <<systemPrompt} + + # Available tools + + {$tools} + PROMPT; + } + + $input->messages = $messages->prepend(Message::forSystem($message)); + } +} diff --git a/src/agent/src/InputProcessorInterface.php b/src/agent/src/InputProcessorInterface.php new file mode 100644 index 000000000..fc0868bb4 --- /dev/null +++ b/src/agent/src/InputProcessorInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +/** + * @author Christopher Hertel + */ +interface InputProcessorInterface +{ + public function processInput(Input $input): void; +} diff --git a/src/agent/src/Output.php b/src/agent/src/Output.php new file mode 100644 index 000000000..dffd7381c --- /dev/null +++ b/src/agent/src/Output.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final class Output +{ + /** + * @param array $options + */ + public function __construct( + public readonly Model $model, + public ResponseInterface $response, + public readonly MessageBagInterface $messages, + public readonly array $options, + ) { + } +} diff --git a/src/agent/src/OutputProcessorInterface.php b/src/agent/src/OutputProcessorInterface.php new file mode 100644 index 000000000..a6ad499c9 --- /dev/null +++ b/src/agent/src/OutputProcessorInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent; + +/** + * @author Christopher Hertel + */ +interface OutputProcessorInterface +{ + public function processOutput(Output $output): void; +} diff --git a/src/agent/src/StructuredOutput/AgentProcessor.php b/src/agent/src/StructuredOutput/AgentProcessor.php new file mode 100644 index 000000000..50a95a292 --- /dev/null +++ b/src/agent/src/StructuredOutput/AgentProcessor.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\StructuredOutput; + +use Symfony\AI\Agent\Exception\InvalidArgumentException; +use Symfony\AI\Agent\Exception\MissingModelSupportException; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\InputProcessorInterface; +use Symfony\AI\Agent\Output; +use Symfony\AI\Agent\OutputProcessorInterface; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Response\ObjectResponse; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @author Christopher Hertel + */ +final class AgentProcessor implements InputProcessorInterface, OutputProcessorInterface +{ + private string $outputStructure; + + public function __construct( + private readonly ResponseFormatFactoryInterface $responseFormatFactory = new ResponseFormatFactory(), + private ?SerializerInterface $serializer = null, + ) { + if (null === $this->serializer) { + $propertyInfo = new PropertyInfoExtractor([], [new PhpDocExtractor()]); + $normalizers = [new ObjectNormalizer(propertyTypeExtractor: $propertyInfo), new ArrayDenormalizer()]; + $this->serializer = new Serializer($normalizers, [new JsonEncoder()]); + } + } + + public function processInput(Input $input): void + { + $options = $input->getOptions(); + + if (!isset($options['output_structure'])) { + return; + } + + if (!$input->model->supports(Capability::OUTPUT_STRUCTURED)) { + throw MissingModelSupportException::forStructuredOutput($input->model::class); + } + + if (true === ($options['stream'] ?? false)) { + throw new InvalidArgumentException('Streamed responses are not supported for structured output'); + } + + $options['response_format'] = $this->responseFormatFactory->create($options['output_structure']); + + $this->outputStructure = $options['output_structure']; + unset($options['output_structure']); + + $input->setOptions($options); + } + + public function processOutput(Output $output): void + { + $options = $output->options; + + if ($output->response instanceof ObjectResponse) { + return; + } + + if (!isset($options['response_format'])) { + return; + } + + if (!isset($this->outputStructure)) { + $output->response = new ObjectResponse(json_decode($output->response->getContent(), true)); + + return; + } + + $output->response = new ObjectResponse( + $this->serializer->deserialize($output->response->getContent(), $this->outputStructure, 'json') + ); + } +} diff --git a/src/agent/src/StructuredOutput/ResponseFormatFactory.php b/src/agent/src/StructuredOutput/ResponseFormatFactory.php new file mode 100644 index 000000000..a81811925 --- /dev/null +++ b/src/agent/src/StructuredOutput/ResponseFormatFactory.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\StructuredOutput; + +use Symfony\AI\Platform\Contract\JsonSchema\Factory; + +use function Symfony\Component\String\u; + +/** + * @author Christopher Hertel + */ +final readonly class ResponseFormatFactory implements ResponseFormatFactoryInterface +{ + public function __construct( + private Factory $schemaFactory = new Factory(), + ) { + } + + public function create(string $responseClass): array + { + return [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => u($responseClass)->afterLast('\\')->toString(), + 'schema' => $this->schemaFactory->buildProperties($responseClass), + 'strict' => true, + ], + ]; + } +} diff --git a/src/agent/src/StructuredOutput/ResponseFormatFactoryInterface.php b/src/agent/src/StructuredOutput/ResponseFormatFactoryInterface.php new file mode 100644 index 000000000..ab28b1091 --- /dev/null +++ b/src/agent/src/StructuredOutput/ResponseFormatFactoryInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\StructuredOutput; + +/** + * @author Oskar Stark + */ +interface ResponseFormatFactoryInterface +{ + /** + * @param class-string $responseClass + * + * @return array{ + * type: 'json_schema', + * json_schema: array{ + * name: string, + * schema: array, + * strict: true, + * } + * } + */ + public function create(string $responseClass): array; +} diff --git a/src/agent/src/Toolbox/AgentProcessor.php b/src/agent/src/Toolbox/AgentProcessor.php new file mode 100644 index 000000000..b3c626e78 --- /dev/null +++ b/src/agent/src/Toolbox/AgentProcessor.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +use Symfony\AI\Agent\AgentAwareInterface; +use Symfony\AI\Agent\AgentAwareTrait; +use Symfony\AI\Agent\Exception\MissingModelSupportException; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\InputProcessorInterface; +use Symfony\AI\Agent\Output; +use Symfony\AI\Agent\OutputProcessorInterface; +use Symfony\AI\Agent\Toolbox\Event\ToolCallsExecuted; +use Symfony\AI\Agent\Toolbox\StreamResponse as ToolboxStreamResponse; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Response\ResponseInterface; +use Symfony\AI\Platform\Response\StreamResponse as GenericStreamResponse; +use Symfony\AI\Platform\Response\ToolCallResponse; +use Symfony\AI\Platform\Tool\Tool; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * @author Christopher Hertel + */ +final class AgentProcessor implements InputProcessorInterface, OutputProcessorInterface, AgentAwareInterface +{ + use AgentAwareTrait; + + public function __construct( + private readonly ToolboxInterface $toolbox, + private readonly ToolResultConverter $resultConverter = new ToolResultConverter(), + private readonly ?EventDispatcherInterface $eventDispatcher = null, + ) { + } + + public function processInput(Input $input): void + { + if (!$input->model->supports(Capability::TOOL_CALLING)) { + throw MissingModelSupportException::forToolCalling($input->model::class); + } + + $toolMap = $this->toolbox->getTools(); + if ([] === $toolMap) { + return; + } + + $options = $input->getOptions(); + // only filter tool map if list of strings is provided as option + if (isset($options['tools']) && $this->isFlatStringArray($options['tools'])) { + $toolMap = array_values(array_filter($toolMap, fn (Tool $tool) => \in_array($tool->name, $options['tools'], true))); + } + + $options['tools'] = $toolMap; + $input->setOptions($options); + } + + public function processOutput(Output $output): void + { + if ($output->response instanceof GenericStreamResponse) { + $output->response = new ToolboxStreamResponse( + $output->response->getContent(), + $this->handleToolCallsCallback($output), + ); + + return; + } + + if (!$output->response instanceof ToolCallResponse) { + return; + } + + $output->response = $this->handleToolCallsCallback($output)($output->response); + } + + /** + * @param array $tools + */ + private function isFlatStringArray(array $tools): bool + { + return array_reduce($tools, fn (bool $carry, mixed $item) => $carry && \is_string($item), true); + } + + private function handleToolCallsCallback(Output $output): \Closure + { + return function (ToolCallResponse $response, ?AssistantMessage $streamedAssistantResponse = null) use ($output): ResponseInterface { + $messages = clone $output->messages; + + if (null !== $streamedAssistantResponse && '' !== $streamedAssistantResponse->content) { + $messages->add($streamedAssistantResponse); + } + + do { + $toolCalls = $response->getContent(); + $messages->add(Message::ofAssistant(toolCalls: $toolCalls)); + + $results = []; + foreach ($toolCalls as $toolCall) { + $result = $this->toolbox->execute($toolCall); + $results[] = new ToolCallResult($toolCall, $result); + $messages->add(Message::ofToolCall($toolCall, $this->resultConverter->convert($result))); + } + + $event = new ToolCallsExecuted(...$results); + $this->eventDispatcher?->dispatch($event); + + $response = $event->hasResponse() ? $event->response : $this->agent->call($messages, $output->options); + } while ($response instanceof ToolCallResponse); + + return $response; + }; + } +} diff --git a/src/agent/src/Toolbox/Attribute/AsTool.php b/src/agent/src/Toolbox/Attribute/AsTool.php new file mode 100644 index 000000000..04811ac33 --- /dev/null +++ b/src/agent/src/Toolbox/Attribute/AsTool.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Attribute; + +/** + * @author Christopher Hertel + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +final readonly class AsTool +{ + public function __construct( + public string $name, + public string $description, + public string $method = '__invoke', + ) { + } +} diff --git a/src/agent/src/Toolbox/Event/ToolCallsExecuted.php b/src/agent/src/Toolbox/Event/ToolCallsExecuted.php new file mode 100644 index 000000000..4101b8d0a --- /dev/null +++ b/src/agent/src/Toolbox/Event/ToolCallsExecuted.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Event; + +use Symfony\AI\Agent\Toolbox\ToolCallResult; +use Symfony\AI\Platform\Response\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final class ToolCallsExecuted +{ + /** + * @var ToolCallResult[] + */ + public readonly array $toolCallResults; + public ResponseInterface $response; + + public function __construct(ToolCallResult ...$toolCallResults) + { + $this->toolCallResults = $toolCallResults; + } + + public function hasResponse(): bool + { + return isset($this->response); + } +} diff --git a/src/agent/src/Toolbox/Exception/ExceptionInterface.php b/src/agent/src/Toolbox/Exception/ExceptionInterface.php new file mode 100644 index 000000000..bbb590084 --- /dev/null +++ b/src/agent/src/Toolbox/Exception/ExceptionInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Exception; + +use Symfony\AI\Agent\Exception\ExceptionInterface as BaseExceptionInterface; + +/** + * @author Christopher Hertel + */ +interface ExceptionInterface extends BaseExceptionInterface +{ +} diff --git a/src/agent/src/Toolbox/Exception/ToolConfigurationException.php b/src/agent/src/Toolbox/Exception/ToolConfigurationException.php new file mode 100644 index 000000000..0e39c7f30 --- /dev/null +++ b/src/agent/src/Toolbox/Exception/ToolConfigurationException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Exception; + +use Symfony\AI\Agent\Exception\InvalidArgumentException; + +/** + * @author Christopher Hertel + */ +final class ToolConfigurationException extends InvalidArgumentException implements ExceptionInterface +{ + public static function invalidMethod(string $toolClass, string $methodName, \ReflectionException $previous): self + { + return new self(\sprintf('Method "%s" not found in tool "%s".', $methodName, $toolClass), previous: $previous); + } +} diff --git a/src/agent/src/Toolbox/Exception/ToolException.php b/src/agent/src/Toolbox/Exception/ToolException.php new file mode 100644 index 000000000..08e496f6c --- /dev/null +++ b/src/agent/src/Toolbox/Exception/ToolException.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Exception; + +use Symfony\AI\Agent\Exception\InvalidArgumentException; +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +/** + * @author Christopher Hertel + */ +final class ToolException extends InvalidArgumentException implements ExceptionInterface +{ + public static function invalidReference(mixed $reference): self + { + return new self(\sprintf('The reference "%s" is not a valid tool.', $reference)); + } + + public static function missingAttribute(string $className): self + { + return new self(\sprintf('The class "%s" is not a tool, please add %s attribute.', $className, AsTool::class)); + } +} diff --git a/src/agent/src/Toolbox/Exception/ToolExecutionException.php b/src/agent/src/Toolbox/Exception/ToolExecutionException.php new file mode 100644 index 000000000..3577020b5 --- /dev/null +++ b/src/agent/src/Toolbox/Exception/ToolExecutionException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Exception; + +use Symfony\AI\Platform\Response\ToolCall; + +/** + * @author Christopher Hertel + */ +final class ToolExecutionException extends \RuntimeException implements ExceptionInterface +{ + public ?ToolCall $toolCall = null; + + public static function executionFailed(ToolCall $toolCall, \Throwable $previous): self + { + $exception = new self(\sprintf('Execution of tool "%s" failed with error: %s', $toolCall->name, $previous->getMessage()), previous: $previous); + $exception->toolCall = $toolCall; + + return $exception; + } +} diff --git a/src/agent/src/Toolbox/Exception/ToolNotFoundException.php b/src/agent/src/Toolbox/Exception/ToolNotFoundException.php new file mode 100644 index 000000000..8e1fb9009 --- /dev/null +++ b/src/agent/src/Toolbox/Exception/ToolNotFoundException.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Exception; + +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Tool\ExecutionReference; + +/** + * @author Christopher Hertel + */ +final class ToolNotFoundException extends \RuntimeException implements ExceptionInterface +{ + public ?ToolCall $toolCall = null; + + public static function notFoundForToolCall(ToolCall $toolCall): self + { + $exception = new self(\sprintf('Tool not found for call: %s.', $toolCall->name)); + $exception->toolCall = $toolCall; + + return $exception; + } + + public static function notFoundForReference(ExecutionReference $reference): self + { + return new self(\sprintf('Tool not found for reference: %s::%s.', $reference->class, $reference->method)); + } +} diff --git a/src/agent/src/Toolbox/FaultTolerantToolbox.php b/src/agent/src/Toolbox/FaultTolerantToolbox.php new file mode 100644 index 000000000..f372ab1ec --- /dev/null +++ b/src/agent/src/Toolbox/FaultTolerantToolbox.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException; +use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Tool\Tool; + +/** + * Catches exceptions thrown by the inner tool box and returns error messages for the LLM instead. + * + * @author Christopher Hertel + */ +final readonly class FaultTolerantToolbox implements ToolboxInterface +{ + public function __construct( + private ToolboxInterface $innerToolbox, + ) { + } + + public function getTools(): array + { + return $this->innerToolbox->getTools(); + } + + public function execute(ToolCall $toolCall): mixed + { + try { + return $this->innerToolbox->execute($toolCall); + } catch (ToolExecutionException $e) { + return \sprintf('An error occurred while executing tool "%s".', $e->toolCall->name); + } catch (ToolNotFoundException) { + $names = array_map(fn (Tool $metadata) => $metadata->name, $this->getTools()); + + return \sprintf('Tool "%s" was not found, please use one of these: %s', $toolCall->name, implode(', ', $names)); + } + } +} diff --git a/src/agent/src/Toolbox/StreamResponse.php b/src/agent/src/Toolbox/StreamResponse.php new file mode 100644 index 000000000..475dbb600 --- /dev/null +++ b/src/agent/src/Toolbox/StreamResponse.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Response\BaseResponse; +use Symfony\AI\Platform\Response\ToolCallResponse; + +/** + * @author Denis Zunke + */ +final class StreamResponse extends BaseResponse +{ + public function __construct( + private readonly \Generator $generator, + private readonly \Closure $handleToolCallsCallback, + ) { + } + + public function getContent(): \Generator + { + $streamedResponse = ''; + foreach ($this->generator as $value) { + if ($value instanceof ToolCallResponse) { + yield from ($this->handleToolCallsCallback)($value, Message::ofAssistant($streamedResponse))->getContent(); + + break; + } + + $streamedResponse .= $value; + yield $value; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Agent.php b/src/agent/src/Toolbox/Tool/Agent.php new file mode 100644 index 000000000..cea81ca13 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Agent.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\AgentInterface; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Response\TextResponse; + +/** + * @author Christopher Hertel + */ +final readonly class Agent +{ + public function __construct( + private AgentInterface $agent, + ) { + } + + /** + * @param string $message the message to pass to the agent + */ + public function __invoke(string $message): string + { + $response = $this->agent->call(new MessageBag(Message::ofUser($message))); + + \assert($response instanceof TextResponse); + + return $response->getContent(); + } +} diff --git a/src/agent/src/Toolbox/Tool/Brave.php b/src/agent/src/Toolbox/Tool/Brave.php new file mode 100644 index 000000000..4a25d3006 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Brave.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool('brave_search', 'Tool that searches the web using Brave Search')] +final readonly class Brave +{ + /** + * @param array $options See https://api-dashboard.search.brave.com/app/documentation/web-search/query#WebSearchAPIQueryParameters + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] + private string $apiKey, + private array $options = [], + ) { + } + + /** + * @param string $query the search query term + * @param int $count The number of search results returned in response. + * Combine this parameter with offset to paginate search results. + * @param int $offset The number of search results to skip before returning results. + * In order to paginate results use this parameter together with count. + * + * @return array + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $count = 20, + #[With(minimum: 0, maximum: 9)] + int $offset = 0, + ): array { + $response = $this->httpClient->request('GET', 'https://api.search.brave.com/res/v1/web/search', [ + 'headers' => ['X-Subscription-Token' => $this->apiKey], + 'query' => array_merge($this->options, [ + 'q' => $query, + 'count' => $count, + 'offset' => $offset, + ]), + ]); + + $data = $response->toArray(); + + return array_map(static function (array $result) { + return ['title' => $result['title'], 'description' => $result['description'], 'url' => $result['url']]; + }, $data['web']['results'] ?? []); + } +} diff --git a/src/agent/src/Toolbox/Tool/Clock.php b/src/agent/src/Toolbox/Tool/Clock.php new file mode 100644 index 000000000..562b3ccf8 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Clock.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Component\Clock\Clock as SymfonyClock; +use Symfony\Component\Clock\ClockInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool('clock', description: 'Provides the current date and time.')] +final readonly class Clock +{ + public function __construct( + private ClockInterface $clock = new SymfonyClock(), + ) { + } + + public function __invoke(): string + { + return \sprintf( + 'Current date is %s (YYYY-MM-DD) and the time is %s (HH:MM:SS).', + $this->clock->now()->format('Y-m-d'), + $this->clock->now()->format('H:i:s'), + ); + } +} diff --git a/src/agent/src/Toolbox/Tool/Crawler.php b/src/agent/src/Toolbox/Tool/Crawler.php new file mode 100644 index 000000000..96e809eee --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Crawler.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Exception\RuntimeException; +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Component\DomCrawler\Crawler as DomCrawler; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool('crawler', 'A tool that crawls one page of a website and returns the visible text of it.')] +final readonly class Crawler +{ + public function __construct( + private HttpClientInterface $httpClient, + ) { + if (!class_exists(DomCrawler::class)) { + throw new RuntimeException('The DomCrawler component is not installed. Please install it using "composer require symfony/dom-crawler".'); + } + } + + /** + * @param string $url the URL of the page to crawl + */ + public function __invoke(string $url): string + { + $response = $this->httpClient->request('GET', $url); + + return (new DomCrawler($response->getContent()))->filter('body')->text(); + } +} diff --git a/src/agent/src/Toolbox/Tool/OpenMeteo.php b/src/agent/src/Toolbox/Tool/OpenMeteo.php new file mode 100644 index 000000000..7e0adef82 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/OpenMeteo.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool(name: 'weather_current', description: 'get current weather for a location', method: 'current')] +#[AsTool(name: 'weather_forecast', description: 'get weather forecast for a location', method: 'forecast')] +final readonly class OpenMeteo +{ + private const WMO_CODES = [ + 0 => 'Clear', + 1 => 'Mostly Clear', + 2 => 'Partly Cloudy', + 3 => 'Overcast', + 45 => 'Fog', + 48 => 'Icy Fog', + 51 => 'Light Drizzle', + 53 => 'Drizzle', + 55 => 'Heavy Drizzle', + 56 => 'Light Freezing Drizzle', + 57 => 'Freezing Drizzle', + 61 => 'Light Rain', + 63 => 'Rain', + 65 => 'Heavy Rain', + 66 => 'Light Freezing Rain', + 67 => 'Freezing Rain', + 71 => 'Light Snow', + 73 => 'Snow', + 75 => 'Heavy Snow', + 77 => 'Snow Grains', + 80 => 'Light Showers', + 81 => 'Showers', + 82 => 'Heavy Showers', + 85 => 'Light Snow Showers', + 86 => 'Snow Showers', + 95 => 'Thunderstorm', + 96 => 'Light Thunderstorm with Hail', + 99 => 'Thunderstorm with Hail', + ]; + + public function __construct( + private HttpClientInterface $httpClient, + ) { + } + + /** + * @param float $latitude the latitude of the location + * @param float $longitude the longitude of the location + * + * @return array{ + * weather: string, + * time: string, + * temperature: string, + * wind_speed: string, + * } + */ + public function current(float $latitude, float $longitude): array + { + $response = $this->httpClient->request('GET', 'https://api.open-meteo.com/v1/forecast', [ + 'query' => [ + 'latitude' => $latitude, + 'longitude' => $longitude, + 'current' => 'weather_code,temperature_2m,wind_speed_10m', + ], + ]); + + $data = $response->toArray(); + + return [ + 'weather' => self::WMO_CODES[$data['current']['weather_code']] ?? 'Unknown', + 'time' => $data['current']['time'], + 'temperature' => $data['current']['temperature_2m'].$data['current_units']['temperature_2m'], + 'wind_speed' => $data['current']['wind_speed_10m'].$data['current_units']['wind_speed_10m'], + ]; + } + + /** + * @param float $latitude the latitude of the location + * @param float $longitude the longitude of the location + * @param int $days the number of days to forecast + * + * @return array{ + * weather: string, + * time: string, + * temperature_min: string, + * temperature_max: string, + * }[] + */ + public function forecast( + float $latitude, + float $longitude, + #[With(minimum: 1, maximum: 16)] + int $days = 7, + ): array { + $response = $this->httpClient->request('GET', 'https://api.open-meteo.com/v1/forecast', [ + 'query' => [ + 'latitude' => $latitude, + 'longitude' => $longitude, + 'daily' => 'weather_code,temperature_2m_max,temperature_2m_min', + 'forecast_days' => $days, + ], + ]); + + $data = $response->toArray(); + $forecast = []; + for ($i = 0; $i < $days; ++$i) { + $forecast[] = [ + 'weather' => self::WMO_CODES[$data['daily']['weather_code'][$i]] ?? 'Unknown', + 'time' => $data['daily']['time'][$i], + 'temperature_min' => $data['daily']['temperature_2m_min'][$i].$data['daily_units']['temperature_2m_min'], + 'temperature_max' => $data['daily']['temperature_2m_max'][$i].$data['daily_units']['temperature_2m_max'], + ]; + } + + return $forecast; + } +} diff --git a/src/agent/src/Toolbox/Tool/SerpApi.php b/src/agent/src/Toolbox/Tool/SerpApi.php new file mode 100644 index 000000000..782cd97c7 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/SerpApi.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool(name: 'serpapi', description: 'search for information on the internet')] +final readonly class SerpApi +{ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + ) { + } + + /** + * @param string $query The search query to use + */ + public function __invoke(string $query): string + { + $response = $this->httpClient->request('GET', 'https://serpapi.com/search', [ + 'query' => [ + 'q' => $query, + 'api_key' => $this->apiKey, + ], + ]); + + return \sprintf('Results for "%s" are "%s".', $query, $this->extractBestResponse($response->toArray())); + } + + /** + * @param array $results + */ + private function extractBestResponse(array $results): string + { + return implode('. ', array_map(fn ($story) => $story['title'], $results['organic_results'])); + } +} diff --git a/src/agent/src/Toolbox/Tool/SimilaritySearch.php b/src/agent/src/Toolbox/Tool/SimilaritySearch.php new file mode 100644 index 000000000..05b0f2da9 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/SimilaritySearch.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\VectorStoreInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool('similarity_search', description: 'Searches for documents similar to a query or sentence.')] +final class SimilaritySearch +{ + /** + * @var VectorDocument[] + */ + public array $usedDocuments = []; + + public function __construct( + private readonly PlatformInterface $platform, + private readonly Model $model, + private readonly VectorStoreInterface $vectorStore, + ) { + } + + /** + * @param string $searchTerm string used for similarity search + */ + public function __invoke(string $searchTerm): string + { + /** @var Vector[] $vectors */ + $vectors = $this->platform->request($this->model, $searchTerm)->getContent(); + $this->usedDocuments = $this->vectorStore->query($vectors[0]); + + if (0 === \count($this->usedDocuments)) { + return 'No results found'; + } + + $result = 'Found documents with following information:'.\PHP_EOL; + foreach ($this->usedDocuments as $document) { + $result .= json_encode($document->metadata); + } + + return $result; + } +} diff --git a/src/agent/src/Toolbox/Tool/Tavily.php b/src/agent/src/Toolbox/Tool/Tavily.php new file mode 100644 index 000000000..f84848d7a --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Tavily.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * Tool integration of tavily.com. + * + * @author Christopher Hertel + */ +#[AsTool('tavily_search', description: 'search for information on the internet', method: 'search')] +#[AsTool('tavily_extract', description: 'fetch content from websites', method: 'extract')] +final readonly class Tavily +{ + /** + * @param array $options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private array $options = ['include_images' => false], + ) { + } + + /** + * @param string $query The search query to use + */ + public function search(string $query): string + { + $response = $this->httpClient->request('POST', 'https://api.tavily.com/search', [ + 'json' => array_merge($this->options, [ + 'query' => $query, + 'api_key' => $this->apiKey, + ]), + ]); + + return $response->getContent(); + } + + /** + * @param string[] $urls URLs to fetch information from + */ + public function extract(array $urls): string + { + $response = $this->httpClient->request('POST', 'https://api.tavily.com/extract', [ + 'json' => [ + 'urls' => $urls, + 'api_key' => $this->apiKey, + ], + ]); + + return $response->getContent(); + } +} diff --git a/src/agent/src/Toolbox/Tool/Wikipedia.php b/src/agent/src/Toolbox/Tool/Wikipedia.php new file mode 100644 index 000000000..9aa527373 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Wikipedia.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool('wikipedia_search', description: 'Searches Wikipedia for a given query', method: 'search')] +#[AsTool('wikipedia_article', description: 'Retrieves a Wikipedia article by its title', method: 'article')] +final readonly class Wikipedia +{ + public function __construct( + private HttpClientInterface $httpClient, + private string $locale = 'en', + ) { + } + + /** + * @param string $query The query to search for on Wikipedia + */ + public function search(string $query): string + { + $result = $this->execute([ + 'action' => 'query', + 'format' => 'json', + 'list' => 'search', + 'srsearch' => $query, + ], $this->locale); + + $titles = array_map(fn (array $item) => $item['title'], $result['query']['search']); + + if (empty($titles)) { + return 'No articles were found on Wikipedia.'; + } + + $response = 'Articles with the following titles were found on Wikipedia:'.\PHP_EOL; + foreach ($titles as $title) { + $response .= ' - '.$title.\PHP_EOL; + } + + return $response.\PHP_EOL.'Use the title of the article with tool "wikipedia_article" to load the content.'; + } + + /** + * @param string $title The title of the article to load from Wikipedia + */ + public function article(string $title): string + { + $result = $this->execute([ + 'action' => 'query', + 'format' => 'json', + 'prop' => 'extracts|info|pageimages', + 'titles' => $title, + 'explaintext' => true, + 'redirects' => true, + ], $this->locale); + + $article = current($result['query']['pages']); + + if (\array_key_exists('missing', $article)) { + return \sprintf('No article with title "%s" was found on Wikipedia.', $title); + } + + $response = ''; + if (\array_key_exists('redirects', $result['query'])) { + foreach ($result['query']['redirects'] as $redirect) { + $response .= \sprintf('The article "%s" redirects to article "%s".', $redirect['from'], $redirect['to']).\PHP_EOL; + } + $response .= \PHP_EOL; + } + + return $response.'This is the content of article "'.$article['title'].'":'.\PHP_EOL.$article['extract']; + } + + /** + * @param array $query + * + * @return array + */ + private function execute(array $query, ?string $locale = null): array + { + $url = \sprintf('https://%s.wikipedia.org/w/api.php', $locale ?? $this->locale); + $response = $this->httpClient->request('GET', $url, ['query' => $query]); + + return $response->toArray(); + } +} diff --git a/src/agent/src/Toolbox/Tool/YouTubeTranscriber.php b/src/agent/src/Toolbox/Tool/YouTubeTranscriber.php new file mode 100644 index 000000000..90c1881ed --- /dev/null +++ b/src/agent/src/Toolbox/Tool/YouTubeTranscriber.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Exception\LogicException; +use Symfony\AI\Agent\Exception\RuntimeException; +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Component\CssSelector\CssSelectorConverter; +use Symfony\Component\DomCrawler\Crawler; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +#[AsTool('youtube_transcript', 'Fetches the transcript of a YouTube video')] +final readonly class YouTubeTranscriber +{ + public function __construct( + private HttpClientInterface $client, + ) { + if (!class_exists(Crawler::class)) { + throw new LogicException('The Symfony DomCrawler component is required to use this tool. Try running "composer require symfony/dom-crawler".'); + } + if (!class_exists(CssSelectorConverter::class)) { + throw new LogicException('The Symfony CSS Selector component is required to use this tool. Try running "composer require symfony/css-selector".'); + } + } + + /** + * @param string $videoId The ID of the YouTube video + */ + public function __invoke(string $videoId): string + { + // Fetch the HTML content of the YouTube video page + $htmlResponse = $this->client->request('GET', 'https://youtube.com/watch?v='.$videoId); + $html = $htmlResponse->getContent(); + + // Use DomCrawler to parse the HTML + $crawler = new Crawler($html); + + // Extract the script containing the ytInitialPlayerResponse + $scriptContent = $crawler->filter('script')->reduce(function (Crawler $node) { + return str_contains($node->text(), 'var ytInitialPlayerResponse = {'); + })->text(); + + // Extract and parse the JSON data from the script + $start = strpos($scriptContent, 'var ytInitialPlayerResponse = ') + \strlen('var ytInitialPlayerResponse = '); + $dataString = substr($scriptContent, $start); + $dataString = substr($dataString, 0, strrpos($dataString, ';') ?: null); + $data = json_decode(trim($dataString), true); + + // Extract the URL for the captions + if (!isset($data['captions']['playerCaptionsTracklistRenderer']['captionTracks'][0]['baseUrl'])) { + throw new RuntimeException('Captions are not available for this video.'); + } + $captionsUrl = $data['captions']['playerCaptionsTracklistRenderer']['captionTracks'][0]['baseUrl']; + + // Fetch and parse the captions XML + $xmlResponse = $this->client->request('GET', $captionsUrl); + $xmlContent = $xmlResponse->getContent(); + $xmlCrawler = new Crawler($xmlContent); + + // Collect all text elements from the captions + $transcript = $xmlCrawler->filter('text')->each(function (Crawler $node) { + return $node->text().' '; + }); + + return implode(\PHP_EOL, $transcript); + } +} diff --git a/src/agent/src/Toolbox/ToolCallResult.php b/src/agent/src/Toolbox/ToolCallResult.php new file mode 100644 index 000000000..153631c29 --- /dev/null +++ b/src/agent/src/Toolbox/ToolCallResult.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +use Symfony\AI\Platform\Response\ToolCall; + +/** + * @author Christopher Hertel + */ +final readonly class ToolCallResult +{ + public function __construct( + public ToolCall $toolCall, + public mixed $result, + ) { + } +} diff --git a/src/agent/src/Toolbox/ToolFactory/AbstractToolFactory.php b/src/agent/src/Toolbox/ToolFactory/AbstractToolFactory.php new file mode 100644 index 000000000..4a3a146bd --- /dev/null +++ b/src/agent/src/Toolbox/ToolFactory/AbstractToolFactory.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\ToolFactory; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Agent\Toolbox\Exception\ToolConfigurationException; +use Symfony\AI\Agent\Toolbox\ToolFactoryInterface; +use Symfony\AI\Platform\Contract\JsonSchema\Factory; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +/** + * @author Christopher Hertel + */ +abstract class AbstractToolFactory implements ToolFactoryInterface +{ + public function __construct( + private readonly Factory $factory = new Factory(), + ) { + } + + protected function convertAttribute(string $className, AsTool $attribute): Tool + { + try { + return new Tool( + new ExecutionReference($className, $attribute->method), + $attribute->name, + $attribute->description, + $this->factory->buildParameters($className, $attribute->method) + ); + } catch (\ReflectionException $e) { + throw ToolConfigurationException::invalidMethod($className, $attribute->method, $e); + } + } +} diff --git a/src/agent/src/Toolbox/ToolFactory/ChainFactory.php b/src/agent/src/Toolbox/ToolFactory/ChainFactory.php new file mode 100644 index 000000000..5fca2bc2a --- /dev/null +++ b/src/agent/src/Toolbox/ToolFactory/ChainFactory.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\ToolFactory; + +use Symfony\AI\Agent\Toolbox\Exception\ToolException; +use Symfony\AI\Agent\Toolbox\ToolFactoryInterface; + +/** + * @author Christopher Hertel + */ +final readonly class ChainFactory implements ToolFactoryInterface +{ + /** + * @var list + */ + private array $factories; + + /** + * @param iterable $factories + */ + public function __construct(iterable $factories) + { + $this->factories = $factories instanceof \Traversable ? iterator_to_array($factories) : $factories; + } + + public function getTool(string $reference): iterable + { + $invalid = 0; + foreach ($this->factories as $factory) { + try { + yield from $factory->getTool($reference); + } catch (ToolException) { + ++$invalid; + continue; + } + + // If the factory does not throw an exception, we don't need to check the others + return; + } + + if ($invalid === \count($this->factories)) { + throw ToolException::invalidReference($reference); + } + } +} diff --git a/src/agent/src/Toolbox/ToolFactory/MemoryToolFactory.php b/src/agent/src/Toolbox/ToolFactory/MemoryToolFactory.php new file mode 100644 index 000000000..80846d96d --- /dev/null +++ b/src/agent/src/Toolbox/ToolFactory/MemoryToolFactory.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\ToolFactory; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Agent\Toolbox\Exception\ToolException; + +/** + * @author Christopher Hertel + */ +final class MemoryToolFactory extends AbstractToolFactory +{ + /** + * @var array + */ + private array $tools = []; + + public function addTool(string|object $class, string $name, string $description, string $method = '__invoke'): self + { + $className = \is_object($class) ? $class::class : $class; + $this->tools[$className][] = new AsTool($name, $description, $method); + + return $this; + } + + /** + * @param class-string $reference + */ + public function getTool(string $reference): iterable + { + if (!isset($this->tools[$reference])) { + throw ToolException::invalidReference($reference); + } + + foreach ($this->tools[$reference] as $tool) { + yield $this->convertAttribute($reference, $tool); + } + } +} diff --git a/src/agent/src/Toolbox/ToolFactory/ReflectionToolFactory.php b/src/agent/src/Toolbox/ToolFactory/ReflectionToolFactory.php new file mode 100644 index 000000000..8e76634e7 --- /dev/null +++ b/src/agent/src/Toolbox/ToolFactory/ReflectionToolFactory.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\ToolFactory; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Agent\Toolbox\Exception\ToolException; + +/** + * Metadata factory that uses reflection in combination with `#[AsTool]` attribute to extract metadata from tools. + * + * @author Christopher Hertel + */ +final class ReflectionToolFactory extends AbstractToolFactory +{ + /** + * @param class-string $reference + */ + public function getTool(string $reference): iterable + { + if (!class_exists($reference)) { + throw ToolException::invalidReference($reference); + } + + $reflectionClass = new \ReflectionClass($reference); + $attributes = $reflectionClass->getAttributes(AsTool::class); + + if (0 === \count($attributes)) { + throw ToolException::missingAttribute($reference); + } + + foreach ($attributes as $attribute) { + yield $this->convertAttribute($reference, $attribute->newInstance()); + } + } +} diff --git a/src/agent/src/Toolbox/ToolFactoryInterface.php b/src/agent/src/Toolbox/ToolFactoryInterface.php new file mode 100644 index 000000000..aafdf65d7 --- /dev/null +++ b/src/agent/src/Toolbox/ToolFactoryInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +use Symfony\AI\Agent\Toolbox\Exception\ToolException; +use Symfony\AI\Platform\Tool\Tool; + +/** + * @author Christopher Hertel + */ +interface ToolFactoryInterface +{ + /** + * @return iterable + * + * @throws ToolException if the metadata for the given reference is not found + */ + public function getTool(string $reference): iterable; +} diff --git a/src/agent/src/Toolbox/ToolResultConverter.php b/src/agent/src/Toolbox/ToolResultConverter.php new file mode 100644 index 000000000..f9e656c0e --- /dev/null +++ b/src/agent/src/Toolbox/ToolResultConverter.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +/** + * @author Christopher Hertel + */ +final readonly class ToolResultConverter +{ + /** + * @param \JsonSerializable|\Stringable|array|float|string|null $result + */ + public function convert(\JsonSerializable|\Stringable|array|float|string|\DateTimeInterface|null $result): ?string + { + if (null === $result) { + return null; + } + + if ($result instanceof \JsonSerializable || \is_array($result)) { + return json_encode($result, flags: \JSON_THROW_ON_ERROR); + } + + if (\is_float($result) || $result instanceof \Stringable) { + return (string) $result; + } + + if ($result instanceof \DateTimeInterface) { + return $result->format(\DATE_ATOM); + } + + return $result; + } +} diff --git a/src/agent/src/Toolbox/Toolbox.php b/src/agent/src/Toolbox/Toolbox.php new file mode 100644 index 000000000..bcab2c0e0 --- /dev/null +++ b/src/agent/src/Toolbox/Toolbox.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException; +use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException; +use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Tool\Tool; + +/** + * @author Christopher Hertel + */ +final class Toolbox implements ToolboxInterface +{ + /** + * List of executable tools. + * + * @var list + */ + private readonly array $tools; + + /** + * List of tool metadata objects. + * + * @var Tool[] + */ + private array $map; + + /** + * @param iterable $tools + */ + public function __construct( + private readonly ToolFactoryInterface $toolFactory, + iterable $tools, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + $this->tools = $tools instanceof \Traversable ? iterator_to_array($tools) : $tools; + } + + public static function create(object ...$tools): self + { + return new self(new ReflectionToolFactory(), $tools); + } + + public function getTools(): array + { + if (isset($this->map)) { + return $this->map; + } + + $map = []; + foreach ($this->tools as $tool) { + foreach ($this->toolFactory->getTool($tool::class) as $metadata) { + $map[] = $metadata; + } + } + + return $this->map = $map; + } + + public function execute(ToolCall $toolCall): mixed + { + $metadata = $this->getMetadata($toolCall); + $tool = $this->getExecutable($metadata); + + try { + $this->logger->debug(\sprintf('Executing tool "%s".', $toolCall->name), $toolCall->arguments); + $result = $tool->{$metadata->reference->method}(...$toolCall->arguments); + } catch (\Throwable $e) { + $this->logger->warning(\sprintf('Failed to execute tool "%s".', $toolCall->name), ['exception' => $e]); + throw ToolExecutionException::executionFailed($toolCall, $e); + } + + return $result; + } + + private function getMetadata(ToolCall $toolCall): Tool + { + foreach ($this->getTools() as $metadata) { + if ($metadata->name === $toolCall->name) { + return $metadata; + } + } + + throw ToolNotFoundException::notFoundForToolCall($toolCall); + } + + private function getExecutable(Tool $metadata): object + { + foreach ($this->tools as $tool) { + if ($tool instanceof $metadata->reference->class) { + return $tool; + } + } + + throw ToolNotFoundException::notFoundForReference($metadata->reference); + } +} diff --git a/src/agent/src/Toolbox/ToolboxInterface.php b/src/agent/src/Toolbox/ToolboxInterface.php new file mode 100644 index 000000000..15478644a --- /dev/null +++ b/src/agent/src/Toolbox/ToolboxInterface.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException; +use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Tool\Tool; + +/** + * @author Christopher Hertel + */ +interface ToolboxInterface +{ + /** + * @return Tool[] + */ + public function getTools(): array; + + /** + * @throws ToolExecutionException if the tool execution fails + * @throws ToolNotFoundException if the tool is not found + */ + public function execute(ToolCall $toolCall): mixed; +} diff --git a/src/agent/tests/InputProcessor/ModelOverrideInputProcessorTest.php b/src/agent/tests/InputProcessor/ModelOverrideInputProcessorTest.php new file mode 100644 index 000000000..5acaddf90 --- /dev/null +++ b/src/agent/tests/InputProcessor/ModelOverrideInputProcessorTest.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\InputProcessor; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Exception\InvalidArgumentException; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\InputProcessor\ModelOverrideInputProcessor; +use Symfony\AI\Platform\Bridge\Anthropic\Claude; +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings; +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +use Symfony\AI\Platform\Message\MessageBag; + +#[CoversClass(ModelOverrideInputProcessor::class)] +#[UsesClass(GPT::class)] +#[UsesClass(Claude::class)] +#[UsesClass(Input::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(Embeddings::class)] +#[Small] +final class ModelOverrideInputProcessorTest extends TestCase +{ + #[Test] + public function processInputWithValidModelOption(): void + { + $gpt = new GPT(); + $claude = new Claude(); + $input = new Input($gpt, new MessageBag(), ['model' => $claude]); + + $processor = new ModelOverrideInputProcessor(); + $processor->processInput($input); + + self::assertSame($claude, $input->model); + } + + #[Test] + public function processInputWithoutModelOption(): void + { + $gpt = new GPT(); + $input = new Input($gpt, new MessageBag(), []); + + $processor = new ModelOverrideInputProcessor(); + $processor->processInput($input); + + self::assertSame($gpt, $input->model); + } + + #[Test] + public function processInputWithInvalidModelOption(): void + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('Option "model" must be an instance of Symfony\AI\Platform\Model.'); + + $gpt = new GPT(); + $model = new MessageBag(); + $input = new Input($gpt, new MessageBag(), ['model' => $model]); + + $processor = new ModelOverrideInputProcessor(); + $processor->processInput($input); + } +} diff --git a/src/agent/tests/InputProcessor/SystemPromptInputProcessorTest.php b/src/agent/tests/InputProcessor/SystemPromptInputProcessorTest.php new file mode 100644 index 000000000..77abe0c6c --- /dev/null +++ b/src/agent/tests/InputProcessor/SystemPromptInputProcessorTest.php @@ -0,0 +1,195 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\InputProcessor; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolNoParams; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolRequiredParams; +use Symfony\AI\Agent\Toolbox\ToolboxInterface; +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\SystemMessage; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +#[CoversClass(SystemPromptInputProcessor::class)] +#[UsesClass(GPT::class)] +#[UsesClass(Message::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(Input::class)] +#[UsesClass(SystemMessage::class)] +#[UsesClass(UserMessage::class)] +#[UsesClass(Text::class)] +#[UsesClass(Tool::class)] +#[UsesClass(ExecutionReference::class)] +#[Small] +final class SystemPromptInputProcessorTest extends TestCase +{ + #[Test] + public function processInputAddsSystemMessageWhenNoneExists(): void + { + $processor = new SystemPromptInputProcessor('This is a system prompt'); + + $input = new Input(new GPT(), new MessageBag(Message::ofUser('This is a user message')), []); + $processor->processInput($input); + + $messages = $input->messages->getMessages(); + self::assertCount(2, $messages); + self::assertInstanceOf(SystemMessage::class, $messages[0]); + self::assertInstanceOf(UserMessage::class, $messages[1]); + self::assertSame('This is a system prompt', $messages[0]->content); + } + + #[Test] + public function processInputDoesNotAddSystemMessageWhenOneExists(): void + { + $processor = new SystemPromptInputProcessor('This is a system prompt'); + + $messages = new MessageBag( + Message::forSystem('This is already a system prompt'), + Message::ofUser('This is a user message'), + ); + $input = new Input(new GPT(), $messages, []); + $processor->processInput($input); + + $messages = $input->messages->getMessages(); + self::assertCount(2, $messages); + self::assertInstanceOf(SystemMessage::class, $messages[0]); + self::assertInstanceOf(UserMessage::class, $messages[1]); + self::assertSame('This is already a system prompt', $messages[0]->content); + } + + #[Test] + public function doesNotIncludeToolsIfToolboxIsEmpty(): void + { + $processor = new SystemPromptInputProcessor( + 'This is a system prompt', + new class implements ToolboxInterface { + public function getTools(): array + { + return []; + } + + public function execute(ToolCall $toolCall): mixed + { + return null; + } + } + ); + + $input = new Input(new GPT(), new MessageBag(Message::ofUser('This is a user message')), []); + $processor->processInput($input); + + $messages = $input->messages->getMessages(); + self::assertCount(2, $messages); + self::assertInstanceOf(SystemMessage::class, $messages[0]); + self::assertInstanceOf(UserMessage::class, $messages[1]); + self::assertSame('This is a system prompt', $messages[0]->content); + } + + #[Test] + public function includeToolDefinitions(): void + { + $processor = new SystemPromptInputProcessor( + 'This is a system prompt', + new class implements ToolboxInterface { + public function getTools(): array + { + return [ + new Tool(new ExecutionReference(ToolNoParams::class), 'tool_no_params', 'A tool without parameters', null), + new Tool( + new ExecutionReference(ToolRequiredParams::class, 'bar'), + 'tool_required_params', + <<processInput($input); + + $messages = $input->messages->getMessages(); + self::assertCount(2, $messages); + self::assertInstanceOf(SystemMessage::class, $messages[0]); + self::assertInstanceOf(UserMessage::class, $messages[1]); + self::assertSame(<<content); + } + + #[Test] + public function withStringableSystemPrompt(): void + { + $processor = new SystemPromptInputProcessor( + new SystemPromptService(), + new class implements ToolboxInterface { + public function getTools(): array + { + return [ + new Tool(new ExecutionReference(ToolNoParams::class), 'tool_no_params', 'A tool without parameters', null), + ]; + } + + public function execute(ToolCall $toolCall): mixed + { + return null; + } + } + ); + + $input = new Input(new GPT(), new MessageBag(Message::ofUser('This is a user message')), []); + $processor->processInput($input); + + $messages = $input->messages->getMessages(); + self::assertCount(2, $messages); + self::assertInstanceOf(SystemMessage::class, $messages[0]); + self::assertInstanceOf(UserMessage::class, $messages[1]); + self::assertSame(<<content); + } +} diff --git a/src/agent/tests/InputProcessor/SystemPromptService.php b/src/agent/tests/InputProcessor/SystemPromptService.php new file mode 100644 index 000000000..1fc0e4945 --- /dev/null +++ b/src/agent/tests/InputProcessor/SystemPromptService.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\InputProcessor; + +final class SystemPromptService implements \Stringable +{ + public function __toString(): string + { + return 'My dynamic system prompt.'; + } +} diff --git a/src/agent/tests/StructuredOutput/ChainProcessorTest.php b/src/agent/tests/StructuredOutput/ChainProcessorTest.php new file mode 100644 index 000000000..b29d89992 --- /dev/null +++ b/src/agent/tests/StructuredOutput/ChainProcessorTest.php @@ -0,0 +1,173 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\StructuredOutput; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Exception\MissingModelSupportException; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\Output; +use Symfony\AI\Agent\StructuredOutput\AgentProcessor; +use Symfony\AI\Agent\Tests\Fixture\SomeStructure; +use Symfony\AI\Agent\Tests\Fixture\StructuredOutput\MathReasoning; +use Symfony\AI\Agent\Tests\Fixture\StructuredOutput\Step; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\Choice; +use Symfony\AI\Platform\Response\ObjectResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\Component\Serializer\SerializerInterface; + +#[CoversClass(AgentProcessor::class)] +#[UsesClass(Input::class)] +#[UsesClass(Output::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(Choice::class)] +#[UsesClass(MissingModelSupportException::class)] +#[UsesClass(TextResponse::class)] +#[UsesClass(ObjectResponse::class)] +#[UsesClass(Model::class)] +final class ChainProcessorTest extends TestCase +{ + #[Test] + public function processInputWithOutputStructure(): void + { + $agentProcessor = new AgentProcessor(new ConfigurableResponseFormatFactory(['some' => 'format'])); + + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $input = new Input($model, new MessageBag(), ['output_structure' => 'SomeStructure']); + + $agentProcessor->processInput($input); + + self::assertSame(['response_format' => ['some' => 'format']], $input->getOptions()); + } + + #[Test] + public function processInputWithoutOutputStructure(): void + { + $agentProcessor = new AgentProcessor(new ConfigurableResponseFormatFactory()); + + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $input = new Input($model, new MessageBag(), []); + + $agentProcessor->processInput($input); + + self::assertSame([], $input->getOptions()); + } + + #[Test] + public function processInputThrowsExceptionWhenLlmDoesNotSupportStructuredOutput(): void + { + self::expectException(MissingModelSupportException::class); + + $agentProcessor = new AgentProcessor(new ConfigurableResponseFormatFactory()); + + $model = new Model('gpt-3'); + $input = new Input($model, new MessageBag(), ['output_structure' => 'SomeStructure']); + + $agentProcessor->processInput($input); + } + + #[Test] + public function processOutputWithResponseFormat(): void + { + $agentProcessor = new AgentProcessor(new ConfigurableResponseFormatFactory(['some' => 'format'])); + + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $options = ['output_structure' => SomeStructure::class]; + $input = new Input($model, new MessageBag(), $options); + $agentProcessor->processInput($input); + + $response = new TextResponse('{"some": "data"}'); + + $output = new Output($model, $response, new MessageBag(), $input->getOptions()); + + $agentProcessor->processOutput($output); + + self::assertInstanceOf(ObjectResponse::class, $output->response); + self::assertInstanceOf(SomeStructure::class, $output->response->getContent()); + self::assertSame('data', $output->response->getContent()->some); + } + + #[Test] + public function processOutputWithComplexResponseFormat(): void + { + $agentProcessor = new AgentProcessor(new ConfigurableResponseFormatFactory(['some' => 'format'])); + + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $options = ['output_structure' => MathReasoning::class]; + $input = new Input($model, new MessageBag(), $options); + $agentProcessor->processInput($input); + + $response = new TextResponse(<<getOptions()); + + $agentProcessor->processOutput($output); + + self::assertInstanceOf(ObjectResponse::class, $output->response); + self::assertInstanceOf(MathReasoning::class, $structure = $output->response->getContent()); + self::assertCount(5, $structure->steps); + self::assertInstanceOf(Step::class, $structure->steps[0]); + self::assertInstanceOf(Step::class, $structure->steps[1]); + self::assertInstanceOf(Step::class, $structure->steps[2]); + self::assertInstanceOf(Step::class, $structure->steps[3]); + self::assertInstanceOf(Step::class, $structure->steps[4]); + self::assertSame('x = -3.75', $structure->finalAnswer); + } + + #[Test] + public function processOutputWithoutResponseFormat(): void + { + $responseFormatFactory = new ConfigurableResponseFormatFactory(); + $serializer = self::createMock(SerializerInterface::class); + $agentProcessor = new AgentProcessor($responseFormatFactory, $serializer); + + $model = self::createMock(Model::class); + $response = new TextResponse(''); + + $output = new Output($model, $response, new MessageBag(), []); + + $agentProcessor->processOutput($output); + + self::assertSame($response, $output->response); + } +} diff --git a/src/agent/tests/StructuredOutput/ConfigurableResponseFormatFactory.php b/src/agent/tests/StructuredOutput/ConfigurableResponseFormatFactory.php new file mode 100644 index 000000000..74a0bdbf8 --- /dev/null +++ b/src/agent/tests/StructuredOutput/ConfigurableResponseFormatFactory.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\StructuredOutput; + +use Symfony\AI\Agent\StructuredOutput\ResponseFormatFactoryInterface; + +final readonly class ConfigurableResponseFormatFactory implements ResponseFormatFactoryInterface +{ + /** + * @param array $responseFormat + */ + public function __construct( + private array $responseFormat = [], + ) { + } + + public function create(string $responseClass): array + { + return $this->responseFormat; + } +} diff --git a/src/agent/tests/StructuredOutput/ResponseFormatFactoryTest.php b/src/agent/tests/StructuredOutput/ResponseFormatFactoryTest.php new file mode 100644 index 000000000..90af8a84f --- /dev/null +++ b/src/agent/tests/StructuredOutput/ResponseFormatFactoryTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\StructuredOutput; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\StructuredOutput\ResponseFormatFactory; +use Symfony\AI\Agent\Tests\Fixture\StructuredOutput\User; +use Symfony\AI\Platform\Contract\JsonSchema\DescriptionParser; +use Symfony\AI\Platform\Contract\JsonSchema\Factory; + +#[CoversClass(ResponseFormatFactory::class)] +#[UsesClass(DescriptionParser::class)] +#[UsesClass(Factory::class)] +final class ResponseFormatFactoryTest extends TestCase +{ + #[Test] + public function create(): void + { + self::assertSame([ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => 'User', + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the user in lowercase', + ], + 'createdAt' => [ + 'type' => 'string', + 'format' => 'date-time', + ], + 'isActive' => ['type' => 'boolean'], + 'age' => ['type' => ['integer', 'null']], + ], + 'required' => ['id', 'name', 'createdAt', 'isActive'], + 'additionalProperties' => false, + ], + 'strict' => true, + ], + ], (new ResponseFormatFactory())->create(User::class)); + } +} diff --git a/src/agent/tests/Toolbox/Attribute/AsToolTest.php b/src/agent/tests/Toolbox/Attribute/AsToolTest.php new file mode 100644 index 000000000..8d54a18ff --- /dev/null +++ b/src/agent/tests/Toolbox/Attribute/AsToolTest.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox\Attribute; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +#[CoversClass(AsTool::class)] +final class AsToolTest extends TestCase +{ + #[Test] + public function canBeConstructed(): void + { + $attribute = new AsTool( + name: 'name', + description: 'description', + ); + + self::assertSame('name', $attribute->name); + self::assertSame('description', $attribute->description); + } +} diff --git a/src/agent/tests/Toolbox/ChainProcessorTest.php b/src/agent/tests/Toolbox/ChainProcessorTest.php new file mode 100644 index 000000000..f3224d06a --- /dev/null +++ b/src/agent/tests/Toolbox/ChainProcessorTest.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Exception\MissingModelSupportException; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\Toolbox\AgentProcessor; +use Symfony\AI\Agent\Toolbox\ToolboxInterface; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +#[CoversClass(AgentProcessor::class)] +#[UsesClass(Input::class)] +#[UsesClass(Tool::class)] +#[UsesClass(ExecutionReference::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(MissingModelSupportException::class)] +#[UsesClass(Model::class)] +class ChainProcessorTest extends TestCase +{ + #[Test] + public function processInputWithoutRegisteredToolsWillResultInNoOptionChange(): void + { + $toolbox = $this->createStub(ToolboxInterface::class); + $toolbox->method('getTools')->willReturn([]); + + $model = new Model('gpt-4', [Capability::TOOL_CALLING]); + $agentProcessor = new AgentProcessor($toolbox); + $input = new Input($model, new MessageBag(), []); + + $agentProcessor->processInput($input); + + self::assertSame([], $input->getOptions()); + } + + #[Test] + public function processInputWithRegisteredToolsWillResultInOptionChange(): void + { + $toolbox = $this->createStub(ToolboxInterface::class); + $tool1 = new Tool(new ExecutionReference('ClassTool1', 'method1'), 'tool1', 'description1', null); + $tool2 = new Tool(new ExecutionReference('ClassTool2', 'method1'), 'tool2', 'description2', null); + $toolbox->method('getTools')->willReturn([$tool1, $tool2]); + + $model = new Model('gpt-4', [Capability::TOOL_CALLING]); + $agentProcessor = new AgentProcessor($toolbox); + $input = new Input($model, new MessageBag(), []); + + $agentProcessor->processInput($input); + + self::assertSame(['tools' => [$tool1, $tool2]], $input->getOptions()); + } + + #[Test] + public function processInputWithRegisteredToolsButToolOverride(): void + { + $toolbox = $this->createStub(ToolboxInterface::class); + $tool1 = new Tool(new ExecutionReference('ClassTool1', 'method1'), 'tool1', 'description1', null); + $tool2 = new Tool(new ExecutionReference('ClassTool2', 'method1'), 'tool2', 'description2', null); + $toolbox->method('getTools')->willReturn([$tool1, $tool2]); + + $model = new Model('gpt-4', [Capability::TOOL_CALLING]); + $agentProcessor = new AgentProcessor($toolbox); + $input = new Input($model, new MessageBag(), ['tools' => ['tool2']]); + + $agentProcessor->processInput($input); + + self::assertSame(['tools' => [$tool2]], $input->getOptions()); + } + + #[Test] + public function processInputWithUnsupportedToolCallingWillThrowException(): void + { + self::expectException(MissingModelSupportException::class); + + $model = new Model('gpt-3'); + $agentProcessor = new AgentProcessor($this->createStub(ToolboxInterface::class)); + $input = new Input($model, new MessageBag(), []); + + $agentProcessor->processInput($input); + } +} diff --git a/src/agent/tests/Toolbox/FaultTolerantToolboxTest.php b/src/agent/tests/Toolbox/FaultTolerantToolboxTest.php new file mode 100644 index 000000000..53bb47754 --- /dev/null +++ b/src/agent/tests/Toolbox/FaultTolerantToolboxTest.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolNoParams; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolRequiredParams; +use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException; +use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException; +use Symfony\AI\Agent\Toolbox\FaultTolerantToolbox; +use Symfony\AI\Agent\Toolbox\ToolboxInterface; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +#[CoversClass(FaultTolerantToolbox::class)] +#[UsesClass(ToolCall::class)] +#[UsesClass(Tool::class)] +#[UsesClass(ExecutionReference::class)] +#[UsesClass(ToolNotFoundException::class)] +#[UsesClass(ToolExecutionException::class)] +final class FaultTolerantToolboxTest extends TestCase +{ + #[Test] + public function faultyToolExecution(): void + { + $faultyToolbox = $this->createFaultyToolbox( + fn (ToolCall $toolCall) => ToolExecutionException::executionFailed($toolCall, new \Exception('error')) + ); + + $faultTolerantToolbox = new FaultTolerantToolbox($faultyToolbox); + $expected = 'An error occurred while executing tool "tool_foo".'; + + $toolCall = new ToolCall('987654321', 'tool_foo'); + $actual = $faultTolerantToolbox->execute($toolCall); + + self::assertSame($expected, $actual); + } + + #[Test] + public function faultyToolCall(): void + { + $faultyToolbox = $this->createFaultyToolbox( + fn (ToolCall $toolCall) => ToolNotFoundException::notFoundForToolCall($toolCall) + ); + + $faultTolerantToolbox = new FaultTolerantToolbox($faultyToolbox); + $expected = 'Tool "tool_xyz" was not found, please use one of these: tool_no_params, tool_required_params'; + + $toolCall = new ToolCall('123456789', 'tool_xyz'); + $actual = $faultTolerantToolbox->execute($toolCall); + + self::assertSame($expected, $actual); + } + + private function createFaultyToolbox(\Closure $exceptionFactory): ToolboxInterface + { + return new class($exceptionFactory) implements ToolboxInterface { + public function __construct(private readonly \Closure $exceptionFactory) + { + } + + /** + * @return Tool[] + */ + public function getTools(): array + { + return [ + new Tool(new ExecutionReference(ToolNoParams::class), 'tool_no_params', 'A tool without parameters', null), + new Tool(new ExecutionReference(ToolRequiredParams::class, 'bar'), 'tool_required_params', 'A tool with required parameters', null), + ]; + } + + public function execute(ToolCall $toolCall): mixed + { + throw ($this->exceptionFactory)($toolCall); + } + }; + } +} diff --git a/src/agent/tests/Toolbox/MetadataFactory/ChainFactoryTest.php b/src/agent/tests/Toolbox/MetadataFactory/ChainFactoryTest.php new file mode 100644 index 000000000..41044dab8 --- /dev/null +++ b/src/agent/tests/Toolbox/MetadataFactory/ChainFactoryTest.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox\MetadataFactory; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Medium; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolMisconfigured; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolMultiple; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolNoAttribute1; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolOptionalParam; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolRequiredParams; +use Symfony\AI\Agent\Toolbox\Exception\ToolConfigurationException; +use Symfony\AI\Agent\Toolbox\Exception\ToolException; +use Symfony\AI\Agent\Toolbox\ToolFactory\ChainFactory; +use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory; +use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory; + +#[CoversClass(ChainFactory::class)] +#[Medium] +#[UsesClass(MemoryToolFactory::class)] +#[UsesClass(ReflectionToolFactory::class)] +#[UsesClass(ToolException::class)] +final class ChainFactoryTest extends TestCase +{ + private ChainFactory $factory; + + protected function setUp(): void + { + $factory1 = (new MemoryToolFactory()) + ->addTool(ToolNoAttribute1::class, 'reference', 'A reference tool') + ->addTool(ToolOptionalParam::class, 'optional_param', 'Tool with optional param', 'bar'); + $factory2 = new ReflectionToolFactory(); + + $this->factory = new ChainFactory([$factory1, $factory2]); + } + + #[Test] + public function testGetMetadataNotExistingClass(): void + { + self::expectException(ToolException::class); + self::expectExceptionMessage('The reference "NoClass" is not a valid tool.'); + + iterator_to_array($this->factory->getTool('NoClass')); + } + + #[Test] + public function testGetMetadataNotConfiguredClass(): void + { + self::expectException(ToolConfigurationException::class); + self::expectExceptionMessage(\sprintf('Method "foo" not found in tool "%s".', ToolMisconfigured::class)); + + iterator_to_array($this->factory->getTool(ToolMisconfigured::class)); + } + + #[Test] + public function testGetMetadataWithAttributeSingleHit(): void + { + $metadata = iterator_to_array($this->factory->getTool(ToolRequiredParams::class)); + + self::assertCount(1, $metadata); + } + + #[Test] + public function testGetMetadataOverwrite(): void + { + $metadata = iterator_to_array($this->factory->getTool(ToolOptionalParam::class)); + + self::assertCount(1, $metadata); + self::assertSame('optional_param', $metadata[0]->name); + self::assertSame('Tool with optional param', $metadata[0]->description); + self::assertSame('bar', $metadata[0]->reference->method); + } + + #[Test] + public function testGetMetadataWithAttributeDoubleHit(): void + { + $metadata = iterator_to_array($this->factory->getTool(ToolMultiple::class)); + + self::assertCount(2, $metadata); + } + + #[Test] + public function testGetMetadataWithMemorySingleHit(): void + { + $metadata = iterator_to_array($this->factory->getTool(ToolNoAttribute1::class)); + + self::assertCount(1, $metadata); + } +} diff --git a/src/agent/tests/Toolbox/MetadataFactory/MemoryFactoryTest.php b/src/agent/tests/Toolbox/MetadataFactory/MemoryFactoryTest.php new file mode 100644 index 000000000..d59447427 --- /dev/null +++ b/src/agent/tests/Toolbox/MetadataFactory/MemoryFactoryTest.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox\MetadataFactory; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolNoAttribute1; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolNoAttribute2; +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Agent\Toolbox\Exception\ToolException; +use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory; +use Symfony\AI\Platform\Contract\JsonSchema\DescriptionParser; +use Symfony\AI\Platform\Contract\JsonSchema\Factory; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +#[CoversClass(MemoryToolFactory::class)] +#[UsesClass(AsTool::class)] +#[UsesClass(Tool::class)] +#[UsesClass(ExecutionReference::class)] +#[UsesClass(ToolException::class)] +#[UsesClass(Factory::class)] +#[UsesClass(DescriptionParser::class)] +final class MemoryFactoryTest extends TestCase +{ + #[Test] + public function getMetadataWithoutTools(): void + { + self::expectException(ToolException::class); + self::expectExceptionMessage('The reference "SomeClass" is not a valid tool.'); + + $factory = new MemoryToolFactory(); + iterator_to_array($factory->getTool('SomeClass')); // @phpstan-ignore-line Yes, this class does not exist + } + + #[Test] + public function getMetadataWithDistinctToolPerClass(): void + { + $factory = (new MemoryToolFactory()) + ->addTool(ToolNoAttribute1::class, 'happy_birthday', 'Generates birthday message') + ->addTool(new ToolNoAttribute2(), 'checkout', 'Buys a number of items per product', 'buy'); + + $metadata = iterator_to_array($factory->getTool(ToolNoAttribute1::class)); + + self::assertCount(1, $metadata); + self::assertInstanceOf(Tool::class, $metadata[0]); + self::assertSame('happy_birthday', $metadata[0]->name); + self::assertSame('Generates birthday message', $metadata[0]->description); + self::assertSame('__invoke', $metadata[0]->reference->method); + + $expectedParams = [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'the name of the person'], + 'years' => ['type' => 'integer', 'description' => 'the age of the person'], + ], + 'required' => ['name', 'years'], + 'additionalProperties' => false, + ]; + + self::assertSame($expectedParams, $metadata[0]->parameters); + } + + #[Test] + public function getMetadataWithMultipleToolsInClass(): void + { + $factory = (new MemoryToolFactory()) + ->addTool(ToolNoAttribute2::class, 'checkout', 'Buys a number of items per product', 'buy') + ->addTool(ToolNoAttribute2::class, 'cancel', 'Cancels an order', 'cancel'); + + $metadata = iterator_to_array($factory->getTool(ToolNoAttribute2::class)); + + self::assertCount(2, $metadata); + self::assertInstanceOf(Tool::class, $metadata[0]); + self::assertSame('checkout', $metadata[0]->name); + self::assertSame('Buys a number of items per product', $metadata[0]->description); + self::assertSame('buy', $metadata[0]->reference->method); + + $expectedParams = [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'description' => 'the ID of the product'], + 'amount' => ['type' => 'integer', 'description' => 'the number of products'], + ], + 'required' => ['id', 'amount'], + 'additionalProperties' => false, + ]; + self::assertSame($expectedParams, $metadata[0]->parameters); + + self::assertInstanceOf(Tool::class, $metadata[1]); + self::assertSame('cancel', $metadata[1]->name); + self::assertSame('Cancels an order', $metadata[1]->description); + self::assertSame('cancel', $metadata[1]->reference->method); + + $expectedParams = [ + 'type' => 'object', + 'properties' => [ + 'orderId' => ['type' => 'string', 'description' => 'the ID of the order'], + ], + 'required' => ['orderId'], + 'additionalProperties' => false, + ]; + self::assertSame($expectedParams, $metadata[1]->parameters); + } +} diff --git a/src/agent/tests/Toolbox/MetadataFactory/ReflectionFactoryTest.php b/src/agent/tests/Toolbox/MetadataFactory/ReflectionFactoryTest.php new file mode 100644 index 000000000..e6133cbec --- /dev/null +++ b/src/agent/tests/Toolbox/MetadataFactory/ReflectionFactoryTest.php @@ -0,0 +1,155 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox\MetadataFactory; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolMultiple; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolRequiredParams; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolWrong; +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Agent\Toolbox\Exception\ToolConfigurationException; +use Symfony\AI\Agent\Toolbox\Exception\ToolException; +use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory; +use Symfony\AI\Platform\Contract\JsonSchema\DescriptionParser; +use Symfony\AI\Platform\Contract\JsonSchema\Factory; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +#[CoversClass(ReflectionToolFactory::class)] +#[UsesClass(AsTool::class)] +#[UsesClass(Tool::class)] +#[UsesClass(ExecutionReference::class)] +#[UsesClass(Factory::class)] +#[UsesClass(DescriptionParser::class)] +#[UsesClass(ToolConfigurationException::class)] +#[UsesClass(ToolException::class)] +final class ReflectionFactoryTest extends TestCase +{ + private ReflectionToolFactory $factory; + + protected function setUp(): void + { + $this->factory = new ReflectionToolFactory(); + } + + #[Test] + public function invalidReferenceNonExistingClass(): void + { + self::expectException(ToolException::class); + self::expectExceptionMessage('The reference "invalid" is not a valid tool.'); + + iterator_to_array($this->factory->getTool('invalid')); // @phpstan-ignore-line Yes, this class does not exist + } + + #[Test] + public function withoutAttribute(): void + { + self::expectException(ToolException::class); + self::expectExceptionMessage(\sprintf('The class "%s" is not a tool, please add %s attribute.', ToolWrong::class, AsTool::class)); + + iterator_to_array($this->factory->getTool(ToolWrong::class)); + } + + #[Test] + public function getDefinition(): void + { + /** @var Tool[] $metadatas */ + $metadatas = iterator_to_array($this->factory->getTool(ToolRequiredParams::class)); + + self::assertToolConfiguration( + metadata: $metadatas[0], + className: ToolRequiredParams::class, + name: 'tool_required_params', + description: 'A tool with required parameters', + method: 'bar', + parameters: [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text', 'number'], + 'additionalProperties' => false, + ], + ); + } + + #[Test] + public function getDefinitionWithMultiple(): void + { + $metadatas = iterator_to_array($this->factory->getTool(ToolMultiple::class)); + + self::assertCount(2, $metadatas); + + [$first, $second] = $metadatas; + + self::assertToolConfiguration( + metadata: $first, + className: ToolMultiple::class, + name: 'tool_hello_world', + description: 'Function to say hello', + method: 'hello', + parameters: [ + 'type' => 'object', + 'properties' => [ + 'world' => [ + 'type' => 'string', + 'description' => 'The world to say hello to', + ], + ], + 'required' => ['world'], + 'additionalProperties' => false, + ], + ); + + self::assertToolConfiguration( + metadata: $second, + className: ToolMultiple::class, + name: 'tool_required_params', + description: 'Function to say a number', + method: 'bar', + parameters: [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text', 'number'], + 'additionalProperties' => false, + ], + ); + } + + private function assertToolConfiguration(Tool $metadata, string $className, string $name, string $description, string $method, array $parameters): void + { + self::assertSame($className, $metadata->reference->class); + self::assertSame($method, $metadata->reference->method); + self::assertSame($name, $metadata->name); + self::assertSame($description, $metadata->description); + self::assertSame($parameters, $metadata->parameters); + } +} diff --git a/src/agent/tests/Toolbox/Tool/BraveTest.php b/src/agent/tests/Toolbox/Tool/BraveTest.php new file mode 100644 index 000000000..730fbe2e9 --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/BraveTest.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox\Tool; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\Tool\Brave; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\HttpClient\Response\MockResponse; + +#[CoversClass(Brave::class)] +final class BraveTest extends TestCase +{ + #[Test] + public function returnsSearchResults(): void + { + $response = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/brave.json'); + $httpClient = new MockHttpClient($response); + $brave = new Brave($httpClient, 'test-api-key'); + + $results = $brave('latest Dallas Cowboys game result'); + + self::assertCount(5, $results); + self::assertArrayHasKey('title', $results[0]); + self::assertSame('Dallas Cowboys Scores, Stats and Highlights - ESPN', $results[0]['title']); + self::assertArrayHasKey('description', $results[0]); + self::assertSame('Visit ESPN for Dallas Cowboys live scores, video highlights, and latest news. Find standings and the full 2024 season schedule.', $results[0]['description']); + self::assertArrayHasKey('url', $results[0]); + self::assertSame('https://www.espn.com/nfl/team/_/name/dal/dallas-cowboys', $results[0]['url']); + } + + #[Test] + public function passesCorrectParametersToApi(): void + { + $response = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/brave.json'); + $httpClient = new MockHttpClient($response); + $brave = new Brave($httpClient, 'test-api-key', ['extra' => 'option']); + + $brave('test query', 10, 5); + + $request = $response->getRequestUrl(); + self::assertStringContainsString('q=test%20query', $request); + self::assertStringContainsString('count=10', $request); + self::assertStringContainsString('offset=5', $request); + self::assertStringContainsString('extra=option', $request); + + $requestOptions = $response->getRequestOptions(); + self::assertArrayHasKey('headers', $requestOptions); + self::assertContains('X-Subscription-Token: test-api-key', $requestOptions['headers']); + } + + #[Test] + public function handlesEmptyResults(): void + { + $response = new MockResponse(json_encode(['web' => ['results' => []]])); + $httpClient = new MockHttpClient($response); + $brave = new Brave($httpClient, 'test-api-key'); + + $results = $brave('this should return nothing'); + + self::assertEmpty($results); + } + + /** + * This can be replaced by `JsonMockResponse::fromFile` when dropping Symfony 6.4. + */ + private function jsonMockResponseFromFile(string $file): JsonMockResponse + { + return new JsonMockResponse(json_decode(file_get_contents($file), true)); + } +} diff --git a/src/agent/tests/Toolbox/Tool/OpenMeteoTest.php b/src/agent/tests/Toolbox/Tool/OpenMeteoTest.php new file mode 100644 index 000000000..6a9050b18 --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/OpenMeteoTest.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox\Tool; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\Tool\OpenMeteo; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +#[CoversClass(OpenMeteo::class)] +final class OpenMeteoTest extends TestCase +{ + #[Test] + public function current(): void + { + $response = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/openmeteo-current.json'); + $httpClient = new MockHttpClient($response); + + $openMeteo = new OpenMeteo($httpClient); + + $actual = $openMeteo->current(52.52, 13.42); + $expected = [ + 'weather' => 'Overcast', + 'time' => '2024-12-21T01:15', + 'temperature' => '2.6°C', + 'wind_speed' => '10.7km/h', + ]; + + static::assertSame($expected, $actual); + } + + #[Test] + public function forecast(): void + { + $response = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/openmeteo-forecast.json'); + $httpClient = new MockHttpClient($response); + + $openMeteo = new OpenMeteo($httpClient); + + $actual = $openMeteo->forecast(52.52, 13.42, 3); + $expected = [ + [ + 'weather' => 'Light Rain', + 'time' => '2024-12-21', + 'temperature_min' => '2°C', + 'temperature_max' => '6°C', + ], + [ + 'weather' => 'Light Showers', + 'time' => '2024-12-22', + 'temperature_min' => '1.3°C', + 'temperature_max' => '6.4°C', + ], + [ + 'weather' => 'Light Snow Showers', + 'time' => '2024-12-23', + 'temperature_min' => '1.5°C', + 'temperature_max' => '4.1°C', + ], + ]; + + static::assertSame($expected, $actual); + } + + /** + * This can be replaced by `JsonMockResponse::fromFile` when dropping Symfony 6.4. + */ + private function jsonMockResponseFromFile(string $file): JsonMockResponse + { + return new JsonMockResponse(json_decode(file_get_contents($file), true)); + } +} diff --git a/src/agent/tests/Toolbox/Tool/WikipediaTest.php b/src/agent/tests/Toolbox/Tool/WikipediaTest.php new file mode 100644 index 000000000..1547eb93c --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/WikipediaTest.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox\Tool; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\Tool\Wikipedia; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +#[CoversClass(Wikipedia::class)] +final class WikipediaTest extends TestCase +{ + #[Test] + public function searchWithResults(): void + { + $response = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/wikipedia-search-result.json'); + $httpClient = new MockHttpClient($response); + + $wikipedia = new Wikipedia($httpClient); + + $actual = $wikipedia->search('current secretary of the united nations'); + $expected = <<jsonMockResponseFromFile(__DIR__.'/fixtures/wikipedia-search-empty.json'); + $httpClient = new MockHttpClient($response); + + $wikipedia = new Wikipedia($httpClient); + + $actual = $wikipedia->search('weird questions without results'); + $expected = 'No articles were found on Wikipedia.'; + + static::assertSame($expected, $actual); + } + + #[Test] + public function articleWithResult(): void + { + $response = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/wikipedia-article.json'); + $httpClient = new MockHttpClient($response); + + $wikipedia = new Wikipedia($httpClient); + + $actual = $wikipedia->article('Secretary-General of the United Nations'); + $expected = <<jsonMockResponseFromFile(__DIR__.'/fixtures/wikipedia-article-redirect.json'); + $httpClient = new MockHttpClient($response); + + $wikipedia = new Wikipedia($httpClient); + + $actual = $wikipedia->article('United Nations secretary-general'); + $expected = <<jsonMockResponseFromFile(__DIR__.'/fixtures/wikipedia-article-missing.json'); + $httpClient = new MockHttpClient($response); + + $wikipedia = new Wikipedia($httpClient); + + $actual = $wikipedia->article('Blah blah blah'); + $expected = 'No article with title "Blah blah blah" was found on Wikipedia.'; + + static::assertSame($expected, $actual); + } + + /** + * This can be replaced by `JsonMockResponse::fromFile` when dropping Symfony 6.4. + */ + private function jsonMockResponseFromFile(string $file): JsonMockResponse + { + return new JsonMockResponse(json_decode(file_get_contents($file), true)); + } +} diff --git a/src/agent/tests/Toolbox/Tool/fixtures/brave.json b/src/agent/tests/Toolbox/Tool/fixtures/brave.json new file mode 100644 index 000000000..d8793382b --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/fixtures/brave.json @@ -0,0 +1,276 @@ +{ + "query": { + "original": "latest Dallas Cowboys game result", + "show_strict_warning": false, + "is_navigational": false, + "is_news_breaking": false, + "spellcheck_off": true, + "country": "us", + "bad_results": false, + "should_fallback": false, + "postal_code": "", + "city": "", + "header_country": "", + "more_results_available": true, + "state": "" + }, + "mixed": { + "type": "mixed", + "main": [ + { + "type": "web", + "index": 0, + "all": false + }, + { + "type": "web", + "index": 1, + "all": false + }, + { + "type": "web", + "index": 2, + "all": false + }, + { + "type": "web", + "index": 3, + "all": false + }, + { + "type": "web", + "index": 4, + "all": false + }, + { + "type": "web", + "index": 5, + "all": false + }, + { + "type": "web", + "index": 6, + "all": false + }, + { + "type": "web", + "index": 7, + "all": false + }, + { + "type": "web", + "index": 8, + "all": false + }, + { + "type": "web", + "index": 9, + "all": false + }, + { + "type": "web", + "index": 10, + "all": false + }, + { + "type": "web", + "index": 11, + "all": false + }, + { + "type": "web", + "index": 12, + "all": false + }, + { + "type": "web", + "index": 13, + "all": false + }, + { + "type": "web", + "index": 14, + "all": false + }, + { + "type": "web", + "index": 15, + "all": false + }, + { + "type": "web", + "index": 16, + "all": false + }, + { + "type": "web", + "index": 17, + "all": false + }, + { + "type": "web", + "index": 18, + "all": false + } + ], + "top": [], + "side": [] + }, + "type": "search", + "web": { + "type": "search", + "results": [ + { + "title": "Dallas Cowboys Scores, Stats and Highlights - ESPN", + "url": "https://www.espn.com/nfl/team/_/name/dal/dallas-cowboys", + "is_source_local": false, + "is_source_both": false, + "description": "Visit ESPN for Dallas Cowboys live scores, video highlights, and latest news. Find standings and the full 2024 season schedule.", + "profile": { + "name": "ESPN", + "url": "https://www.espn.com/nfl/team/_/name/dal/dallas-cowboys", + "long_name": "Entertainment and Sports Programming Network", + "img": "https://imgs.search.brave.com/Kz1hWnjcBXLBXExGU0hCyCn2-pB94hTqPkqNv2qL9Ds/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvM2MzMjQzYzM4/MGZiMjZlNDJlY2Iy/ZjM1N2RjZjMxYzhk/YWNiNmVlMGViMDRl/ZGVhYzJjMzI4OTkz/NDY0MGI4MS93d3cu/ZXNwbi5jb20v" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "espn.com", + "hostname": "www.espn.com", + "favicon": "https://imgs.search.brave.com/Kz1hWnjcBXLBXExGU0hCyCn2-pB94hTqPkqNv2qL9Ds/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvM2MzMjQzYzM4/MGZiMjZlNDJlY2Iy/ZjM1N2RjZjMxYzhk/YWNiNmVlMGViMDRl/ZGVhYzJjMzI4OTkz/NDY0MGI4MS93d3cu/ZXNwbi5jb20v", + "path": "› nfl › team › _ › name › dal › dallas-cowboys" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/yoylqmAf8Idap3k8AvVgIN8VAnBC3qYLTIPUhr-dngk/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9hLmVz/cG5jZG4uY29tL2Nv/bWJpbmVyL2k_aW1n/PS9pL3RlYW1sb2dv/cy9uZmwvNTAwL2Rh/bC5wbmc", + "original": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/nfl/500/dal.png", + "logo": true + } + }, + { + "title": "Dallas Cowboys | Official Site of the Dallas Cowboys", + "url": "https://www.dallascowboys.com/", + "is_source_local": false, + "is_source_both": false, + "description": "As Mickey Spagnola writes in his Friday column, the Cowboys have to be ready for anything in this upcoming draft as the best-laid plans can often go awry. In this week's Mick Shots, Mickey Spagnola looks at how depth options could play a part on who to pick at No. 12. Plus, a closer look at ...", + "profile": { + "name": "Dallascowboys", + "url": "https://www.dallascowboys.com/", + "long_name": "dallascowboys.com", + "img": "https://imgs.search.brave.com/jlBcXKzEJ8ZVEM7kjduly5zdZdDd3ZKJa3KtSH06rxk/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvODk0YjBmYmE0/N2E2ZmM4NjgxYzI2/ZmZmMWMxODE3YTMz/MmM5YmQ4MDBkZmM3/NjFiOWNlYzczMGUz/OTg3NWRhZi93d3cu/ZGFsbGFzY293Ym95/cy5jb20v" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "dallascowboys.com", + "hostname": "www.dallascowboys.com", + "favicon": "https://imgs.search.brave.com/jlBcXKzEJ8ZVEM7kjduly5zdZdDd3ZKJa3KtSH06rxk/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvODk0YjBmYmE0/N2E2ZmM4NjgxYzI2/ZmZmMWMxODE3YTMz/MmM5YmQ4MDBkZmM3/NjFiOWNlYzczMGUz/OTg3NWRhZi93d3cu/ZGFsbGFzY293Ym95/cy5jb20v", + "path": "" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/IfNsWGGP4OLU1pxkWNTYIeZtTxjykyoRTWTaYkKDPU0/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly93d3cu/ZGFsbGFzY293Ym95/cy5jb20v", + "original": "https://www.dallascowboys.com/", + "logo": false + } + }, + { + "title": "Dallas Cowboys News, Scores, Status, Schedule - NFL - CBSSports.com", + "url": "https://www.cbssports.com/nfl/teams/DAL/dallas-cowboys/", + "is_source_local": false, + "is_source_both": false, + "description": "Get the latest news and information for the Dallas Cowboys. 2024 season schedule, scores, stats, and highlights. Find out the latest on your favorite NFL teams on CBSSports.com.", + "profile": { + "name": "Cbssports", + "url": "https://www.cbssports.com/nfl/teams/DAL/dallas-cowboys/", + "long_name": "cbssports.com", + "img": "https://imgs.search.brave.com/G8DEk0_A87RxEMyNA8Uhu5GaN1usv62iX_74SwwTHSk/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvM2FlZjEzYmM3/NzkwMzQ5ZWYwMWQ3/YjJiZGM5MGMxMWFl/ZDBlNmQxMTk2N2Fm/MjljMzU2OGIzMTUz/M2Q4ZjcxNS93d3cu/Y2Jzc3BvcnRzLmNv/bS8" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "cbssports.com", + "hostname": "www.cbssports.com", + "favicon": "https://imgs.search.brave.com/G8DEk0_A87RxEMyNA8Uhu5GaN1usv62iX_74SwwTHSk/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvM2FlZjEzYmM3/NzkwMzQ5ZWYwMWQ3/YjJiZGM5MGMxMWFl/ZDBlNmQxMTk2N2Fm/MjljMzU2OGIzMTUz/M2Q4ZjcxNS93d3cu/Y2Jzc3BvcnRzLmNv/bS8", + "path": "› nfl › teams › DAL › dallas-cowboys" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/9YB61Wfb-DCqHUR_XH_Tq7Iwy9KW9qCjCaO0bKpQ_bU/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9zcG9y/dHNmbHkuY2JzaXN0/YXRpYy5jb20vZmx5/LTA5NDQvYnVuZGxl/cy9zcG9ydHNtZWRp/YWNzcy9pbWFnZXMv/ZmFudGFzeS9kZWZh/dWx0LWFydGljbGUt/aW1hZ2UtbGFyZ2Uu/cG5n", + "original": "https://sportsfly.cbsistatic.com/fly-0944/bundles/sportsmediacss/images/fantasy/default-article-image-large.png", + "logo": false + } + }, + { + "title": "Dallas Cowboys News, Scores, Stats, Schedule | NFL.com", + "url": "https://www.nfl.com/teams/dallas-cowboys/", + "is_source_local": false, + "is_source_both": false, + "description": "Dallas Cowboys · 3rd NFC East · 7 - 10 - 0 Buy Gear · Official Website · @DallasCowboys · @DallasCowboys · @dallascowboys · @DallasCowboys · Info · Roster · Stats · Advertising · news · Apr 21, 2025 · news · Apr 18, 2025 · news · Apr 18, 2025 ·", + "profile": { + "name": "Nfl", + "url": "https://www.nfl.com/teams/dallas-cowboys/", + "long_name": "nfl.com", + "img": "https://imgs.search.brave.com/L4B2SCyb0Ao1-76nGZVpWlnyS8TkQBAEFnf4Lpb_KRY/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvZmQxNWFiOGQ3/Mjc0MWRjYjI0OTQx/N2E5NTNhODVkMGQ2/OTI3NzcyODQ4MzU2/Nzg3YTJmMjJiOGMz/OTM1NjVmYy93d3cu/bmZsLmNvbS8" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "nfl.com", + "hostname": "www.nfl.com", + "favicon": "https://imgs.search.brave.com/L4B2SCyb0Ao1-76nGZVpWlnyS8TkQBAEFnf4Lpb_KRY/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvZmQxNWFiOGQ3/Mjc0MWRjYjI0OTQx/N2E5NTNhODVkMGQ2/OTI3NzcyODQ4MzU2/Nzg3YTJmMjJiOGMz/OTM1NjVmYy93d3cu/bmZsLmNvbS8", + "path": "› teams › dallas-cowboys" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/0EETrH0-svPjjtTCtvV7kIQ5DuPcD03NtVFgNG8A2KM/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9zdGF0/aWMud3d3Lm5mbC5j/b20vdF9oZWFkc2hv/dF9kZXNrdG9wL2xl/YWd1ZS9hcGkvY2x1/YnMvbG9nb3MvREFM", + "original": "https://static.www.nfl.com/t_headshot_desktop/league/api/clubs/logos/DAL", + "logo": true + } + }, + { + "title": "Dallas Cowboys News, Videos, Schedule, Roster, Stats - Yahoo Sports", + "url": "https://sports.yahoo.com/nfl/teams/dallas/", + "is_source_local": false, + "is_source_both": false, + "description": "The team has 10 selections to get right in hopes of turning a 7-10 season in 2024 into an afterthought, and helping the Cowboys return to the playoffs. The good news is that Dallas has been one of the better drafting teams in the league, and they routinely find the right players to help win games.", + "profile": { + "name": "Yahoo!", + "url": "https://sports.yahoo.com/nfl/teams/dallas/", + "long_name": "sports.yahoo.com", + "img": "https://imgs.search.brave.com/pqn4FSmuomyVnDi_JumaeN-Milit-_D15P8bquK1CAc/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTUxYWU5NWYz/ZWIxOGI3M2Q5MzFh/MjlmZDczOWEyMzY5/M2FhZTZiOGIzOTQ0/YzlkMGI3YTI2MmM2/ZmJmMWE2Zi9zcG9y/dHMueWFob28uY29t/Lw" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "sports.yahoo.com", + "hostname": "sports.yahoo.com", + "favicon": "https://imgs.search.brave.com/pqn4FSmuomyVnDi_JumaeN-Milit-_D15P8bquK1CAc/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTUxYWU5NWYz/ZWIxOGI3M2Q5MzFh/MjlmZDczOWEyMzY5/M2FhZTZiOGIzOTQ0/YzlkMGI3YTI2MmM2/ZmJmMWE2Zi9zcG9y/dHMueWFob28uY29t/Lw", + "path": "› nfl › teams › dallas" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/okMqy3l4NCCt72TSaYUZSdJcmgHc9Q1i63ttIe-rIQ0/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9zLnlp/bWcuY29tL2l0L2Fw/aS9yZXMvMS4yL3ZX/bjVTVDlRQl95NmFi/dWl2cWdpQkEtLX5B/L1lYQndhV1E5ZVc1/bGQzTTdkejB4TWpB/d08yZzlOak13TzNF/OU1UQXcvaHR0cHM6/Ly9zLnlpbWcuY29t/L2N2L2FwaXYyL2Rl/ZmF1bHQvbmZsLzIw/MTkwNzI0LzUwMHg1/MDAvMjAxOV9EQUxf/d2JnLnBuZw", + "original": "https://s.yimg.com/it/api/res/1.2/vWn5ST9QB_y6abuivqgiBA--~A/YXBwaWQ9eW5ld3M7dz0xMjAwO2g9NjMwO3E9MTAw/https://s.yimg.com/cv/apiv2/default/nfl/20190724/500x500/2019_DAL_wbg.png", + "logo": false + } + } + ], + "family_friendly": true + } +} diff --git a/src/agent/tests/Toolbox/Tool/fixtures/openmeteo-current.json b/src/agent/tests/Toolbox/Tool/fixtures/openmeteo-current.json new file mode 100644 index 000000000..16d6cb266 --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/fixtures/openmeteo-current.json @@ -0,0 +1,23 @@ +{ + "latitude": 52.52, + "longitude": 13.419998, + "generationtime_ms": 0.06508827209472656, + "utc_offset_seconds": 0, + "timezone": "GMT", + "timezone_abbreviation": "GMT", + "elevation": 40.0, + "current_units": { + "time": "iso8601", + "interval": "seconds", + "weather_code": "wmo code", + "temperature_2m": "°C", + "wind_speed_10m": "km/h" + }, + "current": { + "time": "2024-12-21T01:15", + "interval": 900, + "weather_code": 3, + "temperature_2m": 2.6, + "wind_speed_10m": 10.7 + } +} diff --git a/src/agent/tests/Toolbox/Tool/fixtures/openmeteo-forecast.json b/src/agent/tests/Toolbox/Tool/fixtures/openmeteo-forecast.json new file mode 100644 index 000000000..beb4e1413 --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/fixtures/openmeteo-forecast.json @@ -0,0 +1,37 @@ +{ + "latitude": 52.52, + "longitude": 13.419998, + "generationtime_ms": 0.0629425048828125, + "utc_offset_seconds": 0, + "timezone": "GMT", + "timezone_abbreviation": "GMT", + "elevation": 38.0, + "daily_units": { + "time": "iso8601", + "weather_code": "wmo code", + "temperature_2m_max": "°C", + "temperature_2m_min": "°C" + }, + "daily": { + "time": [ + "2024-12-21", + "2024-12-22", + "2024-12-23" + ], + "weather_code": [ + 61, + 80, + 85 + ], + "temperature_2m_max": [ + 6.0, + 6.4, + 4.1 + ], + "temperature_2m_min": [ + 2.0, + 1.3, + 1.5 + ] + } +} diff --git a/src/agent/tests/Toolbox/Tool/fixtures/wikipedia-article-missing.json b/src/agent/tests/Toolbox/Tool/fixtures/wikipedia-article-missing.json new file mode 100644 index 000000000..2bea603dd --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/fixtures/wikipedia-article-missing.json @@ -0,0 +1,16 @@ +{ + "batchcomplete": "", + "query": { + "pages": { + "-1": { + "ns": 0, + "title": "Blah blah blah", + "missing": "", + "contentmodel": "wikitext", + "pagelanguage": "en", + "pagelanguagehtmlcode": "en", + "pagelanguagedir": "ltr" + } + } + } +} diff --git a/src/agent/tests/Toolbox/Tool/fixtures/wikipedia-article-redirect.json b/src/agent/tests/Toolbox/Tool/fixtures/wikipedia-article-redirect.json new file mode 100644 index 000000000..01175cd27 --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/fixtures/wikipedia-article-redirect.json @@ -0,0 +1,32 @@ +{ + "batchcomplete": "", + "query": { + "redirects": [ + { + "from": "United Nations secretary-general", + "to": "Secretary-General of the United Nations" + } + ], + "pages": { + "162415": { + "pageid": 162415, + "ns": 0, + "title": "Secretary-General of the United Nations", + "extract": "The secretary-general of the United Nations (UNSG or UNSECGEN) is the chief administrative officer of the United Nations and head of the United Nations Secretariat, one of the six principal organs of the United Nations. And so on.", + "contentmodel": "wikitext", + "pagelanguage": "en", + "pagelanguagehtmlcode": "en", + "pagelanguagedir": "ltr", + "touched": "2024-12-07T14:43:16Z", + "lastrevid": 1259468323, + "length": 35508, + "thumbnail": { + "source": "https:\/\/upload.wikimedia.org\/wikipedia\/commons\/thumb\/5\/52\/Emblem_of_the_United_Nations.svg\/50px-Emblem_of_the_United_Nations.svg.png", + "width": 50, + "height": 43 + }, + "pageimage": "Emblem_of_the_United_Nations.svg" + } + } + } +} diff --git a/src/agent/tests/Toolbox/Tool/fixtures/wikipedia-article.json b/src/agent/tests/Toolbox/Tool/fixtures/wikipedia-article.json new file mode 100644 index 000000000..6275e92b9 --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/fixtures/wikipedia-article.json @@ -0,0 +1,26 @@ +{ + "batchcomplete": "", + "query": { + "pages": { + "162415": { + "pageid": 162415, + "ns": 0, + "title": "Secretary-General of the United Nations", + "extract": "The secretary-general of the United Nations (UNSG or UNSECGEN) is the chief administrative officer of the United Nations and head of the United Nations Secretariat, one of the six principal organs of the United Nations. And so on.", + "contentmodel": "wikitext", + "pagelanguage": "en", + "pagelanguagehtmlcode": "en", + "pagelanguagedir": "ltr", + "touched": "2024-12-07T14:43:16Z", + "lastrevid": 1259468323, + "length": 35508, + "thumbnail": { + "source": "https:\/\/upload.wikimedia.org\/wikipedia\/commons\/thumb\/5\/52\/Emblem_of_the_United_Nations.svg\/50px-Emblem_of_the_United_Nations.svg.png", + "width": 50, + "height": 43 + }, + "pageimage": "Emblem_of_the_United_Nations.svg" + } + } + } +} diff --git a/src/agent/tests/Toolbox/Tool/fixtures/wikipedia-search-empty.json b/src/agent/tests/Toolbox/Tool/fixtures/wikipedia-search-empty.json new file mode 100644 index 000000000..3a547ec2b --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/fixtures/wikipedia-search-empty.json @@ -0,0 +1,11 @@ +{ + "batchcomplete": "", + "query": { + "searchinfo": { + "totalhits": 0 + }, + "search": [ + + ] + } +} diff --git a/src/agent/tests/Toolbox/Tool/fixtures/wikipedia-search-result.json b/src/agent/tests/Toolbox/Tool/fixtures/wikipedia-search-result.json new file mode 100644 index 000000000..20c725466 --- /dev/null +++ b/src/agent/tests/Toolbox/Tool/fixtures/wikipedia-search-result.json @@ -0,0 +1,104 @@ +{ + "batchcomplete": "", + "continue": { + "sroffset": 10, + "continue": "-||" + }, + "query": { + "searchinfo": { + "totalhits": 27227 + }, + "search": [ + { + "ns": 0, + "title": "Under-Secretary-General of the United Nations", + "pageid": 3223434, + "size": 15971, + "wordcount": 1569, + "snippet": "An under-secretary-general of the United Nations (USG) is a senior official within the United Nations System, normally appointed by the General Assembly", + "timestamp": "2024-11-28T08:11:08Z" + }, + { + "ns": 0, + "title": "United Nations secretary-general selection", + "pageid": 52735558, + "size": 36343, + "wordcount": 4335, + "snippet": "United Nations secretary-general selection is the process of selecting the next secretary-general of the United Nations. To be selected as secretary-general", + "timestamp": "2024-08-23T07:56:28Z" + }, + { + "ns": 0, + "title": "List of current permanent representatives to the United Nations", + "pageid": 4409476, + "size": 47048, + "wordcount": 1524, + "snippet": "is a list of the current permanent representatives to the United Nations at United Nations Headquarters, New York City. The list includes the country that", + "timestamp": "2024-11-11T03:04:58Z" + }, + { + "ns": 0, + "title": "United Nations", + "pageid": 31769, + "size": 173187, + "wordcount": 15417, + "snippet": "The United Nations (UN) is a diplomatic and political international organization with the intended purpose of maintaining international peace and security", + "timestamp": "2024-11-30T10:50:21Z" + }, + { + "ns": 0, + "title": "United Nations Secretariat", + "pageid": 162410, + "size": 23340, + "wordcount": 2389, + "snippet": "The United Nations Secretariat is one of the six principal organs of the United Nations (UN), The secretariat is the UN's executive arm. The secretariat", + "timestamp": "2024-10-07T15:57:54Z" + }, + { + "ns": 0, + "title": "Flag of the United Nations", + "pageid": 565612, + "size": 18615, + "wordcount": 1219, + "snippet": "The flag of the United Nations is a sky blue banner containing the United Nations' emblem in the centre. The emblem on the flag is coloured white; it is", + "timestamp": "2024-09-26T02:16:57Z" + }, + { + "ns": 0, + "title": "List of current members of the United States House of Representatives", + "pageid": 12498224, + "size": 262590, + "wordcount": 1704, + "snippet": "in the United States House of Representatives List of current United States senators List of members of the United States Congress by longevity of service", + "timestamp": "2024-12-06T14:58:13Z" + }, + { + "ns": 0, + "title": "Member states of the United Nations", + "pageid": 31969, + "size": 107893, + "wordcount": 8436, + "snippet": "The member states of the United Nations comprise 193 sovereign states. The United Nations (UN) is the world's largest intergovernmental organization.", + "timestamp": "2024-12-08T15:19:12Z" + }, + { + "ns": 0, + "title": "Official languages of the United Nations", + "pageid": 25948712, + "size": 57104, + "wordcount": 4976, + "snippet": "The official languages of the United Nations are the six languages used in United Nations (UN) meetings and in which the UN writes all its official documents", + "timestamp": "2024-11-11T13:41:37Z" + }, + { + "ns": 0, + "title": "United States Secretary of State", + "pageid": 32293, + "size": 18112, + "wordcount": 1513, + "snippet": "The United States secretary of state (SecState) is a member of the executive branch of the federal government and the head of the Department of State", + "timestamp": "2024-12-01T17:58:13Z" + } + ] + } +} diff --git a/src/agent/tests/Toolbox/ToolResultConverterTest.php b/src/agent/tests/Toolbox/ToolResultConverterTest.php new file mode 100644 index 000000000..4a4c41596 --- /dev/null +++ b/src/agent/tests/Toolbox/ToolResultConverterTest.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\ToolResultConverter; + +#[CoversClass(ToolResultConverter::class)] +final class ToolResultConverterTest extends TestCase +{ + #[Test] + #[DataProvider('provideResults')] + public function testConvert(mixed $result, ?string $expected): void + { + $converter = new ToolResultConverter(); + + self::assertSame($expected, $converter->convert($result)); + } + + public static function provideResults(): \Generator + { + yield 'null' => [null, null]; + + yield 'integer' => [42, '42']; + + yield 'float' => [42.42, '42.42']; + + yield 'array' => [['key' => 'value'], '{"key":"value"}']; + + yield 'string' => ['plain string', 'plain string']; + + yield 'datetime' => [new \DateTimeImmutable('2021-07-31 12:34:56'), '2021-07-31T12:34:56+00:00']; + + yield 'stringable' => [ + new class implements \Stringable { + public function __toString(): string + { + return 'stringable'; + } + }, + 'stringable', + ]; + + yield 'json_serializable' => [ + new class implements \JsonSerializable { + public function jsonSerialize(): array + { + return ['key' => 'value']; + } + }, + '{"key":"value"}', + ]; + } +} diff --git a/src/agent/tests/Toolbox/ToolboxTest.php b/src/agent/tests/Toolbox/ToolboxTest.php new file mode 100644 index 000000000..64efcc456 --- /dev/null +++ b/src/agent/tests/Toolbox/ToolboxTest.php @@ -0,0 +1,265 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Toolbox; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolException; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolMisconfigured; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolNoAttribute1; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolNoParams; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolOptionalParam; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolRequiredParams; +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Agent\Toolbox\Exception\ToolConfigurationException; +use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException; +use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Agent\Toolbox\ToolFactory\ChainFactory; +use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory; +use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory; +use Symfony\AI\Platform\Contract\JsonSchema\DescriptionParser; +use Symfony\AI\Platform\Contract\JsonSchema\Factory; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +#[CoversClass(Toolbox::class)] +#[UsesClass(ToolCall::class)] +#[UsesClass(AsTool::class)] +#[UsesClass(Tool::class)] +#[UsesClass(ExecutionReference::class)] +#[UsesClass(ReflectionToolFactory::class)] +#[UsesClass(MemoryToolFactory::class)] +#[UsesClass(ChainFactory::class)] +#[UsesClass(Factory::class)] +#[UsesClass(DescriptionParser::class)] +#[UsesClass(ToolConfigurationException::class)] +#[UsesClass(ToolNotFoundException::class)] +#[UsesClass(ToolExecutionException::class)] +final class ToolboxTest extends TestCase +{ + private Toolbox $toolbox; + + protected function setUp(): void + { + $this->toolbox = new Toolbox(new ReflectionToolFactory(), [ + new ToolRequiredParams(), + new ToolOptionalParam(), + new ToolNoParams(), + new ToolException(), + ]); + } + + #[Test] + public function getTools(): void + { + $actual = $this->toolbox->getTools(); + + $toolRequiredParams = new Tool( + new ExecutionReference(ToolRequiredParams::class, 'bar'), + 'tool_required_params', + 'A tool with required parameters', + [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text', 'number'], + 'additionalProperties' => false, + ], + ); + + $toolOptionalParam = new Tool( + new ExecutionReference(ToolOptionalParam::class, 'bar'), + 'tool_optional_param', + 'A tool with one optional parameter', + [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text'], + 'additionalProperties' => false, + ], + ); + + $toolNoParams = new Tool( + new ExecutionReference(ToolNoParams::class), + 'tool_no_params', + 'A tool without parameters', + ); + + $toolException = new Tool( + new ExecutionReference(ToolException::class, 'bar'), + 'tool_exception', + 'This tool is broken', + ); + + $expected = [ + $toolRequiredParams, + $toolOptionalParam, + $toolNoParams, + $toolException, + ]; + + self::assertEquals($expected, $actual); + } + + #[Test] + public function executeWithUnknownTool(): void + { + self::expectException(ToolNotFoundException::class); + self::expectExceptionMessage('Tool not found for call: foo_bar_baz'); + + $this->toolbox->execute(new ToolCall('call_1234', 'foo_bar_baz')); + } + + #[Test] + public function executeWithMisconfiguredTool(): void + { + self::expectException(ToolConfigurationException::class); + self::expectExceptionMessage('Method "foo" not found in tool "Symfony\AI\Agent\Tests\Fixture\Tool\ToolMisconfigured".'); + + $toolbox = new Toolbox(new ReflectionToolFactory(), [new ToolMisconfigured()]); + + $toolbox->execute(new ToolCall('call_1234', 'tool_misconfigured')); + } + + #[Test] + public function executeWithException(): void + { + self::expectException(ToolExecutionException::class); + self::expectExceptionMessage('Execution of tool "tool_exception" failed with error: Tool error.'); + + $this->toolbox->execute(new ToolCall('call_1234', 'tool_exception')); + } + + #[Test] + #[DataProvider('executeProvider')] + public function execute(string $expected, string $toolName, array $toolPayload = []): void + { + self::assertSame( + $expected, + $this->toolbox->execute(new ToolCall('call_1234', $toolName, $toolPayload)), + ); + } + + /** + * @return iterable + */ + public static function executeProvider(): iterable + { + yield 'tool_required_params' => [ + 'Hello says "3".', + 'tool_required_params', + ['text' => 'Hello', 'number' => 3], + ]; + } + + #[Test] + public function toolboxMapWithMemoryFactory(): void + { + $memoryFactory = (new MemoryToolFactory()) + ->addTool(ToolNoAttribute1::class, 'happy_birthday', 'Generates birthday message'); + + $toolbox = new Toolbox($memoryFactory, [new ToolNoAttribute1()]); + $expected = [ + new Tool( + new ExecutionReference(ToolNoAttribute1::class, '__invoke'), + 'happy_birthday', + 'Generates birthday message', + [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'the name of the person', + ], + 'years' => [ + 'type' => 'integer', + 'description' => 'the age of the person', + ], + ], + 'required' => ['name', 'years'], + 'additionalProperties' => false, + ], + ), + ]; + + self::assertEquals($expected, $toolbox->getTools()); + } + + #[Test] + public function toolboxExecutionWithMemoryFactory(): void + { + $memoryFactory = (new MemoryToolFactory()) + ->addTool(ToolNoAttribute1::class, 'happy_birthday', 'Generates birthday message'); + + $toolbox = new Toolbox($memoryFactory, [new ToolNoAttribute1()]); + $response = $toolbox->execute(new ToolCall('call_1234', 'happy_birthday', ['name' => 'John', 'years' => 30])); + + self::assertSame('Happy Birthday, John! You are 30 years old.', $response); + } + + #[Test] + public function toolboxMapWithOverrideViaChain(): void + { + $factory1 = (new MemoryToolFactory()) + ->addTool(ToolOptionalParam::class, 'optional_param', 'Tool with optional param', 'bar'); + $factory2 = new ReflectionToolFactory(); + + $toolbox = new Toolbox(new ChainFactory([$factory1, $factory2]), [new ToolOptionalParam()]); + + $expected = [ + new Tool( + new ExecutionReference(ToolOptionalParam::class, 'bar'), + 'optional_param', + 'Tool with optional param', + [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text'], + 'additionalProperties' => false, + ], + ), + ]; + + self::assertEquals($expected, $toolbox->getTools()); + } +} diff --git a/src/ai-bundle/.gitattributes b/src/ai-bundle/.gitattributes new file mode 100644 index 000000000..ec8c01802 --- /dev/null +++ b/src/ai-bundle/.gitattributes @@ -0,0 +1,6 @@ +/.github export-ignore +/tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +phpstan.dist.neon export-ignore +phpunit.xml.dist export-ignore diff --git a/src/ai-bundle/.github/PULL_REQUEST_TEMPLATE.md b/src/ai-bundle/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..fcb87228a --- /dev/null +++ b/src/ai-bundle/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ai + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/ai-bundle/.github/workflows/close-pull-request.yml b/src/ai-bundle/.github/workflows/close-pull-request.yml new file mode 100644 index 000000000..207153fd5 --- /dev/null +++ b/src/ai-bundle/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ai + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/ai-bundle/.gitignore b/src/ai-bundle/.gitignore new file mode 100644 index 000000000..e90f5de2e --- /dev/null +++ b/src/ai-bundle/.gitignore @@ -0,0 +1,5 @@ +vendor +composer.lock +.php-cs-fixer.cache +.phpunit.cache +coverage diff --git a/src/ai-bundle/LICENSE b/src/ai-bundle/LICENSE new file mode 100644 index 000000000..bc38d714e --- /dev/null +++ b/src/ai-bundle/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/ai-bundle/README.md b/src/ai-bundle/README.md new file mode 100644 index 000000000..b20243c46 --- /dev/null +++ b/src/ai-bundle/README.md @@ -0,0 +1,176 @@ +# Symfony AI Bundle + +Symfony integration bundle for [symfony/ai](https://github.com/symfony/ai) components. + +## Installation + +```bash +composer require symfony/ai-bundle +``` + +## Configuration + +### Simple Example with OpenAI + +```yaml +# config/packages/ai.yaml +ai: + platform: + openai: + api_key: '%env(OPENAI_API_KEY)%' + agent: + default: + model: + name: 'GPT' +``` + +### Advanced Example with Anthropic, Azure, Google and multiple agents +```yaml +# config/packages/ai.yaml +ai: + platform: + anthropic: + api_key: '%env(ANTHROPIC_API_KEY)%' + azure: + # multiple deployments possible + gpt_deployment: + base_url: '%env(AZURE_OPENAI_BASEURL)%' + deployment: '%env(AZURE_OPENAI_GPT)%' + api_key: '%env(AZURE_OPENAI_KEY)%' + api_version: '%env(AZURE_GPT_VERSION)%' + google: + api_key: '%env(GOOGLE_API_KEY)%' + agent: + rag: + platform: 'symfony_ai.platform.azure.gpt_deployment' + structured_output: false # Disables support for "output_structure" option, default is true + model: + name: 'GPT' + version: 'gpt-4o-mini' + system_prompt: 'You are a helpful assistant that can answer questions.' # The default system prompt of the agent + include_tools: true # Include tool definitions at the end of the system prompt + tools: + # Referencing a service with #[AsTool] attribute + - 'Symfony\AI\Agent\Toolbox\Tool\SimilaritySearch' + + # Referencing a service without #[AsTool] attribute + - service: 'App\Agent\Tool\CompanyName' + name: 'company_name' + description: 'Provides the name of your company' + method: 'foo' # Optional with default value '__invoke' + + # Referencing an agent => agent uses agent 🤯 + - service: 'symfony_ai.agent.research' + name: 'wikipedia_research' + description: 'Can research on Wikipedia' + is_agent: true + research: + platform: 'symfony_ai.platform.anthropic' + model: + name: 'Claude' + tools: # If undefined, all tools are injected into the agent, use "tools: false" to disable tools. + - 'Symfony\AI\Agent\Toolbox\Tool\Wikipedia' + fault_tolerant_toolbox: false # Disables fault tolerant toolbox, default is true + store: + # also azure_search, mongodb and pinecone are supported as store type + chroma_db: + # multiple collections possible per type + default: + collection: 'my_collection' + embedder: + default: + # platform: 'symfony_ai.platform.anthropic' + # store: 'symfony_ai.store.chroma_db.default' + model: + name: 'Embeddings' + version: 'text-embedding-ada-002' +``` + +## Usage + +### Agent Service + +Use the `Agent` service to leverage GPT: +```php +use Symfony\AI\Agent\AgentInterface; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +final readonly class MyService +{ + public function __construct( + private AgentInterface $agent, + ) { + } + + public function submit(string $message): string + { + $messages = new MessageBag( + Message::forSystem('Speak like a pirate.'), + Message::ofUser($message), + ); + + return $this->agent->call($messages); + } +} +``` + +### Register Tools + +To use existing tools, you can register them as a service: +```yaml +services: + _defaults: + autowire: true + autoconfigure: true + + Symfony\AI\Agent\Toolbox\Tool\Clock: ~ + Symfony\AI\Agent\Toolbox\Tool\OpenMeteo: ~ + Symfony\AI\Agent\Toolbox\Tool\SerpApi: + $apiKey: '%env(SERP_API_KEY)%' + Symfony\AI\Agent\Toolbox\Tool\SimilaritySearch: ~ + Symfony\AI\Agent\Toolbox\Tool\Tavily: + $apiKey: '%env(TAVILY_API_KEY)%' + Symfony\AI\Agent\Toolbox\Tool\Wikipedia: ~ + Symfony\AI\Agent\Toolbox\Tool\YouTubeTranscriber: ~ +``` + +Custom tools can be registered by using the `#[AsTool]` attribute: + +```php +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +#[AsTool('company_name', 'Provides the name of your company')] +final class CompanyName +{ + public function __invoke(): string + { + return 'ACME Corp.' + } +} +``` + +The agent configuration by default will inject all known tools into the agent. + +To disable this behavior, set the `tools` option to `false`: +```yaml +ai: + agent: + my_agent: + tools: false +``` + +To inject only specific tools, list them in the configuration: +```yaml +ai: + agent: + my_agent: + tools: + - 'Symfony\AI\Agent\Toolbox\Tool\SimilaritySearch' +``` + +### Profiler + +The profiler panel provides insights into the agent's execution: + +![Profiler](./profiler.png) diff --git a/src/ai-bundle/composer.json b/src/ai-bundle/composer.json new file mode 100644 index 000000000..38c56ac5c --- /dev/null +++ b/src/ai-bundle/composer.json @@ -0,0 +1,44 @@ +{ + "name": "symfony/ai-bundle", + "type": "symfony-bundle", + "description": "Symfony integration bundle for Symfony's AI components", + "license": "MIT", + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + } + ], + "require": { + "php": ">=8.2", + "symfony/ai-agent": "dev-main", + "symfony/ai-platform": "dev-main", + "symfony/ai-store": "dev-main", + "symfony/config": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/string": "^6.4 || ^7.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^11.5", + "rector/rector": "^2.0" + }, + "config": { + "sort-packages": true + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\AIBundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\AIBundle\\Tests\\": "tests/" + } + } +} diff --git a/src/ai-bundle/phpstan.dist.neon b/src/ai-bundle/phpstan.dist.neon new file mode 100644 index 000000000..e7fd00638 --- /dev/null +++ b/src/ai-bundle/phpstan.dist.neon @@ -0,0 +1,8 @@ +parameters: + level: 6 + paths: + - src/ + - tests/ + excludePaths: + analyse: + - src/DependencyInjection/Configuration.php diff --git a/src/ai-bundle/phpunit.xml.dist b/src/ai-bundle/phpunit.xml.dist new file mode 100644 index 000000000..4e9e3a684 --- /dev/null +++ b/src/ai-bundle/phpunit.xml.dist @@ -0,0 +1,24 @@ + + + + + tests + + + + + + src + + + diff --git a/src/ai-bundle/profiler.png b/src/ai-bundle/profiler.png new file mode 100644 index 0000000000000000000000000000000000000000..6aa2b5fe176abfb45a80e018a5c92af97ae30c56 GIT binary patch literal 172017 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 F69D-iqe=h( literal 0 HcmV?d00001 diff --git a/src/ai-bundle/src/AIBundle.php b/src/ai-bundle/src/AIBundle.php new file mode 100644 index 000000000..9270fc1a4 --- /dev/null +++ b/src/ai-bundle/src/AIBundle.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AIBundle; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +final class AIBundle extends Bundle +{ +} diff --git a/src/ai-bundle/src/DependencyInjection/AIExtension.php b/src/ai-bundle/src/DependencyInjection/AIExtension.php new file mode 100644 index 000000000..b4281c84c --- /dev/null +++ b/src/ai-bundle/src/DependencyInjection/AIExtension.php @@ -0,0 +1,456 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AIBundle\DependencyInjection; + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\AgentInterface; +use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor; +use Symfony\AI\Agent\InputProcessorInterface; +use Symfony\AI\Agent\OutputProcessorInterface; +use Symfony\AI\Agent\StructuredOutput\AgentProcessor as StructureOutputProcessor; +use Symfony\AI\Agent\Toolbox\AgentProcessor as ToolProcessor; +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Agent\Toolbox\FaultTolerantToolbox; +use Symfony\AI\Agent\Toolbox\Tool\Agent as AgentTool; +use Symfony\AI\Agent\Toolbox\ToolFactory\ChainFactory; +use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory; +use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory; +use Symfony\AI\AIBundle\Profiler\DataCollector; +use Symfony\AI\AIBundle\Profiler\TraceablePlatform; +use Symfony\AI\AIBundle\Profiler\TraceableToolbox; +use Symfony\AI\Platform\Bridge\Anthropic\Claude; +use Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory as AnthropicPlatformFactory; +use Symfony\AI\Platform\Bridge\Azure\OpenAI\PlatformFactory as AzureOpenAIPlatformFactory; +use Symfony\AI\Platform\Bridge\Google\Gemini; +use Symfony\AI\Platform\Bridge\Google\PlatformFactory as GooglePlatformFactory; +use Symfony\AI\Platform\Bridge\Meta\Llama; +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings; +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +use Symfony\AI\Platform\Bridge\OpenAI\PlatformFactory as OpenAIPlatformFactory; +use Symfony\AI\Platform\Bridge\Voyage\Voyage; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Platform; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\ResponseConverterInterface; +use Symfony\AI\Store\Bridge\Azure\SearchStore as AzureSearchStore; +use Symfony\AI\Store\Bridge\ChromaDB\Store as ChromaDBStore; +use Symfony\AI\Store\Bridge\MongoDB\Store as MongoDBStore; +use Symfony\AI\Store\Bridge\Pinecone\Store as PineconeStore; +use Symfony\AI\Store\Embedder; +use Symfony\AI\Store\StoreInterface; +use Symfony\AI\Store\VectorStoreInterface; +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Extension\Extension; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\DependencyInjection\Reference; + +use function Symfony\Component\String\u; + +final class AIExtension extends Extension +{ + public function load(array $configs, ContainerBuilder $container): void + { + $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/Resources/config')); + $loader->load('services.php'); + + $configuration = new Configuration(); + $config = $this->processConfiguration($configuration, $configs); + foreach ($config['platform'] ?? [] as $type => $platform) { + $this->processPlatformConfig($type, $platform, $container); + } + $platforms = array_keys($container->findTaggedServiceIds('symfony_ai.platform')); + if (1 === \count($platforms)) { + $container->setAlias(PlatformInterface::class, reset($platforms)); + } + if ($container->getParameter('kernel.debug')) { + foreach ($platforms as $platform) { + $traceablePlatformDefinition = (new Definition(TraceablePlatform::class)) + ->setDecoratedService($platform) + ->setAutowired(true) + ->addTag('symfony_ai.traceable_platform'); + $suffix = u($platform)->afterLast('.')->toString(); + $container->setDefinition('symfony_ai.traceable_platform.'.$suffix, $traceablePlatformDefinition); + } + } + + foreach ($config['agent'] as $agentName => $agent) { + $this->processAgentConfig($agentName, $agent, $container); + } + if (1 === \count($config['agent']) && isset($agentName)) { + $container->setAlias(AgentInterface::class, 'symfony_ai.agent.'.$agentName); + } + + foreach ($config['store'] ?? [] as $type => $store) { + $this->processStoreConfig($type, $store, $container); + } + $stores = array_keys($container->findTaggedServiceIds('symfony_ai.store')); + if (1 === \count($stores)) { + $container->setAlias(VectorStoreInterface::class, reset($stores)); + $container->setAlias(StoreInterface::class, reset($stores)); + } + + foreach ($config['embedder'] as $embedderName => $embedder) { + $this->processEmbedderConfig($embedderName, $embedder, $container); + } + if (1 === \count($config['embedder']) && isset($embedderName)) { + $container->setAlias(Embedder::class, 'symfony_ai.embedder.'.$embedderName); + } + + $container->registerAttributeForAutoconfiguration(AsTool::class, static function (ChildDefinition $definition, AsTool $attribute): void { + $definition->addTag('symfony_ai.tool', [ + 'name' => $attribute->name, + 'description' => $attribute->description, + 'method' => $attribute->method, + ]); + }); + + $container->registerForAutoconfiguration(InputProcessorInterface::class) + ->addTag('symfony_ai.agent.input_processor'); + $container->registerForAutoconfiguration(OutputProcessorInterface::class) + ->addTag('symfony_ai.agent.output_processor'); + $container->registerForAutoconfiguration(ModelClientInterface::class) + ->addTag('symfony_ai.platform.model_client'); + $container->registerForAutoconfiguration(ResponseConverterInterface::class) + ->addTag('symfony_ai.platform.response_converter'); + + if (false === $container->getParameter('kernel.debug')) { + $container->removeDefinition(DataCollector::class); + $container->removeDefinition(TraceableToolbox::class); + } + } + + /** + * @param array $platform + */ + private function processPlatformConfig(string $type, array $platform, ContainerBuilder $container): void + { + if ('anthropic' === $type) { + $platformId = 'symfony_ai.platform.anthropic'; + $definition = (new Definition(Platform::class)) + ->setFactory(AnthropicPlatformFactory::class.'::create') + ->setAutowired(true) + ->setLazy(true) + ->addTag('proxy', ['interface' => PlatformInterface::class]) + ->setArguments([ + '$apiKey' => $platform['api_key'], + ]) + ->addTag('symfony_ai.platform'); + + if (isset($platform['version'])) { + $definition->replaceArgument('$version', $platform['version']); + } + + $container->setDefinition($platformId, $definition); + + return; + } + + if ('azure' === $type) { + foreach ($platform as $name => $config) { + $platformId = 'symfony_ai.platform.azure.'.$name; + $definition = (new Definition(Platform::class)) + ->setFactory(AzureOpenAIPlatformFactory::class.'::create') + ->setAutowired(true) + ->setLazy(true) + ->addTag('proxy', ['interface' => PlatformInterface::class]) + ->setArguments([ + '$baseUrl' => $config['base_url'], + '$deployment' => $config['deployment'], + '$apiVersion' => $config['api_version'], + '$apiKey' => $config['api_key'], + ]) + ->addTag('symfony_ai.platform'); + + $container->setDefinition($platformId, $definition); + } + + return; + } + + if ('google' === $type) { + $platformId = 'symfony_ai.platform.google'; + $definition = (new Definition(Platform::class)) + ->setFactory(GooglePlatformFactory::class.'::create') + ->setAutowired(true) + ->setLazy(true) + ->addTag('proxy', ['interface' => PlatformInterface::class]) + ->setArguments(['$apiKey' => $platform['api_key']]) + ->addTag('symfony_ai.platform'); + + $container->setDefinition($platformId, $definition); + + return; + } + + if ('openai' === $type) { + $platformId = 'symfony_ai.platform.openai'; + $definition = (new Definition(Platform::class)) + ->setFactory(OpenAIPlatformFactory::class.'::create') + ->setAutowired(true) + ->setLazy(true) + ->addTag('proxy', ['interface' => PlatformInterface::class]) + ->setArguments(['$apiKey' => $platform['api_key']]) + ->addTag('symfony_ai.platform'); + + $container->setDefinition($platformId, $definition); + + return; + } + + throw new \InvalidArgumentException(\sprintf('Platform "%s" is not supported for configuration via bundle at this point.', $type)); + } + + /** + * @param array $config + */ + private function processAgentConfig(string $name, array $config, ContainerBuilder $container): void + { + // MODEL + ['name' => $modelName, 'version' => $version, 'options' => $options] = $config['model']; + + $modelClass = match (strtolower((string) $modelName)) { + 'gpt' => GPT::class, + 'claude' => Claude::class, + 'llama' => Llama::class, + 'gemini' => Gemini::class, + default => throw new \InvalidArgumentException(\sprintf('Model "%s" is not supported.', $modelName)), + }; + $modelDefinition = new Definition($modelClass); + if (null !== $version) { + $modelDefinition->setArgument('$name', $version); + } + if (0 !== \count($options)) { + $modelDefinition->setArgument('$options', $options); + } + $modelDefinition->addTag('symfony_ai.model.language_model'); + $container->setDefinition('symfony_ai.agent.'.$name.'.model', $modelDefinition); + + // AGENT + $agentDefinition = (new Definition(Agent::class)) + ->setAutowired(true) + ->setArgument('$platform', new Reference($config['platform'])) + ->setArgument('$model', new Reference('symfony_ai.agent.'.$name.'.model')); + + $inputProcessors = []; + $outputProcessors = []; + + // TOOL & PROCESSOR + if ($config['tools']['enabled']) { + // Create specific toolbox and process if tools are explicitly defined + if (0 !== \count($config['tools']['services'])) { + $memoryFactoryDefinition = new Definition(MemoryToolFactory::class); + $container->setDefinition('symfony_ai.toolbox.'.$name.'.memory_factory', $memoryFactoryDefinition); + $agentFactoryDefinition = new Definition(ChainFactory::class, [ + '$factories' => [new Reference('symfony_ai.toolbox.'.$name.'.memory_factory'), new Reference(ReflectionToolFactory::class)], + ]); + $container->setDefinition('symfony_ai.toolbox.'.$name.'.agent_factory', $agentFactoryDefinition); + + $tools = []; + foreach ($config['tools']['services'] as $tool) { + $reference = new Reference($tool['service']); + // We use the memory factory in case method, description and name are set + if (isset($tool['name'], $tool['description'])) { + if ($tool['is_agent']) { + $agentWrapperDefinition = new Definition(AgentTool::class, ['$agent' => $reference]); + $container->setDefinition('symfony_ai.toolbox.'.$name.'.agent_wrapper.'.$tool['name'], $agentWrapperDefinition); + $reference = new Reference('symfony_ai.toolbox.'.$name.'.agent_wrapper.'.$tool['name']); + } + $memoryFactoryDefinition->addMethodCall('addTool', [$reference, $tool['name'], $tool['description'], $tool['method'] ?? '__invoke']); + } + $tools[] = $reference; + } + + $toolboxDefinition = (new ChildDefinition('symfony_ai.toolbox.abstract')) + ->replaceArgument('$toolFactory', new Reference('symfony_ai.toolbox.'.$name.'.agent_factory')) + ->replaceArgument('$tools', $tools); + $container->setDefinition('symfony_ai.toolbox.'.$name, $toolboxDefinition); + + if ($config['fault_tolerant_toolbox']) { + $faultTolerantToolboxDefinition = (new Definition('symfony_ai.fault_tolerant_toolbox.'.$name)) + ->setClass(FaultTolerantToolbox::class) + ->setAutowired(true) + ->setDecoratedService('symfony_ai.toolbox.'.$name); + $container->setDefinition('symfony_ai.fault_tolerant_toolbox.'.$name, $faultTolerantToolboxDefinition); + } + + if ($container->getParameter('kernel.debug')) { + $traceableToolboxDefinition = (new Definition('symfony_ai.traceable_toolbox.'.$name)) + ->setClass(TraceableToolbox::class) + ->setAutowired(true) + ->setDecoratedService('symfony_ai.toolbox.'.$name) + ->addTag('symfony_ai.traceable_toolbox'); + $container->setDefinition('symfony_ai.traceable_toolbox.'.$name, $traceableToolboxDefinition); + } + + $toolProcessorDefinition = (new ChildDefinition('symfony_ai.tool.agent_processor.abstract')) + ->replaceArgument('$toolbox', new Reference('symfony_ai.toolbox.'.$name)); + $container->setDefinition('symfony_ai.tool.agent_processor.'.$name, $toolProcessorDefinition); + + $inputProcessors[] = new Reference('symfony_ai.tool.agent_processor.'.$name); + $outputProcessors[] = new Reference('symfony_ai.tool.agent_processor.'.$name); + } else { + $inputProcessors[] = new Reference(ToolProcessor::class); + $outputProcessors[] = new Reference(ToolProcessor::class); + } + } + + // STRUCTURED OUTPUT + if ($config['structured_output']) { + $inputProcessors[] = new Reference(StructureOutputProcessor::class); + $outputProcessors[] = new Reference(StructureOutputProcessor::class); + } + + // SYSTEM PROMPT + if (\is_string($config['system_prompt'])) { + $systemPromptInputProcessorDefinition = new Definition(SystemPromptInputProcessor::class); + $systemPromptInputProcessorDefinition + ->setAutowired(true) + ->setArguments([ + '$systemPrompt' => $config['system_prompt'], + '$toolbox' => $config['include_tools'] ? new Reference('symfony_ai.toolbox.'.$name) : null, + ]); + + $inputProcessors[] = $systemPromptInputProcessorDefinition; + } + + $agentDefinition + ->setArgument('$inputProcessors', $inputProcessors) + ->setArgument('$outputProcessors', $outputProcessors); + + $container->setDefinition('symfony_ai.agent.'.$name, $agentDefinition); + } + + /** + * @param array $stores + */ + private function processStoreConfig(string $type, array $stores, ContainerBuilder $container): void + { + if ('azure_search' === $type) { + foreach ($stores as $name => $store) { + $arguments = [ + '$endpointUrl' => $store['endpoint'], + '$apiKey' => $store['api_key'], + '$indexName' => $store['index_name'], + '$apiVersion' => $store['api_version'], + ]; + + if (\array_key_exists('vector_field', $store)) { + $arguments['$vectorFieldName'] = $store['vector_field']; + } + + $definition = new Definition(AzureSearchStore::class); + $definition + ->setAutowired(true) + ->addTag('symfony_ai.store') + ->setArguments($arguments); + + $container->setDefinition('symfony_ai.store.'.$type.'.'.$name, $definition); + } + } + + if ('chroma_db' === $type) { + foreach ($stores as $name => $store) { + $definition = new Definition(ChromaDBStore::class); + $definition + ->setAutowired(true) + ->setArgument('$collectionName', $store['collection']) + ->addTag('symfony_ai.store'); + + $container->setDefinition('symfony_ai.store.'.$type.'.'.$name, $definition); + } + } + + if ('mongodb' === $type) { + foreach ($stores as $name => $store) { + $arguments = [ + '$databaseName' => $store['database'], + '$collectionName' => $store['collection'], + '$indexName' => $store['index_name'], + ]; + + if (\array_key_exists('vector_field', $store)) { + $arguments['$vectorFieldName'] = $store['vector_field']; + } + + if (\array_key_exists('bulk_write', $store)) { + $arguments['$bulkWrite'] = $store['bulk_write']; + } + + $definition = new Definition(MongoDBStore::class); + $definition + ->setAutowired(true) + ->addTag('symfony_ai.store') + ->setArguments($arguments); + + $container->setDefinition('symfony_ai.store.'.$type.'.'.$name, $definition); + } + } + + if ('pinecone' === $type) { + foreach ($stores as $name => $store) { + $arguments = [ + '$namespace' => $store['namespace'], + ]; + + if (\array_key_exists('filter', $store)) { + $arguments['$filter'] = $store['filter']; + } + + if (\array_key_exists('top_k', $store)) { + $arguments['$topK'] = $store['top_k']; + } + + $definition = new Definition(PineconeStore::class); + $definition + ->setAutowired(true) + ->addTag('symfony_ai.store') + ->setArguments($arguments); + + $container->setDefinition('symfony_ai.store.'.$type.'.'.$name, $definition); + } + } + } + + /** + * @param array $config + */ + private function processEmbedderConfig(int|string $name, array $config, ContainerBuilder $container): void + { + ['name' => $modelName, 'version' => $version, 'options' => $options] = $config['model']; + + $modelClass = match (strtolower((string) $modelName)) { + 'embeddings' => Embeddings::class, + 'voyage' => Voyage::class, + default => throw new \InvalidArgumentException(\sprintf('Model "%s" is not supported.', $modelName)), + }; + $modelDefinition = (new Definition($modelClass)); + if (null !== $version) { + $modelDefinition->setArgument('$name', $version); + } + if (0 !== \count($options)) { + $modelDefinition->setArgument('$options', $options); + } + $modelDefinition->addTag('symfony_ai.model.embeddings_model'); + $container->setDefinition('symfony_ai.embedder.'.$name.'.model', $modelDefinition); + + $definition = new Definition(Embedder::class, [ + '$model' => new Reference('symfony_ai.embedder.'.$name.'.model'), + '$platform' => new Reference($config['platform']), + '$store' => new Reference($config['store']), + ]); + + $container->setDefinition('symfony_ai.embedder.'.$name, $definition); + } +} diff --git a/src/ai-bundle/src/DependencyInjection/Configuration.php b/src/ai-bundle/src/DependencyInjection/Configuration.php new file mode 100644 index 000000000..6a01d3fe9 --- /dev/null +++ b/src/ai-bundle/src/DependencyInjection/Configuration.php @@ -0,0 +1,212 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AIBundle\DependencyInjection; + +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Store\StoreInterface; +use Symfony\Component\Config\Definition\Builder\TreeBuilder; +use Symfony\Component\Config\Definition\ConfigurationInterface; + +final class Configuration implements ConfigurationInterface +{ + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('ai'); + $rootNode = $treeBuilder->getRootNode(); + + $rootNode + ->children() + ->arrayNode('platform') + ->children() + ->arrayNode('anthropic') + ->children() + ->scalarNode('api_key')->isRequired()->end() + ->scalarNode('version')->defaultNull()->end() + ->end() + ->end() + ->arrayNode('azure') + ->normalizeKeys(false) + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('api_key')->isRequired()->end() + ->scalarNode('base_url')->isRequired()->end() + ->scalarNode('deployment')->isRequired()->end() + ->scalarNode('api_version')->info('The used API version')->end() + ->end() + ->end() + ->end() + ->arrayNode('google') + ->children() + ->scalarNode('api_key')->isRequired()->end() + ->end() + ->end() + ->arrayNode('openai') + ->children() + ->scalarNode('api_key')->isRequired()->end() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('agent') + ->normalizeKeys(false) + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('platform') + ->info('Service name of platform') + ->defaultValue(PlatformInterface::class) + ->end() + ->arrayNode('model') + ->children() + ->scalarNode('name')->isRequired()->end() + ->scalarNode('version')->defaultNull()->end() + ->arrayNode('options') + ->scalarPrototype()->end() + ->end() + ->end() + ->end() + ->booleanNode('structured_output')->defaultTrue()->end() + ->scalarNode('system_prompt') + ->validate() + ->ifTrue(fn ($v) => null !== $v && '' === trim($v)) + ->thenInvalid('The default system prompt must not be an empty string') + ->end() + ->defaultNull() + ->info('The default system prompt of the agent') + ->end() + ->booleanNode('include_tools') + ->info('Include tool definitions at the end of the system prompt') + ->defaultFalse() + ->end() + ->arrayNode('tools') + ->addDefaultsIfNotSet() + ->treatFalseLike(['enabled' => false]) + ->treatTrueLike(['enabled' => true]) + ->treatNullLike(['enabled' => true]) + ->beforeNormalization() + ->ifArray() + ->then(function (array $v) { + return [ + 'enabled' => $v['enabled'] ?? true, + 'services' => $v['services'] ?? $v, + ]; + }) + ->end() + ->children() + ->booleanNode('enabled')->defaultTrue()->end() + ->arrayNode('services') + ->arrayPrototype() + ->children() + ->scalarNode('service')->isRequired()->end() + ->scalarNode('name')->end() + ->scalarNode('description')->end() + ->scalarNode('method')->end() + ->booleanNode('is_agent')->defaultFalse()->end() + ->end() + ->beforeNormalization() + ->ifString() + ->then(function (string $v) { + return ['service' => $v]; + }) + ->end() + ->end() + ->end() + ->end() + ->end() + ->booleanNode('fault_tolerant_toolbox')->defaultTrue()->end() + ->end() + ->end() + ->end() + ->arrayNode('store') + ->children() + ->arrayNode('azure_search') + ->normalizeKeys(false) + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('endpoint')->isRequired()->end() + ->scalarNode('api_key')->isRequired()->end() + ->scalarNode('index_name')->isRequired()->end() + ->scalarNode('api_version')->isRequired()->end() + ->scalarNode('vector_field')->end() + ->end() + ->end() + ->end() + ->arrayNode('chroma_db') + ->normalizeKeys(false) + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('collection')->isRequired()->end() + ->end() + ->end() + ->end() + ->arrayNode('mongodb') + ->normalizeKeys(false) + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('database')->isRequired()->end() + ->scalarNode('collection')->isRequired()->end() + ->scalarNode('index_name')->isRequired()->end() + ->scalarNode('vector_field')->end() + ->booleanNode('bulk_write')->end() + ->end() + ->end() + ->end() + ->arrayNode('pinecone') + ->normalizeKeys(false) + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('namespace')->end() + ->arrayNode('filter') + ->scalarPrototype()->end() + ->end() + ->integerNode('top_k')->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->arrayNode('embedder') + ->normalizeKeys(false) + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('store') + ->info('Service name of store') + ->defaultValue(StoreInterface::class) + ->end() + ->scalarNode('platform') + ->info('Service name of platform') + ->defaultValue(PlatformInterface::class) + ->end() + ->arrayNode('model') + ->children() + ->scalarNode('name')->isRequired()->end() + ->scalarNode('version')->defaultNull()->end() + ->arrayNode('options') + ->scalarPrototype()->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + + return $treeBuilder; + } +} diff --git a/src/ai-bundle/src/Profiler/DataCollector.php b/src/ai-bundle/src/Profiler/DataCollector.php new file mode 100644 index 000000000..fa23f77da --- /dev/null +++ b/src/ai-bundle/src/Profiler/DataCollector.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AIBundle\Profiler; + +use Symfony\AI\Agent\Toolbox\ToolboxInterface; +use Symfony\AI\Platform\Tool\Tool; +use Symfony\Bundle\FrameworkBundle\DataCollector\AbstractDataCollector; +use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +/** + * @phpstan-import-type PlatformCallData from TraceablePlatform + * @phpstan-import-type ToolCallData from TraceableToolbox + */ +final class DataCollector extends AbstractDataCollector +{ + /** + * @var TraceablePlatform[] + */ + private readonly array $platforms; + + /** + * @var TraceableToolbox[] + */ + private readonly array $toolboxes; + + /** + * @param TraceablePlatform[] $platforms + * @param TraceableToolbox[] $toolboxes + */ + public function __construct( + #[TaggedIterator('symfony_ai.traceable_platform')] + iterable $platforms, + private readonly ToolboxInterface $defaultToolBox, + #[TaggedIterator('symfony_ai.traceable_toolbox')] + iterable $toolboxes, + ) { + $this->platforms = $platforms instanceof \Traversable ? iterator_to_array($platforms) : $platforms; + $this->toolboxes = $toolboxes instanceof \Traversable ? iterator_to_array($toolboxes) : $toolboxes; + } + + public function collect(Request $request, Response $response, ?\Throwable $exception = null): void + { + $this->data = [ + 'tools' => $this->defaultToolBox->getTools(), + 'platform_calls' => array_merge(...array_map(fn (TraceablePlatform $platform) => $platform->calls, $this->platforms)), + 'tool_calls' => array_merge(...array_map(fn (TraceableToolbox $toolbox) => $toolbox->calls, $this->toolboxes)), + ]; + } + + public static function getTemplate(): string + { + return '@AI/data_collector.html.twig'; + } + + /** + * @return PlatformCallData[] + */ + public function getPlatformCalls(): array + { + return $this->data['platform_calls'] ?? []; + } + + /** + * @return Tool[] + */ + public function getTools(): array + { + return $this->data['tools'] ?? []; + } + + /** + * @return ToolCallData[] + */ + public function getToolCalls(): array + { + return $this->data['tool_calls'] ?? []; + } +} diff --git a/src/ai-bundle/src/Profiler/TraceablePlatform.php b/src/ai-bundle/src/Profiler/TraceablePlatform.php new file mode 100644 index 000000000..112cdd527 --- /dev/null +++ b/src/ai-bundle/src/Profiler/TraceablePlatform.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AIBundle\Profiler; + +use Symfony\AI\Platform\Message\Content\File; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Response\ResponseInterface; + +/** + * @phpstan-type PlatformCallData array{ + * model: Model, + * input: array|string|object, + * options: array, + * response: ResponseInterface, + * } + */ +final class TraceablePlatform implements PlatformInterface +{ + /** + * @var PlatformCallData[] + */ + public array $calls = []; + + public function __construct( + private readonly PlatformInterface $platform, + ) { + } + + public function request(Model $model, array|string|object $input, array $options = []): ResponseInterface + { + $response = $this->platform->request($model, $input, $options); + + if ($input instanceof File) { + $input = $input::class.': '.$input->getFormat(); + } + + $this->calls[] = [ + 'model' => $model, + 'input' => \is_object($input) ? clone $input : $input, + 'options' => $options, + 'response' => $response->getContent(), + ]; + + return $response; + } +} diff --git a/src/ai-bundle/src/Profiler/TraceableToolbox.php b/src/ai-bundle/src/Profiler/TraceableToolbox.php new file mode 100644 index 000000000..9af9b7a40 --- /dev/null +++ b/src/ai-bundle/src/Profiler/TraceableToolbox.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AIBundle\Profiler; + +use Symfony\AI\Agent\Toolbox\ToolboxInterface; +use Symfony\AI\Platform\Response\ToolCall; + +/** + * @phpstan-type ToolCallData array{ + * call: ToolCall, + * result: string, + * } + */ +final class TraceableToolbox implements ToolboxInterface +{ + /** + * @var ToolCallData[] + */ + public array $calls = []; + + public function __construct( + private readonly ToolboxInterface $toolbox, + ) { + } + + public function getTools(): array + { + return $this->toolbox->getTools(); + } + + public function execute(ToolCall $toolCall): mixed + { + $result = $this->toolbox->execute($toolCall); + + $this->calls[] = [ + 'call' => $toolCall, + 'result' => $result, + ]; + + return $result; + } +} diff --git a/src/ai-bundle/src/Resources/config/services.php b/src/ai-bundle/src/Resources/config/services.php new file mode 100644 index 000000000..95563fae6 --- /dev/null +++ b/src/ai-bundle/src/Resources/config/services.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\AI\Agent\StructuredOutput\AgentProcessor as StructureOutputProcessor; +use Symfony\AI\Agent\StructuredOutput\ResponseFormatFactory; +use Symfony\AI\Agent\StructuredOutput\ResponseFormatFactoryInterface; +use Symfony\AI\Agent\Toolbox\AgentProcessor as ToolProcessor; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Agent\Toolbox\ToolboxInterface; +use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory; +use Symfony\AI\Agent\Toolbox\ToolFactoryInterface; +use Symfony\AI\AIBundle\Profiler\DataCollector; +use Symfony\AI\AIBundle\Profiler\TraceableToolbox; + +return static function (ContainerConfigurator $container): void { + $container->services() + ->defaults() + ->autowire() + + // structured output + ->set(ResponseFormatFactory::class) + ->alias(ResponseFormatFactoryInterface::class, ResponseFormatFactory::class) + ->set(StructureOutputProcessor::class) + ->tag('symfony_ai.agent.input_processor') + ->tag('symfony_ai.agent.output_processor') + + // tools + ->set('symfony_ai.toolbox.abstract') + ->class(Toolbox::class) + ->autowire() + ->abstract() + ->args([ + '$toolFactory' => service(ToolFactoryInterface::class), + '$tools' => abstract_arg('Collection of tools'), + ]) + ->set(Toolbox::class) + ->parent('symfony_ai.toolbox.abstract') + ->args([ + '$tools' => tagged_iterator('symfony_ai.tool'), + ]) + ->alias(ToolboxInterface::class, Toolbox::class) + ->set(ReflectionToolFactory::class) + ->alias(ToolFactoryInterface::class, ReflectionToolFactory::class) + ->set('symfony_ai.tool.agent_processor.abstract') + ->class(ToolProcessor::class) + ->abstract() + ->args([ + '$toolbox' => abstract_arg('Toolbox'), + ]) + ->set(ToolProcessor::class) + ->parent('symfony_ai.tool.agent_processor.abstract') + ->tag('symfony_ai.agent.input_processor') + ->tag('symfony_ai.agent.output_processor') + ->args([ + '$toolbox' => service(ToolboxInterface::class), + '$eventDispatcher' => service('event_dispatcher')->nullOnInvalid(), + ]) + + // profiler + ->set(DataCollector::class) + ->tag('data_collector') + ->set(TraceableToolbox::class) + ->decorate(ToolboxInterface::class) + ->tag('symfony_ai.traceable_toolbox') + ; +}; diff --git a/src/ai-bundle/src/Resources/views/data_collector.html.twig b/src/ai-bundle/src/Resources/views/data_collector.html.twig new file mode 100644 index 000000000..dc2f93f20 --- /dev/null +++ b/src/ai-bundle/src/Resources/views/data_collector.html.twig @@ -0,0 +1,252 @@ +{% extends '@WebProfiler/Profiler/layout.html.twig' %} + +{% block toolbar %} + {% if collector.platformCalls|length > 0 %} + {% set icon %} + {{ include('@AI/icon.svg', { y: 18 }) }} + {{ collector.platformCalls|length }} + + calls + + {% endset %} + + {% set text %} +

+
+ Configured Platforms + 1 +
+
+ Platform Calls + {{ collector.platformCalls|length }} +
+
+ Registered Tools + {{ collector.tools|length }} +
+
+ Tool Calls + {{ collector.toolCalls|length }} +
+
+ {% endset %} + + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { 'link': true }) }} + {% endif %} +{% endblock %} + +{% block menu %} + + {{ include('@AI/icon.svg', { y: 16 }) }} + AI + {{ collector.platformCalls|length }} + +{% endblock %} + +{% macro tool_calls(toolCalls) %} + Tool call{{ toolCalls|length > 1 ? 's' }}: +
    + {% for toolCall in toolCalls %} +
  1. + {{ toolCall.name }}({{ toolCall.arguments|map((value, key) => "#{key}: #{value}")|join(', ') }}) + (ID: {{ toolCall.id }}) +
  2. + {% endfor %} +
+{% endmacro %} + +{% block panel %} +

AI

+
+
+
+ 1 + Platforms +
+
+ {{ collector.platformCalls|length }} + Platform Calls +
+
+
+
+
+ {{ collector.tools|length }} + Tools +
+
+ {{ collector.toolCalls|length }} + Tool Calls +
+
+
+

Platform Calls

+ {% if collector.platformCalls|length %} +
+
+

Platform Calls {{ collector.platformCalls|length }}

+
+ {% for call in collector.platformCalls %} + + + + + + + + + + + + + + + + + + + + + + + + +
Call {{ loop.index }}
Model{{ constant('class', call.model) }} (Version: {{ call.model.name }})
Input + {% if call.input.messages is defined %}{# expect MessageBag #} +
    + {% for message in call.input.messages %} +
  1. + {{ message.role.value|title }}: + {% if 'assistant' == message.role.value and message.hasToolCalls%} + {{ _self.tool_calls(message.toolCalls) }} + {% elseif 'tool' == message.role.value %} + Result of tool call with ID {{ message.toolCall.id }}
    + {{ message.content|nl2br }} + {% elseif 'user' == message.role.value %} + {% for item in message.content %} + {% if item.text is defined %} + {{ item.text|nl2br }} + {% else %} + + {% endif %} + {% endfor %} + {% else %} + {{ message.content|nl2br }} + {% endif %} +
  2. + {% endfor %} +
+ {% else %} + {{ dump(call.input) }} + {% endif %} +
Options +
    + {% for key, value in call.options %} + {% if key == 'tools' %} +
  • {{ key }}: +
      + {% for tool in value %} +
    • {{ tool.name }}
    • + {% endfor %} +
    +
  • + {% else %} +
  • {{ key }}: {{ dump(value) }}
  • + {% endif %} + {% endfor %} +
+
Response + {% if call.input.messages is defined and call.response is iterable %}{# expect array of ToolCall #} + {{ _self.tool_calls(call.response) }} + {% elseif call.response is iterable %}{# expect array of Vectors #} +
    + {% for vector in call.response %} +
  1. Vector with {{ vector.dimensions }} dimensions
  2. + {% endfor %} +
+ {% else %} + {{ call.response }} + {% endif %} +
+ {% endfor %} +
+
+
+ {% else %} +
+

No platform calls were made.

+
+ {% endif %} + +

Tools

+ {% if collector.tools|length %} + + + + + + + + + + + {% for tool in collector.tools %} + + + + + + + {% endfor %} + +
NameDescriptionClass & MethodParameters
{{ tool.name }}{{ tool.description }}{{ tool.reference.class }}::{{ tool.reference.method }} + {% if tool.parameters %} +
    + {% for name, parameter in tool.parameters.properties %} +
  • + {{ name }} ({{ parameter.type }})
    + {{ parameter.description }} +
  • + {% endfor %} +
+ {% else %} + none + {% endif %} +
+ {% else %} +
+

No tools were registered.

+
+ {% endif %} + +

Tool Calls

+ {% if collector.toolCalls|length %} + {% for call in collector.toolCalls %} + + + + + + + + + + + + + + + + + + + + +
{{ call.call.name }}
ID{{ call.call.id }}
Arguments{{ dump(call.call.arguments) }}
Response{{ call.result|nl2br }}
+ {% endfor %} + {% else %} +
+

No tool calls were made.

+
+ {% endif %} +{% endblock %} diff --git a/src/ai-bundle/src/Resources/views/icon.svg b/src/ai-bundle/src/Resources/views/icon.svg new file mode 100644 index 000000000..56f1a6c08 --- /dev/null +++ b/src/ai-bundle/src/Resources/views/icon.svg @@ -0,0 +1,16 @@ + + + + + + LLM + diff --git a/src/ai-bundle/tests/Profiler/TraceableToolboxTest.php b/src/ai-bundle/tests/Profiler/TraceableToolboxTest.php new file mode 100644 index 000000000..6990ca891 --- /dev/null +++ b/src/ai-bundle/tests/Profiler/TraceableToolboxTest.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AIBundle\Tests\Profiler; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Toolbox\ToolboxInterface; +use Symfony\AI\AIBundle\Profiler\TraceableToolbox; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +#[CoversClass(TraceableToolbox::class)] +#[Small] +final class TraceableToolboxTest extends TestCase +{ + #[Test] + public function getMap(): void + { + $metadata = new Tool(new ExecutionReference('Foo\Bar'), 'bar', 'description', null); + $toolbox = $this->createToolbox(['tool' => $metadata]); + $traceableToolbox = new TraceableToolbox($toolbox); + + $map = $traceableToolbox->getTools(); + + self::assertSame(['tool' => $metadata], $map); + } + + #[Test] + public function execute(): void + { + $metadata = new Tool(new ExecutionReference('Foo\Bar'), 'bar', 'description', null); + $toolbox = $this->createToolbox(['tool' => $metadata]); + $traceableToolbox = new TraceableToolbox($toolbox); + $toolCall = new ToolCall('foo', '__invoke'); + + $result = $traceableToolbox->execute($toolCall); + + self::assertSame('tool_result', $result); + self::assertCount(1, $traceableToolbox->calls); + self::assertSame($toolCall, $traceableToolbox->calls[0]['call']); + self::assertSame('tool_result', $traceableToolbox->calls[0]['result']); + } + + /** + * @param Tool[] $tools + */ + private function createToolbox(array $tools): ToolboxInterface + { + return new class($tools) implements ToolboxInterface { + public function __construct( + private readonly array $tools, + ) { + } + + public function getTools(): array + { + return $this->tools; + } + + public function execute(ToolCall $toolCall): string + { + return 'tool_result'; + } + }; + } +} diff --git a/src/platform/.gitattributes b/src/platform/.gitattributes new file mode 100644 index 000000000..ec8c01802 --- /dev/null +++ b/src/platform/.gitattributes @@ -0,0 +1,6 @@ +/.github export-ignore +/tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +phpstan.dist.neon export-ignore +phpunit.xml.dist export-ignore diff --git a/src/platform/.github/PULL_REQUEST_TEMPLATE.md b/src/platform/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..fcb87228a --- /dev/null +++ b/src/platform/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ai + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/platform/.github/workflows/close-pull-request.yml b/src/platform/.github/workflows/close-pull-request.yml new file mode 100644 index 000000000..207153fd5 --- /dev/null +++ b/src/platform/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ai + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/platform/.gitignore b/src/platform/.gitignore new file mode 100644 index 000000000..f43db636b --- /dev/null +++ b/src/platform/.gitignore @@ -0,0 +1,3 @@ +composer.lock +vendor +.phpunit.cache diff --git a/src/platform/LICENSE b/src/platform/LICENSE new file mode 100644 index 000000000..bc38d714e --- /dev/null +++ b/src/platform/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/platform/composer.json b/src/platform/composer.json new file mode 100644 index 000000000..02912e405 --- /dev/null +++ b/src/platform/composer.json @@ -0,0 +1,74 @@ +{ + "name": "symfony/ai-platform", + "type": "library", + "description": "PHP library for interacting with AI platform provider.", + "keywords": [ + "ai", + "huggingface", + "transformers", + "inference" + ], + "license": "MIT", + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + } + ], + "require": { + "php": ">=8.2", + "ext-fileinfo": "*", + "oskarstark/enum-helper": "^1.5", + "phpdocumentor/reflection-docblock": "^5.4", + "phpstan/phpdoc-parser": "^2.1", + "psr/cache": "^3.0", + "psr/log": "^3.0", + "symfony/clock": "^6.4 || ^7.1", + "symfony/http-client": "^6.4 || ^7.1", + "symfony/property-access": "^6.4 || ^7.1", + "symfony/property-info": "^6.4 || ^7.1", + "symfony/serializer": "^6.4 || ^7.1", + "symfony/type-info": "^7.2.3", + "symfony/uid": "^6.4 || ^7.1", + "webmozart/assert": "^1.11" + }, + "require-dev": { + "codewithkyrian/transformers": "^0.5.3", + "async-aws/bedrock-runtime": "^0.1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^11.5", + "rector/rector": "^2.0", + "symfony/console": "^6.4 || ^7.1", + "symfony/dotenv": "^6.4 || ^7.1", + "symfony/event-dispatcher": "^6.4 || ^7.1", + "symfony/finder": "^6.4 || ^7.1", + "symfony/process": "^6.4 || ^7.1", + "symfony/var-dumper": "^6.4 || ^7.1" + }, + "suggest": { + "async-aws/bedrock-runtime": "For using the Bedrock platform.", + "codewithkyrian/transformers": "For using the TransformersPHP with FFI to run models in PHP." + }, + "config": { + "allow-plugins": { + "codewithkyrian/transformers-libsloader": true + }, + "sort-packages": true + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\Platform\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\Platform\\Tests\\": "tests/" + } + } +} diff --git a/src/platform/phpstan.dist.neon b/src/platform/phpstan.dist.neon new file mode 100644 index 000000000..8cc83f644 --- /dev/null +++ b/src/platform/phpstan.dist.neon @@ -0,0 +1,10 @@ +includes: + - vendor/phpstan/phpstan-webmozart-assert/extension.neon + - vendor/phpstan/phpstan-symfony/extension.neon + +parameters: + level: 6 + paths: + - src/ + - tests/ + diff --git a/src/platform/phpunit.xml.dist b/src/platform/phpunit.xml.dist new file mode 100644 index 000000000..4e9e3a684 --- /dev/null +++ b/src/platform/phpunit.xml.dist @@ -0,0 +1,24 @@ + + + + + tests + + + + + + src + + + diff --git a/src/platform/src/Bridge/Anthropic/Claude.php b/src/platform/src/Bridge/Anthropic/Claude.php new file mode 100644 index 000000000..539d6fbc8 --- /dev/null +++ b/src/platform/src/Bridge/Anthropic/Claude.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Anthropic; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +class Claude extends Model +{ + public const HAIKU_3 = 'claude-3-haiku-20240307'; + public const HAIKU_35 = 'claude-3-5-haiku-20241022'; + public const SONNET_3 = 'claude-3-sonnet-20240229'; + public const SONNET_35 = 'claude-3-5-sonnet-20240620'; + public const SONNET_35_V2 = 'claude-3-5-sonnet-20241022'; + public const SONNET_37 = 'claude-3-7-sonnet-20250219'; + public const OPUS_3 = 'claude-3-opus-20240229'; + + /** + * @param array $options The default options for the model usage + */ + public function __construct( + string $name = self::SONNET_37, + array $options = ['temperature' => 1.0, 'max_tokens' => 1000], + ) { + $capabilities = [ + Capability::INPUT_MESSAGES, + Capability::INPUT_IMAGE, + Capability::OUTPUT_TEXT, + Capability::OUTPUT_STREAMING, + Capability::TOOL_CALLING, + ]; + + parent::__construct($name, $capabilities, $options); + } +} diff --git a/src/platform/src/Bridge/Anthropic/Contract/AssistantMessageNormalizer.php b/src/platform/src/Bridge/Anthropic/Contract/AssistantMessageNormalizer.php new file mode 100644 index 000000000..d6a4c97d1 --- /dev/null +++ b/src/platform/src/Bridge/Anthropic/Contract/AssistantMessageNormalizer.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Anthropic\Contract; + +use Symfony\AI\Platform\Bridge\Anthropic\Claude; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; + +/** + * @author Christopher Hertel + */ +final class AssistantMessageNormalizer extends ModelContractNormalizer implements NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + protected function supportedDataClass(): string + { + return AssistantMessage::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Claude; + } + + /** + * @param AssistantMessage $data + * + * @return array{ + * role: 'assistant', + * content: list + * }> + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'role' => 'assistant', + 'content' => array_map(static function (ToolCall $toolCall) { + return [ + 'type' => 'tool_use', + 'id' => $toolCall->id, + 'name' => $toolCall->name, + 'input' => empty($toolCall->arguments) ? new \stdClass() : $toolCall->arguments, + ]; + }, $data->toolCalls), + ]; + } +} diff --git a/src/platform/src/Bridge/Anthropic/Contract/DocumentNormalizer.php b/src/platform/src/Bridge/Anthropic/Contract/DocumentNormalizer.php new file mode 100644 index 000000000..2ac4e58a4 --- /dev/null +++ b/src/platform/src/Bridge/Anthropic/Contract/DocumentNormalizer.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Anthropic\Contract; + +use Symfony\AI\Platform\Bridge\Anthropic\Claude; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\Content\Document; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +class DocumentNormalizer extends ModelContractNormalizer +{ + protected function supportedDataClass(): string + { + return Document::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Claude; + } + + /** + * @param Document $data + * + * @return array{type: 'document', source: array{type: 'base64', media_type: string, data: string}} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'type' => 'document', + 'source' => [ + 'type' => 'base64', + 'media_type' => $data->getFormat(), + 'data' => $data->asBase64(), + ], + ]; + } +} diff --git a/src/platform/src/Bridge/Anthropic/Contract/DocumentUrlNormalizer.php b/src/platform/src/Bridge/Anthropic/Contract/DocumentUrlNormalizer.php new file mode 100644 index 000000000..662e0ea7e --- /dev/null +++ b/src/platform/src/Bridge/Anthropic/Contract/DocumentUrlNormalizer.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Anthropic\Contract; + +use Symfony\AI\Platform\Bridge\Anthropic\Claude; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\Content\DocumentUrl; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +final class DocumentUrlNormalizer extends ModelContractNormalizer +{ + protected function supportedDataClass(): string + { + return DocumentUrl::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Claude; + } + + /** + * @param DocumentUrl $data + * + * @return array{type: 'document', source: array{type: 'url', url: string}} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'type' => 'document', + 'source' => [ + 'type' => 'url', + 'url' => $data->url, + ], + ]; + } +} diff --git a/src/platform/src/Bridge/Anthropic/Contract/ImageNormalizer.php b/src/platform/src/Bridge/Anthropic/Contract/ImageNormalizer.php new file mode 100644 index 000000000..a165189fe --- /dev/null +++ b/src/platform/src/Bridge/Anthropic/Contract/ImageNormalizer.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Anthropic\Contract; + +use Symfony\AI\Platform\Bridge\Anthropic\Claude; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Model; + +use function Symfony\Component\String\u; + +/** + * @author Christopher Hertel + */ +final class ImageNormalizer extends ModelContractNormalizer +{ + protected function supportedDataClass(): string + { + return Image::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Claude; + } + + /** + * @param Image $data + * + * @return array{type: 'image', source: array{type: 'base64', media_type: string, data: string}} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'type' => 'image', + 'source' => [ + 'type' => 'base64', + 'media_type' => u($data->getFormat())->replace('jpg', 'jpeg')->toString(), + 'data' => $data->asBase64(), + ], + ]; + } +} diff --git a/src/platform/src/Bridge/Anthropic/Contract/ImageUrlNormalizer.php b/src/platform/src/Bridge/Anthropic/Contract/ImageUrlNormalizer.php new file mode 100644 index 000000000..e0f422fa8 --- /dev/null +++ b/src/platform/src/Bridge/Anthropic/Contract/ImageUrlNormalizer.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Anthropic\Contract; + +use Symfony\AI\Platform\Bridge\Anthropic\Claude; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +final class ImageUrlNormalizer extends ModelContractNormalizer +{ + protected function supportedDataClass(): string + { + return ImageUrl::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Claude; + } + + /** + * @param ImageUrl $data + * + * @return array{type: 'image', source: array{type: 'url', url: string}} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'type' => 'image', + 'source' => [ + 'type' => 'url', + 'url' => $data->url, + ], + ]; + } +} diff --git a/src/platform/src/Bridge/Anthropic/Contract/MessageBagNormalizer.php b/src/platform/src/Bridge/Anthropic/Contract/MessageBagNormalizer.php new file mode 100644 index 000000000..669e77a66 --- /dev/null +++ b/src/platform/src/Bridge/Anthropic/Contract/MessageBagNormalizer.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Anthropic\Contract; + +use Symfony\AI\Platform\Bridge\Anthropic\Claude; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Model; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; + +/** + * @author Christopher Hertel + */ +final class MessageBagNormalizer extends ModelContractNormalizer implements NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + protected function supportedDataClass(): string + { + return MessageBagInterface::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Claude; + } + + /** + * @param MessageBagInterface $data + * + * @return array{ + * messages: array, + * model?: string, + * system?: string, + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $array = [ + 'messages' => $this->normalizer->normalize($data->withoutSystemMessage()->getMessages(), $format, $context), + ]; + + if (null !== $system = $data->getSystemMessage()) { + $array['system'] = $system->content; + } + + if (isset($context[Contract::CONTEXT_MODEL]) && $context[Contract::CONTEXT_MODEL] instanceof Model) { + $array['model'] = $context[Contract::CONTEXT_MODEL]->getName(); + } + + return $array; + } +} diff --git a/src/platform/src/Bridge/Anthropic/Contract/ToolCallMessageNormalizer.php b/src/platform/src/Bridge/Anthropic/Contract/ToolCallMessageNormalizer.php new file mode 100644 index 000000000..f2023296f --- /dev/null +++ b/src/platform/src/Bridge/Anthropic/Contract/ToolCallMessageNormalizer.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Anthropic\Contract; + +use Symfony\AI\Platform\Bridge\Anthropic\Claude; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\ToolCallMessage; +use Symfony\AI\Platform\Model; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; + +/** + * @author Christopher Hertel + */ +final class ToolCallMessageNormalizer extends ModelContractNormalizer implements NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + protected function supportedDataClass(): string + { + return ToolCallMessage::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Claude; + } + + /** + * @param ToolCallMessage $data + * + * @return array{ + * role: 'user', + * content: list + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'role' => 'user', + 'content' => [ + [ + 'type' => 'tool_result', + 'tool_use_id' => $data->toolCall->id, + 'content' => $data->content, + ], + ], + ]; + } +} diff --git a/src/platform/src/Bridge/Anthropic/Contract/ToolNormalizer.php b/src/platform/src/Bridge/Anthropic/Contract/ToolNormalizer.php new file mode 100644 index 000000000..716d3a771 --- /dev/null +++ b/src/platform/src/Bridge/Anthropic/Contract/ToolNormalizer.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Anthropic\Contract; + +use Symfony\AI\Platform\Bridge\Anthropic\Claude; +use Symfony\AI\Platform\Contract\JsonSchema\Factory; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Tool\Tool; + +/** + * @phpstan-import-type JsonSchema from Factory + * + * @author Christopher Hertel + */ +class ToolNormalizer extends ModelContractNormalizer +{ + protected function supportedDataClass(): string + { + return Tool::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Claude; + } + + /** + * @param Tool $data + * + * @return array{ + * name: string, + * description: string, + * input_schema: JsonSchema|array{type: 'object'} + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'name' => $data->name, + 'description' => $data->description, + 'input_schema' => $data->parameters ?? ['type' => 'object'], + ]; + } +} diff --git a/src/platform/src/Bridge/Anthropic/ModelClient.php b/src/platform/src/Bridge/Anthropic/ModelClient.php new file mode 100644 index 000000000..d5c981a15 --- /dev/null +++ b/src/platform/src/Bridge/Anthropic/ModelClient.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Anthropic; + +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final readonly class ModelClient implements ModelClientInterface +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $version = '2023-06-01', + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + } + + public function supports(Model $model): bool + { + return $model instanceof Claude; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + if (isset($options['tools'])) { + $options['tool_choice'] = ['type' => 'auto']; + } + + return $this->httpClient->request('POST', 'https://api.anthropic.com/v1/messages', [ + 'headers' => [ + 'x-api-key' => $this->apiKey, + 'anthropic-version' => $this->version, + ], + 'json' => array_merge($options, $payload), + ]); + } +} diff --git a/src/platform/src/Bridge/Anthropic/PlatformFactory.php b/src/platform/src/Bridge/Anthropic/PlatformFactory.php new file mode 100644 index 000000000..5a674ca59 --- /dev/null +++ b/src/platform/src/Bridge/Anthropic/PlatformFactory.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Anthropic; + +use Symfony\AI\Platform\Bridge\Anthropic\Contract\AssistantMessageNormalizer; +use Symfony\AI\Platform\Bridge\Anthropic\Contract\DocumentNormalizer; +use Symfony\AI\Platform\Bridge\Anthropic\Contract\DocumentUrlNormalizer; +use Symfony\AI\Platform\Bridge\Anthropic\Contract\ImageNormalizer; +use Symfony\AI\Platform\Bridge\Anthropic\Contract\ImageUrlNormalizer; +use Symfony\AI\Platform\Bridge\Anthropic\Contract\MessageBagNormalizer; +use Symfony\AI\Platform\Bridge\Anthropic\Contract\ToolCallMessageNormalizer; +use Symfony\AI\Platform\Bridge\Anthropic\Contract\ToolNormalizer; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +final readonly class PlatformFactory +{ + public static function create( + #[\SensitiveParameter] + string $apiKey, + string $version = '2023-06-01', + ?HttpClientInterface $httpClient = null, + ): Platform { + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + return new Platform( + [new ModelClient($httpClient, $apiKey, $version)], + [new ResponseConverter()], + Contract::create( + new AssistantMessageNormalizer(), + new DocumentNormalizer(), + new DocumentUrlNormalizer(), + new ImageNormalizer(), + new ImageUrlNormalizer(), + new MessageBagNormalizer(), + new ToolCallMessageNormalizer(), + new ToolNormalizer(), + ) + ); + } +} diff --git a/src/platform/src/Bridge/Anthropic/ResponseConverter.php b/src/platform/src/Bridge/Anthropic/ResponseConverter.php new file mode 100644 index 000000000..805baf34a --- /dev/null +++ b/src/platform/src/Bridge/Anthropic/ResponseConverter.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Anthropic; + +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\StreamResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Response\ToolCallResponse; +use Symfony\AI\Platform\ResponseConverterInterface; +use Symfony\Component\HttpClient\Chunk\ServerSentEvent; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Component\HttpClient\Exception\JsonException; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Christopher Hertel + */ +class ResponseConverter implements ResponseConverterInterface +{ + public function supports(Model $model): bool + { + return $model instanceof Claude; + } + + public function convert(ResponseInterface $response, array $options = []): LlmResponse + { + if ($options['stream'] ?? false) { + return new StreamResponse($this->convertStream($response)); + } + + $data = $response->toArray(); + + if (!isset($data['content']) || 0 === \count($data['content'])) { + throw new RuntimeException('Response does not contain any content'); + } + + $toolCalls = []; + foreach ($data['content'] as $content) { + if ('tool_use' === $content['type']) { + $toolCalls[] = new ToolCall($content['id'], $content['name'], $content['input']); + } + } + + if (!isset($data['content'][0]['text']) && 0 === \count($toolCalls)) { + throw new RuntimeException('Response content does not contain any text nor tool calls.'); + } + + if (!empty($toolCalls)) { + return new ToolCallResponse(...$toolCalls); + } + + return new TextResponse($data['content'][0]['text']); + } + + private function convertStream(ResponseInterface $response): \Generator + { + foreach ((new EventSourceHttpClient())->stream($response) as $chunk) { + if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) { + continue; + } + + try { + $data = $chunk->getArrayData(); + } catch (JsonException) { + // try catch only needed for Symfony 6.4 + continue; + } + + if ('content_block_delta' != $data['type'] || !isset($data['delta']['text'])) { + continue; + } + + yield $data['delta']['text']; + } + } +} diff --git a/src/platform/src/Bridge/Azure/Meta/LlamaHandler.php b/src/platform/src/Bridge/Azure/Meta/LlamaHandler.php new file mode 100644 index 000000000..40278c464 --- /dev/null +++ b/src/platform/src/Bridge/Azure/Meta/LlamaHandler.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Azure\Meta; + +use Symfony\AI\Platform\Bridge\Meta\Llama; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\ResponseConverterInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final readonly class LlamaHandler implements ModelClientInterface, ResponseConverterInterface +{ + public function __construct( + private HttpClientInterface $httpClient, + private string $baseUrl, + #[\SensitiveParameter] private string $apiKey, + ) { + } + + public function supports(Model $model): bool + { + return $model instanceof Llama; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + $url = \sprintf('https://%s/chat/completions', $this->baseUrl); + + return $this->httpClient->request('POST', $url, [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'Authorization' => $this->apiKey, + ], + 'json' => array_merge($options, $payload), + ]); + } + + public function convert(ResponseInterface $response, array $options = []): LlmResponse + { + $data = $response->toArray(); + + if (!isset($data['choices'][0]['message']['content'])) { + throw new RuntimeException('Response does not contain output'); + } + + return new TextResponse($data['choices'][0]['message']['content']); + } +} diff --git a/src/platform/src/Bridge/Azure/Meta/PlatformFactory.php b/src/platform/src/Bridge/Azure/Meta/PlatformFactory.php new file mode 100644 index 000000000..59b8b5e64 --- /dev/null +++ b/src/platform/src/Bridge/Azure/Meta/PlatformFactory.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Azure\Meta; + +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +final readonly class PlatformFactory +{ + public static function create( + string $baseUrl, + #[\SensitiveParameter] + string $apiKey, + ?HttpClientInterface $httpClient = null, + ): Platform { + $modelClient = new LlamaHandler($httpClient ?? HttpClient::create(), $baseUrl, $apiKey); + + return new Platform([$modelClient], [$modelClient]); + } +} diff --git a/src/platform/src/Bridge/Azure/OpenAI/EmbeddingsModelClient.php b/src/platform/src/Bridge/Azure/OpenAI/EmbeddingsModelClient.php new file mode 100644 index 000000000..a45dd0661 --- /dev/null +++ b/src/platform/src/Bridge/Azure/OpenAI/EmbeddingsModelClient.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Azure\OpenAI; + +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Webmozart\Assert\Assert; + +/** + * @author Christopher Hertel + */ +final readonly class EmbeddingsModelClient implements ModelClientInterface +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + private string $baseUrl, + private string $deployment, + private string $apiVersion, + #[\SensitiveParameter] private string $apiKey, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + Assert::notStartsWith($baseUrl, 'http://', 'The base URL must not contain the protocol.'); + Assert::notStartsWith($baseUrl, 'https://', 'The base URL must not contain the protocol.'); + Assert::stringNotEmpty($deployment, 'The deployment must not be empty.'); + Assert::stringNotEmpty($apiVersion, 'The API version must not be empty.'); + Assert::stringNotEmpty($apiKey, 'The API key must not be empty.'); + } + + public function supports(Model $model): bool + { + return $model instanceof Embeddings; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + $url = \sprintf('https://%s/openai/deployments/%s/embeddings', $this->baseUrl, $this->deployment); + + return $this->httpClient->request('POST', $url, [ + 'headers' => [ + 'api-key' => $this->apiKey, + ], + 'query' => ['api-version' => $this->apiVersion], + 'json' => array_merge($options, [ + 'model' => $model->getName(), + 'input' => $payload, + ]), + ]); + } +} diff --git a/src/platform/src/Bridge/Azure/OpenAI/GPTModelClient.php b/src/platform/src/Bridge/Azure/OpenAI/GPTModelClient.php new file mode 100644 index 000000000..ac7f9a216 --- /dev/null +++ b/src/platform/src/Bridge/Azure/OpenAI/GPTModelClient.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Azure\OpenAI; + +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Webmozart\Assert\Assert; + +/** + * @author Christopher Hertel + */ +final readonly class GPTModelClient implements ModelClientInterface +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + private string $baseUrl, + private string $deployment, + private string $apiVersion, + #[\SensitiveParameter] private string $apiKey, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + Assert::notStartsWith($baseUrl, 'http://', 'The base URL must not contain the protocol.'); + Assert::notStartsWith($baseUrl, 'https://', 'The base URL must not contain the protocol.'); + Assert::stringNotEmpty($deployment, 'The deployment must not be empty.'); + Assert::stringNotEmpty($apiVersion, 'The API version must not be empty.'); + Assert::stringNotEmpty($apiKey, 'The API key must not be empty.'); + } + + public function supports(Model $model): bool + { + return $model instanceof GPT; + } + + public function request(Model $model, object|array|string $payload, array $options = []): ResponseInterface + { + $url = \sprintf('https://%s/openai/deployments/%s/chat/completions', $this->baseUrl, $this->deployment); + + return $this->httpClient->request('POST', $url, [ + 'headers' => [ + 'api-key' => $this->apiKey, + ], + 'query' => ['api-version' => $this->apiVersion], + 'json' => array_merge($options, $payload), + ]); + } +} diff --git a/src/platform/src/Bridge/Azure/OpenAI/PlatformFactory.php b/src/platform/src/Bridge/Azure/OpenAI/PlatformFactory.php new file mode 100644 index 000000000..fd4af9489 --- /dev/null +++ b/src/platform/src/Bridge/Azure/OpenAI/PlatformFactory.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Azure\OpenAI; + +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings; +use Symfony\AI\Platform\Bridge\OpenAI\GPT\ResponseConverter; +use Symfony\AI\Platform\Bridge\OpenAI\Whisper\AudioNormalizer; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +final readonly class PlatformFactory +{ + public static function create( + string $baseUrl, + string $deployment, + string $apiVersion, + #[\SensitiveParameter] + string $apiKey, + ?HttpClientInterface $httpClient = null, + ): Platform { + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + $embeddingsResponseFactory = new EmbeddingsModelClient($httpClient, $baseUrl, $deployment, $apiVersion, $apiKey); + $GPTResponseFactory = new GPTModelClient($httpClient, $baseUrl, $deployment, $apiVersion, $apiKey); + $whisperResponseFactory = new WhisperModelClient($httpClient, $baseUrl, $deployment, $apiVersion, $apiKey); + + return new Platform( + [$GPTResponseFactory, $embeddingsResponseFactory, $whisperResponseFactory], + [new ResponseConverter(), new Embeddings\ResponseConverter(), new \Symfony\AI\Platform\Bridge\OpenAI\Whisper\ResponseConverter()], + Contract::create(new AudioNormalizer()), + ); + } +} diff --git a/src/platform/src/Bridge/Azure/OpenAI/WhisperModelClient.php b/src/platform/src/Bridge/Azure/OpenAI/WhisperModelClient.php new file mode 100644 index 000000000..a6b7c5d2f --- /dev/null +++ b/src/platform/src/Bridge/Azure/OpenAI/WhisperModelClient.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Azure\OpenAI; + +use Symfony\AI\Platform\Bridge\OpenAI\Whisper; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Webmozart\Assert\Assert; + +/** + * @author Christopher Hertel + */ +final readonly class WhisperModelClient implements ModelClientInterface +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + private string $baseUrl, + private string $deployment, + private string $apiVersion, + #[\SensitiveParameter] private string $apiKey, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + Assert::notStartsWith($baseUrl, 'http://', 'The base URL must not contain the protocol.'); + Assert::notStartsWith($baseUrl, 'https://', 'The base URL must not contain the protocol.'); + Assert::stringNotEmpty($deployment, 'The deployment must not be empty.'); + Assert::stringNotEmpty($apiVersion, 'The API version must not be empty.'); + Assert::stringNotEmpty($apiKey, 'The API key must not be empty.'); + } + + public function supports(Model $model): bool + { + return $model instanceof Whisper; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + $url = \sprintf('https://%s/openai/deployments/%s/audio/translations', $this->baseUrl, $this->deployment); + + return $this->httpClient->request('POST', $url, [ + 'headers' => [ + 'api-key' => $this->apiKey, + 'Content-Type' => 'multipart/form-data', + ], + 'query' => ['api-version' => $this->apiVersion], + 'body' => array_merge($options, $payload), + ]); + } +} diff --git a/src/platform/src/Bridge/Bedrock/Anthropic/ClaudeHandler.php b/src/platform/src/Bridge/Bedrock/Anthropic/ClaudeHandler.php new file mode 100644 index 000000000..08977da56 --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/Anthropic/ClaudeHandler.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock\Anthropic; + +use AsyncAws\BedrockRuntime\BedrockRuntimeClient; +use AsyncAws\BedrockRuntime\Input\InvokeModelRequest; +use AsyncAws\BedrockRuntime\Result\InvokeModelResponse; +use Symfony\AI\Platform\Bridge\Anthropic\Claude; +use Symfony\AI\Platform\Bridge\Bedrock\BedrockModelClient; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Response\ToolCallResponse; + +/** + * @author Björn Altmann + */ +final readonly class ClaudeHandler implements BedrockModelClient +{ + public function __construct( + private BedrockRuntimeClient $bedrockRuntimeClient, + private string $version = '2023-05-31', + ) { + } + + public function supports(Model $model): bool + { + return $model instanceof Claude; + } + + public function request(Model $model, array|string $payload, array $options = []): LlmResponse + { + unset($payload['model']); + + if (isset($options['tools'])) { + $options['tool_choice'] = ['type' => 'auto']; + } + + if (!isset($options['anthropic_version'])) { + $options['anthropic_version'] = 'bedrock-'.$this->version; + } + + $request = [ + 'modelId' => $this->getModelId($model), + 'contentType' => 'application/json', + 'body' => json_encode(array_merge($options, $payload), \JSON_THROW_ON_ERROR), + ]; + + $invokeModelResponse = $this->bedrockRuntimeClient->invokeModel(new InvokeModelRequest($request)); + + return $this->convert($invokeModelResponse); + } + + public function convert(InvokeModelResponse $bedrockResponse): LlmResponse + { + $data = json_decode($bedrockResponse->getBody(), true, 512, \JSON_THROW_ON_ERROR); + + if (!isset($data['content']) || 0 === \count($data['content'])) { + throw new RuntimeException('Response does not contain any content'); + } + + if (!isset($data['content'][0]['text']) && !isset($data['content'][0]['type'])) { + throw new RuntimeException('Response content does not contain any text or type'); + } + + $toolCalls = []; + foreach ($data['content'] as $content) { + if ('tool_use' === $content['type']) { + $toolCalls[] = new ToolCall($content['id'], $content['name'], $content['input']); + } + } + if (!empty($toolCalls)) { + return new ToolCallResponse(...$toolCalls); + } + + return new TextResponse($data['content'][0]['text']); + } + + private function getModelId(Model $model): string + { + $configuredRegion = $this->bedrockRuntimeClient->getConfiguration()->get('region'); + $regionPrefix = substr((string) $configuredRegion, 0, 2); + + return $regionPrefix.'.anthropic.'.$model->getName().'-v1:0'; + } +} diff --git a/src/platform/src/Bridge/Bedrock/BedrockModelClient.php b/src/platform/src/Bridge/Bedrock/BedrockModelClient.php new file mode 100644 index 000000000..25fe1121c --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/BedrockModelClient.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock; + +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; + +/** + * @author Björn Altmann + */ +interface BedrockModelClient +{ + public function supports(Model $model): bool; + + /** + * @param array|string $payload + * @param array $options + */ + public function request(Model $model, array|string $payload, array $options = []): LlmResponse; +} diff --git a/src/platform/src/Bridge/Bedrock/Meta/LlamaModelClient.php b/src/platform/src/Bridge/Bedrock/Meta/LlamaModelClient.php new file mode 100644 index 000000000..4b6e31a57 --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/Meta/LlamaModelClient.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock\Meta; + +use AsyncAws\BedrockRuntime\BedrockRuntimeClient; +use AsyncAws\BedrockRuntime\Input\InvokeModelRequest; +use AsyncAws\BedrockRuntime\Result\InvokeModelResponse; +use Symfony\AI\Platform\Bridge\Bedrock\BedrockModelClient; +use Symfony\AI\Platform\Bridge\Meta\Llama; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\TextResponse; + +/** + * @author Björn Altmann + */ +class LlamaModelClient implements BedrockModelClient +{ + public function __construct( + private readonly BedrockRuntimeClient $bedrockRuntimeClient, + ) { + } + + public function supports(Model $model): bool + { + return $model instanceof Llama; + } + + public function request(Model $model, array|string $payload, array $options = []): LlmResponse + { + $response = $this->bedrockRuntimeClient->invokeModel(new InvokeModelRequest([ + 'modelId' => $this->getModelId($model), + 'contentType' => 'application/json', + 'body' => json_encode($payload, \JSON_THROW_ON_ERROR), + ])); + + return $this->convert($response); + } + + public function convert(InvokeModelResponse $bedrockResponse): LlmResponse + { + $responseBody = json_decode($bedrockResponse->getBody(), true, 512, \JSON_THROW_ON_ERROR); + + if (!isset($responseBody['generation'])) { + throw new \RuntimeException('Response does not contain any content'); + } + + return new TextResponse($responseBody['generation']); + } + + private function getModelId(Model $model): string + { + $configuredRegion = $this->bedrockRuntimeClient->getConfiguration()->get('region'); + $regionPrefix = substr((string) $configuredRegion, 0, 2); + $modifiedModelName = str_replace('llama-3', 'llama3', $model->getName()); + + return $regionPrefix.'.meta.'.str_replace('.', '-', $modifiedModelName).'-v1:0'; + } +} diff --git a/src/platform/src/Bridge/Bedrock/Nova/Contract/AssistantMessageNormalizer.php b/src/platform/src/Bridge/Bedrock/Nova/Contract/AssistantMessageNormalizer.php new file mode 100644 index 000000000..bf6378fd0 --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/Nova/Contract/AssistantMessageNormalizer.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock\Nova\Contract; + +use Symfony\AI\Platform\Bridge\Bedrock\Nova\Nova; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\ToolCall; + +/** + * @author Christopher Hertel + */ +final class AssistantMessageNormalizer extends ModelContractNormalizer +{ + protected function supportedDataClass(): string + { + return AssistantMessage::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Nova; + } + + /** + * @param AssistantMessage $data + * + * @return array{ + * role: 'assistant', + * content: array + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + if ($data->hasToolCalls()) { + return [ + 'role' => 'assistant', + 'content' => array_map(static function (ToolCall $toolCall) { + return [ + 'toolUse' => [ + 'toolUseId' => $toolCall->id, + 'name' => $toolCall->name, + 'input' => empty($toolCall->arguments) ? new \stdClass() : $toolCall->arguments, + ], + ]; + }, $data->toolCalls), + ]; + } + + return [ + 'role' => 'assistant', + 'content' => [['text' => $data->content]], + ]; + } +} diff --git a/src/platform/src/Bridge/Bedrock/Nova/Contract/MessageBagNormalizer.php b/src/platform/src/Bridge/Bedrock/Nova/Contract/MessageBagNormalizer.php new file mode 100644 index 000000000..45a3338db --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/Nova/Contract/MessageBagNormalizer.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock\Nova\Contract; + +use Symfony\AI\Platform\Bridge\Bedrock\Nova\Nova; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Model; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; + +/** + * @author Christopher Hertel + */ +final class MessageBagNormalizer extends ModelContractNormalizer implements NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + protected function supportedDataClass(): string + { + return MessageBagInterface::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Nova; + } + + /** + * @param MessageBagInterface $data + * + * @return array{ + * messages: array>, + * system?: array, + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $array = []; + + if ($data->getSystemMessage()) { + $array['system'][]['text'] = $data->getSystemMessage()->content; + } + + $array['messages'] = $this->normalizer->normalize($data->withoutSystemMessage()->getMessages(), $format, $context); + + return $array; + } +} diff --git a/src/platform/src/Bridge/Bedrock/Nova/Contract/ToolCallMessageNormalizer.php b/src/platform/src/Bridge/Bedrock/Nova/Contract/ToolCallMessageNormalizer.php new file mode 100644 index 000000000..92cd2e7a8 --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/Nova/Contract/ToolCallMessageNormalizer.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock\Nova\Contract; + +use Symfony\AI\Platform\Bridge\Bedrock\Nova\Nova; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\ToolCallMessage; +use Symfony\AI\Platform\Model; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; + +/** + * @author Christopher Hertel + */ +final class ToolCallMessageNormalizer extends ModelContractNormalizer implements NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + protected function supportedDataClass(): string + { + return ToolCallMessage::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Nova; + } + + /** + * @param ToolCallMessage $data + * + * @return array{ + * role: 'user', + * content: array, + * } + * }> + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'role' => 'user', + 'content' => [ + [ + 'toolResult' => [ + 'toolUseId' => $data->toolCall->id, + 'content' => [['json' => $data->content]], + ], + ], + ], + ]; + } +} diff --git a/src/platform/src/Bridge/Bedrock/Nova/Contract/ToolNormalizer.php b/src/platform/src/Bridge/Bedrock/Nova/Contract/ToolNormalizer.php new file mode 100644 index 000000000..8bdd3793f --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/Nova/Contract/ToolNormalizer.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock\Nova\Contract; + +use Symfony\AI\Platform\Bridge\Bedrock\Nova\Nova; +use Symfony\AI\Platform\Contract\JsonSchema\Factory; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Tool\Tool; + +/** + * @phpstan-import-type JsonSchema from Factory + * + * @author Christopher Hertel + */ +class ToolNormalizer extends ModelContractNormalizer +{ + protected function supportedDataClass(): string + { + return Tool::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Nova; + } + + /** + * @param Tool $data + * + * @return array{ + * toolSpec: array{ + * name: string, + * description: string, + * inputSchema: array{ + * json: JsonSchema|array{type: 'object'} + * } + * } + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'toolSpec' => [ + 'name' => $data->name, + 'description' => $data->description, + 'inputSchema' => [ + 'json' => $data->parameters ?? new \stdClass(), + ], + ], + ]; + } +} diff --git a/src/platform/src/Bridge/Bedrock/Nova/Contract/UserMessageNormalizer.php b/src/platform/src/Bridge/Bedrock/Nova/Contract/UserMessageNormalizer.php new file mode 100644 index 000000000..31776d5a1 --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/Nova/Contract/UserMessageNormalizer.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock\Nova\Contract; + +use Symfony\AI\Platform\Bridge\Bedrock\Nova\Nova; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Model; + +use function Symfony\Component\String\u; + +/** + * @author Christopher Hertel + */ +final class UserMessageNormalizer extends ModelContractNormalizer +{ + protected function supportedDataClass(): string + { + return UserMessage::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Nova; + } + + /** + * @param UserMessage $data + * + * @return array{ + * role: 'user', + * content: array + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $array = ['role' => $data->getRole()->value]; + + foreach ($data->content as $value) { + $contentPart = []; + if ($value instanceof Text) { + $contentPart['text'] = $value->text; + } elseif ($value instanceof Image) { + $contentPart['image']['format'] = u($value->getFormat())->replace('image/', '')->replace('jpg', 'jpeg')->toString(); + $contentPart['image']['source']['bytes'] = $value->asBase64(); + } else { + throw new RuntimeException('Unsupported message type.'); + } + $array['content'][] = $contentPart; + } + + return $array; + } +} diff --git a/src/platform/src/Bridge/Bedrock/Nova/Nova.php b/src/platform/src/Bridge/Bedrock/Nova/Nova.php new file mode 100644 index 000000000..826ae533e --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/Nova/Nova.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock\Nova; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +/** + * @author Björn Altmann + */ +final class Nova extends Model +{ + public const MICRO = 'nova-micro'; + public const LITE = 'nova-lite'; + public const PRO = 'nova-pro'; + public const PREMIER = 'nova-premier'; + + /** + * @param array $options The default options for the model usage + */ + public function __construct( + string $name = self::PRO, + array $options = ['temperature' => 1.0, 'max_tokens' => 1000], + ) { + $capabilities = [ + Capability::INPUT_MESSAGES, + Capability::OUTPUT_TEXT, + Capability::TOOL_CALLING, + ]; + + if (self::MICRO !== $name) { + $capabilities[] = Capability::INPUT_IMAGE; + } + + parent::__construct($name, $capabilities, $options); + } +} diff --git a/src/platform/src/Bridge/Bedrock/Nova/NovaHandler.php b/src/platform/src/Bridge/Bedrock/Nova/NovaHandler.php new file mode 100644 index 000000000..9e7624c3e --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/Nova/NovaHandler.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock\Nova; + +use AsyncAws\BedrockRuntime\BedrockRuntimeClient; +use AsyncAws\BedrockRuntime\Input\InvokeModelRequest; +use AsyncAws\BedrockRuntime\Result\InvokeModelResponse; +use Symfony\AI\Platform\Bridge\Bedrock\BedrockModelClient; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Response\ToolCallResponse; + +/** + * @author Björn Altmann + */ +class NovaHandler implements BedrockModelClient +{ + public function __construct( + private readonly BedrockRuntimeClient $bedrockRuntimeClient, + ) { + } + + public function supports(Model $model): bool + { + return $model instanceof Nova; + } + + public function request(Model $model, array|string $payload, array $options = []): LlmResponse + { + $modelOptions = []; + if (isset($options['tools'])) { + $modelOptions['toolConfig']['tools'] = $options['tools']; + } + + if (isset($options['temperature'])) { + $modelOptions['inferenceConfig']['temperature'] = $options['temperature']; + } + + if (isset($options['max_tokens'])) { + $modelOptions['inferenceConfig']['maxTokens'] = $options['max_tokens']; + } + + $request = [ + 'modelId' => $this->getModelId($model), + 'contentType' => 'application/json', + 'body' => json_encode(array_merge($payload, $modelOptions), \JSON_THROW_ON_ERROR), + ]; + + $invokeModelResponse = $this->bedrockRuntimeClient->invokeModel(new InvokeModelRequest($request)); + + return $this->convert($invokeModelResponse); + } + + public function convert(InvokeModelResponse $bedrockResponse): LlmResponse + { + $data = json_decode($bedrockResponse->getBody(), true, 512, \JSON_THROW_ON_ERROR); + + if (!isset($data['output']) || 0 === \count($data['output'])) { + throw new RuntimeException('Response does not contain any content'); + } + + if (!isset($data['output']['message']['content'][0]['text'])) { + throw new RuntimeException('Response content does not contain any text'); + } + + $toolCalls = []; + foreach ($data['output']['message']['content'] as $content) { + if (isset($content['toolUse'])) { + $toolCalls[] = new ToolCall($content['toolUse']['toolUseId'], $content['toolUse']['name'], $content['toolUse']['input']); + } + } + if (!empty($toolCalls)) { + return new ToolCallResponse(...$toolCalls); + } + + return new TextResponse($data['output']['message']['content'][0]['text']); + } + + private function getModelId(Model $model): string + { + $configuredRegion = $this->bedrockRuntimeClient->getConfiguration()->get('region'); + $regionPrefix = substr((string) $configuredRegion, 0, 2); + + return $regionPrefix.'.amazon.'.$model->getName().'-v1:0'; + } +} diff --git a/src/platform/src/Bridge/Bedrock/Platform.php b/src/platform/src/Bridge/Bedrock/Platform.php new file mode 100644 index 000000000..23f23dde6 --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/Platform.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock; + +use Symfony\AI\Platform\Bridge\Anthropic\Contract as AnthropicContract; +use Symfony\AI\Platform\Bridge\Bedrock\Nova\Contract as NovaContract; +use Symfony\AI\Platform\Bridge\Meta\Contract as LlamaContract; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Response\ResponseInterface; + +/** + * @author Björn Altmann + */ +class Platform implements PlatformInterface +{ + /** + * @var BedrockModelClient[] + */ + private readonly array $modelClients; + + /** + * @param iterable $modelClients + */ + public function __construct( + iterable $modelClients, + private ?Contract $contract = null, + ) { + $this->contract = $contract ?? Contract::create( + new AnthropicContract\AssistantMessageNormalizer(), + new AnthropicContract\DocumentNormalizer(), + new AnthropicContract\DocumentUrlNormalizer(), + new AnthropicContract\ImageNormalizer(), + new AnthropicContract\ImageUrlNormalizer(), + new AnthropicContract\MessageBagNormalizer(), + new AnthropicContract\ToolCallMessageNormalizer(), + new AnthropicContract\ToolNormalizer(), + new LlamaContract\MessageBagNormalizer(), + new NovaContract\AssistantMessageNormalizer(), + new NovaContract\MessageBagNormalizer(), + new NovaContract\ToolCallMessageNormalizer(), + new NovaContract\ToolNormalizer(), + new NovaContract\UserMessageNormalizer(), + ); + $this->modelClients = $modelClients instanceof \Traversable ? iterator_to_array($modelClients) : $modelClients; + } + + public function request(Model $model, array|string|object $input, array $options = []): ResponseInterface + { + $payload = $this->contract->createRequestPayload($model, $input); + $options = array_merge($model->getOptions(), $options); + + if (isset($options['tools'])) { + $options['tools'] = $this->contract->createToolOption($options['tools'], $model); + } + + return $this->doRequest($model, $payload, $options); + } + + /** + * @param array|string $payload + * @param array $options + */ + private function doRequest(Model $model, array|string $payload, array $options = []): ResponseInterface + { + foreach ($this->modelClients as $modelClient) { + if ($modelClient->supports($model)) { + return $modelClient->request($model, $payload, $options); + } + } + + throw new RuntimeException('No response factory registered for model "'.$model::class.'" with given input.'); + } +} diff --git a/src/platform/src/Bridge/Bedrock/PlatformFactory.php b/src/platform/src/Bridge/Bedrock/PlatformFactory.php new file mode 100644 index 000000000..bd5d8784c --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/PlatformFactory.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock; + +use AsyncAws\BedrockRuntime\BedrockRuntimeClient; +use Symfony\AI\Platform\Bridge\Bedrock\Anthropic\ClaudeHandler; +use Symfony\AI\Platform\Bridge\Bedrock\Meta\LlamaModelClient; +use Symfony\AI\Platform\Bridge\Bedrock\Nova\NovaHandler; + +/** + * @author Björn Altmann + */ +final readonly class PlatformFactory +{ + public static function create( + BedrockRuntimeClient $bedrockRuntimeClient = new BedrockRuntimeClient(), + ): Platform { + $modelClient[] = new ClaudeHandler($bedrockRuntimeClient); + $modelClient[] = new NovaHandler($bedrockRuntimeClient); + $modelClient[] = new LlamaModelClient($bedrockRuntimeClient); + + return new Platform($modelClient); + } +} diff --git a/src/platform/src/Bridge/Google/Contract/AssistantMessageNormalizer.php b/src/platform/src/Bridge/Google/Contract/AssistantMessageNormalizer.php new file mode 100644 index 000000000..11663a747 --- /dev/null +++ b/src/platform/src/Bridge/Google/Contract/AssistantMessageNormalizer.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Google\Contract; + +use Symfony\AI\Platform\Bridge\Google\Gemini; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Model; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; + +/** + * @author Christopher Hertel + */ +final class AssistantMessageNormalizer extends ModelContractNormalizer implements NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + protected function supportedDataClass(): string + { + return AssistantMessage::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Gemini; + } + + /** + * @param AssistantMessage $data + * + * @return array{array{text: string}} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + ['text' => $data->content], + ]; + } +} diff --git a/src/platform/src/Bridge/Google/Contract/MessageBagNormalizer.php b/src/platform/src/Bridge/Google/Contract/MessageBagNormalizer.php new file mode 100644 index 000000000..1321e2874 --- /dev/null +++ b/src/platform/src/Bridge/Google/Contract/MessageBagNormalizer.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Google\Contract; + +use Symfony\AI\Platform\Bridge\Google\Gemini; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Message\Role; +use Symfony\AI\Platform\Model; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; + +/** + * @author Christopher Hertel + */ +final class MessageBagNormalizer extends ModelContractNormalizer implements NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + protected function supportedDataClass(): string + { + return MessageBagInterface::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Gemini; + } + + /** + * @param MessageBagInterface $data + * + * @return array{ + * contents: list + * }>, + * system_instruction?: array{parts: array{text: string}} + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $array = ['contents' => []]; + + if (null !== $systemMessage = $data->getSystemMessage()) { + $array['system_instruction'] = [ + 'parts' => ['text' => $systemMessage->content], + ]; + } + + foreach ($data->withoutSystemMessage()->getMessages() as $message) { + $array['contents'][] = [ + 'role' => $message->getRole()->equals(Role::Assistant) ? 'model' : 'user', + 'parts' => $this->normalizer->normalize($message, $format, $context), + ]; + } + + return $array; + } +} diff --git a/src/platform/src/Bridge/Google/Contract/UserMessageNormalizer.php b/src/platform/src/Bridge/Google/Contract/UserMessageNormalizer.php new file mode 100644 index 000000000..2f41462b7 --- /dev/null +++ b/src/platform/src/Bridge/Google/Contract/UserMessageNormalizer.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Google\Contract; + +use Symfony\AI\Platform\Bridge\Google\Gemini; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +final class UserMessageNormalizer extends ModelContractNormalizer +{ + protected function supportedDataClass(): string + { + return UserMessage::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Gemini; + } + + /** + * @param UserMessage $data + * + * @return list + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $parts = []; + foreach ($data->content as $content) { + if ($content instanceof Text) { + $parts[] = ['text' => $content->text]; + } + if ($content instanceof Image) { + $parts[] = ['inline_data' => [ + 'mime_type' => $content->getFormat(), + 'data' => $content->asBase64(), + ]]; + } + } + + return $parts; + } +} diff --git a/src/platform/src/Bridge/Google/Gemini.php b/src/platform/src/Bridge/Google/Gemini.php new file mode 100644 index 000000000..ec52fc787 --- /dev/null +++ b/src/platform/src/Bridge/Google/Gemini.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Google; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +/** + * @author Roy Garrido + */ +class Gemini extends Model +{ + public const GEMINI_2_FLASH = 'gemini-2.0-flash'; + public const GEMINI_2_PRO = 'gemini-2.0-pro-exp-02-05'; + public const GEMINI_2_FLASH_LITE = 'gemini-2.0-flash-lite-preview-02-05'; + public const GEMINI_2_FLASH_THINKING = 'gemini-2.0-flash-thinking-exp-01-21'; + public const GEMINI_1_5_FLASH = 'gemini-1.5-flash'; + + /** + * @param array $options The default options for the model usage + */ + public function __construct(string $name = self::GEMINI_2_PRO, array $options = ['temperature' => 1.0]) + { + $capabilities = [ + Capability::INPUT_MESSAGES, + Capability::INPUT_IMAGE, + Capability::OUTPUT_STREAMING, + ]; + + parent::__construct($name, $capabilities, $options); + } +} diff --git a/src/platform/src/Bridge/Google/ModelHandler.php b/src/platform/src/Bridge/Google/ModelHandler.php new file mode 100644 index 000000000..57bc4a0ed --- /dev/null +++ b/src/platform/src/Bridge/Google/ModelHandler.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Google; + +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\StreamResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\ResponseConverterInterface; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Roy Garrido + */ +final readonly class ModelHandler implements ModelClientInterface, ResponseConverterInterface +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + } + + public function supports(Model $model): bool + { + return $model instanceof Gemini; + } + + /** + * @throws TransportExceptionInterface + */ + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + $url = \sprintf( + 'https://generativelanguage.googleapis.com/v1beta/models/%s:%s', + $model->getName(), + $options['stream'] ?? false ? 'streamGenerateContent' : 'generateContent', + ); + + $generationConfig = ['generationConfig' => $options]; + unset($generationConfig['generationConfig']['stream']); + + return $this->httpClient->request('POST', $url, [ + 'headers' => [ + 'x-goog-api-key' => $this->apiKey, + ], + 'json' => array_merge($generationConfig, $payload), + ]); + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + public function convert(ResponseInterface $response, array $options = []): LlmResponse + { + if ($options['stream'] ?? false) { + return new StreamResponse($this->convertStream($response)); + } + + $data = $response->toArray(); + + if (!isset($data['candidates'][0]['content']['parts'][0]['text'])) { + throw new RuntimeException('Response does not contain any content'); + } + + return new TextResponse($data['candidates'][0]['content']['parts'][0]['text']); + } + + private function convertStream(ResponseInterface $response): \Generator + { + foreach ((new EventSourceHttpClient())->stream($response) as $chunk) { + if ($chunk->isFirst() || $chunk->isLast()) { + continue; + } + + $jsonDelta = trim($chunk->getContent()); + + // Remove leading/trailing brackets + if (str_starts_with($jsonDelta, '[') || str_starts_with($jsonDelta, ',')) { + $jsonDelta = substr($jsonDelta, 1); + } + if (str_ends_with($jsonDelta, ']')) { + $jsonDelta = substr($jsonDelta, 0, -1); + } + + // Split in case of multiple JSON objects + $deltas = explode(",\r\n", $jsonDelta); + + foreach ($deltas as $delta) { + if ('' === $delta) { + continue; + } + + try { + $data = json_decode($delta, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new RuntimeException('Failed to decode JSON response', 0, $e); + } + + if (!isset($data['candidates'][0]['content']['parts'][0]['text'])) { + continue; + } + + yield $data['candidates'][0]['content']['parts'][0]['text']; + } + } + } +} diff --git a/src/platform/src/Bridge/Google/PlatformFactory.php b/src/platform/src/Bridge/Google/PlatformFactory.php new file mode 100644 index 000000000..cab1c3b3d --- /dev/null +++ b/src/platform/src/Bridge/Google/PlatformFactory.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Google; + +use Symfony\AI\Platform\Bridge\Google\Contract\AssistantMessageNormalizer; +use Symfony\AI\Platform\Bridge\Google\Contract\MessageBagNormalizer; +use Symfony\AI\Platform\Bridge\Google\Contract\UserMessageNormalizer; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Roy Garrido + */ +final readonly class PlatformFactory +{ + public static function create( + #[\SensitiveParameter] + string $apiKey, + ?HttpClientInterface $httpClient = null, + ): Platform { + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + $responseHandler = new ModelHandler($httpClient, $apiKey); + + return new Platform([$responseHandler], [$responseHandler], Contract::create( + new AssistantMessageNormalizer(), + new MessageBagNormalizer(), + new UserMessageNormalizer(), + )); + } +} diff --git a/src/platform/src/Bridge/HuggingFace/ApiClient.php b/src/platform/src/Bridge/HuggingFace/ApiClient.php new file mode 100644 index 000000000..4a73325eb --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/ApiClient.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace; + +use Symfony\AI\Platform\Model; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +final class ApiClient +{ + public function __construct( + private ?HttpClientInterface $httpClient = null, + ) { + $this->httpClient = $httpClient ?? HttpClient::create(); + } + + /** + * @return Model[] + */ + public function models(?string $provider, ?string $task): array + { + $response = $this->httpClient->request('GET', 'https://huggingface.co/api/models', [ + 'query' => [ + 'inference_provider' => $provider, + 'pipeline_tag' => $task, + ], + ]); + + return array_map(fn (array $model) => new Model($model['id']), $response->toArray()); + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Contract/FileNormalizer.php b/src/platform/src/Bridge/HuggingFace/Contract/FileNormalizer.php new file mode 100644 index 000000000..8c5e7a0b0 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Contract/FileNormalizer.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Contract; + +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\Content\File; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +class FileNormalizer extends ModelContractNormalizer +{ + protected function supportedDataClass(): string + { + return File::class; + } + + protected function supportsModel(Model $model): bool + { + return true; + } + + /** + * @param File $data + * + * @return array{ + * headers: array<'Content-Type', string>, + * body: string + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'headers' => ['Content-Type' => $data->getFormat()], + 'body' => $data->asBinary(), + ]; + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Contract/MessageBagNormalizer.php b/src/platform/src/Bridge/HuggingFace/Contract/MessageBagNormalizer.php new file mode 100644 index 000000000..29f8a4581 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Contract/MessageBagNormalizer.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Contract; + +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Model; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; + +/** + * @author Christopher Hertel + */ +class MessageBagNormalizer extends ModelContractNormalizer implements NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + protected function supportedDataClass(): string + { + return MessageBagInterface::class; + } + + protected function supportsModel(Model $model): bool + { + return true; + } + + /** + * @param MessageBagInterface $data + * + * @return array{ + * headers: array<'Content-Type', 'application/json'>, + * json: array{messages: array} + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => [ + 'messages' => $this->normalizer->normalize($data->getMessages(), $format, $context), + ], + ]; + } +} diff --git a/src/platform/src/Bridge/HuggingFace/ModelClient.php b/src/platform/src/Bridge/HuggingFace/ModelClient.php new file mode 100644 index 000000000..dc6595e47 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/ModelClient.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace; + +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface as PlatformModelClient; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final readonly class ModelClient implements PlatformModelClient +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + private string $provider, + #[\SensitiveParameter] + private string $apiKey, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + } + + public function supports(Model $model): bool + { + return true; + } + + /** + * The difference in HuggingFace here is that we treat the payload as the options for the request not only the body. + */ + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + // Extract task from options if provided + $task = $options['task'] ?? null; + unset($options['task']); + + return $this->httpClient->request('POST', $this->getUrl($model, $task), [ + 'auth_bearer' => $this->apiKey, + ...$this->getPayload($payload, $options), + ]); + } + + private function getUrl(Model $model, ?string $task): string + { + $endpoint = Task::FEATURE_EXTRACTION === $task ? 'pipeline/feature-extraction' : 'models'; + $url = \sprintf('https://router.huggingface.co/%s/%s/%s', $this->provider, $endpoint, $model->getName()); + + if (Task::CHAT_COMPLETION === $task) { + $url .= '/v1/chat/completions'; + } + + return $url; + } + + /** + * @param array $payload + * @param array $options + * + * @return array + */ + private function getPayload(array|string $payload, array $options): array + { + // Expect JSON input if string or not + if (\is_string($payload) || !(isset($payload['body']) || isset($payload['json']))) { + $payload = ['json' => ['inputs' => $payload]]; + + if (0 !== \count($options)) { + $payload['json']['parameters'] = $options; + } + } + + // Merge options into JSON payload + if (isset($payload['json'])) { + $payload['json'] = array_merge($payload['json'], $options); + } + + $payload['headers'] ??= ['Content-Type' => 'application/json']; + + return $payload; + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Output/Classification.php b/src/platform/src/Bridge/HuggingFace/Output/Classification.php new file mode 100644 index 000000000..48775e270 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Output/Classification.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Output; + +/** + * @author Christopher Hertel + */ +final readonly class Classification +{ + public function __construct( + public string $label, + public float $score, + ) { + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Output/ClassificationResult.php b/src/platform/src/Bridge/HuggingFace/Output/ClassificationResult.php new file mode 100644 index 000000000..aa28ab6b6 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Output/ClassificationResult.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Output; + +/** + * @author Christopher Hertel + */ +final class ClassificationResult +{ + /** + * @param Classification[] $classifications + */ + public function __construct( + public array $classifications, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + array_map(fn (array $item) => new Classification($item['label'], $item['score']), $data) + ); + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Output/DetectedObject.php b/src/platform/src/Bridge/HuggingFace/Output/DetectedObject.php new file mode 100644 index 000000000..e81b07881 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Output/DetectedObject.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Output; + +/** + * @author Christopher Hertel + */ +final readonly class DetectedObject +{ + public function __construct( + public string $label, + public float $score, + public float $xmin, + public float $ymin, + public float $xmax, + public float $ymax, + ) { + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Output/FillMaskResult.php b/src/platform/src/Bridge/HuggingFace/Output/FillMaskResult.php new file mode 100644 index 000000000..5bbfbf4cd --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Output/FillMaskResult.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Output; + +/** + * @author Christopher Hertel + */ +final class FillMaskResult +{ + /** + * @param MaskFill[] $fills + */ + public function __construct( + public array $fills, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self(array_map( + fn (array $item) => new MaskFill( + $item['token'], + $item['token_str'], + $item['sequence'], + $item['score'], + ), + $data, + )); + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Output/ImageSegment.php b/src/platform/src/Bridge/HuggingFace/Output/ImageSegment.php new file mode 100644 index 000000000..327942151 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Output/ImageSegment.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Output; + +/** + * @author Christopher Hertel + */ +final readonly class ImageSegment +{ + public function __construct( + public string $label, + public ?float $score, + public string $mask, + ) { + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Output/ImageSegmentationResult.php b/src/platform/src/Bridge/HuggingFace/Output/ImageSegmentationResult.php new file mode 100644 index 000000000..999fab9d3 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Output/ImageSegmentationResult.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Output; + +/** + * @author Christopher Hertel + */ +final class ImageSegmentationResult +{ + /** + * @param ImageSegment[] $segments + */ + public function __construct( + public array $segments, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + array_map(fn (array $item) => new ImageSegment($item['label'], $item['score'], $item['mask']), $data) + ); + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Output/MaskFill.php b/src/platform/src/Bridge/HuggingFace/Output/MaskFill.php new file mode 100644 index 000000000..242ead4ee --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Output/MaskFill.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Output; + +/** + * @author Christopher Hertel + */ +final readonly class MaskFill +{ + public function __construct( + public int $token, + public string $tokenStr, + public string $sequence, + public float $score, + ) { + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Output/ObjectDetectionResult.php b/src/platform/src/Bridge/HuggingFace/Output/ObjectDetectionResult.php new file mode 100644 index 000000000..65c868162 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Output/ObjectDetectionResult.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Output; + +/** + * @author Christopher Hertel + */ +final class ObjectDetectionResult +{ + /** + * @param DetectedObject[] $objects + */ + public function __construct( + public array $objects, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self(array_map( + fn (array $item) => new DetectedObject( + $item['label'], + $item['score'], + $item['box']['xmin'], + $item['box']['ymin'], + $item['box']['xmax'], + $item['box']['ymax'], + ), + $data, + )); + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Output/QuestionAnsweringResult.php b/src/platform/src/Bridge/HuggingFace/Output/QuestionAnsweringResult.php new file mode 100644 index 000000000..67015b4a4 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Output/QuestionAnsweringResult.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Output; + +/** + * @author Christopher Hertel + */ +final readonly class QuestionAnsweringResult +{ + public function __construct( + public string $answer, + public int $startIndex, + public int $endIndex, + public float $score, + ) { + } + + /** + * @param array{answer: string, start: int, end: int, score: float} $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['answer'], + $data['start'], + $data['end'], + $data['score'], + ); + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Output/SentenceSimilarityResult.php b/src/platform/src/Bridge/HuggingFace/Output/SentenceSimilarityResult.php new file mode 100644 index 000000000..a0dea8cd9 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Output/SentenceSimilarityResult.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Output; + +/** + * @author Christopher Hertel + */ +final readonly class SentenceSimilarityResult +{ + /** + * @param array $similarities + */ + public function __construct( + public array $similarities, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self($data); + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Output/TableQuestionAnsweringResult.php b/src/platform/src/Bridge/HuggingFace/Output/TableQuestionAnsweringResult.php new file mode 100644 index 000000000..ac5ad45bc --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Output/TableQuestionAnsweringResult.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Output; + +/** + * @author Christopher Hertel + */ +final readonly class TableQuestionAnsweringResult +{ + /** + * @param array $cells + * @param array $aggregator + */ + public function __construct( + public string $answer, + public array $cells = [], + public array $aggregator = [], + ) { + } + + /** + * @param array{answer: string, cells?: array, aggregator?: array} $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['answer'], + $data['cells'] ?? [], + $data['aggregator'] ?? [], + ); + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Output/Token.php b/src/platform/src/Bridge/HuggingFace/Output/Token.php new file mode 100644 index 000000000..3d1dd5465 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Output/Token.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Output; + +/** + * @author Christopher Hertel + */ +final readonly class Token +{ + public function __construct( + public string $entityGroup, + public float $score, + public string $word, + public int $start, + public int $end, + ) { + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Output/TokenClassificationResult.php b/src/platform/src/Bridge/HuggingFace/Output/TokenClassificationResult.php new file mode 100644 index 000000000..43dcc7c57 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Output/TokenClassificationResult.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Output; + +/** + * @author Christopher Hertel + */ +final class TokenClassificationResult +{ + /** + * @param Token[] $tokens + */ + public function __construct( + public array $tokens, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self(array_map( + fn (array $item) => new Token( + $item['entity_group'], + $item['score'], + $item['word'], + $item['start'], + $item['end'], + ), + $data, + )); + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Output/ZeroShotClassificationResult.php b/src/platform/src/Bridge/HuggingFace/Output/ZeroShotClassificationResult.php new file mode 100644 index 000000000..1d40a5643 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Output/ZeroShotClassificationResult.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace\Output; + +/** + * @author Christopher Hertel + */ +final class ZeroShotClassificationResult +{ + /** + * @param array $labels + * @param array $scores + */ + public function __construct( + public array $labels, + public array $scores, + public ?string $sequence = null, + ) { + } + + /** + * @param array{labels: array, scores: array, sequence?: string} $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['labels'], + $data['scores'], + $data['sequence'] ?? null, + ); + } +} diff --git a/src/platform/src/Bridge/HuggingFace/PlatformFactory.php b/src/platform/src/Bridge/HuggingFace/PlatformFactory.php new file mode 100644 index 000000000..f25c51c17 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/PlatformFactory.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace; + +use Symfony\AI\Platform\Bridge\HuggingFace\Contract\FileNormalizer; +use Symfony\AI\Platform\Bridge\HuggingFace\Contract\MessageBagNormalizer; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +final readonly class PlatformFactory +{ + public static function create( + #[\SensitiveParameter] + string $apiKey, + string $provider = Provider::HF_INFERENCE, + ?HttpClientInterface $httpClient = null, + ): Platform { + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + return new Platform( + [new ModelClient($httpClient, $provider, $apiKey)], + [new ResponseConverter()], + Contract::create( + new FileNormalizer(), + new MessageBagNormalizer(), + ), + ); + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Provider.php b/src/platform/src/Bridge/HuggingFace/Provider.php new file mode 100644 index 000000000..2a24ea9fd --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Provider.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace; + +/** + * @author Christopher Hertel + */ +interface Provider +{ + public const CEREBRAS = 'cerebras'; + public const COHERE = 'cohere'; + public const FAL_AI = 'fal-ai'; + public const FIREWORKS = 'fireworks-ai'; + public const HYPERBOLIC = 'hyperbolic'; + public const HF_INFERENCE = 'hf-inference'; + public const NEBIUS = 'nebius'; + public const NOVITA = 'novita'; + public const REPLICATE = 'replicate'; + public const SAMBA_NOVA = 'sambanova'; + public const TOGETHER = 'together'; +} diff --git a/src/platform/src/Bridge/HuggingFace/ResponseConverter.php b/src/platform/src/Bridge/HuggingFace/ResponseConverter.php new file mode 100644 index 000000000..5607030d2 --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/ResponseConverter.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace; + +use Symfony\AI\Platform\Bridge\HuggingFace\Output\ClassificationResult; +use Symfony\AI\Platform\Bridge\HuggingFace\Output\FillMaskResult; +use Symfony\AI\Platform\Bridge\HuggingFace\Output\ImageSegmentationResult; +use Symfony\AI\Platform\Bridge\HuggingFace\Output\ObjectDetectionResult; +use Symfony\AI\Platform\Bridge\HuggingFace\Output\QuestionAnsweringResult; +use Symfony\AI\Platform\Bridge\HuggingFace\Output\SentenceSimilarityResult; +use Symfony\AI\Platform\Bridge\HuggingFace\Output\TableQuestionAnsweringResult; +use Symfony\AI\Platform\Bridge\HuggingFace\Output\TokenClassificationResult; +use Symfony\AI\Platform\Bridge\HuggingFace\Output\ZeroShotClassificationResult; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\BinaryResponse; +use Symfony\AI\Platform\Response\ObjectResponse; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\Response\VectorResponse; +use Symfony\AI\Platform\ResponseConverterInterface as PlatformResponseConverter; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final readonly class ResponseConverter implements PlatformResponseConverter +{ + public function supports(Model $model): bool + { + return true; + } + + public function convert(ResponseInterface $response, array $options = []): LlmResponse + { + if (503 === $response->getStatusCode()) { + return throw new RuntimeException('Service unavailable.'); + } + + if (404 === $response->getStatusCode()) { + return throw new InvalidArgumentException('Model, provider or task not found (404).'); + } + + $headers = $response->getHeaders(false); + $contentType = $headers['content-type'][0] ?? null; + $content = 'application/json' === $contentType ? $response->toArray(false) : $response->getContent(false); + + if (str_starts_with((string) $response->getStatusCode(), '4')) { + $message = \is_string($content) ? $content : + (\is_array($content['error']) ? $content['error'][0] : $content['error']); + + throw new InvalidArgumentException(\sprintf('API Client Error (%d): %s', $response->getStatusCode(), $message)); + } + + if (200 !== $response->getStatusCode()) { + throw new RuntimeException('Unhandled response code: '.$response->getStatusCode()); + } + + $task = $options['task'] ?? null; + + return match ($task) { + Task::AUDIO_CLASSIFICATION, Task::IMAGE_CLASSIFICATION => new ObjectResponse( + ClassificationResult::fromArray($content) + ), + Task::AUTOMATIC_SPEECH_RECOGNITION => new TextResponse($content['text'] ?? ''), + Task::CHAT_COMPLETION => new TextResponse($content['choices'][0]['message']['content'] ?? ''), + Task::FEATURE_EXTRACTION => new VectorResponse(new Vector($content)), + Task::TEXT_CLASSIFICATION => new ObjectResponse(ClassificationResult::fromArray(reset($content) ?? [])), + Task::FILL_MASK => new ObjectResponse(FillMaskResult::fromArray($content)), + Task::IMAGE_SEGMENTATION => new ObjectResponse(ImageSegmentationResult::fromArray($content)), + Task::IMAGE_TO_TEXT, Task::TEXT_GENERATION => new TextResponse($content[0]['generated_text'] ?? ''), + Task::TEXT_TO_IMAGE => new BinaryResponse($content, $contentType), + Task::OBJECT_DETECTION => new ObjectResponse(ObjectDetectionResult::fromArray($content)), + Task::QUESTION_ANSWERING => new ObjectResponse(QuestionAnsweringResult::fromArray($content)), + Task::SENTENCE_SIMILARITY => new ObjectResponse(SentenceSimilarityResult::fromArray($content)), + Task::SUMMARIZATION => new TextResponse($content[0]['summary_text']), + Task::TABLE_QUESTION_ANSWERING => new ObjectResponse(TableQuestionAnsweringResult::fromArray($content)), + Task::TOKEN_CLASSIFICATION => new ObjectResponse(TokenClassificationResult::fromArray($content)), + Task::TRANSLATION => new TextResponse($content[0]['translation_text'] ?? ''), + Task::ZERO_SHOT_CLASSIFICATION => new ObjectResponse(ZeroShotClassificationResult::fromArray($content)), + + default => throw new RuntimeException(\sprintf('Unsupported task: %s', $task)), + }; + } +} diff --git a/src/platform/src/Bridge/HuggingFace/Task.php b/src/platform/src/Bridge/HuggingFace/Task.php new file mode 100644 index 000000000..e2be0049c --- /dev/null +++ b/src/platform/src/Bridge/HuggingFace/Task.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\HuggingFace; + +/** + * @author Christopher Hertel + */ +interface Task +{ + public const AUDIO_CLASSIFICATION = 'audio-classification'; + public const AUTOMATIC_SPEECH_RECOGNITION = 'automatic-speech-recognition'; + public const CHAT_COMPLETION = 'chat-completion'; + public const FEATURE_EXTRACTION = 'feature-extraction'; + public const FILL_MASK = 'fill-mask'; + public const IMAGE_CLASSIFICATION = 'image-classification'; + public const IMAGE_SEGMENTATION = 'image-segmentation'; + public const IMAGE_TO_TEXT = 'image-to-text'; + public const OBJECT_DETECTION = 'object-detection'; + public const QUESTION_ANSWERING = 'question-answering'; + public const SENTENCE_SIMILARITY = 'sentence-similarity'; + public const SUMMARIZATION = 'summarization'; + public const TABLE_QUESTION_ANSWERING = 'table-question-answering'; + public const TEXT_CLASSIFICATION = 'text-classification'; + public const TEXT_GENERATION = 'text-generation'; + public const TEXT_TO_IMAGE = 'text-to-image'; + public const TOKEN_CLASSIFICATION = 'token-classification'; + public const TRANSLATION = 'translation'; + public const ZERO_SHOT_CLASSIFICATION = 'zero-shot-classification'; +} diff --git a/src/platform/src/Bridge/Meta/Contract/MessageBagNormalizer.php b/src/platform/src/Bridge/Meta/Contract/MessageBagNormalizer.php new file mode 100644 index 000000000..95681e6ad --- /dev/null +++ b/src/platform/src/Bridge/Meta/Contract/MessageBagNormalizer.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Meta\Contract; + +use Symfony\AI\Platform\Bridge\Meta\Llama; +use Symfony\AI\Platform\Bridge\Meta\LlamaPromptConverter; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +final class MessageBagNormalizer extends ModelContractNormalizer +{ + public function __construct( + private readonly LlamaPromptConverter $promptConverter = new LlamaPromptConverter(), + ) { + } + + protected function supportedDataClass(): string + { + return MessageBagInterface::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Llama; + } + + /** + * @param MessageBagInterface $data + * + * @return array{prompt: string} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'prompt' => $this->promptConverter->convertToPrompt($data), + ]; + } +} diff --git a/src/platform/src/Bridge/Meta/Llama.php b/src/platform/src/Bridge/Meta/Llama.php new file mode 100644 index 000000000..d6d50e9e0 --- /dev/null +++ b/src/platform/src/Bridge/Meta/Llama.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Meta; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +class Llama extends Model +{ + public const V3_3_70B_INSTRUCT = 'llama-3.3-70B-Instruct'; + public const V3_2_90B_VISION_INSTRUCT = 'llama-3.2-90b-vision-instruct'; + public const V3_2_11B_VISION_INSTRUCT = 'llama-3.2-11b-vision-instruct'; + public const V3_2_3B = 'llama-3.2-3b'; + public const V3_2_3B_INSTRUCT = 'llama-3.2-3b-instruct'; + public const V3_2_1B = 'llama-3.2-1b'; + public const V3_2_1B_INSTRUCT = 'llama-3.2-1b-instruct'; + public const V3_1_405B_INSTRUCT = 'llama-3.1-405b-instruct'; + public const V3_1_70B = 'llama-3.1-70b'; + public const V3_1_70B_INSTRUCT = 'llama-3-70b-instruct'; + public const V3_1_8B = 'llama-3.1-8b'; + public const V3_1_8B_INSTRUCT = 'llama-3.1-8b-instruct'; + public const V3_70B = 'llama-3-70b'; + public const V3_8B_INSTRUCT = 'llama-3-8b-instruct'; + public const V3_8B = 'llama-3-8b'; + + /** + * @param array $options + */ + public function __construct(string $name = self::V3_1_405B_INSTRUCT, array $options = []) + { + $capabilities = [ + Capability::INPUT_MESSAGES, + Capability::OUTPUT_TEXT, + ]; + + parent::__construct($name, $capabilities, $options); + } +} diff --git a/src/platform/src/Bridge/Meta/LlamaPromptConverter.php b/src/platform/src/Bridge/Meta/LlamaPromptConverter.php new file mode 100644 index 000000000..c481c2590 --- /dev/null +++ b/src/platform/src/Bridge/Meta/LlamaPromptConverter.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Meta; + +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Message\SystemMessage; +use Symfony\AI\Platform\Message\UserMessage; + +/** + * @author Oskar Stark + */ +final class LlamaPromptConverter +{ + public function convertToPrompt(MessageBagInterface $messageBag): string + { + $messages = []; + + /** @var UserMessage|SystemMessage|AssistantMessage $message */ + foreach ($messageBag->getMessages() as $message) { + $messages[] = self::convertMessage($message); + } + + $messages = array_filter($messages, fn ($message) => '' !== $message); + + return trim(implode(\PHP_EOL.\PHP_EOL, $messages)).\PHP_EOL.\PHP_EOL.'<|start_header_id|>assistant<|end_header_id|>'; + } + + public function convertMessage(UserMessage|SystemMessage|AssistantMessage $message): string + { + if ($message instanceof SystemMessage) { + return trim(<<<|start_header_id|>system<|end_header_id|> + + {$message->content}<|eot_id|> + SYSTEM); + } + + if ($message instanceof AssistantMessage) { + if ('' === $message->content || null === $message->content) { + return ''; + } + + return trim(<<{$message->getRole()->value}<|end_header_id|> + + {$message->content}<|eot_id|> + ASSISTANT); + } + + // Handling of UserMessage + $count = \count($message->content); + + $contentParts = []; + if ($count > 1) { + foreach ($message->content as $value) { + if ($value instanceof Text) { + $contentParts[] = $value->text; + } + + if ($value instanceof ImageUrl) { + $contentParts[] = $value->url; + } + } + } elseif (1 === $count) { + $value = $message->content[0]; + if ($value instanceof Text) { + $contentParts[] = $value->text; + } + + if ($value instanceof ImageUrl) { + $contentParts[] = $value->url; + } + } else { + throw new RuntimeException('Unsupported message type.'); + } + + $content = implode(\PHP_EOL, $contentParts); + + return trim(<<{$message->getRole()->value}<|end_header_id|> + + {$content}<|eot_id|> + USER); + } +} diff --git a/src/platform/src/Bridge/Mistral/Contract/ToolNormalizer.php b/src/platform/src/Bridge/Mistral/Contract/ToolNormalizer.php new file mode 100644 index 000000000..d1b47d756 --- /dev/null +++ b/src/platform/src/Bridge/Mistral/Contract/ToolNormalizer.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Mistral\Contract; + +use Symfony\AI\Platform\Contract\Normalizer\ToolNormalizer as BaseToolNormalizer; + +/** + * @author Christopher Hertel + */ +class ToolNormalizer extends BaseToolNormalizer +{ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $array = parent::normalize($data, $format, $context); + + $array['function']['parameters'] ??= ['type' => 'object']; + + return $array; + } +} diff --git a/src/platform/src/Bridge/Mistral/Embeddings.php b/src/platform/src/Bridge/Mistral/Embeddings.php new file mode 100644 index 000000000..6f96f5f48 --- /dev/null +++ b/src/platform/src/Bridge/Mistral/Embeddings.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Mistral; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +final class Embeddings extends Model +{ + public const MISTRAL_EMBED = 'mistral-embed'; + + /** + * @param array $options + */ + public function __construct( + string $name = self::MISTRAL_EMBED, + array $options = [], + ) { + parent::__construct($name, [Capability::INPUT_MULTIPLE], $options); + } +} diff --git a/src/platform/src/Bridge/Mistral/Embeddings/ModelClient.php b/src/platform/src/Bridge/Mistral/Embeddings/ModelClient.php new file mode 100644 index 000000000..ec6b06baf --- /dev/null +++ b/src/platform/src/Bridge/Mistral/Embeddings/ModelClient.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Mistral\Embeddings; + +use Symfony\AI\Platform\Bridge\Mistral\Embeddings; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final readonly class ModelClient implements ModelClientInterface +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + #[\SensitiveParameter] + private string $apiKey, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + } + + public function supports(Model $model): bool + { + return $model instanceof Embeddings; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + return $this->httpClient->request('POST', 'https://api.mistral.ai/v1/embeddings', [ + 'auth_bearer' => $this->apiKey, + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => array_merge($options, [ + 'model' => $model->getName(), + 'input' => $payload, + ]), + ]); + } +} diff --git a/src/platform/src/Bridge/Mistral/Embeddings/ResponseConverter.php b/src/platform/src/Bridge/Mistral/Embeddings/ResponseConverter.php new file mode 100644 index 000000000..cb934d7de --- /dev/null +++ b/src/platform/src/Bridge/Mistral/Embeddings/ResponseConverter.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Mistral\Embeddings; + +use Symfony\AI\Platform\Bridge\Mistral\Embeddings; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\VectorResponse; +use Symfony\AI\Platform\ResponseConverterInterface; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final readonly class ResponseConverter implements ResponseConverterInterface +{ + public function supports(Model $model): bool + { + return $model instanceof Embeddings; + } + + public function convert(ResponseInterface $response, array $options = []): VectorResponse + { + $data = $response->toArray(false); + + if (200 !== $response->getStatusCode()) { + throw new RuntimeException(\sprintf('Unexpected response code %d: %s', $response->getStatusCode(), $response->getContent(false))); + } + + if (!isset($data['data'])) { + throw new RuntimeException('Response does not contain data'); + } + + return new VectorResponse( + ...array_map( + static fn (array $item): Vector => new Vector($item['embedding']), + $data['data'] + ), + ); + } +} diff --git a/src/platform/src/Bridge/Mistral/Llm/ModelClient.php b/src/platform/src/Bridge/Mistral/Llm/ModelClient.php new file mode 100644 index 000000000..93924cdc8 --- /dev/null +++ b/src/platform/src/Bridge/Mistral/Llm/ModelClient.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Mistral\Llm; + +use Symfony\AI\Platform\Bridge\Mistral\Mistral; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final readonly class ModelClient implements ModelClientInterface +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + #[\SensitiveParameter] + private string $apiKey, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + } + + public function supports(Model $model): bool + { + return $model instanceof Mistral; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + return $this->httpClient->request('POST', 'https://api.mistral.ai/v1/chat/completions', [ + 'auth_bearer' => $this->apiKey, + 'headers' => [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], + 'json' => array_merge($options, $payload), + ]); + } +} diff --git a/src/platform/src/Bridge/Mistral/Llm/ResponseConverter.php b/src/platform/src/Bridge/Mistral/Llm/ResponseConverter.php new file mode 100644 index 000000000..7ca164b73 --- /dev/null +++ b/src/platform/src/Bridge/Mistral/Llm/ResponseConverter.php @@ -0,0 +1,199 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Mistral\Llm; + +use Symfony\AI\Platform\Bridge\Mistral\Mistral; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\Choice; +use Symfony\AI\Platform\Response\ChoiceResponse; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\StreamResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Response\ToolCallResponse; +use Symfony\AI\Platform\ResponseConverterInterface; +use Symfony\Component\HttpClient\Chunk\ServerSentEvent; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Component\HttpClient\Exception\JsonException; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +/** + * @author Christopher Hertel + */ +final readonly class ResponseConverter implements ResponseConverterInterface +{ + public function supports(Model $model): bool + { + return $model instanceof Mistral; + } + + /** + * @param array $options + */ + public function convert(HttpResponse $response, array $options = []): LlmResponse + { + if ($options['stream'] ?? false) { + return new StreamResponse($this->convertStream($response)); + } + + $code = $response->getStatusCode(); + $data = $response->toArray(false); + + if (200 !== $code) { + throw new RuntimeException(\sprintf('Unexpected response code %d: %s', $code, $response->getContent(false))); + } + + if (!isset($data['choices'])) { + throw new RuntimeException('Response does not contain choices'); + } + + /** @var Choice[] $choices */ + $choices = array_map($this->convertChoice(...), $data['choices']); + + if (1 !== \count($choices)) { + return new ChoiceResponse(...$choices); + } + + if ($choices[0]->hasToolCall()) { + return new ToolCallResponse(...$choices[0]->getToolCalls()); + } + + return new TextResponse($choices[0]->getContent()); + } + + private function convertStream(HttpResponse $response): \Generator + { + $toolCalls = []; + foreach ((new EventSourceHttpClient())->stream($response) as $chunk) { + if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) { + continue; + } + + try { + $data = $chunk->getArrayData(); + } catch (JsonException) { + // try catch only needed for Symfony 6.4 + continue; + } + + if ($this->streamIsToolCall($data)) { + $toolCalls = $this->convertStreamToToolCalls($toolCalls, $data); + } + + if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) { + yield new ToolCallResponse(...array_map($this->convertToolCall(...), $toolCalls)); + } + + if (!isset($data['choices'][0]['delta']['content'])) { + continue; + } + + yield $data['choices'][0]['delta']['content']; + } + } + + /** + * @param array $toolCalls + * @param array $data + * + * @return array + */ + private function convertStreamToToolCalls(array $toolCalls, array $data): array + { + if (!isset($data['choices'][0]['delta']['tool_calls'])) { + return $toolCalls; + } + + foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) { + if (isset($toolCall['id'])) { + // initialize tool call + $toolCalls[$i] = [ + 'id' => $toolCall['id'], + 'function' => $toolCall['function'], + ]; + continue; + } + + // add arguments delta to tool call + $toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments']; + } + + return $toolCalls; + } + + /** + * @param array $data + */ + private function streamIsToolCall(array $data): bool + { + return isset($data['choices'][0]['delta']['tool_calls']); + } + + /** + * @param array $data + */ + private function isToolCallsStreamFinished(array $data): bool + { + return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason']; + } + + /** + * @param array{ + * index: integer, + * message: array{ + * role: 'assistant', + * content: ?string, + * tool_calls: array{ + * id: string, + * type: 'function', + * function: array{ + * name: string, + * arguments: string + * }, + * }, + * refusal: ?mixed + * }, + * logprobs: string, + * finish_reason: 'stop'|'length'|'tool_calls'|'content_filter', + * } $choice + */ + private function convertChoice(array $choice): Choice + { + if ('tool_calls' === $choice['finish_reason']) { + return new Choice(toolCalls: array_map([$this, 'convertToolCall'], $choice['message']['tool_calls'])); + } + + if ('stop' === $choice['finish_reason']) { + return new Choice($choice['message']['content']); + } + + throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason'])); + } + + /** + * @param array{ + * id: string, + * type: 'function', + * function: array{ + * name: string, + * arguments: string + * } + * } $toolCall + */ + private function convertToolCall(array $toolCall): ToolCall + { + $arguments = json_decode((string) $toolCall['function']['arguments'], true, \JSON_THROW_ON_ERROR); + + return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments); + } +} diff --git a/src/platform/src/Bridge/Mistral/Mistral.php b/src/platform/src/Bridge/Mistral/Mistral.php new file mode 100644 index 000000000..62fec67e4 --- /dev/null +++ b/src/platform/src/Bridge/Mistral/Mistral.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Mistral; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +final class Mistral extends Model +{ + public const CODESTRAL = 'codestral-latest'; + public const CODESTRAL_MAMBA = 'open-codestral-mamba'; + public const MISTRAL_LARGE = 'mistral-large-latest'; + public const MISTRAL_SMALL = 'mistral-small-latest'; + public const MISTRAL_NEMO = 'open-mistral-nemo'; + public const MISTRAL_SABA = 'mistral-saba-latest'; + public const MINISTRAL_3B = 'mistral-3b-latest'; + public const MINISTRAL_8B = 'mistral-8b-latest'; + public const PIXSTRAL_LARGE = 'pixstral-large-latest'; + public const PIXSTRAL = 'pixstral-12b-latest'; + + /** + * @param array $options + */ + public function __construct( + string $name = self::MISTRAL_LARGE, + array $options = [], + ) { + $capabilities = [ + Capability::INPUT_MESSAGES, + Capability::OUTPUT_TEXT, + Capability::OUTPUT_STREAMING, + Capability::OUTPUT_STRUCTURED, + ]; + + if (\in_array($name, [self::PIXSTRAL, self::PIXSTRAL_LARGE, self::MISTRAL_SMALL], true)) { + $capabilities[] = Capability::INPUT_IMAGE; + } + + if (\in_array($name, [ + self::CODESTRAL, + self::MISTRAL_LARGE, + self::MISTRAL_SMALL, + self::MISTRAL_NEMO, + self::MINISTRAL_3B, + self::MINISTRAL_8B, + self::PIXSTRAL, + self::PIXSTRAL_LARGE, + ], true)) { + $capabilities[] = Capability::TOOL_CALLING; + } + + parent::__construct($name, $capabilities, $options); + } +} diff --git a/src/platform/src/Bridge/Mistral/PlatformFactory.php b/src/platform/src/Bridge/Mistral/PlatformFactory.php new file mode 100644 index 000000000..0b5ccabbf --- /dev/null +++ b/src/platform/src/Bridge/Mistral/PlatformFactory.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Mistral; + +use Symfony\AI\Platform\Bridge\Mistral\Contract\ToolNormalizer; +use Symfony\AI\Platform\Bridge\Mistral\Embeddings\ModelClient as EmbeddingsModelClient; +use Symfony\AI\Platform\Bridge\Mistral\Embeddings\ResponseConverter as EmbeddingsResponseConverter; +use Symfony\AI\Platform\Bridge\Mistral\Llm\ModelClient as MistralModelClient; +use Symfony\AI\Platform\Bridge\Mistral\Llm\ResponseConverter as MistralResponseConverter; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +final class PlatformFactory +{ + public static function create( + #[\SensitiveParameter] + string $apiKey, + ?HttpClientInterface $httpClient = null, + ): Platform { + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + return new Platform( + [new EmbeddingsModelClient($httpClient, $apiKey), new MistralModelClient($httpClient, $apiKey)], + [new EmbeddingsResponseConverter(), new MistralResponseConverter()], + Contract::create(new ToolNormalizer()), + ); + } +} diff --git a/src/platform/src/Bridge/Ollama/LlamaModelHandler.php b/src/platform/src/Bridge/Ollama/LlamaModelHandler.php new file mode 100644 index 000000000..56b0d05c9 --- /dev/null +++ b/src/platform/src/Bridge/Ollama/LlamaModelHandler.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Ollama; + +use Symfony\AI\Platform\Bridge\Meta\Llama; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\ResponseConverterInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final readonly class LlamaModelHandler implements ModelClientInterface, ResponseConverterInterface +{ + public function __construct( + private HttpClientInterface $httpClient, + private string $hostUrl, + ) { + } + + public function supports(Model $model): bool + { + return $model instanceof Llama; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + // Revert Ollama's default streaming behavior + $options['stream'] ??= false; + + return $this->httpClient->request('POST', \sprintf('%s/api/chat', $this->hostUrl), [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => array_merge($options, $payload), + ]); + } + + public function convert(ResponseInterface $response, array $options = []): LlmResponse + { + $data = $response->toArray(); + + if (!isset($data['message'])) { + throw new RuntimeException('Response does not contain message'); + } + + if (!isset($data['message']['content'])) { + throw new RuntimeException('Message does not contain content'); + } + + return new TextResponse($data['message']['content']); + } +} diff --git a/src/platform/src/Bridge/Ollama/PlatformFactory.php b/src/platform/src/Bridge/Ollama/PlatformFactory.php new file mode 100644 index 000000000..fdde43e38 --- /dev/null +++ b/src/platform/src/Bridge/Ollama/PlatformFactory.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Ollama; + +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +final class PlatformFactory +{ + public static function create( + string $hostUrl = 'http://localhost:11434', + ?HttpClientInterface $httpClient = null, + ): Platform { + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + $handler = new LlamaModelHandler($httpClient, $hostUrl); + + return new Platform([$handler], [$handler]); + } +} diff --git a/src/platform/src/Bridge/OpenAI/DallE.php b/src/platform/src/Bridge/OpenAI/DallE.php new file mode 100644 index 000000000..b58b3a1b6 --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/DallE.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +/** + * @author Denis Zunke + */ +class DallE extends Model +{ + public const DALL_E_2 = 'dall-e-2'; + public const DALL_E_3 = 'dall-e-3'; + + /** @param array $options The default options for the model usage */ + public function __construct(string $name = self::DALL_E_2, array $options = []) + { + $capabilities = [ + Capability::INPUT_TEXT, + Capability::OUTPUT_IMAGE, + ]; + + parent::__construct($name, $capabilities, $options); + } +} diff --git a/src/platform/src/Bridge/OpenAI/DallE/Base64Image.php b/src/platform/src/Bridge/OpenAI/DallE/Base64Image.php new file mode 100644 index 000000000..18d262853 --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/DallE/Base64Image.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI\DallE; + +use Webmozart\Assert\Assert; + +/** + * @author Denis Zunke + */ +final readonly class Base64Image +{ + public function __construct( + public string $encodedImage, + ) { + Assert::stringNotEmpty($encodedImage, 'The base64 encoded image generated must be given.'); + } +} diff --git a/src/platform/src/Bridge/OpenAI/DallE/ImageResponse.php b/src/platform/src/Bridge/OpenAI/DallE/ImageResponse.php new file mode 100644 index 000000000..8a7f16046 --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/DallE/ImageResponse.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI\DallE; + +use Symfony\AI\Platform\Response\BaseResponse; + +/** + * @author Denis Zunke + */ +class ImageResponse extends BaseResponse +{ + /** @var list */ + private readonly array $images; + + public function __construct( + public ?string $revisedPrompt = null, // Only string on Dall-E 3 usage + Base64Image|UrlImage ...$images, + ) { + $this->images = array_values($images); + } + + /** + * @return list + */ + public function getContent(): array + { + return $this->images; + } +} diff --git a/src/platform/src/Bridge/OpenAI/DallE/ModelClient.php b/src/platform/src/Bridge/OpenAI/DallE/ModelClient.php new file mode 100644 index 000000000..91abadcdb --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/DallE/ModelClient.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI\DallE; + +use Symfony\AI\Platform\Bridge\OpenAI\DallE; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface as PlatformResponseFactory; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\ResponseConverterInterface as PlatformResponseConverter; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; +use Webmozart\Assert\Assert; + +/** + * @see https://platform.openai.com/docs/api-reference/images/create + * + * @author Denis Zunke + */ +final readonly class ModelClient implements PlatformResponseFactory, PlatformResponseConverter +{ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] + private string $apiKey, + ) { + Assert::stringNotEmpty($apiKey, 'The API key must not be empty.'); + Assert::startsWith($apiKey, 'sk-', 'The API key must start with "sk-".'); + } + + public function supports(Model $model): bool + { + return $model instanceof DallE; + } + + public function request(Model $model, array|string $payload, array $options = []): HttpResponse + { + return $this->httpClient->request('POST', 'https://api.openai.com/v1/images/generations', [ + 'auth_bearer' => $this->apiKey, + 'json' => array_merge($options, [ + 'model' => $model->getName(), + 'prompt' => $payload, + ]), + ]); + } + + public function convert(HttpResponse $response, array $options = []): LlmResponse + { + $response = $response->toArray(); + if (!isset($response['data'][0])) { + throw new RuntimeException('No image generated.'); + } + + $images = []; + foreach ($response['data'] as $image) { + if ('url' === $options['response_format']) { + $images[] = new UrlImage($image['url']); + + continue; + } + + $images[] = new Base64Image($image['b64_json']); + } + + return new ImageResponse($image['revised_prompt'] ?? null, ...$images); + } +} diff --git a/src/platform/src/Bridge/OpenAI/DallE/UrlImage.php b/src/platform/src/Bridge/OpenAI/DallE/UrlImage.php new file mode 100644 index 000000000..016c13604 --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/DallE/UrlImage.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI\DallE; + +use Webmozart\Assert\Assert; + +/** + * @author Denis Zunke + */ +final readonly class UrlImage +{ + public function __construct( + public string $url, + ) { + Assert::stringNotEmpty($url, 'The image url must be given.'); + } +} diff --git a/src/platform/src/Bridge/OpenAI/Embeddings.php b/src/platform/src/Bridge/OpenAI/Embeddings.php new file mode 100644 index 000000000..907aa897e --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/Embeddings.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI; + +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +class Embeddings extends Model +{ + public const TEXT_ADA_002 = 'text-embedding-ada-002'; + public const TEXT_3_LARGE = 'text-embedding-3-large'; + public const TEXT_3_SMALL = 'text-embedding-3-small'; + + /** + * @param array $options + */ + public function __construct(string $name = self::TEXT_3_SMALL, array $options = []) + { + parent::__construct($name, [], $options); + } +} diff --git a/src/platform/src/Bridge/OpenAI/Embeddings/ModelClient.php b/src/platform/src/Bridge/OpenAI/Embeddings/ModelClient.php new file mode 100644 index 000000000..72866920b --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/Embeddings/ModelClient.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI\Embeddings; + +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface as PlatformResponseFactory; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Webmozart\Assert\Assert; + +/** + * @author Christopher Hertel + */ +final readonly class ModelClient implements PlatformResponseFactory +{ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] + private string $apiKey, + ) { + Assert::stringNotEmpty($apiKey, 'The API key must not be empty.'); + Assert::startsWith($apiKey, 'sk-', 'The API key must start with "sk-".'); + } + + public function supports(Model $model): bool + { + return $model instanceof Embeddings; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + return $this->httpClient->request('POST', 'https://api.openai.com/v1/embeddings', [ + 'auth_bearer' => $this->apiKey, + 'json' => array_merge($options, [ + 'model' => $model->getName(), + 'input' => $payload, + ]), + ]); + } +} diff --git a/src/platform/src/Bridge/OpenAI/Embeddings/ResponseConverter.php b/src/platform/src/Bridge/OpenAI/Embeddings/ResponseConverter.php new file mode 100644 index 000000000..caf471056 --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/Embeddings/ResponseConverter.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI\Embeddings; + +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\VectorResponse; +use Symfony\AI\Platform\ResponseConverterInterface as PlatformResponseConverter; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final class ResponseConverter implements PlatformResponseConverter +{ + public function supports(Model $model): bool + { + return $model instanceof Embeddings; + } + + public function convert(ResponseInterface $response, array $options = []): VectorResponse + { + $data = $response->toArray(); + + if (!isset($data['data'])) { + throw new RuntimeException('Response does not contain data'); + } + + return new VectorResponse( + ...array_map( + static fn (array $item): Vector => new Vector($item['embedding']), + $data['data'] + ), + ); + } +} diff --git a/src/platform/src/Bridge/OpenAI/GPT.php b/src/platform/src/Bridge/OpenAI/GPT.php new file mode 100644 index 000000000..1e36bd765 --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/GPT.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + * @author Oskar Stark + */ +class GPT extends Model +{ + public const GPT_35_TURBO = 'gpt-3.5-turbo'; + public const GPT_35_TURBO_INSTRUCT = 'gpt-3.5-turbo-instruct'; + public const GPT_4 = 'gpt-4'; + public const GPT_4_TURBO = 'gpt-4-turbo'; + public const GPT_4O = 'gpt-4o'; + public const GPT_4O_MINI = 'gpt-4o-mini'; + public const GPT_4O_AUDIO = 'gpt-4o-audio-preview'; + public const O1_MINI = 'o1-mini'; + public const O1_PREVIEW = 'o1-preview'; + public const O3_MINI = 'o3-mini'; + public const O3_MINI_HIGH = 'o3-mini-high'; + public const GPT_45_PREVIEW = 'gpt-4.5-preview'; + public const GPT_41 = 'gpt-4.1'; + public const GPT_41_MINI = 'gpt-4.1-mini'; + public const GPT_41_NANO = 'gpt-4.1-nano'; + + private const IMAGE_SUPPORTING = [ + self::GPT_4_TURBO, + self::GPT_4O, + self::GPT_4O_MINI, + self::O1_MINI, + self::O1_PREVIEW, + self::O3_MINI, + self::GPT_45_PREVIEW, + self::GPT_41, + self::GPT_41_MINI, + self::GPT_41_NANO, + ]; + + private const STRUCTURED_OUTPUT_SUPPORTING = [ + self::GPT_4O, + self::GPT_4O_MINI, + self::O3_MINI, + self::GPT_45_PREVIEW, + self::GPT_41, + self::GPT_41_MINI, + self::GPT_41_NANO, + ]; + + /** + * @param array $options The default options for the model usage + */ + public function __construct( + string $name = self::GPT_4O, + array $options = ['temperature' => 1.0], + ) { + $capabilities = [ + Capability::INPUT_MESSAGES, + Capability::OUTPUT_TEXT, + Capability::OUTPUT_STREAMING, + Capability::TOOL_CALLING, + ]; + + if (self::GPT_4O_AUDIO === $name) { + $capabilities[] = Capability::INPUT_AUDIO; + } + + if (\in_array($name, self::IMAGE_SUPPORTING, true)) { + $capabilities[] = Capability::INPUT_IMAGE; + } + + if (\in_array($name, self::STRUCTURED_OUTPUT_SUPPORTING, true)) { + $capabilities[] = Capability::OUTPUT_STRUCTURED; + } + + parent::__construct($name, $capabilities, $options); + } +} diff --git a/src/platform/src/Bridge/OpenAI/GPT/ModelClient.php b/src/platform/src/Bridge/OpenAI/GPT/ModelClient.php new file mode 100644 index 000000000..ef2c6ffea --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/GPT/ModelClient.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI\GPT; + +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface as PlatformResponseFactory; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Webmozart\Assert\Assert; + +/** + * @author Christopher Hertel + */ +final readonly class ModelClient implements PlatformResponseFactory +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + #[\SensitiveParameter] + private string $apiKey, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + Assert::stringNotEmpty($apiKey, 'The API key must not be empty.'); + Assert::startsWith($apiKey, 'sk-', 'The API key must start with "sk-".'); + } + + public function supports(Model $model): bool + { + return $model instanceof GPT; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + return $this->httpClient->request('POST', 'https://api.openai.com/v1/chat/completions', [ + 'auth_bearer' => $this->apiKey, + 'json' => array_merge($options, $payload), + ]); + } +} diff --git a/src/platform/src/Bridge/OpenAI/GPT/ResponseConverter.php b/src/platform/src/Bridge/OpenAI/GPT/ResponseConverter.php new file mode 100644 index 000000000..ca43dfb35 --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/GPT/ResponseConverter.php @@ -0,0 +1,204 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI\GPT; + +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +use Symfony\AI\Platform\Exception\ContentFilterException; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\Choice; +use Symfony\AI\Platform\Response\ChoiceResponse; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\StreamResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Response\ToolCallResponse; +use Symfony\AI\Platform\ResponseConverterInterface as PlatformResponseConverter; +use Symfony\Component\HttpClient\Chunk\ServerSentEvent; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Component\HttpClient\Exception\JsonException; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +/** + * @author Christopher Hertel + * @author Denis Zunke + */ +final class ResponseConverter implements PlatformResponseConverter +{ + public function supports(Model $model): bool + { + return $model instanceof GPT; + } + + public function convert(HttpResponse $response, array $options = []): LlmResponse + { + if ($options['stream'] ?? false) { + return new StreamResponse($this->convertStream($response)); + } + + try { + $data = $response->toArray(); + } catch (ClientExceptionInterface $e) { + $data = $response->toArray(throw: false); + + if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) { + throw new ContentFilterException(message: $data['error']['message'], previous: $e); + } + + throw $e; + } + + if (!isset($data['choices'])) { + throw new RuntimeException('Response does not contain choices'); + } + + /** @var Choice[] $choices */ + $choices = array_map($this->convertChoice(...), $data['choices']); + + if (1 !== \count($choices)) { + return new ChoiceResponse(...$choices); + } + + if ($choices[0]->hasToolCall()) { + return new ToolCallResponse(...$choices[0]->getToolCalls()); + } + + return new TextResponse($choices[0]->getContent()); + } + + private function convertStream(HttpResponse $response): \Generator + { + $toolCalls = []; + foreach ((new EventSourceHttpClient())->stream($response) as $chunk) { + if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) { + continue; + } + + try { + $data = $chunk->getArrayData(); + } catch (JsonException) { + // try catch only needed for Symfony 6.4 + continue; + } + + if ($this->streamIsToolCall($data)) { + $toolCalls = $this->convertStreamToToolCalls($toolCalls, $data); + } + + if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) { + yield new ToolCallResponse(...array_map($this->convertToolCall(...), $toolCalls)); + } + + if (!isset($data['choices'][0]['delta']['content'])) { + continue; + } + + yield $data['choices'][0]['delta']['content']; + } + } + + /** + * @param array $toolCalls + * @param array $data + * + * @return array + */ + private function convertStreamToToolCalls(array $toolCalls, array $data): array + { + if (!isset($data['choices'][0]['delta']['tool_calls'])) { + return $toolCalls; + } + + foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) { + if (isset($toolCall['id'])) { + // initialize tool call + $toolCalls[$i] = [ + 'id' => $toolCall['id'], + 'function' => $toolCall['function'], + ]; + continue; + } + + // add arguments delta to tool call + $toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments']; + } + + return $toolCalls; + } + + /** + * @param array $data + */ + private function streamIsToolCall(array $data): bool + { + return isset($data['choices'][0]['delta']['tool_calls']); + } + + /** + * @param array $data + */ + private function isToolCallsStreamFinished(array $data): bool + { + return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason']; + } + + /** + * @param array{ + * index: integer, + * message: array{ + * role: 'assistant', + * content: ?string, + * tool_calls: array{ + * id: string, + * type: 'function', + * function: array{ + * name: string, + * arguments: string + * }, + * }, + * refusal: ?mixed + * }, + * logprobs: string, + * finish_reason: 'stop'|'length'|'tool_calls'|'content_filter', + * } $choice + */ + private function convertChoice(array $choice): Choice + { + if ('tool_calls' === $choice['finish_reason']) { + return new Choice(toolCalls: array_map([$this, 'convertToolCall'], $choice['message']['tool_calls'])); + } + + if (\in_array($choice['finish_reason'], ['stop', 'length'], true)) { + return new Choice($choice['message']['content']); + } + + throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason'])); + } + + /** + * @param array{ + * id: string, + * type: 'function', + * function: array{ + * name: string, + * arguments: string + * } + * } $toolCall + */ + private function convertToolCall(array $toolCall): ToolCall + { + $arguments = json_decode($toolCall['function']['arguments'], true, \JSON_THROW_ON_ERROR); + + return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments); + } +} diff --git a/src/platform/src/Bridge/OpenAI/PlatformFactory.php b/src/platform/src/Bridge/OpenAI/PlatformFactory.php new file mode 100644 index 000000000..1cea3e090 --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/PlatformFactory.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI; + +use Symfony\AI\Platform\Bridge\OpenAI\DallE\ModelClient as DallEModelClient; +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings\ModelClient as EmbeddingsModelClient; +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings\ResponseConverter as EmbeddingsResponseConverter; +use Symfony\AI\Platform\Bridge\OpenAI\GPT\ModelClient as GPTModelClient; +use Symfony\AI\Platform\Bridge\OpenAI\GPT\ResponseConverter as GPTResponseConverter; +use Symfony\AI\Platform\Bridge\OpenAI\Whisper\AudioNormalizer; +use Symfony\AI\Platform\Bridge\OpenAI\Whisper\ModelClient as WhisperModelClient; +use Symfony\AI\Platform\Bridge\OpenAI\Whisper\ResponseConverter as WhisperResponseConverter; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +final readonly class PlatformFactory +{ + public static function create( + #[\SensitiveParameter] + string $apiKey, + ?HttpClientInterface $httpClient = null, + ): Platform { + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + $dallEModelClient = new DallEModelClient($httpClient, $apiKey); + + return new Platform( + [ + new GPTModelClient($httpClient, $apiKey), + new EmbeddingsModelClient($httpClient, $apiKey), + $dallEModelClient, + new WhisperModelClient($httpClient, $apiKey), + ], + [ + new GPTResponseConverter(), + new EmbeddingsResponseConverter(), + $dallEModelClient, + new WhisperResponseConverter(), + ], + Contract::create(new AudioNormalizer()), + ); + } +} diff --git a/src/platform/src/Bridge/OpenAI/TokenOutputProcessor.php b/src/platform/src/Bridge/OpenAI/TokenOutputProcessor.php new file mode 100644 index 000000000..5df6da37b --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/TokenOutputProcessor.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI; + +use Symfony\AI\Agent\Output; +use Symfony\AI\Agent\OutputProcessorInterface; +use Symfony\AI\Platform\Response\StreamResponse; + +/** + * @author Denis Zunke + */ +final class TokenOutputProcessor implements OutputProcessorInterface +{ + public function processOutput(Output $output): void + { + if ($output->response instanceof StreamResponse) { + // Streams have to be handled manually as the tokens are part of the streamed chunks + return; + } + + $rawResponse = $output->response->getRawResponse(); + if (null === $rawResponse) { + return; + } + + $metadata = $output->response->getMetadata(); + + $metadata->add( + 'remaining_tokens', + (int) $rawResponse->getHeaders(false)['x-ratelimit-remaining-tokens'][0], + ); + + $content = $rawResponse->toArray(false); + + if (!\array_key_exists('usage', $content)) { + return; + } + + $metadata->add('prompt_tokens', $content['usage']['prompt_tokens'] ?? null); + $metadata->add('completion_tokens', $content['usage']['completion_tokens'] ?? null); + $metadata->add('total_tokens', $content['usage']['total_tokens'] ?? null); + } +} diff --git a/src/platform/src/Bridge/OpenAI/Whisper.php b/src/platform/src/Bridge/OpenAI/Whisper.php new file mode 100644 index 000000000..3a70bd04d --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/Whisper.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +class Whisper extends Model +{ + public const WHISPER_1 = 'whisper-1'; + + /** + * @param array $options + */ + public function __construct(string $name = self::WHISPER_1, array $options = []) + { + $capabilities = [ + Capability::INPUT_AUDIO, + Capability::OUTPUT_TEXT, + ]; + + parent::__construct($name, $capabilities, $options); + } +} diff --git a/src/platform/src/Bridge/OpenAI/Whisper/AudioNormalizer.php b/src/platform/src/Bridge/OpenAI/Whisper/AudioNormalizer.php new file mode 100644 index 000000000..d33d8bc8c --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/Whisper/AudioNormalizer.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI\Whisper; + +use Symfony\AI\Platform\Bridge\OpenAI\Whisper; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Christopher Hertel + */ +final class AudioNormalizer implements NormalizerInterface +{ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof Audio && $context[Contract::CONTEXT_MODEL] instanceof Whisper; + } + + public function getSupportedTypes(?string $format): array + { + return [ + Audio::class => true, + ]; + } + + /** + * @param Audio $data + * + * @return array{model: string, file: resource} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'model' => $context[Contract::CONTEXT_MODEL]->getName(), + 'file' => $data->asResource(), + ]; + } +} diff --git a/src/platform/src/Bridge/OpenAI/Whisper/ModelClient.php b/src/platform/src/Bridge/OpenAI/Whisper/ModelClient.php new file mode 100644 index 000000000..fb6462e3b --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/Whisper/ModelClient.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI\Whisper; + +use Symfony\AI\Platform\Bridge\OpenAI\Whisper; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface as BaseModelClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Webmozart\Assert\Assert; + +/** + * @author Christopher Hertel + */ +final readonly class ModelClient implements BaseModelClient +{ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] + private string $apiKey, + ) { + Assert::stringNotEmpty($apiKey, 'The API key must not be empty.'); + } + + public function supports(Model $model): bool + { + return $model instanceof Whisper; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + return $this->httpClient->request('POST', 'https://api.openai.com/v1/audio/transcriptions', [ + 'auth_bearer' => $this->apiKey, + 'headers' => ['Content-Type' => 'multipart/form-data'], + 'body' => array_merge($options, $payload, ['model' => $model->getName()]), + ]); + } +} diff --git a/src/platform/src/Bridge/OpenAI/Whisper/ResponseConverter.php b/src/platform/src/Bridge/OpenAI/Whisper/ResponseConverter.php new file mode 100644 index 000000000..094421c2e --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/Whisper/ResponseConverter.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI\Whisper; + +use Symfony\AI\Platform\Bridge\OpenAI\Whisper; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\ResponseConverterInterface as BaseResponseConverter; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +/** + * @author Christopher Hertel + */ +final class ResponseConverter implements BaseResponseConverter +{ + public function supports(Model $model): bool + { + return $model instanceof Whisper; + } + + public function convert(HttpResponse $response, array $options = []): LlmResponse + { + $data = $response->toArray(); + + return new TextResponse($data['text']); + } +} diff --git a/src/platform/src/Bridge/OpenRouter/Client.php b/src/platform/src/Bridge/OpenRouter/Client.php new file mode 100644 index 000000000..65e5cd626 --- /dev/null +++ b/src/platform/src/Bridge/OpenRouter/Client.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenRouter; + +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\ResponseConverterInterface; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Webmozart\Assert\Assert; + +/** + * @author rglozman + */ +final readonly class Client implements ModelClientInterface, ResponseConverterInterface +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + Assert::stringNotEmpty($apiKey, 'The API key must not be empty.'); + Assert::startsWith($apiKey, 'sk-', 'The API key must start with "sk-".'); + } + + public function supports(Model $model): bool + { + return true; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + return $this->httpClient->request('POST', 'https://openrouter.ai/api/v1/chat/completions', [ + 'auth_bearer' => $this->apiKey, + 'json' => array_merge($options, $payload), + ]); + } + + public function convert(ResponseInterface $response, array $options = []): LlmResponse + { + dump($response->getContent(false)); + + $data = $response->toArray(); + + if (!isset($data['choices'][0]['message'])) { + throw new RuntimeException('Response does not contain message'); + } + + if (!isset($data['choices'][0]['message']['content'])) { + throw new RuntimeException('Message does not contain content'); + } + + return new TextResponse($data['choices'][0]['message']['content']); + } +} diff --git a/src/platform/src/Bridge/OpenRouter/PlatformFactory.php b/src/platform/src/Bridge/OpenRouter/PlatformFactory.php new file mode 100644 index 000000000..15b53da27 --- /dev/null +++ b/src/platform/src/Bridge/OpenRouter/PlatformFactory.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenRouter; + +use Symfony\AI\Platform\Bridge\Google\Contract\AssistantMessageNormalizer; +use Symfony\AI\Platform\Bridge\Google\Contract\MessageBagNormalizer; +use Symfony\AI\Platform\Bridge\Google\Contract\UserMessageNormalizer; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author rglozman + */ +final class PlatformFactory +{ + public static function create( + #[\SensitiveParameter] + string $apiKey, + ?HttpClientInterface $httpClient = null, + ): Platform { + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + $handler = new Client($httpClient, $apiKey); + + return new Platform([$handler], [$handler], Contract::create( + new AssistantMessageNormalizer(), + new MessageBagNormalizer(), + new UserMessageNormalizer(), + )); + } +} diff --git a/src/platform/src/Bridge/Replicate/Client.php b/src/platform/src/Bridge/Replicate/Client.php new file mode 100644 index 000000000..f3483352b --- /dev/null +++ b/src/platform/src/Bridge/Replicate/Client.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Replicate; + +use Symfony\Component\Clock\ClockInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final readonly class Client +{ + public function __construct( + private HttpClientInterface $httpClient, + private ClockInterface $clock, + #[\SensitiveParameter] private string $apiKey, + ) { + } + + /** + * @param string $model The model name on Replicate, e.g. "meta/meta-llama-3.1-405b-instruct" + * @param array $body + */ + public function request(string $model, string $endpoint, array $body): ResponseInterface + { + $url = \sprintf('https://api.replicate.com/v1/models/%s/%s', $model, $endpoint); + + $response = $this->httpClient->request('POST', $url, [ + 'headers' => ['Content-Type' => 'application/json'], + 'auth_bearer' => $this->apiKey, + 'json' => ['input' => $body], + ]); + $data = $response->toArray(); + + while (!\in_array($data['status'], ['succeeded', 'failed', 'canceled'], true)) { + $this->clock->sleep(1); // we need to wait until the prediction is ready + + $response = $this->getResponse($data['id']); + $data = $response->toArray(); + } + + return $response; + } + + private function getResponse(string $id): ResponseInterface + { + $url = \sprintf('https://api.replicate.com/v1/predictions/%s', $id); + + return $this->httpClient->request('GET', $url, [ + 'headers' => ['Content-Type' => 'application/json'], + 'auth_bearer' => $this->apiKey, + ]); + } +} diff --git a/src/platform/src/Bridge/Replicate/Contract/LlamaMessageBagNormalizer.php b/src/platform/src/Bridge/Replicate/Contract/LlamaMessageBagNormalizer.php new file mode 100644 index 000000000..f5e7376af --- /dev/null +++ b/src/platform/src/Bridge/Replicate/Contract/LlamaMessageBagNormalizer.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Replicate\Contract; + +use Symfony\AI\Platform\Bridge\Meta\Llama; +use Symfony\AI\Platform\Bridge\Meta\LlamaPromptConverter; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Message\SystemMessage; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +final class LlamaMessageBagNormalizer extends ModelContractNormalizer +{ + public function __construct( + private readonly LlamaPromptConverter $promptConverter = new LlamaPromptConverter(), + ) { + } + + protected function supportedDataClass(): string + { + return MessageBagInterface::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Llama; + } + + /** + * @param MessageBagInterface $data + * + * @return array{system: string, prompt: string} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'system' => $this->promptConverter->convertMessage($data->getSystemMessage() ?? new SystemMessage('')), + 'prompt' => $this->promptConverter->convertToPrompt($data->withoutSystemMessage()), + ]; + } +} diff --git a/src/platform/src/Bridge/Replicate/LlamaModelClient.php b/src/platform/src/Bridge/Replicate/LlamaModelClient.php new file mode 100644 index 000000000..18c36bef5 --- /dev/null +++ b/src/platform/src/Bridge/Replicate/LlamaModelClient.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Replicate; + +use Symfony\AI\Platform\Bridge\Meta\Llama; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Webmozart\Assert\Assert; + +/** + * @author Christopher Hertel + */ +final readonly class LlamaModelClient implements ModelClientInterface +{ + public function __construct( + private Client $client, + ) { + } + + public function supports(Model $model): bool + { + return $model instanceof Llama; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + Assert::isInstanceOf($model, Llama::class); + + return $this->client->request(\sprintf('meta/meta-%s', $model->getName()), 'predictions', $payload); + } +} diff --git a/src/platform/src/Bridge/Replicate/LlamaResponseConverter.php b/src/platform/src/Bridge/Replicate/LlamaResponseConverter.php new file mode 100644 index 000000000..65d1cf19e --- /dev/null +++ b/src/platform/src/Bridge/Replicate/LlamaResponseConverter.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Replicate; + +use Symfony\AI\Platform\Bridge\Meta\Llama; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\ResponseConverterInterface; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +/** + * @author Christopher Hertel + */ +final readonly class LlamaResponseConverter implements ResponseConverterInterface +{ + public function supports(Model $model): bool + { + return $model instanceof Llama; + } + + public function convert(HttpResponse $response, array $options = []): LlmResponse + { + $data = $response->toArray(); + + if (!isset($data['output'])) { + throw new RuntimeException('Response does not contain output'); + } + + return new TextResponse(implode('', $data['output'])); + } +} diff --git a/src/platform/src/Bridge/Replicate/PlatformFactory.php b/src/platform/src/Bridge/Replicate/PlatformFactory.php new file mode 100644 index 000000000..51e9d7a86 --- /dev/null +++ b/src/platform/src/Bridge/Replicate/PlatformFactory.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Replicate; + +use Symfony\AI\Platform\Bridge\Replicate\Contract\LlamaMessageBagNormalizer; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Platform; +use Symfony\Component\Clock\Clock; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +final class PlatformFactory +{ + public static function create( + #[\SensitiveParameter] + string $apiKey, + ?HttpClientInterface $httpClient = null, + ): Platform { + return new Platform( + [new LlamaModelClient(new Client($httpClient ?? HttpClient::create(), new Clock(), $apiKey))], + [new LlamaResponseConverter()], + Contract::create(new LlamaMessageBagNormalizer()), + ); + } +} diff --git a/src/platform/src/Bridge/TransformersPHP/Platform.php b/src/platform/src/Bridge/TransformersPHP/Platform.php new file mode 100644 index 000000000..166afc9aa --- /dev/null +++ b/src/platform/src/Bridge/TransformersPHP/Platform.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\TransformersPHP; + +use Codewithkyrian\Transformers\Pipelines\Task; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Response\ObjectResponse; +use Symfony\AI\Platform\Response\ResponseInterface; +use Symfony\AI\Platform\Response\TextResponse; + +use function Codewithkyrian\Transformers\Pipelines\pipeline; + +/** + * @author Christopher Hertel + */ +final class Platform implements PlatformInterface +{ + public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface + { + if (null === $task = $options['task'] ?? null) { + throw new InvalidArgumentException('The task option is required.'); + } + + $pipeline = pipeline( + $options['task'], + $model->getName(), + $options['quantized'] ?? true, + $options['config'] ?? null, + $options['cacheDir'] ?? null, + $options['revision'] ?? 'main', + $options['modelFilename'] ?? null, + ); + + $data = $pipeline($input); + + return match ($task) { + Task::Text2TextGeneration => new TextResponse($data[0]['generated_text']), + default => new ObjectResponse($data), + }; + } +} diff --git a/src/platform/src/Bridge/TransformersPHP/PlatformFactory.php b/src/platform/src/Bridge/TransformersPHP/PlatformFactory.php new file mode 100644 index 000000000..8dbb789a9 --- /dev/null +++ b/src/platform/src/Bridge/TransformersPHP/PlatformFactory.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\TransformersPHP; + +use Codewithkyrian\Transformers\Transformers; +use Symfony\AI\Platform\Exception\RuntimeException; + +/** + * @author Christopher Hertel + */ +final readonly class PlatformFactory +{ + public static function create(): Platform + { + if (!class_exists(Transformers::class)) { + throw new RuntimeException('TransformersPHP is not installed. Please install it using "composer require codewithkyrian/transformers".'); + } + + return new Platform(); + } +} diff --git a/src/platform/src/Bridge/Voyage/ModelHandler.php b/src/platform/src/Bridge/Voyage/ModelHandler.php new file mode 100644 index 000000000..e38e92047 --- /dev/null +++ b/src/platform/src/Bridge/Voyage/ModelHandler.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Voyage; + +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\VectorResponse; +use Symfony\AI\Platform\ResponseConverterInterface; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final readonly class ModelHandler implements ModelClientInterface, ResponseConverterInterface +{ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + ) { + } + + public function supports(Model $model): bool + { + return $model instanceof Voyage; + } + + public function request(Model $model, object|string|array $payload, array $options = []): ResponseInterface + { + return $this->httpClient->request('POST', 'https://api.voyageai.com/v1/embeddings', [ + 'auth_bearer' => $this->apiKey, + 'json' => [ + 'model' => $model->getName(), + 'input' => $payload, + ], + ]); + } + + public function convert(ResponseInterface $response, array $options = []): LlmResponse + { + $response = $response->toArray(); + + if (!isset($response['data'])) { + throw new RuntimeException('Response does not contain embedding data'); + } + + $vectors = array_map(fn (array $data) => new Vector($data['embedding']), $response['data']); + + return new VectorResponse($vectors[0]); + } +} diff --git a/src/platform/src/Bridge/Voyage/PlatformFactory.php b/src/platform/src/Bridge/Voyage/PlatformFactory.php new file mode 100644 index 000000000..8497a9c02 --- /dev/null +++ b/src/platform/src/Bridge/Voyage/PlatformFactory.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Voyage; + +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +final class PlatformFactory +{ + public static function create( + #[\SensitiveParameter] + string $apiKey, + ?HttpClientInterface $httpClient = null, + ): Platform { + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + $handler = new ModelHandler($httpClient, $apiKey); + + return new Platform([$handler], [$handler]); + } +} diff --git a/src/platform/src/Bridge/Voyage/Voyage.php b/src/platform/src/Bridge/Voyage/Voyage.php new file mode 100644 index 000000000..955748499 --- /dev/null +++ b/src/platform/src/Bridge/Voyage/Voyage.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Voyage; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + */ +class Voyage extends Model +{ + public const V3 = 'voyage-3'; + public const V3_LITE = 'voyage-3-lite'; + public const FINANCE_2 = 'voyage-finance-2'; + public const MULTILINGUAL_2 = 'voyage-multilingual-2'; + public const LAW_2 = 'voyage-law-2'; + public const CODE_2 = 'voyage-code-2'; + + /** + * @param array $options + */ + public function __construct(string $name = self::V3, array $options = []) + { + parent::__construct($name, [Capability::INPUT_MULTIPLE], $options); + } +} diff --git a/src/platform/src/Capability.php b/src/platform/src/Capability.php new file mode 100644 index 000000000..78a7c1b02 --- /dev/null +++ b/src/platform/src/Capability.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform; + +/** + * @author Christopher Hertel + */ +class Capability +{ + // INPUT + public const INPUT_AUDIO = 'input-audio'; + public const INPUT_IMAGE = 'input-image'; + public const INPUT_MESSAGES = 'input-messages'; + public const INPUT_MULTIPLE = 'input-multiple'; + public const INPUT_PDF = 'input-pdf'; + public const INPUT_TEXT = 'input-text'; + + // OUTPUT + public const OUTPUT_AUDIO = 'output-audio'; + public const OUTPUT_IMAGE = 'output-image'; + public const OUTPUT_STREAMING = 'output-streaming'; + public const OUTPUT_STRUCTURED = 'output-structured'; + public const OUTPUT_TEXT = 'output-text'; + + // FUNCTIONALITY + public const TOOL_CALLING = 'tool-calling'; +} diff --git a/src/platform/src/Contract.php b/src/platform/src/Contract.php new file mode 100644 index 000000000..76662f10e --- /dev/null +++ b/src/platform/src/Contract.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform; + +use Symfony\AI\Platform\Contract\Normalizer\Message\AssistantMessageNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Message\Content\AudioNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Message\Content\ImageNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Message\Content\ImageUrlNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Message\Content\TextNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Message\MessageBagNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Message\SystemMessageNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Message\ToolCallMessageNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Message\UserMessageNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Response\ToolCallNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\ToolNormalizer; +use Symfony\AI\Platform\Tool\Tool; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Serializer; + +/** + * @author Christopher Hertel + */ +final readonly class Contract +{ + public const CONTEXT_MODEL = 'model'; + + public function __construct( + private NormalizerInterface $normalizer, + ) { + } + + public static function create(NormalizerInterface ...$normalizer): self + { + // Messages + $normalizer[] = new MessageBagNormalizer(); + $normalizer[] = new AssistantMessageNormalizer(); + $normalizer[] = new SystemMessageNormalizer(); + $normalizer[] = new ToolCallMessageNormalizer(); + $normalizer[] = new UserMessageNormalizer(); + + // Message Content + $normalizer[] = new AudioNormalizer(); + $normalizer[] = new ImageNormalizer(); + $normalizer[] = new ImageUrlNormalizer(); + $normalizer[] = new TextNormalizer(); + + // Options + $normalizer[] = new ToolNormalizer(); + + // Response + $normalizer[] = new ToolCallNormalizer(); + + return new self( + new Serializer($normalizer), + ); + } + + /** + * @param object|array|string $input + * + * @return array|string + */ + public function createRequestPayload(Model $model, object|array|string $input): string|array + { + return $this->normalizer->normalize($input, context: [self::CONTEXT_MODEL => $model]); + } + + /** + * @param Tool[] $tools + * + * @return array + */ + public function createToolOption(array $tools, Model $model): array + { + return $this->normalizer->normalize($tools, context: [self::CONTEXT_MODEL => $model]); + } +} diff --git a/src/platform/src/Contract/JsonSchema/Attribute/With.php b/src/platform/src/Contract/JsonSchema/Attribute/With.php new file mode 100644 index 000000000..02f6cb76a --- /dev/null +++ b/src/platform/src/Contract/JsonSchema/Attribute/With.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Contract\JsonSchema\Attribute; + +use Webmozart\Assert\Assert; + +/** + * @author Oskar Stark + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +final readonly class With +{ + /** + * @param list|null $enum + * @param string|int|string[]|null $const + */ + public function __construct( + // can be used by many types + public ?array $enum = null, + public string|int|array|null $const = null, + + // string + public ?string $pattern = null, + public ?int $minLength = null, + public ?int $maxLength = null, + + // integer + public ?int $minimum = null, + public ?int $maximum = null, + public ?int $multipleOf = null, + public ?int $exclusiveMinimum = null, + public ?int $exclusiveMaximum = null, + + // array + public ?int $minItems = null, + public ?int $maxItems = null, + public ?bool $uniqueItems = null, + public ?int $minContains = null, + public ?int $maxContains = null, + + // object + public ?bool $required = null, + public ?int $minProperties = null, + public ?int $maxProperties = null, + public ?bool $dependentRequired = null, + ) { + if (\is_array($enum)) { + Assert::allString($enum); + } + + if (\is_string($const)) { + Assert::stringNotEmpty(trim($const)); + } + + if (\is_string($pattern)) { + Assert::stringNotEmpty(trim($pattern)); + } + + if (\is_int($minLength)) { + Assert::greaterThanEq($minLength, 0); + + if (\is_int($maxLength)) { + Assert::greaterThanEq($maxLength, $minLength); + } + } + + if (\is_int($maxLength)) { + Assert::greaterThanEq($maxLength, 0); + } + + if (\is_int($minimum)) { + Assert::greaterThanEq($minimum, 0); + + if (\is_int($maximum)) { + Assert::greaterThanEq($maximum, $minimum); + } + } + + if (\is_int($maximum)) { + Assert::greaterThanEq($maximum, 0); + } + + if (\is_int($multipleOf)) { + Assert::greaterThanEq($multipleOf, 0); + } + + if (\is_int($exclusiveMinimum)) { + Assert::greaterThanEq($exclusiveMinimum, 0); + + if (\is_int($exclusiveMaximum)) { + Assert::greaterThanEq($exclusiveMaximum, $exclusiveMinimum); + } + } + + if (\is_int($exclusiveMaximum)) { + Assert::greaterThanEq($exclusiveMaximum, 0); + } + + if (\is_int($minItems)) { + Assert::greaterThanEq($minItems, 0); + + if (\is_int($maxItems)) { + Assert::greaterThanEq($maxItems, $minItems); + } + } + + if (\is_int($maxItems)) { + Assert::greaterThanEq($maxItems, 0); + } + + if (\is_bool($uniqueItems)) { + Assert::true($uniqueItems); + } + + if (\is_int($minContains)) { + Assert::greaterThanEq($minContains, 0); + + if (\is_int($maxContains)) { + Assert::greaterThanEq($maxContains, $minContains); + } + } + + if (\is_int($maxContains)) { + Assert::greaterThanEq($maxContains, 0); + } + + if (\is_int($minProperties)) { + Assert::greaterThanEq($minProperties, 0); + + if (\is_int($maxProperties)) { + Assert::greaterThanEq($maxProperties, $minProperties); + } + } + + if (\is_int($maxProperties)) { + Assert::greaterThanEq($maxProperties, 0); + } + } +} diff --git a/src/platform/src/Contract/JsonSchema/DescriptionParser.php b/src/platform/src/Contract/JsonSchema/DescriptionParser.php new file mode 100644 index 000000000..cdb8a3afb --- /dev/null +++ b/src/platform/src/Contract/JsonSchema/DescriptionParser.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Contract\JsonSchema; + +/** + * @author Christopher Hertel + */ +final readonly class DescriptionParser +{ + public function getDescription(\ReflectionProperty|\ReflectionParameter $reflector): string + { + if ($reflector instanceof \ReflectionProperty) { + return $this->fromProperty($reflector); + } + + return $this->fromParameter($reflector); + } + + private function fromProperty(\ReflectionProperty $property): string + { + $comment = $property->getDocComment(); + + if (\is_string($comment) && preg_match('/@var\s+[a-zA-Z\\\\]+\s+((.*)(?=\*)|.*)/', $comment, $matches)) { + return trim($matches[1]); + } + + $class = $property->getDeclaringClass(); + if ($class->hasMethod('__construct')) { + return $this->fromParameter( + new \ReflectionParameter([$class->getName(), '__construct'], $property->getName()) + ); + } + + return ''; + } + + private function fromParameter(\ReflectionParameter $parameter): string + { + $comment = $parameter->getDeclaringFunction()->getDocComment(); + if (!$comment) { + return ''; + } + + if (preg_match('/@param\s+\S+\s+\$'.preg_quote($parameter->getName(), '/').'\s+((.*)(?=\*)|.*)/', $comment, $matches)) { + return trim($matches[1]); + } + + return ''; + } +} diff --git a/src/platform/src/Contract/JsonSchema/Factory.php b/src/platform/src/Contract/JsonSchema/Factory.php new file mode 100644 index 000000000..cbfc66b92 --- /dev/null +++ b/src/platform/src/Contract/JsonSchema/Factory.php @@ -0,0 +1,185 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Contract\JsonSchema; + +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\TypeIdentifier; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; + +/** + * @phpstan-type JsonSchema array{ + * type: 'object', + * properties: array, + * const?: string|int|list, + * pattern?: string, + * minLength?: int, + * maxLength?: int, + * minimum?: int, + * maximum?: int, + * multipleOf?: int, + * exclusiveMinimum?: int, + * exclusiveMaximum?: int, + * minItems?: int, + * maxItems?: int, + * uniqueItems?: bool, + * minContains?: int, + * maxContains?: int, + * required?: bool, + * minProperties?: int, + * maxProperties?: int, + * dependentRequired?: bool, + * }>, + * required: list, + * additionalProperties: false, + * } + * + * @author Christopher Hertel + */ +final readonly class Factory +{ + private TypeResolver $typeResolver; + + public function __construct( + private DescriptionParser $descriptionParser = new DescriptionParser(), + ?TypeResolver $typeResolver = null, + ) { + $this->typeResolver = $typeResolver ?? TypeResolver::create(); + } + + /** + * @return JsonSchema|null + */ + public function buildParameters(string $className, string $methodName): ?array + { + $reflection = new \ReflectionMethod($className, $methodName); + + return $this->convertTypes($reflection->getParameters()); + } + + /** + * @return JsonSchema|null + */ + public function buildProperties(string $className): ?array + { + $reflection = new \ReflectionClass($className); + + return $this->convertTypes($reflection->getProperties()); + } + + /** + * @param list<\ReflectionProperty|\ReflectionParameter> $elements + * + * @return JsonSchema|null + */ + private function convertTypes(array $elements): ?array + { + if (0 === \count($elements)) { + return null; + } + + $result = [ + 'type' => 'object', + 'properties' => [], + 'required' => [], + 'additionalProperties' => false, + ]; + + foreach ($elements as $element) { + $name = $element->getName(); + $type = $this->typeResolver->resolve($element); + $schema = $this->getTypeSchema($type); + + if ($type->isNullable()) { + $schema['type'] = [$schema['type'], 'null']; + } elseif (!($element instanceof \ReflectionParameter && $element->isOptional())) { + $result['required'][] = $name; + } + + $description = $this->descriptionParser->getDescription($element); + if ('' !== $description) { + $schema['description'] = $description; + } + + // Check for ToolParameter attributes + $attributes = $element->getAttributes(With::class); + if (\count($attributes) > 0) { + $attributeState = array_filter((array) $attributes[0]->newInstance(), fn ($value) => null !== $value); + $schema = array_merge($schema, $attributeState); + } + + $result['properties'][$name] = $schema; + } + + return $result; + } + + /** + * @return array + */ + private function getTypeSchema(Type $type): array + { + switch (true) { + case $type->isIdentifiedBy(TypeIdentifier::INT): + return ['type' => 'integer']; + + case $type->isIdentifiedBy(TypeIdentifier::FLOAT): + return ['type' => 'number']; + + case $type->isIdentifiedBy(TypeIdentifier::BOOL): + return ['type' => 'boolean']; + + case $type->isIdentifiedBy(TypeIdentifier::ARRAY): + \assert($type instanceof CollectionType); + $collectionValueType = $type->getCollectionValueType(); + + if ($collectionValueType->isIdentifiedBy(TypeIdentifier::OBJECT)) { + \assert($collectionValueType instanceof ObjectType); + + return [ + 'type' => 'array', + 'items' => $this->buildProperties($collectionValueType->getClassName()), + ]; + } + + return [ + 'type' => 'array', + 'items' => $this->getTypeSchema($collectionValueType), + ]; + + case $type->isIdentifiedBy(TypeIdentifier::OBJECT): + if ($type instanceof BuiltinType) { + throw new InvalidArgumentException('Cannot build schema from plain object type.'); + } + \assert($type instanceof ObjectType); + if (\in_array($type->getClassName(), ['DateTime', 'DateTimeImmutable', 'DateTimeInterface'], true)) { + return ['type' => 'string', 'format' => 'date-time']; + } else { + // Recursively build the schema for an object type + return $this->buildProperties($type->getClassName()) ?? ['type' => 'object']; + } + + // no break + case $type->isIdentifiedBy(TypeIdentifier::STRING): + default: + // Fallback to string for any unhandled types + return ['type' => 'string']; + } + } +} diff --git a/src/platform/src/Contract/Normalizer/Message/AssistantMessageNormalizer.php b/src/platform/src/Contract/Normalizer/Message/AssistantMessageNormalizer.php new file mode 100644 index 000000000..c33703b41 --- /dev/null +++ b/src/platform/src/Contract/Normalizer/Message/AssistantMessageNormalizer.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Contract\Normalizer\Message; + +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Christopher Hertel + */ +final class AssistantMessageNormalizer implements NormalizerInterface, NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof AssistantMessage; + } + + public function getSupportedTypes(?string $format): array + { + return [ + AssistantMessage::class => true, + ]; + } + + /** + * @param AssistantMessage $data + * + * @return array{role: 'assistant', content: string} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $array = [ + 'role' => $data->getRole()->value, + ]; + + if (null !== $data->content) { + $array['content'] = $data->content; + } + + if ($data->hasToolCalls()) { + $array['tool_calls'] = $this->normalizer->normalize($data->toolCalls, $format, $context); + } + + return $array; + } +} diff --git a/src/platform/src/Contract/Normalizer/Message/Content/AudioNormalizer.php b/src/platform/src/Contract/Normalizer/Message/Content/AudioNormalizer.php new file mode 100644 index 000000000..3fcf220ec --- /dev/null +++ b/src/platform/src/Contract/Normalizer/Message/Content/AudioNormalizer.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Contract\Normalizer\Message\Content; + +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Christopher Hertel + */ +final class AudioNormalizer implements NormalizerInterface +{ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof Audio; + } + + public function getSupportedTypes(?string $format): array + { + return [ + Audio::class => true, + ]; + } + + /** + * @param Audio $data + * + * @return array{type: 'input_audio', input_audio: array{ + * data: string, + * format: 'mp3'|'wav'|string, + * }} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'type' => 'input_audio', + 'input_audio' => [ + 'data' => $data->asBase64(), + 'format' => match ($data->getFormat()) { + 'audio/mpeg' => 'mp3', + 'audio/wav' => 'wav', + default => $data->getFormat(), + }, + ], + ]; + } +} diff --git a/src/platform/src/Contract/Normalizer/Message/Content/ImageNormalizer.php b/src/platform/src/Contract/Normalizer/Message/Content/ImageNormalizer.php new file mode 100644 index 000000000..0e32e317a --- /dev/null +++ b/src/platform/src/Contract/Normalizer/Message/Content/ImageNormalizer.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Contract\Normalizer\Message\Content; + +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Christopher Hertel + */ +final class ImageNormalizer implements NormalizerInterface +{ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof Image; + } + + public function getSupportedTypes(?string $format): array + { + return [ + Image::class => true, + ]; + } + + /** + * @param Image $data + * + * @return array{type: 'image_url', image_url: array{url: string}} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'type' => 'image_url', + 'image_url' => ['url' => $data->asDataUrl()], + ]; + } +} diff --git a/src/platform/src/Contract/Normalizer/Message/Content/ImageUrlNormalizer.php b/src/platform/src/Contract/Normalizer/Message/Content/ImageUrlNormalizer.php new file mode 100644 index 000000000..6b61cd7da --- /dev/null +++ b/src/platform/src/Contract/Normalizer/Message/Content/ImageUrlNormalizer.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Contract\Normalizer\Message\Content; + +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Christopher Hertel + */ +final class ImageUrlNormalizer implements NormalizerInterface +{ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof ImageUrl; + } + + public function getSupportedTypes(?string $format): array + { + return [ + ImageUrl::class => true, + ]; + } + + /** + * @param ImageUrl $data + * + * @return array{type: 'image_url', image_url: array{url: string}} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'type' => 'image_url', + 'image_url' => ['url' => $data->url], + ]; + } +} diff --git a/src/platform/src/Contract/Normalizer/Message/Content/TextNormalizer.php b/src/platform/src/Contract/Normalizer/Message/Content/TextNormalizer.php new file mode 100644 index 000000000..72860679d --- /dev/null +++ b/src/platform/src/Contract/Normalizer/Message/Content/TextNormalizer.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Contract\Normalizer\Message\Content; + +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Christopher Hertel + */ +final class TextNormalizer implements NormalizerInterface +{ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof Text; + } + + public function getSupportedTypes(?string $format): array + { + return [ + Text::class => true, + ]; + } + + /** + * @param Text $data + * + * @return array{type: 'text', text: string} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return ['type' => 'text', 'text' => $data->text]; + } +} diff --git a/src/platform/src/Contract/Normalizer/Message/MessageBagNormalizer.php b/src/platform/src/Contract/Normalizer/Message/MessageBagNormalizer.php new file mode 100644 index 000000000..7d4229e45 --- /dev/null +++ b/src/platform/src/Contract/Normalizer/Message/MessageBagNormalizer.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Contract\Normalizer\Message; + +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Model; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Christopher Hertel + */ +final class MessageBagNormalizer implements NormalizerInterface, NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof MessageBagInterface; + } + + public function getSupportedTypes(?string $format): array + { + return [ + MessageBagInterface::class => true, + ]; + } + + /** + * @param MessageBagInterface $data + * + * @return array{ + * messages: array, + * model?: string, + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $array = [ + 'messages' => $this->normalizer->normalize($data->getMessages(), $format, $context), + ]; + + if (isset($context[Contract::CONTEXT_MODEL]) && $context[Contract::CONTEXT_MODEL] instanceof Model) { + $array['model'] = $context[Contract::CONTEXT_MODEL]->getName(); + } + + return $array; + } +} diff --git a/src/platform/src/Contract/Normalizer/Message/SystemMessageNormalizer.php b/src/platform/src/Contract/Normalizer/Message/SystemMessageNormalizer.php new file mode 100644 index 000000000..4985b87d1 --- /dev/null +++ b/src/platform/src/Contract/Normalizer/Message/SystemMessageNormalizer.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Contract\Normalizer\Message; + +use Symfony\AI\Platform\Message\SystemMessage; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Christopher Hertel + */ +final class SystemMessageNormalizer implements NormalizerInterface +{ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof SystemMessage; + } + + public function getSupportedTypes(?string $format): array + { + return [ + SystemMessage::class => true, + ]; + } + + /** + * @param SystemMessage $data + * + * @return array{role: 'system', content: string} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'role' => $data->getRole()->value, + 'content' => $data->content, + ]; + } +} diff --git a/src/platform/src/Contract/Normalizer/Message/ToolCallMessageNormalizer.php b/src/platform/src/Contract/Normalizer/Message/ToolCallMessageNormalizer.php new file mode 100644 index 000000000..9661717f8 --- /dev/null +++ b/src/platform/src/Contract/Normalizer/Message/ToolCallMessageNormalizer.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Contract\Normalizer\Message; + +use Symfony\AI\Platform\Message\ToolCallMessage; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Christopher Hertel + */ +final class ToolCallMessageNormalizer implements NormalizerInterface, NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof ToolCallMessage; + } + + public function getSupportedTypes(?string $format): array + { + return [ + ToolCallMessage::class => true, + ]; + } + + /** + * @return array{ + * role: 'tool', + * content: string, + * tool_call_id: string, + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'role' => $data->getRole()->value, + 'content' => $this->normalizer->normalize($data->content, $format, $context), + 'tool_call_id' => $data->toolCall->id, + ]; + } +} diff --git a/src/platform/src/Contract/Normalizer/Message/UserMessageNormalizer.php b/src/platform/src/Contract/Normalizer/Message/UserMessageNormalizer.php new file mode 100644 index 000000000..af7794f5c --- /dev/null +++ b/src/platform/src/Contract/Normalizer/Message/UserMessageNormalizer.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Contract\Normalizer\Message; + +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Christopher Hertel + */ +final class UserMessageNormalizer implements NormalizerInterface, NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof UserMessage; + } + + public function getSupportedTypes(?string $format): array + { + return [ + UserMessage::class => true, + ]; + } + + /** + * @param UserMessage $data + * + * @return array{role: 'assistant', content: string} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $array = ['role' => $data->getRole()->value]; + + if (1 === \count($data->content) && $data->content[0] instanceof Text) { + $array['content'] = $data->content[0]->text; + + return $array; + } + + $array['content'] = $this->normalizer->normalize($data->content, $format, $context); + + return $array; + } +} diff --git a/src/platform/src/Contract/Normalizer/ModelContractNormalizer.php b/src/platform/src/Contract/Normalizer/ModelContractNormalizer.php new file mode 100644 index 000000000..c130d362c --- /dev/null +++ b/src/platform/src/Contract/Normalizer/ModelContractNormalizer.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Contract\Normalizer; + +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Model; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Christopher Hertel + */ +abstract class ModelContractNormalizer implements NormalizerInterface +{ + /** + * @return class-string + */ + abstract protected function supportedDataClass(): string; + + abstract protected function supportsModel(Model $model): bool; + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + if (!is_a($data, $this->supportedDataClass(), true)) { + return false; + } + + if (isset($context[Contract::CONTEXT_MODEL]) && $context[Contract::CONTEXT_MODEL] instanceof Model) { + return $this->supportsModel($context[Contract::CONTEXT_MODEL]); + } + + return false; + } + + public function getSupportedTypes(?string $format): array + { + return [ + $this->supportedDataClass() => true, + ]; + } +} diff --git a/src/platform/src/Contract/Normalizer/Response/ToolCallNormalizer.php b/src/platform/src/Contract/Normalizer/Response/ToolCallNormalizer.php new file mode 100644 index 000000000..1771e3233 --- /dev/null +++ b/src/platform/src/Contract/Normalizer/Response/ToolCallNormalizer.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Contract\Normalizer\Response; + +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Christopher Hertel + */ +final class ToolCallNormalizer implements NormalizerInterface +{ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof ToolCall; + } + + public function getSupportedTypes(?string $format): array + { + return [ + ToolCall::class => true, + ]; + } + + /** + * @param ToolCall $data + * + * @return array{ + * id: string, + * type: 'function', + * function: array{ + * name: string, + * arguments: string + * } + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'id' => $data->id, + 'type' => 'function', + 'function' => [ + 'name' => $data->name, + 'arguments' => json_encode($data->arguments), + ], + ]; + } +} diff --git a/src/platform/src/Contract/Normalizer/ToolNormalizer.php b/src/platform/src/Contract/Normalizer/ToolNormalizer.php new file mode 100644 index 000000000..04d3e0624 --- /dev/null +++ b/src/platform/src/Contract/Normalizer/ToolNormalizer.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Contract\Normalizer; + +use Symfony\AI\Platform\Contract\JsonSchema\Factory; +use Symfony\AI\Platform\Tool\Tool; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @phpstan-import-type JsonSchema from Factory + * + * @author Christopher Hertel + */ +class ToolNormalizer implements NormalizerInterface +{ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof Tool; + } + + public function getSupportedTypes(?string $format): array + { + return [ + Tool::class => true, + ]; + } + + /** + * @param Tool $data + * + * @return array{ + * type: 'function', + * function: array{ + * name: string, + * description: string, + * parameters?: JsonSchema + * } + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $function = [ + 'name' => $data->name, + 'description' => $data->description, + ]; + + if (isset($data->parameters)) { + $function['parameters'] = $data->parameters; + } + + return [ + 'type' => 'function', + 'function' => $function, + ]; + } +} diff --git a/src/platform/src/Exception/ContentFilterException.php b/src/platform/src/Exception/ContentFilterException.php new file mode 100644 index 000000000..95dc56200 --- /dev/null +++ b/src/platform/src/Exception/ContentFilterException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Exception; + +/** + * @author Oskar Stark + */ +class ContentFilterException extends InvalidArgumentException +{ +} diff --git a/src/platform/src/Exception/ExceptionInterface.php b/src/platform/src/Exception/ExceptionInterface.php new file mode 100644 index 000000000..c5a865ae6 --- /dev/null +++ b/src/platform/src/Exception/ExceptionInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Exception; + +/** + * @author Oskar Stark + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/platform/src/Exception/InvalidArgumentException.php b/src/platform/src/Exception/InvalidArgumentException.php new file mode 100644 index 000000000..ef9bcb6ce --- /dev/null +++ b/src/platform/src/Exception/InvalidArgumentException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Exception; + +/** + * @author Oskar Stark + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/platform/src/Exception/RuntimeException.php b/src/platform/src/Exception/RuntimeException.php new file mode 100644 index 000000000..b99a1c956 --- /dev/null +++ b/src/platform/src/Exception/RuntimeException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Exception; + +/** + * @author Oskar Stark + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/platform/src/Message/AssistantMessage.php b/src/platform/src/Message/AssistantMessage.php new file mode 100644 index 000000000..adf314b06 --- /dev/null +++ b/src/platform/src/Message/AssistantMessage.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message; + +use Symfony\AI\Platform\Response\ToolCall; + +/** + * @author Denis Zunke + */ +final readonly class AssistantMessage implements MessageInterface +{ + /** + * @param ?ToolCall[] $toolCalls + */ + public function __construct( + public ?string $content = null, + public ?array $toolCalls = null, + ) { + } + + public function getRole(): Role + { + return Role::Assistant; + } + + public function hasToolCalls(): bool + { + return null !== $this->toolCalls && 0 !== \count($this->toolCalls); + } +} diff --git a/src/platform/src/Message/Content/Audio.php b/src/platform/src/Message/Content/Audio.php new file mode 100644 index 000000000..b7a5800c6 --- /dev/null +++ b/src/platform/src/Message/Content/Audio.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message\Content; + +/** + * @author Christopher Hertel + */ +final readonly class Audio extends File +{ +} diff --git a/src/platform/src/Message/Content/ContentInterface.php b/src/platform/src/Message/Content/ContentInterface.php new file mode 100644 index 000000000..fd3ad307f --- /dev/null +++ b/src/platform/src/Message/Content/ContentInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message\Content; + +/** + * @author Denis Zunke + */ +interface ContentInterface +{ +} diff --git a/src/platform/src/Message/Content/Document.php b/src/platform/src/Message/Content/Document.php new file mode 100644 index 000000000..d6bd91440 --- /dev/null +++ b/src/platform/src/Message/Content/Document.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message\Content; + +/** + * @author Christopher Hertel + */ +final readonly class Document extends File +{ +} diff --git a/src/platform/src/Message/Content/DocumentUrl.php b/src/platform/src/Message/Content/DocumentUrl.php new file mode 100644 index 000000000..e44e9e562 --- /dev/null +++ b/src/platform/src/Message/Content/DocumentUrl.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message\Content; + +/** + * @author Christopher Hertel + */ +final readonly class DocumentUrl implements ContentInterface +{ + public function __construct( + public string $url, + ) { + } +} diff --git a/src/platform/src/Message/Content/File.php b/src/platform/src/Message/Content/File.php new file mode 100644 index 000000000..a6d6cbcbf --- /dev/null +++ b/src/platform/src/Message/Content/File.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message\Content; + +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Exception\RuntimeException; + +use function Symfony\Component\String\u; + +/** + * @author Christopher Hertel + */ +readonly class File implements ContentInterface +{ + final public function __construct( + private string|\Closure $data, + private string $format, + private ?string $path = null, + ) { + } + + public static function fromDataUrl(string $dataUrl): static + { + if (!str_starts_with($dataUrl, 'data:')) { + throw new InvalidArgumentException('Invalid audio data URL format.'); + } + + return new static( + base64_decode(u($dataUrl)->after('base64,')->toString()), + u($dataUrl)->after('data:')->before(';base64,')->toString(), + ); + } + + public static function fromFile(string $path): static + { + if (!is_readable($path)) { + throw new InvalidArgumentException(\sprintf('The file "%s" does not exist or is not readable.', $path)); + } + + return new static( + fn () => file_get_contents($path), + mime_content_type($path), + $path, + ); + } + + public function getFormat(): string + { + return $this->format; + } + + public function asBinary(): string + { + return $this->data instanceof \Closure ? ($this->data)() : $this->data; + } + + public function asBase64(): string + { + return base64_encode($this->asBinary()); + } + + public function asDataUrl(): string + { + return \sprintf('data:%s;base64,%s', $this->format, $this->asBase64()); + } + + /** + * @return resource|false + */ + public function asResource() + { + if (null === $this->path) { + throw new RuntimeException('You can only get a resource after creating fromFile.'); + } + + return fopen($this->path, 'r'); + } +} diff --git a/src/platform/src/Message/Content/Image.php b/src/platform/src/Message/Content/Image.php new file mode 100644 index 000000000..1a98399e8 --- /dev/null +++ b/src/platform/src/Message/Content/Image.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message\Content; + +/** + * @author Denis Zunke + */ +final readonly class Image extends File +{ +} diff --git a/src/platform/src/Message/Content/ImageUrl.php b/src/platform/src/Message/Content/ImageUrl.php new file mode 100644 index 000000000..63420ff4a --- /dev/null +++ b/src/platform/src/Message/Content/ImageUrl.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message\Content; + +/** + * @author Christopher Hertel + */ +final readonly class ImageUrl implements ContentInterface +{ + public function __construct( + public string $url, + ) { + } +} diff --git a/src/platform/src/Message/Content/Text.php b/src/platform/src/Message/Content/Text.php new file mode 100644 index 000000000..ddf371aff --- /dev/null +++ b/src/platform/src/Message/Content/Text.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message\Content; + +/** + * @author Denis Zunke + */ +final readonly class Text implements ContentInterface +{ + public function __construct( + public string $text, + ) { + } +} diff --git a/src/platform/src/Message/Message.php b/src/platform/src/Message/Message.php new file mode 100644 index 000000000..6b2b9d46d --- /dev/null +++ b/src/platform/src/Message/Message.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message; + +use Symfony\AI\Platform\Message\Content\ContentInterface; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Response\ToolCall; + +/** + * @author Christopher Hertel + * @author Denis Zunke + */ +final readonly class Message +{ + // Disabled by default, just a bridge to the specific messages + private function __construct() + { + } + + public static function forSystem(string $content): SystemMessage + { + return new SystemMessage($content); + } + + /** + * @param ?ToolCall[] $toolCalls + */ + public static function ofAssistant(?string $content = null, ?array $toolCalls = null): AssistantMessage + { + return new AssistantMessage($content, $toolCalls); + } + + public static function ofUser(string|ContentInterface ...$content): UserMessage + { + $content = array_map( + static fn (string|ContentInterface $entry) => \is_string($entry) ? new Text($entry) : $entry, + $content, + ); + + return new UserMessage(...$content); + } + + public static function ofToolCall(ToolCall $toolCall, string $content): ToolCallMessage + { + return new ToolCallMessage($toolCall, $content); + } +} diff --git a/src/platform/src/Message/MessageBag.php b/src/platform/src/Message/MessageBag.php new file mode 100644 index 000000000..87ad0c5b6 --- /dev/null +++ b/src/platform/src/Message/MessageBag.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message; + +/** + * @final + * + * @author Christopher Hertel + */ +class MessageBag implements MessageBagInterface +{ + /** + * @var list + */ + private array $messages; + + public function __construct(MessageInterface ...$messages) + { + $this->messages = array_values($messages); + } + + public function add(MessageInterface $message): void + { + $this->messages[] = $message; + } + + /** + * @return list + */ + public function getMessages(): array + { + return $this->messages; + } + + public function getSystemMessage(): ?SystemMessage + { + foreach ($this->messages as $message) { + if ($message instanceof SystemMessage) { + return $message; + } + } + + return null; + } + + public function with(MessageInterface $message): self + { + $messages = clone $this; + $messages->add($message); + + return $messages; + } + + public function merge(MessageBagInterface $messageBag): self + { + $messages = clone $this; + $messages->messages = array_merge($messages->messages, $messageBag->getMessages()); + + return $messages; + } + + public function withoutSystemMessage(): self + { + $messages = clone $this; + $messages->messages = array_values(array_filter( + $messages->messages, + static fn (MessageInterface $message) => !$message instanceof SystemMessage, + )); + + return $messages; + } + + public function prepend(MessageInterface $message): self + { + $messages = clone $this; + $messages->messages = array_merge([$message], $messages->messages); + + return $messages; + } + + public function containsAudio(): bool + { + foreach ($this->messages as $message) { + if ($message instanceof UserMessage && $message->hasAudioContent()) { + return true; + } + } + + return false; + } + + public function containsImage(): bool + { + foreach ($this->messages as $message) { + if ($message instanceof UserMessage && $message->hasImageContent()) { + return true; + } + } + + return false; + } + + public function count(): int + { + return \count($this->messages); + } +} diff --git a/src/platform/src/Message/MessageBagInterface.php b/src/platform/src/Message/MessageBagInterface.php new file mode 100644 index 000000000..070c51969 --- /dev/null +++ b/src/platform/src/Message/MessageBagInterface.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message; + +/** + * @author Oskar Stark + */ +interface MessageBagInterface extends \Countable +{ + public function add(MessageInterface $message): void; + + /** + * @return list + */ + public function getMessages(): array; + + public function getSystemMessage(): ?SystemMessage; + + public function with(MessageInterface $message): self; + + public function merge(self $messageBag): self; + + public function withoutSystemMessage(): self; + + public function prepend(MessageInterface $message): self; + + public function containsAudio(): bool; + + public function containsImage(): bool; +} diff --git a/src/platform/src/Message/MessageInterface.php b/src/platform/src/Message/MessageInterface.php new file mode 100644 index 000000000..fe1e4233a --- /dev/null +++ b/src/platform/src/Message/MessageInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message; + +/** + * @author Denis Zunke + */ +interface MessageInterface +{ + public function getRole(): Role; +} diff --git a/src/platform/src/Message/Role.php b/src/platform/src/Message/Role.php new file mode 100644 index 000000000..39cfa23ce --- /dev/null +++ b/src/platform/src/Message/Role.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message; + +use OskarStark\Enum\Trait\Comparable; + +/** + * @author Christopher Hertel + */ +enum Role: string +{ + use Comparable; + + case System = 'system'; + case Assistant = 'assistant'; + case User = 'user'; + case ToolCall = 'tool'; +} diff --git a/src/platform/src/Message/SystemMessage.php b/src/platform/src/Message/SystemMessage.php new file mode 100644 index 000000000..9c496123e --- /dev/null +++ b/src/platform/src/Message/SystemMessage.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message; + +/** + * @author Denis Zunke + */ +final readonly class SystemMessage implements MessageInterface +{ + public function __construct(public string $content) + { + } + + public function getRole(): Role + { + return Role::System; + } +} diff --git a/src/platform/src/Message/ToolCallMessage.php b/src/platform/src/Message/ToolCallMessage.php new file mode 100644 index 000000000..c0a7aac0b --- /dev/null +++ b/src/platform/src/Message/ToolCallMessage.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message; + +use Symfony\AI\Platform\Response\ToolCall; + +/** + * @author Denis Zunke + */ +final readonly class ToolCallMessage implements MessageInterface +{ + public function __construct( + public ToolCall $toolCall, + public string $content, + ) { + } + + public function getRole(): Role + { + return Role::ToolCall; + } +} diff --git a/src/platform/src/Message/UserMessage.php b/src/platform/src/Message/UserMessage.php new file mode 100644 index 000000000..78c7e3f5b --- /dev/null +++ b/src/platform/src/Message/UserMessage.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Message; + +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\AI\Platform\Message\Content\ContentInterface; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Content\ImageUrl; + +/** + * @author Denis Zunke + */ +final readonly class UserMessage implements MessageInterface +{ + /** + * @var list + */ + public array $content; + + public function __construct( + ContentInterface ...$content, + ) { + $this->content = $content; + } + + public function getRole(): Role + { + return Role::User; + } + + public function hasAudioContent(): bool + { + foreach ($this->content as $content) { + if ($content instanceof Audio) { + return true; + } + } + + return false; + } + + public function hasImageContent(): bool + { + foreach ($this->content as $content) { + if ($content instanceof Image || $content instanceof ImageUrl) { + return true; + } + } + + return false; + } +} diff --git a/src/platform/src/Model.php b/src/platform/src/Model.php new file mode 100644 index 000000000..8d572b124 --- /dev/null +++ b/src/platform/src/Model.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform; + +/** + * @author Christopher Hertel + */ +class Model +{ + /** + * @param string[] $capabilities + * @param array $options + */ + public function __construct( + private readonly string $name, + private readonly array $capabilities = [], + private readonly array $options = [], + ) { + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return string[] + */ + public function getCapabilities(): array + { + return $this->capabilities; + } + + public function supports(string $capability): bool + { + return \in_array($capability, $this->capabilities, true); + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } +} diff --git a/src/platform/src/ModelClientInterface.php b/src/platform/src/ModelClientInterface.php new file mode 100644 index 000000000..76b6ae875 --- /dev/null +++ b/src/platform/src/ModelClientInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform; + +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Christopher Hertel + */ +interface ModelClientInterface +{ + public function supports(Model $model): bool; + + /** + * @param array $payload + * @param array $options + */ + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface; +} diff --git a/src/platform/src/Platform.php b/src/platform/src/Platform.php new file mode 100644 index 000000000..b45b92348 --- /dev/null +++ b/src/platform/src/Platform.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform; + +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Response\AsyncResponse; +use Symfony\AI\Platform\Response\ResponseInterface; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +/** + * @author Christopher Hertel + */ +final class Platform implements PlatformInterface +{ + /** + * @var ModelClientInterface[] + */ + private readonly array $modelClients; + + /** + * @var ResponseConverterInterface[] + */ + private readonly array $responseConverter; + + /** + * @param iterable $modelClients + * @param iterable $responseConverter + */ + public function __construct( + iterable $modelClients, + iterable $responseConverter, + private ?Contract $contract = null, + ) { + $this->contract = $contract ?? Contract::create(); + $this->modelClients = $modelClients instanceof \Traversable ? iterator_to_array($modelClients) : $modelClients; + $this->responseConverter = $responseConverter instanceof \Traversable ? iterator_to_array($responseConverter) : $responseConverter; + } + + public function request(Model $model, array|string|object $input, array $options = []): ResponseInterface + { + $payload = $this->contract->createRequestPayload($model, $input); + $options = array_merge($model->getOptions(), $options); + + if (isset($options['tools'])) { + $options['tools'] = $this->contract->createToolOption($options['tools'], $model); + } + + $response = $this->doRequest($model, $payload, $options); + + return $this->convertResponse($model, $response, $options); + } + + /** + * @param array $payload + * @param array $options + */ + private function doRequest(Model $model, array|string $payload, array $options = []): HttpResponse + { + foreach ($this->modelClients as $modelClient) { + if ($modelClient->supports($model)) { + return $modelClient->request($model, $payload, $options); + } + } + + throw new RuntimeException('No response factory registered for model "'.$model::class.'" with given input.'); + } + + /** + * @param array $options + */ + private function convertResponse(Model $model, HttpResponse $response, array $options): ResponseInterface + { + foreach ($this->responseConverter as $responseConverter) { + if ($responseConverter->supports($model)) { + return new AsyncResponse($responseConverter, $response, $options); + } + } + + throw new RuntimeException('No response converter registered for model "'.$model::class.'" with given input.'); + } +} diff --git a/src/platform/src/PlatformInterface.php b/src/platform/src/PlatformInterface.php new file mode 100644 index 000000000..ba0208615 --- /dev/null +++ b/src/platform/src/PlatformInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform; + +use Symfony\AI\Platform\Response\ResponseInterface; + +/** + * @author Christopher Hertel + */ +interface PlatformInterface +{ + /** + * @param array|string|object $input + * @param array $options + */ + public function request(Model $model, array|string|object $input, array $options = []): ResponseInterface; +} diff --git a/src/platform/src/Response/AsyncResponse.php b/src/platform/src/Response/AsyncResponse.php new file mode 100644 index 000000000..eb9aef107 --- /dev/null +++ b/src/platform/src/Response/AsyncResponse.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response; + +use Symfony\AI\Platform\Response\Exception\RawResponseAlreadySetException; +use Symfony\AI\Platform\Response\Metadata\MetadataAwareTrait; +use Symfony\AI\Platform\ResponseConverterInterface; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +/** + * @author Christopher Hertel + */ +final class AsyncResponse implements ResponseInterface +{ + use MetadataAwareTrait; + + private bool $isConverted = false; + private ResponseInterface $convertedResponse; + + /** + * @param array $options + */ + public function __construct( + private readonly ResponseConverterInterface $responseConverter, + private readonly HttpResponse $response, + private readonly array $options = [], + ) { + } + + public function getContent(): string|iterable|object|null + { + return $this->unwrap()->getContent(); + } + + public function getRawResponse(): HttpResponse + { + return $this->response; + } + + public function setRawResponse(HttpResponse $rawResponse): void + { + // Empty by design as the raw response is already set in the constructor and must only be set once + throw new RawResponseAlreadySetException(); + } + + public function unwrap(): ResponseInterface + { + if (!$this->isConverted) { + $this->convertedResponse = $this->responseConverter->convert($this->response, $this->options); + + if (null === $this->convertedResponse->getRawResponse()) { + // Fallback to set the raw response when it was not handled by the response converter itself + $this->convertedResponse->setRawResponse($this->response); + } + + $this->isConverted = true; + } + + return $this->convertedResponse; + } + + /** + * @param array $arguments + */ + public function __call(string $name, array $arguments): mixed + { + return $this->unwrap()->{$name}(...$arguments); + } + + public function __get(string $name): mixed + { + return $this->unwrap()->{$name}; + } +} diff --git a/src/platform/src/Response/BaseResponse.php b/src/platform/src/Response/BaseResponse.php new file mode 100644 index 000000000..78013e917 --- /dev/null +++ b/src/platform/src/Response/BaseResponse.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response; + +use Symfony\AI\Platform\Response\Metadata\MetadataAwareTrait; + +/** + * @author Denis Zunke + */ +abstract class BaseResponse implements ResponseInterface +{ + use MetadataAwareTrait; + use RawResponseAwareTrait; +} diff --git a/src/platform/src/Response/BinaryResponse.php b/src/platform/src/Response/BinaryResponse.php new file mode 100644 index 000000000..460df8ef3 --- /dev/null +++ b/src/platform/src/Response/BinaryResponse.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response; + +use Symfony\AI\Platform\Exception\RuntimeException; + +/** + * @author Christopher Hertel + */ +final class BinaryResponse extends BaseResponse +{ + public function __construct( + public string $data, + public ?string $mimeType = null, + ) { + } + + public function getContent(): string + { + return $this->data; + } + + public function toBase64(): string + { + return base64_encode($this->data); + } + + public function toDataUri(): string + { + if (null === $this->mimeType) { + throw new RuntimeException('Mime type is not set.'); + } + + return 'data:'.$this->mimeType.';base64,'.$this->toBase64(); + } +} diff --git a/src/platform/src/Response/Choice.php b/src/platform/src/Response/Choice.php new file mode 100644 index 000000000..46e1e3883 --- /dev/null +++ b/src/platform/src/Response/Choice.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response; + +/** + * @author Christopher Hertel + */ +final readonly class Choice +{ + /** + * @param ToolCall[] $toolCalls + */ + public function __construct( + private ?string $content = null, + private array $toolCalls = [], + ) { + } + + public function getContent(): ?string + { + return $this->content; + } + + public function hasContent(): bool + { + return null !== $this->content; + } + + /** + * @return ToolCall[] + */ + public function getToolCalls(): array + { + return $this->toolCalls; + } + + public function hasToolCall(): bool + { + return 0 !== \count($this->toolCalls); + } +} diff --git a/src/platform/src/Response/ChoiceResponse.php b/src/platform/src/Response/ChoiceResponse.php new file mode 100644 index 000000000..d81f34957 --- /dev/null +++ b/src/platform/src/Response/ChoiceResponse.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response; + +use Symfony\AI\Platform\Exception\InvalidArgumentException; + +/** + * @author Christopher Hertel + */ +final class ChoiceResponse extends BaseResponse +{ + /** + * @var Choice[] + */ + private readonly array $choices; + + public function __construct(Choice ...$choices) + { + if (0 === \count($choices)) { + throw new InvalidArgumentException('Response must have at least one choice.'); + } + + $this->choices = $choices; + } + + /** + * @return Choice[] + */ + public function getContent(): array + { + return $this->choices; + } +} diff --git a/src/platform/src/Response/Exception/RawResponseAlreadySetException.php b/src/platform/src/Response/Exception/RawResponseAlreadySetException.php new file mode 100644 index 000000000..a8a6c1ca8 --- /dev/null +++ b/src/platform/src/Response/Exception/RawResponseAlreadySetException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response\Exception; + +use Symfony\AI\Platform\Exception\RuntimeException; + +/** + * @author Denis Zunke + */ +final class RawResponseAlreadySetException extends RuntimeException +{ + public function __construct() + { + parent::__construct('The raw response was already set.'); + } +} diff --git a/src/platform/src/Response/Metadata/Metadata.php b/src/platform/src/Response/Metadata/Metadata.php new file mode 100644 index 000000000..ea6f04f1c --- /dev/null +++ b/src/platform/src/Response/Metadata/Metadata.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response\Metadata; + +/** + * @implements \IteratorAggregate + * @implements \ArrayAccess + * + * @author Denis Zunke + */ +class Metadata implements \JsonSerializable, \Countable, \IteratorAggregate, \ArrayAccess +{ + /** + * @var array + */ + private array $metadata = []; + + /** + * @param array $metadata + */ + public function __construct(array $metadata = []) + { + $this->set($metadata); + } + + /** + * @return array + */ + public function all(): array + { + return $this->metadata; + } + + /** + * @param array $metadata + */ + public function set(array $metadata): void + { + $this->metadata = $metadata; + } + + public function add(string $key, mixed $value): void + { + $this->metadata[$key] = $value; + } + + public function has(string $key): bool + { + return \array_key_exists($key, $this->metadata); + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->metadata[$key] ?? $default; + } + + public function remove(string $key): void + { + unset($this->metadata[$key]); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->all(); + } + + public function count(): int + { + return \count($this->metadata); + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->metadata); + } + + public function offsetExists(mixed $offset): bool + { + return $this->has((string) $offset); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->get((string) $offset); + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $this->add((string) $offset, $value); + } + + public function offsetUnset(mixed $offset): void + { + $this->remove((string) $offset); + } +} diff --git a/src/platform/src/Response/Metadata/MetadataAwareTrait.php b/src/platform/src/Response/Metadata/MetadataAwareTrait.php new file mode 100644 index 000000000..ed3fffa62 --- /dev/null +++ b/src/platform/src/Response/Metadata/MetadataAwareTrait.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response\Metadata; + +/** + * @author Denis Zunke + */ +trait MetadataAwareTrait +{ + private ?Metadata $metadata = null; + + public function getMetadata(): Metadata + { + return $this->metadata ??= new Metadata(); + } +} diff --git a/src/platform/src/Response/ObjectResponse.php b/src/platform/src/Response/ObjectResponse.php new file mode 100644 index 000000000..f98ba36f9 --- /dev/null +++ b/src/platform/src/Response/ObjectResponse.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response; + +/** + * @author Christopher Hertel + */ +final class ObjectResponse extends BaseResponse +{ + /** + * @param object|array $structuredOutput + */ + public function __construct( + private readonly object|array $structuredOutput, + ) { + } + + /** + * @return object|array + */ + public function getContent(): object|array + { + return $this->structuredOutput; + } +} diff --git a/src/platform/src/Response/RawResponseAwareTrait.php b/src/platform/src/Response/RawResponseAwareTrait.php new file mode 100644 index 000000000..029ab77b7 --- /dev/null +++ b/src/platform/src/Response/RawResponseAwareTrait.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response; + +use Symfony\AI\Platform\Response\Exception\RawResponseAlreadySetException; +use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyHttpResponse; + +/** + * @author Denis Zunke + */ +trait RawResponseAwareTrait +{ + protected ?SymfonyHttpResponse $rawResponse = null; + + public function setRawResponse(SymfonyHttpResponse $rawResponse): void + { + if (null !== $this->rawResponse) { + throw new RawResponseAlreadySetException(); + } + + $this->rawResponse = $rawResponse; + } + + public function getRawResponse(): ?SymfonyHttpResponse + { + return $this->rawResponse; + } +} diff --git a/src/platform/src/Response/ResponseInterface.php b/src/platform/src/Response/ResponseInterface.php new file mode 100644 index 000000000..c8738240f --- /dev/null +++ b/src/platform/src/Response/ResponseInterface.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response; + +use Symfony\AI\Platform\Response\Exception\RawResponseAlreadySetException; +use Symfony\AI\Platform\Response\Metadata\Metadata; +use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyHttpResponse; + +/** + * @author Christopher Hertel + * @author Denis Zunke + */ +interface ResponseInterface +{ + /** + * @return string|iterable|object|null + */ + public function getContent(): string|iterable|object|null; + + public function getMetadata(): Metadata; + + public function getRawResponse(): ?SymfonyHttpResponse; + + /** + * @throws RawResponseAlreadySetException if the response is tried to be set more than once + */ + public function setRawResponse(SymfonyHttpResponse $rawResponse): void; +} diff --git a/src/platform/src/Response/StreamResponse.php b/src/platform/src/Response/StreamResponse.php new file mode 100644 index 000000000..684c4d8d0 --- /dev/null +++ b/src/platform/src/Response/StreamResponse.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response; + +/** + * @author Christopher Hertel + */ +final class StreamResponse extends BaseResponse +{ + public function __construct( + private readonly \Generator $generator, + ) { + } + + public function getContent(): \Generator + { + yield from $this->generator; + } +} diff --git a/src/platform/src/Response/TextResponse.php b/src/platform/src/Response/TextResponse.php new file mode 100644 index 000000000..7bb9047c0 --- /dev/null +++ b/src/platform/src/Response/TextResponse.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response; + +/** + * @author Christopher Hertel + */ +final class TextResponse extends BaseResponse +{ + public function __construct( + private readonly string $content, + ) { + } + + public function getContent(): string + { + return $this->content; + } +} diff --git a/src/platform/src/Response/ToolCall.php b/src/platform/src/Response/ToolCall.php new file mode 100644 index 000000000..8633632fc --- /dev/null +++ b/src/platform/src/Response/ToolCall.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response; + +/** + * @author Christopher Hertel + */ +final readonly class ToolCall implements \JsonSerializable +{ + /** + * @param array $arguments + */ + public function __construct( + public string $id, + public string $name, + public array $arguments = [], + ) { + } + + /** + * @return array{ + * id: string, + * type: 'function', + * function: array{ + * name: string, + * arguments: string + * } + * } + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'type' => 'function', + 'function' => [ + 'name' => $this->name, + 'arguments' => json_encode($this->arguments), + ], + ]; + } +} diff --git a/src/platform/src/Response/ToolCallResponse.php b/src/platform/src/Response/ToolCallResponse.php new file mode 100644 index 000000000..fcdc94c3b --- /dev/null +++ b/src/platform/src/Response/ToolCallResponse.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response; + +use Symfony\AI\Platform\Exception\InvalidArgumentException; + +/** + * @author Christopher Hertel + */ +final class ToolCallResponse extends BaseResponse +{ + /** + * @var ToolCall[] + */ + private readonly array $toolCalls; + + public function __construct(ToolCall ...$toolCalls) + { + if (0 === \count($toolCalls)) { + throw new InvalidArgumentException('Response must have at least one tool call.'); + } + + $this->toolCalls = $toolCalls; + } + + /** + * @return ToolCall[] + */ + public function getContent(): array + { + return $this->toolCalls; + } +} diff --git a/src/platform/src/Response/VectorResponse.php b/src/platform/src/Response/VectorResponse.php new file mode 100644 index 000000000..e63c898c4 --- /dev/null +++ b/src/platform/src/Response/VectorResponse.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response; + +use Symfony\AI\Platform\Vector\Vector; + +/** + * @author Christopher Hertel + */ +final class VectorResponse extends BaseResponse +{ + /** + * @var Vector[] + */ + private readonly array $vectors; + + public function __construct(Vector ...$vector) + { + $this->vectors = $vector; + } + + /** + * @return Vector[] + */ + public function getContent(): array + { + return $this->vectors; + } +} diff --git a/src/platform/src/ResponseConverterInterface.php b/src/platform/src/ResponseConverterInterface.php new file mode 100644 index 000000000..9bdfd0551 --- /dev/null +++ b/src/platform/src/ResponseConverterInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform; + +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +/** + * @author Christopher Hertel + */ +interface ResponseConverterInterface +{ + public function supports(Model $model): bool; + + /** + * @param array $options + */ + public function convert(HttpResponse $response, array $options = []): LlmResponse; +} diff --git a/src/platform/src/Tool/ExecutionReference.php b/src/platform/src/Tool/ExecutionReference.php new file mode 100644 index 000000000..0a68c005a --- /dev/null +++ b/src/platform/src/Tool/ExecutionReference.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tool; + +/** + * @author Christopher Hertel + */ +final class ExecutionReference +{ + public function __construct( + public string $class, + public string $method = '__invoke', + ) { + } +} diff --git a/src/platform/src/Tool/Tool.php b/src/platform/src/Tool/Tool.php new file mode 100644 index 000000000..826a04f4c --- /dev/null +++ b/src/platform/src/Tool/Tool.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tool; + +use Symfony\AI\Platform\Contract\JsonSchema\Factory; + +/** + * @phpstan-import-type JsonSchema from Factory + * + * @author Christopher Hertel + */ +final readonly class Tool +{ + /** + * @param JsonSchema|null $parameters + */ + public function __construct( + public ExecutionReference $reference, + public string $name, + public string $description, + public ?array $parameters = null, + ) { + } +} diff --git a/src/platform/src/Vector/NullVector.php b/src/platform/src/Vector/NullVector.php new file mode 100644 index 000000000..f714efaff --- /dev/null +++ b/src/platform/src/Vector/NullVector.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Vector; + +use Symfony\AI\Platform\Exception\RuntimeException; + +/** + * @author Oskar Stark + */ +final class NullVector implements VectorInterface +{ + public function getData(): array + { + throw new RuntimeException('getData() method cannot be called on a NullVector.'); + } + + public function getDimensions(): int + { + throw new RuntimeException('getDimensions() method cannot be called on a NullVector.'); + } +} diff --git a/src/platform/src/Vector/Vector.php b/src/platform/src/Vector/Vector.php new file mode 100644 index 000000000..f09f08a6a --- /dev/null +++ b/src/platform/src/Vector/Vector.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Vector; + +use Symfony\AI\Platform\Exception\InvalidArgumentException; + +/** + * @author Christopher Hertel + */ +final class Vector implements VectorInterface +{ + /** + * @param list $data + */ + public function __construct( + private readonly array $data, + private ?int $dimensions = null, + ) { + if (null !== $dimensions && $dimensions !== \count($data)) { + throw new InvalidArgumentException('Vector must have '.$dimensions.' dimensions'); + } + + if (0 === \count($data)) { + throw new InvalidArgumentException('Vector must have at least one dimension'); + } + + if (\is_int($dimensions) && \count($data) !== $dimensions) { + throw new InvalidArgumentException('Vector must have '.$dimensions.' dimensions'); + } + + if (null === $this->dimensions) { + $this->dimensions = \count($data); + } + } + + /** + * @return list + */ + public function getData(): array + { + return $this->data; + } + + public function getDimensions(): int + { + return $this->dimensions; + } +} diff --git a/src/platform/src/Vector/VectorInterface.php b/src/platform/src/Vector/VectorInterface.php new file mode 100644 index 000000000..78ea1a933 --- /dev/null +++ b/src/platform/src/Vector/VectorInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Vector; + +/** + * @author Oskar Stark + */ +interface VectorInterface +{ + /** + * @return list + */ + public function getData(): array; + + public function getDimensions(): int; +} diff --git a/src/platform/tests/Bridge/Anthropic/ResponseConverterTest.php b/src/platform/tests/Bridge/Anthropic/ResponseConverterTest.php new file mode 100644 index 000000000..58c989719 --- /dev/null +++ b/src/platform/tests/Bridge/Anthropic/ResponseConverterTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\Anthropic; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Anthropic\ResponseConverter; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Response\ToolCallResponse; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +#[CoversClass(ResponseConverter::class)] +#[Small] +#[UsesClass(ToolCall::class)] +#[UsesClass(ToolCallResponse::class)] +final class ResponseConverterTest extends TestCase +{ + public function testConvertThrowsExceptionWhenContentIsToolUseAndLacksText(): void + { + $httpClient = new MockHttpClient(new JsonMockResponse([ + 'content' => [ + [ + 'type' => 'tool_use', + 'id' => 'toolu_01UM4PcTjC1UDiorSXVHSVFM', + 'name' => 'xxx_tool', + 'input' => ['action' => 'get_data'], + ], + ], + ])); + $httpResponse = $httpClient->request('POST', 'https://api.anthropic.com/v1/messages'); + $handler = new ResponseConverter(); + + $response = $handler->convert($httpResponse); + self::assertInstanceOf(ToolCallResponse::class, $response); + self::assertCount(1, $response->getContent()); + self::assertSame('toolu_01UM4PcTjC1UDiorSXVHSVFM', $response->getContent()[0]->id); + self::assertSame('xxx_tool', $response->getContent()[0]->name); + self::assertSame(['action' => 'get_data'], $response->getContent()[0]->arguments); + } +} diff --git a/src/platform/tests/Bridge/Bedrock/Nova/ContractTest.php b/src/platform/tests/Bridge/Bedrock/Nova/ContractTest.php new file mode 100644 index 000000000..c152cdfbb --- /dev/null +++ b/src/platform/tests/Bridge/Bedrock/Nova/ContractTest.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\Bedrock\Nova; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Medium; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Bedrock\Nova\Contract\AssistantMessageNormalizer; +use Symfony\AI\Platform\Bridge\Bedrock\Nova\Contract\MessageBagNormalizer; +use Symfony\AI\Platform\Bridge\Bedrock\Nova\Contract\ToolCallMessageNormalizer; +use Symfony\AI\Platform\Bridge\Bedrock\Nova\Contract\ToolNormalizer; +use Symfony\AI\Platform\Bridge\Bedrock\Nova\Contract\UserMessageNormalizer; +use Symfony\AI\Platform\Bridge\Bedrock\Nova\Nova; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\SystemMessage; +use Symfony\AI\Platform\Message\ToolCallMessage; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Response\ToolCall; + +#[Medium] +#[CoversClass(AssistantMessageNormalizer::class)] +#[CoversClass(MessageBagNormalizer::class)] +#[CoversClass(ToolCallMessageNormalizer::class)] +#[CoversClass(ToolNormalizer::class)] +#[CoversClass(UserMessageNormalizer::class)] +#[UsesClass(UserMessage::class)] +#[UsesClass(AssistantMessage::class)] +#[UsesClass(ToolCallMessage::class)] +#[UsesClass(SystemMessage::class)] +#[UsesClass(MessageBag::class)] +final class ContractTest extends TestCase +{ + #[Test] + #[DataProvider('provideMessageBag')] + public function testConvert(MessageBag $bag, array $expected): void + { + $contract = Contract::create( + new AssistantMessageNormalizer(), + new MessageBagNormalizer(), + new ToolCallMessageNormalizer(), + new ToolNormalizer(), + new UserMessageNormalizer(), + ); + + self::assertEquals($expected, $contract->createRequestPayload(new Nova(), $bag)); + } + + /** + * @return iterable + */ + public static function provideMessageBag(): iterable + { + yield 'simple text' => [ + new MessageBag(Message::ofUser('Write a story about a magic backpack.')), + [ + 'messages' => [ + ['role' => 'user', 'content' => [['text' => 'Write a story about a magic backpack.']]], + ], + ], + ]; + + yield 'with assistant message' => [ + new MessageBag( + Message::ofUser('Hello'), + Message::ofAssistant('Great to meet you. What would you like to know?'), + Message::ofUser('I have two dogs in my house. How many paws are in my house?'), + ), + [ + 'messages' => [ + ['role' => 'user', 'content' => [['text' => 'Hello']]], + ['role' => 'assistant', 'content' => [['text' => 'Great to meet you. What would you like to know?']]], + ['role' => 'user', 'content' => [['text' => 'I have two dogs in my house. How many paws are in my house?']]], + ], + ], + ]; + + yield 'with system messages' => [ + new MessageBag( + Message::forSystem('You are a cat. Your name is Neko.'), + Message::ofUser('Hello there'), + ), + [ + 'system' => [['text' => 'You are a cat. Your name is Neko.']], + 'messages' => [ + ['role' => 'user', 'content' => [['text' => 'Hello there']]], + ], + ], + ]; + + yield 'with tool use' => [ + new MessageBag( + Message::ofUser('Hello there, what is the time?'), + Message::ofAssistant(toolCalls: [new ToolCall('123456', 'clock', [])]), + Message::ofToolCall(new ToolCall('123456', 'clock', []), '2023-10-01T10:00:00+00:00'), + Message::ofAssistant('It is 10:00 AM.'), + ), + [ + 'messages' => [ + ['role' => 'user', 'content' => [['text' => 'Hello there, what is the time?']]], + [ + 'role' => 'assistant', + 'content' => [ + [ + 'toolUse' => [ + 'toolUseId' => '123456', + 'name' => 'clock', + 'input' => new \stdClass(), + ], + ], + ], + ], + [ + 'role' => 'user', + 'content' => [ + [ + 'toolResult' => [ + 'toolUseId' => '123456', + 'content' => [ + ['json' => '2023-10-01T10:00:00+00:00'], + ], + ], + ], + ], + ], + ['role' => 'assistant', 'content' => [['text' => 'It is 10:00 AM.']]], + ], + ], + ]; + } +} diff --git a/src/platform/tests/Bridge/Google/Contract/AssistantMessageNormalizerTest.php b/src/platform/tests/Bridge/Google/Contract/AssistantMessageNormalizerTest.php new file mode 100644 index 000000000..27101c318 --- /dev/null +++ b/src/platform/tests/Bridge/Google/Contract/AssistantMessageNormalizerTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\Google\Contract; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Google\Contract\AssistantMessageNormalizer; +use Symfony\AI\Platform\Bridge\Google\Gemini; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Model; + +#[Small] +#[CoversClass(AssistantMessageNormalizer::class)] +#[UsesClass(Gemini::class)] +#[UsesClass(AssistantMessage::class)] +#[UsesClass(Model::class)] +final class AssistantMessageNormalizerTest extends TestCase +{ + #[Test] + public function supportsNormalization(): void + { + $normalizer = new AssistantMessageNormalizer(); + + self::assertTrue($normalizer->supportsNormalization(new AssistantMessage('Hello'), context: [ + Contract::CONTEXT_MODEL => new Gemini(), + ])); + self::assertFalse($normalizer->supportsNormalization('not an assistant message')); + } + + #[Test] + public function getSupportedTypes(): void + { + $normalizer = new AssistantMessageNormalizer(); + + self::assertSame([AssistantMessage::class => true], $normalizer->getSupportedTypes(null)); + } + + #[Test] + public function normalize(): void + { + $normalizer = new AssistantMessageNormalizer(); + $message = new AssistantMessage('Great to meet you. What would you like to know?'); + + $normalized = $normalizer->normalize($message); + + self::assertSame([['text' => 'Great to meet you. What would you like to know?']], $normalized); + } +} diff --git a/src/platform/tests/Bridge/Google/Contract/MessageBagNormalizerTest.php b/src/platform/tests/Bridge/Google/Contract/MessageBagNormalizerTest.php new file mode 100644 index 000000000..c4b8da61d --- /dev/null +++ b/src/platform/tests/Bridge/Google/Contract/MessageBagNormalizerTest.php @@ -0,0 +1,157 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\Google\Contract; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Medium; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Google\Contract\AssistantMessageNormalizer; +use Symfony\AI\Platform\Bridge\Google\Contract\MessageBagNormalizer; +use Symfony\AI\Platform\Bridge\Google\Contract\UserMessageNormalizer; +use Symfony\AI\Platform\Bridge\Google\Gemini; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Model; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +#[Medium] +#[CoversClass(MessageBagNormalizer::class)] +#[CoversClass(UserMessageNormalizer::class)] +#[CoversClass(AssistantMessageNormalizer::class)] +#[UsesClass(Model::class)] +#[UsesClass(Gemini::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(UserMessage::class)] +#[UsesClass(AssistantMessage::class)] +final class MessageBagNormalizerTest extends TestCase +{ + #[Test] + public function supportsNormalization(): void + { + $normalizer = new MessageBagNormalizer(); + + self::assertTrue($normalizer->supportsNormalization(new MessageBag(), context: [ + Contract::CONTEXT_MODEL => new Gemini(), + ])); + self::assertFalse($normalizer->supportsNormalization('not a message bag')); + } + + #[Test] + public function getSupportedTypes(): void + { + $normalizer = new MessageBagNormalizer(); + + $expected = [ + MessageBagInterface::class => true, + ]; + + self::assertSame($expected, $normalizer->getSupportedTypes(null)); + } + + #[Test] + #[DataProvider('provideMessageBagData')] + public function normalize(MessageBag $bag, array $expected): void + { + $normalizer = new MessageBagNormalizer(); + + // Set up the inner normalizers + $userMessageNormalizer = new UserMessageNormalizer(); + $assistantMessageNormalizer = new AssistantMessageNormalizer(); + + // Mock a normalizer that delegates to the appropriate concrete normalizer + $mockNormalizer = $this->createMock(NormalizerInterface::class); + $mockNormalizer->method('normalize') + ->willReturnCallback(function ($message) use ($userMessageNormalizer, $assistantMessageNormalizer): ?array { + if ($message instanceof UserMessage) { + return $userMessageNormalizer->normalize($message); + } + if ($message instanceof AssistantMessage) { + return $assistantMessageNormalizer->normalize($message); + } + + return null; + }); + + $normalizer->setNormalizer($mockNormalizer); + + $normalized = $normalizer->normalize($bag); + + self::assertEquals($expected, $normalized); + } + + /** + * @return iterable + */ + public static function provideMessageBagData(): iterable + { + yield 'simple text' => [ + new MessageBag(Message::ofUser('Write a story about a magic backpack.')), + [ + 'contents' => [ + ['role' => 'user', 'parts' => [['text' => 'Write a story about a magic backpack.']]], + ], + ], + ]; + + yield 'text with image' => [ + new MessageBag( + Message::ofUser('Tell me about this instrument', Image::fromFile(\dirname(__DIR__, 4).'/Fixture/image.jpg')) + ), + [ + 'contents' => [ + ['role' => 'user', 'parts' => [ + ['text' => 'Tell me about this instrument'], + ['inline_data' => ['mime_type' => 'image/jpeg', 'data' => base64_encode(file_get_contents(\dirname(__DIR__, 4).'/Fixture/image.jpg'))]], + ]], + ], + ], + ]; + + yield 'with assistant message' => [ + new MessageBag( + Message::ofUser('Hello'), + Message::ofAssistant('Great to meet you. What would you like to know?'), + Message::ofUser('I have two dogs in my house. How many paws are in my house?'), + ), + [ + 'contents' => [ + ['role' => 'user', 'parts' => [['text' => 'Hello']]], + ['role' => 'model', 'parts' => [['text' => 'Great to meet you. What would you like to know?']]], + ['role' => 'user', 'parts' => [['text' => 'I have two dogs in my house. How many paws are in my house?']]], + ], + ], + ]; + + yield 'with system messages' => [ + new MessageBag( + Message::forSystem('You are a cat. Your name is Neko.'), + Message::ofUser('Hello there'), + ), + [ + 'contents' => [ + ['role' => 'user', 'parts' => [['text' => 'Hello there']]], + ], + 'system_instruction' => [ + 'parts' => ['text' => 'You are a cat. Your name is Neko.'], + ], + ], + ]; + } +} diff --git a/src/platform/tests/Bridge/Google/Contract/UserMessageNormalizerTest.php b/src/platform/tests/Bridge/Google/Contract/UserMessageNormalizerTest.php new file mode 100644 index 000000000..1ce885edb --- /dev/null +++ b/src/platform/tests/Bridge/Google/Contract/UserMessageNormalizerTest.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\Google\Contract; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Google\Contract\UserMessageNormalizer; +use Symfony\AI\Platform\Bridge\Google\Gemini; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\Content\File; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\UserMessage; + +#[Small] +#[CoversClass(UserMessageNormalizer::class)] +#[UsesClass(Gemini::class)] +#[UsesClass(UserMessage::class)] +#[UsesClass(Text::class)] +#[UsesClass(File::class)] +final class UserMessageNormalizerTest extends TestCase +{ + #[Test] + public function supportsNormalization(): void + { + $normalizer = new UserMessageNormalizer(); + + self::assertTrue($normalizer->supportsNormalization(new UserMessage(new Text('Hello')), context: [ + Contract::CONTEXT_MODEL => new Gemini(), + ])); + self::assertFalse($normalizer->supportsNormalization('not a user message')); + } + + #[Test] + public function getSupportedTypes(): void + { + $normalizer = new UserMessageNormalizer(); + + self::assertSame([UserMessage::class => true], $normalizer->getSupportedTypes(null)); + } + + #[Test] + public function normalizeTextContent(): void + { + $normalizer = new UserMessageNormalizer(); + $message = new UserMessage(new Text('Write a story about a magic backpack.')); + + $normalized = $normalizer->normalize($message); + + self::assertSame([['text' => 'Write a story about a magic backpack.']], $normalized); + } + + #[Test] + public function normalizeImageContent(): void + { + $normalizer = new UserMessageNormalizer(); + $imageContent = Image::fromFile(\dirname(__DIR__, 4).'/Fixture/image.jpg'); + $message = new UserMessage(new Text('Tell me about this instrument'), $imageContent); + + $normalized = $normalizer->normalize($message); + + self::assertCount(2, $normalized); + self::assertSame(['text' => 'Tell me about this instrument'], $normalized[0]); + self::assertArrayHasKey('inline_data', $normalized[1]); + self::assertSame('image/jpeg', $normalized[1]['inline_data']['mime_type']); + self::assertNotEmpty($normalized[1]['inline_data']['data']); + + // Verify that the base64 data string starts correctly for a JPEG + self::assertStringStartsWith('/9j/', $normalized[1]['inline_data']['data']); + } +} diff --git a/src/platform/tests/Bridge/HuggingFace/ModelClientTest.php b/src/platform/tests/Bridge/HuggingFace/ModelClientTest.php new file mode 100644 index 000000000..5f14c29d9 --- /dev/null +++ b/src/platform/tests/Bridge/HuggingFace/ModelClientTest.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\HuggingFace; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\HuggingFace\Contract\FileNormalizer; +use Symfony\AI\Platform\Bridge\HuggingFace\Contract\MessageBagNormalizer; +use Symfony\AI\Platform\Bridge\HuggingFace\ModelClient; +use Symfony\AI\Platform\Bridge\HuggingFace\Task; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Model; +use Symfony\Component\HttpClient\MockHttpClient; + +#[CoversClass(ModelClient::class)] +#[Small] +#[UsesClass(Model::class)] +final class ModelClientTest extends TestCase +{ + #[DataProvider('urlTestCases')] + public function testGetUrlForDifferentInputsAndTasks(?string $task, string $expectedUrl): void + { + $reflection = new \ReflectionClass(ModelClient::class); + $getUrlMethod = $reflection->getMethod('getUrl'); + $getUrlMethod->setAccessible(true); + + $model = new Model('test-model'); + $httpClient = new MockHttpClient(); + $modelClient = new ModelClient($httpClient, 'test-provider', 'test-api-key'); + + $actualUrl = $getUrlMethod->invoke($modelClient, $model, $task); + + self::assertEquals($expectedUrl, $actualUrl); + } + + public static function urlTestCases(): \Iterator + { + $messageBag = new MessageBag(); + $messageBag->add(new UserMessage(new Text('Test message'))); + yield 'string input' => [ + 'task' => null, + 'expectedUrl' => 'https://router.huggingface.co/test-provider/models/test-model', + ]; + yield 'array input' => [ + 'task' => null, + 'expectedUrl' => 'https://router.huggingface.co/test-provider/models/test-model', + ]; + yield 'image input' => [ + 'task' => null, + 'expectedUrl' => 'https://router.huggingface.co/test-provider/models/test-model', + ]; + yield 'feature extraction' => [ + 'task' => Task::FEATURE_EXTRACTION, + 'expectedUrl' => 'https://router.huggingface.co/test-provider/pipeline/feature-extraction/test-model', + ]; + yield 'message bag' => [ + 'task' => Task::CHAT_COMPLETION, + 'expectedUrl' => 'https://router.huggingface.co/test-provider/models/test-model/v1/chat/completions', + ]; + } + + #[DataProvider('payloadTestCases')] + public function testGetPayloadForDifferentInputsAndTasks(object|array|string $input, array $options, array $expectedKeys, array $expectedValues = []): void + { + // Contract handling first + $contract = Contract::create( + new FileNormalizer(), + new MessageBagNormalizer() + ); + + $payload = $contract->createRequestPayload(new Model('test-model'), $input); + + $reflection = new \ReflectionClass(ModelClient::class); + $getPayloadMethod = $reflection->getMethod('getPayload'); + $getPayloadMethod->setAccessible(true); + + $httpClient = new MockHttpClient(); + $modelClient = new ModelClient($httpClient, 'test-provider', 'test-api-key'); + + $actual = $getPayloadMethod->invoke($modelClient, $payload, $options); + + // Check that expected keys exist + foreach ($expectedKeys as $key) { + self::assertArrayHasKey($key, $actual); + } + + // Check expected values if specified + foreach ($expectedValues as $path => $value) { + $keys = explode('.', $path); + $current = $actual; + foreach ($keys as $key) { + self::assertArrayHasKey($key, $current); + $current = $current[$key]; + } + + self::assertEquals($value, $current); + } + } + + public static function payloadTestCases(): \Iterator + { + yield 'string input' => [ + 'input' => 'Hello world', + 'options' => [], + 'expectedKeys' => ['headers', 'json'], + 'expectedValues' => [ + 'headers.Content-Type' => 'application/json', + 'json.inputs' => 'Hello world', + ], + ]; + + yield 'array input' => [ + 'input' => ['text' => 'Hello world'], + 'options' => ['temperature' => 0.7], + 'expectedKeys' => ['headers', 'json'], + 'expectedValues' => [ + 'headers.Content-Type' => 'application/json', + 'json.inputs' => ['text' => 'Hello world'], + 'json.parameters.temperature' => 0.7, + ], + ]; + + $messageBag = new MessageBag(); + $messageBag->add(new UserMessage(new Text('Test message'))); + + yield 'message bag' => [ + 'input' => $messageBag, + 'options' => ['max_tokens' => 100], + 'expectedKeys' => ['headers', 'json'], + 'expectedValues' => [ + 'headers.Content-Type' => 'application/json', + 'json.max_tokens' => 100, + ], + ]; + } +} diff --git a/src/platform/tests/Bridge/Meta/LlamaPromptConverterTest.php b/src/platform/tests/Bridge/Meta/LlamaPromptConverterTest.php new file mode 100644 index 000000000..7539981ad --- /dev/null +++ b/src/platform/tests/Bridge/Meta/LlamaPromptConverterTest.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\Meta; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Meta\LlamaPromptConverter; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\SystemMessage; +use Symfony\AI\Platform\Message\UserMessage; + +#[CoversClass(LlamaPromptConverter::class)] +#[Small] +#[UsesClass(AssistantMessage::class)] +#[UsesClass(ImageUrl::class)] +#[UsesClass(Text::class)] +#[UsesClass(Message::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(SystemMessage::class)] +#[UsesClass(UserMessage::class)] +final class LlamaPromptConverterTest extends TestCase +{ + #[Test] + public function convertMessages(): void + { + $messageBag = new MessageBag(); + foreach (self::provideMessages() as $message) { + $messageBag->add($message[1]); + } + + self::assertSame(<<<|start_header_id|>system<|end_header_id|> + + You are a helpful chatbot.<|eot_id|> + + <|start_header_id|>user<|end_header_id|> + + Hello, how are you?<|eot_id|> + + <|start_header_id|>user<|end_header_id|> + + Hello, how are you? + What is your name?<|eot_id|> + + <|start_header_id|>user<|end_header_id|> + + Hello, how are you? + What is your name? + https://example.com/image.jpg<|eot_id|> + + <|start_header_id|>assistant<|end_header_id|> + + I am an assistant.<|eot_id|> + + <|start_header_id|>assistant<|end_header_id|> + EXPECTED, + (new LlamaPromptConverter())->convertToPrompt($messageBag) + ); + } + + #[Test] + #[DataProvider('provideMessages')] + public function convertMessage(string $expected, UserMessage|SystemMessage|AssistantMessage $message): void + { + self::assertSame( + $expected, + (new LlamaPromptConverter())->convertMessage($message) + ); + } + + /** + * @return iterable + */ + public static function provideMessages(): iterable + { + yield 'System message' => [ + <<<|start_header_id|>system<|end_header_id|> + + You are a helpful chatbot.<|eot_id|> + SYSTEM, + Message::forSystem('You are a helpful chatbot.'), + ]; + + yield 'UserMessage' => [ + <<user<|end_header_id|> + + Hello, how are you?<|eot_id|> + USER, + Message::ofUser('Hello, how are you?'), + ]; + + yield 'UserMessage with two texts' => [ + <<user<|end_header_id|> + + Hello, how are you? + What is your name?<|eot_id|> + USER, + Message::ofUser('Hello, how are you?', 'What is your name?'), + ]; + + yield 'UserMessage with two texts and one image' => [ + <<user<|end_header_id|> + + Hello, how are you? + What is your name? + https://example.com/image.jpg<|eot_id|> + USER, + Message::ofUser('Hello, how are you?', 'What is your name?', new ImageUrl('https://example.com/image.jpg')), + ]; + + yield 'AssistantMessage' => [ + <<assistant<|end_header_id|> + + I am an assistant.<|eot_id|> + ASSISTANT, + new AssistantMessage('I am an assistant.'), + ]; + + yield 'AssistantMessage with null content' => [ + '', + new AssistantMessage(), + ]; + } +} diff --git a/src/platform/tests/Bridge/OpenAI/DallE/Base64ImageTest.php b/src/platform/tests/Bridge/OpenAI/DallE/Base64ImageTest.php new file mode 100644 index 000000000..615f474f4 --- /dev/null +++ b/src/platform/tests/Bridge/OpenAI/DallE/Base64ImageTest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\OpenAI\DallE; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenAI\DallE\Base64Image; + +#[CoversClass(Base64Image::class)] +#[Small] +final class Base64ImageTest extends TestCase +{ + #[Test] + public function itCreatesBase64Image(): void + { + $emptyPixel = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + $base64Image = new Base64Image($emptyPixel); + + self::assertSame($emptyPixel, $base64Image->encodedImage); + } + + #[Test] + public function itThrowsExceptionWhenBase64ImageIsEmpty(): void + { + self::expectException(\InvalidArgumentException::class); + self::expectExceptionMessage('The base64 encoded image generated must be given.'); + + new Base64Image(''); + } +} diff --git a/src/platform/tests/Bridge/OpenAI/DallE/ImageResponseTest.php b/src/platform/tests/Bridge/OpenAI/DallE/ImageResponseTest.php new file mode 100644 index 000000000..75e9ce84a --- /dev/null +++ b/src/platform/tests/Bridge/OpenAI/DallE/ImageResponseTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\OpenAI\DallE; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenAI\DallE\Base64Image; +use Symfony\AI\Platform\Bridge\OpenAI\DallE\ImageResponse; +use Symfony\AI\Platform\Bridge\OpenAI\DallE\UrlImage; + +#[CoversClass(ImageResponse::class)] +#[UsesClass(Base64Image::class)] +#[UsesClass(UrlImage::class)] +#[Small] +final class ImageResponseTest extends TestCase +{ + #[Test] + public function itCreatesImagesResponse(): void + { + $base64Image = new Base64Image('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); + $generatedImagesResponse = new ImageResponse(null, $base64Image); + + self::assertNull($generatedImagesResponse->revisedPrompt); + self::assertCount(1, $generatedImagesResponse->getContent()); + self::assertSame($base64Image, $generatedImagesResponse->getContent()[0]); + } + + #[Test] + public function itCreatesImagesResponseWithRevisedPrompt(): void + { + $base64Image = new Base64Image('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); + $generatedImagesResponse = new ImageResponse('revised prompt', $base64Image); + + self::assertSame('revised prompt', $generatedImagesResponse->revisedPrompt); + self::assertCount(1, $generatedImagesResponse->getContent()); + self::assertSame($base64Image, $generatedImagesResponse->getContent()[0]); + } + + #[Test] + public function itIsCreatableWithMultipleImages(): void + { + $image1 = new UrlImage('https://example'); + $image2 = new UrlImage('https://example2'); + + $generatedImagesResponse = new ImageResponse(null, $image1, $image2); + + self::assertCount(2, $generatedImagesResponse->getContent()); + self::assertSame($image1, $generatedImagesResponse->getContent()[0]); + self::assertSame($image2, $generatedImagesResponse->getContent()[1]); + } +} diff --git a/src/platform/tests/Bridge/OpenAI/DallE/ModelClientTest.php b/src/platform/tests/Bridge/OpenAI/DallE/ModelClientTest.php new file mode 100644 index 000000000..02e851171 --- /dev/null +++ b/src/platform/tests/Bridge/OpenAI/DallE/ModelClientTest.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\OpenAI\DallE; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenAI\DallE; +use Symfony\AI\Platform\Bridge\OpenAI\DallE\Base64Image; +use Symfony\AI\Platform\Bridge\OpenAI\DallE\ImageResponse; +use Symfony\AI\Platform\Bridge\OpenAI\DallE\ModelClient; +use Symfony\AI\Platform\Bridge\OpenAI\DallE\UrlImage; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +#[CoversClass(ModelClient::class)] +#[UsesClass(DallE::class)] +#[UsesClass(UrlImage::class)] +#[UsesClass(Base64Image::class)] +#[UsesClass(ImageResponse::class)] +#[Small] +final class ModelClientTest extends TestCase +{ + #[Test] + public function itIsSupportingTheCorrectModel(): void + { + $modelClient = new ModelClient(new MockHttpClient(), 'sk-api-key'); + + self::assertTrue($modelClient->supports(new DallE())); + } + + #[Test] + public function itIsExecutingTheCorrectRequest(): void + { + $responseCallback = static function (string $method, string $url, array $options): HttpResponse { + self::assertSame('POST', $method); + self::assertSame('https://api.openai.com/v1/images/generations', $url); + self::assertSame('Authorization: Bearer sk-api-key', $options['normalized_headers']['authorization'][0]); + self::assertSame('{"n":1,"response_format":"url","model":"dall-e-2","prompt":"foo"}', $options['body']); + + return new MockResponse(); + }; + $httpClient = new MockHttpClient([$responseCallback]); + $modelClient = new ModelClient($httpClient, 'sk-api-key'); + $modelClient->request(new DallE(), 'foo', ['n' => 1, 'response_format' => 'url']); + } + + #[Test] + public function itIsConvertingTheResponse(): void + { + $httpResponse = self::createStub(HttpResponse::class); + $httpResponse->method('toArray')->willReturn([ + 'data' => [ + ['url' => 'https://example.com/image.jpg'], + ], + ]); + + $modelClient = new ModelClient(new MockHttpClient(), 'sk-api-key'); + $response = $modelClient->convert($httpResponse, ['response_format' => 'url']); + + self::assertCount(1, $response->getContent()); + self::assertInstanceOf(UrlImage::class, $response->getContent()[0]); + self::assertSame('https://example.com/image.jpg', $response->getContent()[0]->url); + } + + #[Test] + public function itIsConvertingTheResponseWithRevisedPrompt(): void + { + $emptyPixel = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + $httpResponse = self::createStub(HttpResponse::class); + $httpResponse->method('toArray')->willReturn([ + 'data' => [ + ['b64_json' => $emptyPixel, 'revised_prompt' => 'revised prompt'], + ], + ]); + + $modelClient = new ModelClient(new MockHttpClient(), 'sk-api-key'); + $response = $modelClient->convert($httpResponse, ['response_format' => 'b64_json']); + + self::assertInstanceOf(ImageResponse::class, $response); + self::assertCount(1, $response->getContent()); + self::assertInstanceOf(Base64Image::class, $response->getContent()[0]); + self::assertSame($emptyPixel, $response->getContent()[0]->encodedImage); + self::assertSame('revised prompt', $response->revisedPrompt); + } +} diff --git a/src/platform/tests/Bridge/OpenAI/DallE/UrlImageTest.php b/src/platform/tests/Bridge/OpenAI/DallE/UrlImageTest.php new file mode 100644 index 000000000..d71d43200 --- /dev/null +++ b/src/platform/tests/Bridge/OpenAI/DallE/UrlImageTest.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\OpenAI\DallE; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenAI\DallE\UrlImage; + +#[CoversClass(UrlImage::class)] +#[Small] +final class UrlImageTest extends TestCase +{ + #[Test] + public function itCreatesUrlImage(): void + { + $urlImage = new UrlImage('https://example.com/image.jpg'); + + self::assertSame('https://example.com/image.jpg', $urlImage->url); + } + + #[Test] + public function itThrowsExceptionWhenUrlIsEmpty(): void + { + self::expectException(\InvalidArgumentException::class); + self::expectExceptionMessage('The image url must be given.'); + + new UrlImage(''); + } +} diff --git a/src/platform/tests/Bridge/OpenAI/DallETest.php b/src/platform/tests/Bridge/OpenAI/DallETest.php new file mode 100644 index 000000000..0597b20c5 --- /dev/null +++ b/src/platform/tests/Bridge/OpenAI/DallETest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\OpenAI; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenAI\DallE; + +#[CoversClass(DallE::class)] +#[Small] +final class DallETest extends TestCase +{ + #[Test] + public function itCreatesDallEWithDefaultSettings(): void + { + $dallE = new DallE(); + + self::assertSame(DallE::DALL_E_2, $dallE->getName()); + self::assertSame([], $dallE->getOptions()); + } + + #[Test] + public function itCreatesDallEWithCustomSettings(): void + { + $dallE = new DallE(DallE::DALL_E_3, ['response_format' => 'base64', 'n' => 2]); + + self::assertSame(DallE::DALL_E_3, $dallE->getName()); + self::assertSame(['response_format' => 'base64', 'n' => 2], $dallE->getOptions()); + } +} diff --git a/src/platform/tests/Bridge/OpenAI/Embeddings/ResponseConverterTest.php b/src/platform/tests/Bridge/OpenAI/Embeddings/ResponseConverterTest.php new file mode 100644 index 000000000..48175b06c --- /dev/null +++ b/src/platform/tests/Bridge/OpenAI/Embeddings/ResponseConverterTest.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\OpenAI\Embeddings; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings\ResponseConverter; +use Symfony\AI\Platform\Response\VectorResponse; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\Contracts\HttpClient\ResponseInterface; + +#[CoversClass(ResponseConverter::class)] +#[Small] +#[UsesClass(Vector::class)] +#[UsesClass(VectorResponse::class)] +class ResponseConverterTest extends TestCase +{ + #[Test] + public function itConvertsAResponseToAVectorResponse(): void + { + $response = $this->createStub(ResponseInterface::class); + $response + ->method('toArray') + ->willReturn(json_decode($this->getEmbeddingStub(), true)); + + $vectorResponse = (new ResponseConverter())->convert($response); + $convertedContent = $vectorResponse->getContent(); + + self::assertCount(2, $convertedContent); + + self::assertSame([0.3, 0.4, 0.4], $convertedContent[0]->getData()); + self::assertSame([0.0, 0.0, 0.2], $convertedContent[1]->getData()); + } + + private function getEmbeddingStub(): string + { + return <<<'JSON' + { + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": [0.3, 0.4, 0.4] + }, + { + "object": "embedding", + "index": 1, + "embedding": [0.0, 0.0, 0.2] + } + ] + } + JSON; + } +} diff --git a/src/platform/tests/Bridge/OpenAI/GPT/ResponseConverterTest.php b/src/platform/tests/Bridge/OpenAI/GPT/ResponseConverterTest.php new file mode 100644 index 000000000..71478bd64 --- /dev/null +++ b/src/platform/tests/Bridge/OpenAI/GPT/ResponseConverterTest.php @@ -0,0 +1,192 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\OpenAI\GPT; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenAI\GPT\ResponseConverter; +use Symfony\AI\Platform\Exception\ContentFilterException; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Response\Choice; +use Symfony\AI\Platform\Response\ChoiceResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Response\ToolCallResponse; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +#[CoversClass(ResponseConverter::class)] +#[Small] +#[UsesClass(Choice::class)] +#[UsesClass(ChoiceResponse::class)] +#[UsesClass(TextResponse::class)] +#[UsesClass(ToolCall::class)] +#[UsesClass(ToolCallResponse::class)] +class ResponseConverterTest extends TestCase +{ + public function testConvertTextResponse(): void + { + $converter = new ResponseConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello world', + ], + 'finish_reason' => 'stop', + ], + ], + ]); + + $response = $converter->convert($httpResponse); + + self::assertInstanceOf(TextResponse::class, $response); + self::assertSame('Hello world', $response->getContent()); + } + + public function testConvertToolCallResponse(): void + { + $converter = new ResponseConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => null, + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'test_function', + 'arguments' => '{"arg1": "value1"}', + ], + ], + ], + ], + 'finish_reason' => 'tool_calls', + ], + ], + ]); + + $response = $converter->convert($httpResponse); + + self::assertInstanceOf(ToolCallResponse::class, $response); + $toolCalls = $response->getContent(); + self::assertCount(1, $toolCalls); + self::assertSame('call_123', $toolCalls[0]->id); + self::assertSame('test_function', $toolCalls[0]->name); + self::assertSame(['arg1' => 'value1'], $toolCalls[0]->arguments); + } + + public function testConvertMultipleChoices(): void + { + $converter = new ResponseConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Choice 1', + ], + 'finish_reason' => 'stop', + ], + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Choice 2', + ], + 'finish_reason' => 'stop', + ], + ], + ]); + + $response = $converter->convert($httpResponse); + + self::assertInstanceOf(ChoiceResponse::class, $response); + $choices = $response->getContent(); + self::assertCount(2, $choices); + self::assertSame('Choice 1', $choices[0]->getContent()); + self::assertSame('Choice 2', $choices[1]->getContent()); + } + + public function testContentFilterException(): void + { + $converter = new ResponseConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + + $httpResponse->expects($this->exactly(2)) + ->method('toArray') + ->willReturnCallback(function ($throw = true) { + if ($throw) { + throw new class extends \Exception implements ClientExceptionInterface { + public function getResponse(): ResponseInterface + { + throw new RuntimeException('Not implemented'); + } + }; + } + + return [ + 'error' => [ + 'code' => 'content_filter', + 'message' => 'Content was filtered', + ], + ]; + }); + + self::expectException(ContentFilterException::class); + self::expectExceptionMessage('Content was filtered'); + + $converter->convert($httpResponse); + } + + public function testThrowsExceptionWhenNoChoices(): void + { + $converter = new ResponseConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([]); + + self::expectException(RuntimeException::class); + self::expectExceptionMessage('Response does not contain choices'); + + $converter->convert($httpResponse); + } + + public function testThrowsExceptionForUnsupportedFinishReason(): void + { + $converter = new ResponseConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Test content', + ], + 'finish_reason' => 'unsupported_reason', + ], + ], + ]); + + self::expectException(RuntimeException::class); + self::expectExceptionMessage('Unsupported finish reason "unsupported_reason"'); + + $converter->convert($httpResponse); + } +} diff --git a/src/platform/tests/Bridge/OpenAI/TokenOutputProcessorTest.php b/src/platform/tests/Bridge/OpenAI/TokenOutputProcessorTest.php new file mode 100644 index 000000000..f2ea7595d --- /dev/null +++ b/src/platform/tests/Bridge/OpenAI/TokenOutputProcessorTest.php @@ -0,0 +1,155 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\OpenAI; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Output; +use Symfony\AI\Platform\Bridge\OpenAI\TokenOutputProcessor; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\Metadata\Metadata; +use Symfony\AI\Platform\Response\ResponseInterface; +use Symfony\AI\Platform\Response\StreamResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyHttpResponse; + +#[CoversClass(TokenOutputProcessor::class)] +#[UsesClass(Output::class)] +#[UsesClass(TextResponse::class)] +#[UsesClass(StreamResponse::class)] +#[UsesClass(Metadata::class)] +#[Small] +final class TokenOutputProcessorTest extends TestCase +{ + #[Test] + public function itHandlesStreamResponsesWithoutProcessing(): void + { + $processor = new TokenOutputProcessor(); + $streamResponse = new StreamResponse((static function () { yield 'test'; })()); + $output = $this->createOutput($streamResponse); + + $processor->processOutput($output); + + $metadata = $output->response->getMetadata(); + self::assertCount(0, $metadata); + } + + #[Test] + public function itDoesNothingWithoutRawResponse(): void + { + $processor = new TokenOutputProcessor(); + $textResponse = new TextResponse('test'); + $output = $this->createOutput($textResponse); + + $processor->processOutput($output); + + $metadata = $output->response->getMetadata(); + self::assertCount(0, $metadata); + } + + #[Test] + public function itAddsRemainingTokensToMetadata(): void + { + $processor = new TokenOutputProcessor(); + $textResponse = new TextResponse('test'); + + $textResponse->setRawResponse($this->createRawResponse()); + + $output = $this->createOutput($textResponse); + + $processor->processOutput($output); + + $metadata = $output->response->getMetadata(); + self::assertCount(1, $metadata); + self::assertSame(1000, $metadata->get('remaining_tokens')); + } + + #[Test] + public function itAddsUsageTokensToMetadata(): void + { + $processor = new TokenOutputProcessor(); + $textResponse = new TextResponse('test'); + + $rawResponse = $this->createRawResponse([ + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 20, + 'total_tokens' => 30, + ], + ]); + + $textResponse->setRawResponse($rawResponse); + + $output = $this->createOutput($textResponse); + + $processor->processOutput($output); + + $metadata = $output->response->getMetadata(); + self::assertCount(4, $metadata); + self::assertSame(1000, $metadata->get('remaining_tokens')); + self::assertSame(10, $metadata->get('prompt_tokens')); + self::assertSame(20, $metadata->get('completion_tokens')); + self::assertSame(30, $metadata->get('total_tokens')); + } + + #[Test] + public function itHandlesMissingUsageFields(): void + { + $processor = new TokenOutputProcessor(); + $textResponse = new TextResponse('test'); + + $rawResponse = $this->createRawResponse([ + 'usage' => [ + // Missing some fields + 'prompt_tokens' => 10, + ], + ]); + + $textResponse->setRawResponse($rawResponse); + + $output = $this->createOutput($textResponse); + + $processor->processOutput($output); + + $metadata = $output->response->getMetadata(); + self::assertCount(4, $metadata); + self::assertSame(1000, $metadata->get('remaining_tokens')); + self::assertSame(10, $metadata->get('prompt_tokens')); + self::assertNull($metadata->get('completion_tokens')); + self::assertNull($metadata->get('total_tokens')); + } + + private function createRawResponse(array $data = []): SymfonyHttpResponse + { + $rawResponse = self::createStub(SymfonyHttpResponse::class); + $rawResponse->method('getHeaders')->willReturn([ + 'x-ratelimit-remaining-tokens' => ['1000'], + ]); + $rawResponse->method('toArray')->willReturn($data); + + return $rawResponse; + } + + private function createOutput(ResponseInterface $response): Output + { + return new Output( + self::createStub(Model::class), + $response, + self::createStub(MessageBagInterface::class), + [], + ); + } +} diff --git a/src/platform/tests/Contract/JsonSchema/Attribute/ToolParameterTest.php b/src/platform/tests/Contract/JsonSchema/Attribute/ToolParameterTest.php new file mode 100644 index 000000000..4a2b69d5a --- /dev/null +++ b/src/platform/tests/Contract/JsonSchema/Attribute/ToolParameterTest.php @@ -0,0 +1,263 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Contract\JsonSchema\Attribute; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Webmozart\Assert\InvalidArgumentException; + +#[CoversClass(With::class)] +final class ToolParameterTest extends TestCase +{ + #[Test] + public function validEnum(): void + { + $enum = ['value1', 'value2']; + $toolParameter = new With(enum: $enum); + self::assertSame($enum, $toolParameter->enum); + } + + #[Test] + public function invalidEnumContainsNonString(): void + { + self::expectException(InvalidArgumentException::class); + $enum = ['value1', 2]; + new With(enum: $enum); + } + + #[Test] + public function validConstString(): void + { + $const = 'constant value'; + $toolParameter = new With(const: $const); + self::assertSame($const, $toolParameter->const); + } + + #[Test] + public function invalidConstEmptyString(): void + { + self::expectException(InvalidArgumentException::class); + $const = ' '; + new With(const: $const); + } + + #[Test] + public function validPattern(): void + { + $pattern = '/^[a-z]+$/'; + $toolParameter = new With(pattern: $pattern); + self::assertSame($pattern, $toolParameter->pattern); + } + + #[Test] + public function invalidPatternEmptyString(): void + { + self::expectException(InvalidArgumentException::class); + $pattern = ' '; + new With(pattern: $pattern); + } + + #[Test] + public function validMinLength(): void + { + $minLength = 5; + $toolParameter = new With(minLength: $minLength); + self::assertSame($minLength, $toolParameter->minLength); + } + + #[Test] + public function invalidMinLengthNegative(): void + { + self::expectException(InvalidArgumentException::class); + new With(minLength: -1); + } + + #[Test] + public function validMinLengthAndMaxLength(): void + { + $minLength = 5; + $maxLength = 10; + $toolParameter = new With(minLength: $minLength, maxLength: $maxLength); + self::assertSame($minLength, $toolParameter->minLength); + self::assertSame($maxLength, $toolParameter->maxLength); + } + + #[Test] + public function invalidMaxLengthLessThanMinLength(): void + { + self::expectException(InvalidArgumentException::class); + new With(minLength: 10, maxLength: 5); + } + + #[Test] + public function validMinimum(): void + { + $minimum = 0; + $toolParameter = new With(minimum: $minimum); + self::assertSame($minimum, $toolParameter->minimum); + } + + #[Test] + public function invalidMinimumNegative(): void + { + self::expectException(InvalidArgumentException::class); + new With(minimum: -1); + } + + #[Test] + public function validMultipleOf(): void + { + $multipleOf = 5; + $toolParameter = new With(multipleOf: $multipleOf); + self::assertSame($multipleOf, $toolParameter->multipleOf); + } + + #[Test] + public function invalidMultipleOfNegative(): void + { + self::expectException(InvalidArgumentException::class); + new With(multipleOf: -5); + } + + #[Test] + public function validExclusiveMinimumAndMaximum(): void + { + $exclusiveMinimum = 1; + $exclusiveMaximum = 10; + $toolParameter = new With(exclusiveMinimum: $exclusiveMinimum, exclusiveMaximum: $exclusiveMaximum); + self::assertSame($exclusiveMinimum, $toolParameter->exclusiveMinimum); + self::assertSame($exclusiveMaximum, $toolParameter->exclusiveMaximum); + } + + #[Test] + public function invalidExclusiveMaximumLessThanExclusiveMinimum(): void + { + self::expectException(InvalidArgumentException::class); + new With(exclusiveMinimum: 10, exclusiveMaximum: 5); + } + + #[Test] + public function validMinItemsAndMaxItems(): void + { + $minItems = 1; + $maxItems = 5; + $toolParameter = new With(minItems: $minItems, maxItems: $maxItems); + self::assertSame($minItems, $toolParameter->minItems); + self::assertSame($maxItems, $toolParameter->maxItems); + } + + #[Test] + public function invalidMaxItemsLessThanMinItems(): void + { + self::expectException(InvalidArgumentException::class); + new With(minItems: 5, maxItems: 1); + } + + #[Test] + public function validUniqueItemsTrue(): void + { + $toolParameter = new With(uniqueItems: true); + self::assertTrue($toolParameter->uniqueItems); + } + + #[Test] + public function invalidUniqueItemsFalse(): void + { + self::expectException(InvalidArgumentException::class); + new With(uniqueItems: false); + } + + #[Test] + public function validMinContainsAndMaxContains(): void + { + $minContains = 1; + $maxContains = 3; + $toolParameter = new With(minContains: $minContains, maxContains: $maxContains); + self::assertSame($minContains, $toolParameter->minContains); + self::assertSame($maxContains, $toolParameter->maxContains); + } + + #[Test] + public function invalidMaxContainsLessThanMinContains(): void + { + self::expectException(InvalidArgumentException::class); + new With(minContains: 3, maxContains: 1); + } + + #[Test] + public function validRequired(): void + { + $toolParameter = new With(required: true); + self::assertTrue($toolParameter->required); + } + + #[Test] + public function validMinPropertiesAndMaxProperties(): void + { + $minProperties = 1; + $maxProperties = 5; + $toolParameter = new With(minProperties: $minProperties, maxProperties: $maxProperties); + self::assertSame($minProperties, $toolParameter->minProperties); + self::assertSame($maxProperties, $toolParameter->maxProperties); + } + + #[Test] + public function invalidMaxPropertiesLessThanMinProperties(): void + { + self::expectException(InvalidArgumentException::class); + new With(minProperties: 5, maxProperties: 1); + } + + #[Test] + public function validDependentRequired(): void + { + $toolParameter = new With(dependentRequired: true); + self::assertTrue($toolParameter->dependentRequired); + } + + #[Test] + public function validCombination(): void + { + $toolParameter = new With( + enum: ['value1', 'value2'], + const: 'constant', + pattern: '/^[a-z]+$/', + minLength: 5, + maxLength: 10, + minimum: 0, + maximum: 100, + multipleOf: 5, + exclusiveMinimum: 1, + exclusiveMaximum: 99, + minItems: 1, + maxItems: 10, + uniqueItems: true, + minContains: 1, + maxContains: 5, + required: true, + minProperties: 1, + maxProperties: 5, + dependentRequired: true + ); + + self::assertInstanceOf(With::class, $toolParameter); + } + + #[Test] + public function invalidCombination(): void + { + self::expectException(InvalidArgumentException::class); + new With(minLength: -1, maxLength: -2); + } +} diff --git a/src/platform/tests/Contract/JsonSchema/DescriptionParserTest.php b/src/platform/tests/Contract/JsonSchema/DescriptionParserTest.php new file mode 100644 index 000000000..fb61ee2ed --- /dev/null +++ b/src/platform/tests/Contract/JsonSchema/DescriptionParserTest.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Contract\JsonSchema; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Tests\Fixture\StructuredOutput\User; +use Symfony\AI\Agent\Tests\Fixture\StructuredOutput\UserWithConstructor; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolRequiredParams; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolWithoutDocs; +use Symfony\AI\Platform\Contract\JsonSchema\DescriptionParser; + +#[CoversClass(DescriptionParser::class)] +final class DescriptionParserTest extends TestCase +{ + #[Test] + public function fromPropertyWithoutDocBlock(): void + { + $property = new \ReflectionProperty(User::class, 'id'); + + $actual = (new DescriptionParser())->getDescription($property); + + self::assertSame('', $actual); + } + + #[Test] + public function fromPropertyWithDocBlock(): void + { + $property = new \ReflectionProperty(User::class, 'name'); + + $actual = (new DescriptionParser())->getDescription($property); + + self::assertSame('The name of the user in lowercase', $actual); + } + + #[Test] + public function fromPropertyWithConstructorDocBlock(): void + { + $property = new \ReflectionProperty(UserWithConstructor::class, 'name'); + + $actual = (new DescriptionParser())->getDescription($property); + + self::assertSame('The name of the user in lowercase', $actual); + } + + #[Test] + public function fromParameterWithoutDocBlock(): void + { + $parameter = new \ReflectionParameter([ToolWithoutDocs::class, 'bar'], 'text'); + + $actual = (new DescriptionParser())->getDescription($parameter); + + self::assertSame('', $actual); + } + + #[Test] + public function fromParameterWithDocBlock(): void + { + $parameter = new \ReflectionParameter([ToolRequiredParams::class, 'bar'], 'text'); + + $actual = (new DescriptionParser())->getDescription($parameter); + + self::assertSame('The text given to the tool', $actual); + } + + #[Test] + #[DataProvider('provideMethodDescriptionCases')] + public function fromParameterWithDocs(string $comment, string $expected): void + { + $method = self::createMock(\ReflectionMethod::class); + $method->method('getDocComment')->willReturn($comment); + $parameter = self::createMock(\ReflectionParameter::class); + $parameter->method('getDeclaringFunction')->willReturn($method); + $parameter->method('getName')->willReturn('myParam'); + + $actual = (new DescriptionParser())->getDescription($parameter); + + self::assertSame($expected, $actual); + } + + public static function provideMethodDescriptionCases(): \Generator + { + yield 'empty doc block' => [ + 'comment' => '', + 'expected' => '', + ]; + + yield 'single line doc block with description' => [ + 'comment' => '/** @param string $myParam The description */', + 'expected' => 'The description', + ]; + + yield 'multi line doc block with description and other tags' => [ + 'comment' => <<<'TEXT' + /** + * @param string $myParam The description + * @return void + */ + TEXT, + 'expected' => 'The description', + ]; + + yield 'multi line doc block with multiple parameters' => [ + 'comment' => <<<'TEXT' + /** + * @param string $myParam The description + * @param string $anotherParam The wrong description + */ + TEXT, + 'expected' => 'The description', + ]; + + yield 'multi line doc block with parameter that is not searched for' => [ + 'comment' => <<<'TEXT' + /** + * @param string $unknownParam The description + */ + TEXT, + 'expected' => '', + ]; + } +} diff --git a/src/platform/tests/Contract/JsonSchema/FactoryTest.php b/src/platform/tests/Contract/JsonSchema/FactoryTest.php new file mode 100644 index 000000000..d86621fbd --- /dev/null +++ b/src/platform/tests/Contract/JsonSchema/FactoryTest.php @@ -0,0 +1,251 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Contract\JsonSchema; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Tests\Fixture\StructuredOutput\MathReasoning; +use Symfony\AI\Agent\Tests\Fixture\StructuredOutput\Step; +use Symfony\AI\Agent\Tests\Fixture\StructuredOutput\User; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolNoParams; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolOptionalParam; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolRequiredParams; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolWithToolParameterAttribute; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\AI\Platform\Contract\JsonSchema\DescriptionParser; +use Symfony\AI\Platform\Contract\JsonSchema\Factory; + +#[CoversClass(Factory::class)] +#[UsesClass(With::class)] +#[UsesClass(DescriptionParser::class)] +final class FactoryTest extends TestCase +{ + private Factory $factory; + + protected function setUp(): void + { + $this->factory = new Factory(); + } + + protected function tearDown(): void + { + unset($this->factory); + } + + #[Test] + public function buildParametersDefinitionRequired(): void + { + $actual = $this->factory->buildParameters(ToolRequiredParams::class, 'bar'); + $expected = [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text', 'number'], + 'additionalProperties' => false, + ]; + + self::assertSame($expected, $actual); + } + + #[Test] + public function buildParametersDefinitionRequiredWithAdditionalToolParameterAttribute(): void + { + $actual = $this->factory->buildParameters(ToolWithToolParameterAttribute::class, '__invoke'); + $expected = [ + 'type' => 'object', + 'properties' => [ + 'animal' => [ + 'type' => 'string', + 'description' => 'The animal given to the tool', + 'enum' => ['dog', 'cat', 'bird'], + ], + 'numberOfArticles' => [ + 'type' => 'integer', + 'description' => 'The number of articles given to the tool', + 'const' => 42, + ], + 'infoEmail' => [ + 'type' => 'string', + 'description' => 'The info email given to the tool', + 'const' => 'info@example.de', + ], + 'locales' => [ + 'type' => 'string', + 'description' => 'The locales given to the tool', + 'const' => ['de', 'en'], + ], + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + 'pattern' => '^[a-zA-Z]+$', + 'minLength' => 1, + 'maxLength' => 10, + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'The number given to the tool', + 'minimum' => 1, + 'maximum' => 10, + 'multipleOf' => 2, + 'exclusiveMinimum' => 1, + 'exclusiveMaximum' => 10, + ], + 'products' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => 'The products given to the tool', + 'minItems' => 1, + 'maxItems' => 10, + 'uniqueItems' => true, + 'minContains' => 1, + 'maxContains' => 10, + ], + 'shippingAddress' => [ + 'type' => 'string', + 'description' => 'The shipping address given to the tool', + 'required' => true, + 'minProperties' => 1, + 'maxProperties' => 10, + 'dependentRequired' => true, + ], + ], + 'required' => [ + 'animal', + 'numberOfArticles', + 'infoEmail', + 'locales', + 'text', + 'number', + 'products', + 'shippingAddress', + ], + 'additionalProperties' => false, + ]; + + self::assertSame($expected, $actual); + } + + #[Test] + public function buildParametersDefinitionOptional(): void + { + $actual = $this->factory->buildParameters(ToolOptionalParam::class, 'bar'); + $expected = [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text'], + 'additionalProperties' => false, + ]; + + self::assertSame($expected, $actual); + } + + #[Test] + public function buildParametersDefinitionNone(): void + { + $actual = $this->factory->buildParameters(ToolNoParams::class, '__invoke'); + + self::assertNull($actual); + } + + #[Test] + public function buildPropertiesForUserClass(): void + { + $expected = [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the user in lowercase', + ], + 'createdAt' => [ + 'type' => 'string', + 'format' => 'date-time', + ], + 'isActive' => ['type' => 'boolean'], + 'age' => ['type' => ['integer', 'null']], + ], + 'required' => ['id', 'name', 'createdAt', 'isActive'], + 'additionalProperties' => false, + ]; + + $actual = $this->factory->buildProperties(User::class); + + self::assertSame($expected, $actual); + } + + #[Test] + public function buildPropertiesForMathReasoningClass(): void + { + $expected = [ + 'type' => 'object', + 'properties' => [ + 'steps' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'explanation' => ['type' => 'string'], + 'output' => ['type' => 'string'], + ], + 'required' => ['explanation', 'output'], + 'additionalProperties' => false, + ], + ], + 'finalAnswer' => ['type' => 'string'], + ], + 'required' => ['steps', 'finalAnswer'], + 'additionalProperties' => false, + ]; + + $actual = $this->factory->buildProperties(MathReasoning::class); + + self::assertSame($expected, $actual); + } + + #[Test] + public function buildPropertiesForStepClass(): void + { + $expected = [ + 'type' => 'object', + 'properties' => [ + 'explanation' => ['type' => 'string'], + 'output' => ['type' => 'string'], + ], + 'required' => ['explanation', 'output'], + 'additionalProperties' => false, + ]; + + $actual = $this->factory->buildProperties(Step::class); + + self::assertSame($expected, $actual); + } +} diff --git a/src/platform/tests/Contract/Normalizer/Message/AssistantMessageNormalizerTest.php b/src/platform/tests/Contract/Normalizer/Message/AssistantMessageNormalizerTest.php new file mode 100644 index 000000000..226302676 --- /dev/null +++ b/src/platform/tests/Contract/Normalizer/Message/AssistantMessageNormalizerTest.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Contract\Normalizer\Message; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Contract\Normalizer\Message\AssistantMessageNormalizer; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +#[CoversClass(AssistantMessageNormalizer::class)] +#[UsesClass(AssistantMessage::class)] +#[UsesClass(ToolCall::class)] +final class AssistantMessageNormalizerTest extends TestCase +{ + private AssistantMessageNormalizer $normalizer; + + protected function setUp(): void + { + $this->normalizer = new AssistantMessageNormalizer(); + } + + #[Test] + public function supportsNormalization(): void + { + self::assertTrue($this->normalizer->supportsNormalization(new AssistantMessage('content'))); + self::assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + #[Test] + public function getSupportedTypes(): void + { + self::assertSame([AssistantMessage::class => true], $this->normalizer->getSupportedTypes(null)); + } + + #[Test] + public function normalizeWithContent(): void + { + $message = new AssistantMessage('I am an assistant'); + + $expected = [ + 'role' => 'assistant', + 'content' => 'I am an assistant', + ]; + + self::assertSame($expected, $this->normalizer->normalize($message)); + } + + #[Test] + public function normalizeWithToolCalls(): void + { + $toolCalls = [ + new ToolCall('id1', 'function1', ['param' => 'value']), + new ToolCall('id2', 'function2', ['param' => 'value2']), + ]; + $message = new AssistantMessage('Content with tools', $toolCalls); + + $expectedToolCalls = [ + ['id' => 'id1', 'function' => 'function1', 'arguments' => ['param' => 'value']], + ['id' => 'id2', 'function' => 'function2', 'arguments' => ['param' => 'value2']], + ]; + + $innerNormalizer = $this->createMock(NormalizerInterface::class); + $innerNormalizer->expects(self::once()) + ->method('normalize') + ->with($message->toolCalls, null, []) + ->willReturn($expectedToolCalls); + + $this->normalizer->setNormalizer($innerNormalizer); + + $expected = [ + 'role' => 'assistant', + 'content' => 'Content with tools', + 'tool_calls' => $expectedToolCalls, + ]; + + self::assertSame($expected, $this->normalizer->normalize($message)); + } + + #[Test] + public function normalizeWithNullContent(): void + { + $toolCalls = [new ToolCall('id1', 'function1', ['param' => 'value'])]; + $message = new AssistantMessage(null, $toolCalls); + + $expectedToolCalls = [['id' => 'id1', 'function' => 'function1', 'arguments' => ['param' => 'value']]]; + + $innerNormalizer = $this->createMock(NormalizerInterface::class); + $innerNormalizer->expects(self::once()) + ->method('normalize') + ->with($message->toolCalls, null, []) + ->willReturn($expectedToolCalls); + + $this->normalizer->setNormalizer($innerNormalizer); + + $expected = [ + 'role' => 'assistant', + 'tool_calls' => $expectedToolCalls, + ]; + + self::assertSame($expected, $this->normalizer->normalize($message)); + } +} diff --git a/src/platform/tests/Contract/Normalizer/Message/Content/AudioNormalizerTest.php b/src/platform/tests/Contract/Normalizer/Message/Content/AudioNormalizerTest.php new file mode 100644 index 000000000..35c60630f --- /dev/null +++ b/src/platform/tests/Contract/Normalizer/Message/Content/AudioNormalizerTest.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Contract\Normalizer\Message\Content; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Contract\Normalizer\Message\Content\AudioNormalizer; +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\AI\Platform\Message\Content\File; + +#[CoversClass(AudioNormalizer::class)] +#[UsesClass(Audio::class)] +#[UsesClass(File::class)] +final class AudioNormalizerTest extends TestCase +{ + private AudioNormalizer $normalizer; + + protected function setUp(): void + { + $this->normalizer = new AudioNormalizer(); + } + + #[Test] + public function supportsNormalization(): void + { + self::assertTrue($this->normalizer->supportsNormalization(Audio::fromFile(\dirname(__DIR__, 5).'/Fixture/audio.mp3'))); + self::assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + #[Test] + public function getSupportedTypes(): void + { + self::assertSame([Audio::class => true], $this->normalizer->getSupportedTypes(null)); + } + + #[Test] + #[DataProvider('provideAudioData')] + public function normalize(string $data, string $format, array $expected): void + { + $audio = new Audio(base64_decode($data), $format); + + self::assertSame($expected, $this->normalizer->normalize($audio)); + } + + public static function provideAudioData(): \Generator + { + yield 'mp3 data' => [ + 'SUQzBAAAAAAAfVREUkMAAAAMAAADMg==', + 'audio/mpeg', + [ + 'type' => 'input_audio', + 'input_audio' => [ + 'data' => 'SUQzBAAAAAAAfVREUkMAAAAMAAADMg==', + 'format' => 'mp3', + ], + ], + ]; + + yield 'wav data' => [ + 'UklGRiQAAABXQVZFZm10IBA=', + 'audio/wav', + [ + 'type' => 'input_audio', + 'input_audio' => [ + 'data' => 'UklGRiQAAABXQVZFZm10IBA=', + 'format' => 'wav', + ], + ], + ]; + } +} diff --git a/src/platform/tests/Contract/Normalizer/Message/Content/ImageNormalizerTest.php b/src/platform/tests/Contract/Normalizer/Message/Content/ImageNormalizerTest.php new file mode 100644 index 000000000..5bbcdd4e0 --- /dev/null +++ b/src/platform/tests/Contract/Normalizer/Message/Content/ImageNormalizerTest.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Contract\Normalizer\Message\Content; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Contract\Normalizer\Message\Content\ImageNormalizer; +use Symfony\AI\Platform\Message\Content\File; +use Symfony\AI\Platform\Message\Content\Image; + +#[CoversClass(ImageNormalizer::class)] +#[UsesClass(Image::class)] +#[UsesClass(File::class)] +final class ImageNormalizerTest extends TestCase +{ + private ImageNormalizer $normalizer; + + protected function setUp(): void + { + $this->normalizer = new ImageNormalizer(); + } + + #[Test] + public function supportsNormalization(): void + { + self::assertTrue($this->normalizer->supportsNormalization(Image::fromFile(\dirname(__DIR__, 5).'/Fixture/image.jpg'))); + self::assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + #[Test] + public function getSupportedTypes(): void + { + self::assertSame([Image::class => true], $this->normalizer->getSupportedTypes(null)); + } + + #[Test] + public function normalize(): void + { + $image = Image::fromDataUrl('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABKklEQVR42mNk+A8AAwMhIv9n+Q=='); + + $expected = [ + 'type' => 'image_url', + 'image_url' => ['url' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABKklEQVR42mNk+A8AAwMhIv9n+Q=='], + ]; + + self::assertSame($expected, $this->normalizer->normalize($image)); + } +} diff --git a/src/platform/tests/Contract/Normalizer/Message/Content/ImageUrlNormalizerTest.php b/src/platform/tests/Contract/Normalizer/Message/Content/ImageUrlNormalizerTest.php new file mode 100644 index 000000000..9e2d29b2f --- /dev/null +++ b/src/platform/tests/Contract/Normalizer/Message/Content/ImageUrlNormalizerTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Contract\Normalizer\Message\Content; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Contract\Normalizer\Message\Content\ImageUrlNormalizer; +use Symfony\AI\Platform\Message\Content\ImageUrl; + +#[CoversClass(ImageUrlNormalizer::class)] +#[UsesClass(ImageUrl::class)] +final class ImageUrlNormalizerTest extends TestCase +{ + private ImageUrlNormalizer $normalizer; + + protected function setUp(): void + { + $this->normalizer = new ImageUrlNormalizer(); + } + + #[Test] + public function supportsNormalization(): void + { + self::assertTrue($this->normalizer->supportsNormalization(new ImageUrl('https://example.com/image.jpg'))); + self::assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + #[Test] + public function getSupportedTypes(): void + { + self::assertSame([ImageUrl::class => true], $this->normalizer->getSupportedTypes(null)); + } + + #[Test] + public function normalize(): void + { + $imageUrl = new ImageUrl('https://example.com/image.jpg'); + + $expected = [ + 'type' => 'image_url', + 'image_url' => ['url' => 'https://example.com/image.jpg'], + ]; + + self::assertSame($expected, $this->normalizer->normalize($imageUrl)); + } +} diff --git a/src/platform/tests/Contract/Normalizer/Message/Content/TextNormalizerTest.php b/src/platform/tests/Contract/Normalizer/Message/Content/TextNormalizerTest.php new file mode 100644 index 000000000..3859ddf72 --- /dev/null +++ b/src/platform/tests/Contract/Normalizer/Message/Content/TextNormalizerTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Contract\Normalizer\Message\Content; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Contract\Normalizer\Message\Content\TextNormalizer; +use Symfony\AI\Platform\Message\Content\Text; + +#[CoversClass(TextNormalizer::class)] +#[UsesClass(Text::class)] +final class TextNormalizerTest extends TestCase +{ + private TextNormalizer $normalizer; + + protected function setUp(): void + { + $this->normalizer = new TextNormalizer(); + } + + #[Test] + public function supportsNormalization(): void + { + self::assertTrue($this->normalizer->supportsNormalization(new Text('Hello, world!'))); + self::assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + #[Test] + public function getSupportedTypes(): void + { + self::assertSame([Text::class => true], $this->normalizer->getSupportedTypes(null)); + } + + #[Test] + public function normalize(): void + { + $text = new Text('Hello, world!'); + + $expected = [ + 'type' => 'text', + 'text' => 'Hello, world!', + ]; + + self::assertSame($expected, $this->normalizer->normalize($text)); + } +} diff --git a/src/platform/tests/Contract/Normalizer/Message/MessageBagNormalizerTest.php b/src/platform/tests/Contract/Normalizer/Message/MessageBagNormalizerTest.php new file mode 100644 index 000000000..ad79a7d18 --- /dev/null +++ b/src/platform/tests/Contract/Normalizer/Message/MessageBagNormalizerTest.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Contract\Normalizer\Message; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Contract\Normalizer\Message\MessageBagNormalizer; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Message\SystemMessage; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Model; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +#[CoversClass(MessageBagNormalizer::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(SystemMessage::class)] +#[UsesClass(UserMessage::class)] +#[UsesClass(Text::class)] +#[UsesClass(GPT::class)] +#[UsesClass(Model::class)] +final class MessageBagNormalizerTest extends TestCase +{ + private MessageBagNormalizer $normalizer; + + protected function setUp(): void + { + $this->normalizer = new MessageBagNormalizer(); + } + + #[Test] + public function supportsNormalization(): void + { + $messageBag = $this->createMock(MessageBagInterface::class); + + self::assertTrue($this->normalizer->supportsNormalization($messageBag)); + self::assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + #[Test] + public function getSupportedTypes(): void + { + self::assertSame([MessageBagInterface::class => true], $this->normalizer->getSupportedTypes(null)); + } + + #[Test] + public function normalizeWithoutModel(): void + { + $messages = [ + new SystemMessage('You are a helpful assistant'), + new UserMessage(new Text('Hello')), + ]; + + $messageBag = new MessageBag(...$messages); + + $innerNormalizer = $this->createMock(NormalizerInterface::class); + $innerNormalizer->expects(self::once()) + ->method('normalize') + ->with($messages, null, []) + ->willReturn([ + ['role' => 'system', 'content' => 'You are a helpful assistant'], + ['role' => 'user', 'content' => 'Hello'], + ]); + + $this->normalizer->setNormalizer($innerNormalizer); + + $expected = [ + 'messages' => [ + ['role' => 'system', 'content' => 'You are a helpful assistant'], + ['role' => 'user', 'content' => 'Hello'], + ], + ]; + + self::assertSame($expected, $this->normalizer->normalize($messageBag)); + } + + #[Test] + public function normalizeWithModel(): void + { + $messages = [ + new SystemMessage('You are a helpful assistant'), + new UserMessage(new Text('Hello')), + ]; + + $messageBag = new MessageBag(...$messages); + + $innerNormalizer = $this->createMock(NormalizerInterface::class); + $innerNormalizer->expects(self::once()) + ->method('normalize') + ->with($messages, null, [Contract::CONTEXT_MODEL => new GPT()]) + ->willReturn([ + ['role' => 'system', 'content' => 'You are a helpful assistant'], + ['role' => 'user', 'content' => 'Hello'], + ]); + + $this->normalizer->setNormalizer($innerNormalizer); + + $expected = [ + 'messages' => [ + ['role' => 'system', 'content' => 'You are a helpful assistant'], + ['role' => 'user', 'content' => 'Hello'], + ], + 'model' => 'gpt-4o', + ]; + + self::assertSame($expected, $this->normalizer->normalize($messageBag, context: [ + Contract::CONTEXT_MODEL => new GPT(), + ])); + } +} diff --git a/src/platform/tests/Contract/Normalizer/Message/SystemMessageNormalizerTest.php b/src/platform/tests/Contract/Normalizer/Message/SystemMessageNormalizerTest.php new file mode 100644 index 000000000..a83e37166 --- /dev/null +++ b/src/platform/tests/Contract/Normalizer/Message/SystemMessageNormalizerTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Contract\Normalizer\Message; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Contract\Normalizer\Message\SystemMessageNormalizer; +use Symfony\AI\Platform\Message\SystemMessage; + +#[CoversClass(SystemMessageNormalizer::class)] +#[UsesClass(SystemMessage::class)] +final class SystemMessageNormalizerTest extends TestCase +{ + private SystemMessageNormalizer $normalizer; + + protected function setUp(): void + { + $this->normalizer = new SystemMessageNormalizer(); + } + + #[Test] + public function supportsNormalization(): void + { + self::assertTrue($this->normalizer->supportsNormalization(new SystemMessage('content'))); + self::assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + #[Test] + public function getSupportedTypes(): void + { + self::assertSame([SystemMessage::class => true], $this->normalizer->getSupportedTypes(null)); + } + + #[Test] + public function normalize(): void + { + $message = new SystemMessage('You are a helpful assistant'); + + $expected = [ + 'role' => 'system', + 'content' => 'You are a helpful assistant', + ]; + + self::assertSame($expected, $this->normalizer->normalize($message)); + } +} diff --git a/src/platform/tests/Contract/Normalizer/Message/ToolCallMessageNormalizerTest.php b/src/platform/tests/Contract/Normalizer/Message/ToolCallMessageNormalizerTest.php new file mode 100644 index 000000000..b14b0add6 --- /dev/null +++ b/src/platform/tests/Contract/Normalizer/Message/ToolCallMessageNormalizerTest.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Contract\Normalizer\Message; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Contract\Normalizer\Message\ToolCallMessageNormalizer; +use Symfony\AI\Platform\Message\ToolCallMessage; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +#[CoversClass(ToolCallMessageNormalizer::class)] +#[UsesClass(ToolCallMessage::class)] +#[UsesClass(ToolCall::class)] +final class ToolCallMessageNormalizerTest extends TestCase +{ + private ToolCallMessageNormalizer $normalizer; + + protected function setUp(): void + { + $this->normalizer = new ToolCallMessageNormalizer(); + } + + #[Test] + public function supportsNormalization(): void + { + $toolCallMessage = new ToolCallMessage(new ToolCall('id', 'function'), 'content'); + + self::assertTrue($this->normalizer->supportsNormalization($toolCallMessage)); + self::assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + #[Test] + public function getSupportedTypes(): void + { + self::assertSame([ToolCallMessage::class => true], $this->normalizer->getSupportedTypes(null)); + } + + #[Test] + public function normalize(): void + { + $toolCall = new ToolCall('tool_call_123', 'get_weather', ['location' => 'Paris']); + $message = new ToolCallMessage($toolCall, 'Weather data for Paris'); + $expectedContent = 'Normalized weather data for Paris'; + + $innerNormalizer = $this->createMock(NormalizerInterface::class); + $innerNormalizer->expects(self::once()) + ->method('normalize') + ->with($message->content, null, []) + ->willReturn($expectedContent); + + $this->normalizer->setNormalizer($innerNormalizer); + + $expected = [ + 'role' => 'tool', + 'content' => $expectedContent, + 'tool_call_id' => 'tool_call_123', + ]; + + self::assertSame($expected, $this->normalizer->normalize($message)); + } +} diff --git a/src/platform/tests/Contract/Normalizer/Message/UserMessageNormalizerTest.php b/src/platform/tests/Contract/Normalizer/Message/UserMessageNormalizerTest.php new file mode 100644 index 000000000..6eda0b0bb --- /dev/null +++ b/src/platform/tests/Contract/Normalizer/Message/UserMessageNormalizerTest.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Contract\Normalizer\Message; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Contract\Normalizer\Message\UserMessageNormalizer; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +#[CoversClass(UserMessageNormalizer::class)] +#[UsesClass(UserMessage::class)] +#[UsesClass(Text::class)] +#[UsesClass(ImageUrl::class)] +final class UserMessageNormalizerTest extends TestCase +{ + private UserMessageNormalizer $normalizer; + + protected function setUp(): void + { + $this->normalizer = new UserMessageNormalizer(); + } + + #[Test] + public function supportsNormalization(): void + { + self::assertTrue($this->normalizer->supportsNormalization(new UserMessage(new Text('content')))); + self::assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + #[Test] + public function getSupportedTypes(): void + { + self::assertSame([UserMessage::class => true], $this->normalizer->getSupportedTypes(null)); + } + + #[Test] + public function normalizeWithSingleTextContent(): void + { + $textContent = new Text('Hello, how can you help me?'); + $message = new UserMessage($textContent); + + $expected = [ + 'role' => 'user', + 'content' => 'Hello, how can you help me?', + ]; + + self::assertSame($expected, $this->normalizer->normalize($message)); + } + + #[Test] + public function normalizeWithMixedContent(): void + { + $textContent = new Text('Please describe this image:'); + $imageContent = new ImageUrl('https://example.com/image.jpg'); + $message = new UserMessage($textContent, $imageContent); + + $expectedContent = [ + ['type' => 'text', 'text' => 'Please describe this image:'], + ['type' => 'image', 'url' => 'https://example.com/image.jpg'], + ]; + + $innerNormalizer = $this->createMock(NormalizerInterface::class); + $innerNormalizer->expects(self::once()) + ->method('normalize') + ->with($message->content, null, []) + ->willReturn($expectedContent); + + $this->normalizer->setNormalizer($innerNormalizer); + + $expected = [ + 'role' => 'user', + 'content' => $expectedContent, + ]; + + self::assertSame($expected, $this->normalizer->normalize($message)); + } +} diff --git a/src/platform/tests/Contract/Normalizer/ToolNormalizerTest.php b/src/platform/tests/Contract/Normalizer/ToolNormalizerTest.php new file mode 100644 index 000000000..b08192c42 --- /dev/null +++ b/src/platform/tests/Contract/Normalizer/ToolNormalizerTest.php @@ -0,0 +1,160 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Contract\Normalizer; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolException; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolNoParams; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolOptionalParam; +use Symfony\AI\Agent\Tests\Fixture\Tool\ToolRequiredParams; +use Symfony\AI\Platform\Contract\Normalizer\ToolNormalizer; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +#[CoversClass(ToolNormalizer::class)] +#[Small] +class ToolNormalizerTest extends TestCase +{ + #[Test] + #[DataProvider('provideTools')] + public function normalize(Tool $tool, array $expected): void + { + self::assertSame($expected, (new ToolNormalizer())->normalize($tool)); + } + + public static function provideTools(): \Generator + { + yield 'required params' => [ + new Tool( + new ExecutionReference(ToolRequiredParams::class, 'bar'), + 'tool_required_params', + 'A tool with required parameters', + [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text', 'number'], + 'additionalProperties' => false, + ], + ), + [ + 'type' => 'function', + 'function' => [ + 'name' => 'tool_required_params', + 'description' => 'A tool with required parameters', + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text', 'number'], + 'additionalProperties' => false, + ], + ], + ], + ]; + + yield 'optional param' => [ + new Tool( + new ExecutionReference(ToolOptionalParam::class, 'bar'), + 'tool_optional_param', + 'A tool with one optional parameter', + [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text'], + 'additionalProperties' => false, + ], + ), + [ + 'type' => 'function', + 'function' => [ + 'name' => 'tool_optional_param', + 'description' => 'A tool with one optional parameter', + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', + ], + 'number' => [ + 'type' => 'integer', + 'description' => 'A number given to the tool', + ], + ], + 'required' => ['text'], + 'additionalProperties' => false, + ], + ], + ], + ]; + + yield 'no params' => [ + new Tool( + new ExecutionReference(ToolNoParams::class), + 'tool_no_params', + 'A tool without parameters', + ), + [ + 'type' => 'function', + 'function' => [ + 'name' => 'tool_no_params', + 'description' => 'A tool without parameters', + ], + ], + ]; + + yield 'exception' => [ + new Tool( + new ExecutionReference(ToolException::class, 'bar'), + 'tool_exception', + 'This tool is broken', + ), + [ + 'type' => 'function', + 'function' => [ + 'name' => 'tool_exception', + 'description' => 'This tool is broken', + ], + ], + ]; + } +} diff --git a/src/platform/tests/ContractTest.php b/src/platform/tests/ContractTest.php new file mode 100644 index 000000000..4d237e0db --- /dev/null +++ b/src/platform/tests/ContractTest.php @@ -0,0 +1,217 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Large; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings; +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +use Symfony\AI\Platform\Bridge\OpenAI\Whisper; +use Symfony\AI\Platform\Bridge\OpenAI\Whisper\AudioNormalizer; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Contract\Normalizer\Message\AssistantMessageNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Message\Content\ImageNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Message\Content\ImageUrlNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Message\Content\TextNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Message\MessageBagNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Message\SystemMessageNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Message\ToolCallMessageNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Message\UserMessageNormalizer; +use Symfony\AI\Platform\Contract\Normalizer\Response\ToolCallNormalizer; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\SystemMessage; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Model; + +#[Large] +#[CoversClass(Contract::class)] +#[CoversClass(AssistantMessageNormalizer::class)] +#[CoversClass(AudioNormalizer::class)] +#[CoversClass(ImageNormalizer::class)] +#[CoversClass(ImageUrlNormalizer::class)] +#[CoversClass(TextNormalizer::class)] +#[CoversClass(MessageBagNormalizer::class)] +#[CoversClass(SystemMessageNormalizer::class)] +#[CoversClass(ToolCallMessageNormalizer::class)] +#[CoversClass(UserMessageNormalizer::class)] +#[CoversClass(ToolCallNormalizer::class)] +#[UsesClass(AssistantMessage::class)] +#[UsesClass(MessageBag::class)] +#[UsesClass(SystemMessage::class)] +#[UsesClass(UserMessage::class)] +#[UsesClass(Model::class)] +final class ContractTest extends TestCase +{ + #[Test] + #[DataProvider('providePayloadTestCases')] + public function createRequestPayload(Model $model, array|string|object $input, array|string $expected): void + { + $contract = Contract::create(); + + $actual = $contract->createRequestPayload($model, $input); + + self::assertSame($expected, $actual); + } + + /** + * @return iterable|string + * }> + */ + public static function providePayloadTestCases(): iterable + { + yield 'MessageBag with GPT' => [ + 'model' => new GPT(), + 'input' => new MessageBag( + Message::forSystem('System message'), + Message::ofUser('User message'), + Message::ofAssistant('Assistant message'), + ), + 'expected' => [ + 'messages' => [ + ['role' => 'system', 'content' => 'System message'], + ['role' => 'user', 'content' => 'User message'], + ['role' => 'assistant', 'content' => 'Assistant message'], + ], + 'model' => 'gpt-4o', + ], + ]; + + $audio = Audio::fromFile(\dirname(__DIR__, 2).'/tests/Fixture/audio.mp3'); + yield 'Audio within MessageBag with GPT' => [ + 'model' => new GPT(), + 'input' => new MessageBag(Message::ofUser('What is this recording about?', $audio)), + 'expected' => [ + 'messages' => [ + [ + 'role' => 'user', + 'content' => [ + ['type' => 'text', 'text' => 'What is this recording about?'], + [ + 'type' => 'input_audio', + 'input_audio' => [ + 'data' => $audio->asBase64(), + 'format' => 'mp3', + ], + ], + ], + ], + ], + 'model' => 'gpt-4o', + ], + ]; + + $image = Image::fromFile(\dirname(__DIR__, 2).'/tests/Fixture/image.jpg'); + yield 'Image within MessageBag with GPT' => [ + 'model' => new GPT(), + 'input' => new MessageBag( + Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), + Message::ofUser('Describe the image as a comedian would do it.', $image), + ), + 'expected' => [ + 'messages' => [ + [ + 'role' => 'system', + 'content' => 'You are an image analyzer bot that helps identify the content of images.', + ], + [ + 'role' => 'user', + 'content' => [ + ['type' => 'text', 'text' => 'Describe the image as a comedian would do it.'], + ['type' => 'image_url', 'image_url' => ['url' => $image->asDataUrl()]], + ], + ], + ], + 'model' => 'gpt-4o', + ], + ]; + + yield 'ImageUrl within MessageBag with GPT' => [ + 'model' => new GPT(), + 'input' => new MessageBag( + Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), + Message::ofUser('Describe the image as a comedian would do it.', new ImageUrl('https://example.com/image.jpg')), + ), + 'expected' => [ + 'messages' => [ + [ + 'role' => 'system', + 'content' => 'You are an image analyzer bot that helps identify the content of images.', + ], + [ + 'role' => 'user', + 'content' => [ + ['type' => 'text', 'text' => 'Describe the image as a comedian would do it.'], + ['type' => 'image_url', 'image_url' => ['url' => 'https://example.com/image.jpg']], + ], + ], + ], + 'model' => 'gpt-4o', + ], + ]; + + yield 'Text Input with Embeddings' => [ + 'model' => new Embeddings(), + 'input' => 'This is a test input.', + 'expected' => 'This is a test input.', + ]; + + yield 'Longer Conversation with GPT' => [ + 'model' => new GPT(), + 'input' => new MessageBag( + Message::forSystem('My amazing system prompt.'), + Message::ofAssistant('It is time to sleep.'), + Message::ofUser('Hello, world!'), + new AssistantMessage('Hello User!'), + Message::ofUser('My hint for how to analyze an image.', new ImageUrl('http://image-generator.local/my-fancy-image.png')), + ), + 'expected' => [ + 'messages' => [ + ['role' => 'system', 'content' => 'My amazing system prompt.'], + ['role' => 'assistant', 'content' => 'It is time to sleep.'], + ['role' => 'user', 'content' => 'Hello, world!'], + ['role' => 'assistant', 'content' => 'Hello User!'], + ['role' => 'user', 'content' => [ + ['type' => 'text', 'text' => 'My hint for how to analyze an image.'], + ['type' => 'image_url', 'image_url' => ['url' => 'http://image-generator.local/my-fancy-image.png']], + ]], + ], + 'model' => 'gpt-4o', + ], + ]; + } + + #[Test] + public function extendedContractHandlesWhisper(): void + { + $contract = Contract::create(new AudioNormalizer()); + + $audio = Audio::fromFile(\dirname(__DIR__, 2).'/tests/Fixture/audio.mp3'); + + $actual = $contract->createRequestPayload(new Whisper(), $audio); + + self::assertArrayHasKey('model', $actual); + self::assertSame('whisper-1', $actual['model']); + self::assertArrayHasKey('file', $actual); + self::assertTrue(\is_resource($actual['file'])); + } +} diff --git a/src/platform/tests/Message/AssistantMessageTest.php b/src/platform/tests/Message/AssistantMessageTest.php new file mode 100644 index 000000000..130c00f9f --- /dev/null +++ b/src/platform/tests/Message/AssistantMessageTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\Role; +use Symfony\AI\Platform\Response\ToolCall; + +#[CoversClass(AssistantMessage::class)] +#[UsesClass(ToolCall::class)] +#[Small] +final class AssistantMessageTest extends TestCase +{ + #[Test] + public function theRoleOfTheMessageIsAsExpected(): void + { + self::assertSame(Role::Assistant, (new AssistantMessage())->getRole()); + } + + #[Test] + public function constructionWithoutToolCallIsPossible(): void + { + $message = new AssistantMessage('foo'); + + self::assertSame('foo', $message->content); + self::assertNull($message->toolCalls); + } + + #[Test] + public function constructionWithoutContentIsPossible(): void + { + $toolCall = new ToolCall('foo', 'foo'); + $message = new AssistantMessage(toolCalls: [$toolCall]); + + self::assertNull($message->content); + self::assertSame([$toolCall], $message->toolCalls); + self::assertTrue($message->hasToolCalls()); + } +} diff --git a/src/platform/tests/Message/Content/AudioTest.php b/src/platform/tests/Message/Content/AudioTest.php new file mode 100644 index 000000000..04365694b --- /dev/null +++ b/src/platform/tests/Message/Content/AudioTest.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message\Content; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Message\Content\Audio; + +#[CoversClass(Audio::class)] +#[Small] +final class AudioTest extends TestCase +{ + #[Test] + public function constructWithValidData(): void + { + $audio = new Audio('somedata', 'audio/mpeg'); + + self::assertSame('somedata', $audio->asBinary()); + self::assertSame('audio/mpeg', $audio->getFormat()); + } + + #[Test] + public function fromDataUrlWithValidUrl(): void + { + $dataUrl = 'data:audio/mpeg;base64,SUQzBAAAAAAAfVREUkMAAAAMAAADMg=='; + $audio = Audio::fromDataUrl($dataUrl); + + self::assertSame('SUQzBAAAAAAAfVREUkMAAAAMAAADMg==', $audio->asBase64()); + self::assertSame('audio/mpeg', $audio->getFormat()); + } + + #[Test] + public function fromDataUrlWithInvalidUrl(): void + { + self::expectException(\InvalidArgumentException::class); + self::expectExceptionMessage('Invalid audio data URL format.'); + + Audio::fromDataUrl('invalid-url'); + } + + #[Test] + public function fromFileWithValidPath(): void + { + $audio = Audio::fromFile(\dirname(__DIR__, 3).'/Fixture/audio.mp3'); + + self::assertSame('audio/mpeg', $audio->getFormat()); + self::assertNotEmpty($audio->asBinary()); + } + + #[Test] + public function fromFileWithInvalidPath(): void + { + self::expectException(\InvalidArgumentException::class); + self::expectExceptionMessage('The file "foo.mp3" does not exist or is not readable.'); + + Audio::fromFile('foo.mp3'); + } +} diff --git a/src/platform/tests/Message/Content/BinaryTest.php b/src/platform/tests/Message/Content/BinaryTest.php new file mode 100644 index 000000000..dcc75b4df --- /dev/null +++ b/src/platform/tests/Message/Content/BinaryTest.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message\Content; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Message\Content\File; + +#[CoversClass(File::class)] +#[Small] +final class BinaryTest extends TestCase +{ + #[Test] + public function createFromDataUrl(): void + { + $dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; + + $binary = File::fromDataUrl($dataUrl); + + self::assertSame('image/png', $binary->getFormat()); + self::assertNotEmpty($binary->asBinary()); + self::assertSame('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', $binary->asBase64()); + } + + #[Test] + public function throwsExceptionForInvalidDataUrl(): void + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('Invalid audio data URL format.'); + + File::fromDataUrl('invalid-data-url'); + } + + #[Test] + public function createFromFile(): void + { + $content = 'test file content'; + $filename = sys_get_temp_dir().'/binary-test-file.txt'; + file_put_contents($filename, $content); + + try { + $binary = File::fromFile($filename); + + self::assertSame('text/plain', $binary->getFormat()); + self::assertSame($content, $binary->asBinary()); + } finally { + unlink($filename); + } + } + + #[Test] + #[DataProvider('provideExistingFiles')] + public function createFromExistingFiles(string $filePath, string $expectedFormat): void + { + $binary = File::fromFile($filePath); + + self::assertSame($expectedFormat, $binary->getFormat()); + self::assertNotEmpty($binary->asBinary()); + } + + /** + * @return iterable + */ + public static function provideExistingFiles(): iterable + { + yield 'mp3' => [\dirname(__DIR__, 3).'/Fixture/audio.mp3', 'audio/mpeg']; + yield 'jpg' => [\dirname(__DIR__, 3).'/Fixture/image.jpg', 'image/jpeg']; + } + + #[Test] + public function throwsExceptionForNonExistentFile(): void + { + self::expectException(\InvalidArgumentException::class); + + File::fromFile('/non/existent/file.jpg'); + } + + #[Test] + public function convertToDataUrl(): void + { + $data = 'Hello World'; + $format = 'text/plain'; + $binary = new File($data, $format); + + $dataUrl = $binary->asDataUrl(); + + self::assertSame('data:text/plain;base64,'.base64_encode($data), $dataUrl); + } + + #[Test] + public function roundTripConversion(): void + { + $originalDataUrl = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKNSAwIG9iago8PC9MZW5ndGggNiAwIFIvRmls'; + + $binary = File::fromDataUrl($originalDataUrl); + $resultDataUrl = $binary->asDataUrl(); + + self::assertSame($originalDataUrl, $resultDataUrl); + self::assertSame('application/pdf', $binary->getFormat()); + self::assertSame('JVBERi0xLjQKJcfsj6IKNSAwIG9iago8PC9MZW5ndGggNiAwIFIvRmls', $binary->asBase64()); + } +} diff --git a/src/platform/tests/Message/Content/ImageTest.php b/src/platform/tests/Message/Content/ImageTest.php new file mode 100644 index 000000000..82c099885 --- /dev/null +++ b/src/platform/tests/Message/Content/ImageTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message\Content; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Message\Content\Image; + +#[CoversClass(Image::class)] +final class ImageTest extends TestCase +{ + #[Test] + public function constructWithValidDataUrl(): void + { + $image = Image::fromDataUrl('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABKklEQVR42mNk+A8AAwMhIv9n+X'); + + self::assertStringStartsWith('data:image/png;base64', $image->asDataUrl()); + } + + #[Test] + public function withValidFile(): void + { + $image = Image::fromFile(\dirname(__DIR__, 3).'/Fixture/image.jpg'); + + self::assertStringStartsWith('data:image/jpeg;base64,', $image->asDataUrl()); + } + + #[Test] + public function fromBinaryWithInvalidFile(): void + { + self::expectExceptionMessage('The file "foo.jpg" does not exist or is not readable.'); + + Image::fromFile('foo.jpg'); + } +} diff --git a/src/platform/tests/Message/Content/ImageUrlTest.php b/src/platform/tests/Message/Content/ImageUrlTest.php new file mode 100644 index 000000000..3455d56c0 --- /dev/null +++ b/src/platform/tests/Message/Content/ImageUrlTest.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message\Content; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Message\Content\ImageUrl; + +#[CoversClass(ImageUrl::class)] +#[Small] +final class ImageUrlTest extends TestCase +{ + #[Test] + public function constructWithValidUrl(): void + { + $image = new ImageUrl('https://foo.com/test.png'); + + self::assertSame('https://foo.com/test.png', $image->url); + } +} diff --git a/src/platform/tests/Message/Content/TextTest.php b/src/platform/tests/Message/Content/TextTest.php new file mode 100644 index 000000000..2f791c08a --- /dev/null +++ b/src/platform/tests/Message/Content/TextTest.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message\Content; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Message\Content\Text; + +#[CoversClass(Text::class)] +#[Small] +final class TextTest extends TestCase +{ + #[Test] + public function constructionIsPossible(): void + { + $obj = new Text('foo'); + + self::assertSame('foo', $obj->text); + } +} diff --git a/src/platform/tests/Message/MessageBagTest.php b/src/platform/tests/Message/MessageBagTest.php new file mode 100644 index 000000000..17a0c5a63 --- /dev/null +++ b/src/platform/tests/Message/MessageBagTest.php @@ -0,0 +1,178 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\SystemMessage; +use Symfony\AI\Platform\Message\ToolCallMessage; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Response\ToolCall; + +#[CoversClass(MessageBag::class)] +#[UsesClass(Message::class)] +#[UsesClass(UserMessage::class)] +#[UsesClass(SystemMessage::class)] +#[UsesClass(AssistantMessage::class)] +#[UsesClass(ImageUrl::class)] +#[UsesClass(Text::class)] +#[UsesClass(ToolCall::class)] +#[UsesClass(ToolCallMessage::class)] +#[Small] +final class MessageBagTest extends TestCase +{ + #[Test] + public function getSystemMessage(): void + { + $messageBag = new MessageBag( + Message::forSystem('My amazing system prompt.'), + Message::ofAssistant('It is time to sleep.'), + Message::ofUser('Hello, world!'), + Message::ofToolCall(new ToolCall('tool', 'tool_name', ['param' => 'value']), 'Yes, go sleeping.'), + ); + + $systemMessage = $messageBag->getSystemMessage(); + + self::assertSame('My amazing system prompt.', $systemMessage->content); + } + + #[Test] + public function getSystemMessageWithoutSystemMessage(): void + { + $messageBag = new MessageBag( + Message::ofAssistant('It is time to sleep.'), + Message::ofUser('Hello, world!'), + Message::ofToolCall(new ToolCall('tool', 'tool_name', ['param' => 'value']), 'Yes, go sleeping.'), + ); + + self::assertNull($messageBag->getSystemMessage()); + } + + #[Test] + public function with(): void + { + $messageBag = new MessageBag( + Message::forSystem('My amazing system prompt.'), + Message::ofAssistant('It is time to sleep.'), + Message::ofUser('Hello, world!'), + ); + + $newMessage = Message::ofAssistant('It is time to wake up.'); + $newMessageBag = $messageBag->with($newMessage); + + self::assertCount(3, $messageBag); + self::assertCount(4, $newMessageBag); + + $newMessageFromBag = $newMessageBag->getMessages()[3]; + + self::assertInstanceOf(AssistantMessage::class, $newMessageFromBag); + self::assertSame('It is time to wake up.', $newMessageFromBag->content); + } + + #[Test] + public function merge(): void + { + $messageBag = new MessageBag( + Message::forSystem('My amazing system prompt.'), + Message::ofAssistant('It is time to sleep.'), + Message::ofUser('Hello, world!'), + ); + + $messageBag = $messageBag->merge(new MessageBag( + Message::ofAssistant('It is time to wake up.') + )); + + self::assertCount(4, $messageBag); + + $messageFromBag = $messageBag->getMessages()[3]; + + self::assertInstanceOf(AssistantMessage::class, $messageFromBag); + self::assertSame('It is time to wake up.', $messageFromBag->content); + } + + #[Test] + public function withoutSystemMessage(): void + { + $messageBag = new MessageBag( + Message::forSystem('My amazing system prompt.'), + Message::ofAssistant('It is time to sleep.'), + Message::forSystem('A system prompt in the middle.'), + Message::ofUser('Hello, world!'), + Message::forSystem('Another system prompt at the end'), + ); + + $newMessageBag = $messageBag->withoutSystemMessage(); + + self::assertCount(5, $messageBag); + self::assertCount(2, $newMessageBag); + + $assistantMessage = $newMessageBag->getMessages()[0]; + self::assertInstanceOf(AssistantMessage::class, $assistantMessage); + self::assertSame('It is time to sleep.', $assistantMessage->content); + + $userMessage = $newMessageBag->getMessages()[1]; + self::assertInstanceOf(UserMessage::class, $userMessage); + self::assertInstanceOf(Text::class, $userMessage->content[0]); + self::assertSame('Hello, world!', $userMessage->content[0]->text); + } + + #[Test] + public function prepend(): void + { + $messageBag = new MessageBag( + Message::ofAssistant('It is time to sleep.'), + Message::ofUser('Hello, world!'), + ); + + $newMessage = Message::forSystem('My amazing system prompt.'); + $newMessageBag = $messageBag->prepend($newMessage); + + self::assertCount(2, $messageBag); + self::assertCount(3, $newMessageBag); + + $newMessageBagMessage = $newMessageBag->getMessages()[0]; + + self::assertInstanceOf(SystemMessage::class, $newMessageBagMessage); + self::assertSame('My amazing system prompt.', $newMessageBagMessage->content); + } + + #[Test] + public function containsImageReturnsFalseWithoutImage(): void + { + $messageBag = new MessageBag( + Message::ofAssistant('It is time to sleep.'), + Message::ofUser('Hello, world!'), + ); + + self::assertFalse($messageBag->containsImage()); + } + + #[Test] + public function containsImageReturnsTrueWithImage(): void + { + $messageBag = new MessageBag( + Message::ofAssistant('It is time to sleep.'), + Message::ofUser('Hello, world!'), + Message::ofUser('My hint for how to analyze an image.', new ImageUrl('http://image-generator.local/my-fancy-image.png')), + ); + + self::assertTrue($messageBag->containsImage()); + } +} diff --git a/src/platform/tests/Message/MessageTest.php b/src/platform/tests/Message/MessageTest.php new file mode 100644 index 000000000..a1e3a7d55 --- /dev/null +++ b/src/platform/tests/Message/MessageTest.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\Role; +use Symfony\AI\Platform\Message\SystemMessage; +use Symfony\AI\Platform\Message\ToolCallMessage; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Response\ToolCall; + +#[CoversClass(Message::class)] +#[UsesClass(UserMessage::class)] +#[UsesClass(SystemMessage::class)] +#[UsesClass(AssistantMessage::class)] +#[UsesClass(ToolCallMessage::class)] +#[UsesClass(Role::class)] +#[UsesClass(ToolCall::class)] +#[UsesClass(ImageUrl::class)] +#[UsesClass(Text::class)] +#[Small] +final class MessageTest extends TestCase +{ + #[Test] + public function createSystemMessage(): void + { + $message = Message::forSystem('My amazing system prompt.'); + + self::assertSame('My amazing system prompt.', $message->content); + } + + #[Test] + public function createAssistantMessage(): void + { + $message = Message::ofAssistant('It is time to sleep.'); + + self::assertSame('It is time to sleep.', $message->content); + } + + #[Test] + public function createAssistantMessageWithToolCalls(): void + { + $toolCalls = [ + new ToolCall('call_123456', 'my_tool', ['foo' => 'bar']), + new ToolCall('call_456789', 'my_faster_tool'), + ]; + $message = Message::ofAssistant(toolCalls: $toolCalls); + + self::assertCount(2, $message->toolCalls); + self::assertTrue($message->hasToolCalls()); + } + + #[Test] + public function createUserMessage(): void + { + $message = Message::ofUser('Hi, my name is John.'); + + self::assertCount(1, $message->content); + self::assertInstanceOf(Text::class, $message->content[0]); + self::assertSame('Hi, my name is John.', $message->content[0]->text); + } + + #[Test] + public function createUserMessageWithTextContent(): void + { + $text = new Text('Hi, my name is John.'); + $message = Message::ofUser($text); + + self::assertSame([$text], $message->content); + } + + #[Test] + public function createUserMessageWithImages(): void + { + $message = Message::ofUser( + new Text('Hi, my name is John.'), + new ImageUrl('http://images.local/my-image.png'), + 'The following image is a joke.', + new ImageUrl('http://images.local/my-image2.png'), + ); + + self::assertCount(4, $message->content); + } + + #[Test] + public function createToolCallMessage(): void + { + $toolCall = new ToolCall('call_123456', 'my_tool', ['foo' => 'bar']); + $message = Message::ofToolCall($toolCall, 'Foo bar.'); + + self::assertSame('Foo bar.', $message->content); + self::assertSame($toolCall, $message->toolCall); + } +} diff --git a/src/platform/tests/Message/RoleTest.php b/src/platform/tests/Message/RoleTest.php new file mode 100644 index 000000000..bc74d4c68 --- /dev/null +++ b/src/platform/tests/Message/RoleTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Message\Role; + +#[CoversClass(Role::class)] +#[Small] +final class RoleTest extends TestCase +{ + #[Test] + public function values(): void + { + self::assertSame('system', Role::System->value); + self::assertSame('assistant', Role::Assistant->value); + self::assertSame('user', Role::User->value); + self::assertSame('tool', Role::ToolCall->value); + } + + #[Test] + public function equals(): void + { + self::assertTrue(Role::System->equals(Role::System)); + } + + #[Test] + public function notEquals(): void + { + self::assertTrue(Role::System->notEquals(Role::Assistant)); + } + + #[Test] + public function notEqualsOneOf(): void + { + self::assertTrue(Role::System->notEqualsOneOf([Role::Assistant, Role::User])); + } + + #[Test] + public function equalsOneOf(): void + { + self::assertTrue(Role::System->equalsOneOf([Role::System, Role::User])); + } +} diff --git a/src/platform/tests/Message/SystemMessageTest.php b/src/platform/tests/Message/SystemMessageTest.php new file mode 100644 index 000000000..160f79bc4 --- /dev/null +++ b/src/platform/tests/Message/SystemMessageTest.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Message\Role; +use Symfony\AI\Platform\Message\SystemMessage; + +#[CoversClass(SystemMessage::class)] +#[Small] +final class SystemMessageTest extends TestCase +{ + #[Test] + public function constructionIsPossible(): void + { + $message = new SystemMessage('foo'); + + self::assertSame(Role::System, $message->getRole()); + self::assertSame('foo', $message->content); + } +} diff --git a/src/platform/tests/Message/ToolCallMessageTest.php b/src/platform/tests/Message/ToolCallMessageTest.php new file mode 100644 index 000000000..015cd1070 --- /dev/null +++ b/src/platform/tests/Message/ToolCallMessageTest.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Message\ToolCallMessage; +use Symfony\AI\Platform\Response\ToolCall; + +#[CoversClass(ToolCallMessage::class)] +#[UsesClass(ToolCall::class)] +#[Small] +final class ToolCallMessageTest extends TestCase +{ + #[Test] + public function constructionIsPossible(): void + { + $toolCall = new ToolCall('foo', 'bar'); + $obj = new ToolCallMessage($toolCall, 'bar'); + + self::assertSame($toolCall, $obj->toolCall); + self::assertSame('bar', $obj->content); + } +} diff --git a/src/platform/tests/Message/UserMessageTest.php b/src/platform/tests/Message/UserMessageTest.php new file mode 100644 index 000000000..4d94d6a01 --- /dev/null +++ b/src/platform/tests/Message/UserMessageTest.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Message; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\Role; +use Symfony\AI\Platform\Message\UserMessage; + +#[CoversClass(UserMessage::class)] +#[UsesClass(Text::class)] +#[UsesClass(Audio::class)] +#[UsesClass(ImageUrl::class)] +#[UsesClass(Role::class)] +#[Small] +final class UserMessageTest extends TestCase +{ + #[Test] + public function constructionIsPossible(): void + { + $obj = new UserMessage(new Text('foo')); + + self::assertSame(Role::User, $obj->getRole()); + self::assertCount(1, $obj->content); + self::assertInstanceOf(Text::class, $obj->content[0]); + self::assertSame('foo', $obj->content[0]->text); + } + + #[Test] + public function constructionIsPossibleWithMultipleContent(): void + { + $message = new UserMessage(new Text('foo'), new ImageUrl('https://foo.com/bar.jpg')); + + self::assertCount(2, $message->content); + } + + #[Test] + public function hasAudioContentWithoutAudio(): void + { + $message = new UserMessage(new Text('foo'), new Text('bar')); + + self::assertFalse($message->hasAudioContent()); + } + + #[Test] + public function hasAudioContentWithAudio(): void + { + $message = new UserMessage(new Text('foo'), Audio::fromFile(\dirname(__DIR__, 2).'/Fixture/audio.mp3')); + + self::assertTrue($message->hasAudioContent()); + } + + #[Test] + public function hasImageContentWithoutImage(): void + { + $message = new UserMessage(new Text('foo'), new Text('bar')); + + self::assertFalse($message->hasImageContent()); + } + + #[Test] + public function hasImageContentWithImage(): void + { + $message = new UserMessage(new Text('foo'), new ImageUrl('https://foo.com/bar.jpg')); + + self::assertTrue($message->hasImageContent()); + } +} diff --git a/src/platform/tests/ModelTest.php b/src/platform/tests/ModelTest.php new file mode 100644 index 000000000..30ae5fcba --- /dev/null +++ b/src/platform/tests/ModelTest.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +#[CoversClass(Model::class)] +#[Small] +#[UsesClass(Capability::class)] +final class ModelTest extends TestCase +{ + #[Test] + public function returnsName(): void + { + $model = new Model('gpt-4'); + + self::assertSame('gpt-4', $model->getName()); + } + + #[Test] + public function returnsCapabilities(): void + { + $model = new Model('gpt-4', [Capability::INPUT_TEXT, Capability::OUTPUT_TEXT]); + + self::assertSame([Capability::INPUT_TEXT, Capability::OUTPUT_TEXT], $model->getCapabilities()); + } + + #[Test] + public function checksSupportForCapability(): void + { + $model = new Model('gpt-4', [Capability::INPUT_TEXT, Capability::OUTPUT_TEXT]); + + self::assertTrue($model->supports(Capability::INPUT_TEXT)); + self::assertTrue($model->supports(Capability::OUTPUT_TEXT)); + self::assertFalse($model->supports(Capability::INPUT_IMAGE)); + } + + #[Test] + public function returnsEmptyCapabilitiesByDefault(): void + { + $model = new Model('gpt-4'); + + self::assertSame([], $model->getCapabilities()); + } + + #[Test] + public function returnsOptions(): void + { + $options = [ + 'temperature' => 0.7, + 'max_tokens' => 1024, + ]; + $model = new Model('gpt-4', [], $options); + + self::assertSame($options, $model->getOptions()); + } + + #[Test] + public function returnsEmptyOptionsByDefault(): void + { + $model = new Model('gpt-4'); + + self::assertSame([], $model->getOptions()); + } +} diff --git a/src/platform/tests/Response/AsyncResponseTest.php b/src/platform/tests/Response/AsyncResponseTest.php new file mode 100644 index 000000000..2c906ecd4 --- /dev/null +++ b/src/platform/tests/Response/AsyncResponseTest.php @@ -0,0 +1,169 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Response; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Response\AsyncResponse; +use Symfony\AI\Platform\Response\BaseResponse; +use Symfony\AI\Platform\Response\Exception\RawResponseAlreadySetException; +use Symfony\AI\Platform\Response\Metadata\Metadata; +use Symfony\AI\Platform\Response\ResponseInterface; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\ResponseConverterInterface; +use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyHttpResponse; + +#[CoversClass(AsyncResponse::class)] +#[UsesClass(Metadata::class)] +#[UsesClass(TextResponse::class)] +#[UsesClass(RawResponseAlreadySetException::class)] +#[Small] +final class AsyncResponseTest extends TestCase +{ + #[Test] + public function itUnwrapsTheResponseWhenGettingContent(): void + { + $httpResponse = $this->createStub(SymfonyHttpResponse::class); + $textResponse = new TextResponse('test content'); + + $responseConverter = self::createMock(ResponseConverterInterface::class); + $responseConverter->expects(self::once()) + ->method('convert') + ->with($httpResponse, []) + ->willReturn($textResponse); + + $asyncResponse = new AsyncResponse($responseConverter, $httpResponse); + + self::assertSame('test content', $asyncResponse->getContent()); + } + + #[Test] + public function itConvertsTheResponseOnlyOnce(): void + { + $httpResponse = $this->createStub(SymfonyHttpResponse::class); + $textResponse = new TextResponse('test content'); + + $responseConverter = self::createMock(ResponseConverterInterface::class); + $responseConverter->expects(self::once()) + ->method('convert') + ->with($httpResponse, []) + ->willReturn($textResponse); + + $asyncResponse = new AsyncResponse($responseConverter, $httpResponse); + + // Call unwrap multiple times, but the converter should only be called once + $asyncResponse->unwrap(); + $asyncResponse->unwrap(); + $asyncResponse->getContent(); + } + + #[Test] + public function itGetsRawResponseDirectly(): void + { + $httpResponse = $this->createStub(SymfonyHttpResponse::class); + $responseConverter = $this->createStub(ResponseConverterInterface::class); + + $asyncResponse = new AsyncResponse($responseConverter, $httpResponse); + + self::assertSame($httpResponse, $asyncResponse->getRawResponse()); + } + + #[Test] + public function itThrowsExceptionWhenSettingRawResponse(): void + { + self::expectException(RawResponseAlreadySetException::class); + + $httpResponse = $this->createStub(SymfonyHttpResponse::class); + $responseConverter = $this->createStub(ResponseConverterInterface::class); + + $asyncResponse = new AsyncResponse($responseConverter, $httpResponse); + $asyncResponse->setRawResponse($httpResponse); + } + + #[Test] + public function itSetsRawResponseOnUnwrappedResponseWhenNeeded(): void + { + $httpResponse = $this->createStub(SymfonyHttpResponse::class); + + $unwrappedResponse = $this->createResponse(null); + + $responseConverter = $this->createStub(ResponseConverterInterface::class); + $responseConverter->method('convert')->willReturn($unwrappedResponse); + + $asyncResponse = new AsyncResponse($responseConverter, $httpResponse); + $asyncResponse->unwrap(); + + // The raw response in the model response is now set and not null anymore + self::assertSame($httpResponse, $unwrappedResponse->getRawResponse()); + } + + #[Test] + public function itDoesNotSetRawResponseOnUnwrappedResponseWhenAlreadySet(): void + { + $originHttpResponse = $this->createStub(SymfonyHttpResponse::class); + $anotherHttpResponse = $this->createStub(SymfonyHttpResponse::class); + + $unwrappedResponse = $this->createResponse($anotherHttpResponse); + + $responseConverter = $this->createStub(ResponseConverterInterface::class); + $responseConverter->method('convert')->willReturn($unwrappedResponse); + + $asyncResponse = new AsyncResponse($responseConverter, $originHttpResponse); + $asyncResponse->unwrap(); + + // It is still the same raw response as set initially and so not overwritten + self::assertSame($anotherHttpResponse, $unwrappedResponse->getRawResponse()); + } + + /** + * Workaround for low deps because mocking the ResponseInterface leads to an exception with + * mock creation "Type Traversable|object|array|string|null contains both object and a class type" + * in PHPUnit MockClass. + */ + private function createResponse(?SymfonyHttpResponse $rawResponse): ResponseInterface + { + return new class($rawResponse) extends BaseResponse { + public function __construct(protected ?SymfonyHttpResponse $rawResponse) + { + } + + public function getContent(): string + { + return 'test content'; + } + + public function getRawResponse(): ?SymfonyHttpResponse + { + return $this->rawResponse; + } + }; + } + + #[Test] + public function itPassesOptionsToConverter(): void + { + $httpResponse = $this->createStub(SymfonyHttpResponse::class); + $options = ['option1' => 'value1', 'option2' => 'value2']; + + $responseConverter = self::createMock(ResponseConverterInterface::class); + $responseConverter->expects(self::once()) + ->method('convert') + ->with($httpResponse, $options) + ->willReturn($this->createResponse(null)); + + $asyncResponse = new AsyncResponse($responseConverter, $httpResponse, $options); + $asyncResponse->unwrap(); + } +} diff --git a/src/platform/tests/Response/BaseResponseTest.php b/src/platform/tests/Response/BaseResponseTest.php new file mode 100644 index 000000000..cd4afdbe6 --- /dev/null +++ b/src/platform/tests/Response/BaseResponseTest.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Response; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\Attributes\UsesTrait; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Response\BaseResponse; +use Symfony\AI\Platform\Response\Exception\RawResponseAlreadySetException; +use Symfony\AI\Platform\Response\Metadata\Metadata; +use Symfony\AI\Platform\Response\Metadata\MetadataAwareTrait; +use Symfony\AI\Platform\Response\RawResponseAwareTrait; +use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyHttpResponse; + +#[CoversClass(BaseResponse::class)] +#[UsesTrait(MetadataAwareTrait::class)] +#[UsesTrait(RawResponseAwareTrait::class)] +#[UsesClass(Metadata::class)] +#[UsesClass(RawResponseAlreadySetException::class)] +#[Small] +final class BaseResponseTest extends TestCase +{ + #[Test] + public function itCanHandleMetadata(): void + { + $response = $this->createResponse(); + $metadata = $response->getMetadata(); + + self::assertCount(0, $metadata); + + $metadata->add('key', 'value'); + $metadata = $response->getMetadata(); + + self::assertCount(1, $metadata); + } + + #[Test] + public function itCanBeEnrichedWithARawResponse(): void + { + $response = $this->createResponse(); + $rawResponse = self::createMock(SymfonyHttpResponse::class); + + $response->setRawResponse($rawResponse); + self::assertSame($rawResponse, $response->getRawResponse()); + } + + #[Test] + public function itThrowsAnExceptionWhenSettingARawResponseTwice(): void + { + self::expectException(RawResponseAlreadySetException::class); + + $response = $this->createResponse(); + $rawResponse = self::createMock(SymfonyHttpResponse::class); + + $response->setRawResponse($rawResponse); + $response->setRawResponse($rawResponse); + } + + private function createResponse(): BaseResponse + { + return new class extends BaseResponse { + public function getContent(): string + { + return 'test'; + } + }; + } +} diff --git a/src/platform/tests/Response/ChoiceResponseTest.php b/src/platform/tests/Response/ChoiceResponseTest.php new file mode 100644 index 000000000..f2938cd67 --- /dev/null +++ b/src/platform/tests/Response/ChoiceResponseTest.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Response; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Response\Choice; +use Symfony\AI\Platform\Response\ChoiceResponse; + +#[CoversClass(ChoiceResponse::class)] +#[UsesClass(Choice::class)] +#[Small] +final class ChoiceResponseTest extends TestCase +{ + #[Test] + public function choiceResponseCreation(): void + { + $choice1 = new Choice('choice1'); + $choice2 = new Choice(null); + $choice3 = new Choice('choice3'); + $response = new ChoiceResponse($choice1, $choice2, $choice3); + + self::assertCount(3, $response->getContent()); + self::assertSame('choice1', $response->getContent()[0]->getContent()); + self::assertNull($response->getContent()[1]->getContent()); + self::assertSame('choice3', $response->getContent()[2]->getContent()); + } + + #[Test] + public function choiceResponseWithNoChoices(): void + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('Response must have at least one choice.'); + + new ChoiceResponse(); + } +} diff --git a/src/platform/tests/Response/ChoiceTest.php b/src/platform/tests/Response/ChoiceTest.php new file mode 100644 index 000000000..8fcff2324 --- /dev/null +++ b/src/platform/tests/Response/ChoiceTest.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Response; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Response\Choice; +use Symfony\AI\Platform\Response\ToolCall; + +#[CoversClass(Choice::class)] +#[UsesClass(ToolCall::class)] +#[Small] +final class ChoiceTest extends TestCase +{ + #[Test] + public function choiceEmpty(): void + { + $choice = new Choice(); + self::assertFalse($choice->hasContent()); + self::assertNull($choice->getContent()); + self::assertFalse($choice->hasToolCall()); + self::assertCount(0, $choice->getToolCalls()); + } + + #[Test] + public function choiceWithContent(): void + { + $choice = new Choice('content'); + self::assertTrue($choice->hasContent()); + self::assertSame('content', $choice->getContent()); + self::assertFalse($choice->hasToolCall()); + self::assertCount(0, $choice->getToolCalls()); + } + + #[Test] + public function choiceWithToolCall(): void + { + $choice = new Choice(null, [new ToolCall('name', 'arguments')]); + self::assertFalse($choice->hasContent()); + self::assertNull($choice->getContent()); + self::assertTrue($choice->hasToolCall()); + self::assertCount(1, $choice->getToolCalls()); + } + + #[Test] + public function choiceWithContentAndToolCall(): void + { + $choice = new Choice('content', [new ToolCall('name', 'arguments')]); + self::assertTrue($choice->hasContent()); + self::assertSame('content', $choice->getContent()); + self::assertTrue($choice->hasToolCall()); + self::assertCount(1, $choice->getToolCalls()); + } +} diff --git a/src/platform/tests/Response/Exception/RawResponseAlreadySetTest.php b/src/platform/tests/Response/Exception/RawResponseAlreadySetTest.php new file mode 100644 index 000000000..1cb275586 --- /dev/null +++ b/src/platform/tests/Response/Exception/RawResponseAlreadySetTest.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Response\Exception; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Response\Exception\RawResponseAlreadySetException; + +#[CoversClass(RawResponseAlreadySetException::class)] +#[Small] +final class RawResponseAlreadySetTest extends TestCase +{ + #[Test] + public function itHasCorrectExceptionMessage(): void + { + $exception = new RawResponseAlreadySetException(); + + self::assertSame('The raw response was already set.', $exception->getMessage()); + } +} diff --git a/src/platform/tests/Response/Metadata/MetadataAwareTraitTest.php b/src/platform/tests/Response/Metadata/MetadataAwareTraitTest.php new file mode 100644 index 000000000..5edb33372 --- /dev/null +++ b/src/platform/tests/Response/Metadata/MetadataAwareTraitTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Response\Metadata; + +use PHPUnit\Framework\Attributes\CoversTrait; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Response\Metadata\Metadata; +use Symfony\AI\Platform\Response\Metadata\MetadataAwareTrait; + +#[CoversTrait(MetadataAwareTrait::class)] +#[Small] +#[UsesClass(Metadata::class)] +final class MetadataAwareTraitTest extends TestCase +{ + #[Test] + public function itCanHandleMetadata(): void + { + $response = $this->createTestClass(); + $metadata = $response->getMetadata(); + + self::assertCount(0, $metadata); + + $metadata->add('key', 'value'); + $metadata = $response->getMetadata(); + + self::assertCount(1, $metadata); + } + + private function createTestClass(): object + { + return new class { + use MetadataAwareTrait; + }; + } +} diff --git a/src/platform/tests/Response/Metadata/MetadataTest.php b/src/platform/tests/Response/Metadata/MetadataTest.php new file mode 100644 index 000000000..f55e62b8a --- /dev/null +++ b/src/platform/tests/Response/Metadata/MetadataTest.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Response\Metadata; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Response\Metadata\Metadata; + +#[CoversClass(Metadata::class)] +#[Small] +final class MetadataTest extends TestCase +{ + #[Test] + public function itCanBeCreatedEmpty(): void + { + $metadata = new Metadata(); + self::assertCount(0, $metadata); + self::assertSame([], $metadata->all()); + } + + #[Test] + public function itCanBeCreatedWithInitialData(): void + { + $metadata = new Metadata(['key' => 'value']); + self::assertCount(1, $metadata); + self::assertSame(['key' => 'value'], $metadata->all()); + } + + #[Test] + public function itCanAddNewMetadata(): void + { + $metadata = new Metadata(); + $metadata->add('key', 'value'); + + self::assertTrue($metadata->has('key')); + self::assertSame('value', $metadata->get('key')); + } + + #[Test] + public function itCanCheckIfMetadataExists(): void + { + $metadata = new Metadata(['key' => 'value']); + + self::assertTrue($metadata->has('key')); + self::assertFalse($metadata->has('nonexistent')); + } + + #[Test] + public function itCanGetMetadataWithDefault(): void + { + $metadata = new Metadata(['key' => 'value']); + + self::assertSame('value', $metadata->get('key')); + self::assertSame('default', $metadata->get('nonexistent', 'default')); + self::assertNull($metadata->get('nonexistent')); + } + + #[Test] + public function itCanRemoveMetadata(): void + { + $metadata = new Metadata(['key' => 'value']); + self::assertTrue($metadata->has('key')); + + $metadata->remove('key'); + self::assertFalse($metadata->has('key')); + } + + #[Test] + public function itCanSetEntireMetadataArray(): void + { + $metadata = new Metadata(['key1' => 'value1']); + $metadata->set(['key2' => 'value2', 'key3' => 'value3']); + + self::assertFalse($metadata->has('key1')); + self::assertTrue($metadata->has('key2')); + self::assertTrue($metadata->has('key3')); + self::assertSame(['key2' => 'value2', 'key3' => 'value3'], $metadata->all()); + } + + #[Test] + public function itImplementsJsonSerializable(): void + { + $metadata = new Metadata(['key' => 'value']); + self::assertSame(['key' => 'value'], $metadata->jsonSerialize()); + } + + #[Test] + public function itImplementsArrayAccess(): void + { + $metadata = new Metadata(['key' => 'value']); + + self::assertArrayHasKey('key', $metadata); + self::assertSame('value', $metadata['key']); + + $metadata['new'] = 'newValue'; + self::assertSame('newValue', $metadata['new']); + + unset($metadata['key']); + self::assertArrayNotHasKey('key', $metadata); + } + + #[Test] + public function itImplementsIteratorAggregate(): void + { + $metadata = new Metadata(['key1' => 'value1', 'key2' => 'value2']); + $result = iterator_to_array($metadata); + + self::assertSame(['key1' => 'value1', 'key2' => 'value2'], $result); + } + + #[Test] + public function itImplementsCountable(): void + { + $metadata = new Metadata(); + self::assertCount(0, $metadata); + + $metadata->add('key', 'value'); + self::assertCount(1, $metadata); + + $metadata->add('key2', 'value2'); + self::assertCount(2, $metadata); + + $metadata->remove('key'); + self::assertCount(1, $metadata); + } +} diff --git a/src/platform/tests/Response/RawResponseAwareTraitTest.php b/src/platform/tests/Response/RawResponseAwareTraitTest.php new file mode 100644 index 000000000..03ad37df4 --- /dev/null +++ b/src/platform/tests/Response/RawResponseAwareTraitTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Response; + +use PHPUnit\Framework\Attributes\CoversTrait; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Response\Exception\RawResponseAlreadySetException; +use Symfony\AI\Platform\Response\RawResponseAwareTrait; +use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyHttpResponse; + +#[CoversTrait(RawResponseAwareTrait::class)] +#[Small] +#[UsesClass(RawResponseAlreadySetException::class)] +final class RawResponseAwareTraitTest extends TestCase +{ + #[Test] + public function itCanBeEnrichedWithARawResponse(): void + { + $response = $this->createTestClass(); + $rawResponse = self::createMock(SymfonyHttpResponse::class); + + $response->setRawResponse($rawResponse); + self::assertSame($rawResponse, $response->getRawResponse()); + } + + #[Test] + public function itThrowsAnExceptionWhenSettingARawResponseTwice(): void + { + self::expectException(RawResponseAlreadySetException::class); + + $response = $this->createTestClass(); + $rawResponse = self::createMock(SymfonyHttpResponse::class); + + $response->setRawResponse($rawResponse); + $response->setRawResponse($rawResponse); + } + + private function createTestClass(): object + { + return new class { + use RawResponseAwareTrait; + }; + } +} diff --git a/src/platform/tests/Response/StreamResponseTest.php b/src/platform/tests/Response/StreamResponseTest.php new file mode 100644 index 000000000..3242f9749 --- /dev/null +++ b/src/platform/tests/Response/StreamResponseTest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Response; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Response\StreamResponse; + +#[CoversClass(StreamResponse::class)] +#[Small] +final class StreamResponseTest extends TestCase +{ + #[Test] + public function getContent(): void + { + $generator = (function () { + yield 'data1'; + yield 'data2'; + })(); + + $response = new StreamResponse($generator); + self::assertInstanceOf(\Generator::class, $response->getContent()); + + $content = iterator_to_array($response->getContent()); + + self::assertCount(2, $content); + self::assertSame('data1', $content[0]); + self::assertSame('data2', $content[1]); + } +} diff --git a/src/platform/tests/Response/StructuredResponseTest.php b/src/platform/tests/Response/StructuredResponseTest.php new file mode 100644 index 000000000..69c1232f8 --- /dev/null +++ b/src/platform/tests/Response/StructuredResponseTest.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Response; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Response\ObjectResponse; + +#[CoversClass(ObjectResponse::class)] +#[Small] +final class StructuredResponseTest extends TestCase +{ + #[Test] + public function getContentWithArray(): void + { + $response = new ObjectResponse($expected = ['foo' => 'bar', 'baz' => ['qux']]); + self::assertSame($expected, $response->getContent()); + } + + #[Test] + public function getContentWithObject(): void + { + $response = new ObjectResponse($expected = (object) ['foo' => 'bar', 'baz' => ['qux']]); + self::assertSame($expected, $response->getContent()); + } +} diff --git a/src/platform/tests/Response/TextResponseTest.php b/src/platform/tests/Response/TextResponseTest.php new file mode 100644 index 000000000..9a2fd13c2 --- /dev/null +++ b/src/platform/tests/Response/TextResponseTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Response; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Response\TextResponse; + +#[CoversClass(TextResponse::class)] +#[Small] +final class TextResponseTest extends TestCase +{ + #[Test] + public function getContent(): void + { + $response = new TextResponse($expected = 'foo'); + self::assertSame($expected, $response->getContent()); + } +} diff --git a/src/platform/tests/Response/TollCallResponseTest.php b/src/platform/tests/Response/TollCallResponseTest.php new file mode 100644 index 000000000..8978f17ec --- /dev/null +++ b/src/platform/tests/Response/TollCallResponseTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Response; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Response\ToolCallResponse; + +#[CoversClass(ToolCallResponse::class)] +#[UsesClass(ToolCall::class)] +#[Small] +final class TollCallResponseTest extends TestCase +{ + #[Test] + public function throwsIfNoToolCall(): void + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('Response must have at least one tool call.'); + + new ToolCallResponse(); + } + + #[Test] + public function getContent(): void + { + $response = new ToolCallResponse($toolCall = new ToolCall('ID', 'name', ['foo' => 'bar'])); + self::assertSame([$toolCall], $response->getContent()); + } +} diff --git a/src/platform/tests/Response/ToolCallTest.php b/src/platform/tests/Response/ToolCallTest.php new file mode 100644 index 000000000..5737c6b85 --- /dev/null +++ b/src/platform/tests/Response/ToolCallTest.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Response; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Response\ToolCall; + +#[CoversClass(ToolCall::class)] +#[Small] +final class ToolCallTest extends TestCase +{ + #[Test] + public function toolCall(): void + { + $toolCall = new ToolCall('id', 'name', ['foo' => 'bar']); + self::assertSame('id', $toolCall->id); + self::assertSame('name', $toolCall->name); + self::assertSame(['foo' => 'bar'], $toolCall->arguments); + } + + #[Test] + public function toolCallJsonSerialize(): void + { + $toolCall = new ToolCall('id', 'name', ['foo' => 'bar']); + self::assertSame([ + 'id' => 'id', + 'type' => 'function', + 'function' => [ + 'name' => 'name', + 'arguments' => '{"foo":"bar"}', + ], + ], $toolCall->jsonSerialize()); + } +} diff --git a/src/store/.gitattributes b/src/store/.gitattributes new file mode 100644 index 000000000..ec8c01802 --- /dev/null +++ b/src/store/.gitattributes @@ -0,0 +1,6 @@ +/.github export-ignore +/tests export-ignore +.gitattributes export-ignore +.gitignore export-ignore +phpstan.dist.neon export-ignore +phpunit.xml.dist export-ignore diff --git a/src/store/.github/PULL_REQUEST_TEMPLATE.md b/src/store/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..fcb87228a --- /dev/null +++ b/src/store/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ai + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/store/.github/workflows/close-pull-request.yml b/src/store/.github/workflows/close-pull-request.yml new file mode 100644 index 000000000..207153fd5 --- /dev/null +++ b/src/store/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ai + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/store/.gitignore b/src/store/.gitignore new file mode 100644 index 000000000..f43db636b --- /dev/null +++ b/src/store/.gitignore @@ -0,0 +1,3 @@ +composer.lock +vendor +.phpunit.cache diff --git a/src/store/LICENSE b/src/store/LICENSE new file mode 100644 index 000000000..bc38d714e --- /dev/null +++ b/src/store/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/store/composer.json b/src/store/composer.json new file mode 100644 index 000000000..60b90fdbd --- /dev/null +++ b/src/store/composer.json @@ -0,0 +1,75 @@ +{ + "name": "symfony/ai-store", + "type": "library", + "description": "PHP library for abstracting interaction with data stores in AI applications.", + "keywords": [ + "ai", + "mongodb", + "pinecone", + "chromadb" + ], + "license": "MIT", + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + } + ], + "require": { + "php": ">=8.2", + "ext-fileinfo": "*", + "oskarstark/enum-helper": "^1.5", + "phpdocumentor/reflection-docblock": "^5.4", + "phpstan/phpdoc-parser": "^2.1", + "psr/cache": "^3.0", + "psr/log": "^3.0", + "symfony/clock": "^6.4 || ^7.1", + "symfony/http-client": "^6.4 || ^7.1", + "symfony/property-access": "^6.4 || ^7.1", + "symfony/property-info": "^6.4 || ^7.1", + "symfony/serializer": "^6.4 || ^7.1", + "symfony/type-info": "^7.2.3", + "symfony/uid": "^6.4 || ^7.1", + "webmozart/assert": "^1.11" + }, + "conflict": { + "mongodb/mongodb": "<1.21" + }, + "require-dev": { + "codewithkyrian/chromadb-php": "^0.2.1 || ^0.3 || ^0.4", + "mongodb/mongodb": "^1.21", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^11.5", + "probots-io/pinecone-php": "^1.0", + "symfony/console": "^6.4 || ^7.1", + "symfony/dotenv": "^6.4 || ^7.1", + "symfony/event-dispatcher": "^6.4 || ^7.1", + "symfony/finder": "^6.4 || ^7.1", + "symfony/process": "^6.4 || ^7.1", + "symfony/var-dumper": "^6.4 || ^7.1" + }, + "suggest": { + "codewithkyrian/chromadb-php": "For using the ChromaDB as retrieval vector store.", + "mongodb/mongodb": "For using MongoDB Atlas as retrieval vector store.", + "probots-io/pinecone-php": "For using the Pinecone as retrieval vector store." + }, + "config": { + "sort-packages": true + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\Store\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\Store\\Tests\\": "tests/" + } + } +} diff --git a/src/store/phpstan.dist.neon b/src/store/phpstan.dist.neon new file mode 100644 index 000000000..8cc83f644 --- /dev/null +++ b/src/store/phpstan.dist.neon @@ -0,0 +1,10 @@ +includes: + - vendor/phpstan/phpstan-webmozart-assert/extension.neon + - vendor/phpstan/phpstan-symfony/extension.neon + +parameters: + level: 6 + paths: + - src/ + - tests/ + diff --git a/src/store/phpunit.xml.dist b/src/store/phpunit.xml.dist new file mode 100644 index 000000000..4e9e3a684 --- /dev/null +++ b/src/store/phpunit.xml.dist @@ -0,0 +1,24 @@ + + + + + tests + + + + + + src + + + diff --git a/src/store/src/Bridge/Azure/SearchStore.php b/src/store/src/Bridge/Azure/SearchStore.php new file mode 100644 index 000000000..a2b945e5e --- /dev/null +++ b/src/store/src/Bridge/Azure/SearchStore.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Bridge\Azure; + +use Symfony\AI\Platform\Vector\NullVector; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\VectorStoreInterface; +use Symfony\Component\Uid\Uuid; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Christopher Hertel + */ +final readonly class SearchStore implements VectorStoreInterface +{ + /** + * @param string $vectorFieldName The name of the field int the index that contains the vector + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $endpointUrl, + #[\SensitiveParameter] private string $apiKey, + private string $indexName, + private string $apiVersion, + private string $vectorFieldName = 'vector', + ) { + } + + public function add(VectorDocument ...$documents): void + { + $this->request('index', [ + 'value' => array_map([$this, 'convertToIndexableArray'], $documents), + ]); + } + + public function query(Vector $vector, array $options = [], ?float $minScore = null): array + { + $result = $this->request('search', [ + 'vectorQueries' => [$this->buildVectorQuery($vector)], + ]); + + return array_map([$this, 'convertToVectorDocument'], $result['value']); + } + + /** + * @param array $payload + * + * @return array + */ + private function request(string $endpoint, array $payload): array + { + $url = \sprintf('%s/indexes/%s/docs/%s', $this->endpointUrl, $this->indexName, $endpoint); + $response = $this->httpClient->request('POST', $url, [ + 'headers' => [ + 'api-key' => $this->apiKey, + ], + 'query' => ['api-version' => $this->apiVersion], + 'json' => $payload, + ]); + + return $response->toArray(); + } + + /** + * @return array + */ + private function convertToIndexableArray(VectorDocument $document): array + { + return array_merge([ + 'id' => $document->id, + $this->vectorFieldName => $document->vector->getData(), + ], $document->metadata->getArrayCopy()); + } + + /** + * @param array $data + */ + private function convertToVectorDocument(array $data): VectorDocument + { + return new VectorDocument( + id: Uuid::fromString($data['id']), + vector: !\array_key_exists($this->vectorFieldName, $data) || null === $data[$this->vectorFieldName] + ? new NullVector() + : new Vector($data[$this->vectorFieldName]), + metadata: new Metadata($data), + ); + } + + /** + * @return array{ + * kind: 'vector', + * vector: float[], + * exhaustive: true, + * fields: non-empty-string, + * weight: float, + * k: int, + * } + */ + private function buildVectorQuery(Vector $vector): array + { + return [ + 'kind' => 'vector', + 'vector' => $vector->getData(), + 'exhaustive' => true, + 'fields' => $this->vectorFieldName, + 'weight' => 0.5, + 'k' => 5, + ]; + } +} diff --git a/src/store/src/Bridge/ChromaDB/Store.php b/src/store/src/Bridge/ChromaDB/Store.php new file mode 100644 index 000000000..27dc1053f --- /dev/null +++ b/src/store/src/Bridge/ChromaDB/Store.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Bridge\ChromaDB; + +use Codewithkyrian\ChromaDB\Client; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\VectorStoreInterface; +use Symfony\Component\Uid\Uuid; + +/** + * @author Christopher Hertel + */ +final readonly class Store implements VectorStoreInterface +{ + public function __construct( + private Client $client, + private string $collectionName, + ) { + } + + public function add(VectorDocument ...$documents): void + { + $ids = []; + $vectors = []; + $metadata = []; + foreach ($documents as $document) { + $ids[] = (string) $document->id; + $vectors[] = $document->vector->getData(); + $metadata[] = $document->metadata->getArrayCopy(); + } + + $collection = $this->client->getOrCreateCollection($this->collectionName); + $collection->add($ids, $vectors, $metadata); + } + + public function query(Vector $vector, array $options = [], ?float $minScore = null): array + { + $collection = $this->client->getOrCreateCollection($this->collectionName); + $queryResponse = $collection->query( + queryEmbeddings: [$vector->getData()], + nResults: 4, + ); + + $documents = []; + for ($i = 0; $i < \count($queryResponse->metadatas[0]); ++$i) { + $documents[] = new VectorDocument( + id: Uuid::fromString($queryResponse->ids[0][$i]), + vector: new Vector($queryResponse->embeddings[0][$i]), + metadata: new Metadata($queryResponse->metadatas[0][$i]), + ); + } + + return $documents; + } +} diff --git a/src/store/src/Bridge/MongoDB/Store.php b/src/store/src/Bridge/MongoDB/Store.php new file mode 100644 index 000000000..bf6b90098 --- /dev/null +++ b/src/store/src/Bridge/MongoDB/Store.php @@ -0,0 +1,196 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Bridge\MongoDB; + +use MongoDB\BSON\Binary; +use MongoDB\Client; +use MongoDB\Collection; +use MongoDB\Driver\Exception\CommandException; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\Exception\InvalidArgumentException; +use Symfony\AI\Store\InitializableStoreInterface; +use Symfony\AI\Store\VectorStoreInterface; +use Symfony\Component\Uid\Uuid; + +/** + * @see https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-overview/ + * + * For this store you need to create a separate MongoDB Atlas Search index. + * The index needs to be created with the following settings: + * { + * "fields": [ + * { + * "numDimensions": 1536, + * "path": "vector", + * "similarity": "euclidean", + * "type": "vector" + * } + * ] + * } + * + * Note, that the `path` key needs to match the $vectorFieldName. + * + * For the `similarity` key you can choose between `euclidean`, `cosine` and `dotProduct`. + * {@see https://www.mongodb.com/docs/atlas/atlas-search/field-types/knn-vector/#define-the-index-for-the-fts-field-type-type} + * + * @author Oskar Stark + */ +final readonly class Store implements VectorStoreInterface, InitializableStoreInterface +{ + /** + * @param string $databaseName The name of the database + * @param string $collectionName The name of the collection + * @param string $indexName The name of the Atlas Search index + * @param string $vectorFieldName The name of the field int the index that contains the vector + * @param bool $bulkWrite Use bulk write operations + */ + public function __construct( + private Client $client, + private string $databaseName, + private string $collectionName, + private string $indexName, + private string $vectorFieldName = 'vector', + private bool $bulkWrite = false, + private LoggerInterface $logger = new NullLogger(), + ) { + } + + public function add(VectorDocument ...$documents): void + { + $operations = []; + + foreach ($documents as $document) { + $operation = [ + ['_id' => $this->toBinary($document->id)], // we use binary for the id, because of storage efficiency + array_filter([ + 'metadata' => $document->metadata->getArrayCopy(), + $this->vectorFieldName => $document->vector->getData(), + ]), + ['upsert' => true], // insert if not exists + ]; + + if ($this->bulkWrite) { + $operations[] = ['replaceOne' => $operation]; + continue; + } + + $this->getCollection()->replaceOne(...$operation); + } + + if ($this->bulkWrite) { + $this->getCollection()->bulkWrite($operations); + } + } + + /** + * @param array{ + * limit?: positive-int, + * numCandidates?: positive-int, + * filter?: array + * } $options + */ + public function query(Vector $vector, array $options = [], ?float $minScore = null): array + { + $pipeline = [ + [ + '$vectorSearch' => array_merge([ + 'index' => $this->indexName, + 'path' => $this->vectorFieldName, + 'queryVector' => $vector->getData(), + 'numCandidates' => 200, + 'limit' => 5, + ], $options), + ], + [ + '$addFields' => [ + 'score' => ['$meta' => 'vectorSearchScore'], + ], + ], + ]; + + if (null !== $minScore) { + $pipeline[] = [ + '$match' => [ + 'score' => ['$gte' => $minScore], + ], + ]; + } + + $results = $this->getCollection()->aggregate( + $pipeline, + ['typeMap' => ['root' => 'array', 'document' => 'array', 'array' => 'array']] + ); + + $documents = []; + + foreach ($results as $result) { + $documents[] = new VectorDocument( + id: $this->toUuid($result['_id']), + vector: new Vector($result[$this->vectorFieldName]), + metadata: new Metadata($result['metadata'] ?? []), + score: $result['score'], + ); + } + + return $documents; + } + + /** + * @param array{fields?: array} $options + */ + public function initialize(array $options = []): void + { + if ([] !== $options && !\array_key_exists('fields', $options)) { + throw new InvalidArgumentException('The only supported option is "fields"'); + } + + try { + $this->getCollection()->createSearchIndex( + [ + 'fields' => array_merge([ + [ + 'numDimensions' => 1536, + 'path' => $this->vectorFieldName, + 'similarity' => 'euclidean', + 'type' => 'vector', + ], + ], $options['fields'] ?? []), + ], + [ + 'name' => $this->indexName, + 'type' => 'vectorSearch', + ], + ); + } catch (CommandException $e) { + $this->logger->warning($e->getMessage()); + } + } + + private function getCollection(): Collection + { + return $this->client->getCollection($this->databaseName, $this->collectionName); + } + + private function toBinary(Uuid $uuid): Binary + { + return new Binary($uuid->toBinary(), Binary::TYPE_UUID); + } + + private function toUuid(Binary $binary): Uuid + { + return Uuid::fromString($binary->getData()); + } +} diff --git a/src/store/src/Bridge/Pinecone/Store.php b/src/store/src/Bridge/Pinecone/Store.php new file mode 100644 index 000000000..f17923148 --- /dev/null +++ b/src/store/src/Bridge/Pinecone/Store.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Bridge\Pinecone; + +use Probots\Pinecone\Client; +use Probots\Pinecone\Resources\Data\VectorResource; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\VectorStoreInterface; +use Symfony\Component\Uid\Uuid; + +/** + * @author Christopher Hertel + */ +final readonly class Store implements VectorStoreInterface +{ + /** + * @param array $filter + */ + public function __construct( + private Client $pinecone, + private ?string $namespace = null, + private array $filter = [], + private int $topK = 3, + ) { + } + + public function add(VectorDocument ...$documents): void + { + $vectors = []; + foreach ($documents as $document) { + $vectors[] = [ + 'id' => (string) $document->id, + 'values' => $document->vector->getData(), + 'metadata' => $document->metadata->getArrayCopy(), + ]; + } + + if ([] === $vectors) { + return; + } + + $this->getVectors()->upsert($vectors, $this->namespace); + } + + public function query(Vector $vector, array $options = [], ?float $minScore = null): array + { + $response = $this->getVectors()->query( + vector: $vector->getData(), + namespace: $options['namespace'] ?? $this->namespace, + filter: $options['filter'] ?? $this->filter, + topK: $options['topK'] ?? $this->topK, + includeValues: true, + ); + + $documents = []; + foreach ($response->json()['matches'] as $match) { + $documents[] = new VectorDocument( + id: Uuid::fromString($match['id']), + vector: new Vector($match['values']), + metadata: new Metadata($match['metadata']), + score: $match['score'], + ); + } + + return $documents; + } + + private function getVectors(): VectorResource + { + return $this->pinecone->data()->vectors(); + } +} diff --git a/src/store/src/Document/Metadata.php b/src/store/src/Document/Metadata.php new file mode 100644 index 000000000..5ce7c105c --- /dev/null +++ b/src/store/src/Document/Metadata.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Document; + +/** + * @template-extends \ArrayObject + * + * @author Christopher Hertel + */ +final class Metadata extends \ArrayObject +{ +} diff --git a/src/store/src/Document/TextDocument.php b/src/store/src/Document/TextDocument.php new file mode 100644 index 000000000..bfb4f7f20 --- /dev/null +++ b/src/store/src/Document/TextDocument.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Document; + +use Symfony\Component\Uid\Uuid; +use Webmozart\Assert\Assert; + +/** + * @author Christopher Hertel + */ +final readonly class TextDocument +{ + public function __construct( + public Uuid $id, + public string $content, + public Metadata $metadata = new Metadata(), + ) { + Assert::stringNotEmpty(trim($this->content)); + } +} diff --git a/src/store/src/Document/VectorDocument.php b/src/store/src/Document/VectorDocument.php new file mode 100644 index 000000000..294c94521 --- /dev/null +++ b/src/store/src/Document/VectorDocument.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Document; + +use Symfony\AI\Platform\Vector\VectorInterface; +use Symfony\Component\Uid\Uuid; + +/** + * @author Christopher Hertel + */ +final readonly class VectorDocument +{ + public function __construct( + public Uuid $id, + public VectorInterface $vector, + public Metadata $metadata = new Metadata(), + public ?float $score = null, + ) { + } +} diff --git a/src/store/src/Embedder.php b/src/store/src/Embedder.php new file mode 100644 index 000000000..d19b3243a --- /dev/null +++ b/src/store/src/Embedder.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Store\Document\TextDocument; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\Component\Clock\Clock; +use Symfony\Component\Clock\ClockInterface; + +/** + * @author Christopher Hertel + */ +final readonly class Embedder +{ + private ClockInterface $clock; + + public function __construct( + private PlatformInterface $platform, + private Model $model, + private StoreInterface $store, + ?ClockInterface $clock = null, + private LoggerInterface $logger = new NullLogger(), + ) { + $this->clock = $clock ?? Clock::get(); + } + + /** + * @param TextDocument|TextDocument[] $documents + */ + public function embed(TextDocument|array $documents, int $chunkSize = 0, int $sleep = 0): void + { + if ($documents instanceof TextDocument) { + $documents = [$documents]; + } + + if ([] === $documents) { + $this->logger->debug('No documents to embed'); + + return; + } + + $chunks = 0 !== $chunkSize ? array_chunk($documents, $chunkSize) : [$documents]; + + foreach ($chunks as $chunk) { + $this->store->add(...$this->createVectorDocuments($chunk)); + + if (0 !== $sleep) { + $this->clock->sleep($sleep); + } + } + } + + /** + * @param TextDocument[] $documents + * + * @return VectorDocument[] + */ + private function createVectorDocuments(array $documents): array + { + if ($this->model->supports(Capability::INPUT_MULTIPLE)) { + $response = $this->platform->request($this->model, array_map(fn (TextDocument $document) => $document->content, $documents)); + + $vectors = $response->getContent(); + } else { + $responses = []; + foreach ($documents as $document) { + $responses[] = $this->platform->request($this->model, $document->content); + } + + $vectors = []; + foreach ($responses as $response) { + $vectors = array_merge($vectors, $response->getContent()); + } + } + + $vectorDocuments = []; + foreach ($documents as $i => $document) { + $vectorDocuments[] = new VectorDocument($document->id, $vectors[$i], $document->metadata); + } + + return $vectorDocuments; + } +} diff --git a/src/store/src/Exception/ExceptionInterface.php b/src/store/src/Exception/ExceptionInterface.php new file mode 100644 index 000000000..918a9005e --- /dev/null +++ b/src/store/src/Exception/ExceptionInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Exception; + +/** + * @author Oskar Stark + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/store/src/Exception/InvalidArgumentException.php b/src/store/src/Exception/InvalidArgumentException.php new file mode 100644 index 000000000..82cbefdd4 --- /dev/null +++ b/src/store/src/Exception/InvalidArgumentException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Exception; + +/** + * @author Oskar Stark + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/store/src/Exception/RuntimeException.php b/src/store/src/Exception/RuntimeException.php new file mode 100644 index 000000000..6cd47c742 --- /dev/null +++ b/src/store/src/Exception/RuntimeException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Exception; + +/** + * @author Oskar Stark + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/store/src/InitializableStoreInterface.php b/src/store/src/InitializableStoreInterface.php new file mode 100644 index 000000000..f5c62837a --- /dev/null +++ b/src/store/src/InitializableStoreInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store; + +/** + * @author Oskar Stark + */ +interface InitializableStoreInterface extends StoreInterface +{ + /** + * @param array $options + */ + public function initialize(array $options = []): void; +} diff --git a/src/store/src/StoreInterface.php b/src/store/src/StoreInterface.php new file mode 100644 index 000000000..65e585af7 --- /dev/null +++ b/src/store/src/StoreInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store; + +use Symfony\AI\Store\Document\VectorDocument; + +/** + * @author Christopher Hertel + */ +interface StoreInterface +{ + public function add(VectorDocument ...$documents): void; +} diff --git a/src/store/src/VectorStoreInterface.php b/src/store/src/VectorStoreInterface.php new file mode 100644 index 000000000..2df2ade97 --- /dev/null +++ b/src/store/src/VectorStoreInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store; + +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Document\VectorDocument; + +/** + * @author Christopher Hertel + */ +interface VectorStoreInterface extends StoreInterface +{ + /** + * @param array $options + * + * @return VectorDocument[] + */ + public function query(Vector $vector, array $options = [], ?float $minScore = null): array; +} diff --git a/src/store/tests/Document/NullVectorTest.php b/src/store/tests/Document/NullVectorTest.php new file mode 100644 index 000000000..8423f31f0 --- /dev/null +++ b/src/store/tests/Document/NullVectorTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Tests\Document; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Vector\NullVector; +use Symfony\AI\Platform\Vector\VectorInterface; +use Symfony\AI\Store\Exception\RuntimeException; + +#[CoversClass(NullVector::class)] +final class NullVectorTest extends TestCase +{ + #[Test] + public function implementsInterface(): void + { + self::assertInstanceOf(VectorInterface::class, new NullVector()); + } + + #[Test] + public function getDataThrowsOnAccess(): void + { + self::expectException(RuntimeException::class); + + (new NullVector())->getData(); + } + + #[Test] + public function getDimensionsThrowsOnAccess(): void + { + self::expectException(RuntimeException::class); + + (new NullVector())->getDimensions(); + } +} diff --git a/src/store/tests/Document/VectorTest.php b/src/store/tests/Document/VectorTest.php new file mode 100644 index 000000000..d14b57970 --- /dev/null +++ b/src/store/tests/Document/VectorTest.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Tests\Document; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Platform\Vector\VectorInterface; + +#[CoversClass(Vector::class)] +final class VectorTest extends TestCase +{ + #[Test] + public function implementsInterface(): void + { + self::assertInstanceOf( + VectorInterface::class, + new Vector([1.0, 2.0, 3.0]) + ); + } + + #[Test] + public function withDimensionNull(): void + { + $vector = new Vector($vectors = [1.0, 2.0, 3.0], null); + + self::assertSame($vectors, $vector->getData()); + self::assertSame(3, $vector->getDimensions()); + } +} diff --git a/src/store/tests/Double/PlatformTestHandler.php b/src/store/tests/Double/PlatformTestHandler.php new file mode 100644 index 000000000..7c616421a --- /dev/null +++ b/src/store/tests/Double/PlatformTestHandler.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Tests\Double; + +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Platform; +use Symfony\AI\Platform\Response\ResponseInterface; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\VectorResponse; +use Symfony\AI\Platform\ResponseConverterInterface; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +final class PlatformTestHandler implements ModelClientInterface, ResponseConverterInterface +{ + public int $createCalls = 0; + + public function __construct( + private readonly ?ResponseInterface $create = null, + ) { + } + + public static function createPlatform(?ResponseInterface $create = null): Platform + { + $handler = new self($create); + + return new Platform([$handler], [$handler]); + } + + public function supports(Model $model): bool + { + return true; + } + + public function request(Model $model, array|string|object $payload, array $options = []): HttpResponse + { + ++$this->createCalls; + + return new MockResponse(); + } + + public function convert(HttpResponse $response, array $options = []): LlmResponse + { + return $this->create ?? new VectorResponse(new Vector([1, 2, 3])); + } +} diff --git a/src/store/tests/Double/TestStore.php b/src/store/tests/Double/TestStore.php new file mode 100644 index 000000000..2bfe409a3 --- /dev/null +++ b/src/store/tests/Double/TestStore.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Tests\Double; + +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\StoreInterface; + +final class TestStore implements StoreInterface +{ + /** + * @var VectorDocument[] + */ + public array $documents = []; + + public int $addCalls = 0; + + public function add(VectorDocument ...$documents): void + { + ++$this->addCalls; + $this->documents = array_merge($this->documents, $documents); + } +} diff --git a/src/store/tests/EmbedderTest.php b/src/store/tests/EmbedderTest.php new file mode 100644 index 000000000..d41463b77 --- /dev/null +++ b/src/store/tests/EmbedderTest.php @@ -0,0 +1,138 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Store\Tests; + +use PhpLlm\LlmChain\Tests\Double\PlatformTestHandler; +use PhpLlm\LlmChain\Tests\Double\TestStore; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Medium; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\AI\Platform\Bridge\OpenAI\Embeddings; +use Symfony\AI\Platform\Message\ToolCallMessage; +use Symfony\AI\Platform\Platform; +use Symfony\AI\Platform\Response\AsyncResponse; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Response\VectorResponse; +use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Store\Document\Metadata; +use Symfony\AI\Store\Document\TextDocument; +use Symfony\AI\Store\Document\VectorDocument; +use Symfony\AI\Store\Embedder; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\Uid\Uuid; + +#[CoversClass(Embedder::class)] +#[Medium] +#[UsesClass(TextDocument::class)] +#[UsesClass(Vector::class)] +#[UsesClass(VectorDocument::class)] +#[UsesClass(ToolCallMessage::class)] +#[UsesClass(ToolCall::class)] +#[UsesClass(Embeddings::class)] +#[UsesClass(Platform::class)] +#[UsesClass(AsyncResponse::class)] +#[UsesClass(VectorResponse::class)] +final class EmbedderTest extends TestCase +{ + #[Test] + public function embedSingleDocument(): void + { + $document = new TextDocument($id = Uuid::v4(), 'Test content'); + $vector = new Vector([0.1, 0.2, 0.3]); + + $embedder = new Embedder( + PlatformTestHandler::createPlatform(new VectorResponse($vector)), + new Embeddings(), + $store = new TestStore(), + new MockClock(), + ); + + $embedder->embed($document); + + self::assertCount(1, $store->documents); + self::assertInstanceOf(VectorDocument::class, $store->documents[0]); + self::assertSame($id, $store->documents[0]->id); + self::assertSame($vector, $store->documents[0]->vector); + } + + #[Test] + public function embedEmptyDocumentList(): void + { + $logger = self::createMock(LoggerInterface::class); + $logger->expects(self::once())->method('debug')->with('No documents to embed'); + + $embedder = new Embedder( + PlatformTestHandler::createPlatform(), + new Embeddings(), + $store = new TestStore(), + new MockClock(), + $logger, + ); + + $embedder->embed([]); + + self::assertSame([], $store->documents); + } + + #[Test] + public function embedDocumentWithMetadata(): void + { + $metadata = new Metadata(['key' => 'value']); + $document = new TextDocument($id = Uuid::v4(), 'Test content', $metadata); + $vector = new Vector([0.1, 0.2, 0.3]); + + $embedder = new Embedder( + PlatformTestHandler::createPlatform(new VectorResponse($vector)), + new Embeddings(), + $store = new TestStore(), + new MockClock(), + ); + + $embedder->embed($document); + + self::assertSame(1, $store->addCalls); + self::assertCount(1, $store->documents); + self::assertInstanceOf(VectorDocument::class, $store->documents[0]); + self::assertSame($id, $store->documents[0]->id); + self::assertSame($vector, $store->documents[0]->vector); + self::assertSame(['key' => 'value'], $store->documents[0]->metadata->getArrayCopy()); + } + + #[Test] + public function embedWithSleep(): void + { + $vector1 = new Vector([0.1, 0.2, 0.3]); + $vector2 = new Vector([0.4, 0.5, 0.6]); + + $document1 = new TextDocument(Uuid::v4(), 'Test content 1'); + $document2 = new TextDocument(Uuid::v4(), 'Test content 2'); + + $embedder = new Embedder( + PlatformTestHandler::createPlatform(new VectorResponse($vector1, $vector2)), + new Embeddings(), + $store = new TestStore(), + $clock = new MockClock('2024-01-01 00:00:00'), + ); + + $embedder->embed( + documents: [$document1, $document2], + sleep: 3 + ); + + self::assertSame(1, $store->addCalls); + self::assertCount(2, $store->documents); + self::assertSame('2024-01-01 00:00:03', $clock->now()->format('Y-m-d H:i:s')); + } +}