From 78f589432ce89b0c66ba0eb20d21d40fff992923 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 12 May 2025 23:12:43 +0200 Subject: [PATCH] refactor: major structure refactoring --- .php-cs-fixer.dist.php | 4 + Makefile | 5 + README.md | 129 ++++++----- composer.json | 1 + examples/anthropic/chat.php | 18 +- examples/anthropic/stream.php | 18 +- examples/anthropic/toolcall.php | 18 +- examples/azure/audio-transcript.php | 10 +- examples/azure/chat-gpt.php | 18 +- examples/azure/chat-llama.php | 18 +- examples/azure/embeddings.php | 10 +- examples/bedrock/chat-claude.php | 20 +- examples/bedrock/chat-llama.php | 18 +- examples/bedrock/chat-nova.php | 18 +- examples/bedrock/image-claude-binary.php | 20 +- examples/bedrock/image-nova.php | 20 +- examples/bedrock/toolcall-claude.php | 18 +- examples/bedrock/toolcall-nova.php | 36 +++ examples/chat-system-prompt.php | 18 +- examples/google/chat.php | 18 +- examples/google/image-input.php | 20 +- examples/google/stream.php | 18 +- examples/huggingface/_model-listing.php | 8 +- examples/huggingface/audio-classification.php | 10 +- .../automatic-speech-recognition.php | 12 +- examples/huggingface/chat-completion.php | 14 +- examples/huggingface/feature-extraction.php | 12 +- examples/huggingface/fill-mask.php | 8 +- examples/huggingface/image-classification.php | 10 +- examples/huggingface/image-segmentation.php | 10 +- examples/huggingface/image-to-text.php | 12 +- examples/huggingface/object-detection.php | 10 +- examples/huggingface/question-answering.php | 8 +- examples/huggingface/sentence-similarity.php | 8 +- examples/huggingface/summarization.php | 10 +- .../huggingface/table-question-answering.php | 8 +- examples/huggingface/text-classification.php | 8 +- examples/huggingface/text-generation.php | 10 +- examples/huggingface/text-to-image.php | 12 +- examples/huggingface/token-classification.php | 8 +- examples/huggingface/translation.php | 10 +- .../huggingface/zero-shot-classification.php | 8 +- examples/ollama/chat-llama.php | 18 +- examples/openai/audio-input.php | 20 +- examples/openai/audio-transcript.php | 10 +- examples/openai/chat-o1.php | 26 +-- examples/openai/chat.php | 18 +- examples/openai/embeddings.php | 10 +- examples/openai/image-input-binary.php | 20 +- examples/openai/image-input-url.php | 20 +- examples/openai/image-output-dall-e-2.php | 8 +- examples/openai/image-output-dall-e-3.php | 12 +- examples/openai/stream.php | 18 +- examples/openai/structured-output-clock.php | 16 +- examples/openai/structured-output-math.php | 16 +- examples/openai/token-metadata.php | 26 +-- examples/openai/toolcall-stream.php | 18 +- examples/openai/toolcall.php | 18 +- examples/openrouter/chat-gemini.php | 20 +- examples/parallel-chat-gpt.php | 22 +- examples/parallel-embeddings.php | 16 +- examples/replicate/chat-llama.php | 18 +- .../mongodb-similarity-search.php} | 34 +-- .../pinecone-similarity-search.php} | 34 +-- examples/toolbox/brave.php | 18 +- examples/toolbox/clock.php | 22 +- examples/toolbox/serpapi.php | 18 +- examples/toolbox/tavily.php | 18 +- examples/toolbox/weather-event.php | 20 +- examples/transformers/text-generation.php | 12 +- examples/voyage/embeddings.php | 10 +- phpstan.dist.neon | 1 + src/Bridge/Anthropic/Claude.php | 62 ----- src/Bridge/Anthropic/ModelHandler.php | 188 --------------- src/Bridge/Azure/Meta/LlamaHandler.php | 60 ----- .../Bedrock/Anthropic/ClaudeHandler.php | 166 ------------- src/Bridge/Bedrock/BedrockModelClient.php | 22 -- src/Bridge/Bedrock/Nova/Nova.php | 66 ------ .../Bedrock/Nova/NovaPromptConverter.php | 95 -------- src/Bridge/Bedrock/Platform.php | 46 ---- src/Bridge/Google/Gemini.php | 60 ----- src/Bridge/Google/GooglePromptConverter.php | 75 ------ src/Bridge/HuggingFace/Model.php | 30 --- src/Bridge/HuggingFace/ModelClient.php | 105 --------- src/Bridge/HuggingFace/PlatformFactory.php | 23 -- src/Bridge/HuggingFace/ResponseConverter.php | 86 ------- src/Bridge/Meta/Llama.php | 70 ------ src/Bridge/Ollama/LlamaModelHandler.php | 57 ----- src/Bridge/OpenAI/DallE.php | 31 --- src/Bridge/OpenAI/Embeddings.php | 38 --- src/Bridge/OpenAI/GPT.php | 107 --------- src/Bridge/OpenAI/Whisper.php | 31 --- .../OpenAI/Whisper/ResponseConverter.php | 28 --- src/Bridge/OpenRouter/GenericModel.php | 55 ----- src/Bridge/OpenRouter/PlatformFactory.php | 23 -- src/Bridge/Replicate/LlamaModelClient.php | 39 ---- .../Replicate/LlamaResponseConverter.php | 33 --- src/Bridge/TransformersPHP/Model.php | 30 --- src/Bridge/Voyage/Voyage.php | 41 ---- src/{ => Chain}/Chain.php | 69 +++--- ...eProcessor.php => ChainAwareInterface.php} | 4 +- src/Chain/ChainAwareTrait.php | 2 - src/{ => Chain}/ChainInterface.php | 6 +- .../Exception/ExceptionInterface.php | 2 +- .../Exception/InvalidArgumentException.php | 2 +- src/{ => Chain}/Exception/LogicException.php | 2 +- .../MissingModelSupportException.php} | 6 +- .../Exception/RuntimeException.php | 2 +- src/Chain/Input.php | 6 +- .../LlmOverrideInputProcessor.php | 28 --- .../ModelOverrideInputProcessor.php | 28 +++ .../SystemPromptInputProcessor.php | 20 +- ...cessor.php => InputProcessorInterface.php} | 2 +- src/Chain/Output.php | 8 +- ...essor.php => OutputProcessorInterface.php} | 2 +- src/Chain/StructuredOutput/ChainProcessor.php | 23 +- .../ResponseFormatFactory.php | 2 +- src/Chain/Toolbox/ChainProcessor.php | 32 +-- src/Chain/Toolbox/Event/ToolCallsExecuted.php | 2 +- .../Toolbox/Exception/ExceptionInterface.php | 2 +- .../Exception/ToolConfigurationException.php | 4 +- src/Chain/Toolbox/Exception/ToolException.php | 21 ++ .../Exception/ToolExecutionException.php | 4 +- .../Exception/ToolMetadataException.php | 21 -- .../Exception/ToolNotFoundException.php | 8 +- src/Chain/Toolbox/FaultTolerantToolbox.php | 13 +- src/Chain/Toolbox/Metadata.php | 51 ---- src/Chain/Toolbox/MetadataFactory.php | 17 -- .../Toolbox/MetadataFactory/ChainFactory.php | 44 ---- src/Chain/Toolbox/StreamResponse.php | 6 +- src/Chain/Toolbox/Tool/Brave.php | 2 +- src/Chain/Toolbox/Tool/Chain.php | 10 +- src/Chain/Toolbox/Tool/Clock.php | 2 +- src/Chain/Toolbox/Tool/Crawler.php | 2 +- src/Chain/Toolbox/Tool/OpenMeteo.php | 2 +- src/Chain/Toolbox/Tool/SerpApi.php | 2 +- src/Chain/Toolbox/Tool/SimilaritySearch.php | 16 +- src/Chain/Toolbox/Tool/Wikipedia.php | 20 +- src/Chain/Toolbox/Tool/YouTubeTranscriber.php | 8 +- src/Chain/Toolbox/ToolCallResult.php | 2 +- .../AbstractToolFactory.php} | 16 +- .../Toolbox/ToolFactory/ChainFactory.php | 44 ++++ .../MemoryToolFactory.php} | 12 +- .../ReflectionToolFactory.php} | 16 +- src/Chain/Toolbox/ToolFactoryInterface.php | 18 ++ src/Chain/Toolbox/ToolResultConverter.php | 8 +- src/Chain/Toolbox/Toolbox.php | 31 +-- src/Chain/Toolbox/ToolboxInterface.php | 7 +- src/Model/EmbeddingsModel.php | 10 - src/Model/LanguageModel.php | 18 -- src/Model/Message/AssistantMessage.php | 53 ----- src/Model/Message/Content/Audio.php | 26 --- src/Model/Message/Content/Content.php | 9 - src/Model/Message/Content/Image.php | 19 -- src/Model/Message/Content/ImageUrl.php | 24 -- src/Model/Message/Content/Text.php | 21 -- src/Model/Message/MessageInterface.php | 10 - src/Model/Message/SystemMessage.php | 31 --- src/Model/Message/ToolCallMessage.php | 37 --- src/Model/Message/UserMessage.php | 72 ------ src/Model/Model.php | 15 -- .../Exception/RawResponseAlreadySet.php | 13 -- src/Platform.php | 75 ------ src/Platform/Bridge/Anthropic/Claude.php | 37 +++ .../Contract/AssistantMessageNormalizer.php | 56 +++++ .../Anthropic/Contract/ImageNormalizer.php | 49 ++++ .../Contract/MessageBagNormalizer.php | 54 +++++ .../Contract/ToolCallMessageNormalizer.php | 53 +++++ .../Anthropic/Contract/ToolNormalizer.php | 43 ++++ .../Bridge/Anthropic/ModelHandler.php | 105 +++++++++ .../Bridge/Anthropic/PlatformFactory.php | 16 +- .../Bridge/Azure/Meta/LlamaHandler.php | 54 +++++ .../Bridge/Azure/Meta/PlatformFactory.php | 4 +- .../Azure/OpenAI/EmbeddingsModelClient.php | 18 +- .../Bridge/Azure/OpenAI/GPTModelClient.php | 21 +- .../Bridge/Azure/OpenAI/PlatformFactory.php | 14 +- .../Azure/OpenAI/WhisperModelClient.php | 26 +-- .../Bedrock/Anthropic/ClaudeHandler.php | 87 +++++++ .../Bridge/Bedrock/BedrockModelClient.php | 19 ++ .../Bridge/Bedrock/Meta/LlamaModelClient.php | 37 ++- .../Contract/AssistantMessageNormalizer.php | 62 +++++ .../Nova/Contract/MessageBagNormalizer.php | 48 ++++ .../Contract/ToolCallMessageNormalizer.php | 55 +++++ .../Bedrock/Nova/Contract/ToolNormalizer.php | 51 ++++ .../Nova/Contract/UserMessageNormalizer.php | 62 +++++ src/Platform/Bridge/Bedrock/Nova/Nova.php | 36 +++ .../Bridge/Bedrock/Nova/NovaHandler.php | 55 ++--- src/Platform/Bridge/Bedrock/Platform.php | 70 ++++++ .../Bridge/Bedrock/PlatformFactory.php | 8 +- .../Contract/AssistantMessageNormalizer.php | 39 ++++ .../Google/Contract/MessageBagNormalizer.php | 59 +++++ .../Google/Contract/UserMessageNormalizer.php | 48 ++++ src/Platform/Bridge/Google/Gemini.php | 31 +++ .../Bridge/Google/ModelHandler.php | 38 ++- .../Bridge/Google/PlatformFactory.php | 14 +- .../Bridge/HuggingFace/ApiClient.php | 3 +- .../HuggingFace/Contract/FileNormalizer.php | 36 +++ .../Contract/MessageBagNormalizer.php | 42 ++++ .../Bridge/HuggingFace/ModelClient.php | 84 +++++++ .../HuggingFace/Output/Classification.php | 2 +- .../Output/ClassificationResult.php | 2 +- .../HuggingFace/Output/DetectedObject.php | 2 +- .../HuggingFace/Output/FillMaskResult.php | 2 +- .../HuggingFace/Output/ImageSegment.php | 4 +- .../Output/ImageSegmentationResult.php | 2 +- .../Bridge/HuggingFace/Output/MaskFill.php | 2 +- .../Output/ObjectDetectionResult.php | 2 +- .../Output/QuestionAnsweringResult.php | 2 +- .../Output/SentenceSimilarityResult.php | 2 +- .../Output/TableQuestionAnsweringResult.php | 2 +- .../Bridge/HuggingFace/Output/Token.php | 2 +- .../Output/TokenClassificationResult.php | 2 +- .../Output/ZeroShotClassificationResult.php | 2 +- .../Bridge/HuggingFace/PlatformFactory.php | 33 +++ .../Bridge/HuggingFace/Provider.php | 2 +- .../Bridge/HuggingFace/ResponseConverter.php | 86 +++++++ .../Bridge/HuggingFace/Task.php | 2 +- .../Meta/Contract/MessageBagNormalizer.php | 41 ++++ src/Platform/Bridge/Meta/Llama.php | 40 ++++ .../Bridge/Meta/LlamaPromptConverter.php | 22 +- .../Bridge/Ollama/LlamaModelHandler.php | 55 +++++ .../Bridge/Ollama/PlatformFactory.php | 4 +- src/Platform/Bridge/OpenAI/DallE.php | 25 ++ .../Bridge/OpenAI/DallE/Base64Image.php | 2 +- .../Bridge/OpenAI/DallE/ImageResponse.php | 6 +- .../Bridge/OpenAI/DallE/ModelClient.php | 22 +- .../Bridge/OpenAI/DallE/UrlImage.php | 2 +- src/Platform/Bridge/OpenAI/Embeddings.php | 22 ++ .../Bridge/OpenAI/Embeddings/ModelClient.php | 16 +- .../OpenAI/Embeddings/ResponseConverter.php | 18 +- src/Platform/Bridge/OpenAI/GPT.php | 79 +++++++ .../Bridge/OpenAI/GPT/ModelClient.php | 20 +- .../Bridge/OpenAI/GPT/ResponseConverter.php | 44 ++-- .../Bridge/OpenAI/PlatformFactory.php | 21 +- .../Bridge/OpenAI/TokenOutputProcessor.php | 8 +- src/Platform/Bridge/OpenAI/Whisper.php | 26 +++ .../Bridge/OpenAI/Whisper/AudioNormalizer.php | 38 +++ .../Bridge/OpenAI/Whisper/ModelClient.php | 22 +- .../OpenAI/Whisper/ResponseConverter.php | 27 +++ .../Bridge/OpenRouter/Client.php | 32 ++- .../Bridge/OpenRouter/PlatformFactory.php | 31 +++ .../Bridge/Replicate/Client.php | 8 +- .../Contract/LlamaMessageBagNormalizer.php | 43 ++++ .../Bridge/Replicate/LlamaModelClient.php | 31 +++ .../Replicate/LlamaResponseConverter.php | 32 +++ .../Bridge/Replicate/PlatformFactory.php | 7 +- .../Bridge/TransformersPHP/Platform.php | 16 +- .../TransformersPHP/PlatformFactory.php | 4 +- .../Bridge/Voyage/ModelHandler.php | 26 +-- .../Bridge/Voyage/PlatformFactory.php | 4 +- src/Platform/Bridge/Voyage/Voyage.php | 26 +++ src/Platform/Capability.php | 26 +++ src/Platform/Contract.php | 76 ++++++ .../Contract}/JsonSchema/Attribute/With.php | 48 ++-- .../JsonSchema/DescriptionParser.php | 4 +- .../Contract}/JsonSchema/Factory.php | 18 +- .../Message/AssistantMessageNormalizer.php | 49 ++++ .../Message/Content/AudioNormalizer.php | 46 ++++ .../Message/Content/ImageNormalizer.php | 36 +++ .../Message/Content/ImageUrlNormalizer.php | 36 +++ .../Message/Content/TextNormalizer.php | 33 +++ .../Message/MessageBagNormalizer.php | 50 ++++ .../Message/SystemMessageNormalizer.php | 36 +++ .../Message/ToolCallMessageNormalizer.php | 43 ++++ .../Message/UserMessageNormalizer.php | 48 ++++ .../Normalizer/ModelContractNormalizer.php | 37 +++ .../Response/ToolCallNormalizer.php | 47 ++++ .../Contract/Normalizer/ToolNormalizer.php | 54 +++++ .../Exception/ContentFilterException.php | 2 +- src/Platform/Exception/ExceptionInterface.php | 9 + .../Exception/InvalidArgumentException.php | 9 + src/Platform/Exception/RuntimeException.php | 9 + src/Platform/Message/AssistantMessage.php | 29 +++ src/Platform/Message/Content/Audio.php | 9 + .../Message/Content/ContentInterface.php | 9 + .../Message/Content/File.php | 12 +- src/Platform/Message/Content/Image.php | 9 + src/Platform/Message/Content/ImageUrl.php | 13 ++ src/Platform/Message/Content/Text.php | 13 ++ src/{Model => Platform}/Message/Message.php | 14 +- .../Message/MessageBag.php | 12 +- .../Message/MessageBagInterface.php | 6 +- src/Platform/Message/MessageInterface.php | 10 + src/{Model => Platform}/Message/Role.php | 2 +- src/Platform/Message/SystemMessage.php | 17 ++ src/Platform/Message/ToolCallMessage.php | 21 ++ src/Platform/Message/UserMessage.php | 51 ++++ src/Platform/Model.php | 45 ++++ src/Platform/ModelClient.php | 22 -- src/Platform/ModelClientInterface.php | 18 ++ src/Platform/Platform.php | 80 +++++++ src/{ => Platform}/PlatformInterface.php | 5 +- .../Response/AsyncResponse.php | 12 +- .../Response/BaseResponse.php | 4 +- .../Response/BinaryResponse.php | 6 +- src/{Model => Platform}/Response/Choice.php | 4 +- .../Response/ChoiceResponse.php | 6 +- .../RawResponseAlreadySetException.php | 15 ++ .../Response/Metadata/Metadata.php | 2 +- .../Response/Metadata/MetadataAwareTrait.php | 2 +- .../Response/ObjectResponse.php} | 4 +- .../Response/RawResponseAwareTrait.php | 6 +- .../Response/ResponseInterface.php | 8 +- .../Response/StreamResponse.php | 2 +- .../Response/TextResponse.php | 2 +- src/{Model => Platform}/Response/ToolCall.php | 2 +- .../Response/ToolCallResponse.php | 6 +- .../Response/VectorResponse.php | 4 +- ...ter.php => ResponseConverterInterface.php} | 10 +- .../Tool}/ExecutionReference.php | 2 +- src/Platform/Tool/Tool.php | 24 ++ .../Vector}/NullVector.php | 4 +- src/{Document => Platform/Vector}/Vector.php | 12 +- .../Vector}/VectorInterface.php | 2 +- .../Bridge/Azure}/SearchStore.php | 14 +- src/{ => Store}/Bridge/ChromaDB/Store.php | 10 +- src/{ => Store}/Bridge/MongoDB/Store.php | 12 +- src/{ => Store}/Bridge/Pinecone/Store.php | 8 +- src/{ => Store}/Document/Metadata.php | 2 +- src/{ => Store}/Document/TextDocument.php | 2 +- src/{ => Store}/Document/VectorDocument.php | 3 +- src/{ => Store}/Embedder.php | 19 +- src/Store/Exception/ExceptionInterface.php | 9 + .../Exception/InvalidArgumentException.php | 9 + src/Store/Exception/RuntimeException.php | 9 + src/Store/StoreInterface.php | 2 +- src/Store/VectorStoreInterface.php | 4 +- .../Bedrock/Nova/NovaPromptConverterTest.php | 101 -------- .../Google/GooglePromptConverterTest.php | 95 -------- .../LlmOverrideInputProcessorTest.php | 67 ------ .../ModelOverrideInputProcessorTest.php | 67 ++++++ .../SystemPromptInputProcessorTest.php | 42 ++-- .../StructuredOutput/ChainProcessorTest.php | 60 +++-- .../ResponseFormatFactoryTest.php | 4 +- tests/Chain/Toolbox/ChainProcessorTest.php | 56 ++--- .../Toolbox/FaultTolerantToolboxTest.php | 16 +- .../MetadataFactory/ChainFactoryTest.php | 34 +-- .../MetadataFactory/MemoryFactoryTest.php | 38 +-- .../MetadataFactory/ReflectionFactoryTest.php | 40 ++-- tests/Chain/Toolbox/Tool/WikipediaTest.php | 4 +- tests/Chain/Toolbox/ToolboxTest.php | 219 +++++++++--------- tests/Double/PlatformTestHandler.php | 22 +- tests/Double/TestStore.php | 2 +- tests/Fixture/Tool/ToolMultiple.php | 4 +- tests/Fixture/Tool/ToolNoAttribute1.php | 2 +- tests/Fixture/Tool/ToolNoAttribute2.php | 4 +- tests/Fixture/Tool/ToolOptionalParam.php | 2 +- tests/Fixture/Tool/ToolRequiredParams.php | 2 +- .../Tool/ToolWithToolParameterAttribute.php | 2 +- tests/Fixture/Tool/ToolWithoutDocs.php | 2 +- tests/Fixture/Tool/ToolWrong.php | 2 +- tests/Model/Message/UserMessageTest.php | 113 --------- .../Bridge/Anthropic/ModelHandlerTest.php | 12 +- .../Bridge/Bedrock/Nova/ContractTest.php | 138 +++++++++++ .../AssistantMessageNormalizerTest.php | 54 +++++ .../Contract/MessageBagNormalizerTest.php | 150 ++++++++++++ .../Contract/UserMessageNormalizerTest.php | 76 ++++++ .../Bridge/HuggingFace/ModelClientTest.php | 99 +++----- .../Bridge/Meta/LlamaPromptConverterTest.php | 20 +- .../Bridge/OpenAI/DallE/Base64ImageTest.php | 4 +- .../Bridge/OpenAI/DallE/ImageResponseTest.php | 8 +- .../Bridge/OpenAI/DallE/ModelClientTest.php | 14 +- .../Bridge/OpenAI/DallE/UrlImageTest.php | 4 +- .../Bridge/OpenAI/DallETest.php | 4 +- .../Embeddings/ResponseConverterTest.php | 10 +- .../OpenAI/GPT/ResponseConverterTest.php | 20 +- .../OpenAI/TokenOutputProcessorTest.php | 50 ++-- .../Attribute/ToolParameterTest.php | 4 +- .../JsonSchema/DescriptionParserTest.php | 4 +- .../Contract}/JsonSchema/FactoryTest.php | 8 +- .../AssistantMessageNormalizerTest.php | 108 +++++++++ .../Message/Content/AudioNormalizerTest.php | 76 ++++++ .../Message/Content/ImageNormalizerTest.php | 52 +++++ .../Content/ImageUrlNormalizerTest.php | 50 ++++ .../Message/Content/TextNormalizerTest.php | 50 ++++ .../Message/MessageBagNormalizerTest.php | 117 ++++++++++ .../Message/SystemMessageNormalizerTest.php | 50 ++++ .../Message/ToolCallMessageNormalizerTest.php | 66 ++++++ .../Message/UserMessageNormalizerTest.php | 84 +++++++ .../Normalizer/ToolNormalizerTest.php | 151 ++++++++++++ tests/Platform/ContractTest.php | 210 +++++++++++++++++ .../Message/AssistantMessageTest.php | 32 +-- .../Message/Content/AudioTest.php | 44 +--- .../Message/Content/BinaryTest.php | 10 +- .../Message/Content/ImageTest.php | 6 +- .../Message/Content/ImageUrlTest.php | 12 +- .../Message/Content/TextTest.php | 12 +- .../Message/MessageBagTest.php | 48 +--- .../Message/MessageTest.php | 31 +-- .../{Model => Platform}/Message/RoleTest.php | 4 +- .../Message/SystemMessageTest.php | 14 +- .../Message/ToolCallMessageTest.php | 16 +- tests/Platform/Message/UserMessageTest.php | 76 ++++++ tests/Platform/ModelTest.php | 73 ++++++ .../Response/AsyncResponseTest.php | 36 +-- .../Response/BaseResponseTest.php | 16 +- .../Response/ChoiceResponseTest.php | 8 +- .../Response/ChoiceTest.php | 6 +- .../Exception/RawResponseAlreadySetTest.php | 8 +- .../Metadata/MetadataAwareTraitTest.php | 7 +- .../Response/Metadata/MetadataTest.php | 6 +- .../Response/RawResponseAwareTraitTest.php | 10 +- .../Response/StreamResponseTest.php | 4 +- .../Response/StructuredResponseTest.php | 10 +- .../Response/TextResponseTest.php | 4 +- .../Response/TollCallResponseTest.php | 8 +- .../Response/ToolCallTest.php | 4 +- tests/{ => Store}/Document/NullVectorTest.php | 8 +- tests/{ => Store}/Document/VectorTest.php | 6 +- tests/{ => Store}/EmbedderTest.php | 26 +-- 410 files changed, 6474 insertions(+), 4589 deletions(-) create mode 100644 examples/bedrock/toolcall-nova.php rename examples/{store-mongodb-similarity-search.php => store/mongodb-similarity-search.php} (70%) rename examples/{store-pinecone-similarity-search.php => store/pinecone-similarity-search.php} (68%) delete mode 100644 src/Bridge/Anthropic/Claude.php delete mode 100644 src/Bridge/Anthropic/ModelHandler.php delete mode 100644 src/Bridge/Azure/Meta/LlamaHandler.php delete mode 100644 src/Bridge/Bedrock/Anthropic/ClaudeHandler.php delete mode 100644 src/Bridge/Bedrock/BedrockModelClient.php delete mode 100644 src/Bridge/Bedrock/Nova/Nova.php delete mode 100644 src/Bridge/Bedrock/Nova/NovaPromptConverter.php delete mode 100644 src/Bridge/Bedrock/Platform.php delete mode 100644 src/Bridge/Google/Gemini.php delete mode 100644 src/Bridge/Google/GooglePromptConverter.php delete mode 100644 src/Bridge/HuggingFace/Model.php delete mode 100644 src/Bridge/HuggingFace/ModelClient.php delete mode 100644 src/Bridge/HuggingFace/PlatformFactory.php delete mode 100644 src/Bridge/HuggingFace/ResponseConverter.php delete mode 100644 src/Bridge/Meta/Llama.php delete mode 100644 src/Bridge/Ollama/LlamaModelHandler.php delete mode 100644 src/Bridge/OpenAI/DallE.php delete mode 100644 src/Bridge/OpenAI/Embeddings.php delete mode 100644 src/Bridge/OpenAI/GPT.php delete mode 100644 src/Bridge/OpenAI/Whisper.php delete mode 100644 src/Bridge/OpenAI/Whisper/ResponseConverter.php delete mode 100644 src/Bridge/OpenRouter/GenericModel.php delete mode 100644 src/Bridge/OpenRouter/PlatformFactory.php delete mode 100644 src/Bridge/Replicate/LlamaModelClient.php delete mode 100644 src/Bridge/Replicate/LlamaResponseConverter.php delete mode 100644 src/Bridge/TransformersPHP/Model.php delete mode 100644 src/Bridge/Voyage/Voyage.php rename src/{ => Chain}/Chain.php (50%) rename src/Chain/{ChainAwareProcessor.php => ChainAwareInterface.php} (67%) rename src/{ => Chain}/ChainInterface.php (59%) rename src/{ => Chain}/Exception/ExceptionInterface.php (66%) rename src/{ => Chain}/Exception/InvalidArgumentException.php (75%) rename src/{ => Chain}/Exception/LogicException.php (72%) rename src/{Exception/MissingModelSupport.php => Chain/Exception/MissingModelSupportException.php} (75%) rename src/{ => Chain}/Exception/RuntimeException.php (73%) delete mode 100644 src/Chain/InputProcessor/LlmOverrideInputProcessor.php create mode 100644 src/Chain/InputProcessor/ModelOverrideInputProcessor.php rename src/Chain/{InputProcessor.php => InputProcessorInterface.php} (78%) rename src/Chain/{OutputProcessor.php => OutputProcessorInterface.php} (78%) create mode 100644 src/Chain/Toolbox/Exception/ToolException.php delete mode 100644 src/Chain/Toolbox/Exception/ToolMetadataException.php delete mode 100644 src/Chain/Toolbox/Metadata.php delete mode 100644 src/Chain/Toolbox/MetadataFactory.php delete mode 100644 src/Chain/Toolbox/MetadataFactory/ChainFactory.php rename src/Chain/Toolbox/{MetadataFactory/AbstractFactory.php => ToolFactory/AbstractToolFactory.php} (66%) create mode 100644 src/Chain/Toolbox/ToolFactory/ChainFactory.php rename src/Chain/Toolbox/{MetadataFactory/MemoryFactory.php => ToolFactory/MemoryToolFactory.php} (64%) rename src/Chain/Toolbox/{MetadataFactory/ReflectionFactory.php => ToolFactory/ReflectionToolFactory.php} (54%) create mode 100644 src/Chain/Toolbox/ToolFactoryInterface.php delete mode 100644 src/Model/EmbeddingsModel.php delete mode 100644 src/Model/LanguageModel.php delete mode 100644 src/Model/Message/AssistantMessage.php delete mode 100644 src/Model/Message/Content/Audio.php delete mode 100644 src/Model/Message/Content/Content.php delete mode 100644 src/Model/Message/Content/Image.php delete mode 100644 src/Model/Message/Content/ImageUrl.php delete mode 100644 src/Model/Message/Content/Text.php delete mode 100644 src/Model/Message/MessageInterface.php delete mode 100644 src/Model/Message/SystemMessage.php delete mode 100644 src/Model/Message/ToolCallMessage.php delete mode 100644 src/Model/Message/UserMessage.php delete mode 100644 src/Model/Model.php delete mode 100644 src/Model/Response/Exception/RawResponseAlreadySet.php delete mode 100644 src/Platform.php create mode 100644 src/Platform/Bridge/Anthropic/Claude.php create mode 100644 src/Platform/Bridge/Anthropic/Contract/AssistantMessageNormalizer.php create mode 100644 src/Platform/Bridge/Anthropic/Contract/ImageNormalizer.php create mode 100644 src/Platform/Bridge/Anthropic/Contract/MessageBagNormalizer.php create mode 100644 src/Platform/Bridge/Anthropic/Contract/ToolCallMessageNormalizer.php create mode 100644 src/Platform/Bridge/Anthropic/Contract/ToolNormalizer.php create mode 100644 src/Platform/Bridge/Anthropic/ModelHandler.php rename src/{ => Platform}/Bridge/Anthropic/PlatformFactory.php (50%) create mode 100644 src/Platform/Bridge/Azure/Meta/LlamaHandler.php rename src/{ => Platform}/Bridge/Azure/Meta/PlatformFactory.php (84%) rename src/{ => Platform}/Bridge/Azure/OpenAI/EmbeddingsModelClient.php (75%) rename src/{ => Platform}/Bridge/Azure/OpenAI/GPTModelClient.php (67%) rename src/{ => Platform}/Bridge/Azure/OpenAI/PlatformFactory.php (70%) rename src/{ => Platform}/Bridge/Azure/OpenAI/WhisperModelClient.php (61%) create mode 100644 src/Platform/Bridge/Bedrock/Anthropic/ClaudeHandler.php create mode 100644 src/Platform/Bridge/Bedrock/BedrockModelClient.php rename src/{ => Platform}/Bridge/Bedrock/Meta/LlamaModelClient.php (51%) create mode 100644 src/Platform/Bridge/Bedrock/Nova/Contract/AssistantMessageNormalizer.php create mode 100644 src/Platform/Bridge/Bedrock/Nova/Contract/MessageBagNormalizer.php create mode 100644 src/Platform/Bridge/Bedrock/Nova/Contract/ToolCallMessageNormalizer.php create mode 100644 src/Platform/Bridge/Bedrock/Nova/Contract/ToolNormalizer.php create mode 100644 src/Platform/Bridge/Bedrock/Nova/Contract/UserMessageNormalizer.php create mode 100644 src/Platform/Bridge/Bedrock/Nova/Nova.php rename src/{ => Platform}/Bridge/Bedrock/Nova/NovaHandler.php (53%) create mode 100644 src/Platform/Bridge/Bedrock/Platform.php rename src/{ => Platform}/Bridge/Bedrock/PlatformFactory.php (67%) create mode 100644 src/Platform/Bridge/Google/Contract/AssistantMessageNormalizer.php create mode 100644 src/Platform/Bridge/Google/Contract/MessageBagNormalizer.php create mode 100644 src/Platform/Bridge/Google/Contract/UserMessageNormalizer.php create mode 100644 src/Platform/Bridge/Google/Gemini.php rename src/{ => Platform}/Bridge/Google/ModelHandler.php (74%) rename src/{ => Platform}/Bridge/Google/PlatformFactory.php (53%) rename src/{ => Platform}/Bridge/HuggingFace/ApiClient.php (89%) create mode 100644 src/Platform/Bridge/HuggingFace/Contract/FileNormalizer.php create mode 100644 src/Platform/Bridge/HuggingFace/Contract/MessageBagNormalizer.php create mode 100644 src/Platform/Bridge/HuggingFace/ModelClient.php rename src/{ => Platform}/Bridge/HuggingFace/Output/Classification.php (74%) rename src/{ => Platform}/Bridge/HuggingFace/Output/ClassificationResult.php (89%) rename src/{ => Platform}/Bridge/HuggingFace/Output/DetectedObject.php (82%) rename src/{ => Platform}/Bridge/HuggingFace/Output/FillMaskResult.php (91%) rename src/{ => Platform}/Bridge/HuggingFace/Output/ImageSegment.php (65%) rename src/{ => Platform}/Bridge/HuggingFace/Output/ImageSegmentationResult.php (89%) rename src/{ => Platform}/Bridge/HuggingFace/Output/MaskFill.php (79%) rename src/{ => Platform}/Bridge/HuggingFace/Output/ObjectDetectionResult.php (92%) rename src/{ => Platform}/Bridge/HuggingFace/Output/QuestionAnsweringResult.php (90%) rename src/{ => Platform}/Bridge/HuggingFace/Output/SentenceSimilarityResult.php (85%) rename src/{ => Platform}/Bridge/HuggingFace/Output/TableQuestionAnsweringResult.php (91%) rename src/{ => Platform}/Bridge/HuggingFace/Output/Token.php (80%) rename src/{ => Platform}/Bridge/HuggingFace/Output/TokenClassificationResult.php (91%) rename src/{ => Platform}/Bridge/HuggingFace/Output/ZeroShotClassificationResult.php (90%) create mode 100644 src/Platform/Bridge/HuggingFace/PlatformFactory.php rename src/{ => Platform}/Bridge/HuggingFace/Provider.php (90%) create mode 100644 src/Platform/Bridge/HuggingFace/ResponseConverter.php rename src/{ => Platform}/Bridge/HuggingFace/Task.php (95%) create mode 100644 src/Platform/Bridge/Meta/Contract/MessageBagNormalizer.php create mode 100644 src/Platform/Bridge/Meta/Llama.php rename src/{ => Platform}/Bridge/Meta/LlamaPromptConverter.php (76%) create mode 100644 src/Platform/Bridge/Ollama/LlamaModelHandler.php rename src/{ => Platform}/Bridge/Ollama/PlatformFactory.php (86%) create mode 100644 src/Platform/Bridge/OpenAI/DallE.php rename src/{ => Platform}/Bridge/OpenAI/DallE/Base64Image.php (83%) rename src/{ => Platform}/Bridge/OpenAI/DallE/ImageResponse.php (75%) rename src/{ => Platform}/Bridge/OpenAI/DallE/ModelClient.php (70%) rename src/{ => Platform}/Bridge/OpenAI/DallE/UrlImage.php (81%) create mode 100644 src/Platform/Bridge/OpenAI/Embeddings.php rename src/{ => Platform}/Bridge/OpenAI/Embeddings/ModelClient.php (63%) rename src/{ => Platform}/Bridge/OpenAI/Embeddings/ResponseConverter.php (58%) create mode 100644 src/Platform/Bridge/OpenAI/GPT.php rename src/{ => Platform}/Bridge/OpenAI/GPT/ModelClient.php (62%) rename src/{ => Platform}/Bridge/OpenAI/GPT/ResponseConverter.php (79%) rename src/{ => Platform}/Bridge/OpenAI/PlatformFactory.php (54%) rename src/{ => Platform}/Bridge/OpenAI/TokenOutputProcessor.php (82%) create mode 100644 src/Platform/Bridge/OpenAI/Whisper.php create mode 100644 src/Platform/Bridge/OpenAI/Whisper/AudioNormalizer.php rename src/{ => Platform}/Bridge/OpenAI/Whisper/ModelClient.php (50%) create mode 100644 src/Platform/Bridge/OpenAI/Whisper/ResponseConverter.php rename src/{ => Platform}/Bridge/OpenRouter/Client.php (61%) create mode 100644 src/Platform/Bridge/OpenRouter/PlatformFactory.php rename src/{ => Platform}/Bridge/Replicate/Client.php (82%) create mode 100644 src/Platform/Bridge/Replicate/Contract/LlamaMessageBagNormalizer.php create mode 100644 src/Platform/Bridge/Replicate/LlamaModelClient.php create mode 100644 src/Platform/Bridge/Replicate/LlamaResponseConverter.php rename src/{ => Platform}/Bridge/Replicate/PlatformFactory.php (66%) rename src/{ => Platform}/Bridge/TransformersPHP/Platform.php (69%) rename src/{ => Platform}/Bridge/TransformersPHP/PlatformFactory.php (78%) rename src/{ => Platform}/Bridge/Voyage/ModelHandler.php (59%) rename src/{ => Platform}/Bridge/Voyage/PlatformFactory.php (86%) create mode 100644 src/Platform/Bridge/Voyage/Voyage.php create mode 100644 src/Platform/Capability.php create mode 100644 src/Platform/Contract.php rename src/{Chain => Platform/Contract}/JsonSchema/Attribute/With.php (74%) rename src/{Chain => Platform/Contract}/JsonSchema/DescriptionParser.php (87%) rename src/{Chain => Platform/Contract}/JsonSchema/Factory.php (90%) create mode 100644 src/Platform/Contract/Normalizer/Message/AssistantMessageNormalizer.php create mode 100644 src/Platform/Contract/Normalizer/Message/Content/AudioNormalizer.php create mode 100644 src/Platform/Contract/Normalizer/Message/Content/ImageNormalizer.php create mode 100644 src/Platform/Contract/Normalizer/Message/Content/ImageUrlNormalizer.php create mode 100644 src/Platform/Contract/Normalizer/Message/Content/TextNormalizer.php create mode 100644 src/Platform/Contract/Normalizer/Message/MessageBagNormalizer.php create mode 100644 src/Platform/Contract/Normalizer/Message/SystemMessageNormalizer.php create mode 100644 src/Platform/Contract/Normalizer/Message/ToolCallMessageNormalizer.php create mode 100644 src/Platform/Contract/Normalizer/Message/UserMessageNormalizer.php create mode 100644 src/Platform/Contract/Normalizer/ModelContractNormalizer.php create mode 100644 src/Platform/Contract/Normalizer/Response/ToolCallNormalizer.php create mode 100644 src/Platform/Contract/Normalizer/ToolNormalizer.php rename src/{ => Platform}/Exception/ContentFilterException.php (68%) create mode 100644 src/Platform/Exception/ExceptionInterface.php create mode 100644 src/Platform/Exception/InvalidArgumentException.php create mode 100644 src/Platform/Exception/RuntimeException.php create mode 100644 src/Platform/Message/AssistantMessage.php create mode 100644 src/Platform/Message/Content/Audio.php create mode 100644 src/Platform/Message/Content/ContentInterface.php rename src/{Model => Platform}/Message/Content/File.php (78%) create mode 100644 src/Platform/Message/Content/Image.php create mode 100644 src/Platform/Message/Content/ImageUrl.php create mode 100644 src/Platform/Message/Content/Text.php rename src/{Model => Platform}/Message/Message.php (65%) rename src/{Model => Platform}/Message/MessageBag.php (91%) rename src/{Model => Platform}/Message/MessageBagInterface.php (74%) create mode 100644 src/Platform/Message/MessageInterface.php rename src/{Model => Platform}/Message/Role.php (83%) create mode 100644 src/Platform/Message/SystemMessage.php create mode 100644 src/Platform/Message/ToolCallMessage.php create mode 100644 src/Platform/Message/UserMessage.php create mode 100644 src/Platform/Model.php delete mode 100644 src/Platform/ModelClient.php create mode 100644 src/Platform/ModelClientInterface.php create mode 100644 src/Platform/Platform.php rename src/{ => Platform}/PlatformInterface.php (71%) rename src/{Model => Platform}/Response/AsyncResponse.php (82%) rename src/{Model => Platform}/Response/BaseResponse.php (58%) rename src/{Model => Platform}/Response/BinaryResponse.php (79%) rename src/{Model => Platform}/Response/Choice.php (86%) rename src/{Model => Platform}/Response/ChoiceResponse.php (76%) create mode 100644 src/Platform/Response/Exception/RawResponseAlreadySetException.php rename src/{Model => Platform}/Response/Metadata/Metadata.php (97%) rename src/{Model => Platform}/Response/Metadata/MetadataAwareTrait.php (79%) rename src/{Model/Response/StructuredResponse.php => Platform/Response/ObjectResponse.php} (80%) rename src/{Model => Platform}/Response/RawResponseAwareTrait.php (73%) rename src/{Model => Platform}/Response/ResponseInterface.php (62%) rename src/{Model => Platform}/Response/StreamResponse.php (85%) rename src/{Model => Platform}/Response/TextResponse.php (85%) rename src/{Model => Platform}/Response/ToolCall.php (94%) rename src/{Model => Platform}/Response/ToolCallResponse.php (77%) rename src/{Model => Platform}/Response/VectorResponse.php (81%) rename src/Platform/{ResponseConverter.php => ResponseConverterInterface.php} (50%) rename src/{Chain/Toolbox => Platform/Tool}/ExecutionReference.php (82%) create mode 100644 src/Platform/Tool/Tool.php rename src/{Document => Platform/Vector}/NullVector.php (79%) rename src/{Document => Platform/Vector}/Vector.php (71%) rename src/{Document => Platform/Vector}/VectorInterface.php (81%) rename src/{Bridge/Azure/Store => Store/Bridge/Azure}/SearchStore.php (86%) rename src/{ => Store}/Bridge/ChromaDB/Store.php (85%) rename src/{ => Store}/Bridge/MongoDB/Store.php (94%) rename src/{ => Store}/Bridge/Pinecone/Store.php (91%) rename src/{ => Store}/Document/Metadata.php (76%) rename src/{ => Store}/Document/TextDocument.php (89%) rename src/{ => Store}/Document/VectorDocument.php (76%) rename src/{ => Store}/Embedder.php (77%) create mode 100644 src/Store/Exception/ExceptionInterface.php create mode 100644 src/Store/Exception/InvalidArgumentException.php create mode 100644 src/Store/Exception/RuntimeException.php delete mode 100644 tests/Bridge/Bedrock/Nova/NovaPromptConverterTest.php delete mode 100644 tests/Bridge/Google/GooglePromptConverterTest.php delete mode 100644 tests/Chain/InputProcessor/LlmOverrideInputProcessorTest.php create mode 100644 tests/Chain/InputProcessor/ModelOverrideInputProcessorTest.php delete mode 100644 tests/Model/Message/UserMessageTest.php rename tests/{ => Platform}/Bridge/Anthropic/ModelHandlerTest.php (77%) create mode 100644 tests/Platform/Bridge/Bedrock/Nova/ContractTest.php create mode 100644 tests/Platform/Bridge/Google/Contract/AssistantMessageNormalizerTest.php create mode 100644 tests/Platform/Bridge/Google/Contract/MessageBagNormalizerTest.php create mode 100644 tests/Platform/Bridge/Google/Contract/UserMessageNormalizerTest.php rename tests/{ => Platform}/Bridge/HuggingFace/ModelClientTest.php (60%) rename tests/{ => Platform}/Bridge/Meta/LlamaPromptConverterTest.php (88%) rename tests/{ => Platform}/Bridge/OpenAI/DallE/Base64ImageTest.php (87%) rename tests/{ => Platform}/Bridge/OpenAI/DallE/ImageResponseTest.php (88%) rename tests/{ => Platform}/Bridge/OpenAI/DallE/ModelClientTest.php (88%) rename tests/{ => Platform}/Bridge/OpenAI/DallE/UrlImageTest.php (85%) rename tests/{ => Platform}/Bridge/OpenAI/DallETest.php (88%) rename tests/{ => Platform}/Bridge/OpenAI/Embeddings/ResponseConverterTest.php (82%) rename tests/{ => Platform}/Bridge/OpenAI/GPT/ResponseConverterTest.php (92%) rename tests/{ => Platform}/Bridge/OpenAI/TokenOutputProcessorTest.php (78%) rename tests/{Chain => Platform/Contract}/JsonSchema/Attribute/ToolParameterTest.php (98%) rename tests/{Chain => Platform/Contract}/JsonSchema/DescriptionParserTest.php (96%) rename tests/{Chain => Platform/Contract}/JsonSchema/FactoryTest.php (96%) create mode 100644 tests/Platform/Contract/Normalizer/Message/AssistantMessageNormalizerTest.php create mode 100644 tests/Platform/Contract/Normalizer/Message/Content/AudioNormalizerTest.php create mode 100644 tests/Platform/Contract/Normalizer/Message/Content/ImageNormalizerTest.php create mode 100644 tests/Platform/Contract/Normalizer/Message/Content/ImageUrlNormalizerTest.php create mode 100644 tests/Platform/Contract/Normalizer/Message/Content/TextNormalizerTest.php create mode 100644 tests/Platform/Contract/Normalizer/Message/MessageBagNormalizerTest.php create mode 100644 tests/Platform/Contract/Normalizer/Message/SystemMessageNormalizerTest.php create mode 100644 tests/Platform/Contract/Normalizer/Message/ToolCallMessageNormalizerTest.php create mode 100644 tests/Platform/Contract/Normalizer/Message/UserMessageNormalizerTest.php create mode 100644 tests/Platform/Contract/Normalizer/ToolNormalizerTest.php create mode 100644 tests/Platform/ContractTest.php rename tests/{Model => Platform}/Message/AssistantMessageTest.php (50%) rename tests/{Model => Platform}/Message/Content/AudioTest.php (55%) rename tests/{Model => Platform}/Message/Content/BinaryTest.php (90%) rename tests/{Model => Platform}/Message/Content/ImageTest.php (82%) rename tests/{Model => Platform}/Message/Content/ImageUrlTest.php (55%) rename tests/{Model => Platform}/Message/Content/TextTest.php (57%) rename tests/{Model => Platform}/Message/MessageBagTest.php (76%) rename tests/{Model => Platform}/Message/MessageTest.php (73%) rename tests/{Model => Platform}/Message/RoleTest.php (92%) rename tests/{Model => Platform}/Message/SystemMessageTest.php (57%) rename tests/{Model => Platform}/Message/ToolCallMessageTest.php (56%) create mode 100644 tests/Platform/Message/UserMessageTest.php create mode 100644 tests/Platform/ModelTest.php rename tests/{Model => Platform}/Response/AsyncResponseTest.php (85%) rename tests/{Model => Platform}/Response/BaseResponseTest.php (74%) rename tests/{Model => Platform}/Response/ChoiceResponseTest.php (84%) rename tests/{Model => Platform}/Response/ChoiceTest.php (92%) rename tests/{Model => Platform}/Response/Exception/RawResponseAlreadySetTest.php (62%) rename tests/{Model => Platform}/Response/Metadata/MetadataAwareTraitTest.php (74%) rename tests/{Model => Platform}/Response/Metadata/MetadataTest.php (95%) rename tests/{Model => Platform}/Response/RawResponseAwareTraitTest.php (76%) rename tests/{Model => Platform}/Response/StreamResponseTest.php (88%) rename tests/{Model => Platform}/Response/StructuredResponseTest.php (60%) rename tests/{Model => Platform}/Response/TextResponseTest.php (81%) rename tests/{Model => Platform}/Response/TollCallResponseTest.php (79%) rename tests/{Model => Platform}/Response/ToolCallTest.php (90%) rename tests/{ => Store}/Document/NullVectorTest.php (78%) rename tests/{ => Store}/Document/VectorTest.php (82%) rename tests/{ => Store}/EmbedderTest.php (87%) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 2ce08136..acbf45ae 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -11,6 +11,10 @@ ->setParallelConfig(ParallelConfigFactory::detect()) ->setRules([ '@Symfony' => true, + '@Symfony:risky' => true, + 'protected_to_private' => false, + 'declare_strict_types' => false, 'heredoc_indentation' => ['indentation' => 'start_plus_one'], ]) + ->setRiskyAllowed(true) ->setFinder($finder); diff --git a/Makefile b/Makefile index a563a3c1..a55a8978 100644 --- a/Makefile +++ b/Makefile @@ -32,3 +32,8 @@ ci: ci-stable ci-stable: deps-stable rector cs phpstan tests ci-lowest: deps-low rector cs phpstan tests + +fix-transformers: + wget -P /tmp https://github.com/rindow/rindow-matlib/releases/download/1.1.1/rindow-matlib_1.1.1-24.04_amd64.deb + dpkg-deb -x /tmp/rindow-matlib_1.1.1-24.04_amd64.deb /tmp/librindowmatlib_extracted + cp /tmp/librindowmatlib_extracted/usr/lib/rindowmatlib-thread/librindowmatlib.so vendor/codewithkyrian/transformers/libs/librindowmatlib.so diff --git a/README.md b/README.md index e926b6d0..fd06ca20 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ Those models are provided by different **platforms**, like OpenAI, Azure, Google #### Example Instantiation ```php -use PhpLlm\LlmChain\Bridge\OpenAI\Embeddings; -use PhpLlm\LlmChain\Bridge\OpenAI\GPT; -use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\PlatformFactory; // Platform: OpenAI $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); @@ -90,13 +90,13 @@ have different content types, like `Text`, `Image` or `Audio`. #### Example Chain call with messages ```php -use PhpLlm\LlmChain\Chain; -use PhpLlm\LlmChain\Model\Message\Message; -use PhpLlm\LlmChain\Model\Message\MessageBag; +use PhpLlm\LlmChain\Chain\Chain; +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Message\MessageBag; // Platform & LLM instantiation -$chain = new Chain($platform, $llm); +$chain = new Chain($platform, $model); $messages = new MessageBag( Message::forSystem('You are a helpful chatbot answering questions about LLM Chain.'), Message::ofUser('Hello, how are you?'), @@ -146,6 +146,7 @@ Tools are services that can be called by the LLM to provide additional features Tool calling can be enabled by registering the processors in the chain: ```php +use PhpLlm\LlmChain\Chain\Chain; use PhpLlm\LlmChain\Chain\Toolbox\ChainProcessor; use PhpLlm\LlmChain\Chain\Toolbox\Toolbox; @@ -156,7 +157,7 @@ $yourTool = new YourTool(); $toolbox = Toolbox::create($yourTool); $toolProcessor = new ChainProcessor($toolbox); -$chain = new Chain($platform, $llm, inputProcessor: [$toolProcessor], outputProcessor: [$toolProcessor]); +$chain = new Chain($platform, $model, inputProcessor: [$toolProcessor], outputProcessor: [$toolProcessor]); ``` Custom tools can basically be any class, but must configure by the `#[AsTool]` attribute. @@ -219,8 +220,8 @@ partially support by LLMs like GPT. To leverage this, configure the `#[With]` attribute on the method arguments of your tool: ```php -use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With; use PhpLlm\LlmChain\Chain\Toolbox\Attribute\AsTool; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\Attribute\With; #[AsTool('my_tool', 'Example tool with parameters requirements.')] final class MyTool @@ -252,10 +253,10 @@ attribute to the class is not possible in those cases, but you can explicitly re ```php use PhpLlm\LlmChain\Chain\Toolbox\Toolbox; -use PhpLlm\LlmChain\Chain\Toolbox\MetadataFactory\MemoryFactory; +use PhpLlm\LlmChain\Chain\Toolbox\ToolFactory\MemoryToolFactory; use Symfony\Component\Clock\Clock; -$metadataFactory = (new MemoryFactory()) +$metadataFactory = (new MemoryToolFactory()) ->addTool(Clock::class, 'clock', 'Get the current date and time', 'now'); $toolbox = new Toolbox($metadataFactory, [new Clock()]); ``` @@ -268,12 +269,12 @@ tools in the same chain - which even enables you to overwrite the pre-existing c ```php use PhpLlm\LlmChain\Chain\Toolbox\Toolbox; -use PhpLlm\LlmChain\Chain\Toolbox\MetadataFactory\ChainFactory; -use PhpLlm\LlmChain\Chain\Toolbox\MetadataFactory\MemoryFactory; -use PhpLlm\LlmChain\Chain\Toolbox\MetadataFactory\ReflectionFactory; +use PhpLlm\LlmChain\Chain\Toolbox\ToolFactory\ChainFactory; +use PhpLlm\LlmChain\Chain\Toolbox\ToolFactory\MemoryToolFactory; +use PhpLlm\LlmChain\Chain\Toolbox\ToolFactory\ReflectionToolFactory; -$reflectionFactory = new ReflectionFactory(); // Register tools with #[AsTool] attribute -$metadataFactory = (new MemoryFactory()) // Register or overwrite tools explicitly +$reflectionFactory = new ReflectionToolFactory(); // Register tools with #[AsTool] attribute +$metadataFactory = (new MemoryToolFactory()) // Register or overwrite tools explicitly ->addTool(...); $toolbox = new Toolbox(new ChainFactory($metadataFactory, $reflectionFactory), [...]); ``` @@ -287,14 +288,14 @@ Similar to third-party tools, you can also use a chain as a tool in another chai complex logic or to reuse a chain in multiple places or hide sub-chains from the LLM. ```php -use PhpLlm\LlmChain\Chain\Toolbox\MetadataFactory\MemoryFactory; +use PhpLlm\LlmChain\Chain\Toolbox\ToolFactory\MemoryToolFactory; use PhpLlm\LlmChain\Chain\Toolbox\Toolbox; use PhpLlm\LlmChain\Chain\Toolbox\Tool\Chain; // Chain was initialized before $chainTool = new Chain($chain); -$metadataFactory = (new MemoryFactory()) +$metadataFactory = (new MemoryToolFactory()) ->addTool($chainTool, 'research_agent', 'Meaningful description for sub-chain'); $toolbox = new Toolbox($metadataFactory, [$chainTool]); ``` @@ -306,6 +307,7 @@ To gracefully handle errors that occur during tool calling, e.g. wrong tool name to the LLM. ```php +use PhpLlm\LlmChain\Chain\Chain; use PhpLlm\LlmChain\Chain\Toolbox\ChainProcessor; use PhpLlm\LlmChain\Chain\Toolbox\FaultTolerantToolbox; @@ -314,7 +316,7 @@ use PhpLlm\LlmChain\Chain\Toolbox\FaultTolerantToolbox; $toolbox = new FaultTolerantToolbox($innerToolbox); $toolProcessor = new ChainProcessor($toolbox); -$chain = new Chain($platform, $llm, inputProcessor: [$toolProcessor], outputProcessor: [$toolProcessor]); +$chain = new Chain($platform, $model, inputProcessor: [$toolProcessor], outputProcessor: [$toolProcessor]); ``` #### Tool Filtering @@ -362,12 +364,11 @@ For populating a vector store, LLM Chain provides the service `Embedder`, which `EmbeddingsModel` and one of `StoreInterface`, and works with a collection of `Document` objects as input: ```php -use PhpLlm\LlmChain\Embedder; -use PhpLlm\LlmChain\Bridge\OpenAI\Embeddings; -use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory; -use PhpLlm\LlmChain\Bridge\Pinecone\Store; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\PlatformFactory; +use PhpLlm\LlmChain\Store\Bridge\Pinecone\Store; +use PhpLlm\LlmChain\Store\Embedder; use Probots\Pinecone\Pinecone; -use Symfony\Component\HttpClient\HttpClient; $embedder = new Embedder( PlatformFactory::create($_ENV['OPENAI_API_KEY']), @@ -380,8 +381,8 @@ $embedder->embed($documents); The collection of `Document` instances is usually created by text input of your domain entities: ```php -use PhpLlm\LlmChain\Document\Metadata; -use PhpLlm\LlmChain\Document\TextDocument; +use PhpLlm\LlmChain\Store\Document\Metadata; +use PhpLlm\LlmChain\Store\Document\TextDocument; foreach ($entities as $entity) { $documents[] = new TextDocument( @@ -399,19 +400,19 @@ In the end the chain is used in combination with a retrieval tool on top of the `SimilaritySearch` tool provided by the library: ```php -use PhpLlm\LlmChain\Chain; -use PhpLlm\LlmChain\Model\Message\Message; -use PhpLlm\LlmChain\Model\Message\MessageBag; +use PhpLlm\LlmChain\Chain\Chain; use PhpLlm\LlmChain\Chain\Toolbox\ChainProcessor; use PhpLlm\LlmChain\Chain\Toolbox\Tool\SimilaritySearch; use PhpLlm\LlmChain\Chain\Toolbox\Toolbox; +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Message\MessageBag; // Initialize Platform & Models $similaritySearch = new SimilaritySearch($embeddings, $store); $toolbox = Toolbox::create($similaritySearch); -$processor = new ChainProcessor($toolbox); -$chain = new Chain($platform, $llm, [$processor], [$processor]); +$processor = new Chain($toolbox); +$chain = new Chain($platform, $model, [$processor], [$processor]); $messages = new MessageBag( Message::forSystem(<<call($messages); #### Code Examples -1. [MongoDB Store](examples/store-mongodb-similarity-search.php) -1. [Pinecone Store](examples/store-pinecone-similarity-search.php) +1. [MongoDB Store](examples/store/mongodb-similarity-search.php) +1. [Pinecone Store](examples/store/pinecone-similarity-search.php) #### Supported Stores @@ -450,12 +451,13 @@ LLM Chain supports that use-case by abstracting the hustle of defining and provi the response back to PHP objects. To achieve this, a specific chain processor needs to be registered: + ```php -use PhpLlm\LlmChain\Chain; -use PhpLlm\LlmChain\Model\Message\Message; -use PhpLlm\LlmChain\Model\Message\MessageBag; +use PhpLlm\LlmChain\Chain\Chain; use PhpLlm\LlmChain\Chain\StructuredOutput\ChainProcessor; use PhpLlm\LlmChain\Chain\StructuredOutput\ResponseFormatFactory; +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Message\MessageBag; use PhpLlm\LlmChain\Tests\Chain\StructuredOutput\Data\MathReasoning; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; @@ -465,7 +467,7 @@ use Symfony\Component\Serializer\Serializer; $serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]); $processor = new ChainProcessor(new ResponseFormatFactory(), $serializer); -$chain = new Chain($platform, $llm, [$processor], [$processor]); +$chain = new Chain($platform, $model, [$processor], [$processor]); $messages = new MessageBag( Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'), @@ -481,8 +483,8 @@ dump($response->getContent()); // returns an instance of `MathReasoning` class Also PHP array structures as `response_format` are supported, which also requires the chain processor mentioned above: ```php -use PhpLlm\LlmChain\Model\Message\Message; -use PhpLlm\LlmChain\Model\Message\MessageBag; +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Message\MessageBag; // Initialize Platform, LLM and Chain with processors and Clock tool @@ -518,13 +520,13 @@ Since LLMs usually generate a response word by word, most of them also support s Events. LLM Chain supports that by abstracting the conversion and returning a Generator as content of the response. ```php -use PhpLlm\LlmChain\Chain; +use PhpLlm\LlmChain\Chain\Chain; use PhpLlm\LlmChain\Message\Message; use PhpLlm\LlmChain\Message\MessageBag; // Initialize Platform and LLM -$chain = new Chain($llm); +$chain = new Chain($model); $messages = new MessageBag( Message::forSystem('You are a thoughtful philosopher.'), Message::ofUser('What is the purpose of an ant?'), @@ -551,9 +553,9 @@ needs to be used. Some LLMs also support images as input, which LLM Chain supports as `Content` type within the `UserMessage`: ```php -use PhpLlm\LlmChain\Model\Message\Content\Image; -use PhpLlm\LlmChain\Model\Message\Message; -use PhpLlm\LlmChain\Model\Message\MessageBag; +use PhpLlm\LlmChain\Platform\Message\Content\Image; +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Message\MessageBag; // Initialize Platform, LLM & Chain @@ -579,9 +581,9 @@ $response = $chain->call($messages); Similar to images, some LLMs also support audio as input, which is just another `Content` type within the `UserMessage`: ```php -use PhpLlm\LlmChain\Model\Message\Content\Audio; -use PhpLlm\LlmChain\Model\Message\Message; -use PhpLlm\LlmChain\Model\Message\MessageBag; +use PhpLlm\LlmChain\Platform\Message\Content\Audio; +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Message\MessageBag; // Initialize Platform, LLM & Chain @@ -606,7 +608,7 @@ therefore LLM Chain implements a `EmbeddingsModel` interface with various models The standalone usage results in an `Vector` instance: ```php -use PhpLlm\LlmChain\Bridge\OpenAI\Embeddings; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings; // Initialize Platform @@ -655,11 +657,11 @@ The behavior of the Chain is extendable with services that implement `InputProce interface. They are provided while instantiating the Chain instance: ```php -use PhpLlm\LlmChain\Chain; +use PhpLlm\LlmChain\Chain\Chain; // Initialize Platform, LLM and processors -$chain = new Chain($platform, $llm, $inputProcessors, $outputProcessors); +$chain = new Chain($platform, $model, $inputProcessors, $outputProcessors); ``` #### InputProcessor @@ -669,10 +671,10 @@ able to mutate both on top of the `Input` instance provided. ```php use PhpLlm\LlmChain\Chain\Input; -use PhpLlm\LlmChain\Chain\InputProcessor; -use PhpLlm\LlmChain\Model\Message\AssistantMessage +use PhpLlm\LlmChain\Chain\InputProcessorInterface; +use PhpLlm\LlmChain\Platform\Message\AssistantMessage; -final class MyProcessor implements InputProcessor +final class MyProcessor implements InputProcessorInterface { public function processInput(Input $input): void { @@ -694,10 +696,9 @@ mutate or replace the given response: ```php use PhpLlm\LlmChain\Chain\Output; -use PhpLlm\LlmChain\Chain\OutputProcessor; -use PhpLlm\LlmChain\Model\Message\AssistantMessage +use PhpLlm\LlmChain\Chain\OutputProcessorInterface; -final class MyProcessor implements OutputProcessor +final class MyProcessor implements OutputProcessorInterface { public function processOutput(Output $out): void { @@ -716,13 +717,12 @@ provided, in case the processor implemented the `ChainAwareProcessor` interface, `ChainAwareTrait`: ```php -use PhpLlm\LlmChain\Chain\ChainAwareProcessor; +use PhpLlm\LlmChain\Chain\ChainAwareInterface; use PhpLlm\LlmChain\Chain\ChainAwareTrait; use PhpLlm\LlmChain\Chain\Output; -use PhpLlm\LlmChain\Chain\OutputProcessor; -use PhpLlm\LlmChain\Model\Message\AssistantMessage +use PhpLlm\LlmChain\Chain\OutputProcessorInterface; -final class MyProcessor implements OutputProcessor, ChainAwareProcessor +final class MyProcessor implements OutputProcessorInterface, ChainAwareInterface { use ChainAwareTrait; @@ -740,11 +740,12 @@ LLM Chain comes out of the box with an integration for [HuggingFace](https://hug hosting and sharing all kinds of models, including LLMs, embeddings, image generation, and classification models. You can just instantiate the Platform with the corresponding HuggingFace bridge and use it with the `task` option: + ```php use PhpLlm\LlmChain\Bridge\HuggingFace\Model; -use PhpLlm\LlmChain\Bridge\HuggingFace\PlatformFactory; -use PhpLlm\LlmChain\Bridge\HuggingFace\Task; -use PhpLlm\LlmChain\Model\Message\Content\Image; +use PhpLlm\LlmChain\Platform\Bridge\HuggingFace\PlatformFactory; +use PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Task; +use PhpLlm\LlmChain\Platform\Message\Content\Image; $platform = PlatformFactory::create($apiKey); $model = new Model('facebook/detr-resnet-50'); @@ -790,7 +791,7 @@ The usage with LLM Chain is similar to the HuggingFace integration, and also req ```php use Codewithkyrian\Transformers\Pipelines\Task; use PhpLlm\LlmChain\Bridge\TransformersPHP\Model; -use PhpLlm\LlmChain\Bridge\TransformersPHP\PlatformFactory; +use PhpLlm\LlmChain\Platform\Bridge\TransformersPHP\PlatformFactory; $platform = PlatformFactory::create(); $model = new Model('Xenova/LaMini-Flan-T5-783M'); diff --git a/composer.json b/composer.json index a5e8fe69..34fe7f80 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "mongodb/mongodb": "^1.21", "php-cs-fixer/shim": "^3.70", "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", diff --git a/examples/anthropic/chat.php b/examples/anthropic/chat.php index 87105d10..510cd78d 100644 --- a/examples/anthropic/chat.php +++ b/examples/anthropic/chat.php @@ -1,28 +1,28 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['ANTHROPIC_API_KEY'])) { - echo 'Please set the ANTHROPIC_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the ANTHROPIC_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['ANTHROPIC_API_KEY']); -$llm = new Claude(Claude::SONNET_37); +$model = new Claude(Claude::SONNET_37); -$chain = new Chain($platform, $llm); +$chain = new Chain($platform, $model); $messages = new MessageBag( Message::forSystem('You are a pirate and you write funny.'), Message::ofUser('What is the Symfony framework?'), ); $response = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/anthropic/stream.php b/examples/anthropic/stream.php index a7d31b4b..6320cd63 100644 --- a/examples/anthropic/stream.php +++ b/examples/anthropic/stream.php @@ -1,24 +1,24 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['ANTHROPIC_API_KEY'])) { - echo 'Please set the ANTHROPIC_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the ANTHROPIC_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['ANTHROPIC_API_KEY']); -$llm = new Claude(); +$model = new Claude(); -$chain = new Chain($platform, $llm); +$chain = new Chain($platform, $model); $messages = new MessageBag( Message::forSystem('You are a thoughtful philosopher.'), Message::ofUser('What is the purpose of an ant?'), @@ -30,4 +30,4 @@ foreach ($response->getContent() as $word) { echo $word; } -echo PHP_EOL; +echo \PHP_EOL; diff --git a/examples/anthropic/toolcall.php b/examples/anthropic/toolcall.php index efed663b..a224d526 100644 --- a/examples/anthropic/toolcall.php +++ b/examples/anthropic/toolcall.php @@ -1,13 +1,13 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['ANTHROPIC_API_KEY'])) { - echo 'Please set the ANTHROPIC_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the ANTHROPIC_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['ANTHROPIC_API_KEY']); -$llm = new Claude(); +$model = new Claude(); $wikipedia = new Wikipedia(HttpClient::create()); $toolbox = Toolbox::create($wikipedia); $processor = new ChainProcessor($toolbox); -$chain = new Chain($platform, $llm, [$processor], [$processor]); +$chain = new Chain($platform, $model, [$processor], [$processor]); $messages = new MessageBag(Message::ofUser('Who is the current chancellor of Germany?')); $response = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/azure/audio-transcript.php b/examples/azure/audio-transcript.php index a2544f01..713f34d4 100644 --- a/examples/azure/audio-transcript.php +++ b/examples/azure/audio-transcript.php @@ -1,8 +1,8 @@ request($model, $file); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/azure/chat-gpt.php b/examples/azure/chat-gpt.php index 2ec2c1b6..7b28aa6d 100644 --- a/examples/azure/chat-gpt.php +++ b/examples/azure/chat-gpt.php @@ -1,10 +1,10 @@ call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/azure/chat-llama.php b/examples/azure/chat-llama.php index 28bc623f..a895dc6a 100644 --- a/examples/azure/chat-llama.php +++ b/examples/azure/chat-llama.php @@ -1,24 +1,24 @@ 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; + 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']); -$llm = new Llama(Llama::V3_3_70B_INSTRUCT); +$model = new Llama(Llama::V3_3_70B_INSTRUCT); -$chain = new Chain($platform, $llm); +$chain = new Chain($platform, $model); $messages = new MessageBag(Message::ofUser('I am going to Paris, what should I see?')); $response = $chain->call($messages, [ 'max_tokens' => 2048, @@ -28,4 +28,4 @@ 'frequency_penalty' => 0, ]); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/azure/embeddings.php b/examples/azure/embeddings.php index a3dcb495..d92eed99 100644 --- a/examples/azure/embeddings.php +++ b/examples/azure/embeddings.php @@ -1,8 +1,8 @@ getContent()[0]->getDimensions().PHP_EOL; +echo 'Dimensions: '.$response->getContent()[0]->getDimensions().\PHP_EOL; diff --git a/examples/bedrock/chat-claude.php b/examples/bedrock/chat-claude.php index 481f93db..b8fece13 100644 --- a/examples/bedrock/chat-claude.php +++ b/examples/bedrock/chat-claude.php @@ -1,10 +1,10 @@ call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/bedrock/chat-llama.php b/examples/bedrock/chat-llama.php index 19e7306f..5ac723e3 100644 --- a/examples/bedrock/chat-llama.php +++ b/examples/bedrock/chat-llama.php @@ -1,10 +1,10 @@ call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/bedrock/chat-nova.php b/examples/bedrock/chat-nova.php index 9c91fd46..b68eaf8c 100644 --- a/examples/bedrock/chat-nova.php +++ b/examples/bedrock/chat-nova.php @@ -1,10 +1,10 @@ call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/bedrock/image-claude-binary.php b/examples/bedrock/image-claude-binary.php index 47dd8811..c229b9ad 100644 --- a/examples/bedrock/image-claude-binary.php +++ b/examples/bedrock/image-claude-binary.php @@ -1,11 +1,11 @@ call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/bedrock/image-nova.php b/examples/bedrock/image-nova.php index 81c1557e..70bc654b 100644 --- a/examples/bedrock/image-nova.php +++ b/examples/bedrock/image-nova.php @@ -1,11 +1,11 @@ call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/bedrock/toolcall-claude.php b/examples/bedrock/toolcall-claude.php index 8e96f48b..149a5cb1 100644 --- a/examples/bedrock/toolcall-claude.php +++ b/examples/bedrock/toolcall-claude.php @@ -1,13 +1,13 @@ call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/bedrock/toolcall-nova.php b/examples/bedrock/toolcall-nova.php new file mode 100644 index 00000000..f43302e5 --- /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 ChainProcessor($toolbox); +$chain = new Chain($platform, $model, [$processor], [$processor]); + +$messages = new MessageBag( + Message::ofUser('Who is the current chancellor of Germany? Use Wikipedia to find the answer.') +); +$response = $chain->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/chat-system-prompt.php b/examples/chat-system-prompt.php index 81b71ec2..49886e0c 100644 --- a/examples/chat-system-prompt.php +++ b/examples/chat-system-prompt.php @@ -1,28 +1,28 @@ loadEnv(dirname(__DIR__).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI); +$model = new GPT(GPT::GPT_4O_MINI); $processor = new SystemPromptInputProcessor('You are Yoda and write like he speaks. But short.'); -$chain = new Chain($platform, $llm, [$processor]); +$chain = new Chain($platform, $model, [$processor]); $messages = new MessageBag(Message::ofUser('What is the meaning of life?')); $response = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/google/chat.php b/examples/google/chat.php index babdbc9e..133e1e76 100644 --- a/examples/google/chat.php +++ b/examples/google/chat.php @@ -1,28 +1,28 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['GOOGLE_API_KEY'])) { - echo 'Please set the GOOGLE_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the GOOGLE_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['GOOGLE_API_KEY']); -$llm = new Gemini(Gemini::GEMINI_2_FLASH); +$model = new Gemini(Gemini::GEMINI_2_FLASH); -$chain = new Chain($platform, $llm); +$chain = new Chain($platform, $model); $messages = new MessageBag( Message::forSystem('You are a pirate and you write funny.'), Message::ofUser('What is the Symfony framework?'), ); $response = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/google/image-input.php b/examples/google/image-input.php index 5d99aac5..d6644a1f 100644 --- a/examples/google/image-input.php +++ b/examples/google/image-input.php @@ -1,25 +1,25 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['GOOGLE_API_KEY'])) { - echo 'Please set the GOOGLE_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the GOOGLE_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['GOOGLE_API_KEY']); -$llm = new Gemini(Gemini::GEMINI_1_5_FLASH); +$model = new Gemini(Gemini::GEMINI_1_5_FLASH); -$chain = new Chain($platform, $llm); +$chain = new Chain($platform, $model); $messages = new MessageBag( Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), Message::ofUser( @@ -29,4 +29,4 @@ ); $response = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/google/stream.php b/examples/google/stream.php index d6054914..de9bc5d3 100644 --- a/examples/google/stream.php +++ b/examples/google/stream.php @@ -1,24 +1,24 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['GOOGLE_API_KEY'])) { - echo 'Please set the GOOGLE_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the GOOGLE_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['GOOGLE_API_KEY']); -$llm = new Gemini(Gemini::GEMINI_2_FLASH); +$model = new Gemini(Gemini::GEMINI_2_FLASH); -$chain = new Chain($platform, $llm); +$chain = new Chain($platform, $model); $messages = new MessageBag( Message::forSystem('You are a funny clown that entertains people.'), Message::ofUser('What is the purpose of an ant?'), @@ -30,4 +30,4 @@ foreach ($response->getContent() as $word) { echo $word; } -echo PHP_EOL; +echo \PHP_EOL; diff --git a/examples/huggingface/_model-listing.php b/examples/huggingface/_model-listing.php index 37495ca3..143d893c 100644 --- a/examples/huggingface/_model-listing.php +++ b/examples/huggingface/_model-listing.php @@ -1,11 +1,11 @@ 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, ConsoleOutput $output) { + ->setCode(function (InputInterface $input, OutputInterface $output) { $io = new SymfonyStyle($input, $output); $io->title('HuggingFace Model Listing'); diff --git a/examples/huggingface/audio-classification.php b/examples/huggingface/audio-classification.php index 98642170..d5d89c6d 100644 --- a/examples/huggingface/audio-classification.php +++ b/examples/huggingface/audio-classification.php @@ -1,16 +1,16 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } diff --git a/examples/huggingface/automatic-speech-recognition.php b/examples/huggingface/automatic-speech-recognition.php index 93117a59..943a55e3 100644 --- a/examples/huggingface/automatic-speech-recognition.php +++ b/examples/huggingface/automatic-speech-recognition.php @@ -1,16 +1,16 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } @@ -22,4 +22,4 @@ 'task' => Task::AUTOMATIC_SPEECH_RECOGNITION, ]); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/huggingface/chat-completion.php b/examples/huggingface/chat-completion.php index 97649668..257ea999 100644 --- a/examples/huggingface/chat-completion.php +++ b/examples/huggingface/chat-completion.php @@ -1,17 +1,17 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } @@ -23,4 +23,4 @@ 'task' => Task::CHAT_COMPLETION, ]); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/huggingface/feature-extraction.php b/examples/huggingface/feature-extraction.php index 2322a3dc..32d437da 100644 --- a/examples/huggingface/feature-extraction.php +++ b/examples/huggingface/feature-extraction.php @@ -1,16 +1,16 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } @@ -23,4 +23,4 @@ assert($response instanceof VectorResponse); -echo 'Dimensions: '.$response->getContent()[0]->getDimensions().PHP_EOL; +echo 'Dimensions: '.$response->getContent()[0]->getDimensions().\PHP_EOL; diff --git a/examples/huggingface/fill-mask.php b/examples/huggingface/fill-mask.php index e6373ea2..b04e959b 100644 --- a/examples/huggingface/fill-mask.php +++ b/examples/huggingface/fill-mask.php @@ -1,15 +1,15 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } diff --git a/examples/huggingface/image-classification.php b/examples/huggingface/image-classification.php index 479526b9..a997a119 100644 --- a/examples/huggingface/image-classification.php +++ b/examples/huggingface/image-classification.php @@ -1,16 +1,16 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } diff --git a/examples/huggingface/image-segmentation.php b/examples/huggingface/image-segmentation.php index a361b125..3447ad82 100644 --- a/examples/huggingface/image-segmentation.php +++ b/examples/huggingface/image-segmentation.php @@ -1,16 +1,16 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } diff --git a/examples/huggingface/image-to-text.php b/examples/huggingface/image-to-text.php index 90e09751..2c135efa 100644 --- a/examples/huggingface/image-to-text.php +++ b/examples/huggingface/image-to-text.php @@ -1,16 +1,16 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } @@ -22,4 +22,4 @@ 'task' => Task::IMAGE_TO_TEXT, ]); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/huggingface/object-detection.php b/examples/huggingface/object-detection.php index 8bc3d362..72f35c82 100644 --- a/examples/huggingface/object-detection.php +++ b/examples/huggingface/object-detection.php @@ -1,16 +1,16 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } diff --git a/examples/huggingface/question-answering.php b/examples/huggingface/question-answering.php index 33c3e345..c1d71551 100644 --- a/examples/huggingface/question-answering.php +++ b/examples/huggingface/question-answering.php @@ -1,15 +1,15 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } diff --git a/examples/huggingface/sentence-similarity.php b/examples/huggingface/sentence-similarity.php index 243da7cf..40bbd95e 100644 --- a/examples/huggingface/sentence-similarity.php +++ b/examples/huggingface/sentence-similarity.php @@ -1,15 +1,15 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } diff --git a/examples/huggingface/summarization.php b/examples/huggingface/summarization.php index d3c6ee3b..5266d1f2 100644 --- a/examples/huggingface/summarization.php +++ b/examples/huggingface/summarization.php @@ -1,15 +1,15 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } @@ -30,4 +30,4 @@ 'task' => Task::SUMMARIZATION, ]); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/huggingface/table-question-answering.php b/examples/huggingface/table-question-answering.php index 7b475911..aa134937 100644 --- a/examples/huggingface/table-question-answering.php +++ b/examples/huggingface/table-question-answering.php @@ -1,15 +1,15 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } diff --git a/examples/huggingface/text-classification.php b/examples/huggingface/text-classification.php index 3e95c41a..29345190 100644 --- a/examples/huggingface/text-classification.php +++ b/examples/huggingface/text-classification.php @@ -1,15 +1,15 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } diff --git a/examples/huggingface/text-generation.php b/examples/huggingface/text-generation.php index 878eff3b..f2257257 100644 --- a/examples/huggingface/text-generation.php +++ b/examples/huggingface/text-generation.php @@ -1,15 +1,15 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } @@ -20,4 +20,4 @@ 'task' => Task::TEXT_GENERATION, ]); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/huggingface/text-to-image.php b/examples/huggingface/text-to-image.php index ff27bc55..c30e093c 100644 --- a/examples/huggingface/text-to-image.php +++ b/examples/huggingface/text-to-image.php @@ -1,16 +1,16 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } @@ -23,4 +23,4 @@ assert($response instanceof BinaryResponse); -echo $response->toBase64().PHP_EOL; +echo $response->toBase64().\PHP_EOL; diff --git a/examples/huggingface/token-classification.php b/examples/huggingface/token-classification.php index a5bc7a93..c782a3c5 100644 --- a/examples/huggingface/token-classification.php +++ b/examples/huggingface/token-classification.php @@ -1,15 +1,15 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } diff --git a/examples/huggingface/translation.php b/examples/huggingface/translation.php index 7e001e62..e8a959e8 100644 --- a/examples/huggingface/translation.php +++ b/examples/huggingface/translation.php @@ -1,15 +1,15 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } @@ -22,4 +22,4 @@ 'tgt_lang' => 'en', ]); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/huggingface/zero-shot-classification.php b/examples/huggingface/zero-shot-classification.php index b8acadce..bfc31c9d 100644 --- a/examples/huggingface/zero-shot-classification.php +++ b/examples/huggingface/zero-shot-classification.php @@ -1,15 +1,15 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['HUGGINGFACE_KEY'])) { - echo 'Please set the HUGGINGFACE_KEY environment variable.'.PHP_EOL; + echo 'Please set the HUGGINGFACE_KEY environment variable.'.\PHP_EOL; exit(1); } diff --git a/examples/ollama/chat-llama.php b/examples/ollama/chat-llama.php index d97b982e..4df42d13 100644 --- a/examples/ollama/chat-llama.php +++ b/examples/ollama/chat-llama.php @@ -1,28 +1,28 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OLLAMA_HOST_URL'])) { - echo 'Please set the OLLAMA_HOST_URL environment variable.'.PHP_EOL; + echo 'Please set the OLLAMA_HOST_URL environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OLLAMA_HOST_URL']); -$llm = new Llama('llama3.2'); +$model = new Llama('llama3.2'); -$chain = new Chain($platform, $llm); +$chain = new Chain($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 = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/openai/audio-input.php b/examples/openai/audio-input.php index 61c64f3f..572b3128 100644 --- a/examples/openai/audio-input.php +++ b/examples/openai/audio-input.php @@ -1,25 +1,25 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_AUDIO); +$model = new GPT(GPT::GPT_4O_AUDIO); -$chain = new Chain($platform, $llm); +$chain = new Chain($platform, $model); $messages = new MessageBag( Message::ofUser( 'What is this recording about?', @@ -28,4 +28,4 @@ ); $response = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/openai/audio-transcript.php b/examples/openai/audio-transcript.php index 4ce7cf88..3567cab0 100644 --- a/examples/openai/audio-transcript.php +++ b/examples/openai/audio-transcript.php @@ -1,15 +1,15 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } @@ -19,4 +19,4 @@ $response = $platform->request($model, $file); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/openai/chat-o1.php b/examples/openai/chat-o1.php index 5c88377f..79ec0c91 100644 --- a/examples/openai/chat-o1.php +++ b/examples/openai/chat-o1.php @@ -1,37 +1,37 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + 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; +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']); -$llm = new GPT(GPT::O1_PREVIEW); +$model = new GPT(GPT::O1_PREVIEW); $prompt = <<call(new MessageBag(Message::ofUser($prompt))); +$response = (new Chain($platform, $model))->call(new MessageBag(Message::ofUser($prompt))); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/openai/chat.php b/examples/openai/chat.php index ef8f9f65..ae75ecc3 100644 --- a/examples/openai/chat.php +++ b/examples/openai/chat.php @@ -1,26 +1,26 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI, [ +$model = new GPT(GPT::GPT_4O_MINI, [ 'temperature' => 0.5, // default options for the model ]); -$chain = new Chain($platform, $llm); +$chain = new Chain($platform, $model); $messages = new MessageBag( Message::forSystem('You are a pirate and you write funny.'), Message::ofUser('What is the Symfony framework?'), @@ -29,4 +29,4 @@ 'max_tokens' => 500, // specific options just for this call ]); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/openai/embeddings.php b/examples/openai/embeddings.php index 780c74df..b483c94d 100644 --- a/examples/openai/embeddings.php +++ b/examples/openai/embeddings.php @@ -1,15 +1,15 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } @@ -24,4 +24,4 @@ assert($response instanceof VectorResponse); -echo 'Dimensions: '.$response->getContent()[0]->getDimensions().PHP_EOL; +echo 'Dimensions: '.$response->getContent()[0]->getDimensions().\PHP_EOL; diff --git a/examples/openai/image-input-binary.php b/examples/openai/image-input-binary.php index 013a642e..2ff1aff3 100644 --- a/examples/openai/image-input-binary.php +++ b/examples/openai/image-input-binary.php @@ -1,25 +1,25 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI); +$model = new GPT(GPT::GPT_4O_MINI); -$chain = new Chain($platform, $llm); +$chain = new Chain($platform, $model); $messages = new MessageBag( Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), Message::ofUser( @@ -29,4 +29,4 @@ ); $response = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/openai/image-input-url.php b/examples/openai/image-input-url.php index 707fe2f5..d21ff853 100644 --- a/examples/openai/image-input-url.php +++ b/examples/openai/image-input-url.php @@ -1,25 +1,25 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI); +$model = new GPT(GPT::GPT_4O_MINI); -$chain = new Chain($platform, $llm); +$chain = new Chain($platform, $model); $messages = new MessageBag( Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), Message::ofUser( @@ -29,4 +29,4 @@ ); $response = $chain->call($messages); -echo $response->getContent().PHP_EOL; +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 index 97520b2d..a4ed8005 100644 --- a/examples/openai/image-output-dall-e-2.php +++ b/examples/openai/image-output-dall-e-2.php @@ -1,14 +1,14 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } @@ -24,5 +24,5 @@ ); foreach ($response->getContent() as $index => $image) { - echo 'Image '.$index.': '.$image->url.PHP_EOL; + 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 index 92a184cf..c7799d5d 100644 --- a/examples/openai/image-output-dall-e-3.php +++ b/examples/openai/image-output-dall-e-3.php @@ -1,15 +1,15 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } @@ -25,8 +25,8 @@ assert($response instanceof ImageResponse); -echo 'Revised Prompt: '.$response->revisedPrompt.PHP_EOL.PHP_EOL; +echo 'Revised Prompt: '.$response->revisedPrompt.\PHP_EOL.\PHP_EOL; foreach ($response->getContent() as $index => $image) { - echo 'Image '.$index.': '.$image->url.PHP_EOL; + echo 'Image '.$index.': '.$image->url.\PHP_EOL; } diff --git a/examples/openai/stream.php b/examples/openai/stream.php index 5cb86e62..aedcd227 100644 --- a/examples/openai/stream.php +++ b/examples/openai/stream.php @@ -1,24 +1,24 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI); +$model = new GPT(GPT::GPT_4O_MINI); -$chain = new Chain($platform, $llm); +$chain = new Chain($platform, $model); $messages = new MessageBag( Message::forSystem('You are a thoughtful philosopher.'), Message::ofUser('What is the purpose of an ant?'), @@ -30,4 +30,4 @@ foreach ($response->getContent() as $word) { echo $word; } -echo PHP_EOL; +echo \PHP_EOL; diff --git a/examples/openai/structured-output-clock.php b/examples/openai/structured-output-clock.php index 1300aafb..7185b379 100644 --- a/examples/openai/structured-output-clock.php +++ b/examples/openai/structured-output-clock.php @@ -1,14 +1,14 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI); +$model = new GPT(GPT::GPT_4O_MINI); $clock = new Clock(new SymfonyClock()); $toolbox = Toolbox::create($clock); $toolProcessor = new ToolProcessor($toolbox); $structuredOutputProcessor = new StructuredOutputProcessor(); -$chain = new Chain($platform, $llm, [$toolProcessor, $structuredOutputProcessor], [$toolProcessor, $structuredOutputProcessor]); +$chain = new Chain($platform, $model, [$toolProcessor, $structuredOutputProcessor], [$toolProcessor, $structuredOutputProcessor]); $messages = new MessageBag(Message::ofUser('What date and time is it?')); $response = $chain->call($messages, ['response_format' => [ diff --git a/examples/openai/structured-output-math.php b/examples/openai/structured-output-math.php index 2c809576..de6e8688 100644 --- a/examples/openai/structured-output-math.php +++ b/examples/openai/structured-output-math.php @@ -1,11 +1,11 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI); +$model = new GPT(GPT::GPT_4O_MINI); $processor = new ChainProcessor(); -$chain = new Chain($platform, $llm, [$processor], [$processor]); +$chain = new Chain($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'), diff --git a/examples/openai/token-metadata.php b/examples/openai/token-metadata.php index c3e7679a..2a365f53 100644 --- a/examples/openai/token-metadata.php +++ b/examples/openai/token-metadata.php @@ -1,27 +1,27 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI, [ +$model = new GPT(GPT::GPT_4O_MINI, [ 'temperature' => 0.5, // default options for the model ]); -$chain = new Chain($platform, $llm, outputProcessors: [new TokenOutputProcessor()]); +$chain = new Chain($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?'), @@ -32,7 +32,7 @@ $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; +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 index a73825b0..b8244d18 100644 --- a/examples/openai/toolcall-stream.php +++ b/examples/openai/toolcall-stream.php @@ -1,13 +1,13 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI); +$model = new GPT(GPT::GPT_4O_MINI); $wikipedia = new Wikipedia(HttpClient::create()); $toolbox = Toolbox::create($wikipedia); $processor = new ChainProcessor($toolbox); -$chain = new Chain($platform, $llm, [$processor], [$processor]); +$chain = new Chain($platform, $model, [$processor], [$processor]); $messages = new MessageBag(Message::ofUser(<<loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI); +$model = new GPT(GPT::GPT_4O_MINI); $transcriber = new YouTubeTranscriber(HttpClient::create()); $toolbox = Toolbox::create($transcriber); $processor = new ChainProcessor($toolbox); -$chain = new Chain($platform, $llm, [$processor], [$processor]); +$chain = new Chain($platform, $model, [$processor], [$processor]); $messages = new MessageBag(Message::ofUser('Please summarize this video for me: https://www.youtube.com/watch?v=6uXW-ulpj0s')); $response = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/openrouter/chat-gemini.php b/examples/openrouter/chat-gemini.php index e2039af9..5e28a5f5 100644 --- a/examples/openrouter/chat-gemini.php +++ b/examples/openrouter/chat-gemini.php @@ -1,28 +1,30 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENROUTER_KEY'])) { - echo 'Please set the OPENROUTER_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENROUTER_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENROUTER_KEY']); -$llm = new GenericModel('google/gemini-2.0-flash-thinking-exp:free'); +// 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'); -$chain = new Chain($platform, $llm); +$chain = new Chain($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 = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/parallel-chat-gpt.php b/examples/parallel-chat-gpt.php index 4581384a..7bbeab50 100644 --- a/examples/parallel-chat-gpt.php +++ b/examples/parallel-chat-gpt.php @@ -2,22 +2,22 @@ declare(strict_types=1); -use PhpLlm\LlmChain\Bridge\OpenAI\GPT; -use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory; -use PhpLlm\LlmChain\Model\Message\Message; -use PhpLlm\LlmChain\Model\Message\MessageBag; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\PlatformFactory; +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Message\MessageBag; use Symfony\Component\Dotenv\Dotenv; require_once dirname(__DIR__).'/vendor/autoload.php'; (new Dotenv())->loadEnv(dirname(__DIR__).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI, [ +$model = new GPT(GPT::GPT_4O_MINI, [ 'temperature' => 0.5, // default options for the model ]); @@ -25,14 +25,14 @@ 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; +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($llm, $messages->with(Message::ofUser($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; +echo 'Waiting for the responses ...'.\PHP_EOL; foreach ($responses as $response) { - echo 'Next Letter: '.$response->getContent().PHP_EOL; + echo 'Next Letter: '.$response->getContent().\PHP_EOL; } diff --git a/examples/parallel-embeddings.php b/examples/parallel-embeddings.php index 39727fe8..eb63539c 100644 --- a/examples/parallel-embeddings.php +++ b/examples/parallel-embeddings.php @@ -2,16 +2,16 @@ declare(strict_types=1); -use PhpLlm\LlmChain\Bridge\OpenAI\Embeddings; -use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory; -use PhpLlm\LlmChain\Model\Response\VectorResponse; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\PlatformFactory; +use PhpLlm\LlmChain\Platform\Response\VectorResponse; use Symfony\Component\Dotenv\Dotenv; require_once dirname(__DIR__).'/vendor/autoload.php'; (new Dotenv())->loadEnv(dirname(__DIR__).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } @@ -20,15 +20,15 @@ $small = new Embeddings(Embeddings::TEXT_3_SMALL); $large = new Embeddings(Embeddings::TEXT_3_LARGE); -echo 'Initiating parallel embeddings calls to platform ...'.PHP_EOL; +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; + echo ' - Request for model '.$name.' initiated.'.\PHP_EOL; $responses[] = $platform->request($model, 'Hello, world!'); } -echo 'Waiting for the responses ...'.PHP_EOL; +echo 'Waiting for the responses ...'.\PHP_EOL; foreach ($responses as $response) { assert($response instanceof VectorResponse); - echo 'Dimensions: '.$response->getContent()[0]->getDimensions().PHP_EOL; + echo 'Dimensions: '.$response->getContent()[0]->getDimensions().\PHP_EOL; } diff --git a/examples/replicate/chat-llama.php b/examples/replicate/chat-llama.php index cf35721e..8173c844 100644 --- a/examples/replicate/chat-llama.php +++ b/examples/replicate/chat-llama.php @@ -1,28 +1,28 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['REPLICATE_API_KEY'])) { - echo 'Please set the REPLICATE_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the REPLICATE_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['REPLICATE_API_KEY']); -$llm = new Llama(); +$model = new Llama(); -$chain = new Chain($platform, $llm); +$chain = new Chain($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 = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/store-mongodb-similarity-search.php b/examples/store/mongodb-similarity-search.php similarity index 70% rename from examples/store-mongodb-similarity-search.php rename to examples/store/mongodb-similarity-search.php index 9957cf41..99bc61f3 100644 --- a/examples/store-mongodb-similarity-search.php +++ b/examples/store/mongodb-similarity-search.php @@ -1,27 +1,27 @@ loadEnv(dirname(__DIR__).'/.env'); +require_once dirname(__DIR__, 2).'/vendor/autoload.php'; +(new Dotenv())->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; + echo 'Please set OPENAI_API_KEY and MONGODB_URI environment variables.'.\PHP_EOL; exit(1); } @@ -45,7 +45,7 @@ foreach ($movies as $movie) { $documents[] = new TextDocument( id: Uuid::v4(), - content: 'Title: '.$movie['title'].PHP_EOL.'Director: '.$movie['director'].PHP_EOL.'Description: '.$movie['description'], + content: 'Title: '.$movie['title'].\PHP_EOL.'Director: '.$movie['director'].\PHP_EOL.'Description: '.$movie['description'], metadata: new Metadata($movie), ); } @@ -58,12 +58,12 @@ // initialize the index $store->initialize(); -$llm = new GPT(GPT::GPT_4O_MINI); +$model = new GPT(GPT::GPT_4O_MINI); $similaritySearch = new SimilaritySearch($platform, $embeddings, $store); $toolbox = Toolbox::create($similaritySearch); $processor = new ChainProcessor($toolbox); -$chain = new Chain($platform, $llm, [$processor], [$processor]); +$chain = new Chain($platform, $model, [$processor], [$processor]); $messages = new MessageBag( Message::forSystem('Please answer all user questions only using SimilaritySearch function.'), @@ -71,4 +71,4 @@ ); $response = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/store-pinecone-similarity-search.php b/examples/store/pinecone-similarity-search.php similarity index 68% rename from examples/store-pinecone-similarity-search.php rename to examples/store/pinecone-similarity-search.php index 3f28bebd..a863dcdd 100644 --- a/examples/store-pinecone-similarity-search.php +++ b/examples/store/pinecone-similarity-search.php @@ -1,27 +1,27 @@ loadEnv(dirname(__DIR__).'/.env'); +require_once dirname(__DIR__, 2).'/vendor/autoload.php'; +(new Dotenv())->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; + echo 'Please set OPENAI_API_KEY, PINECONE_API_KEY and PINECONE_HOST environment variables.'.\PHP_EOL; exit(1); } @@ -39,7 +39,7 @@ foreach ($movies as $movie) { $documents[] = new TextDocument( id: Uuid::v4(), - content: 'Title: '.$movie['title'].PHP_EOL.'Director: '.$movie['director'].PHP_EOL.'Description: '.$movie['description'], + content: 'Title: '.$movie['title'].\PHP_EOL.'Director: '.$movie['director'].\PHP_EOL.'Description: '.$movie['description'], metadata: new Metadata($movie), ); } @@ -49,12 +49,12 @@ $embedder = new Embedder($platform, $embeddings = new Embeddings(), $store); $embedder->embed($documents); -$llm = new GPT(GPT::GPT_4O_MINI); +$model = new GPT(GPT::GPT_4O_MINI); $similaritySearch = new SimilaritySearch($platform, $embeddings, $store); $toolbox = Toolbox::create($similaritySearch); $processor = new ChainProcessor($toolbox); -$chain = new Chain($platform, $llm, [$processor], [$processor]); +$chain = new Chain($platform, $model, [$processor], [$processor]); $messages = new MessageBag( Message::forSystem('Please answer all user questions only using SimilaritySearch function.'), @@ -62,4 +62,4 @@ ); $response = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/toolbox/brave.php b/examples/toolbox/brave.php index e7af1e42..d971f916 100644 --- a/examples/toolbox/brave.php +++ b/examples/toolbox/brave.php @@ -1,14 +1,14 @@ 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; + echo 'Please set the OPENAI_API_KEY and BRAVE_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI); +$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 ChainProcessor($toolbox); -$chain = new Chain($platform, $llm, [$processor], [$processor]); +$chain = new Chain($platform, $model, [$processor], [$processor]); $messages = new MessageBag(Message::ofUser('What was the latest game result of Dallas Cowboys?')); $response = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/toolbox/clock.php b/examples/toolbox/clock.php index 219addfc..7972c3f2 100644 --- a/examples/toolbox/clock.php +++ b/examples/toolbox/clock.php @@ -1,13 +1,13 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI); +$model = new GPT(GPT::GPT_4O_MINI); -$metadataFactory = (new MemoryFactory()) +$metadataFactory = (new MemoryToolFactory()) ->addTool(Clock::class, 'clock', 'Get the current date and time', 'now'); $toolbox = new Toolbox($metadataFactory, [new Clock()]); $processor = new ChainProcessor($toolbox); -$chain = new Chain($platform, $llm, [$processor], [$processor]); +$chain = new Chain($platform, $model, [$processor], [$processor]); $messages = new MessageBag(Message::ofUser('What date and time is it?')); $response = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/toolbox/serpapi.php b/examples/toolbox/serpapi.php index f39e5422..c5348472 100644 --- a/examples/toolbox/serpapi.php +++ b/examples/toolbox/serpapi.php @@ -1,13 +1,13 @@ 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; + echo 'Please set the OPENAI_API_KEY and SERP_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI); +$model = new GPT(GPT::GPT_4O_MINI); $serpApi = new SerpApi(HttpClient::create(), $_ENV['SERP_API_KEY']); $toolbox = Toolbox::create($serpApi); $processor = new ChainProcessor($toolbox); -$chain = new Chain($platform, $llm, [$processor], [$processor]); +$chain = new Chain($platform, $model, [$processor], [$processor]); $messages = new MessageBag(Message::ofUser('Who is the current chancellor of Germany?')); $response = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/toolbox/tavily.php b/examples/toolbox/tavily.php index 07e27437..e6245f71 100644 --- a/examples/toolbox/tavily.php +++ b/examples/toolbox/tavily.php @@ -1,13 +1,13 @@ 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; + echo 'Please set the OPENAI_API_KEY and TAVILY_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI); +$model = new GPT(GPT::GPT_4O_MINI); $tavily = new Tavily(HttpClient::create(), $_ENV['TAVILY_API_KEY']); $toolbox = Toolbox::create($tavily); $processor = new ChainProcessor($toolbox); -$chain = new Chain($platform, $llm, [$processor], [$processor]); +$chain = new Chain($platform, $model, [$processor], [$processor]); $messages = new MessageBag(Message::ofUser('What was the latest game result of Dallas Cowboys?')); $response = $chain->call($messages); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/toolbox/weather-event.php b/examples/toolbox/weather-event.php index 7952c078..3b05a49c 100644 --- a/examples/toolbox/weather-event.php +++ b/examples/toolbox/weather-event.php @@ -1,15 +1,15 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['OPENAI_API_KEY'])) { - echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; exit(1); } $platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); -$llm = new GPT(GPT::GPT_4O_MINI); +$model = new GPT(GPT::GPT_4O_MINI); $openMeteo = new OpenMeteo(HttpClient::create()); $toolbox = Toolbox::create($openMeteo); $eventDispatcher = new EventDispatcher(); $processor = new ChainProcessor($toolbox, eventDispatcher: $eventDispatcher); -$chain = new Chain($platform, $llm, [$processor], [$processor]); +$chain = new Chain($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 StructuredResponse($toolCallResult->result); + $event->response = new ObjectResponse($toolCallResult->result); } } }); diff --git a/examples/transformers/text-generation.php b/examples/transformers/text-generation.php index de408fd6..36fdb1f8 100644 --- a/examples/transformers/text-generation.php +++ b/examples/transformers/text-generation.php @@ -1,19 +1,19 @@ Task::Text2TextGeneration, ]); -echo $response->getContent().PHP_EOL; +echo $response->getContent().\PHP_EOL; diff --git a/examples/voyage/embeddings.php b/examples/voyage/embeddings.php index 41be8e25..10482bc6 100644 --- a/examples/voyage/embeddings.php +++ b/examples/voyage/embeddings.php @@ -1,15 +1,15 @@ loadEnv(dirname(__DIR__, 2).'/.env'); if (empty($_ENV['VOYAGE_API_KEY'])) { - echo 'Please set the VOYAGE_API_KEY environment variable.'.PHP_EOL; + echo 'Please set the VOYAGE_API_KEY environment variable.'.\PHP_EOL; exit(1); } @@ -24,4 +24,4 @@ assert($response instanceof VectorResponse); -echo 'Dimensions: '.$response->getContent()[0]->getDimensions().PHP_EOL; +echo 'Dimensions: '.$response->getContent()[0]->getDimensions().\PHP_EOL; diff --git a/phpstan.dist.neon b/phpstan.dist.neon index c812837d..07af0b94 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,5 +1,6 @@ includes: - vendor/phpstan/phpstan-webmozart-assert/extension.neon + - vendor/phpstan/phpstan-symfony/extension.neon parameters: level: 6 diff --git a/src/Bridge/Anthropic/Claude.php b/src/Bridge/Anthropic/Claude.php deleted file mode 100644 index 6d75ec6d..00000000 --- a/src/Bridge/Anthropic/Claude.php +++ /dev/null @@ -1,62 +0,0 @@ - $options The default options for the model usage - */ - public function __construct( - private string $name = self::SONNET_37, - private array $options = ['temperature' => 1.0, 'max_tokens' => 1000], - ) { - } - - public function getName(): string - { - return $this->name; - } - - public function getOptions(): array - { - return $this->options; - } - - public function supportsAudioInput(): bool - { - return false; - } - - public function supportsImageInput(): bool - { - return true; - } - - public function supportsStreaming(): bool - { - return true; - } - - public function supportsStructuredOutput(): bool - { - return false; - } - - public function supportsToolCalling(): bool - { - return true; - } -} diff --git a/src/Bridge/Anthropic/ModelHandler.php b/src/Bridge/Anthropic/ModelHandler.php deleted file mode 100644 index 458a82aa..00000000 --- a/src/Bridge/Anthropic/ModelHandler.php +++ /dev/null @@ -1,188 +0,0 @@ -httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); - } - - public function supports(Model $model, array|string|object $input): bool - { - return $model instanceof Claude && $input instanceof MessageBagInterface; - } - - public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface - { - Assert::isInstanceOf($input, MessageBagInterface::class); - - if (isset($options['tools'])) { - $tools = $options['tools']; - $options['tools'] = []; - /** @var Metadata $tool */ - foreach ($tools as $tool) { - $toolDefinition = [ - 'name' => $tool->name, - 'description' => $tool->description, - 'input_schema' => $tool->parameters ?? ['type' => 'object'], - ]; - $options['tools'][] = $toolDefinition; - } - $options['tool_choice'] = ['type' => 'auto']; - } - - $body = [ - 'model' => $model->getName(), - 'messages' => $input->withoutSystemMessage()->jsonSerialize(), - ]; - - $body['messages'] = array_map(static function (MessageInterface $message) { - if ($message instanceof ToolCallMessage) { - return [ - 'role' => 'user', - 'content' => [ - [ - 'type' => 'tool_result', - 'tool_use_id' => $message->toolCall->id, - 'content' => $message->content, - ], - ], - ]; - } - if ($message instanceof AssistantMessage && $message->hasToolCalls()) { - 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, - ]; - }, $message->toolCalls), - ]; - } - if ($message instanceof UserMessage && $message->hasImageContent()) { - // make sure images are encoded for Bedrock invocation - return [ - 'role' => 'user', - 'content' => array_map(static function (Content $content) { - if ($content instanceof Image) { - return [ - 'type' => 'image', - 'source' => [ - 'type' => 'base64', - 'media_type' => u($content->getFormat())->replace('jpg', 'jpeg')->toString(), - 'data' => $content->asBase64(), - ], - ]; - } - - return $content; - }, $message->content), - ]; - } - - return $message; - }, $body['messages']); - - if ($system = $input->getSystemMessage()) { - $body['system'] = $system->content; - } - - 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, $body), - ]); - } - - 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/Bridge/Azure/Meta/LlamaHandler.php b/src/Bridge/Azure/Meta/LlamaHandler.php deleted file mode 100644 index dc28fdf1..00000000 --- a/src/Bridge/Azure/Meta/LlamaHandler.php +++ /dev/null @@ -1,60 +0,0 @@ -baseUrl); - - return $this->httpClient->request('POST', $url, [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'Authorization' => $this->apiKey, - ], - 'json' => array_merge($options, [ - 'model' => $model->getName(), - 'messages' => $input, - ]), - ]); - } - - 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/Bridge/Bedrock/Anthropic/ClaudeHandler.php b/src/Bridge/Bedrock/Anthropic/ClaudeHandler.php deleted file mode 100644 index 26630b52..00000000 --- a/src/Bridge/Bedrock/Anthropic/ClaudeHandler.php +++ /dev/null @@ -1,166 +0,0 @@ - $tool->name, - 'description' => $tool->description, - 'input_schema' => $tool->parameters ?? ['type' => 'object'], - ]; - $options['tools'][] = $toolDefinition; - } - $options['tool_choice'] = ['type' => 'auto']; - } - - $body = [ - 'anthropic_version' => 'bedrock-'.$this->version, - 'max_tokens' => $model->getOptions()['max_tokens'], - 'temperature' => $model->getOptions()['temperature'], - 'messages' => $input->withoutSystemMessage()->jsonSerialize(), - ]; - - $body['messages'] = array_map(static function (MessageInterface $message) { - if ($message instanceof ToolCallMessage) { - return [ - 'role' => 'user', - 'content' => [ - [ - 'type' => 'tool_result', - 'tool_use_id' => $message->toolCall->id, - 'content' => $message->content, - ], - ], - ]; - } - if ($message instanceof AssistantMessage && $message->hasToolCalls()) { - 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, - ]; - }, $message->toolCalls), - ]; - } - if ($message instanceof UserMessage && $message->hasImageContent()) { - // make sure images are encoded for Bedrock invocation - return [ - 'role' => 'user', - 'content' => array_map(static function (Content $content) { - if ($content instanceof Image) { - return [ - 'type' => 'image', - 'source' => [ - 'type' => 'base64', - 'media_type' => u($content->getFormat())->replace('jpg', 'jpeg')->toString(), - 'data' => $content->asBase64(), - ], - ]; - } - - return $content; - }, $message->content), - ]; - } - - return $message; - }, $body['messages']); - - if ($system = $input->getSystemMessage()) { - $body['system'] = $system->content; - } - - $request = [ - 'modelId' => $this->getModelId($model), - 'contentType' => 'application/json', - 'body' => json_encode(array_merge($options, $body), 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/Bridge/Bedrock/BedrockModelClient.php b/src/Bridge/Bedrock/BedrockModelClient.php deleted file mode 100644 index 289278d1..00000000 --- a/src/Bridge/Bedrock/BedrockModelClient.php +++ /dev/null @@ -1,22 +0,0 @@ -|string|object $input - */ - public function supports(Model $model, array|string|object $input): bool; - - /** - * @param array|string|object $input - * @param array $options - */ - public function request(Model $model, array|string|object $input, array $options = []): LlmResponse; -} diff --git a/src/Bridge/Bedrock/Nova/Nova.php b/src/Bridge/Bedrock/Nova/Nova.php deleted file mode 100644 index cb109212..00000000 --- a/src/Bridge/Bedrock/Nova/Nova.php +++ /dev/null @@ -1,66 +0,0 @@ - $options The default options for the model usage - */ - public function __construct( - private string $name = self::PRO, - private array $options = ['temperature' => 1.0, 'max_tokens' => 1000], - ) { - } - - public function getName(): string - { - return $this->name; - } - - public function getOptions(): array - { - return $this->options; - } - - public function supportsAudioInput(): bool - { - return false; - } - - public function supportsImageInput(): bool - { - if (self::MICRO === $this->name) { - return false; - } - - return true; - } - - public function supportsStreaming(): bool - { - return false; - } - - public function supportsStructuredOutput(): bool - { - return false; - } - - public function supportsToolCalling(): bool - { - // Tool calling is supported but: - // Invoke currently has some validation errors on the bedrock api side when returning tool calling results. - // Its encouraged to use the converse api instead. - return false; - } -} diff --git a/src/Bridge/Bedrock/Nova/NovaPromptConverter.php b/src/Bridge/Bedrock/Nova/NovaPromptConverter.php deleted file mode 100644 index e052de75..00000000 --- a/src/Bridge/Bedrock/Nova/NovaPromptConverter.php +++ /dev/null @@ -1,95 +0,0 @@ -> - */ - public function convertToPrompt(MessageBagInterface $messageBag): array - { - $messages = []; - - /** @var UserMessage|SystemMessage|AssistantMessage $message */ - foreach ($messageBag->getMessages() as $message) { - $messages[] = $this->convertMessage($message); - } - - return $messages; - } - - /** - * @return array - */ - public function convertMessage(UserMessage|SystemMessage|AssistantMessage|ToolCallMessage $message): array - { - $convertedMessage = []; - $convertedMessage['role'] = $message->getRole()->value; - $content = []; - - if ($message instanceof ToolCallMessage) { - return [ - 'role' => 'user', - 'content' => [ - [ - 'toolResult' => [ - 'toolUseId' => $message->toolCall->id, - 'content' => [ - 'text' => $message->content, - ], - ], - ], - ], - ]; - } - - if ($message instanceof AssistantMessage && $message->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, - ], - ]; - }, $message->toolCalls), - ]; - } - - if (is_string($message->content)) { - $convertedMessage['content'][]['text'] = $message->content; - } else { - foreach ($message->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.'); - } - $convertedMessage['content'][] = $contentPart; - } - } - - return $convertedMessage; - } -} diff --git a/src/Bridge/Bedrock/Platform.php b/src/Bridge/Bedrock/Platform.php deleted file mode 100644 index 972c510f..00000000 --- a/src/Bridge/Bedrock/Platform.php +++ /dev/null @@ -1,46 +0,0 @@ - $modelClients - */ - public function __construct(iterable $modelClients) - { - $this->modelClients = $modelClients instanceof \Traversable ? iterator_to_array($modelClients) : $modelClients; - } - - public function request(Model $model, array|string|object $input, array $options = []): ResponseInterface - { - $options = array_merge($model->getOptions(), $options); - - return $this->doRequest($model, $input, $options); - } - - /** - * @param array|string|object $input - * @param array $options - */ - private function doRequest(Model $model, array|string|object $input, array $options = []): ResponseInterface - { - foreach ($this->modelClients as $modelClient) { - if ($modelClient->supports($model, $input)) { - return $modelClient->request($model, $input, $options); - } - } - - throw new RuntimeException('No response factory registered for model "'.$model::class.'" with given input.'); - } -} diff --git a/src/Bridge/Google/Gemini.php b/src/Bridge/Google/Gemini.php deleted file mode 100644 index e8a82d5b..00000000 --- a/src/Bridge/Google/Gemini.php +++ /dev/null @@ -1,60 +0,0 @@ - $options The default options for the model usage - */ - public function __construct( - private string $name = self::GEMINI_2_PRO, - private array $options = ['temperature' => 1.0], - ) { - } - - public function getName(): string - { - return $this->name; - } - - public function getOptions(): array - { - return $this->options; - } - - public function supportsAudioInput(): bool - { - return false; // it does, but implementation here is still open - } - - public function supportsImageInput(): bool - { - return true; - } - - public function supportsStreaming(): bool - { - return true; - } - - public function supportsStructuredOutput(): bool - { - return false; // it does, but implementation here is still open - } - - public function supportsToolCalling(): bool - { - return false; // it does, but implementation here is still open - } -} diff --git a/src/Bridge/Google/GooglePromptConverter.php b/src/Bridge/Google/GooglePromptConverter.php deleted file mode 100644 index 8951a3a3..00000000 --- a/src/Bridge/Google/GooglePromptConverter.php +++ /dev/null @@ -1,75 +0,0 @@ - - * }>, - * system_instruction?: array{parts: array{text: string}} - * } - */ - public function convertToPrompt(MessageBagInterface $bag): array - { - $body = ['contents' => []]; - - $systemMessage = $bag->getSystemMessage(); - if (null !== $systemMessage) { - $body['system_instruction'] = [ - 'parts' => ['text' => $systemMessage->content], - ]; - } - - foreach ($bag->withoutSystemMessage()->getMessages() as $message) { - $body['contents'][] = [ - 'role' => $message->getRole()->equals(Role::Assistant) ? 'model' : 'user', - 'parts' => $this->convertMessage($message), - ]; - } - - return $body; - } - - /** - * @return list - */ - private function convertMessage(MessageInterface $message): array - { - if ($message instanceof AssistantMessage) { - return [['text' => $message->content]]; - } - - if ($message instanceof UserMessage) { - $parts = []; - foreach ($message->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; - } - - return []; - } -} diff --git a/src/Bridge/HuggingFace/Model.php b/src/Bridge/HuggingFace/Model.php deleted file mode 100644 index 8aadff4f..00000000 --- a/src/Bridge/HuggingFace/Model.php +++ /dev/null @@ -1,30 +0,0 @@ - $options - */ - public function __construct( - private ?string $name = null, - private array $options = [], - ) { - } - - public function getName(): string - { - return $this->name ?? ''; - } - - public function getOptions(): array - { - return $this->options; - } -} diff --git a/src/Bridge/HuggingFace/ModelClient.php b/src/Bridge/HuggingFace/ModelClient.php deleted file mode 100644 index 6abd6fb5..00000000 --- a/src/Bridge/HuggingFace/ModelClient.php +++ /dev/null @@ -1,105 +0,0 @@ -httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); - } - - public function supports(BaseModel $model, object|array|string $input): bool - { - return $model instanceof Model; - } - - public function request(BaseModel $model, object|array|string $input, array $options = []): ResponseInterface - { - Assert::isInstanceOf($model, Model::class); - $task = $options['task'] ?? null; - unset($options['task']); - - return $this->httpClient->request('POST', $this->getUrl($model, $input, $task), [ - 'auth_bearer' => $this->apiKey, - ...$this->getPayload($input, $options), - ]); - } - - /** - * @param array|string|object $input - */ - private function getUrl(Model $model, object|array|string $input, ?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 ($input instanceof MessageBagInterface) { - $url .= '/v1/chat/completions'; - } - - return $url; - } - - /** - * @param array|string|object $input - * @param array $options - * - * @return array - */ - private function getPayload(object|array|string $input, array $options): array - { - if ($input instanceof Audio || $input instanceof Image) { - return [ - 'headers' => ['Content-Type' => $input->getFormat()], - 'body' => $input->asBinary(), - ]; - } - - if ($input instanceof MessageBagInterface) { - return [ - 'headers' => ['Content-Type' => 'application/json'], - 'json' => [ - 'messages' => $input, - ...$options, - ], - ]; - } - - if (is_string($input) || is_array($input)) { - $payload = [ - 'headers' => ['Content-Type' => 'application/json'], - 'json' => [ - 'inputs' => $input, - ], - ]; - - if (0 !== count($options)) { - $payload['json']['parameters'] = $options; - } - - return $payload; - } - - throw new InvalidArgumentException('Unsupported input type: '.get_debug_type($input)); - } -} diff --git a/src/Bridge/HuggingFace/PlatformFactory.php b/src/Bridge/HuggingFace/PlatformFactory.php deleted file mode 100644 index 0a1b3dd1..00000000 --- a/src/Bridge/HuggingFace/PlatformFactory.php +++ /dev/null @@ -1,23 +0,0 @@ -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 StructuredResponse( - 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 StructuredResponse(ClassificationResult::fromArray(reset($content) ?? [])), - Task::FILL_MASK => new StructuredResponse(FillMaskResult::fromArray($content)), - Task::IMAGE_SEGMENTATION => new StructuredResponse(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 StructuredResponse(ObjectDetectionResult::fromArray($content)), - Task::QUESTION_ANSWERING => new StructuredResponse(QuestionAnsweringResult::fromArray($content)), - Task::SENTENCE_SIMILARITY => new StructuredResponse(SentenceSimilarityResult::fromArray($content)), - Task::SUMMARIZATION => new TextResponse($content[0]['summary_text']), - Task::TABLE_QUESTION_ANSWERING => new StructuredResponse(TableQuestionAnsweringResult::fromArray(dump($content))), - Task::TOKEN_CLASSIFICATION => new StructuredResponse(TokenClassificationResult::fromArray($content)), - Task::TRANSLATION => new TextResponse($content[0]['translation_text'] ?? ''), - Task::ZERO_SHOT_CLASSIFICATION => new StructuredResponse(ZeroShotClassificationResult::fromArray($content)), - - default => throw new RuntimeException(sprintf('Unsupported task: %s', $task)), - }; - } -} diff --git a/src/Bridge/Meta/Llama.php b/src/Bridge/Meta/Llama.php deleted file mode 100644 index e151edde..00000000 --- a/src/Bridge/Meta/Llama.php +++ /dev/null @@ -1,70 +0,0 @@ - $options - */ - public function __construct( - private string $name = self::V3_1_405B_INSTRUCT, - private array $options = [], - ) { - } - - public function getName(): string - { - return $this->name; - } - - public function getOptions(): array - { - return $this->options; - } - - public function supportsAudioInput(): bool - { - return false; - } - - public function supportsImageInput(): bool - { - return false; // it does, but implementation here is still open. - } - - public function supportsStreaming(): bool - { - return false; // it does, but implementation here is still open. - } - - public function supportsToolCalling(): bool - { - return false; // it does, but implementation here is still open. - } - - public function supportsStructuredOutput(): bool - { - return false; - } -} diff --git a/src/Bridge/Ollama/LlamaModelHandler.php b/src/Bridge/Ollama/LlamaModelHandler.php deleted file mode 100644 index 93fe5293..00000000 --- a/src/Bridge/Ollama/LlamaModelHandler.php +++ /dev/null @@ -1,57 +0,0 @@ -httpClient->request('POST', sprintf('%s/api/chat', $this->hostUrl), [ - 'headers' => ['Content-Type' => 'application/json'], - 'json' => [ - 'model' => $model->getName(), - 'messages' => $input, - 'stream' => false, - ], - ]); - } - - 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/Bridge/OpenAI/DallE.php b/src/Bridge/OpenAI/DallE.php deleted file mode 100644 index 5df76b88..00000000 --- a/src/Bridge/OpenAI/DallE.php +++ /dev/null @@ -1,31 +0,0 @@ - $options The default options for the model usage */ - public function __construct( - private string $name = self::DALL_E_2, - private array $options = [], - ) { - } - - public function getName(): string - { - return $this->name; - } - - /** @return array */ - public function getOptions(): array - { - return $this->options; - } -} diff --git a/src/Bridge/OpenAI/Embeddings.php b/src/Bridge/OpenAI/Embeddings.php deleted file mode 100644 index 67912ba1..00000000 --- a/src/Bridge/OpenAI/Embeddings.php +++ /dev/null @@ -1,38 +0,0 @@ - $options - */ - public function __construct( - private string $name = self::TEXT_3_SMALL, - private array $options = [], - ) { - } - - public function getName(): string - { - return $this->name; - } - - public function getOptions(): array - { - return $this->options; - } - - public function supportsMultipleInputs(): bool - { - return false; - } -} diff --git a/src/Bridge/OpenAI/GPT.php b/src/Bridge/OpenAI/GPT.php deleted file mode 100644 index e2ed6316..00000000 --- a/src/Bridge/OpenAI/GPT.php +++ /dev/null @@ -1,107 +0,0 @@ - $options The default options for the model usage - */ - public function __construct( - private readonly string $name = self::GPT_4O, - private readonly array $options = ['temperature' => 1.0], - private bool $supportsAudioInput = false, - private bool $supportsImageInput = false, - private bool $supportsStructuredOutput = false, - ) { - if (false === $this->supportsAudioInput) { - $this->supportsAudioInput = self::GPT_4O_AUDIO === $this->name; - } - - if (false === $this->supportsImageInput) { - $this->supportsImageInput = in_array($this->name, self::IMAGE_SUPPORTING, true); - } - - if (false === $this->supportsStructuredOutput) { - $this->supportsStructuredOutput = in_array($this->name, self::STRUCTURED_OUTPUT_SUPPORTING, true); - } - } - - public function getName(): string - { - return $this->name; - } - - public function getOptions(): array - { - return $this->options; - } - - public function supportsAudioInput(): bool - { - return $this->supportsAudioInput; - } - - public function supportsImageInput(): bool - { - return $this->supportsImageInput; - } - - public function supportsStreaming(): bool - { - return true; - } - - public function supportsStructuredOutput(): bool - { - return $this->supportsStructuredOutput; - } - - public function supportsToolCalling(): bool - { - return true; - } -} diff --git a/src/Bridge/OpenAI/Whisper.php b/src/Bridge/OpenAI/Whisper.php deleted file mode 100644 index 77638478..00000000 --- a/src/Bridge/OpenAI/Whisper.php +++ /dev/null @@ -1,31 +0,0 @@ - $options - */ - public function __construct( - private string $name = self::WHISPER_1, - private array $options = [], - ) { - } - - public function getName(): string - { - return $this->name; - } - - public function getOptions(): array - { - return $this->options; - } -} diff --git a/src/Bridge/OpenAI/Whisper/ResponseConverter.php b/src/Bridge/OpenAI/Whisper/ResponseConverter.php deleted file mode 100644 index 7cfdf3cc..00000000 --- a/src/Bridge/OpenAI/Whisper/ResponseConverter.php +++ /dev/null @@ -1,28 +0,0 @@ -toArray(); - - return new TextResponse($data['text']); - } -} diff --git a/src/Bridge/OpenRouter/GenericModel.php b/src/Bridge/OpenRouter/GenericModel.php deleted file mode 100644 index 9ee1e753..00000000 --- a/src/Bridge/OpenRouter/GenericModel.php +++ /dev/null @@ -1,55 +0,0 @@ - $options - */ - public function __construct( - private string $name = Llama::V3_2_90B_VISION_INSTRUCT, - private array $options = [], - ) { - } - - public function getName(): string - { - return $this->name; - } - - public function getOptions(): array - { - return $this->options; - } - - public function supportsAudioInput(): bool - { - return false; // it does, but implementation here is still open. - } - - public function supportsImageInput(): bool - { - return false; // it does, but implementation here is still open. - } - - public function supportsStreaming(): bool - { - return false; // it does, but implementation here is still open. - } - - public function supportsToolCalling(): bool - { - return false; // it does, but implementation here is still open. - } - - public function supportsStructuredOutput(): bool - { - return false; // it does, but implementation here is still open. - } -} diff --git a/src/Bridge/OpenRouter/PlatformFactory.php b/src/Bridge/OpenRouter/PlatformFactory.php deleted file mode 100644 index da36ebbc..00000000 --- a/src/Bridge/OpenRouter/PlatformFactory.php +++ /dev/null @@ -1,23 +0,0 @@ -client->request(sprintf('meta/meta-%s', $model->getName()), 'predictions', [ - 'system' => $this->promptConverter->convertMessage($input->getSystemMessage() ?? new SystemMessage('')), - 'prompt' => $this->promptConverter->convertToPrompt($input->withoutSystemMessage()), - ]); - } -} diff --git a/src/Bridge/Replicate/LlamaResponseConverter.php b/src/Bridge/Replicate/LlamaResponseConverter.php deleted file mode 100644 index b9eca166..00000000 --- a/src/Bridge/Replicate/LlamaResponseConverter.php +++ /dev/null @@ -1,33 +0,0 @@ -toArray(); - - if (!isset($data['output'])) { - throw new RuntimeException('Response does not contain output'); - } - - return new TextResponse(implode('', $data['output'])); - } -} diff --git a/src/Bridge/TransformersPHP/Model.php b/src/Bridge/TransformersPHP/Model.php deleted file mode 100644 index 418e464f..00000000 --- a/src/Bridge/TransformersPHP/Model.php +++ /dev/null @@ -1,30 +0,0 @@ - $options - */ - public function __construct( - private ?string $name = null, - private array $options = [], - ) { - } - - public function getName(): string - { - return $this->name ?? ''; - } - - public function getOptions(): array - { - return $this->options; - } -} diff --git a/src/Bridge/Voyage/Voyage.php b/src/Bridge/Voyage/Voyage.php deleted file mode 100644 index 937f4061..00000000 --- a/src/Bridge/Voyage/Voyage.php +++ /dev/null @@ -1,41 +0,0 @@ - $options - */ - public function __construct( - private string $name = self::V3, - private array $options = [], - ) { - } - - public function getName(): string - { - return $this->name; - } - - public function getOptions(): array - { - return $this->options; - } - - public function supportsMultipleInputs(): bool - { - return true; - } -} diff --git a/src/Chain.php b/src/Chain/Chain.php similarity index 50% rename from src/Chain.php rename to src/Chain/Chain.php index 9f6e7121..3363627d 100644 --- a/src/Chain.php +++ b/src/Chain/Chain.php @@ -2,20 +2,17 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain; - -use PhpLlm\LlmChain\Chain\ChainAwareProcessor; -use PhpLlm\LlmChain\Chain\Input; -use PhpLlm\LlmChain\Chain\InputProcessor; -use PhpLlm\LlmChain\Chain\Output; -use PhpLlm\LlmChain\Chain\OutputProcessor; -use PhpLlm\LlmChain\Exception\InvalidArgumentException; -use PhpLlm\LlmChain\Exception\MissingModelSupport; -use PhpLlm\LlmChain\Exception\RuntimeException; -use PhpLlm\LlmChain\Model\LanguageModel; -use PhpLlm\LlmChain\Model\Message\MessageBagInterface; -use PhpLlm\LlmChain\Model\Response\AsyncResponse; -use PhpLlm\LlmChain\Model\Response\ResponseInterface; +namespace PhpLlm\LlmChain\Chain; + +use PhpLlm\LlmChain\Chain\Exception\InvalidArgumentException; +use PhpLlm\LlmChain\Chain\Exception\MissingModelSupportException; +use PhpLlm\LlmChain\Chain\Exception\RuntimeException; +use PhpLlm\LlmChain\Platform\Capability; +use PhpLlm\LlmChain\Platform\Message\MessageBagInterface; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\PlatformInterface; +use PhpLlm\LlmChain\Platform\Response\AsyncResponse; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; @@ -24,28 +21,28 @@ final readonly class Chain implements ChainInterface { /** - * @var InputProcessor[] + * @var InputProcessorInterface[] */ private array $inputProcessors; /** - * @var OutputProcessor[] + * @var OutputProcessorInterface[] */ private array $outputProcessors; /** - * @param InputProcessor[] $inputProcessors - * @param OutputProcessor[] $outputProcessors + * @param InputProcessorInterface[] $inputProcessors + * @param OutputProcessorInterface[] $outputProcessors */ public function __construct( private PlatformInterface $platform, - private LanguageModel $llm, + private Model $model, iterable $inputProcessors = [], iterable $outputProcessors = [], private LoggerInterface $logger = new NullLogger(), ) { - $this->inputProcessors = $this->initializeProcessors($inputProcessors, InputProcessor::class); - $this->outputProcessors = $this->initializeProcessors($outputProcessors, OutputProcessor::class); + $this->inputProcessors = $this->initializeProcessors($inputProcessors, InputProcessorInterface::class); + $this->outputProcessors = $this->initializeProcessors($outputProcessors, OutputProcessorInterface::class); } /** @@ -53,23 +50,23 @@ public function __construct( */ public function call(MessageBagInterface $messages, array $options = []): ResponseInterface { - $input = new Input($this->llm, $messages, $options); - array_map(fn (InputProcessor $processor) => $processor->processInput($input), $this->inputProcessors); + $input = new Input($this->model, $messages, $options); + array_map(fn (InputProcessorInterface $processor) => $processor->processInput($input), $this->inputProcessors); - $llm = $input->llm; + $model = $input->model; $messages = $input->messages; $options = $input->getOptions(); - if ($messages->containsAudio() && !$llm->supportsAudioInput()) { - throw MissingModelSupport::forAudioInput($llm::class); + if ($messages->containsAudio() && !$model->supports(Capability::INPUT_AUDIO)) { + throw MissingModelSupportException::forAudioInput($model::class); } - if ($messages->containsImage() && !$llm->supportsImageInput()) { - throw MissingModelSupport::forImageInput($llm::class); + if ($messages->containsImage() && !$model->supports(Capability::INPUT_IMAGE)) { + throw MissingModelSupportException::forImageInput($model::class); } try { - $response = $this->platform->request($llm, $messages, $options); + $response = $this->platform->request($model, $messages, $options); if ($response instanceof AsyncResponse) { $response = $response->unwrap(); @@ -85,26 +82,26 @@ public function call(MessageBagInterface $messages, array $options = []): Respon throw new RuntimeException('Failed to request model', previous: $e); } - $output = new Output($llm, $response, $messages, $options); - array_map(fn (OutputProcessor $processor) => $processor->processOutput($output), $this->outputProcessors); + $output = new Output($model, $response, $messages, $options); + array_map(fn (OutputProcessorInterface $processor) => $processor->processOutput($output), $this->outputProcessors); return $output->response; } /** - * @param InputProcessor[]|OutputProcessor[] $processors - * @param class-string $interface + * @param InputProcessorInterface[]|OutputProcessorInterface[] $processors + * @param class-string $interface * - * @return InputProcessor[]|OutputProcessor[] + * @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)); + throw new InvalidArgumentException(\sprintf('Processor %s must implement %s interface.', $processor::class, $interface)); } - if ($processor instanceof ChainAwareProcessor) { + if ($processor instanceof ChainAwareInterface) { $processor->setChain($this); } } diff --git a/src/Chain/ChainAwareProcessor.php b/src/Chain/ChainAwareInterface.php similarity index 67% rename from src/Chain/ChainAwareProcessor.php rename to src/Chain/ChainAwareInterface.php index 4ce94122..99757468 100644 --- a/src/Chain/ChainAwareProcessor.php +++ b/src/Chain/ChainAwareInterface.php @@ -4,9 +4,7 @@ namespace PhpLlm\LlmChain\Chain; -use PhpLlm\LlmChain\Chain; - -interface ChainAwareProcessor +interface ChainAwareInterface { public function setChain(Chain $chain): void; } diff --git a/src/Chain/ChainAwareTrait.php b/src/Chain/ChainAwareTrait.php index 25be91a3..9a088b6c 100644 --- a/src/Chain/ChainAwareTrait.php +++ b/src/Chain/ChainAwareTrait.php @@ -4,8 +4,6 @@ namespace PhpLlm\LlmChain\Chain; -use PhpLlm\LlmChain\Chain; - trait ChainAwareTrait { private Chain $chain; diff --git a/src/ChainInterface.php b/src/Chain/ChainInterface.php similarity index 59% rename from src/ChainInterface.php rename to src/Chain/ChainInterface.php index 44b483a6..d952104b 100644 --- a/src/ChainInterface.php +++ b/src/Chain/ChainInterface.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain; +namespace PhpLlm\LlmChain\Chain; -use PhpLlm\LlmChain\Model\Message\MessageBagInterface; -use PhpLlm\LlmChain\Model\Response\ResponseInterface; +use PhpLlm\LlmChain\Platform\Message\MessageBagInterface; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface; interface ChainInterface { diff --git a/src/Exception/ExceptionInterface.php b/src/Chain/Exception/ExceptionInterface.php similarity index 66% rename from src/Exception/ExceptionInterface.php rename to src/Chain/Exception/ExceptionInterface.php index 47688052..186d204f 100644 --- a/src/Exception/ExceptionInterface.php +++ b/src/Chain/Exception/ExceptionInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Exception; +namespace PhpLlm\LlmChain\Chain\Exception; interface ExceptionInterface extends \Throwable { diff --git a/src/Exception/InvalidArgumentException.php b/src/Chain/Exception/InvalidArgumentException.php similarity index 75% rename from src/Exception/InvalidArgumentException.php rename to src/Chain/Exception/InvalidArgumentException.php index 201b9624..a9a53ae4 100644 --- a/src/Exception/InvalidArgumentException.php +++ b/src/Chain/Exception/InvalidArgumentException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Exception; +namespace PhpLlm\LlmChain\Chain\Exception; class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface { diff --git a/src/Exception/LogicException.php b/src/Chain/Exception/LogicException.php similarity index 72% rename from src/Exception/LogicException.php rename to src/Chain/Exception/LogicException.php index a620875e..29934138 100644 --- a/src/Exception/LogicException.php +++ b/src/Chain/Exception/LogicException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Exception; +namespace PhpLlm\LlmChain\Chain\Exception; class LogicException extends \LogicException implements ExceptionInterface { diff --git a/src/Exception/MissingModelSupport.php b/src/Chain/Exception/MissingModelSupportException.php similarity index 75% rename from src/Exception/MissingModelSupport.php rename to src/Chain/Exception/MissingModelSupportException.php index ecb2c645..4e909389 100644 --- a/src/Exception/MissingModelSupport.php +++ b/src/Chain/Exception/MissingModelSupportException.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Exception; +namespace PhpLlm\LlmChain\Chain\Exception; -final class MissingModelSupport extends RuntimeException +final class MissingModelSupportException extends RuntimeException { private function __construct(string $model, string $support) { - parent::__construct(sprintf('Model "%s" does not support "%s".', $model, $support)); + parent::__construct(\sprintf('Model "%s" does not support "%s".', $model, $support)); } public static function forToolCalling(string $model): self diff --git a/src/Exception/RuntimeException.php b/src/Chain/Exception/RuntimeException.php similarity index 73% rename from src/Exception/RuntimeException.php rename to src/Chain/Exception/RuntimeException.php index 25012b5f..0ceada7c 100644 --- a/src/Exception/RuntimeException.php +++ b/src/Chain/Exception/RuntimeException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Exception; +namespace PhpLlm\LlmChain\Chain\Exception; class RuntimeException extends \RuntimeException implements ExceptionInterface { diff --git a/src/Chain/Input.php b/src/Chain/Input.php index 9aa4d8d7..102d22aa 100644 --- a/src/Chain/Input.php +++ b/src/Chain/Input.php @@ -4,8 +4,8 @@ namespace PhpLlm\LlmChain\Chain; -use PhpLlm\LlmChain\Model\LanguageModel; -use PhpLlm\LlmChain\Model\Message\MessageBagInterface; +use PhpLlm\LlmChain\Platform\Message\MessageBagInterface; +use PhpLlm\LlmChain\Platform\Model; final class Input { @@ -13,7 +13,7 @@ final class Input * @param array $options */ public function __construct( - public LanguageModel $llm, + public Model $model, public MessageBagInterface $messages, private array $options, ) { diff --git a/src/Chain/InputProcessor/LlmOverrideInputProcessor.php b/src/Chain/InputProcessor/LlmOverrideInputProcessor.php deleted file mode 100644 index 0d50957e..00000000 --- a/src/Chain/InputProcessor/LlmOverrideInputProcessor.php +++ /dev/null @@ -1,28 +0,0 @@ -getOptions(); - - if (!array_key_exists('llm', $options)) { - return; - } - - if (!$options['llm'] instanceof LanguageModel) { - throw new InvalidArgumentException(sprintf('Option "llm" must be an instance of %s.', LanguageModel::class)); - } - - $input->llm = $options['llm']; - } -} diff --git a/src/Chain/InputProcessor/ModelOverrideInputProcessor.php b/src/Chain/InputProcessor/ModelOverrideInputProcessor.php new file mode 100644 index 00000000..e0a1dc6e --- /dev/null +++ b/src/Chain/InputProcessor/ModelOverrideInputProcessor.php @@ -0,0 +1,28 @@ +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/Chain/InputProcessor/SystemPromptInputProcessor.php b/src/Chain/InputProcessor/SystemPromptInputProcessor.php index 1a256b6d..c326530c 100644 --- a/src/Chain/InputProcessor/SystemPromptInputProcessor.php +++ b/src/Chain/InputProcessor/SystemPromptInputProcessor.php @@ -5,14 +5,14 @@ namespace PhpLlm\LlmChain\Chain\InputProcessor; use PhpLlm\LlmChain\Chain\Input; -use PhpLlm\LlmChain\Chain\InputProcessor; -use PhpLlm\LlmChain\Chain\Toolbox\Metadata; +use PhpLlm\LlmChain\Chain\InputProcessorInterface; use PhpLlm\LlmChain\Chain\Toolbox\ToolboxInterface; -use PhpLlm\LlmChain\Model\Message\Message; +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Tool\Tool; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -final readonly class SystemPromptInputProcessor implements InputProcessor +final readonly class SystemPromptInputProcessor implements InputProcessorInterface { /** * @param \Stringable|string $systemPrompt the system prompt to prepend to the input messages @@ -38,23 +38,23 @@ public function processInput(Input $input): void $message = (string) $this->systemPrompt; if ($this->toolbox instanceof ToolboxInterface - && [] !== $this->toolbox->getMap() + && [] !== $this->toolbox->getTools() ) { $this->logger->debug('Append tool definitions to system prompt.'); - $tools = implode(PHP_EOL.PHP_EOL, array_map( - fn (Metadata $tool) => << <<name} {$tool->description} TOOL, - $this->toolbox->getMap() + $this->toolbox->getTools() )); $message = <<systemPrompt} - + # Available tools - + {$tools} PROMPT; } diff --git a/src/Chain/InputProcessor.php b/src/Chain/InputProcessorInterface.php similarity index 78% rename from src/Chain/InputProcessor.php rename to src/Chain/InputProcessorInterface.php index dd690b01..255dedd5 100644 --- a/src/Chain/InputProcessor.php +++ b/src/Chain/InputProcessorInterface.php @@ -4,7 +4,7 @@ namespace PhpLlm\LlmChain\Chain; -interface InputProcessor +interface InputProcessorInterface { public function processInput(Input $input): void; } diff --git a/src/Chain/Output.php b/src/Chain/Output.php index 6ac8e823..a5db450f 100644 --- a/src/Chain/Output.php +++ b/src/Chain/Output.php @@ -4,9 +4,9 @@ namespace PhpLlm\LlmChain\Chain; -use PhpLlm\LlmChain\Model\LanguageModel; -use PhpLlm\LlmChain\Model\Message\MessageBagInterface; -use PhpLlm\LlmChain\Model\Response\ResponseInterface; +use PhpLlm\LlmChain\Platform\Message\MessageBagInterface; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface; final class Output { @@ -14,7 +14,7 @@ final class Output * @param array $options */ public function __construct( - public readonly LanguageModel $llm, + public readonly Model $model, public ResponseInterface $response, public readonly MessageBagInterface $messages, public readonly array $options, diff --git a/src/Chain/OutputProcessor.php b/src/Chain/OutputProcessorInterface.php similarity index 78% rename from src/Chain/OutputProcessor.php rename to src/Chain/OutputProcessorInterface.php index ea4ecb7a..f4aa0093 100644 --- a/src/Chain/OutputProcessor.php +++ b/src/Chain/OutputProcessorInterface.php @@ -4,7 +4,7 @@ namespace PhpLlm\LlmChain\Chain; -interface OutputProcessor +interface OutputProcessorInterface { public function processOutput(Output $output): void; } diff --git a/src/Chain/StructuredOutput/ChainProcessor.php b/src/Chain/StructuredOutput/ChainProcessor.php index d7bc0c8b..7c08470e 100644 --- a/src/Chain/StructuredOutput/ChainProcessor.php +++ b/src/Chain/StructuredOutput/ChainProcessor.php @@ -4,13 +4,14 @@ namespace PhpLlm\LlmChain\Chain\StructuredOutput; +use PhpLlm\LlmChain\Chain\Exception\InvalidArgumentException; +use PhpLlm\LlmChain\Chain\Exception\MissingModelSupportException; use PhpLlm\LlmChain\Chain\Input; -use PhpLlm\LlmChain\Chain\InputProcessor; +use PhpLlm\LlmChain\Chain\InputProcessorInterface; use PhpLlm\LlmChain\Chain\Output; -use PhpLlm\LlmChain\Chain\OutputProcessor; -use PhpLlm\LlmChain\Exception\InvalidArgumentException; -use PhpLlm\LlmChain\Exception\MissingModelSupport; -use PhpLlm\LlmChain\Model\Response\StructuredResponse; +use PhpLlm\LlmChain\Chain\OutputProcessorInterface; +use PhpLlm\LlmChain\Platform\Capability; +use PhpLlm\LlmChain\Platform\Response\ObjectResponse; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Encoder\JsonEncoder; @@ -19,7 +20,7 @@ use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; -final class ChainProcessor implements InputProcessor, OutputProcessor +final class ChainProcessor implements InputProcessorInterface, OutputProcessorInterface { private string $outputStructure; @@ -42,8 +43,8 @@ public function processInput(Input $input): void return; } - if (!$input->llm->supportsStructuredOutput()) { - throw MissingModelSupport::forStructuredOutput($input->llm::class); + if (!$input->model->supports(Capability::OUTPUT_STRUCTURED)) { + throw MissingModelSupportException::forStructuredOutput($input->model::class); } if (true === ($options['stream'] ?? false)) { @@ -62,7 +63,7 @@ public function processOutput(Output $output): void { $options = $output->options; - if ($output->response instanceof StructuredResponse) { + if ($output->response instanceof ObjectResponse) { return; } @@ -71,12 +72,12 @@ public function processOutput(Output $output): void } if (!isset($this->outputStructure)) { - $output->response = new StructuredResponse(json_decode($output->response->getContent(), true)); + $output->response = new ObjectResponse(json_decode($output->response->getContent(), true)); return; } - $output->response = new StructuredResponse( + $output->response = new ObjectResponse( $this->serializer->deserialize($output->response->getContent(), $this->outputStructure, 'json') ); } diff --git a/src/Chain/StructuredOutput/ResponseFormatFactory.php b/src/Chain/StructuredOutput/ResponseFormatFactory.php index 99d57c9c..d6f153ac 100644 --- a/src/Chain/StructuredOutput/ResponseFormatFactory.php +++ b/src/Chain/StructuredOutput/ResponseFormatFactory.php @@ -4,7 +4,7 @@ namespace PhpLlm\LlmChain\Chain\StructuredOutput; -use PhpLlm\LlmChain\Chain\JsonSchema\Factory; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\Factory; use function Symfony\Component\String\u; diff --git a/src/Chain/Toolbox/ChainProcessor.php b/src/Chain/Toolbox/ChainProcessor.php index 2e58c113..1e2285e6 100644 --- a/src/Chain/Toolbox/ChainProcessor.php +++ b/src/Chain/Toolbox/ChainProcessor.php @@ -4,23 +4,25 @@ namespace PhpLlm\LlmChain\Chain\Toolbox; -use PhpLlm\LlmChain\Chain\ChainAwareProcessor; +use PhpLlm\LlmChain\Chain\ChainAwareInterface; use PhpLlm\LlmChain\Chain\ChainAwareTrait; +use PhpLlm\LlmChain\Chain\Exception\MissingModelSupportException; use PhpLlm\LlmChain\Chain\Input; -use PhpLlm\LlmChain\Chain\InputProcessor; +use PhpLlm\LlmChain\Chain\InputProcessorInterface; use PhpLlm\LlmChain\Chain\Output; -use PhpLlm\LlmChain\Chain\OutputProcessor; +use PhpLlm\LlmChain\Chain\OutputProcessorInterface; use PhpLlm\LlmChain\Chain\Toolbox\Event\ToolCallsExecuted; use PhpLlm\LlmChain\Chain\Toolbox\StreamResponse as ToolboxStreamResponse; -use PhpLlm\LlmChain\Exception\MissingModelSupport; -use PhpLlm\LlmChain\Model\Message\AssistantMessage; -use PhpLlm\LlmChain\Model\Message\Message; -use PhpLlm\LlmChain\Model\Response\ResponseInterface; -use PhpLlm\LlmChain\Model\Response\StreamResponse as GenericStreamResponse; -use PhpLlm\LlmChain\Model\Response\ToolCallResponse; +use PhpLlm\LlmChain\Platform\Capability; +use PhpLlm\LlmChain\Platform\Message\AssistantMessage; +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface; +use PhpLlm\LlmChain\Platform\Response\StreamResponse as GenericStreamResponse; +use PhpLlm\LlmChain\Platform\Response\ToolCallResponse; +use PhpLlm\LlmChain\Platform\Tool\Tool; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -final class ChainProcessor implements InputProcessor, OutputProcessor, ChainAwareProcessor +final class ChainProcessor implements InputProcessorInterface, OutputProcessorInterface, ChainAwareInterface { use ChainAwareTrait; @@ -33,11 +35,11 @@ public function __construct( public function processInput(Input $input): void { - if (!$input->llm->supportsToolCalling()) { - throw MissingModelSupport::forToolCalling($input->llm::class); + if (!$input->model->supports(Capability::TOOL_CALLING)) { + throw MissingModelSupportException::forToolCalling($input->model::class); } - $toolMap = $this->toolbox->getMap(); + $toolMap = $this->toolbox->getTools(); if ([] === $toolMap) { return; } @@ -45,7 +47,7 @@ public function processInput(Input $input): void $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 (Metadata $tool) => in_array($tool->name, $options['tools'], true))); + $toolMap = array_values(array_filter($toolMap, fn (Tool $tool) => \in_array($tool->name, $options['tools'], true))); } $options['tools'] = $toolMap; @@ -75,7 +77,7 @@ public function processOutput(Output $output): void */ private function isFlatStringArray(array $tools): bool { - return array_reduce($tools, fn (bool $carry, mixed $item) => $carry && is_string($item), true); + return array_reduce($tools, fn (bool $carry, mixed $item) => $carry && \is_string($item), true); } private function handleToolCallsCallback(Output $output): \Closure diff --git a/src/Chain/Toolbox/Event/ToolCallsExecuted.php b/src/Chain/Toolbox/Event/ToolCallsExecuted.php index ad4b00cb..678010d5 100644 --- a/src/Chain/Toolbox/Event/ToolCallsExecuted.php +++ b/src/Chain/Toolbox/Event/ToolCallsExecuted.php @@ -5,7 +5,7 @@ namespace PhpLlm\LlmChain\Chain\Toolbox\Event; use PhpLlm\LlmChain\Chain\Toolbox\ToolCallResult; -use PhpLlm\LlmChain\Model\Response\ResponseInterface; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface; final class ToolCallsExecuted { diff --git a/src/Chain/Toolbox/Exception/ExceptionInterface.php b/src/Chain/Toolbox/Exception/ExceptionInterface.php index f41e11b2..a8aac3e7 100644 --- a/src/Chain/Toolbox/Exception/ExceptionInterface.php +++ b/src/Chain/Toolbox/Exception/ExceptionInterface.php @@ -4,7 +4,7 @@ namespace PhpLlm\LlmChain\Chain\Toolbox\Exception; -use PhpLlm\LlmChain\Exception\ExceptionInterface as BaseExceptionInterface; +use PhpLlm\LlmChain\Chain\Exception\ExceptionInterface as BaseExceptionInterface; interface ExceptionInterface extends BaseExceptionInterface { diff --git a/src/Chain/Toolbox/Exception/ToolConfigurationException.php b/src/Chain/Toolbox/Exception/ToolConfigurationException.php index 57f9bfd9..f8a545fc 100644 --- a/src/Chain/Toolbox/Exception/ToolConfigurationException.php +++ b/src/Chain/Toolbox/Exception/ToolConfigurationException.php @@ -4,12 +4,12 @@ namespace PhpLlm\LlmChain\Chain\Toolbox\Exception; -use PhpLlm\LlmChain\Exception\InvalidArgumentException; +use PhpLlm\LlmChain\Chain\Exception\InvalidArgumentException; 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); + return new self(\sprintf('Method "%s" not found in tool "%s".', $methodName, $toolClass), previous: $previous); } } diff --git a/src/Chain/Toolbox/Exception/ToolException.php b/src/Chain/Toolbox/Exception/ToolException.php new file mode 100644 index 00000000..73b4f966 --- /dev/null +++ b/src/Chain/Toolbox/Exception/ToolException.php @@ -0,0 +1,21 @@ +name, $previous->getMessage()), previous: $previous); + $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/Chain/Toolbox/Exception/ToolMetadataException.php b/src/Chain/Toolbox/Exception/ToolMetadataException.php deleted file mode 100644 index 130dffa4..00000000 --- a/src/Chain/Toolbox/Exception/ToolMetadataException.php +++ /dev/null @@ -1,21 +0,0 @@ -name)); + $exception = new self(\sprintf('Tool not found for call: %s.', $toolCall->name)); $exception->toolCall = $toolCall; return $exception; @@ -21,6 +21,6 @@ public static function notFoundForToolCall(ToolCall $toolCall): self public static function notFoundForReference(ExecutionReference $reference): self { - return new self(sprintf('Tool not found for reference: %s::%s.', $reference->class, $reference->method)); + return new self(\sprintf('Tool not found for reference: %s::%s.', $reference->class, $reference->method)); } } diff --git a/src/Chain/Toolbox/FaultTolerantToolbox.php b/src/Chain/Toolbox/FaultTolerantToolbox.php index 5864b716..02dfedc2 100644 --- a/src/Chain/Toolbox/FaultTolerantToolbox.php +++ b/src/Chain/Toolbox/FaultTolerantToolbox.php @@ -6,7 +6,8 @@ use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolExecutionException; use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolNotFoundException; -use PhpLlm\LlmChain\Model\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Tool\Tool; /** * Catches exceptions thrown by the inner tool box and returns error messages for the LLM instead. @@ -18,9 +19,9 @@ public function __construct( ) { } - public function getMap(): array + public function getTools(): array { - return $this->innerToolbox->getMap(); + return $this->innerToolbox->getTools(); } public function execute(ToolCall $toolCall): mixed @@ -28,11 +29,11 @@ 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); + return \sprintf('An error occurred while executing tool "%s".', $e->toolCall->name); } catch (ToolNotFoundException) { - $names = array_map(fn (Metadata $metadata) => $metadata->name, $this->getMap()); + $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)); + return \sprintf('Tool "%s" was not found, please use one of these: %s', $toolCall->name, implode(', ', $names)); } } } diff --git a/src/Chain/Toolbox/Metadata.php b/src/Chain/Toolbox/Metadata.php deleted file mode 100644 index ae9ab6b1..00000000 --- a/src/Chain/Toolbox/Metadata.php +++ /dev/null @@ -1,51 +0,0 @@ - $this->name, - 'description' => $this->description, - ]; - - if (isset($this->parameters)) { - $function['parameters'] = $this->parameters; - } - - return [ - 'type' => 'function', - 'function' => $function, - ]; - } -} diff --git a/src/Chain/Toolbox/MetadataFactory.php b/src/Chain/Toolbox/MetadataFactory.php deleted file mode 100644 index 2f3c0687..00000000 --- a/src/Chain/Toolbox/MetadataFactory.php +++ /dev/null @@ -1,17 +0,0 @@ - - * - * @throws ToolMetadataException if the metadata for the given reference is not found - */ - public function getMetadata(string $reference): iterable; -} diff --git a/src/Chain/Toolbox/MetadataFactory/ChainFactory.php b/src/Chain/Toolbox/MetadataFactory/ChainFactory.php deleted file mode 100644 index 295febd1..00000000 --- a/src/Chain/Toolbox/MetadataFactory/ChainFactory.php +++ /dev/null @@ -1,44 +0,0 @@ - - */ - private array $factories; - - /** - * @param iterable $factories - */ - public function __construct(iterable $factories) - { - $this->factories = $factories instanceof \Traversable ? iterator_to_array($factories) : $factories; - } - - public function getMetadata(string $reference): iterable - { - $invalid = 0; - foreach ($this->factories as $factory) { - try { - yield from $factory->getMetadata($reference); - } catch (ToolMetadataException) { - ++$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 ToolMetadataException::invalidReference($reference); - } - } -} diff --git a/src/Chain/Toolbox/StreamResponse.php b/src/Chain/Toolbox/StreamResponse.php index 190c837b..945e0a7a 100644 --- a/src/Chain/Toolbox/StreamResponse.php +++ b/src/Chain/Toolbox/StreamResponse.php @@ -4,9 +4,9 @@ namespace PhpLlm\LlmChain\Chain\Toolbox; -use PhpLlm\LlmChain\Model\Message\Message; -use PhpLlm\LlmChain\Model\Response\BaseResponse; -use PhpLlm\LlmChain\Model\Response\ToolCallResponse; +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Response\BaseResponse; +use PhpLlm\LlmChain\Platform\Response\ToolCallResponse; final class StreamResponse extends BaseResponse { diff --git a/src/Chain/Toolbox/Tool/Brave.php b/src/Chain/Toolbox/Tool/Brave.php index aae6e6e7..8b2184fe 100644 --- a/src/Chain/Toolbox/Tool/Brave.php +++ b/src/Chain/Toolbox/Tool/Brave.php @@ -4,8 +4,8 @@ namespace PhpLlm\LlmChain\Chain\Toolbox\Tool; -use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With; use PhpLlm\LlmChain\Chain\Toolbox\Attribute\AsTool; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\Attribute\With; use Symfony\Contracts\HttpClient\HttpClientInterface; #[AsTool('brave_search', 'Tool that searches the web using Brave Search')] diff --git a/src/Chain/Toolbox/Tool/Chain.php b/src/Chain/Toolbox/Tool/Chain.php index c8c092b3..a30a5aca 100644 --- a/src/Chain/Toolbox/Tool/Chain.php +++ b/src/Chain/Toolbox/Tool/Chain.php @@ -4,10 +4,10 @@ namespace PhpLlm\LlmChain\Chain\Toolbox\Tool; -use PhpLlm\LlmChain\ChainInterface; -use PhpLlm\LlmChain\Model\Message\Message; -use PhpLlm\LlmChain\Model\Message\MessageBag; -use PhpLlm\LlmChain\Model\Response\TextResponse; +use PhpLlm\LlmChain\Chain\ChainInterface; +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Message\MessageBag; +use PhpLlm\LlmChain\Platform\Response\TextResponse; final readonly class Chain { @@ -23,7 +23,7 @@ public function __invoke(string $message): string { $response = $this->chain->call(new MessageBag(Message::ofUser($message))); - assert($response instanceof TextResponse); + \assert($response instanceof TextResponse); return $response->getContent(); } diff --git a/src/Chain/Toolbox/Tool/Clock.php b/src/Chain/Toolbox/Tool/Clock.php index ed0c2e68..057b1f75 100644 --- a/src/Chain/Toolbox/Tool/Clock.php +++ b/src/Chain/Toolbox/Tool/Clock.php @@ -18,7 +18,7 @@ public function __construct( public function __invoke(): string { - return sprintf( + 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/Chain/Toolbox/Tool/Crawler.php b/src/Chain/Toolbox/Tool/Crawler.php index 5d347e15..81a5996b 100644 --- a/src/Chain/Toolbox/Tool/Crawler.php +++ b/src/Chain/Toolbox/Tool/Crawler.php @@ -4,8 +4,8 @@ namespace PhpLlm\LlmChain\Chain\Toolbox\Tool; +use PhpLlm\LlmChain\Chain\Exception\RuntimeException; use PhpLlm\LlmChain\Chain\Toolbox\Attribute\AsTool; -use PhpLlm\LlmChain\Exception\RuntimeException; use Symfony\Component\DomCrawler\Crawler as DomCrawler; use Symfony\Contracts\HttpClient\HttpClientInterface; diff --git a/src/Chain/Toolbox/Tool/OpenMeteo.php b/src/Chain/Toolbox/Tool/OpenMeteo.php index 6c36d9a0..4279d257 100644 --- a/src/Chain/Toolbox/Tool/OpenMeteo.php +++ b/src/Chain/Toolbox/Tool/OpenMeteo.php @@ -4,8 +4,8 @@ namespace PhpLlm\LlmChain\Chain\Toolbox\Tool; -use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With; use PhpLlm\LlmChain\Chain\Toolbox\Attribute\AsTool; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\Attribute\With; use Symfony\Contracts\HttpClient\HttpClientInterface; #[AsTool(name: 'weather_current', description: 'get current weather for a location', method: 'current')] diff --git a/src/Chain/Toolbox/Tool/SerpApi.php b/src/Chain/Toolbox/Tool/SerpApi.php index d0d26e76..b107ada9 100644 --- a/src/Chain/Toolbox/Tool/SerpApi.php +++ b/src/Chain/Toolbox/Tool/SerpApi.php @@ -28,7 +28,7 @@ public function __invoke(string $query): string ], ]); - return sprintf('Results for "%s" are "%s".', $query, $this->extractBestResponse($response->toArray())); + return \sprintf('Results for "%s" are "%s".', $query, $this->extractBestResponse($response->toArray())); } /** diff --git a/src/Chain/Toolbox/Tool/SimilaritySearch.php b/src/Chain/Toolbox/Tool/SimilaritySearch.php index 75183457..e1176916 100644 --- a/src/Chain/Toolbox/Tool/SimilaritySearch.php +++ b/src/Chain/Toolbox/Tool/SimilaritySearch.php @@ -5,10 +5,10 @@ namespace PhpLlm\LlmChain\Chain\Toolbox\Tool; use PhpLlm\LlmChain\Chain\Toolbox\Attribute\AsTool; -use PhpLlm\LlmChain\Document\Vector; -use PhpLlm\LlmChain\Document\VectorDocument; -use PhpLlm\LlmChain\Model\EmbeddingsModel; -use PhpLlm\LlmChain\PlatformInterface; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\PlatformInterface; +use PhpLlm\LlmChain\Platform\Vector\Vector; +use PhpLlm\LlmChain\Store\Document\VectorDocument; use PhpLlm\LlmChain\Store\VectorStoreInterface; #[AsTool('similarity_search', description: 'Searches for documents similar to a query or sentence.')] @@ -21,7 +21,7 @@ final class SimilaritySearch public function __construct( private readonly PlatformInterface $platform, - private readonly EmbeddingsModel $embeddings, + private readonly Model $model, private readonly VectorStoreInterface $vectorStore, ) { } @@ -32,14 +32,14 @@ public function __construct( public function __invoke(string $searchTerm): string { /** @var Vector[] $vectors */ - $vectors = $this->platform->request($this->embeddings, $searchTerm)->getContent(); + $vectors = $this->platform->request($this->model, $searchTerm)->getContent(); $this->usedDocuments = $this->vectorStore->query($vectors[0]); - if (0 === count($this->usedDocuments)) { + if (0 === \count($this->usedDocuments)) { return 'No results found'; } - $result = 'Found documents with following information:'.PHP_EOL; + $result = 'Found documents with following information:'.\PHP_EOL; foreach ($this->usedDocuments as $document) { $result .= json_encode($document->metadata); } diff --git a/src/Chain/Toolbox/Tool/Wikipedia.php b/src/Chain/Toolbox/Tool/Wikipedia.php index 470147f5..703d3c4f 100644 --- a/src/Chain/Toolbox/Tool/Wikipedia.php +++ b/src/Chain/Toolbox/Tool/Wikipedia.php @@ -35,12 +35,12 @@ public function search(string $query): string return 'No articles were found on Wikipedia.'; } - $response = 'Articles with the following titles were found on Wikipedia:'.PHP_EOL; + $response = 'Articles with the following titles were found on Wikipedia:'.\PHP_EOL; foreach ($titles as $title) { - $response .= ' - '.$title.PHP_EOL; + $response .= ' - '.$title.\PHP_EOL; } - return $response.PHP_EOL.'Use the title of the article with tool "wikipedia_article" to load the content.'; + return $response.\PHP_EOL.'Use the title of the article with tool "wikipedia_article" to load the content.'; } /** @@ -59,19 +59,19 @@ public function article(string $title): string $article = current($result['query']['pages']); - if (array_key_exists('missing', $article)) { - return sprintf('No article with title "%s" was found on Wikipedia.', $title); + 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'])) { + 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 .= \sprintf('The article "%s" redirects to article "%s".', $redirect['from'], $redirect['to']).\PHP_EOL; } - $response .= PHP_EOL; + $response .= \PHP_EOL; } - return $response.'This is the content of article "'.$article['title'].'":'.PHP_EOL.$article['extract']; + return $response.'This is the content of article "'.$article['title'].'":'.\PHP_EOL.$article['extract']; } /** @@ -81,7 +81,7 @@ public function article(string $title): string */ private function execute(array $query, ?string $locale = null): array { - $url = sprintf('https://%s.wikipedia.org/w/api.php', $locale ?? $this->locale); + $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/Chain/Toolbox/Tool/YouTubeTranscriber.php b/src/Chain/Toolbox/Tool/YouTubeTranscriber.php index 8f457ef8..9931b1f9 100644 --- a/src/Chain/Toolbox/Tool/YouTubeTranscriber.php +++ b/src/Chain/Toolbox/Tool/YouTubeTranscriber.php @@ -4,9 +4,9 @@ namespace PhpLlm\LlmChain\Chain\Toolbox\Tool; +use PhpLlm\LlmChain\Chain\Exception\LogicException; +use PhpLlm\LlmChain\Chain\Exception\RuntimeException; use PhpLlm\LlmChain\Chain\Toolbox\Attribute\AsTool; -use PhpLlm\LlmChain\Exception\LogicException; -use PhpLlm\LlmChain\Exception\RuntimeException; use Symfony\Component\CssSelector\CssSelectorConverter; use Symfony\Component\DomCrawler\Crawler; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -43,7 +43,7 @@ public function __invoke(string $videoId): string })->text(); // Extract and parse the JSON data from the script - $start = strpos($scriptContent, 'var ytInitialPlayerResponse = ') + strlen('var ytInitialPlayerResponse = '); + $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); @@ -64,6 +64,6 @@ public function __invoke(string $videoId): string return $node->text().' '; }); - return implode(PHP_EOL, $transcript); + return implode(\PHP_EOL, $transcript); } } diff --git a/src/Chain/Toolbox/ToolCallResult.php b/src/Chain/Toolbox/ToolCallResult.php index ec954428..600d071c 100644 --- a/src/Chain/Toolbox/ToolCallResult.php +++ b/src/Chain/Toolbox/ToolCallResult.php @@ -4,7 +4,7 @@ namespace PhpLlm\LlmChain\Chain\Toolbox; -use PhpLlm\LlmChain\Model\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Response\ToolCall; final readonly class ToolCallResult { diff --git a/src/Chain/Toolbox/MetadataFactory/AbstractFactory.php b/src/Chain/Toolbox/ToolFactory/AbstractToolFactory.php similarity index 66% rename from src/Chain/Toolbox/MetadataFactory/AbstractFactory.php rename to src/Chain/Toolbox/ToolFactory/AbstractToolFactory.php index c91a4931..62dda568 100644 --- a/src/Chain/Toolbox/MetadataFactory/AbstractFactory.php +++ b/src/Chain/Toolbox/ToolFactory/AbstractToolFactory.php @@ -2,26 +2,26 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Chain\Toolbox\MetadataFactory; +namespace PhpLlm\LlmChain\Chain\Toolbox\ToolFactory; -use PhpLlm\LlmChain\Chain\JsonSchema\Factory; use PhpLlm\LlmChain\Chain\Toolbox\Attribute\AsTool; use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolConfigurationException; -use PhpLlm\LlmChain\Chain\Toolbox\ExecutionReference; -use PhpLlm\LlmChain\Chain\Toolbox\Metadata; -use PhpLlm\LlmChain\Chain\Toolbox\MetadataFactory; +use PhpLlm\LlmChain\Chain\Toolbox\ToolFactoryInterface; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\Factory; +use PhpLlm\LlmChain\Platform\Tool\ExecutionReference; +use PhpLlm\LlmChain\Platform\Tool\Tool; -abstract class AbstractFactory implements MetadataFactory +abstract class AbstractToolFactory implements ToolFactoryInterface { public function __construct( private readonly Factory $factory = new Factory(), ) { } - protected function convertAttribute(string $className, AsTool $attribute): Metadata + protected function convertAttribute(string $className, AsTool $attribute): Tool { try { - return new Metadata( + return new Tool( new ExecutionReference($className, $attribute->method), $attribute->name, $attribute->description, diff --git a/src/Chain/Toolbox/ToolFactory/ChainFactory.php b/src/Chain/Toolbox/ToolFactory/ChainFactory.php new file mode 100644 index 00000000..baf6e3dd --- /dev/null +++ b/src/Chain/Toolbox/ToolFactory/ChainFactory.php @@ -0,0 +1,44 @@ + + */ + 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/Chain/Toolbox/MetadataFactory/MemoryFactory.php b/src/Chain/Toolbox/ToolFactory/MemoryToolFactory.php similarity index 64% rename from src/Chain/Toolbox/MetadataFactory/MemoryFactory.php rename to src/Chain/Toolbox/ToolFactory/MemoryToolFactory.php index 66337916..ee682bee 100644 --- a/src/Chain/Toolbox/MetadataFactory/MemoryFactory.php +++ b/src/Chain/Toolbox/ToolFactory/MemoryToolFactory.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Chain\Toolbox\MetadataFactory; +namespace PhpLlm\LlmChain\Chain\Toolbox\ToolFactory; use PhpLlm\LlmChain\Chain\Toolbox\Attribute\AsTool; -use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolMetadataException; +use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolException; -final class MemoryFactory extends AbstractFactory +final class MemoryToolFactory extends AbstractToolFactory { /** * @var array @@ -16,7 +16,7 @@ final class MemoryFactory extends AbstractFactory public function addTool(string|object $class, string $name, string $description, string $method = '__invoke'): self { - $className = is_object($class) ? $class::class : $class; + $className = \is_object($class) ? $class::class : $class; $this->tools[$className][] = new AsTool($name, $description, $method); return $this; @@ -25,10 +25,10 @@ public function addTool(string|object $class, string $name, string $description, /** * @param class-string $reference */ - public function getMetadata(string $reference): iterable + public function getTool(string $reference): iterable { if (!isset($this->tools[$reference])) { - throw ToolMetadataException::invalidReference($reference); + throw ToolException::invalidReference($reference); } foreach ($this->tools[$reference] as $tool) { diff --git a/src/Chain/Toolbox/MetadataFactory/ReflectionFactory.php b/src/Chain/Toolbox/ToolFactory/ReflectionToolFactory.php similarity index 54% rename from src/Chain/Toolbox/MetadataFactory/ReflectionFactory.php rename to src/Chain/Toolbox/ToolFactory/ReflectionToolFactory.php index 0d854637..c3a12ff0 100644 --- a/src/Chain/Toolbox/MetadataFactory/ReflectionFactory.php +++ b/src/Chain/Toolbox/ToolFactory/ReflectionToolFactory.php @@ -2,32 +2,30 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Chain\Toolbox\MetadataFactory; +namespace PhpLlm\LlmChain\Chain\Toolbox\ToolFactory; -use PhpLlm\LlmChain\Chain\JsonSchema\Factory; use PhpLlm\LlmChain\Chain\Toolbox\Attribute\AsTool; -use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolMetadataException; -use PhpLlm\LlmChain\Chain\Toolbox\Metadata; +use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolException; /** * Metadata factory that uses reflection in combination with `#[AsTool]` attribute to extract metadata from tools. */ -final class ReflectionFactory extends AbstractFactory +final class ReflectionToolFactory extends AbstractToolFactory { /** * @param class-string $reference */ - public function getMetadata(string $reference): iterable + public function getTool(string $reference): iterable { if (!class_exists($reference)) { - throw ToolMetadataException::invalidReference($reference); + throw ToolException::invalidReference($reference); } $reflectionClass = new \ReflectionClass($reference); $attributes = $reflectionClass->getAttributes(AsTool::class); - if (0 === count($attributes)) { - throw ToolMetadataException::missingAttribute($reference); + if (0 === \count($attributes)) { + throw ToolException::missingAttribute($reference); } foreach ($attributes as $attribute) { diff --git a/src/Chain/Toolbox/ToolFactoryInterface.php b/src/Chain/Toolbox/ToolFactoryInterface.php new file mode 100644 index 00000000..76511994 --- /dev/null +++ b/src/Chain/Toolbox/ToolFactoryInterface.php @@ -0,0 +1,18 @@ + + * + * @throws ToolException if the metadata for the given reference is not found + */ + public function getTool(string $reference): iterable; +} diff --git a/src/Chain/Toolbox/ToolResultConverter.php b/src/Chain/Toolbox/ToolResultConverter.php index 140ecb23..263c497e 100644 --- a/src/Chain/Toolbox/ToolResultConverter.php +++ b/src/Chain/Toolbox/ToolResultConverter.php @@ -15,16 +15,16 @@ public function convert(\JsonSerializable|\Stringable|array|float|string|\DateTi return null; } - if ($result instanceof \JsonSerializable || is_array($result)) { - return json_encode($result, flags: JSON_THROW_ON_ERROR); + if ($result instanceof \JsonSerializable || \is_array($result)) { + return json_encode($result, flags: \JSON_THROW_ON_ERROR); } - if (is_float($result) || $result instanceof \Stringable) { + if (\is_float($result) || $result instanceof \Stringable) { return (string) $result; } if ($result instanceof \DateTimeInterface) { - return $result->format(DATE_ATOM); + return $result->format(\DATE_ATOM); } return $result; diff --git a/src/Chain/Toolbox/Toolbox.php b/src/Chain/Toolbox/Toolbox.php index 555ccd73..09fb18b7 100644 --- a/src/Chain/Toolbox/Toolbox.php +++ b/src/Chain/Toolbox/Toolbox.php @@ -6,20 +6,25 @@ use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolExecutionException; use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolNotFoundException; -use PhpLlm\LlmChain\Chain\Toolbox\MetadataFactory\ReflectionFactory; -use PhpLlm\LlmChain\Model\Response\ToolCall; +use PhpLlm\LlmChain\Chain\Toolbox\ToolFactory\ReflectionToolFactory; +use PhpLlm\LlmChain\Platform\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Tool\Tool; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; final class Toolbox implements ToolboxInterface { /** + * List of executable tools. + * * @var list */ private readonly array $tools; /** - * @var Metadata[] + * List of tool metadata objects. + * + * @var Tool[] */ private array $map; @@ -27,7 +32,7 @@ final class Toolbox implements ToolboxInterface * @param iterable $tools */ public function __construct( - private readonly MetadataFactory $metadataFactory, + private readonly ToolFactoryInterface $toolFactory, iterable $tools, private readonly LoggerInterface $logger = new NullLogger(), ) { @@ -36,10 +41,10 @@ public function __construct( public static function create(object ...$tools): self { - return new self(new ReflectionFactory(), $tools); + return new self(new ReflectionToolFactory(), $tools); } - public function getMap(): array + public function getTools(): array { if (isset($this->map)) { return $this->map; @@ -47,7 +52,7 @@ public function getMap(): array $map = []; foreach ($this->tools as $tool) { - foreach ($this->metadataFactory->getMetadata($tool::class) as $metadata) { + foreach ($this->toolFactory->getTool($tool::class) as $metadata) { $map[] = $metadata; } } @@ -58,22 +63,22 @@ public function getMap(): array public function execute(ToolCall $toolCall): mixed { $metadata = $this->getMetadata($toolCall); - $tool = $this->getTool($metadata); + $tool = $this->getExecutable($metadata); try { - $this->logger->debug(sprintf('Executing tool "%s".', $toolCall->name), $toolCall->arguments); + $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]); + $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): Metadata + private function getMetadata(ToolCall $toolCall): Tool { - foreach ($this->getMap() as $metadata) { + foreach ($this->getTools() as $metadata) { if ($metadata->name === $toolCall->name) { return $metadata; } @@ -82,7 +87,7 @@ private function getMetadata(ToolCall $toolCall): Metadata throw ToolNotFoundException::notFoundForToolCall($toolCall); } - private function getTool(Metadata $metadata): object + private function getExecutable(Tool $metadata): object { foreach ($this->tools as $tool) { if ($tool instanceof $metadata->reference->class) { diff --git a/src/Chain/Toolbox/ToolboxInterface.php b/src/Chain/Toolbox/ToolboxInterface.php index 0f49c470..e09ef80d 100644 --- a/src/Chain/Toolbox/ToolboxInterface.php +++ b/src/Chain/Toolbox/ToolboxInterface.php @@ -6,14 +6,15 @@ use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolExecutionException; use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolNotFoundException; -use PhpLlm\LlmChain\Model\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Tool\Tool; interface ToolboxInterface { /** - * @return Metadata[] + * @return Tool[] */ - public function getMap(): array; + public function getTools(): array; /** * @throws ToolExecutionException if the tool execution fails diff --git a/src/Model/EmbeddingsModel.php b/src/Model/EmbeddingsModel.php deleted file mode 100644 index 3f354410..00000000 --- a/src/Model/EmbeddingsModel.php +++ /dev/null @@ -1,10 +0,0 @@ -toolCalls && 0 !== \count($this->toolCalls); - } - - /** - * @return array{ - * role: Role::Assistant, - * content: ?string, - * tool_calls?: ToolCall[], - * } - */ - public function jsonSerialize(): array - { - $array = [ - 'role' => Role::Assistant, - ]; - - if (null !== $this->content) { - $array['content'] = $this->content; - } - - if ($this->hasToolCalls()) { - $array['tool_calls'] = $this->toolCalls; - } - - return $array; - } -} diff --git a/src/Model/Message/Content/Audio.php b/src/Model/Message/Content/Audio.php deleted file mode 100644 index 3a5695a5..00000000 --- a/src/Model/Message/Content/Audio.php +++ /dev/null @@ -1,26 +0,0 @@ - 'input_audio', - 'input_audio' => [ - 'data' => $this->asBase64(), - 'format' => match ($this->getFormat()) { - 'audio/mpeg' => 'mp3', - 'audio/wav' => 'wav', - default => $this->getFormat(), - }, - ], - ]; - } -} diff --git a/src/Model/Message/Content/Content.php b/src/Model/Message/Content/Content.php deleted file mode 100644 index a97cc9d7..00000000 --- a/src/Model/Message/Content/Content.php +++ /dev/null @@ -1,9 +0,0 @@ - 'image_url', - 'image_url' => ['url' => $this->asDataUrl()], - ]; - } -} diff --git a/src/Model/Message/Content/ImageUrl.php b/src/Model/Message/Content/ImageUrl.php deleted file mode 100644 index a9e63791..00000000 --- a/src/Model/Message/Content/ImageUrl.php +++ /dev/null @@ -1,24 +0,0 @@ - 'image_url', - 'image_url' => ['url' => $this->url], - ]; - } -} diff --git a/src/Model/Message/Content/Text.php b/src/Model/Message/Content/Text.php deleted file mode 100644 index 08b0d87b..00000000 --- a/src/Model/Message/Content/Text.php +++ /dev/null @@ -1,21 +0,0 @@ - 'text', 'text' => $this->text]; - } -} diff --git a/src/Model/Message/MessageInterface.php b/src/Model/Message/MessageInterface.php deleted file mode 100644 index efae114e..00000000 --- a/src/Model/Message/MessageInterface.php +++ /dev/null @@ -1,10 +0,0 @@ - Role::System, - 'content' => $this->content, - ]; - } -} diff --git a/src/Model/Message/ToolCallMessage.php b/src/Model/Message/ToolCallMessage.php deleted file mode 100644 index 20a97679..00000000 --- a/src/Model/Message/ToolCallMessage.php +++ /dev/null @@ -1,37 +0,0 @@ - Role::ToolCall, - 'content' => $this->content, - 'tool_call_id' => $this->toolCall->id, - ]; - } -} diff --git a/src/Model/Message/UserMessage.php b/src/Model/Message/UserMessage.php deleted file mode 100644 index ce6d2b23..00000000 --- a/src/Model/Message/UserMessage.php +++ /dev/null @@ -1,72 +0,0 @@ - - */ - public array $content; - - public function __construct( - Content ...$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; - } - - /** - * @return array{ - * role: Role::User, - * content: string|list - * } - */ - public function jsonSerialize(): array - { - $array = ['role' => Role::User]; - if (1 === count($this->content) && $this->content[0] instanceof Text) { - $array['content'] = $this->content[0]->text; - - return $array; - } - - $array['content'] = $this->content; - - return $array; - } -} diff --git a/src/Model/Model.php b/src/Model/Model.php deleted file mode 100644 index 134f3b01..00000000 --- a/src/Model/Model.php +++ /dev/null @@ -1,15 +0,0 @@ - - */ - public function getOptions(): array; -} diff --git a/src/Model/Response/Exception/RawResponseAlreadySet.php b/src/Model/Response/Exception/RawResponseAlreadySet.php deleted file mode 100644 index 6c9a436c..00000000 --- a/src/Model/Response/Exception/RawResponseAlreadySet.php +++ /dev/null @@ -1,13 +0,0 @@ - $modelClients - * @param iterable $responseConverter - */ - public function __construct(iterable $modelClients, iterable $responseConverter) - { - $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 - { - $options = array_merge($model->getOptions(), $options); - - $response = $this->doRequest($model, $input, $options); - - return $this->convertResponse($model, $input, $response, $options); - } - - /** - * @param array|string|object $input - * @param array $options - */ - private function doRequest(Model $model, array|string|object $input, array $options = []): HttpResponse - { - foreach ($this->modelClients as $modelClient) { - if ($modelClient->supports($model, $input)) { - return $modelClient->request($model, $input, $options); - } - } - - throw new RuntimeException('No response factory registered for model "'.$model::class.'" with given input.'); - } - - /** - * @param array|string|object $input - * @param array $options - */ - private function convertResponse(Model $model, object|array|string $input, HttpResponse $response, array $options): ResponseInterface - { - foreach ($this->responseConverter as $responseConverter) { - if ($responseConverter->supports($model, $input)) { - 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/Bridge/Anthropic/Claude.php b/src/Platform/Bridge/Anthropic/Claude.php new file mode 100644 index 00000000..c1ff3a06 --- /dev/null +++ b/src/Platform/Bridge/Anthropic/Claude.php @@ -0,0 +1,37 @@ + $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/Bridge/Anthropic/Contract/AssistantMessageNormalizer.php b/src/Platform/Bridge/Anthropic/Contract/AssistantMessageNormalizer.php new file mode 100644 index 00000000..340becaa --- /dev/null +++ b/src/Platform/Bridge/Anthropic/Contract/AssistantMessageNormalizer.php @@ -0,0 +1,56 @@ + + * }> + * } + */ + 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/Bridge/Anthropic/Contract/ImageNormalizer.php b/src/Platform/Bridge/Anthropic/Contract/ImageNormalizer.php new file mode 100644 index 00000000..bb15c183 --- /dev/null +++ b/src/Platform/Bridge/Anthropic/Contract/ImageNormalizer.php @@ -0,0 +1,49 @@ + 'image', + 'source' => [ + 'type' => 'base64', + 'media_type' => u($data->getFormat())->replace('jpg', 'jpeg')->toString(), + 'data' => $data->asBase64(), + ], + ]; + } +} diff --git a/src/Platform/Bridge/Anthropic/Contract/MessageBagNormalizer.php b/src/Platform/Bridge/Anthropic/Contract/MessageBagNormalizer.php new file mode 100644 index 00000000..1a44ccae --- /dev/null +++ b/src/Platform/Bridge/Anthropic/Contract/MessageBagNormalizer.php @@ -0,0 +1,54 @@ +, + * 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/Bridge/Anthropic/Contract/ToolCallMessageNormalizer.php b/src/Platform/Bridge/Anthropic/Contract/ToolCallMessageNormalizer.php new file mode 100644 index 00000000..fa954ffe --- /dev/null +++ b/src/Platform/Bridge/Anthropic/Contract/ToolCallMessageNormalizer.php @@ -0,0 +1,53 @@ + + * } + */ + 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/Bridge/Anthropic/Contract/ToolNormalizer.php b/src/Platform/Bridge/Anthropic/Contract/ToolNormalizer.php new file mode 100644 index 00000000..58cc64f6 --- /dev/null +++ b/src/Platform/Bridge/Anthropic/Contract/ToolNormalizer.php @@ -0,0 +1,43 @@ + $data->name, + 'description' => $data->description, + 'input_schema' => $data->parameters ?? ['type' => 'object'], + ]; + } +} diff --git a/src/Platform/Bridge/Anthropic/ModelHandler.php b/src/Platform/Bridge/Anthropic/ModelHandler.php new file mode 100644 index 00000000..29e097f3 --- /dev/null +++ b/src/Platform/Bridge/Anthropic/ModelHandler.php @@ -0,0 +1,105 @@ +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), + ]); + } + + 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/Bridge/Anthropic/PlatformFactory.php b/src/Platform/Bridge/Anthropic/PlatformFactory.php similarity index 50% rename from src/Bridge/Anthropic/PlatformFactory.php rename to src/Platform/Bridge/Anthropic/PlatformFactory.php index 644c462a..56d9f9b5 100644 --- a/src/Bridge/Anthropic/PlatformFactory.php +++ b/src/Platform/Bridge/Anthropic/PlatformFactory.php @@ -2,9 +2,14 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Anthropic; +namespace PhpLlm\LlmChain\Platform\Bridge\Anthropic; -use PhpLlm\LlmChain\Platform; +use PhpLlm\LlmChain\Platform\Bridge\Anthropic\Contract\AssistantMessageNormalizer; +use PhpLlm\LlmChain\Platform\Bridge\Anthropic\Contract\MessageBagNormalizer; +use PhpLlm\LlmChain\Platform\Bridge\Anthropic\Contract\ToolCallMessageNormalizer; +use PhpLlm\LlmChain\Platform\Bridge\Anthropic\Contract\ToolNormalizer; +use PhpLlm\LlmChain\Platform\Contract; +use PhpLlm\LlmChain\Platform\Platform; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -19,6 +24,11 @@ public static function create( $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); $responseHandler = new ModelHandler($httpClient, $apiKey, $version); - return new Platform([$responseHandler], [$responseHandler]); + return new Platform([$responseHandler], [$responseHandler], Contract::create( + new AssistantMessageNormalizer(), + new MessageBagNormalizer(), + new ToolCallMessageNormalizer(), + new ToolNormalizer(), + )); } } diff --git a/src/Platform/Bridge/Azure/Meta/LlamaHandler.php b/src/Platform/Bridge/Azure/Meta/LlamaHandler.php new file mode 100644 index 00000000..26679f5f --- /dev/null +++ b/src/Platform/Bridge/Azure/Meta/LlamaHandler.php @@ -0,0 +1,54 @@ +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/Bridge/Azure/Meta/PlatformFactory.php b/src/Platform/Bridge/Azure/Meta/PlatformFactory.php similarity index 84% rename from src/Bridge/Azure/Meta/PlatformFactory.php rename to src/Platform/Bridge/Azure/Meta/PlatformFactory.php index e6889d1a..ec2b1b75 100644 --- a/src/Bridge/Azure/Meta/PlatformFactory.php +++ b/src/Platform/Bridge/Azure/Meta/PlatformFactory.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Azure\Meta; +namespace PhpLlm\LlmChain\Platform\Bridge\Azure\Meta; -use PhpLlm\LlmChain\Platform; +use PhpLlm\LlmChain\Platform\Platform; use Symfony\Component\HttpClient\HttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; diff --git a/src/Bridge/Azure/OpenAI/EmbeddingsModelClient.php b/src/Platform/Bridge/Azure/OpenAI/EmbeddingsModelClient.php similarity index 75% rename from src/Bridge/Azure/OpenAI/EmbeddingsModelClient.php rename to src/Platform/Bridge/Azure/OpenAI/EmbeddingsModelClient.php index ce0e3d13..dfdb4304 100644 --- a/src/Bridge/Azure/OpenAI/EmbeddingsModelClient.php +++ b/src/Platform/Bridge/Azure/OpenAI/EmbeddingsModelClient.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Azure\OpenAI; +namespace PhpLlm\LlmChain\Platform\Bridge\Azure\OpenAI; -use PhpLlm\LlmChain\Bridge\OpenAI\Embeddings; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Platform\ModelClient; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\ModelClientInterface; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; use Webmozart\Assert\Assert; -final readonly class EmbeddingsModelClient implements ModelClient +final readonly class EmbeddingsModelClient implements ModelClientInterface { private EventSourceHttpClient $httpClient; @@ -31,14 +31,14 @@ public function __construct( Assert::stringNotEmpty($apiKey, 'The API key must not be empty.'); } - public function supports(Model $model, object|array|string $input): bool + public function supports(Model $model): bool { return $model instanceof Embeddings; } - public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface { - $url = sprintf('https://%s/openai/deployments/%s/embeddings', $this->baseUrl, $this->deployment); + $url = \sprintf('https://%s/openai/deployments/%s/embeddings', $this->baseUrl, $this->deployment); return $this->httpClient->request('POST', $url, [ 'headers' => [ @@ -47,7 +47,7 @@ public function request(Model $model, object|array|string $input, array $options 'query' => ['api-version' => $this->apiVersion], 'json' => array_merge($options, [ 'model' => $model->getName(), - 'input' => $input, + 'input' => $payload, ]), ]); } diff --git a/src/Bridge/Azure/OpenAI/GPTModelClient.php b/src/Platform/Bridge/Azure/OpenAI/GPTModelClient.php similarity index 67% rename from src/Bridge/Azure/OpenAI/GPTModelClient.php rename to src/Platform/Bridge/Azure/OpenAI/GPTModelClient.php index d80e7ef0..2caef7c7 100644 --- a/src/Bridge/Azure/OpenAI/GPTModelClient.php +++ b/src/Platform/Bridge/Azure/OpenAI/GPTModelClient.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Azure\OpenAI; +namespace PhpLlm\LlmChain\Platform\Bridge\Azure\OpenAI; -use PhpLlm\LlmChain\Bridge\OpenAI\GPT; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Platform\ModelClient; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\ModelClientInterface; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; use Webmozart\Assert\Assert; -final readonly class GPTModelClient implements ModelClient +final readonly class GPTModelClient implements ModelClientInterface { private EventSourceHttpClient $httpClient; @@ -31,24 +31,21 @@ public function __construct( Assert::stringNotEmpty($apiKey, 'The API key must not be empty.'); } - public function supports(Model $model, object|array|string $input): bool + public function supports(Model $model): bool { return $model instanceof GPT; } - public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface + 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); + $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, [ - 'model' => $model->getName(), - 'messages' => $input, - ]), + 'json' => array_merge($options, $payload), ]); } } diff --git a/src/Bridge/Azure/OpenAI/PlatformFactory.php b/src/Platform/Bridge/Azure/OpenAI/PlatformFactory.php similarity index 70% rename from src/Bridge/Azure/OpenAI/PlatformFactory.php rename to src/Platform/Bridge/Azure/OpenAI/PlatformFactory.php index 25b8719f..17ef7d8e 100644 --- a/src/Bridge/Azure/OpenAI/PlatformFactory.php +++ b/src/Platform/Bridge/Azure/OpenAI/PlatformFactory.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Azure\OpenAI; +namespace PhpLlm\LlmChain\Platform\Bridge\Azure\OpenAI; -use PhpLlm\LlmChain\Bridge\OpenAI\Embeddings; -use PhpLlm\LlmChain\Bridge\OpenAI\GPT\ResponseConverter; -use PhpLlm\LlmChain\Bridge\OpenAI\Whisper; -use PhpLlm\LlmChain\Platform; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT\ResponseConverter; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Whisper\AudioNormalizer; +use PhpLlm\LlmChain\Platform\Contract; +use PhpLlm\LlmChain\Platform\Platform; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -28,7 +29,8 @@ public static function create( return new Platform( [$GPTResponseFactory, $embeddingsResponseFactory, $whisperResponseFactory], - [new ResponseConverter(), new Embeddings\ResponseConverter(), new Whisper\ResponseConverter()], + [new ResponseConverter(), new Embeddings\ResponseConverter(), new \PhpLlm\LlmChain\Platform\Bridge\OpenAI\Whisper\ResponseConverter()], + Contract::create(new AudioNormalizer()), ); } } diff --git a/src/Bridge/Azure/OpenAI/WhisperModelClient.php b/src/Platform/Bridge/Azure/OpenAI/WhisperModelClient.php similarity index 61% rename from src/Bridge/Azure/OpenAI/WhisperModelClient.php rename to src/Platform/Bridge/Azure/OpenAI/WhisperModelClient.php index 1282db95..935f1f22 100644 --- a/src/Bridge/Azure/OpenAI/WhisperModelClient.php +++ b/src/Platform/Bridge/Azure/OpenAI/WhisperModelClient.php @@ -2,18 +2,17 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Azure\OpenAI; +namespace PhpLlm\LlmChain\Platform\Bridge\Azure\OpenAI; -use PhpLlm\LlmChain\Bridge\OpenAI\Whisper; -use PhpLlm\LlmChain\Model\Message\Content\Audio; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Platform\ModelClient; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Whisper; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\ModelClientInterface; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; use Webmozart\Assert\Assert; -final readonly class WhisperModelClient implements ModelClient +final readonly class WhisperModelClient implements ModelClientInterface { private EventSourceHttpClient $httpClient; @@ -32,16 +31,14 @@ public function __construct( Assert::stringNotEmpty($apiKey, 'The API key must not be empty.'); } - public function supports(Model $model, object|array|string $input): bool + public function supports(Model $model): bool { - return $model instanceof Whisper && $input instanceof Audio; + return $model instanceof Whisper; } - public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface { - assert($input instanceof Audio); - - $url = sprintf('https://%s/openai/deployments/%s/audio/translations', $this->baseUrl, $this->deployment); + $url = \sprintf('https://%s/openai/deployments/%s/audio/translations', $this->baseUrl, $this->deployment); return $this->httpClient->request('POST', $url, [ 'headers' => [ @@ -49,10 +46,7 @@ public function request(Model $model, object|array|string $input, array $options 'Content-Type' => 'multipart/form-data', ], 'query' => ['api-version' => $this->apiVersion], - 'body' => array_merge($options, $model->getOptions(), [ - 'model' => $model->getName(), - 'file' => $input->asResource(), - ]), + 'body' => array_merge($options, $payload), ]); } } diff --git a/src/Platform/Bridge/Bedrock/Anthropic/ClaudeHandler.php b/src/Platform/Bridge/Bedrock/Anthropic/ClaudeHandler.php new file mode 100644 index 00000000..d4e5ed81 --- /dev/null +++ b/src/Platform/Bridge/Bedrock/Anthropic/ClaudeHandler.php @@ -0,0 +1,87 @@ + '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/Bridge/Bedrock/BedrockModelClient.php b/src/Platform/Bridge/Bedrock/BedrockModelClient.php new file mode 100644 index 00000000..659c4e3a --- /dev/null +++ b/src/Platform/Bridge/Bedrock/BedrockModelClient.php @@ -0,0 +1,19 @@ +|string $payload + * @param array $options + */ + public function request(Model $model, array|string $payload, array $options = []): LlmResponse; +} diff --git a/src/Bridge/Bedrock/Meta/LlamaModelClient.php b/src/Platform/Bridge/Bedrock/Meta/LlamaModelClient.php similarity index 51% rename from src/Bridge/Bedrock/Meta/LlamaModelClient.php rename to src/Platform/Bridge/Bedrock/Meta/LlamaModelClient.php index 2a50af1c..4ea3e862 100644 --- a/src/Bridge/Bedrock/Meta/LlamaModelClient.php +++ b/src/Platform/Bridge/Bedrock/Meta/LlamaModelClient.php @@ -1,43 +1,34 @@ $this->promptConverter->convertToPrompt($input), - ]; - $response = $this->bedrockRuntimeClient->invokeModel(new InvokeModelRequest([ 'modelId' => $this->getModelId($model), 'contentType' => 'application/json', - 'body' => json_encode($body, JSON_THROW_ON_ERROR), + 'body' => json_encode($payload, \JSON_THROW_ON_ERROR), ])); return $this->convert($response); @@ -45,7 +36,7 @@ public function request(Model $model, object|array|string $input, array $options public function convert(InvokeModelResponse $bedrockResponse): LlmResponse { - $responseBody = json_decode($bedrockResponse->getBody(), true, 512, JSON_THROW_ON_ERROR); + $responseBody = json_decode($bedrockResponse->getBody(), true, 512, \JSON_THROW_ON_ERROR); if (!isset($responseBody['generation'])) { throw new \RuntimeException('Response does not contain any content'); diff --git a/src/Platform/Bridge/Bedrock/Nova/Contract/AssistantMessageNormalizer.php b/src/Platform/Bridge/Bedrock/Nova/Contract/AssistantMessageNormalizer.php new file mode 100644 index 00000000..c24e2f0b --- /dev/null +++ b/src/Platform/Bridge/Bedrock/Nova/Contract/AssistantMessageNormalizer.php @@ -0,0 +1,62 @@ + + * } + */ + 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/Bridge/Bedrock/Nova/Contract/MessageBagNormalizer.php b/src/Platform/Bridge/Bedrock/Nova/Contract/MessageBagNormalizer.php new file mode 100644 index 00000000..9270e3c4 --- /dev/null +++ b/src/Platform/Bridge/Bedrock/Nova/Contract/MessageBagNormalizer.php @@ -0,0 +1,48 @@ +>, + * 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/Bridge/Bedrock/Nova/Contract/ToolCallMessageNormalizer.php b/src/Platform/Bridge/Bedrock/Nova/Contract/ToolCallMessageNormalizer.php new file mode 100644 index 00000000..7ab6b3cd --- /dev/null +++ b/src/Platform/Bridge/Bedrock/Nova/Contract/ToolCallMessageNormalizer.php @@ -0,0 +1,55 @@ +, + * } + * }> + * } + */ + 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/Bridge/Bedrock/Nova/Contract/ToolNormalizer.php b/src/Platform/Bridge/Bedrock/Nova/Contract/ToolNormalizer.php new file mode 100644 index 00000000..9e125e51 --- /dev/null +++ b/src/Platform/Bridge/Bedrock/Nova/Contract/ToolNormalizer.php @@ -0,0 +1,51 @@ + [ + 'name' => $data->name, + 'description' => $data->description, + 'inputSchema' => [ + 'json' => $data->parameters ?? new \stdClass(), + ], + ], + ]; + } +} diff --git a/src/Platform/Bridge/Bedrock/Nova/Contract/UserMessageNormalizer.php b/src/Platform/Bridge/Bedrock/Nova/Contract/UserMessageNormalizer.php new file mode 100644 index 00000000..5a9ebb1c --- /dev/null +++ b/src/Platform/Bridge/Bedrock/Nova/Contract/UserMessageNormalizer.php @@ -0,0 +1,62 @@ + + * } + */ + 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/Bridge/Bedrock/Nova/Nova.php b/src/Platform/Bridge/Bedrock/Nova/Nova.php new file mode 100644 index 00000000..2d1e5bf1 --- /dev/null +++ b/src/Platform/Bridge/Bedrock/Nova/Nova.php @@ -0,0 +1,36 @@ + $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/Bridge/Bedrock/Nova/NovaHandler.php b/src/Platform/Bridge/Bedrock/Nova/NovaHandler.php similarity index 53% rename from src/Bridge/Bedrock/Nova/NovaHandler.php rename to src/Platform/Bridge/Bedrock/Nova/NovaHandler.php index 42e62526..cd6cc434 100644 --- a/src/Bridge/Bedrock/Nova/NovaHandler.php +++ b/src/Platform/Bridge/Bedrock/Nova/NovaHandler.php @@ -2,53 +2,36 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Bedrock\Nova; +namespace PhpLlm\LlmChain\Platform\Bridge\Bedrock\Nova; use AsyncAws\BedrockRuntime\BedrockRuntimeClient; use AsyncAws\BedrockRuntime\Input\InvokeModelRequest; use AsyncAws\BedrockRuntime\Result\InvokeModelResponse; -use PhpLlm\LlmChain\Bridge\Bedrock\BedrockModelClient; -use PhpLlm\LlmChain\Chain\Toolbox\Metadata; -use PhpLlm\LlmChain\Exception\RuntimeException; -use PhpLlm\LlmChain\Model\Message\MessageBagInterface; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Model\Response\ResponseInterface as LlmResponse; -use PhpLlm\LlmChain\Model\Response\TextResponse; -use PhpLlm\LlmChain\Model\Response\ToolCall; -use PhpLlm\LlmChain\Model\Response\ToolCallResponse; -use Webmozart\Assert\Assert; +use PhpLlm\LlmChain\Platform\Bridge\Bedrock\BedrockModelClient; +use PhpLlm\LlmChain\Platform\Exception\RuntimeException; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface as LlmResponse; +use PhpLlm\LlmChain\Platform\Response\TextResponse; +use PhpLlm\LlmChain\Platform\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Response\ToolCallResponse; class NovaHandler implements BedrockModelClient { public function __construct( private readonly BedrockRuntimeClient $bedrockRuntimeClient, - private readonly NovaPromptConverter $novaPromptConverter = new NovaPromptConverter(), ) { } - public function supports(Model $model, object|array|string $input): bool + public function supports(Model $model): bool { - return $model instanceof Nova && $input instanceof MessageBagInterface; + return $model instanceof Nova; } - public function request(Model $model, object|array|string $input, array $options = []): LlmResponse + public function request(Model $model, array|string $payload, array $options = []): LlmResponse { - Assert::isInstanceOf($input, MessageBagInterface::class); - $modelOptions = []; if (isset($options['tools'])) { - $tools = $options['tools']; - $modelOptions['toolConfig'] = []; - /** @var Metadata $tool */ - foreach ($tools as $tool) { - $toolDefinition = [ - 'name' => $tool->name, - 'description' => $tool->description, - 'inputSchema' => $tool->parameters ?? new \stdClass(), - ]; - $modelOptions['toolConfig']['tools'][]['toolSpec'] = $toolDefinition; - } - // $modelOptions['toolChoice'] = ['auto' => []]; + $modelOptions['toolConfig']['tools'] = $options['tools']; } if (isset($options['temperature'])) { @@ -59,18 +42,10 @@ public function request(Model $model, object|array|string $input, array $options $modelOptions['inferenceConfig']['maxTokens'] = $options['max_tokens']; } - $body = [ - 'messages' => $this->novaPromptConverter->convertToPrompt($input->withoutSystemMessage()), - ]; - - if ($input->getSystemMessage()) { - $body['system'][]['text'] = $input->getSystemMessage()->content; - } - $request = [ 'modelId' => $this->getModelId($model), 'contentType' => 'application/json', - 'body' => json_encode(array_merge($body, $modelOptions), JSON_THROW_ON_ERROR), + 'body' => json_encode(array_merge($payload, $modelOptions), \JSON_THROW_ON_ERROR), ]; $invokeModelResponse = $this->bedrockRuntimeClient->invokeModel(new InvokeModelRequest($request)); @@ -80,9 +55,9 @@ public function request(Model $model, object|array|string $input, array $options public function convert(InvokeModelResponse $bedrockResponse): LlmResponse { - $data = json_decode($bedrockResponse->getBody(), true, 512, JSON_THROW_ON_ERROR); + $data = json_decode($bedrockResponse->getBody(), true, 512, \JSON_THROW_ON_ERROR); - if (!isset($data['output']) || 0 === count($data['output'])) { + if (!isset($data['output']) || 0 === \count($data['output'])) { throw new RuntimeException('Response does not contain any content'); } diff --git a/src/Platform/Bridge/Bedrock/Platform.php b/src/Platform/Bridge/Bedrock/Platform.php new file mode 100644 index 00000000..8edf4917 --- /dev/null +++ b/src/Platform/Bridge/Bedrock/Platform.php @@ -0,0 +1,70 @@ + $modelClients + */ + public function __construct( + iterable $modelClients, + private ?Contract $contract = null, + ) { + $this->contract = $contract ?? Contract::create( + new AnthropicContract\AssistantMessageNormalizer(), + new AnthropicContract\ImageNormalizer(), + 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/Bridge/Bedrock/PlatformFactory.php b/src/Platform/Bridge/Bedrock/PlatformFactory.php similarity index 67% rename from src/Bridge/Bedrock/PlatformFactory.php rename to src/Platform/Bridge/Bedrock/PlatformFactory.php index 2b54b915..959194eb 100644 --- a/src/Bridge/Bedrock/PlatformFactory.php +++ b/src/Platform/Bridge/Bedrock/PlatformFactory.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Bedrock; +namespace PhpLlm\LlmChain\Platform\Bridge\Bedrock; use AsyncAws\BedrockRuntime\BedrockRuntimeClient; -use PhpLlm\LlmChain\Bridge\Bedrock\Anthropic\ClaudeHandler; -use PhpLlm\LlmChain\Bridge\Bedrock\Meta\LlamaModelClient; -use PhpLlm\LlmChain\Bridge\Bedrock\Nova\NovaHandler; +use PhpLlm\LlmChain\Platform\Bridge\Bedrock\Anthropic\ClaudeHandler; +use PhpLlm\LlmChain\Platform\Bridge\Bedrock\Meta\LlamaModelClient; +use PhpLlm\LlmChain\Platform\Bridge\Bedrock\Nova\NovaHandler; final readonly class PlatformFactory { diff --git a/src/Platform/Bridge/Google/Contract/AssistantMessageNormalizer.php b/src/Platform/Bridge/Google/Contract/AssistantMessageNormalizer.php new file mode 100644 index 00000000..215de196 --- /dev/null +++ b/src/Platform/Bridge/Google/Contract/AssistantMessageNormalizer.php @@ -0,0 +1,39 @@ + $data->content], + ]; + } +} diff --git a/src/Platform/Bridge/Google/Contract/MessageBagNormalizer.php b/src/Platform/Bridge/Google/Contract/MessageBagNormalizer.php new file mode 100644 index 00000000..53ffedab --- /dev/null +++ b/src/Platform/Bridge/Google/Contract/MessageBagNormalizer.php @@ -0,0 +1,59 @@ + + * }>, + * 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/Bridge/Google/Contract/UserMessageNormalizer.php b/src/Platform/Bridge/Google/Contract/UserMessageNormalizer.php new file mode 100644 index 00000000..05ce87cd --- /dev/null +++ b/src/Platform/Bridge/Google/Contract/UserMessageNormalizer.php @@ -0,0 +1,48 @@ + + */ + 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/Bridge/Google/Gemini.php b/src/Platform/Bridge/Google/Gemini.php new file mode 100644 index 00000000..fb261c49 --- /dev/null +++ b/src/Platform/Bridge/Google/Gemini.php @@ -0,0 +1,31 @@ + $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/Bridge/Google/ModelHandler.php b/src/Platform/Bridge/Google/ModelHandler.php similarity index 74% rename from src/Bridge/Google/ModelHandler.php rename to src/Platform/Bridge/Google/ModelHandler.php index f3875c25..0bf8a94c 100644 --- a/src/Bridge/Google/ModelHandler.php +++ b/src/Platform/Bridge/Google/ModelHandler.php @@ -2,16 +2,15 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Google; - -use PhpLlm\LlmChain\Exception\RuntimeException; -use PhpLlm\LlmChain\Model\Message\MessageBagInterface; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Model\Response\ResponseInterface as LlmResponse; -use PhpLlm\LlmChain\Model\Response\StreamResponse; -use PhpLlm\LlmChain\Model\Response\TextResponse; -use PhpLlm\LlmChain\Platform\ModelClient; -use PhpLlm\LlmChain\Platform\ResponseConverter; +namespace PhpLlm\LlmChain\Platform\Bridge\Google; + +use PhpLlm\LlmChain\Platform\Exception\RuntimeException; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\ModelClientInterface; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface as LlmResponse; +use PhpLlm\LlmChain\Platform\Response\StreamResponse; +use PhpLlm\LlmChain\Platform\Response\TextResponse; +use PhpLlm\LlmChain\Platform\ResponseConverterInterface; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; @@ -20,33 +19,29 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; -use Webmozart\Assert\Assert; -final readonly class ModelHandler implements ModelClient, ResponseConverter +final readonly class ModelHandler implements ModelClientInterface, ResponseConverterInterface { private EventSourceHttpClient $httpClient; public function __construct( HttpClientInterface $httpClient, #[\SensitiveParameter] private string $apiKey, - private GooglePromptConverter $promptConverter = new GooglePromptConverter(), ) { $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); } - public function supports(Model $model, array|string|object $input): bool + public function supports(Model $model): bool { - return $model instanceof Gemini && $input instanceof MessageBagInterface; + return $model instanceof Gemini; } /** * @throws TransportExceptionInterface */ - public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface { - Assert::isInstanceOf($input, MessageBagInterface::class); - - $url = sprintf( + $url = \sprintf( 'https://generativelanguage.googleapis.com/v1beta/models/%s:%s', $model->getName(), $options['stream'] ?? false ? 'streamGenerateContent' : 'generateContent', @@ -59,7 +54,7 @@ public function request(Model $model, object|array|string $input, array $options 'headers' => [ 'x-goog-api-key' => $this->apiKey, ], - 'json' => array_merge($generationConfig, $this->promptConverter->convertToPrompt($input)), + 'json' => array_merge($generationConfig, $payload), ]); } @@ -111,9 +106,8 @@ private function convertStream(ResponseInterface $response): \Generator } try { - $data = json_decode($delta, true, 512, JSON_THROW_ON_ERROR); + $data = json_decode($delta, true, 512, \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - dump($delta); throw new RuntimeException('Failed to decode JSON response', 0, $e); } diff --git a/src/Bridge/Google/PlatformFactory.php b/src/Platform/Bridge/Google/PlatformFactory.php similarity index 53% rename from src/Bridge/Google/PlatformFactory.php rename to src/Platform/Bridge/Google/PlatformFactory.php index 7e601f83..4cd6f1e4 100644 --- a/src/Bridge/Google/PlatformFactory.php +++ b/src/Platform/Bridge/Google/PlatformFactory.php @@ -2,9 +2,13 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Google; +namespace PhpLlm\LlmChain\Platform\Bridge\Google; -use PhpLlm\LlmChain\Platform; +use PhpLlm\LlmChain\Platform\Bridge\Google\Contract\AssistantMessageNormalizer; +use PhpLlm\LlmChain\Platform\Bridge\Google\Contract\MessageBagNormalizer; +use PhpLlm\LlmChain\Platform\Bridge\Google\Contract\UserMessageNormalizer; +use PhpLlm\LlmChain\Platform\Contract; +use PhpLlm\LlmChain\Platform\Platform; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -18,6 +22,10 @@ public static function create( $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); $responseHandler = new ModelHandler($httpClient, $apiKey); - return new Platform([$responseHandler], [$responseHandler]); + return new Platform([$responseHandler], [$responseHandler], Contract::create( + new AssistantMessageNormalizer(), + new MessageBagNormalizer(), + new UserMessageNormalizer(), + )); } } diff --git a/src/Bridge/HuggingFace/ApiClient.php b/src/Platform/Bridge/HuggingFace/ApiClient.php similarity index 89% rename from src/Bridge/HuggingFace/ApiClient.php rename to src/Platform/Bridge/HuggingFace/ApiClient.php index 552fef77..01f33c3a 100644 --- a/src/Bridge/HuggingFace/ApiClient.php +++ b/src/Platform/Bridge/HuggingFace/ApiClient.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace; +use PhpLlm\LlmChain\Platform\Model; use Symfony\Component\HttpClient\HttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; diff --git a/src/Platform/Bridge/HuggingFace/Contract/FileNormalizer.php b/src/Platform/Bridge/HuggingFace/Contract/FileNormalizer.php new file mode 100644 index 00000000..6bd0138b --- /dev/null +++ b/src/Platform/Bridge/HuggingFace/Contract/FileNormalizer.php @@ -0,0 +1,36 @@ +, + * 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/Bridge/HuggingFace/Contract/MessageBagNormalizer.php b/src/Platform/Bridge/HuggingFace/Contract/MessageBagNormalizer.php new file mode 100644 index 00000000..2f41e945 --- /dev/null +++ b/src/Platform/Bridge/HuggingFace/Contract/MessageBagNormalizer.php @@ -0,0 +1,42 @@ +, + * 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/Bridge/HuggingFace/ModelClient.php b/src/Platform/Bridge/HuggingFace/ModelClient.php new file mode 100644 index 00000000..46850c14 --- /dev/null +++ b/src/Platform/Bridge/HuggingFace/ModelClient.php @@ -0,0 +1,84 @@ +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/Bridge/HuggingFace/Output/Classification.php b/src/Platform/Bridge/HuggingFace/Output/Classification.php similarity index 74% rename from src/Bridge/HuggingFace/Output/Classification.php rename to src/Platform/Bridge/HuggingFace/Output/Classification.php index 8474f492..8e074eb3 100644 --- a/src/Bridge/HuggingFace/Output/Classification.php +++ b/src/Platform/Bridge/HuggingFace/Output/Classification.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace\Output; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Output; final readonly class Classification { diff --git a/src/Bridge/HuggingFace/Output/ClassificationResult.php b/src/Platform/Bridge/HuggingFace/Output/ClassificationResult.php similarity index 89% rename from src/Bridge/HuggingFace/Output/ClassificationResult.php rename to src/Platform/Bridge/HuggingFace/Output/ClassificationResult.php index 0043e3f4..e77dd5f5 100644 --- a/src/Bridge/HuggingFace/Output/ClassificationResult.php +++ b/src/Platform/Bridge/HuggingFace/Output/ClassificationResult.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace\Output; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Output; final class ClassificationResult { diff --git a/src/Bridge/HuggingFace/Output/DetectedObject.php b/src/Platform/Bridge/HuggingFace/Output/DetectedObject.php similarity index 82% rename from src/Bridge/HuggingFace/Output/DetectedObject.php rename to src/Platform/Bridge/HuggingFace/Output/DetectedObject.php index 199159ec..87348564 100644 --- a/src/Bridge/HuggingFace/Output/DetectedObject.php +++ b/src/Platform/Bridge/HuggingFace/Output/DetectedObject.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace\Output; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Output; final readonly class DetectedObject { diff --git a/src/Bridge/HuggingFace/Output/FillMaskResult.php b/src/Platform/Bridge/HuggingFace/Output/FillMaskResult.php similarity index 91% rename from src/Bridge/HuggingFace/Output/FillMaskResult.php rename to src/Platform/Bridge/HuggingFace/Output/FillMaskResult.php index 556482bf..e25a9a57 100644 --- a/src/Bridge/HuggingFace/Output/FillMaskResult.php +++ b/src/Platform/Bridge/HuggingFace/Output/FillMaskResult.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace\Output; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Output; final class FillMaskResult { diff --git a/src/Bridge/HuggingFace/Output/ImageSegment.php b/src/Platform/Bridge/HuggingFace/Output/ImageSegment.php similarity index 65% rename from src/Bridge/HuggingFace/Output/ImageSegment.php rename to src/Platform/Bridge/HuggingFace/Output/ImageSegment.php index f3764589..13edfd7a 100644 --- a/src/Bridge/HuggingFace/Output/ImageSegment.php +++ b/src/Platform/Bridge/HuggingFace/Output/ImageSegment.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace\Output; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Output; final readonly class ImageSegment { public function __construct( public string $label, - public float $score, + public ?float $score, public string $mask, ) { } diff --git a/src/Bridge/HuggingFace/Output/ImageSegmentationResult.php b/src/Platform/Bridge/HuggingFace/Output/ImageSegmentationResult.php similarity index 89% rename from src/Bridge/HuggingFace/Output/ImageSegmentationResult.php rename to src/Platform/Bridge/HuggingFace/Output/ImageSegmentationResult.php index b087d1fd..012687ec 100644 --- a/src/Bridge/HuggingFace/Output/ImageSegmentationResult.php +++ b/src/Platform/Bridge/HuggingFace/Output/ImageSegmentationResult.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace\Output; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Output; final class ImageSegmentationResult { diff --git a/src/Bridge/HuggingFace/Output/MaskFill.php b/src/Platform/Bridge/HuggingFace/Output/MaskFill.php similarity index 79% rename from src/Bridge/HuggingFace/Output/MaskFill.php rename to src/Platform/Bridge/HuggingFace/Output/MaskFill.php index 56c48346..22655b8c 100644 --- a/src/Bridge/HuggingFace/Output/MaskFill.php +++ b/src/Platform/Bridge/HuggingFace/Output/MaskFill.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace\Output; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Output; final readonly class MaskFill { diff --git a/src/Bridge/HuggingFace/Output/ObjectDetectionResult.php b/src/Platform/Bridge/HuggingFace/Output/ObjectDetectionResult.php similarity index 92% rename from src/Bridge/HuggingFace/Output/ObjectDetectionResult.php rename to src/Platform/Bridge/HuggingFace/Output/ObjectDetectionResult.php index 727933e5..25fd084e 100644 --- a/src/Bridge/HuggingFace/Output/ObjectDetectionResult.php +++ b/src/Platform/Bridge/HuggingFace/Output/ObjectDetectionResult.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace\Output; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Output; final class ObjectDetectionResult { diff --git a/src/Bridge/HuggingFace/Output/QuestionAnsweringResult.php b/src/Platform/Bridge/HuggingFace/Output/QuestionAnsweringResult.php similarity index 90% rename from src/Bridge/HuggingFace/Output/QuestionAnsweringResult.php rename to src/Platform/Bridge/HuggingFace/Output/QuestionAnsweringResult.php index 49dbdecf..412188a6 100644 --- a/src/Bridge/HuggingFace/Output/QuestionAnsweringResult.php +++ b/src/Platform/Bridge/HuggingFace/Output/QuestionAnsweringResult.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace\Output; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Output; final readonly class QuestionAnsweringResult { diff --git a/src/Bridge/HuggingFace/Output/SentenceSimilarityResult.php b/src/Platform/Bridge/HuggingFace/Output/SentenceSimilarityResult.php similarity index 85% rename from src/Bridge/HuggingFace/Output/SentenceSimilarityResult.php rename to src/Platform/Bridge/HuggingFace/Output/SentenceSimilarityResult.php index e3911514..7511f640 100644 --- a/src/Bridge/HuggingFace/Output/SentenceSimilarityResult.php +++ b/src/Platform/Bridge/HuggingFace/Output/SentenceSimilarityResult.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace\Output; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Output; final readonly class SentenceSimilarityResult { diff --git a/src/Bridge/HuggingFace/Output/TableQuestionAnsweringResult.php b/src/Platform/Bridge/HuggingFace/Output/TableQuestionAnsweringResult.php similarity index 91% rename from src/Bridge/HuggingFace/Output/TableQuestionAnsweringResult.php rename to src/Platform/Bridge/HuggingFace/Output/TableQuestionAnsweringResult.php index 1121ea4a..2538b3ae 100644 --- a/src/Bridge/HuggingFace/Output/TableQuestionAnsweringResult.php +++ b/src/Platform/Bridge/HuggingFace/Output/TableQuestionAnsweringResult.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace\Output; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Output; final readonly class TableQuestionAnsweringResult { diff --git a/src/Bridge/HuggingFace/Output/Token.php b/src/Platform/Bridge/HuggingFace/Output/Token.php similarity index 80% rename from src/Bridge/HuggingFace/Output/Token.php rename to src/Platform/Bridge/HuggingFace/Output/Token.php index a1161431..c8ea5372 100644 --- a/src/Bridge/HuggingFace/Output/Token.php +++ b/src/Platform/Bridge/HuggingFace/Output/Token.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace\Output; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Output; final readonly class Token { diff --git a/src/Bridge/HuggingFace/Output/TokenClassificationResult.php b/src/Platform/Bridge/HuggingFace/Output/TokenClassificationResult.php similarity index 91% rename from src/Bridge/HuggingFace/Output/TokenClassificationResult.php rename to src/Platform/Bridge/HuggingFace/Output/TokenClassificationResult.php index a6398b89..f3df8c11 100644 --- a/src/Bridge/HuggingFace/Output/TokenClassificationResult.php +++ b/src/Platform/Bridge/HuggingFace/Output/TokenClassificationResult.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace\Output; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Output; final class TokenClassificationResult { diff --git a/src/Bridge/HuggingFace/Output/ZeroShotClassificationResult.php b/src/Platform/Bridge/HuggingFace/Output/ZeroShotClassificationResult.php similarity index 90% rename from src/Bridge/HuggingFace/Output/ZeroShotClassificationResult.php rename to src/Platform/Bridge/HuggingFace/Output/ZeroShotClassificationResult.php index 462e6514..eb57bd22 100644 --- a/src/Bridge/HuggingFace/Output/ZeroShotClassificationResult.php +++ b/src/Platform/Bridge/HuggingFace/Output/ZeroShotClassificationResult.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace\Output; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Output; final class ZeroShotClassificationResult { diff --git a/src/Platform/Bridge/HuggingFace/PlatformFactory.php b/src/Platform/Bridge/HuggingFace/PlatformFactory.php new file mode 100644 index 00000000..12526d6c --- /dev/null +++ b/src/Platform/Bridge/HuggingFace/PlatformFactory.php @@ -0,0 +1,33 @@ +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/Bridge/HuggingFace/Task.php b/src/Platform/Bridge/HuggingFace/Task.php similarity index 95% rename from src/Bridge/HuggingFace/Task.php rename to src/Platform/Bridge/HuggingFace/Task.php index ca4c2d0f..0899feeb 100644 --- a/src/Bridge/HuggingFace/Task.php +++ b/src/Platform/Bridge/HuggingFace/Task.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\HuggingFace; +namespace PhpLlm\LlmChain\Platform\Bridge\HuggingFace; interface Task { diff --git a/src/Platform/Bridge/Meta/Contract/MessageBagNormalizer.php b/src/Platform/Bridge/Meta/Contract/MessageBagNormalizer.php new file mode 100644 index 00000000..3b8b9138 --- /dev/null +++ b/src/Platform/Bridge/Meta/Contract/MessageBagNormalizer.php @@ -0,0 +1,41 @@ + $this->promptConverter->convertToPrompt($data), + ]; + } +} diff --git a/src/Platform/Bridge/Meta/Llama.php b/src/Platform/Bridge/Meta/Llama.php new file mode 100644 index 00000000..ab77a851 --- /dev/null +++ b/src/Platform/Bridge/Meta/Llama.php @@ -0,0 +1,40 @@ + $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/Bridge/Meta/LlamaPromptConverter.php b/src/Platform/Bridge/Meta/LlamaPromptConverter.php similarity index 76% rename from src/Bridge/Meta/LlamaPromptConverter.php rename to src/Platform/Bridge/Meta/LlamaPromptConverter.php index ed6d0955..c9b9033a 100644 --- a/src/Bridge/Meta/LlamaPromptConverter.php +++ b/src/Platform/Bridge/Meta/LlamaPromptConverter.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Meta; +namespace PhpLlm\LlmChain\Platform\Bridge\Meta; -use PhpLlm\LlmChain\Exception\RuntimeException; -use PhpLlm\LlmChain\Model\Message\AssistantMessage; -use PhpLlm\LlmChain\Model\Message\Content\ImageUrl; -use PhpLlm\LlmChain\Model\Message\Content\Text; -use PhpLlm\LlmChain\Model\Message\MessageBagInterface; -use PhpLlm\LlmChain\Model\Message\SystemMessage; -use PhpLlm\LlmChain\Model\Message\UserMessage; +use PhpLlm\LlmChain\Platform\Exception\RuntimeException; +use PhpLlm\LlmChain\Platform\Message\AssistantMessage; +use PhpLlm\LlmChain\Platform\Message\Content\ImageUrl; +use PhpLlm\LlmChain\Platform\Message\Content\Text; +use PhpLlm\LlmChain\Platform\Message\MessageBagInterface; +use PhpLlm\LlmChain\Platform\Message\SystemMessage; +use PhpLlm\LlmChain\Platform\Message\UserMessage; final class LlamaPromptConverter { @@ -25,7 +25,7 @@ public function convertToPrompt(MessageBagInterface $messageBag): string $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|>'; + 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 @@ -51,7 +51,7 @@ public function convertMessage(UserMessage|SystemMessage|AssistantMessage $messa } // Handling of UserMessage - $count = count($message->content); + $count = \count($message->content); $contentParts = []; if ($count > 1) { @@ -77,7 +77,7 @@ public function convertMessage(UserMessage|SystemMessage|AssistantMessage $messa throw new RuntimeException('Unsupported message type.'); } - $content = implode(PHP_EOL, $contentParts); + $content = implode(\PHP_EOL, $contentParts); return trim(<<{$message->getRole()->value}<|end_header_id|> diff --git a/src/Platform/Bridge/Ollama/LlamaModelHandler.php b/src/Platform/Bridge/Ollama/LlamaModelHandler.php new file mode 100644 index 00000000..ee0038d7 --- /dev/null +++ b/src/Platform/Bridge/Ollama/LlamaModelHandler.php @@ -0,0 +1,55 @@ +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/Bridge/Ollama/PlatformFactory.php b/src/Platform/Bridge/Ollama/PlatformFactory.php similarity index 86% rename from src/Bridge/Ollama/PlatformFactory.php rename to src/Platform/Bridge/Ollama/PlatformFactory.php index 00c663e4..f557696f 100644 --- a/src/Bridge/Ollama/PlatformFactory.php +++ b/src/Platform/Bridge/Ollama/PlatformFactory.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Ollama; +namespace PhpLlm\LlmChain\Platform\Bridge\Ollama; -use PhpLlm\LlmChain\Platform; +use PhpLlm\LlmChain\Platform\Platform; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; diff --git a/src/Platform/Bridge/OpenAI/DallE.php b/src/Platform/Bridge/OpenAI/DallE.php new file mode 100644 index 00000000..1f7b2c68 --- /dev/null +++ b/src/Platform/Bridge/OpenAI/DallE.php @@ -0,0 +1,25 @@ + $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/Bridge/OpenAI/DallE/Base64Image.php b/src/Platform/Bridge/OpenAI/DallE/Base64Image.php similarity index 83% rename from src/Bridge/OpenAI/DallE/Base64Image.php rename to src/Platform/Bridge/OpenAI/DallE/Base64Image.php index d1f8c902..c410020d 100644 --- a/src/Bridge/OpenAI/DallE/Base64Image.php +++ b/src/Platform/Bridge/OpenAI/DallE/Base64Image.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\OpenAI\DallE; +namespace PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE; use Webmozart\Assert\Assert; diff --git a/src/Bridge/OpenAI/DallE/ImageResponse.php b/src/Platform/Bridge/OpenAI/DallE/ImageResponse.php similarity index 75% rename from src/Bridge/OpenAI/DallE/ImageResponse.php rename to src/Platform/Bridge/OpenAI/DallE/ImageResponse.php index eabece9d..6ec59f4c 100644 --- a/src/Bridge/OpenAI/DallE/ImageResponse.php +++ b/src/Platform/Bridge/OpenAI/DallE/ImageResponse.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\OpenAI\DallE; +namespace PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE; -use PhpLlm\LlmChain\Model\Response\BaseResponse; +use PhpLlm\LlmChain\Platform\Response\BaseResponse; class ImageResponse extends BaseResponse { @@ -15,7 +15,7 @@ public function __construct( public ?string $revisedPrompt = null, // Only string on Dall-E 3 usage Base64Image|UrlImage ...$images, ) { - $this->images = \array_values($images); + $this->images = array_values($images); } /** diff --git a/src/Bridge/OpenAI/DallE/ModelClient.php b/src/Platform/Bridge/OpenAI/DallE/ModelClient.php similarity index 70% rename from src/Bridge/OpenAI/DallE/ModelClient.php rename to src/Platform/Bridge/OpenAI/DallE/ModelClient.php index 39552d94..fa1d22b3 100644 --- a/src/Bridge/OpenAI/DallE/ModelClient.php +++ b/src/Platform/Bridge/OpenAI/DallE/ModelClient.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\OpenAI\DallE; +namespace PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE; -use PhpLlm\LlmChain\Bridge\OpenAI\DallE; -use PhpLlm\LlmChain\Exception\RuntimeException; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Model\Response\ResponseInterface as LlmResponse; -use PhpLlm\LlmChain\Platform\ModelClient as PlatformResponseFactory; -use PhpLlm\LlmChain\Platform\ResponseConverter as PlatformResponseConverter; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE; +use PhpLlm\LlmChain\Platform\Exception\RuntimeException; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\ModelClientInterface as PlatformResponseFactory; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface as LlmResponse; +use PhpLlm\LlmChain\Platform\ResponseConverterInterface as PlatformResponseConverter; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; use Webmozart\Assert\Assert; @@ -28,18 +28,18 @@ public function __construct( Assert::startsWith($apiKey, 'sk-', 'The API key must start with "sk-".'); } - public function supports(Model $model, array|string|object $input): bool + public function supports(Model $model): bool { return $model instanceof DallE; } - public function request(Model $model, object|array|string $input, array $options = []): HttpResponse + 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, [ + 'json' => array_merge($options, [ 'model' => $model->getName(), - 'prompt' => $input, + 'prompt' => $payload, ]), ]); } diff --git a/src/Bridge/OpenAI/DallE/UrlImage.php b/src/Platform/Bridge/OpenAI/DallE/UrlImage.php similarity index 81% rename from src/Bridge/OpenAI/DallE/UrlImage.php rename to src/Platform/Bridge/OpenAI/DallE/UrlImage.php index a9b2d8d4..8532f96d 100644 --- a/src/Bridge/OpenAI/DallE/UrlImage.php +++ b/src/Platform/Bridge/OpenAI/DallE/UrlImage.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\OpenAI\DallE; +namespace PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE; use Webmozart\Assert\Assert; diff --git a/src/Platform/Bridge/OpenAI/Embeddings.php b/src/Platform/Bridge/OpenAI/Embeddings.php new file mode 100644 index 00000000..8aee12f2 --- /dev/null +++ b/src/Platform/Bridge/OpenAI/Embeddings.php @@ -0,0 +1,22 @@ + $options + */ + public function __construct(string $name = self::TEXT_3_SMALL, array $options = []) + { + parent::__construct($name, [], $options); + } +} diff --git a/src/Bridge/OpenAI/Embeddings/ModelClient.php b/src/Platform/Bridge/OpenAI/Embeddings/ModelClient.php similarity index 63% rename from src/Bridge/OpenAI/Embeddings/ModelClient.php rename to src/Platform/Bridge/OpenAI/Embeddings/ModelClient.php index a1bbbd83..c4834afe 100644 --- a/src/Bridge/OpenAI/Embeddings/ModelClient.php +++ b/src/Platform/Bridge/OpenAI/Embeddings/ModelClient.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\OpenAI\Embeddings; +namespace PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings; -use PhpLlm\LlmChain\Bridge\OpenAI\Embeddings; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Platform\ModelClient as PlatformResponseFactory; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\ModelClientInterface as PlatformResponseFactory; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; use Webmozart\Assert\Assert; @@ -22,18 +22,18 @@ public function __construct( Assert::startsWith($apiKey, 'sk-', 'The API key must start with "sk-".'); } - public function supports(Model $model, array|string|object $input): bool + public function supports(Model $model): bool { return $model instanceof Embeddings; } - public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface + 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($model->getOptions(), $options, [ + 'json' => array_merge($options, [ 'model' => $model->getName(), - 'input' => $input, + 'input' => $payload, ]), ]); } diff --git a/src/Bridge/OpenAI/Embeddings/ResponseConverter.php b/src/Platform/Bridge/OpenAI/Embeddings/ResponseConverter.php similarity index 58% rename from src/Bridge/OpenAI/Embeddings/ResponseConverter.php rename to src/Platform/Bridge/OpenAI/Embeddings/ResponseConverter.php index d158ad97..0a8d5b15 100644 --- a/src/Bridge/OpenAI/Embeddings/ResponseConverter.php +++ b/src/Platform/Bridge/OpenAI/Embeddings/ResponseConverter.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\OpenAI\Embeddings; +namespace PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings; -use PhpLlm\LlmChain\Bridge\OpenAI\Embeddings; -use PhpLlm\LlmChain\Document\Vector; -use PhpLlm\LlmChain\Exception\RuntimeException; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Model\Response\VectorResponse; -use PhpLlm\LlmChain\Platform\ResponseConverter as PlatformResponseConverter; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings; +use PhpLlm\LlmChain\Platform\Exception\RuntimeException; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\Response\VectorResponse; +use PhpLlm\LlmChain\Platform\ResponseConverterInterface as PlatformResponseConverter; +use PhpLlm\LlmChain\Platform\Vector\Vector; use Symfony\Contracts\HttpClient\ResponseInterface; final class ResponseConverter implements PlatformResponseConverter { - public function supports(Model $model, array|string|object $input): bool + public function supports(Model $model): bool { return $model instanceof Embeddings; } @@ -28,7 +28,7 @@ public function convert(ResponseInterface $response, array $options = []): Vecto } return new VectorResponse( - ...\array_map( + ...array_map( static fn (array $item): Vector => new Vector($item['embedding']), $data['data'] ), diff --git a/src/Platform/Bridge/OpenAI/GPT.php b/src/Platform/Bridge/OpenAI/GPT.php new file mode 100644 index 00000000..76fcd41f --- /dev/null +++ b/src/Platform/Bridge/OpenAI/GPT.php @@ -0,0 +1,79 @@ + $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/Bridge/OpenAI/GPT/ModelClient.php b/src/Platform/Bridge/OpenAI/GPT/ModelClient.php similarity index 62% rename from src/Bridge/OpenAI/GPT/ModelClient.php rename to src/Platform/Bridge/OpenAI/GPT/ModelClient.php index 433d3d79..f4906d15 100644 --- a/src/Bridge/OpenAI/GPT/ModelClient.php +++ b/src/Platform/Bridge/OpenAI/GPT/ModelClient.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\OpenAI\GPT; +namespace PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT; -use PhpLlm\LlmChain\Bridge\OpenAI\GPT; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Platform\ModelClient as PlatformResponseFactory; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\ModelClientInterface as PlatformResponseFactory; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; @@ -18,26 +18,24 @@ public function __construct( HttpClientInterface $httpClient, - #[\SensitiveParameter] private string $apiKey, + #[\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, array|string|object $input): bool + public function supports(Model $model): bool { return $model instanceof GPT; } - public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface + 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, [ - 'model' => $model->getName(), - 'messages' => $input, - ]), + 'json' => array_merge($options, $payload), ]); } } diff --git a/src/Bridge/OpenAI/GPT/ResponseConverter.php b/src/Platform/Bridge/OpenAI/GPT/ResponseConverter.php similarity index 79% rename from src/Bridge/OpenAI/GPT/ResponseConverter.php rename to src/Platform/Bridge/OpenAI/GPT/ResponseConverter.php index 247003e5..d69f6992 100644 --- a/src/Bridge/OpenAI/GPT/ResponseConverter.php +++ b/src/Platform/Bridge/OpenAI/GPT/ResponseConverter.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\OpenAI\GPT; - -use PhpLlm\LlmChain\Bridge\OpenAI\GPT; -use PhpLlm\LlmChain\Exception\ContentFilterException; -use PhpLlm\LlmChain\Exception\RuntimeException; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Model\Response\Choice; -use PhpLlm\LlmChain\Model\Response\ChoiceResponse; -use PhpLlm\LlmChain\Model\Response\ResponseInterface as LlmResponse; -use PhpLlm\LlmChain\Model\Response\StreamResponse; -use PhpLlm\LlmChain\Model\Response\TextResponse; -use PhpLlm\LlmChain\Model\Response\ToolCall; -use PhpLlm\LlmChain\Model\Response\ToolCallResponse; -use PhpLlm\LlmChain\Platform\ResponseConverter as PlatformResponseConverter; +namespace PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT; + +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT; +use PhpLlm\LlmChain\Platform\Exception\ContentFilterException; +use PhpLlm\LlmChain\Platform\Exception\RuntimeException; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\Response\Choice; +use PhpLlm\LlmChain\Platform\Response\ChoiceResponse; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface as LlmResponse; +use PhpLlm\LlmChain\Platform\Response\StreamResponse; +use PhpLlm\LlmChain\Platform\Response\TextResponse; +use PhpLlm\LlmChain\Platform\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Response\ToolCallResponse; +use PhpLlm\LlmChain\Platform\ResponseConverterInterface as PlatformResponseConverter; use Symfony\Component\HttpClient\Chunk\ServerSentEvent; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Component\HttpClient\Exception\JsonException; @@ -24,7 +24,7 @@ final class ResponseConverter implements PlatformResponseConverter { - public function supports(Model $model, array|string|object $input): bool + public function supports(Model $model): bool { return $model instanceof GPT; } @@ -52,9 +52,9 @@ public function convert(HttpResponse $response, array $options = []): LlmRespons } /** @var Choice[] $choices */ - $choices = \array_map($this->convertChoice(...), $data['choices']); + $choices = array_map($this->convertChoice(...), $data['choices']); - if (1 !== count($choices)) { + if (1 !== \count($choices)) { return new ChoiceResponse(...$choices); } @@ -85,7 +85,7 @@ private function convertStream(HttpResponse $response): \Generator } if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) { - yield new ToolCallResponse(...\array_map($this->convertToolCall(...), $toolCalls)); + yield new ToolCallResponse(...array_map($this->convertToolCall(...), $toolCalls)); } if (!isset($data['choices'][0]['delta']['content'])) { @@ -164,14 +164,14 @@ private function isToolCallsStreamFinished(array $data): bool private function convertChoice(array $choice): Choice { if ('tool_calls' === $choice['finish_reason']) { - return new Choice(toolCalls: \array_map([$this, 'convertToolCall'], $choice['message']['tool_calls'])); + return new Choice(toolCalls: array_map([$this, 'convertToolCall'], $choice['message']['tool_calls'])); } - if (in_array($choice['finish_reason'], ['stop', 'length'], true)) { + 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'])); + throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason'])); } /** @@ -186,7 +186,7 @@ private function convertChoice(array $choice): Choice */ private function convertToolCall(array $toolCall): ToolCall { - $arguments = json_decode($toolCall['function']['arguments'], true, JSON_THROW_ON_ERROR); + $arguments = json_decode($toolCall['function']['arguments'], true, \JSON_THROW_ON_ERROR); return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments); } diff --git a/src/Bridge/OpenAI/PlatformFactory.php b/src/Platform/Bridge/OpenAI/PlatformFactory.php similarity index 54% rename from src/Bridge/OpenAI/PlatformFactory.php rename to src/Platform/Bridge/OpenAI/PlatformFactory.php index dbaf8dfc..28cace51 100644 --- a/src/Bridge/OpenAI/PlatformFactory.php +++ b/src/Platform/Bridge/OpenAI/PlatformFactory.php @@ -2,16 +2,18 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\OpenAI; +namespace PhpLlm\LlmChain\Platform\Bridge\OpenAI; -use PhpLlm\LlmChain\Bridge\OpenAI\DallE\ModelClient as DallEModelClient; -use PhpLlm\LlmChain\Bridge\OpenAI\Embeddings\ModelClient as EmbeddingsModelClient; -use PhpLlm\LlmChain\Bridge\OpenAI\Embeddings\ResponseConverter as EmbeddingsResponseConverter; -use PhpLlm\LlmChain\Bridge\OpenAI\GPT\ModelClient as GPTModelClient; -use PhpLlm\LlmChain\Bridge\OpenAI\GPT\ResponseConverter as GPTResponseConverter; -use PhpLlm\LlmChain\Bridge\OpenAI\Whisper\ModelClient as WhisperModelClient; -use PhpLlm\LlmChain\Bridge\OpenAI\Whisper\ResponseConverter as WhisperResponseConverter; -use PhpLlm\LlmChain\Platform; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE\ModelClient as DallEModelClient; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings\ModelClient as EmbeddingsModelClient; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings\ResponseConverter as EmbeddingsResponseConverter; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT\ModelClient as GPTModelClient; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT\ResponseConverter as GPTResponseConverter; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Whisper\AudioNormalizer; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Whisper\ModelClient as WhisperModelClient; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Whisper\ResponseConverter as WhisperResponseConverter; +use PhpLlm\LlmChain\Platform\Contract; +use PhpLlm\LlmChain\Platform\Platform; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -39,6 +41,7 @@ public static function create( $dallEModelClient, new WhisperResponseConverter(), ], + Contract::create(new AudioNormalizer()), ); } } diff --git a/src/Bridge/OpenAI/TokenOutputProcessor.php b/src/Platform/Bridge/OpenAI/TokenOutputProcessor.php similarity index 82% rename from src/Bridge/OpenAI/TokenOutputProcessor.php rename to src/Platform/Bridge/OpenAI/TokenOutputProcessor.php index 69089b5b..2bf0bd64 100644 --- a/src/Bridge/OpenAI/TokenOutputProcessor.php +++ b/src/Platform/Bridge/OpenAI/TokenOutputProcessor.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\OpenAI; +namespace PhpLlm\LlmChain\Platform\Bridge\OpenAI; use PhpLlm\LlmChain\Chain\Output; -use PhpLlm\LlmChain\Chain\OutputProcessor; -use PhpLlm\LlmChain\Model\Response\StreamResponse; +use PhpLlm\LlmChain\Chain\OutputProcessorInterface; +use PhpLlm\LlmChain\Platform\Response\StreamResponse; -final class TokenOutputProcessor implements OutputProcessor +final class TokenOutputProcessor implements OutputProcessorInterface { public function processOutput(Output $output): void { diff --git a/src/Platform/Bridge/OpenAI/Whisper.php b/src/Platform/Bridge/OpenAI/Whisper.php new file mode 100644 index 00000000..449e25da --- /dev/null +++ b/src/Platform/Bridge/OpenAI/Whisper.php @@ -0,0 +1,26 @@ + $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/Bridge/OpenAI/Whisper/AudioNormalizer.php b/src/Platform/Bridge/OpenAI/Whisper/AudioNormalizer.php new file mode 100644 index 00000000..3ab1082c --- /dev/null +++ b/src/Platform/Bridge/OpenAI/Whisper/AudioNormalizer.php @@ -0,0 +1,38 @@ + 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/Bridge/OpenAI/Whisper/ModelClient.php b/src/Platform/Bridge/OpenAI/Whisper/ModelClient.php similarity index 50% rename from src/Bridge/OpenAI/Whisper/ModelClient.php rename to src/Platform/Bridge/OpenAI/Whisper/ModelClient.php index ca512b4b..e88ac07e 100644 --- a/src/Bridge/OpenAI/Whisper/ModelClient.php +++ b/src/Platform/Bridge/OpenAI/Whisper/ModelClient.php @@ -2,12 +2,11 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\OpenAI\Whisper; +namespace PhpLlm\LlmChain\Platform\Bridge\OpenAI\Whisper; -use PhpLlm\LlmChain\Bridge\OpenAI\Whisper; -use PhpLlm\LlmChain\Model\Message\Content\Audio; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Platform\ModelClient as BaseModelClient; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Whisper; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\ModelClientInterface as BaseModelClient; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; use Webmozart\Assert\Assert; @@ -22,22 +21,17 @@ public function __construct( Assert::stringNotEmpty($apiKey, 'The API key must not be empty.'); } - public function supports(Model $model, object|array|string $input): bool + public function supports(Model $model): bool { - return $model instanceof Whisper && $input instanceof Audio; + return $model instanceof Whisper; } - public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface { - assert($input instanceof Audio); - 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, $model->getOptions(), [ - 'model' => $model->getName(), - 'file' => $input->asResource(), - ]), + 'body' => array_merge($options, $payload, ['model' => $model->getName()]), ]); } } diff --git a/src/Platform/Bridge/OpenAI/Whisper/ResponseConverter.php b/src/Platform/Bridge/OpenAI/Whisper/ResponseConverter.php new file mode 100644 index 00000000..c2af16e3 --- /dev/null +++ b/src/Platform/Bridge/OpenAI/Whisper/ResponseConverter.php @@ -0,0 +1,27 @@ +toArray(); + + return new TextResponse($data['text']); + } +} diff --git a/src/Bridge/OpenRouter/Client.php b/src/Platform/Bridge/OpenRouter/Client.php similarity index 61% rename from src/Bridge/OpenRouter/Client.php rename to src/Platform/Bridge/OpenRouter/Client.php index 13862385..6fdfb99e 100644 --- a/src/Bridge/OpenRouter/Client.php +++ b/src/Platform/Bridge/OpenRouter/Client.php @@ -2,21 +2,20 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\OpenRouter; - -use PhpLlm\LlmChain\Exception\RuntimeException; -use PhpLlm\LlmChain\Model\Message\MessageBagInterface; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Model\Response\ResponseInterface as LlmResponse; -use PhpLlm\LlmChain\Model\Response\TextResponse; -use PhpLlm\LlmChain\Platform\ModelClient; -use PhpLlm\LlmChain\Platform\ResponseConverter; +namespace PhpLlm\LlmChain\Platform\Bridge\OpenRouter; + +use PhpLlm\LlmChain\Platform\Exception\RuntimeException; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\ModelClientInterface; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface as LlmResponse; +use PhpLlm\LlmChain\Platform\Response\TextResponse; +use PhpLlm\LlmChain\Platform\ResponseConverterInterface; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; use Webmozart\Assert\Assert; -final readonly class Client implements ModelClient, ResponseConverter +final readonly class Client implements ModelClientInterface, ResponseConverterInterface { private EventSourceHttpClient $httpClient; @@ -29,24 +28,23 @@ public function __construct( Assert::startsWith($apiKey, 'sk-', 'The API key must start with "sk-".'); } - public function supports(Model $model, array|string|object $input): bool + public function supports(Model $model): bool { - return $input instanceof MessageBagInterface && $model instanceof GenericModel; + return true; } - public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface + 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, [ - 'model' => $model->getName(), - 'messages' => $input, - ]), + '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'])) { diff --git a/src/Platform/Bridge/OpenRouter/PlatformFactory.php b/src/Platform/Bridge/OpenRouter/PlatformFactory.php new file mode 100644 index 00000000..1f78b137 --- /dev/null +++ b/src/Platform/Bridge/OpenRouter/PlatformFactory.php @@ -0,0 +1,31 @@ +httpClient->request('POST', $url, [ 'headers' => ['Content-Type' => 'application/json'], @@ -32,7 +32,7 @@ public function request(string $model, string $endpoint, array $body): ResponseI ]); $data = $response->toArray(); - while (!in_array($data['status'], ['succeeded', 'failed', 'canceled'], true)) { + 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']); @@ -44,7 +44,7 @@ public function request(string $model, string $endpoint, array $body): ResponseI private function getResponse(string $id): ResponseInterface { - $url = sprintf('https://api.replicate.com/v1/predictions/%s', $id); + $url = \sprintf('https://api.replicate.com/v1/predictions/%s', $id); return $this->httpClient->request('GET', $url, [ 'headers' => ['Content-Type' => 'application/json'], diff --git a/src/Platform/Bridge/Replicate/Contract/LlamaMessageBagNormalizer.php b/src/Platform/Bridge/Replicate/Contract/LlamaMessageBagNormalizer.php new file mode 100644 index 00000000..2c03b14c --- /dev/null +++ b/src/Platform/Bridge/Replicate/Contract/LlamaMessageBagNormalizer.php @@ -0,0 +1,43 @@ + $this->promptConverter->convertMessage($data->getSystemMessage() ?? new SystemMessage('')), + 'prompt' => $this->promptConverter->convertToPrompt($data->withoutSystemMessage()), + ]; + } +} diff --git a/src/Platform/Bridge/Replicate/LlamaModelClient.php b/src/Platform/Bridge/Replicate/LlamaModelClient.php new file mode 100644 index 00000000..8782cd3c --- /dev/null +++ b/src/Platform/Bridge/Replicate/LlamaModelClient.php @@ -0,0 +1,31 @@ +client->request(\sprintf('meta/meta-%s', $model->getName()), 'predictions', $payload); + } +} diff --git a/src/Platform/Bridge/Replicate/LlamaResponseConverter.php b/src/Platform/Bridge/Replicate/LlamaResponseConverter.php new file mode 100644 index 00000000..97d986b6 --- /dev/null +++ b/src/Platform/Bridge/Replicate/LlamaResponseConverter.php @@ -0,0 +1,32 @@ +toArray(); + + if (!isset($data['output'])) { + throw new RuntimeException('Response does not contain output'); + } + + return new TextResponse(implode('', $data['output'])); + } +} diff --git a/src/Bridge/Replicate/PlatformFactory.php b/src/Platform/Bridge/Replicate/PlatformFactory.php similarity index 66% rename from src/Bridge/Replicate/PlatformFactory.php rename to src/Platform/Bridge/Replicate/PlatformFactory.php index 2e2acb0b..13faf41b 100644 --- a/src/Bridge/Replicate/PlatformFactory.php +++ b/src/Platform/Bridge/Replicate/PlatformFactory.php @@ -2,9 +2,11 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Replicate; +namespace PhpLlm\LlmChain\Platform\Bridge\Replicate; -use PhpLlm\LlmChain\Platform; +use PhpLlm\LlmChain\Platform\Bridge\Replicate\Contract\LlamaMessageBagNormalizer; +use PhpLlm\LlmChain\Platform\Contract; +use PhpLlm\LlmChain\Platform\Platform; use Symfony\Component\Clock\Clock; use Symfony\Component\HttpClient\HttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -19,6 +21,7 @@ public static function create( return new Platform( [new LlamaModelClient(new Client($httpClient ?? HttpClient::create(), new Clock(), $apiKey))], [new LlamaResponseConverter()], + Contract::create(new LlamaMessageBagNormalizer()), ); } } diff --git a/src/Bridge/TransformersPHP/Platform.php b/src/Platform/Bridge/TransformersPHP/Platform.php similarity index 69% rename from src/Bridge/TransformersPHP/Platform.php rename to src/Platform/Bridge/TransformersPHP/Platform.php index 3e110d24..e853808a 100644 --- a/src/Bridge/TransformersPHP/Platform.php +++ b/src/Platform/Bridge/TransformersPHP/Platform.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\TransformersPHP; +namespace PhpLlm\LlmChain\Platform\Bridge\TransformersPHP; use Codewithkyrian\Transformers\Pipelines\Task; -use PhpLlm\LlmChain\Exception\InvalidArgumentException; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Model\Response\ResponseInterface; -use PhpLlm\LlmChain\Model\Response\StructuredResponse; -use PhpLlm\LlmChain\Model\Response\TextResponse; -use PhpLlm\LlmChain\PlatformInterface; +use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\PlatformInterface; +use PhpLlm\LlmChain\Platform\Response\ObjectResponse; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface; +use PhpLlm\LlmChain\Platform\Response\TextResponse; use function Codewithkyrian\Transformers\Pipelines\pipeline; @@ -36,7 +36,7 @@ public function request(Model $model, object|array|string $input, array $options return match ($task) { Task::Text2TextGeneration => new TextResponse($data[0]['generated_text']), - default => new StructuredResponse($data), + default => new ObjectResponse($data), }; } } diff --git a/src/Bridge/TransformersPHP/PlatformFactory.php b/src/Platform/Bridge/TransformersPHP/PlatformFactory.php similarity index 78% rename from src/Bridge/TransformersPHP/PlatformFactory.php rename to src/Platform/Bridge/TransformersPHP/PlatformFactory.php index 04d2cae1..ca12ff68 100644 --- a/src/Bridge/TransformersPHP/PlatformFactory.php +++ b/src/Platform/Bridge/TransformersPHP/PlatformFactory.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\TransformersPHP; +namespace PhpLlm\LlmChain\Platform\Bridge\TransformersPHP; use Codewithkyrian\Transformers\Transformers; -use PhpLlm\LlmChain\Exception\RuntimeException; +use PhpLlm\LlmChain\Platform\Exception\RuntimeException; final readonly class PlatformFactory { diff --git a/src/Bridge/Voyage/ModelHandler.php b/src/Platform/Bridge/Voyage/ModelHandler.php similarity index 59% rename from src/Bridge/Voyage/ModelHandler.php rename to src/Platform/Bridge/Voyage/ModelHandler.php index 872191b8..965cbc5b 100644 --- a/src/Bridge/Voyage/ModelHandler.php +++ b/src/Platform/Bridge/Voyage/ModelHandler.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Voyage; - -use PhpLlm\LlmChain\Document\Vector; -use PhpLlm\LlmChain\Exception\RuntimeException; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Model\Response\ResponseInterface as LlmResponse; -use PhpLlm\LlmChain\Model\Response\VectorResponse; -use PhpLlm\LlmChain\Platform\ModelClient; -use PhpLlm\LlmChain\Platform\ResponseConverter; +namespace PhpLlm\LlmChain\Platform\Bridge\Voyage; + +use PhpLlm\LlmChain\Platform\Exception\RuntimeException; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\ModelClientInterface; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface as LlmResponse; +use PhpLlm\LlmChain\Platform\Response\VectorResponse; +use PhpLlm\LlmChain\Platform\ResponseConverterInterface; +use PhpLlm\LlmChain\Platform\Vector\Vector; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; -final readonly class ModelHandler implements ModelClient, ResponseConverter +final readonly class ModelHandler implements ModelClientInterface, ResponseConverterInterface { public function __construct( private HttpClientInterface $httpClient, @@ -22,18 +22,18 @@ public function __construct( ) { } - public function supports(Model $model, array|string|object $input): bool + public function supports(Model $model): bool { return $model instanceof Voyage; } - public function request(Model $model, object|string|array $input, array $options = []): ResponseInterface + 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' => $input, + 'input' => $payload, ], ]); } diff --git a/src/Bridge/Voyage/PlatformFactory.php b/src/Platform/Bridge/Voyage/PlatformFactory.php similarity index 86% rename from src/Bridge/Voyage/PlatformFactory.php rename to src/Platform/Bridge/Voyage/PlatformFactory.php index 1f8a4641..28fd2638 100644 --- a/src/Bridge/Voyage/PlatformFactory.php +++ b/src/Platform/Bridge/Voyage/PlatformFactory.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Voyage; +namespace PhpLlm\LlmChain\Platform\Bridge\Voyage; -use PhpLlm\LlmChain\Platform; +use PhpLlm\LlmChain\Platform\Platform; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; diff --git a/src/Platform/Bridge/Voyage/Voyage.php b/src/Platform/Bridge/Voyage/Voyage.php new file mode 100644 index 00000000..8dc58946 --- /dev/null +++ b/src/Platform/Bridge/Voyage/Voyage.php @@ -0,0 +1,26 @@ + $options + */ + public function __construct(string $name = self::V3, array $options = []) + { + parent::__construct($name, [Capability::INPUT_MULTIPLE], $options); + } +} diff --git a/src/Platform/Capability.php b/src/Platform/Capability.php new file mode 100644 index 00000000..f15fe4c5 --- /dev/null +++ b/src/Platform/Capability.php @@ -0,0 +1,26 @@ +|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/Chain/JsonSchema/Attribute/With.php b/src/Platform/Contract/JsonSchema/Attribute/With.php similarity index 74% rename from src/Chain/JsonSchema/Attribute/With.php rename to src/Platform/Contract/JsonSchema/Attribute/With.php index 1d66059a..fe01d2d9 100644 --- a/src/Chain/JsonSchema/Attribute/With.php +++ b/src/Platform/Contract/JsonSchema/Attribute/With.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Chain\JsonSchema\Attribute; +namespace PhpLlm\LlmChain\Platform\Contract\JsonSchema\Attribute; use Webmozart\Assert\Assert; @@ -43,95 +43,95 @@ public function __construct( public ?int $maxProperties = null, public ?bool $dependentRequired = null, ) { - if (is_array($enum)) { + if (\is_array($enum)) { Assert::allString($enum); } - if (is_string($const)) { + if (\is_string($const)) { Assert::stringNotEmpty(trim($const)); } - if (is_string($pattern)) { + if (\is_string($pattern)) { Assert::stringNotEmpty(trim($pattern)); } - if (is_int($minLength)) { + if (\is_int($minLength)) { Assert::greaterThanEq($minLength, 0); - if (is_int($maxLength)) { + if (\is_int($maxLength)) { Assert::greaterThanEq($maxLength, $minLength); } } - if (is_int($maxLength)) { + if (\is_int($maxLength)) { Assert::greaterThanEq($maxLength, 0); } - if (is_int($minimum)) { + if (\is_int($minimum)) { Assert::greaterThanEq($minimum, 0); - if (is_int($maximum)) { + if (\is_int($maximum)) { Assert::greaterThanEq($maximum, $minimum); } } - if (is_int($maximum)) { + if (\is_int($maximum)) { Assert::greaterThanEq($maximum, 0); } - if (is_int($multipleOf)) { + if (\is_int($multipleOf)) { Assert::greaterThanEq($multipleOf, 0); } - if (is_int($exclusiveMinimum)) { + if (\is_int($exclusiveMinimum)) { Assert::greaterThanEq($exclusiveMinimum, 0); - if (is_int($exclusiveMaximum)) { + if (\is_int($exclusiveMaximum)) { Assert::greaterThanEq($exclusiveMaximum, $exclusiveMinimum); } } - if (is_int($exclusiveMaximum)) { + if (\is_int($exclusiveMaximum)) { Assert::greaterThanEq($exclusiveMaximum, 0); } - if (is_int($minItems)) { + if (\is_int($minItems)) { Assert::greaterThanEq($minItems, 0); - if (is_int($maxItems)) { + if (\is_int($maxItems)) { Assert::greaterThanEq($maxItems, $minItems); } } - if (is_int($maxItems)) { + if (\is_int($maxItems)) { Assert::greaterThanEq($maxItems, 0); } - if (is_bool($uniqueItems)) { + if (\is_bool($uniqueItems)) { Assert::true($uniqueItems); } - if (is_int($minContains)) { + if (\is_int($minContains)) { Assert::greaterThanEq($minContains, 0); - if (is_int($maxContains)) { + if (\is_int($maxContains)) { Assert::greaterThanEq($maxContains, $minContains); } } - if (is_int($maxContains)) { + if (\is_int($maxContains)) { Assert::greaterThanEq($maxContains, 0); } - if (is_int($minProperties)) { + if (\is_int($minProperties)) { Assert::greaterThanEq($minProperties, 0); - if (is_int($maxProperties)) { + if (\is_int($maxProperties)) { Assert::greaterThanEq($maxProperties, $minProperties); } } - if (is_int($maxProperties)) { + if (\is_int($maxProperties)) { Assert::greaterThanEq($maxProperties, 0); } } diff --git a/src/Chain/JsonSchema/DescriptionParser.php b/src/Platform/Contract/JsonSchema/DescriptionParser.php similarity index 87% rename from src/Chain/JsonSchema/DescriptionParser.php rename to src/Platform/Contract/JsonSchema/DescriptionParser.php index 3759037d..865c0833 100644 --- a/src/Chain/JsonSchema/DescriptionParser.php +++ b/src/Platform/Contract/JsonSchema/DescriptionParser.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Chain\JsonSchema; +namespace PhpLlm\LlmChain\Platform\Contract\JsonSchema; final readonly class DescriptionParser { @@ -19,7 +19,7 @@ private function fromProperty(\ReflectionProperty $property): string { $comment = $property->getDocComment(); - if (is_string($comment) && preg_match('/@var\s+[a-zA-Z\\\\]+\s+((.*)(?=\*)|.*)/', $comment, $matches)) { + if (\is_string($comment) && preg_match('/@var\s+[a-zA-Z\\\\]+\s+((.*)(?=\*)|.*)/', $comment, $matches)) { return trim($matches[1]); } diff --git a/src/Chain/JsonSchema/Factory.php b/src/Platform/Contract/JsonSchema/Factory.php similarity index 90% rename from src/Chain/JsonSchema/Factory.php rename to src/Platform/Contract/JsonSchema/Factory.php index 5f6b9947..ebb7e5cf 100644 --- a/src/Chain/JsonSchema/Factory.php +++ b/src/Platform/Contract/JsonSchema/Factory.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Chain\JsonSchema; +namespace PhpLlm\LlmChain\Platform\Contract\JsonSchema; -use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With; -use PhpLlm\LlmChain\Exception\InvalidArgumentException; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\Attribute\With; +use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -81,7 +81,7 @@ public function buildProperties(string $className): ?array */ private function convertTypes(array $elements): ?array { - if (0 === count($elements)) { + if (0 === \count($elements)) { return null; } @@ -110,7 +110,7 @@ private function convertTypes(array $elements): ?array // Check for ToolParameter attributes $attributes = $element->getAttributes(With::class); - if (count($attributes) > 0) { + if (\count($attributes) > 0) { $attributeState = array_filter((array) $attributes[0]->newInstance(), fn ($value) => null !== $value); $schema = array_merge($schema, $attributeState); } @@ -137,11 +137,11 @@ private function getTypeSchema(Type $type): array return ['type' => 'boolean']; case $type->isIdentifiedBy(TypeIdentifier::ARRAY): - assert($type instanceof CollectionType); + \assert($type instanceof CollectionType); $collectionValueType = $type->getCollectionValueType(); if ($collectionValueType->isIdentifiedBy(TypeIdentifier::OBJECT)) { - assert($collectionValueType instanceof ObjectType); + \assert($collectionValueType instanceof ObjectType); return [ 'type' => 'array', @@ -158,8 +158,8 @@ private function getTypeSchema(Type $type): array 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)) { + \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 diff --git a/src/Platform/Contract/Normalizer/Message/AssistantMessageNormalizer.php b/src/Platform/Contract/Normalizer/Message/AssistantMessageNormalizer.php new file mode 100644 index 00000000..cf9f4d3c --- /dev/null +++ b/src/Platform/Contract/Normalizer/Message/AssistantMessageNormalizer.php @@ -0,0 +1,49 @@ + 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/Contract/Normalizer/Message/Content/AudioNormalizer.php b/src/Platform/Contract/Normalizer/Message/Content/AudioNormalizer.php new file mode 100644 index 00000000..6812d707 --- /dev/null +++ b/src/Platform/Contract/Normalizer/Message/Content/AudioNormalizer.php @@ -0,0 +1,46 @@ + 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/Contract/Normalizer/Message/Content/ImageNormalizer.php b/src/Platform/Contract/Normalizer/Message/Content/ImageNormalizer.php new file mode 100644 index 00000000..469378be --- /dev/null +++ b/src/Platform/Contract/Normalizer/Message/Content/ImageNormalizer.php @@ -0,0 +1,36 @@ + 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/Contract/Normalizer/Message/Content/ImageUrlNormalizer.php b/src/Platform/Contract/Normalizer/Message/Content/ImageUrlNormalizer.php new file mode 100644 index 00000000..718c1424 --- /dev/null +++ b/src/Platform/Contract/Normalizer/Message/Content/ImageUrlNormalizer.php @@ -0,0 +1,36 @@ + 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/Contract/Normalizer/Message/Content/TextNormalizer.php b/src/Platform/Contract/Normalizer/Message/Content/TextNormalizer.php new file mode 100644 index 00000000..4e7fb94d --- /dev/null +++ b/src/Platform/Contract/Normalizer/Message/Content/TextNormalizer.php @@ -0,0 +1,33 @@ + 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/Contract/Normalizer/Message/MessageBagNormalizer.php b/src/Platform/Contract/Normalizer/Message/MessageBagNormalizer.php new file mode 100644 index 00000000..c204975c --- /dev/null +++ b/src/Platform/Contract/Normalizer/Message/MessageBagNormalizer.php @@ -0,0 +1,50 @@ + 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/Contract/Normalizer/Message/SystemMessageNormalizer.php b/src/Platform/Contract/Normalizer/Message/SystemMessageNormalizer.php new file mode 100644 index 00000000..c9d82eea --- /dev/null +++ b/src/Platform/Contract/Normalizer/Message/SystemMessageNormalizer.php @@ -0,0 +1,36 @@ + 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/Contract/Normalizer/Message/ToolCallMessageNormalizer.php b/src/Platform/Contract/Normalizer/Message/ToolCallMessageNormalizer.php new file mode 100644 index 00000000..623dea64 --- /dev/null +++ b/src/Platform/Contract/Normalizer/Message/ToolCallMessageNormalizer.php @@ -0,0 +1,43 @@ + 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/Contract/Normalizer/Message/UserMessageNormalizer.php b/src/Platform/Contract/Normalizer/Message/UserMessageNormalizer.php new file mode 100644 index 00000000..c615114e --- /dev/null +++ b/src/Platform/Contract/Normalizer/Message/UserMessageNormalizer.php @@ -0,0 +1,48 @@ + 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/Contract/Normalizer/ModelContractNormalizer.php b/src/Platform/Contract/Normalizer/ModelContractNormalizer.php new file mode 100644 index 00000000..6d3fae5c --- /dev/null +++ b/src/Platform/Contract/Normalizer/ModelContractNormalizer.php @@ -0,0 +1,37 @@ +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/Contract/Normalizer/Response/ToolCallNormalizer.php b/src/Platform/Contract/Normalizer/Response/ToolCallNormalizer.php new file mode 100644 index 00000000..3793381a --- /dev/null +++ b/src/Platform/Contract/Normalizer/Response/ToolCallNormalizer.php @@ -0,0 +1,47 @@ + 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/Contract/Normalizer/ToolNormalizer.php b/src/Platform/Contract/Normalizer/ToolNormalizer.php new file mode 100644 index 00000000..166375e7 --- /dev/null +++ b/src/Platform/Contract/Normalizer/ToolNormalizer.php @@ -0,0 +1,54 @@ + 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/Exception/ContentFilterException.php b/src/Platform/Exception/ContentFilterException.php similarity index 68% rename from src/Exception/ContentFilterException.php rename to src/Platform/Exception/ContentFilterException.php index 5936222e..707f33f1 100644 --- a/src/Exception/ContentFilterException.php +++ b/src/Platform/Exception/ContentFilterException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Exception; +namespace PhpLlm\LlmChain\Platform\Exception; class ContentFilterException extends InvalidArgumentException { diff --git a/src/Platform/Exception/ExceptionInterface.php b/src/Platform/Exception/ExceptionInterface.php new file mode 100644 index 00000000..1cd57043 --- /dev/null +++ b/src/Platform/Exception/ExceptionInterface.php @@ -0,0 +1,9 @@ +toolCalls && 0 !== \count($this->toolCalls); + } +} diff --git a/src/Platform/Message/Content/Audio.php b/src/Platform/Message/Content/Audio.php new file mode 100644 index 00000000..f7006be8 --- /dev/null +++ b/src/Platform/Message/Content/Audio.php @@ -0,0 +1,9 @@ +format, $this->asBase64()); + return \sprintf('data:%s;base64,%s', $this->format, $this->asBase64()); } /** diff --git a/src/Platform/Message/Content/Image.php b/src/Platform/Message/Content/Image.php new file mode 100644 index 00000000..438f5323 --- /dev/null +++ b/src/Platform/Message/Content/Image.php @@ -0,0 +1,9 @@ + \is_string($entry) ? new Text($entry) : $entry, + $content = array_map( + static fn (string|ContentInterface $entry) => \is_string($entry) ? new Text($entry) : $entry, $content, ); diff --git a/src/Model/Message/MessageBag.php b/src/Platform/Message/MessageBag.php similarity index 91% rename from src/Model/Message/MessageBag.php rename to src/Platform/Message/MessageBag.php index f0fad99d..f3cbf73a 100644 --- a/src/Model/Message/MessageBag.php +++ b/src/Platform/Message/MessageBag.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Message; +namespace PhpLlm\LlmChain\Platform\Message; /** * @final @@ -102,14 +102,6 @@ public function containsImage(): bool public function count(): int { - return count($this->messages); - } - - /** - * @return MessageInterface[] - */ - public function jsonSerialize(): array - { - return $this->messages; + return \count($this->messages); } } diff --git a/src/Model/Message/MessageBagInterface.php b/src/Platform/Message/MessageBagInterface.php similarity index 74% rename from src/Model/Message/MessageBagInterface.php rename to src/Platform/Message/MessageBagInterface.php index c5c832d8..e168a9df 100644 --- a/src/Model/Message/MessageBagInterface.php +++ b/src/Platform/Message/MessageBagInterface.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Message; +namespace PhpLlm\LlmChain\Platform\Message; -interface MessageBagInterface extends \JsonSerializable, \Countable +interface MessageBagInterface extends \Countable { public function add(MessageInterface $message): void; @@ -17,7 +17,7 @@ public function getSystemMessage(): ?SystemMessage; public function with(MessageInterface $message): self; - public function merge(MessageBagInterface $messageBag): self; + public function merge(self $messageBag): self; public function withoutSystemMessage(): self; diff --git a/src/Platform/Message/MessageInterface.php b/src/Platform/Message/MessageInterface.php new file mode 100644 index 00000000..dd1e7b80 --- /dev/null +++ b/src/Platform/Message/MessageInterface.php @@ -0,0 +1,10 @@ + + */ + 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/Model.php b/src/Platform/Model.php new file mode 100644 index 00000000..744b3249 --- /dev/null +++ b/src/Platform/Model.php @@ -0,0 +1,45 @@ + $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/ModelClient.php b/src/Platform/ModelClient.php deleted file mode 100644 index 09881fc1..00000000 --- a/src/Platform/ModelClient.php +++ /dev/null @@ -1,22 +0,0 @@ -|string|object $input - */ - public function supports(Model $model, array|string|object $input): bool; - - /** - * @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/ModelClientInterface.php b/src/Platform/ModelClientInterface.php new file mode 100644 index 00000000..1718bd10 --- /dev/null +++ b/src/Platform/ModelClientInterface.php @@ -0,0 +1,18 @@ + $payload + * @param array $options + */ + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface; +} diff --git a/src/Platform/Platform.php b/src/Platform/Platform.php new file mode 100644 index 00000000..59e63afa --- /dev/null +++ b/src/Platform/Platform.php @@ -0,0 +1,80 @@ + $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/PlatformInterface.php b/src/Platform/PlatformInterface.php similarity index 71% rename from src/PlatformInterface.php rename to src/Platform/PlatformInterface.php index e99ee73f..97f974f1 100644 --- a/src/PlatformInterface.php +++ b/src/Platform/PlatformInterface.php @@ -2,10 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain; +namespace PhpLlm\LlmChain\Platform; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Model\Response\ResponseInterface; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface; interface PlatformInterface { diff --git a/src/Model/Response/AsyncResponse.php b/src/Platform/Response/AsyncResponse.php similarity index 82% rename from src/Model/Response/AsyncResponse.php rename to src/Platform/Response/AsyncResponse.php index 6eff7fb8..fa8e960f 100644 --- a/src/Model/Response/AsyncResponse.php +++ b/src/Platform/Response/AsyncResponse.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Response; +namespace PhpLlm\LlmChain\Platform\Response; -use PhpLlm\LlmChain\Model\Response\Exception\RawResponseAlreadySet; -use PhpLlm\LlmChain\Model\Response\Metadata\MetadataAwareTrait; -use PhpLlm\LlmChain\Platform\ResponseConverter; +use PhpLlm\LlmChain\Platform\Response\Exception\RawResponseAlreadySetException; +use PhpLlm\LlmChain\Platform\Response\Metadata\MetadataAwareTrait; +use PhpLlm\LlmChain\Platform\ResponseConverterInterface; use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; final class AsyncResponse implements ResponseInterface @@ -20,7 +20,7 @@ final class AsyncResponse implements ResponseInterface * @param array $options */ public function __construct( - private readonly ResponseConverter $responseConverter, + private readonly ResponseConverterInterface $responseConverter, private readonly HttpResponse $response, private readonly array $options = [], ) { @@ -39,7 +39,7 @@ public function getRawResponse(): HttpResponse 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 RawResponseAlreadySet(); + throw new RawResponseAlreadySetException(); } public function unwrap(): ResponseInterface diff --git a/src/Model/Response/BaseResponse.php b/src/Platform/Response/BaseResponse.php similarity index 58% rename from src/Model/Response/BaseResponse.php rename to src/Platform/Response/BaseResponse.php index b8848d2f..caa88b09 100644 --- a/src/Model/Response/BaseResponse.php +++ b/src/Platform/Response/BaseResponse.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Response; +namespace PhpLlm\LlmChain\Platform\Response; -use PhpLlm\LlmChain\Model\Response\Metadata\MetadataAwareTrait; +use PhpLlm\LlmChain\Platform\Response\Metadata\MetadataAwareTrait; abstract class BaseResponse implements ResponseInterface { diff --git a/src/Model/Response/BinaryResponse.php b/src/Platform/Response/BinaryResponse.php similarity index 79% rename from src/Model/Response/BinaryResponse.php rename to src/Platform/Response/BinaryResponse.php index 6a1b1ff0..7ade6e49 100644 --- a/src/Model/Response/BinaryResponse.php +++ b/src/Platform/Response/BinaryResponse.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Response; +namespace PhpLlm\LlmChain\Platform\Response; -use PhpLlm\LlmChain\Exception\RuntimeException; +use PhpLlm\LlmChain\Platform\Exception\RuntimeException; final class BinaryResponse extends BaseResponse { @@ -21,7 +21,7 @@ public function getContent(): string public function toBase64(): string { - return \base64_encode($this->data); + return base64_encode($this->data); } public function toDataUri(): string diff --git a/src/Model/Response/Choice.php b/src/Platform/Response/Choice.php similarity index 86% rename from src/Model/Response/Choice.php rename to src/Platform/Response/Choice.php index d7a62941..4b68e112 100644 --- a/src/Model/Response/Choice.php +++ b/src/Platform/Response/Choice.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Response; +namespace PhpLlm\LlmChain\Platform\Response; final readonly class Choice { @@ -35,6 +35,6 @@ public function getToolCalls(): array public function hasToolCall(): bool { - return 0 !== count($this->toolCalls); + return 0 !== \count($this->toolCalls); } } diff --git a/src/Model/Response/ChoiceResponse.php b/src/Platform/Response/ChoiceResponse.php similarity index 76% rename from src/Model/Response/ChoiceResponse.php rename to src/Platform/Response/ChoiceResponse.php index 760cab9f..8a61d410 100644 --- a/src/Model/Response/ChoiceResponse.php +++ b/src/Platform/Response/ChoiceResponse.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Response; +namespace PhpLlm\LlmChain\Platform\Response; -use PhpLlm\LlmChain\Exception\InvalidArgumentException; +use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException; final class ChoiceResponse extends BaseResponse { @@ -15,7 +15,7 @@ final class ChoiceResponse extends BaseResponse public function __construct(Choice ...$choices) { - if (0 === count($choices)) { + if (0 === \count($choices)) { throw new InvalidArgumentException('Response must have at least one choice.'); } diff --git a/src/Platform/Response/Exception/RawResponseAlreadySetException.php b/src/Platform/Response/Exception/RawResponseAlreadySetException.php new file mode 100644 index 00000000..0b040b89 --- /dev/null +++ b/src/Platform/Response/Exception/RawResponseAlreadySetException.php @@ -0,0 +1,15 @@ + diff --git a/src/Model/Response/Metadata/MetadataAwareTrait.php b/src/Platform/Response/Metadata/MetadataAwareTrait.php similarity index 79% rename from src/Model/Response/Metadata/MetadataAwareTrait.php rename to src/Platform/Response/Metadata/MetadataAwareTrait.php index eac0f88c..6c6aca7d 100644 --- a/src/Model/Response/Metadata/MetadataAwareTrait.php +++ b/src/Platform/Response/Metadata/MetadataAwareTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Response\Metadata; +namespace PhpLlm\LlmChain\Platform\Response\Metadata; trait MetadataAwareTrait { diff --git a/src/Model/Response/StructuredResponse.php b/src/Platform/Response/ObjectResponse.php similarity index 80% rename from src/Model/Response/StructuredResponse.php rename to src/Platform/Response/ObjectResponse.php index 96c53123..07e7166c 100644 --- a/src/Model/Response/StructuredResponse.php +++ b/src/Platform/Response/ObjectResponse.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Response; +namespace PhpLlm\LlmChain\Platform\Response; -final class StructuredResponse extends BaseResponse +final class ObjectResponse extends BaseResponse { /** * @param object|array $structuredOutput diff --git a/src/Model/Response/RawResponseAwareTrait.php b/src/Platform/Response/RawResponseAwareTrait.php similarity index 73% rename from src/Model/Response/RawResponseAwareTrait.php rename to src/Platform/Response/RawResponseAwareTrait.php index 29578dd3..1658ec23 100644 --- a/src/Model/Response/RawResponseAwareTrait.php +++ b/src/Platform/Response/RawResponseAwareTrait.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Response; +namespace PhpLlm\LlmChain\Platform\Response; -use PhpLlm\LlmChain\Model\Response\Exception\RawResponseAlreadySet; +use PhpLlm\LlmChain\Platform\Response\Exception\RawResponseAlreadySetException; use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyHttpResponse; trait RawResponseAwareTrait @@ -14,7 +14,7 @@ trait RawResponseAwareTrait public function setRawResponse(SymfonyHttpResponse $rawResponse): void { if (null !== $this->rawResponse) { - throw new RawResponseAlreadySet(); + throw new RawResponseAlreadySetException(); } $this->rawResponse = $rawResponse; diff --git a/src/Model/Response/ResponseInterface.php b/src/Platform/Response/ResponseInterface.php similarity index 62% rename from src/Model/Response/ResponseInterface.php rename to src/Platform/Response/ResponseInterface.php index a1651b79..ca02239f 100644 --- a/src/Model/Response/ResponseInterface.php +++ b/src/Platform/Response/ResponseInterface.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Response; +namespace PhpLlm\LlmChain\Platform\Response; -use PhpLlm\LlmChain\Model\Response\Exception\RawResponseAlreadySet; -use PhpLlm\LlmChain\Model\Response\Metadata\Metadata; +use PhpLlm\LlmChain\Platform\Response\Exception\RawResponseAlreadySetException; +use PhpLlm\LlmChain\Platform\Response\Metadata\Metadata; use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyHttpResponse; interface ResponseInterface @@ -20,7 +20,7 @@ public function getMetadata(): Metadata; public function getRawResponse(): ?SymfonyHttpResponse; /** - * @throws RawResponseAlreadySet if the response is tried to be set more than once + * @throws RawResponseAlreadySetException if the response is tried to be set more than once */ public function setRawResponse(SymfonyHttpResponse $rawResponse): void; } diff --git a/src/Model/Response/StreamResponse.php b/src/Platform/Response/StreamResponse.php similarity index 85% rename from src/Model/Response/StreamResponse.php rename to src/Platform/Response/StreamResponse.php index da45ef23..ed73c952 100644 --- a/src/Model/Response/StreamResponse.php +++ b/src/Platform/Response/StreamResponse.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Response; +namespace PhpLlm\LlmChain\Platform\Response; final class StreamResponse extends BaseResponse { diff --git a/src/Model/Response/TextResponse.php b/src/Platform/Response/TextResponse.php similarity index 85% rename from src/Model/Response/TextResponse.php rename to src/Platform/Response/TextResponse.php index 09c37de7..82dba840 100644 --- a/src/Model/Response/TextResponse.php +++ b/src/Platform/Response/TextResponse.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Response; +namespace PhpLlm\LlmChain\Platform\Response; final class TextResponse extends BaseResponse { diff --git a/src/Model/Response/ToolCall.php b/src/Platform/Response/ToolCall.php similarity index 94% rename from src/Model/Response/ToolCall.php rename to src/Platform/Response/ToolCall.php index a6743bff..bc25cd3d 100644 --- a/src/Model/Response/ToolCall.php +++ b/src/Platform/Response/ToolCall.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Response; +namespace PhpLlm\LlmChain\Platform\Response; final readonly class ToolCall implements \JsonSerializable { diff --git a/src/Model/Response/ToolCallResponse.php b/src/Platform/Response/ToolCallResponse.php similarity index 77% rename from src/Model/Response/ToolCallResponse.php rename to src/Platform/Response/ToolCallResponse.php index 0f4530d0..b9a928a8 100644 --- a/src/Model/Response/ToolCallResponse.php +++ b/src/Platform/Response/ToolCallResponse.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Response; +namespace PhpLlm\LlmChain\Platform\Response; -use PhpLlm\LlmChain\Exception\InvalidArgumentException; +use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException; final class ToolCallResponse extends BaseResponse { @@ -15,7 +15,7 @@ final class ToolCallResponse extends BaseResponse public function __construct(ToolCall ...$toolCalls) { - if (0 === count($toolCalls)) { + if (0 === \count($toolCalls)) { throw new InvalidArgumentException('Response must have at least one tool call.'); } diff --git a/src/Model/Response/VectorResponse.php b/src/Platform/Response/VectorResponse.php similarity index 81% rename from src/Model/Response/VectorResponse.php rename to src/Platform/Response/VectorResponse.php index 9e54a704..302ba0c0 100644 --- a/src/Model/Response/VectorResponse.php +++ b/src/Platform/Response/VectorResponse.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Model\Response; +namespace PhpLlm\LlmChain\Platform\Response; -use PhpLlm\LlmChain\Document\Vector; +use PhpLlm\LlmChain\Platform\Vector\Vector; final class VectorResponse extends BaseResponse { diff --git a/src/Platform/ResponseConverter.php b/src/Platform/ResponseConverterInterface.php similarity index 50% rename from src/Platform/ResponseConverter.php rename to src/Platform/ResponseConverterInterface.php index a6a03262..f6524075 100644 --- a/src/Platform/ResponseConverter.php +++ b/src/Platform/ResponseConverterInterface.php @@ -4,16 +4,12 @@ namespace PhpLlm\LlmChain\Platform; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Model\Response\ResponseInterface as LlmResponse; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface as LlmResponse; use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; -interface ResponseConverter +interface ResponseConverterInterface { - /** - * @param array|string|object $input - */ - public function supports(Model $model, array|string|object $input): bool; + public function supports(Model $model): bool; /** * @param array $options diff --git a/src/Chain/Toolbox/ExecutionReference.php b/src/Platform/Tool/ExecutionReference.php similarity index 82% rename from src/Chain/Toolbox/ExecutionReference.php rename to src/Platform/Tool/ExecutionReference.php index df67a42e..5ef1af2c 100644 --- a/src/Chain/Toolbox/ExecutionReference.php +++ b/src/Platform/Tool/ExecutionReference.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Chain\Toolbox; +namespace PhpLlm\LlmChain\Platform\Tool; final class ExecutionReference { diff --git a/src/Platform/Tool/Tool.php b/src/Platform/Tool/Tool.php new file mode 100644 index 00000000..bc5909a0 --- /dev/null +++ b/src/Platform/Tool/Tool.php @@ -0,0 +1,24 @@ +dimensions) { - $this->dimensions = count($data); + $this->dimensions = \count($data); } } diff --git a/src/Document/VectorInterface.php b/src/Platform/Vector/VectorInterface.php similarity index 81% rename from src/Document/VectorInterface.php rename to src/Platform/Vector/VectorInterface.php index 46d41988..5b6f4a82 100644 --- a/src/Document/VectorInterface.php +++ b/src/Platform/Vector/VectorInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Document; +namespace PhpLlm\LlmChain\Platform\Vector; interface VectorInterface { diff --git a/src/Bridge/Azure/Store/SearchStore.php b/src/Store/Bridge/Azure/SearchStore.php similarity index 86% rename from src/Bridge/Azure/Store/SearchStore.php rename to src/Store/Bridge/Azure/SearchStore.php index 53d11b96..090efbcf 100644 --- a/src/Bridge/Azure/Store/SearchStore.php +++ b/src/Store/Bridge/Azure/SearchStore.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Azure\Store; +namespace PhpLlm\LlmChain\Store\Bridge\Azure; -use PhpLlm\LlmChain\Document\Metadata; -use PhpLlm\LlmChain\Document\NullVector; -use PhpLlm\LlmChain\Document\Vector; -use PhpLlm\LlmChain\Document\VectorDocument; +use PhpLlm\LlmChain\Platform\Vector\NullVector; +use PhpLlm\LlmChain\Platform\Vector\Vector; +use PhpLlm\LlmChain\Store\Document\Metadata; +use PhpLlm\LlmChain\Store\Document\VectorDocument; use PhpLlm\LlmChain\Store\VectorStoreInterface; use Symfony\Component\Uid\Uuid; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -50,7 +50,7 @@ public function query(Vector $vector, array $options = [], ?float $minScore = nu */ private function request(string $endpoint, array $payload): array { - $url = sprintf('%s/indexes/%s/docs/%s', $this->endpointUrl, $this->indexName, $endpoint); + $url = \sprintf('%s/indexes/%s/docs/%s', $this->endpointUrl, $this->indexName, $endpoint); $response = $this->httpClient->request('POST', $url, [ 'headers' => [ 'api-key' => $this->apiKey, @@ -80,7 +80,7 @@ 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] + vector: !\array_key_exists($this->vectorFieldName, $data) || null === $data[$this->vectorFieldName] ? new NullVector() : new Vector($data[$this->vectorFieldName]), metadata: new Metadata($data), diff --git a/src/Bridge/ChromaDB/Store.php b/src/Store/Bridge/ChromaDB/Store.php similarity index 85% rename from src/Bridge/ChromaDB/Store.php rename to src/Store/Bridge/ChromaDB/Store.php index cd2cfe91..0815d69b 100644 --- a/src/Bridge/ChromaDB/Store.php +++ b/src/Store/Bridge/ChromaDB/Store.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\ChromaDB; +namespace PhpLlm\LlmChain\Store\Bridge\ChromaDB; use Codewithkyrian\ChromaDB\Client; -use PhpLlm\LlmChain\Document\Metadata; -use PhpLlm\LlmChain\Document\Vector; -use PhpLlm\LlmChain\Document\VectorDocument; +use PhpLlm\LlmChain\Platform\Vector\Vector; +use PhpLlm\LlmChain\Store\Document\Metadata; +use PhpLlm\LlmChain\Store\Document\VectorDocument; use PhpLlm\LlmChain\Store\VectorStoreInterface; use Symfony\Component\Uid\Uuid; @@ -43,7 +43,7 @@ public function query(Vector $vector, array $options = [], ?float $minScore = nu ); $documents = []; - for ($i = 0; $i < count($queryResponse->metadatas[0]); ++$i) { + 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]), diff --git a/src/Bridge/MongoDB/Store.php b/src/Store/Bridge/MongoDB/Store.php similarity index 94% rename from src/Bridge/MongoDB/Store.php rename to src/Store/Bridge/MongoDB/Store.php index 8f30a33d..eb254ea5 100644 --- a/src/Bridge/MongoDB/Store.php +++ b/src/Store/Bridge/MongoDB/Store.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\MongoDB; +namespace PhpLlm\LlmChain\Store\Bridge\MongoDB; use MongoDB\BSON\Binary; use MongoDB\Client; use MongoDB\Collection; use MongoDB\Driver\Exception\CommandException; -use PhpLlm\LlmChain\Document\Metadata; -use PhpLlm\LlmChain\Document\Vector; -use PhpLlm\LlmChain\Document\VectorDocument; -use PhpLlm\LlmChain\Exception\InvalidArgumentException; +use PhpLlm\LlmChain\Platform\Vector\Vector; +use PhpLlm\LlmChain\Store\Document\Metadata; +use PhpLlm\LlmChain\Store\Document\VectorDocument; +use PhpLlm\LlmChain\Store\Exception\InvalidArgumentException; use PhpLlm\LlmChain\Store\InitializableStoreInterface; use PhpLlm\LlmChain\Store\VectorStoreInterface; use Psr\Log\LoggerInterface; @@ -146,7 +146,7 @@ public function query(Vector $vector, array $options = [], ?float $minScore = nu */ public function initialize(array $options = []): void { - if ([] !== $options && !array_key_exists('fields', $options)) { + if ([] !== $options && !\array_key_exists('fields', $options)) { throw new InvalidArgumentException('The only supported option is "fields"'); } diff --git a/src/Bridge/Pinecone/Store.php b/src/Store/Bridge/Pinecone/Store.php similarity index 91% rename from src/Bridge/Pinecone/Store.php rename to src/Store/Bridge/Pinecone/Store.php index 6dba2786..499ff0a3 100644 --- a/src/Bridge/Pinecone/Store.php +++ b/src/Store/Bridge/Pinecone/Store.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\Pinecone; +namespace PhpLlm\LlmChain\Store\Bridge\Pinecone; -use PhpLlm\LlmChain\Document\Metadata; -use PhpLlm\LlmChain\Document\Vector; -use PhpLlm\LlmChain\Document\VectorDocument; +use PhpLlm\LlmChain\Platform\Vector\Vector; +use PhpLlm\LlmChain\Store\Document\Metadata; +use PhpLlm\LlmChain\Store\Document\VectorDocument; use PhpLlm\LlmChain\Store\VectorStoreInterface; use Probots\Pinecone\Client; use Probots\Pinecone\Resources\Data\VectorResource; diff --git a/src/Document/Metadata.php b/src/Store/Document/Metadata.php similarity index 76% rename from src/Document/Metadata.php rename to src/Store/Document/Metadata.php index 0ad0191b..d4c59bae 100644 --- a/src/Document/Metadata.php +++ b/src/Store/Document/Metadata.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Document; +namespace PhpLlm\LlmChain\Store\Document; /** * @template-extends \ArrayObject diff --git a/src/Document/TextDocument.php b/src/Store/Document/TextDocument.php similarity index 89% rename from src/Document/TextDocument.php rename to src/Store/Document/TextDocument.php index b80dfc18..d6bfc6a2 100644 --- a/src/Document/TextDocument.php +++ b/src/Store/Document/TextDocument.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Document; +namespace PhpLlm\LlmChain\Store\Document; use Symfony\Component\Uid\Uuid; use Webmozart\Assert\Assert; diff --git a/src/Document/VectorDocument.php b/src/Store/Document/VectorDocument.php similarity index 76% rename from src/Document/VectorDocument.php rename to src/Store/Document/VectorDocument.php index b0bc2413..af7dcd74 100644 --- a/src/Document/VectorDocument.php +++ b/src/Store/Document/VectorDocument.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Document; +namespace PhpLlm\LlmChain\Store\Document; +use PhpLlm\LlmChain\Platform\Vector\VectorInterface; use Symfony\Component\Uid\Uuid; final readonly class VectorDocument diff --git a/src/Embedder.php b/src/Store/Embedder.php similarity index 77% rename from src/Embedder.php rename to src/Store/Embedder.php index 6e95fa58..c8386f3c 100644 --- a/src/Embedder.php +++ b/src/Store/Embedder.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain; +namespace PhpLlm\LlmChain\Store; -use PhpLlm\LlmChain\Document\TextDocument; -use PhpLlm\LlmChain\Document\VectorDocument; -use PhpLlm\LlmChain\Model\EmbeddingsModel; -use PhpLlm\LlmChain\Store\StoreInterface; +use PhpLlm\LlmChain\Platform\Capability; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\PlatformInterface; +use PhpLlm\LlmChain\Store\Document\TextDocument; +use PhpLlm\LlmChain\Store\Document\VectorDocument; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\Clock\Clock; @@ -19,7 +20,7 @@ public function __construct( private PlatformInterface $platform, - private EmbeddingsModel $embeddings, + private Model $model, private StoreInterface $store, ?ClockInterface $clock = null, private LoggerInterface $logger = new NullLogger(), @@ -60,14 +61,14 @@ public function embed(TextDocument|array $documents, int $chunkSize = 0, int $sl */ private function createVectorDocuments(array $documents): array { - if ($this->embeddings->supportsMultipleInputs()) { - $response = $this->platform->request($this->embeddings, array_map(fn (TextDocument $document) => $document->content, $documents)); + 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->embeddings, $document->content); + $responses[] = $this->platform->request($this->model, $document->content); } $vectors = []; diff --git a/src/Store/Exception/ExceptionInterface.php b/src/Store/Exception/ExceptionInterface.php new file mode 100644 index 00000000..4d64a679 --- /dev/null +++ b/src/Store/Exception/ExceptionInterface.php @@ -0,0 +1,9 @@ +convertToPrompt($bag)); - } - - /** - * @return iterable - */ - public static function provideMessageBag(): iterable - { - yield 'simple text' => [ - new MessageBag(Message::ofUser('Write a story about a magic backpack.')), - [ - ['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?'), - ), - [ - ['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'), - ), - [ - ['role' => 'system', 'content' => [['text' => 'You are a cat. Your name is Neko.']]], - ['role' => 'user', 'content' => [['text' => 'Hello there']]], - ], - ]; - - yield 'with tool use' => [ - new MessageBag( - Message::ofUser('Hello there, what is the time?'), - Message::ofToolCall(new ToolCall('123456', 'clock', []), '2023-10-01T10:00:00+00:00'), - Message::ofAssistant('It is 10:00 AM.'), - ), - [ - ['role' => 'user', 'content' => [['text' => 'Hello there, what is the time?']]], - [ - 'role' => 'user', - 'content' => [ - [ - 'toolResult' => [ - 'toolUseId' => '123456', - 'content' => [ - 'text' => '2023-10-01T10:00:00+00:00', - ], - ], - ], - ], - ], - ['role' => 'assistant', 'content' => [['text' => 'It is 10:00 AM.']]], - ], - ]; - } -} diff --git a/tests/Bridge/Google/GooglePromptConverterTest.php b/tests/Bridge/Google/GooglePromptConverterTest.php deleted file mode 100644 index 1b695aae..00000000 --- a/tests/Bridge/Google/GooglePromptConverterTest.php +++ /dev/null @@ -1,95 +0,0 @@ -convertToPrompt($bag)); - } - - /** - * @return iterable - */ - public static function provideMessageBag(): 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__, 2).'/Fixture/image.jpg')) - ), - [ - 'contents' => [ - ['role' => 'user', 'parts' => [ - ['text' => 'Tell me about this instrument'], - ['inline_data' => ['mime_type' => 'image/jpeg', 'data' => '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']], ]], - ], - ], - ]; - - 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/tests/Chain/InputProcessor/LlmOverrideInputProcessorTest.php b/tests/Chain/InputProcessor/LlmOverrideInputProcessorTest.php deleted file mode 100644 index 4c436da8..00000000 --- a/tests/Chain/InputProcessor/LlmOverrideInputProcessorTest.php +++ /dev/null @@ -1,67 +0,0 @@ - $claude]); - - $processor = new LlmOverrideInputProcessor(); - $processor->processInput($input); - - self::assertSame($claude, $input->llm); - } - - #[Test] - public function processInputWithoutLlmOption(): void - { - $gpt = new GPT(); - $input = new Input($gpt, new MessageBag(), []); - - $processor = new LlmOverrideInputProcessor(); - $processor->processInput($input); - - self::assertSame($gpt, $input->llm); - } - - #[Test] - public function processInputWithInvalidLlmOption(): void - { - self::expectException(InvalidArgumentException::class); - self::expectExceptionMessage('Option "llm" must be an instance of PhpLlm\LlmChain\Model\LanguageModel.'); - - $gpt = new GPT(); - $model = new Embeddings(); - $input = new Input($gpt, new MessageBag(), ['llm' => $model]); - - $processor = new LlmOverrideInputProcessor(); - $processor->processInput($input); - } -} diff --git a/tests/Chain/InputProcessor/ModelOverrideInputProcessorTest.php b/tests/Chain/InputProcessor/ModelOverrideInputProcessorTest.php new file mode 100644 index 00000000..50516a5c --- /dev/null +++ b/tests/Chain/InputProcessor/ModelOverrideInputProcessorTest.php @@ -0,0 +1,67 @@ + $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 PhpLlm\LlmChain\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/tests/Chain/InputProcessor/SystemPromptInputProcessorTest.php b/tests/Chain/InputProcessor/SystemPromptInputProcessorTest.php index ee5c5bc6..19b7d0de 100644 --- a/tests/Chain/InputProcessor/SystemPromptInputProcessorTest.php +++ b/tests/Chain/InputProcessor/SystemPromptInputProcessorTest.php @@ -4,18 +4,18 @@ namespace PhpLlm\LlmChain\Tests\Chain\InputProcessor; -use PhpLlm\LlmChain\Bridge\OpenAI\GPT; use PhpLlm\LlmChain\Chain\Input; use PhpLlm\LlmChain\Chain\InputProcessor\SystemPromptInputProcessor; -use PhpLlm\LlmChain\Chain\Toolbox\ExecutionReference; -use PhpLlm\LlmChain\Chain\Toolbox\Metadata; use PhpLlm\LlmChain\Chain\Toolbox\ToolboxInterface; -use PhpLlm\LlmChain\Model\Message\Content\Text; -use PhpLlm\LlmChain\Model\Message\Message; -use PhpLlm\LlmChain\Model\Message\MessageBag; -use PhpLlm\LlmChain\Model\Message\SystemMessage; -use PhpLlm\LlmChain\Model\Message\UserMessage; -use PhpLlm\LlmChain\Model\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT; +use PhpLlm\LlmChain\Platform\Message\Content\Text; +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Message\MessageBag; +use PhpLlm\LlmChain\Platform\Message\SystemMessage; +use PhpLlm\LlmChain\Platform\Message\UserMessage; +use PhpLlm\LlmChain\Platform\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Tool\ExecutionReference; +use PhpLlm\LlmChain\Platform\Tool\Tool; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolNoParams; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolRequiredParams; use PHPUnit\Framework\Attributes\CoversClass; @@ -32,7 +32,7 @@ #[UsesClass(SystemMessage::class)] #[UsesClass(UserMessage::class)] #[UsesClass(Text::class)] -#[UsesClass(Metadata::class)] +#[UsesClass(Tool::class)] #[UsesClass(ExecutionReference::class)] #[Small] final class SystemPromptInputProcessorTest extends TestCase @@ -77,7 +77,7 @@ public function doesNotIncludeToolsIfToolboxIsEmpty(): void $processor = new SystemPromptInputProcessor( 'This is a system prompt', new class implements ToolboxInterface { - public function getMap(): array + public function getTools(): array { return []; } @@ -105,11 +105,11 @@ public function includeToolDefinitions(): void $processor = new SystemPromptInputProcessor( 'This is a system prompt', new class implements ToolboxInterface { - public function getMap(): array + public function getTools(): array { return [ - new Metadata(new ExecutionReference(ToolNoParams::class), 'tool_no_params', 'A tool without parameters', null), - new Metadata( + new Tool(new ExecutionReference(ToolNoParams::class), 'tool_no_params', 'A tool without parameters', null), + new Tool( new ExecutionReference(ToolRequiredParams::class, 'bar'), 'tool_required_params', <<content); diff --git a/tests/Chain/StructuredOutput/ChainProcessorTest.php b/tests/Chain/StructuredOutput/ChainProcessorTest.php index a9bfb9ff..553c8a42 100644 --- a/tests/Chain/StructuredOutput/ChainProcessorTest.php +++ b/tests/Chain/StructuredOutput/ChainProcessorTest.php @@ -4,15 +4,16 @@ namespace PhpLlm\LlmChain\Tests\Chain\StructuredOutput; +use PhpLlm\LlmChain\Chain\Exception\MissingModelSupportException; use PhpLlm\LlmChain\Chain\Input; use PhpLlm\LlmChain\Chain\Output; use PhpLlm\LlmChain\Chain\StructuredOutput\ChainProcessor; -use PhpLlm\LlmChain\Exception\MissingModelSupport; -use PhpLlm\LlmChain\Model\LanguageModel; -use PhpLlm\LlmChain\Model\Message\MessageBag; -use PhpLlm\LlmChain\Model\Response\Choice; -use PhpLlm\LlmChain\Model\Response\StructuredResponse; -use PhpLlm\LlmChain\Model\Response\TextResponse; +use PhpLlm\LlmChain\Platform\Capability; +use PhpLlm\LlmChain\Platform\Message\MessageBag; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\Response\Choice; +use PhpLlm\LlmChain\Platform\Response\ObjectResponse; +use PhpLlm\LlmChain\Platform\Response\TextResponse; use PhpLlm\LlmChain\Tests\Double\ConfigurableResponseFormatFactory; use PhpLlm\LlmChain\Tests\Fixture\SomeStructure; use PhpLlm\LlmChain\Tests\Fixture\StructuredOutput\MathReasoning; @@ -28,9 +29,10 @@ #[UsesClass(Output::class)] #[UsesClass(MessageBag::class)] #[UsesClass(Choice::class)] -#[UsesClass(MissingModelSupport::class)] +#[UsesClass(MissingModelSupportException::class)] #[UsesClass(TextResponse::class)] -#[UsesClass(StructuredResponse::class)] +#[UsesClass(ObjectResponse::class)] +#[UsesClass(Model::class)] final class ChainProcessorTest extends TestCase { #[Test] @@ -38,10 +40,8 @@ public function processInputWithOutputStructure(): void { $chainProcessor = new ChainProcessor(new ConfigurableResponseFormatFactory(['some' => 'format'])); - $llm = self::createMock(LanguageModel::class); - $llm->method('supportsStructuredOutput')->willReturn(true); - - $input = new Input($llm, new MessageBag(), ['output_structure' => 'SomeStructure']); + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $input = new Input($model, new MessageBag(), ['output_structure' => 'SomeStructure']); $chainProcessor->processInput($input); @@ -53,8 +53,8 @@ public function processInputWithoutOutputStructure(): void { $chainProcessor = new ChainProcessor(new ConfigurableResponseFormatFactory()); - $llm = self::createMock(LanguageModel::class); - $input = new Input($llm, new MessageBag(), []); + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $input = new Input($model, new MessageBag(), []); $chainProcessor->processInput($input); @@ -64,14 +64,12 @@ public function processInputWithoutOutputStructure(): void #[Test] public function processInputThrowsExceptionWhenLlmDoesNotSupportStructuredOutput(): void { - self::expectException(MissingModelSupport::class); + self::expectException(MissingModelSupportException::class); $chainProcessor = new ChainProcessor(new ConfigurableResponseFormatFactory()); - $llm = self::createMock(LanguageModel::class); - $llm->method('supportsStructuredOutput')->willReturn(false); - - $input = new Input($llm, new MessageBag(), ['output_structure' => 'SomeStructure']); + $model = new Model('gpt-3'); + $input = new Input($model, new MessageBag(), ['output_structure' => 'SomeStructure']); $chainProcessor->processInput($input); } @@ -81,20 +79,18 @@ public function processOutputWithResponseFormat(): void { $chainProcessor = new ChainProcessor(new ConfigurableResponseFormatFactory(['some' => 'format'])); - $llm = self::createMock(LanguageModel::class); - $llm->method('supportsStructuredOutput')->willReturn(true); - + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); $options = ['output_structure' => SomeStructure::class]; - $input = new Input($llm, new MessageBag(), $options); + $input = new Input($model, new MessageBag(), $options); $chainProcessor->processInput($input); $response = new TextResponse('{"some": "data"}'); - $output = new Output($llm, $response, new MessageBag(), $input->getOptions()); + $output = new Output($model, $response, new MessageBag(), $input->getOptions()); $chainProcessor->processOutput($output); - self::assertInstanceOf(StructuredResponse::class, $output->response); + self::assertInstanceOf(ObjectResponse::class, $output->response); self::assertInstanceOf(SomeStructure::class, $output->response->getContent()); self::assertSame('data', $output->response->getContent()->some); } @@ -104,11 +100,9 @@ public function processOutputWithComplexResponseFormat(): void { $chainProcessor = new ChainProcessor(new ConfigurableResponseFormatFactory(['some' => 'format'])); - $llm = self::createMock(LanguageModel::class); - $llm->method('supportsStructuredOutput')->willReturn(true); - + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); $options = ['output_structure' => MathReasoning::class]; - $input = new Input($llm, new MessageBag(), $options); + $input = new Input($model, new MessageBag(), $options); $chainProcessor->processInput($input); $response = new TextResponse(<<getOptions()); + $output = new Output($model, $response, new MessageBag(), $input->getOptions()); $chainProcessor->processOutput($output); - self::assertInstanceOf(StructuredResponse::class, $output->response); + 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]); @@ -161,10 +155,10 @@ public function processOutputWithoutResponseFormat(): void $serializer = self::createMock(SerializerInterface::class); $chainProcessor = new ChainProcessor($responseFormatFactory, $serializer); - $llm = self::createMock(LanguageModel::class); + $model = self::createMock(Model::class); $response = new TextResponse(''); - $output = new Output($llm, $response, new MessageBag(), []); + $output = new Output($model, $response, new MessageBag(), []); $chainProcessor->processOutput($output); diff --git a/tests/Chain/StructuredOutput/ResponseFormatFactoryTest.php b/tests/Chain/StructuredOutput/ResponseFormatFactoryTest.php index 3f80e297..1a05472c 100644 --- a/tests/Chain/StructuredOutput/ResponseFormatFactoryTest.php +++ b/tests/Chain/StructuredOutput/ResponseFormatFactoryTest.php @@ -4,9 +4,9 @@ namespace PhpLlm\LlmChain\Tests\Chain\StructuredOutput; -use PhpLlm\LlmChain\Chain\JsonSchema\DescriptionParser; -use PhpLlm\LlmChain\Chain\JsonSchema\Factory; use PhpLlm\LlmChain\Chain\StructuredOutput\ResponseFormatFactory; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\DescriptionParser; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\Factory; use PhpLlm\LlmChain\Tests\Fixture\StructuredOutput\User; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/Chain/Toolbox/ChainProcessorTest.php b/tests/Chain/Toolbox/ChainProcessorTest.php index 32372fb7..b85c3579 100644 --- a/tests/Chain/Toolbox/ChainProcessorTest.php +++ b/tests/Chain/Toolbox/ChainProcessorTest.php @@ -4,14 +4,15 @@ namespace PhpLlm\LlmChain\Tests\Chain\Toolbox; +use PhpLlm\LlmChain\Chain\Exception\MissingModelSupportException; use PhpLlm\LlmChain\Chain\Input; use PhpLlm\LlmChain\Chain\Toolbox\ChainProcessor; -use PhpLlm\LlmChain\Chain\Toolbox\ExecutionReference; -use PhpLlm\LlmChain\Chain\Toolbox\Metadata; use PhpLlm\LlmChain\Chain\Toolbox\ToolboxInterface; -use PhpLlm\LlmChain\Exception\MissingModelSupport; -use PhpLlm\LlmChain\Model\LanguageModel; -use PhpLlm\LlmChain\Model\Message\MessageBag; +use PhpLlm\LlmChain\Platform\Capability; +use PhpLlm\LlmChain\Platform\Message\MessageBag; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\Tool\ExecutionReference; +use PhpLlm\LlmChain\Platform\Tool\Tool; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -19,23 +20,22 @@ #[CoversClass(ChainProcessor::class)] #[UsesClass(Input::class)] -#[UsesClass(Metadata::class)] +#[UsesClass(Tool::class)] #[UsesClass(ExecutionReference::class)] #[UsesClass(MessageBag::class)] -#[UsesClass(MissingModelSupport::class)] +#[UsesClass(MissingModelSupportException::class)] +#[UsesClass(Model::class)] class ChainProcessorTest extends TestCase { #[Test] public function processInputWithoutRegisteredToolsWillResultInNoOptionChange(): void { $toolbox = $this->createStub(ToolboxInterface::class); - $toolbox->method('getMap')->willReturn([]); - - $llm = self::createMock(LanguageModel::class); - $llm->method('supportsToolCalling')->willReturn(true); + $toolbox->method('getTools')->willReturn([]); + $model = new Model('gpt-4', [Capability::TOOL_CALLING]); $chainProcessor = new ChainProcessor($toolbox); - $input = new Input($llm, new MessageBag(), []); + $input = new Input($model, new MessageBag(), []); $chainProcessor->processInput($input); @@ -46,15 +46,13 @@ public function processInputWithoutRegisteredToolsWillResultInNoOptionChange(): public function processInputWithRegisteredToolsWillResultInOptionChange(): void { $toolbox = $this->createStub(ToolboxInterface::class); - $tool1 = new Metadata(new ExecutionReference('ClassTool1', 'method1'), 'tool1', 'description1', null); - $tool2 = new Metadata(new ExecutionReference('ClassTool2', 'method1'), 'tool2', 'description2', null); - $toolbox->method('getMap')->willReturn([$tool1, $tool2]); - - $llm = self::createMock(LanguageModel::class); - $llm->method('supportsToolCalling')->willReturn(true); + $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]); $chainProcessor = new ChainProcessor($toolbox); - $input = new Input($llm, new MessageBag(), []); + $input = new Input($model, new MessageBag(), []); $chainProcessor->processInput($input); @@ -65,15 +63,13 @@ public function processInputWithRegisteredToolsWillResultInOptionChange(): void public function processInputWithRegisteredToolsButToolOverride(): void { $toolbox = $this->createStub(ToolboxInterface::class); - $tool1 = new Metadata(new ExecutionReference('ClassTool1', 'method1'), 'tool1', 'description1', null); - $tool2 = new Metadata(new ExecutionReference('ClassTool2', 'method1'), 'tool2', 'description2', null); - $toolbox->method('getMap')->willReturn([$tool1, $tool2]); - - $llm = self::createMock(LanguageModel::class); - $llm->method('supportsToolCalling')->willReturn(true); + $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]); $chainProcessor = new ChainProcessor($toolbox); - $input = new Input($llm, new MessageBag(), ['tools' => ['tool2']]); + $input = new Input($model, new MessageBag(), ['tools' => ['tool2']]); $chainProcessor->processInput($input); @@ -83,13 +79,11 @@ public function processInputWithRegisteredToolsButToolOverride(): void #[Test] public function processInputWithUnsupportedToolCallingWillThrowException(): void { - self::expectException(MissingModelSupport::class); - - $llm = self::createMock(LanguageModel::class); - $llm->method('supportsToolCalling')->willReturn(false); + self::expectException(MissingModelSupportException::class); + $model = new Model('gpt-3'); $chainProcessor = new ChainProcessor($this->createStub(ToolboxInterface::class)); - $input = new Input($llm, new MessageBag(), []); + $input = new Input($model, new MessageBag(), []); $chainProcessor->processInput($input); } diff --git a/tests/Chain/Toolbox/FaultTolerantToolboxTest.php b/tests/Chain/Toolbox/FaultTolerantToolboxTest.php index 1b533761..91d86789 100644 --- a/tests/Chain/Toolbox/FaultTolerantToolboxTest.php +++ b/tests/Chain/Toolbox/FaultTolerantToolboxTest.php @@ -6,11 +6,11 @@ use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolExecutionException; use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolNotFoundException; -use PhpLlm\LlmChain\Chain\Toolbox\ExecutionReference; use PhpLlm\LlmChain\Chain\Toolbox\FaultTolerantToolbox; -use PhpLlm\LlmChain\Chain\Toolbox\Metadata; use PhpLlm\LlmChain\Chain\Toolbox\ToolboxInterface; -use PhpLlm\LlmChain\Model\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Tool\ExecutionReference; +use PhpLlm\LlmChain\Platform\Tool\Tool; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolNoParams; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolRequiredParams; use PHPUnit\Framework\Attributes\CoversClass; @@ -20,7 +20,7 @@ #[CoversClass(FaultTolerantToolbox::class)] #[UsesClass(ToolCall::class)] -#[UsesClass(Metadata::class)] +#[UsesClass(Tool::class)] #[UsesClass(ExecutionReference::class)] #[UsesClass(ToolNotFoundException::class)] #[UsesClass(ToolExecutionException::class)] @@ -66,13 +66,13 @@ public function __construct(private readonly \Closure $exceptionFactory) } /** - * @return Metadata[] + * @return Tool[] */ - public function getMap(): array + public function getTools(): array { return [ - new Metadata(new ExecutionReference(ToolNoParams::class), 'tool_no_params', 'A tool without parameters', null), - new Metadata(new ExecutionReference(ToolRequiredParams::class, 'bar'), 'tool_required_params', 'A tool with required parameters', null), + 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), ]; } diff --git a/tests/Chain/Toolbox/MetadataFactory/ChainFactoryTest.php b/tests/Chain/Toolbox/MetadataFactory/ChainFactoryTest.php index a98de3f6..9ad48286 100644 --- a/tests/Chain/Toolbox/MetadataFactory/ChainFactoryTest.php +++ b/tests/Chain/Toolbox/MetadataFactory/ChainFactoryTest.php @@ -5,10 +5,10 @@ namespace PhpLlm\LlmChain\Tests\Chain\Toolbox\MetadataFactory; use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolConfigurationException; -use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolMetadataException; -use PhpLlm\LlmChain\Chain\Toolbox\MetadataFactory\ChainFactory; -use PhpLlm\LlmChain\Chain\Toolbox\MetadataFactory\MemoryFactory; -use PhpLlm\LlmChain\Chain\Toolbox\MetadataFactory\ReflectionFactory; +use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolException; +use PhpLlm\LlmChain\Chain\Toolbox\ToolFactory\ChainFactory; +use PhpLlm\LlmChain\Chain\Toolbox\ToolFactory\MemoryToolFactory; +use PhpLlm\LlmChain\Chain\Toolbox\ToolFactory\ReflectionToolFactory; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolMisconfigured; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolMultiple; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolNoAttribute1; @@ -22,19 +22,19 @@ #[CoversClass(ChainFactory::class)] #[Medium] -#[UsesClass(MemoryFactory::class)] -#[UsesClass(ReflectionFactory::class)] -#[UsesClass(ToolMetadataException::class)] +#[UsesClass(MemoryToolFactory::class)] +#[UsesClass(ReflectionToolFactory::class)] +#[UsesClass(ToolException::class)] final class ChainFactoryTest extends TestCase { private ChainFactory $factory; protected function setUp(): void { - $factory1 = (new MemoryFactory()) + $factory1 = (new MemoryToolFactory()) ->addTool(ToolNoAttribute1::class, 'reference', 'A reference tool') ->addTool(ToolOptionalParam::class, 'optional_param', 'Tool with optional param', 'bar'); - $factory2 = new ReflectionFactory(); + $factory2 = new ReflectionToolFactory(); $this->factory = new ChainFactory([$factory1, $factory2]); } @@ -42,25 +42,25 @@ protected function setUp(): void #[Test] public function testGetMetadataNotExistingClass(): void { - self::expectException(ToolMetadataException::class); + self::expectException(ToolException::class); self::expectExceptionMessage('The reference "NoClass" is not a valid tool.'); - iterator_to_array($this->factory->getMetadata('NoClass')); + 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)); + self::expectExceptionMessage(\sprintf('Method "foo" not found in tool "%s".', ToolMisconfigured::class)); - iterator_to_array($this->factory->getMetadata(ToolMisconfigured::class)); + iterator_to_array($this->factory->getTool(ToolMisconfigured::class)); } #[Test] public function testGetMetadataWithAttributeSingleHit(): void { - $metadata = iterator_to_array($this->factory->getMetadata(ToolRequiredParams::class)); + $metadata = iterator_to_array($this->factory->getTool(ToolRequiredParams::class)); self::assertCount(1, $metadata); } @@ -68,7 +68,7 @@ public function testGetMetadataWithAttributeSingleHit(): void #[Test] public function testGetMetadataOverwrite(): void { - $metadata = iterator_to_array($this->factory->getMetadata(ToolOptionalParam::class)); + $metadata = iterator_to_array($this->factory->getTool(ToolOptionalParam::class)); self::assertCount(1, $metadata); self::assertSame('optional_param', $metadata[0]->name); @@ -79,7 +79,7 @@ public function testGetMetadataOverwrite(): void #[Test] public function testGetMetadataWithAttributeDoubleHit(): void { - $metadata = iterator_to_array($this->factory->getMetadata(ToolMultiple::class)); + $metadata = iterator_to_array($this->factory->getTool(ToolMultiple::class)); self::assertCount(2, $metadata); } @@ -87,7 +87,7 @@ public function testGetMetadataWithAttributeDoubleHit(): void #[Test] public function testGetMetadataWithMemorySingleHit(): void { - $metadata = iterator_to_array($this->factory->getMetadata(ToolNoAttribute1::class)); + $metadata = iterator_to_array($this->factory->getTool(ToolNoAttribute1::class)); self::assertCount(1, $metadata); } diff --git a/tests/Chain/Toolbox/MetadataFactory/MemoryFactoryTest.php b/tests/Chain/Toolbox/MetadataFactory/MemoryFactoryTest.php index 4208fb34..b4b689a2 100644 --- a/tests/Chain/Toolbox/MetadataFactory/MemoryFactoryTest.php +++ b/tests/Chain/Toolbox/MetadataFactory/MemoryFactoryTest.php @@ -4,13 +4,13 @@ namespace PhpLlm\LlmChain\Tests\Chain\Toolbox\MetadataFactory; -use PhpLlm\LlmChain\Chain\JsonSchema\DescriptionParser; -use PhpLlm\LlmChain\Chain\JsonSchema\Factory; use PhpLlm\LlmChain\Chain\Toolbox\Attribute\AsTool; -use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolMetadataException; -use PhpLlm\LlmChain\Chain\Toolbox\ExecutionReference; -use PhpLlm\LlmChain\Chain\Toolbox\Metadata; -use PhpLlm\LlmChain\Chain\Toolbox\MetadataFactory\MemoryFactory; +use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolException; +use PhpLlm\LlmChain\Chain\Toolbox\ToolFactory\MemoryToolFactory; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\DescriptionParser; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\Factory; +use PhpLlm\LlmChain\Platform\Tool\ExecutionReference; +use PhpLlm\LlmChain\Platform\Tool\Tool; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolNoAttribute1; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolNoAttribute2; use PHPUnit\Framework\Attributes\CoversClass; @@ -18,11 +18,11 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -#[CoversClass(MemoryFactory::class)] +#[CoversClass(MemoryToolFactory::class)] #[UsesClass(AsTool::class)] -#[UsesClass(Metadata::class)] +#[UsesClass(Tool::class)] #[UsesClass(ExecutionReference::class)] -#[UsesClass(ToolMetadataException::class)] +#[UsesClass(ToolException::class)] #[UsesClass(Factory::class)] #[UsesClass(DescriptionParser::class)] final class MemoryFactoryTest extends TestCase @@ -30,24 +30,24 @@ final class MemoryFactoryTest extends TestCase #[Test] public function getMetadataWithoutTools(): void { - self::expectException(ToolMetadataException::class); + self::expectException(ToolException::class); self::expectExceptionMessage('The reference "SomeClass" is not a valid tool.'); - $factory = new MemoryFactory(); - iterator_to_array($factory->getMetadata('SomeClass')); // @phpstan-ignore-line Yes, this class does not exist + $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 MemoryFactory()) + $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->getMetadata(ToolNoAttribute1::class)); + $metadata = iterator_to_array($factory->getTool(ToolNoAttribute1::class)); self::assertCount(1, $metadata); - self::assertInstanceOf(Metadata::class, $metadata[0]); + 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); @@ -68,14 +68,14 @@ public function getMetadataWithDistinctToolPerClass(): void #[Test] public function getMetadataWithMultipleToolsInClass(): void { - $factory = (new MemoryFactory()) + $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->getMetadata(ToolNoAttribute2::class)); + $metadata = iterator_to_array($factory->getTool(ToolNoAttribute2::class)); self::assertCount(2, $metadata); - self::assertInstanceOf(Metadata::class, $metadata[0]); + 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); @@ -91,7 +91,7 @@ public function getMetadataWithMultipleToolsInClass(): void ]; self::assertSame($expectedParams, $metadata[0]->parameters); - self::assertInstanceOf(Metadata::class, $metadata[1]); + 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); diff --git a/tests/Chain/Toolbox/MetadataFactory/ReflectionFactoryTest.php b/tests/Chain/Toolbox/MetadataFactory/ReflectionFactoryTest.php index e1e1d859..52dda1af 100644 --- a/tests/Chain/Toolbox/MetadataFactory/ReflectionFactoryTest.php +++ b/tests/Chain/Toolbox/MetadataFactory/ReflectionFactoryTest.php @@ -4,14 +4,14 @@ namespace PhpLlm\LlmChain\Tests\Chain\Toolbox\MetadataFactory; -use PhpLlm\LlmChain\Chain\JsonSchema\DescriptionParser; -use PhpLlm\LlmChain\Chain\JsonSchema\Factory; use PhpLlm\LlmChain\Chain\Toolbox\Attribute\AsTool; use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolConfigurationException; -use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolMetadataException; -use PhpLlm\LlmChain\Chain\Toolbox\ExecutionReference; -use PhpLlm\LlmChain\Chain\Toolbox\Metadata; -use PhpLlm\LlmChain\Chain\Toolbox\MetadataFactory\ReflectionFactory; +use PhpLlm\LlmChain\Chain\Toolbox\Exception\ToolException; +use PhpLlm\LlmChain\Chain\Toolbox\ToolFactory\ReflectionToolFactory; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\DescriptionParser; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\Factory; +use PhpLlm\LlmChain\Platform\Tool\ExecutionReference; +use PhpLlm\LlmChain\Platform\Tool\Tool; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolMultiple; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolRequiredParams; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolWrong; @@ -20,46 +20,46 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -#[CoversClass(ReflectionFactory::class)] +#[CoversClass(ReflectionToolFactory::class)] #[UsesClass(AsTool::class)] -#[UsesClass(Metadata::class)] +#[UsesClass(Tool::class)] #[UsesClass(ExecutionReference::class)] #[UsesClass(Factory::class)] #[UsesClass(DescriptionParser::class)] #[UsesClass(ToolConfigurationException::class)] -#[UsesClass(ToolMetadataException::class)] +#[UsesClass(ToolException::class)] final class ReflectionFactoryTest extends TestCase { - private ReflectionFactory $factory; + private ReflectionToolFactory $factory; protected function setUp(): void { - $this->factory = new ReflectionFactory(); + $this->factory = new ReflectionToolFactory(); } #[Test] public function invalidReferenceNonExistingClass(): void { - self::expectException(ToolMetadataException::class); + self::expectException(ToolException::class); self::expectExceptionMessage('The reference "invalid" is not a valid tool.'); - iterator_to_array($this->factory->getMetadata('invalid')); // @phpstan-ignore-line Yes, this class does not exist + iterator_to_array($this->factory->getTool('invalid')); // @phpstan-ignore-line Yes, this class does not exist } #[Test] public function withoutAttribute(): void { - self::expectException(ToolMetadataException::class); - self::expectExceptionMessage(sprintf('The class "%s" is not a tool, please add %s attribute.', ToolWrong::class, AsTool::class)); + 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->getMetadata(ToolWrong::class)); + iterator_to_array($this->factory->getTool(ToolWrong::class)); } #[Test] public function getDefinition(): void { - /** @var Metadata[] $metadatas */ - $metadatas = iterator_to_array($this->factory->getMetadata(ToolRequiredParams::class)); + /** @var Tool[] $metadatas */ + $metadatas = iterator_to_array($this->factory->getTool(ToolRequiredParams::class)); self::assertToolConfiguration( metadata: $metadatas[0], @@ -88,7 +88,7 @@ className: ToolRequiredParams::class, #[Test] public function getDefinitionWithMultiple(): void { - $metadatas = iterator_to_array($this->factory->getMetadata(ToolMultiple::class)); + $metadatas = iterator_to_array($this->factory->getTool(ToolMultiple::class)); self::assertCount(2, $metadatas); @@ -137,7 +137,7 @@ className: ToolMultiple::class, ); } - private function assertToolConfiguration(Metadata $metadata, string $className, string $name, string $description, string $method, array $parameters): void + 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); diff --git a/tests/Chain/Toolbox/Tool/WikipediaTest.php b/tests/Chain/Toolbox/Tool/WikipediaTest.php index cf1a87ba..58bb51e2 100644 --- a/tests/Chain/Toolbox/Tool/WikipediaTest.php +++ b/tests/Chain/Toolbox/Tool/WikipediaTest.php @@ -35,7 +35,7 @@ public function searchWithResults(): void - Member states of the United Nations - Official languages of the United Nations - United States Secretary of State - + Use the title of the article with tool "wikipedia_article" to load the content. EOT; @@ -84,7 +84,7 @@ public function articleWithRedirect(): void $actual = $wikipedia->article('United Nations secretary-general'); $expected = <<toolbox = new Toolbox(new ReflectionFactory(), [ + $this->toolbox = new Toolbox(new ReflectionToolFactory(), [ new ToolRequiredParams(), new ToolOptionalParam(), new ToolNoParams(), @@ -57,71 +57,72 @@ protected function setUp(): void } #[Test] - public function toolsMap(): void + public function getTools(): void { - $actual = $this->toolbox->getMap(); - $expected = [ + $actual = $this->toolbox->getTools(); + + $toolRequiredParams = new Tool( + new ExecutionReference(ToolRequiredParams::class, 'bar'), + 'tool_required_params', + 'A tool with required parameters', [ - '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, + 'type' => 'object', + 'properties' => [ + 'text' => [ + 'type' => 'string', + 'description' => 'The text given to the tool', ], - ], - ], - [ - '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, + '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' => 'function', - 'function' => [ - 'name' => 'tool_no_params', - 'description' => 'A tool without parameters', - ], - ], - [ - 'type' => 'function', - 'function' => [ - 'name' => 'tool_exception', - 'description' => 'This tool is broken', + '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::assertSame(json_encode($expected), json_encode($actual)); + self::assertEquals($expected, $actual); } #[Test] @@ -139,7 +140,7 @@ public function executeWithMisconfiguredTool(): void self::expectException(ToolConfigurationException::class); self::expectExceptionMessage('Method "foo" not found in tool "PhpLlm\LlmChain\Tests\Fixture\Tool\ToolMisconfigured".'); - $toolbox = new Toolbox(new ReflectionFactory(), [new ToolMisconfigured()]); + $toolbox = new Toolbox(new ReflectionToolFactory(), [new ToolMisconfigured()]); $toolbox->execute(new ToolCall('call_1234', 'tool_misconfigured')); } @@ -178,42 +179,40 @@ public static function executeProvider(): iterable #[Test] public function toolboxMapWithMemoryFactory(): void { - $memoryFactory = (new MemoryFactory()) + $memoryFactory = (new MemoryToolFactory()) ->addTool(ToolNoAttribute1::class, 'happy_birthday', 'Generates birthday message'); $toolbox = new Toolbox($memoryFactory, [new ToolNoAttribute1()]); $expected = [ - [ - 'type' => 'function', - 'function' => [ - 'name' => 'happy_birthday', - 'description' => 'Generates birthday message', - 'parameters' => [ - 'type' => 'object', - 'properties' => [ - 'name' => [ - 'type' => 'string', - 'description' => 'the name of the person', - ], - 'years' => [ - 'type' => 'integer', - 'description' => 'the age of the person', - ], + 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, ], + 'required' => ['name', 'years'], + 'additionalProperties' => false, ], - ], + ), ]; - self::assertSame(json_encode($expected), json_encode($toolbox->getMap())); + self::assertEquals($expected, $toolbox->getTools()); } #[Test] public function toolboxExecutionWithMemoryFactory(): void { - $memoryFactory = (new MemoryFactory()) + $memoryFactory = (new MemoryToolFactory()) ->addTool(ToolNoAttribute1::class, 'happy_birthday', 'Generates birthday message'); $toolbox = new Toolbox($memoryFactory, [new ToolNoAttribute1()]); @@ -225,37 +224,35 @@ public function toolboxExecutionWithMemoryFactory(): void #[Test] public function toolboxMapWithOverrideViaChain(): void { - $factory1 = (new MemoryFactory()) + $factory1 = (new MemoryToolFactory()) ->addTool(ToolOptionalParam::class, 'optional_param', 'Tool with optional param', 'bar'); - $factory2 = new ReflectionFactory(); + $factory2 = new ReflectionToolFactory(); $toolbox = new Toolbox(new ChainFactory([$factory1, $factory2]), [new ToolOptionalParam()]); $expected = [ - [ - 'type' => 'function', - 'function' => [ - 'name' => 'optional_param', - 'description' => 'Tool with optional param', - 'parameters' => [ - 'type' => 'object', - 'properties' => [ - 'text' => [ - 'type' => 'string', - 'description' => 'The text given to the tool', - ], - 'number' => [ - 'type' => 'integer', - 'description' => 'A number given to the tool', - ], + 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, ], + 'required' => ['text'], + 'additionalProperties' => false, ], - ], + ), ]; - self::assertSame(json_encode($expected), json_encode($toolbox->getMap())); + self::assertEquals($expected, $toolbox->getTools()); } } diff --git a/tests/Double/PlatformTestHandler.php b/tests/Double/PlatformTestHandler.php index 605c1351..5f76d059 100644 --- a/tests/Double/PlatformTestHandler.php +++ b/tests/Double/PlatformTestHandler.php @@ -4,18 +4,18 @@ namespace PhpLlm\LlmChain\Tests\Double; -use PhpLlm\LlmChain\Document\Vector; -use PhpLlm\LlmChain\Model\Model; -use PhpLlm\LlmChain\Model\Response\ResponseInterface; -use PhpLlm\LlmChain\Model\Response\ResponseInterface as LlmResponse; -use PhpLlm\LlmChain\Model\Response\VectorResponse; -use PhpLlm\LlmChain\Platform; -use PhpLlm\LlmChain\Platform\ModelClient; -use PhpLlm\LlmChain\Platform\ResponseConverter; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\ModelClientInterface; +use PhpLlm\LlmChain\Platform\Platform; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface as LlmResponse; +use PhpLlm\LlmChain\Platform\Response\VectorResponse; +use PhpLlm\LlmChain\Platform\ResponseConverterInterface; +use PhpLlm\LlmChain\Platform\Vector\Vector; use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; -final class PlatformTestHandler implements ModelClient, ResponseConverter +final class PlatformTestHandler implements ModelClientInterface, ResponseConverterInterface { public int $createCalls = 0; @@ -31,12 +31,12 @@ public static function createPlatform(?ResponseInterface $create = null): Platfo return new Platform([$handler], [$handler]); } - public function supports(Model $model, object|array|string $input): bool + public function supports(Model $model): bool { return true; } - public function request(Model $model, object|array|string $input, array $options = []): HttpResponse + public function request(Model $model, array|string|object $payload, array $options = []): HttpResponse { ++$this->createCalls; diff --git a/tests/Double/TestStore.php b/tests/Double/TestStore.php index 4828a568..637f7b85 100644 --- a/tests/Double/TestStore.php +++ b/tests/Double/TestStore.php @@ -4,7 +4,7 @@ namespace PhpLlm\LlmChain\Tests\Double; -use PhpLlm\LlmChain\Document\VectorDocument; +use PhpLlm\LlmChain\Store\Document\VectorDocument; use PhpLlm\LlmChain\Store\StoreInterface; final class TestStore implements StoreInterface diff --git a/tests/Fixture/Tool/ToolMultiple.php b/tests/Fixture/Tool/ToolMultiple.php index 9cf13310..20ce2111 100644 --- a/tests/Fixture/Tool/ToolMultiple.php +++ b/tests/Fixture/Tool/ToolMultiple.php @@ -15,7 +15,7 @@ final class ToolMultiple */ public function hello(string $world): string { - return sprintf('Hello "%s".', $world); + return \sprintf('Hello "%s".', $world); } /** @@ -24,6 +24,6 @@ public function hello(string $world): string */ public function bar(string $text, int $number): string { - return sprintf('%s says "%d".', $text, $number); + return \sprintf('%s says "%d".', $text, $number); } } diff --git a/tests/Fixture/Tool/ToolNoAttribute1.php b/tests/Fixture/Tool/ToolNoAttribute1.php index 964d149f..c4f81523 100644 --- a/tests/Fixture/Tool/ToolNoAttribute1.php +++ b/tests/Fixture/Tool/ToolNoAttribute1.php @@ -12,6 +12,6 @@ final class ToolNoAttribute1 */ public function __invoke(string $name, int $years): string { - return sprintf('Happy Birthday, %s! You are %d years old.', $name, $years); + return \sprintf('Happy Birthday, %s! You are %d years old.', $name, $years); } } diff --git a/tests/Fixture/Tool/ToolNoAttribute2.php b/tests/Fixture/Tool/ToolNoAttribute2.php index ac6dc4aa..c9922d23 100644 --- a/tests/Fixture/Tool/ToolNoAttribute2.php +++ b/tests/Fixture/Tool/ToolNoAttribute2.php @@ -12,7 +12,7 @@ final class ToolNoAttribute2 */ public function buy(int $id, int $amount): string { - return sprintf('You bought %d of product %d.', $amount, $id); + return \sprintf('You bought %d of product %d.', $amount, $id); } /** @@ -20,6 +20,6 @@ public function buy(int $id, int $amount): string */ public function cancel(string $orderId): string { - return sprintf('You canceled order %s.', $orderId); + return \sprintf('You canceled order %s.', $orderId); } } diff --git a/tests/Fixture/Tool/ToolOptionalParam.php b/tests/Fixture/Tool/ToolOptionalParam.php index d3fe2cf5..5a582e89 100644 --- a/tests/Fixture/Tool/ToolOptionalParam.php +++ b/tests/Fixture/Tool/ToolOptionalParam.php @@ -15,6 +15,6 @@ final class ToolOptionalParam */ public function bar(string $text, int $number = 3): string { - return sprintf('%s says "%d".', $text, $number); + return \sprintf('%s says "%d".', $text, $number); } } diff --git a/tests/Fixture/Tool/ToolRequiredParams.php b/tests/Fixture/Tool/ToolRequiredParams.php index 0ede84e3..40826130 100644 --- a/tests/Fixture/Tool/ToolRequiredParams.php +++ b/tests/Fixture/Tool/ToolRequiredParams.php @@ -15,6 +15,6 @@ final class ToolRequiredParams */ public function bar(string $text, int $number): string { - return sprintf('%s says "%d".', $text, $number); + return \sprintf('%s says "%d".', $text, $number); } } diff --git a/tests/Fixture/Tool/ToolWithToolParameterAttribute.php b/tests/Fixture/Tool/ToolWithToolParameterAttribute.php index 7f5b2551..429c36f8 100644 --- a/tests/Fixture/Tool/ToolWithToolParameterAttribute.php +++ b/tests/Fixture/Tool/ToolWithToolParameterAttribute.php @@ -4,8 +4,8 @@ namespace PhpLlm\LlmChain\Tests\Fixture\Tool; -use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With; use PhpLlm\LlmChain\Chain\Toolbox\Attribute\AsTool; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\Attribute\With; #[AsTool('tool_with_ToolParameter_attribute', 'A tool which has a parameter with described with #[ToolParameter] attribute')] final class ToolWithToolParameterAttribute diff --git a/tests/Fixture/Tool/ToolWithoutDocs.php b/tests/Fixture/Tool/ToolWithoutDocs.php index 9a01923a..9e6b17c2 100644 --- a/tests/Fixture/Tool/ToolWithoutDocs.php +++ b/tests/Fixture/Tool/ToolWithoutDocs.php @@ -11,6 +11,6 @@ final class ToolWithoutDocs { public function bar(string $text, int $number): string { - return sprintf('%s says "%d".', $text, $number); + return \sprintf('%s says "%d".', $text, $number); } } diff --git a/tests/Fixture/Tool/ToolWrong.php b/tests/Fixture/Tool/ToolWrong.php index f1964a70..cc2ee399 100644 --- a/tests/Fixture/Tool/ToolWrong.php +++ b/tests/Fixture/Tool/ToolWrong.php @@ -12,6 +12,6 @@ final class ToolWrong */ public function bar(string $text, int $number): string { - return sprintf('%s says "%d".', $text, $number); + return \sprintf('%s says "%d".', $text, $number); } } diff --git a/tests/Model/Message/UserMessageTest.php b/tests/Model/Message/UserMessageTest.php deleted file mode 100644 index 2463dece..00000000 --- a/tests/Model/Message/UserMessageTest.php +++ /dev/null @@ -1,113 +0,0 @@ - Role::User, 'content' => 'foo']), \json_encode($obj)); - self::assertSame(Role::User, $obj->getRole()); - } - - #[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()); - } - - #[Test] - #[DataProvider('provideSerializationTests')] - public function serializationResultsAsExpected(UserMessage $message, array $expectedArray): void - { - self::assertSame(\json_encode($message), \json_encode($expectedArray)); - } - - public static function provideSerializationTests(): \Generator - { - yield 'With only text' => [ - new UserMessage(new Text('foo')), - ['role' => Role::User, 'content' => 'foo'], - ]; - - yield 'With single image' => [ - new UserMessage(new Text('foo'), new ImageUrl('https://foo.com/bar.jpg')), - [ - 'role' => Role::User, - 'content' => [ - ['type' => 'text', 'text' => 'foo'], - ['type' => 'image_url', 'image_url' => ['url' => 'https://foo.com/bar.jpg']], - ], - ], - ]; - - yield 'With single multiple images' => [ - new UserMessage(new Text('foo'), new ImageUrl('https://foo.com/bar.jpg'), new ImageUrl('https://foo.com/baz.jpg')), - [ - 'role' => Role::User, - 'content' => [ - ['type' => 'text', 'text' => 'foo'], - ['type' => 'image_url', 'image_url' => ['url' => 'https://foo.com/bar.jpg']], - ['type' => 'image_url', 'image_url' => ['url' => 'https://foo.com/baz.jpg']], - ], - ], - ]; - } -} diff --git a/tests/Bridge/Anthropic/ModelHandlerTest.php b/tests/Platform/Bridge/Anthropic/ModelHandlerTest.php similarity index 77% rename from tests/Bridge/Anthropic/ModelHandlerTest.php rename to tests/Platform/Bridge/Anthropic/ModelHandlerTest.php index 48a27e74..c992e528 100644 --- a/tests/Bridge/Anthropic/ModelHandlerTest.php +++ b/tests/Platform/Bridge/Anthropic/ModelHandlerTest.php @@ -2,16 +2,22 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Bridge\Anthropic; +namespace PhpLlm\LlmChain\Tests\Platform\Bridge\Anthropic; -use PhpLlm\LlmChain\Bridge\Anthropic\ModelHandler; -use PhpLlm\LlmChain\Model\Response\ToolCallResponse; +use PhpLlm\LlmChain\Platform\Bridge\Anthropic\ModelHandler; +use PhpLlm\LlmChain\Platform\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Response\ToolCallResponse; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\JsonMockResponse; #[CoversClass(ModelHandler::class)] +#[Small] +#[UsesClass(ToolCall::class)] +#[UsesClass(ToolCallResponse::class)] final class ModelHandlerTest extends TestCase { public function testConvertThrowsExceptionWhenContentIsToolUseAndLacksText(): void diff --git a/tests/Platform/Bridge/Bedrock/Nova/ContractTest.php b/tests/Platform/Bridge/Bedrock/Nova/ContractTest.php new file mode 100644 index 00000000..8b9fa113 --- /dev/null +++ b/tests/Platform/Bridge/Bedrock/Nova/ContractTest.php @@ -0,0 +1,138 @@ +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/tests/Platform/Bridge/Google/Contract/AssistantMessageNormalizerTest.php b/tests/Platform/Bridge/Google/Contract/AssistantMessageNormalizerTest.php new file mode 100644 index 00000000..0283fee4 --- /dev/null +++ b/tests/Platform/Bridge/Google/Contract/AssistantMessageNormalizerTest.php @@ -0,0 +1,54 @@ +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/tests/Platform/Bridge/Google/Contract/MessageBagNormalizerTest.php b/tests/Platform/Bridge/Google/Contract/MessageBagNormalizerTest.php new file mode 100644 index 00000000..1ec5554e --- /dev/null +++ b/tests/Platform/Bridge/Google/Contract/MessageBagNormalizerTest.php @@ -0,0 +1,150 @@ +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/tests/Platform/Bridge/Google/Contract/UserMessageNormalizerTest.php b/tests/Platform/Bridge/Google/Contract/UserMessageNormalizerTest.php new file mode 100644 index 00000000..2da91fb6 --- /dev/null +++ b/tests/Platform/Bridge/Google/Contract/UserMessageNormalizerTest.php @@ -0,0 +1,76 @@ +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/tests/Bridge/HuggingFace/ModelClientTest.php b/tests/Platform/Bridge/HuggingFace/ModelClientTest.php similarity index 60% rename from tests/Bridge/HuggingFace/ModelClientTest.php rename to tests/Platform/Bridge/HuggingFace/ModelClientTest.php index ba6812d9..512b1cba 100644 --- a/tests/Bridge/HuggingFace/ModelClientTest.php +++ b/tests/Platform/Bridge/HuggingFace/ModelClientTest.php @@ -2,16 +2,17 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Bridge\HuggingFace; - -use PhpLlm\LlmChain\Bridge\HuggingFace\Model; -use PhpLlm\LlmChain\Bridge\HuggingFace\ModelClient; -use PhpLlm\LlmChain\Bridge\HuggingFace\Task; -use PhpLlm\LlmChain\Model\Message\Content\Image; -use PhpLlm\LlmChain\Model\Message\Content\Text; -use PhpLlm\LlmChain\Model\Message\MessageBag; -use PhpLlm\LlmChain\Model\Message\UserMessage; -use PhpLlm\LlmChain\Model\Model as BaseModel; +namespace PhpLlm\LlmChain\Tests\Platform\Bridge\HuggingFace; + +use PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Contract\FileNormalizer; +use PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Contract\MessageBagNormalizer; +use PhpLlm\LlmChain\Platform\Bridge\HuggingFace\ModelClient; +use PhpLlm\LlmChain\Platform\Bridge\HuggingFace\Task; +use PhpLlm\LlmChain\Platform\Contract; +use PhpLlm\LlmChain\Platform\Message\Content\Text; +use PhpLlm\LlmChain\Platform\Message\MessageBag; +use PhpLlm\LlmChain\Platform\Message\UserMessage; +use PhpLlm\LlmChain\Platform\Model; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Small; @@ -24,49 +25,8 @@ #[UsesClass(Model::class)] final class ModelClientTest extends TestCase { - public function testSupportsWithHuggingFaceModel(): void - { - $httpClient = new MockHttpClient(); - $modelClient = new ModelClient($httpClient, 'test-provider', 'test-api-key'); - $model = new Model('test-model'); - - self::assertTrue($modelClient->supports($model, 'test-input')); - } - - public function testSupportsWithNonHuggingFaceModel(): void - { - $httpClient = new MockHttpClient(); - $modelClient = new ModelClient($httpClient, 'test-provider', 'test-api-key'); - $model = self::createMock(BaseModel::class); - - self::assertFalse($modelClient->supports($model, 'test-input')); - } - - public function testRequestWithUnsupportedInputType(): void - { - $httpClient = new MockHttpClient(); - $modelClient = new ModelClient($httpClient, 'test-provider', 'test-api-key'); - $model = new Model('test-model'); - - self::expectException(\InvalidArgumentException::class); - self::expectExceptionMessage('Unsupported input type: stdClass'); - - $modelClient->request($model, new \stdClass()); - } - - public function testRequestWithNonHuggingFaceModel(): void - { - $httpClient = new MockHttpClient(); - $modelClient = new ModelClient($httpClient, 'test-provider', 'test-api-key'); - $model = self::createMock(BaseModel::class); - - self::expectException(\InvalidArgumentException::class); - - $modelClient->request($model, 'test input'); - } - #[DataProvider('urlTestCases')] - public function testGetUrlForDifferentInputsAndTasks(object|array|string $input, ?string $task, string $expectedUrl): void + public function testGetUrlForDifferentInputsAndTasks(?string $task, string $expectedUrl): void { $reflection = new \ReflectionClass(ModelClient::class); $getUrlMethod = $reflection->getMethod('getUrl'); @@ -76,7 +36,7 @@ public function testGetUrlForDifferentInputsAndTasks(object|array|string $input, $httpClient = new MockHttpClient(); $modelClient = new ModelClient($httpClient, 'test-provider', 'test-api-key'); - $actualUrl = $getUrlMethod->invoke($modelClient, $model, $input, $task); + $actualUrl = $getUrlMethod->invoke($modelClient, $model, $task); self::assertEquals($expectedUrl, $actualUrl); } @@ -86,35 +46,38 @@ public static function urlTestCases(): \Iterator $messageBag = new MessageBag(); $messageBag->add(new UserMessage(new Text('Test message'))); yield 'string input' => [ - 'input' => 'Hello world', 'task' => null, 'expectedUrl' => 'https://router.huggingface.co/test-provider/models/test-model', ]; yield 'array input' => [ - 'input' => ['text' => 'Hello world'], 'task' => null, 'expectedUrl' => 'https://router.huggingface.co/test-provider/models/test-model', ]; yield 'image input' => [ - 'input' => Image::fromDataUrl('data:image/jpeg;base64,/9j/Cg=='), 'task' => null, 'expectedUrl' => 'https://router.huggingface.co/test-provider/models/test-model', ]; yield 'feature extraction' => [ - 'input' => 'Extract features', 'task' => Task::FEATURE_EXTRACTION, 'expectedUrl' => 'https://router.huggingface.co/test-provider/pipeline/feature-extraction/test-model', ]; yield 'message bag' => [ - 'input' => $messageBag, - 'task' => null, + '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, ?string $task, array $options, array $expectedKeys, array $expectedValues = []): void + 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); @@ -122,17 +85,17 @@ public function testGetPayloadForDifferentInputsAndTasks(object|array|string $in $httpClient = new MockHttpClient(); $modelClient = new ModelClient($httpClient, 'test-provider', 'test-api-key'); - $payload = $getPayloadMethod->invoke($modelClient, $input, $options); + $actual = $getPayloadMethod->invoke($modelClient, $payload, $options); // Check that expected keys exist foreach ($expectedKeys as $key) { - self::assertArrayHasKey($key, $payload); + self::assertArrayHasKey($key, $actual); } // Check expected values if specified foreach ($expectedValues as $path => $value) { $keys = explode('.', $path); - $current = $payload; + $current = $actual; foreach ($keys as $key) { self::assertArrayHasKey($key, $current); $current = $current[$key]; @@ -144,11 +107,8 @@ public function testGetPayloadForDifferentInputsAndTasks(object|array|string $in public static function payloadTestCases(): \Iterator { - $messageBag = new MessageBag(); - $messageBag->add(new UserMessage(new Text('Test message'))); yield 'string input' => [ 'input' => 'Hello world', - 'task' => null, 'options' => [], 'expectedKeys' => ['headers', 'json'], 'expectedValues' => [ @@ -156,9 +116,9 @@ public static function payloadTestCases(): \Iterator 'json.inputs' => 'Hello world', ], ]; + yield 'array input' => [ 'input' => ['text' => 'Hello world'], - 'task' => null, 'options' => ['temperature' => 0.7], 'expectedKeys' => ['headers', 'json'], 'expectedValues' => [ @@ -167,9 +127,12 @@ public static function payloadTestCases(): \Iterator 'json.parameters.temperature' => 0.7, ], ]; + + $messageBag = new MessageBag(); + $messageBag->add(new UserMessage(new Text('Test message'))); + yield 'message bag' => [ 'input' => $messageBag, - 'task' => null, 'options' => ['max_tokens' => 100], 'expectedKeys' => ['headers', 'json'], 'expectedValues' => [ diff --git a/tests/Bridge/Meta/LlamaPromptConverterTest.php b/tests/Platform/Bridge/Meta/LlamaPromptConverterTest.php similarity index 88% rename from tests/Bridge/Meta/LlamaPromptConverterTest.php rename to tests/Platform/Bridge/Meta/LlamaPromptConverterTest.php index 74e8fb43..43377271 100644 --- a/tests/Bridge/Meta/LlamaPromptConverterTest.php +++ b/tests/Platform/Bridge/Meta/LlamaPromptConverterTest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Bridge\Meta; - -use PhpLlm\LlmChain\Bridge\Meta\LlamaPromptConverter; -use PhpLlm\LlmChain\Model\Message\AssistantMessage; -use PhpLlm\LlmChain\Model\Message\Content\ImageUrl; -use PhpLlm\LlmChain\Model\Message\Content\Text; -use PhpLlm\LlmChain\Model\Message\Message; -use PhpLlm\LlmChain\Model\Message\MessageBag; -use PhpLlm\LlmChain\Model\Message\SystemMessage; -use PhpLlm\LlmChain\Model\Message\UserMessage; +namespace PhpLlm\LlmChain\Tests\Platform\Bridge\Meta; + +use PhpLlm\LlmChain\Platform\Bridge\Meta\LlamaPromptConverter; +use PhpLlm\LlmChain\Platform\Message\AssistantMessage; +use PhpLlm\LlmChain\Platform\Message\Content\ImageUrl; +use PhpLlm\LlmChain\Platform\Message\Content\Text; +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Message\MessageBag; +use PhpLlm\LlmChain\Platform\Message\SystemMessage; +use PhpLlm\LlmChain\Platform\Message\UserMessage; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Small; diff --git a/tests/Bridge/OpenAI/DallE/Base64ImageTest.php b/tests/Platform/Bridge/OpenAI/DallE/Base64ImageTest.php similarity index 87% rename from tests/Bridge/OpenAI/DallE/Base64ImageTest.php rename to tests/Platform/Bridge/OpenAI/DallE/Base64ImageTest.php index fb0be784..ba512855 100644 --- a/tests/Bridge/OpenAI/DallE/Base64ImageTest.php +++ b/tests/Platform/Bridge/OpenAI/DallE/Base64ImageTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Bridge\OpenAI\DallE; +namespace PhpLlm\LlmChain\Tests\Platform\Bridge\OpenAI\DallE; -use PhpLlm\LlmChain\Bridge\OpenAI\DallE\Base64Image; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE\Base64Image; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/Bridge/OpenAI/DallE/ImageResponseTest.php b/tests/Platform/Bridge/OpenAI/DallE/ImageResponseTest.php similarity index 88% rename from tests/Bridge/OpenAI/DallE/ImageResponseTest.php rename to tests/Platform/Bridge/OpenAI/DallE/ImageResponseTest.php index abda0ddd..0991698f 100644 --- a/tests/Bridge/OpenAI/DallE/ImageResponseTest.php +++ b/tests/Platform/Bridge/OpenAI/DallE/ImageResponseTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Bridge\OpenAI\DallE; +namespace PhpLlm\LlmChain\Tests\Platform\Bridge\OpenAI\DallE; -use PhpLlm\LlmChain\Bridge\OpenAI\DallE\Base64Image; -use PhpLlm\LlmChain\Bridge\OpenAI\DallE\ImageResponse; -use PhpLlm\LlmChain\Bridge\OpenAI\DallE\UrlImage; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE\Base64Image; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE\ImageResponse; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE\UrlImage; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/Bridge/OpenAI/DallE/ModelClientTest.php b/tests/Platform/Bridge/OpenAI/DallE/ModelClientTest.php similarity index 88% rename from tests/Bridge/OpenAI/DallE/ModelClientTest.php rename to tests/Platform/Bridge/OpenAI/DallE/ModelClientTest.php index b57c52d7..21ba64ff 100644 --- a/tests/Bridge/OpenAI/DallE/ModelClientTest.php +++ b/tests/Platform/Bridge/OpenAI/DallE/ModelClientTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Bridge\OpenAI\DallE; +namespace PhpLlm\LlmChain\Tests\Platform\Bridge\OpenAI\DallE; -use PhpLlm\LlmChain\Bridge\OpenAI\DallE; -use PhpLlm\LlmChain\Bridge\OpenAI\DallE\Base64Image; -use PhpLlm\LlmChain\Bridge\OpenAI\DallE\ImageResponse; -use PhpLlm\LlmChain\Bridge\OpenAI\DallE\ModelClient; -use PhpLlm\LlmChain\Bridge\OpenAI\DallE\UrlImage; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE\Base64Image; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE\ImageResponse; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE\ModelClient; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE\UrlImage; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; @@ -31,7 +31,7 @@ public function itIsSupportingTheCorrectModel(): void { $modelClient = new ModelClient(new MockHttpClient(), 'sk-api-key'); - self::assertTrue($modelClient->supports(new DallE(), 'foo')); + self::assertTrue($modelClient->supports(new DallE())); } #[Test] diff --git a/tests/Bridge/OpenAI/DallE/UrlImageTest.php b/tests/Platform/Bridge/OpenAI/DallE/UrlImageTest.php similarity index 85% rename from tests/Bridge/OpenAI/DallE/UrlImageTest.php rename to tests/Platform/Bridge/OpenAI/DallE/UrlImageTest.php index bff3322f..0dd8ecdc 100644 --- a/tests/Bridge/OpenAI/DallE/UrlImageTest.php +++ b/tests/Platform/Bridge/OpenAI/DallE/UrlImageTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Bridge\OpenAI\DallE; +namespace PhpLlm\LlmChain\Tests\Platform\Bridge\OpenAI\DallE; -use PhpLlm\LlmChain\Bridge\OpenAI\DallE\UrlImage; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE\UrlImage; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/Bridge/OpenAI/DallETest.php b/tests/Platform/Bridge/OpenAI/DallETest.php similarity index 88% rename from tests/Bridge/OpenAI/DallETest.php rename to tests/Platform/Bridge/OpenAI/DallETest.php index 6445ce54..47eac89e 100644 --- a/tests/Bridge/OpenAI/DallETest.php +++ b/tests/Platform/Bridge/OpenAI/DallETest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Bridge\OpenAI; +namespace PhpLlm\LlmChain\Tests\Platform\Bridge\OpenAI; -use PhpLlm\LlmChain\Bridge\OpenAI\DallE; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\DallE; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/Bridge/OpenAI/Embeddings/ResponseConverterTest.php b/tests/Platform/Bridge/OpenAI/Embeddings/ResponseConverterTest.php similarity index 82% rename from tests/Bridge/OpenAI/Embeddings/ResponseConverterTest.php rename to tests/Platform/Bridge/OpenAI/Embeddings/ResponseConverterTest.php index c9e9f5e2..62e590a2 100644 --- a/tests/Bridge/OpenAI/Embeddings/ResponseConverterTest.php +++ b/tests/Platform/Bridge/OpenAI/Embeddings/ResponseConverterTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Bridge\OpenAI\Embeddings; +namespace PhpLlm\LlmChain\Tests\Platform\Bridge\OpenAI\Embeddings; -use PhpLlm\LlmChain\Bridge\OpenAI\Embeddings\ResponseConverter; -use PhpLlm\LlmChain\Document\Vector; -use PhpLlm\LlmChain\Model\Response\VectorResponse; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings\ResponseConverter; +use PhpLlm\LlmChain\Platform\Response\VectorResponse; +use PhpLlm\LlmChain\Platform\Vector\Vector; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; @@ -26,7 +26,7 @@ public function itConvertsAResponseToAVectorResponse(): void $response = $this->createStub(ResponseInterface::class); $response ->method('toArray') - ->willReturn(\json_decode($this->getEmbeddingStub(), true)); + ->willReturn(json_decode($this->getEmbeddingStub(), true)); $vectorResponse = (new ResponseConverter())->convert($response); $convertedContent = $vectorResponse->getContent(); diff --git a/tests/Bridge/OpenAI/GPT/ResponseConverterTest.php b/tests/Platform/Bridge/OpenAI/GPT/ResponseConverterTest.php similarity index 92% rename from tests/Bridge/OpenAI/GPT/ResponseConverterTest.php rename to tests/Platform/Bridge/OpenAI/GPT/ResponseConverterTest.php index 13ae2f23..ddf498ab 100644 --- a/tests/Bridge/OpenAI/GPT/ResponseConverterTest.php +++ b/tests/Platform/Bridge/OpenAI/GPT/ResponseConverterTest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Bridge\OpenAI\GPT; - -use PhpLlm\LlmChain\Bridge\OpenAI\GPT\ResponseConverter; -use PhpLlm\LlmChain\Exception\ContentFilterException; -use PhpLlm\LlmChain\Exception\RuntimeException; -use PhpLlm\LlmChain\Model\Response\Choice; -use PhpLlm\LlmChain\Model\Response\ChoiceResponse; -use PhpLlm\LlmChain\Model\Response\TextResponse; -use PhpLlm\LlmChain\Model\Response\ToolCall; -use PhpLlm\LlmChain\Model\Response\ToolCallResponse; +namespace PhpLlm\LlmChain\Tests\Platform\Bridge\OpenAI\GPT; + +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT\ResponseConverter; +use PhpLlm\LlmChain\Platform\Exception\ContentFilterException; +use PhpLlm\LlmChain\Platform\Exception\RuntimeException; +use PhpLlm\LlmChain\Platform\Response\Choice; +use PhpLlm\LlmChain\Platform\Response\ChoiceResponse; +use PhpLlm\LlmChain\Platform\Response\TextResponse; +use PhpLlm\LlmChain\Platform\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Response\ToolCallResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\UsesClass; diff --git a/tests/Bridge/OpenAI/TokenOutputProcessorTest.php b/tests/Platform/Bridge/OpenAI/TokenOutputProcessorTest.php similarity index 78% rename from tests/Bridge/OpenAI/TokenOutputProcessorTest.php rename to tests/Platform/Bridge/OpenAI/TokenOutputProcessorTest.php index eb4d864e..93e743f1 100644 --- a/tests/Bridge/OpenAI/TokenOutputProcessorTest.php +++ b/tests/Platform/Bridge/OpenAI/TokenOutputProcessorTest.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Bridge\OpenAI; +namespace PhpLlm\LlmChain\Tests\Platform\Bridge\OpenAI; -use PhpLlm\LlmChain\Bridge\OpenAI\TokenOutputProcessor; use PhpLlm\LlmChain\Chain\Output; -use PhpLlm\LlmChain\Model\LanguageModel; -use PhpLlm\LlmChain\Model\Message\MessageBagInterface; -use PhpLlm\LlmChain\Model\Response\Metadata\Metadata; -use PhpLlm\LlmChain\Model\Response\ResponseInterface; -use PhpLlm\LlmChain\Model\Response\StreamResponse; -use PhpLlm\LlmChain\Model\Response\TextResponse; +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\TokenOutputProcessor; +use PhpLlm\LlmChain\Platform\Message\MessageBagInterface; +use PhpLlm\LlmChain\Platform\Model; +use PhpLlm\LlmChain\Platform\Response\Metadata\Metadata; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface; +use PhpLlm\LlmChain\Platform\Response\StreamResponse; +use PhpLlm\LlmChain\Platform\Response\TextResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; @@ -24,7 +24,6 @@ #[UsesClass(TextResponse::class)] #[UsesClass(StreamResponse::class)] #[UsesClass(Metadata::class)] -#[UsesClass(SymfonyHttpResponse::class)] #[Small] final class TokenOutputProcessorTest extends TestCase { @@ -60,13 +59,7 @@ public function itAddsRemainingTokensToMetadata(): void $processor = new TokenOutputProcessor(); $textResponse = new TextResponse('test'); - $rawResponse = self::createStub(SymfonyHttpResponse::class); - $rawResponse->method('getHeaders')->willReturn([ - 'x-ratelimit-remaining-tokens' => ['1000'], - ]); - $rawResponse->method('toArray')->willReturn([]); - - $textResponse->setRawResponse($rawResponse); + $textResponse->setRawResponse($this->createRawResponse()); $output = $this->createOutput($textResponse); @@ -83,11 +76,7 @@ public function itAddsUsageTokensToMetadata(): void $processor = new TokenOutputProcessor(); $textResponse = new TextResponse('test'); - $rawResponse = self::createStub(SymfonyHttpResponse::class); - $rawResponse->method('getHeaders')->willReturn([ - 'x-ratelimit-remaining-tokens' => ['1000'], - ]); - $rawResponse->method('toArray')->willReturn([ + $rawResponse = $this->createRawResponse([ 'usage' => [ 'prompt_tokens' => 10, 'completion_tokens' => 20, @@ -115,11 +104,7 @@ public function itHandlesMissingUsageFields(): void $processor = new TokenOutputProcessor(); $textResponse = new TextResponse('test'); - $rawResponse = self::createStub(SymfonyHttpResponse::class); - $rawResponse->method('getHeaders')->willReturn([ - 'x-ratelimit-remaining-tokens' => ['1000'], - ]); - $rawResponse->method('toArray')->willReturn([ + $rawResponse = $this->createRawResponse([ 'usage' => [ // Missing some fields 'prompt_tokens' => 10, @@ -140,10 +125,21 @@ public function itHandlesMissingUsageFields(): void 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(LanguageModel::class), + self::createStub(Model::class), $response, self::createStub(MessageBagInterface::class), [], diff --git a/tests/Chain/JsonSchema/Attribute/ToolParameterTest.php b/tests/Platform/Contract/JsonSchema/Attribute/ToolParameterTest.php similarity index 98% rename from tests/Chain/JsonSchema/Attribute/ToolParameterTest.php rename to tests/Platform/Contract/JsonSchema/Attribute/ToolParameterTest.php index 9f0dd4db..fcd1feeb 100644 --- a/tests/Chain/JsonSchema/Attribute/ToolParameterTest.php +++ b/tests/Platform/Contract/JsonSchema/Attribute/ToolParameterTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Chain\JsonSchema\Attribute; +namespace PhpLlm\LlmChain\Tests\Platform\Contract\JsonSchema\Attribute; -use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\Attribute\With; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; diff --git a/tests/Chain/JsonSchema/DescriptionParserTest.php b/tests/Platform/Contract/JsonSchema/DescriptionParserTest.php similarity index 96% rename from tests/Chain/JsonSchema/DescriptionParserTest.php rename to tests/Platform/Contract/JsonSchema/DescriptionParserTest.php index e178c84e..453869f7 100644 --- a/tests/Chain/JsonSchema/DescriptionParserTest.php +++ b/tests/Platform/Contract/JsonSchema/DescriptionParserTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Chain\JsonSchema; +namespace PhpLlm\LlmChain\Tests\Platform\Contract\JsonSchema; -use PhpLlm\LlmChain\Chain\JsonSchema\DescriptionParser; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\DescriptionParser; use PhpLlm\LlmChain\Tests\Fixture\StructuredOutput\User; use PhpLlm\LlmChain\Tests\Fixture\StructuredOutput\UserWithConstructor; use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolRequiredParams; diff --git a/tests/Chain/JsonSchema/FactoryTest.php b/tests/Platform/Contract/JsonSchema/FactoryTest.php similarity index 96% rename from tests/Chain/JsonSchema/FactoryTest.php rename to tests/Platform/Contract/JsonSchema/FactoryTest.php index 5c26c799..747d13ed 100644 --- a/tests/Chain/JsonSchema/FactoryTest.php +++ b/tests/Platform/Contract/JsonSchema/FactoryTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Chain\JsonSchema; +namespace PhpLlm\LlmChain\Tests\Platform\Contract\JsonSchema; -use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With; -use PhpLlm\LlmChain\Chain\JsonSchema\DescriptionParser; -use PhpLlm\LlmChain\Chain\JsonSchema\Factory; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\Attribute\With; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\DescriptionParser; +use PhpLlm\LlmChain\Platform\Contract\JsonSchema\Factory; use PhpLlm\LlmChain\Tests\Fixture\StructuredOutput\MathReasoning; use PhpLlm\LlmChain\Tests\Fixture\StructuredOutput\Step; use PhpLlm\LlmChain\Tests\Fixture\StructuredOutput\User; diff --git a/tests/Platform/Contract/Normalizer/Message/AssistantMessageNormalizerTest.php b/tests/Platform/Contract/Normalizer/Message/AssistantMessageNormalizerTest.php new file mode 100644 index 00000000..94893c9c --- /dev/null +++ b/tests/Platform/Contract/Normalizer/Message/AssistantMessageNormalizerTest.php @@ -0,0 +1,108 @@ +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/tests/Platform/Contract/Normalizer/Message/Content/AudioNormalizerTest.php b/tests/Platform/Contract/Normalizer/Message/Content/AudioNormalizerTest.php new file mode 100644 index 00000000..2fa72bd5 --- /dev/null +++ b/tests/Platform/Contract/Normalizer/Message/Content/AudioNormalizerTest.php @@ -0,0 +1,76 @@ +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/tests/Platform/Contract/Normalizer/Message/Content/ImageNormalizerTest.php b/tests/Platform/Contract/Normalizer/Message/Content/ImageNormalizerTest.php new file mode 100644 index 00000000..e2b7dfdc --- /dev/null +++ b/tests/Platform/Contract/Normalizer/Message/Content/ImageNormalizerTest.php @@ -0,0 +1,52 @@ +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/tests/Platform/Contract/Normalizer/Message/Content/ImageUrlNormalizerTest.php b/tests/Platform/Contract/Normalizer/Message/Content/ImageUrlNormalizerTest.php new file mode 100644 index 00000000..34f09de9 --- /dev/null +++ b/tests/Platform/Contract/Normalizer/Message/Content/ImageUrlNormalizerTest.php @@ -0,0 +1,50 @@ +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/tests/Platform/Contract/Normalizer/Message/Content/TextNormalizerTest.php b/tests/Platform/Contract/Normalizer/Message/Content/TextNormalizerTest.php new file mode 100644 index 00000000..c2f41567 --- /dev/null +++ b/tests/Platform/Contract/Normalizer/Message/Content/TextNormalizerTest.php @@ -0,0 +1,50 @@ +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/tests/Platform/Contract/Normalizer/Message/MessageBagNormalizerTest.php b/tests/Platform/Contract/Normalizer/Message/MessageBagNormalizerTest.php new file mode 100644 index 00000000..644ec183 --- /dev/null +++ b/tests/Platform/Contract/Normalizer/Message/MessageBagNormalizerTest.php @@ -0,0 +1,117 @@ +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/tests/Platform/Contract/Normalizer/Message/SystemMessageNormalizerTest.php b/tests/Platform/Contract/Normalizer/Message/SystemMessageNormalizerTest.php new file mode 100644 index 00000000..ab61ef75 --- /dev/null +++ b/tests/Platform/Contract/Normalizer/Message/SystemMessageNormalizerTest.php @@ -0,0 +1,50 @@ +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/tests/Platform/Contract/Normalizer/Message/ToolCallMessageNormalizerTest.php b/tests/Platform/Contract/Normalizer/Message/ToolCallMessageNormalizerTest.php new file mode 100644 index 00000000..899a1181 --- /dev/null +++ b/tests/Platform/Contract/Normalizer/Message/ToolCallMessageNormalizerTest.php @@ -0,0 +1,66 @@ +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/tests/Platform/Contract/Normalizer/Message/UserMessageNormalizerTest.php b/tests/Platform/Contract/Normalizer/Message/UserMessageNormalizerTest.php new file mode 100644 index 00000000..50727e03 --- /dev/null +++ b/tests/Platform/Contract/Normalizer/Message/UserMessageNormalizerTest.php @@ -0,0 +1,84 @@ +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/tests/Platform/Contract/Normalizer/ToolNormalizerTest.php b/tests/Platform/Contract/Normalizer/ToolNormalizerTest.php new file mode 100644 index 00000000..a54a4f68 --- /dev/null +++ b/tests/Platform/Contract/Normalizer/ToolNormalizerTest.php @@ -0,0 +1,151 @@ +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/tests/Platform/ContractTest.php b/tests/Platform/ContractTest.php new file mode 100644 index 00000000..baee89ad --- /dev/null +++ b/tests/Platform/ContractTest.php @@ -0,0 +1,210 @@ +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/tests/Model/Message/AssistantMessageTest.php b/tests/Platform/Message/AssistantMessageTest.php similarity index 50% rename from tests/Model/Message/AssistantMessageTest.php rename to tests/Platform/Message/AssistantMessageTest.php index 0fc32bcb..4d7cdb77 100644 --- a/tests/Model/Message/AssistantMessageTest.php +++ b/tests/Platform/Message/AssistantMessageTest.php @@ -2,13 +2,12 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Message; +namespace PhpLlm\LlmChain\Tests\Platform\Message; -use PhpLlm\LlmChain\Model\Message\AssistantMessage; -use PhpLlm\LlmChain\Model\Message\Role; -use PhpLlm\LlmChain\Model\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Message\AssistantMessage; +use PhpLlm\LlmChain\Platform\Message\Role; +use PhpLlm\LlmChain\Platform\Response\ToolCall; 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; @@ -44,27 +43,4 @@ public function constructionWithoutContentIsPossible(): void self::assertSame([$toolCall], $message->toolCalls); self::assertTrue($message->hasToolCalls()); } - - #[Test] - #[DataProvider('provideJsonSerializerTests')] - public function jsonConversionIsWorkingAsExpected(AssistantMessage $message, array $expectedResult): void - { - self::assertEqualsCanonicalizing($expectedResult, $message->jsonSerialize()); - } - - public static function provideJsonSerializerTests(): \Generator - { - yield 'Message with content' => [ - new AssistantMessage('Foo Bar Baz'), - ['role' => Role::Assistant, 'content' => 'Foo Bar Baz'], - ]; - - $toolCall1 = new ToolCall('call_123456', 'my_tool', ['foo' => 'bar']); - $toolCall2 = new ToolCall('call_456789', 'my_faster_tool'); - - yield 'Message with tool calls' => [ - new AssistantMessage(toolCalls: [$toolCall1, $toolCall2]), - ['role' => Role::Assistant, 'tool_calls' => [$toolCall1, $toolCall2]], - ]; - } } diff --git a/tests/Model/Message/Content/AudioTest.php b/tests/Platform/Message/Content/AudioTest.php similarity index 55% rename from tests/Model/Message/Content/AudioTest.php rename to tests/Platform/Message/Content/AudioTest.php index 56e1e7ac..205046a9 100644 --- a/tests/Model/Message/Content/AudioTest.php +++ b/tests/Platform/Message/Content/AudioTest.php @@ -2,11 +2,10 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Message\Content; +namespace PhpLlm\LlmChain\Tests\Platform\Message\Content; -use PhpLlm\LlmChain\Model\Message\Content\Audio; +use PhpLlm\LlmChain\Platform\Message\Content\Audio; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -46,7 +45,7 @@ public function fromDataUrlWithInvalidUrl(): void #[Test] public function fromFileWithValidPath(): void { - $audio = Audio::fromFile(dirname(__DIR__, 3).'/Fixture/audio.mp3'); + $audio = Audio::fromFile(\dirname(__DIR__, 3).'/Fixture/audio.mp3'); self::assertSame('audio/mpeg', $audio->getFormat()); self::assertNotEmpty($audio->asBinary()); @@ -60,41 +59,4 @@ public function fromFileWithInvalidPath(): void Audio::fromFile('foo.mp3'); } - - #[Test] - #[DataProvider('provideAudioData')] - public function jsonSerializeReturnsCorrectFormat(string $data, string $format, array $expected): void - { - $audio = new Audio(base64_decode($data), $format); - $actual = $audio->jsonSerialize(); - - self::assertSame($expected, $actual); - } - - 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/tests/Model/Message/Content/BinaryTest.php b/tests/Platform/Message/Content/BinaryTest.php similarity index 90% rename from tests/Model/Message/Content/BinaryTest.php rename to tests/Platform/Message/Content/BinaryTest.php index 683b39fd..8dbca19c 100644 --- a/tests/Model/Message/Content/BinaryTest.php +++ b/tests/Platform/Message/Content/BinaryTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Message\Content; +namespace PhpLlm\LlmChain\Tests\Platform\Message\Content; -use PhpLlm\LlmChain\Exception\InvalidArgumentException; -use PhpLlm\LlmChain\Model\Message\Content\File; +use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException; +use PhpLlm\LlmChain\Platform\Message\Content\File; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Small; @@ -69,8 +69,8 @@ public function createFromExistingFiles(string $filePath, string $expectedFormat */ 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']; + yield 'mp3' => [\dirname(__DIR__, 3).'/Fixture/audio.mp3', 'audio/mpeg']; + yield 'jpg' => [\dirname(__DIR__, 3).'/Fixture/image.jpg', 'image/jpeg']; } #[Test] diff --git a/tests/Model/Message/Content/ImageTest.php b/tests/Platform/Message/Content/ImageTest.php similarity index 82% rename from tests/Model/Message/Content/ImageTest.php rename to tests/Platform/Message/Content/ImageTest.php index 073b07a4..28e11d7b 100644 --- a/tests/Model/Message/Content/ImageTest.php +++ b/tests/Platform/Message/Content/ImageTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Message\Content; +namespace PhpLlm\LlmChain\Tests\Platform\Message\Content; -use PhpLlm\LlmChain\Model\Message\Content\Image; +use PhpLlm\LlmChain\Platform\Message\Content\Image; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -23,7 +23,7 @@ public function constructWithValidDataUrl(): void #[Test] public function withValidFile(): void { - $image = Image::fromFile(dirname(__DIR__, 3).'/Fixture/image.jpg'); + $image = Image::fromFile(\dirname(__DIR__, 3).'/Fixture/image.jpg'); self::assertStringStartsWith('data:image/jpeg;base64,', $image->asDataUrl()); } diff --git a/tests/Model/Message/Content/ImageUrlTest.php b/tests/Platform/Message/Content/ImageUrlTest.php similarity index 55% rename from tests/Model/Message/Content/ImageUrlTest.php rename to tests/Platform/Message/Content/ImageUrlTest.php index 44adeb64..421bc448 100644 --- a/tests/Model/Message/Content/ImageUrlTest.php +++ b/tests/Platform/Message/Content/ImageUrlTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Message\Content; +namespace PhpLlm\LlmChain\Tests\Platform\Message\Content; -use PhpLlm\LlmChain\Model\Message\Content\ImageUrl; +use PhpLlm\LlmChain\Platform\Message\Content\ImageUrl; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; @@ -21,12 +21,4 @@ public function constructWithValidUrl(): void self::assertSame('https://foo.com/test.png', $image->url); } - - #[Test] - public function jsonConversionIsWorkingAsExpected(): void - { - $image = new ImageUrl('https://foo.com/test.png'); - - self::assertSame(['type' => 'image_url', 'image_url' => ['url' => 'https://foo.com/test.png']], $image->jsonSerialize()); - } } diff --git a/tests/Model/Message/Content/TextTest.php b/tests/Platform/Message/Content/TextTest.php similarity index 57% rename from tests/Model/Message/Content/TextTest.php rename to tests/Platform/Message/Content/TextTest.php index 7ce538cf..be03a761 100644 --- a/tests/Model/Message/Content/TextTest.php +++ b/tests/Platform/Message/Content/TextTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Message\Content; +namespace PhpLlm\LlmChain\Tests\Platform\Message\Content; -use PhpLlm\LlmChain\Model\Message\Content\Text; +use PhpLlm\LlmChain\Platform\Message\Content\Text; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; @@ -21,12 +21,4 @@ public function constructionIsPossible(): void self::assertSame('foo', $obj->text); } - - #[Test] - public function jsonConversionIsWorkingAsExpected(): void - { - $obj = new Text('foo'); - - self::assertSame(['type' => 'text', 'text' => 'foo'], $obj->jsonSerialize()); - } } diff --git a/tests/Model/Message/MessageBagTest.php b/tests/Platform/Message/MessageBagTest.php similarity index 76% rename from tests/Model/Message/MessageBagTest.php rename to tests/Platform/Message/MessageBagTest.php index 405583c2..86706bee 100644 --- a/tests/Model/Message/MessageBagTest.php +++ b/tests/Platform/Message/MessageBagTest.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Message; - -use PhpLlm\LlmChain\Model\Message\AssistantMessage; -use PhpLlm\LlmChain\Model\Message\Content\ImageUrl; -use PhpLlm\LlmChain\Model\Message\Content\Text; -use PhpLlm\LlmChain\Model\Message\Message; -use PhpLlm\LlmChain\Model\Message\MessageBag; -use PhpLlm\LlmChain\Model\Message\SystemMessage; -use PhpLlm\LlmChain\Model\Message\ToolCallMessage; -use PhpLlm\LlmChain\Model\Message\UserMessage; -use PhpLlm\LlmChain\Model\Response\ToolCall; +namespace PhpLlm\LlmChain\Tests\Platform\Message; + +use PhpLlm\LlmChain\Platform\Message\AssistantMessage; +use PhpLlm\LlmChain\Platform\Message\Content\ImageUrl; +use PhpLlm\LlmChain\Platform\Message\Content\Text; +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Message\MessageBag; +use PhpLlm\LlmChain\Platform\Message\SystemMessage; +use PhpLlm\LlmChain\Platform\Message\ToolCallMessage; +use PhpLlm\LlmChain\Platform\Message\UserMessage; +use PhpLlm\LlmChain\Platform\Response\ToolCall; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; @@ -168,30 +168,4 @@ public function containsImageReturnsTrueWithImage(): void self::assertTrue($messageBag->containsImage()); } - - #[Test] - public function jsonSerialize(): void - { - $messageBag = 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')), - ); - - $json = json_encode($messageBag); - - self::assertJson($json); - self::assertJsonStringEqualsJsonString(json_encode([ - ['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']], - ]], - ]), $json); - } } diff --git a/tests/Model/Message/MessageTest.php b/tests/Platform/Message/MessageTest.php similarity index 73% rename from tests/Model/Message/MessageTest.php rename to tests/Platform/Message/MessageTest.php index 5d3de446..c461495a 100644 --- a/tests/Model/Message/MessageTest.php +++ b/tests/Platform/Message/MessageTest.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Message; - -use PhpLlm\LlmChain\Model\Message\AssistantMessage; -use PhpLlm\LlmChain\Model\Message\Content\ImageUrl; -use PhpLlm\LlmChain\Model\Message\Content\Text; -use PhpLlm\LlmChain\Model\Message\Message; -use PhpLlm\LlmChain\Model\Message\Role; -use PhpLlm\LlmChain\Model\Message\SystemMessage; -use PhpLlm\LlmChain\Model\Message\ToolCallMessage; -use PhpLlm\LlmChain\Model\Message\UserMessage; -use PhpLlm\LlmChain\Model\Response\ToolCall; +namespace PhpLlm\LlmChain\Tests\Platform\Message; + +use PhpLlm\LlmChain\Platform\Message\AssistantMessage; +use PhpLlm\LlmChain\Platform\Message\Content\ImageUrl; +use PhpLlm\LlmChain\Platform\Message\Content\Text; +use PhpLlm\LlmChain\Platform\Message\Message; +use PhpLlm\LlmChain\Platform\Message\Role; +use PhpLlm\LlmChain\Platform\Message\SystemMessage; +use PhpLlm\LlmChain\Platform\Message\ToolCallMessage; +use PhpLlm\LlmChain\Platform\Message\UserMessage; +use PhpLlm\LlmChain\Platform\Response\ToolCall; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; @@ -90,15 +90,6 @@ public function createUserMessageWithImages(): void ); self::assertCount(4, $message->content); - self::assertSame(\json_encode([ - 'role' => Role::User, - 'content' => [ - ['type' => 'text', 'text' => 'Hi, my name is John.'], - ['type' => 'image_url', 'image_url' => ['url' => 'http://images.local/my-image.png']], - ['type' => 'text', 'text' => 'The following image is a joke.'], - ['type' => 'image_url', 'image_url' => ['url' => 'http://images.local/my-image2.png']], - ], - ]), \json_encode($message)); } #[Test] diff --git a/tests/Model/Message/RoleTest.php b/tests/Platform/Message/RoleTest.php similarity index 92% rename from tests/Model/Message/RoleTest.php rename to tests/Platform/Message/RoleTest.php index fd82e666..e5c03b7e 100644 --- a/tests/Model/Message/RoleTest.php +++ b/tests/Platform/Message/RoleTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Message; +namespace PhpLlm\LlmChain\Tests\Platform\Message; -use PhpLlm\LlmChain\Model\Message\Role; +use PhpLlm\LlmChain\Platform\Message\Role; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/Model/Message/SystemMessageTest.php b/tests/Platform/Message/SystemMessageTest.php similarity index 57% rename from tests/Model/Message/SystemMessageTest.php rename to tests/Platform/Message/SystemMessageTest.php index aa5fd381..f122c443 100644 --- a/tests/Model/Message/SystemMessageTest.php +++ b/tests/Platform/Message/SystemMessageTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Message; +namespace PhpLlm\LlmChain\Tests\Platform\Message; -use PhpLlm\LlmChain\Model\Message\Role; -use PhpLlm\LlmChain\Model\Message\SystemMessage; +use PhpLlm\LlmChain\Platform\Message\Role; +use PhpLlm\LlmChain\Platform\Message\SystemMessage; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; @@ -23,12 +23,4 @@ public function constructionIsPossible(): void self::assertSame(Role::System, $message->getRole()); self::assertSame('foo', $message->content); } - - #[Test] - public function jsonConversionIsWorkingAsExpected(): void - { - $systemMessage = new SystemMessage('foo'); - - self::assertSame(['role' => Role::System, 'content' => 'foo'], $systemMessage->jsonSerialize()); - } } diff --git a/tests/Model/Message/ToolCallMessageTest.php b/tests/Platform/Message/ToolCallMessageTest.php similarity index 56% rename from tests/Model/Message/ToolCallMessageTest.php rename to tests/Platform/Message/ToolCallMessageTest.php index a6558163..d742ae37 100644 --- a/tests/Model/Message/ToolCallMessageTest.php +++ b/tests/Platform/Message/ToolCallMessageTest.php @@ -2,11 +2,10 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Message; +namespace PhpLlm\LlmChain\Tests\Platform\Message; -use PhpLlm\LlmChain\Model\Message\Role; -use PhpLlm\LlmChain\Model\Message\ToolCallMessage; -use PhpLlm\LlmChain\Model\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Message\ToolCallMessage; +use PhpLlm\LlmChain\Platform\Response\ToolCall; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; @@ -27,13 +26,4 @@ public function constructionIsPossible(): void self::assertSame($toolCall, $obj->toolCall); self::assertSame('bar', $obj->content); } - - #[Test] - public function jsonConversionIsWorkingAsExpected(): void - { - $toolCall = new ToolCall('foo', 'bar'); - $obj = new ToolCallMessage($toolCall, 'bar'); - - self::assertSame(['role' => Role::ToolCall, 'content' => 'bar', 'tool_call_id' => 'foo'], $obj->jsonSerialize()); - } } diff --git a/tests/Platform/Message/UserMessageTest.php b/tests/Platform/Message/UserMessageTest.php new file mode 100644 index 00000000..2898e60c --- /dev/null +++ b/tests/Platform/Message/UserMessageTest.php @@ -0,0 +1,76 @@ +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/tests/Platform/ModelTest.php b/tests/Platform/ModelTest.php new file mode 100644 index 00000000..9566c5f8 --- /dev/null +++ b/tests/Platform/ModelTest.php @@ -0,0 +1,73 @@ +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/tests/Model/Response/AsyncResponseTest.php b/tests/Platform/Response/AsyncResponseTest.php similarity index 85% rename from tests/Model/Response/AsyncResponseTest.php rename to tests/Platform/Response/AsyncResponseTest.php index a32a406b..3488c513 100644 --- a/tests/Model/Response/AsyncResponseTest.php +++ b/tests/Platform/Response/AsyncResponseTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Response; - -use PhpLlm\LlmChain\Model\Response\AsyncResponse; -use PhpLlm\LlmChain\Model\Response\BaseResponse; -use PhpLlm\LlmChain\Model\Response\Exception\RawResponseAlreadySet; -use PhpLlm\LlmChain\Model\Response\Metadata\Metadata; -use PhpLlm\LlmChain\Model\Response\ResponseInterface; -use PhpLlm\LlmChain\Model\Response\TextResponse; -use PhpLlm\LlmChain\Platform\ResponseConverter; +namespace PhpLlm\LlmChain\Tests\Platform\Response; + +use PhpLlm\LlmChain\Platform\Response\AsyncResponse; +use PhpLlm\LlmChain\Platform\Response\BaseResponse; +use PhpLlm\LlmChain\Platform\Response\Exception\RawResponseAlreadySetException; +use PhpLlm\LlmChain\Platform\Response\Metadata\Metadata; +use PhpLlm\LlmChain\Platform\Response\ResponseInterface; +use PhpLlm\LlmChain\Platform\Response\TextResponse; +use PhpLlm\LlmChain\Platform\ResponseConverterInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; @@ -21,7 +21,7 @@ #[CoversClass(AsyncResponse::class)] #[UsesClass(Metadata::class)] #[UsesClass(TextResponse::class)] -#[UsesClass(RawResponseAlreadySet::class)] +#[UsesClass(RawResponseAlreadySetException::class)] #[Small] final class AsyncResponseTest extends TestCase { @@ -31,7 +31,7 @@ public function itUnwrapsTheResponseWhenGettingContent(): void $httpResponse = $this->createStub(SymfonyHttpResponse::class); $textResponse = new TextResponse('test content'); - $responseConverter = self::createMock(ResponseConverter::class); + $responseConverter = self::createMock(ResponseConverterInterface::class); $responseConverter->expects(self::once()) ->method('convert') ->with($httpResponse, []) @@ -48,7 +48,7 @@ public function itConvertsTheResponseOnlyOnce(): void $httpResponse = $this->createStub(SymfonyHttpResponse::class); $textResponse = new TextResponse('test content'); - $responseConverter = self::createMock(ResponseConverter::class); + $responseConverter = self::createMock(ResponseConverterInterface::class); $responseConverter->expects(self::once()) ->method('convert') ->with($httpResponse, []) @@ -66,7 +66,7 @@ public function itConvertsTheResponseOnlyOnce(): void public function itGetsRawResponseDirectly(): void { $httpResponse = $this->createStub(SymfonyHttpResponse::class); - $responseConverter = $this->createStub(ResponseConverter::class); + $responseConverter = $this->createStub(ResponseConverterInterface::class); $asyncResponse = new AsyncResponse($responseConverter, $httpResponse); @@ -76,10 +76,10 @@ public function itGetsRawResponseDirectly(): void #[Test] public function itThrowsExceptionWhenSettingRawResponse(): void { - self::expectException(RawResponseAlreadySet::class); + self::expectException(RawResponseAlreadySetException::class); $httpResponse = $this->createStub(SymfonyHttpResponse::class); - $responseConverter = $this->createStub(ResponseConverter::class); + $responseConverter = $this->createStub(ResponseConverterInterface::class); $asyncResponse = new AsyncResponse($responseConverter, $httpResponse); $asyncResponse->setRawResponse($httpResponse); @@ -92,7 +92,7 @@ public function itSetsRawResponseOnUnwrappedResponseWhenNeeded(): void $unwrappedResponse = $this->createResponse(null); - $responseConverter = $this->createStub(ResponseConverter::class); + $responseConverter = $this->createStub(ResponseConverterInterface::class); $responseConverter->method('convert')->willReturn($unwrappedResponse); $asyncResponse = new AsyncResponse($responseConverter, $httpResponse); @@ -110,7 +110,7 @@ public function itDoesNotSetRawResponseOnUnwrappedResponseWhenAlreadySet(): void $unwrappedResponse = $this->createResponse($anotherHttpResponse); - $responseConverter = $this->createStub(ResponseConverter::class); + $responseConverter = $this->createStub(ResponseConverterInterface::class); $responseConverter->method('convert')->willReturn($unwrappedResponse); $asyncResponse = new AsyncResponse($responseConverter, $originHttpResponse); @@ -150,7 +150,7 @@ public function itPassesOptionsToConverter(): void $httpResponse = $this->createStub(SymfonyHttpResponse::class); $options = ['option1' => 'value1', 'option2' => 'value2']; - $responseConverter = self::createMock(ResponseConverter::class); + $responseConverter = self::createMock(ResponseConverterInterface::class); $responseConverter->expects(self::once()) ->method('convert') ->with($httpResponse, $options) diff --git a/tests/Model/Response/BaseResponseTest.php b/tests/Platform/Response/BaseResponseTest.php similarity index 74% rename from tests/Model/Response/BaseResponseTest.php rename to tests/Platform/Response/BaseResponseTest.php index cab6a3b4..58d46e34 100644 --- a/tests/Model/Response/BaseResponseTest.php +++ b/tests/Platform/Response/BaseResponseTest.php @@ -2,15 +2,17 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Response; +namespace PhpLlm\LlmChain\Tests\Platform\Response; -use PhpLlm\LlmChain\Model\Response\BaseResponse; -use PhpLlm\LlmChain\Model\Response\Exception\RawResponseAlreadySet; -use PhpLlm\LlmChain\Model\Response\Metadata\MetadataAwareTrait; -use PhpLlm\LlmChain\Model\Response\RawResponseAwareTrait; +use PhpLlm\LlmChain\Platform\Response\BaseResponse; +use PhpLlm\LlmChain\Platform\Response\Exception\RawResponseAlreadySetException; +use PhpLlm\LlmChain\Platform\Response\Metadata\Metadata; +use PhpLlm\LlmChain\Platform\Response\Metadata\MetadataAwareTrait; +use PhpLlm\LlmChain\Platform\Response\RawResponseAwareTrait; 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\Contracts\HttpClient\ResponseInterface as SymfonyHttpResponse; @@ -18,6 +20,8 @@ #[CoversClass(BaseResponse::class)] #[UsesTrait(MetadataAwareTrait::class)] #[UsesTrait(RawResponseAwareTrait::class)] +#[UsesClass(Metadata::class)] +#[UsesClass(RawResponseAlreadySetException::class)] #[Small] final class BaseResponseTest extends TestCase { @@ -48,7 +52,7 @@ public function itCanBeEnrichedWithARawResponse(): void #[Test] public function itThrowsAnExceptionWhenSettingARawResponseTwice(): void { - self::expectException(RawResponseAlreadySet::class); + self::expectException(RawResponseAlreadySetException::class); $response = $this->createResponse(); $rawResponse = self::createMock(SymfonyHttpResponse::class); diff --git a/tests/Model/Response/ChoiceResponseTest.php b/tests/Platform/Response/ChoiceResponseTest.php similarity index 84% rename from tests/Model/Response/ChoiceResponseTest.php rename to tests/Platform/Response/ChoiceResponseTest.php index 62c15536..6d5fabbe 100644 --- a/tests/Model/Response/ChoiceResponseTest.php +++ b/tests/Platform/Response/ChoiceResponseTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Response; +namespace PhpLlm\LlmChain\Tests\Platform\Response; -use PhpLlm\LlmChain\Exception\InvalidArgumentException; -use PhpLlm\LlmChain\Model\Response\Choice; -use PhpLlm\LlmChain\Model\Response\ChoiceResponse; +use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException; +use PhpLlm\LlmChain\Platform\Response\Choice; +use PhpLlm\LlmChain\Platform\Response\ChoiceResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/Model/Response/ChoiceTest.php b/tests/Platform/Response/ChoiceTest.php similarity index 92% rename from tests/Model/Response/ChoiceTest.php rename to tests/Platform/Response/ChoiceTest.php index d3f53948..e18d17ed 100644 --- a/tests/Model/Response/ChoiceTest.php +++ b/tests/Platform/Response/ChoiceTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Response; +namespace PhpLlm\LlmChain\Tests\Platform\Response; -use PhpLlm\LlmChain\Model\Response\Choice; -use PhpLlm\LlmChain\Model\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Response\Choice; +use PhpLlm\LlmChain\Platform\Response\ToolCall; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/Model/Response/Exception/RawResponseAlreadySetTest.php b/tests/Platform/Response/Exception/RawResponseAlreadySetTest.php similarity index 62% rename from tests/Model/Response/Exception/RawResponseAlreadySetTest.php rename to tests/Platform/Response/Exception/RawResponseAlreadySetTest.php index 42d35744..fa7bb397 100644 --- a/tests/Model/Response/Exception/RawResponseAlreadySetTest.php +++ b/tests/Platform/Response/Exception/RawResponseAlreadySetTest.php @@ -2,22 +2,22 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Response\Exception; +namespace PhpLlm\LlmChain\Tests\Platform\Response\Exception; -use PhpLlm\LlmChain\Model\Response\Exception\RawResponseAlreadySet; +use PhpLlm\LlmChain\Platform\Response\Exception\RawResponseAlreadySetException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -#[CoversClass(RawResponseAlreadySet::class)] +#[CoversClass(RawResponseAlreadySetException::class)] #[Small] final class RawResponseAlreadySetTest extends TestCase { #[Test] public function itHasCorrectExceptionMessage(): void { - $exception = new RawResponseAlreadySet(); + $exception = new RawResponseAlreadySetException(); self::assertSame('The raw response was already set.', $exception->getMessage()); } diff --git a/tests/Model/Response/Metadata/MetadataAwareTraitTest.php b/tests/Platform/Response/Metadata/MetadataAwareTraitTest.php similarity index 74% rename from tests/Model/Response/Metadata/MetadataAwareTraitTest.php rename to tests/Platform/Response/Metadata/MetadataAwareTraitTest.php index c1f5543f..852b77c5 100644 --- a/tests/Model/Response/Metadata/MetadataAwareTraitTest.php +++ b/tests/Platform/Response/Metadata/MetadataAwareTraitTest.php @@ -2,16 +2,19 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Response\Metadata; +namespace PhpLlm\LlmChain\Tests\Platform\Response\Metadata; -use PhpLlm\LlmChain\Model\Response\Metadata\MetadataAwareTrait; +use PhpLlm\LlmChain\Platform\Response\Metadata\Metadata; +use PhpLlm\LlmChain\Platform\Response\Metadata\MetadataAwareTrait; use PHPUnit\Framework\Attributes\CoversTrait; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[CoversTrait(MetadataAwareTrait::class)] #[Small] +#[UsesClass(Metadata::class)] final class MetadataAwareTraitTest extends TestCase { #[Test] diff --git a/tests/Model/Response/Metadata/MetadataTest.php b/tests/Platform/Response/Metadata/MetadataTest.php similarity index 95% rename from tests/Model/Response/Metadata/MetadataTest.php rename to tests/Platform/Response/Metadata/MetadataTest.php index 70081482..96429fbf 100644 --- a/tests/Model/Response/Metadata/MetadataTest.php +++ b/tests/Platform/Response/Metadata/MetadataTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Response\Metadata; +namespace PhpLlm\LlmChain\Tests\Platform\Response\Metadata; -use PhpLlm\LlmChain\Model\Response\Metadata\Metadata; +use PhpLlm\LlmChain\Platform\Response\Metadata\Metadata; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; @@ -107,7 +107,7 @@ public function itImplementsArrayAccess(): void public function itImplementsIteratorAggregate(): void { $metadata = new Metadata(['key1' => 'value1', 'key2' => 'value2']); - $result = \iterator_to_array($metadata); + $result = iterator_to_array($metadata); self::assertSame(['key1' => 'value1', 'key2' => 'value2'], $result); } diff --git a/tests/Model/Response/RawResponseAwareTraitTest.php b/tests/Platform/Response/RawResponseAwareTraitTest.php similarity index 76% rename from tests/Model/Response/RawResponseAwareTraitTest.php rename to tests/Platform/Response/RawResponseAwareTraitTest.php index fda510ac..e429e1d5 100644 --- a/tests/Model/Response/RawResponseAwareTraitTest.php +++ b/tests/Platform/Response/RawResponseAwareTraitTest.php @@ -2,18 +2,20 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Response; +namespace PhpLlm\LlmChain\Tests\Platform\Response; -use PhpLlm\LlmChain\Model\Response\Exception\RawResponseAlreadySet; -use PhpLlm\LlmChain\Model\Response\RawResponseAwareTrait; +use PhpLlm\LlmChain\Platform\Response\Exception\RawResponseAlreadySetException; +use PhpLlm\LlmChain\Platform\Response\RawResponseAwareTrait; 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\Contracts\HttpClient\ResponseInterface as SymfonyHttpResponse; #[CoversTrait(RawResponseAwareTrait::class)] #[Small] +#[UsesClass(RawResponseAlreadySetException::class)] final class RawResponseAwareTraitTest extends TestCase { #[Test] @@ -29,7 +31,7 @@ public function itCanBeEnrichedWithARawResponse(): void #[Test] public function itThrowsAnExceptionWhenSettingARawResponseTwice(): void { - self::expectException(RawResponseAlreadySet::class); + self::expectException(RawResponseAlreadySetException::class); $response = $this->createTestClass(); $rawResponse = self::createMock(SymfonyHttpResponse::class); diff --git a/tests/Model/Response/StreamResponseTest.php b/tests/Platform/Response/StreamResponseTest.php similarity index 88% rename from tests/Model/Response/StreamResponseTest.php rename to tests/Platform/Response/StreamResponseTest.php index fd208483..793d7789 100644 --- a/tests/Model/Response/StreamResponseTest.php +++ b/tests/Platform/Response/StreamResponseTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Response; +namespace PhpLlm\LlmChain\Tests\Platform\Response; -use PhpLlm\LlmChain\Model\Response\StreamResponse; +use PhpLlm\LlmChain\Platform\Response\StreamResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/Model/Response/StructuredResponseTest.php b/tests/Platform/Response/StructuredResponseTest.php similarity index 60% rename from tests/Model/Response/StructuredResponseTest.php rename to tests/Platform/Response/StructuredResponseTest.php index 578ea8e5..bb9bb8e4 100644 --- a/tests/Model/Response/StructuredResponseTest.php +++ b/tests/Platform/Response/StructuredResponseTest.php @@ -2,29 +2,29 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Response; +namespace PhpLlm\LlmChain\Tests\Platform\Response; -use PhpLlm\LlmChain\Model\Response\StructuredResponse; +use PhpLlm\LlmChain\Platform\Response\ObjectResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -#[CoversClass(StructuredResponse::class)] +#[CoversClass(ObjectResponse::class)] #[Small] final class StructuredResponseTest extends TestCase { #[Test] public function getContentWithArray(): void { - $response = new StructuredResponse($expected = ['foo' => 'bar', 'baz' => ['qux']]); + $response = new ObjectResponse($expected = ['foo' => 'bar', 'baz' => ['qux']]); self::assertSame($expected, $response->getContent()); } #[Test] public function getContentWithObject(): void { - $response = new StructuredResponse($expected = (object) ['foo' => 'bar', 'baz' => ['qux']]); + $response = new ObjectResponse($expected = (object) ['foo' => 'bar', 'baz' => ['qux']]); self::assertSame($expected, $response->getContent()); } } diff --git a/tests/Model/Response/TextResponseTest.php b/tests/Platform/Response/TextResponseTest.php similarity index 81% rename from tests/Model/Response/TextResponseTest.php rename to tests/Platform/Response/TextResponseTest.php index 7dce5313..39ab2d28 100644 --- a/tests/Model/Response/TextResponseTest.php +++ b/tests/Platform/Response/TextResponseTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Response; +namespace PhpLlm\LlmChain\Tests\Platform\Response; -use PhpLlm\LlmChain\Model\Response\TextResponse; +use PhpLlm\LlmChain\Platform\Response\TextResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/Model/Response/TollCallResponseTest.php b/tests/Platform/Response/TollCallResponseTest.php similarity index 79% rename from tests/Model/Response/TollCallResponseTest.php rename to tests/Platform/Response/TollCallResponseTest.php index dbbb3acf..c20e9ca2 100644 --- a/tests/Model/Response/TollCallResponseTest.php +++ b/tests/Platform/Response/TollCallResponseTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Response; +namespace PhpLlm\LlmChain\Tests\Platform\Response; -use PhpLlm\LlmChain\Exception\InvalidArgumentException; -use PhpLlm\LlmChain\Model\Response\ToolCall; -use PhpLlm\LlmChain\Model\Response\ToolCallResponse; +use PhpLlm\LlmChain\Platform\Exception\InvalidArgumentException; +use PhpLlm\LlmChain\Platform\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Response\ToolCallResponse; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/Model/Response/ToolCallTest.php b/tests/Platform/Response/ToolCallTest.php similarity index 90% rename from tests/Model/Response/ToolCallTest.php rename to tests/Platform/Response/ToolCallTest.php index 0479ac83..7386280a 100644 --- a/tests/Model/Response/ToolCallTest.php +++ b/tests/Platform/Response/ToolCallTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Model\Response; +namespace PhpLlm\LlmChain\Tests\Platform\Response; -use PhpLlm\LlmChain\Model\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Response\ToolCall; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/Document/NullVectorTest.php b/tests/Store/Document/NullVectorTest.php similarity index 78% rename from tests/Document/NullVectorTest.php rename to tests/Store/Document/NullVectorTest.php index c3839dd8..c9642c93 100644 --- a/tests/Document/NullVectorTest.php +++ b/tests/Store/Document/NullVectorTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Document; +namespace PhpLlm\LlmChain\Tests\Store\Document; -use PhpLlm\LlmChain\Document\NullVector; -use PhpLlm\LlmChain\Document\VectorInterface; -use PhpLlm\LlmChain\Exception\RuntimeException; +use PhpLlm\LlmChain\Platform\Vector\NullVector; +use PhpLlm\LlmChain\Platform\Vector\VectorInterface; +use PhpLlm\LlmChain\Store\Exception\RuntimeException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; diff --git a/tests/Document/VectorTest.php b/tests/Store/Document/VectorTest.php similarity index 82% rename from tests/Document/VectorTest.php rename to tests/Store/Document/VectorTest.php index feb97d0d..16644b81 100644 --- a/tests/Document/VectorTest.php +++ b/tests/Store/Document/VectorTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests\Document; +namespace PhpLlm\LlmChain\Tests\Store\Document; -use PhpLlm\LlmChain\Document\Vector; -use PhpLlm\LlmChain\Document\VectorInterface; +use PhpLlm\LlmChain\Platform\Vector\Vector; +use PhpLlm\LlmChain\Platform\Vector\VectorInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; diff --git a/tests/EmbedderTest.php b/tests/Store/EmbedderTest.php similarity index 87% rename from tests/EmbedderTest.php rename to tests/Store/EmbedderTest.php index 99423b4a..1db27707 100644 --- a/tests/EmbedderTest.php +++ b/tests/Store/EmbedderTest.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Tests; - -use PhpLlm\LlmChain\Bridge\OpenAI\Embeddings; -use PhpLlm\LlmChain\Document\Metadata; -use PhpLlm\LlmChain\Document\TextDocument; -use PhpLlm\LlmChain\Document\Vector; -use PhpLlm\LlmChain\Document\VectorDocument; -use PhpLlm\LlmChain\Embedder; -use PhpLlm\LlmChain\Model\Message\ToolCallMessage; -use PhpLlm\LlmChain\Model\Response\AsyncResponse; -use PhpLlm\LlmChain\Model\Response\ToolCall; -use PhpLlm\LlmChain\Model\Response\VectorResponse; -use PhpLlm\LlmChain\Platform; +namespace PhpLlm\LlmChain\Tests\Store; + +use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings; +use PhpLlm\LlmChain\Platform\Message\ToolCallMessage; +use PhpLlm\LlmChain\Platform\Platform; +use PhpLlm\LlmChain\Platform\Response\AsyncResponse; +use PhpLlm\LlmChain\Platform\Response\ToolCall; +use PhpLlm\LlmChain\Platform\Response\VectorResponse; +use PhpLlm\LlmChain\Platform\Vector\Vector; +use PhpLlm\LlmChain\Store\Document\Metadata; +use PhpLlm\LlmChain\Store\Document\TextDocument; +use PhpLlm\LlmChain\Store\Document\VectorDocument; +use PhpLlm\LlmChain\Store\Embedder; use PhpLlm\LlmChain\Tests\Double\PlatformTestHandler; use PhpLlm\LlmChain\Tests\Double\TestStore; use PHPUnit\Framework\Attributes\CoversClass;